Files
rpotter6298 5c095b19a5 Flatten to a single package directory
The checkout is now astrape/astrape-dashboard, not astrape/astrape-dashboard/<package>. The package
contents move to the repository root and pyproject maps the package name to
'.' via package-dir, with the subpackages listed explicitly.

The systemd units move out to /srv/astrape/deploy/systemd, outside every
repository, because they describe this deployment rather than any one
component.

Verified both install modes: editable, which is how the deployment runs, and
a plain wheel install, which is what the container build does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:05:19 +02:00

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()