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:
+366
@@ -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);
|
||||
Reference in New Issue
Block a user