From 0094077f7df72c36f6ff9c8d169aaa98f85ed09f Mon Sep 17 00:00:00 2001 From: prashantpandeygit Date: Thu, 1 Jan 2026 21:19:10 +0530 Subject: [PATCH 1/2] feat: add forecast vs actual comparison analysis endpoint --- src/quartz_api/internal/models/__init__.py | 1 + .../internal/models/endpoint_types.py | 28 ++++++ .../internal/service/regions/router.py | 96 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/src/quartz_api/internal/models/__init__.py b/src/quartz_api/internal/models/__init__.py index eb3cd439..90718d99 100644 --- a/src/quartz_api/internal/models/__init__.py +++ b/src/quartz_api/internal/models/__init__.py @@ -7,6 +7,7 @@ ) from .endpoint_types import ( ActualPower, + ForecastActualComparison, ForecastHorizon, PredictedPower, SiteProperties, diff --git a/src/quartz_api/internal/models/endpoint_types.py b/src/quartz_api/internal/models/endpoint_types.py index 431754bc..1740ee5c 100644 --- a/src/quartz_api/internal/models/endpoint_types.py +++ b/src/quartz_api/internal/models/endpoint_types.py @@ -53,6 +53,34 @@ def to_timezone(self, tz: str) -> "ActualPower": Time=self.Time.astimezone(tz=ZoneInfo(key=tz)), ) + +class ForecastActualComparison(BaseModel): + """Comparison of forecast vs actual power values.""" + + time: dt.datetime + forecast_power_kw: float + actual_power_kw: float | None = None + error_kw: float | None = None + absolute_error_kw: float | None = None + percent_error: float | None = None + forecast_created_time: dt.datetime | None = None + + def to_timezone(self, tz: str) -> "ForecastActualComparison": + """Convert time to specific timezone""" + return ForecastActualComparison( + time=self.time.astimezone(tz=ZoneInfo(key=tz)), + forecast_power_kw=self.forecast_power_kw, + actual_power_kw=self.actual_power_kw, + error_kw=self.error_kw, + absolute_error_kw=self.absolute_error_kw, + percent_error=self.percent_error, + forecast_created_time=( + self.forecast_created_time.astimezone(tz=ZoneInfo(key=tz)) + if self.forecast_created_time + else None + ), + ) + class LocationPropertiesBase(BaseModel): """Properties common to all locations.""" diff --git a/src/quartz_api/internal/service/regions/router.py b/src/quartz_api/internal/service/regions/router.py index 72d5da32..349ea43e 100644 --- a/src/quartz_api/internal/service/regions/router.py +++ b/src/quartz_api/internal/service/regions/router.py @@ -239,3 +239,99 @@ async def get_forecast_csv( headers={"Content-Disposition": f"attachment;filename={csv_file_path}"}, ) + +class GetForecastVsActualResponse(BaseModel): + """Response model for forecast vs actual""" + + comparisons: list[models.ForecastActualComparison] + summary: dict[str, float] | None = None + + +@router.get( + "/{source}/{region}/forecast/vs-actual", + status_code=status.HTTP_200_OK, +) +async def get_forecast_vs_actual( + source: ValidSource, + region: str, + db: models.DBClientDependency, + auth: AuthDependency, + tz: models.TZDependency, + forecast_horizon_minutes: int | None = None, + include_summary: bool = False, +) -> GetForecastVsActualResponse: + """get forecast and actual values at once""" + forecast_response = await get_forecast_timeseries_route( + source=source, + region=region, + db=db, + auth=auth, + tz=tz, + forecast_horizon=( + models.ForecastHorizon.horizon + if forecast_horizon_minutes + else models.ForecastHorizon.latest + ), + forecast_horizon_minutes=forecast_horizon_minutes, + smooth_flag=False, + ) + + generation_response = await get_historic_timeseries_route( + source=source, + region=region, + db=db, + auth=auth, + tz=tz, + ) + + now = dt.datetime.now(tz=dt.UTC) + forecast_values = [f for f in forecast_response.values if f.Time < now] + actuals_by_time = { + a.Time.replace(second=0, microsecond=0): a + for a in generation_response.values + } + + comparisons: list[models.ForecastActualComparison] = [] + for forecast in forecast_values: + rounded_time = forecast.Time.replace(second=0, microsecond=0) + actual = actuals_by_time.get(rounded_time) + + error_kw = None + if actual is not None: + error_kw = forecast.PowerKW - actual.PowerKW + + comparisons.append( + models.ForecastActualComparison( + time=forecast.Time, + forecast_power_kw=forecast.PowerKW, + actual_power_kw=actual.PowerKW if actual else None, + error_kw=error_kw, + absolute_error_kw=abs(error_kw) if error_kw is not None else None, + percent_error=( + (error_kw / actual.PowerKW * 100) + if actual and actual.PowerKW > 0 + else None + ), + forecast_created_time=forecast.CreatedTime, + ).to_timezone(tz=tz), + ) + + comparisons.sort(key=lambda x: x.time) + + summary = None + if include_summary: + valid = [c for c in comparisons if c.error_kw is not None] + if valid: + errors = [c.error_kw for c in valid] + abs_errors = [abs(e) for e in errors] + n = len(errors) + summary = { + "mae_kw": sum(abs_errors) / n, + "me_kw": sum(errors) / n, + "rmse_kw": (sum(e**2 for e in errors) / n)**0.5, + "max_error_kw": max(abs_errors), + "min_error_kw": min(abs_errors), + "sample_count": n, + } + + return GetForecastVsActualResponse(comparisons=comparisons, summary=summary) From 86016cfa58eb5a9ade8344ed405f6ea4c41813ed Mon Sep 17 00:00:00 2001 From: prashantpandeygit Date: Fri, 2 Jan 2026 00:09:12 +0530 Subject: [PATCH 2/2] edit docstring --- src/quartz_api/internal/models/endpoint_types.py | 2 +- src/quartz_api/internal/service/regions/router.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/quartz_api/internal/models/endpoint_types.py b/src/quartz_api/internal/models/endpoint_types.py index 1740ee5c..e0590d26 100644 --- a/src/quartz_api/internal/models/endpoint_types.py +++ b/src/quartz_api/internal/models/endpoint_types.py @@ -66,7 +66,7 @@ class ForecastActualComparison(BaseModel): forecast_created_time: dt.datetime | None = None def to_timezone(self, tz: str) -> "ForecastActualComparison": - """Convert time to specific timezone""" + """Convert time to specific timezone.""" return ForecastActualComparison( time=self.time.astimezone(tz=ZoneInfo(key=tz)), forecast_power_kw=self.forecast_power_kw, diff --git a/src/quartz_api/internal/service/regions/router.py b/src/quartz_api/internal/service/regions/router.py index 349ea43e..60a9ae0d 100644 --- a/src/quartz_api/internal/service/regions/router.py +++ b/src/quartz_api/internal/service/regions/router.py @@ -241,7 +241,7 @@ async def get_forecast_csv( class GetForecastVsActualResponse(BaseModel): - """Response model for forecast vs actual""" + """Response model for forecast vs actual.""" comparisons: list[models.ForecastActualComparison] summary: dict[str, float] | None = None @@ -260,7 +260,7 @@ async def get_forecast_vs_actual( forecast_horizon_minutes: int | None = None, include_summary: bool = False, ) -> GetForecastVsActualResponse: - """get forecast and actual values at once""" + """Get forecast and actual values at once.""" forecast_response = await get_forecast_timeseries_route( source=source, region=region,