Files
Symcon_Belevo_Energiemanage…/PV_Forecast_plotmemory/module.php

590 lines
19 KiB
PHP

<?php
class PV_Forecast_plotmemory extends IPSModule
{
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
private const WEBHOOK_GUID = "{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}";
public function Create()
{
parent::Create();
// Core
$this->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 '<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PV Snapshot ' . htmlspecialchars($date) . '</title>
<script src="https://code.highcharts.com/highcharts.js"></script>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;padding:16px;background:#fff;}
h2{margin:0 0 12px 0;}
#chart{height:560px;width:100%;}
.meta{color:#374151;font-size:13px;margin-bottom:10px;}
</style>
</head>
<body>
<h2>PV: Erwartung vs. Tatsächlich (' . htmlspecialchars($date) . ')</h2>
<div class="meta">Gespeichert: ' . date('d.m.Y H:i:s', (int)($payload["meta"]["created_at"] ?? time())) . '</div>
<div id="chart"></div>
<script>
const forecast = ' . $forecastJson . ';
const actual = ' . $actualJson . ';
Highcharts.chart("chart", {
title: { text: null },
xAxis: { type: "datetime" },
yAxis: { title: { text: "Leistung (kW)" } },
tooltip: { shared: true, xDateFormat: "%d.%m.%Y %H:%M" },
legend: { enabled: true },
plotOptions: { series: { turboThreshold: 0 } },
series: [
{ name: "Erwartet (Solcast)", data: forecast, lineWidth: 3 },
{ name: "Tatsächlich (Archiv)", data: actual, lineWidth: 3 }
],
credits: { enabled: false }
});
</script>
</body>
</html>';
}
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);
}
}