Initial commit: astrape-dashboard split out of the combined Astrape tree

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>
This commit is contained in:
rpotter6298
2026-08-30 14:19:09 +02:00
commit 665c941802
16 changed files with 1912 additions and 0 deletions
@@ -0,0 +1,273 @@
"""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()