ML Core API
ml_core
Unified ML model interface and shared utilities for substation forecasting.
Why this package exists
To enable rapid experimentation with different model architectures (XGBoost, GNNs, etc.), we need a common interface that shields the model developer from the underlying data engineering.
ml_core.features
Feature engineering package.
Public interface:
FeatureEngineer— abstract base; implement to swap the full feature pipeline.TabularFeatureEngineer— default implementation: nearest-cell NWP spatial join followed by the declarative tabular pipeline.
Private sub-modules (importable by tests, not part of the public API):
_parsed_features— typed feature descriptors andParsedFeaturesparser._nwp— NWP upsampling, processing, and power/NWP join helpers._lags— power-lag, weather-lag (dual-strategy), and leaky-lag nullification.tabular_feature_engineer—_engineer_featuresorchestrator and tabular pipeline helpers.
Attributes
__all__ = ['FeatureEngineer', 'TabularFeatureEngineer']
module-attribute
Classes
FeatureEngineer
Bases: ABC
Turns raw power + NWP + metadata into a model-ready AllFeatures frame.
Source code in packages/ml_core/src/ml_core/features/feature_engineer.py
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 | |
Methods:
engineer(*, selected_features, power_time_series, time_series_metadata, nwp, power_fcst_init_time=None, nwp_init_time=None, nwp_publication_delay_hours=NWP_PUBLICATION_DELAY_HOURS, local_timezone=DEFAULT_LOCAL_TIMEZONE)
abstractmethod
Engineer features for training/inference.
Two operating modes, matching tabular_feature_engineer._engineer_features (see
that function for the full detail this docstring summarises):
- Bulk mode (
power_fcst_init_time=None, the default): NWP-centric, vectorised over every NWP run in the input, one forecast per(nwp_init_time, valid_time)pair. Used for training and multi-run backtesting. - Single-run mode (
power_fcst_init_timegiven): power-centric, joins exactly one NWP run (nwp_init_time, or derived frompower_fcst_init_timeminusnwp_publication_delay_hoursif omitted) and stamps a constantpower_fcst_init_timeon every row. Used for production inference and replay backfilling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
selected_features
|
set[str]
|
The feature names to produce. |
required |
power_time_series
|
LazyFrame[PowerTimeSeries]
|
Observed power, one row per |
required |
time_series_metadata
|
DataFrame[TimeSeriesMetadata]
|
Per-time-series metadata (carries |
required |
nwp
|
LazyFrame[Nwp]
|
Gridded NWP in physical units, keyed by |
required |
power_fcst_init_time
|
datetime | None
|
|
None
|
nwp_init_time
|
datetime | None
|
The NWP run to join in single-run mode. Must be |
None
|
nwp_publication_delay_hours
|
int
|
Delay used to derive whichever of
|
NWP_PUBLICATION_DELAY_HOURS
|
local_timezone
|
str
|
IANA zone the local-time features are computed in. |
DEFAULT_LOCAL_TIMEZONE
|
Returns:
| Type | Description |
|---|---|
LazyFrame[AllFeatures]
|
A lazy |
Source code in packages/ml_core/src/ml_core/features/feature_engineer.py
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 | |
TabularFeatureEngineer
Bases: FeatureEngineer
Nearest res-5 NWP cell per time series, then the declarative tabular pipeline.
Source code in packages/ml_core/src/ml_core/features/tabular_feature_engineer.py
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 | |
Methods:
engineer(*, selected_features, power_time_series, time_series_metadata, nwp, power_fcst_init_time=None, nwp_init_time=None, nwp_publication_delay_hours=NWP_PUBLICATION_DELAY_HOURS, local_timezone=DEFAULT_LOCAL_TIMEZONE)
Map each NWP cell to its nearest time series, then run the tabular feature pipeline.
See :meth:FeatureEngineer.engineer for the argument and operating-mode contract, and
_engineer_features in this module for the pipeline itself — including
local_timezone, the IANA zone the local-time features are computed in.
Source code in packages/ml_core/src/ml_core/features/tabular_feature_engineer.py
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 | |
ml_core.base_forecaster
BaseForecaster: the interface every forecasting model implements.
Also holds BaseForecasterConfig, which carries a trained model's experiment identity.
Attributes
TRAINED_METADATA_FILENAME = 'time_series_metadata.parquet'
module-attribute
Name of the TimeSeriesMetadata rows a saved model carries for its trained population.
Production inference reads each series' location from here, never from the
TimeSeriesMetadata roster, so an unreadable or thinned roster cannot fail a live slot or
silently drop a series from it. Being the model's own frozen copy of what it trained against also
keeps a series' H3 cell and static feature values identical between training and serving. See
https://openclimatefix.github.io/nged-substation-forecast/design-philosophy/inherent-stability/#the-rules.
Logging it as a second MLflow artifact instead of putting it in the model directory would reopen
the merge problem _MLFLOW_MODEL_ARTIFACT documents.
Classes
BaseForecasterConfig
Bases: BaseModel
Universal configuration for all forecasting models.
Subclasses add model-specific hyperparameters. Having a shared base ensures that every
forecaster carries its own feature list and optional MLflow experiment id in one
serialisable object, simplifying save/load and the conf/model/*.yaml config wiring.
The tag fields (weather_source, training_strategy) are stamped onto MLflow runs for
leaderboard grouping. They live here so that constructing the config from a model YAML's
model_params validates them at load time — they are present in every
conf/model/*.yaml file under model_params.
experiment_name is the per-experiment key (set to the MLflow experiment name at
registration and stored in the saved config); it is stamped onto every PowerForecast
row, distinct from the model-family MODEL_NAME. random_seed is threaded into each
model's training so that re-training a fold reproduces the same model — keeping retries
and the leaderboard stable.
Model identity (name and version) lives on the BaseForecaster class itself as
MODEL_NAME and MODEL_VERSION — those are properties of the implementation, not
the experiment config.
Serialisation must be canonical. A config is compared and stored as its serialised form:
register_experiment stamps model_dump_json() onto the MLflow experiment as the
config tag and compares a re-registration against it, and logs flatten_config(...) as
write-once MLflow params. Python's per-process string hash randomisation means a set
iterates in a different order in every process, so a set dumped straight to a list would make
two dumps of the same config differ — a re-registration would then look like a config change
and its param write would be rejected. selected_features is therefore serialised sorted. A
subclass that adds a set-valued (or otherwise unordered) field must do the same.
Unknown keys are rejected, not ignored (extra="forbid"). A key no field declares raises
ValidationError, so a misspelled hyperparameter in a run's config_overrides fails at
registration, and a stored config carrying a key the current code no longer declares is
refused rather than silently losing it — the recovery is to re-train, never to hand-edit.
Why this is worth failing over:
https://openclimatefix.github.io/nged-substation-forecast/ml_experimentation/model-configuration/#tweaking-a-config-for-an-experiment.
Source code in packages/ml_core/src/ml_core/base_forecaster.py
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 | |
Attributes
model_config = ConfigDict(extra='forbid')
class-attribute
instance-attribute
selected_features
instance-attribute
ml_flow_experiment_id = None
class-attribute
instance-attribute
experiment_name = ''
class-attribute
instance-attribute
weather_source = ''
class-attribute
instance-attribute
training_strategy = ''
class-attribute
instance-attribute
random_seed = 0
class-attribute
instance-attribute
BaseForecaster
Bases: ABC
Defines the universal interface for all energy forecasting ML models.
Every forecasting model subclasses this abstract base to allow shared Dagster assets and evaluation code to remain completely agnostic to the underlying model implementation.
Subclasses must define MODEL_NAME, MODEL_VERSION and CONFIG_CLASS as class-level
constants. MODEL_NAME and MODEL_VERSION are stamped onto every PowerForecast row at
predict time and used as the MLflow experiment name. Bumping MODEL_VERSION requires a code
change (intentional), not a config edit.
Lazy evaluation contract: train and predict both accept a pt.LazyFrame[AllFeatures].
Callers must not collect before passing data in; doing so wastes memory and prevents Polars
from optimising the full query plan. A subclass materialises the data at the model boundary
(typically a single .collect(), streamed). Keeping that bounded is the caller's
responsibility: it prunes the inputs (NWP control member, the relevant H3 cells, the window's
init_time partitions) and, where the full ensemble is needed, processes one init_time chunk
at a time — filtering the engineered output cannot prune the upstream join/upsample. See the NWP
scan-pruning notes in
https://openclimatefix.github.io/nged-substation-forecast/architecture/overview/.
Persistence has two layers. Subclasses implement save/load for their own on-disk
format and need know nothing about MLflow. The concrete save_to_mlflow/load_from_mlflow
methods, shared by all subclasses, wrap that disk format with MLflow's artifact store, so the
same trained model can be shared across machines by round-tripping through a run.
Source code in packages/ml_core/src/ml_core/base_forecaster.py
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 | |
Attributes
MODEL_NAME
class-attribute
MODEL_VERSION
class-attribute
CONFIG_CLASS
class-attribute
The config class load rebuilds from a saved model_params mapping.
A subclass's load must build its config through this attribute rather than naming its
config class again, so the class ml_core.production_helpers._check_meta_is_servable
validates a saved config against is always the class load uses.
feature_engineer = TabularFeatureEngineer()
class-attribute
The feature pipeline this forecaster's data is engineered through.
Associated by composition (the forecaster references a feature engineer rather than
implementing feature engineering), so a forecaster can swap the whole pipeline by overriding
this with a different FeatureEngineer. The default produces the tabular AllFeatures
frame that train/predict consume.
model_params = model_params
instance-attribute
trained_time_series_ids
abstractmethod
property
The sorted time_series_id population this model will serve a predict for.
This is the model's own frozen record of who it trained on — not a statement about how
the model is internally structured. A model may hold one sub-model per series, a single
model spanning many series, or anything in between; the contract is only about which
time_series_ids it will score.
Why this is load-bearing. The train==predict population invariant: the
population a model is scored on must equal the population it was trained on, even if the
live eligibility set has drifted since training (power coverage changes, so a series may
newly qualify or drop out). Consumers (cv_power_forecasts, live_forecasts) filter
their inputs to this set, so a model scores exactly the population it learned — never
whatever eligibility says today. That is what keeps the leaderboard apples-to-apples and
stops production forecasting a series the model never saw.
Subclasses persist and reconstruct this set through their own save/load (e.g. in
meta.json), so it survives a round-trip through MLflow.
Methods:
__init__(model_params)
Store the config that carries this model's hyper-parameters and experiment identity.
Source code in packages/ml_core/src/ml_core/base_forecaster.py
285 286 287 | |
save(path)
abstractmethod
Save the trained model state to a directory, replacing anything already there.
Two requirements on every implementation:
- The saved directory must contain a
meta.jsonwith amodel_classfield — the fully-qualified{module}.{qualname}of the concrete subclass (e.g."xgboost_forecaster.forecaster.XGBoostForecaster") — so that production inference (ml_core.production_helpers.load_forecaster_from_dir) can reconstruct the correct class from a plain model directory with no other context (issue #221). pathmust be cleared first, so that saving over a directory holding a larger model's files leaves none of them behind. Merging instead of replacing is how a dropped time series' weights survive a re-train (issue #197);XGBoostForecaster.saveclears withshutil.rmtree(path, ignore_errors=True).
The clearing requirement makes path the model's to own while it saves, so anything a
caller left there is gone afterwards. (Depositing a file after a save is fine, and is how
production_helpers.fetch_model_artifacts puts promotion.json beside the model.)
Source code in packages/ml_core/src/ml_core/base_forecaster.py
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
load(path)
abstractmethod
classmethod
Reconstruct a trained instance from a previously saved directory.
Source code in packages/ml_core/src/ml_core/base_forecaster.py
333 334 335 336 | |
save_to_mlflow(run_id, *, time_series_metadata)
Upload this trained model to the given MLflow run, as one replaceable archive.
Writes the model to a temporary directory via save (the subclass's own format), adds
the frozen metadata copy (write_trained_metadata), packs that directory into a single
model.tar.gz and logs that one file to the run's artifact root. Logging one archive
rather than a directory of files is what makes a re-upload replace the previous model
instead of merging with it — see _MLFLOW_MODEL_ARTIFACT. The caller is responsible for
setting the tracking URI (mlflow.set_tracking_uri) beforehand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The MLflow run to attach the artifact to. |
required |
time_series_metadata
|
DataFrame[TimeSeriesMetadata]
|
The roster rows this model was engineered against. Required,
because a model uploaded without them cannot be promoted — see
|
required |
Source code in packages/ml_core/src/ml_core/base_forecaster.py
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 | |
load_from_mlflow(run_id)
classmethod
Download a trained model's archive from an MLflow run, unpack it and load it.
Downloads into a temporary directory and loads from there. There is deliberately no
local-disk cache: a CV fold run is reused across re-materialisations
(get_or_create_fold_run resolves the same run for every re-run of a fold's partition),
so a cache keyed by run_id would not be unique for its contents — and production
inference makes no MLflow call at all. Full rationale:
https://openclimatefix.github.io/nged-substation-forecast/architecture/ml-orchestration/#why-there-is-no-local-cache.
The caller is responsible for setting the tracking URI (mlflow.set_tracking_uri)
beforehand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The MLflow run the model was saved under. |
required |
Returns:
| Type | Description |
|---|---|
Self
|
The reconstructed, trained forecaster. |
Source code in packages/ml_core/src/ml_core/base_forecaster.py
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 | |
train(data, time_series_ids)
abstractmethod
Fit the model on data for the given time_series_id population.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
LazyFrame[AllFeatures]
|
The engineered features (lazy; the caller must not pre-collect). |
required |
time_series_ids
|
list[int]
|
The population the model must train on — the caller's eligible set.
The model decides how to map it onto boosters: one booster per id
( |
required |
Source code in packages/ml_core/src/ml_core/base_forecaster.py
403 404 405 406 407 408 409 410 411 412 413 414 | |
predict(data, *, fold_id='live')
abstractmethod
Return power forecasts for all rows in the given AllFeatures data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
LazyFrame[AllFeatures]
|
The engineered features to forecast from. |
required |
fold_id
|
str
|
The value stamped onto every row's |
'live'
|
Source code in packages/ml_core/src/ml_core/base_forecaster.py
416 417 418 419 420 421 422 423 424 425 426 427 428 | |
Functions:
write_trained_metadata(model_dir, time_series_metadata)
Write a model's frozen TimeSeriesMetadata copy into its saved directory.
Call this after a subclass's save, which clears the directory first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_dir
|
Path
|
The directory a subclass's |
required |
time_series_metadata
|
DataFrame[TimeSeriesMetadata]
|
The roster rows the model was engineered against.
|
required |
Source code in packages/ml_core/src/ml_core/base_forecaster.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
load_trained_metadata(model_dir)
Read back the TimeSeriesMetadata rows a saved model carries.
set_model rather than validate, matching how the R&D assets read the roster itself:
this reads back what was written, so re-checking it would only reject rosters the rest of the
system already accepts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_dir
|
Path
|
A directory written by |
required |
Returns:
| Type | Description |
|---|---|
DataFrame[TimeSeriesMetadata]
|
One row per series in |
DataFrame[TimeSeriesMetadata]
|
|
DataFrame[TimeSeriesMetadata]
|
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
The directory holds no such file, so it was saved by code predating
this file or assembled by hand. This is a promotion fault rather than a data outage —
the model on disk is not one this code can serve — so it raises rather than degrading,
as a missing |
Source code in packages/ml_core/src/ml_core/base_forecaster.py
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 | |
ml_core.metrics
Metric computation for cross-validation results.
Attributes
Classes
NoOverlappingActualsError
Bases: ValueError
Raised by compute_metrics when no forecast row joins to any observed actual.
A distinct subclass so callers that score a fold in per-series batches can treat "this
batch's series have no overlapping actuals" as skippable — mirroring how such series
silently vanish from the inner join when the whole fold is scored in one call — while
every other ValueError (negative lead times, missing capacity) still propagates.
Source code in packages/ml_core/src/ml_core/metrics.py
27 28 29 30 31 32 33 34 | |
Functions:
compute_effective_capacity(power_lf)
Compute the v0.1 effective capacity (full-history P99 of |power|) per time series.
One row per time_series_id: effective_capacity_mw is the 99th percentile of
abs(power) over all non-null observations. Series whose P99 is null or non-positive (e.g.
all-null or all-zero power) are dropped, since EffectiveCapacity requires
effective_capacity_mw > 0.
time is set to that series' latest observed timestep (time.max()). The v0.1 capacity
is a single scalar per series, so time is really an "as of" marker — it stamps the estimate
as current to the end of the observed history — rather than a timestep the value varies over.
The v0.7 upgrade makes capacity genuinely time-varying (one row per (time_series_id, time)),
and only then does time carry per-row meaning. v0.1 stays one scalar row per series rather
than the value repeated at every half-hour: densifying a constant adds rows without information,
and the metrics join is by time_series_id alone until capacity varies.
Kept as a pure helper (no Dagster, no IO) so the P99 logic is unit-testable in isolation.
Source code in packages/ml_core/src/ml_core/metrics.py
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 | |
compute_metrics(cv_forecasts, actuals, metadata, capacity)
Compute evaluation metrics from CV predictions and observed power.
Full metric definitions, equations, and design rationale: https://openclimatefix.github.io/nged-substation-forecast/techniques/evaluation-metrics/
For each (time_series_id, fold_id, power_fcst_model_name) group:
- Joins predictions to observed
poweron(time_series_id, valid_time). - Assigns each row a
horizon_slicefrom its lead time (valid_time − power_fcst_init_time) — see_horizon_slice_exprfor the bands. - Collapses the ensemble members within each forecast run (per
power_fcst_init_time) into per-timestamp quantities: the deterministic ensemble mean, the fair CRPS, the Fortin-corrected ensemble variance, and the empiricalDELIVERY_QUANTILES. Each run covering avalid_timeis scored independently, exactly as a production consumer would experience it — runs at different lead times are never pooled. - Aggregates per
horizon_slice, plus the"all"aggregate over every lead time: MAE, NMAE, RMSE, and MBE on the ensemble mean; CRPS and the spread-skill ratio from the member-aware quantities; pinball loss at each delivery quantile (plus their mean); and PICP and interval width for each symmetric quantile band. - Joins
time_series_typefrommetadataonto each row. - Returns one row per
(time_series_id, fold_id, power_fcst_model_name, horizon_slice, metric_name, metric_param)in the tallMetricsformat.
NMAE is normalised by the pre-computed full-history effective_capacity_mw (joined per
time_series_id from capacity) — a capacity-like denominator, computed over the full
history so it stays stable across folds. This time_series_id-only join is correct while
capacity is one scalar row per series (the v0.1 shape). When the v0.7
upgrade makes capacity time-varying (one row per (time_series_id,
time)), this join must become a temporal as-of join on (time_series_id, valid_time).
Single-member "ensembles" (e.g. a deterministic baseline forecaster) are scored unconditionally: their fair CRPS equals their MAE, their spread-skill ratio is 0, and their quantile bands are degenerate (all quantiles coincide, so PICP ≈ 0 and interval width = 0). Those are honest descriptions of a deterministic forecast, not errors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cv_forecasts
|
DataFrame[PowerForecast]
|
CV predictions to evaluate.
Currently only CV fold rows are handled; |
required |
actuals
|
LazyFrame[PowerTimeSeries]
|
Observed half-hourly power (lazy — only the joined subset is collected).
Deduplicated on |
required |
metadata
|
DataFrame[TimeSeriesMetadata]
|
Substation metadata used to join |
required |
capacity
|
DataFrame[EffectiveCapacity]
|
Pre-computed per-series effective capacity; |
required |
Returns:
| Type | Description |
|---|---|
DataFrame[Metrics]
|
A validated tall |
Raises:
| Type | Description |
|---|---|
NoOverlappingActualsError
|
If no rows survive the inner join (forecasts cover a period with no observed data). |
ValueError
|
If any forecast row has a negative lead time ( |
Source code in packages/ml_core/src/ml_core/metrics.py
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 | |
build_mlflow_aggregate_metrics(metrics_df)
Return a flat {metric_key: value} dict for mlflow.log_metrics.
Computes mean metric_value across all series ("all" aggregate), per
time_series_type, and per horizon_slice. The key token is {metric_name} for
metric_param="all" metrics and {metric_name}_{metric_param} for parametric ones
(e.g. pinball_loss_p10, picp_p10_p90); parametric metrics are restricted to the
_MLFLOW_LOGGED_PARAMETRIC headline subset. Key formats:
"{token}__all"— overall aggregate (horizon_slice="all")."{token}__{type_slug}"— per-type aggregates (horizon_slice="all")."{token}__all__{horizon_slice}"— overall aggregate per lead-time band (e.g."nmae__all__day_ahead"). Per-type sliced aggregates are deliberately not logged — that detail stays queryable in theforecast_metricsDelta table, as does the full 13-quantile / 6-band parametric detail.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
metrics_df
|
DataFrame
|
Per-series |
required |
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
Flat dict of MLflow metric key → mean float value. |
Source code in packages/ml_core/src/ml_core/metrics.py
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 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 | |
enrich_metrics_rows(per_series_metrics, experiment_name, evaluation_scope, window_start, window_end, window_label, computed_at, mlflow_run_id)
Add scope and evaluation-window provenance columns to a per-series Metrics frame.
Called by the metrics Dagster asset after compute_metrics() returns, once the
window bounds and MLflow run ID are known. Kept here so the enrichment logic is
unit-testable without Dagster.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
per_series_metrics
|
DataFrame[Metrics]
|
Frame produced by |
required |
experiment_name
|
str
|
Experiment that produced these forecasts. |
required |
evaluation_scope
|
EvalScopeType
|
|
required |
window_start
|
datetime
|
Inclusive start of the evaluated |
required |
window_end
|
datetime
|
Inclusive end of the evaluated |
required |
window_label
|
str
|
Human-readable label ( |
required |
computed_at
|
datetime
|
UTC timestamp when this metric batch was computed. |
required |
mlflow_run_id
|
str | None
|
MLflow fold run ID; |
required |
Returns:
| Type | Description |
|---|---|
DataFrame[Metrics]
|
A validated |
Source code in packages/ml_core/src/ml_core/metrics.py
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 610 | |
ml_core.cv_helpers
Pure, data-only helpers for the cross-validation Dagster assets.
Every function here is deliberately free of I/O (no Delta, MLflow, or Dagster imports) so it can be unit-tested in isolation. The CV asset bodies stay thin by delegating their logic here.
Attributes
CV_PARTITION_KEY_SEPARATOR = '__'
module-attribute
Separator between experiment name and fold id in a CV partition key.
A double-underscore reduces collision risk with experiment names that contain single underscores.
Classes
Functions:
date_to_utc_datetime(d, *, end_of_day=False)
Return a tz-aware UTC datetime at the start (or inclusive end) of the given date.
Used to turn a fold's [start, end] calendar dates into the half-open-free, inclusive
[start 00:00:00, end 23:59:59] UTC window that the training and validation data loads filter
on (and that eligibility uses for val_end).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
date
|
The calendar date. |
required |
end_of_day
|
bool
|
If True, return |
False
|
Source code in packages/ml_core/src/ml_core/cv_helpers.py
22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
eligible_time_series_ids(coverage, fold, min_training_months)
Return the sorted time_series_ids eligible for a fold, from data coverage alone.
A time series is eligible when it has at least min_training_months of observations
before the fold's val_start and observations through the fold's val_end.
Eligibility is a function of the data only — it does not depend on any model or experiment config — so every experiment evaluates a fold on the identical population, which is what makes leaderboard comparisons fair.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coverage
|
DataFrame
|
One row per time series with the columns |
required |
fold
|
CvFoldConfig
|
The CV fold whose eligibility is being computed. |
required |
min_training_months
|
int
|
Minimum months of pre- |
required |
Returns:
| Type | Description |
|---|---|
list[int]
|
Sorted list of eligible |
Source code in packages/ml_core/src/ml_core/cv_helpers.py
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 | |
parse_cv_partition_key(partition_key)
Return (experiment_name, fold_id) from a CV partition key.
Partition key format: "{experiment_name}__{fold_id}". The separator is a
double-underscore to reduce collision risk with experiment names that contain single
underscores. Splitting from the right keeps __ inside the experiment name intact.
Source code in packages/ml_core/src/ml_core/cv_helpers.py
84 85 86 87 88 89 90 91 92 | |
flatten_config(config, prefix='')
Flatten a (possibly nested) config into dotted-key string params for MLflow.
MLflow params are scalar strings, so nested dicts are flattened with dotted keys and every
leaf value is stringified. A pydantic BaseModel is first dumped to a plain dict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
BaseModel | dict[str, Any]
|
A pydantic model or (possibly nested) dict to flatten. |
required |
prefix
|
str
|
Internal recursion prefix; callers should not set it. |
''
|
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
A flat |
Source code in packages/ml_core/src/ml_core/cv_helpers.py
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | |
ml_core.mlflow_runs
Idempotent MLflow run-resolution helpers, used by every CV/registration code path.
Single concern: resolving MLflow runs by tag. The CV assets (trained_cv_model,
cv_power_forecasts, metrics) and register_experiment_job run in separate processes
(and, on retries, at separate times), so a live Run handle cannot be passed between them.
Instead, every code path discovers or creates the run it needs by tag and resumes it by ID.
Each helper returns an ID string, never an open run handle. The caller wraps the returned ID
in with mlflow.start_run(run_id=...) to log and close the run within its own process. Because
lookup is by tag, every helper is safe to call from any process and idempotent under Dagster
retries (a re-run resumes the same run rather than duplicating it).
The tracking URI is set by the caller (mlflow.set_tracking_uri(...)); these helpers simply
use mlflow / MlflowClient, which honour whatever URI is in effect. This is what lets the
helpers run against a file-based MLflow in tests with no server.
Attributes
CV_PARENT_RUN_NAME = 'cv_summary'
module-attribute
Run name of an experiment's parent run, which carries the aggregate leaderboard metrics.
Classes
PromotableRun
dataclass
One MLflow fold run (cv_role=fold) — a valid promoted_model promotion candidate.
Source code in packages/ml_core/src/ml_core/mlflow_runs.py
149 150 151 152 153 154 155 156 | |
Attributes
run_id
instance-attribute
experiment_name
instance-attribute
fold_id
instance-attribute
start_time
instance-attribute
Methods:
__init__(run_id, experiment_name, fold_id, start_time)
Functions:
load_experiment_forecaster(experiment_name)
Reconstruct the forecaster class + resolved config from an experiment's MLflow tags.
register_experiment stamps the config JSON and the forecaster class's fully-qualified
import path (forecaster_target) on the experiment. The config JSON carries no class
identity of its own, so the BaseForecasterConfig subclass to deserialise it into is reached
through the forecaster's CONFIG_CLASS — the same class its load uses. The caller is
responsible for setting the tracking URI (mlflow.set_tracking_uri) beforehand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
experiment_name
|
str
|
The MLflow experiment name (also the partition-key prefix). |
required |
Returns:
| Type | Description |
|---|---|
type[BaseForecaster]
|
A |
BaseForecasterConfig
|
|
Source code in packages/ml_core/src/ml_core/mlflow_runs.py
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 | |
get_or_create_experiment(experiment_name)
Return the MLflow experiment id for experiment_name, creating it if absent.
This is the self-healing fallback path; the canonical creator is
register_experiment_job, which also stamps the experiment's config/description
tags. Created here untagged so a stray asset call cannot race the job into a tagless,
half-registered experiment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
experiment_name
|
str
|
Human-readable, unique experiment name. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The MLflow experiment id. |
Source code in packages/ml_core/src/ml_core/mlflow_runs.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | |
get_or_create_parent_run(experiment_id)
Return the experiment's parent run id (tag cv_role=parent), creating it if absent.
The parent run holds the experiment's aggregate (leaderboard) metrics and the flattened config params. Resolved by tag so any process finds the same run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
experiment_id
|
str
|
The MLflow experiment id. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The parent run id. |
Source code in packages/ml_core/src/ml_core/mlflow_runs.py
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 | |
get_or_create_fold_run(experiment_id, parent_run_id, fold_id)
Return the fold's child run id (tags cv_role=fold, fold_id=...), creating it if absent.
The fold run holds that fold's per-fold tags and metrics (never MLflow params — a fold run is
reused across every re-materialisation of its partition, and params are write-once, so
nothing that can legitimately change between materialisations may be logged as one; see
trained_cv_model in defs/cv_assets.py). It is created nested under the
experiment's parent run, so the MLflow UI groups folds beneath cv_summary. Resolved by
(cv_role, fold_id) so any process — and any Dagster retry of the fold — finds the same
run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
experiment_id
|
str
|
The MLflow experiment id. |
required |
parent_run_id
|
str
|
The experiment's parent run id (from |
required |
fold_id
|
str
|
The fold identifier (e.g. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The fold run id. |
Source code in packages/ml_core/src/ml_core/mlflow_runs.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 | |
list_promotable_runs()
List every fold run (cv_role=fold) across all MLflow experiments, newest first.
A read-only convenience for the promotable_model_runs asset
(defs/production_assets.py), which logs this as a metadata table in the Dagster UI so a
promoted_model promotion candidate's run id can be copy-pasted into that asset's
launchpad rather than retyped from memory. The champion is still picked by eye off the MLflow
leaderboard; this only lists candidates. The caller is responsible for setting the tracking
URI (mlflow.set_tracking_uri) beforehand.
Source code in packages/ml_core/src/ml_core/mlflow_runs.py
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 | |
ml_core.production_helpers
Pure, IO-light helpers for production (live) inference.
Every function here is unit-testable in isolation: the two data-shaping helpers
(select_nwp_init_time, build_live_power_frame) take power_fcst_init_time as an
explicit parameter rather than calling datetime.now() internally, so a test can pass any
fixed time and get a deterministic result; the two disk/MLflow helpers
(load_forecaster_from_dir, fetch_model_artifacts) do the IO and check that the saved
model is one this code can still build a config for and parse the features of. The
live_forecasts and promoted_model Dagster assets
(src/nged_substation_forecast/defs/production_assets.py) stay thin shells over these.
Attributes
AvailabilityModeType = Literal['live', 'replay']
module-attribute
Which NWP-availability rule select_nwp_init_time applies.
"live": the scheduled path. No modelled publication delay — the Delta table only contains runs that have genuinely been published, so the cutoff ispower_fcst_init_timeitself."replay": re-running a past slot. The cutoff ispower_fcst_init_time - nwp_publication_delay_hours, reconstructing what was actually available at that historicalpower_fcst_init_time(without the delay we would leak runs that only landed afterwards).
Classes
Functions:
select_nwp_init_time(available_init_times, *, power_fcst_init_time, availability_mode, nwp_publication_delay_hours=NWP_PUBLICATION_DELAY_HOURS)
Return the freshest NWP init_time available at power_fcst_init_time.
Which runs count as available depends on availability_mode.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
available_init_times
|
Sequence[datetime]
|
The |
required |
power_fcst_init_time
|
datetime
|
The scheduled forecast time (the partition's window end). |
required |
availability_mode
|
AvailabilityModeType
|
|
required |
nwp_publication_delay_hours
|
int
|
Only used in |
NWP_PUBLICATION_DELAY_HOURS
|
Returns:
| Type | Description |
|---|---|
datetime
|
The freshest |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no available |
Source code in packages/ml_core/src/ml_core/production_helpers.py
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 | |
build_live_power_frame(observed_power, time_series_ids, *, power_fcst_init_time, history, horizon)
Build a dense half-hourly (time_series_id, time) spine for live inference.
Needed because ml_core.features._nwp._join_nwp_single_run is power-centric — with no
future power rows a live run would emit zero forecast rows. Left-joins observed power onto
a spine covering (power_fcst_init_time - history, power_fcst_init_time + horizon] for
every requested time_series_id, so rows beyond the last observation are present with
power = null. Also harmless for replay (future observations already exist there;
_nullify_leaky_lags prevents lag leakage regardless).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed_power
|
LazyFrame[PowerTimeSeries]
|
Lazy observed power, one row per |
required |
time_series_ids
|
list[int]
|
The series to build a spine for (typically
|
required |
power_fcst_init_time
|
datetime
|
The forecast init time. The spine's window is anchored on this. |
required |
history
|
timedelta
|
How far before |
required |
horizon
|
timedelta
|
How far after |
required |
Returns:
| Type | Description |
|---|---|
LazyFrame[PowerTimeSeries]
|
A lazy |
LazyFrame[PowerTimeSeries]
|
half-hourly grid, observed values joined in, future/missing rows null. |
Source code in packages/ml_core/src/ml_core/production_helpers.py
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 | |
load_forecaster_from_dir(path)
Load the production model from a plain disk directory (no MLflow at inference time).
Reads meta.json and resolves model_class via contracts.config_schemas.import_class
(the same mechanism ml_core.mlflow_runs.load_experiment_forecaster uses), then calls the
concrete subclass's load(path).
The forecaster returned is one this code can actually serve, not merely one it could deserialise: a config it cannot rebuild, or a feature vocabulary it cannot parse, is rejected here rather than partway through a live tick's feature engineering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Directory previously populated by |
required |
Returns:
| Type | Description |
|---|---|
BaseForecaster
|
The reconstructed, trained forecaster. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
|
ValueError
|
This code cannot serve the saved model — see |
Source code in packages/ml_core/src/ml_core/production_helpers.py
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 | |
fetch_model_artifacts(run_id, dest)
Download and unpack an MLflow run's saved model into dest, replacing it atomically.
Downloads and unpacks into a temporary directory first, so a failed or interrupted download
never touches dest — only a fully-downloaded model is moved into place (via rmtree +
move). dest is local disk by convention — Settings.production_model_path derives
from local_artifacts_path, though nothing enforces that — so unlike the Delta tables this is
a directory of many files with no commit protocol over it, and a part-written one would be
served. The run holds the model as a single archive artifact
(ml_core.base_forecaster._MLFLOW_MODEL_ARTIFACT), so dest gets exactly the files the
last save_to_mlflow wrote and can never inherit a stale file from an earlier, larger model.
The downloaded model's saved config is checked against the running code before the swap,
reading the staged meta.json rather than loading the model, so a model this code cannot
serve is refused while the previous champion stays in dest and keeps serving. Reading the
JSON is deliberate: it applies the same validation the subclass's load would, without
pulling every booster into memory to do it.
Also writes a promotion.json ({"mlflow_run_id", "promoted_at"}) into dest for
provenance; a BaseForecaster.load implementation reads its own population from its saved
record (e.g. XGBoostForecaster from meta.json's trained_time_series_ids), never
from a directory listing, so this extra file is harmless.
The caller is responsible for setting the tracking URI (mlflow.set_tracking_uri)
beforehand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The MLflow run the model was saved under (via |
required |
dest
|
Path
|
Directory to populate — typically |
required |
Raises:
| Type | Description |
|---|---|
MlflowException
|
|
ValueError
|
The run holds no |
Source code in packages/ml_core/src/ml_core/production_helpers.py
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 | |
ml_core.repro
Reproducibility provenance: the git SHA and Delta-table versions behind an MLflow run.
Answers "exactly which code and which data produced this?" for any MLflow run. The git SHA pins
the code — stamped explicitly because MLflow's mlflow.source.git.commit auto-detection
needs gitpython installed and the working directory inside the repo, neither of which holds in a
production container. Each Delta table's version() pins the data: Delta Lake time travel makes
data versioning one integer per table, so a run can later be replayed with
pl.scan_delta(path, version=N) after git checkout {sha}.
Every function here is deliberately non-raising: provenance is metadata, and a missing .git
directory (containers) or an absent Delta table must never fail the surrounding training or
forecasting run. Such failures degrade to the sentinels "unknown" / "absent".
provenance_tags stage-prefixes its keys (register_, train_, predict_,
metrics_) because a single MLflow fold run is written by three separate assets —
trained_cv_model, cv_power_forecasts and metrics — each potentially on a different code
revision and data state. Un-prefixed keys would clobber one another; the prefix preserves all
three provenance snapshots side by side.
Attributes
logger = logging.getLogger(__name__)
module-attribute
MlflowTags = dict[str, str]
module-attribute
A {tag_key: tag_value} mapping ready to hand to mlflow.set_tags.
StageType = Literal['register', 'train', 'predict', 'metrics']
module-attribute
The assets that stamp provenance; each value becomes a tag-key prefix (see provenance_tags).
Add a new member when a new asset starts stamping.
TableNameType = Literal['power_time_series', 'nwp_data', 'eligible_time_series', 'power_forecasts', 'effective_capacity']
module-attribute
Logical names of the Delta tables whose versions get stamped — the keys of a delta_paths
mapping. Add a new member when a stage starts reading (and stamping) another table.
UNKNOWN = 'unknown'
module-attribute
Sentinel git SHA / dirty flag returned when no git repository is reachable (e.g. a container).
ABSENT = 'absent'
module-attribute
Sentinel Delta version returned when a table does not exist or cannot be read.
Classes
Functions:
get_git_info(cwd=None)
Return {"git_sha", "git_dirty"} for the current checkout, never raising.
git_dirty is "true" when the working tree has uncommitted changes, "false" when
clean. When the SHA cannot be read (no .git, no git binary, a timeout) both values are
UNKNOWN; when the SHA is read but the dirty check fails, the SHA is kept and only
git_dirty degrades to UNKNOWN — a good SHA is never discarded.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cwd
|
Path | None
|
Directory the |
None
|
Source code in packages/ml_core/src/ml_core/repro.py
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 | |
get_delta_versions(paths, storage_options=None)
Return {f"delta_version__{name}": str(version)} for each named Delta table.
Never raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
paths
|
dict[TableNameType, str]
|
|
required |
storage_options
|
ObjectStoreOptions | None
|
object-store options for a remote URI; |
None
|
Returns:
| Type | Description |
|---|---|
MlflowTags
|
One entry per input path. A table that does not exist (or cannot be read) maps to |
MlflowTags
|
|
Source code in packages/ml_core/src/ml_core/repro.py
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 | |
provenance_tags(stage, delta_paths=None, storage_options=None)
Build stage-prefixed MLflow tags stamping code (git) and data (Delta version) provenance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stage
|
StageType
|
Prefix identifying the writing asset. Keeps the tags of assets that share one MLflow run from clobbering. |
required |
delta_paths
|
dict[TableNameType, str] | None
|
|
None
|
storage_options
|
ObjectStoreOptions | None
|
object-store options for remote table URIs; |
None
|
Returns:
| Type | Description |
|---|---|
MlflowTags
|
e.g. for |
MlflowTags
|
"train_delta_version__power_time_series", ...}``. |
Source code in packages/ml_core/src/ml_core/repro.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |