Files
adminandClaude Opus 5 0cb0d4c3ae Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle
Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand
jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur
Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt.

Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr
Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt
sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und
Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe,
damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im
gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die
Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit,
Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl
daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr
(?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten
Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im
Zaehlerstand aber nicht).

Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und
Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein
geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die
Blende am Bug ist schmaler und heller - von oben sieht man von einer
senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie
ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand
traegt jetzt die Farbe statt einer zweiten Form.

Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern
JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und
Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei
Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten
werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die
ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche
Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen
sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist
nicht das Mittel der Monatsmittel.

Gezeichnet wird das von einem Baustein, den sich Historien- und
Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis
zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die
sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen
Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie
und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden.
Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn
Zeilen aufmacht statt mit siebzehn.

Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette
ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und
AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche
fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige
Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot
und Gelb bleiben den Zustaenden vorbehalten.

Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger
gibt es kein Ueberfahren, und ein title erscheint dort nie.

Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte
decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft
jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen
Teil des Jahres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:08:51 +02:00

368 lines
15 KiB
PHP

<?php
/**
* Modell-Schicht fuer die Preistabellen der Einstellungsseite.
*
* Eine Preistabelle ist eine Zeitreihe: ab einem Stichtag gelten neue Preise,
* bis der naechste Eintrag beginnt. Mehr steht dort auch nicht - insbesondere
* kein Enddatum. Das gab es frueher als eigene Spalte, und weil es eigenstaendig
* gepflegt wurde, konnte es etwas anderes behaupten als der naechste Stichtag:
* in gridCosts liefen zwei Zeilen ueber das erste Quartal 2023, der JOIN in
* ajax/getStats.php fand fuer jeden Messwert zwei Preise, und der
* Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der Statistik.
*
* Ohne die Spalte kann das nicht mehr passieren: zwei Zeitraeume koennen sich
* nicht ueberlappen, weil der eine endet, wo der andere beginnt, und eine
* Luecke kann es auch nicht geben. Die juengste Zeile hat kein Ende und gilt
* weiter, bis ein neuer Preis eingetragen wird - so verhaelt sich ein Tarif ja
* auch. Wo die Statistik das Ende braucht, holt sie es sich mit
* LEAD(active_date) beim naechsten Stichtag.
*
* Was bleibt: vor dem aeltesten Stichtag gibt es keinen Preis. Messwerte, die
* davor liegen, fallen aus der Statistik heraus - dagegen hilft nur hinsehen,
* siehe preisLuecke().
*/
require_once(__DIR__ . "/mysql.php");
/** Verbindung zur Messwert-Datenbank. */
function solarDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Die bearbeitbaren Preistabellen.
*
* Je Feld:
* faktor rechnet zwischen Speicher- und Anzeigeeinheit um. Gespeichert
* wird in Euro, angezeigt und eingegeben in Cent - so, wie es auf
* der Rechnung steht. 0,3163 EUR/kWh sind 31,63 ct/kWh; niemand
* tippt gern vier Nachkommastellen. Vorgabe 1, also unveraendert.
* stellen Nachkommastellen der Anzeige, zugleich die Schrittweite im Feld.
* max Obergrenze. Was darueber liegt, ist ein Tippfehler und keine
* Preiserhoehung.
*
* Nicht jedes Feld ist ein Preis. Der Energieinhalt des Gases stand frueher
* als 6.58 im SQL, die Benzinersparnis als Teiler 24.5 - beides gehoert neben
* den Preis, weil es am Brennstoff und am Fahrzeug haengt und nicht an der
* Physik. Beim Auto aber nicht als "kWh je Liter": diese Zahl steht auf keinem
* Datenblatt. Eingetragen werden die beiden Verbraeuche, die man kennt, und
* die Statistik rechnet daraus, wieviele Liter eine geladene kWh ersetzt.
*
* Kommt spaeter eine weitere Tabelle desselben Zuschnitts dazu, reicht hier
* ein Eintrag - Endpunkt, Maske und Speicherlogik bleiben unveraendert.
*/
function preisReihen()
{
return [
"strom" => [
"titel" => "Strompreise",
"hinweis" => "Arbeitspreis, Einspeisevergütung und Grundpreis, wie sie in die "
. "Jahresstatistik auf der Historienseite eingehen.",
"tabelle" => "gridCosts",
"symbol" => "bi-plug",
"felder" => [
"cost" => ["titel" => "Arbeitspreis", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"gain" => ["titel" => "Einspeisung", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"base" => ["titel" => "Grundpreis", "einheit" => "€/Jahr",
"faktor" => 1, "stellen" => 2, "max" => 5000],
],
],
"gas" => [
"titel" => "Gaspreise",
"hinweis" => "Womit die Ersparnis der Heizung gerechnet wird: was das Gas kostet, "
. "das der Heizstab bei Sonnenüberschuss ersetzt.",
"tabelle" => "gasCosts",
"symbol" => "bi-fire",
"felder" => [
"cost" => ["titel" => "Gaspreis", "einheit" => "ct/l",
"faktor" => 100, "stellen" => 2, "max" => 1000],
"kwhPerLitre" => ["titel" => "Energieinhalt", "einheit" => "kWh/l",
"faktor" => 1, "stellen" => 2, "max" => 20],
],
],
"sprit" => [
"titel" => "Benzinpreise",
"hinweis" => "Für „Benzin gespart“ und „Ersparnis Benzin“: der Preis an der Säule "
. "und was die beiden Autos verbrauchen — daraus ergibt sich, wieviele "
. "Liter eine geladene Kilowattstunde ersetzt.",
"tabelle" => "fuelCosts",
"symbol" => "bi-fuel-pump",
"felder" => [
"cost" => ["titel" => "Benzinpreis", "einheit" => "€/l",
"faktor" => 1, "stellen" => 3, "max" => 10],
"lPer100km" => ["titel" => "Verbrenner", "einheit" => "l/100 km",
"faktor" => 1, "stellen" => 1, "max" => 50],
"kwhPer100km" => ["titel" => "Elektro", "einheit" => "kWh/100 km",
"faktor" => 1, "stellen" => 1, "max" => 100],
],
],
];
}
/** Beschreibung einer Reihe, oder null bei unbekanntem Schluessel. */
function preisReihe($key)
{
$reihen = preisReihen();
return isset($reihen[$key]) ? $reihen[$key] : null;
}
/**
* Spaltenbreiten einer Zeile im Bootstrap-Raster (zusammen zwoelf).
*
* Damit Kopfzeile und Felder aus derselben Quelle kommen: das PHP zeichnet die
* Ueberschriften, das JavaScript die Zeilen, und beide holen sich die Breiten
* hier.
*
* Datum, Ende und Papierkorb nehmen sich so wenig wie moeglich - das Ende ist
* nur noch Text, der Papierkorb ein Knopf. Was uebrig bleibt, teilen sich die
* Felder. Geht die Teilung nicht auf, bekommt der Rest das Feld mit der
* laengsten Einheit: hinter "kWh/100 km" bleiben von einer Sechstelspalte
* keine 50 Pixel fuer die Zahl, hinter "EUR/l" ist reichlich Platz.
*
* Ab vier Feldern wird es eng; die Zeile bricht dann um, was haesslicher, aber
* nicht kaputt ist.
*/
function preisSpalten($felder)
{
$felder = array_values($felder);
$anzahl = max(1, count($felder));
$rand = $anzahl >= 3 ? ["ab" => 2, "bis" => 2, "aktion" => 1]
: ["ab" => 3, "bis" => 3, "aktion" => 1];
$rest = 12 - $rand["ab"] - $rand["bis"] - $rand["aktion"];
$breiten = array_fill(0, $anzahl, max(1, intdiv($rest, $anzahl)));
$uebrig = $rest - array_sum($breiten);
if ($uebrig > 0) {
$laengen = array_map(function ($f) { return mb_strlen($f["einheit"]); }, $felder);
$breiten[array_search(max($laengen), $laengen)] += $uebrig;
}
return $rand + ["felder" => $breiten];
}
/**
* Alle Zeitraeume einer Reihe, aeltester zuerst, Werte in Anzeigeeinheit.
*
* Geliefert wird nur der Stichtag; wo eine Zeile endet, ergibt sich aus der
* naechsten und rechnet die Maske selbst aus.
*/
function preiseLaden($key)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
$spalten = array_keys($reihe["felder"]);
$res = solarDb()->query("SELECT active_date, " . implode(", ", $spalten)
. " FROM " . $reihe["tabelle"] . " ORDER BY active_date");
if (!$res) {
throw new RuntimeException(solarDb()->error);
}
$zeilen = [];
while ($row = $res->fetch_assoc()) {
$zeile = ["ab" => $row["active_date"]];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$zeile[$spalte] = round(floatval($row[$spalte]) * $feld["faktor"], $feld["stellen"]);
}
$zeilen[] = $zeile;
}
return $zeilen;
}
/** Datum in der Form JJJJ-MM-TT, sonst null. */
function preisDatum($wert)
{
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) {
return null;
}
$datum = DateTimeImmutable::createFromFormat("!Y-m-d", $wert);
// createFromFormat schluckt den 31. Februar und macht den 3. Maerz daraus;
// der Rueckvergleich faengt das ab.
return ($datum && $datum->format("Y-m-d") === $wert) ? $datum : null;
}
/** Tag der aeltesten Messung in EnergyFlow, als "JJJJ-MM-TT". */
function ersteMessung()
{
static $tag = null;
if ($tag === null) {
$res = solarDb()->query("SELECT MIN(datetime) FROM EnergyFlow");
$zeile = $res ? $res->fetch_row() : null;
$tag = ($zeile && $zeile[0]) ? substr($zeile[0], 0, 10) : "";
}
return $tag;
}
/**
* Beginnt die Reihe zu spaet? Dann fehlt den aeltesten Messwerten der Preis.
*
* Das ist die einzige Luecke, die es noch geben kann - alle anderen hat das
* Weglassen der Spalte end_date erledigt. Auftreten kann sie, wenn jemand den
* aeltesten Zeitraum loescht.
*/
function preisLuecke($zeilen)
{
$erste = ersteMessung();
if (!$erste || !$zeilen) {
return "";
}
$stichtag = $zeilen[0]["ab"];
return $stichtag > $erste ? $erste : "";
}
/**
* Eine ganze Reihe schreiben: pruefen und ersetzen.
*
* Ersetzt statt abzugleichen. Die Tabellen haben eine Handvoll Zeilen und
* keinen kuenstlichen Schluessel - der Stichtag selbst ist der
* Primaerschluessel, und beim Verschieben eines Stichtags waere ein Abgleich
* mehr Buchhaltung als Gewinn. Alles laeuft in einer Transaktion, es gibt also
* keinen Moment, in dem die Statistik auf eine halbe Tabelle blickt.
*/
function preiseSpeichern($key, $zeilen)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
if (!is_array($zeilen) || !$zeilen) {
throw new InvalidArgumentException("Es muss mindestens ein Zeitraum bleiben.");
}
$spalten = array_keys($reihe["felder"]);
// --- Pruefen --------------------------------------------------------
$geprueft = [];
foreach ($zeilen as $zeile) {
$ab = preisDatum($zeile["ab"] ?? "");
if (!$ab) {
throw new InvalidArgumentException("Ein Zeitraum hat kein gültiges Startdatum.");
}
$satz = ["ab" => $ab];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$wert = $zeile[$spalte] ?? null;
if (!is_numeric($wert)) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y") . " ist keine Zahl.");
}
$wert = floatval($wert);
if ($wert < 0 || $wert > $feld["max"]) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y")
. " liegt außerhalb des Erwartbaren (0 bis " . $feld["max"] . " "
. $feld["einheit"] . ").");
}
$satz[$spalte] = $wert / $feld["faktor"];
}
$geprueft[] = $satz;
}
// Nach Stichtag sortieren; zwei gleiche Stichtage kann die Tabelle nicht
// halten (Primaerschluessel) und die Statistik nicht deuten.
usort($geprueft, function ($a, $b) { return $a["ab"] <=> $b["ab"]; });
for ($i = 1; $i < count($geprueft); $i++) {
if ($geprueft[$i]["ab"] == $geprueft[$i - 1]["ab"]) {
throw new InvalidArgumentException(
"Zwei Zeiträume beginnen am " . $geprueft[$i]["ab"]->format("d.m.Y") . ".");
}
}
// --- Schreiben ------------------------------------------------------
$db = solarDb();
$db->begin_transaction();
try {
if (!$db->query("DELETE FROM " . $reihe["tabelle"])) {
throw new RuntimeException($db->error);
}
$platzhalter = implode(", ", array_fill(0, count($spalten) + 1, "?"));
$stmt = $db->prepare("INSERT INTO " . $reihe["tabelle"]
. " (active_date, " . implode(", ", $spalten) . ")"
. " VALUES (" . $platzhalter . ")");
if (!$stmt) {
throw new RuntimeException($db->error);
}
foreach ($geprueft as $satz) {
$werte = [$satz["ab"]->format("Y-m-d")];
foreach ($spalten as $spalte) {
$werte[] = $satz[$spalte];
}
$stmt->bind_param("s" . str_repeat("d", count($spalten)), ...$werte);
if (!$stmt->execute()) {
throw new RuntimeException($stmt->error);
}
}
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return count($geprueft);
}
/**
* Grundpreis Strom fuer ein Kalenderjahr, in Euro.
*
* Der Grundpreis ist der einzige Posten, der nicht an einem Messwert haengt -
* er faellt auch an, wenn nichts verbraucht wird. Deshalb steht er nicht im
* grossen SUM-Ausdruck der Statistik, sondern wird hier eigens gerechnet:
* jeder Zeitraum steuert seinen Anteil an den Tagen des Jahres bei. Das ist
* unabhaengig davon, wieviele Messwerte tatsaechlich aufgezeichnet wurden -
* 2022 hat merklich Luecken, und ein fehlender Nachmittag darf den Grundpreis
* nicht senken.
*
* LEAD() holt den naechsten Stichtag; einen Tag davor endet der Zeitraum. Die
* juengste Zeile hat keinen Nachfolger und laeuft bis zum Jahresende weiter.
*/
function grundpreisImJahr($jahr)
{
$jahr = intval($jahr);
return grundpreisImZeitraum($jahr . "-01-01", $jahr . "-12-31");
}
/**
* Grundpreis Strom fuer einen Zeitraum innerhalb eines Kalenderjahres.
*
* Der Grundpreis steht als Jahresbetrag in der Tarifzeile; auf einen Zeitraum
* kommt er anteilig nach Tagen. Geteilt wird deshalb durch die Tage des
* JAHRES, nicht durch die des Zeitraums - sonst zahlte ein halbes Jahr den
* vollen Betrag.
*
* Von und bis muessen im selben Jahr liegen: die Statistik fragt entweder ein
* ganzes Jahr oder dessen Anfang bis zu einem Stichtag, und ein Zeitraum ueber
* den Jahreswechsel haette zwei verschiedene Nenner.
*/
function grundpreisImZeitraum($von, $bis)
{
$vonTag = new DateTimeImmutable($von);
$bisTag = new DateTimeImmutable($bis);
if ($vonTag->format("Y") !== $bisTag->format("Y")) {
throw new InvalidArgumentException("Zeitraum muss innerhalb eines Jahres liegen.");
}
$von = $vonTag->format("Y-m-d");
$bis = $bisTag->format("Y-m-d");
// Tage des Jahres, 365 oder 366 - der Nenner des Jahresbetrags.
$tage = intval((new DateTimeImmutable($vonTag->format("Y") . "-12-31"))->format("z")) + 1;
$stmt = solarDb()->prepare(
"WITH p AS (SELECT active_date, base,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt
FROM gridCosts)
SELECT SUM(base * (DATEDIFF(LEAST(IFNULL(DATE_SUB(folgt, INTERVAL 1 DAY), ?), ?),
GREATEST(active_date, ?)) + 1) / ?)
FROM p
WHERE active_date <= ? AND (folgt IS NULL OR folgt > ?)");
$stmt->bind_param("sssiss", $bis, $bis, $von, $tage, $bis, $von);
$stmt->execute();
$wert = $stmt->get_result()->fetch_row()[0];
return $wert === null ? 0.0 : floatval($wert);
}