Einstellungsseite, Skoda-Seite und Karte

Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 21:21:27 +02:00
co-authored by Claude Opus 5
parent c9a9e16b21
commit d285ec27a0
26 changed files with 4862 additions and 190 deletions
+366
View File
@@ -0,0 +1,366 @@
<?php
/*
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
*
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
*
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
* Minuten bis zu einer Stunde reichen.
*
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
*/
require_once("../helper.php");
header('Content-Type: application/json; charset=utf-8');
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
$was = isset($_GET["was"]) ? $_GET["was"] : "live";
$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
if ($mysql->connect_errno) {
http_response_code(500);
echo json_encode(["error" => "Datenbank nicht erreichbar."]);
exit;
}
$mysql->set_charset("utf8mb4");
/*
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
*/
function sitzungenSQL($seit)
{
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
FROM (
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
THEN 0 ELSE 1 END AS neu,
/*
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
* Die API kennt keine Kilowattstunden, deshalb wird hier über
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
* liegt die Leistung fast konstant an, da ist das genau genug.
*
* Die Bedingung hält die Summe innerhalb einer Ladung: über
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
* unter der Kurve frei erfunden.
*/
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
BETWEEN 1 AND 1800
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
ELSE 0 END AS kwh_teil
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) s) t
WHERE chg_state = 'CHARGING'
GROUP BY sitzung";
}
/*
* Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
* Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
* Prozent verloren.
*/
const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
/**
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
*
* Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
* ihnen ist genau das, was das Ladegerät verheizt:
*
* wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
* kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
*
* Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
* Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
* Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
*
* Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
* Ladestands weit ausreissen können.
*
* Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
* dann keine Kilowattstunden an, statt eine erfundene Zahl.
*/
function kapazitaetSchaetzen($mysql)
{
$sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
CASE WHEN kwh_fahrzeug > 0
THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000";
$res = $mysql->query($sql);
if (!$res) {
return null;
}
$zaehler = [];
$fahrzeug = [];
$paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
while ($z = $res->fetch_assoc()) {
$zaehler[] = floatval($z["je100_zaehler"]);
if ($z["je100_fahrzeug"] !== null) {
$fahrzeug[] = floatval($z["je100_fahrzeug"]);
$paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
}
}
if (count($zaehler) < 3) {
return null;
}
$gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
if ($gemessen) {
$netto = median($fahrzeug);
$grad = median($paare);
} else {
$netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
$grad = LADE_WIRKUNGSGRAD_ANNAHME;
}
return [
"je100_zaehler" => round(median($zaehler), 2),
"je100_netto" => round($netto, 2),
"wirkungsgrad" => round($grad, 3),
"gemessen" => $gemessen,
"je_soc_netto" => round($netto / 100, 4),
"n" => count($zaehler),
"n_fahrzeug" => count($fahrzeug),
];
}
/** Median eines nicht leeren Zahlenfeldes. */
function median($werte)
{
sort($werte);
return $werte[intdiv(count($werte), 2)];
}
$antwort = [];
switch ($was) {
/* ------------------------------------------------------------------ live -- */
case "live":
$res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
$zeile = $res ? $res->fetch_assoc() : null;
$antwort = ["stand" => $zeile];
// Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
// allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
if ($zeile) {
$res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
. $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
$r = $res ? $res->fetch_assoc() : null;
$antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
}
// Kennzahlen über den gesamten Bestand, für die Kopfzeile.
$res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
MAX(odo_km) AS km FROM skoda");
$antwort["bestand"] = $res ? $res->fetch_assoc() : null;
break;
/* -------------------------------------------------------------- ladungen -- */
case "ladungen":
$sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
ORDER BY beginn DESC LIMIT 200";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) {
$antwort["ladungen"][] = $z;
}
break;
/* ----------------------------------------------------------------- kurve -- */
case "kurve":
// Verlauf einer Ladung. Ohne Parameter die jüngste.
$von = isset($_GET["von"]) ? $_GET["von"] : null;
$bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
if (!$von || !$bis) {
$sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
ORDER BY beginn DESC LIMIT 1";
$res = $mysql->query($sql);
$z = $res ? $res->fetch_assoc() : null;
if (!$z) { $antwort["punkte"] = []; break; }
$von = $z["beginn"];
$bis = $z["ende"];
}
// Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
$stmt = $mysql->prepare(
"SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
FROM skoda
WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
ORDER BY datetime");
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$antwort["punkte"] = [];
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von;
$antwort["bis"] = $bis;
break;
/* ------------------------------------------------------------ gesundheit -- */
case "gesundheit":
/*
* Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
* nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
* also nur eine Näherung, sein Verlauf über die Jahre dagegen die
* eigentliche Aussage.
*
* Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
* Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
* Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
* Zähler.
*/
$sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
ROUND(wh/1000/zuwachs*100, 2) AS je100
FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000
ORDER BY beginn";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
// Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
// ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
// etwas sehen könnte.
$n = count($antwort["ladungen"]);
$antwort["trend"] = null;
if ($n >= 6) {
$t0 = strtotime($antwort["ladungen"][0]["beginn"]);
$xs = []; $ys = [];
foreach ($antwort["ladungen"] as $l) {
$xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
$ys[] = floatval($l["je100"]);
}
$mx = array_sum($xs) / $n;
$my = array_sum($ys) / $n;
$sxx = 0; $sxy = 0;
for ($i = 0; $i < $n; $i++) {
$sxx += ($xs[$i] - $mx) ** 2;
$sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
}
if ($sxx > 0) {
$steigung = $sxy / $sxx;
$achse = $my - $steigung * $mx;
$rest = 0;
for ($i = 0; $i < $n; $i++) {
$rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
}
$streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
$fehler = $streu / sqrt($sxx);
$antwort["trend"] = [
"steigung" => round($steigung, 3),
"fehler" => round($fehler, 3),
"achse" => round($achse, 2),
"prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
"nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
"spanne_jahre" => round(max($xs), 2),
];
}
}
// Median je Jahr - der Stand, um den die Wolke streut.
$jahre = [];
foreach ($antwort["ladungen"] as $l) {
$j = substr($l["beginn"], 0, 4);
$jahre[$j][] = floatval($l["je100"]);
}
$antwort["jahre"] = [];
foreach ($jahre as $j => $werte) {
sort($werte);
$k = count($werte);
$antwort["jahre"][] = [
"jahr" => $j,
"n" => $k,
"q1" => round($werte[intdiv($k, 4)], 2),
"median" => round($werte[intdiv($k, 2)], 2),
"q3" => round($werte[intdiv(3 * $k, 4)], 2),
];
}
break;
/* --------------------------------------------------------------- fahrten -- */
case "fahrten":
/*
* Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
* letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
* IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
* Strecke dazwischen nicht.
*
* CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
* eine Differenz unter null bricht die Abfrage sonst ab - genau das
* passiert, sobald zwischen zwei Ständen geladen wurde.
*/
$sql = "SELECT * FROM (
SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
LAG(datetime) OVER w AS abfahrt,
LAG(address) OVER w AS von,
LAG(lat) OVER w AS von_lat,
LAG(lon) OVER w AS von_lon,
CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
FROM skoda
WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
AND datetime >= NOW() - INTERVAL $tage DAY
WINDOW w AS (ORDER BY datetime)) f
WHERE km > 0
ORDER BY ankunft DESC LIMIT 100";
$res = $mysql->query($sql);
$antwort["fahrten"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
// Aktueller Standort, für die Karte.
$res = $mysql->query("SELECT datetime, lat, lon, address, park_state
FROM skoda WHERE lat IS NOT NULL
ORDER BY id DESC LIMIT 1");
$antwort["standort"] = $res ? $res->fetch_assoc() : null;
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
break;
default:
http_response_code(400);
$antwort = ["error" => "Unbekannter Abschnitt."];
}
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);