Publishing a Trained Model#
After training a model with one of the Trainer classes, you can save it
into the v2 registry layout that every registry backend understands —
LocalRegistry, HFRegistry, and S3Registry all read the same tree.
This page walks through writing a trained model into a local registry,
understanding the derived vehicle_slug and config_slug, and loading it
back through pt.load_model().
The v2 Registry Layout#
Every model in the registry lives in a five-segment path:
<registry_root>/v2/<make>/<vehicle_slug>/<year>/<config_slug>/v<N>/
metadata.json
model.onnx # (or another binary, depending on the estimator)
The bundled example at
routee/powertrain/resources/bundled_registry/v2/toyota/camry_ice/2016/rf_c3326385/v1/
is a concrete reference for what the on-disk layout looks like.
The derived vehicle_slug#
The second path segment identifies the vehicle. Like the config_slug, you
don't write it — it is derived from the model's metadata as a pure function:
<model>_<powertrain_family>
model — the
ModelConfig.modelname. Use the vehicle's full commercial designation, including whatever distinguishes same-year stablemates:camry,golf_1.5tsivsgolf_2.0tdi,leaf_24_kwhvsleaf_30_kwh.powertrain_family — the coarse family of
powertrain_type:ice,hev,bev,phev,heavy_duty. The two PHEV operating modes (PHEV_EV_MODE/PHEV_HEV_MODE) collapse to onephevfamily — a charge-depleting and a charge-sustaining model describe the same vehicle, and that split lives inModelConfig.variant(and thus theconfig_slug).
So model="Camry" with powertrain_type=ICE lands at
.../toyota/camry_ice/..., and both Volt operating-mode models share
.../chevrolet/volt_phev/.... The model token is sanitized (lowercased,
whitespace and / become -), and the slug is never parsed back apart — the
registry always re-derives it from metadata and compares.
The remaining vehicle attributes — engine, drivetrain, trim,
fuel_type, mass_lbs — are deliberately not identity. They are
descriptive metadata: individually filterable
(pt.query_available_models(engine="4cyl")) and correctable on an
already-published model without renaming its registry path. Only put a
distinction in the model name when it genuinely names a different vehicle.
The derived config_slug#
The config_slug disambiguates multiple trained configurations for the same
vehicle and year. You don't pick it — it is derived from the model's
metadata, so it stays consistent and can't drift from what the model actually
is. The slug is a pure function:
<architecture>_<variant?>_<feature_set_hash>
architecture — a short code for the estimator family (
rf,ngb,cnn), frommetadata.estimator.architecture_tag.variant — the optional
ModelConfig.variantlabel, included only when set.feature_set_hash — a short hash of the feature set, so different feature compositions get different slugs automatically.
For example, rf_c3326385 (a random forest over speed & grade) or
ngb_stochastic_96224f1f (an NGBoost model with variant="stochastic").
Because the hash already separates different feature sets, the only time you
need to intervene is when two models share the same architecture and feature
set but represent different regimes — e.g. a "steady thermal state" model and
a "warm-up" model. Set ModelConfig.variant to tell them apart:
config = pt.ModelConfig(..., variant="steady") # -> rf_steady_<hash>
config = pt.ModelConfig(..., variant="warmup") # -> rf_warmup_<hash>
The registry recomputes both derived slugs when loading and raises if either
disagrees with the on-disk path, so a moved or hand-edited model surfaces
loudly instead of silently mis-loading. version is the one coordinate the
registry assigns, not part of the derived identity — retraining the same
configuration bumps v<N>.
Train and Publish#
Model.save_to_registry() writes a trained model into the canonical layout in
one call. It pulls make and year from model.metadata.vehicle and derives
the vehicle_slug (model + powertrain family) and the config_slug, so make
sure those fields on your ModelConfig are correct before training.
import routee.powertrain as pt
from routee.powertrain.trainers.sklearn_random_forest import (
SklearnRandomForestTrainer,
)
config = pt.ModelConfig(
vehicle_description="2024 Test Sedan",
powertrain_type=pt.PowertrainType.ICE,
feature_set=pt.FeatureSet(features=[
pt.DataColumn(name="speed_mph", units="mph"),
pt.DataColumn(name="grade_dec", units="decimal"),
]),
distance=pt.DataColumn(name="miles", units="miles"),
target=pt.TargetSet(targets=[
pt.DataColumn(name="gallons_fastsim", units="gallons_gasoline"),
]),
make="Test",
model="Sedan",
year=2024,
)
model = SklearnRandomForestTrainer().train(training_df, config)
# both slugs are derived; version defaults to the next unused version.
model_id = model.save_to_registry(registry_root="./my_local_registry")
print(model_id) # test/sedan_ice/2024/rf_aaa9554f/v1
print(model.key) # test/sedan_ice/2024/rf_aaa9554f (version-less identity)
This creates ./my_local_registry/v2/test/sedan_ice/2024/rf_aaa9554f/v1/ with
metadata.json and model.onnx inside. If that exact version already has
files, the call raises FileExistsError — omit version to auto-increment, or
pass overwrite=True.
Every Model also exposes its version-less identity as model.key (a
ModelKey), available the moment it is trained — even before it is placed in a
registry.
Load It Back#
There are two equivalent ways to load a model from the registry you just wrote.
Option A — explicit LocalRegistry:
from routee.powertrain.registry.local import LocalRegistry
model = LocalRegistry("./my_local_registry").load(model_id)
predictions = model.predict(links_df)
Option B — pt.load_model() driven by environment variables:
import os
import routee.powertrain as pt
os.environ["ROUTEE_REGISTRY_BACKEND"] = "local"
os.environ["ROUTEE_LOCAL_REGISTRY_ROOT"] = "./my_local_registry"
# Omit the trailing v<N> to get the latest version. model.key.to_path() gives
# exactly this version-less path.
model = pt.load_model("test/sedan_ice/2024/rf_aaa9554f")
You can also list and query what's in the registry the same way you would with the default HuggingFace registry:
pt.list_available_models()
pt.query_available_models(make="test", powertrain_type="ICE")
What's in metadata.json#
metadata.json is written automatically by the save call. Its fields are
grouped by the job a reader needs them for — vehicle (identity), contract
(input/output), estimator (how to load the binary), and provenance (where
the model came from). Most of the contents come from your ModelConfig and the
trainer:
Field |
Source |
|---|---|
|
|
|
Package version at save time |
|
Computed during |
|
The trainer (e.g. |
|
The trainer (e.g. |
|
The estimator (lookback / grouping column / pad) |
|
Filename of the binary (e.g. |
|
From |
|
From |
|
From |
|
From |
|
From |
|
From |
|
From |
|
From |
|
From |
|
From |
|
Stamped by |
Metadata.config reconstructs the original flat ModelConfig from these
grouped sections on demand, so nothing is stored twice.
Recording provenance#
provenance.source records how the training data was produced. It is a
tagged union — set ModelConfig.training_source to one of three types and the
method discriminator picks the right one back out on load:
# The standard path: a model pipeline over FASTSim simulation output.
config = pt.ModelConfig(
...,
training_source=pt.FastSimSource(
# NatLabRockies/fastsim-vehicles, plus a git tag pinning that repo
fastsim_vehicle_id="v1/fastsim-3/conv/toyota/camry-4cyl-2wd/2016/base/r1",
fastsim_vehicles_ref="v1.2.0",
fastsim_version="3.1.0",
# the pipeline, and the training run that produced this model
pipeline_version="0.4.1",
pipeline_run_id="gha-2026-07-14-8871",
pipeline_repo_ref="9f3c1ab",
# the prepare-training-data runs it was fit to, and their sources
dataset_run_ids=["ptd-2026-07-14-001", "ptd-2026-07-14-002"],
data_sources=["wm1", "wm2"],
),
)
# Trained on real-world vehicle data instead.
config = pt.ModelConfig(
...,
training_source=pt.RealWorldSource(
data_source="fleet_dna",
fleet="delivery_vans",
collection_start="2023-01-01",
collection_end="2023-12-31",
n_vehicles=42,
dataset_name="fleet-dna-2023",
dataset_hash=pt.hash_dataframe(training_df),
),
)
RealWorldSource and LegacySource end with two dataset labels — dataset_name
(a human-readable identifier) and dataset_hash (a fingerprint, from
pt.hash_dataframe(df)). They live on the source because labeling the data is
part of describing where it came from. FastSimSource deliberately has neither;
see below. model.metadata.provenance.dataset_name and .dataset_hash read
through to whichever variant is set, and are None for a FastSimSource.
Every field is optional — record what you know. pt.LegacySource is the third
variant, used by the v1 converter for models whose origin predates this section.
Provenance is not part of the model digest, so it stays correctable after publish: backfilling a FASTSim version onto a published model does not change its identity. What the digest does cover is the vehicle identity, the contract, and the estimator binary's own sha256 — and that binary already changes whenever the training data or hyperparameters do.
Neither derived slug in the path is stored in metadata.json: the
vehicle_slug is derived from vehicle.model + the powertrain_type family,
and the config_slug from estimator.architecture_tag + vehicle.variant +
contract.feature_set (see routee/powertrain/registry/slug.py).
See routee/powertrain/core/metadata.py for the full schema and
routee/powertrain/core/model_config.py for the ModelConfig fields.