Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/quartz_api/internal/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
)
from .endpoint_types import (
ActualPower,
ForecastActualComparison,
ForecastHorizon,
PredictedPower,
SiteProperties,
Expand Down
28 changes: 28 additions & 0 deletions src/quartz_api/internal/models/endpoint_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
96 changes: 96 additions & 0 deletions src/quartz_api/internal/service/regions/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)