Source code for smartcer.gym.tracking.callback

"""
Stable-Baselines3 callback that logs the KPIs of every completed episode and saves its tables.

Adapted from storage-node-environment (`storage_node_env/gym/tracking/callback.py`): it reads the
`ResultsTracker` of a `TrackResults` wrapper instead of a tracker inside the environment, and saves
every `save_freq` completed episodes instead of every N steps. Stable-Baselines3 is optional.
"""

from pathlib import Path
from typing import Any

from smartcer.gym.tracking.tracker import ResultsTracker


try:
    from stable_baselines3.common.callbacks import BaseCallback
    SB3_AVAILABLE = True
except ImportError:  # pragma: no cover - exercised only without Stable-Baselines3
    BaseCallback = object  # type: ignore[assignment, misc]
    SB3_AVAILABLE = False

# KPIs written to the SB3 logger at the end of every episode, under 'smartcer/<name>'
LOGGED_KPIS = ('welfare', 'reward', 'incentive', 'shared_energy_kwh', 'sharing_rate',
               'penalty_mean', 'equivalent_full_cycles')


[docs] class ResultsCallback(BaseCallback): # type: ignore[misc, valid-type] """ Logs the KPIs of each completed episode of the first environment and saves its tables. The training environment must be wrapped with `TrackResults` and vectorised in the same process (e.g. `DummyVecEnv`, the default of `make_vec_env`). Attributes: output_dir: Folder of the saved episodes (`episode_<n>/`, see `ResultsTracker.save`). save_freq: Save every `save_freq` completed episodes; 0 never saves. run_config: Run configuration written with every saved episode, if given. """ def __init__(self, output_dir: str | Path, save_freq: int = 1, run_config: dict[str, Any] | None = None, verbose: int = 0) -> None: """ Create the callback. Args: output_dir: Output folder of the saved episodes. save_freq: Save every `save_freq` completed episodes (≥ 0; 0 only logs). run_config: Run configuration to write with each saved episode. verbose: Verbosity of the SB3 callback. Raises: ImportError: If Stable-Baselines3 is not installed. ValueError: If `save_freq` is negative. """ if not SB3_AVAILABLE: raise ImportError('ResultsCallback needs stable-baselines3 (extra "rl")') if save_freq < 0: raise ValueError(f'save_freq must be ≥ 0, got {save_freq}') super().__init__(verbose) self.output_dir = Path(output_dir) self.save_freq = int(save_freq) self.run_config = run_config self._tracker: ResultsTracker | None = None self._seen = 0 def _init_callback(self) -> None: """ Find the tracker of the first training environment. Raises: RuntimeError: If the environment is not vectorised in this process or not wrapped with `TrackResults`. """ envs = getattr(self.training_env, 'envs', None) if not envs: raise RuntimeError('ResultsCallback needs a vectorised environment in this process ' '(DummyVecEnv)') try: self._tracker = envs[0].get_wrapper_attr('tracker') except AttributeError as error: raise RuntimeError('wrap the training environment with ' 'smartcer.gym.tracking.TrackResults') from error self._seen = self._tracker.episodes_completed def _on_step(self) -> bool: """ Log and save the episode just completed, if any. Returns: bool: Always True (training continues). """ tracker = self._tracker if tracker is None or tracker.episodes_completed == self._seen: return True self._seen = tracker.episodes_completed kpis = tracker.kpis() for name in LOGGED_KPIS: self.logger.record(f'smartcer/{name}', kpis[name]) if self.save_freq and self._seen % self.save_freq == 0: folder = tracker.save(self.output_dir / f'episode_{self._seen:05d}', self.run_config) if self.verbose > 0: self.logger.info(f'episode {self._seen} saved to {folder}') return True