no message
This commit is contained in:
30
PV_Forecast_plotmemory/form.json
Normal file
30
PV_Forecast_plotmemory/form.json
Normal file
@@ -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);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
186
PV_Forecast_plotmemory/module.html
Normal file
186
PV_Forecast_plotmemory/module.html
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PV_Forecast_plotmemory</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: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kopfzeile */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #1f2937;
|
||||||
|
border-bottom: 1px solid #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart */
|
||||||
|
#chart {
|
||||||
|
height: 520px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="meta" id="meta">Lade…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chart"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const metaEl = document.getElementById("meta");
|
||||||
|
|
||||||
|
window.onerror = (msg, url, line, col) => {
|
||||||
|
metaEl.textContent = `JS-Fehler: ${msg} (${line}:${col})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const instanceId = Number("{{INSTANCE_ID}}");
|
||||||
|
if (!Number.isFinite(instanceId) || instanceId <= 0) {
|
||||||
|
metaEl.textContent = "Fehler: INSTANCE_ID nicht korrekt.";
|
||||||
|
throw new Error("Invalid INSTANCE_ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
`${location.protocol}//${location.host}` +
|
||||||
|
`/hook/solcastcompare?instance=${instanceId}&action=data`;
|
||||||
|
|
||||||
|
let chart;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
metaEl.textContent = "⏳ Daten werden geladen…";
|
||||||
|
const r = await fetch(endpoint, { cache: "no-store" });
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${r.status}${text ? " - " + text : ""}`);
|
||||||
|
}
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
if (typeof Highcharts === "undefined") {
|
||||||
|
metaEl.textContent = "Fehler: Highcharts nicht geladen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecast = data?.series?.forecast ?? [];
|
||||||
|
const actual = data?.series?.actual ?? [];
|
||||||
|
|
||||||
|
const cachedAt = data?.meta?.forecast_cached_at
|
||||||
|
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
|
||||||
|
: "unbekannt";
|
||||||
|
|
||||||
|
const nowMs = data?.meta?.now ?? Date.now();
|
||||||
|
|
||||||
|
metaEl.textContent =
|
||||||
|
`OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
title: {
|
||||||
|
text: "PV: Erwartung vs. Tatsächlich (heute)",
|
||||||
|
style: { fontWeight: "700" }
|
||||||
|
},
|
||||||
|
|
||||||
|
chart: {
|
||||||
|
animation: false,
|
||||||
|
spacingTop: 18
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
type: "datetime",
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
title: {
|
||||||
|
text: "Leistung (kW)",
|
||||||
|
style: { fontWeight: "700" }
|
||||||
|
},
|
||||||
|
gridLineColor: "#e5e7eb"
|
||||||
|
},
|
||||||
|
|
||||||
|
legend: { enabled: true },
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
shared: true,
|
||||||
|
formatter: function () {
|
||||||
|
let s = `<b>${Highcharts.dateFormat('%d.%m.%Y %H:%M', this.x)}</b><br/>`;
|
||||||
|
this.points.forEach(p => {
|
||||||
|
const val = (p.y === null || typeof p.y === "undefined")
|
||||||
|
? "–"
|
||||||
|
: `${p.y.toFixed(1)} kW`;
|
||||||
|
s += `<span style="color:${p.color}">●</span> ${p.series.name}: <b>${val}</b><br/>`;
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
plotOptions: {
|
||||||
|
series: {
|
||||||
|
animation: false,
|
||||||
|
turboThreshold: 0,
|
||||||
|
marker: { enabled: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Erwartet (Solcast)",
|
||||||
|
data: forecast,
|
||||||
|
color: "#38bdf8",
|
||||||
|
lineWidth: 4,
|
||||||
|
marker: { radius: 4 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tatsächlich (Archiv)",
|
||||||
|
data: actual,
|
||||||
|
color: "#7c3aed",
|
||||||
|
lineWidth: 4,
|
||||||
|
marker: { radius: 5, symbol: "diamond" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
credits: { enabled: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!chart) {
|
||||||
|
chart = Highcharts.chart("chart", options);
|
||||||
|
} else {
|
||||||
|
chart.series[0].setData(forecast, false);
|
||||||
|
chart.series[1].setData(actual, false);
|
||||||
|
chart.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await loadData();
|
||||||
|
render(data);
|
||||||
|
} catch (e) {
|
||||||
|
metaEl.textContent = "Fehler beim Laden: " + (e?.message ?? e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
PV_Forecast_plotmemory/module.json
Normal file
12
PV_Forecast_plotmemory/module.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
589
PV_Forecast_plotmemory/module.php
Normal file
589
PV_Forecast_plotmemory/module.php
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
<?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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
PV_Forecast_plotmemory/readme.md
Normal file
59
PV_Forecast_plotmemory/readme.md
Normal file
@@ -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);
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user