Source code for routee.powertrain.core.model

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, TYPE_CHECKING, Union

import pandas as pd

from routee.powertrain.core.metadata import Metadata, SCHEMA_VERSION_STRING
from routee.powertrain.estimators.estimator_interface import Estimator
from routee.powertrain.estimators.onnx import ONNXEstimator
from routee.powertrain.estimators.ngboost_estimator import NGBoostEstimator

from routee.powertrain.io.archive import (
    load_model_from_path,
    save_model_directory,
    save_archive,
    save_tar_archive,
    save_to_registry as _save_to_registry,
)
from routee.powertrain.io.to_lookup_table import to_lookup_table
from routee.powertrain.validation.feature_visualization import (
    contour_plot,
    visualize_features,
)

if TYPE_CHECKING:
    from pandas import Series
    from routee.powertrain.registry.model_id import ModelKey

REGISTERED_ESTIMATORS: Dict[str, type[Estimator]] = {
    "ONNXEstimator": ONNXEstimator,
    "NGBoostEstimator": NGBoostEstimator,
}


[docs] @dataclass class Model: """ A RouteE-Powertrain vehicle model represents a single vehicle (i.e. a 2016 Toyota Camry with a 1.5 L gasoline engine). """ estimator: Estimator metadata: Metadata @property def key(self) -> ModelKey: """This model's intrinsic, version-less identity. Derived from metadata (``make``/``model``/``year`` + the derived ``config_slug``), so it is always available — even for a freshly-trained model that has never been placed in a registry. The registry ``version`` is *not* part of this; it is assigned only by registry operations (``save_to_registry`` returns a full ``ModelId``). """ from routee.powertrain.registry.model_id import ModelKey return ModelKey.from_metadata(self.metadata) @property def digest(self) -> Optional[str]: """This model's registry-independent instance identity. The ``sha256:<hex>`` content digest minted at train time (see ``routee.powertrain.core.digest``). Unlike ``key``, which groups all retrains of the same configuration, the digest is unique per trained artifact — two models trained the same day on different data get distinct digests. ``None`` for legacy models saved before digests existed. """ return self.metadata.model_digest @property def feature_set(self): return self.metadata.config.feature_set @property def feature_names(self) -> List[str]: return self.metadata.config.feature_set.feature_name_list
[docs] @classmethod def from_file(cls, file: Union[str, Path]): """ Load a vehicle model from a file or directory. Supports directories (containing metadata.json + binary), .zip archives, and .tar.gz archives. Args: file: the path to the file or directory to load Returns: a powertrain vehicle """ return load_model_from_path(file)
[docs] def to_file(self, file: Union[str, Path]): """ Save a vehicle model to a file or directory. If *file* has no suffix, saves as a flat directory. If it ends with ``.zip``, saves as a ZIP archive. If it ends with ``.tar.gz``, saves as a tar archive. Args: file: the path to save to """ path = Path(file) if path.suffix == ".zip": path.parent.mkdir(parents=True, exist_ok=True) save_archive(self, path) elif path.name.endswith(".tar.gz") or path.suffix == ".tar": path.parent.mkdir(parents=True, exist_ok=True) save_tar_archive(self, path) else: # No extension → flat directory save_model_directory(self, path)
[docs] def save_to_registry( self, registry_root: Union[str, Path], config_slug: Optional[str] = None, version: Optional[int] = None, schema_version: str = SCHEMA_VERSION_STRING, overwrite: bool = False, ): """ Save this model into a local registry directory tree. Builds the canonical ``<registry_root>/<schema_version>/<make>/<model>/<year>/<config_slug>/v<N>/`` layout from ``self.metadata.config``. The ``config_slug`` is derived from metadata unless overridden, and ``version`` defaults to the next unused version. See ``routee.powertrain.io.archive.save_to_registry`` for full details. Returns: the ``ModelId`` that was written. """ return _save_to_registry( self, registry_root=registry_root, config_slug=config_slug, version=version, schema_version=schema_version, overwrite=overwrite, )
[docs] def to_lookup_table( self, feature_parameters: list[dict], energy_target: str, ) -> pd.DataFrame: """ Convert the the model to a lookup table for the given feature parameters. """ return to_lookup_table(self, feature_parameters, energy_target)
[docs] def visualize_features( self, n_samples: Optional[int] = 100, output_path: Optional[str] = None, return_predictions: Optional[bool] = False, ) -> Optional[Dict[str, "Series"]]: """ generates test links to independently test the model's features and creates plots of those predictions Args: n_samples: the number of samples used to generate the plots output_path: an optional path to save the plots as png files. return_predictions: if true, returns the dictionary containing the prediction values Returns: optionally returns a dictionary containing the predictions where the key is the feature tested """ feature_set = self.metadata.config.feature_set feature_ranges = {} for f in feature_set.features: if f.constraints.upper is None or f.constraints.lower is None: raise ValueError( f"Feature: {f.name} has constraints without a lower/upper bound. " f"You can add constraints when training a model or set custom constraints during visualization using " f"routee.powertrain.validation.feature_visualization.visualize_features" ) feature_ranges[f.name] = { "upper": f.constraints.upper, "lower": f.constraints.lower, "n_samples": n_samples, } return visualize_features( model=self, feature_ranges=feature_ranges, output_path=output_path, return_predictions=return_predictions, )
[docs] def contour( self, x_feature: str, y_feature: str, n_samples: Optional[int] = 100, output_path: Optional[str] = None, ): """ generates a contour plot of the two test features: x_feature and y_feature. Args: x_feature: one of the features used to generate the energy matrix and will be the x-axis feature y_feature: one of the features used to generate the energy matrix and will be the y-axis feature n_samples: the number of samples used to generate the plots output_path: an optional path to save the plots as png files. """ feature_set = self.metadata.config.feature_set feature_ranges = {} for f in feature_set.features: if f.constraints.upper is None or f.constraints.lower is None: raise ValueError( f"Feature: {f.name} has constraints without a lower/upper bound. " f"You can add constraints when training a model or set custom constraints during visualization using " f"routee.powertrain.validation.feature_visualization.contour_plot" ) feature_ranges[f.name] = { "upper": f.constraints.upper, "lower": f.constraints.lower, "n_samples": n_samples, } return contour_plot( model=self, x_feature=x_feature, y_feature=y_feature, feature_ranges=feature_ranges, output_path=output_path, )
[docs] def predict( self, links_df: pd.DataFrame, ) -> pd.DataFrame: """ Predict absolute energy consumption for each link Args: links_df: a dataframe containing the links to predict on. Must contain every column in ``self.feature_set`` plus the distance column, and (if the estimator's ``input_spec`` declares a grouping column) that grouping column. Returns: a dataframe containing the predicted energy consumption for each link """ config = self.metadata.config feature_set = config.feature_set missing = [ f.name for f in feature_set.features if f.name not in links_df.columns ] if missing: raise ValueError( f"links_df is missing the following required features: {missing}. " f"Expected features: {feature_set.feature_name_list}" ) input_spec = self.estimator.input_spec if input_spec.grouping_column is not None: if input_spec.grouping_column not in links_df.columns: raise ValueError( f"Estimator requires a grouping column '{input_spec.grouping_column}' " f"(lookback={input_spec.lookback}) but it was not found in links_df." ) pred_energy_df = self.estimator.predict(links_df, config) for energy in config.target.targets: pred_energy_df[energy.name] = ( pred_energy_df[energy.name] * config.real_world_adjustment_factor ) return pred_energy_df
def __repr__(self) -> str: """ Shows a nice pretty printed summary of the model including: - Model average fuel consumption - Select set of errors - Expected features and their units - Powertrain specifications """ config = self.metadata.config summary_lines = [] summary_lines.append("=" * 40) summary_lines.append("Model Summary") summary_lines.append("-" * 20) summary_lines.append(f"Vehicle description: {config.vehicle_description}") summary_lines.append(f"Powertrain type: {config.powertrain_type.name}") summary_lines.append("=" * 40) estimator_errors = self.metadata.errors.estimator_errors summary_lines.append("Estimator Summary") summary_lines.append("-" * 20) feature_set = config.feature_set for feature in feature_set.features: summary_lines.append(f"Feature: {feature.name} ({feature.units})") summary_lines.append( f"Distance: {config.distance.name} ({config.distance.units})" ) for target in config.target.targets: summary_lines.append(f"Target: {target.name} ({target.units})") target_errors = estimator_errors.error_by_target.get(target.name) if target_errors is None: raise ValueError(f"Could not find errors for target {target.name}") summary_lines.append( f"Raw Predicted Consumption: {target_errors.pred_dist_per_energy:.3f} " f"({config.distance.units}/{target.units})" ) summary_lines.append( f"Real World Predicted Consumption: {target_errors.real_world_pred_dist_per_energy:.3f} " f"({config.distance.units}/{target.units})" ) summary_lines.append(f"Predict Method: {config.predict_method.value.upper()}") summary_lines.append("=" * 40) return "\n".join(summary_lines) def _repr_html_(self) -> str: """ Returns an html table of the model summary for display in a notebook """ config = self.metadata.config # Start the HTML table html_lines = ['<table border="1" style="border-collapse: collapse;">'] # Title: Model Summary html_lines.append( '<tr><th colspan="2" style="border-bottom: 2px solid black; text-align: center;">Model Summary</th></tr>' ) html_lines.append( f"<tr><td>Vehicle description</td><td>{config.vehicle_description}</td></tr>" ) html_lines.append( f"<tr><td>Powertrain type</td><td>{config.powertrain_type.name}</td></tr>" ) estimator_errors = self.metadata.errors.estimator_errors # Title: Estimator Summary html_lines.append( '<tr><th colspan="2" style="border-bottom: 2px solid black; text-align: center;">Estimator Summary</th></tr>' ) feature_set = config.feature_set for feature in feature_set.features: html_lines.append( f"<tr><td>Feature</td><td>{feature.name} ({feature.units})</td></tr>" ) html_lines.append( "<tr><td>Distance</td>" f"<td>{config.distance.name} ({config.distance.units})</td></tr>" ) for target in config.target.targets: html_lines.append( f"<tr><td>Target</td><td>{target.name} ({target.units})</td></tr>" ) target_errors = estimator_errors.error_by_target.get(target.name) if target_errors is None: raise ValueError(f"Could not find errors for target {target.name}") html_lines.append( "<tr><td>Predicted Consumption</td>" f"<td>{target_errors.pred_dist_per_energy:.3f} " f"({config.distance.units}/{target.units})</td></tr>" ) html_lines.append( "<tr><td>Real World Predicted Consumption</td>" f"<td>{target_errors.real_world_pred_dist_per_energy:.3f} " f"({config.distance.units}/{target.units})</td></tr>" ) html_lines.append( f"<tr><td>Predict Method</td>" f"<td>{config.predict_method.value.upper()}</td></tr>" ) # End the HTML table html_lines.append("</table>") return "".join(html_lines)