Contracts API
Contracts
Defines the "data contracts": the schemas defining the precise shape of each data source and its semantics.
It also owns the thin configuration layer that sits beside those schemas: the CV fold config,
and the class_target/import_class pair that turns a class into a _target_ string and
back. Both are model-agnostic and need nothing heavier than pydantic and PyYAML.
Dependency Isolation
This package is designed to be extremely lightweight. It defines the shape of the data using Patito and Polars, but it does not contain any ML-specific logic or heavy dependencies like MLflow. This ensures that any component in the system (e.g., a data ingestion script or a dashboard) can import these schemas without bringing in the entire ML stack.
Key Data Contracts
PowerTimeSeries: Half-hourly power observations (MW or MVA) pertime_series_id, as received from NGED.TimeSeriesMetadata: Substation and customer meter metadata, including lat/lon, H3 index, and asset type (primary substation, GSP, BSP, solar PV, wind, BESS, etc.).Nwp: ECMWF ENS NWP weather data in physical units (Float32), on disk and in memory alike. The on-disk copy is rounded to a 13-bit significand and laid out for compression and row-group pruning bydelta_store.nwp.AllFeatures: The final joined dataset passed to ML models. Primary key is(time_series_id, power_fcst_init_time, valid_time[, ensemble_member]). Includes NWP weather variables, power lag/rolling features and datetime features.time_series_typeis the one metadata column it can carry, and only when a feature set asks for it.PowerForecast: ML model output schema.power_fcstis in MW (active power) or MVA (apparent power), with the unit given pertime_series_idinTimeSeriesMetadata. A planned change will normalise it to [−1, +1] for NGED to multiply by a capacity — see Forecast Building Blocks. Includespower_fcst_model_name,power_fcst_model_version,power_fcst_init_time,nwp_init_time,valid_time,time_series_id, andensemble_member.
Design Principles
- The contract is the authoritative account of what the data means. It says what the data
should be, not what some current code path happens to produce. So when code and contract
disagree, the code is the first suspect: a null the contract forbids usually means an upstream
join kept a row it should have dropped, or a caller passed input it should have rejected.
Widening a field to
| None, or relaxing a range, so that a failingvalidate()passes buries that defect in the one place the rest of the system trusts. Fix the code instead, and change the contract only when you can say what the data now means and why that meaning is right. Get the change agreed before making it, including a widening that looks like a formality — every reader ofcontractsis relying on it to still mean what it said yesterday. - Column naming: Prefer
snake_case, except for acronyms or SI units. Capitalise "DER" (distributed energy resource) and use uppercase for "MW" (megawatts). - Semantic checks: Range validation should be generous — the aim is to catch physically impossible values (e.g., 1 GW from a 1 MW solar farm), not possible-but-unlikely values.
- Datetime ranges: Timestamps on the columns where external data enters —
PowerTimeSeries.time,Nwp.init_timeandNwp.valid_time— are bounded to[MIN_PLAUSIBLE_DATETIME, MAX_PLAUSIBLE_DATETIME](2000-01-01 to 2100-01-01, inclusive), which rejects a corrupt feed or an epoch-unit mix-up without ever excluding a real reading. The check lives in each model'svalidateoverride viacheck_datetime_bounds, because Patito silently ignoresge/leon a datetime field — it derives its bounds checks from the JSON schema'sminimum/maximum, which JSON Schema defines for numbers only. Columns on our own output schemas (PowerForecast,EffectiveCapacity,AllFeatures) have not opted in: they are computed from already-bounded inputs rather than received from outside. - Degrade, don't abort, at an ingestion boundary:
validate()stays strict everywhere — it is also used as a hard assertion in tests and R&D code, where a raise-on-violation contract must not silently change. But a single malformed row from an external feed should not abort ingestion of every other well-formed row in the batch, soPowerTimeSeries.drop_implausible_rows()filters out rows with an out-of-range or minute-misalignedtimebeforevalidate()runs, returning the survivors plus a count of what was dropped. Only the NGED JSON ingestion path (nged_data.read_nged_json) calls it; the duplicate/sortedness checks invalidate()are never relaxed, because those indicate a bug in our own pipeline rather than malformed external data. - No lookahead bias:
AllFeaturescarriespower_fcst_init_time(when we make the forecast) as a distinct field fromnwp_init_time(when the NWP model ran). Power lag features are nullified bynullify_leaky_lags()when the lag is shorter than or equal to the forecast lead time.
contracts.common
Shared building blocks for the Patito schemas.
The canonical UTC dtype, the plausible-datetime bounds and the checks that enforce them, and the delivery quantile levels.
Attributes
UTC_DATETIME_DTYPE = pl.Datetime(time_unit='us', time_zone='UTC')
module-attribute
MIN_PLAUSIBLE_DATETIME = datetime(2000, 1, 1, tzinfo=UTC)
module-attribute
The earliest timestamp a bounded datetime column may carry (inclusive).
A column is bounded when its model's validate passes it to :func:check_datetime_bounds; the
constant says nothing about columns that have not opted in.
NGED telemetry cannot predate the instrumentation that produced it, and the ECMWF archive we
ingest begins later still, so no legitimate row is older than this. The bound is also deliberately
far later than 1847: Europe/London ran on local mean time at UTC−0:01:15 until then, so a
pre-1848 timestamp produces a sub-minute UTC offset and a nonsensical value for every local-time
feature. Enforcing this bound is what guarantees the local-time features in ml_core never see
a sub-minute UTC offset.
MAX_PLAUSIBLE_DATETIME = datetime(2100, 1, 1, tzinfo=UTC)
module-attribute
The latest timestamp a bounded datetime column may carry (inclusive).
This is a fixed date rather than an offset from the current time, so validation never depends on the wall clock: a frame that validated when it was written still validates when it is read back years later, and tests need no clock control. A fixed far-future bound still catches an epoch-unit mix-up, where Unix milliseconds read as seconds land tens of thousands of years in the future — the plausible failure on any path that converts a numeric timestamp rather than parsing an ISO-8601 string. It deliberately does not catch a small clock skew that ships tomorrow's data as today's; that is a monitoring concern, not a contract one.
DELIVERY_QUANTILES = (0.01, 0.02, 0.05, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.95, 0.98, 0.99)
module-attribute
The thirteen quantile levels agreed with NGED for the delivery tables.
Deliberately tail-heavy: NGED is far more interested in the tails than the shoulders. This
tuple is the single source of truth for every quantile-indexed artefact — the pinball-loss
metric_param labels today, and the percentile columns of the delivery-table
representations (Representations 2 and 3) when those land in v0.5.
Functions:
check_datetime_bounds(dataframe, column, *more_columns)
Raise ValueError if any timestamp lies outside the plausible-datetime range.
Call this from a Patito model's validate override, after super().validate(). It exists
because Patito silently ignores ge/le on a datetime field: Patito derives its bounds
checks from the Pydantic JSON schema's minimum/maximum keywords, which JSON Schema
defines for numbers only, so a datetime field's Ge/Le metadata never reaches the JSON
schema and no check is ever generated. (ge/le on a numeric field works normally, which
is why PowerTimeSeries.power can state its bounds on the field itself.)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataframe
|
DataFrame
|
An already-validated frame. Every named column must be a datetime column. |
required |
column
|
str
|
Name of a datetime column to bound. |
required |
*more_columns
|
str
|
Names of any further datetime columns to bound. |
()
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If any value is before :data: |
Source code in packages/contracts/src/contracts/common.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | |
split_by_datetime_plausibility(dataframe, column)
Partition dataframe into (plausible, implausible) rows by column.
A row is implausible when its column value is before :data:MIN_PLAUSIBLE_DATETIME or
after :data:MAX_PLAUSIBLE_DATETIME — the same bounds :func:check_datetime_bounds enforces.
Nulls are always plausible (absence is not malformedness).
Use this at an ingestion boundary to drop-and-report malformed external rows instead of
aborting the whole batch; use :func:check_datetime_bounds where a hard assertion is
appropriate instead (e.g. inside a Patito model's validate).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataframe
|
DataFrame
|
Any frame; |
required |
column
|
str
|
Name of the datetime column to test. |
required |
Returns:
| Type | Description |
|---|---|
tuple[DataFrame, DataFrame]
|
|
Source code in packages/contracts/src/contracts/common.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
quantile_label(quantile)
Return the canonical p{level} label for a quantile, e.g. 0.05 → "p5".
The label format matches the percentile column names agreed with NGED for the delivery
tables (see DELIVERY_QUANTILES).
Source code in packages/contracts/src/contracts/common.py
151 152 153 154 155 156 157 | |
validate_schema(model, df)
Validate a Polars DataFrame's or LazyFrame's schema against a Patito model.
Raises DataFrameValidationError on failure. On LazyFrames this materializes no data; it just
calls collect_schema().
Source code in packages/contracts/src/contracts/common.py
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | |
contracts.power_schemas
Data schemas for the NGED substation forecast project.
Attributes
LIST_OF_TIME_SERIES_TYPES = ('BESS', 'Biofuel', 'CHP', 'Data Centre', 'Disaggregated Demand', 'Energy from Waste', 'EV Charging', 'Geothermal', 'Hydro', 'Hydrogen Electrolysis', 'Industrial Demand', 'Mixed (Demand)', 'Mixed (Generation)', 'Other (Demand)', 'Other (Generation)', 'Other (Storage)', 'Peaking Plant', 'PV', 'Rail', 'Raw Flow', 'Synchronous Condenser', 'Wind')
module-attribute
All time-series type values used in NGED data.
Types present in the V1 trial area: BESS, Biofuel, Disaggregated Demand, Other (Generation), PV, Raw Flow, Wind.
Notes:
- BESS: Battery energy storage system.
- Disaggregated Demand: In the trial area, exclusively associated with "Primary" substations. All "Primary" substations in the trial area have their TimeSeriesType set to "Disaggregated Demand". Indicates that NGED have already removed metered generation connected to that primary.
- Raw Flow: Used for BSP and GSP substations.
FoldId = str
module-attribute
Fold identifier for PowerForecast.fold_id.
A CV fold's id is a short label defined in conf/cv/default.yaml (e.g.
"mid_2025_to_mid_2026"); fold identity is config-driven, never hard-coded here. "live" is
the reserved sentinel for a production forecast that belongs to no CV fold.
Classes
DropImplausibleRowsResult
Bases: NamedTuple
Result of PowerTimeSeries.drop_implausible_rows.
Source code in packages/contracts/src/contracts/power_schemas.py
20 21 22 23 24 | |
Attributes
survivors
instance-attribute
n_dropped
instance-attribute
PowerTimeSeries
Bases: Model
Half-hourly power observations (MW or MVA), one row per (time_series_id, time).
Source code in packages/contracts/src/contracts/power_schemas.py
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
Attributes
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
time = pt.Field(dtype=UTC_DATETIME_DTYPE, description=f'End time of the 30-minute observation period (all NGED data is already half-hourly). Must fall between {MIN_PLAUSIBLE_DATETIME} and {MAX_PLAUSIBLE_DATETIME} (enforced by `validate`, not by the field, because Patito ignores `ge`/`le` on datetime fields — see `check_datetime_bounds`).')
class-attribute
instance-attribute
power = pt.Field(dtype=(pl.Float32), ge=(-1000), le=1000, description="Average power (MW or MVA) over the preceding 30-minute period. Unit defined in TimeSeriesMetadata. Sign convention depends on `substation_type` in `TimeSeriesMetadata`. At a substation (`BSP`, `GSP`, `Primary`), positive means power flowing towards end-users and negative means excess generation flowing back into the grid. At a customer meter (`EHV Customer`, `HV Customer`), positive means the customer is sending power to NGED's grid and negative means the customer is drawing power from it. Those five values are the whole enum, so every series falls into exactly one case.")
class-attribute
instance-attribute
columns_to_sort_by = ('time_series_id', 'time')
class-attribute
Methods:
validate(dataframe, columns=None, allow_missing_columns=False, allow_superfluous_columns=False, drop_superfluous_columns=False)
classmethod
Validate the given dataframe, ensuring time is plausible, at :00 or :30, and unique.
Source code in packages/contracts/src/contracts/power_schemas.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
drop_implausible_rows(dataframe)
classmethod
Drop rows with a malformed time, returning (survivors, n_dropped).
A row is dropped when its time lies outside the plausible datetime range, is null (the
schema declares time non-nullable, so a null this early is already malformed), or does
not fall on the top or bottom of the hour (minute 00 or 30). All three indicate a
malformed upstream reading — not a bug in our own pipeline — so under inherent
stability
an ingestion boundary should degrade the batch rather than abort it entirely.
This exists alongside validate, which stays strict and raises on the same two rules:
validate is also used as a hard assertion in tests and R&D code, where a
raise-on-violation contract must not silently change. Call this method BEFORE
validate, and only at a boundary that receives data from outside our system (e.g.
NGED's raw JSON feed) — the uniqueness and sortedness checks in validate are NOT
relaxed here, because those indicate a bug in OUR pipeline, not malformed external data,
and should keep raising.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataframe
|
DataFrame
|
An already-cast frame with a |
required |
Returns:
| Type | Description |
|---|---|
DropImplausibleRowsResult
|
|
Source code in packages/contracts/src/contracts/power_schemas.py
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | |
TimeSeriesMetadata
Bases: Model
One row per substation or asset: its name, location, H3 index, and substation type.
Source code in packages/contracts/src/contracts/power_schemas.py
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | |
Attributes
time_series_id = _get_time_series_id_dtype(unique=True)
class-attribute
instance-attribute
time_series_name = pt.Field(dtype=(pl.String), description='Human-readable name for the substation or asset.', examples=['ALFORD 33 11kV S STN', 'BAMBERS FARM WIND GENERATION MABLETHORPE 33kV S ST', 'Leverton Solar Park'])
class-attribute
instance-attribute
time_series_type = pt.Field(dtype=(pl.Enum(LIST_OF_TIME_SERIES_TYPES)), description='Asset category (e.g. ‘PV’, ‘Wind’, ‘Disaggregated Demand’). See LIST_OF_TIME_SERIES_TYPES.')
class-attribute
instance-attribute
units = pt.Field(dtype=(pl.Enum(['MW', 'MVA'])), description='Power unit for this time series: ‘MW’ (active power) or ‘MVA’ (apparent power).')
class-attribute
instance-attribute
licence_area = pt.Field(dtype=(pl.Enum(['EMids'])), description='NGED licence area (for the trail area, this is always ‘EMids’).')
class-attribute
instance-attribute
substation_number = pt.Field(dtype=(pl.Int32), gt=0, lt=1000000, description='Perhaps surprisingly, each customer meter in the NGED trial area has its own substation_number (not one per physical substation).')
class-attribute
instance-attribute
substation_type = pt.Field(dtype=(pl.Enum(['BSP', 'EHV Customer', 'GSP', 'HV Customer', 'Primary'])), description='Substation voltage level / role: BSP, EHV Customer, GSP, HV Customer, or Primary. HV = high voltage. EHV = extra high voltage.')
class-attribute
instance-attribute
latitude = pt.Field(dtype=(pl.Float32), ge=49, le=61, description="Latitude in decimal degrees. For customer time series, gives the location of the substation, not the customer's site.")
class-attribute
instance-attribute
longitude = pt.Field(dtype=(pl.Float32), ge=(-9), le=2, description="Longitude in decimal degrees. For customer time series, gives the location of the substation, not the customer's site.")
class-attribute
instance-attribute
information = pt.Field(dtype=(pl.String), allow_missing=True, description='Free-text NGED notes field; always null in the V1 trial area.')
class-attribute
instance-attribute
area_wkt = pt.Field(dtype=(pl.String), allow_missing=True, description='WKT polygon for the asset’s area. In the trial, only Primary substations have this. NGED don’t have polygons for customer sites (though they hope to add that in future). For customer sites, where present, refers to the area covered by the generator itself.')
class-attribute
instance-attribute
area_center_lat = pt.Field(dtype=(pl.Float32), allow_missing=True, description='Centroid latitude of the area polygon. For customer sites, the area, where present, refers to the area covered by the generator itself.')
class-attribute
instance-attribute
area_center_lon = pt.Field(dtype=(pl.Float32), allow_missing=True, description='Centroid longitude of the area polygon. For customer sites, the area, where present, refers to the area covered by the generator itself.')
class-attribute
instance-attribute
h3_res_5 = pt.Field(dtype=(pl.UInt64), description='H3 discrete spatial index at resolution 5.')
class-attribute
instance-attribute
PowerForecast
Bases: Model
Forecast data schema for deterministic ensemble forecasts.
Internal vs delivered schema (Milestone 1 report Table 1, p.28): the columns
experiment_name, fold_id, and ml_flow_experiment_id are INTERNAL-ONLY —
they exist on this schema and the internal power_forecasts Delta table to support
cross-validation and the leaderboard, but they are NOT part of the power_forecast
table delivered to NGED.
Source code in packages/contracts/src/contracts/power_schemas.py
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 | |
Attributes
valid_time = pt.Field(dtype=UTC_DATETIME_DTYPE, constraints=(pl.col('valid_time') > pl.col('power_fcst_init_time')), description='The target time this forecast is valid for. Constrained to be strictly after power_fcst_init_time: a row targeting a valid time at or before its own initialisation is an undeliverable hindcast row — at power_fcst_init_time that valid time is already observed. The live service only forecasts strictly future valid times and bulk-mode feature engineering drops hindcast rows at source, so a constraint violation here indicates a pipeline regression.')
class-attribute
instance-attribute
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
ensemble_member = pt.Field(dtype=(pl.Int8), description='Ensemble member index. 0 is the control NWP ensemble member.')
class-attribute
instance-attribute
ml_flow_experiment_id = pt.Field(dtype=(pl.Int32), allow_missing=True, description='MLflow experiment ID; links to the MLflow experiment that produced this forecast.')
class-attribute
instance-attribute
nwp_init_time = pt.Field(dtype=UTC_DATETIME_DTYPE, allow_missing=True, description='The datetime that the underlying weather forecast was initialised. Null for models that do not use NWP (e.g. persistence baselines).')
class-attribute
instance-attribute
power_fcst_model_name = pt.Field(dtype=(pl.String), description='Identifier for our ML-based power forecasting model. Model-family identity set by the BaseForecaster subclass (MODEL_NAME).')
class-attribute
instance-attribute
experiment_name = pt.Field(dtype=(pl.String), description='Per-experiment key identifying the experiment that produced this forecast. Distinct from `power_fcst_model_name`, which is the model-family identity (`MODEL_NAME`); do not overload that with experiment identity. Forecasts are partitioned in Delta by (experiment_name, fold_id). INTERNAL-ONLY: projected out of the `power_forecast` table delivered to NGED.')
class-attribute
instance-attribute
power_fcst_model_version = pt.Field(dtype=(pl.Int16), description='Model version integer, bumped with each breaking change to the model implementation.')
class-attribute
instance-attribute
power_fcst_init_time = pt.Field(dtype=UTC_DATETIME_DTYPE, description='The datetime that the power forecast was initialised. This might be called `t0` in some other OCF projects.')
class-attribute
instance-attribute
power_fcst = pt.Field(dtype=(pl.Float32), description="The power forecast itself in units of MW (active power) or MVA (apparent power). The unit is defined in the `TimeSeriesMetadata` for this `time_series_id`. Sign convention depends on `substation_type` in `TimeSeriesMetadata`. At a substation (`BSP`, `GSP`, `Primary`), positive means power flowing towards end-users and negative means excess generation flowing back into the grid. At a customer meter (`EHV Customer`, `HV Customer`), positive means the customer is sending power to NGED's grid and negative means the customer is drawing power from it. Those five values are the whole enum, so every series falls into exactly one case. Rows read back from the internal `power_forecasts` Delta table carry reduced precision: values are rounded to a 13-bit significand at write time (max relative error 2^-13 ≈ 1.2e-4, far below forecast error) to aid compression; see `delta_store.power_forecasts`.")
class-attribute
instance-attribute
fold_id = pt.Field(dtype=(pl.String), description="Identifies the source of this forecast row. For cross-validation runs, the value is the fold's label from conf/cv/default.yaml (e.g. 'mid_2025_to_mid_2026'). 'live' means a production forecast with no associated CV fold. All forecasts — CV and live — live in the same Delta table; filter on this column to select the population you need.")
class-attribute
instance-attribute
PRIMARY_KEY = ('time_series_id', 'power_fcst_init_time', 'valid_time', 'ensemble_member')
class-attribute
At most one forecast per series, per init time, per target time, per ensemble member.
Methods:
validate(dataframe, columns=None, allow_missing_columns=False, allow_superfluous_columns=False, drop_superfluous_columns=False)
classmethod
Validate the given dataframe, ensuring the primary key is unique.
A duplicated primary key means either a join fanned out on the way here or the same rows were written twice, and both corrupt the metrics computed from them. It is our own bug rather than the outside world misbehaving, so this raises rather than degrading — see https://openclimatefix.github.io/nged-substation-forecast/design-philosophy/inherent-stability/.
Source code in packages/contracts/src/contracts/power_schemas.py
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 | |
EffectiveCapacity
Bases: Model
Effective capacity of each time series at each half-hourly timestep.
Delivered to NGED as effective_capacity Delta table (Table 4 in the Milestone 1 report).
This table is backward-looking only — it does not cover the forecast period.
v0.1 implementation: one row per time_series_id, time set to the end of
the available observation history, effective_capacity_mw = P99 of abs(power) over
the full observed history. This is a static scalar per series.
Planned upgrade (v0.7): replace the P99 scalar with a time-varying capacity estimate
(see
https://openclimatefix.github.io/nged-substation-forecast/techniques/convex-optimisation/ and
https://openclimatefix.github.io/nged-substation-forecast/techniques/differentiable-physics/
for the candidate estimation methods),
giving one row per (time_series_id, time) half-hourly timestep. This schema is unchanged;
the effective_capacity asset body changes and the metrics pipeline swaps its
time_series_id-only NMAE-denominator join for a temporal as-of join. Do not pre-densify
the v0.1 scalar into one row per half-hour — densifying a constant buys nothing, and the as-of
join handles sparse capacity rows naturally.
Source code in packages/contracts/src/contracts/power_schemas.py
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | |
Attributes
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
time = pt.Field(dtype=UTC_DATETIME_DTYPE, description='The half-hourly timestep this capacity estimate applies to. In v0.1, this is the end of the available observation history for that series.')
class-attribute
instance-attribute
effective_capacity_mw = pt.Field(dtype=(pl.Float32), gt=0, description="OCF's estimate of the effective capacity (MW) of this asset at this timestep. For generators: absorbs PV panel degradation, partial inverter trips, etc., but ignores ANM curtailment — a wind farm ANM-capped at 5 MW with 10 MW physical capability has effective_capacity_mw = 10. For substations: the 99th percentile of observed load over a rolling time window, under normal running arrangement only. 'Switched' power (Table 5) should be added or subtracted when a switching event is in effect.")
class-attribute
instance-attribute
Functions:
contracts.weather_schemas
Contracts for numerical weather prediction data.
The Nwp frame as stored and read, plus the run-completeness and data-quality reports that assess
an ingested ECMWF ENS run.
Attributes
WeatherFeature = Literal['temperature_2m', 'dew_point_temperature_2m', 'wind_speed_10m', 'wind_direction_10m', 'wind_speed_100m', 'wind_direction_100m', 'pressure_surface', 'pressure_reduced_to_mean_sea_level', 'geopotential_height_500hpa', 'downward_long_wave_radiation_flux_surface', 'downward_short_wave_radiation_flux_surface', 'precipitation_surface', 'categorical_precipitation_type_surface']
module-attribute
ECMWF_ENS_ENSEMBLE_MEMBERS = frozenset(range(51))
module-attribute
The ensemble members every ECMWF IFS ENS run carries: the control member plus 50 perturbed
members, indexed 0-50 by Dynamical.org (matching Nwp.ensemble_member, which is 0-based).
The count is a fixed property of the ECMWF IFS ENS configuration, corroborated by our own data: the 2026-07-14 incident described in https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/ lost a forecast step for "50 of 51 ensemble members", and the per-partition row arithmetic in https://openclimatefix.github.io/nged-substation-forecast/architecture/performance/ is 1671 H3 cells x 51 ensemble members x 85 native steps.
ECMWF_ENS_LEAD_TIME_HOURS = tuple(range(0, 145, 3)) + tuple(range(150, 361, 6))
module-attribute
The 85 native forecast steps of the ECMWF IFS ENS 15-day 0.25-degree dataset, in hours after
init_time: 3-hourly out to 144 h (49 steps, lead-0 inclusive), then 6-hourly out to 360 h
(36 steps) — i.e. init_time to init_time + 15 days inclusive.
Lead-0 is included: it is a real step, distinguished only by the de-accumulated variables being
legitimately null there (see Nwp.deaccumulated_var_names).
ECMWF_ENS_H3_RESOLUTION = 5
module-attribute
The H3 spatial resolution the ecmwf_ens ingest pipeline grids ECMWF ENS data to, and the
resolution power_time_series_and_metadata computes for every time series so the two line up on
one grid. Every NWP model shares this one resolution today; per-model resolution (so a future NWP
source can use a different one) is tracked in
https://github.com/openclimatefix/nged-substation-forecast/issues/114.
Classes
NwpModelId
Bases: StrEnum
The NWP models we ingest, used as the nwp_model_id column's vocabulary.
Source code in packages/contracts/src/contracts/weather_schemas.py
40 41 42 43 | |
Attributes
ECMWF_ENS_0_25_degree = auto()
class-attribute
instance-attribute
NwpVariableWhollyMissing
Bases: ValueError
A de-accumulated NWP variable is null in every slice beyond lead-0 of a run.
The field is wholesale absent rather than locally corrupt, so Nwp.validate rejects it: an
all-null weather column would otherwise train and serve as a silently-degraded input for the
full 15-day horizon, which is worse than falling back on the previous run.
A distinct exception type rather than a bare ValueError because the ecmwf_ens asset
retries it. An upstream publication still in progress can present this way — a variable's
chunks read as fill-value null until the worker writing them commits — so waiting is a better
first response than failing the partition. Note the limit of that: it only reaches this check
when the unwritten variables are the de-accumulated ones. The nine instantaneous variables are
non-nullable, so a frame missing one of those is rejected by base Patito validation first, with
no retry — as is an all-null categorical_precipitation_type_surface, which is nullable but
carries its own historical invariant. See
https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/.
Source code in packages/contracts/src/contracts/weather_schemas.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | |
Nwp
Bases: Model
Weather data schema for NWP forecasts.
Gridded ECMWF ENS ensemble weather, one row per (nwp_model_id, init_time, valid_time, ensemble_member, h3_index).
Stored on disk as plain Float32, rounded to a significand-bit budget by
delta_store.nwp.write_nwp — see
https://openclimatefix.github.io/nged-substation-forecast/architecture/overview/ for the
physical format and measured numbers.
validate is the fatal ingest gate; assess_nwp_quality reports the tolerated nulls in the
de-accumulated variables (the known upstream ECMWF ENS corruption) and
assess_nwp_run_completeness reports a run that is missing whole members, steps or cells —
both as non-fatal checks, because both are the upstream provider misbehaving rather than a
contract violation. Which patterns are fatal versus tolerated, and why, is documented at
https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/.
Source code in packages/contracts/src/contracts/weather_schemas.py
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | |
Attributes
nwp_model_id = pt.Field(dtype=(pl.String), constraints=(pl.col('nwp_model_id').is_in([(model.name) for model in NwpModelId])), description="Which NWP model produced this row (e.g. 'ECMWF_ENS_0_25_degree').")
class-attribute
instance-attribute
init_time = pt.Field(dtype=UTC_DATETIME_DTYPE, description='When the NWP model run was initialised.')
class-attribute
instance-attribute
valid_time = pt.Field(dtype=UTC_DATETIME_DTYPE, description="The time for which this NWP value is valid. Most variables (temperature, wind, pressure, geopotential) are instantaneous — they describe conditions at this moment. Precipitation and radiation (downward_long_wave_radiation_flux_surface, downward_short_wave_radiation_flux_surface, precipitation_surface) are period-ending rates: each value represents the average rate over the period that ends at valid_time (i.e. the preceding forecast step interval). Dynamical.org de-accumulates these from ECMWF's raw cumulative fields before we receive them. Resampling must honour that distinction — the step interval is 3 h out to lead 144 h and 6 h beyond it, so treating a period-ending value as instantaneous shifts it by up to 3 h. Conventions for every variable, and what the shift costs: <https://openclimatefix.github.io/nged-substation-forecast/architecture/nwp-variable-conventions/>")
class-attribute
instance-attribute
ensemble_member = pt.Field(dtype=(pl.Int8), description='Ensemble member index (0-based).')
class-attribute
instance-attribute
h3_index = pt.Field(dtype=(pl.Int64), description='H3 cell index, at `ECMWF_ENS_H3_RESOLUTION` (every NWP model shares it today).')
class-attribute
instance-attribute
temperature_2m = pt.Field(dtype=(pl.Float32), description='Air temperature at 2 m above ground. Unit: degrees C.', ge=(-100), le=100)
class-attribute
instance-attribute
dew_point_temperature_2m = pt.Field(dtype=(pl.Float32), description='Dew-point temperature at 2 m. Unit: degrees C.', ge=(-100), le=100)
class-attribute
instance-attribute
wind_speed_10m = pt.Field(dtype=(pl.Float32), description="Wind speed at 10 m. Unit: meters per second. This is the magnitude of the cell's *vector* mean wind, not the mean of its grid points' scalar speeds. See <https://openclimatefix.github.io/nged-substation-forecast/architecture/nwp-variable-conventions/#wind-is-stored-as-speed-and-direction-and-why>", ge=0, le=200)
class-attribute
instance-attribute
wind_direction_10m = pt.Field(dtype=(pl.Float32), description='Wind direction at 10 m. The angle where the wind is coming from. Degrees. 0° is North; 90° is East. This is a *circular* quantity: 359° and 1° are two degrees apart, so interpolating, averaging, differencing or taking a quantile of it as if it were an ordinary number is wrong wherever the values straddle North. Convert to components first. See <https://openclimatefix.github.io/nged-substation-forecast/architecture/nwp-variable-conventions/>', ge=0, le=360)
class-attribute
instance-attribute
wind_speed_100m = pt.Field(dtype=(pl.Float32), description='Wind speed at 100 m. Unit: meters per second. This is a vector mean, with the same caveat as `wind_speed_10m`. It is the height that matters most for wind generation, because a turbine power curve responds to the scalar speed at each grid point. See <https://openclimatefix.github.io/nged-substation-forecast/architecture/nwp-variable-conventions/#wind-is-stored-as-speed-and-direction-and-why>', ge=0, le=200)
class-attribute
instance-attribute
wind_direction_100m = pt.Field(dtype=(pl.Float32), description='Wind direction at 100 m. The angle where the wind is coming from. Degrees. 0° is North; 90° is East. This is circular, with the same handling requirement as `wind_direction_10m`.', ge=0, le=360)
class-attribute
instance-attribute
pressure_surface = pt.Field(dtype=(pl.Float32), description='Surface pressure. Unit: Pa.', ge=0, le=200000)
class-attribute
instance-attribute
pressure_reduced_to_mean_sea_level = pt.Field(dtype=(pl.Float32), description='Mean sea-level pressure. Unit: Pa.', ge=0, le=200000)
class-attribute
instance-attribute
geopotential_height_500hpa = pt.Field(dtype=(pl.Float32), description='Geopotential height of the 500 hPa pressure surface. Unit: m.', ge=0, le=10000)
class-attribute
instance-attribute
downward_long_wave_radiation_flux_surface = pt.Field(dtype=(pl.Float32), description='Downward long-wave radiation flux at surface. Note that this variable is all-null for lead time 0. Unit: W m-2.', ge=0, le=1500)
class-attribute
instance-attribute
downward_short_wave_radiation_flux_surface = pt.Field(dtype=(pl.Float32), description='Downward short-wave (solar) radiation flux at surface. Note that this variable is all-null for lead time 0. Unit: W m-2.', ge=0, le=1500)
class-attribute
instance-attribute
precipitation_surface = pt.Field(dtype=(pl.Float32), description='Total precipitation rate at surface, de-accumulated by Dynamical. Note that this variable is all-null for lead time 0. Unit: kg m-2 s-1.', ge=0, le=0.01)
class-attribute
instance-attribute
categorical_precipitation_type_surface = pt.Field(dtype=(pl.Int16), ge=0, le=255, description="This field is always NaN for init_times on and before 2024-11-12, and populated from the 2024-11-13 00Z run onwards (confirmed by direct inspection of the source data). Derived from ECMWF's `ptype` field. See https://codes.ecmwf.int/grib/param-db/260015 0=No precipitation; 1=Rain; 2=Thunderstorm; 3=Freezing rain; 4=Mixed/ice; 5=Snow; 6=Wet snow; 7=Mixture of rain and snow; 8=Ice pellets; 9=Graupel; 10=Hail; 11=Drizzle; 12=Freezing drizzle; 13=Hail (less than 5 mm); 14=Hail (greater than or equal to 5 mm); 15-191=Reserved; 192-254=Reserved for local use; 255=Missing")
class-attribute
instance-attribute
categorical_var_names = frozenset({'categorical_precipitation_type_surface'})
class-attribute
deaccumulated_var_names = frozenset({'precipitation_surface', 'downward_short_wave_radiation_flux_surface', 'downward_long_wave_radiation_flux_surface'})
class-attribute
The variables Dynamical.org de-accumulates from ECMWF's cumulative source fields to rates.
All three are legitimately null at lead-0, and they share a de-accumulation step whose known
upstream corruption leaves further nulls beyond it. Those are tolerated at ingest and reported
by :func:assess_nwp_quality; only a variable null in every slice beyond lead-0 is fatal.
Which corruption patterns arrive, what the H3 aggregation absorbs before they reach a validated
frame, and why the survivors are tolerated:
https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/#nulls-in-the-de-accumulated-variables-tolerated.
Methods:
all_weather_var_names()
classmethod
All meteorological variable field names (continuous + categorical).
Source code in packages/contracts/src/contracts/weather_schemas.py
336 337 338 339 | |
continuous_var_names()
classmethod
Meteorological variable field names suitable for linear interpolation.
Source code in packages/contracts/src/contracts/weather_schemas.py
341 342 343 344 | |
validate(dataframe, columns=None, allow_missing_columns=False, allow_superfluous_columns=False, drop_superfluous_columns=False)
classmethod
Validate the frame.
Bounds init_time/valid_time to the plausible datetime range, rejects a de-accumulated
variable that is wholly missing (smaller null patterns are tolerated), and enforces both
uniqueness and the ptype-introduction invariant.
Source code in packages/contracts/src/contracts/weather_schemas.py
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | |
scan_delta(path=None, storage_options=None)
classmethod
Lazily scan the NWP Delta table, typed and cast to this contract's dtypes.
The table stores physical-unit Float32 directly (see delta_store.nwp.write_nwp),
so no rescale step is needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path | None
|
Path or URI of the |
None
|
storage_options
|
ObjectStoreOptions | None
|
delta-rs object-store options (credentials/endpoint) for a remote
|
None
|
Source code in packages/contracts/src/contracts/weather_schemas.py
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | |
NwpQualityReport
dataclass
Non-fatal data-quality summary for one NWP run.
Carries the tolerated nulls that the de-accumulated variables still hold after the H3 spatial
aggregation, beyond lead-0. Usually that means whole (ensemble_member, valid_time) slices that
arrived empty, plus the cells where upstream scatter happened to take out every contributing
grid point; the two are counted separately because they warrant different responses, but
neither fails the run. Only a variable that is null in every slice is fatal, and
:meth:Nwp.validate rejects that before this runs.
Read this as "how much did we lose", not as "how corrupt was the feed". The aggregation absorbs
most per-pixel upstream corruption before it reaches a cell, so this is a poor proxy for the
upstream null rate. That rate is measured where it lives, on the raw grid, by
:class:dynamical_data.ecmwf_ens.upstream_nulls.UpstreamNullRate; the ecmwf_ens asset
publishes both on one check, and they are not comparable as rates.
Source code in packages/contracts/src/contracts/weather_schemas.py
543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 | |
Attributes
affected
instance-attribute
One row per affected (variable, init_time, ensemble_member, valid_time) slice, with
n_null and n_total cell counts. Empty when the run is clean.
n_null_cells
property
Total null cells across all affected slices.
n_affected_slices
property
Number of (variable, member, valid_time) slices carrying at least one null.
n_whole_null_slices
property
Affected slices that arrived entirely null.
The field is missing altogether for that one (variable, member, valid_time).
Worth watching separately from the scattered case below: a rising count is the shape a partial upstream publication takes, and it is the number this report is best at, because a wholly-null slice reaches the cells intact however they are aggregated.
n_scattered_slices
property
Affected slices carrying only some null cells.
Expect this to be small. Scattered upstream corruption is mostly absorbed by the H3 aggregation, so a slice reaches this count only where the scatter took out every grid point of some cell. It is not a measure of the upstream per-pixel null rate.
is_healthy
property
True when the run has no unexpected nulls.
affected_variables
property
The de-accumulated variables carrying unexpected nulls, sorted.
Methods:
__init__(affected)
NwpRunCompletenessReport
dataclass
Run-level shape summary for one ingested NWP run.
Answers a single question: is the whole (member x step x cell) grid there?
Deliberately a report rather than an exception. A short run is the upstream provider
misbehaving, not a contract violation, and the
never-raise-on-absent-input rule
says we land what arrived and warn, rather than throwing away an otherwise-good run. The
ecmwf_ens asset wraps this into a WARN, non-blocking AssetCheckResult and publishes the
counts as materialisation metadata.
Source code in packages/contracts/src/contracts/weather_schemas.py
626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 | |
Attributes
init_times
instance-attribute
Distinct init_time values in the frame. A whole run has exactly one.
n_rows
instance-attribute
Rows in the frame.
expected_n_rows
instance-attribute
len(expected_ensemble_members) * len(expected_lead_time_hours) * expected_n_h3_cells —
the size of the complete grid.
n_ensemble_members
instance-attribute
Distinct ensemble_member values observed.
n_valid_times
instance-attribute
Distinct valid_time values observed.
n_h3_cells
instance-attribute
Distinct h3_index values observed.
expected_n_h3_cells
instance-attribute
Distinct h3_index values the H3 grid weights say this run should cover.
valid_time_min
instance-attribute
Earliest valid_time, or None for an empty frame.
valid_time_max
instance-attribute
Latest valid_time, or None for an empty frame.
missing_ensemble_members
instance-attribute
Expected members with no rows at all, sorted.
unexpected_ensemble_members
instance-attribute
Observed members outside the expected set, sorted — the upstream ensemble changed shape.
missing_lead_time_hours
instance-attribute
Expected forecast steps (hours after init_time) with no rows at all, sorted.
unexpected_valid_times
instance-attribute
Observed valid_times that are not on any expected forecast step, sorted.
is_complete
property
True when the run carries the shape we expect.
That is, when the frame is one run whose member set, forecast-step set, H3 cell count and row count all match the expectation.
Cells are compared by count, not by set: the report never receives the expected h3_index
values, only how many there should be. Substituting one cell for another would therefore
pass. That is not a live gap, because the asset derives the expected count from the very H3
grid weights the converter joins against — see
https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/.
h3_cell_shortfall
property
Expected H3 cells minus observed.
The difference is net, so it is negative if the run carries extra cells and zero if one cell was swapped for another.
Methods:
__init__(init_times, n_rows, expected_n_rows, n_ensemble_members, n_valid_times, n_h3_cells, expected_n_h3_cells, valid_time_min, valid_time_max, missing_ensemble_members, unexpected_ensemble_members, missing_lead_time_hours, unexpected_valid_times)
describe()
One human-readable sentence naming every gap, for the asset check's description.
Source code in packages/contracts/src/contracts/weather_schemas.py
712 713 714 715 716 717 718 719 720 721 | |
Functions:
assess_nwp_quality(dataframe)
Summarise the tolerated-but-noteworthy nulls in a validated NWP run.
Reports the nulls in the de-accumulated variables (precipitation/radiation) beyond lead-0 that
:meth:Nwp.validate deliberately tolerates: whole (ensemble_member, valid_time) slices that
arrived empty, and the cells where the upstream per-pixel corruption survived the H3
aggregation by taking out every grid point of a cell. Pure and Dagster-free
(unit-testable in isolation); the ecmwf_ens asset wraps the result into a WARN
AssetCheckResult. See
https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/.
Source code in packages/contracts/src/contracts/weather_schemas.py
612 613 614 615 616 617 618 619 620 621 622 623 | |
assess_nwp_run_completeness(dataframe, expected_n_h3_cells, expected_ensemble_members=ECMWF_ENS_ENSEMBLE_MEMBERS, expected_lead_time_hours=ECMWF_ENS_LEAD_TIME_HOURS)
Summarise whether one ingested NWP run covers its full (member x step x cell) grid.
Called from the ecmwf_ens asset, deliberately not from Nwp.validate: validation runs on
arbitrary frames (filtered test fixtures, pruned scans, single-member training reads), whereas
completeness is a property of one whole ingested run and would be false on every one of those.
Pure and Dagster-free, so it is unit-testable in isolation.
Never raises on a validated Nwp frame — not on an empty one, not on a multi-init_time one,
not on one whose valid_times are off-grid — so it cannot turn the warning path into a failure
path (rule 7 of
Inherent Stability).
The qualifier matters: the key columns being non-null and present is exactly what Nwp.validate
guarantees, and the sole production caller validates before calling.
Row counting is safe here despite Polars' 32-bit row index: this runs on a single in-memory run (at V1 scale 1671 cells x 51 members x 85 steps ~ 7.24M rows), four orders of magnitude below the 2**32 ceiling that the whole ~5.9B-row NWP Delta table would hit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataframe
|
DataFrame[Nwp]
|
One whole ingested NWP run, already through |
required |
expected_n_h3_cells
|
int
|
Distinct H3 cells the run should cover — pass
|
required |
expected_ensemble_members
|
frozenset[int]
|
Members the run should carry. Defaults to the ECMWF ENS
ensemble, the only |
ECMWF_ENS_ENSEMBLE_MEMBERS
|
expected_lead_time_hours
|
tuple[int, ...]
|
Forecast steps, in hours after |
ECMWF_ENS_LEAD_TIME_HOURS
|
Source code in packages/contracts/src/contracts/weather_schemas.py
756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 | |
contracts.geo_schemas
Spatial contracts: the H3-cell-to-NWP-grid weights that drive the spatial NWP aggregation.
Classes
H3GridWeights
Bases: Model
Schema for the pre-computed H3 grid weights.
This contract defines the mapping between H3 hexagons and a regular latitude/longitude grid. It
is used to ensure type safety when passing spatial mapping data from generic geospatial
utilities (like packages/geo) to dataset-specific ingestion pipelines (like
packages/dynamical_data).
Source code in packages/contracts/src/contracts/geo_schemas.py
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
Attributes
h3_index = pt.Field(dtype=(pl.UInt64), description='H3 cell index.')
class-attribute
instance-attribute
nwp_lat = pt.Field(dtype=(pl.Float32), ge=(-90), le=90, description='Latitude of the NWP grid box centre (decimal degrees).')
class-attribute
instance-attribute
nwp_lon = pt.Field(dtype=(pl.Float32), ge=(-180), le=180, description='Longitude of the NWP grid box centre (decimal degrees).')
class-attribute
instance-attribute
proportion = pt.Field(dtype=(pl.Float32), ge=0, le=1, description='Fraction of the H3 hexagon that overlaps this NWP grid box (0 to 1).')
class-attribute
instance-attribute
contracts.ml_schemas
Contracts for the ML pipeline.
The feature vocabulary, the joined AllFeatures frame handed to models, the eligible-time-series
population, and the metrics schema.
Attributes
TimeFeature = Literal['local_time_of_day_sin', 'local_time_of_day_cos', 'local_time_of_year_sin', 'local_time_of_year_cos', 'local_day_of_week_sin', 'local_day_of_week_cos', 'local_day_of_week', 'local_utc_offset_minutes']
module-attribute
SafeInputBaseColumn = Literal['time_series_id', 'time_series_type', 'nwp_lead_time_hours', 'ensemble_member', 'power_fcst_init_time', 'nwp_init_time']
module-attribute
HORIZON_SLICES = ('all', 'intraday', 'day_ahead', 'short_medium_range', 'extended_range')
module-attribute
Horizon slice labels matching the four forecast ranges from the project report.
Bands are left-closed intervals of lead time (valid_time − power_fcst_init_time), as
implemented by ml_core.metrics:
"all": aggregates over all horizons and is always computed"intraday": [0 h, 6 h)"day_ahead": [6 h, 36 h)"short_medium_range": [36 h, 168 h) — day 2 to day 7"extended_range": [168 h, ∞) — day 8 onwards (unbounded above; in practice forecasts stop at the 14-day NWP horizon)
METRIC_NAMES = ('mae', 'nmae', 'rmse', 'mbe', 'crps', 'spread_skill_ratio', 'pinball_loss', 'mean_pinball_loss', 'picp', 'interval_width')
module-attribute
Metric names currently implemented.
Full definitions, equations, and design rationale: https://openclimatefix.github.io/nged-substation-forecast/techniques/evaluation-metrics/
Deterministic (scored on the per-run ensemble mean):
"mae": mean absolute error (MW)"nmae": normalised MAE (dimensionless; normalised by full-history effective capacity)"rmse": root mean squared error (MW)"mbe": mean bias error (MW; positive = over-prediction)
Probabilistic (scored on the ensemble members before the mean collapse):
"crps": fair (finite-ensemble-unbiased) continuous ranked probability score (MW). The only metric here that is comparable across models with different ensemble sizes."spread_skill_ratio": Fortin-corrected RMS ensemble spread ÷ RMSE of the ensemble mean (dimensionless; 1.0 = well-calibrated, < 1 = underdispersed/overconfident)."pinball_loss": quantile loss (MW) at the quantile named bymetric_param."mean_pinball_loss": unweighted mean of"pinball_loss"over the thirteenDELIVERY_QUANTILES(MW). Tail-heavy by construction, matching NGED's priorities."picp": prediction-interval coverage probability of the band named bymetric_param(dimensionless fraction). The calibrated reference for empirical quantiles from a finite ensemble sits below the nominal coverage — e.g. ≈ 0.769, not 0.8, forp10_p90at 51 members."interval_width": mean width (MW) of the band named bymetric_param— the sharpness companion to"picp"(coverage is only impressive if the band is also narrow).
QUANTILE_METRIC_PARAMS = tuple((quantile_label(q)) for q in DELIVERY_QUANTILES)
module-attribute
metric_param labels for quantile-indexed metrics (pinball loss), e.g. "p1" … "p99".
Derived from DELIVERY_QUANTILES so that evaluation is always scored at exactly the quantile
levels the product delivers.
BAND_METRIC_PARAMS = tuple(f'{quantile_label(q)}_{quantile_label(1 - q)}' for q in DELIVERY_QUANTILES if q < 0.5)
module-attribute
metric_param labels for the symmetric prediction-interval bands (PICP, interval width).
One band per symmetric pair of DELIVERY_QUANTILES: "p1_p99", "p2_p98", "p5_p95",
"p10_p90", "p20_p80", "p35_p65". Together they trace a coverage curve from the 30%
band to the 98% band.
METRIC_PARAMS = ('all', *QUANTILE_METRIC_PARAMS, *BAND_METRIC_PARAMS)
module-attribute
Parameter values for parametric metrics.
"all": all scalar metrics with no extra parameter dimension (MAE, NMAE, RMSE, MBE, CRPS, spread-skill ratio, mean pinball loss).- Quantile labels (
"p1"…"p99", fromQUANTILE_METRIC_PARAMS): pinball loss. - Band labels (
"p1_p99"…"p35_p65", fromBAND_METRIC_PARAMS): PICP and interval width.
EVALUATION_SCOPES = ('leaderboard', 'production_monitoring', 'ad_hoc')
module-attribute
Evaluation scopes that coexist in the forecast_metrics table.
"leaderboard": CV-fold leaderboard metrics"production_monitoring": live trailing-window monitoring"ad_hoc": one-off analyses (no MLflow run)
EvalScopeType = Literal['leaderboard', 'ad_hoc']
module-attribute
Type annotation for the evaluation scopes currently accepted by the metrics asset config.
Distinct from EVALUATION_SCOPES (the runtime tuple driving the Polars Enum): that tuple
includes "production_monitoring" for future use; this Literal reflects only the scopes
the asset actually handles today. Expand to match EVALUATION_SCOPES when Phase 8 lands.
TIME_SERIES_TYPE_SLICES = ('all', *LIST_OF_TIME_SERIES_TYPES)
module-attribute
Values for the time_series_type metric slice.
Every time_series_type plus the sentinel "all" for the across-everything aggregate.
Classes
AllFeatures
Bases: Model
Final joined dataset ready for the ML model.
Weather features are kept in their physical units (e.g., degrees Celsius, m/s) to ensure precision during interpolation and feature engineering.
DYNAMIC FEATURES: In addition to the explicitly defined columns below, the pipeline supports dynamically generated features. You can request these in your model config:
power_lag_{hours}h: The power value shifted by X hours (e.g.,power_lag_24h).temperature_2m_rolling_mean_{hours}h: Rolling average of temperature over X hours (e.g.,temperature_2m_rolling_mean_6h).
Note: Dynamic features are not explicitly typed as Patito fields below. This is intentional to allow infinite parameterization (e.g., any lag hour) without the overhead of metaprogramming or defining hundreds of static fields. The pipeline dynamically asserts their presence during feature engineering.
Source code in packages/contracts/src/contracts/ml_schemas.py
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | |
Attributes
valid_time = pt.Field(dtype=UTC_DATETIME_DTYPE)
class-attribute
instance-attribute
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
time_series_type = pt.Field(dtype=(pl.Enum(LIST_OF_TIME_SERIES_TYPES)), allow_missing=True, description="The substation's category. Only emitted when a feature set requests it, and never null: the feature pipeline drops any time series with no row in the metadata.")
class-attribute
instance-attribute
ensemble_member = pt.Field(dtype=(pl.UInt8), allow_missing=True)
class-attribute
instance-attribute
power_fcst_init_time = pt.Field(dtype=UTC_DATETIME_DTYPE, description="When OCF's power forecast model was initialised. This might be called `t0` in other OCF projects.")
class-attribute
instance-attribute
nwp_init_time = pt.Field(dtype=UTC_DATETIME_DTYPE, allow_missing=True, description='When the NWP run was initialised.')
class-attribute
instance-attribute
power = pt.Field(dtype=(pl.Float32), description='The observed power, or null where there is no observation. Live inference feeds a dense spine that runs past the last observation, and training drops the null rows.')
class-attribute
instance-attribute
nwp_lead_time_hours = pt.Field(dtype=(pl.Float32), allow_missing=True)
class-attribute
instance-attribute
temperature_2m = _FEATURE_DTYPE
class-attribute
instance-attribute
dew_point_temperature_2m = _FEATURE_DTYPE
class-attribute
instance-attribute
wind_speed_10m = _FEATURE_DTYPE
class-attribute
instance-attribute
wind_direction_10m = _FEATURE_DTYPE
class-attribute
instance-attribute
wind_speed_100m = _FEATURE_DTYPE
class-attribute
instance-attribute
wind_direction_100m = _FEATURE_DTYPE
class-attribute
instance-attribute
pressure_surface = _FEATURE_DTYPE
class-attribute
instance-attribute
pressure_reduced_to_mean_sea_level = _FEATURE_DTYPE
class-attribute
instance-attribute
geopotential_height_500hpa = _FEATURE_DTYPE
class-attribute
instance-attribute
downward_long_wave_radiation_flux_surface = _FEATURE_DTYPE
class-attribute
instance-attribute
downward_short_wave_radiation_flux_surface = _FEATURE_DTYPE
class-attribute
instance-attribute
precipitation_surface = _FEATURE_DTYPE
class-attribute
instance-attribute
categorical_precipitation_type_surface = _FEATURE_DTYPE
class-attribute
instance-attribute
windchill = _FEATURE_DTYPE
class-attribute
instance-attribute
local_utc_offset_minutes = pt.Field(dtype=(pl.Int16), allow_missing=True, description="Offset of the local time zone from UTC, in minutes (0 or 60 for GB). Minutes keep sub-hour zones distinct: India's +5:30 is 330 and Nepal's +5:45 is 345. Int16 spans the real extremes, -720 (Etc/GMT+12) to +840 (Pacific/Kiritimati).")
class-attribute
instance-attribute
local_time_of_day_sin = _FEATURE_DTYPE
class-attribute
instance-attribute
local_time_of_day_cos = _FEATURE_DTYPE
class-attribute
instance-attribute
local_time_of_year_sin = _FEATURE_DTYPE
class-attribute
instance-attribute
local_time_of_year_cos = _FEATURE_DTYPE
class-attribute
instance-attribute
local_day_of_week_sin = _FEATURE_DTYPE
class-attribute
instance-attribute
local_day_of_week_cos = _FEATURE_DTYPE
class-attribute
instance-attribute
local_day_of_week = pt.Field(dtype=(pl.Enum(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'])))
class-attribute
instance-attribute
Metrics
Bases: Model
Evaluation metrics for power forecasts — tall format.
One row per (time_series_id, power_fcst_model_name, fold_id, horizon_slice, metric_name,
metric_param). metric_param encodes the extra parameter dimension for metrics that have
one, or "all" for scalar metrics with no extra dimension. Examples:
| time_series_id | fold_id | horizon_slice | metric_name | metric_param | metric_value |
|---|---|---|---|---|---|
| 1 | 1 | all | mae | all | 5.2 |
| 1 | 1 | day_ahead | rmse | all | 7.1 |
| 1 | 1 | day_ahead | pinball_loss | p10 | 2.1 |
| 1 | 1 | day_ahead | pinball_loss | p50 | 3.4 |
| 1 | 1 | day_ahead | mean_pinball_loss | all | 2.4 |
| 1 | 1 | day_ahead | picp | p10_p90 | 0.78 |
Primary key: (time_series_id, power_fcst_model_name, fold_id, horizon_slice, metric_name,
metric_param).
Source code in packages/contracts/src/contracts/ml_schemas.py
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | |
Attributes
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
power_fcst_model_name = pt.Field(dtype=(pl.String), description='Identifier for the ML-based power forecasting model family.')
class-attribute
instance-attribute
fold_id = pt.Field(dtype=(pl.String), description="CV fold year (e.g. '2022'), or 'live' for production forecasts. Matches PowerForecast.fold_id.")
class-attribute
instance-attribute
horizon_slice = pt.Field(dtype=(pl.Enum(HORIZON_SLICES)), description="'all' aggregates over all forecast horizons. Other values select the HORIZON_SLICES bands.")
class-attribute
instance-attribute
metric_name = pt.Field(dtype=(pl.Enum(METRIC_NAMES)), description="The name of the metric (e.g. 'mae', 'rmse').")
class-attribute
instance-attribute
metric_param = pt.Field(dtype=(pl.Enum(METRIC_PARAMS)), description="Extra parameter dimension for parametric metrics. 'all' for scalar metrics (MAE, NMAE, RMSE, MBE, CRPS, spread-skill ratio, mean pinball loss). For pinball loss: a DELIVERY_QUANTILES label ('p1' … 'p99'). For PICP and interval width: a symmetric band label ('p1_p99' … 'p35_p65').")
class-attribute
instance-attribute
metric_value = pt.Field(dtype=(pl.Float32), description='The computed metric value.')
class-attribute
instance-attribute
evaluation_scope = pt.Field(dtype=(pl.Enum(EVALUATION_SCOPES)), allow_missing=True, description='Which evaluation produced this row, so leaderboard, live-monitoring, and one-off metrics coexist in one table and stay separable.')
class-attribute
instance-attribute
The columns from evaluation_scope and below are populated by the metrics Dagster asset.
They are allow_missing so that the pure compute_metrics() helper can emit the core
metric rows and have the asset enrich them with scope/window provenance before the frame is
written to the forecast_metrics Delta table.
time_series_type = pt.Field(dtype=(pl.Enum(TIME_SERIES_TYPE_SLICES)), allow_missing=True, description='The time-series category for this row. Never null: `compute_metrics` raises if any scored series has no metadata row, because a null would drop that series out of every per-type aggregate while still counting towards the overall mean.')
class-attribute
instance-attribute
window_start = pt.Field(dtype=UTC_DATETIME_DTYPE, allow_missing=True, description="Inclusive start of the valid_time window this row covers. For a CV fold this is the fold's val_start; for monitoring it is the trailing-window start.")
class-attribute
instance-attribute
window_end = pt.Field(dtype=UTC_DATETIME_DTYPE, allow_missing=True, description='End of the valid_time window this row covers (fold val_end or window end).')
class-attribute
instance-attribute
window_label = pt.Field(dtype=(pl.String), allow_missing=True, description="Human label for the window, e.g. '2025', '24h', '7d', 'full_fold'.")
class-attribute
instance-attribute
computed_at = pt.Field(dtype=UTC_DATETIME_DTYPE, allow_missing=True, description='When this metric row was written (provenance; orders the append-only monitoring series and distinguishes recomputations).')
class-attribute
instance-attribute
mlflow_run_id = pt.Field(dtype=(pl.String), allow_missing=True, description="Convenience cross-link to the MLflow run this metric row belongs to. Null for 'ad_hoc' rows, which have no MLflow run.")
class-attribute
instance-attribute
experiment_name = pt.Field(dtype=(pl.String), allow_missing=True, description='Experiment that produced these forecasts. Delta partition key alongside fold_id. Populated by the metrics Dagster asset after compute_metrics() returns; allow_missing so compute_metrics() itself need not produce it.')
class-attribute
instance-attribute
EligibleTimeSeries
Bases: Model
The canonical per-fold population of eligible time_series_ids.
Written by the eligible_time_series Dagster asset (one Delta partition per fold_id)
and read by trained_cv_model and cv_power_forecasts. Eligibility is a function of
data coverage and the fold dates only — never the model or experiment config — so every
experiment trains and scores a fold on the identical population, which is what makes
leaderboard comparisons apples-to-apples.
One row per eligible (fold_id, time_series_id).
Source code in packages/contracts/src/contracts/ml_schemas.py
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 | |
Attributes
fold_id = pt.Field(dtype=(pl.String), description="The CV fold this eligibility row belongs to (e.g. 'mid_2025_to_mid_2026'); the Delta partition key.")
class-attribute
instance-attribute
time_series_id = _get_time_series_id_dtype()
class-attribute
instance-attribute
Functions:
contracts.config_schemas
Configuration schemas, and the class-path round-trip that _target_ strings ride on.
Attributes
Classes
CvFoldConfig
Bases: BaseModel
Configuration for a single expanding-window CV fold.
leaderboard distinguishes the epoch-pinned leaderboard folds (the apples-to-apples
evaluation protocol) from optional non-leaderboard dev folds such as smoke_test: a
leaderboard=False fold runs through the identical pipeline but never feeds the leaderboard.
min_training_months overrides CvConfig.min_training_months for this fold alone (None
falls back to the config-level value). A short dev fold sets it to its train length so
eligibility does not demand the leaderboard's longer history.
Source code in packages/contracts/src/contracts/config_schemas.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | |
Attributes
fold_id
instance-attribute
train_start
instance-attribute
train_end
instance-attribute
val_start
instance-attribute
val_end
instance-attribute
leaderboard = True
class-attribute
instance-attribute
min_training_months = Field(default=None, ge=1)
class-attribute
instance-attribute
CvConfig
Bases: BaseModel
Configuration for expanding-window cross-validation.
The folds list defines the evaluation protocol shared by all experiments on the leaderboard. All models must be evaluated against the same folds to ensure apples-to-apples comparison.
min_training_months controls which time series are eligible for each fold: a time series is only included if it has at least this many months of data before val_start (and data through val_end).
Source code in packages/contracts/src/contracts/config_schemas.py
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | |
Attributes
folds
instance-attribute
min_training_months = Field(default=6, ge=1)
class-attribute
instance-attribute
fold_ids
property
The fold ids in declaration order (e.g. ["2022", "2023", ...]).
Used to build the cv_experiment_folds partitions and to expand experiment
registration into per-fold partition keys — always read from config, never hard-coded.
leaderboard_fold_ids
property
The fold ids of the leaderboard folds only, in declaration order.
Non-leaderboard dev folds (e.g. smoke_test) are excluded. Used to expand the
full_cv / register_only run modes and to scope leaderboard metrics.
Methods:
get_fold(fold_id)
Return the fold with the given fold_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fold_id
|
str
|
The fold identifier to look up (e.g. |
required |
Raises:
| Type | Description |
|---|---|
KeyError
|
If no fold with that id exists in the config. |
Source code in packages/contracts/src/contracts/config_schemas.py
140 141 142 143 144 145 146 147 148 149 150 151 152 | |
Functions:
class_target(obj)
Return the fully-qualified import path of a class (or of an instance's class).
The inverse of import_class. Together they are how a config file, an MLflow experiment tag
and a saved model's meta.json all name a Python class: a module.ClassName string that
survives being written to disk and read back in another process.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
type | object
|
The class to name, or an instance whose class should be named. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in packages/contracts/src/contracts/config_schemas.py
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | |
import_class(target)
Resolve a fully-qualified class path to the class object.
The inverse of class_target: imports the module and looks the class up on it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
str
|
A |
required |
Returns:
| Type | Description |
|---|---|
type
|
The class named by |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in packages/contracts/src/contracts/config_schemas.py
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | |
load_cv_config(path)
Load and validate the cross-validation config from a YAML file.
The CV folds are the leaderboard's evaluation protocol and must be read from
conf/cv/default.yaml (never hard-coded) so every experiment and asset shares one
canonical definition.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Path to the CV config YAML (e.g. |
required |
Returns:
| Type | Description |
|---|---|
CvConfig
|
The validated |
Source code in packages/contracts/src/contracts/config_schemas.py
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | |
contracts.settings
Settings: every data path and object-store credential the pipeline reads.
Values are resolved from the environment and the workspace .env.
Attributes
url_adapter = TypeAdapter(AnyHttpUrl)
module-attribute
PROJECT_ROOT = _find_project_root(Path(__file__))
module-attribute
The repo root — anchor for the repo-relative defaults below (conf/, data/, .env).
Resolved by :func:_find_project_root; see its docstring for the per-install-mode behaviour
and the caveat for wheels installed outside a workspace checkout.
Classes
Settings
Bases: BaseSettings
Configuration settings for the NGED substation forecast project.
Source code in packages/contracts/src/contracts/settings.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | |
Attributes
mlflow_tracking_uri = Field(default='sqlite:///mlflow.db', description='MLflow tracking URI. Centralized here for environment-specific configuration (e.g., local SQLite for development, remote server for production). The application entrypoint should read this setting and set the MLFLOW_TRACKING_URI environment variable accordingly, which MLflow will automatically pick up.')
class-attribute
instance-attribute
nged_s3_bucket_url = Field(default='', description="NGED source S3 bucket URL. Typically stored in the .env file. Empty unless you need to ingest from NGED's bucket; `get_nged_s3_store` raises if it is.")
class-attribute
instance-attribute
nged_s3_bucket_access_key = Field(default='', description='Access key for the NGED source S3 bucket. Typically stored in the .env file.')
class-attribute
instance-attribute
nged_s3_bucket_secret = Field(default='', description='Secret key for the NGED source S3 bucket. Typically stored in the .env file.')
class-attribute
instance-attribute
cv_config_path = Field(default=(PROJECT_ROOT / 'conf' / 'cv' / 'default.yaml'), description='Path to the canonical cross-validation fold definitions. These folds are the shared leaderboard evaluation protocol; read them from here, never hard-coded.')
class-attribute
instance-attribute
data_path_internal = Field(default=(str(PROJECT_ROOT / 'data')), description="Root of OCF's own working data tables (NWP, power, forecast_metrics, …). A local path by default; may be a remote URI (e.g. 's3://bucket/data') so the data tables live on S3. Every *_data_path below that is left unset derives from this root, except the NGED-facing delivery tables, which derive from data_path_delivery instead.")
class-attribute
instance-attribute
data_path_delivery = Field(default=(str(PROJECT_ROOT / 'data')), description='Root of the NGED-facing delivery tables (power_forecasts, effective_capacity, …). Defaults to the same local path as data_path_internal, so local dev sees one directory and the split is invisible; on AWS this points at the separate, NGED-facing bucket.')
class-attribute
instance-attribute
local_artifacts_path = Field(default=(str(PROJECT_ROOT / 'data')), description='Root of the always-local artifacts (the production model). Kept separate from the data-table roots because these back local-filesystem-only libraries and must stay local even when the data tables live on S3.')
class-attribute
instance-attribute
data_store_endpoint_url = Field(default='', description='S3-compatible endpoint URL for the data tables (e.g. a MinIO/dev endpoint). Empty on AWS, where the endpoint is inferred. When set, the store is also allowed to use plain HTTP (dev endpoints rarely have TLS).')
class-attribute
instance-attribute
data_store_access_key_id = Field(default='', description='Access key for the data-table object store; empty on AWS (IAM).')
class-attribute
instance-attribute
data_store_secret_access_key = Field(default='', description='Secret key for the data-table object store; empty on AWS (IAM).')
class-attribute
instance-attribute
data_store_region = Field(default='', description='Region for the data-table object store; empty to auto-discover.')
class-attribute
instance-attribute
storage_options
property
delta-rs / polars / obstore storage_options for the managed data tables.
Empty on AWS — object_store auto-discovers the Fargate task's IAM-role credentials and
region — and empty for a local data-path root (delta-rs ignores it there). Populated from
the data_store_* settings only for a dev/MinIO/S3-compatible endpoint. The aws_*
keys are the shared object_store aliases understood by delta-rs, polars cloud IO, and
obstore alike, so one value feeds every IO site. Returned as an ObjectStoreOptions
TypedDict so ty checks each key here, where they are authored; widen it to a plain
dict at each IO boundary with typeddict_to_dict.
nged_data_path = ''
class-attribute
instance-attribute
Directory holding the NGED power_time_series Delta table and metadata parquet.
nwp_data_path = ''
class-attribute
instance-attribute
Delta table of NWP weather data.
power_forecasts_data_path = ''
class-attribute
instance-attribute
Delta table of power forecasts (partitioned by experiment_name, fold_id).
An NGED-facing delivery table — derives from data_path_delivery, not data_path_internal.
forecast_metrics_data_path = ''
class-attribute
instance-attribute
Delta table of forecast evaluation metrics.
power_time_series_data_path = ''
class-attribute
instance-attribute
Delta table of half-hourly power observations (under nged_data_path).
metadata_path = ''
class-attribute
instance-attribute
Parquet file of per-series substation metadata (under nged_data_path).
eligible_time_series_data_path = Field(default='', description='Delta table of the canonical per-fold eligible time_series_id population, written by the eligible_time_series asset (partitioned by fold_id) and read by trained_cv_model and cv_power_forecasts so every experiment scores a fold on the identical, experiment-independent population.')
class-attribute
instance-attribute
effective_capacity_data_path = Field(default='', description='Delta table of per-series effective capacity (v0.1: full-history P99 of |power|), written by the effective_capacity asset and read by the metrics asset as the NMAE denominator. An NGED-facing delivery table — derives from data_path_delivery, not data_path_internal.')
class-attribute
instance-attribute
h3_grid_weights_path = ''
class-attribute
instance-attribute
Parquet file of fractional H3 cell overlap with the GB boundary.
production_model_path = Field(default='', description="Directory holding the current production model, written by the promoted_model asset (ml_core.production_helpers.fetch_model_artifacts) and read by live_forecasts via a plain BaseForecaster disk load — no MLflow at inference time. Later COPY'd into the container image at build time (issue #222).")
class-attribute
instance-attribute
sentry_dsn = Field(default='', description='Sentry.io project DSN. Empty (the default) disables Sentry entirely — every Sentry code path becomes a no-op — so laptops and CI need no Sentry config. Set it in the .env file to enable error telemetry. Passed explicitly to sentry_sdk.init as the single source of truth (the SDK would otherwise also auto-read the SENTRY_DSN env var).')
class-attribute
instance-attribute
sentry_environment = Field(default='local', description="Sentry environment tag, the dimension that separates deployments in Sentry's UI, alerts, and cron monitors. The always-on production box sets 'production'; each developer overrides the 'local' fallback with '<name>-laptop' (e.g. 'jacks-laptop') so laptop telemetry is cleanly filterable and the production-scoped missed-check-in alert never fires for a laptop.")
class-attribute
instance-attribute
sentry_traces_sample_rate = Field(default=0.0, description='Fraction of transactions sampled for Sentry performance tracing (off by default).')
class-attribute
instance-attribute
sentry_monitor_forecasts = Field(default=False, description="Emit the live_forecasts success heartbeat to Sentry's cron monitor (the missed-check-in alarm). True only on the always-on production deployment; left False on laptops so an intermittently-run laptop never registers a monitor environment that Sentry would then mark as missed.")
class-attribute
instance-attribute
model_config = SettingsConfigDict(env_file=(PROJECT_ROOT / '.env'), extra='forbid', env_file_encoding='utf-8', env_prefix='')
class-attribute
instance-attribute
Methods:
require_nged_source_credentials()
Raise ValueError unless all three NGED source-bucket credentials are set.
Call this immediately before doing something that reads NGED's bucket, so the failure names
the missing configuration instead of surfacing as an opaque auth error from obstore.
Do not call it from process start-up or module import to fail a deployment fast: inference needs none of these credentials, so that would stop the forecast over a missing ingest secret. See the AWS runbook.
Raises:
| Type | Description |
|---|---|
ValueError
|
Naming exactly which of the three environment variables are unset. |
Source code in packages/contracts/src/contracts/settings.py
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |
get_nged_s3_store()
Returns an initialized obstore.store.S3Store instance for the NGED bucket.
Raises:
| Type | Description |
|---|---|
ValueError
|
If any of the three source-bucket credentials is unset. |
Source code in packages/contracts/src/contracts/settings.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 | |
validate_url(v)
classmethod
Validate that the S3 bucket URL is a valid URL, if one was given at all.
Empty means "no NGED ingest configured", which is the default and not an error;
require_nged_source_credentials is what rejects it where it matters. A non-empty
value still has to be a real URL — strict about malformed, liberal about missing.
Source code in packages/contracts/src/contracts/settings.py
374 375 376 377 378 379 380 381 382 383 384 385 | |
Functions:
get_settings()
cached
Return the shared, lazily-constructed Settings singleton.
Prefer this over constructing Settings() at module import time: instantiation reads
.env and the environment, so deferring it to first use keeps library modules (e.g. the
contracts schemas) importable whatever the environment holds, and lets a test change the
environment before the first read.
Cached with lru_cache so every caller shares one instance (matching the previous
module-level singletons). Call get_settings.cache_clear() in a test that needs to
re-read the environment after changing it.
Source code in packages/contracts/src/contracts/settings.py
388 389 390 391 392 393 394 395 396 397 398 399 400 401 | |
contracts.uri
Small URI helpers for paths that may be local filesystem paths or remote URIs.
Settings data-location fields are plain str so they can hold either a local path
(/home/.../data/NWP) or a remote URI (s3://bucket/NWP). pathlib.Path mangles
the latter (Path("s3://b/a") / "c" drops the scheme), so joins route through here.
The existence/parent helpers below give the asset IO layer a single local-or-remote-aware
call for the two things it does around every Delta/parquet write: make sure the parent
directory exists (a no-op on object stores, which have no directories) and check whether a
table/object is already there. Remote calls go through delta-rs / obstore with the caller's
storage_options so the same code path serves both a local data-path root and an s3:// one.
Classes
ObjectStoreOptions
Bases: TypedDict
object_store options for the managed data tables.
These are the shared aws_* aliases understood by delta-rs, Polars, and obstore alike, so
one value feeds every IO site.
Authored as a TypedDict (rather than a bare dict[str, str]) so ty checks every key
where it is written — see Settings.storage_options. Widen it to the plain dict the IO
libraries expect with typeddict_to_dict at each call boundary. Empty on AWS (object_store
auto-discovers the IAM-role credentials and region) and for a local data-path root.
Source code in packages/contracts/src/contracts/uri.py
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | |
Attributes
aws_endpoint_url
instance-attribute
aws_allow_http
instance-attribute
aws_access_key_id
instance-attribute
aws_secret_access_key
instance-attribute
aws_region
instance-attribute
Functions:
is_remote_uri(uri)
Return whether uri carries a URI scheme (e.g. s3://) rather than a local path.
Source code in packages/contracts/src/contracts/uri.py
46 47 48 | |
uri_join(base, *parts)
Join parts onto base with local- or remote-aware semantics.
Local bases join through pathlib (yielding an absolute path string); remote,
scheme-bearing bases join posix-style so "s3://bucket/a" + "b" stays
"s3://bucket/a/b" instead of being mangled by Path.__truediv__.
Source code in packages/contracts/src/contracts/uri.py
51 52 53 54 55 56 57 58 59 60 | |
if_local_path_then_make_parent_dir(uri)
Create the parent directory of a local uri; a no-op for a remote URI.
Local filesystems need a table/file's parent directory to exist before a write; object
stores (s3://) have no directories — a write creates the key's prefix implicitly — so
there is nothing to do and this returns immediately.
Source code in packages/contracts/src/contracts/uri.py
63 64 65 66 67 68 69 70 71 72 | |
delta_table_exists(uri, storage_options=None)
Return whether a Delta table already exists at uri (local path or remote URI).
Wraps DeltaTable.is_deltatable, which inspects the _delta_log through delta-rs'
object_store and so works identically for a local path and an s3:// URI given the
matching storage_options. Replaces Path(uri).exists() at the write-guard sites,
which would raise on a remote URI.
Source code in packages/contracts/src/contracts/uri.py
75 76 77 78 79 80 81 82 83 | |
object_exists(uri, storage_options=None)
Return whether a single object/file at uri exists (local file or remote object).
For a local uri this is Path.exists(); for a remote URI it issues an object-store
head via obstore, so it works for a plain file (e.g. a .parquet) that is not a Delta
table. Use delta_table_exists for Delta tables.
Source code in packages/contracts/src/contracts/uri.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |