- 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>
611 lines
25 KiB
PHP
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);
|