From 686e3b7556b2cefdcbc0d786b75f2aac8862391f Mon Sep 17 00:00:00 2001 From: Nicholas Siemons Date: Fri, 18 Sep 2026 16:40:44 -0700 Subject: [PATCH] Restore the tomeliot_dev API surface steer-financing depends on (0.2.24) steer-financing was developed against the tomeliot_dev branch, which forked before main's history was rewritten and was never merged back. None of its 13 demos ran against main and 26 of its serialization tests failed. Merging the branch is not viable -- it is ~3 months stale and lacks 15 main-only symbols with 79 downstream call sites (apply_plot_layout, Periodic_table, the 0.2.21 deserialization allowlist), and the rewritten history makes the merge conflict in 28 files / 85 hunks for a real content delta of 13 files. Port the four additions instead. All are additive; no existing behaviour changes and steer-core's own 376 tests are unaffected. - Constants/Format.py: DEFAULT_MONTH_FMT ("%Y-%m") - ValidationMixin.validate_not_none / validate_active_mode - SerializerMixin honours a _serializer_exclude hook, so a subclass can skip transient caches holding objects msgpack cannot encode. Without it steer_financing's cached pd.DatetimeIndex made every serialize() raise TypeError: can not serialize 'DatetimeIndex' object. - SerializerMixin round-trips timedelta and set Verified: steer-core 376 passed / 0 failed; steer-financing's suite is set-identical to the tomeliot_dev baseline (zero regressions) and fully green once its optional deps are installed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 31 +++++++++++++++++++++++++ steer_core/Constants/Format.py | 3 +++ steer_core/Mixins/Serializer.py | 25 +++++++++++++++++++- steer_core/Mixins/TypeChecker.py | 40 ++++++++++++++++++++++++++++++++ steer_core/__init__.py | 2 +- 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14975b..fc34b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.24] - 2026-09-18 + +### Added +- **`SerializerMixin` honours a `_serializer_exclude` hook.** Subclasses may + declare a set of attribute names to skip during serialization. Without it, + a transient cache holding an object msgpack cannot encode (e.g. a cached + `pd.DatetimeIndex`) makes the whole object unserializable — + `steer_financing.Revenues.time_series_allocation` declares exactly such a + set and every `serialize()` on it raised + `TypeError: can not serialize 'DatetimeIndex' object`. +- `SerializerMixin` round-trips `timedelta` (`__timedelta__`) and `set` + (`__set__`). Both previously fell through to the generic branch and failed. +- `ValidationMixin.validate_not_none(value, name)` — raises `ValueError` when + a required value is `None`. +- `ValidationMixin.validate_active_mode(current_mode, required_mode, property_name)` + — raises `RuntimeError` when a property is read in the wrong project mode + (e.g. reading `nominal_npv` while in `real` mode). +- `Constants.Format.DEFAULT_MONTH_FMT` (`"%Y-%m"`), alongside the existing + hour and day formats. + +### Notes +- These four items existed only on the `tomeliot_dev` branch, which forked + before `main`'s history was rewritten and was never merged back. + `steer-financing` was developed against them, so none of its demos ran and + 26 of its serialization tests failed against `main`. All are additive; no + existing behaviour changes. +- Downstream packages that define their own `SerializerMixin` subclasses must + still register their root package with `allow_class_roots()` (see 0.2.21) — + `steer_financing`, `steer_infrastructure`, `steer_opencell_production` and + `steer_ccus_tea` all currently subclass it outside the default allowlist. + ## [0.2.23] - 2026-09-14 ### Fixed diff --git a/steer_core/Constants/Format.py b/steer_core/Constants/Format.py index 3986523..333b819 100644 --- a/steer_core/Constants/Format.py +++ b/steer_core/Constants/Format.py @@ -7,3 +7,6 @@ # Daily-aligned datetime format (e.g., "2024-01-15") DEFAULT_DAY_FMT = "%Y-%m-%d" + +# Month-aligned datetime format (e.g., "2024-01") +DEFAULT_MONTH_FMT = "%Y-%m" diff --git a/steer_core/Mixins/Serializer.py b/steer_core/Mixins/Serializer.py index 0a2f4c1..72e7fdb 100644 --- a/steer_core/Mixins/Serializer.py +++ b/steer_core/Mixins/Serializer.py @@ -8,7 +8,7 @@ import zlib import numpy as np from typing import TypeVar, Any -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum # Patch msgpack for numpy support once at module import @@ -183,6 +183,10 @@ def _serialize_value(self, value: Any) -> Any: # datetime handling if value_type is datetime: return {'__datetime__': value.isoformat()} + + # timedelta handling + if value_type is timedelta: + return {'__timedelta__': value.total_seconds()} # Pandas DataFrame handling (lazy import to avoid hard dependency) try: @@ -210,6 +214,13 @@ def _serialize_value(self, value: Any) -> Any: # List handling if value_type is list: return [self._serialize_value(item) for item in value] + + # Set handling + if value_type is set: + return { + '__set__': True, + 'items': [self._serialize_value(item) for item in value] + } # Dict handling - single pass detection and serialization if value_type is dict: @@ -290,14 +301,22 @@ def _to_dict(self) -> dict: Override this in subclasses to customize serialization behavior. Single-pass implementation for efficiency. + Subclasses may define ``_serializer_exclude`` as a set of attribute + names to skip during serialization (e.g. transient caches holding + objects msgpack cannot encode, such as a cached ``pd.DatetimeIndex``). + Returns: Dictionary representation of the object. """ + exclude = getattr(self, '_serializer_exclude', None) or set() result = {} for key, value in self.__dict__.items(): # Skip _parent - PropagationMixin rebuilds it via _from_dict if key == '_parent': continue + # Skip attributes explicitly excluded by subclasses (transient caches) + if key in exclude: + continue # Skip non-serializable callables if callable(value) and not hasattr(value, '_to_dict'): continue @@ -363,6 +382,8 @@ def _deserialize_value(cls, value: Any) -> Any: return obj_class._from_dict(obj_data) elif '__datetime__' in value: return datetime.fromisoformat(value['__datetime__']) + elif '__timedelta__' in value: + return timedelta(seconds=value['__timedelta__']) elif '__enum__' in value: # Reconstruct enum using cached class lookup enum_class = _get_enum_class(value['class']) @@ -370,6 +391,8 @@ def _deserialize_value(cls, value: Any) -> Any: elif '__tuple__' in value: # Recursively reconstruct tuple items return tuple(cls._deserialize_value(item) for item in value['items']) + elif '__set__' in value: + return set(cls._deserialize_value(item) for item in value['items']) elif '__dataframe__' in value: import pandas as pd if value.get('__multiindex__'): diff --git a/steer_core/Mixins/TypeChecker.py b/steer_core/Mixins/TypeChecker.py index a72e70c..d365248 100644 --- a/steer_core/Mixins/TypeChecker.py +++ b/steer_core/Mixins/TypeChecker.py @@ -463,3 +463,43 @@ def validate_compounding_frequency(base_freq, compounding_freq, name: str = "com f"base_binning_frequency '{base_freq.value}' is coarser than " f"{name} '{compounding_freq.value}'." ) + + @staticmethod + def validate_not_none(value, name: str) -> None: + """Validate that a value is not None. + + Args: + value: The value to validate. + name: The name of the parameter for error messages. + + Raises: + ValueError: If the value is None. + + Examples: + >>> ValidationMixin.validate_not_none(42, 'rate') # OK + >>> ValidationMixin.validate_not_none(None, 'rate') # Raises ValueError + """ + if value is None: + raise ValueError(f"{name} must not be None.") + + @staticmethod + def validate_active_mode(current_mode, required_mode, property_name: str) -> None: + """Validate that the current mode matches the required mode for a property. + + Args: + current_mode: The current mode (enum value or string). + required_mode: The required mode for the property (enum value or string). + property_name: The name of the property being accessed. + + Raises: + RuntimeError: If the current mode does not match the required mode. + + Examples: + >>> ValidationMixin.validate_active_mode('real', 'real', 'real_npv') # OK + >>> ValidationMixin.validate_active_mode('real', 'nominal', 'nominal_npv') # Raises RuntimeError + """ + if current_mode != required_mode: + raise RuntimeError( + f"'{property_name}' is not available in '{current_mode}' mode. " + f"Switch to '{required_mode}' mode to access this property." + ) diff --git a/steer_core/__init__.py b/steer_core/__init__.py index ff415e9..6a05b26 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -__version__ = "0.2.23" +__version__ = "0.2.24" from .Mixins.Colors import ColorMixin from .Mixins.Coordinates import CoordinateMixin