From 4042614f1d0f083bcabe02354a2b0af736473453 Mon Sep 17 00:00:00 2001 From: "belevo\\mh" Date: Tue, 20 Jan 2026 14:47:31 +0100 Subject: [PATCH] =?UTF-8?q?Produktion=20Prognose=20Modul=20hinzugef=C3=BCg?= =?UTF-8?q?t.=20Mit=20dem=20Vergleich=20met=20der=20richtigen=20Produktion?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PV_Forecast_plotmemory/form.json | 30 ++ PV_Forecast_plotmemory/module.html | 186 +++++++++ PV_Forecast_plotmemory/module.json | 12 + PV_Forecast_plotmemory/module.php | 589 +++++++++++++++++++++++++++++ PV_Forecast_plotmemory/readme.md | 59 +++ 5 files changed, 876 insertions(+) create mode 100644 PV_Forecast_plotmemory/form.json create mode 100644 PV_Forecast_plotmemory/module.html create mode 100644 PV_Forecast_plotmemory/module.json create mode 100644 PV_Forecast_plotmemory/module.php create mode 100644 PV_Forecast_plotmemory/readme.md diff --git a/PV_Forecast_plotmemory/form.json b/PV_Forecast_plotmemory/form.json new file mode 100644 index 0000000..ff547a2 --- /dev/null +++ b/PV_Forecast_plotmemory/form.json @@ -0,0 +1,30 @@ +{ + "elements": [ + { "type": "ValidationTextBox", "name": "URL", "caption": "Solcast URL" }, + { "type": "SelectVariable", "name": "ActualVariableID", "caption": "Ist-Produktion Variable (Leistung)" }, + { "type": "CheckBox", "name": "ActualIsWatt", "caption": "Istwerte sind in Watt (in kW umrechnen)" }, + + { + "type": "Select", + "name": "RefreshMode", + "caption": "Forecast-Aktualisierung", + "options": [ + { "caption": "Alle X Minuten", "value": "interval" }, + { "caption": "Einmal täglich (Uhrzeit)", "value": "daily" } + ] + }, + { "type": "NumberSpinner", "name": "RefreshMinutes", "caption": "Intervall (Minuten)", "minimum": 1, "maximum": 240 }, + { "type": "ValidationTextBox", "name": "RefreshTime", "caption": "Tägliche Uhrzeit (HH:MM, z.B. 00:01)" }, + + { "type": "CheckBox", "name": "EnableDailySnapshots", "caption": "Tägliche Tages-Plots speichern" }, + { "type": "ValidationTextBox", "name": "SnapshotTime", "caption": "Snapshot-Uhrzeit (HH:MM, z.B. 23:59)" }, + { "type": "NumberSpinner", "name": "KeepDays", "caption": "Aufbewahrung (Tage)", "minimum": 1, "maximum": 365 } + ], + "actions": [ + { + "type": "Button", + "caption": "Forecast jetzt aktualisieren", + "onClick": "IPS_RequestAction($id, \"UpdateForecast\", 0);" + } + ] +} diff --git a/PV_Forecast_plotmemory/module.html b/PV_Forecast_plotmemory/module.html new file mode 100644 index 0000000..be31056 --- /dev/null +++ b/PV_Forecast_plotmemory/module.html @@ -0,0 +1,186 @@ + + + + + PV_Forecast_plotmemory + + + + + + +
+
Lade…
+
+ +
+ + + + diff --git a/PV_Forecast_plotmemory/module.json b/PV_Forecast_plotmemory/module.json new file mode 100644 index 0000000..1b034b7 --- /dev/null +++ b/PV_Forecast_plotmemory/module.json @@ -0,0 +1,12 @@ +{ + "id": "{DF3B17A3-C2F7-0B57-F632-34668D9206E6}", + "name": "PV_Forecast_plotmemory ", + "type": 3, + "vendor": "Belevo AG", + "aliases": [], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "PVF", + "url": "" +} diff --git a/PV_Forecast_plotmemory/module.php b/PV_Forecast_plotmemory/module.php new file mode 100644 index 0000000..3f92ed6 --- /dev/null +++ b/PV_Forecast_plotmemory/module.php @@ -0,0 +1,589 @@ +RegisterPropertyString("URL", ""); + $this->RegisterPropertyInteger("ActualVariableID", 0); + $this->RegisterPropertyBoolean("ActualIsWatt", true); + + // Forecast fetch scheduler + $this->RegisterPropertyString("RefreshMode", "interval"); // interval | daily + $this->RegisterPropertyInteger("RefreshMinutes", 5); + $this->RegisterPropertyString("RefreshTime", "06:00"); // HH:MM + + // Daily snapshot + $this->RegisterPropertyBoolean("EnableDailySnapshots", true); + $this->RegisterPropertyString("SnapshotTime", "23:59"); // HH:MM + $this->RegisterPropertyInteger("KeepDays", 30); + + // Timer + $this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);'); + $this->RegisterTimer("SnapshotTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "SnapshotTick", 0);'); + + // WebHook endpoint + $this->RegisterHook("/hook/solcastcompare_plotmemory"); + } + + public function ApplyChanges() + { + parent::ApplyChanges(); + + // Tile Visualization aktivieren + $this->SetVisualizationType(1); + + // Forecast timer + $mode = $this->ReadPropertyString("RefreshMode"); + if ($mode === "interval") { + $mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes")); + $this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000); + } else { + // daily check each minute + $this->SetTimerInterval("UpdateForecastTimer", 60 * 1000); + } + + // Snapshot timer + if ($this->ReadPropertyBoolean("EnableDailySnapshots")) { + $this->SetTimerInterval("SnapshotTimer", 60 * 1000); // every minute + } else { + $this->SetTimerInterval("SnapshotTimer", 0); + } + + $this->RegisterHook("/hook/solcastcompare"); + } + + public function RequestAction($Ident, $Value) + { + switch ($Ident) { + case "UpdateForecast": + $this->HandleForecastSchedule(); + return; + + case "SnapshotTick": + $this->HandleSnapshotSchedule(); + return; + + default: + throw new Exception("Unknown Ident: " . $Ident); + } + } + + // ----------------- Forecast scheduling ----------------- + + private function HandleForecastSchedule(): void + { + $mode = $this->ReadPropertyString("RefreshMode"); + + if ($mode === "interval") { + $this->UpdateForecast(); + return; + } + + // daily + $timeStr = trim($this->ReadPropertyString("RefreshTime")); // HH:MM + $targetSec = $this->ParseHHMMToSeconds($timeStr); + if ($targetSec === null) { + $this->SendDebug("Scheduler", "Ungueltige RefreshTime: " . $timeStr, 0); + return; + } + + $now = time(); + $nowSec = ((int)date("H", $now) * 3600) + ((int)date("i", $now) * 60); + + $lastRun = (int)$this->GetBuffer("LastDailyRunForecast"); + $today0 = strtotime("today"); + + if ($nowSec >= $targetSec && $lastRun < $today0) { + $this->SendDebug("Scheduler", "Taegliches Forecast-Update (" . $timeStr . ")", 0); + $this->UpdateForecast(); + $this->SetBuffer("LastDailyRunForecast", (string)$now); + } + } + + // ----------------- Snapshot scheduling ----------------- + + private function HandleSnapshotSchedule(): void + { + if (!$this->ReadPropertyBoolean("EnableDailySnapshots")) { + return; + } + + $timeStr = trim($this->ReadPropertyString("SnapshotTime")); + $targetSec = $this->ParseHHMMToSeconds($timeStr); + if ($targetSec === null) { + $this->SendDebug("Snapshot", "Ungueltige SnapshotTime: " . $timeStr, 0); + return; + } + + $now = time(); + $nowSec = ((int)date("H", $now) * 3600) + ((int)date("i", $now) * 60); + + $lastRun = (int)$this->GetBuffer("LastDailySnapshotRun"); + $today0 = strtotime("today"); + + if ($nowSec >= $targetSec && $lastRun < $today0) { + $this->SendDebug("Snapshot", "Tages-Snapshot wird erstellt (" . $timeStr . ")", 0); + + // Safety: ensure we have a forecast cached (if not, fetch once) + if ($this->GetBuffer("ForecastRaw") === "") { + $this->UpdateForecast(); + } + + $this->CreateDailySnapshotForDate(new DateTime('today', new DateTimeZone(date_default_timezone_get()))); + $this->CleanupOldSnapshots(); + + $this->SetBuffer("LastDailySnapshotRun", (string)$now); + } + } + + private function ParseHHMMToSeconds(string $hhmm): ?int + { + if (!preg_match('/^(\d{1,2}):(\d{2})$/', $hhmm, $m)) { + return null; + } + $h = (int)$m[1]; + $min = (int)$m[2]; + if ($h < 0 || $h > 23 || $min < 0 || $min > 59) { + return null; + } + return $h * 3600 + $min * 60; + } + + // ----------------- Forecast Fetch ----------------- + + private function UpdateForecast(): void + { + $url = trim($this->ReadPropertyString("URL")); + if ($url === "") { + $this->SendDebug("UpdateForecast", "URL ist leer", 0); + return; + } + + $json = @Sys_GetURLContent($url); + if ($json === false || $json === "") { + $this->SendDebug("UpdateForecast", "Leere Antwort von URL", 0); + return; + } + + $data = json_decode($json, true); + if (!is_array($data)) { + $this->SendDebug("UpdateForecast", "JSON decode fehlgeschlagen", 0); + return; + } + + $this->SetBuffer("ForecastRaw", $json); + $this->SetBuffer("ForecastTS", (string)time()); + $this->SendDebug("UpdateForecast", "Forecast aktualisiert", 0); + } + + // ----------------- Tile Visualization ----------------- + + public function GetVisualizationTile(): string + { + if ($this->GetBuffer("ForecastRaw") === "") { + $this->UpdateForecast(); + } + + $html = file_get_contents(__DIR__ . "/module.html"); + $html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html); + return $html; + } + + // ----------------- WebHook: data + download ----------------- + + protected function ProcessHookData() + { + $instance = isset($_GET["instance"]) ? (int)$_GET["instance"] : 0; + $action = isset($_GET["action"]) ? (string)$_GET["action"] : "data"; + + if ($instance !== $this->InstanceID) { + http_response_code(404); + echo "Wrong instance"; + return; + } + + if ($action === "data") { + $this->HandleHookData(); + return; + } + + if ($action === "download") { + $days = isset($_GET["days"]) ? max(1, min(365, (int)$_GET["days"])) : 7; + $this->HandleHookDownloadZip($days); + return; + } + + http_response_code(400); + echo "Unknown action"; + } + + private function HandleHookData(): void + { + $tzLocal = new DateTimeZone(date_default_timezone_get()); + $tzUtc = new DateTimeZone('UTC'); + + // Lokaler Tag + $startLocal = new DateTime('today', $tzLocal); + $endLocal = new DateTime('tomorrow', $tzLocal); + + $startLocalTs = $startLocal->getTimestamp(); + $endLocalTs = $endLocal->getTimestamp(); + + // Lokaler Tagesbereich als UTC-Grenzen (Solcast period_end ist UTC "Z") + $startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp(); + $endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp(); + + $nowTs = time(); + + $forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs); + $actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs, true); + + $out = [ + "meta" => [ + "forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"), + "bucket_seconds" => 1800, + "actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt"), + "start_local" => $startLocalTs * 1000, + "end_local" => $endLocalTs * 1000, + "now" => $nowTs * 1000 + ], + "series" => [ + "forecast" => $forecast, + "actual" => $actual + ] + ]; + + header("Content-Type: application/json; charset=utf-8"); + echo json_encode($out); + } + + // ----------------- Forecast series (Solcast) ----------------- + + private function GetForecastSeriesFilteredUtc(int $startUtcTs, int $endUtcTs): array + { + $raw = $this->GetBuffer("ForecastRaw"); + if ($raw === "") return []; + + $data = json_decode($raw, true); + if (!is_array($data)) return []; + + // forecast endpoint: forecasts; live endpoint: estimated_actuals + if (isset($data["forecasts"]) && is_array($data["forecasts"])) { + $rows = $data["forecasts"]; + } elseif (isset($data["estimated_actuals"]) && is_array($data["estimated_actuals"])) { + $rows = $data["estimated_actuals"]; + } else { + return []; + } + + $series = []; + foreach ($rows as $row) { + if (!isset($row["period_end"], $row["pv_power_rooftop"])) continue; + + $ts = strtotime($row["period_end"]); // UTC wegen Z + if ($ts === false) continue; + + if ($ts < $startUtcTs || $ts >= $endUtcTs) continue; + + // Solcast: kW + $series[] = [$ts * 1000, (float)$row["pv_power_rooftop"]]; + } + + usort($series, fn($a, $b) => $a[0] <=> $b[0]); + return $series; + } + + // ----------------- Actual series (Archive) ----------------- + // $cutAfterNow=true -> future buckets = null (live view) + // $cutAfterNow=false -> keep as much as possible (snapshot) + private function GetActualSeriesFromArchive(int $startTs, int $endTs, int $bucketSeconds, int $nowTs, bool $cutAfterNow): array + { + $varId = (int)$this->ReadPropertyInteger("ActualVariableID"); + if ($varId <= 0 || !IPS_VariableExists($varId)) return []; + + $archiveId = $this->GetArchiveInstanceID(); + if ($archiveId === 0) return []; + + if (!AC_GetLoggingStatus($archiveId, $varId)) { + $this->SendDebug("Actual", "Variable wird nicht geloggt im Archiv", 0); + return []; + } + + $logged = AC_GetLoggedValues($archiveId, $varId, $startTs, $endTs, 0); + if (!is_array($logged) || count($logged) === 0) { + return $this->BuildNullRaster($startTs, $endTs, $bucketSeconds); + } + + // bucketEnd -> [sum, count] + $buckets = []; + foreach ($logged as $row) { + if (!isset($row["TimeStamp"], $row["Value"])) continue; + + $ts = (int)$row["TimeStamp"]; + $bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds; + $bucketEnd = $bucketStart + $bucketSeconds; + + if ($bucketEnd < $startTs || $bucketEnd > $endTs) continue; + + if (!isset($buckets[$bucketEnd])) $buckets[$bucketEnd] = [0.0, 0]; + $buckets[$bucketEnd][0] += (float)$row["Value"]; + $buckets[$bucketEnd][1] += 1; + } + + $series = []; + $isWatt = (bool)$this->ReadPropertyBoolean("ActualIsWatt"); + + for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) { + + if ($cutAfterNow && $t > $nowTs + $bucketSeconds) { + $series[] = [$t * 1000, null]; + continue; + } + + if (!isset($buckets[$t]) || $buckets[$t][1] <= 0) { + $series[] = [$t * 1000, null]; + continue; + } + + $avg = $buckets[$t][0] / $buckets[$t][1]; + + // W -> kW + if ($isWatt) $avg = $avg / 1000.0; + + $series[] = [$t * 1000, $avg]; + } + + return $series; + } + + private function BuildNullRaster(int $startTs, int $endTs, int $bucketSeconds): array + { + $series = []; + for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) { + $series[] = [$t * 1000, null]; + } + return $series; + } + + private function GetArchiveInstanceID(): int + { + $list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID); + return (is_array($list) && count($list) > 0) ? (int)$list[0] : 0; + } + + // ----------------- Daily snapshot creation ----------------- + + private function GetStorageDir(): string + { + $dir = __DIR__ . "/storage"; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + return $dir; + } + + private function CreateDailySnapshotForDate(DateTime $dayLocal): void + { + $tzLocal = new DateTimeZone(date_default_timezone_get()); + $tzUtc = new DateTimeZone('UTC'); + + // dayLocal should be "today" in local TZ + $dayLocal->setTimezone($tzLocal); + + $startLocal = (clone $dayLocal); + $startLocal->setTime(0, 0, 0); + + $endLocal = (clone $startLocal); + $endLocal->modify('+1 day'); + + $startLocalTs = $startLocal->getTimestamp(); + $endLocalTs = $endLocal->getTimestamp(); + + $startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp(); + $endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp(); + + $nowTs = time(); // snapshot time (typically 23:59) + + $forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs); + $actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs, false); + + $dateStr = $startLocal->format('Y-m-d'); + + $payload = [ + "meta" => [ + "date" => $dateStr, + "created_at" => time(), + "forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"), + "bucket_seconds" => 1800, + "actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt"), + "timezone" => $tzLocal->getName() + ], + "series" => [ + "forecast" => $forecast, + "actual" => $actual + ] + ]; + + $dir = $this->GetStorageDir(); + + // Save JSON + $jsonPath = $dir . "/" . $dateStr . ".json"; + @file_put_contents($jsonPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Save HTML plot + $htmlPath = $dir . "/" . $dateStr . ".html"; + @file_put_contents($htmlPath, $this->BuildSnapshotHtml($payload)); + + $this->SendDebug("Snapshot", "Gespeichert: " . basename($jsonPath) . " / " . basename($htmlPath), 0); + } + + private function BuildSnapshotHtml(array $payload): string + { + $date = $payload["meta"]["date"] ?? "unbekannt"; + $forecastJson = json_encode($payload["series"]["forecast"] ?? []); + $actualJson = json_encode($payload["series"]["actual"] ?? []); + + // Hinweis: nutzt Highcharts CDN. (Wenn du offline öffnen willst, sag Bescheid, dann liefern wir highcharts.js lokal mit.) + return ' + + + + PV Snapshot ' . htmlspecialchars($date) . ' + + + + +

PV: Erwartung vs. Tatsächlich (' . htmlspecialchars($date) . ')

+
Gespeichert: ' . date('d.m.Y H:i:s', (int)($payload["meta"]["created_at"] ?? time())) . '
+
+ + + +'; + } + + private function CleanupOldSnapshots(): void + { + $keep = max(1, (int)$this->ReadPropertyInteger("KeepDays")); + $dir = $this->GetStorageDir(); + + $cutoff = strtotime("today") - ($keep * 86400); + + foreach (glob($dir . "/*.json") as $file) { + $base = basename($file, ".json"); // YYYY-MM-DD + $ts = strtotime($base); + if ($ts !== false && $ts < $cutoff) { + @unlink($file); + $html = $dir . "/" . $base . ".html"; + if (is_file($html)) @unlink($html); + } + } + } + + // ----------------- ZIP download ----------------- + + private function HandleHookDownloadZip(int $days): void + { + $dir = $this->GetStorageDir(); + + $dates = []; + for ($i = 0; $i < $days; $i++) { + $d = new DateTime('today', new DateTimeZone(date_default_timezone_get())); + $d->modify("-{$i} day"); + $dates[] = $d->format("Y-m-d"); + } + + $zipPath = sys_get_temp_dir() . "/pv_forecast_" . $this->InstanceID . "_" . time() . ".zip"; + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + http_response_code(500); + echo "Could not create zip"; + return; + } + + $added = 0; + foreach (array_reverse($dates) as $dateStr) { + $jsonFile = $dir . "/" . $dateStr . ".json"; + $htmlFile = $dir . "/" . $dateStr . ".html"; + + if (is_file($htmlFile)) { + $zip->addFile($htmlFile, $dateStr . ".html"); + $added++; + } + if (is_file($jsonFile)) { + $zip->addFile($jsonFile, $dateStr . ".json"); + $added++; + } + } + + $zip->close(); + + if ($added === 0) { + @unlink($zipPath); + http_response_code(404); + echo "No snapshots found"; + return; + } + + header("Content-Type: application/zip"); + header('Content-Disposition: attachment; filename="pv_plots_' . $this->InstanceID . '_' . $days . 'days.zip"'); + header("Content-Length: " . filesize($zipPath)); + + readfile($zipPath); + @unlink($zipPath); + } + + // ----------------- Hook registration ----------------- + + private function RegisterHook(string $Hook) + { + $ids = IPS_GetInstanceListByModuleID(self::WEBHOOK_GUID); + if (count($ids) === 0) return; + + $hookID = (int)$ids[0]; + $hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true); + if (!is_array($hooks)) $hooks = []; + + foreach ($hooks as $h) { + if (isset($h["Hook"]) && $h["Hook"] === ltrim($Hook, "/")) { + return; + } + if (isset($h["Hook"]) && $h["Hook"] === $Hook) { + return; + } + } + + $hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID]; + IPS_SetProperty($hookID, "Hooks", json_encode($hooks)); + IPS_ApplyChanges($hookID); + } +} diff --git a/PV_Forecast_plotmemory/readme.md b/PV_Forecast_plotmemory/readme.md new file mode 100644 index 0000000..bc8dd0b --- /dev/null +++ b/PV_Forecast_plotmemory/readme.md @@ -0,0 +1,59 @@ +# PV_Visu + +Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug. + +## Inhaltsverzeichnis + +1. [Funktionsumfang](#funktionsumfang) +2. [Voraussetzungen](#voraussetzungen) +3. [Installation](#installation) +4. [Instanz einrichten](#instanz-einrichten) +5. [WebFront](#webfront) +6. [PHP-Befehlsreferenz](#php-befehlsreferenz) + +## Funktionsumfang + +- Anzeige von Tages-Quoten (%) +- Produktion: Eigenverbrauch vs. Einspeisung +- Verbrauch: PV-Anteil vs. Netz-Anteil +- Zwei Balkendiagramme +- Absolute Tages-Summen (kWh) + +## Voraussetzungen + +- IP-Symcon ≥ 7.1 +- Archiv-Modul aktiviert +- Vier kWh-Zähler-Variablen + +## Installation + +1. **Module Store** → Suche nach „PV_Visu“ und installieren +2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen: + ``` + https://github.com/DeinRepo/PV_Visu.git + ``` + und Modul neu einlesen. + +## Instanz einrichten + +- **Instanz hinzufügen** → Filter: „PV_Visu“ +- Variablen zuweisen: + + | Property | Beschreibung | + | -------------- | -------------------------- | + | VarProduction | PV-Produktionszähler (kWh) | + | VarConsumption | Gesamtverbrauch (kWh) | + | VarFeedIn | Einspeisung (kWh) | + | VarGrid | Netz-Bezug (kWh) | + +## WebFront + +- **Tile-Typ:** PV_Visu +- Balken 1 (Grün): Produktion +- Balken 2 (Orange/Rot): Verbrauch + +## PHP-Befehlsreferenz + +```php +IPS_RequestAction($InstanceID, 'update', true); +```