Rolläden runter bei astronomischem Sonneneinfall

Ich habe ein Seitenfenster im Homeoffice, bei dem die Sonne nachmittags so blöd einfällt, dass ich am Computer nichts mehr lesen kann. Also habe ich mir ein Skript gebastelt, um die Anfangs- und Endzeiten des störenden Sonneneinfalls in Abhängigkeit von folgenden konfigurierbaren Parametern zu berechnen:

  • Azimut des Fensters
  • Geographische Koordinaten des Hauses
  • Vertikal/Horizontal des Einfallswinkels der Sonne aufs Fenster.

Kann benutzt werden, um Rollos zu steuern, z.B. um Sonneneinstrahlung nur bei direktem Einfallwinkel zu beschatten, somit Kühlung sparen und trotzdem viel Licht haben. Hier ist die Python-Version, weiter unten die PHP Version (auf Symcon getested und funktionell).

############################################################
# SUNLIGHT ON A WINDOW – YEARLY TIME TABLE + PLOT
#
# The window is upright (vertical glass).
#
# For each day of a given year, this script computes the
# local time interval where:
#
#   1) The sun is above the horizon.
#   2) The sun's elevation (angle above horizon) is
#      between VERT_MIN_ANGLE and VERT_MAX_ANGLE.
#      - Typically VERT_MIN_ANGLE = 0°, VERT_MAX_ANGLE = 45°.
#   3) The sun's azimuth lies within a horizontal cone of
#      ±HORIZ_HALF_ANGLE around the window's outward normal
#      (WINDOW_AZIMUTH).
#
# Physically, this corresponds to times when the sun hits
# an upright window within a specified vertical and horizontal
# angular range.
#
# The first such time in a day is reported as the "start time",
# the last such time as the "end time". For a west-facing
# window with VERT_MAX_ANGLE = 45°, the "start time" in late
# autumn will typically be when the sun DESCENDS through 45°
# elevation in the afternoon and enters the horizontal cone.
#
# Outputs:
#   1) CSV file with one row per day (date, start, end, duration).
#   2) Plot of start/end times over the year.
############################################################


############################################################
# INITIAL PARAMETERS (USER-MODIFIABLE)
############################################################

# Geographic coordinates of the house (Appenzell by default)
LATITUDE  = 47.331      # degrees North
LONGITUDE = 9.409       # degrees East

# Window orientation: azimuth of the window’s outward normal.
# Convention: 0° = North, 90° = East, 180° = South, 270° = West.
# Example: 297.9° ≈ WNW, window facing "left" on the plan.
WINDOW_AZIMUTH = 297.9   # <-- MODIFY HERE

# Vertical angle constraints (sun elevation above horizon, in degrees).
# We typically want rays no higher than 45° above the horizon.
VERT_MIN_ANGLE   = 0.0    # lower vertical bound (°)
VERT_MAX_ANGLE   = 60.0   # upper vertical bound (°) – "sun ≤ 45°"

# Horizontal angle constraint around the window normal.
# Sun must be within ±HORIZ_HALF_ANGLE horizontally from perpendicular.
# For ±45°, we require:
#   |azimuth_sun - WINDOW_AZIMUTH| ≤ 45°  (modulo 360)
HORIZ_HALF_ANGLE = 60.0   # half-angle of horizontal acceptance cone (°)

# Time resolution for the scan (minutes)
TIME_STEP_MINUTES = 5     # smaller values → more precise, slower

# Year to compute
YEAR = 2025

# Approximate local time offset from UTC (CET ~ UTC+1, no DST handling here)
LOCAL_UTC_OFFSET_HOURS = 1

# Output CSV file name
OUTPUT_CSV = "sun_window_times.csv"


############################################################
# IMPORTS
############################################################

import math
import datetime as dt
import csv
from pathlib import Path
import matplotlib.pyplot as plt


############################################################
# SOLAR POSITION COMPUTATION (NOAA-STYLE APPROXIMATION)
############################################################

def julian_day(dt_utc):
    """
    Compute the astronomical Julian Day (JD) for a given UTC datetime.

    Parameters
    ----------
    dt_utc : datetime.datetime
        Naive datetime assumed to be in UTC.

    Returns
    -------
    float
        Julian Day number corresponding to dt_utc.

    Notes
    -----
    The Julian Day is a continuous count of days since a reference epoch.
    It is used internally by many astronomical algorithms.
    """
    year = dt_utc.year
    month = dt_utc.month
    # Fractional day includes hours, minutes, seconds
    day = dt_utc.day + (dt_utc.hour + dt_utc.minute/60 + dt_utc.second/3600) / 24.0

    # For January and February, treat them as month 13 and 14 of previous year
    if month <= 2:
        year -= 1
        month += 12

    A = math.floor(year / 100)
    B = 2 - A + math.floor(A / 4)

    JD = (math.floor(365.25 * (year + 4716))
          + math.floor(30.6001 * (month + 1))
          + day + B - 1524.5)
    return JD


def solar_position(dt_utc, lat_deg, lon_deg):
    """
    Compute solar azimuth and elevation for a given UTC datetime and location.

    Parameters
    ----------
    dt_utc : datetime.datetime
        Naive datetime assumed to be in UTC.
    lat_deg : float
        Geographic latitude in degrees (positive north).
    lon_deg : float
        Geographic longitude in degrees (positive east).

    Returns
    -------
    (float, float)
        A tuple (azimuth, elevation) in degrees:
        - azimuth: 0–360°, measured from North clockwise
        - elevation: degrees above the horizon

    Notes
    -----
    This function implements an approximate algorithm similar to the
    NOAA Solar Calculator. It is sufficiently accurate for architectural
    sunlight studies (typical errors around a degree).
    """
    jd = julian_day(dt_utc)
    # Centuries from J2000.0
    T = (jd - 2451545.0) / 36525.0

    # Sun's geometric mean longitude (deg)
    L0 = (280.46646 + T * (36000.76983 + T * 0.0003032)) % 360.0

    # Sun's mean anomaly (deg)
    M = 357.52911 + T * (35999.05029 - 0.0001537 * T)

    # Eccentricity of Earth's orbit
    e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T)

    # Sun's equation of center (deg)
    Mrad = math.radians(M)
    C = ((1.914602 - T * (0.004817 + 0.000014 * T)) * math.sin(Mrad)
         + (0.019993 - 0.000101 * T) * math.sin(2 * Mrad)
         + 0.000289 * math.sin(3 * Mrad))

    # True longitude of the Sun (deg)
    true_long = L0 + C

    # Sun's apparent longitude (deg), corrected for nutation and aberration
    omega = 125.04 - 1934.136 * T
    lam = true_long - 0.00569 - 0.00478 * math.sin(math.radians(omega))

    # Mean obliquity of the ecliptic (deg)
    eps0 = 23 + (26 + ((21.448 - T * (46.815 + T * (0.00059 - 0.001813 * T))) / 60.0)) / 60.0
    # Corrected obliquity (deg)
    eps = eps0 + 0.00256 * math.sin(math.radians(omega))

    # Sun's declination (deg)
    sin_decl = math.sin(math.radians(eps)) * math.sin(math.radians(lam))
    decl = math.degrees(math.asin(sin_decl))

    # Equation of time (minutes)
    y = math.tan(math.radians(eps / 2.0)) ** 2
    L0_rad = math.radians(L0)
    E_time = 4.0 * math.degrees(
        y * math.sin(2 * L0_rad)
        - 2 * e * math.sin(Mrad)
        + 4 * e * y * math.sin(Mrad) * math.cos(2 * L0_rad)
        - 0.5 * y * y * math.sin(4 * L0_rad)
        - 1.25 * e * e * math.sin(2 * Mrad)
    )

    # True solar time (minutes)
    minutes = dt_utc.hour * 60 + dt_utc.minute + dt_utc.second / 60.0
    tst = (minutes + E_time + 4 * lon_deg) % 1440.0  # time zone = 0 (UTC)

    # Hour angle (deg): negative before solar noon, positive after
    if tst / 4.0 < 0:
        ha = tst / 4.0 + 180.0
    else:
        ha = tst / 4.0 - 180.0

    # Convert to radians
    lat_rad = math.radians(lat_deg)
    ha_rad = math.radians(ha)
    decl_rad = math.radians(decl)

    # Solar zenith angle (deg)
    cos_zenith = (
        math.sin(lat_rad) * math.sin(decl_rad) +
        math.cos(lat_rad) * math.cos(decl_rad) * math.cos(ha_rad)
    )
    cos_zenith = max(-1.0, min(1.0, cos_zenith))  # numeric safety
    zenith = math.degrees(math.acos(cos_zenith))

    # Elevation angle above horizon (deg)
    elevation = 90.0 - zenith

    # Solar azimuth (deg, 0–360 from north, clockwise)
    sin_az = -math.sin(ha_rad) * math.cos(decl_rad) / math.cos(math.radians(elevation))
    cos_az = (
        (math.sin(decl_rad) - math.sin(lat_rad) * math.sin(math.radians(elevation)))
        / (math.cos(lat_rad) * math.cos(math.radians(elevation)))
    )
    azimuth = math.degrees(math.atan2(sin_az, cos_az))
    azimuth = (azimuth + 360.0) % 360.0

    return azimuth, elevation


############################################################
# YEARLY SCAN
############################################################

def compute_times_for_year():
    """
    Compute daily direct-sun intervals for the specified YEAR.

    Direct sun on the window is defined as times when all of:
      1) elevation > 0° (sun above horizon),
      2) VERT_MIN_ANGLE < elevation <= VERT_MAX_ANGLE,
      3) horizontal angle |Δazimuth| ≤ HORIZ_HALF_ANGLE, where
         Δazimuth is the smallest signed difference between
         sun azimuth and WINDOW_AZIMUTH in [-180°, +180°].

    For each day, we:
      - Scan local times from 04:00 to 22:00 in steps of TIME_STEP_MINUTES.
      - Convert local time to UTC using LOCAL_UTC_OFFSET_HOURS.
      - Compute solar (azimuth, elevation).
      - Check the conditions above.
      - Collect all matching times for that day.
      - Store the earliest match as "start_time_local",
        the latest match as "end_time_local", and the duration
        in hours between them.

    Returns
    -------
    list of dict
        Each dict has keys:
        - "date" (YYYY-MM-DD)
        - "start_time_local" (HH:MM, local, or "" if never)
        - "end_time_local"   (HH:MM, local, or "" if never)
        - "duration_hours"   (float, hours)
    """
    results = []

    for day in range(1, 366):
        date = dt.date(YEAR, 1, 1) + dt.timedelta(days=day - 1)

        # Stop if we've rolled into the next year (handles leap vs non-leap)
        if date.year != YEAR:
            break

        # Local scan range: 04:00–22:00
        start_local = dt.datetime.combine(date, dt.time(4, 0))
        end_local   = dt.datetime.combine(date, dt.time(22, 0))

        t = start_local
        matches = []

        while t <= end_local:
            # Convert approximate local time to UTC
            t_utc = t - dt.timedelta(hours=LOCAL_UTC_OFFSET_HOURS)

            az, el = solar_position(t_utc, LATITUDE, LONGITUDE)

            # Sun must be above horizon and within vertical interval
            if el > 0.0 and VERT_MIN_ANGLE < el <= VERT_MAX_ANGLE:
                # Horizontal condition: sun within ±HORIZ_HALF_ANGLE of window normal
                delta_az = ((az - WINDOW_AZIMUTH + 180.0) % 360.0) - 180.0
                if abs(delta_az) <= HORIZ_HALF_ANGLE:
                    matches.append(t)

            t += dt.timedelta(minutes=TIME_STEP_MINUTES)

        if matches:
            first = matches[0]
            last  = matches[-1]
            duration_hours = (last - first).total_seconds() / 3600.0
            results.append({
                "date": date.isoformat(),
                "start_time_local": first.time().strftime("%H:%M"),
                "end_time_local":   last.time().strftime("%H:%M"),
                "duration_hours":   round(duration_hours, 2),
            })
        else:
            # No direct sun for this day under the chosen constraints
            results.append({
                "date": date.isoformat(),
                "start_time_local": "",
                "end_time_local":   "",
                "duration_hours":   0.0,
            })

    return results


############################################################
# CSV WRITING
############################################################

def write_csv(results, path):
    """
    Write the list of daily results to a CSV file.

    Parameters
    ----------
    results : list of dict
        Output of compute_times_for_year(). Each dict must have:
        "date", "start_time_local", "end_time_local", "duration_hours".
    path : str or pathlib.Path
        Output file path.

    Side Effects
    ------------
    Creates or overwrites the CSV file at the given path.
    """
    path = Path(path)
    with path.open("w", newline="") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=["date", "start_time_local", "end_time_local", "duration_hours"]
        )
        writer.writeheader()
        writer.writerows(results)
    print(f"Saved table to: {path.resolve()}")


############################################################
# PLOTTING
############################################################

def plot_results(results):
    """
    Plot the start and end local times of direct sun on the window
    over the course of the year, with:
      - y-axis in hh:mm AM/PM
      - earlier times at the top (inverted axis)
      - yellow shading between start and end time curves.
    """
    import datetime as dt
    import math
    import matplotlib.pyplot as plt
    import matplotlib.ticker as ticker
    import numpy as np

    date_labels = []
    start_hours = []
    end_hours   = []

    for r in results:
        # Convert "YYYY-MM-DD" → "dd.mm."
        y, m, d = map(int, r["date"].split("-"))
        date_obj = dt.date(y, m, d)
        date_labels.append(date_obj.strftime("%d.%m."))

        if r["start_time_local"]:
            sh, sm = map(int, r["start_time_local"].split(":"))
            eh, em = map(int, r["end_time_local"].split(":"))
            start_hours.append(sh + sm / 60.0)
            end_hours.append(eh + em / 60.0)
        else:
            start_hours.append(math.nan)
            end_hours.append(math.nan)

    x = np.arange(len(results))

    plt.figure(figsize=(12, 4))

    # Plot lines
    plt.plot(x, start_hours, label="Start time", color="blue")
    plt.plot(x, end_hours, label="End time", color="red")

    # --- Fill region between curves (yellow) ---
    plt.fill_between(
        x,
        start_hours,
        end_hours,
        where=~np.isnan(start_hours) & ~np.isnan(end_hours),
        color="yellow",
        alpha=0.4,
        interpolate=True
    )

    plt.xlabel("Date")
    plt.ylabel("Local Time (hh:mm AM/PM)")
    plt.title(
        "Direct sun on upright window\n"
        f"Azimuth={WINDOW_AZIMUTH}°, vertical {VERT_MIN_ANGLE}–{VERT_MAX_ANGLE}°, "
        f"horizontal ±{HORIZ_HALF_ANGLE}°"
    )
    plt.legend()
    plt.grid(True, linestyle="--", alpha=0.5)

    # --- Format Y axis as hh:mm AM/PM ---
    def format_hhmm_ampm(x, pos):
        if math.isnan(x):
            return ""
        hour = int(x)
        minute = int((x - hour) * 60)
        t = dt.time(hour % 24, minute)
        return t.strftime("%I:%M %p")

    ax = plt.gca()
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(format_hhmm_ampm))
    ax.yaxis.set_major_locator(ticker.MultipleLocator(1))  # 1 hour steps

    # --- Invert y-axis so earlier times appear on top ---
    ax.invert_yaxis()

    # --- X-axis date labels every ~30 days ---
    tick_positions = list(range(0, len(x), 30))
    tick_labels    = [date_labels[i] for i in tick_positions]
    plt.xticks(tick_positions, tick_labels, rotation=45, ha="right")

    plt.tight_layout()
    plt.show()



############################################################
# MAIN
############################################################

if __name__ == "__main__":
    # 1. Compute daily sun-illumination intervals
    results = compute_times_for_year()

    # 2. Write them to CSV
    write_csv(results, OUTPUT_CSV)

    # 3. Plot start/end times over the year
    plot_results(results)

hier ist die PHP-Version, etwas erweitert für den Aufruf durch zyklische Ereignisse

<?php
/******************************************************************************
 *  SUN–WINDOW ANALYSIS SCRIPT (SYMCON-AWARE, WITH ASYMMETRIC H CONES + PNG)
 *
 *  Purpose:
 *  --------
 *  For each day of a given year, compute when an upright window receives
 *  direct sun, given:
 *    - window azimuth (normal direction)
 *    - vertical elevation range [vmin, vmax]
 *    - horizontal acceptance cone:
 *         hleft  = degrees to the LEFT  (negative Δazimuth)
 *         hright = degrees to the RIGHT (positive Δazimuth)
 *
 *  Modes (parameter "p"):
 *  ----------------------
 *    p not given → "interactive" mode:
 *                  - print help
 *                  - print today's start & end times
 *                  - print full CSV (date,start,end,duration)
 *                  - if running inside Symcon, also generate a yearly PNG
 *                    graph and store it as a Media object.
 *
 *    p=help      → print documentation only.
 *
 *    p=start     → RETURN (not echo) the start time for the given date
 *                  as "HH:MM:SS" or "" if no incidence.
 *
 *    p=end       → RETURN the end time ("HH:MM:SS" or "").
 *
 *    p=csv       → output CSV (date,start,end,duration) only.
 *
 *    p=plot      → output an HTML/Chart.js plot (for web, not Symcon).
 *
 *  Parameter sources:
 *  ------------------
 *    - In IP-Symcon: via $_IPS, when called with IPS_RunScript(Wait)Ex.
 *    - In CLI:       via positional mode + arg=value pairs.
 *    - In Web:       via $_GET.
 *
 *  Adjustable parameters (with defaults in $DEFAULTS):
 *  ---------------------------------------------------
 *    lat   → latitude      (deg)
 *    lon   → longitude     (deg)
 *    az    → window azimuth (deg, 0=N, 90=E, 180=S, 270=W)
 *    vmin  → minimum sun elevation (deg above horizon)
 *    vmax  → maximum sun elevation (deg above horizon)
 *    hleft → max degrees to the LEFT  of window normal accepted (Δaz < 0)
 *    hright→ max degrees to the RIGHT of window normal accepted (Δaz > 0)
 *    step  → time step in minutes (for the yearly scan)
 *    year  → year to compute
 *    tz    → local timezone offset from UTC in hours
 ******************************************************************************/

// ----------------------------------------------------------------------------
// Detect IP-Symcon & media category
// ----------------------------------------------------------------------------
$IS_SYMCON = defined('IPS_BASE');

// Category where the PNG media object should live
// (Change this to whatever category ID you want in your own object tree)
$MEDIA_CATEGORY_ID = 53008;   // e.g. "Spielbrüggli 4 → media"

// ----------------------------------------------------------------------------
// DEFAULT PARAMETERS
// ----------------------------------------------------------------------------
$DEFAULTS = [
    'lat'   => 47.331,
    'lon'   => 9.409,
    'az'    => 207.9,
    'vmin'  => 0.0,
    'vmax'  => 45.0,
    'hleft' => 30.0,   // max degrees to the left  of window normal
    'hright'=> 30.0,   // max degrees to the right of window normal
    'step'  => 5,
    'year'  => 2025,
    'tz'    => 1,
];

// ----------------------------------------------------------------------------
// PARAMETER PARSING (SYMCON + CLI + WEB)
// ----------------------------------------------------------------------------

/**
 * Generic parameter resolver:
 *  - Symcon: from $_IPS
 *  - CLI:    from arg=value in $argv
 *  - Web:    from $_GET
 */
function getParam(string $name, $default) {
    global $IS_SYMCON;

    // IP-Symcon: parameters come via $_IPS
    if ($IS_SYMCON) {
        global $_IPS;
        return $_IPS[$name] ?? $default;
    }

    // CLI usage: php script.php mode lat=47.3 lon=9.4 ...
    if (php_sapi_name() === 'cli') {
        global $argv;
        foreach ($argv as $arg) {
            if (strpos($arg, "$name=") === 0) {
                return substr($arg, strlen("$name="));
            }
        }
        return $default;
    }

    // Web usage: script.php?p=start&lat=...&lon=...
    return $_GET[$name] ?? $default;
}

/**
 * Resolve "mode" (p) from environment.
 */
function getMode() {
    global $IS_SYMCON;

    if ($IS_SYMCON) {
        global $_IPS;
        return $_IPS['p'] ?? null;
    }

    if (php_sapi_name() === 'cli') {
        global $argv;
        // First non-arg=value word is treated as mode
        if (isset($argv[1]) && strpos($argv[1], '=') === false) {
            return $argv[1];
        }
        return null;
    }

    return $_GET['p'] ?? null;
}

/**
 * Resolve date parameter (YYYY-MM-DD).
 * If not present, return today's date.
 */
function getDateParam() {
    global $IS_SYMCON;

    if ($IS_SYMCON) {
        global $_IPS;
        return $_IPS['date'] ?? null;
    }

    if (php_sapi_name() === 'cli') {
        global $argv;
        foreach ($argv as $arg) {
            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $arg)) {
                return $arg;
            }
        }
        return null;
    }

    return $_GET['date'] ?? null;
}

function resolveDate($d) {
    return $d ?: date('Y-m-d');
}

// ----------------------------------------------------------------------------
// SOLAR CALCULATIONS
// ----------------------------------------------------------------------------

/**
 * Convert a UTC datetime to Julian Day.
 */
function julianDay(DateTime $dtUTC): float {
    $year  = (int)$dtUTC->format('Y');
    $month = (int)$dtUTC->format('m');
    $day   = (int)$dtUTC->format('d');
    $hour  = (int)$dtUTC->format('H');
    $min   = (int)$dtUTC->format('i');
    $sec   = (int)$dtUTC->format('s');

    $frac = $day + ($hour + $min / 60 + $sec / 3600) / 24;

    if ($month <= 2) {
        $year--;
        $month += 12;
    }

    $A = floor($year / 100);
    $B = 2 - $A + floor($A / 4);

    return floor(365.25 * ($year + 4716))
         + floor(30.6001 * ($month + 1))
         + $frac + $B - 1524.5;
}

/**
 * Compute solar azimuth and elevation (deg) for a given UTC datetime and location.
 *  azimuth:   deg from north, clockwise (0=N, 90=E, 180=S, 270=W)
 *  elevation: deg above horizon
 */
function solarPosition(DateTime $dtUTC, float $lat, float $lon): array {
    $jd = julianDay($dtUTC);
    $T  = ($jd - 2451545.0) / 36525.0;

    $L0 = fmod(280.46646 + $T * (36000.76983 + $T * 0.0003032), 360);
    $M  = 357.52911 + $T * (35999.05029 - 0.0001537 * $T);
    $e  = 0.016708634 - $T * (0.000042037 + 0.0000001267 * $T);

    $Mrad = deg2rad($M);
    $C = (1.914602 - $T * (0.004817 + 0.000014 * $T)) * sin($Mrad)
       + (0.019993 - 0.000101 * $T) * sin(2 * $Mrad)
       + 0.000289 * sin(3 * $Mrad);

    $trueLong = $L0 + $C;

    $omega = 125.04 - 1934.136 * $T;
    $lam   = $trueLong - 0.00569 - 0.00478 * sin(deg2rad($omega));

    $eps0 = 23 + (26 + ((21.448 - $T * (46.815 + $T * (0.00059 - 0.001813 * $T))) / 60)) / 60;
    $eps  = $eps0 + 0.00256 * sin(deg2rad($omega));

    $decl = rad2deg(asin(sin(deg2rad($eps)) * sin(deg2rad($lam))));

    $y     = pow(tan(deg2rad($eps / 2)), 2);
    $L0rad = deg2rad($L0);
    $E     = 4 * rad2deg(
        $y * sin(2 * $L0rad)
        - 2 * $e * sin($Mrad)
        + 4 * $e * $y * sin($Mrad) * cos(2 * $L0rad)
        - 0.5 * $y * $y * sin(4 * $L0rad)
        - 1.25 * $e * $e * sin(2 * $Mrad)
    );

    $hour = (int)$dtUTC->format('H');
    $min  = (int)$dtUTC->format('i');
    $sec  = (int)$dtUTC->format('s');

    $mins = $hour * 60 + $min + $sec / 60;
    $tst  = fmod($mins + $E + 4 * $lon, 1440);

    $ha = ($tst / 4 < 0) ? $tst / 4 + 180 : $tst / 4 - 180;

    $latR = deg2rad($lat);
    $haR  = deg2rad($ha);
    $decR = deg2rad($decl);

    $cosZen = sin($latR) * sin($decR) + cos($latR) * cos($decR) * cos($haR);
    $cosZen = max(-1, min(1, $cosZen));

    $zen = rad2deg(acos($cosZen));
    $el  = 90 - $zen;

    $sinAz = -sin($haR) * cos($decR) / cos(deg2rad($el));
    $cosAz = (sin($decR) - sin($latR) * sin(deg2rad($el)))
           / (cos($latR) * cos(deg2rad($el)));

    $az = rad2deg(atan2($sinAz, $cosAz));
    if ($az < 0) $az += 360;

    return [$az, $el];
}

// ----------------------------------------------------------------------------
// YEARLY SCAN
// ----------------------------------------------------------------------------

/**
 * For a full year, compute daily start/end times when the window
 * receives direct sun, given vertical and horizontal angular constraints.
 */
function computeTimesForYear(
    int $year, float $lat, float $lon, float $azWin,
    float $vmin, float $vmax,
    float $hleft, float $hright,
    int $stepMin, int $tz
): array {

    $res   = [];
    $start = new DateTime("$year-01-01");

    for ($i = 0; $i < 366; $i++) {
        $date = (clone $start)->modify("+$i day");
        if ((int)$date->format('Y') !== $year) break;

        $ts = new DateTime($date->format('Y-m-d') . " 04:00:00");
        $te = new DateTime($date->format('Y-m-d') . " 22:00:00");

        $t    = clone $ts;
        $hits = [];

        while ($t <= $te) {
            // local → UTC
            $tUTC      = (clone $t)->modify(sprintf("%+d hour", -$tz));
            [$az, $el] = solarPosition($tUTC, $lat, $lon);

            if ($el > 0 && $el > $vmin && $el <= $vmax) {
                // Signed azimuth difference in [-180, 180], sun - window
                $daz = fmod($az - $azWin + 180, 360) - 180;

                $accept = false;
                if ($daz >= 0 && $daz <= $hright) {
                    // Sun to the right (clockwise) of window normal
                    $accept = true;
                } elseif ($daz < 0 && -$daz <= $hleft) {
                    // Sun to the left (counter-clockwise) of window normal
                    $accept = true;
                }

                if ($accept) {
                    $hits[] = clone $t;
                }
            }
            $t->modify("+$stepMin minutes");
        }

        if ($hits) {
            $f   = $hits[0];
            $l   = $hits[count($hits) - 1];
            $dur = ($l->getTimestamp() - $f->getTimestamp()) / 3600;
            $res[] = [
                'date'     => $date->format('Y-m-d'),
                'start'    => $f->format('H:i'),
                'end'      => $l->format('H:i'),
                'duration' => round($dur, 2),
            ];
        } else {
            $res[] = [
                'date'     => $date->format('Y-m-d'),
                'start'    => '',
                'end'      => '',
                'duration' => 0,
            ];
        }
    }

    return $res;
}

// ----------------------------------------------------------------------------
// GRAPH PNG CREATION (SYMCON ONLY)
// ----------------------------------------------------------------------------

/**
 * Create/update a PNG graph showing start/end times and shaded "direct sun"
 * region for the whole year, and store it as a Media object.
 *
 * This uses IPS_SetMediaContent() so no filesystem path handling is needed;
 * the image appears as a Media object named "SunWindowGraph <year>".
 */
function createSymconPNGGraph(array $res, int $year, float $vmin, float $vmax, float $hleft, float $hright) {
    global $IS_SYMCON, $MEDIA_CATEGORY_ID;
    if (!$IS_SYMCON) return;
    if (count($res) < 2) return;

    IPS_LogMessage('SunWindow', "createSymconPNGGraph(): year=$year, days=".count($res).", vmin=$vmin, vmax=$vmax, hleft=$hleft, hright=$hright, mediaCat=$MEDIA_CATEGORY_ID");

    // Basic canvas
    $width  = 2400;  // width in pixels
    $height = 800;   // height in pixels
    $marginLeft   = 70;
    $marginRight  = 20;
    $marginTop    = 40;
    $marginBottom = 70;

    $img = imagecreatetruecolor($width, $height);

    // Colors
    $white   = imagecolorallocate($img, 255, 255, 255);
    $black   = imagecolorallocate($img,   0,   0,   0);
    $gray    = imagecolorallocate($img, 220, 220, 220);
    $blue    = imagecolorallocate($img,   0,   0, 255);
    $red     = imagecolorallocate($img, 255,   0,   0);
    $yellow  = imagecolorallocate($img, 255, 255, 180);

    imagefill($img, 0, 0, $white);

    $nDays      = count($res);
    $plotWidth  = $width  - $marginLeft - $marginRight;
    $plotHeight = $height - $marginTop  - $marginBottom;

    // Axis limits (hours of day)
    $yMin = 0;
    $yMax = 24;

    // Helper to map (day index, hour) to pixel coords
    $xForDay = function(int $i) use ($marginLeft, $plotWidth, $nDays) {
        return $marginLeft + ($nDays > 1 ? $plotWidth * $i / ($nDays - 1) : 0);
    };
    // EARLIER HOURS AT TOP: 0h at top, 24h at bottom
    $yForHour = function(float $h) use ($marginTop, $plotHeight, $yMin, $yMax) {
        return $marginTop + $plotHeight * (($h - $yMin) / ($yMax - $yMin));
    };

    // Prepare arrays of start/end hours (decimal) or null
    $startHours = [];
    $endHours   = [];
    for ($i = 0; $i < $nDays; $i++) {
        $row = $res[$i];
        if ($row['start'] !== '') {
            [$sh, $sm] = array_map('intval', explode(':', $row['start']));
            $startHours[$i] = $sh + $sm/60.0;
        } else {
            $startHours[$i] = null;
        }

        if ($row['end'] !== '') {
            [$eh, $em] = array_map('intval', explode(':', $row['end']));
            $endHours[$i] = $eh + $em/60.0;
        } else {
            $endHours[$i] = null;
        }
    }

    // Axes
    imageline($img, $marginLeft, $marginTop, $marginLeft, $marginTop + $plotHeight, $black);
    imageline($img, $marginLeft, $marginTop + $plotHeight, $marginLeft + $plotWidth, $marginTop + $plotHeight, $black);

    // Horizontal grid every 2 h (00:00 at top, 24:00 bottom)
    for ($h = 0; $h <= 24; $h += 2) {
        $y = $yForHour($h);
        imageline($img, $marginLeft, $y, $marginLeft + $plotWidth, $y, $gray);
        imagestring($img, 2, 5, $y - 7, sprintf('%02d:00', $h), $black);
    }

    // Vertical grid + dd.mm labels every ~30 days
    for ($i = 0; $i < $nDays; $i += 30) {
        $row = $res[$i];
        $x = $xForDay($i);
        imageline($img, $x, $marginTop, $x, $marginTop + $plotHeight, $gray);
        $ts = strtotime($row['date']);
        $label = date('d.m', $ts);
        $textWidth = strlen($label) * imagefontwidth(2);
        imagestring($img, 2, (int)($x - $textWidth/2), $marginTop + $plotHeight + 5, $label, $black);
    }

    // --- Shaded region between start and end (yellow) ---
    for ($i = 1; $i < $nDays; $i++) {
        $s0 = $startHours[$i-1];
        $e0 = $endHours[$i-1];
        $s1 = $startHours[$i];
        $e1 = $endHours[$i];

        if ($s0 !== null && $e0 !== null && $s1 !== null && $e1 !== null) {
            $x0 = $xForDay($i-1);
            $x1 = $xForDay($i);
            $yS0 = $yForHour($s0);
            $yS1 = $yForHour($s1);
            $yE0 = $yForHour($e0);
            $yE1 = $yForHour($e1);

            $points = [
                $x0, $yS0,
                $x1, $yS1,
                $x1, $yE1,
                $x0, $yE0
            ];
            imagefilledpolygon($img, $points, 4, $yellow);
        }
    }

    // --- Draw start/end lines on top ---
    $prevX = null; $prevYS = null; $prevYE = null;
    for ($i = 0; $i < $nDays; $i++) {
        $x = $xForDay($i);

        // Start line
        if ($startHours[$i] !== null) {
            $ys = $yForHour($startHours[$i]);
            if ($prevX !== null && $prevYS !== null) {
                imageline($img, $prevX, $prevYS, $x, $ys, $blue);
            }
            $prevYS = $ys;
        } else {
            $prevYS = null;
        }

        // End line
        if ($endHours[$i] !== null) {
            $ye = $yForHour($endHours[$i]);
            if ($prevX !== null && $prevYE !== null) {
                imageline($img, $prevX, $prevYE, $x, $ye, $red);
            }
            $prevYE = $ye;
        } else {
            $prevYE = null;
        }

        $prevX = $x;
    }

    // Title
    imagestring($img, 3, $marginLeft, 10, "Direct sun on window - Year $year", $black);

    // Legend
    $legendX = $marginLeft + 250;
    $legendY = $marginTop + 5;
    $boxW = 14;
    $boxH = 8;
    $line = 0;

    // Yellow shading
    imagefilledrectangle($img, $legendX, $legendY + $line*15, $legendX+$boxW, $legendY + $line*15 + $boxH, $yellow);
    imagerectangle($img, $legendX, $legendY + $line*15, $legendX+$boxW, $legendY + $line*15 + $boxH, $black);
    imagestring($img, 2, $legendX + $boxW + 5, $legendY + $line*15 - 2, "direct sun (within cones)", $black);
    $line++;

    // Blue line
    imageline($img, $legendX, $legendY + $line*15 + 3, $legendX+$boxW, $legendY + $line*15 + 3, $blue);
    imagestring($img, 2, $legendX + $boxW + 5, $legendY + $line*15 - 2, "start time", $black);
    $line++;

    // Red line
    imageline($img, $legendX, $legendY + $line*15 + 3, $legendX+$boxW, $legendY + $line*15 + 3, $red);
    imagestring($img, 2, $legendX + $boxW + 5, $legendY + $line*15 - 2, "end time", $black);
    $line++;

    // Text explaining thresholds, using proper degree symbol (ISO-8859-1-safe)
    $deg = chr(176);
    $text = sprintf(
        "Shading: elevation %.1f%s–%.1f%s, azimuth in -%.1f%s..+%.1f%s from window normal",
        $vmin, $deg, $vmax, $deg, $hleft, $deg, $hright, $deg
    );
    imagestring($img, 2, $marginLeft, $height - $marginBottom + 10, $text, $black);

    // Encode PNG into memory
    ob_start();
    imagepng($img);
    $pngData = ob_get_clean();
    imagedestroy($img);
    IPS_LogMessage('SunWindow', "PNG generated, bytes=".strlen($pngData));

    // Create or reuse media object, store content directly
    $mediaName = "SunWindowGraph $year";
    $mediaID = null;
    foreach (IPS_GetMediaList() as $mid) {
        $m = IPS_GetMedia($mid);
        if ($m['MediaType'] == 1) { // 1 = image
            $o = IPS_GetObject($mid);
            if ($o['ObjectName'] === $mediaName) {
                $mediaID = $mid;
                break;
            }
        }
    }

    if ($mediaID === null) {
        $mediaID = IPS_CreateMedia(1); // image
        IPS_SetName($mediaID, $mediaName);
        IPS_SetParent($mediaID, $MEDIA_CATEGORY_ID);
        IPS_LogMessage('SunWindow', "Created new media object ID=$mediaID in category $MEDIA_CATEGORY_ID");
    } else {
        IPS_SetParent($mediaID, $MEDIA_CATEGORY_ID);
        IPS_LogMessage('SunWindow', "Reusing media object ID=$mediaID, moved to category $MEDIA_CATEGORY_ID");
    }

    IPS_SetMediaContent($mediaID, base64_encode($pngData));
    IPS_LogMessage('SunWindow', "Media content updated for ID=$mediaID");
}

// ----------------------------------------------------------------------------
// OUTPUT FUNCTIONS
// ----------------------------------------------------------------------------

function outputHelp() {
    global $IS_SYMCON;
    $txt = <<<TXT
SUN-WINDOW SCRIPT HELP

Modes:
  p not given → help + today's start & end + CSV
  p=help      → documentation only
  p=start     → start time for date
  p=end       → end time for date
  p=csv       → CSV only
  p=plot      → Chart.js plot (web only)

Parameters:
  lat, lon, az, vmin, vmax, hleft, hright, step, year, tz, date

TXT;

    if ($IS_SYMCON) {
        echo $txt;
    } else {
        header('Content-Type:text/plain; charset=utf-8');
        echo $txt;
    }
}

/**
 * Returns the start or end time (HH:MM:SS) for a given date,
 * or "" if there is no sun incidence under current geometry.
 */
function outputSingle($res, string $date, string $which): string {
    foreach ($res as $r) {
        if ($r['date'] === $date) {
            $t = ($which === 'start') ? $r['start'] : $r['end'];
            return $t ? $t . ':00' : '';
        }
    }
    return '';
}

function outputCSV($res) {
    global $IS_SYMCON;

    if (!$IS_SYMCON) {
        header('Content-Type:text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename=sunwindow.csv');
    }

    $fh = fopen('php://output', 'w');
    fputcsv($fh, ['date', 'start', 'end', 'duration']);
    foreach ($res as $r) {
        fputcsv($fh, $r);
    }
}

function outputPlot($res) {
    global $IS_SYMCON;
    if ($IS_SYMCON) {
        echo "Plot unavailable in Symcon.";
        return;
    }

    header('Content-Type:text/html; charset=utf-8');

    $labels = [];
    $s      = [];
    $e      = [];

    foreach ($res as $r) {
        $labels[] = date('d.m.', strtotime($r['date']));
        if ($r['start']) {
            [$h, $m]  = explode(':', $r['start']);
            $s[]      = $h + $m / 60;
            [$h2,$m2] = explode(':', $r['end']);
            $e[]      = $h2 + $m2 / 60;
        } else {
            $s[] = null;
            $e[] = null;
        }
    }

    $L = json_encode($labels);
    $S = json_encode($s);
    $E = json_encode($e);

    echo <<<HTML
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<canvas id="c"></canvas>
<script>
new Chart(document.getElementById('c'), {
  type: 'line',
  data: {
    labels: $L,
    datasets: [
      {label: 'Start', data: $S, borderColor: 'blue'},
      {label: 'End',   data: $E, borderColor: 'red'}
    ]
  },
  options: {
    scales: {
      y: {reverse: true, min: 0, max: 24}
    }
  }
});
</script>
</body>
</html>
HTML;
}

// ----------------------------------------------------------------------------
// MAIN DISPATCH
// ----------------------------------------------------------------------------

// 1) Read mode and date
$p = getMode();
$d = resolveDate(getDateParam());

// 2) Resolve parameters (from environment or defaults)
$lat    = floatval(getParam('lat',   $DEFAULTS['lat']));
$lon    = floatval(getParam('lon',   $DEFAULTS['lon']));
$az     = floatval(getParam('az',    $DEFAULTS['az']));
$vmin   = floatval(getParam('vmin',  $DEFAULTS['vmin']));
$vmax   = floatval(getParam('vmax',  $DEFAULTS['vmax']));
$hleft  = floatval(getParam('hleft', $DEFAULTS['hleft']));
$hright = floatval(getParam('hright',$DEFAULTS['hright']));
$step   = intval(getParam('step',    $DEFAULTS['step']));
$year   = intval(getParam('year',    $DEFAULTS['year']));
$tz     = intval(getParam('tz',      $DEFAULTS['tz']));

// 3) Compute the yearly table (done once per invocation)
$RESULTS = computeTimesForYear($year, $lat, $lon, $az, $vmin, $vmax, $hleft, $hright, $step, $tz);

// 4) Debug: log what the dispatch sees (Symcon only)
if ($IS_SYMCON) {
    IPS_LogMessage(
        'SunWindow',
        sprintf(
            'Dispatch: p=%s date=%s lat=%.3f lon=%.3f az=%.1f vmin=%.1f vmax=%.1f hleft=%.1f hright=%.1f step=%d year=%d tz=%d',
            var_export($p, true),
            $d,
            $lat,
            $lon,
            $az,
            $vmin,
            $vmax,
            $hleft,
            $hright,
            $step,
            $year,
            $tz
        )
    );
}

// 5) Mode handling -----------------------------------------------------------

if ($p === 'help') {
    outputHelp();
    return;
}

if ($p === 'start') {
    $val = outputSingle($RESULTS, $d, 'start');

    if ($IS_SYMCON) {
        // In Symcon, IPS_RunScriptWaitEx() returns the ECHOed output,
        // not the PHP return value. Therefore we echo the time and
        // just "return;" (no value) afterwards.
        IPS_LogMessage('SunWindow', "Mode=start, date=$d, echoing start='$val'");
        echo $val;
        return;
    }

    // CLI / Web include-style usage: return the value
    return $val;
}

if ($p === 'end') {
    $val = outputSingle($RESULTS, $d, 'end');

    if ($IS_SYMCON) {
        IPS_LogMessage('SunWindow', "Mode=end, date=$d, echoing end='$val'");
        echo $val;
        return;
    }

    return $val;
}


if ($p === 'csv') {
    if ($IS_SYMCON) {
        IPS_LogMessage('SunWindow', 'Mode=csv, outputting CSV text only.');
        foreach ($RESULTS as $r) {
            echo "{$r['date']},{$r['start']},{$r['end']},{$r['duration']}\n";
        }
        return;
    }
    outputCSV($RESULTS);
    return;
}

if ($p === 'plot') {
    if ($IS_SYMCON) {
        IPS_LogMessage('SunWindow', 'Mode=plot requested (mainly useful in web context).');
    }
    outputPlot($RESULTS);
    return;
}

// 6) DEFAULT MODE (no p): help + today + CSV + PNG (if Symcon) --------------

if ($IS_SYMCON) {
    IPS_LogMessage('SunWindow', 'Mode=default (no p), printing help + today + CSV and generating PNG.');
}

outputHelp();

// In Symcon, also generate PNG and register media
if ($IS_SYMCON) {
    createSymconPNGGraph($RESULTS, $year, $vmin, $vmax, $hleft, $hright);
}

echo "\n\n=== TODAY START ($d) ===\n";
$ts = outputSingle($RESULTS, $d, 'start');
echo ($ts !== '' ? $ts : "[no sun incidence]") . "\n";

echo "\n=== TODAY END ($d) ===\n";
$te = outputSingle($RESULTS, $d, 'end');
echo ($te !== '' ? $te : "[no sun incidence]") . "\n";

echo "\n=== CSV ===\n";
echo "date,start,end,duration\n";
foreach ($RESULTS as $r) {
    echo "{$r['date']},{$r['start']},{$r['end']},{$r['duration']}\n";
}

?>