Skip to main content

⚙️ dwdWetter.php

<?php
/*
===============================================================================
 dwdWetter – Wetterdaten vom Deutschen Wetterdienst (DWD)
 Moderne, strukturierte JSON-Ausgabe pro Station inkl. Icon-Mapping (wwPic)
 Version: V 2025-11-24
 PHP: min. 8.2
 Quelle: https://opendata.dwd.de/
===============================================================================
*/

// -----------------------------------------------------------------------------
// EINSTELLUNGEN
// -----------------------------------------------------------------------------

$config = [

    // Liste der Stationen (DWD MOSMIX IDs)
    'stations' => [
        'K428'  => 'Bitburg',
        '10609' => 'Trier'
    ],

    // Anzahl Vorhersagetage 1-10 (Default: 3)
    'forecast_days' => 10,

    // Datenintervall (Cache) in Minuten
    'cache_minutes' => 60,

    // Ausgabeordner für JSON-Dateien (empfohlen: separater json-Ordner)
    'output_path' => __DIR__ . '/json/',

    // Debug-Modus
    'debug' => true,

    // ------------------------- Erweiterte Parameter ---------------------------

    // Anzahl Stunden pro Vorhersageschritt (1, 3, 6 ...)
    'step_hours' => 1,

    // Timeout für DWD HTTP-Abfragen (Sekunden)
    'curl_timeout' => 30,

    // maximale Debug-Loggrösse in KB (0 = unbegrenzt)
    'debug_max_kb' => 1024,

    // temporärer Ordner für KMZ/KML-Dateien
    'tmp_path' => __DIR__ . '/tmp/',

    // Stationen überspringen
    'skip_stations' => [],

    // Nur speichern, wenn gültige Daten vorhanden sind
    'save_only_if_valid' => true,

    // Log-Level: debug | info | verbose
    'log_level' => 'info',

    // Muster für Ausgabedateien
    // Platzhalter: {stationID} {stationName} {date}
    'filename_pattern' => 'wetter_{stationID}.json',

    // Zeitzone
    'timezone' => 'Europe/Berlin',

    // ------------------------- Icons / Wettercodes ----------------------------

    // Basisordner für Icon-Sets
    'icons_path' => __DIR__ . '/dwd_img/',

    // Name des Icon-Sets (Unterordner; leer = direkt in icons_path)
    'icon_set' => '',

    // Icon-Mapping aktiv
    'icons_enabled' => true,

    // Ab wie viel % Sonnenschein (SunD3) Tag als "sonnig" gilt (für 1s, 2s, 80s)
    'sun_threshold' => 30,

    // Ab wie viel °C Hitzesymbol (0h) verwendet wird
    'hot_temp' => 35.0
];

date_default_timezone_set($config['timezone']);


// -----------------------------------------------------------------------------
// LOG-ORDNER ANLEGEN (vor erster dbg()-Nutzung)
// -----------------------------------------------------------------------------

$logsDir = __DIR__ . '/logs/';
if (!is_dir($logsDir)) {
    mkdir($logsDir, 0755, true);
}

// -----------------------------------------------------------------------------
// DEBUG-FUNKTION
// -----------------------------------------------------------------------------

function dbg($msg, $config, $level = 'info')
{
    $allowed = ['debug' => 1, 'info' => 2, 'verbose' => 3];

    if (!isset($allowed[$config['log_level']])) {
        return;
    }
    if ($allowed[$config['log_level']] < $allowed[$level]) {
        return;
    }
    if (!$config['debug']) {
        return;
    }

    // Logdatei im logs-Ordner
    $file = __DIR__ . '/logs/dwdWetter_debug.log';

    // Grösse prüfen
    if ($config['debug_max_kb'] > 0 && file_exists($file)) {
        if (filesize($file) > ($config['debug_max_kb'] * 1024)) {
            file_put_contents($file, "=== Log gekuerzt " . date('Y-m-d H:i:s') . " ===\n");
        }
    }

    $line = "[" . date('Y-m-d H:i:s') . "] " . $msg . PHP_EOL;
    file_put_contents($file, $line, FILE_APPEND);
}


// -----------------------------------------------------------------------------
// Ordner erzeugen
// -----------------------------------------------------------------------------

// JSON-Ausgabeordner
if (!is_dir($config['output_path'])) {
    mkdir($config['output_path'], 0755, true);
    dbg("Ordner erstellt: " . $config['output_path'], $config, 'info');
}

// TMP-Ordner
if (!is_dir($config['tmp_path'])) {
    mkdir($config['tmp_path'], 0755, true);
    dbg("TMP-Ordner erstellt: " . $config['tmp_path'], $config, 'info');
}

// Icon-Basis- und ggf. Set-Ordner anlegen
$iconsBaseDir = rtrim($config['icons_path'], "/\\") . DIRECTORY_SEPARATOR;
$iconsDir     = $iconsBaseDir;

if ($config['icon_set'] !== '') {
    $iconsDir .= rtrim($config['icon_set'], "/\\") . DIRECTORY_SEPARATOR;
}

if (!is_dir($iconsDir)) {
    mkdir($iconsDir, 0755, true);
    dbg("Icon-Ordner erstellt: " . $iconsDir, $config, 'debug');
}


// -----------------------------------------------------------------------------
// Hilfsfunktionen
// -----------------------------------------------------------------------------

function getHumidity($T, $TD)
{
    if (!is_numeric($T) || !is_numeric($TD)) {
        return null;
    }
    $T  = $T - 273.15;
    $TD = $TD - 273.15;
    return round(100 * (exp((17.625 * $TD) / (243.04 + $TD)) / exp((17.625 * $T) / (243.04 + $T))));
}

function getWindDirection($deg)
{
    $dirs = ['N','NNO','NO','ONO','O','OSO','SO','SSO','S','SSW','SW','WSW','W','WNW','NW','NNW'];
    $step = 360 / count($dirs);
    return $dirs[floor(($deg + ($step / 2)) / $step) % count($dirs)];
}

/**
 * Liest ein MOSMIX-Parameter-Array aus dem dwd:Forecast-Block (DOM/XPath).
 *
 * @param DOMXPath   $xpath        XPath-Objekt mit registrierten kml/dwd-Namespaces
 * @param DOMElement $forecastNode kml:ExtendedData unterhalb Placemark
 * @param string     $id           z.B. 'TTT', 'Td', 'DD', ...
 * @param string     $nsDwd        Namespace-URI für dwd (wird nur zur Info durchgereicht)
 * @return array                   Werte-Array (Strings)
 */
function getParamArray($xpath, $forecastNode, $id, $nsDwd)
{
    if (!$forecastNode || !$xpath || !$id) {
        return [];
    }

    // dwd:Forecast/@dwd:elementName = $id → dwd:value
    $query = './/dwd:Forecast[@dwd:elementName="'.$id.'"]/dwd:value';
    $nodes = $xpath->query($query, $forecastNode);

    if ($nodes === false || $nodes->length === 0) {
        return [];
    }

    // Wir nehmen den ersten passenden dwd:value-Knoten
    $valNode = $nodes->item(0);
    if (!$valNode) {
        return [];
    }

    $v = trim($valNode->textContent);
    if ($v === '') {
        return [];
    }

    // Mehrfache Leerzeichen/Zeilenumbrüche normalisieren und in Array aufsplitten
    $v   = preg_replace('!\s+!', ' ', $v);
    $arr = explode(' ', trim($v));

    return $arr;
}

// wwPic
if (!function_exists('wwPic')) {
    function wwPic($Code, $bolSun, $intTagBeginn, $intTagEnde, $WT, $bolMaxT)
    {
        $intHour = $WT;
        $bolDay  = ($intHour > $intTagBeginn && $intHour < $intTagEnde);

        switch ($Code) {
            case 0:
                // wenn Tag und heiss: 0h | wenn Tag: 0d | wenn Nacht: 0n
                if ($bolDay == true and $bolMaxT == true) {
                    $icon = '0h';
                } elseif ($bolDay == true) {
                    $icon = '0d';
                } else {
                    $icon = '0n';
                }
                break;
            case 1:
                // wenn sonniger Tag: 1s | wenn Tag: 1d | wenn Nacht: 1n
                if ($bolDay == true and $bolSun == true) {
                    $icon = '1s';
                } elseif ($bolDay == true) {
                    $icon = '1d';
                } else {
                    $icon = '1n';
                }
                break;
            case 2:
                // wenn sonniger Tag: 2s | wenn Tag: 2d | wenn Nacht: 2n
                if ($bolDay == true and $bolSun == true) {
                    $icon = '2s';
                } elseif ($bolDay == true) {
                    $icon = '2d';
                } else {
                    $icon = '2n';
                }
                break;
            case 3:
                // wenn Tag: 3d | wenn Nacht: 3n
                $icon = ($bolDay) ? '3d' : '3n';
                break;
            case 4:
            case 5:
            case 6:
            case 7:
            case 8:
            case 9:
                $icon = '4-9';
                break;
            case 10:
            case 11:
            case 12:
            case 13:
            case 14:
            case 15:
            case 16:
                $icon = '10-16';
                break;
            case 17:
                $icon = '17';
                break;
            case 18:
                $icon = '18';
                break;
            case 19:
                $icon = '19';
                break;
            case 20:
                $icon = '20';
                break;
            case 21:
                $icon = '21';
                break;
            case 22:
                $icon = '22';
                break;
            case 23:
            case 24:
                $icon = '23-24';
                break;
            case 25:
                $icon = '25';
                break;
            case 26:
                $icon = '26';
                break;
            case 27:
                $icon = '27';
                break;
            case 28:
                $icon = '28';
                break;
            case 29:
                $icon = '29';
                break;
            case 30:
            case 31:
            case 32:
                $icon = '30-32';
                break;
            case 33:
            case 34:
            case 35:
                $icon = '33-35';
                break;
            case 36:
            case 37:
            case 38:
            case 39:
                $icon = '36-39';
                break;
            case 40:
            case 41:
            case 42:
            case 43:
            case 44:
            case 45:
            case 46:
            case 47:
            case 48:
            case 49:
                $icon = '40-49';
                break;
            case 50:
            case 51:
            case 52:
            case 53:
                $icon = '50-53';
                break;
            case 54:
            case 55:
            case 56:
            case 57:
            case 58:
            case 59:
                $icon = '55-59';
                break;
            case 60:
            case 61:
            case 62:
            case 63:
            case 64:
            case 65:
                $icon = '60-65';
                break;
            case 66:
            case 67:
                $icon = '66-67';
                break;
            case 68:
            case 69:
                $icon = '68-69';
                break;
            case 70:
            case 71:
            case 72:
            case 73:
            case 74:
            case 75:
            case 76:
            case 77:
            case 78:
            case 79:
                $icon = '70-79';
                break;
            case 80:
                // wenn sonniger Tag: 80s | wenn Tag: 80d | wenn Nacht: 80n
                if ($bolDay == true and $bolSun == true) {
                    $icon = '80s';
                } elseif ($bolDay == true) {
                    $icon = '80d';
                } else {
                    $icon = '80n';
                }
                break;
            case 81:
                $icon = '81';
                break;
            case 82:
                $icon = '82';
                break;
            case 83:
            case 84:
                $icon = '83-84';
                break;
            case 85:
            case 86:
            case 87:
            case 88:
                $icon = '85-88';
                break;
            case 89:
            case 90:
                $icon = '89-90';
                break;
            case 91:
            case 92:
            case 93:
            case 94:
            case 95:
            case 96:
            case 97:
            case 98:
            case 99:
                $icon = '91-99';
                break;
            default:
                $icon = 'unknown';
                break;
        }
        return $icon . '.png';
    }
}

// Tagesicon aus DWD-ww-Code für daily-Block ableiten
if (!function_exists('getDailyIconFromCode')) {
    function getDailyIconFromCode(int $code): string
    {
        $icon = 'unknown';

        switch (true) {
            case $code === 0:
                $icon = '0d';
                break;
            case $code === 1:
                $icon = '1d';
                break;
            case $code === 2:
                $icon = '2d';
                break;
            case $code === 3:
                $icon = '3d';
                break;

            case ($code >= 4 && $code <= 9):
                $icon = '4-9';
                break;

            case ($code >= 10 && $code <= 16):
                $icon = '10-16';
                break;

            case $code === 17:
            case $code === 18:
            case $code === 19:
            case $code === 20:
            case $code === 21:
            case $code === 22:
            case $code === 25:
            case $code === 26:
            case $code === 27:
            case $code === 28:
            case $code === 29:
            case $code === 81:
            case $code === 82:
                $icon = (string)$code;
                break;

            case ($code >= 23 && $code <= 24):
                $icon = '23-24';
                break;

            case ($code >= 30 && $code <= 32):
                $icon = '30-32';
                break;

            case ($code >= 33 && $code <= 35):
                $icon = '33-35';
                break;

            case ($code >= 36 && $code <= 39):
                $icon = '36-39';
                break;

            case ($code >= 40 && $code <= 49):
                $icon = '40-49';
                break;

            case ($code >= 50 && $code <= 53):
                $icon = '50-53';
                break;

            case ($code >= 54 && $code <= 59):
                // wie im wwPic-Mapping: 55-59.png
                $icon = '55-59';
                break;

            case ($code >= 60 && $code <= 65):
                $icon = '60-65';
                break;

            case ($code >= 66 && $code <= 67):
                $icon = '66-67';
                break;

            case ($code >= 68 && $code <= 69):
                $icon = '68-69';
                break;

            case ($code >= 70 && $code <= 79):
                $icon = '70-79';
                break;

            case $code === 80:
                $icon = '80d';
                break;

            case ($code >= 83 && $code <= 84):
                $icon = '83-84';
                break;

            case ($code >= 85 && $code <= 88):
                $icon = '85-88';
                break;

            case ($code >= 89 && $code <= 90):
                $icon = '89-90';
                break;

            case ($code >= 91 && $code <= 99):
                $icon = '91-99';
                break;
        }

        return $icon . '.png';
    }
}

// -----------------------------------------------------------------------------
// Verarbeitung pro Station
// -----------------------------------------------------------------------------

foreach ($config['stations'] as $stationID => $stationName) {

    if (in_array($stationID, $config['skip_stations'])) {
        dbg("Station uebersprungen: $stationID", $config, 'debug');
        continue;
    }

    dbg("Verarbeite Station $stationID ($stationName)", $config, 'info');

    // Dateiname nach Pattern
    $filename = str_replace(
        ['{stationID}', '{stationName}', '{date}'],
        [$stationID, $stationName, date('Y-m-d')],
        $config['filename_pattern']
    );

    $targetJSON = $config['output_path'] . $filename;

    // Cache prüfen
    if (file_exists($targetJSON)) {
        $age = time() - filemtime($targetJSON);
        if ($age < ($config['cache_minutes'] * 60)) {
            dbg("Cache gueltig fuer $stationID – kein Reload", $config, 'info');
            continue;
        }
    }

    // URL
    $url =
        "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/" .
        $stationID . "/kml/MOSMIX_L_LATEST_" . $stationID . ".kmz";

    dbg("Lade KMZ: $url", $config, 'info');

    // Download – TMP-Datei im tmp-Ordner
    $tmpFile = rtrim($config['tmp_path'], "/\\") . '/tmp_' . $stationID . '.kmz';
    $curl    = curl_init($url);
    $fp      = fopen($tmpFile, 'w');
    curl_setopt($curl, CURLOPT_FILE, $fp);
    curl_setopt($curl, CURLOPT_TIMEOUT, $config['curl_timeout']);
    curl_exec($curl);
    $http = curl_getinfo($curl, CURLINFO_HTTP_CODE);
    fclose($fp);
    curl_close($curl);

    if ($http !== 200) {
        dbg("Fehler HTTP $http bei $stationID", $config, 'info');
        continue;
    }

    // KMZ öffnen
    $zip = new ZipArchive();
    if ($zip->open($tmpFile) !== true) {
        dbg("ZipArchive Fehler bei $stationID", $config, 'info');
        continue;
    }

    $kmlName    = $zip->getNameIndex(0);
    $kmlContent = $zip->getFromName($kmlName);
    $zip->close();

    if (!$kmlContent) {
        dbg("Keine KML in KMZ gefunden ($stationID)", $config, 'info');
        continue;
    }

    dbg(
        "KML-Laenge fuer $stationID: " . strlen($kmlContent) .
        " Bytes, erster Ausschnitt: " . substr($kmlContent, 0, 120),
        $config,
        'debug'
    );

    // Encoding ggf. auf UTF-8 konvertieren (DWD-KML kommt i.d.R. als ISO-8859-1)
    if (preg_match('/encoding="([^"]+)"/i', substr($kmlContent, 0, 200), $m)) {
        $enc = strtoupper($m[1]);
        if ($enc !== 'UTF-8') {
            $kmlContent = iconv($enc, 'UTF-8//IGNORE', $kmlContent);
            $kmlContent = preg_replace('/encoding="[^"]+"/i', 'encoding="UTF-8"', $kmlContent, 1);
        }
    }

    // XML mit DOM laden (ohne SimpleXML)
    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $dom->preserveWhiteSpace = false;
    $dom->formatOutput       = false;

    if (!$dom->loadXML($kmlContent)) {
        $errors = libxml_get_errors();
        $msg = "DOMDocument loadXML Fehler bei $stationID:";
        foreach ($errors as $e) {
            $msg .= " [".$e->code."] Line ".$e->line.": ".trim($e->message);
        }
        dbg($msg, $config, 'info');
        libxml_clear_errors();
        continue;
    }
    libxml_clear_errors();

    // Namespaces aus dem Root-Element holen
    $root  = $dom->documentElement;
    $nsKml = $root->lookupnamespaceURI('kml');
    $nsDwd = $root->lookupnamespaceURI('dwd');

    if (!$nsKml || !$nsDwd) {
        dbg(
            "Namespaces kml/dwd fehlen oder unerwartet fuer $stationID",
            $config,
            'info'
        );
        continue;
    }

    // XPath vorbereiten
    $xpath = new DOMXPath($dom);
    $xpath->registerNamespace('kml', $nsKml);
    $xpath->registerNamespace('dwd', $nsDwd);

    // kml:Document holen
    $docNodeList = $xpath->query('/kml:kml/kml:Document');
    if ($docNodeList->length === 0) {
        dbg("kml:Document nicht gefunden fuer $stationID", $config, 'info');
        continue;
    }
    $docNode = $docNodeList->item(0);

    // -------------------- ForecastTimeSteps (dwd-Namespace) -------------------

    $tsNodes = $xpath->query('./kml:ExtendedData/dwd:ProductDefinition/dwd:ForecastTimeSteps/dwd:TimeStep', $docNode);
    $timeSteps = [];
    if ($tsNodes !== false) {
        foreach ($tsNodes as $ts) {
            $timeSteps[] = trim($ts->textContent);
        }
    }

    if (count($timeSteps) === 0) {
        dbg("Keine ForecastTimeSteps fuer $stationID gefunden", $config, 'info');
        continue;
    }

    // IssueTime
    $issueNodeList = $xpath->query('./kml:ExtendedData/dwd:ProductDefinition/dwd:IssueTime', $docNode);
    $issueStr      = null;
    if ($issueNodeList !== false && $issueNodeList->length > 0) {
        $issueStr = trim($issueNodeList->item(0)->textContent);
    }
    $issueTS = $issueStr ? strtotime($issueStr) : time();

    // ------------------------- Forecast-Parameter -----------------------------

    // Ersten Placemark (Station) holen
    $placemarkList = $xpath->query('./kml:Placemark', $docNode);
    if ($placemarkList->length === 0) {
        dbg("kml:Placemark nicht gefunden fuer $stationID", $config, 'info');
        continue;
    }
    $placemarkNode = $placemarkList->item(0);

    // ExtendedData unter Placemark
    $extDataList = $xpath->query('./kml:ExtendedData', $placemarkNode);
    if ($extDataList->length === 0) {
        dbg("kml:ExtendedData (Forecast) nicht gefunden fuer $stationID", $config, 'info');
        continue;
    }
    $extDataNode = $extDataList->item(0);

    // Diesen ExtendedData-Knoten geben wir an getParamArray weiter
    $forecastNode = $extDataNode;

    $ids = ['TN','TX','TTT','Td','DD','FF','FX3','Neff','PPPP','RRdc','RR6c','SunD3','VV','ww'];

    $data = [];
    foreach ($ids as $id) {
        $data[$id] = getParamArray($xpath, $forecastNode, $id, $nsDwd);
    }

    // Ausgabe-Header
    // Station Koordinaten
    $coordNodeList = $xpath->query('./kml:Placemark/kml:Point/kml:coordinates', $docNode);
    $lon = 0.0;
    $lat = 0.0;
    if ($coordNodeList !== false && $coordNodeList->length > 0) {
        $coordStr = trim($coordNodeList->item(0)->textContent);
        $coord    = explode(',', $coordStr);
        $lon      = isset($coord[0]) ? floatval($coord[0]) : 0.0;
        $lat      = isset($coord[1]) ? floatval($coord[1]) : 0.0;
    }

    // Sonnenzeiten (Timestamps)
    $sun = date_sun_info(time(), $lat, $lon);

    $out = [
        'stationID'   => $stationID,
        'stationName' => $stationName,
        'coordinates' => ['lat' => $lat, 'lon' => $lon],
        'pubDate'     => date('Y-m-d H:i', $issueTS),
        'sunrise'     => date('H:i', $sun['sunrise']),
        'sunset'      => date('H:i', $sun['sunset']),
        'iconSet'     => $config['icon_set'],
        'forecast'    => [],
        'daily'       => []   // NEU: Tageszusammenfassungen
    ];

    $maxEntries = $config['forecast_days'] * 24;

    // Tag-Beginn / Tag-Ende (für wwPic)
    $intTagBeginn = (int)date('G', $sun['sunrise']) - 1;
    $intTagEnde   = (int)date('G', $sun['sunset']) + 1;

    foreach ($timeSteps as $k => $t) {

        if ($k >= $maxEntries) {
            break;
        }

        $dt   = new DateTime($t);
        $hour = (int)$dt->format('H');

        // step_hours filtern
        if ($hour % $config['step_hours'] !== 0) {
            continue;
        }

        $weekday = ['So.','Mo.','Di.','Mi.','Do.','Fr.','Sa.'][$dt->format('w')];

        // Rohdaten
        $T          = isset($data['TTT'][$k]) ? floatval($data['TTT'][$k]) : 0.0;   // Kelvin
        $TD         = isset($data['Td'][$k])  ? floatval($data['Td'][$k])  : 0.0;   // Kelvin
        $windDirDeg = isset($data['DD'][$k])  ? floatval($data['DD'][$k])  : 0.0;
        $wwCode     = isset($data['ww'][$k])  ? intval($data['ww'][$k])    : 0;
        $sunD3      = isset($data['SunD3'][$k]) ? floatval($data['SunD3'][$k]) : 0.0;

        // Temperatur °C
        $tempC = round($T - 273.15, 1);
        $dewC  = round($TD - 273.15, 1);

        // Sonnenschein in % (wie im alten Code)
        $intS   = $sunD3 / 3600;
        $intS   = round($intS * 100 / 3);
        $bolSun = ($intS >= $config['sun_threshold']);

        // Hitzetag?
        $bolMaxT = ($tempC >= $config['hot_temp']);

        // Uhrzeit als Stunde für wwPic
        $WT = (int)$dt->format('G');

        // Icon-Dateiname
        $iconFile = null;
        if ($config['icons_enabled']) {
            $iconFile = wwPic($wwCode, $bolSun, $intTagBeginn, $intTagEnde, $WT, $bolMaxT);
        }

        $entry = [
            'date'             => $dt->format('Y-m-d'),
            'time'             => $dt->format('H:i'),
            'weekday'          => $weekday,
            'temp2m'           => $tempC,
            'dewPoint'         => $dewC,
            'humidity'         => getHumidity($T, $TD),
            'windDirection'    => getWindDirection($windDirDeg),
            'windDirectionDeg' => $windDirDeg,
            'windSpeedKmh'     => round((isset($data['FF'][$k])   ? floatval($data['FF'][$k])   : 0.0) * 3.6),
            'windGustKmh'      => round((isset($data['FX3'][$k])  ? floatval($data['FX3'][$k])  : 0.0) * 3.6),
            'cloud'            => intval(isset($data['Neff'][$k]) ? $data['Neff'][$k] : 0),
            'pressure'         => round((isset($data['PPPP'][$k]) ? floatval($data['PPPP'][$k]) : 0.0) / 100),
            'rain6h'           => isset($data['RR6c'][$k]) ? floatval($data['RR6c'][$k]) : 0.0,
            'rain24h'          => isset($data['RRdc'][$k]) ? floatval($data['RRdc'][$k]) : 0.0,
            'sunPercent'       => $intS,
            'visibilityKm'     => round((isset($data['VV'][$k])   ? floatval($data['VV'][$k])   : 0.0) / 1000, 2),
            'sigWeatherCode'   => $wwCode,
            'icon'             => $iconFile          // z.B. "60-65.png" oder "1d.png"
        ];

        $out['forecast'][] = $entry;
    }

    // -------------------- Tageszusammenfassungen aus forecast -----------------

    if (count($out['forecast']) > 0) {
        $dailyAgg = [];

        foreach ($out['forecast'] as $entry) {
            $d = $entry['date'];

            if (!isset($dailyAgg[$d])) {
                $dailyAgg[$d] = [
                    'date'          => $d,
                    'weekday'       => $entry['weekday'],
                    'tempMin'       => $entry['temp2m'],
                    'tempMax'       => $entry['temp2m'],
                    'tempSum'       => 0.0,
                    'dewMin'        => $entry['dewPoint'],
                    'dewMax'        => $entry['dewPoint'],
                    'humidityMin'   => $entry['humidity'],
                    'humidityMax'   => $entry['humidity'],
                    'humiditySum'   => 0.0,
                    'cloudSum'      => 0.0,
                    'sunPercentSum' => 0.0,
                    'windSpeedMax'  => $entry['windSpeedKmh'],
                    'windGustMax'   => $entry['windGustKmh'],
                    'rain6hSum'     => 0.0,
                    'rain24hMax'    => $entry['rain24h'],
                    'count'         => 0,
                    'wwCounts'      => []
                ];
            }

            $agg =& $dailyAgg[$d];

            $agg['count']++;
            $agg['tempSum']       += $entry['temp2m'];
            $agg['humiditySum']   += $entry['humidity'];
            $agg['cloudSum']      += $entry['cloud'];
            $agg['sunPercentSum'] += $entry['sunPercent'];
            $agg['rain6hSum']     += $entry['rain6h'];

            if ($entry['temp2m'] < $agg['tempMin']) {
                $agg['tempMin'] = $entry['temp2m'];
            }
            if ($entry['temp2m'] > $agg['tempMax']) {
                $agg['tempMax'] = $entry['temp2m'];
            }

            if ($entry['dewPoint'] < $agg['dewMin']) {
                $agg['dewMin'] = $entry['dewPoint'];
            }
            if ($entry['dewPoint'] > $agg['dewMax']) {
                $agg['dewMax'] = $entry['dewPoint'];
            }

            if ($entry['humidity'] < $agg['humidityMin']) {
                $agg['humidityMin'] = $entry['humidity'];
            }
            if ($entry['humidity'] > $agg['humidityMax']) {
                $agg['humidityMax'] = $entry['humidity'];
            }

            if ($entry['windSpeedKmh'] > $agg['windSpeedMax']) {
                $agg['windSpeedMax'] = $entry['windSpeedKmh'];
            }
            if ($entry['windGustKmh'] > $agg['windGustMax']) {
                $agg['windGustMax'] = $entry['windGustKmh'];
            }

            if ($entry['rain24h'] > $agg['rain24hMax']) {
                $agg['rain24hMax'] = $entry['rain24h'];
            }

            $ww = $entry['sigWeatherCode'];
            if (!isset($agg['wwCounts'][$ww])) {
                $agg['wwCounts'][$ww] = 0;
            }
            $agg['wwCounts'][$ww]++;
        }

        foreach ($dailyAgg as $d => $agg) {
            if ($agg['count'] <= 0) {
                continue;
            }

            // häufigster ww-Code des Tages
            $mainWw   = null;
            $maxCount = -1;
            foreach ($agg['wwCounts'] as $ww => $cnt) {
                if ($cnt > $maxCount) {
                    $maxCount = $cnt;
                    $mainWw   = $ww;
                }
            }

            $out['daily'][] = [
                'date'               => $agg['date'],
                'weekday'            => $agg['weekday'],
                'tempMin'            => round($agg['tempMin'], 1),
                'tempMax'            => round($agg['tempMax'], 1),
                'tempMean'           => round($agg['tempSum'] / $agg['count'], 1),
                'dewPointMin'        => round($agg['dewMin'], 1),
                'dewPointMax'        => round($agg['dewMax'], 1),
                'humidityMin'        => $agg['humidityMin'],
                'humidityMax'        => $agg['humidityMax'],
                'humidityMean'       => round($agg['humiditySum'] / $agg['count']),
                'cloudMean'          => round($agg['cloudSum'] / $agg['count']),
                'sunPercentMean'     => round($agg['sunPercentSum'] / $agg['count']),
                'windSpeedMax'       => $agg['windSpeedMax'],
                'windGustMax'        => $agg['windGustMax'],
                'rain6hSum'          => round($agg['rain6hSum'], 1),   // Summe 6h-Mengen des Tages
                'rain24hMax'         => round($agg['rain24hMax'], 1),  // max. 24h-Wert im Tag
                'mainSigWeatherCode' => $mainWw,
                'mainIcon'           => ($mainWw !== null)
                    ? getDailyIconFromCode((int)$mainWw)
                    : 'unknown.png'
            ];
        }
    }

    // Validitätsprüfung
    if ($config['save_only_if_valid'] && count($out['forecast']) < 1) {
        dbg("Abbruch: Keine gueltigen Daten fuer $stationID", $config, 'info');
        continue;
    }

    // JSON speichern
    $json = json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    file_put_contents($targetJSON, $json);

    dbg("JSON gespeichert: " . $targetJSON, $config, 'info');
}

dbg("Fertig.", $config, 'info');

// Optionale Ausgabe im Browser bei Direktaufruf
# if (php_sapi_name() !== 'cli') {
#     echo "dwdWetter: Verarbeitung abgeschlossen. Siehe JSON-Ordner und Logdatei.";
# }

?>