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>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
<?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);
|
||||
}
|
||||
Reference in New Issue
Block a user