Skip to content

🎯 Sites API β†’ v1Β #320

Description

@braddf

V1 Sites (GB) β€” implementation spec

What this is

Bring GB site-level forecasting into the v1 API, replacing pv-site-api (api-site.quartz.solar), which is deprecated. Sites become a first-class noun under the existing v1 prefix, backed by the Data Platform through StorageInterface like everything else in v1.

Design for India as well as GB. The same scaffold will serve Indian sites on its own separate deployment after this ships, so:

  • country and source are path parameters. Nothing should hardcode GB or solar, including helpers, cache keys and config lookups.
  • source will include wind. Anything keyed on energy type needs to work for both, even though only solar is reachable today.
  • Site forecasts are 15-minute resolution; region forecasts are either 15 or 30-minute. Never assume a fixed step β€” always read the timestamps.
  • Per-country values (forecast model, generation observer) come from config, not constants. This is simple right now but will be in place for future multi-model site forecasting.

Anything that would need rewriting to add a second country is worth raising before building it.


Conventions

These are the v1 conventions and are not negotiable, because clients read every route the same way.

Rule Example
Field naming snake_case client_site_name
Power / energy kW, capital W in the suffix power_kW, capacity_kW
Timestamps _utc suffix, timezone-aware ISO 8601 time_utc, last_updated_utc
Identity UUID site_id
Shared metadata hoisted onto the response wrapper, never repeated per value capacity_kW at the top
List responses always an object, never a bare array {"sites": [...]}

Copy field names from service/v1/endpoint_types.py, which is the source of truth for the region models the site models sit beside.


Routes

# Site CRUD
GET    /v1/{country}/{source}/sites                        β†’ SiteList
POST   /v1/{country}/{source}/sites                        β†’ SiteDetail (201)
GET    /v1/{country}/{source}/sites/{site_id}              β†’ SiteDetail
PUT    /v1/{country}/{source}/sites/{site_id}              β†’ SiteDetail

# Per-site time series
GET    /v1/{country}/{source}/sites/{site_id}/forecast     β†’ SiteForecastResponse
GET    /v1/{country}/{source}/sites/{site_id}/generation   β†’ SiteGenerationResponse
POST   /v1/{country}/{source}/sites/{site_id}/generation   β†’ 202
GET    /v1/{country}/{source}/sites/{site_id}/clearsky     β†’ SiteClearskyResponse

# Multi-site
GET    /v1/{country}/{source}/sites/forecasts/period       β†’ SiteForecastMatrix
GET    /v1/{country}/{source}/sites/generation/period      β†’ SiteGenerationMatrix
GET    /v1/{country}/{source}/sites/forecasts/snapshot     β†’ SiteForecastSnapshot
GET    /v1/{country}/{source}/sites/generation/snapshot    β†’ SiteGenerationSnapshot
GET    /v1/{country}/{source}/sites/clearsky               β†’ SiteClearskyMatrix

Out of scope for this piece: DELETE /sites/{site_id} and the aggregate=total|dno|gsp parameter. Both are specified separately, we'll circle back to these when needed and/or confirmed on DP side.


Response shapes

SiteDetail / SiteList

// GET /sites
{
  "sites": [ /* SiteDetail */ ]
}

// SiteDetail
{
  "site_id": "8d39a579-8bed-490e-800e-1395a8eb6535",
  "client_site_id": 1234,
  "client_site_name": "Rooftop A",
  "status": "active",
  "capacity_kW": 5.0,
  "inverter_capacity_kW": 4.0,
  "module_capacity_kW": 5.2,
  "latitude": 51.5,
  "longitude": -0.1,
  "orientation": 180,
  "tilt": 35,
  "metadata": {"dno": "…", "gsp": "…"}
}

status is one of "active", "inactive", "commissioning" β€” a string, so new values can be added later without a schema change. A site with no stored status reads as "active". Only "active" sites are forecast.

The /sites response is enveloped deliberately in case we need to add e.g. pagination later with a next_cursor, without changing the overall response shape.

POST and PUT take a SiteInput body β€” the same fields minus site_id, capacity_kW and metadata. PUT is a partial update: absent fields are left unchanged.

Time series

// SiteForecastResponse
{
  "site_id": "…",
  "capacity_kW": 5.0,
  "model_name": "…",
  "model_version": "…",
  "last_updated_utc": "2026-04-17T06:00:00Z",
  "latest_init_utc": "2026-04-17T05:30:00Z",
  "values": [{"time_utc": "2026-04-17T12:00:00Z", "power_kW": 2.1}]
}

// SiteGenerationResponse
{
  "site_id": "…",
  "capacity_kW": 5.0,
  "observer_name": "site_api",
  "values": [{"time_utc": "…", "power_kW": 1.9}]
}

Site forecasts carry no probabilistic levels; omit plevels_kW entirely rather than returning an empty object. If this changes in future, we can easily add to this response shape.

POST /sites/{site_id}/generation takes a flat list and returns 202 with no body:

[{"time_utc": "2026-04-17T12:00:00Z", "power_kW": 1.452}]

Multi-site forecasts ("2D", forecast over period, up to 10* sites)

One shared times_utc array with parallel value arrays per site β€” no per-row timestamps.

// SiteForecastMatrix
{
  "model_name": "…",
  "model_version": "…",
  "last_updated_utc": "…",
  "latest_init_utc": "…",
  "times_utc": ["…", "…"],
  "sites": [
    {"site_id": "…", "capacity_kW": 5.0, "power_kW": [2.1, 2.3]}
  ]
}

// SiteGenerationMatrix β€” same, with observer_name instead of the model fields
// SiteClearskyMatrix   β€” same, with neither

Snapshots (one timestamp, many sites)

// SiteForecastSnapshot
{
  "time_utc": "2026-04-17T12:00:00Z",
  "model_name": "…",
  "model_version": "…",
  "last_updated_utc": "…",
  "latest_init_utc": "…",
  "values": [{"site_id": "…", "capacity_kW": 5.0, "power_kW": 2.1}]
}

// SiteGenerationSnapshot β€” same, with observer_name instead of the model fields

Default timestamp is now, floored to 30 minutes.


Behaviour

Parameters

Every parameter on every route. Nothing accepts a parameter not listed here β€” an unknown one should 422 rather than be ignored, so a typo surfaces instead of silently returning the default.

Route Parameters Default window
GET /sites status, name, min_latitude, max_latitude, min_longitude, max_longitude β€”
POST /sites body only (SiteInput) β€”
GET /sites/{site_id} β€” β€”
PUT /sites/{site_id} body only (SiteInput, partial) β€”
GET /sites/{site_id}/forecast start_utc, end_utc, horizon_minutes, creation_limit_utc now β†’ +48h
GET /sites/{site_id}/generation start_utc, end_utc last 24h
POST /sites/{site_id}/generation body only β€”
GET /sites/{site_id}/clearsky start_utc, end_utc now β†’ +48h
GET /sites/forecasts/period site_ids, start_utc, end_utc, horizon_minutes, creation_limit_utc now floored to 6h, Β±2 days
GET /sites/generation/period site_ids, start_utc, end_utc as above
GET /sites/clearsky site_ids, start_utc, end_utc as above
GET /sites/forecasts/snapshot time_utc now, floored to 30 min
GET /sites/generation/snapshot time_utc now, floored to 30 min

Notes:

  • horizon_minutes filters to a single forecast horizon β€” 60 returns only the 1-hour-ahead value for each target timestep. Same name and meaning as the region forecast routes. Not available on the snapshot routes, which are already a single point in time.
  • status filters the site list by lifecycle state. The platform has no metadata filter, so this is applied in the API after fetching.
  • name is a case-insensitive substring match on client_site_name, matching the behaviour of ?name= on GET /regions.
  • The bounding box is four separate typed floats. pv-site-api took latitude_longitude_min / max as comma-joined strings parsed by hand; four floats let FastAPI validate them. All four are required together or none β€” a partial box is a 400.
  • No model_name or observer parameters, deliberately. Both are resolved from config and reported in the response. See Forecast model selection and Generation observer below.
  • creation_limit_utc returns the forecast as it was known at a point in time, by excluding runs created after it β€” the same name and meaning as on the region forecast route. It matters more for sites than for regions, since site customers schedule and trade against a specific forecast and later need to show what it said at the time, per site. created_cutoff is already a parameter on get_predicted_generation in StorageInterface, so this is a query param wired to an argument on a call the route makes anyway.

History is available up to one year back; earlier start_utc is a 400.

The multi-site cap

/sites/forecasts/period, /sites/generation/period and /sites/clearsky serve at most 10 sites per request
* MAX_SITES_PER_PERIOD_REQUEST β€” a named constant, not a literal, so it can move to config, and not 100% what the best number is yet for the DP so we'll have to test this.

site_ids is an optional repeated query param:

Request Behaviour
omitted, caller has ≀ 10 sites return all of them
omitted, caller has > 10 sites 400, naming the cap and how to fix it
given, ≀ 10 entries return those sites
given, > 10 entries 422

Never return an arbitrary subset. Truncating to "the first 10" is silently wrong twice: the caller cannot tell data is missing, and the platform guarantees no ordering, so "the first 10" need not be the same ten between two identical requests β€” a dashboard would show a different slice of a portfolio on each refresh with nothing to explain it. Make the 400 self-service:

You have 14 sites; this endpoint returns at most 10 per request. Pass site_ids to choose which, e.g. ?site_ids=<uuid>&site_ids=<uuid>. Site UUIDs come from GET /sites.

These endpoints fan out one platform call per site, so bound the concurrency of the fan-out independently of the request cap β€” ten sites each from twenty callers is still two hundred concurrent calls.

The snapshot routes are a single platform call for the whole set of user's sites and are not capped.

Ordering

The platform gives no ordering guarantee. Impose a deterministic order on every list response β€” client_site_name, tie-broken on site_id. Without it, a cached list reshuffles between refreshes for no visible reason, and pagination could never be added.

Caching

Site data is per-company, so a cached response must never be served to a different company. The existing v1 cache key builder deliberately shares entries between users whose access is equivalent, which is correct for public region data and wrong here. Treat this as the highest-risk part of the work.

Suggested TTLs, to tune: site list and per-site series and snapshots 60s. The period endpoints are live platform reads and should be cached briefly or not at all.

Forecast model selection

Per-site, in this order:

  1. metadata["forecast_name"] on the site, if set β€” a per-site override stored in the platform.
  2. The country/source default from country_config.py.

Always report the resolved model as model_name in the response. Do not add a model_name request parameter yet β€” GB runs one site model, and reporting it now means the parameter can be added later without changing the response.

Generation observer

Site generation reads and writes use the observer name from config (site_api for GB today). Report it as observer_name. Observer names are validated ^[a-z0-9_]+$ by the platform.

Clearsky

Pure pvlib compute from latitude, longitude, tilt, orientation and module capacity β€” no platform call. pvlib is a new dependency; add it to pyproject.toml.

If a site is missing module_capacity_kW or inverter_capacity_kW, return a 400 naming the missing field. Do not infer one from the other.

Errors

Status When
400 unusable but well-formed request β€” window out of range, over the site cap, partial bounding box, clearsky without capacities
401 missing or invalid token, from the auth layer
403 the caller has no company β€” they can never see any site
404 the site does not exist, or belongs to another company
422 schema validation, from FastAPI
502 platform gRPC failure, via the existing handler in cmd/main.py

403 and 404 divide on whether the answer depends on the resource.

A caller whose token carries no hubspot_company_id and is not ocf:admin can never see any site, on any route. That is a property of the caller, so it is a 403 on every site route including the list ones β€” not an empty list. An empty list says "you have no sites", which is a different and probably false statement, and it sends someone to the wrong place to debug it. Make the message actionable: the account is not linked to a company, and support can link it.

Once a caller does have a company, every failure to find a site is a 404, whether the UUID belongs to another company or to nothing at all. A 403 there would confirm the site exists, which leaks the existence of another company's sites to anyone who can guess or obtain a UUID. The two cases must be indistinguishable in status code, body and timing.

Decide this once in the scoping layer rather than per route. The storage layer is not consistent about it today β€” depending on the call it returns an empty list, a 404, or a 403 for the same no-company caller β€” so a route that leans on whatever the backend happens to do will not match this table.


Platform constraints

Facts about the Data Platform that shape the contract. They explain requirements that look arbitrary otherwise.

  1. location_name is validated ^[a-z0-9_|]+$, 2–100 characters. Customer-supplied names cannot be stored there, which is why client_site_name lives in the location's metadata and why sites are addressed by UUID and never by name.
  2. A location carries one capacity field, effective_capacity_watts, which maps to capacity_kW. inverter_capacity_kW, module_capacity_kW, client_site_id, tilt, orientation and status are all stored in the free-form metadata struct. Metadata is flat and its values are strings, ints or floats.
  3. No pagination on any platform RPC. ListLocations returns everything matching the filter in one message. This is why GET /sites is unpaginated for now.
  4. No metadata filter on ListLocations. Any filtering on status happens in the API after fetching.
  5. No DeleteLocation RPC. Relevant only to the deferred DELETE route; nothing here should assume a location can be removed.
  6. Updating a location replaces its metadata wholesale. A partial update has to merge against what is already stored, or unrelated fields are lost.
  7. enclosed_location_uuid_filter answers "which GSP/DNO encloses this site", if site-to-region linkage is needed.

Authorization

Sites are owned. Every site route is scoped to the caller's company, and getting this wrong means one customer reading another's portfolio – the most serious failure available in this work compared to the /regions section of the v1 API.

What exists already:

  • The JWT carries app_metadata.hubspot_company_id.
  • get_org_id_from_authdata() in internal/middleware/auth.py turns the claims into an organisation identifier, returning None for ocf:admin (all companies) and a sentinel when the caller has no company at all.
  • The platform filters server-side on organisation_id_filter.
  • The existing India sites router (internal/service/sites/router.py) uses this path today and is a working reference.

No new Auth0 permission is required. Company membership alone grants access to that company's sites; there is no country-permission check on site routes. We might add a site-specific role later, but this will be easy to insert an extra check before the ownership logic.

Two hazards worth knowing before you start, both of which fail silently rather than loudly:

  1. The region routes pass an empty auth dict to the storage layer, which for those routes means "no filter" β€” correct, since region data is public. The same value on a site query also means no filter, so copying a region route as the basis for a site route produces an endpoint that returns every company's sites and raises no error.
  2. Cached site responses can cross company boundaries if the cache key does not distinguish callers. See Caching above.

Do not use pv-site-api as a reference for authorization. It has a bug, which is known, tracked separately, and deliberately not being fixed given that API is being retired. It is mentioned only so the pattern is not carried across.


Migration from pv-site-api

pv-site-api v1 Notes
GET /sites GET /v1/GB/solar/sites latitude_longitude_min/max comma-strings become typed float params
POST /sites POST /v1/GB/solar/sites PVSiteInputMetadata β†’ SiteInput
PUT /sites/{uuid} PUT /v1/GB/solar/sites/{site_id}
GET /sites/{uuid}/pv_actual GET /v1/GB/solar/sites/{site_id}/generation gains start_utc / end_utc
POST /sites/{uuid}/pv_actual POST /v1/GB/solar/sites/{site_id}/generation body flattens from MultiplePVActual to a list
GET /sites/pv_actual?site_uuids=… GET /v1/GB/solar/sites/generation/period?site_ids=…
GET /sites/{uuid}/pv_forecast GET /v1/GB/solar/sites/{site_id}/forecast
GET /sites/pv_forecast?site_uuids=… GET /v1/GB/solar/sites/forecasts/period?site_ids=… horizon_minutes keeps its name
GET /sites/{uuid}/clearsky_estimate GET /v1/GB/solar/sites/{site_id}/clearsky
GET /sites/clearsky_estimate?site_uuids=… GET /v1/GB/solar/sites/clearsky?site_ids=… the v0 route has no auth dependency; the v1 one does
GET /api_status β€” not ported

Deliberate omissions:

  • compact β€” the columnar matrix is the only multi-site format, and it is already compact.
  • sum_by β€” see aggregate, specified separately.
  • The /delete/ path segment in DELETE /sites/delete/{uuid}.

Breaking changes for clients (but this is a "new" API, so this is fine!)

  • snake_case throughout; kw β†’ kW
  • expected_generation_kw / actual_generation_kw β†’ power_kW
  • target_datetime_utc / datetime_utc β†’ time_utc
  • UUIDs are UUID-typed, not strings
  • country and source now in the path, so every URL changes
  • 15-minute site data where a client may have assumed 30

What must be provably true

These are testable things we'll want to ensure are the case before we deploy, and there are def more than listed here!

Tenancy (the ones that matter most):

  • A caller whose token carries no company claim gets a 403 from every site route, never an empty list.
  • A caller requesting another company's site by UUID cannot distinguish the response from one for a UUID that does not exist anywhere.
  • Two callers from different companies making the identical request never receive each other's data, including on a second request when the first was cached.
  • An admin's response is never served to a non-admin from cache, and the reverse.
  • No site route can reach the storage layer unscoped β€” including a route added later by someone who has not read this document.

Behaviour:

  • Over-cap requests return 400 and never a truncated list.
  • List responses are in the same order for the same input.
  • A partial PUT leaves unmentioned fields, including metadata, unchanged.
  • Posting generation and reading it back returns the values that were sent.
  • A site with no stored status behaves as "active".

Reference

v1 region routes src/quartz_api/internal/service/v1/routes/
v1 response models and param types src/quartz_api/internal/service/v1/endpoint_types.py
Country / model / observer config src/quartz_api/internal/service/v1/country_config.py
Cache key builder and pre-warming src/quartz_api/internal/service/v1/cache.py
Storage interface src/quartz_api/internal/models/db_interface.py
Data Platform client src/quartz_api/internal/backends/dataplatform/client.py
Auth claims handling src/quartz_api/internal/middleware/auth.py
Working tenant-scoped example src/quartz_api/internal/service/sites/router.py
Tests and fixtures src/quartz_api/internal/service/v1/test_router.py
uv run quartz-api                                    # /v1/docs for the rendered schema
pytest src/ --ignore=src/quartz_api/internal/backends/quartzdb/ \
            --ignore=src/quartz_api/tests/integration/
ruff check src/

Anything here that seems wrong, or that would need rewriting to add a second country, is worth raising before building around it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions