From 665c941802f93dd05421ad4311f93cb8a407b7c0 Mon Sep 17 00:00:00 2001 From: rpotter6298 Date: Sun, 30 Aug 2026 14:19:09 +0200 Subject: [PATCH] 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) --- .gitignore | 5 + astrape_dashboard/__init__.py | 0 astrape_dashboard/classes/__init__.py | 0 .../classes/displays/__init__.py | 0 astrape_dashboard/classes/displays/oracle.py | 446 ++++++++++++++++++ .../classes/displays/oracle_quality.py | 152 ++++++ astrape_dashboard/classes/displays/station.py | 273 +++++++++++ astrape_dashboard/classes/displays/weather.py | 315 +++++++++++++ astrape_dashboard/classes/sample_data.py | 74 +++ astrape_dashboard/classes/webui.py | 437 +++++++++++++++++ astrape_dashboard/scripts/__init__.py | 0 astrape_dashboard/scripts/daemons/__init__.py | 0 .../scripts/daemons/web_daemon.py | 166 +++++++ astrape_dashboard/scripts/debug/__init__.py | 0 .../scripts/debug/debug_weather.py | 31 ++ pyproject.toml | 13 + 16 files changed, 1912 insertions(+) create mode 100644 .gitignore create mode 100644 astrape_dashboard/__init__.py create mode 100644 astrape_dashboard/classes/__init__.py create mode 100644 astrape_dashboard/classes/displays/__init__.py create mode 100644 astrape_dashboard/classes/displays/oracle.py create mode 100644 astrape_dashboard/classes/displays/oracle_quality.py create mode 100644 astrape_dashboard/classes/displays/station.py create mode 100644 astrape_dashboard/classes/displays/weather.py create mode 100644 astrape_dashboard/classes/sample_data.py create mode 100644 astrape_dashboard/classes/webui.py create mode 100644 astrape_dashboard/scripts/__init__.py create mode 100644 astrape_dashboard/scripts/daemons/__init__.py create mode 100644 astrape_dashboard/scripts/daemons/web_daemon.py create mode 100644 astrape_dashboard/scripts/debug/__init__.py create mode 100644 astrape_dashboard/scripts/debug/debug_weather.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0650fbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ diff --git a/astrape_dashboard/__init__.py b/astrape_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/classes/__init__.py b/astrape_dashboard/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/classes/displays/__init__.py b/astrape_dashboard/classes/displays/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/classes/displays/oracle.py b/astrape_dashboard/classes/displays/oracle.py new file mode 100644 index 0000000..cfffaad --- /dev/null +++ b/astrape_dashboard/classes/displays/oracle.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import json +from dataclasses import asdict +from datetime import datetime + +from gibil.classes.oracle_builder import EnergyOracleBuilder +from gibil.classes.forecast_config import EnergyForecastConfig +from astrape_ems.classes.store import SigenStore +from astrape_oracle.classes.weather.store import WeatherStore +from gibil.classes.models import ( + NetPowerForecastPoint, + PowerForecastPoint, + PowerForecastRun, +) +from astrape_oracle.classes.oracle_store import OracleStore + + +class OracleDisplay: + """Renders energy oracle curves for the Astrape web UI.""" + + def render(self) -> str: + return """ +
+
+
+

Energy Oracle

+

Solar, usage, and net power projection curves

+
+
+
+ +
+
+
+ +
+
+ + """ + + def data_payload(self) -> str: + plant_store = SigenStore.from_env() + builder = EnergyOracleBuilder( + weather_history=WeatherStore.from_env(), + plant_history=plant_store, + config=EnergyForecastConfig.from_env(), + ) + oracle_store = OracleStore.from_env() + stored_runs = oracle_store.load_latest_runs() + if stored_runs is None: + solar_run, load_run, net_run = builder.build() + else: + solar_run, load_run, net_run = stored_runs + actual_points = plant_store.load_recent_actual_points() + try: + historical_net_runs = oracle_store.load_lagged_net_runs() + historical_solar_runs = oracle_store.load_lagged_power_runs("solar") + historical_load_runs = oracle_store.load_lagged_power_runs("load") + except Exception: + historical_net_runs = [] + historical_solar_runs = [] + historical_load_runs = [] + return json.dumps( + { + "issued_at": self._iso(net_run.issued_at), + "now": self._iso(net_run.issued_at), + "solar_model": solar_run.model_version, + "load_model": load_run.model_version, + "solar_points": [ + self._power_point(point) for point in solar_run.points + ], + "load_points": [ + self._power_point(point) for point in load_run.points + ], + "net_points": [self._net_point(point) for point in net_run.points], + "actual_points": [ + self._actual_point(point) for point in actual_points + ], + "historical_net_runs": [ + self._historical_net_run(run) for run in historical_net_runs + ], + "historical_solar_runs": [ + self._historical_power_run(run) for run in historical_solar_runs + ], + "historical_load_runs": [ + self._historical_power_run(run) for run in historical_load_runs + ], + } + ) + + def _power_point(self, point: PowerForecastPoint) -> dict[str, object]: + return { + "target_at": self._iso(point.target_at), + "horizon_minutes": point.horizon_minutes, + "expected_power_w": point.expected_power_w, + "p10_power_w": point.p10_power_w, + "p50_power_w": point.p50_power_w, + "p90_power_w": point.p90_power_w, + "confidence": point.confidence, + "source": point.source, + "model_version": point.model_version, + "metadata": point.metadata, + } + + def _net_point(self, point: NetPowerForecastPoint) -> dict[str, object]: + return asdict(point) | {"target_at": self._iso(point.target_at)} + + def _actual_point(self, point: dict[str, object]) -> dict[str, object]: + return { + "target_at": self._iso(point["target_at"]), + "solar_power_w": point["solar_power_w"], + "load_power_w": point["load_power_w"], + "net_power_w": point["net_power_w"], + "grid_import_w": point["grid_import_w"], + "grid_export_w": point["grid_export_w"], + "sample_count": point["sample_count"], + } + + def _historical_net_run(self, run: dict[str, object]) -> dict[str, object]: + return { + "lag_hours": run.get("lag_hours"), + "issued_at": self._iso(run["issued_at"]), + "points": [ + { + "target_at": self._iso(point["target_at"]), + "horizon_minutes": point["horizon_minutes"], + "expected_net_power_w": point["expected_net_power_w"], + "safe_net_power_w": point["safe_net_power_w"], + "p10_net_power_w": point.get("p10_net_power_w"), + "p50_net_power_w": point.get("p50_net_power_w"), + "p90_net_power_w": point.get("p90_net_power_w"), + "solar_p50_power_w": point["solar_p50_power_w"], + "load_p50_power_w": point["load_p50_power_w"], + "solar_p10_power_w": point["solar_p10_power_w"], + "solar_p90_power_w": point.get("solar_p90_power_w"), + "load_p10_power_w": point.get("load_p10_power_w"), + "load_p90_power_w": point["load_p90_power_w"], + } + for point in run["points"] + ], + } + + def _historical_power_run(self, run: dict[str, object]) -> dict[str, object]: + return { + "lag_hours": run.get("lag_hours"), + "issued_at": self._iso(run["issued_at"]), + "kind": run["kind"], + "source": run["source"], + "model_version": run["model_version"], + "points": [ + { + "target_at": self._iso(point["target_at"]), + "horizon_minutes": point["horizon_minutes"], + "expected_power_w": point["expected_power_w"], + "p10_power_w": point["p10_power_w"], + "p50_power_w": point["p50_power_w"], + "p90_power_w": point["p90_power_w"], + "confidence": point["confidence"], + } + for point in run["points"] + ], + } + + def _iso(self, value: datetime) -> str: + return value.isoformat() diff --git a/astrape_dashboard/classes/displays/oracle_quality.py b/astrape_dashboard/classes/displays/oracle_quality.py new file mode 100644 index 0000000..9da90a5 --- /dev/null +++ b/astrape_dashboard/classes/displays/oracle_quality.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +from datetime import timedelta + +from astrape_oracle.classes.oracle_store import OracleStore + + +class OracleQualityDisplay: + """Renders oracle prediction quality tables.""" + + def render(self) -> str: + return """ +
+
+
+

Oracle Quality

+

Prediction error by model and horizon

+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
KindModelHorizonSamplesBiasMAEMedian AEMAPECoverage
+
+
+ + """ + + def data_payload(self, lookback_hours: float = 168) -> str: + try: + rows = OracleStore.from_env().load_evaluation_summary( + lookback=timedelta(hours=lookback_hours) + ) + except Exception: + rows = [] + + return json.dumps( + { + "lookback_hours": lookback_hours, + "rows": [self._row(row) for row in rows], + } + ) + + def _row(self, row: dict[str, object]) -> dict[str, object]: + return { + key: self._json_value(value) + for key, value in row.items() + } + + def _json_value(self, value: object) -> object: + if value is None or isinstance(value, (str, int, float, bool)): + return value + try: + return float(value) + except (TypeError, ValueError): + return str(value) diff --git a/astrape_dashboard/classes/displays/station.py b/astrape_dashboard/classes/displays/station.py new file mode 100644 index 0000000..55284ed --- /dev/null +++ b/astrape_dashboard/classes/displays/station.py @@ -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 """ +
+
+
+

Weather Station

+

Local Ecowitt history

+
+
+ + +
+
+
+
+ +
+
+ + """ + + 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() diff --git a/astrape_dashboard/classes/displays/weather.py b/astrape_dashboard/classes/displays/weather.py new file mode 100644 index 0000000..b32e743 --- /dev/null +++ b/astrape_dashboard/classes/displays/weather.py @@ -0,0 +1,315 @@ +"""Renders Open-Meteo forecast history and resolved truth for the web UI.""" + +from __future__ import annotations + +import json +from datetime import datetime +from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth +from astrape_oracle.classes.datasets import WeatherDisplayDataset + + +class WeatherDisplay: + """Renders weather source data for the Astrape web UI.""" + + def render(self) -> str: + return """ +
+
+
+

Weather

+

External forecast history

+
+
+ +
+
Horizons
+
+
+ + Resolved truth +
+
+
+
+
+ +
+
+ + """ + + def data_payload(self, dataset: WeatherDisplayDataset | None = None) -> str: + if dataset is None: + dataset = WeatherDisplayDataset(forecast_points=[], resolved_truth=[]) + + forecast_points = [self._forecast_point(point) for point in dataset.forecast_points] + resolved_truth = [self._truth_point(point) for point in dataset.resolved_truth] + horizons = sorted({point["horizon_hours"] for point in forecast_points}) + + return json.dumps( + { + "now": datetime.now().astimezone().isoformat(), + "forecast_points": forecast_points, + "resolved_truth": resolved_truth, + "horizons": horizons, + "min_horizon": 1, + "max_horizon": 47, + } + ) + + def _forecast_point(self, point: WeatherForecastPoint) -> dict[str, object]: + return { + "issued_at": self._iso(point.issued_at), + "target_at": self._iso(point.target_at), + "horizon_hours": point.horizon_hours, + "source": point.source, + "temperature_c": point.temperature_c, + "shortwave_radiation_w_m2": point.shortwave_radiation_w_m2, + "cloud_cover_pct": point.cloud_cover_pct, + } + + def _truth_point(self, point: WeatherResolvedTruth) -> dict[str, object]: + return { + "resolved_at": self._iso(point.resolved_at), + "source": point.source, + "temperature_c": point.temperature_c, + "shortwave_radiation_w_m2": point.shortwave_radiation_w_m2, + "cloud_cover_pct": point.cloud_cover_pct, + } + + def _iso(self, value: datetime) -> str: + return value.isoformat() diff --git a/astrape_dashboard/classes/sample_data.py b/astrape_dashboard/classes/sample_data.py new file mode 100644 index 0000000..2c8cf94 --- /dev/null +++ b/astrape_dashboard/classes/sample_data.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from math import pi, sin + +from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth +from astrape_oracle.classes.datasets import WeatherDisplayDataset + + +class WeatherSampleData: + """Builds temporary display-only weather data for UI tuning.""" + + def build(self, hours: int = 72) -> WeatherDisplayDataset: + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + start = now - timedelta(hours=hours) + horizons = [2, 4, 8, 12, 24] + + forecast_points: list[WeatherForecastPoint] = [] + resolved_truth: list[WeatherResolvedTruth] = [] + + for offset in range(hours + 1): + target_at = start + timedelta(hours=offset) + truth_temperature = self._temperature(target_at, offset) + truth_solar = self._solar(target_at, offset) + + resolved_truth.append( + WeatherResolvedTruth( + resolved_at=target_at, + source="sample", + temperature_c=truth_temperature, + shortwave_radiation_w_m2=truth_solar, + ) + ) + + for horizon in horizons: + forecast_points.append( + WeatherForecastPoint( + issued_at=target_at - timedelta(hours=horizon), + target_at=target_at, + horizon_hours=horizon, + temperature_c=truth_temperature + + self._temperature_error(offset, horizon), + shortwave_radiation_w_m2=max( + 0, + truth_solar + self._solar_error(offset, horizon), + ), + cloud_cover_pct=max( + 0, + min(100, 45 + 30 * sin((offset + horizon) / 9)), + ), + source="sample", + ) + ) + + return WeatherDisplayDataset( + forecast_points=forecast_points, + resolved_truth=resolved_truth, + ) + + def _temperature(self, target_at: datetime, offset: int) -> float: + daily = sin(((target_at.hour - 7) / 24) * 2 * pi) + slow = sin(offset / 18) + return round(6.5 + daily * 5.5 + slow * 1.3, 1) + + def _solar(self, target_at: datetime, offset: int) -> float: + daylight = max(0, sin(((target_at.hour - 5) / 15) * pi)) + cloud_effect = 0.75 + 0.25 * sin(offset / 7) + return round(780 * daylight * cloud_effect, 1) + + def _temperature_error(self, offset: int, horizon: int) -> float: + return round((horizon / 8) * sin((offset + horizon) / 5), 1) + + def _solar_error(self, offset: int, horizon: int) -> float: + return round((horizon * 9) * sin((offset + horizon) / 4), 1) diff --git a/astrape_dashboard/classes/webui.py b/astrape_dashboard/classes/webui.py new file mode 100644 index 0000000..d92bccc --- /dev/null +++ b/astrape_dashboard/classes/webui.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from os import environ + +from astrape_ems.classes.env import EnvLoader +from astrape_dashboard.classes.sample_data import WeatherSampleData +from astrape_oracle.classes.weather.store import WeatherStore, WeatherStoreConfigurationError +from astrape_dashboard.classes.displays.weather import WeatherDisplay +from astrape_dashboard.classes.displays.station import WeatherStationDisplay +from astrape_dashboard.classes.displays.oracle import OracleDisplay +from astrape_dashboard.classes.displays.oracle_quality import OracleQualityDisplay + + +class WebUI: + """Composes Astrape web modules into a small control panel.""" + + def __init__(self) -> None: + self.weather_display = WeatherDisplay() + self.weather_station_display = WeatherStationDisplay() + self.oracle_display = OracleDisplay() + self.oracle_quality_display = OracleQualityDisplay() + + def render_page(self, page: str = "oracle") -> str: + current_page = ( + page + if page in {"oracle", "weather", "weather_station", "quality"} + else "oracle" + ) + return f""" + + + + + Astrape + + + +
+
+

Astrape

+

Gibil control panel

+
+ {self._nav(current_page)} +
+
+ {self._page_body(current_page)} +
+ + + """ + + def _nav(self, current_page: str) -> str: + pages = [ + ("oracle", "/oracle", "Oracle"), + ("weather", "/weather", "Weather"), + ("weather_station", "/weather-station", "Station"), + ("quality", "/quality", "Quality"), + ] + links = [ + f'{label}' + for key, href, label in pages + ] + return f"" + + def _page_body(self, page: str) -> str: + if page == "weather": + return self.weather_display.render() + if page == "weather_station": + return self.weather_station_display.render() + if page == "quality": + return self.oracle_quality_display.render() + return self.oracle_display.render() + + def weather_payload(self) -> str: + EnvLoader().load() + if environ.get("ASTRAPE_WEB_SAMPLE_DATA") == "1": + return self.weather_display.data_payload(WeatherSampleData().build()) + + try: + dataset = WeatherStore.from_env().load_display_dataset() + except WeatherStoreConfigurationError: + dataset = None + + return self.weather_display.data_payload(dataset) + + def weather_station_payload(self, lookback_hours: float = 168) -> str: + EnvLoader().load() + try: + hours = min(2160, max(1, float(lookback_hours))) + except (TypeError, ValueError): + hours = 168 + + try: + now = datetime.now(timezone.utc) + dataset = WeatherStore.from_env().load_station_display_dataset( + start_at=now - timedelta(hours=hours), + end_at=now, + ) + except WeatherStoreConfigurationError: + dataset = None + + return self.weather_station_display.data_payload(dataset) + + def oracle_payload(self) -> str: + EnvLoader().load() + return self.oracle_display.data_payload() + + def oracle_quality_payload(self, lookback_hours: float = 168) -> str: + EnvLoader().load() + return self.oracle_quality_display.data_payload(lookback_hours=lookback_hours) diff --git a/astrape_dashboard/scripts/__init__.py b/astrape_dashboard/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/scripts/daemons/__init__.py b/astrape_dashboard/scripts/daemons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/scripts/daemons/web_daemon.py b/astrape_dashboard/scripts/daemons/web_daemon.py new file mode 100644 index 0000000..73692e8 --- /dev/null +++ b/astrape_dashboard/scripts/daemons/web_daemon.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib import import_module, reload +from os import environ +from pathlib import Path +import json +from urllib.parse import parse_qs, urlparse + +from astrape_ems.classes.env import EnvLoader + +EnvLoader().load() + +HOST = environ.get("ASTRAPE_WEB_HOST", "0.0.0.0") +PORT = int(environ.get("ASTRAPE_WEB_PORT", "8080")) +PROJECT_ROOT = Path(__file__).resolve().parents[2] +WATCHED_PATHS = [ + PROJECT_ROOT / "gibil" / "classes" / "models.py", + PROJECT_ROOT / "gibil" / "classes" / "webui.py", + PROJECT_ROOT / "gibil" / "classes" / "weather" / "display.py", + PROJECT_ROOT / "gibil" / "classes" / "weather" / "station_display.py", + PROJECT_ROOT / "gibil" / "classes" / "oracle" / "display.py", + PROJECT_ROOT / "gibil" / "classes" / "oracle" / "quality_display.py", + PROJECT_ROOT / "gibil" / "classes" / "weather" / "store.py", + PROJECT_ROOT / "gibil" / "classes" / "oracle" / "store.py", + PROJECT_ROOT / "gibil" / "classes" / "oracle" / "builder.py", + PROJECT_ROOT / "gibil" / "classes" / "oracle" / "config.py", + PROJECT_ROOT / "gibil" / "classes" / "sigen" / "store.py", +] + + +class AstrapeWebHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + + if path in {"/", "/oracle"}: + self._send_html(self._webui().render_page("oracle")) + return + + if path == "/weather": + self._send_html(self._webui().render_page("weather")) + return + + if path == "/weather-station": + self._send_html(self._webui().render_page("weather_station")) + return + + if path == "/quality": + self._send_html(self._webui().render_page("quality")) + return + + if path == "/api/weather": + self._send_json_text(self._webui().weather_payload()) + return + + if path == "/api/weather-station": + params = parse_qs(parsed.query) + lookback_hours = self._float_param(params, "lookback_hours", 168) + self._send_json_text( + self._webui().weather_station_payload( + lookback_hours=lookback_hours + ) + ) + return + + if path == "/api/oracle": + self._send_json_text(self._webui().oracle_payload()) + return + + if path == "/api/oracle-quality": + params = parse_qs(parsed.query) + lookback_hours = self._float_param(params, "lookback_hours", 168) + self._send_json_text( + self._webui().oracle_quality_payload( + lookback_hours=lookback_hours + ) + ) + return + + if path == "/api/ui-version": + self._send_json_text(json.dumps({"version": self._ui_version()})) + return + + self.send_error(404) + + def log_message(self, format: str, *args: object) -> None: + print(f"{self.address_string()} - {format % args}") + + def _webui(self): + models_module = import_module("gibil.classes.models") + weather_store_module = import_module("astrape_oracle.classes.weather.store") + sigen_store_module = import_module("astrape_ems.classes.store") + oracle_store_module = import_module("astrape_oracle.classes.oracle_store") + oracle_config_module = import_module("gibil.classes.forecast_config") + oracle_builder_module = import_module("gibil.classes.oracle_builder") + oracle_display_module = import_module("astrape_dashboard.classes.displays.oracle") + oracle_quality_display_module = import_module( + "astrape_dashboard.classes.displays.oracle_quality" + ) + weather_display_module = import_module("astrape_oracle.classes.datasets") + weather_station_display_module = import_module( + "astrape_oracle.classes.datasets" + ) + webui_module = import_module("astrape_dashboard.classes.webui") + reload(models_module) + reload(weather_display_module) + reload(weather_station_display_module) + reload(weather_store_module) + reload(sigen_store_module) + reload(oracle_store_module) + reload(oracle_config_module) + reload(oracle_builder_module) + reload(oracle_display_module) + reload(oracle_quality_display_module) + reload(webui_module) + return webui_module.WebUI() + + def _float_param( + self, + params: dict[str, list[str]], + key: str, + default: float, + ) -> float: + values = params.get(key) + if not values: + return default + try: + return float(values[0]) + except ValueError: + return default + + def _ui_version(self) -> str: + mtimes = [ + str(path.stat().st_mtime_ns) + for path in WATCHED_PATHS + if path.exists() + ] + return ".".join(mtimes) + + def _send_html(self, body: str) -> None: + encoded = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_json_text(self, body: str) -> None: + encoded = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +def main() -> None: + server = ThreadingHTTPServer((HOST, PORT), AstrapeWebHandler) + print(f"Astrape web UI listening on http://{HOST}:{PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/astrape_dashboard/scripts/debug/__init__.py b/astrape_dashboard/scripts/debug/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astrape_dashboard/scripts/debug/debug_weather.py b/astrape_dashboard/scripts/debug/debug_weather.py new file mode 100644 index 0000000..8417a42 --- /dev/null +++ b/astrape_dashboard/scripts/debug/debug_weather.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Debug script to trace resolved truth data.""" + +from astrape_ems.classes.env import EnvLoader +from astrape_oracle.classes.weather.store import WeatherStore +from astrape_dashboard.classes.displays.weather import WeatherDisplay +from datetime import datetime, timezone + +EnvLoader().load() + +store = WeatherStore.from_env() +dataset = store.load_display_dataset() + +print(f"\n=== DEBUG OUTPUT ===") +print(f"Forecast points: {len(dataset.forecast_points)}") +print(f"Resolved truth points: {len(dataset.resolved_truth)}") + +print(f"\nResolved truth details:") +for i, point in enumerate(dataset.resolved_truth): + print(f" [{i}] resolved_at={point.resolved_at}, temp={point.temperature_c}, radiation={point.shortwave_radiation_w_m2}") + +print(f"\nAPI payload:") +display = WeatherDisplay() +payload = display.data_payload(dataset) +import json +data = json.loads(payload) +print(f" Resolved truth in payload: {len(data['resolved_truth'])}") +for i, point in enumerate(data['resolved_truth']): + print(f" [{i}] resolved_at={point['resolved_at']}, temp={point['temperature_c']}") + +print(f"\n=== END DEBUG ===\n") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a3b2bf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "astrape-dashboard" +version = "0.1.0" +description = "Astrape web frontend" +requires-python = ">=3.12" +dependencies = ["gibil", "astrape-ems", "astrape-oracle"] + +[tool.setuptools.packages.find] +include = ["astrape_dashboard*"]