Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions steer_core/Constants/Format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
25 changes: 24 additions & 1 deletion steer_core/Mixins/Serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -363,13 +382,17 @@ 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'])
return enum_class(value['value'])
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__'):
Expand Down
40 changes: 40 additions & 0 deletions steer_core/Mixins/TypeChecker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
2 changes: 1 addition & 1 deletion steer_core/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading