first_commit

This commit is contained in:
rpotter6298
2026-04-25 20:35:25 +02:00
commit 9d15860f0b
22 changed files with 2254 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from os import environ
from sys import stderr
from time import sleep
from gibil.classes.env_loader import EnvLoader
from gibil.classes.weather_builder import (
OpenMeteoArchiveClient,
OpenMeteoClient,
WeatherBuilder,
)
from gibil.classes.weather_store import WeatherStore
@dataclass(frozen=True)
class DbDaemonConfig:
latitude: float
longitude: float
forecast_hours: int
truth_lookback_days: int
truth_end_delay_days: int
poll_seconds: int
@classmethod
def from_env(cls) -> "DbDaemonConfig":
return cls(
latitude=float(_required_env("ASTRAPE_LATITUDE")),
longitude=float(_required_env("ASTRAPE_LONGITUDE")),
forecast_hours=int(environ.get("ASTRAPE_WEATHER_FORECAST_HOURS", "48")),
truth_lookback_days=int(
environ.get("ASTRAPE_WEATHER_TRUTH_LOOKBACK_DAYS", "14")
),
truth_end_delay_days=int(
environ.get("ASTRAPE_WEATHER_TRUTH_END_DELAY_DAYS", "5")
),
poll_seconds=int(environ.get("ASTRAPE_WEATHER_POLL_SECONDS", "3600")),
)
class DbDaemon:
"""Runs builder components that populate Astrape's database."""
def __init__(
self,
config: DbDaemonConfig,
weather_client: OpenMeteoClient,
archive_client: OpenMeteoArchiveClient,
weather_builder: WeatherBuilder,
weather_store: WeatherStore,
) -> None:
self.config = config
self.weather_client = weather_client
self.archive_client = archive_client
self.weather_builder = weather_builder
self.weather_store = weather_store
@classmethod
def from_env(cls) -> "DbDaemon":
return cls(
config=DbDaemonConfig.from_env(),
weather_client=OpenMeteoClient(),
archive_client=OpenMeteoArchiveClient(),
weather_builder=WeatherBuilder(),
weather_store=WeatherStore.from_env(),
)
def initialize(self) -> None:
self.weather_store.initialize()
def run_once(self) -> tuple[int, int]:
raw_run = self.weather_client.fetch_forecast(
latitude=self.config.latitude,
longitude=self.config.longitude,
forecast_hours=self.config.forecast_hours,
)
forecast_run = self.weather_builder.build_forecast_run(
source=raw_run.source,
latitude=raw_run.latitude,
longitude=raw_run.longitude,
points=raw_run.points,
issued_at=raw_run.issued_at,
)
forecast_count = self.weather_store.save_forecast_run(forecast_run)
zero_hour_truth_count = self.weather_store.save_zero_hour_forecast_as_truth(
forecast_run
)
today = datetime.now(timezone.utc).date()
truth_end = today - timedelta(days=self.config.truth_end_delay_days)
truth_start = truth_end - timedelta(days=self.config.truth_lookback_days)
truth_points = self.archive_client.fetch_resolved_truth(
latitude=self.config.latitude,
longitude=self.config.longitude,
start_date=truth_start,
end_date=truth_end,
)
archive_truth_count = self.weather_store.save_resolved_truth(truth_points)
return forecast_count, zero_hour_truth_count + archive_truth_count
def run_forever(self) -> None:
self.initialize()
while True:
forecast_count, truth_count = self.run_once()
print(
f"stored_weather_forecast_points={forecast_count} "
f"stored_weather_resolved_truth={truth_count}",
flush=True,
)
sleep(self.config.poll_seconds)
def main() -> None:
try:
EnvLoader().load()
daemon = DbDaemon.from_env()
daemon.run_forever()
except Exception as error:
print(f"db_daemon_startup_error={error}", file=stderr)
raise SystemExit(1) from error
def _required_env(name: str) -> str:
value = environ.get(name)
if not value:
raise RuntimeError(
f"{name} is required. Set ASTRAPE_DATABASE_URL, "
"ASTRAPE_LATITUDE, and ASTRAPE_LONGITUDE before starting db_daemon."
)
return value
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
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 gibil.classes.env_loader 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" / "webui.py",
PROJECT_ROOT / "gibil" / "classes" / "weather_display.py",
PROJECT_ROOT / "gibil" / "classes" / "weather_store.py",
]
class AstrapeWebHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/":
self._send_html(self._webui().render_page())
return
if self.path == "/api/weather":
self._send_json_text(self._webui().weather_payload())
return
if self.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):
weather_store_module = import_module("gibil.classes.weather_store")
weather_display_module = import_module("gibil.classes.weather_display")
webui_module = import_module("gibil.classes.webui")
reload(weather_store_module)
reload(weather_display_module)
reload(webui_module)
return webui_module.WebUI()
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()