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