Eigenes Meteogramm statt des eingebetteten von meteoblue

Das Widget von meteoblue war ein fremdes Dokument im iframe: es passte
farblich nicht, liess sich nicht aendern, wurde auf schmalen Schirmen als
Ganzes heruntergerechnet und konnte die eigenen Messwerte nicht zeigen.
Genau das kann das neue Modul - links vom Jetzt-Strich steht, was die
Wetterstation gemessen hat, rechts die Vorhersage.

Aufgebaut wie die uebrigen Anzeige-Module: meteogrammKatalog.js sagt, WAS
gezeigt wird, meteogramm.js nur, WIE. Der Renderer kennt kein Feld und keine
Spur beim Namen. Eine weitere Spur ist damit ein Objekt im Katalog und keine
Codeaenderung.

Ein einziges SVG ueber alle drei Felder, damit Zeitachse, Nachtstreifen,
Jetzt-Linie und Zeiger nur einmal existieren und ueberall gleich sitzen.

Zwei Entscheidungen, die man sonst sucht:

Das Wolkenband zeichnet je Schicht ein Rechteck mit waagerechtem
Farbverlauf. Ein Rechteck je Stunde war der erste Versuch und sah aus wie
Fernsehrauschen - zweihundert harte Kanten nebeneinander. Open-Meteo kennt
ohnehin nur drei Schichten statt eines Hoehenprofils.

Die Strahlung teilt sich das Feld mit der Temperatur, statt ein viertes zu
bekommen: gleicher Tagesrhythmus, gleiche Nacht-Schattierung, und ein
viertes Feld machte das Ganze am Handy hoeher als das externe Widget.

Die alte Einbindung bleibt vorerst und ist umschaltbar. mountMeteogram()
laeuft jetzt erst beim Umschalten - wer meteoblue nie ansieht, laedt es auch
nicht mehr.

Die Karte heisst "Wetter" statt "Forecast": darunter steht bereits eine
zweite Karte dieses Namens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 13:54:55 +02:00
co-authored by Claude Opus 5
parent a4b25ba279
commit 2c48d78880
8 changed files with 1496 additions and 5 deletions
+186
View File
@@ -0,0 +1,186 @@
<?php
/*
* Daten für das Meteogramm der Solar-Seite (js/solar/meteogramm.js).
*
* ?tage=5&rueck=24 tage 2…7 nach vorn, rueck 0…72 Stunden zurück
*
* Die Vorhersage kommt aus solarLog.weatherHours und weatherDays (geschrieben
* von SolarManager/gatherForecastData.py), die gemessenen Stunden links vom
* Jetzt-Strich aus weatherStation.
*
* Verschmolzen wird nur teilweise, und das ist der Kern dieser Datei: Die
* eigene Station misst Temperatur, Wind, Böe, Richtung, Feuchte und Druck.
* Sie hat weder Regenmesser noch Pyranometer noch Wolkenkamera. Bewölkung,
* Strahlung, Niederschlag und Wettercode bleiben deshalb auch in der
* Vergangenheit aus weatherHours - dort stehen für vergangene Stunden keine
* Vorhersagen mehr, sondern nachgerechnete Werte (ist_vorhersage = 0).
* Genau dafür holt der Sammler zwei Tage Vergangenheit mit.
*
* Zeiten gehen als Unix-Sekunden hinaus, Messreihen als Tupel und nicht als
* Objekte: bei 200 Stunden mit 16 Feldern spart das ein Vielfaches. Welche
* Stelle was bedeutet, steht unten bei SPALTEN und noch einmal im Katalog
* (js/solar/meteogrammKatalog.js) - beide Listen müssen zueinander passen.
*
* Die Live-Werte kommen nicht von hier, sondern über MQTT (weatherStation/#).
*/
require_once("../helper.php");
require_once("../restricted/costs.php"); // solarDb()
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
/*
* Die Spaltenlage einer Stundenzeile. Neue Größen hängen HINTEN an - dann
* bleiben die Stellen bestehender Spuren gültig und der Katalog muss nur
* einen Schlüssel dazubekommen.
*
* 0 t Unix-Sekunden, volle Stunde
* 1 gemessen 1 = die eigene Station hat diese Stunde gemessen
* 2 temp °C 3 regen mm 4 schnee cm
* 5 wind km/h 6 boe km/h 7 richtung Grad
* 8 strahlung W/m² 9 wtief % 10 wmittel %
* 11 whoch % 12 code WMO 13 feuchte %
* 14 druck hPa 15 regenWkt %
*/
try {
$db = solarDb();
$tage = max(2, min(7, intval($_GET["tage"] ?? 5)));
$rueck = max(0, min(72, intval($_GET["rueck"] ?? 24)));
// Auf volle Stunden rasten: die Vorhersage kennt nur Stunden, und eine
// Achse, die bei 13:47 anfängt, hätte keine runden Tagesgrenzen.
$jetzt = time();
$von = (intdiv($jetzt, 3600) - $rueck) * 3600;
$bis = (intdiv($jetzt, 3600) + $tage * 24) * 3600;
// --- Vorhersage und nachgerechnete Stunden ---------------------------
$reihe = [];
$stmt = $db->prepare(
"SELECT UNIX_TIMESTAMP(datetime) AS t, temp, regen, schnee, wind, boe,
richtung, strahlung, wolken_tief, wolken_mittel, wolken_hoch,
wettercode, feuchte, druck, regen_wkt
FROM weatherHours
WHERE datetime BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?)
ORDER BY datetime");
$stmt->bind_param("ii", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$t = (int)$z["t"];
$reihe[$t] = [
$t, 0,
zahl($z["temp"], 1), zahl($z["regen"], 1), zahl($z["schnee"], 1),
zahl($z["wind"], 1), zahl($z["boe"], 1), ganz($z["richtung"]),
ganz($z["strahlung"]),
ganz($z["wolken_tief"]), ganz($z["wolken_mittel"]), ganz($z["wolken_hoch"]),
ganz($z["wettercode"]), ganz($z["feuchte"]), ganz($z["druck"]),
ganz($z["regen_wkt"]),
];
}
$stmt->close();
// --- Gemessene Stunden der eigenen Station ---------------------------
//
// Derselbe Ausdruck dient als Spalte und als GROUP BY - ein Schalter
// statt zweier Abfragen, wie in ajax/speicher.php.
//
// MAX(gustSpeed), aber AVG(windSpeed): Open-Meteos Böe ist die Spitze der
// Stunde. Mit einem Mittelwert auf der Messseite spränge die Böenlinie
// genau am Jetzt-Strich.
//
// Die Windrichtung ist ein Winkel und verträgt kein arithmetisches
// Mittel: aus 350° und 10° würden 180°, also die Gegenrichtung. Deshalb
// die Vektorsumme über Sinus und Kosinus.
if ($rueck > 0) {
$stmt = $db->prepare(
"SELECT FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600 AS t,
AVG(tempAmb) AS temp, AVG(windSpeed) AS wind,
MAX(gustSpeed) AS boe, AVG(hum) AS feuchte, AVG(qff) AS druck,
DEGREES(ATAN2(AVG(SIN(RADIANS(windDir))),
AVG(COS(RADIANS(windDir))))) AS richtung
FROM weatherStation
WHERE datetime >= FROM_UNIXTIME(?) AND datetime < FROM_UNIXTIME(?)
GROUP BY t ORDER BY t");
$stmt->bind_param("ii", $von, $jetzt);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$t = (int)$z["t"];
if (!isset($reihe[$t])) {
continue; // gemessen, aber ohne Vorhersagezeile - nichts zu zeigen
}
$reihe[$t][1] = 1;
$reihe[$t][2] = zahl($z["temp"], 1);
$reihe[$t][5] = zahl($z["wind"], 1);
$reihe[$t][6] = zahl($z["boe"], 1);
$reihe[$t][7] = ((int)round($z["richtung"]) + 360) % 360;
$reihe[$t][13] = ganz($z["feuchte"]);
$reihe[$t][14] = ganz($z["druck"]);
}
$stmt->close();
}
ksort($reihe);
// --- Tage: Sonnenzeiten und Tagesspanne ------------------------------
//
// Sonnenauf- und -untergang kommen aus weatherDays und nicht aus
// daylight: daylight reicht nur bis morgen, das Meteogramm bis zu sieben
// Tage. Tage ohne Sonnenzeit (nachgetragenes Archiv) fallen hier heraus -
// sie liegen ohnehin weit vor dem angezeigten Fenster.
$tageListe = [];
$stmt = $db->prepare(
"SELECT UNIX_TIMESTAMP(date) AS t, temp_min, temp_max, wettercode,
UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenauf)) AS auf,
UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenunter)) AS unter
FROM weatherDays
WHERE date BETWEEN DATE(FROM_UNIXTIME(?)) AND DATE(FROM_UNIXTIME(?))
AND sonnenauf IS NOT NULL
ORDER BY date");
$stmt->bind_param("ii", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
while ($z = $res->fetch_assoc()) {
$tageListe[] = [
(int)$z["t"], zahl($z["temp_min"], 1), zahl($z["temp_max"], 1),
ganz($z["wettercode"]), (int)$z["auf"], (int)$z["unter"],
];
}
$stmt->close();
// Wie frisch die Vorhersage ist - das Modul schreibt daraus "Stand 09:40"
// und färbt es warnend, wenn der Sammler stehengeblieben ist.
$stand = 0;
if ($res = $db->query("SELECT UNIX_TIMESTAMP(MAX(abgerufen)) AS s FROM weatherHours")) {
$stand = (int)($res->fetch_assoc()["s"] ?? 0);
}
echo json_encode([
"von" => $von, "bis" => $bis, "jetzt" => $jetzt, "stand" => $stand,
"stunden" => array_values($reihe),
"tage" => $tageListe,
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(["error" => "Meteogramm nicht abrufbar: " . $e->getMessage()]);
}
/** Zahl mit fester Nachkommastelle, NULL bleibt NULL. */
function zahl($wert, $stellen)
{
return $wert === null ? null : round((float)$wert, $stellen);
}
/** Ganze Zahl, NULL bleibt NULL - eine fehlende Bewölkung ist nicht 0 %. */
function ganz($wert)
{
return $wert === null ? null : (int)round((float)$wert);
}