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