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