Files
Smart-Dashboard/ajax/skoda.php
T
adminandClaude Opus 5 deb81045d5 Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass
  ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php)
- ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und
  rahmt sie mit der Zeile davor und danach ein
- solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung
- Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden
- Steckersymbol versteht evPlug als 1/0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:30:18 +02:00

611 lines
25 KiB
PHP

<?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
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
*
* 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. Wird von mehreren Abschnitten
* gebraucht, deshalb einmal hier.
*
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
*
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
* lässt die Ladung aus.
*/
const LADUNG_LUECKE_S = 3 * 3600;
function sitzungenSQL($seit)
{
$luecke = LADUNG_LUECKE_S;
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
/*
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
*/
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
COUNT(*) AS punkte
FROM (
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
SELECT a.*,
a.chg_state = 'CHARGING' AS laedt,
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
CASE WHEN a.chg_state = 'CHARGING'
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
THEN 1 ELSE 0 END AS neu,
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
ELSE a.soc END AS soc_start,
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
/*
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
* gemessen HINTER dem Ladegerät. Die API kennt keine
* Kilowattstunden, deshalb wird über die Zeit integriert, je
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
*
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
* fahrzeugseitigen Wert.
*/
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
* a.dt_vor / 3600
ELSE 0 END AS kwh_teil,
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
THEN 1 ELSE 0 END AS zu_lang
FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
LAG(chg_state) OVER w AS st_vor,
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
LAG(soc) OVER w AS soc_vor,
LAG(wb_wh_total) OVER w AS wh_vor,
LAG(chg_kw) OVER w AS kw_vor
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) a) b) t
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
WHERE laedt OR weiter
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),
];
}
/**
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
*
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
*
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
*
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
*
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
* Wallbox-Verlauf selbst und nicht aus der Zeile.
*
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
* hochgerechnet wird nichts.
*
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
*/
function ladeverlauf($mysql, $von, $bis)
{
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
$stmt = $mysql->prepare(
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
FROM skoda_ladepunkte
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
if (!$stmt) {
return [[], [], null]; // Tabelle fehlt noch
}
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$wahl = $stmt->get_result()->fetch_assoc();
if (!$wahl) {
return [[], [], null];
}
$wallbox = $wahl["wallbox"];
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
$stmt = $mysql->prepare(
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
ORDER BY datetime");
$stmt->bind_param("sss", $wallbox, $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$verlauf = [];
while ($z = $res->fetch_assoc()) {
$verlauf[] = [
"datetime" => $z["datetime"],
"t" => strtotime($z["datetime"]),
"kw" => floatval($z["kw"]),
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
"quelle" => $z["quelle"],
];
}
if (!$verlauf) {
return [[], [], null];
}
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
// selbst trägt.
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
$vor = null;
foreach ($verlauf as $p) {
if ($p["wh"] === null) {
continue;
}
if ($p["t"] >= $t) {
if ($vor === null) {
return $ersatz ?: $p["wh"];
}
$spanne = $p["t"] - $vor["t"];
return $spanne > 0
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
: $p["wh"];
}
$vor = $p;
}
return $vor ? $vor["wh"] : $ersatz;
};
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
$erster = $verlauf[0]["datetime"];
$letzter = end($verlauf)["datetime"];
$stmt = $mysql->prepare(
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
ORDER BY erfasst DESC LIMIT 1)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
AND soc IS NOT NULL)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
ORDER BY 1 LIMIT 1)
ORDER BY erfasst");
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
$stmt->execute();
$res = $stmt->get_result();
$meldungen = [];
while ($z = $res->fetch_assoc()) {
$t = strtotime($z["erfasst"]);
$meldungen[] = [
"datetime" => $z["erfasst"],
"t" => $t,
"soc" => intval($z["soc"]),
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
"chg_state"=> $z["chg_state"],
"wh" => $zaehlerBei($t, intval($z["wh"])),
];
}
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
foreach ($verlauf as &$p) {
$p["soc"] = null;
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
$a = $meldungen[$i - 1];
$b = $meldungen[$i];
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
continue;
}
$spanne = $b["wh"] - $a["wh"];
$p["soc"] = $spanne > 0
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
: floatval($a["soc"]);
break;
}
unset($p["t"]);
}
unset($p);
foreach ($meldungen as &$m) {
unset($m["t"], $m["wh"]);
}
unset($m);
return [$verlauf, $meldungen, $wallbox];
}
/** 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;
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
= ladeverlauf($mysql, $von, $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;
/* --------------------------------------------------------------- strecke -- */
case "strecke":
/*
* Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem
* Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz
* zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die
* gefahrene Strecke. Das ist belastbarer als eine Summe über die
* erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die
* Fahrt in der Liste, im Zählerstand aber nicht.
*
* CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine
* Differenz unter null bräche die Abfrage ab.
*/
$zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365];
$spalten = [];
foreach ($zeitraeume as $name => $spanne) {
$spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY
THEN CAST(odo_km AS SIGNED) END) AS von_$name";
}
$sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, "
. implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL";
$res = $mysql->query($sql);
$z = $res ? $res->fetch_assoc() : null;
$jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null;
$antwort["odo"] = $jetzt;
$antwort["seit"] = $z ? $z["seit"] : null;
$antwort["strecke"] = [];
foreach ($zeitraeume as $name => $spanne) {
$von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null;
$antwort["strecke"][$name] = [
"tage" => $spanne,
"km" => ($von === null || $jetzt === null) ? null : $jetzt - $von,
/*
* Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert
* aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss
* das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben.
*/
"vollstaendig" => ($z && $z["seit"] !== null)
&& strtotime($z["seit"]) <= time() - $spanne * 86400
];
}
break;
default:
http_response_code(400);
$antwort = ["error" => "Unbekannter Abschnitt."];
}
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);