from __future__ import annotations
import json
from typing import Literal, Optional, cast
import numpy as np
import onnx
import onnxruntime as rt
import pandas as pd
from routee.powertrain.core.model_config import ModelConfig, PredictMethod
from routee.powertrain.estimators.estimator_interface import (
ColumnSpec,
Estimator,
InputSpec,
PadStrategy,
)
from routee.powertrain.utils.threading import get_restricted_threads
ONNX_INPUT_NAME = "input"
ONNX_DTYPE = "float32"
_META_LOOKBACK = "routee_lookback"
_META_GROUPING = "routee_grouping_column"
_META_PAD = "routee_pad_strategy"
_META_INPUT_COLUMNS = "routee_input_columns"
_META_OUTPUT_COLUMNS = "routee_output_columns"
_META_PREDICT_METHOD = "routee_predict_method"
_META_DISTANCE_COLUMN = "routee_distance_column"
#: Every metadata_props key this module manages, stripped before re-embedding so
#: a re-save never leaves stale contract entries behind.
_ROUTEE_META_KEYS = (
_META_LOOKBACK,
_META_GROUPING,
_META_PAD,
_META_INPUT_COLUMNS,
_META_OUTPUT_COLUMNS,
_META_PREDICT_METHOD,
_META_DISTANCE_COLUMN,
)
def _read_input_spec(onnx_model: onnx.ModelProto) -> InputSpec:
"""Read an InputSpec from ONNX metadata_props, or return the default if absent."""
lookback: int = 0
grouping: Optional[str] = None
pad: PadStrategy = "repeat_first"
input_columns: Optional[list[ColumnSpec]] = None
output_columns: Optional[list[ColumnSpec]] = None
predict_method: Optional[str] = None
distance_column: Optional[str] = None
for mp in onnx_model.metadata_props:
if mp.key == _META_LOOKBACK:
lookback = int(mp.value)
elif mp.key == _META_GROUPING:
grouping = mp.value or None
elif mp.key == _META_PAD:
if mp.value not in ("zero", "repeat_first"):
raise ValueError(f"Unknown pad strategy in ONNX metadata: {mp.value}")
pad = cast(PadStrategy, mp.value)
elif mp.key == _META_INPUT_COLUMNS:
input_columns = [ColumnSpec(**d) for d in json.loads(mp.value)]
elif mp.key == _META_OUTPUT_COLUMNS:
output_columns = [ColumnSpec(**d) for d in json.loads(mp.value)]
elif mp.key == _META_PREDICT_METHOD:
predict_method = mp.value or None
elif mp.key == _META_DISTANCE_COLUMN:
distance_column = mp.value or None
return InputSpec(
lookback=lookback,
grouping_column=grouping,
pad_strategy=pad,
input_columns=input_columns,
output_columns=output_columns,
predict_method=predict_method,
distance_column=distance_column,
)
def _embed_input_spec(
onnx_model: onnx.ModelProto, input_spec: InputSpec
) -> onnx.ModelProto:
"""Return a copy of ``onnx_model`` with ``input_spec`` embedded in metadata_props.
The input/output contract (ordered columns, predict method, distance column)
is written whenever present — including the common ``lookback == 0`` tabular
case — so a consumer holding only the ``.onnx`` can reconstruct the exact
positional input order. The windowing keys are written only when
``lookback > 0``.
"""
model = onnx.ModelProto()
model.CopyFrom(onnx_model)
keep = [mp for mp in model.metadata_props if mp.key not in _ROUTEE_META_KEYS]
del model.metadata_props[:]
model.metadata_props.extend(keep)
def _add(key: str, value: str) -> None:
prop = model.metadata_props.add()
prop.key = key
prop.value = value
if input_spec.input_columns is not None:
_add(
_META_INPUT_COLUMNS,
json.dumps([c.model_dump() for c in input_spec.input_columns]),
)
if input_spec.output_columns is not None:
_add(
_META_OUTPUT_COLUMNS,
json.dumps([c.model_dump() for c in input_spec.output_columns]),
)
if input_spec.predict_method is not None:
_add(_META_PREDICT_METHOD, input_spec.predict_method)
if input_spec.distance_column is not None:
_add(_META_DISTANCE_COLUMN, input_spec.distance_column)
if input_spec.lookback > 0:
_add(_META_LOOKBACK, str(input_spec.lookback))
_add(_META_GROUPING, input_spec.grouping_column or "")
_add(_META_PAD, input_spec.pad_strategy)
return model
def _build_windows(
feature_matrix: np.ndarray,
links_df: pd.DataFrame,
lookback: int,
grouping_column: str,
pad_strategy: Literal["zero", "repeat_first"],
) -> np.ndarray:
"""Assemble a (N, lookback, F) tensor of per-row sliding windows within each group."""
n_rows, n_features = feature_matrix.shape
windowed = np.zeros((n_rows, lookback, n_features), dtype=ONNX_DTYPE)
groups = links_df.groupby(grouping_column, sort=False).indices
for idx_array in groups.values():
idx_array = np.asarray(idx_array)
group_features = feature_matrix[idx_array]
if pad_strategy == "repeat_first":
pad_row = group_features[:1]
else:
pad_row = np.zeros((1, n_features), dtype=ONNX_DTYPE)
padded = np.concatenate(
[np.repeat(pad_row, lookback - 1, axis=0), group_features],
axis=0,
)
for i, row_pos in enumerate(idx_array):
windowed[row_pos] = padded[i : i + lookback]
return windowed
[docs]
class ONNXEstimator(Estimator):
"""Runs any ONNX model via ``onnxruntime``.
When ``input_spec.lookback > 0`` the estimator wraps feature rows into a
``(N, lookback, F)`` windowed tensor grouped by ``input_spec.grouping_column``
(with padding at sequence starts per ``input_spec.pad_strategy``) before
inference. When ``lookback == 0`` the estimator feeds a plain ``(N, F)``
tabular tensor — the common case for tree ensembles converted via ``skl2onnx``.
"""
onnx_model: onnx.ModelProto
session: rt.InferenceSession
file_extension: str = ".onnx"
def __init__(
self,
onnx_model: onnx.ModelProto,
input_spec: InputSpec = InputSpec(),
) -> None:
self.onnx_model = onnx_model
sess_options = rt.SessionOptions()
restricted_threads = get_restricted_threads()
if restricted_threads is not None:
sess_options.intra_op_num_threads = restricted_threads
self.session = rt.InferenceSession(
onnx_model.SerializeToString(),
sess_options=sess_options,
providers=["CPUExecutionProvider"],
)
self._input_spec = input_spec
[docs]
def to_bytes(self) -> bytes:
model = _embed_input_spec(self.onnx_model, self._input_spec)
return model.SerializeToString()
[docs]
@classmethod
def from_bytes(cls, data: bytes) -> ONNXEstimator:
onnx_model = onnx.load_from_string(data)
return cls(onnx_model, input_spec=_read_input_spec(onnx_model))
[docs]
def predict(
self,
links_df: pd.DataFrame,
config: ModelConfig,
) -> pd.DataFrame:
distance = config.distance
target_set = config.target
predict_method = config.predict_method
if predict_method not in (PredictMethod.RATE, PredictMethod.RAW):
raise ValueError(
f"Predict method {predict_method} is not supported by ONNXEstimator"
)
# ``all_feature_names`` is the single source of truth for the positional
# input order (features, plus distance appended for RAW) — the same list
# that ``bind_io_contract`` embeds into the estimator binary.
feature_name_list = config.all_feature_names
feature_matrix = links_df[feature_name_list].to_numpy(dtype=ONNX_DTYPE)
spec = self._input_spec
if spec.lookback > 0:
if spec.grouping_column is None:
raise ValueError(
"ONNXEstimator with lookback > 0 requires input_spec.grouping_column"
)
x = _build_windows(
feature_matrix,
links_df,
spec.lookback,
spec.grouping_column,
spec.pad_strategy,
)
else:
x = feature_matrix
pred = self.session.run(None, {ONNX_INPUT_NAME: x})[0]
energy_df = pd.DataFrame(index=links_df.index)
for i, energy in enumerate(target_set.targets):
energy_pred_series = pd.Series(pred[:, i], index=links_df.index)
if predict_method == PredictMethod.RATE:
energy_pred = energy_pred_series * links_df[distance.name]
else:
energy_pred = energy_pred_series
energy_df[energy.name] = energy_pred
return energy_df