Skip to content

🎯 Quartz API v1 #199

@braddf

Description

@braddf

This will be deployed on a versioned URL to migrate user to new combined and rationalised API spec gradually.

Epic: v1 API β€” Multi-country Unified API

Overview

This epic covers the design and initial build of the v1 API β€” a unified, multi-country replacement for the existing v0 UK-only solar API. The v1 introduces a country-first URL scheme (/{country}/{source}/…), UUID-based region identifiers, consistent kW units throughout, and a clean separation between per-region time-series, multi-region snapshots, and a pre-warmed period matrix for large 2D data to be served without overwhelming the Data Platform or slowing down this API.

The v1 runs as an independent FastAPI sub-app mounted at /v1, with its own Scalar documentation UI, Auth0-integrated OAuth2 flow, and a config-driven cache architecture designed for future Redis migration.
Where possible, new routes are mapped 1:1 against their v0 equivalents to ensure easy-as-possible client migration.

Note

Current state: Core discovery, per-region, snapshot, and period matrix endpoints are all live with a new unit test suite. Auth passthrough to the Data Platform and the delta/accuracy family of endpoints remain outstanding.


Capabilities

Discovery

Preflight endpoints for clients to self-describe what's available before making data requests.

Route Description
GET /v1/sources List energy sources (solar, wind)
GET /v1/countries List countries with full capability manifest (region types, models, generation sources)
GET /v1/{country}/{source}/region-types Region types for a country, each with available forecast models
GET /v1/{country}/{source}/generation-sources Observed generation data sources for a country

Note: /countries now returns a full capability manifest including region types, forecast models, and generation sources for every country. The more granular routes (/region-types, /generation-sources) predate this and overlap with it β€” whether they are still needed as standalone endpoints is an open question (see below). /sources is similarly simple enough to question.

Regions

Route Description
GET /v1/{country}/{source}/regions List all regions of a given type (?region_type=gsp), or children of a parent (?parent_id=…)
GET /v1/{country}/{source}/regions/{region_id} Metadata for a single region

Forecasts

Route Description
GET /v1/{country}/{source}/regions/{region_id}/forecast Forecast timeseries for a single region
GET /v1/{country}/{source}/regions/{region_id}/forecast/last-updated Creation time of the most recent forecast (cached 10s)
GET /v1/{country}/{source}/forecasts/snapshot All regions at one timestamp (region_type required, cached 2min)
GET /v1/{country}/{source}/forecasts/period All regions across a time window β€” served from pre-warmed cache
POST /v1/{country}/{source}/forecasts/refresh Trigger background cache re-warm (requires ocf:admin)

Generation (observed)

Route Description
GET /v1/{country}/{source}/regions/{region_id}/generation Observed timeseries for a single region
GET /v1/{country}/{source}/generation/snapshot All regions at one timestamp (region_type required, cached 2min)
GET /v1/{country}/{source}/generation/period All regions across a time window β€” served from pre-warmed cache
POST /v1/{country}/{source}/generation/refresh Trigger background cache re-warm (requires ocf:admin)

Countries currently configured

Code Nation Region types Forecast models
GB UK national, gsp, dno blend, blend_adjust, pvnet_intraday, pvnet_day_ahead, pvnet_ecmwf, pvnet_ukv_only, pvnet_sat_only
NL Netherlands national, netbeheerder nl_regional_pv_ecmwf_mo_sat_adjust and variants, ned_nl_national

Architecture decisions

v1 is a separate FastAPI sub-app, not just extra routes

v1 is mounted as an independent FastAPI instance at /v1 via server.mount("/v1", v1_app). This gives it:

  • Its own OpenAPI schema at /v1/openapi.json
  • Its own docs UI at /v1/docs β€” independently configured, independently styled, free to diverge completely from v0
  • Its own middleware chain β€” rate limiting, auth, and caching all configured independently
  • Clean separation: v0 routes can be removed later without touching v1 code

Beyond the technical benefits, treating v1 as its own app means documentation is a first-class concern, not bolted on. We can customise the schema, branding, and interactive experience for v1 without being constrained by what v0 does. The choice to use Scalar for v1 is one example of this; deeper customisation (custom themes, curated examples, branded landing page(s)) becomes straightforward.

The trade-off is slightly more startup complexity (dependency overrides and lifespan management applied twice). This is contained in main.py.

Country-first URL structure

All v1 routes follow /{country}/{source}/… rather than /forecasts/GB/solar/…. This mirrors the potential Auth0 permission/scopes naming convention (gb_solar_regional) and makes country the primary commercial subscription boundary β€” a client's token scope maps naturally to their URL prefix.

UUIDs as primary region identifiers

v1 uses UUIDs throughout for region IDs. The GET /regions?region_type=gsp endpoint returns UUID-based RegionDetail objects; clients should store the UUID and use it for all subsequent calls. A national slug alias is also accepted on per-region routes as a convenience.

v0 used integer gsp_id values. How to best help clients that still think in integers β€” or the official Elexon GSP ID strings β€” is an open question (see below).

Consistent units: kW throughout

v0 mixed MW (forecast values) and kW (PV Live observed values). v1 uses kW for everything β€” forecasts, observed generation, and capacity figures. Field name is always power_kW.

Snapshot and timeseries are separate routes, not a Union

/forecasts/snapshot and /forecasts/timeseries are distinct endpoints with distinct response types, distinct cache strategies, and non-overlapping use cases:

/forecasts/snapshot /forecasts/timeseries
Shape One time Γ— N regions N regions Γ— M times
Cache 2min TTL, live DP call Pre-warmed per-region keys, 24h TTL
Cold cache Returns live data Returns 503 + Retry-After: 60
Model filter Supported Not supported (serves pre-warmed data only)

They were not merged into one endpoint with a Union return type β€” the response shapes are fundamentally different and the caching requirements are incompatible.

Pre-warmed timeseries cache with per-region keys

The /forecasts/period and /generation/period endpoints serve data entirely from cache β€” no live DP calls per request. On startup, _warm_all_v1_caches fetches a 4-day window for every region and writes one cache key per region UUID.

Storing data per-region (rather than one large blob) means:

  • Sub-window queries β€” a request for a shorter time range or a subset of regions is resolved in-memory by filtering the cached values; no re-fetch needed
  • Partial updates β€” individual region keys can be refreshed without invalidating the whole dataset
  • Redis-ready β€” N keys can be fetched in a single pipeline round-trip when we move off InMemoryBackend

Cache warming targets are derived entirely from country config β€” adding a new country or observer requires only a config change, no code changes.

Config-driven country and region type definitions

All country/region/model metadata lives in service/v1/country_config.py. Each RegionTypeConfig declares:

  • Its user-facing type slug and label
  • Its internal LocationType (maps to the DP's location hierarchy)
  • The forecast_models available for that region type
  • A default_model applied when no model param is passed

A central FORECASTER_LABELS dict maps DP forecaster names to human-readable labels. This keeps the DP's internal naming out of API responses and makes label changes a one-line edit.

Scalar for v1 docs, Swagger for v0

v1 uses Scalar at /v1/docs. v0 keeps Swagger at /swagger and ReDoc at /docs. OAuth2 PKCE is configured via x-scalar-secret-client-id on the security scheme flow, with audience embedded in the authorizationUrl for Auth0 compatibility.


v0 β†’ v1 migration map

v0 route v1 equivalent Notes
GET /v0/solar/GB/national/forecast GET /v1/GB/solar/regions/national/forecast ?model=blend_adjust for trend-adjusted
GET /v0/solar/GB/national/pvlive GET /v1/GB/solar/regions/national/generation ?observer=pvlive_in_day or pvlive_day_after
GET /v0/solar/GB/gsp/{id}/forecast GET /v1/GB/solar/regions/{uuid}/forecast Integer ID β†’ UUID; see open question on ID compatibility
GET /v0/solar/GB/gsp/{id}/pvlive GET /v1/GB/solar/regions/{uuid}/generation
GET /v0/solar/GB/gsp/forecast/all/ GET /v1/GB/solar/forecasts/snapshot?region_type=gsp Replaces both compact and non-compact variants
POST /v0/solar/GB/gsp/forecast/all/refresh POST /v1/GB/solar/forecasts/refresh?region_type=gsp Warms timeseries matrix cache
GET /v0/solar/GB/gsp/pvlive/all GET /v1/GB/solar/generation/snapshot?region_type=gsp
GET /v0/system/GB/gsp/ GET /v1/GB/solar/regions?region_type=gsp Returns UUID-based RegionDetail

Intentionally not ported (for now)

  • compact param β€” ForecastSnapshot is compact by design
  • smooth_flag β€” not applicable for DP backend
  • GET /v0/solar/GB/status β€” separate concern, no v1 equivalent planned

Open questions / decisions still to make

1. trend_adjuster_on compatibility
v0 had a trend_adjuster_on boolean (default true) that silently swapped the underlying "model". An idea in v1 is to make this explicit: ?model=blend_adjust vs ?model=blend.
This is more explicit, but emphasises the difference, and also maybe highlights the adjuster more than we might want, poss inviting more questions...

Decisions needed:

  • Do we actually want clients to be able to request the unadjusted output?
  • Do we retain trend_adjuster boolean behaviour in v1, or adopt a parallel-model framing to keep things distinct.

2. Integer / string GSP ID compatibility
v0 clients reference regions by integer gsp_id. v1 uses UUIDs as the primary identifier. Some options for easing migration:

  • Accept integer gsp_id as an alias on per-region routes (look up UUID internally)
  • Accept official NESO GSP ID strings (e.g. ABHA1 format) as an alias
  • Expose gsp_id in region metadata so clients can build their own lookup once
  • Document the UUID lookup flow and require clients to migrate fully

3. Auth passthrough to DP
Five places in the v1 routes pass authdata={} to DP calls. Auth enforcement is currently on our side only. Needs coordination with the DP team before tightening.

4. NL generation sources and forecast models
NL has a nednl generation source configured but model availability needs confirming against live DP data.

5. CSV export
v0 had a CSV variant on the legacy regions router. Not in v1 yet. Is there a UI or downstream use case that needs it? If yes, which endpoints should support it?

6. Delta endpoints (Not for launch)
Not built yet. Three new endpoints could be added:

  • GET /regions/{id}/delta β€” per-timestep forecast vs actuals (client-side join; no DP primitive in SDK 0.25.0)
  • GET /regions/{id}/delta/weekly β€” week-average error per horizon (DP-native via GetWeekAverageDeltas, unblocked today)
  • GET /deltas?region_type=… β€” snapshot of forecast-vs-actual across all regions
  • GET /regions/{id}/delta/horizons β€” compare two forecast horizons

7. Discovery route redundancy
/countries now returns a full capability manifest β€” region types, forecast models, and generation sources β€” making the more granular /region-types and /generation-sources routes largely redundant for any client that calls /countries first. /sources is similarly thin. Options:

  • Remove /region-types, /generation-sources, and /sources and point clients to /countries
  • Keep them as convenience endpoints for targeted queries (avoids parsing the full countries payload)
  • Retain for now, revisit once we have real client feedback on usage patterns

8. Wind source
GET /sources already returns wind but no country has wind region types configured. Placeholder for when DP wind data is available.

9. Archival / Historic data
Particularly for regional-level data but also National, what limit backwards should users be able to get?
How large a date range in a single query?
What rate-limiting on these sorts of requests?

Metadata

Metadata

Assignees

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