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