"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; /* --------------------------------------------------------------- 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);