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>
This commit is contained in:
+232
-35
@@ -42,54 +42,100 @@ if ($mysql->connect_errno) {
|
||||
$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.
|
||||
* 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.
|
||||
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
|
||||
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
|
||||
* 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) AS soc_von, MAX(soc) AS soc_bis,
|
||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
|
||||
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(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
|
||||
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 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,
|
||||
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,
|
||||
/*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* Ü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 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'
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -167,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
@@ -237,6 +431,9 @@ case "kurve":
|
||||
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 -- */
|
||||
|
||||
Reference in New Issue
Block a user