Files
Smart-Dashboard/restricted/costs.php
T
adminandClaude Opus 5 d285ec27a0 Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

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

345 lines
14 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);
$von = $jahr . "-01-01";
$bis = $jahr . "-12-31";
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
$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);
}