Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/branch_ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json

name: Branch Push CI (Python)

on:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/bump_tag.yml
Original file line number Diff line number Diff line change
@@ -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 }}"'

Expand All @@ -9,4 +11,4 @@ on:
jobs:
bump-tag:
uses: openclimatefix/.github/.github/workflows/bump_tag.yml@main
secrets: inherit
secrets: inherit
4 changes: 3 additions & 1 deletion .github/workflows/tagged_ci.yml
Original file line number Diff line number Diff line change
@@ -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 }}'

Expand All @@ -11,4 +13,4 @@ jobs:
secrets: inherit
with:
containerfile: Containerfile
enable_pypi: false
enable_pypi: false
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.15.0/dp_sdk-0.15.0-py3-none-any.whl" }

[project.urls]
repository = "https://github.com/openclimatefix/quartz-api"
Expand Down
4 changes: 2 additions & 2 deletions src/quartz_api/cmd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
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
from quartz_api.internal.service import regions, sites, substations

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
Expand Down Expand Up @@ -188,7 +188,7 @@ def redoc_html() -> FileResponse:
)

# Add routers to the server according to configuration
for router_module in [sites, regions]:
for router_module in [sites, regions, substations]:
if conf.get_string("api.router") == router_module.__name__.split(".")[-1]:
server.include_router(router_module.router)
server.openapi_tags = [{
Expand Down
2 changes: 2 additions & 0 deletions src/quartz_api/cmd/server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ api {
loglevel = "debug"
loglevel = ${?LOGLEVEL}
// Which router to serve requests through
// Supported routers: "none", "sites", "regions", "substations"
router = "none"
router = ${?ROUTER}
origins = "*"
origins = ${?ORIGINS}
}

// The backend to use for the service
// Supported backends: "dummydb", "quartzdb", "dataplatform"
backend {
source = "dummydb"
source = ${?SOURCE}
Expand Down
80 changes: 80 additions & 0 deletions src/quartz_api/internal/backends/dataplatform/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,86 @@ 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.")
pass

@override
async def get_substations(
self,
authdata: dict[str, str],
) -> list[internal.Site]:
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 [
internal.Site(
site_uuid=loc.location_uuid,
client_site_name=loc.location_name,
orientation=loc.metadata.fields["orientation"].number_value
if "orientation" in loc.metadata.fields
else None,
tilt=loc.metadata.fields["tilt"].number_value
if "tilt" in loc.metadata.fields
else None,
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: str,
authdata: dict[str, str],
) -> list[internal.PredictedPower]:
forecast = await self._get_predicted_power_production_for_location(
substation_uuid,
dp.EnergySource.SOLAR,
authdata["sub"],
)
return forecast

@override
async def get_location(
self,
location_uuid: str,
authdata: dict[str, str],
) -> internal.SiteProperties:
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}'",
)
if len(resp.locations) > 1:
raise HTTPException(
status_code=500,
detail=f"Multiple locations found for UUID '{location_uuid}'",
)
loc = resp.locations[0]

return internal.Site(
site_uuid=loc.location_uuid,
client_site_name=loc.location_name,
orientation=loc.metadata.fields["orientation"].number_value
if "orientation" in loc.metadata.fields
else None,
tilt=loc.metadata.fields["tilt"].number_value
if "tilt" in loc.metadata.fields
else None,
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,
Expand Down
30 changes: 30 additions & 0 deletions src/quartz_api/internal/backends/dummydb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,36 @@ async def post_site_generation(
) -> None:
pass

@override
async def get_substations(
self,
authdata: dict[str, str],
) -> list[internal.Site]:
uuid = str(uuid4())

site = internal.Site(
site_uuid=uuid,
client_site_id=1,
latitude=26,
longitude=76,
capacity_kw=76,
)

return [site]

@override
async def get_location(
self,
location_uuid: str,
authdata: dict[str, str],
) -> internal.Site:
return internal.Site(
site_uuid=str(uuid4()),
client_site_id=1,
latitude=26,
longitude=76,
capacity_kw=76,
)

def _basicSolarPowerProductionFunc(
timeUnix: int,
Expand Down
16 changes: 15 additions & 1 deletion src/quartz_api/internal/backends/quartzdb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from quartz_api import internal
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.middleware.auth import EMAIL_KEY, AuthDependency
from quartz_api.internal.models import ForecastHorizon

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -432,6 +432,20 @@ async def post_site_generation(
insert_generation_values(session, generation_values_df)
session.commit()

@override
async def get_substations(
self,
auth: AuthDependency,
) -> list[internal.Site]:
raise NotImplementedError("QuartzDB backend does not support substations")

@override
async def get_location(
self,
location_uuid: str,
auth: AuthDependency,
) -> internal.Site:
raise NotImplementedError("QuartzDB backend does not support locations")

def check_user_has_access_to_site(
session: Session,
Expand Down
15 changes: 15 additions & 0 deletions src/quartz_api/internal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import datetime as dt
from enum import Enum
from typing import Annotated
from uuid import UUID

from fastapi import Depends, HTTPException
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -231,6 +232,20 @@ async def post_site_generation(
"""Post the generation for a site."""
pass

@abc.abstractmethod
async def get_substations(self, authdata: dict[str, str]) -> list[Site]:
"""Get a list of substations."""
pass

@abc.abstractmethod
async def get_location(
self,
location_uuid: UUID,
authdata: dict[str, str],
) -> SiteProperties:
"""Get location metadata."""
pass

def get_db_client() -> DatabaseInterface:
"""Get the client implementation.

Expand Down
3 changes: 3 additions & 0 deletions src/quartz_api/internal/service/substations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Routes for accessing substation-level forecasts."""

from .router import router
68 changes: 68 additions & 0 deletions src/quartz_api/internal/service/substations/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""The 'substations' FastAPI router object and associated routes logic."""

import pathlib
from uuid import UUID

from fastapi import APIRouter, status

from quartz_api import internal
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() -> list[str]:
"""Get substation groupings.
Currently only primary substations are supported.
"""
return ["primary"]


@router.get(
"/substations/primary",
status_code=status.HTTP_200_OK,
)
async def get_primary_substations(
db: internal.DBClientDependency,
auth: AuthDependency,
) -> list[internal.Site]:
"""Get all primary substations."""
substations = await db.get_substations(authdata=auth)
return substations

@router.get(
"/substations/primary/{substation_uuid}",
status_code=status.HTTP_200_OK,
)
async def get_primary_substation(
substation_uuid: UUID,
db: internal.DBClientDependency,
auth: AuthDependency,
) -> internal.Site:
"""Get a primary substation by UUID."""
substation = await db.get_location(
location_uuid=substation_uuid,
authdata=auth,
)
return substation

@router.get(
"/substations/primary/{substation_uuid}/forecast",
status_code=status.HTTP_200_OK,
)
async def get_substation_forecast(
substation_uuid: UUID,
db: internal.DBClientDependency,
auth: AuthDependency,
) -> list[internal.PredictedPower]:
"""Get forecasted generation values of a primary substation."""
forecast = await db.get_predicted_solar_power_production_for_location(
location=substation_uuid,
authdata=auth,
)
return forecast
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.