665c941802
The web frontend: the UI itself, the oracle, weather, station and quality renderers, and the serving daemon. The weather and station display modules were each split in two. The dataclasses went to astrape-oracle with the stores that return them; only the rendering stayed here, so the dependency runs dashboard -> oracle -> ems -> gibil with no cycle. Extracted from a single repository where the forecasting engine, the database plumbing, the hardware client, the ingest daemons and the web UI all imported each other freely. Verified that every module imports with gibil resolved from its own installed checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""Renders local weather station history for the web UI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from gibil.classes.models import WeatherStationObservation
|
|
from astrape_oracle.classes.datasets import WeatherStationDisplayDataset
|
|
|
|
|
|
class WeatherStationDisplay:
|
|
"""Renders local weather station history for the Astrape web UI."""
|
|
|
|
def render(self) -> str:
|
|
return """
|
|
<section class="panel weather-station-panel" data-module="weather-station-display">
|
|
<div class="panel-heading">
|
|
<div>
|
|
<h2>Weather Station</h2>
|
|
<p>Local Ecowitt history</p>
|
|
</div>
|
|
<div class="control-row">
|
|
<label>
|
|
Range
|
|
<select id="station-range">
|
|
<option value="24">24 hours</option>
|
|
<option value="72">3 days</option>
|
|
<option value="168" selected>7 days</option>
|
|
<option value="720">30 days</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Variable
|
|
<select id="station-variable">
|
|
<option value="solar_radiation_w_m2">Sun</option>
|
|
<option value="rain_rate_mm_h">Rain rate</option>
|
|
<option value="rain_daily_mm">Daily rain</option>
|
|
<option value="wind_speed_m_s">Wind speed</option>
|
|
<option value="wind_gust_m_s">Wind gust</option>
|
|
<option value="temperature_c">Temperature</option>
|
|
<option value="relative_humidity_pct">Humidity</option>
|
|
<option value="relative_pressure_hpa">Pressure</option>
|
|
<option value="uv_index">UV index</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="station-stats" class="stat-grid"></div>
|
|
<div class="chart-shell">
|
|
<canvas id="station-chart" width="1100" height="420"></canvas>
|
|
</div>
|
|
</section>
|
|
<script>
|
|
window.astrapeModules = window.astrapeModules || {};
|
|
window.astrapeModules.weatherStationDisplay = (() => {
|
|
const variables = {
|
|
solar_radiation_w_m2: { label: "Sun", unit: "W/m2", color: "#fbbf24", decimals: 0 },
|
|
rain_rate_mm_h: { label: "Rain rate", unit: "mm/h", color: "#60a5fa", decimals: 1 },
|
|
rain_daily_mm: { label: "Daily rain", unit: "mm", color: "#38bdf8", decimals: 1 },
|
|
wind_speed_m_s: { label: "Wind speed", unit: "m/s", color: "#34d399", decimals: 1 },
|
|
wind_gust_m_s: { label: "Wind gust", unit: "m/s", color: "#22d3ee", decimals: 1 },
|
|
temperature_c: { label: "Temperature", unit: "C", color: "#fb7185", decimals: 1 },
|
|
relative_humidity_pct: { label: "Humidity", unit: "%", color: "#a78bfa", decimals: 0 },
|
|
relative_pressure_hpa: { label: "Pressure", unit: "hPa", color: "#f8fafc", decimals: 1 },
|
|
uv_index: { label: "UV index", unit: "", color: "#f59e0b", decimals: 1 },
|
|
};
|
|
|
|
function init() {
|
|
document.getElementById("station-range").addEventListener("change", refresh);
|
|
document.getElementById("station-variable").addEventListener("change", render);
|
|
refresh();
|
|
setInterval(refresh, 30000);
|
|
}
|
|
|
|
async function refresh() {
|
|
const hours = document.getElementById("station-range").value;
|
|
const response = await fetch(`/api/weather-station?lookback_hours=${hours}`, { cache: "no-store" });
|
|
window.astrapeWeatherStationData = await response.json();
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
const payload = window.astrapeWeatherStationData || { observations: [], stats: {} };
|
|
renderStats(payload);
|
|
drawChart(payload);
|
|
}
|
|
|
|
function renderStats(payload) {
|
|
const stats = payload.stats || {};
|
|
const items = [
|
|
{ label: "Sun peak", value: stats.max_solar_radiation_w_m2, unit: "W/m2", decimals: 0 },
|
|
{ label: "Rain today", value: stats.latest_rain_daily_mm, unit: "mm", decimals: 1 },
|
|
{ label: "Rain rate peak", value: stats.max_rain_rate_mm_h, unit: "mm/h", decimals: 1 },
|
|
{ label: "Wind peak", value: stats.max_wind_gust_m_s, unit: "m/s", decimals: 1 },
|
|
{ label: "Outdoor temp", value: stats.latest_temperature_c, unit: "C", decimals: 1 },
|
|
{ label: "Humidity", value: stats.latest_relative_humidity_pct, unit: "%", decimals: 0 },
|
|
];
|
|
document.getElementById("station-stats").innerHTML = items.map((item) => `
|
|
<div class="stat-card">
|
|
<div class="stat-label">${item.label}</div>
|
|
<div class="stat-value">${formatValue(item.value, item.decimals)}<span>${item.unit}</span></div>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function drawChart(payload) {
|
|
const canvas = document.getElementById("station-chart");
|
|
const ctx = canvas.getContext("2d");
|
|
const variable = document.getElementById("station-variable").value;
|
|
const config = variables[variable];
|
|
const points = (payload.observations || [])
|
|
.map((point) => ({ observed_at: point.observed_at, value: point[variable] }))
|
|
.filter((point) => point.value !== null && Number.isFinite(Number(point.value)));
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (!points.length) {
|
|
drawEmpty(ctx, canvas);
|
|
return;
|
|
}
|
|
|
|
const xs = points.map((point) => new Date(point.observed_at).getTime());
|
|
const ys = points.map((point) => Number(point.value));
|
|
const bounds = {
|
|
minX: Math.min(...xs),
|
|
maxX: Math.max(...xs),
|
|
minY: Math.min(...ys),
|
|
maxY: Math.max(...ys),
|
|
};
|
|
if (bounds.minY === bounds.maxY) {
|
|
bounds.minY -= 1;
|
|
bounds.maxY += 1;
|
|
}
|
|
|
|
drawAxes(ctx, canvas, bounds, config);
|
|
drawSeries(ctx, canvas, bounds, points, config.color);
|
|
}
|
|
|
|
function drawAxes(ctx, canvas, bounds, config) {
|
|
const margin = chartMargin();
|
|
ctx.strokeStyle = "#94a3b8";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(margin.left, margin.top);
|
|
ctx.lineTo(margin.left, canvas.height - margin.bottom);
|
|
ctx.lineTo(canvas.width - margin.right, canvas.height - margin.bottom);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = "#94a3b8";
|
|
ctx.font = "12px system-ui";
|
|
ctx.fillText(`${config.label} (${config.unit})`, margin.left, 16);
|
|
ctx.fillText(formatValue(bounds.maxY, config.decimals), 10, margin.top + 4);
|
|
ctx.fillText(formatValue(bounds.minY, config.decimals), 10, canvas.height - margin.bottom);
|
|
}
|
|
|
|
function drawSeries(ctx, canvas, bounds, points, color) {
|
|
const margin = chartMargin();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
points.forEach((point, index) => {
|
|
const x = scale(new Date(point.observed_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
|
|
const y = scale(Number(point.value), bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
|
|
if (index === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawEmpty(ctx, canvas) {
|
|
ctx.fillStyle = "#9aa8ba";
|
|
ctx.font = "14px system-ui";
|
|
ctx.fillText("No station observations in this range yet.", 28, 42);
|
|
}
|
|
|
|
function scale(value, inMin, inMax, outMin, outMax) {
|
|
if (inMin === inMax) return (outMin + outMax) / 2;
|
|
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
|
|
}
|
|
|
|
function chartMargin() {
|
|
return { top: 28, right: 28, bottom: 34, left: 58 };
|
|
}
|
|
|
|
function formatValue(value, decimals) {
|
|
if (value === null || value === undefined || !Number.isFinite(Number(value))) return "--";
|
|
return Number(value).toFixed(decimals);
|
|
}
|
|
|
|
return { init };
|
|
})();
|
|
window.astrapeModules.weatherStationDisplay.init();
|
|
</script>
|
|
"""
|
|
|
|
def data_payload(
|
|
self,
|
|
dataset: WeatherStationDisplayDataset | None = None,
|
|
) -> str:
|
|
if dataset is None:
|
|
dataset = WeatherStationDisplayDataset(observations=[])
|
|
|
|
observations = [
|
|
self._observation(observation) for observation in dataset.observations
|
|
]
|
|
|
|
return json.dumps(
|
|
{
|
|
"now": datetime.now().astimezone().isoformat(),
|
|
"observations": observations,
|
|
"stats": self._stats(observations),
|
|
}
|
|
)
|
|
|
|
def _observation(
|
|
self,
|
|
observation: WeatherStationObservation,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"observed_at": self._iso(observation.observed_at),
|
|
"received_at": self._iso(observation.received_at),
|
|
"source": observation.source,
|
|
"temperature_c": observation.temperature_c,
|
|
"relative_humidity_pct": observation.relative_humidity_pct,
|
|
"dew_point_c": observation.dew_point_c,
|
|
"pressure_hpa": observation.pressure_hpa,
|
|
"wind_speed_m_s": observation.wind_speed_m_s,
|
|
"wind_gust_m_s": observation.wind_gust_m_s,
|
|
"wind_max_m_s": observation.wind_max_m_s,
|
|
"wind_direction_deg": observation.wind_direction_deg,
|
|
"solar_radiation_w_m2": observation.solar_radiation_w_m2,
|
|
"uv_index": observation.uv_index,
|
|
"rain_rate_mm_h": observation.rain_rate_mm_h,
|
|
"rain_event_mm": observation.rain_event_mm,
|
|
"rain_hourly_mm": observation.rain_hourly_mm,
|
|
"rain_daily_mm": observation.rain_daily_mm,
|
|
"rain_weekly_mm": observation.rain_weekly_mm,
|
|
"rain_monthly_mm": observation.rain_monthly_mm,
|
|
"rain_yearly_mm": observation.rain_yearly_mm,
|
|
"indoor_temperature_c": observation.indoor_temperature_c,
|
|
"indoor_humidity_pct": observation.indoor_humidity_pct,
|
|
"absolute_pressure_hpa": observation.absolute_pressure_hpa,
|
|
"relative_pressure_hpa": observation.relative_pressure_hpa,
|
|
}
|
|
|
|
def _stats(self, observations: list[dict[str, object]]) -> dict[str, object]:
|
|
latest = observations[-1] if observations else {}
|
|
return {
|
|
"latest_temperature_c": latest.get("temperature_c"),
|
|
"latest_relative_humidity_pct": latest.get("relative_humidity_pct"),
|
|
"latest_rain_daily_mm": latest.get("rain_daily_mm"),
|
|
"max_solar_radiation_w_m2": self._max_value(
|
|
observations, "solar_radiation_w_m2"
|
|
),
|
|
"max_rain_rate_mm_h": self._max_value(observations, "rain_rate_mm_h"),
|
|
"max_wind_gust_m_s": self._max_value(observations, "wind_gust_m_s"),
|
|
}
|
|
|
|
def _max_value(
|
|
self,
|
|
observations: list[dict[str, object]],
|
|
key: str,
|
|
) -> float | None:
|
|
values = [
|
|
value
|
|
for observation in observations
|
|
if isinstance((value := observation.get(key)), (int, float))
|
|
]
|
|
if not values:
|
|
return None
|
|
return float(max(values))
|
|
|
|
def _iso(self, value: datetime) -> str:
|
|
return value.isoformat()
|