diff --git a/.github/workflows/branch_ci.yml b/.github/workflows/branch_ci.yml index 96c9079..071a33b 100644 --- a/.github/workflows/branch_ci.yml +++ b/.github/workflows/branch_ci.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Branch Push CI (Python) on: diff --git a/.github/workflows/bump_tag.yml b/.github/workflows/bump_tag.yml index c426aed..96cb93c 100644 --- a/.github/workflows/bump_tag.yml +++ b/.github/workflows/bump_tag.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Merged CI run-name: 'Bump tag with merge #${{ github.event.number }} "${{ github.event.pull_request.title }}"' @@ -9,4 +11,4 @@ on: jobs: bump-tag: uses: openclimatefix/.github/.github/workflows/bump_tag.yml@main - secrets: inherit \ No newline at end of file + secrets: inherit diff --git a/.github/workflows/tagged_ci.yml b/.github/workflows/tagged_ci.yml index 054e718..a186891 100644 --- a/.github/workflows/tagged_ci.yml +++ b/.github/workflows/tagged_ci.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow + name: Tagged CI run-name: 'Tagged CI for ${{ github.ref_name }} by ${{ github.actor }}' @@ -11,4 +13,4 @@ jobs: secrets: inherit with: containerfile: Containerfile - enable_pypi: false \ No newline at end of file + enable_pypi: false diff --git a/pyproject.toml b/pyproject.toml index 0d9c247..333bf90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dev = [ ] [tool.uv.sources] -dp-sdk = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.14.0/dp_sdk-0.14.0-py3-none-any.whl" } +dp-sdk = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.16.0/dp_sdk-0.16.0-py3-none-any.whl" } [project.urls] repository = "https://github.com/openclimatefix/quartz-api" diff --git a/src/quartz_api/cmd/main.py b/src/quartz_api/cmd/main.py index adbd26c..928e417 100644 --- a/src/quartz_api/cmd/main.py +++ b/src/quartz_api/cmd/main.py @@ -27,10 +27,14 @@ ``` """ +import functools +import importlib +import importlib.metadata import logging import pathlib import sys -from importlib.metadata import version +from collections.abc import Generator +from contextlib import asynccontextmanager from typing import Any import uvicorn @@ -40,14 +44,13 @@ from fastapi.openapi.utils import get_openapi from grpclib.client import Channel from pydantic import BaseModel -from pyhocon import ConfigFactory +from pyhocon import ConfigFactory, ConfigTree from starlette.responses import FileResponse from starlette.staticfiles import StaticFiles +from quartz_api.internal import models, service from quartz_api.internal.backends import DataPlatformClient, DummyClient, QuartzClient from quartz_api.internal.middleware import audit, auth -from quartz_api.internal.models import DatabaseInterface, get_db_client -from quartz_api.internal.service import regions, sites log = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -59,6 +62,7 @@ class GetHealthResponse(BaseModel): status: int + def _custom_openapi(server: FastAPI) -> dict[str, Any]: """Customize the OpenAPI schema for ReDoc.""" if server.openapi_schema: @@ -87,20 +91,51 @@ def _custom_openapi(server: FastAPI) -> dict[str, Any]: return openapi_schema -def run() -> None: - """Run the API using a uvicorn server.""" - # Get the application configuration from the environment - conf = ConfigFactory.parse_file((pathlib.Path(__file__).parent / "server.conf").as_posix()) +@asynccontextmanager +async def _lifespan(server: FastAPI, conf: ConfigTree) -> Generator[None]: + """Configure FastAPI app instance with startup and shutdown events.""" + db_instance: models.DatabaseInterface | None = None + grpc_channel: Channel | None = None - # Create the FastAPI server + match conf.get_string("backend.source"): + case "quartzdb": + db_instance = QuartzClient( + database_url=conf.get_string("backend.quartzdb.database_url"), + ) + case "dummydb": + db_instance = DummyClient() + log.warning("disabled backend. NOT recommended for production") + case "dataplatform": + grpc_channel = Channel( + host=conf.get_string("backend.dataplatform.host"), + port=conf.get_int("backend.dataplatform.port"), + ) + client = dp.DataPlatformDataServiceStub(channel=grpc_channel) + db_instance = DataPlatformClient.from_dp(dp_client=client) + case _ as backend_type: + raise ValueError(f"Unknown backend: {backend_type}") + + server.dependency_overrides[models.get_db_client] = lambda: db_instance + + yield + + if grpc_channel: + grpc_channel.close() + + +def _create_server(conf: ConfigTree) -> FastAPI: + """Configure FastAPI app instance with routes, dependencies, and middleware.""" server = FastAPI( - version=version("quartz_api"), + version=importlib.metadata.version("quartz_api"), + lifespan=functools.partial(_lifespan, conf=conf), title="Quartz API", description=__doc__, - openapi_tags=[{ - "name": "API Information", - "description": "Routes providing information about the API.", - }], + openapi_tags=[ + { + "name": "API Information", + "description": "Routes providing information about the API.", + }, + ], docs_url="/swagger", redoc_url=None, ) @@ -126,14 +161,33 @@ def redoc_html() -> FileResponse: # Setup sentry, if configured if conf.get_string("sentry.dsn") != "": import sentry_sdk + sentry_sdk.init( dsn=conf.get_string("sentry.dsn"), environment=conf.get_string("sentry.environment"), traces_sample_rate=1, ) - sentry_sdk.set_tag("app_name", "quartz_api") - sentry_sdk.set_tag("version", version("quartz_api")) + sentry_sdk.set_tag("server_name", "quartz_api") + sentry_sdk.set_tag("version", importlib.metadata.version("quartz_api")) + + # Add routers to the server according to configuration + for r in conf.get_string("api.routers").split(","): + try: + mod = importlib.import_module(service.__name__ + f".{r}") + server.include_router(mod.router) + except ModuleNotFoundError as e: + raise OSError(f"No such router router '{r}'") from e + server.openapi_tags = [ + { + "name": mod.__name__.split(".")[-1].capitalize(), + "description": mod.__doc__, + }, + *server.openapi_tags, + ] + + # Customize the OpenAPI schema + server.openapi = lambda: _custom_openapi(server) # Override dependencies according to configuration match (conf.get_string("auth0.domain"), conf.get_string("auth0.audience")): @@ -149,30 +203,8 @@ def redoc_html() -> FileResponse: case _: raise ValueError("Invalid Auth0 configuration") - db_instance: DatabaseInterface - match conf.get_string("backend.source"): - case "quartzdb": - db_instance = QuartzClient( - database_url=conf.get_string("backend.quartzdb.database_url"), - ) - case "dummydb": - db_instance = DummyClient() - log.warning("disabled backend. NOT recommended for production") - case "dataplatform": - - channel = Channel( - host=conf.get_string("backend.dataplatform.host"), - port=conf.get_int("backend.dataplatform.port"), - ) - client = dp.DataPlatformDataServiceStub(channel=channel) - db_instance = DataPlatformClient.from_dp(dp_client=client) - case _: - raise ValueError( - "Unknown backend. " - f"Expected one of {list(conf.get('backend').keys())}", - ) - - server.dependency_overrides[get_db_client] = lambda: db_instance + timezone: str = conf.get_string("api.timezone") + server.dependency_overrides[models.get_timezone] = lambda: timezone # Add middlewares server.add_middleware( @@ -182,28 +214,22 @@ def redoc_html() -> FileResponse: allow_methods=["*"], allow_headers=["*"], ) - server.add_middleware( - audit.RequestLoggerMiddleware, - db_client=db_instance, - ) + server.add_middleware(audit.RequestLoggerMiddleware) - # Add routers to the server according to configuration - for router_module in [sites, regions]: - if conf.get_string("api.router") == router_module.__name__.split(".")[-1]: - server.include_router(router_module.router) - server.openapi_tags = [{ - "name": router_module.__name__.split(".")[-1].capitalize(), - "description": router_module.__doc__, - }, *server.openapi_tags] - break + return server - # Customize the OpenAPI schema - server.openapi = lambda: _custom_openapi(server) + +def run() -> None: + """Run the API using a uvicorn server.""" + # Get the application configuration from the environment + conf = ConfigFactory.parse_file((pathlib.Path(__file__).parent / "server.conf").as_posix()) + + server = _create_server(conf=conf) # Run the server with uvicorn uvicorn.run( server, - host="0.0.0.0", # noqa: S104 + host="0.0.0.0", # noqa: S104 port=conf.get_int("api.port"), log_level=conf.get_string("api.loglevel"), ) @@ -211,3 +237,4 @@ def redoc_html() -> FileResponse: if __name__ == "__main__": run() + diff --git a/src/quartz_api/cmd/server.conf b/src/quartz_api/cmd/server.conf index 7c539b9..bc2ec3c 100644 --- a/src/quartz_api/cmd/server.conf +++ b/src/quartz_api/cmd/server.conf @@ -7,14 +7,19 @@ api { port = ${?PORT} loglevel = "debug" loglevel = ${?LOGLEVEL} - // Which router to serve requests through - router = "none" - router = ${?ROUTER} + // Comma seperated list of routers to enable + // Supported routers: "sites", "regions", "substations" + routers = "" + routers = ${?ROUTERS} origins = "*" origins = ${?ORIGINS} + // The IANA timezone string to use for date/time operations + timezone = "UTC" + timezone = ${?TZ} } // The backend to use for the service +// Supported backends: "dummydb", "quartzdb", "dataplatform" backend { source = "dummydb" source = ${?SOURCE} diff --git a/src/quartz_api/internal/__init__.py b/src/quartz_api/internal/__init__.py index 5bc1ab6..8b13789 100644 --- a/src/quartz_api/internal/__init__.py +++ b/src/quartz_api/internal/__init__.py @@ -1,10 +1 @@ -from .models import ( - ActualPower, - PredictedPower, - Site, - SiteProperties, - DBClientDependency, - ForecastHorizon, - DatabaseInterface, - get_db_client -) + diff --git a/src/quartz_api/internal/backends/dataplatform/client.py b/src/quartz_api/internal/backends/dataplatform/client.py index d4108ba..516a9a2 100644 --- a/src/quartz_api/internal/backends/dataplatform/client.py +++ b/src/quartz_api/internal/backends/dataplatform/client.py @@ -2,18 +2,20 @@ import datetime as dt import logging +from uuid import UUID from dp_sdk.ocf import dp from fastapi import HTTPException from typing_extensions import override -from quartz_api import internal -from quartz_api.internal.models import ForecastHorizon +from quartz_api.internal import models from ..utils import get_window +log = logging.getLogger("dataplatform.client") -class Client(internal.DatabaseInterface): + +class Client(models.DatabaseInterface): """Defines a data platform interface that conforms to the DatabaseInterface.""" dp_client: dp.DataPlatformDataServiceStub @@ -29,12 +31,12 @@ def from_dp(cls, dp_client: dp.DataPlatformDataServiceStub) -> "Client": async def get_predicted_solar_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: values = await self._get_predicted_power_production_for_location( - location=location, + location_uuid=UUID(location), energy_source=dp.EnergySource.SOLAR, forecast_horizon=forecast_horizon, forecast_horizon_minutes=forecast_horizon_minutes, @@ -47,12 +49,12 @@ async def get_predicted_solar_power_production_for_location( async def get_predicted_wind_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: values = await self._get_predicted_power_production_for_location( - location=location, + location_uuid=UUID(location), energy_source=dp.EnergySource.WIND, forecast_horizon=forecast_horizon, forecast_horizon_minutes=forecast_horizon_minutes, @@ -65,9 +67,9 @@ async def get_predicted_wind_power_production_for_location( async def get_actual_solar_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: values = await self._get_actual_power_production_for_location( - location, + UUID(location), dp.EnergySource.SOLAR, oauth_id=None, ) @@ -77,9 +79,9 @@ async def get_actual_solar_power_production_for_location( async def get_actual_wind_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: values = await self._get_actual_power_production_for_location( - location, + UUID(location), dp.EnergySource.WIND, oauth_id=None, ) @@ -104,7 +106,7 @@ async def get_solar_regions(self) -> list[str]: return [loc.location_uuid for loc in resp.locations] @override - async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: + async def get_sites(self, authdata: dict[str, str]) -> list[models.Site]: req = dp.ListLocationsRequest( energy_source_filter=dp.EnergySource.SOLAR, location_type_filter=dp.LocationType.SITE, @@ -112,7 +114,7 @@ async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: ) resp = await self.dp_client.list_locations(req) return [ - internal.Site( + models.Site( site_uuid=loc.location_uuid, client_site_name=loc.location_name, orientation=loc.metadata.fields["orientation"].number_value @@ -131,18 +133,18 @@ async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: @override async def put_site( self, - site_uuid: str, - site_properties: internal.SiteProperties, + site_uuid: UUID, + site_properties: models.SiteProperties, authdata: dict[str, str], - ) -> internal.Site: + ) -> models.Site: raise NotImplementedError("Data Platform client doesn't yet support site writing.") @override async def get_site_forecast( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: forecast = await self._get_predicted_power_production_for_location( site_uuid, dp.EnergySource.SOLAR, @@ -153,9 +155,9 @@ async def get_site_forecast( @override async def get_site_generation( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: generation = await self._get_actual_power_production_for_location( site_uuid, dp.EnergySource.SOLAR, @@ -166,27 +168,134 @@ async def get_site_generation( @override async def post_site_generation( self, - site_uuid: str, - generation: list[internal.ActualPower], + site_uuid: UUID, + generation: list[models.ActualPower], authdata: dict[str, str], ) -> None: raise NotImplementedError("Data Platform client doesn't yet support site writing.") @override async def save_api_call_to_db(self, url: str, authdata: dict[str, str]) -> None: - logging.warning("Data Platform client does not support logging API calls to DB.") + log.warning("Data Platform client does not support logging API calls to DB.") pass + @override + async def get_substations( + self, + authdata: dict[str, str], + ) -> list[models.Substation]: + req = dp.ListLocationsRequest( + energy_source_filter=dp.EnergySource.SOLAR, + location_type_filter=dp.LocationType.PRIMARY_SUBSTATION, + user_oauth_id_filter=authdata["sub"], + ) + resp = await self.dp_client.list_locations(req) + return [ + models.Substation( + substation_uuid=loc.location_uuid, + substation_name=loc.location_name, + substation_type="primary" + if loc.location_type == dp.LocationType.PRIMARY_SUBSTATION + else "secondary", + capacity_kw=loc.effective_capacity_watts // 1000.0, + latitude=loc.latlng.latitude, + longitude=loc.latlng.longitude, + ) + for loc in resp.locations + ] + + @override + async def get_substation_forecast( + self, + substation_uuid: UUID, + authdata: dict[str, str], + ) -> list[models.PredictedPower]: + # Get the substation + req = dp.ListLocationsRequest( + location_uuids_filter=[substation_uuid], + energy_source_filter=dp.EnergySource.SOLAR, + location_type_filter=dp.LocationType.PRIMARY_SUBSTATION, + user_oauth_id_filter=authdata["sub"], + ) + resp = await self.dp_client.list_locations(req) + if len(resp.locations) == 0: + raise HTTPException( + status_code=404, + detail=f"No substation found for UUID '{substation_uuid}'", + ) + substation = resp.locations[0] + + # Get the GSP that the substation belongs to + req = dp.ListLocationsRequest( + enclosed_location_uuid_filter=[substation_uuid], + location_type_filter=dp.LocationType.GSP, + user_oauth_id_filter=authdata["sub"], + ) + gsps = await self.dp_client.list_locations(req) + if len(gsps.locations) == 0: + raise HTTPException( + status_code=404, + detail=f"No GSP found for substation UUID '{substation_uuid}'", + ) + gsp = gsps.locations[0] + forecast = await self._get_predicted_power_production_for_location( + gsp.location_uuid, + dp.EnergySource.SOLAR, + authdata["sub"], + ) + + # Scale the forecast to the substation capacity + scale_factor: float = substation.effective_capacity_watts / gsp.effective_capacity_watts + for value in forecast: + value.PowerKW = value.PowerKW * scale_factor + + log.debug( + "gsp=%s, substation=%s, scalefactor=%s, scaling GSP to substation", + gsp.location_uuid, + substation.location_uuid, + scale_factor, + ) + + return forecast + + @override + async def get_substation( + self, + location_uuid: UUID, + authdata: dict[str, str], + ) -> models.SubstationProperties: + req = dp.ListLocationsRequest( + location_uuids_filter=[location_uuid], + energy_source_filter=dp.EnergySource.SOLAR, + user_oauth_id_filter=authdata["sub"], + ) + resp = await self.dp_client.list_locations(req) + if len(resp.locations) == 0: + raise HTTPException( + status_code=404, + detail=f"No substation found for UUID '{location_uuid}'", + ) + loc = resp.locations[0] + + return models.SubstationProperties( + substation_name=loc.location_name, + substation_type="primary", + capacity_kw=loc.effective_capacity_watts // 1000.0, + latitude=loc.latlng.latitude, + longitude=loc.latlng.longitude, + ) + + async def _get_actual_power_production_for_location( self, - location: str, + location_uuid: UUID, energy_source: dp.EnergySource, oauth_id: str | None, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: """Local function to retrieve actual values regardless of energy type.""" if oauth_id is not None: await self._check_user_access( - location, + location_uuid, energy_source, dp.LocationType.SITE, oauth_id, @@ -194,7 +303,7 @@ async def _get_actual_power_production_for_location( start, end = get_window() req = dp.GetObservationsAsTimeseriesRequest( - location_uuid=location, + location_uuid=location_uuid, observer_name="ruvnl", energy_source=energy_source, time_window=dp.TimeWindow( @@ -203,8 +312,8 @@ async def _get_actual_power_production_for_location( ), ) resp = await self.dp_client.get_observations_as_timeseries(req) - out: list[internal.ActualPower] = [ - internal.ActualPower( + out: list[models.ActualPower] = [ + models.ActualPower( Time=value.timestamp_utc, PowerKW=int(value.effective_capacity_watts * value.value_fraction / 1000.0), ) @@ -215,17 +324,17 @@ async def _get_actual_power_production_for_location( async def _get_predicted_power_production_for_location( self, - location: str, + location_uuid: UUID, energy_source: dp.EnergySource, oauth_id: str | None, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, - smooth_flag: bool = True, # noqa: ARG002 - ) -> list[internal.PredictedPower]: + smooth_flag: bool = True, # noqa: ARG002 + ) -> list[models.PredictedPower]: """Local function to retrieve predicted values regardless of energy type.""" if oauth_id is not None: _ = await self._check_user_access( - location, + location_uuid, energy_source, dp.LocationType.SITE, oauth_id, @@ -233,9 +342,9 @@ async def _get_predicted_power_production_for_location( start, end = get_window() - if forecast_horizon == ForecastHorizon.latest or forecast_horizon_minutes is None: + if forecast_horizon == models.ForecastHorizon.latest or forecast_horizon_minutes is None: forecast_horizon_minutes = 0 - elif forecast_horizon == ForecastHorizon.day_ahead: + elif forecast_horizon == models.ForecastHorizon.day_ahead: # The intra-day forecast caps out at 8 hours horizon, so anything greater than that is # assumed to be day-ahead. It doesn't seem like it's as simple as just using 24 hours, # from my asking around at least @@ -245,7 +354,7 @@ async def _get_predicted_power_production_for_location( # taking into account the desired horizon. # * At some point, we may want to allow the user to specify a particular forecaster. req = dp.GetLatestForecastsRequest( - location_uuid=location, + location_uuid=location_uuid, energy_source=energy_source, pivot_timestamp_utc=start - dt.timedelta(minutes=forecast_horizon_minutes), ) @@ -259,7 +368,7 @@ async def _get_predicted_power_production_for_location( forecaster = resp.forecasts[0].forecaster req = dp.GetForecastAsTimeseriesRequest( - location_uuid=location, + location_uuid=location_uuid, energy_source=energy_source, horizon_mins=forecast_horizon_minutes, time_window=dp.TimeWindow( @@ -270,8 +379,8 @@ async def _get_predicted_power_production_for_location( ) resp = await self.dp_client.get_forecast_as_timeseries(req) - out: list[internal.PredictedPower] = [ - internal.PredictedPower( + out: list[models.PredictedPower] = [ + models.PredictedPower( Time=value.target_timestamp_utc, PowerKW=int(value.effective_capacity_watts * value.p50_value_fraction / 1000.0), CreatedTime=value.created_timestamp_utc, @@ -282,14 +391,14 @@ async def _get_predicted_power_production_for_location( async def _check_user_access( self, - location: str, + location_uuid: UUID, energy_source: dp.EnergySource, location_type: dp.LocationType, oauth_id: str, ) -> bool: """Check if a user has access to a given location.""" req = dp.ListLocationsRequest( - location_uuids_filter=[location], + location_uuids_filter=[location_uuid], energy_source_filter=energy_source, location_type_filter=location_type, user_oauth_id_filter=oauth_id, @@ -298,6 +407,6 @@ async def _check_user_access( if len(resp.locations) == 0: raise HTTPException( status_code=404, - detail=f"No location found for UUID {location} and OAuth ID {oauth_id}", + detail=f"No location found for UUID {location_uuid} and OAuth ID {oauth_id}", ) return True diff --git a/src/quartz_api/internal/backends/dataplatform/test_client.py b/src/quartz_api/internal/backends/dataplatform/test_client.py index 8e15b5e..b4389fe 100644 --- a/src/quartz_api/internal/backends/dataplatform/test_client.py +++ b/src/quartz_api/internal/backends/dataplatform/test_client.py @@ -14,28 +14,36 @@ def mock_list_locations(req: dp.ListLocationsRequest) -> dp.ListLocationsResponse: - if req.user_oauth_id_filter == "access_user": - return dp.ListLocationsResponse( - locations=[ - dp.ListLocationsResponseLocationSummary( - location_name="mock_location", - location_uuid=str(uuid.uuid4()), - energy_source=dp.EnergySource.SOLAR, - effective_capacity_watts=1e6, - location_type=dp.LocationType.SITE, - latlng=dp.LatLng(51.5, -0.1), - metadata=Struct( - fields={ - "orientation": Value(number_value=180.0), - "tilt": Value(number_value=30.0), - }, - ), - ), - ], - ) - else: + if req.user_oauth_id_filter != "access_user": return dp.ListLocationsResponse(locations=[]) + match req.location_type_filter: + case dp.LocationType.SITE: + capacity = 1e3 + case dp.LocationType.PRIMARY_SUBSTATION: + capacity = 1e5 + case _: + capacity = 1e6 + + return dp.ListLocationsResponse( + locations=[ + dp.ListLocationsResponseLocationSummary( + location_name="mock_location", + location_uuid=str(uuid.uuid4()), + energy_source=dp.EnergySource.SOLAR, + effective_capacity_watts=capacity, + location_type=req.location_type_filter, + latlng=dp.LatLng(51.5, -0.1), + metadata=Struct( + fields={ + "orientation": Value(number_value=180.0), + "tilt": Value(number_value=30.0), + }, + ), + ), + ], + ) + def mock_get_forecast( req: dp.GetForecastAsTimeseriesRequest, @@ -46,10 +54,10 @@ def mock_get_forecast( target_timestamp_utc=TEST_TIMESTAMP_UTC + dt.timedelta(hours=i), p50_value_fraction=0.5, effective_capacity_watts=1e6, - initialization_timestamp_utc=\ - TEST_TIMESTAMP_UTC - dt.timedelta(minutes=req.horizon_mins), - created_timestamp_utc=\ - TEST_TIMESTAMP_UTC - dt.timedelta(hours=1, minutes=req.horizon_mins), + initialization_timestamp_utc=TEST_TIMESTAMP_UTC + - dt.timedelta(minutes=req.horizon_mins), + created_timestamp_utc=TEST_TIMESTAMP_UTC + - dt.timedelta(hours=1, minutes=req.horizon_mins), other_statistics_fractions={"p90": 0.9, "p10": 0.1}, metadata=Struct(fields={}), ) @@ -78,18 +86,21 @@ def mock_get_latest_forecasts( ) -> dp.GetLatestForecastsResponse: t = req.pivot_timestamp_utc - dt.timedelta(hours=1) forecaster_name = f"mock_forecaster_{t.day}{t.hour}" - return dp.GetLatestForecastsResponse(forecasts=[ - dp.GetLatestForecastsResponseForecast( - initialization_timestamp_utc=t, - created_timestamp_utc=t - dt.timedelta(hours=1), - forecaster=dp.Forecaster(forecaster_name, forecaster_version="1.0"), - location_uuid=req.location_uuid, - ), - ]) + return dp.GetLatestForecastsResponse( + forecasts=[ + dp.GetLatestForecastsResponseForecast( + initialization_timestamp_utc=t, + created_timestamp_utc=t - dt.timedelta(hours=1), + forecaster=dp.Forecaster(forecaster_name, forecaster_version="1.0"), + location_uuid=req.location_uuid, + ), + ], + ) + class TestDataPlatformClient(unittest.IsolatedAsyncioTestCase): @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") - async def test_get_sites(self, client_mock) -> None: + async def test_get_sites(self, client_mock: dp.DataPlatformDataServiceStub) -> None: @dataclasses.dataclass class TestCase: name: str @@ -118,24 +129,24 @@ class TestCase: self.assertEqual(len(resp), tc.expected_num_sites) @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") - async def test_get_site_forecast(self, client_mock) -> None: + async def test_get_site_forecast(self, client_mock: dp.DataPlatformDataServiceStub) -> None: @dataclasses.dataclass class TestCase: name: str - site_uuid: str + site_uuid: uuid.UUID authdata: dict[str, str] should_error: bool testcases: list[TestCase] = [ TestCase( name="Should return forecast when user has access", - site_uuid=str(uuid.uuid4()), + site_uuid=uuid.uuid4(), authdata={"sub": "access_user"}, should_error=False, ), TestCase( name="Should raise HTTPException when user has no access", - site_uuid=str(uuid.uuid4()), + site_uuid=uuid.uuid4(), authdata={"sub": "no_access_user"}, should_error=True, ), @@ -164,25 +175,25 @@ class TestCase: @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") async def test_get_site_generation( self, - client_mock, + client_mock: dp.DataPlatformDataServiceStub, ) -> None: @dataclasses.dataclass class TestCase: name: str - site_uuid: str + site_uuid: uuid.UUID authdata: dict[str, str] should_error: bool testcases: list[TestCase] = [ TestCase( name="Should return generation when user has access", - site_uuid=str(uuid.uuid4()), + site_uuid=uuid.uuid4(), authdata={"sub": "access_user"}, should_error=False, ), TestCase( name="Should raise HTTPException when user has no access", - site_uuid=str(uuid.uuid4()), + site_uuid=uuid.uuid4(), authdata={"sub": "no_access_user"}, should_error=True, ), @@ -208,3 +219,134 @@ class TestCase: authdata=tc.authdata, ) self.assertEqual(len(resp), 5) + + @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") + async def test_get_substations( + self, + client_mock: dp.DataPlatformDataServiceStub, + ) -> None: + @dataclasses.dataclass + class TestCase: + name: str + authdata: dict[str, str] + expected_num_substations: int + + testcases: list[TestCase] = [ + TestCase( + name="Should return substations when user has access", + authdata={"sub": "access_user"}, + expected_num_substations=1, + ), + TestCase( + name="Should return no substations when user has no access", + authdata={"sub": "no_access_user"}, + expected_num_substations=0, + ), + ] + + client = Client.from_dp(client_mock) + for tc in testcases: + client_mock.list_locations = AsyncMock(side_effect=mock_list_locations) + + with self.subTest(tc.name): + resp = await client.get_substations(authdata=tc.authdata) + self.assertEqual(len(resp), tc.expected_num_substations) + + @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") + async def test_get_substation( + self, + client_mock: dp.DataPlatformDataServiceStub, + ) -> None: + @dataclasses.dataclass + class TestCase: + name: str + location_uuid: uuid.UUID + authdata: dict[str, str] + should_error: bool + + testcases: list[TestCase] = [ + TestCase( + name="Should return substation when user has access", + location_uuid=uuid.uuid4(), + authdata={"sub": "access_user"}, + should_error=False, + ), + TestCase( + name="Should raise HTTPException when user has no access", + location_uuid=uuid.uuid4(), + authdata={"sub": "no_access_user"}, + should_error=True, + ), + ] + + client = Client.from_dp(client_mock) + for tc in testcases: + client_mock.list_locations = AsyncMock(side_effect=mock_list_locations) + + with self.subTest(tc.name): + if tc.should_error: + with self.assertRaises(HTTPException): + await client.get_substation( + location_uuid=tc.location_uuid, + authdata=tc.authdata, + ) + else: + resp = await client.get_substation( + location_uuid=tc.location_uuid, + authdata=tc.authdata, + ) + self.assertIsNotNone(resp) + + @patch("dp_sdk.ocf.dp.DataPlatformDataServiceStub") + async def test_get_substation_forecast( + self, + client_mock: dp.DataPlatformDataServiceStub, + ) -> None: + @dataclasses.dataclass + class TestCase: + name: str + substation_uuid: uuid.UUID + authdata: dict[str, str] + expected_values: list[float] + should_error: bool + + testcases: list[TestCase] = [ + TestCase( + name="Should return GSP-scaled forecast when user has access", + substation_uuid=uuid.uuid4(), + authdata={"sub": "access_user"}, + # The forecast returns 5e5 watts for every value, and the substation's + # effective capacity is 1e5 watts (10% of the GSP's 1e6 watts), so + # the scaled values should be 0.1*5e5W = 50kW for each entry. + expected_values=[50] * 5, + should_error=False, + ), + TestCase( + name="Should raise HTTPException when user has no access", + substation_uuid=uuid.uuid4(), + authdata={"sub": "no_access_user"}, + expected_values=[], + should_error=True, + ), + ] + + client = Client.from_dp(client_mock) + for tc in testcases: + client_mock.list_locations = AsyncMock(side_effect=mock_list_locations) + client_mock.get_forecast_as_timeseries = AsyncMock(side_effect=mock_get_forecast) + client_mock.get_latest_forecasts = AsyncMock(side_effect=mock_get_latest_forecasts) + + with self.subTest(tc.name): + if tc.should_error: + with self.assertRaises(HTTPException): + resp = await client.get_substation_forecast( + substation_uuid=tc.substation_uuid, + authdata=tc.authdata, + ) + else: + resp = await client.get_substation_forecast( + substation_uuid=tc.substation_uuid, + authdata=tc.authdata, + ) + actual_values = [v.PowerKW for v in resp] + self.assertListEqual(actual_values, tc.expected_values) diff --git a/src/quartz_api/internal/backends/dummydb/client.py b/src/quartz_api/internal/backends/dummydb/client.py index 9572f73..b662f9b 100644 --- a/src/quartz_api/internal/backends/dummydb/client.py +++ b/src/quartz_api/internal/backends/dummydb/client.py @@ -4,12 +4,11 @@ import datetime as dt import math import random -from uuid import uuid4 +from uuid import UUID, uuid4 from typing_extensions import override -from quartz_api import internal -from quartz_api.internal.models import ForecastHorizon +from quartz_api.internal import models from ..utils import get_window from ._models import DummyDBPredictedPowerProduction @@ -18,26 +17,27 @@ step: dt.timedelta = dt.timedelta(minutes=15) -class Client(internal.DatabaseInterface): +class Client(models.DatabaseInterface): """Defines a dummy database that conforms to the DatabaseInterface.""" @override async def get_predicted_solar_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, - ) -> list[internal.PredictedPower]: + smooth_flag: bool = True, + ) -> list[models.PredictedPower]: # Get the window start, end = get_window() numSteps = int((end - start) / step) - values: list[internal.PredictedPower] = [] + values: list[models.PredictedPower] = [] for i in range(numSteps): time = start + i * step _PowerProduction = _basicSolarPowerProductionFunc(int(time.timestamp())) values.append( - internal.PredictedPower( + models.PredictedPower( Time=time, PowerKW=int(_PowerProduction.PowerProductionKW), CreatedTime=dt.datetime.now(tz=dt.UTC), @@ -50,19 +50,20 @@ async def get_predicted_solar_power_production_for_location( async def get_predicted_wind_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, - ) -> list[internal.PredictedPower]: + smooth_flag: bool = True, + ) -> list[models.PredictedPower]: # Get the window start, end = get_window() numSteps = int((end - start) / step) - values: list[internal.PredictedPower] = [] + values: list[models.PredictedPower] = [] for i in range(numSteps): time = start + i * step _PowerProduction = _basicWindPowerProductionFunc() values.append( - internal.PredictedPower( + models.PredictedPower( Time=time, PowerKW=int(_PowerProduction.PowerProductionKW), CreatedTime=dt.datetime.now(tz=dt.UTC), @@ -75,17 +76,17 @@ async def get_predicted_wind_power_production_for_location( async def get_actual_solar_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: # Get the window start, end = get_window() numSteps = int((end - start) / step) - values: list[internal.ActualPower] = [] + values: list[models.ActualPower] = [] for i in range(numSteps): time = start + i * step _PowerProduction = _basicSolarPowerProductionFunc(int(time.timestamp())) values.append( - internal.ActualPower( + models.ActualPower( Time=time, PowerKW=int(_PowerProduction.PowerProductionKW), ), @@ -97,17 +98,17 @@ async def get_actual_solar_power_production_for_location( async def get_actual_wind_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: # Get the window start, end = get_window() numSteps = int((end - start) / step) - values: list[internal.ActualPower] = [] + values: list[models.ActualPower] = [] for i in range(numSteps): time = start + i * step _PowerProduction = _basicWindPowerProductionFunc() values.append( - internal.ActualPower( + models.ActualPower( Time=time, PowerKW=int(_PowerProduction.PowerProductionKW), ), @@ -128,15 +129,15 @@ async def save_api_call_to_db(self, url: str, authdata: dict[str, str]) -> None: pass @override - async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: - uuid = str(uuid4()) - - site = internal.Site( - site_uuid=uuid, - client_site_id=1, + async def get_sites(self, authdata: dict[str, str]) -> list[models.Site]: + site = models.Site( + site_uuid=uuid4(), + client_site_name="Dummy Site", latitude=26, longitude=76, capacity_kw=76, + orientation=180, + tilt=30, ) return [site] @@ -144,41 +145,80 @@ async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: @override async def put_site( self, - site_uuid: str, - site_properties: internal.SiteProperties, + site_uuid: UUID, + site_properties: models.SiteProperties, authdata: dict[str, str], - ) -> internal.Site: - pass + ) -> models.Site: + sites = await self.get_sites(authdata=authdata) + return sites[0] @override async def get_site_forecast( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.PredictedPower]: - values = self.get_predicted_solar_power_production_for_location(location="dummy") - + ) -> list[models.PredictedPower]: + values = await self.get_predicted_solar_power_production_for_location(location="dummy") return values @override async def get_site_generation( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.ActualPower]: - values = self.get_actual_solar_power_production_for_location(location="dummy") - + ) -> list[models.ActualPower]: + values = await self.get_actual_solar_power_production_for_location(location="dummy") return values @override async def post_site_generation( self, - site_uuid: str, - generation: list[internal.ActualPower], + site_uuid: UUID, + generation: list[models.ActualPower], authdata: dict[str, str], ) -> None: pass + @override + async def get_substations( + self, + authdata: dict[str, str], + ) -> list[models.Substation]: + sub = models.Substation( + substation_uuid=uuid4(), + substation_name="Dummy Substation", + substation_type="primary", + latitude=26, + longitude=76, + capacity_kw=76, + ) + + return [sub] + + @override + async def get_substation( + self, + location_uuid: UUID, + authdata: dict[str, str], + ) -> models.SubstationProperties: + return models.SubstationProperties( + substation_name="Dummy Substation", + substation_type="primary", + latitude=26, + longitude=76, + capacity_kw=76, + ) + + @override + async def get_substation_forecast( + self, + location_uuid: UUID, + authdata: dict[str, str], + ) -> list[models.PredictedPower]: + values = await self.get_predicted_solar_power_production_for_location(location="dummy") + + return values + def _basicSolarPowerProductionFunc( timeUnix: int, diff --git a/src/quartz_api/internal/backends/dummydb/test_dummydb.py b/src/quartz_api/internal/backends/dummydb/test_dummydb.py index d305607..c538b5e 100644 --- a/src/quartz_api/internal/backends/dummydb/test_dummydb.py +++ b/src/quartz_api/internal/backends/dummydb/test_dummydb.py @@ -1,7 +1,9 @@ +import datetime as dt import unittest +import uuid -from quartz_api.internal import ActualPower from quartz_api.internal.middleware.auth import EMAIL_KEY +from quartz_api.internal.models import ActualPower from .client import Client @@ -10,23 +12,19 @@ class TestDummyDatabase(unittest.IsolatedAsyncioTestCase): async def test_get_predicted_wind_power_production_for_location(self) -> None: - locID = "testID" - out = await client.get_predicted_wind_power_production_for_location(locID) + out = await client.get_predicted_wind_power_production_for_location("testID") self.assertIsNotNone(out) async def test_get_predicted_solar_power_production_for_location(self) -> None: - locID = "testID" - out = await client.get_predicted_solar_power_production_for_location(locID) + out = await client.get_predicted_solar_power_production_for_location("testID") self.assertIsNotNone(out) async def test_get_actual_wind_power_production_for_location(self) -> None: - locID = "testID" - out = await client.get_actual_wind_power_production_for_location(locID) + out = await client.get_actual_wind_power_production_for_location("testID") self.assertIsNotNone(out) async def test_get_actual_solar_power_production_for_location(self) -> None: - locID = "testID" - out = await client.get_actual_solar_power_production_for_location(locID) + out = await client.get_actual_solar_power_production_for_location("testID") self.assertIsNotNone(out) async def test_get_wind_regions(self) -> None: @@ -42,16 +40,16 @@ async def test_get_sites(self) -> None: self.assertIsNotNone(out) async def test_get_site_forecast(self) -> None: - out = await client.get_site_forecast(site_uuid="testID", authdata={}) + out = await client.get_site_forecast(site_uuid=uuid.uuid4(), authdata={}) self.assertIsNotNone(out) async def test_get_site_generation(self) -> None: - out = await client.get_site_generation(site_uuid="testID", authdata={}) + out = await client.get_site_generation(site_uuid=uuid.uuid4(), authdata={}) self.assertIsNotNone(out) async def test_post_site_generation(self) -> None: await client.post_site_generation( - site_uuid="testID", - generation=[ActualPower(Time=1, PowerKW=1)], + site_uuid=uuid.uuid4(), + generation=[ActualPower(Time=dt.datetime(2021, 1, 1, tzinfo=dt.UTC), PowerKW=1)], authdata={}, ) diff --git a/src/quartz_api/internal/backends/quartzdb/client.py b/src/quartz_api/internal/backends/quartzdb/client.py index 706ac1f..bfb0dfd 100644 --- a/src/quartz_api/internal/backends/quartzdb/client.py +++ b/src/quartz_api/internal/backends/quartzdb/client.py @@ -25,16 +25,15 @@ from sqlalchemy.orm import Session from typing_extensions import override -from quartz_api import internal +from quartz_api.internal import models from quartz_api.internal.backends.quartzdb.smooth import smooth_forecast from quartz_api.internal.backends.utils import get_window -from quartz_api.internal.middleware.auth import EMAIL_KEY -from quartz_api.internal.models import ForecastHorizon +from quartz_api.internal.middleware.auth import EMAIL_KEY, AuthDependency log = logging.getLogger(__name__) -class Client(internal.DatabaseInterface): +class Client(models.DatabaseInterface): """Defines Quartz DB client that conforms to the DatabaseInterface.""" connection: DatabaseConnection @@ -64,20 +63,20 @@ def _get_predicted_power_production_for_location( location: str, asset_type: LocationAssetType, ml_model_name: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: """Gets the predicted power production for a location, regardless of type.""" # Get the window start, _ = get_window() # get house ahead forecast - if forecast_horizon == ForecastHorizon.day_ahead: + if forecast_horizon == models.ForecastHorizon.day_ahead: day_ahead_hours = 9 day_ahead_timezone_delta_hours = 5.5 forecast_horizon_minutes = None - elif forecast_horizon == ForecastHorizon.horizon: + elif forecast_horizon == models.ForecastHorizon.horizon: day_ahead_hours = None day_ahead_timezone_delta_hours = None else: @@ -118,7 +117,7 @@ def _get_predicted_power_production_for_location( # convert ForecastValueSQL to PredictedPower out = [ - internal.PredictedPower( + models.PredictedPower( PowerKW=int(value.forecast_power_kw) if value.forecast_power_kw >= 0 else 0, # Set negative values of PowerKW up to 0 @@ -138,7 +137,7 @@ def _get_generation_for_location( self, location: str, asset_type: LocationAssetType, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: """Gets the measured power production for a location.""" # Get the window start, end = get_window() @@ -161,7 +160,7 @@ def _get_generation_for_location( # convert from GenerationSQL to ActualPower out = [ - internal.ActualPower( + models.ActualPower( PowerKW=int(value.generation_power_kw) if value.generation_power_kw >= 0 else 0, # Set negative values of PowerKW up to 0 @@ -176,10 +175,10 @@ def _get_generation_for_location( async def get_predicted_solar_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: # set this to be hard coded for now model_name = "pvnet_india" @@ -196,10 +195,10 @@ async def get_predicted_solar_power_production_for_location( async def get_predicted_wind_power_production_for_location( self, location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: # set this to be hard coded for now model_name = "windnet_india_adjust" @@ -216,14 +215,14 @@ async def get_predicted_wind_power_production_for_location( async def get_actual_solar_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: return self._get_generation_for_location(location=location, asset_type=LocationAssetType.pv) @override async def get_actual_wind_power_production_for_location( self, location: str, - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: return self._get_generation_for_location( location=location, asset_type=LocationAssetType.wind, @@ -238,7 +237,7 @@ async def get_solar_regions(self) -> list[str]: return ["ruvnl"] @override - async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: + async def get_sites(self, authdata: dict[str, str]) -> list[models.Site]: # get sites uuids from user with self._get_session() as session: user = get_user_by_email(session, authdata[EMAIL_KEY]) @@ -246,8 +245,8 @@ async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: sites = [] for site_sql in sites_sql: - site = internal.Site( - site_uuid=str(site_sql.location_uuid), + site = models.Site( + site_uuid=site_sql.location_uuid, client_site_name=site_sql.client_location_name, orientation=site_sql.orientation, tilt=site_sql.tilt, @@ -262,14 +261,14 @@ async def get_sites(self, authdata: dict[str, str]) -> list[internal.Site]: @override async def put_site( self, - site_uuid: str, - site_properties: internal.SiteProperties, + site_uuid: UUID, + site_properties: models.SiteProperties, authdata: dict[str, str], - ) -> internal.Site: + ) -> models.Site: # get sites uuids from user with self._get_session() as session: user = get_user_by_email(session, authdata[EMAIL_KEY]) - site = get_site_by_uuid(session, site_uuid) + site = get_site_by_uuid(session, str(site_uuid)) check_user_has_access_to_site(session, authdata[EMAIL_KEY], site.location_uuid) site_dict = site_properties.model_dump(exclude_unset=True, exclude_none=False) @@ -278,7 +277,7 @@ async def put_site( site, _ = edit_site( session=session, - site_uuid=site_uuid, + site_uuid=str(site_uuid), site_info=site_info, user_uuid=user.user_uuid, ) @@ -288,9 +287,9 @@ async def put_site( @override async def get_site_forecast( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.PredictedPower]: + ) -> list[models.PredictedPower]: # TODO feels like there is some duplicated code here which could be refactored # hard coded model name @@ -307,24 +306,22 @@ async def get_site_forecast( ) # get site and the get the ml model name - site = get_site_by_uuid(session=session, site_uuid=site_uuid) + site = get_site_by_uuid(session=session, site_uuid=str(site_uuid)) if site.ml_model is not None: ml_model_name = site.ml_model.name log.info(f"Using ml model {ml_model_name}") values = get_latest_forecast_values_by_site( session, - site_uuids=[UUID(site_uuid) if isinstance(site_uuid, str) else site_uuid], + site_uuids=[site_uuid], start_utc=start, model_name=ml_model_name, ) - forecast_values: list[ForecastValueSQL] = values[ - UUID(site_uuid) if isinstance(site_uuid, str) else site_uuid - ] + forecast_values: list[ForecastValueSQL] = values[site_uuid] # convert ForecastValueSQL to PredictedPower out = [ - internal.PredictedPower( + models.PredictedPower( PowerKW=int(value.forecast_power_kw) if value.forecast_power_kw >= 0 else 0, # Set negative values of PowerKW up to 0 @@ -339,9 +336,9 @@ async def get_site_forecast( @override async def get_site_generation( self, - site_uuid: str, + site_uuid: UUID, authdata: dict[str, str], - ) -> list[internal.ActualPower]: + ) -> list[models.ActualPower]: # TODO feels like there is some duplicated code here which could be refactored # Get the window @@ -354,9 +351,6 @@ async def get_site_generation( site_uuid=site_uuid, ) - if isinstance(site_uuid, str): - site_uuid = UUID(site_uuid) - # read actual generations values = get_pv_generation_by_sites( session=session, @@ -367,7 +361,7 @@ async def get_site_generation( # convert from GenerationSQL to PredictedPower out = [ - internal.ActualPower( + models.ActualPower( PowerKW=int(value.generation_power_kw) if value.generation_power_kw >= 0 else 0, # Set negative values of PowerKW up to 0 @@ -381,8 +375,8 @@ async def get_site_generation( @override async def post_site_generation( self, - site_uuid: str, - generation: list[internal.ActualPower], + site_uuid: UUID, + generation: list[models.ActualPower], authdata: dict[str, str], ) -> None: with self._get_session() as session: @@ -404,7 +398,7 @@ async def post_site_generation( generation_values_df = pd.DataFrame(generations) capacity_factor = float(os.getenv("ERROR_GENERATION_CAPACITY_FACTOR", 1.1)) - site = get_site_by_uuid(session=session, site_uuid=site_uuid) + site = get_site_by_uuid(session=session, site_uuid=str(site_uuid)) site_capacity_kw = site.capacity_kw exceeded_capacity = generation_values_df[ generation_values_df["power_kw"] > site_capacity_kw * capacity_factor @@ -432,21 +426,42 @@ async def post_site_generation( insert_generation_values(session, generation_values_df) session.commit() + @override + async def get_substations( + self, + auth: AuthDependency, + ) -> list[models.Substation]: + raise NotImplementedError("QuartzDB backend does not support substations") + + @override + async def get_substation_forecast( + self, + substation_uuid: UUID, + auth: AuthDependency, + ) -> list[models.PredictedPower]: + raise NotImplementedError("QuartzDB backend does not support substations") + + @override + async def get_substation( + self, + location_uuid: UUID, + auth: AuthDependency, + ) -> models.SubstationProperties: + raise NotImplementedError("QuartzDB backend does not support substations") def check_user_has_access_to_site( session: Session, email: str, - site_uuid: str, + site_uuid: UUID, ) -> None: """Checks if a user has access to a site.""" user = get_user_by_email(session=session, email=email) site_uuids = [str(site.location_uuid) for site in user.location_group.locations] - site_uuid = str(site_uuid) - if site_uuid not in site_uuids: + if str(site_uuid) not in site_uuids: raise HTTPException( status_code=403, detail=f"Forbidden. User ({email}) " - f"does not have access to this site {site_uuid}. " - f"User has access to {site_uuids}", + f"does not have access to this site {site_uuid!s}. " + f"User has access to {[str(s) for s in site_uuids]}", ) diff --git a/src/quartz_api/internal/backends/quartzdb/conftest.py b/src/quartz_api/internal/backends/quartzdb/conftest.py index de1d969..6515b35 100644 --- a/src/quartz_api/internal/backends/quartzdb/conftest.py +++ b/src/quartz_api/internal/backends/quartzdb/conftest.py @@ -2,6 +2,7 @@ import datetime as dt import logging +from collections.abc import Generator import pytest from pvsite_datamodel.read.model import get_or_create_model @@ -21,7 +22,7 @@ @pytest.fixture(scope="session") -def engine() -> Engine: +def engine() -> Generator[Engine]: """Database engine fixture.""" with PostgresContainer("postgres:14.5") as postgres: url = postgres.get_connection_url() @@ -31,7 +32,7 @@ def engine() -> Engine: @pytest.fixture(scope="session") -def tables(engine: Engine) -> None: +def tables(engine: Engine) -> Generator[None]: """Create tables fixture.""" Base.metadata.create_all(engine) yield @@ -42,7 +43,7 @@ def tables(engine: Engine) -> None: def db_session( engine: Engine, tables: None, # noqa: ARG001 -) -> Session: +) -> Generator[Session]: """Return a sqlalchemy session, which tears down everything properly post-test.""" connection = engine.connect() # begin the nested transaction diff --git a/src/quartz_api/internal/backends/quartzdb/smooth.py b/src/quartz_api/internal/backends/quartzdb/smooth.py index e2e38a3..dd69001 100644 --- a/src/quartz_api/internal/backends/quartzdb/smooth.py +++ b/src/quartz_api/internal/backends/quartzdb/smooth.py @@ -2,10 +2,10 @@ import pandas as pd -from quartz_api.internal import PredictedPower +from quartz_api.internal import models -def smooth_forecast(values: list[PredictedPower]) -> list[PredictedPower]: +def smooth_forecast(values: list[models.PredictedPower]) -> list[models.PredictedPower]: """Smooths the forecast values.""" # convert to dataframe df = pd.DataFrame( @@ -26,7 +26,7 @@ def smooth_forecast(values: list[PredictedPower]) -> list[PredictedPower]: # convert back to list of PredictedPower return [ - PredictedPower( + models.PredictedPower( Time=index, PowerKW=row.PowerKW, CreatedTime=row.CreatedTime, diff --git a/src/quartz_api/internal/backends/quartzdb/test_csv.py b/src/quartz_api/internal/backends/quartzdb/test_csv.py index d14ede6..6abe90e 100644 --- a/src/quartz_api/internal/backends/quartzdb/test_csv.py +++ b/src/quartz_api/internal/backends/quartzdb/test_csv.py @@ -5,7 +5,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from quartz_api.internal import PredictedPower +from quartz_api.internal.models import PredictedPower from quartz_api.internal.service.regions._csv import format_csv_and_created_time from ...models import ForecastHorizon @@ -13,9 +13,6 @@ log = logging.getLogger(__name__) -# TODO add list of test that are here - - @pytest.fixture() def client(engine: Engine, db_session: Session) -> Client: """Hooks Client into pytest db_session fixture""" @@ -27,7 +24,7 @@ def client(engine: Engine, db_session: Session) -> Client: # Skip for now @pytest.mark.skip(reason="Not finished yet") class TestCsvExport: - def test_format_csv_and_created_time(self, client, forecast_values_wind) -> None: + def test_format_csv_and_created_time(self, client: Client, forecast_values_wind: None) -> None: """Test the format_csv_and_created_time function.""" forecast_values_wind = client.get_predicted_wind_power_production_for_location( location="testID", diff --git a/src/quartz_api/internal/backends/quartzdb/test_quartzdb.py b/src/quartz_api/internal/backends/quartzdb/test_quartzdb.py index b8c0ee9..07ce589 100644 --- a/src/quartz_api/internal/backends/quartzdb/test_quartzdb.py +++ b/src/quartz_api/internal/backends/quartzdb/test_quartzdb.py @@ -1,15 +1,17 @@ """Tests for QuartzDBClient methods.""" # ruff: noqa: ARG002 +import datetime as dt import logging import pytest from fastapi import HTTPException +from pvsite_datamodel.sqlmodels import GenerationSQL, LocationSQL from sqlalchemy import Engine from sqlalchemy.orm import Session -from quartz_api.internal import ActualPower, PredictedPower, SiteProperties from quartz_api.internal.middleware.auth import EMAIL_KEY +from quartz_api.internal.models import ActualPower, PredictedPower, SiteProperties from .client import Client @@ -44,8 +46,8 @@ async def test_get_predicted_wind_power_production_for_location( @pytest.mark.asyncio async def test_get_predicted_wind_power_production_for_location_raise_error( self, - client, - forecast_values_wind, + client: Client, + forecast_values_wind: None, ) -> None: with pytest.raises(HTTPException): _ = await client.get_predicted_wind_power_production_for_location("testID2") @@ -53,8 +55,8 @@ async def test_get_predicted_wind_power_production_for_location_raise_error( @pytest.mark.asyncio async def test_get_predicted_solar_power_production_for_location( self, - client, - forecast_values, + client: Client, + forecast_values: None, ) -> None: locID = "testID" result = await client.get_predicted_solar_power_production_for_location(locID) @@ -64,7 +66,9 @@ async def test_get_predicted_solar_power_production_for_location( assert isinstance(record, PredictedPower) @pytest.mark.asyncio - async def test_get_actual_wind_power_production_for_location(self, client, generations) -> None: + async def test_get_actual_wind_power_production_for_location( + self, client: Client, generations: list[GenerationSQL], + ) -> None: locID = "testID" result = await client.get_actual_wind_power_production_for_location(locID) @@ -75,8 +79,8 @@ async def test_get_actual_wind_power_production_for_location(self, client, gener @pytest.mark.asyncio async def test_get_actual_solar_power_production_for_location( self, - client, - generations, + client: Client, + generations: list[GenerationSQL], ) -> None: locID = "testID" result = await client.get_actual_solar_power_production_for_location(locID) @@ -86,49 +90,63 @@ async def test_get_actual_solar_power_production_for_location( assert isinstance(record, ActualPower) @pytest.mark.asyncio - async def test_get_wind_regions(self, client) -> None: + async def test_get_wind_regions(self, client: Client) -> None: result = await client.get_wind_regions() assert len(result) == 1 assert result[0] == "ruvnl" @pytest.mark.asyncio - async def test_get_solar_regions(self, client) -> None: + async def test_get_solar_regions(self, client: Client) -> None: result = await client.get_solar_regions() assert len(result) == 1 assert result[0] == "ruvnl" @pytest.mark.asyncio - async def test_get_sites(self, client, sites) -> None: + async def test_get_sites(self, client: Client, sites: list[LocationSQL]) -> None: sites_from_api = await client.get_sites(authdata={EMAIL_KEY: "test@test.com"}) assert len(sites_from_api) == 2 @pytest.mark.asyncio - async def test_get_sites_no_sites(self, client, sites) -> None: + async def test_get_sites_no_sites(self, client: Client, sites: list[LocationSQL]) -> None: sites_from_api = await client.get_sites(authdata={EMAIL_KEY: "test2@test.com"}) assert len(sites_from_api) == 0 @pytest.mark.asyncio - async def test_get_put_site(self, client, sites) -> None: + async def test_get_put_site(self, client: Client, sites: list[LocationSQL]) -> None: sites_from_api = await client.get_sites(authdata={EMAIL_KEY: "test@test.com"}) assert sites_from_api[0].client_site_name == "ruvnl_pv_testID1" site = await client.put_site( site_uuid=sites[0].location_uuid, - site_properties=SiteProperties(client_site_name="test_zzz"), + site_properties=SiteProperties( + client_site_name="test_zzz", + latitude=12.34, + longitude=56.78, + capacity_kw=100.0, + orientation=180.0, + tilt=30.0, + ), authdata={EMAIL_KEY: "test@test.com"}, ) assert site.client_location_name == "test_zzz" assert site.latitude is not None @pytest.mark.asyncio - async def test_get_site_forecast(self, client, sites, forecast_values_site) -> None: + async def test_get_site_forecast( + self, + client: Client, + sites: list[LocationSQL], + forecast_values_site: None, + ) -> None: out = await client.get_site_forecast( - site_uuid=str(sites[0].location_uuid), + site_uuid=sites[0].location_uuid, authdata={EMAIL_KEY: "test@test.com"}, ) assert len(out) > 0 @pytest.mark.asyncio - async def test_get_site_forecast_no_forecast_values(self, client, sites) -> None: + async def test_get_site_forecast_no_forecast_values( + self, client: Client, sites: list[LocationSQL], + ) -> None: out = await client.get_site_forecast( site_uuid=sites[0].location_uuid, authdata={EMAIL_KEY: "test@test.com"}, @@ -136,7 +154,9 @@ async def test_get_site_forecast_no_forecast_values(self, client, sites) -> None assert len(out) == 0 @pytest.mark.asyncio - async def test_get_site_forecast_no_access(self, client, sites) -> None: + async def test_get_site_forecast_no_access( + self, client: Client, sites: list[LocationSQL], + ) -> None: with pytest.raises(HTTPException): _ = await client.get_site_forecast( site_uuid=sites[0].location_uuid, @@ -144,27 +164,31 @@ async def test_get_site_forecast_no_access(self, client, sites) -> None: ) @pytest.mark.asyncio - async def test_get_site_generation(self, client, sites, generations) -> None: + async def test_get_site_generation( + self, client: Client, sites: list[LocationSQL], generations: list[GenerationSQL], + ) -> None: out = await client.get_site_generation( - site_uuid=str(sites[0].location_uuid), + site_uuid=sites[0].location_uuid, authdata={EMAIL_KEY: "test@test.com"}, ) assert len(out) > 0 @pytest.mark.asyncio - async def test_post_site_generation(self, client, sites) -> None: + async def test_post_site_generation(self, client: Client, sites: list[LocationSQL]) -> None: await client.post_site_generation( site_uuid=sites[0].location_uuid, - generation=[ActualPower(Time=1, PowerKW=1)], + generation=[ActualPower(Time=dt.datetime(2021, 1, 1, tzinfo=dt.UTC), PowerKW=1)], authdata={EMAIL_KEY: "test@test.com"}, ) @pytest.mark.asyncio - async def test_post_site_generation_exceding_max_capacity(self, client, sites): + async def test_post_site_generation_exceding_max_capacity( + self, client: Client, sites: list[LocationSQL], + ) -> None: try: await client.post_site_generation( site_uuid=sites[0].location_uuid, - generation=[ActualPower(Time=1, PowerKW=1000)], + generation=[ActualPower(Time=dt.datetime(2021, 1, 1, tzinfo=dt.UTC), PowerKW=1000)], authdata={EMAIL_KEY: "test@test.com"}, ) except HTTPException as e: diff --git a/src/quartz_api/internal/middleware/audit.py b/src/quartz_api/internal/middleware/audit.py index 390c3d5..cb3f60f 100644 --- a/src/quartz_api/internal/middleware/audit.py +++ b/src/quartz_api/internal/middleware/audit.py @@ -2,20 +2,21 @@ import logging from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING from fastapi import FastAPI, Request, Response from starlette.middleware.base import BaseHTTPMiddleware -from quartz_api.internal import DatabaseInterface +if TYPE_CHECKING: + from quartz_api.internal import models class RequestLoggerMiddleware(BaseHTTPMiddleware): """Middleware to log API requests to the database.""" - def __init__(self, server: FastAPI, db_client: DatabaseInterface) -> None: + def __init__(self, server: FastAPI) -> None: """Initialize the middleware with the FastAPI server and database client.""" super().__init__(server) - self.db_client = db_client async def dispatch( self, @@ -38,6 +39,9 @@ async def dispatch( url += f"?{request.url.query}" try: + db_client: models.DatabaseInterface = getattr(request.app.state, "db_instance", None) + if db_client is None: + raise RuntimeError("Database client not found in app state.") await self.db_client.save_api_call_to_db(url=url, authdata=auth) except Exception as e: logging.error(f"Failed to log request to DB: {e}") diff --git a/src/quartz_api/internal/middleware/auth.py b/src/quartz_api/internal/middleware/auth.py index 465956f..c145a45 100644 --- a/src/quartz_api/internal/middleware/auth.py +++ b/src/quartz_api/internal/middleware/auth.py @@ -6,7 +6,6 @@ import jwt from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from typing_extensions import override token_auth_scheme = HTTPBearer() @@ -38,7 +37,7 @@ def __call__( raise HTTPException(status_code=401, detail=str(e)) from e try: - payload = jwt.decode( + payload: dict[str, str] = jwt.decode( token, signing_key, algorithms=self._algorithm, @@ -56,8 +55,8 @@ def __call__( class DummyAuth: """Dummy auth dependency for testing purposes.""" - @override def __call__(self) -> dict[str, str]: + """Return a dummy authentication payload.""" return { EMAIL_KEY: "test@test.com", "sub": "google-oath2|012345678909876543210", diff --git a/src/quartz_api/internal/models.py b/src/quartz_api/internal/models.py deleted file mode 100644 index d2f87f0..0000000 --- a/src/quartz_api/internal/models.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Defines the application models and interfaces.""" - -import abc -import datetime as dt -from enum import Enum -from typing import Annotated - -from fastapi import Depends, HTTPException -from pydantic import BaseModel, Field - - -class ForecastHorizon(str, Enum): - """Defines the forecast horizon options. - - Can either be - - latest: Gets the latest forecast values. - - horizon: Gets the forecast values for a specific horizon. - - day_ahead: Gets the day ahead forecast values. - """ - - latest = "latest" - horizon = "horizon" - day_ahead = "day_ahead" - - -class PredictedPower(BaseModel): - """Defines the data structure for a predicted power value returned by the API.""" - - PowerKW: float - Time: dt.datetime - CreatedTime: dt.datetime = Field(exclude=True) - - def to_timezone(self, tz: dt.timezone) -> "PredictedPower": - """Converts the time of this predicted power value to the given timezone.""" - return PredictedPower( - PowerKW=self.PowerKW, - Time=self.Time.astimezone(tz=tz), - CreatedTime=self.CreatedTime.astimezone(tz=tz), - ) - - -class ActualPower(BaseModel): - """Defines the data structure for an actual power value returned by the API.""" - - PowerKW: float - Time: dt.datetime - - def to_timezone(self, tz: dt.timezone) -> "ActualPower": - """Converts the time of this predicted power value to the given timezone.""" - return ActualPower( - PowerKW=self.PowerKW, - Time=self.Time.astimezone(tz=tz), - ) - - -class SiteProperties(BaseModel): - """Site metadata.""" - - client_site_name: str | None = Field( - None, - json_schema_extra={"description": "The name of the site as given by the providing user."}, - ) - orientation: float | None = Field( - None, - json_schema_extra={ - "description": "The rotation of the panel in degrees. 180° points south", - }, - ) - tilt: float | None = Field( - None, - json_schema_extra={ - "description": "The tile of the panel in degrees. 90° indicates the panel is vertical.", - }, - ) - latitude: float | None = Field( - None, - json_schema_extra={"description": "The site's latitude"}, - ge=-90, - le=90, - ) - longitude: float | None = Field( - None, - json_schema_extra={"description": "The site's longitude"}, - ge=-180, - le=180, - ) - capacity_kw: float | None = Field( - None, - json_schema_extra={"description": "The site's total capacity in kw"}, - ge=0, - ) - - -class Site(BaseModel): - """Site metadata with site_uuid.""" - - site_uuid: str = Field(..., json_schema_extra={"description": "The site uuid assigned by ocf."}) - client_site_name: str | None = Field( - None, - json_schema_extra={"description": "The name of the site as given by the providing user."}, - ) - orientation: float | None = Field( - 180, - json_schema_extra={ - "description": "The rotation of the panel in degrees. 180° points south", - }, - ) - tilt: float | None = Field( - 35, - json_schema_extra={ - "description": "The tile of the panel in degrees. 90° indicates the panel is vertical.", - }, - ) - latitude: float = Field( - ..., - json_schema_extra={"description": "The site's latitude"}, - ge=-90, - le=90, - ) - longitude: float = Field( - ..., - json_schema_extra={"description": "The site's longitude"}, - ge=-180, - le=180, - ) - capacity_kw: float = Field( - ..., - json_schema_extra={"description": "The site's total capacity in kw"}, - ge=0, - ) - - -class DatabaseInterface(abc.ABC): - """Defines the interface for a generic database connection.""" - - @abc.abstractmethod - async def get_predicted_solar_power_production_for_location( - self, - location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, - forecast_horizon_minutes: int | None = None, - smooth_flag: bool = True, - ) -> list[PredictedPower]: - """Returns a list of predicted solar power production for a given location. - - Args: - location: The location for which to fetch predicted power. - forecast_horizon: The forecast horizon to use. - forecast_horizon_minutes: The forecast horizon in minutes to use. - smooth_flag: Whether to smooth the forecast data. - """ - pass - - @abc.abstractmethod - async def get_actual_solar_power_production_for_location( - self, - location: str, - ) -> list[ActualPower]: - """Returns a list of actual solar power production for a given location.""" - pass - - @abc.abstractmethod - async def get_predicted_wind_power_production_for_location( - self, - location: str, - forecast_horizon: ForecastHorizon = ForecastHorizon.latest, - forecast_horizon_minutes: int | None = None, - smooth_flag: bool = True, - ) -> list[PredictedPower]: - """Returns a list of predicted wind power production for a given location.""" - pass - - @abc.abstractmethod - async def get_actual_wind_power_production_for_location( - self, - location: str, - ) -> list[ActualPower]: - """Returns a list of actual wind power production for a given location.""" - pass - - @abc.abstractmethod - async def get_wind_regions(self) -> list[str]: - """Returns a list of wind regions.""" - pass - - @abc.abstractmethod - async def get_solar_regions(self) -> list[str]: - """Returns a list of solar regions.""" - pass - - @abc.abstractmethod - async def save_api_call_to_db(self, url: str, authdata: dict[str, str]) -> None: - """Saves an API call to the database.""" - pass - - @abc.abstractmethod - async def get_sites(self, authdata: dict[str, str]) -> list[Site]: - """Get a list of sites.""" - pass - - @abc.abstractmethod - async def put_site( - self, - site_uuid: str, - site_properties: SiteProperties, - authdata: dict[str, str], - ) -> Site: - """Update site info.""" - pass - - @abc.abstractmethod - async def get_site_forecast( - self, - site_uuid: str, - authdata: dict[str, str], - ) -> list[PredictedPower]: - """Get a forecast for a site.""" - pass - - @abc.abstractmethod - async def get_site_generation( - self, site_uuid: str, authdata: dict[str, str], - ) -> list[ActualPower]: - """Get the generation for a site.""" - pass - - @abc.abstractmethod - async def post_site_generation( - self, site_uuid: str, generation: list[ActualPower], authdata: dict[str, str], - ) -> None: - """Post the generation for a site.""" - pass - -def get_db_client() -> DatabaseInterface: - """Get the client implementation. - - Note: This should be overridden via FastAPI's dependency injection system with an actual - database client implementation. - """ - raise HTTPException( - status_code=401, - detail="No database client implementation has been provided.", - ) - -DBClientDependency = Annotated[DatabaseInterface, Depends(get_db_client)] diff --git a/src/quartz_api/internal/models/__init__.py b/src/quartz_api/internal/models/__init__.py new file mode 100644 index 0000000..eb3cd43 --- /dev/null +++ b/src/quartz_api/internal/models/__init__.py @@ -0,0 +1,20 @@ +"""Domain models and interfaces for the application.""" + +from .db_interface import ( + DatabaseInterface, + DBClientDependency, + get_db_client, +) +from .endpoint_types import ( + ActualPower, + ForecastHorizon, + PredictedPower, + SiteProperties, + Site, + SubstationProperties, + Substation, + TZDependency, + get_timezone, +) + + diff --git a/src/quartz_api/internal/models/db_interface.py b/src/quartz_api/internal/models/db_interface.py new file mode 100644 index 0000000..3783c4b --- /dev/null +++ b/src/quartz_api/internal/models/db_interface.py @@ -0,0 +1,155 @@ +"""Defines the domain interface for interacting with a backend.""" + +import abc +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, HTTPException + +from .endpoint_types import ( + ActualPower, + ForecastHorizon, + PredictedPower, + Site, + SiteProperties, + Substation, + SubstationProperties, +) + + +class DatabaseInterface(abc.ABC): + """Defines the interface for a generic database connection.""" + + @abc.abstractmethod + async def get_predicted_solar_power_production_for_location( + self, + location: str, + forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon_minutes: int | None = None, + smooth_flag: bool = True, + ) -> list[PredictedPower]: + """Returns a list of predicted solar power production for a given location. + + Args: + location: The location for which to fetch predicted power. + forecast_horizon: The forecast horizon to use. + forecast_horizon_minutes: The forecast horizon in minutes to use. + smooth_flag: Whether to smooth the forecast data. + """ + pass + + @abc.abstractmethod + async def get_actual_solar_power_production_for_location( + self, + location: str, + ) -> list[ActualPower]: + """Returns a list of actual solar power production for a given location.""" + pass + + @abc.abstractmethod + async def get_predicted_wind_power_production_for_location( + self, + location: str, + forecast_horizon: ForecastHorizon = ForecastHorizon.latest, + forecast_horizon_minutes: int | None = None, + smooth_flag: bool = True, + ) -> list[PredictedPower]: + """Returns a list of predicted wind power production for a given location.""" + pass + + @abc.abstractmethod + async def get_actual_wind_power_production_for_location( + self, + location: str, + ) -> list[ActualPower]: + """Returns a list of actual wind power production for a given location.""" + pass + + @abc.abstractmethod + async def get_wind_regions(self) -> list[str]: + """Returns a list of wind regions.""" + pass + + @abc.abstractmethod + async def get_solar_regions(self) -> list[str]: + """Returns a list of solar regions.""" + pass + + @abc.abstractmethod + async def save_api_call_to_db(self, url: str, authdata: dict[str, str]) -> None: + """Saves an API call to the database.""" + pass + + @abc.abstractmethod + async def get_sites(self, authdata: dict[str, str]) -> list[Site]: + """Get a list of sites.""" + pass + + @abc.abstractmethod + async def put_site( + self, + site_uuid: UUID, + site_properties: SiteProperties, + authdata: dict[str, str], + ) -> Site: + """Update site info.""" + pass + + @abc.abstractmethod + async def get_site_forecast( + self, + site_uuid: UUID, + authdata: dict[str, str], + ) -> list[PredictedPower]: + """Get a forecast for a site.""" + pass + + @abc.abstractmethod + async def get_site_generation( + self, site_uuid: UUID, authdata: dict[str, str], + ) -> list[ActualPower]: + """Get the generation for a site.""" + pass + + @abc.abstractmethod + async def post_site_generation( + self, site_uuid: UUID, generation: list[ActualPower], authdata: dict[str, str], + ) -> None: + """Post the generation for a site.""" + pass + + @abc.abstractmethod + async def get_substations(self, authdata: dict[str, str]) -> list[Substation]: + """Get a list of substations.""" + pass + + @abc.abstractmethod + async def get_substation_forecast( + self, + location_uuid: UUID, + authdata: dict[str, str], + ) -> list[PredictedPower]: + """Get forecasted generation values of a substation.""" + pass + + @abc.abstractmethod + async def get_substation( + self, + location_uuid: UUID, + authdata: dict[str, str], + ) -> SubstationProperties: + """Get substation metadata.""" + pass + +def get_db_client() -> DatabaseInterface: + """Get the client implementation. + + Note: This should be overridden via FastAPI's dependency injection system with an actual + database client implementation. + """ + raise HTTPException( + status_code=401, + detail="No database client implementation has been provided.", + ) + +DBClientDependency = Annotated[DatabaseInterface, Depends(get_db_client)] diff --git a/src/quartz_api/internal/models/endpoint_types.py b/src/quartz_api/internal/models/endpoint_types.py new file mode 100644 index 0000000..431754b --- /dev/null +++ b/src/quartz_api/internal/models/endpoint_types.py @@ -0,0 +1,133 @@ +"""Defines the domain models for the application.""" + +import datetime as dt +from enum import Enum +from typing import Annotated, Literal +from uuid import UUID +from zoneinfo import ZoneInfo + +from fastapi import Depends +from pydantic import BaseModel, Field + + +class ForecastHorizon(str, Enum): + """Defines the forecast horizon options. + + Can either be + - latest: Gets the latest forecast values. + - horizon: Gets the forecast values for a specific horizon. + - day_ahead: Gets the day ahead forecast values. + """ + + latest = "latest" + horizon = "horizon" + day_ahead = "day_ahead" + + +class PredictedPower(BaseModel): + """Defines the data structure for a predicted power value returned by the API.""" + + PowerKW: float + Time: dt.datetime + CreatedTime: dt.datetime = Field(exclude=True) + + def to_timezone(self, tz: str) -> "PredictedPower": + """Converts the time of this predicted power value to the given timezone.""" + return PredictedPower( + PowerKW=self.PowerKW, + Time=self.Time.astimezone(tz=ZoneInfo(key=tz)), + CreatedTime=self.CreatedTime.astimezone(tz=ZoneInfo(key=tz)), + ) + + +class ActualPower(BaseModel): + """Defines the data structure for an actual power value returned by the API.""" + + PowerKW: float + Time: dt.datetime + + def to_timezone(self, tz: str) -> "ActualPower": + """Converts the time of this predicted power value to the given timezone.""" + return ActualPower( + PowerKW=self.PowerKW, + Time=self.Time.astimezone(tz=ZoneInfo(key=tz)), + ) + +class LocationPropertiesBase(BaseModel): + """Properties common to all locations.""" + + latitude: float = Field( + ..., + json_schema_extra={"description": "The location's latitude"}, + ge=-90, + le=90, + ) + longitude: float = Field( + ..., + json_schema_extra={"description": "The location's longitude"}, + ge=-180, + le=180, + ) + capacity_kw: float = Field( + ..., + json_schema_extra={"description": "The location's total capacity in kw"}, + ge=0, + ) + +class SiteProperties(LocationPropertiesBase): + """Properties specific to a site.""" + + client_site_name: str | None = Field( + None, + json_schema_extra={"description": "The name of the site as given by the providing user."}, + ) + orientation: float | None = Field( + 180, + json_schema_extra={ + "description": "The rotation of the panel in degrees. 180° points south", + }, + ) + tilt: float | None = Field( + 35, + json_schema_extra={ + "description": "The tile of the panel in degrees. 90° indicates the panel is vertical.", + }, + ) + +class Site(SiteProperties): + """Site information, including properties and unique identifier.""" + + site_uuid: UUID = Field( + ..., + json_schema_extra={"description": "The unique identifier for the site."}, + ) + +class SubstationProperties(LocationPropertiesBase): + """Properties specific to a substation.""" + + substation_name: str | None = Field( + None, + json_schema_extra={"description": "The name of the substation."}, + ) + substation_type : Literal["primary", "secondary"] = Field( + ..., + json_schema_extra={"description": "The type of the substation."}, + ) + +class Substation(SubstationProperties): + """Substation information, including properties and unique identifier.""" + + substation_uuid: UUID = Field( + ..., + json_schema_extra={"description": "The unique identifier for the substation."}, + ) + + +def get_timezone() -> str: + """Stub function for timezone dependency. + + Note: This should be overidden in the router to provide the actual timezone. + """ + return "UTC" + +TZDependency = Annotated[str, Depends(get_timezone)] diff --git a/src/quartz_api/internal/service/constants.py b/src/quartz_api/internal/service/constants.py deleted file mode 100644 index 3280f9f..0000000 --- a/src/quartz_api/internal/service/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Constants used across the application.""" - -import pytz - -local_tz = pytz.timezone("Asia/Kolkata") diff --git a/src/quartz_api/internal/service/regions/_csv.py b/src/quartz_api/internal/service/regions/_csv.py index 2ef550e..329b0a2 100644 --- a/src/quartz_api/internal/service/regions/_csv.py +++ b/src/quartz_api/internal/service/regions/_csv.py @@ -4,14 +4,16 @@ import pandas as pd -from quartz_api.internal import PredictedPower -from quartz_api.internal.models import ForecastHorizon +from quartz_api.internal.models import ForecastHorizon, PredictedPower +# NOTE: We should probably make this take a timezone argument. +# This would mean changing the column names, so I'm leaving it for now, +# as I don't know whether this would affect clients. def format_csv_and_created_time( values: list[PredictedPower], forecast_horizon: ForecastHorizon, -) -> (pd.DataFrame, datetime): +) -> tuple[pd.DataFrame, datetime]: """Format the predicted power values into a pandas dataframe ready for CSV export. We also get the maximum created time of these forecasts diff --git a/src/quartz_api/internal/service/regions/_resample.py b/src/quartz_api/internal/service/regions/_resample.py index f2c96a8..68c0c54 100644 --- a/src/quartz_api/internal/service/regions/_resample.py +++ b/src/quartz_api/internal/service/regions/_resample.py @@ -1,33 +1,36 @@ """Functions to resample data.""" -import pandas as pd +import datetime as dt +import math +from collections import defaultdict -from quartz_api.internal import ActualPower +from quartz_api.internal import models -def resample_generation(values: list[ActualPower], internal_minutes: int) -> list[ActualPower]: - """Resample generation data to a specified interval.""" +def resample_generation( + values: list[models.ActualPower], + interval_minutes: int, +) -> list[models.ActualPower]: + """Perform binning on the generation data, with a specified bin width.""" if not values: return [] - # convert to dataframe - df = pd.DataFrame( - { - "Time": [value.Time for value in values], - "PowerKW": [value.PowerKW for value in values], - }, - ) - - # resample - df = df.set_index("Time").resample(f"{internal_minutes}min").mean().dropna() - - df["PowerKW"].clip(lower=0, inplace=True) # Set negative values of PowerKW up to 0 - - # convert back to list of ActualPower - return [ - ActualPower( - Time=index, - PowerKW=row.PowerKW, - ) - for index, row in df.iterrows() - ] + buckets: dict[dt.datetime, list[float]] = defaultdict(list[float]) + interval_seconds = interval_minutes * 60 + + for value in values: + ts = value.Time.timestamp() + floored_ts = math.floor(ts / interval_seconds) * interval_seconds + bucket_time = dt.datetime.fromtimestamp(floored_ts, tz=value.Time.tzinfo) + buckets[bucket_time].append(value.PowerKW) + + results: list[models.ActualPower] = [] + for bucket_time in sorted(buckets.keys()): + avg_power = sum(buckets[bucket_time]) / len(buckets[bucket_time]) + if avg_power < 0: + avg_power = 0.0 + + results.append(models.ActualPower(Time=bucket_time, PowerKW=avg_power)) + + return results + diff --git a/src/quartz_api/internal/service/regions/router.py b/src/quartz_api/internal/service/regions/router.py index cfa4122..72d5da3 100644 --- a/src/quartz_api/internal/service/regions/router.py +++ b/src/quartz_api/internal/service/regions/router.py @@ -10,16 +10,9 @@ from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from starlette import status -from starlette.requests import Request -from quartz_api.internal import ( - ActualPower, - DBClientDependency, - ForecastHorizon, - PredictedPower, -) +from quartz_api.internal import models from quartz_api.internal.middleware.auth import AuthDependency -from quartz_api.internal.service.constants import local_tz from ._csv import format_csv_and_created_time from ._resample import resample_generation @@ -59,7 +52,7 @@ class GetRegionsResponse(BaseModel): ) async def get_regions_route( source: ValidSource, - db: DBClientDependency, + db: models.DBClientDependency, auth: AuthDependency, # TODO: add auth scopes ) -> GetRegionsResponse: @@ -74,7 +67,7 @@ async def get_regions_route( class GetHistoricGenerationResponse(BaseModel): """Model for the historic generation endpoint response.""" - values: list[ActualPower] + values: list[models.ActualPower] @router.get( @@ -83,15 +76,15 @@ class GetHistoricGenerationResponse(BaseModel): ) async def get_historic_timeseries_route( source: ValidSource, - request: Request, region: str, - db: DBClientDependency, + db: models.DBClientDependency, auth: AuthDependency, + tz: models.TZDependency, # TODO: add auth scopes resample_minutes: int | None = None, ) -> GetHistoricGenerationResponse: """Get observed generation as a timeseries for a given source and region.""" - values: list[ActualPower] = [] + values: list[models.ActualPower] = [] try: if source == "wind": @@ -105,17 +98,21 @@ async def get_historic_timeseries_route( ) from e if resample_minutes is not None: - values = resample_generation(values=values, internal_minutes=resample_minutes) + values = resample_generation(values=values, interval_minutes=resample_minutes) return GetHistoricGenerationResponse( - values=[y.to_timezone(tz=local_tz) for y in values if y.Time < dt.datetime.now(tz=dt.UTC)], + values=[ + y.to_timezone(tz=tz) + for y in values + if y.Time < dt.datetime.now(tz=dt.UTC) + ], ) class GetForecastGenerationResponse(BaseModel): """Model for the forecast generation endpoint response.""" - values: list[PredictedPower] + values: list[models.PredictedPower] @router.get( @@ -125,10 +122,11 @@ class GetForecastGenerationResponse(BaseModel): async def get_forecast_timeseries_route( source: ValidSource, region: str, - db: DBClientDependency, + db: models.DBClientDependency, auth: AuthDependency, + tz: models.TZDependency, # TODO: add auth scopes - forecast_horizon: ForecastHorizon = ForecastHorizon.day_ahead, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.day_ahead, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, ) -> GetForecastGenerationResponse: @@ -137,9 +135,9 @@ async def get_forecast_timeseries_route( The smooth_flag indicates whether to return smoothed forecasts or not. Note that for Day Ahead forecasts, smoothing is never applied. """ - values: list[PredictedPower] = [] + values: list[models.PredictedPower] = [] - if forecast_horizon == ForecastHorizon.day_ahead: + if forecast_horizon == models.ForecastHorizon.day_ahead: smooth_flag = False try: @@ -164,7 +162,7 @@ async def get_forecast_timeseries_route( ) from e return GetForecastGenerationResponse( - values=[y.to_timezone(tz=local_tz) for y in values], + values=[y.to_timezone(tz=tz) for y in values], ) @@ -175,9 +173,10 @@ async def get_forecast_timeseries_route( async def get_forecast_csv( source: ValidSource, region: str, - db: DBClientDependency, + db: models.DBClientDependency, auth: AuthDependency, - forecast_horizon: ForecastHorizon | None = ForecastHorizon.latest, + tz: models.TZDependency, + forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, ) -> StreamingResponse: """Route to get the day ahead forecast as a CSV file. @@ -187,8 +186,8 @@ async def get_forecast_csv( - day_ahead: The forecast for the next day, from 00:00. """ if forecast_horizon is not None and forecast_horizon not in [ - ForecastHorizon.latest, - ForecastHorizon.day_ahead, + models.ForecastHorizon.latest, + models.ForecastHorizon.day_ahead, ]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -202,6 +201,7 @@ async def get_forecast_csv( auth=auth, forecast_horizon=forecast_horizon, smooth_flag=False, + tz=tz, ) # format to dataframe @@ -211,12 +211,13 @@ async def get_forecast_csv( ) # make file format + # NOTE: See note above format_csv_and_created_time about timezones now_ist = pd.Timestamp.now(tz="Asia/Kolkata") tomorrow_ist = df["Date [IST]"].iloc[0] match forecast_horizon: - case ForecastHorizon.latest: + case models.ForecastHorizon.latest: forecast_type = "intraday" - case ForecastHorizon.day_ahead: + case models.ForecastHorizon.day_ahead: forecast_type = "da" case _: raise HTTPException( diff --git a/src/quartz_api/internal/service/regions/test_resample.py b/src/quartz_api/internal/service/regions/test_resample.py index 1b2375a..476f2e4 100644 --- a/src/quartz_api/internal/service/regions/test_resample.py +++ b/src/quartz_api/internal/service/regions/test_resample.py @@ -1,44 +1,43 @@ +import datetime as dt import unittest -import pandas as pd - from quartz_api.internal.models import ActualPower from ._resample import resample_generation class TestResampleGeneration(unittest.TestCase): - def test_resample_generation_1_period(self): + def test_resample_generation_1_period(self) -> None: values = [ - ActualPower(Time="2021-01-01T00:00:00Z", PowerKW=1.0), - ActualPower(Time="2021-01-01T00:08:00Z", PowerKW=2.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T00:00:00Z"), PowerKW=1.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T00:08:00Z"), PowerKW=2.0), ] values = resample_generation(values, 15) self.assertEqual(len(values), 1) - self.assertEqual(values[0].Time, pd.Timestamp("2021-01-01T00:00:00Z")) + self.assertEqual(values[0].Time, dt.datetime.fromisoformat("2021-01-01T00:00:00Z")) self.assertEqual(values[0].PowerKW, 1.5) - def test_resample_generation_3_periods_with_gaps(self): + def test_resample_generation_3_periods_with_gaps(self) -> None: values = [ - ActualPower(Time="2021-01-01T00:00:00Z", PowerKW=1.0), - ActualPower(Time="2021-01-01T00:08:00Z", PowerKW=2.0), - ActualPower(Time="2021-01-01T00:22:00Z", PowerKW=3.0), - ActualPower(Time="2021-01-01T01:30:00Z", PowerKW=4.0), - ActualPower(Time="2021-01-01T01:31:00Z", PowerKW=5.0), - ActualPower(Time="2021-01-01T01:32:00Z", PowerKW=6.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T00:00:00Z"), PowerKW=1.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T00:08:00Z"), PowerKW=2.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T00:22:00Z"), PowerKW=3.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T01:30:00Z"), PowerKW=4.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T01:31:00Z"), PowerKW=5.0), + ActualPower(Time=dt.datetime.fromisoformat("2021-01-01T01:32:00Z"), PowerKW=6.0), ] values = resample_generation(values, 15) self.assertEqual(len(values), 3) - self.assertEqual(values[0].Time, pd.Timestamp("2021-01-01T00:00:00Z")) + self.assertEqual(values[0].Time, dt.datetime.fromisoformat("2021-01-01T00:00:00Z")) self.assertEqual(values[0].PowerKW, 1.5) - self.assertEqual(values[1].Time, pd.Timestamp("2021-01-01T00:15:00Z")) + self.assertEqual(values[1].Time, dt.datetime.fromisoformat("2021-01-01T00:15:00Z")) self.assertEqual(values[1].PowerKW, 3.0) - self.assertEqual(values[2].Time, pd.Timestamp("2021-01-01T01:30:00Z")) + self.assertEqual(values[2].Time, dt.datetime.fromisoformat("2021-01-01T01:30:00Z")) self.assertEqual(values[2].PowerKW, 5.0) diff --git a/src/quartz_api/internal/service/sites/router.py b/src/quartz_api/internal/service/sites/router.py index e0b096f..bd73c54 100644 --- a/src/quartz_api/internal/service/sites/router.py +++ b/src/quartz_api/internal/service/sites/router.py @@ -1,17 +1,12 @@ """The 'sites' FastAPI router object and associated routes logic.""" import pathlib +from uuid import UUID from fastapi import APIRouter from starlette import status -from quartz_api.internal import ( - ActualPower, - DBClientDependency, - PredictedPower, - Site, - SiteProperties, -) +from quartz_api.internal import models from quartz_api.internal.middleware.auth import AuthDependency router = APIRouter(tags=[pathlib.Path(__file__).parent.stem.capitalize()]) @@ -21,21 +16,24 @@ status_code=status.HTTP_200_OK, ) async def get_sites( - db: DBClientDependency, + db: models.DBClientDependency, auth: AuthDependency, -) -> list[Site]: +) -> list[models.Site]: """Get sites.""" sites = await db.get_sites(authdata=auth) return sites -@router.put("/sites/{site_uuid}", response_model=SiteProperties, status_code=status.HTTP_200_OK) +@router.put( + "/sites/{site_uuid}", + response_model=models.SiteProperties, + status_code=status.HTTP_200_OK) async def put_site_info( - site_uuid: str, - site_info: SiteProperties, - db: DBClientDependency, + site_uuid: UUID, + site_info: models.SiteProperties, + db: models.DBClientDependency, auth: AuthDependency, -) -> SiteProperties: +) -> models.SiteProperties: """### This route allows a user to update site information for a single site. #### Parameters @@ -53,12 +51,12 @@ async def put_site_info( status_code=status.HTTP_200_OK, ) async def get_forecast( - site_uuid: str, - db: DBClientDependency, + site_uuid: UUID, + db: models.DBClientDependency, auth: AuthDependency, -) -> list[PredictedPower]: +) -> list[models.PredictedPower]: """Get forecast of a site.""" - forecast = db.get_site_forecast(site_uuid=site_uuid, authdata=auth) + forecast = await db.get_site_forecast(site_uuid=site_uuid, authdata=auth) return forecast @@ -67,10 +65,10 @@ async def get_forecast( status_code=status.HTTP_200_OK, ) async def get_generation( - site_uuid: str, - db: DBClientDependency, + site_uuid: UUID, + db: models.DBClientDependency, auth: AuthDependency, -) -> list[ActualPower]: +) -> list[models.ActualPower]: """Get get generation fo a site.""" generation = await db.get_site_generation(site_uuid=site_uuid, authdata=auth) return generation @@ -81,9 +79,9 @@ async def get_generation( status_code=status.HTTP_200_OK, ) async def post_generation( - site_uuid: str, - generation: list[ActualPower], - db: DBClientDependency, + site_uuid: UUID, + generation: list[models.ActualPower], + db: models.DBClientDependency, auth: AuthDependency, ) -> None: """Post observed generation data. diff --git a/src/quartz_api/internal/service/substations/__init__.py b/src/quartz_api/internal/service/substations/__init__.py new file mode 100644 index 0000000..2357268 --- /dev/null +++ b/src/quartz_api/internal/service/substations/__init__.py @@ -0,0 +1,3 @@ +"""Routes for accessing substation-level forecasts.""" + +from .router import router diff --git a/src/quartz_api/internal/service/substations/router.py b/src/quartz_api/internal/service/substations/router.py new file mode 100644 index 0000000..b3f4c4f --- /dev/null +++ b/src/quartz_api/internal/service/substations/router.py @@ -0,0 +1,66 @@ +"""The 'substations' FastAPI router object and associated routes logic.""" + +import pathlib +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, status + +from quartz_api.internal import models +from quartz_api.internal.middleware.auth import AuthDependency + +router = APIRouter(tags=[pathlib.Path(__file__).parent.stem.capitalize()]) + +@router.get( + "/substations", + status_code=status.HTTP_200_OK, +) +async def get_substations( + db: models.DBClientDependency, + auth: AuthDependency, + substation_type: Literal["primary"] = "primary", # noqa: ARG001 +) -> list[models.Substation]: + """Get all substations. + + Note that currently only 'primary' substations are supported. + """ + substations = await db.get_substations(authdata=auth) + return substations + +@router.get( + "/substations/{substation_uuid}", + status_code=status.HTTP_200_OK, +) +async def get_substation( + substation_uuid: UUID, + db: models.DBClientDependency, + auth: AuthDependency, +) -> models.SubstationProperties: + """Get a substation by UUID.""" + substation = await db.get_substation( + location_uuid=substation_uuid, + authdata=auth, + ) + return substation + +@router.get( + "/substations/{substation_uuid}/forecast", + status_code=status.HTTP_200_OK, +) +async def get_substation_forecast( + substation_uuid: UUID, + db: models.DBClientDependency, + auth: AuthDependency, + tz: models.TZDependency, +) -> list[models.PredictedPower]: + """Get forecasted generation values of a substation.""" + forecast = await db.get_substation_forecast( + location_uuid=substation_uuid, + authdata=auth, + ) + forecast = [ + value.to_timezone(tz=tz) + for value in forecast + ] + return forecast + diff --git a/uv.lock b/uv.lock index 3693c58..8dc1674 100644 --- a/uv.lock +++ b/uv.lock @@ -462,14 +462,14 @@ wheels = [ [[package]] name = "dp-sdk" -version = "0.14.0" -source = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.14.0/dp_sdk-0.14.0-py3-none-any.whl" } +version = "0.16.0" +source = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.16.0/dp_sdk-0.16.0-py3-none-any.whl" } dependencies = [ { name = "betterproto" }, { name = "grpcio" }, ] wheels = [ - { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.14.0/dp_sdk-0.14.0-py3-none-any.whl", hash = "sha256:bb5e251681a154159853d0bf91d7fd662e18a8b93ca8487df468e0d0ca7a7142" }, + { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.16.0/dp_sdk-0.16.0-py3-none-any.whl", hash = "sha256:4494a160fc3e59e6c6d5df26f6433c7a77754851a3748b51e79bc76de358c470" }, ] [package.metadata] @@ -1611,7 +1611,7 @@ wheels = [ [[package]] name = "quartz-api" -version = "0.3.1" +version = "0.3.3" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -1646,7 +1646,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=41.0.0" }, - { name = "dp-sdk", url = "https://github.com/openclimatefix/data-platform/releases/download/v0.14.0/dp_sdk-0.14.0-py3-none-any.whl" }, + { name = "dp-sdk", url = "https://github.com/openclimatefix/data-platform/releases/download/v0.16.0/dp_sdk-0.16.0-py3-none-any.whl" }, { name = "fastapi", specifier = ">=0.105.0" }, { name = "numpy", specifier = ">=1.25.0" }, { name = "pvsite-datamodel", specifier = "==1.2.3" },