diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index d75ba4cc6..82fe21e3d 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -1,37 +1,90 @@ -name: Repository Tests +name: CI on: push: + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Lint (critical rules) + run: | + python -m ruff check citylearn tests scripts/manual scripts/ci --select E9,F821 + test: + needs: lint runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10"] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: ${{ matrix.python-version }} - name: Cache pip dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements.txt', 'test_requirements.txt', 'setup.py') }} restore-keys: | - ${{ runner.os }}-pip- + ${{ runner.os }}-py${{ matrix.python-version }}-pip- - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r test_requirements.txt - pip install gymnasium pip install -e . - - name: Run tests - run: python -m pytest --ignore=tests/scripts + run: python -m pytest -q --ignore=scripts/manual + + - name: Performance smoke check + if: matrix.python-version == '3.10' + run: | + mkdir -p ${{ runner.temp }}/perf + python scripts/ci/perf_smoke.py \ + --episode-steps 600 \ + --seconds 60 \ + --none-max-ms 30 \ + --end-max-ms 45 \ + --ratio-max 2.0 \ + --baseline-file scripts/ci/perf_baseline.json \ + --baseline-regression-ratio 3.0 \ + --baseline-slack-ms 10.0 \ + --metrics-output ${{ runner.temp }}/perf/perf_smoke_report.json + + - name: Upload performance smoke report + if: always() && matrix.python-version == '3.10' + uses: actions/upload-artifact@v4 + with: + name: perf-smoke-report + path: ${{ runner.temp }}/perf/perf_smoke_report.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3a9dc31b7..23175d82c 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,5 @@ examples/* render_logs/ SimulationData/ results/ +runtime.csv +*-runtime.csv diff --git a/MANIFEST.in b/MANIFEST.in index ed77a9094..68629ae0e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include citylearn/assets/* -recursive-include citylearn/misc * \ No newline at end of file +recursive-include citylearn/misc * +include requirements.txt diff --git a/README.md b/README.md index d461dbaca..658e0a56f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ Install latest release in PyPi with `pip`: pip install CityLearn ``` +Optional dependency for PV autosizing (`PySAM`): +```console +pip install "CityLearn[pysam]" +``` + ## Documentation Refer to the [docs](https://intelligent-environments-lab.github.io/CityLearn/). diff --git a/citylearn/__init__.py b/citylearn/__init__.py index e59b17b4f..2d06a5f20 100644 --- a/citylearn/__init__.py +++ b/citylearn/__init__.py @@ -1 +1 @@ -__version__ = '2.5.0' +__version__ = '2.6.0b1' diff --git a/citylearn/agents/base.py b/citylearn/agents/base.py index a87abcd9b..d781d4330 100644 --- a/citylearn/agents/base.py +++ b/citylearn/agents/base.py @@ -148,11 +148,12 @@ def learn(self, episodes: int = None, deterministic: bool = None, deterministic_ deterministic = deterministic or (deterministic_finish and episode >= episodes - 1) observations, _ = self.env.reset() self.episode_time_steps = self.episode_tracker.episode_time_steps - terminated = False + terminated = self.env.terminated + truncated = self.env.truncated time_step = 0 rewards_list = [] - while not terminated: + while not (terminated or truncated): actions = self.predict(observations, deterministic=deterministic) # apply actions to citylearn_env @@ -176,13 +177,23 @@ def learn(self, episodes: int = None, deterministic: bool = None, deterministic_ time_step += 1 - rewards = np.array(rewards_list, dtype='float') - rewards_summary = { - 'min': rewards.min(axis=0), - 'max': rewards.max(axis=0), - 'sum': rewards.sum(axis=0), - 'mean': rewards.mean(axis=0) - } + if len(rewards_list) > 0: + rewards = np.array(rewards_list, dtype='float') + rewards_summary = { + 'min': rewards.min(axis=0), + 'max': rewards.max(axis=0), + 'sum': rewards.sum(axis=0), + 'mean': rewards.mean(axis=0) + } + else: + reward_length = len(self.action_space) + empty = np.zeros(reward_length, dtype='float') + rewards_summary = { + 'min': empty, + 'max': empty, + 'sum': empty, + 'mean': empty, + } logging.info(f'Completed episode: {episode + 1}/{episodes}, Reward: {rewards_summary}') def predict(self, observations: List[List[float]], deterministic: bool = None) -> List[List[float]]: @@ -282,4 +293,4 @@ def predict(self, observations: List[List[float]], deterministic: bool = None) - self.actions = actions self.next_time_step() - return actions \ No newline at end of file + return actions diff --git a/citylearn/base.py b/citylearn/base.py index 53e66e171..6cfb0ebcd 100644 --- a/citylearn/base.py +++ b/citylearn/base.py @@ -102,12 +102,22 @@ def __next_episode_time_steps(self, episode_time_steps: Union[int, List[Tuple[in splits = None - if isinstance(episode_time_steps, List): + if isinstance(episode_time_steps, list): splits = episode_time_steps else: + if episode_time_steps <= 0: + raise ValueError(f'episode_time_steps must be >= 1, got {episode_time_steps}.') + earliest_start_time_step = self.__simulation_start_time_step latest_start_time_step = (self.__simulation_end_time_step + 1) - episode_time_steps + + if latest_start_time_step < earliest_start_time_step: + raise ValueError( + f'episode_time_steps ({episode_time_steps}) exceeds available simulation window ' + f'({self.simulation_time_steps}). Reduce episode_time_steps or adjust ' + 'simulation_start_time_step/simulation_end_time_step.' + ) if rolling_episode_split: start_time_steps = range(earliest_start_time_step, latest_start_time_step + 1) @@ -118,10 +128,13 @@ def __next_episode_time_steps(self, episode_time_steps: Union[int, List[Tuple[in splits = np.array([start_time_steps, end_time_steps], dtype=int).T splits = splits.tolist() + if len(splits) == 0: + raise ValueError('No valid episode splits could be created from the provided episode_time_steps.') + if random_episode_split: seed = int(random_seed*(self.episode + 1)) nprs = np.random.RandomState(seed) - ix = nprs.choice(len(splits) - 1) + ix = 0 if len(splits) == 1 else int(nprs.choice(len(splits))) else: ix = self.episode%len(splits) @@ -269,4 +282,4 @@ def reset_time_step(self): Sets `time_step` to 0. """ - self.__time_step = 0 \ No newline at end of file + self.__time_step = 0 diff --git a/citylearn/building.py b/citylearn/building.py index d22976a94..f9ac46e38 100644 --- a/citylearn/building.py +++ b/citylearn/building.py @@ -1,17 +1,22 @@ import logging -from typing import Any, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union from gymnasium import spaces import numpy as np import pandas as pd -import torch +try: + import torch +except ImportError: # pragma: no cover - optional dependency for LSTM dynamics only + torch = None from citylearn.base import Environment, EpisodeTracker from citylearn.data import CarbonIntensity, EnergySimulation, Pricing, TOLERANCE, Weather, ZERO_DIVISION_PLACEHOLDER from citylearn.dynamics import Dynamics, LSTMDynamics from citylearn.electric_vehicle_charger import Charger from citylearn.energy_model import Battery, ElectricDevice, ElectricHeater, HeatPump, PV, StorageDevice, StorageTank, WashingMachine +from citylearn.internal.building_ops import BuildingOpsService from citylearn.occupant import LogisticRegressionOccupant, Occupant from citylearn.power_outage import PowerOutage from citylearn.preprocessing import Normalize, PeriodicNormalization +from citylearn.utilities import parse_bool LOGGER = logging.getLogger() logging.basicConfig(level=logging.INFO) @@ -93,6 +98,9 @@ def __init__( electric_vehicle_chargers: List[Charger] = None, time_step_ratio: int = None, washing_machines: List[WashingMachine] = None, **kwargs: Any ): charging_constraints = kwargs.pop('charging_constraints', None) + electrical_service = kwargs.pop('electrical_service', None) + electrical_storage_phase_connection = kwargs.pop('electrical_storage_phase_connection', None) + self.equity_group = kwargs.pop('equity_group', None) self.name = name self.dhw_storage = dhw_storage self.cooling_storage = cooling_storage @@ -127,9 +135,18 @@ def __init__( self.stochastic_power_outage = stochastic_power_outage self.non_periodic_normalized_observation_space_limits = None self.periodic_normalized_observation_space_limits = None + self._energy_simulation_observation_sources: List[Tuple[str, np.ndarray]] = [] + self._weather_observation_sources: List[Tuple[str, np.ndarray]] = [] + self._pricing_observation_sources: List[Tuple[str, np.ndarray]] = [] + self._carbon_observation_sources: List[Tuple[str, np.ndarray]] = [] + self._ops_service = BuildingOpsService(self) self.observation_space = self.estimate_observation_space(include_all=False, normalize=False) self.action_space = self.estimate_action_space() - self._initialize_charging_constraints(charging_constraints) + self._initialize_charging_constraints( + charging_constraints, + electrical_service=electrical_service, + electrical_storage_phase_connection=electrical_storage_phase_connection, + ) @property def energy_simulation(self) -> EnergySimulation: @@ -234,6 +251,18 @@ def charger_phase_map(self) -> Mapping[str, str]: @property def charging_building_limit_kw(self) -> float: return getattr(self, '_building_charger_limit_kw', None) + + @property + def electrical_service_enabled(self) -> bool: + return bool(getattr(self, '_electrical_service_enabled', False)) + + @property + def electrical_service_mode(self) -> str: + return getattr(self, '_electrical_service_mode', 'single_phase') + + @property + def electrical_storage_phase_connection(self) -> str: + return getattr(self, '_electrical_storage_phase_connection', 'L1') @property def washing_machines(self) -> List[WashingMachine]: @@ -761,27 +790,137 @@ def _update_charger_lookup(self): if hasattr(self, '_include_phase_encoding'): self._update_phase_encoding_observations() - def _initialize_charging_constraints(self, config: Mapping[str, Any]): + @staticmethod + def _parse_non_negative_limit(value: Any, path: str) -> Optional[float]: + if value is None: + return None + + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f'{path} must be a finite non-negative number or null.') from exc + + if np.isnan(parsed): + raise ValueError(f'{path} cannot be NaN.') + + if np.isposinf(parsed): + return None + + if np.isneginf(parsed) or parsed < 0.0: + raise ValueError(f'{path} must be >= 0.0 when provided.') + + return parsed + + @staticmethod + def _normalize_phase_label(value: Any) -> Optional[str]: + if value is None: + return None + + text = str(value).strip() + if text == '': + return None + + lowered = text.lower() + mapping = { + 'l1': 'L1', + 'l2': 'L2', + 'l3': 'L3', + 'phase_1': 'L1', + 'phase_2': 'L2', + 'phase_3': 'L3', + 'all_phases': 'all_phases', + 'all-phases': 'all_phases', + 'three_phase': 'all_phases', + 'three-phase': 'all_phases', + } + + return mapping.get(lowered, text) + + def _resolve_phase_connection(self, phase_connection: Any, owner: str) -> str: + mode = getattr(self, '_electrical_service_mode', 'single_phase') + normalized = self._normalize_phase_label(phase_connection) + + if normalized is None: + return 'L1' if mode == 'single_phase' else 'all_phases' + + if mode == 'single_phase': + if normalized != 'L1': + raise ValueError(f'{owner} phase_connection={normalized} is invalid for single_phase service. Use L1.') + return normalized + + if normalized not in {'L1', 'L2', 'L3', 'all_phases'}: + raise ValueError(f'{owner} phase_connection={normalized} is invalid. Use one of L1/L2/L3/all_phases.') + + return normalized + + def _initialize_charging_constraints( + self, + config: Mapping[str, Any], + electrical_service: Mapping[str, Any] = None, + electrical_storage_phase_connection: str = None, + ): self._charging_constraints_config = config or {} - self._charging_constraints_enabled = bool(self._charging_constraints_config) - observations_config = self._charging_constraints_config.get('observations', {}) or {} + self._electrical_service_config = electrical_service or {} + self._electrical_service_enabled = bool(self._electrical_service_config) + self._electrical_service_mode = 'single_phase' + self._electrical_service_default_split = 'balanced' + self._electrical_service_limits = { + 'total': {'import_kw': None, 'export_kw': None}, + 'per_phase': {}, + } + self._electrical_storage_phase_connection = 'L1' + + observations_config = {} + expose_flag = None + + if self._electrical_service_enabled: + observations_config = self._electrical_service_config.get('observations', {}) or {} + expose_flag = self._electrical_service_config.get('expose_observations') + else: + observations_config = self._charging_constraints_config.get('observations', {}) or {} + expose_flag = self._charging_constraints_config.get('expose_observations') + + self._charging_constraints_enabled = bool(self._charging_constraints_config) or self._electrical_service_enabled - expose_flag = self._charging_constraints_config.get('expose_observations') if 'headroom' in observations_config: - self._expose_charging_constraints = bool(observations_config.get('headroom', False)) + self._expose_charging_constraints = parse_bool( + observations_config.get('headroom', False), + default=False, + path='charging_constraints.observations.headroom', + ) elif expose_flag is not None: - self._expose_charging_constraints = bool(expose_flag) + self._expose_charging_constraints = parse_bool( + expose_flag, + default=False, + path='charging_constraints.expose_observations', + ) else: self._expose_charging_constraints = True - self._expose_charging_violation = bool(observations_config.get('violation', True)) - self._include_phase_encoding = bool(observations_config.get('phase_encoding', False)) + self._expose_charging_export_headroom = parse_bool( + observations_config.get('headroom_export', self._electrical_service_enabled), + default=self._electrical_service_enabled, + path='charging_constraints.observations.headroom_export', + ) + self._expose_charging_violation = parse_bool( + observations_config.get('violation', True), + default=True, + path='charging_constraints.observations.violation', + ) + self._include_phase_encoding = parse_bool( + observations_config.get('phase_encoding', False), + default=False, + path='charging_constraints.observations.phase_encoding', + ) self._building_charger_limit_kw = None self._phase_limits = [] self._charger_phase_map: Mapping[str, str] = {} self._charging_constraints_state = None self._charging_constraint_penalty_kwh = 0.0 self._charging_constraint_last_penalty_kwh = 0.0 + self._charging_constraint_violation_history = np.zeros(1, dtype='float32') + self._charging_total_power_history_kw = np.zeros(1, dtype='float32') + self._charging_phase_power_history_kw: Mapping[str, np.ndarray] = {} self._phase_encoding_observations: Mapping[str, float] = {} self._phase_encoding_phase_names: List[str] = [] @@ -789,18 +928,107 @@ def _initialize_charging_constraints(self, config: Mapping[str, Any]): self._charging_constraints_state = None return - self._building_charger_limit_kw = self._charging_constraints_config.get('building_limit_kw') - phases = self._charging_constraints_config.get('phases', []) or [] + if self._electrical_service_enabled: + mode = str(self._electrical_service_config.get('mode', 'single_phase')).strip().lower() + if mode not in {'single_phase', 'three_phase'}: + raise ValueError("electrical_service.mode must be one of {'single_phase', 'three_phase'}.") + self._electrical_service_mode = mode + + split = str(self._electrical_service_config.get('default_split', 'balanced')).strip().lower() + if split not in {'balanced', 'l1', 'l2', 'l3'}: + raise ValueError("electrical_service.default_split must be one of {'balanced', 'L1', 'L2', 'L3'}.") + if mode == 'single_phase' and split not in {'balanced', 'l1'}: + raise ValueError("single_phase electrical service only accepts default_split as 'balanced' or 'L1'.") + self._electrical_service_default_split = split + + limits = self._electrical_service_config.get('limits', {}) or {} + total_limits = limits.get('total', {}) or {} + self._electrical_service_limits['total']['import_kw'] = self._parse_non_negative_limit( + total_limits.get('import_kw'), 'electrical_service.limits.total.import_kw' + ) + self._electrical_service_limits['total']['export_kw'] = self._parse_non_negative_limit( + total_limits.get('export_kw'), 'electrical_service.limits.total.export_kw' + ) + + per_phase_limits = limits.get('per_phase', {}) or {} + phase_names = ['L1'] if mode == 'single_phase' else ['L1', 'L2', 'L3'] + + normalized_phase_limits: Dict[str, Mapping[str, Any]] = {} + for phase_name, values in per_phase_limits.items(): + normalized_name = self._normalize_phase_label(phase_name) + normalized_phase_limits[normalized_name] = values + + if mode == 'single_phase': + invalid = [name for name in normalized_phase_limits if name not in {None, 'L1'}] + if invalid: + raise ValueError(f'single_phase electrical service cannot define per_phase limits for: {invalid}.') + else: + invalid = [name for name in normalized_phase_limits if name not in {'L1', 'L2', 'L3'}] + if invalid: + raise ValueError(f'three_phase electrical service only accepts per_phase keys L1/L2/L3. Invalid keys: {invalid}.') + + for phase_name in phase_names: + phase_limit_config = normalized_phase_limits.get(phase_name, {}) or {} + import_limit = self._parse_non_negative_limit( + phase_limit_config.get('import_kw'), + f'electrical_service.limits.per_phase.{phase_name}.import_kw', + ) + export_limit = self._parse_non_negative_limit( + phase_limit_config.get('export_kw'), + f'electrical_service.limits.per_phase.{phase_name}.export_kw', + ) + self._electrical_service_limits['per_phase'][phase_name] = { + 'import_kw': import_limit, + 'export_kw': export_limit, + } + self._phase_limits.append( + { + 'name': phase_name, + 'limit_kw': import_limit, + 'import_kw': import_limit, + 'export_kw': export_limit, + 'chargers': [], + } + ) + + self._building_charger_limit_kw = self._electrical_service_limits['total']['import_kw'] + + for charger in self.electric_vehicle_chargers or []: + charger_connection = self._resolve_phase_connection( + getattr(charger, 'phase_connection', None), + f'charger:{charger.charger_id}', + ) + self._charger_phase_map[charger.charger_id] = charger_connection + if charger_connection in {'L1', 'L2', 'L3'}: + for phase in self._phase_limits: + if phase['name'] == charger_connection: + phase['chargers'].append(charger.charger_id) + break + + self._electrical_storage_phase_connection = self._resolve_phase_connection( + electrical_storage_phase_connection, + 'electrical_storage', + ) - for phase in phases: - name = phase.get('name') - if not name: - name = f"phase_{len(self._phase_limits) + 1}" - limit = phase.get('limit_kw') - chargers = phase.get('chargers', []) or [] - self._phase_limits.append({'name': name, 'limit_kw': limit, 'chargers': chargers}) - for charger_id in chargers: - self._charger_phase_map[charger_id] = name + else: + self._building_charger_limit_kw = self._parse_non_negative_limit( + self._charging_constraints_config.get('building_limit_kw'), + 'charging_constraints.building_limit_kw', + ) + phases = self._charging_constraints_config.get('phases', []) or [] + + for idx, phase in enumerate(phases): + name = phase.get('name') + if not name: + name = f"phase_{len(self._phase_limits) + 1}" + limit = self._parse_non_negative_limit( + phase.get('limit_kw'), + f'charging_constraints.phases[{idx}].limit_kw', + ) + chargers = phase.get('chargers', []) or [] + self._phase_limits.append({'name': name, 'limit_kw': limit, 'import_kw': limit, 'export_kw': None, 'chargers': chargers}) + for charger_id in chargers: + self._charger_phase_map[charger_id] = name if self._include_phase_encoding and not self._phase_limits: self._include_phase_encoding = False @@ -812,13 +1040,20 @@ def _initialize_charging_constraints(self, config: Mapping[str, Any]): if self._building_charger_limit_kw is not None: observation_keys.append('charging_building_headroom_kw') for phase in self._phase_limits: - if phase.get('limit_kw') is not None: + if phase.get('import_kw') is not None: observation_keys.append(f"charging_phase_{phase['name']}_headroom_kw") + + if self._electrical_service_enabled and self._expose_charging_export_headroom: + if self._electrical_service_limits['total'].get('export_kw') is not None: + observation_keys.append('charging_building_export_headroom_kw') + for phase in self._phase_limits: + if phase.get('export_kw') is not None: + observation_keys.append(f"charging_phase_{phase['name']}_export_headroom_kw") + for key in observation_keys: if key not in self.observation_metadata: self.observation_metadata[key] = True - # Recompute observation space to include new limits self.observation_space = self.estimate_observation_space(include_all=False, normalize=False) violation_key = 'charging_constraint_violation_kwh' @@ -831,10 +1066,54 @@ def _initialize_charging_constraints(self, config: Mapping[str, Any]): self.observation_metadata[key] = True self._set_default_charging_headroom() + self._reset_charging_constraint_histories() if hasattr(self, 'observation_metadata'): self.observation_space = self.estimate_observation_space(include_all=False, normalize=False) + def _reset_charging_constraint_histories(self): + steps = 1 + + if getattr(self, 'episode_tracker', None) is not None: + try: + steps = int(self.episode_tracker.episode_time_steps) + except Exception: + steps = 1 + + if steps <= 0: + try: + steps = int(len(self.energy_simulation.month)) + except Exception: + steps = 1 + + self._charging_constraint_violation_history = np.zeros(steps, dtype='float32') + self._charging_total_power_history_kw = np.zeros(steps, dtype='float32') + self._charging_phase_power_history_kw = { + phase.get('name'): np.zeros(steps, dtype='float32') + for phase in self._phase_limits + if phase.get('name') is not None + } + + def _record_charging_constraint_state( + self, + violation_kwh: float, + total_power_kw: float, + phase_power_kw: Optional[Mapping[str, float]] = None, + ): + t = int(self.time_step) + if t < 0: + return + + if t >= len(self._charging_constraint_violation_history): + return + + self._charging_constraint_violation_history[t] = float(violation_kwh) + self._charging_total_power_history_kw[t] = float(total_power_kw) + phase_power_kw = phase_power_kw or {} + + for phase_name, history in self._charging_phase_power_history_kw.items(): + history[t] = float(phase_power_kw.get(phase_name, 0.0)) + def _update_phase_encoding_observations(self): previous_keys = list(getattr(self, '_phase_encoding_observation_keys', [])) self._phase_encoding_observation_keys = [] @@ -860,7 +1139,18 @@ def _update_phase_encoding_observations(self): self._phase_encoding_observations = {} return - phase_names = sorted({phase.get('name') for phase in self._phase_limits if phase.get('name')}) + phase_names = sorted( + { + phase.get('name') + for phase in self._phase_limits + if phase.get('name') + } + | { + value + for value in self._charger_phase_map.values() + if value is not None + } + ) has_unassigned = any(charger_id not in self._charger_phase_map for charger_id in chargers) if has_unassigned: phase_names = phase_names + ['unassigned'] @@ -889,104 +1179,36 @@ def _set_default_charging_headroom(self): return building_headroom = None if self._building_charger_limit_kw is None else float(self._building_charger_limit_kw) + building_export_headroom = None + if getattr(self, '_electrical_service_enabled', False): + export_limit = self._electrical_service_limits.get('total', {}).get('export_kw') + building_export_headroom = None if export_limit is None else float(export_limit) + phase_headroom = { - phase['name']: None if phase.get('limit_kw') is None else float(phase.get('limit_kw')) + phase['name']: None if phase.get('import_kw') is None else float(phase.get('import_kw')) + for phase in self._phase_limits + } + phase_export_headroom = { + phase['name']: None if phase.get('export_kw') is None else float(phase.get('export_kw')) for phase in self._phase_limits } self._charging_constraints_state = { 'building_headroom_kw': building_headroom, + 'building_export_headroom_kw': building_export_headroom, 'phase_headroom_kw': phase_headroom, + 'phase_export_headroom_kw': phase_export_headroom, + 'total_power_kw': 0.0, + 'phase_power_kw': {phase['name']: 0.0 for phase in self._phase_limits}, } - def _apply_charging_constraints_to_actions(self, actions: Optional[Mapping[str, float]]) -> Optional[Mapping[str, float]]: - self._charging_constraint_penalty_kwh = 0.0 - self._charging_constraint_last_penalty_kwh = 0.0 - - if not self._charging_constraints_enabled: - return actions - - if not actions: - self._set_default_charging_headroom() - return actions + def _apply_charging_constraints_to_actions( + self, + actions: Optional[Mapping[str, float]], + electrical_storage_action: Optional[float] = None, + ) -> Tuple[Optional[Mapping[str, float]], Optional[float]]: + """Compatibility wrapper for charging-constraints action limiter service.""" - positive_requests = {} - scales = {} - for charger_id, action in actions.items(): - if action is None or action <= 0.0: - continue - charger = self._charger_lookup.get(charger_id) - if charger is None: - continue - max_power = getattr(charger, 'max_charging_power', 0.0) or 0.0 - if max_power <= 0.0: - continue - positive_requests[charger_id] = action * max_power - scales[charger_id] = 1.0 - - violation_kw = 0.0 - - if positive_requests: - total_kw = sum(positive_requests.values()) - building_limit = self._building_charger_limit_kw - if building_limit is not None and building_limit >= 0.0 and total_kw > building_limit: - scale = 0.0 if building_limit == 0 else building_limit / total_kw - for cid in scales: - scales[cid] *= scale - violation_kw += total_kw - building_limit - - for phase in self._phase_limits: - limit = phase.get('limit_kw') - if limit is None or limit < 0.0: - continue - chargers = phase.get('chargers', []) or [] - phase_sum = sum(positive_requests.get(cid, 0.0) * scales.get(cid, 1.0) for cid in chargers if cid in positive_requests) - if phase_sum > limit: - phase_scale = 0.0 if limit == 0 else limit / phase_sum - for cid in chargers: - if cid in scales: - scales[cid] *= phase_scale - violation_kw += phase_sum - limit - - scaled_positive_kw = {cid: positive_requests[cid] * scales.get(cid, 1.0) for cid in positive_requests} - - for charger_id, action in list(actions.items()): - if action is None or action <= 0.0: - continue - charger = self._charger_lookup.get(charger_id) - if charger is None: - continue - max_power = getattr(charger, 'max_charging_power', 0.0) or 0.0 - if max_power <= 0.0: - actions[charger_id] = 0.0 - continue - target_kw = scaled_positive_kw.get(charger_id, 0.0) - actions[charger_id] = max(0.0, min(action, target_kw / max_power)) - - if getattr(self, '_expose_charging_constraints', False): - used_kw = sum(scaled_positive_kw.values()) - building_headroom = None if self._building_charger_limit_kw is None else self._building_charger_limit_kw - used_kw - phase_headroom = {} - for phase in self._phase_limits: - limit = phase.get('limit_kw') - if limit is None: - phase_headroom[phase['name']] = None - else: - used = sum(scaled_positive_kw.get(cid, 0.0) for cid in phase.get('chargers', [])) - phase_headroom[phase['name']] = limit - used - - self._charging_constraints_state = { - 'building_headroom_kw': building_headroom, - 'phase_headroom_kw': phase_headroom, - } - - penalty_kwh = violation_kw * (self.seconds_per_time_step / 3600) - self._charging_constraint_penalty_kwh = penalty_kwh - self._charging_constraint_last_penalty_kwh = penalty_kwh - - else: - self._set_default_charging_headroom() - - return actions + return self._ops_service.apply_charging_constraints_to_actions(actions, electrical_storage_action) def consume_charging_constraint_penalty(self) -> float: penalty = self._charging_constraint_penalty_kwh @@ -1109,376 +1331,66 @@ def get_metadata(self) -> Mapping[str, Any]: 'annual_non_shiftable_load_estimate': self.energy_simulation.non_shiftable_load.sum() / n_years, 'annual_solar_generation_estimate': self.pv.get_generation(self.energy_simulation.solar_generation).sum() / n_years, 'charging_constraints': self._charging_constraints_config, + 'electrical_service': self._electrical_service_config, + 'electrical_storage_phase_connection': self._electrical_storage_phase_connection, 'charger_phase_map': self._charger_phase_map, } def observations(self, include_all: bool = None, normalize: bool = None, periodic_normalization: bool = None, check_limits: bool = None) -> Mapping[str, float]: - r"""Observations at current time step. - - Parameters - ---------- - include_all: bool, default: False, - Whether to estimate for all observations as listed in `observation_metadata` or only those that are active. - normalize : bool, default: False - Whether to apply min-max normalization bounded between [0, 1]. - periodic_normalization: bool, default: False - Whether to apply sine-cosine normalization to cyclic observations including hour, day_type and month. - check_limits: bool, default: False - Whether to check if observations are within observation space and if not, will send output to log describing - out of bounds observations. Useful for agents that will fail if observations fall outside space e.g. RLlib agents. + r"""Observations at current time step.""" - Returns - ------- - observation_space : spaces.Box - Observation low and high limits. - - Notes - ----- - Lower and upper bounds of net electricity consumption are rough estimates and may not be completely accurate hence, - scaling this observation-variable using these bounds may result in normalized values above 1 or below 0. - """ - - normalize = False if normalize is None else normalize - periodic_normalization = False if periodic_normalization is None else periodic_normalization - include_all = False if include_all is None else include_all - check_limits = False if check_limits is None else check_limits - - observations = {} - data = self._get_observations_data() - - if include_all: - valid_observations = list(set(data.keys()) | set(self.active_observations)) - else: - valid_observations = self.active_observations - - observations = {k: data[k] for k in valid_observations if k in data.keys()} - - observations = self.update_ev_charger_observations(observations, valid_observations, self.electric_vehicle_chargers) - - observations = self.update_washing_machine_observations(observations, valid_observations, self.washing_machines) - - unknown_observations = set(observations.keys()).difference(set(valid_observations)) - assert len(unknown_observations) == 0, f'Unknown observations: {unknown_observations}' - - non_periodic_low_limit, non_periodic_high_limit = self.non_periodic_normalized_observation_space_limits - periodic_low_limit, periodic_high_limit = self.periodic_normalized_observation_space_limits - periodic_observations = self.get_periodic_observation_metadata() - - - if check_limits: - for k in self.active_observations: - value = observations[k] - lower = non_periodic_low_limit[k] - upper = non_periodic_high_limit[k] - if not lower <= value <= upper: - report = { - 'Building': self.name, - 'episode': self.episode_tracker.episode, - 'time_step': f'{self.time_step + 1}/{self.episode_tracker.episode_time_steps}', - 'observation': k, - 'value': value, - 'lower': lower, - 'upper': upper - } - LOGGER.debug(f'Observation outside space limit: {report}') - - else: - pass - - else: - pass - - if periodic_normalization: - observations_copy = {k: v for k, v in observations.items()} - observations = {} - pn = PeriodicNormalization(x_max=0) - - for k, v in observations_copy.items(): - if k in periodic_observations: - pn.x_max = max(periodic_observations[k]) - sin_x, cos_x = v * pn - observations[f'{k}_cos'] = cos_x - observations[f'{k}_sin'] = sin_x - - else: - observations[k] = v - else: - pass - - if normalize: - nm = Normalize(0.0, 1.0) - - for k, v in observations.items(): - nm.x_min = periodic_low_limit[k] - nm.x_max = periodic_high_limit[k] - observations[k] = v * nm + return self._ops_service.observations( + include_all=include_all, + normalize=normalize, + periodic_normalization=periodic_normalization, + check_limits=check_limits, + ) - else: - pass + def update_ev_charger_observations(self, observations, valid_observations, ev_chargers, include_all: bool = False): + """Compatibility wrapper for EV charger observation service.""" - return observations + return self._ops_service.update_ev_charger_observations( + observations, + valid_observations, + ev_chargers, + include_all=include_all, + ) - def update_ev_charger_observations(self, observations, valid_observations, ev_chargers): - """ - Update the observations for each electric vehicle charger using charger simulation data. - - Parameters: - observations (dict): Dictionary to populate with observation values. - valid_observations (set or list): Allowed observation keys. - ev_chargers (iterable): List of charger objects, each with: - - charger_id - - charger_simulation (ChargerSchedule) - """ - - for charger in ev_chargers: - charger_id = charger.charger_id - sim = charger.charger_simulation - t = self.time_step - - # Keys - connected_state_key = f'electric_vehicle_charger_{charger_id}_connected_state' - incoming_state_key = f'electric_vehicle_charger_{charger_id}_incoming_state' - departure_key = f'connected_electric_vehicle_at_charger_{charger_id}_departure_time' - req_soc_key = f'connected_electric_vehicle_at_charger_{charger_id}_required_soc_departure' - soc_key = f'connected_electric_vehicle_at_charger_{charger_id}_soc' - capacity_key = f'connected_electric_vehicle_at_charger_{charger_id}_battery_capacity' - arrival_key = f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_arrival_time' - soc_arrival_key = f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_soc_arrival' - - # Get current state - state = sim.electric_vehicle_charger_state[t] if t < len(sim.electric_vehicle_charger_state) else np.nan - - # --------------------------- - # Update Connected EV Section - # --------------------------- - if charger.connected_electric_vehicle and state == 1: - if connected_state_key in valid_observations: - observations[connected_state_key] = 1 - if departure_key in valid_observations: - observations[departure_key] = int(sim.electric_vehicle_departure_time[t]) - if req_soc_key in valid_observations: - observations[req_soc_key] = float(sim.electric_vehicle_required_soc_departure[t]) - if soc_key in valid_observations: - observations[soc_key] = charger.connected_electric_vehicle.battery.soc[t] - if capacity_key in valid_observations: - observations[capacity_key] = float(charger.connected_electric_vehicle.battery.capacity) - else: - if connected_state_key in valid_observations: - observations[connected_state_key] = 0 - if departure_key in valid_observations: - observations[departure_key] = -1 - if req_soc_key in valid_observations: - observations[req_soc_key] = -0.1 - if soc_key in valid_observations: - observations[soc_key] = -0.1 - if capacity_key in valid_observations: - observations[capacity_key] = -1.0 - - # --------------------------- - # Update Incoming EV Section - # --------------------------- - if charger.incoming_electric_vehicle and state == 2: - if incoming_state_key in valid_observations: - observations[incoming_state_key] = 1 - if arrival_key in valid_observations: - observations[arrival_key] = int(sim.electric_vehicle_estimated_arrival_time[t]) - if soc_arrival_key in valid_observations: - observations[soc_arrival_key] = float(sim.electric_vehicle_estimated_soc_arrival[t]) - else: - if incoming_state_key in valid_observations: - observations[incoming_state_key] = 0 - if arrival_key in valid_observations: - observations[arrival_key] = -1 - if soc_arrival_key in valid_observations: - observations[soc_arrival_key] = -0.1 - - return observations - - def update_washing_machine_observations(self, observations, valid_observations, washing_machines): - """ - Update the observations for each washing machine. - - Parameters: - observations (dict): The dictionary to update with observation values. - valid_observations (set or list): Collection of valid observation keys. - washing_machines (iterable): List of washing machine objects. Each machine is expected to have: - - name attribute - - washing_machine_simulation attribute with wm_start_time_step and wm_end_time_step arrays - - observations() method that returns a dictionary - """ - for wm in washing_machines: - wm_name = wm.name - - # Get all observations from the washing machine - wm_obs = wm.observations() - - # Update start time if valid - start_key = f'{wm_name}_start_time_step' - if start_key in valid_observations: - observations[start_key] = next( - (value for key, value in wm_obs.items() if "_start_time_step" in key), - -1 # default value if not found - ) - - # Update end time if valid - end_key = f'{wm_name}_end_time_step' - if end_key in valid_observations: - observations[end_key] = next( - (value for key, value in wm_obs.items() if "_end_time_step" in key), - -1 # default value if not found - ) - return observations + """Compatibility wrapper for washing-machine observation service.""" + return self._ops_service.update_washing_machine_observations( + observations, + valid_observations, + washing_machines, + ) + def _get_observations_data(self, include_all: bool = False) -> Mapping[str, Union[float, int]]: + """Compatibility wrapper for base observation data service.""" + return self._ops_service.get_observations_data(include_all=include_all) - def _get_observations_data(self) -> Mapping[str, Union[float, int]]: - electric_vehicle_chargers_dict = {} - washing_machines_dict = {} - - for charger in self.electric_vehicle_chargers: - charger_id = charger.charger_id - connected_car = charger.connected_electric_vehicle - - if connected_car is not None: - # Use current timestep values to align rewards/observations with actions at t - # Last charged energy for current timestep (0.0 if not set) - last_charged_kwh = 0.0 - if 0 <= self.time_step < len(charger.past_charging_action_values_kwh): - last_charged_kwh = float(charger.past_charging_action_values_kwh[self.time_step]) - - # Current battery SOC after applying action at t - battery_soc = connected_car.battery.soc[self.time_step] - - # Previous SOC (t-1) or initial at t=0 - previous_battery_soc = connected_car.battery.initial_soc if self.time_step == 0 else connected_car.battery.soc[self.time_step - 1] - - # Schedule values at current timestep - required_soc = charger.charger_simulation.electric_vehicle_required_soc_departure[self.time_step] - hours_until_departure = charger.charger_simulation.electric_vehicle_departure_time[self.time_step] - - battery_capacity = connected_car.battery.capacity - min_capacity = (1 - connected_car.battery.depth_of_discharge) * battery_capacity - - electric_vehicle_chargers_dict[charger_id] = { - "connected": True, - "last_charged_kwh": last_charged_kwh, - "previous_battery_soc": previous_battery_soc, - "battery_soc": battery_soc, - "battery_capacity": battery_capacity, - "min_capacity": min_capacity, - "required_soc": required_soc, - "hours_until_departure": hours_until_departure, - "max_charging_power": charger.max_charging_power, - "max_discharging_power": charger.max_discharging_power, - } + def _refresh_observation_source_cache(self): + """Cache episode-sliced observation sources to avoid per-step dynamic lookups.""" - else: - electric_vehicle_chargers_dict[charger_id] = { - "connected": False, - "last_charged_kwh": 0.0, - "previous_battery_soc": None, - "battery_soc": None, - "battery_capacity": None, - "min_capacity": None, - "required_soc": None, - "hours_until_departure": None, - "max_charging_power": charger.max_charging_power, - "max_discharging_power": charger.max_discharging_power, - } - - for wm in self.washing_machines: - washing_machine_name = wm.name - t = self.time_step - # Use current timestep values; default to sentinel values if out of bounds - def _safe(arr, idx, default): + def _collect(source) -> List[Tuple[str, np.ndarray]]: + collected: List[Tuple[str, np.ndarray]] = [] + for key, value in vars(source).items(): + if not isinstance(value, np.ndarray): + continue + observation_name = key.lstrip('_') try: - return arr[idx] - except Exception: - return default - - start_time_step = _safe(wm.washing_machine_simulation.wm_start_time_step, t, -1) - end_time_step = _safe(wm.washing_machine_simulation.wm_end_time_step, t, -1) - load_profile = _safe(wm.washing_machine_simulation.load_profile, t, 0.0) - - washing_machines_dict[washing_machine_name] = { - "wm_start_time_step": start_time_step, - "wm_end_time_step": end_time_step, - "load_profile": load_profile, - } - - observations = { - **{ - k.lstrip('_'): self.energy_simulation.__getattr__(k.lstrip('_'))[self.time_step] - for k, v in vars(self.energy_simulation).items() if isinstance(v, np.ndarray) - }, - **{ - k.lstrip('_'): self.weather.__getattr__(k.lstrip('_'))[self.time_step] - for k, v in vars(self.weather).items() if isinstance(v, np.ndarray) - }, - **{ - k.lstrip('_'): self.pricing.__getattr__(k.lstrip('_'))[self.time_step] - for k, v in vars(self.pricing).items() if isinstance(v, np.ndarray) - }, - **{ - k.lstrip('_'): self.carbon_intensity.__getattr__(k.lstrip('_'))[self.time_step] - for k, v in vars(self.carbon_intensity).items() if isinstance(v, np.ndarray) - }, - 'solar_generation':abs(self.solar_generation[self.time_step]), - **{ - 'cooling_storage_soc':self.cooling_storage.soc[self.time_step], - 'heating_storage_soc':self.heating_storage.soc[self.time_step], - 'dhw_storage_soc':self.dhw_storage.soc[self.time_step], - 'electrical_storage_soc':self.electrical_storage.soc[self.time_step], - }, - 'cooling_demand': self.__energy_from_cooling_device[self.time_step] + abs(min(self.cooling_storage.energy_balance[self.time_step], 0.0)), - 'heating_demand': self.__energy_from_heating_device[self.time_step] + abs(min(self.heating_storage.energy_balance[self.time_step], 0.0)), - 'dhw_demand': self.__energy_from_dhw_device[self.time_step] + abs(min(self.dhw_storage.energy_balance[self.time_step], 0.0)), - 'net_electricity_consumption': self.net_electricity_consumption[self.time_step], - 'cooling_electricity_consumption': self.cooling_electricity_consumption[self.time_step], - 'heating_electricity_consumption': self.heating_electricity_consumption[self.time_step], - 'dhw_electricity_consumption': self.dhw_electricity_consumption[self.time_step], - 'cooling_storage_electricity_consumption': self.cooling_storage_electricity_consumption[self.time_step], - 'heating_storage_electricity_consumption': self.heating_storage_electricity_consumption[self.time_step], - 'dhw_storage_electricity_consumption': self.dhw_storage_electricity_consumption[self.time_step], - 'electrical_storage_electricity_consumption': self.electrical_storage_electricity_consumption[self.time_step], - 'washing_machine_electricity_consumption': self.washing_machines_electricity_consumption[self.time_step], - 'cooling_device_efficiency': self.cooling_device.get_cop(self.weather.outdoor_dry_bulb_temperature[self.time_step], heating=False), - 'heating_device_efficiency': self.heating_device.get_cop(self.weather.outdoor_dry_bulb_temperature[self.time_step], heating=True) \ - if isinstance(self.heating_device, HeatPump) else self.heating_device.efficiency, - 'dhw_device_efficiency': self.dhw_device.get_cop(self.weather.outdoor_dry_bulb_temperature[self.time_step], heating=True) \ - if isinstance(self.dhw_device, HeatPump) else self.dhw_device.efficiency, - 'indoor_dry_bulb_temperature_cooling_set_point': self.energy_simulation.indoor_dry_bulb_temperature_cooling_set_point[self.time_step], - 'indoor_dry_bulb_temperature_heating_set_point': self.energy_simulation.indoor_dry_bulb_temperature_heating_set_point[self.time_step], - 'indoor_dry_bulb_temperature_cooling_delta': self.energy_simulation.indoor_dry_bulb_temperature[self.time_step] - self.energy_simulation.indoor_dry_bulb_temperature_cooling_set_point[self.time_step], - 'indoor_dry_bulb_temperature_heating_delta': self.energy_simulation.indoor_dry_bulb_temperature[self.time_step] - self.energy_simulation.indoor_dry_bulb_temperature_heating_set_point[self.time_step], - 'comfort_band': self.energy_simulation.comfort_band[self.time_step], - 'occupant_count': self.energy_simulation.occupant_count[self.time_step], - 'power_outage': self.__power_outage_signal[self.time_step], - 'electric_vehicles_chargers_dict': electric_vehicle_chargers_dict, - 'washing_machines_dict': washing_machines_dict, - } - if ( - getattr(self, '_charging_constraints_enabled', False) - and getattr(self, '_expose_charging_constraints', False) - and isinstance(self._charging_constraints_state, dict) - ): - state = self._charging_constraints_state - headroom = state.get('building_headroom_kw') - if headroom is not None: - observations['charging_building_headroom_kw'] = headroom - for phase_name, value in (state.get('phase_headroom_kw') or {}).items(): - if value is not None: - observations[f'charging_phase_{phase_name}_headroom_kw'] = value + series = source.__getattr__(observation_name) + except AttributeError: + continue + collected.append((observation_name, series)) - if getattr(self, '_charging_constraints_enabled', False): - if getattr(self, '_expose_charging_violation', False): - observations['charging_constraint_violation_kwh'] = self._charging_constraint_last_penalty_kwh - if getattr(self, '_phase_encoding_observations', None): - observations.update(self._phase_encoding_observations) + return collected - return observations + self._energy_simulation_observation_sources = _collect(self.energy_simulation) + self._weather_observation_sources = _collect(self.weather) + self._pricing_observation_sources = _collect(self.pricing) + self._carbon_observation_sources = _collect(self.carbon_intensity) @staticmethod def get_periodic_observation_metadata() -> Mapping[str, int]: @@ -1504,134 +1416,19 @@ def apply_actions(self, dhw_storage_action: float = None, electrical_storage_action: float = None, washing_machine_actions: dict = None, electric_vehicle_storage_actions: dict = None, ): - r"""Update cooling and heating demand for next timestep and charge/discharge storage devices. - - The order of action execution is dependent on polarity of the storage actions. If the electrical - storage is to be discharged, its action is executed first before all other actions. Likewise, if - the storage for an end-use is to be discharged, the storage action is executed before the control - action for the end-use electric device. Discharging the storage devices before fulfilling thermal - and non-shiftable loads ensures that the discharged energy is considered when allocating electricity - consumption to meet building loads. Likewise, meeting building loads before charging storage devices - ensures that comfort is met before attempting to shift loads. - - Parameters - ---------- - cooling_or_heating_device_action : float, default: np.nan - Fraction of `cooling_device` or `heating_device` `nominal_power` to make available. An action - < 0.0 is for the `cooling_device`, while an action > 0.0 is for the `heating_device`. - cooling_device_action : float, default: np.nan - Fraction of `cooling_device` `nominal_power` to make available for space cooling. - heating_device_action : float, default: np.nan - Fraction of `heating_device` `nominal_power` to make available for space heating. - cooling_storage_action : float, default: 0.0 - Fraction of `cooling_storage` `capacity` to charge/discharge by. - heating_storage_action : float, default: 0.0 - Fraction of `heating_storage` `capacity` to charge/discharge by. - dhw_storage_action : float, default: 0.0 - Fraction of `dhw_storage` `capacity` to charge/discharge by. - electrical_storage_action : float, default: 0.0 - Fraction of `electrical_storage` `nominal power` to charge/discharge by. - electric_vehicle_storage_actions : dict, default: None - A dictionary where keys are charger IDs and values are the fraction of connected EV battery `capacity` - **kwargs - """ - - if electric_vehicle_storage_actions is not None: - electric_vehicle_storage_actions = self._apply_charging_constraints_to_actions(dict(electric_vehicle_storage_actions)) - else: - self._apply_charging_constraints_to_actions(None) - - # hvac devices - if 'cooling_or_heating_device' in self.active_actions: - assert 'cooling_device' not in self.active_actions and 'heating_device' not in self.active_actions, \ - 'cooling_device and heating_device actions must be set to False when cooling_or_heating_device is True.' \ - ' They will be implicitly set based on the polarity of cooling_or_heating_device.' - cooling_device_action = abs(min(cooling_or_heating_device_action, 0.0)) - heating_device_action = abs(max(cooling_or_heating_device_action, 0.0)) - - else: - assert not ('cooling_device' in self.active_actions and 'heating_device' in self.active_actions), \ - 'cooling_device and heating_device actions cannot both be set to True to avoid both actions having' \ - ' values > 0.0 in the same time step. Use cooling_or_heating_device action instead to control' \ - ' both cooling_device and heating_device in a building.' - cooling_device_action = np.nan if 'cooling_device' not in self.active_actions else cooling_device_action - heating_device_action = np.nan if 'heating_device' not in self.active_actions else heating_device_action - - # energy storage devices - cooling_storage_action = 0.0 if 'cooling_storage' not in self.active_actions else cooling_storage_action - heating_storage_action = 0.0 if 'heating_storage' not in self.active_actions else heating_storage_action - dhw_storage_action = 0.0 if 'dhw_storage' not in self.active_actions else dhw_storage_action - electrical_storage_action = 0.0 if 'electrical_storage' not in self.active_actions else electrical_storage_action - - # set action priority - actions = { - 'cooling_demand': (self.update_cooling_demand, (cooling_device_action,)), - 'heating_demand': (self.update_heating_demand, (heating_device_action,)), - 'cooling_device': (self.update_energy_from_cooling_device, ()), - 'cooling_storage': (self.update_cooling_storage, (cooling_storage_action,)), - 'heating_device': (self.update_energy_from_heating_device, ()), - 'heating_storage': (self.update_heating_storage, (heating_storage_action,)), - 'dhw_device': (self.update_energy_from_dhw_device, ()), - 'dhw_storage': (self.update_dhw_storage, (dhw_storage_action,)), - 'non_shiftable_load': (self.update_non_shiftable_load, ()), - 'electrical_storage': (self.update_electrical_storage, (electrical_storage_action,)), - } - - priority_list = list(actions.keys()) - - if electric_vehicle_storage_actions is not None: - electric_vehicle_priority_list = [] - for charger_id, action in electric_vehicle_storage_actions.items(): - action_key = f'electric_vehicle_storage_{charger_id}' - if action_key not in self.active_actions: - raise ValueError("This action should not be applied. Verify") - for charger in self.electric_vehicle_chargers: - if charger.charger_id == charger_id: - actions[action_key] = (charger.update_connected_electric_vehicle_soc, (action,)) - electric_vehicle_priority_list.append(action_key) - priority_list = priority_list + electric_vehicle_priority_list # the priority lists are merged - - if washing_machine_actions is not None: - washing_machine_priority_list = [] - for washing_machine_name, action in washing_machine_actions.items(): - action_key = f'{washing_machine_name}' - if action_key not in self.active_actions: - raise ValueError("This action should not be applied. Verify") - for wm in self.washing_machines: - if wm.name == washing_machine_name: - actions[action_key] = (wm.start_cycle, (action,)) - washing_machine_priority_list.append(action_key) - priority_list = priority_list + washing_machine_priority_list - - if electrical_storage_action < 0.0: - key = 'electrical_storage' - priority_list.remove(key) - priority_list = [key] + priority_list - - else: - pass - - for key in ['cooling', 'heating', 'dhw']: - storage = f'{key}_storage' - device = f'{key}_device' - - if actions[storage][1][0] < 0.0: - storage_ix = priority_list.index(storage) - device_ix = priority_list.index(device) - priority_list[storage_ix] = device - priority_list[device_ix] = storage - - else: - pass - - for k in priority_list: - func, args = actions[k] - - try: - func(*args) - - except NotImplementedError: - pass + r"""Update cooling and heating demand for next timestep and charge/discharge storage devices.""" + + return self._ops_service.apply_actions( + cooling_or_heating_device_action=cooling_or_heating_device_action, + cooling_device_action=cooling_device_action, + heating_device_action=heating_device_action, + cooling_storage_action=cooling_storage_action, + heating_storage_action=heating_storage_action, + dhw_storage_action=dhw_storage_action, + electrical_storage_action=electrical_storage_action, + washing_machine_actions=washing_machine_actions, + electric_vehicle_storage_actions=electric_vehicle_storage_actions, + ) def update_cooling_demand(self, action: float): """Update space cooling demand for current time step.""" @@ -1901,8 +1698,11 @@ def estimate_observation_space_limits(self, include_all: bool = None, periodic_n periodic_observations = self.get_periodic_observation_metadata() low_limit, high_limit = {}, {} data = self._get_observation_space_limits_data() - total_charger_power_kw = sum(getattr(charger, 'max_charging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) - max_violation_energy = total_charger_power_kw * (self.seconds_per_time_step / 3600) + total_charger_power_kw = 0.0 + total_charger_power_kw += sum(getattr(charger, 'max_charging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) + total_charger_power_kw += sum(getattr(charger, 'max_discharging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) + total_storage_power_kw = float(getattr(self.electrical_storage, 'nominal_power', 0.0) or 0.0) + max_violation_energy = (total_charger_power_kw + total_storage_power_kw) * (self.seconds_per_time_step / 3600) for key in observation_names: if key.startswith('charging_phase_one_hot_'): @@ -2140,15 +1940,26 @@ def _get_observation_space_limits_data(self) -> Mapping[str, List[Union[float, i if getattr(self, '_expose_charging_constraints', False): if self._building_charger_limit_kw is not None: data['charging_building_headroom_kw'] = np.full(timesteps, float(self._building_charger_limit_kw), dtype='float32') + if getattr(self, '_electrical_service_enabled', False): + export_limit = self._electrical_service_limits.get('total', {}).get('export_kw') + if export_limit is not None: + data['charging_building_export_headroom_kw'] = np.full(timesteps, float(export_limit), dtype='float32') for phase in self._phase_limits: - limit = phase.get('limit_kw') - if limit is None: - continue - key = f"charging_phase_{phase['name']}_headroom_kw" - data[key] = np.full(timesteps, float(limit), dtype='float32') - - total_charger_power_kw = sum(getattr(charger, 'max_charging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) - max_violation_energy = total_charger_power_kw * (self.seconds_per_time_step / 3600) + import_limit = phase.get('import_kw') + if import_limit is not None: + key = f"charging_phase_{phase['name']}_headroom_kw" + data[key] = np.full(timesteps, float(import_limit), dtype='float32') + if getattr(self, '_electrical_service_enabled', False): + export_limit = phase.get('export_kw') + if export_limit is not None: + key = f"charging_phase_{phase['name']}_export_headroom_kw" + data[key] = np.full(timesteps, float(export_limit), dtype='float32') + + total_charger_power_kw = 0.0 + total_charger_power_kw += sum(getattr(charger, 'max_charging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) + total_charger_power_kw += sum(getattr(charger, 'max_discharging_power', 0.0) or 0.0 for charger in self.electric_vehicle_chargers) + total_storage_power_kw = float(getattr(self.electrical_storage, 'nominal_power', 0.0) or 0.0) + max_violation_energy = (total_charger_power_kw + total_storage_power_kw) * (self.seconds_per_time_step / 3600) data['charging_constraint_violation_kwh'] = np.array([0.0, max_violation_energy], dtype='float32') phase_one_hot_keys = getattr(self, '_phase_encoding_observation_keys', []) or [] @@ -2551,6 +2362,7 @@ def reset(self): # variable reset self.reset_dynamic_variables() self.reset_data_sets() + self._refresh_observation_source_cache() self.__solar_generation = self.pv.get_generation(self.energy_simulation.solar_generation) * -1 self.__energy_from_cooling_device = self.energy_simulation.cooling_demand.copy() self.__energy_from_heating_device = self.energy_simulation.heating_demand.copy() @@ -2562,6 +2374,8 @@ def reset(self): self.__power_outage_signal = self.reset_power_outage_signal() self.__chargers_electricity_consumption = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') self.__washing_machines_electricity_consumption = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') + self._set_default_charging_headroom() + self._reset_charging_constraint_histories() def reset_power_outage_signal(self) -> np.ndarray: """Resets power outage signal time series. @@ -2621,7 +2435,7 @@ def update_variables(self): # cooling electricity consumption cooling_demand = self.__energy_from_cooling_device[self.time_step] + self.cooling_storage.energy_balance[self.time_step] cooling_electricity_consumption = self.cooling_device.get_input_power(cooling_demand, temperature, heating=False) - self.cooling_device.update_electricity_consumption(cooling_electricity_consumption) + self.cooling_device.set_electricity_consumption(cooling_electricity_consumption) # heating electricity consumption heating_demand = self.__energy_from_heating_device[self.time_step] + self.heating_storage.energy_balance[self.time_step] @@ -2629,9 +2443,9 @@ def update_variables(self): if isinstance(self.heating_device, HeatPump): heating_electricity_consumption = self.heating_device.get_input_power(heating_demand, temperature, heating=True) else: - heating_electricity_consumption = self.dhw_device.get_input_power(heating_demand) + heating_electricity_consumption = self.heating_device.get_input_power(heating_demand) - self.heating_device.update_electricity_consumption(heating_electricity_consumption) + self.heating_device.set_electricity_consumption(heating_electricity_consumption) # dhw electricity consumption dhw_demand = self.__energy_from_dhw_device[self.time_step] + self.dhw_storage.energy_balance[self.time_step] @@ -2641,15 +2455,18 @@ def update_variables(self): else: dhw_electricity_consumption = self.dhw_device.get_input_power(dhw_demand) - self.dhw_device.update_electricity_consumption(dhw_electricity_consumption) + self.dhw_device.set_electricity_consumption(dhw_electricity_consumption) # non shiftable load electricity consumption non_shiftable_load_electricity_consumption = self.__energy_to_non_shiftable_load[self.time_step] - self.non_shiftable_load_device.update_electricity_consumption(non_shiftable_load_electricity_consumption) + self.non_shiftable_load_device.set_electricity_consumption(non_shiftable_load_electricity_consumption) # electrical storage - electrical_storage_electricity_consumption = self.electrical_storage.energy_balance[self.time_step] - self.electrical_storage.update_electricity_consumption(electrical_storage_electricity_consumption, enforce_polarity=False) + # NOTE: + # `Battery.charge(...)` already updates electrical storage electricity consumption + # at the current control step. Re-applying it here causes double counting at t=0. + # Keep this branch intentionally no-op for electrical storage to preserve a single + # source of truth in the storage model update path. else: pass @@ -2702,6 +2519,12 @@ def update_variables(self): # net electriciy consumption emission self.__net_electricity_consumption_emission[self.time_step] = max(0.0, net_electricity_consumption*self.carbon_intensity.carbon_intensity[self.time_step]) + def set_net_electricity_consumption_cost(self, value: float, time_step: int = None): + """Override net electricity consumption cost at a given time step.""" + + idx = self.time_step if time_step is None else int(time_step) + self.__net_electricity_consumption_cost[idx] = float(value) + def __str__(self) -> str: """ Return a text representation of the current state. @@ -3017,6 +2840,9 @@ def update_indoor_dry_bulb_temperature(self): or heating demand at each `time_step`. """ + if torch is None: + raise ImportError('torch is required to use LSTMDynamicsBuilding.') + # predict model_input_tensor = torch.tensor(self.get_dynamics_input().T) model_input_tensor = model_input_tensor[np.newaxis, :, :] @@ -3306,9 +3132,9 @@ def update_set_points(self): else: pass - def _get_observations_data(self) -> Mapping[str, Union[float, int]]: + def _get_observations_data(self, include_all: bool = False) -> Mapping[str, Union[float, int]]: return { - **super()._get_observations_data(), + **super()._get_observations_data(include_all=include_all), **{ k.lstrip('_'): self.occupant.parameters.__getattr__(k.lstrip('_'))[self.time_step] for k, v in vars(self.occupant.parameters).items() if isinstance(v, np.ndarray) diff --git a/citylearn/citylearn.py b/citylearn/citylearn.py index d4bc5a50d..4b5781321 100644 --- a/citylearn/citylearn.py +++ b/citylearn/citylearn.py @@ -1,1990 +1,1291 @@ -from collections import defaultdict -from copy import deepcopy -from enum import Enum -import hashlib -import importlib -import logging -import os -from pathlib import Path -from typing import Any, List, Mapping, Tuple, Union -from gymnasium import Env, spaces -import csv -import datetime -import numpy as np -import pandas as pd -import random -from citylearn.base import Environment, EpisodeTracker -from citylearn.building import Building, DynamicsBuilding -from citylearn.cost_function import CostFunction -from citylearn.data import CarbonIntensity, DataSet, ChargerSimulation, EnergySimulation, LogisticRegressionOccupantParameters, Pricing, WashingMachineSimulation, Weather -from citylearn.electric_vehicle import ElectricVehicle -from citylearn.energy_model import Battery, PV, WashingMachine -from citylearn.reward_function import MultiBuildingRewardFunction, RewardFunction -from citylearn.utilities import FileHandler - -LOGGER = logging.getLogger() -logging.getLogger('matplotlib.font_manager').disabled = True -logging.getLogger('matplotlib.pyplot').disabled = True - -class EvaluationCondition(Enum): - """Evaluation conditions. - - Used in `citylearn.CityLearnEnv.calculate` method. - """ - - # general (soft private) - _DEFAULT = '' - _STORAGE_SUFFIX = '_without_storage' - _PARTIAL_LOAD_SUFFIX = '_and_partial_load' - _PV_SUFFIX = '_and_pv' - - # Building type - WITH_STORAGE_AND_PV = _DEFAULT - WITHOUT_STORAGE_BUT_WITH_PV = _STORAGE_SUFFIX - WITHOUT_STORAGE_AND_PV = WITHOUT_STORAGE_BUT_WITH_PV +_PV_SUFFIX - - # DynamicsBuilding type - WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV = WITH_STORAGE_AND_PV - WITHOUT_STORAGE_BUT_WITH_PARTIAL_LOAD_AND_PV = WITHOUT_STORAGE_BUT_WITH_PV - WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV = WITHOUT_STORAGE_BUT_WITH_PARTIAL_LOAD_AND_PV + _PARTIAL_LOAD_SUFFIX - WITHOUT_STORAGE_AND_PARTIAL_LOAD_AND_PV = WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV + _PV_SUFFIX - -class CityLearnEnv(Environment, Env): - r"""CityLearn nvironment class. - - Parameters - ---------- - schema: Union[str, Path, Mapping[str, Any]] - Name of CityLearn data set, filepath to JSON representation or :code:`dict` object of a CityLearn schema. - Call :py:meth:`citylearn.data.DataSet.get_names` for list of available CityLearn data sets. - root_directory: Union[str, Path] - Absolute path to directory that contains the data files including the schema. - buildings: Union[List[Building], List[str], List[int]], optional - Buildings to include in environment. If list of :code:`citylearn.building.Building` is provided, will override :code:`buildings` definition in schema. - If list of :str: is provided will include only schema :code:`buildings` keys that are contained in provided list of :code:`str`. - If list of :int: is provided will include only schema :code:`buildings` whose index is contained in provided list of :code:`int`. - simulation_start_time_step: int, optional - Time step to start reading data files contents. - simulation_end_time_step: int, optional - Time step to end reading from data files contents. - episode_time_steps: Union[int, List[Tuple[int, int]]], optional - If type is `int`, it is the number of time steps in an episode. If type is `List[Tuple[int, int]]]` is provided, - it is a list of episode start and end time steps between `simulation_start_time_step` and `simulation_end_time_step`. - Defaults to (`simulation_end_time_step` - `simulation_start_time_step`) + 1. Will ignore `rolling_episode_split` if `episode_splits` is of type `List[Tuple[int, int]]]`. - rolling_episode_split: bool, default: False - True if episode sequences are split such that each time step is a candidate for `episode_start_time_step` otherwise, False to split episodes in steps of `episode_time_steps`. - random_episode_split: bool, default: False - True if episode splits are to be selected at random during training otherwise, False to select sequentially. - seconds_per_time_step: float - Number of seconds in 1 `time_step` and must be set to >= 1. - reward_function: Union[RewardFunction, str], optional - Reward function class instance or path to function class e.g. 'citylearn.reward_function.IndependentSACReward'. - If provided, will override :code:`reward_function` definition in schema. - reward_function_kwargs: Mapping[str, Any], optional - Parameters to be parsed to :py:attr:`reward_function` at intialization. - central_agent: bool, optional - Expect 1 central agent to control all buildings. - shared_observations: List[str], optional - Names of common observations across all buildings i.e. observations that have the same value irrespective of the building. - active_observations: Union[List[str], List[List[str]]], optional - List of observations to be made available in the buildings. Can be specified for all buildings in a :code:`List[str]` or for - each building independently in a :code:`List[List[str]]`. Will override the observations defined in the :code:`schema`. - inactive_observations: Union[List[str], List[List[str]]], optional - List of observations to be made unavailable in the buildings. Can be specified for all buildings in a :code:`List[str]` or for - each building independently in a :code:`List[List[str]]`. Will override the observations defined in the :code:`schema`. - active_actions: Union[List[str], List[List[str]]], optional - List of actions to be made available in the buildings. Can be specified for all buildings in a :code:`List[str]` or for - each building independently in a :code:`List[List[str]]`. Will override the actions defined in the :code:`schema`. - inactive_actions: Union[List[str], List[List[str]]], optional - List of actions to be made unavailable in the buildings. Can be specified for all buildings in a :code:`List[str]` or for - each building independently in a :code:`List[List[str]]`. Will override the actions defined in the :code:`schema`. - simulate_power_outage: Union[bool, List[bool]] - Whether to simulate power outages. Can be specified for all buildings as single :code:`bool` or for - each building independently in a :code:`List[bool]`. Will override power outage defined in the :code:`schema`. - solar_generation: Union[bool, List[bool]] - Wehther to allow solar generation. Can be specified for all buildings as single :code:`bool` or for - each building independently in a :code:`List[bool]`. Will override :code:`pv` defined in the :code:`schema`. - random_seed: int, optional - Pseudorandom number generator seed for repeatable results. - - Other Parameters - ---------------- - render_directory: Union[str, Path], optional - Base directory where rendering and export artifacts are stored. Relative paths are resolved from the project root. - render_directory_name: str, optional - Folder name created inside the project root for rendering and export artifacts when ``render_directory`` is not provided. - Defaults to ``render_logs``. - render_session_name: str, optional - Name of the subfolder created under ``render_directory``/``render_directory_name`` for export artifacts. When omitted, - a timestamp is used. - render_mode: str, optional - Rendering strategy. Accepted values are ``'none'`` (default), ``'during'`` for streaming exports each step, and - ``'end'`` for exports performed at episode completion while still allowing manual snapshots via :meth:`render`. - **kwargs : dict - Other keyword arguments used to initialize super classes. - - Notes - ----- - Parameters passed to `citylearn.citylearn.CityLearnEnv.__init__` that are also defined in `schema` will override their `schema` definition. - """ - - DEFAULT_RENDER_START_DATE = datetime.date(2024, 1, 1) - - def __init__(self, - schema: Union[str, Path, Mapping[str, Any]], root_directory: Union[str, Path] = None, buildings: Union[List[Building], List[str], List[int]] = None, - electric_vehicles: Union[List[ElectricVehicle], List[str], List[int]] = None, - simulation_start_time_step: int = None, simulation_end_time_step: int = None, episode_time_steps: Union[int, List[Tuple[int, int]]] = None, rolling_episode_split: bool = None, - random_episode_split: bool = None, seconds_per_time_step: float = None, reward_function: Union[RewardFunction, str] = None, reward_function_kwargs: Mapping[str, Any] = None, - central_agent: bool = None, shared_observations: List[str] = None, active_observations: Union[List[str], List[List[str]]] = None, - inactive_observations: Union[List[str], List[List[str]]] = None, active_actions: Union[List[str], List[List[str]]] = None, - inactive_actions: Union[List[str], List[List[str]]] = None, simulate_power_outage: bool = None, solar_generation: bool = None, random_seed: int = None, time_step_ratio: int = None, - start_date: Union[str, datetime.date] = None, render_session_name: str = None, render_mode: str = 'none', **kwargs: Any - ): - render_directory = kwargs.pop('render_directory', None) - render_directory_name = kwargs.pop('render_directory_name', 'render_logs') - render_flag = kwargs.pop('render', None) - kw_render_mode = kwargs.pop('render_mode', None) - requested_render_mode = render_mode if kw_render_mode is None else kw_render_mode - requested_render_mode = 'none' if requested_render_mode is None else str(requested_render_mode).lower() - kw_render_session_name = kwargs.pop('render_session_name', None) - if kw_render_session_name is not None: - render_session_name = kw_render_session_name if render_session_name is None else render_session_name - self.schema = schema - schema_start_date = self.schema.get('start_date') if isinstance(self.schema, dict) else None - schema_render_mode = self.schema.get('render_mode') if isinstance(self.schema, dict) else None - if schema_render_mode is not None: - requested_render_mode = str(schema_render_mode).lower() - if requested_render_mode not in {'none', 'during', 'end'}: - raise ValueError("render_mode must be one of {'none', 'during', 'end'}.") - self.render_mode = requested_render_mode - self._buffer_render = self.render_mode == 'end' +from collections import defaultdict +from copy import deepcopy +from enum import Enum +import hashlib +import importlib +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any, List, Mapping, Tuple, Union +from gymnasium import Env, spaces +import datetime +import numpy as np +import pandas as pd +import random +from citylearn.base import Environment, EpisodeTracker +from citylearn.building import Building, DynamicsBuilding +from citylearn.cost_function import CostFunction +from citylearn.data import CarbonIntensity, DataSet, ChargerSimulation, EnergySimulation, LogisticRegressionOccupantParameters, Pricing, WashingMachineSimulation, Weather +from citylearn.electric_vehicle import ElectricVehicle +from citylearn.energy_model import Battery, PV, WashingMachine +from citylearn.exporter import EpisodeExporter +from citylearn.internal.kpi import CityLearnKPIService +from citylearn.internal.loading import CityLearnLoadingService +from citylearn.internal.runtime import CityLearnRuntimeService +from citylearn.utilities import parse_bool +from citylearn.reward_function import ( + MultiBuildingRewardFunction, + RewardFunction, +) +from citylearn.utilities import FileHandler + +if TYPE_CHECKING: + from citylearn.agents.base import Agent + +LOGGER = logging.getLogger() +logging.getLogger('matplotlib.font_manager').disabled = True +logging.getLogger('matplotlib.pyplot').disabled = True + +class EvaluationCondition(Enum): + """Evaluation conditions. + + Used in `citylearn.CityLearnEnv.calculate` method. + """ + + # general (soft private) + _DEFAULT = '' + _STORAGE_SUFFIX = '_without_storage' + _PARTIAL_LOAD_SUFFIX = '_and_partial_load' + _PV_SUFFIX = '_and_pv' + + # Building type + WITH_STORAGE_AND_PV = _DEFAULT + WITHOUT_STORAGE_BUT_WITH_PV = _STORAGE_SUFFIX + WITHOUT_STORAGE_AND_PV = WITHOUT_STORAGE_BUT_WITH_PV +_PV_SUFFIX + + # DynamicsBuilding type + WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV = WITH_STORAGE_AND_PV + WITHOUT_STORAGE_BUT_WITH_PARTIAL_LOAD_AND_PV = WITHOUT_STORAGE_BUT_WITH_PV + WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV = WITHOUT_STORAGE_BUT_WITH_PARTIAL_LOAD_AND_PV + _PARTIAL_LOAD_SUFFIX + WITHOUT_STORAGE_AND_PARTIAL_LOAD_AND_PV = WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV + _PV_SUFFIX + +class CityLearnEnv(Environment, Env): + r"""CityLearn nvironment class. + + Parameters + ---------- + schema: Union[str, Path, Mapping[str, Any]] + Name of CityLearn data set, filepath to JSON representation or :code:`dict` object of a CityLearn schema. + Call :py:meth:`citylearn.data.DataSet.get_names` for list of available CityLearn data sets. + root_directory: Union[str, Path] + Absolute path to directory that contains the data files including the schema. + buildings: Union[List[Building], List[str], List[int]], optional + Buildings to include in environment. If list of :code:`citylearn.building.Building` is provided, will override :code:`buildings` definition in schema. + If list of :str: is provided will include only schema :code:`buildings` keys that are contained in provided list of :code:`str`. + If list of :int: is provided will include only schema :code:`buildings` whose index is contained in provided list of :code:`int`. + simulation_start_time_step: int, optional + Time step to start reading data files contents. + simulation_end_time_step: int, optional + Time step to end reading from data files contents. + episode_time_steps: Union[int, List[Tuple[int, int]]], optional + If type is `int`, it is the number of time steps in an episode. If type is `List[Tuple[int, int]]]` is provided, + it is a list of episode start and end time steps between `simulation_start_time_step` and `simulation_end_time_step`. + Defaults to (`simulation_end_time_step` - `simulation_start_time_step`) + 1. Will ignore `rolling_episode_split` if `episode_splits` is of type `List[Tuple[int, int]]]`. + rolling_episode_split: bool, default: False + True if episode sequences are split such that each time step is a candidate for `episode_start_time_step` otherwise, False to split episodes in steps of `episode_time_steps`. + random_episode_split: bool, default: False + True if episode splits are to be selected at random during training otherwise, False to select sequentially. + seconds_per_time_step: float + Number of seconds in 1 `time_step` and must be set to >= 1. + reward_function: Union[RewardFunction, str], optional + Reward function class instance or path to function class e.g. 'citylearn.reward_function.IndependentSACReward'. + If provided, will override :code:`reward_function` definition in schema. + reward_function_kwargs: Mapping[str, Any], optional + Parameters to be parsed to :py:attr:`reward_function` at intialization. + central_agent: bool, optional + Expect 1 central agent to control all buildings. + shared_observations: List[str], optional + Names of common observations across all buildings i.e. observations that have the same value irrespective of the building. + active_observations: Union[List[str], List[List[str]]], optional + List of observations to be made available in the buildings. Can be specified for all buildings in a :code:`List[str]` or for + each building independently in a :code:`List[List[str]]`. Will override the observations defined in the :code:`schema`. + inactive_observations: Union[List[str], List[List[str]]], optional + List of observations to be made unavailable in the buildings. Can be specified for all buildings in a :code:`List[str]` or for + each building independently in a :code:`List[List[str]]`. Will override the observations defined in the :code:`schema`. + active_actions: Union[List[str], List[List[str]]], optional + List of actions to be made available in the buildings. Can be specified for all buildings in a :code:`List[str]` or for + each building independently in a :code:`List[List[str]]`. Will override the actions defined in the :code:`schema`. + inactive_actions: Union[List[str], List[List[str]]], optional + List of actions to be made unavailable in the buildings. Can be specified for all buildings in a :code:`List[str]` or for + each building independently in a :code:`List[List[str]]`. Will override the actions defined in the :code:`schema`. + simulate_power_outage: Union[bool, List[bool]] + Whether to simulate power outages. Can be specified for all buildings as single :code:`bool` or for + each building independently in a :code:`List[bool]`. Will override power outage defined in the :code:`schema`. + solar_generation: Union[bool, List[bool]] + Wehther to allow solar generation. Can be specified for all buildings as single :code:`bool` or for + each building independently in a :code:`List[bool]`. Will override :code:`pv` defined in the :code:`schema`. + random_seed: int, optional + Pseudorandom number generator seed for repeatable results. + + Other Parameters + ---------------- + render_directory: Union[str, Path], optional + Base directory where rendering and export artifacts are stored. Relative paths are resolved from the project root. + render_directory_name: str, optional + Folder name created inside the project root for rendering and export artifacts when ``render_directory`` is not provided. + Defaults to ``render_logs``. + render_session_name: str, optional + Name of the subfolder created under ``render_directory``/``render_directory_name`` for export artifacts. When omitted, + a timestamp is used. + render_mode: str, optional + Rendering strategy. Accepted values are ``'none'`` (default), ``'during'`` for streaming exports each step, and + ``'end'`` for exports performed at episode completion while still allowing manual snapshots via :meth:`render`. + export_kpis_on_episode_end: bool, optional + Whether to automatically export ``exported_kpis.csv`` when an episode terminates. + If not provided, defaults to the effective rendering setting (enabled when rendering is enabled). + **kwargs : dict + Other keyword arguments used to initialize super classes. + + Notes + ----- + Parameters passed to `citylearn.citylearn.CityLearnEnv.__init__` that are also defined in `schema` will override their `schema` definition. + """ + + DEFAULT_RENDER_START_DATE = datetime.date(2024, 1, 1) + + def __init__(self, + schema: Union[str, Path, Mapping[str, Any]], root_directory: Union[str, Path] = None, buildings: Union[List[Building], List[str], List[int]] = None, + electric_vehicles: Union[List[ElectricVehicle], List[str], List[int]] = None, + simulation_start_time_step: int = None, simulation_end_time_step: int = None, episode_time_steps: Union[int, List[Tuple[int, int]]] = None, rolling_episode_split: bool = None, + random_episode_split: bool = None, seconds_per_time_step: float = None, reward_function: Union[RewardFunction, str] = None, reward_function_kwargs: Mapping[str, Any] = None, + central_agent: bool = None, shared_observations: List[str] = None, active_observations: Union[List[str], List[List[str]]] = None, + inactive_observations: Union[List[str], List[List[str]]] = None, active_actions: Union[List[str], List[List[str]]] = None, + inactive_actions: Union[List[str], List[List[str]]] = None, simulate_power_outage: bool = None, solar_generation: bool = None, random_seed: int = None, time_step_ratio: int = None, + start_date: Union[str, datetime.date] = None, render_session_name: str = None, render_mode: str = 'none', + export_kpis_on_episode_end: bool = None, **kwargs: Any + ): + render_directory = kwargs.pop('render_directory', None) + render_directory_name = kwargs.pop('render_directory_name', 'render_logs') + render_flag = kwargs.pop('render', None) + kw_export_kpis_on_episode_end = kwargs.pop('export_kpis_on_episode_end', None) + if kw_export_kpis_on_episode_end is not None and export_kpis_on_episode_end is None: + export_kpis_on_episode_end = kw_export_kpis_on_episode_end + debug_timing = kwargs.pop('debug_timing', None) + check_observation_limits = kwargs.pop('check_observation_limits', None) + metrics_log_interval = kwargs.pop('metrics_log_interval', None) + kw_render_mode = kwargs.pop('render_mode', None) + requested_render_mode = render_mode if kw_render_mode is None else kw_render_mode + requested_render_mode = 'none' if requested_render_mode is None else str(requested_render_mode).lower() + kw_render_session_name = kwargs.pop('render_session_name', None) + if kw_render_session_name is not None: + render_session_name = kw_render_session_name if render_session_name is None else render_session_name + self.schema = schema + self.community_market_enabled = False + self.community_market_sell_ratio = 0.8 + self.community_market_grid_export_price = 0.0 + self._last_community_market_settlement = [] + self._community_market_settlement_history = [] + self._configure_community_market() + schema_start_date = self.schema.get('start_date') if isinstance(self.schema, dict) else None + schema_render_mode = self.schema.get('render_mode') if isinstance(self.schema, dict) else None + schema_export_kpis = self.schema.get('export_kpis_on_episode_end') if isinstance(self.schema, dict) else None + if schema_export_kpis is not None and export_kpis_on_episode_end is None: + export_kpis_on_episode_end = parse_bool( + schema_export_kpis, + default=False, + path='export_kpis_on_episode_end', + ) + if schema_render_mode is not None: + requested_render_mode = str(schema_render_mode).lower() + if requested_render_mode not in {'none', 'during', 'end'}: + raise ValueError("render_mode must be one of {'none', 'during', 'end'}.") + self.render_mode = requested_render_mode + self._buffer_render = False self._defer_render_flush = False self._render_buffer = defaultdict(list) + self.debug_timing = parse_bool( + self.schema.get('debug_timing', False) if debug_timing is None else debug_timing, + default=False, + path='debug_timing', + ) + self.check_observation_limits = parse_bool( + self.schema.get('check_observation_limits', False) if check_observation_limits is None else check_observation_limits, + default=False, + path='check_observation_limits', + ) + self.metrics_log_interval = int(self.schema.get('metrics_log_interval', 0) if metrics_log_interval is None else metrics_log_interval) + self._observations_cache: List[List[float]] = None + self._observations_cache_time_step: int = -1 self._render_start_date = self._parse_render_start_date(start_date if start_date is not None else schema_start_date) self.previous_month = None self.current_day = self._render_start_date.day self.year = self._render_start_date.year self._final_kpis_exported = False - self.__rewards = None - self.buildings = [] - self.random_seed = self.schema.get('random_seed', None) if random_seed is None else random_seed - schema_render_session = self.schema.get('render_session_name') if isinstance(self.schema, dict) else None - self.render_session_name = render_session_name if render_session_name is not None else schema_render_session - if self.render_session_name is not None: - self.render_session_name = str(self.render_session_name).strip() - if self.render_session_name == '': - self.render_session_name = None - elif Path(self.render_session_name).is_absolute(): - raise ValueError('render_session_name must be a relative path. Use render_directory to choose an absolute location.') - elif '..' in Path(self.render_session_name).parts: - raise ValueError('render_session_name cannot contain parent directory references (“..”).') - root_directory, buildings, electric_vehicles, episode_time_steps, rolling_episode_split, random_episode_split, \ - seconds_per_time_step, reward_function, central_agent, shared_observations, episode_tracker = self._load( - deepcopy(self.schema), - root_directory=root_directory, - buildings=buildings, - electric_vehicles=electric_vehicles, - simulation_start_time_step=simulation_start_time_step, - simulation_end_time_step=simulation_end_time_step, - episode_time_steps=episode_time_steps, - rolling_episode_split=rolling_episode_split, - random_episode=random_episode_split, - seconds_per_time_step=seconds_per_time_step, - time_step_ratio=time_step_ratio, - reward_function=reward_function, - reward_function_kwargs=reward_function_kwargs, - central_agent=central_agent, - shared_observations=shared_observations, - active_observations=active_observations, - inactive_observations=inactive_observations, - active_actions=active_actions, - inactive_actions=inactive_actions, - simulate_power_outage=simulate_power_outage, - solar_generation=solar_generation, - random_seed=self.random_seed, - ) - self.root_directory = root_directory - self.buildings = buildings - self.electric_vehicles = electric_vehicles - get_time_step_ratio = buildings[0].time_step_ratio if len(buildings) > 0 else 1.0 - self.time_step_ratio = get_time_step_ratio - - # now call super class initialization and set episode tracker now that buildings are set - super().__init__(seconds_per_time_step=seconds_per_time_step, random_seed=self.random_seed, episode_tracker=episode_tracker, time_step_ratio=self.time_step_ratio) - - # set other class variables - self.episode_time_steps = episode_time_steps - self.rolling_episode_split = rolling_episode_split - self.random_episode_split = random_episode_split - self.central_agent = central_agent - self.shared_observations = shared_observations - - # set reward function - self.reward_function = reward_function - - # rendering switch: schema['render'] overrides explicit flag, otherwise rely on render_mode defaults - schema_render = self.schema.get('render', None) if isinstance(self.schema, dict) else None - if schema_render is not None: - render_enabled_flag = bool(schema_render) - elif render_flag is not None: - render_enabled_flag = bool(render_flag) - else: - render_enabled_flag = self.render_mode in {'during', 'end'} - - self.render_enabled = render_enabled_flag - - # reset environment and initializes episode time steps - self.reset() - - # reset episode tracker to start after initializing episode time steps during reset - self.episode_tracker.reset_episode_index() - - # set reward metadata - self.reward_function.env_metadata = self.get_metadata() - - # reward history tracker - self.__episode_rewards = [] - - # reward history tracker - - if self.root_directory is None: - self.root_directory = os.path.dirname(os.path.abspath(__file__)) - - project_root = Path(__file__).resolve().parents[1] - render_directory_name = render_directory_name or 'render_logs' - - if render_directory is not None: - render_root = Path(render_directory).expanduser() - if not render_root.is_absolute(): - render_root = project_root / render_root - else: - render_root = project_root / render_directory_name - - self.render_output_root = render_root.expanduser().resolve() - self._render_timestamp = None - self._render_directory_path = None - self._render_dir_initialized = False - self.new_folder_path = None - self._render_start_datetime = None - + self.__rewards = None + self.buildings = [] + self.random_seed = self.schema.get('random_seed', None) if random_seed is None else random_seed + schema_render_session = self.schema.get('render_session_name') if isinstance(self.schema, dict) else None + self.render_session_name = render_session_name if render_session_name is not None else schema_render_session + if self.render_session_name is not None: + self.render_session_name = str(self.render_session_name).strip() + if self.render_session_name == '': + self.render_session_name = None + elif Path(self.render_session_name).is_absolute(): + raise ValueError('render_session_name must be a relative path. Use render_directory to choose an absolute location.') + elif '..' in Path(self.render_session_name).parts: + raise ValueError('render_session_name cannot contain parent directory references (“..”).') + self._loading_service = CityLearnLoadingService(self) + self._runtime_service = CityLearnRuntimeService(self) + self._kpi_service = CityLearnKPIService(self) + root_directory, buildings, electric_vehicles, episode_time_steps, rolling_episode_split, random_episode_split, \ + seconds_per_time_step, reward_function, central_agent, shared_observations, episode_tracker = self._load( + deepcopy(self.schema), + root_directory=root_directory, + buildings=buildings, + electric_vehicles=electric_vehicles, + simulation_start_time_step=simulation_start_time_step, + simulation_end_time_step=simulation_end_time_step, + episode_time_steps=episode_time_steps, + rolling_episode_split=rolling_episode_split, + random_episode=random_episode_split, + seconds_per_time_step=seconds_per_time_step, + time_step_ratio=time_step_ratio, + reward_function=reward_function, + reward_function_kwargs=reward_function_kwargs, + central_agent=central_agent, + shared_observations=shared_observations, + active_observations=active_observations, + inactive_observations=inactive_observations, + active_actions=active_actions, + inactive_actions=inactive_actions, + simulate_power_outage=simulate_power_outage, + solar_generation=solar_generation, + random_seed=self.random_seed, + ) + self.root_directory = root_directory + self.buildings = buildings + self.electric_vehicles = electric_vehicles + get_time_step_ratio = buildings[0].time_step_ratio if len(buildings) > 0 else 1.0 + self.time_step_ratio = get_time_step_ratio + + # now call super class initialization and set episode tracker now that buildings are set + super().__init__(seconds_per_time_step=seconds_per_time_step, random_seed=self.random_seed, episode_tracker=episode_tracker, time_step_ratio=self.time_step_ratio) + + # set other class variables + self.episode_time_steps = episode_time_steps + self.rolling_episode_split = rolling_episode_split + self.random_episode_split = random_episode_split + self.central_agent = central_agent + self.shared_observations = shared_observations + + # set reward function + self.reward_function = reward_function + self._refresh_action_cache() + + # rendering switch: schema['render'] overrides explicit flag, otherwise rely on render_mode defaults + schema_render = self.schema.get('render', None) if isinstance(self.schema, dict) else None + if schema_render is not None: + render_enabled_flag = parse_bool(schema_render, default=False, path='render') + elif render_flag is not None: + render_enabled_flag = parse_bool(render_flag, default=False, path='render') + else: + render_enabled_flag = self.render_mode in {'during', 'end'} + + self.render_enabled = render_enabled_flag + if export_kpis_on_episode_end is None: + export_kpis_on_episode_end = self.render_enabled + else: + export_kpis_on_episode_end = parse_bool( + export_kpis_on_episode_end, + default=self.render_enabled, + path='export_kpis_on_episode_end', + ) + self.export_kpis_on_episode_end = export_kpis_on_episode_end + + # reset environment and initializes episode time steps + self.reset() + + # reset episode tracker to start after initializing episode time steps during reset + self.episode_tracker.reset_episode_index() + + # set reward metadata + self.reward_function.env_metadata = self.get_metadata() + + # reward history tracker + self.__episode_rewards = [] + + # reward history tracker + + if self.root_directory is None: + self.root_directory = os.path.dirname(os.path.abspath(__file__)) + + project_root = Path(__file__).resolve().parents[1] + render_directory_name = render_directory_name or 'render_logs' + + if render_directory is not None: + render_root = Path(render_directory).expanduser() + if not render_root.is_absolute(): + render_root = project_root / render_root + else: + render_root = project_root / render_directory_name + + self.render_output_root = render_root.expanduser().resolve() + self._render_timestamp = None + self._render_directory_path = None + self._render_dir_initialized = False + self.new_folder_path = None + self._render_start_datetime = None + self._episode_exporter = EpisodeExporter(self) + if self.render_enabled: self._ensure_render_output_dir(ensure_exists=False) - - @property - def render_start_date(self) -> datetime.date: - """Date used as the origin for rendered timestamps.""" - - return self._render_start_date - - @property - def schema(self) -> Mapping[str, Any]: - """`dict` object of CityLearn schema.""" - - return self.__schema - - @property - def render_enabled(self) -> bool: - """Whether environment rendering/logging is enabled.""" - - return getattr(self, '_CityLearnEnv__render_enabled', False) - - @property - def root_directory(self) -> Union[str, Path]: - """Absolute path to directory that contains the data files including the schema.""" - - return self.__root_directory - - @property - def buildings(self) -> List[Building]: - """Buildings in CityLearn environment.""" - - return self.__buildings - - @property - def electric_vehicles(self) -> List[ElectricVehicle]: - """Electric Vehicles in CityLearn environment.""" - - return self.__electric_vehicles - - @property - def time_steps(self) -> int: - """Number of time steps in current episode split.""" - - return self.episode_tracker.episode_time_steps - - @property - def episode_time_steps(self) -> Union[int, List[Tuple[int, int]]]: - """If type is `int`, it is the number of time steps in an episode. If type is `List[Tuple[int, int]]]` is provided, it is a list of - episode start and end time steps between `simulation_start_time_step` and `simulation_end_time_step`. Defaults to (`simulation_end_time_step` - - `simulation_start_time_step`) + 1. Will ignore `rolling_episode_split` if `episode_splits` is of type `List[Tuple[int, int]]]`.""" - - return self.__episode_time_steps - - @property - def rolling_episode_split(self) -> bool: - """True if episode sequences are split such that each time step is a candidate for `episode_start_time_step` otherwise, - False to split episodes in steps of `episode_time_steps`.""" - - return self.__rolling_episode_split - - @property - def random_episode_split(self) -> bool: - """True if episode splits are to be selected at random during training otherwise, False to select sequentially.""" - - return self.__random_episode_split - - @property - def episode(self) -> int: - """Current episode index.""" - - return self.episode_tracker.episode - - @property - def reward_function(self) -> RewardFunction: - """Reward function class instance.""" - - return self.__reward_function - - @property - def rewards(self) -> List[List[float]]: - """Reward time series""" - - return self.__rewards - - @property - def episode_rewards(self) -> List[Mapping[str, Union[float, List[float]]]]: - """Reward summary statistics for elapsed episodes.""" - - return self.__episode_rewards - - @property - def central_agent(self) -> bool: - """Expect 1 central agent to control all buildings.""" - - return self.__central_agent - - @property - def shared_observations(self) -> List[str]: - """Names of common observations across all buildings i.e. observations that have the same value irrespective of the building.""" - - return self.__shared_observations - - @property - def terminated(self) -> bool: - """Check if simulation has reached completion.""" - - return self.time_step == self.time_steps - 1 - - @property - def truncated(self) -> bool: - """Check if episode truncates due to a time limit or a reason that is not defined as part of the task MDP.""" - - return False - - @property - def observation_space(self) -> List[spaces.Box]: - """Controller(s) observation spaces. - - Returns - ------- - observation_space : List[spaces.Box] - List of agent(s) observation spaces. - - Notes - ----- - If `central_agent` is True, a list of 1 `spaces.Box` object is returned that contains all buildings' limits with the limits in the same order as `buildings`. - The `shared_observations` limits are only included in the first building's limits. If `central_agent` is False, a list of `space.Box` objects as - many as `buildings` is returned in the same order as `buildings`. - """ - - if self.central_agent: - low_limit = [] - high_limit = [] - shared_observations = [] - - for i, b in enumerate(self.buildings): - for l, h, s in zip(b.observation_space.low, b.observation_space.high, b.active_observations): - if i == 0 or s not in self.shared_observations or s not in shared_observations: - low_limit.append(l) - high_limit.append(h) - - else: - pass - - if s in self.shared_observations and s not in shared_observations: - shared_observations.append(s) - - else: - pass - - observation_space = [spaces.Box(low=np.array(low_limit), high=np.array(high_limit), dtype=np.float32)] - - else: - observation_space = [b.observation_space for b in self.buildings] - - return observation_space - - @property - def action_space(self) -> List[spaces.Box]: - """Controller(s) action spaces. - - Returns - ------- - action_space : List[spaces.Box] - List of agent(s) action spaces. - - Notes - ----- - If `central_agent` is True, a list of 1 `spaces.Box` object is returned that contains all buildings' limits with the limits in the same order as `buildings`. - If `central_agent` is False, a list of `space.Box` objects as many as `buildings` is returned in the same order as `buildings`. - """ - - if self.central_agent: - low_limit = [v for b in self.buildings for v in b.action_space.low] - high_limit = [v for b in self.buildings for v in b.action_space.high] - action_space = [spaces.Box(low=np.array(low_limit), high=np.array(high_limit), dtype=np.float32)] - else: - action_space = [b.action_space for b in self.buildings] - - return action_space - - @property - def observations(self) -> List[List[float]]: - """Observations at current time step. - - Notes - ----- - If `central_agent` is True, a list of 1 sublist containing all building observation values is returned in the same order as `buildings`. - The `shared_observations` values are only included in the first building's observation values. If `central_agent` is False, a list of sublists - is returned where each sublist is a list of 1 building's observation values and the sublist in the same order as `buildings`. - """ - - if self.central_agent: - observations = [] - shared_observations = [] - - for i, b in enumerate(self.buildings): - for k, v in b.observations(normalize=False, periodic_normalization=False, check_limits=True).items(): - if i == 0 or k not in self.shared_observations or k not in shared_observations: - observations.append(v) - - else: - pass - - if k in self.shared_observations and k not in shared_observations: - shared_observations.append(k) - - else: - pass - - observations = [observations] - - else: - observations = [list(b.observations(normalize=False, periodic_normalization=False, check_limits=True).values()) for b in self.buildings] - - return observations - - @property - def observation_names(self) -> List[List[str]]: - """Names of returned observations. - - Notes - ----- - If `central_agent` is True, a list of 1 sublist containing all building observation names is returned in the same order as `buildings`. - The `shared_observations` names are only included in the first building's observation names. If `central_agent` is False, a list of sublists - is returned where each sublist is a list of 1 building's observation names and the sublist in the same order as `buildings`. - """ - - if self.central_agent: - observation_names = [] - - for i, b in enumerate(self.buildings): - for k, _ in b.observations(normalize=False, periodic_normalization=False).items(): - if i == 0 or k not in self.shared_observations or k not in observation_names: - observation_names.append(k) - - else: - pass - - observation_names = [observation_names] - - else: - observation_names = [list(b.observations().keys()) for b in self.buildings] - - return observation_names - - @property - def action_names(self) -> List[List[str]]: - """Names of received actions. - - Notes - ----- - If `central_agent` is True, a list of 1 sublist containing all building action names is returned in the same order as `buildings`. - If `central_agent` is False, a list of sublists is returned where each sublist is a list of 1 building's action names and the sublist - in the same order as `buildings`. - """ - - if self.central_agent: - action_names = [] - - for b in self.buildings: - action_names += b.active_actions - - action_names = [action_names] - - else: - action_names = [b.active_actions for b in self.buildings] - - return action_names - - @property - def net_electricity_consumption_emission_without_storage_and_partial_load_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_emission_without_storage_and_partial_load_and_pv` time series, in [kg_co2].""" - - return pd.DataFrame([ - b.net_electricity_consumption_emission_without_storage_and_partial_load_and_pv - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_emission_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_cost_without_storage_and_partial_load_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_cost_without_storage_and_partial_load_and_pv` time series, in [$].""" - - return pd.DataFrame([ - b.net_electricity_consumption_cost_without_storage_and_partial_load_and_pv - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_cost_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_without_storage_and_partial_load_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_without_storage_and_partial_load_and_pv` time series, in [kWh].""" - - return pd.DataFrame([ - b.net_electricity_consumption_without_storage_and_partial_load_and_pv - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - - @property - def net_electricity_consumption_emission_without_storage_and_partial_load(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_emission_without_storage_and_partial_load` time series, in [kg_co2].""" - - return pd.DataFrame([ - b.net_electricity_consumption_emission_without_storage_and_partial_load - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_emission_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_cost_without_storage_and_partial_load(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_cost_without_storage_and_partial_load` time series, in [$].""" - - return pd.DataFrame([ - b.net_electricity_consumption_cost_without_storage_and_partial_load - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_cost_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_without_storage_and_partial_load(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_without_storage_and_partial_load` time series, in [kWh].""" - - return pd.DataFrame([ - b.net_electricity_consumption_without_storage_and_partial_load - if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_emission_without_storage_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_emission_without_storage_and_pv` time series, in [kg_co2].""" - - return pd.DataFrame([ - b.net_electricity_consumption_emission_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_cost_without_storage_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_cost_without_storage_and_pv` time series, in [$].""" - - return pd.DataFrame([ - b.net_electricity_consumption_cost_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_without_storage_and_pv(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_without_storage_and_pv` time series, in [kWh].""" - - return pd.DataFrame([ - b.net_electricity_consumption_without_storage_and_pv - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - - @property - def net_electricity_consumption_emission_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_emission_without_storage` time series, in [kg_co2].""" - - return pd.DataFrame([ - b.net_electricity_consumption_emission_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_cost_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_cost_without_storage` time series, in [$].""" - - return pd.DataFrame([ - b.net_electricity_consumption_cost_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_without_storage` time series, in [kWh].""" - - return pd.DataFrame([ - b.net_electricity_consumption_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_emission_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_emission_without_storage` time series, in [kg_co2].""" - - return pd.DataFrame([ - b.net_electricity_consumption_emission_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).tolist() - - @property - def net_electricity_consumption_cost_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_cost_without_storage` time series, in [$].""" - - return pd.DataFrame([ - b.net_electricity_consumption_cost_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_without_storage(self) -> np.ndarray: - """Summed `Building.net_electricity_consumption_without_storage` time series, in [kWh].""" - - return pd.DataFrame([ - b.net_electricity_consumption_without_storage - for b in self.buildings - ]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def net_electricity_consumption_emission(self) -> List[float]: - """Summed `Building.net_electricity_consumption_emission` time series, in [kg_co2].""" - - return self.__net_electricity_consumption_emission - - @property - def net_electricity_consumption_cost(self) -> List[float]: - """Summed `Building.net_electricity_consumption_cost` time series, in [$].""" - - return self.__net_electricity_consumption_cost - - @property - def net_electricity_consumption(self) -> List[float]: - """Summed `Building.net_electricity_consumption` time series, in [kWh].""" - - return self.__net_electricity_consumption - - @property - def cooling_electricity_consumption(self) -> np.ndarray: - """Summed `Building.cooling_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.cooling_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def heating_electricity_consumption(self) -> np.ndarray: - """Summed `Building.heating_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.heating_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def dhw_electricity_consumption(self) -> np.ndarray: - """Summed `Building.dhw_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.dhw_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def cooling_storage_electricity_consumption(self) -> np.ndarray: - """Summed `Building.cooling_storage_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.cooling_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def heating_storage_electricity_consumption(self) -> np.ndarray: - """Summed `Building.heating_storage_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.heating_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def dhw_storage_electricity_consumption(self) -> np.ndarray: - """Summed `Building.dhw_storage_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.dhw_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def electrical_storage_electricity_consumption(self) -> np.ndarray: - """Summed `Building.electrical_storage_electricity_consumption` time series, in [kWh].""" - - return pd.DataFrame([b.electrical_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_cooling_device_to_cooling_storage(self) -> np.ndarray: - """Summed `Building.energy_from_cooling_device_to_cooling_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_cooling_device_to_cooling_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_heating_device_to_heating_storage(self) -> np.ndarray: - """Summed `Building.energy_from_heating_device_to_heating_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_heating_device_to_heating_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_dhw_device_to_dhw_storage(self) -> np.ndarray: - """Summed `Building.energy_from_dhw_device_to_dhw_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_dhw_device_to_dhw_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_to_electrical_storage(self) -> np.ndarray: - """Summed `Building.energy_to_electrical_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_to_electrical_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_cooling_device(self) -> np.ndarray: - """Summed `Building.energy_from_cooling_device` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_cooling_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_heating_device(self) -> np.ndarray: - """Summed `Building.energy_from_heating_device` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_heating_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_dhw_device(self) -> np.ndarray: - """Summed `Building.energy_from_dhw_device` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_dhw_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_to_non_shiftable_load(self) -> np.ndarray: - """Summed `Building.energy_to_non_shiftable_load` time series, in [kWh].""" - - return pd.DataFrame([b.energy_to_non_shiftable_load for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_cooling_storage(self) -> np.ndarray: - """Summed `Building.energy_from_cooling_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_cooling_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - - @property - def total_self_consumption(self) -> np.ndarray: - """Total self-consumption from electrical and thermal storage, in [kWh].""" - return ( - self.energy_from_electrical_storage + - self.energy_from_cooling_storage + - self.energy_from_heating_storage + - self.energy_from_dhw_storage - ) - - @property - def energy_from_heating_storage(self) -> np.ndarray: - """Summed `Building.energy_from_heating_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_heating_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_dhw_storage(self) -> np.ndarray: - """Summed `Building.energy_from_dhw_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_dhw_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def energy_from_electrical_storage(self) -> np.ndarray: - """Summed `Building.energy_from_electrical_storage` time series, in [kWh].""" - - return pd.DataFrame([b.energy_from_electrical_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def cooling_demand(self) -> np.ndarray: - """Summed `Building.cooling_demand`, in [kWh].""" - - return pd.DataFrame([b.cooling_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def heating_demand(self) -> np.ndarray: - """Summed `Building.heating_demand`, in [kWh].""" - - return pd.DataFrame([b.heating_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def dhw_demand(self) -> np.ndarray: - """Summed `Building.dhw_demand`, in [kWh].""" - - return pd.DataFrame([b.dhw_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def non_shiftable_load(self) -> np.ndarray: - """Summed `Building.non_shiftable_load`, in [kWh].""" - - return pd.DataFrame([b.non_shiftable_load for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def solar_generation(self) -> np.ndarray: - """Summed `Building.solar_generation, in [kWh]`.""" - - return pd.DataFrame([b.solar_generation for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() - - @property - def power_outage(self) -> np.ndarray: - """Time series of number of buildings experiencing power outage.""" - - return pd.DataFrame([b.power_outage_signal for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy()[:self.time_step + 1] - - @schema.setter - def schema(self, schema: Union[str, Path, Mapping[str, Any]]): - dataset = DataSet() - - if isinstance(schema, (str, Path)) and os.path.isfile(schema): - schema_filepath = Path(schema) if isinstance(schema, str) else schema - schema = FileHandler.read_json(schema) - schema['root_directory'] = os.path.split(schema_filepath.absolute())[0] if schema['root_directory'] is None \ - else schema['root_directory'] - - elif isinstance(schema, str) and schema in dataset.get_dataset_names(): - schema = dataset.get_schema(schema) - schema['root_directory'] = '' if schema['root_directory'] is None else schema['root_directory'] - - elif isinstance(schema, dict): - schema = deepcopy(schema) - schema['root_directory'] = '' if schema['root_directory'] is None else schema['root_directory'] - - else: - raise UnknownSchemaError() - - self.__schema = schema - - @render_enabled.setter - def render_enabled(self, enabled: bool): - self.__render_enabled = bool(enabled) - - @root_directory.setter - def root_directory(self, root_directory: Union[str, Path]): - self.__root_directory = root_directory - - @buildings.setter - def buildings(self, buildings: List[Building]): - self.__buildings = buildings - - @electric_vehicles.setter - def electric_vehicles(self, electric_vehicles: List[ElectricVehicle]): - self.__electric_vehicles = electric_vehicles - - @Environment.episode_tracker.setter - def episode_tracker(self, episode_tracker: EpisodeTracker): - Environment.episode_tracker.fset(self, episode_tracker) - - for b in self.buildings: - b.episode_tracker = self.episode_tracker - - @episode_time_steps.setter - def episode_time_steps(self, episode_time_steps: Union[int, List[Tuple[int, int]]]): - self.__episode_time_steps = self.episode_tracker.simulation_time_steps if episode_time_steps is None else episode_time_steps - - @rolling_episode_split.setter - def rolling_episode_split(self, rolling_episode_split: bool): - self.__rolling_episode_split = False if rolling_episode_split is None else rolling_episode_split - - @random_episode_split.setter - def random_episode_split(self, random_episode_split: bool): - self.__random_episode_split = False if random_episode_split is None else random_episode_split - - @reward_function.setter - def reward_function(self, reward_function: RewardFunction): - self.__reward_function = reward_function - - @central_agent.setter - def central_agent(self, central_agent: bool): - self.__central_agent = central_agent - - @shared_observations.setter - def shared_observations(self, shared_observations: List[str]): - self.__shared_observations = self.get_default_shared_observations() if shared_observations is None else shared_observations - - @Environment.random_seed.setter - def random_seed(self, seed: int): - Environment.random_seed.fset(self, seed) - - for b in self.buildings: - b.random_seed = self.random_seed - - @Environment.time_step_ratio.setter - def time_step_ratio(self, time_step_ratio: int): - Environment.time_step_ratio.fset(self, time_step_ratio) - - for b in self.buildings: - b.time_step_ratio = self.time_step_ratio - - def get_metadata(self) -> Mapping[str, Any]: - return { - **super().get_metadata(), - 'reward_function': self.reward_function.__class__.__name__, - 'central_agent': self.central_agent, - 'shared_observations': self.shared_observations, - 'buildings': [b.get_metadata() for b in self.buildings], - } - - @staticmethod - def get_default_shared_observations() -> List[str]: - """Names of default common observations across all buildings i.e. observations that have the same value irrespective of the building. - - Notes - ----- - May be used to assigned :attr:`shared_observations` value during `CityLearnEnv` object initialization. - """ - - return [ - 'month', 'day_type', 'hour', 'minutes', 'daylight_savings_status', - 'outdoor_dry_bulb_temperature', 'outdoor_dry_bulb_temperature_predicted_1', - 'outdoor_dry_bulb_temperature_predicted_2', 'outdoor_dry_bulb_temperature_predicted_3', - 'outdoor_relative_humidity', 'outdoor_relative_humidity_predicted_1', - 'outdoor_relative_humidity_predicted_2', 'outdoor_relative_humidity_predicted_3', - 'diffuse_solar_irradiance', 'diffuse_solar_irradiance_predicted_1', - 'diffuse_solar_irradiance_predicted_2', 'diffuse_solar_irradiance_predicted_3', - 'direct_solar_irradiance', 'direct_solar_irradiance_predicted_1', - 'direct_solar_irradiance_predicted_2', 'direct_solar_irradiance_predicted_3', - 'carbon_intensity', 'electricity_pricing', 'electricity_pricing_predicted_1', - 'electricity_pricing_predicted_2', 'electricity_pricing_predicted_3', - ] - - def step(self, actions: List[List[float]]) -> Tuple[List[List[float]], List[float], bool, bool, dict]: - """Apply actions at current timestep, update variables/reward, then advance time. - - Parameters - ---------- - actions: List[List[float]] - Fractions of `buildings` storage devices' capacities to charge/discharge by. - If `central_agent` is True, `actions` parameter should be a list of 1 list containing all buildings' actions and follows - the ordering of buildings in `buildings`. If `central_agent` is False, `actions` parameter should be a list of sublists - where each sublists contains the actions for each building in `buildings` and follows the ordering of buildings in `buildings`. - - Returns - ------- - observations: List[List[float]] - :attr:`observations` current value. - reward: List[float] - :meth:`get_reward` current value. - terminated: bool - A boolean value for if the episode has ended, in which case further :meth:`step` calls will return undefined results. - A done signal may be emitted for different reasons: Maybe the task underlying the environment was solved successfully, - a certain timelimit was exceeded, or the physics simulation has entered an invalid observation. - truncated: bool - A boolean value for if episode truncates due to a time limit or a reason that is not defined as part of the task MDP. - Will always return False in this base class. - info: dict - A dictionary that may contain additional information regarding the reason for a `terminated` signal. - `info` contains auxiliary diagnostic information (helpful for debugging, learning, and logging). - Override :meth"`get_info` to get custom key-value pairs in `info`. - """ - actions = self._parse_actions(actions) - - # Apply actions at current timestep t - for building, building_actions in zip(self.buildings, actions): - building.apply_actions(**building_actions) - - # Update environment/building variables for timestep t (reflect effects of actions) - self.update_variables() - - # NOTE: - # This call to retrieve each building's observation dictionary is an expensive call especially since the observations - # are retrieved again to send to agent but the observations in dict form is needed for the reward function to easily - # extract building-level values. Can't think of a better way to handle this without giving the reward direct access to - # env, which is not the best design for competition integrity sake. Will revisit the building.observations() function - # to see how it can be optimized. - reward_observations = [b.observations(include_all=True, normalize=False, periodic_normalization=False) for b in self.buildings] - reward = self.reward_function.calculate(observations=reward_observations) - self.__rewards.append(reward) - - # Advance to next timestep t+1 - self.next_time_step() - - # store episode reward summary at the end of episode (upon reaching final timestep) - if self.terminated: - if self.render_mode == 'during' and self.render_enabled: - # Final step was already streamed during the most recent `next_time_step` call. - pass - rewards = np.array(self.__rewards[1:], dtype='float32') - self.__episode_rewards.append({ - 'min': rewards.min(axis=0).tolist(), - 'max': rewards.max(axis=0).tolist(), - 'sum': rewards.sum(axis=0).tolist(), - 'mean': rewards.mean(axis=0).tolist() - }) - if self.render_mode == 'end' and self.render_enabled: - if self.time_step > 0: - final_index = min(self.time_steps - 1, self.time_step - 1) - else: - final_index = 0 - - has_buffered_rows = any(self._render_buffer.values()) - - if not has_buffered_rows: - state_snapshot = self._override_render_time_step(final_index) - self._defer_render_flush = True - try: - self.render() - finally: - self._restore_render_time_step(state_snapshot) - self._defer_render_flush = False - - self._flush_render_buffer() - - if self.render_enabled and not self._final_kpis_exported: - self.export_final_kpis() - - return self.observations, reward, self.terminated, self.truncated, self.get_info() - - def get_info(self) -> Mapping[Any, Any]: - """Other information to return from the `citylearn.CityLearnEnv.step` function.""" - - return {} - - def _parse_actions(self, actions: List[List[float]]) -> List[Mapping[str, float]]: - """Return mapping of action name to action value for each building.""" - - actions = list(actions) - building_actions = [] - - if self.central_agent: - actions = actions[0] - number_of_actions = len(actions) - expected_number_of_actions = self.action_space[0].shape[0] - assert number_of_actions == expected_number_of_actions, \ - f'Expected {expected_number_of_actions} actions but {number_of_actions} were parsed to env.step.' - - for building in self.buildings: - size = building.action_space.shape[0] - building_actions.append(actions[0:size]) - actions = actions[size:] - - else: - building_actions = [list(a) for a in actions] - - # check that appropriate number of building actions have been provided - for b, a in zip(self.buildings, building_actions): - number_of_actions = len(a) - expected_number_of_actions = b.action_space.shape[0] - assert number_of_actions == expected_number_of_actions,\ - f'Expected {expected_number_of_actions} for {b.name} but {number_of_actions} actions were provided.' - - active_actions = [[k for k, v in b.action_metadata.items() if v] for b in self.buildings] - - # Create a list of dictionaries for actions including EV-specific actions - parsed_actions = [] - - for i, building in enumerate(self.buildings): - action_dict = {} - electric_vehicle_actions = {} - washing_machine_actions = {} - - # Populate the action_dict with regular actions - for k, action in zip(active_actions[i], building_actions[i]): - if 'electric_vehicle_storage' in k: - # Collect EV actions separately - charger_id = k.replace("electric_vehicle_storage_", "") - electric_vehicle_actions[charger_id] = action - elif 'washing_machine' in k: - # Collect Washing Machine actions separately - washing_machine_actions[k] = action - else: - action_dict[f'{k}_action'] = action - - # Add EV actions to the action_dict if they exist - if electric_vehicle_actions: - action_dict['electric_vehicle_storage_actions'] = electric_vehicle_actions # aqui podes criar dicionario - - if washing_machine_actions: - action_dict['washing_machine_actions'] = washing_machine_actions - - # Fill missing actions with default NaN - for k in building.action_metadata: - if ( - f'{k}_action' not in action_dict and - 'electric_vehicle_storage' not in k and - 'washing_machine' not in k - ): - action_dict[f'{k}_action'] = np.nan - - - - parsed_actions.append(action_dict) - - - return parsed_actions - - def evaluate(self, control_condition: EvaluationCondition = None, baseline_condition: EvaluationCondition = None, comfort_band: float = None) -> pd.DataFrame: - r"""Evaluate cost functions at current time step. - - Calculates and returns building-level and district-level cost functions normalized w.r.t. the no control scenario. - - Parameters - ---------- - control_condition: EvaluationCondition, default: :code:`EvaluationCondition.WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV` - Condition for net electricity consumption, cost and emission to use in calculating cost functions for the control/flexible scenario. - baseline_condition: EvaluationCondition, default: :code:`EvaluationCondition.WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV` - Condition for net electricity consumption, cost and emission to use in calculating cost functions for the baseline scenario - that is used to normalize the control_condition scenario. - comfort_band: float, optional - Comfort band above dry_bulb_temperature_cooling_set_point and below dry_bulb_temperature_heating_set_point beyond - which occupant is assumed to be uncomfortable. Defaults to :py:attr:`citylearn.data.EnergySimulation.DEFUALT_COMFORT_BAND`. - - Returns - ------- - cost_functions: pd.DataFrame - Cost function summary including the following: electricity consumption, zero net energy, carbon emissions, cost, - discomfort (total, too cold, too hot, minimum delta, maximum delta, average delta), ramping, 1 - load factor, - average daily peak and average annual peak. - - Notes - ----- - The equation for the returned cost function values is :math:`\frac{C_{\textrm{control}}}{C_{\textrm{no control}}}` - where :math:`C_{\textrm{control}}` is the value when the agent(s) control the environment and :math:`C_{\textrm{no control}}` - is the value when none of the storages and partial load cooling and heating devices in the environment are actively controlled. - """ - - # lambda functions to get building or district level properties w.r.t. evaluation condition - get_net_electricity_consumption = lambda x, c: getattr(x, f'net_electricity_consumption{c.value}') - get_net_electricity_consumption_cost = lambda x, c: getattr(x, f'net_electricity_consumption_cost{c.value}') - get_net_electricity_consumption_emission = lambda x, c: getattr(x, f'net_electricity_consumption_emission{c.value}') - - # Safe division helper for KPI ratios - def _safe_div(control_value: float, baseline_value: float): - try: - c = control_value - b = baseline_value - # Treat None/NaN/inf as 0.0 for robust normalization on short horizons - def _coerce(x): - try: - v = float(x) - return v if np.isfinite(v) else 0.0 - except Exception: - return 0.0 - c = _coerce(c) - b = _coerce(b) - if b == 0.0: - return 1.0 if c == 0.0 else None - return c / b - except Exception: - return None - - comfort_band = EnergySimulation.DEFUALT_COMFORT_BAND if comfort_band is None else comfort_band - building_level = [] - - for b in self.buildings: - if isinstance(b, DynamicsBuilding): - control_condition = EvaluationCondition.WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV if control_condition is None else control_condition - baseline_condition = EvaluationCondition.WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV if baseline_condition is None else baseline_condition - - else: - control_condition = EvaluationCondition.WITH_STORAGE_AND_PV if control_condition is None else control_condition - baseline_condition = EvaluationCondition.WITHOUT_STORAGE_BUT_WITH_PV if baseline_condition is None else baseline_condition - - discomfort_kwargs = { - 'indoor_dry_bulb_temperature': b.indoor_dry_bulb_temperature, - 'dry_bulb_temperature_cooling_set_point': b.indoor_dry_bulb_temperature_cooling_set_point, - 'dry_bulb_temperature_heating_set_point': b.indoor_dry_bulb_temperature_heating_set_point, - 'band': b.comfort_band if comfort_band is None else comfort_band, - 'occupant_count': b.occupant_count, - } - unmet, cold, hot,\ - cold_minimum_delta, cold_maximum_delta, cold_average_delta,\ - hot_minimum_delta, hot_maximum_delta, hot_average_delta =\ - CostFunction.discomfort(**discomfort_kwargs) - expected_energy = b.cooling_demand + b.heating_demand + b.dhw_demand + b.non_shiftable_load - served_energy = b.energy_from_cooling_device + b.energy_from_cooling_storage\ - + b.energy_from_heating_device + b.energy_from_heating_storage\ - + b.energy_from_dhw_device + b.energy_from_dhw_storage\ - + b.energy_to_non_shiftable_load - ec_c = CostFunction.electricity_consumption(get_net_electricity_consumption(b, control_condition))[-1] - ec_b = CostFunction.electricity_consumption(get_net_electricity_consumption(b, baseline_condition))[-1] - zne_c = CostFunction.zero_net_energy(get_net_electricity_consumption(b, control_condition))[-1] - zne_b = CostFunction.zero_net_energy(get_net_electricity_consumption(b, baseline_condition))[-1] - ce_c = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(b, control_condition))[-1] - ce_b = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(b, baseline_condition))[-1] if sum(b.carbon_intensity.carbon_intensity) != 0 else 0 - cost_c = CostFunction.cost(get_net_electricity_consumption_cost(b, control_condition))[-1] - cost_b = CostFunction.cost(get_net_electricity_consumption_cost(b, baseline_condition))[-1] if sum(b.pricing.electricity_pricing) != 0 else 0 - - building_level_ = pd.DataFrame([{ - 'cost_function': 'electricity_consumption_total', - 'value': _safe_div(ec_c, ec_b), - }, { - 'cost_function': 'zero_net_energy', - 'value': _safe_div(zne_c, zne_b), - }, { - 'cost_function': 'carbon_emissions_total', - 'value': _safe_div(ce_c, ce_b), - }, { - 'cost_function': 'cost_total', - 'value': _safe_div(cost_c, cost_b), - }, { - 'cost_function': 'discomfort_proportion', - 'value': unmet[-1], - }, { - 'cost_function': 'discomfort_cold_proportion', - 'value': cold[-1], - }, { - 'cost_function': 'discomfort_hot_proportion', - 'value': hot[-1], - }, { - 'cost_function': 'discomfort_cold_delta_minimum', - 'value': cold_minimum_delta[-1], - }, { - 'cost_function': 'discomfort_cold_delta_maximum', - 'value': cold_maximum_delta[-1], - }, { - 'cost_function': 'discomfort_cold_delta_average', - 'value': cold_average_delta[-1], - }, { - 'cost_function': 'discomfort_hot_delta_minimum', - 'value': hot_minimum_delta[-1], - }, { - 'cost_function': 'discomfort_hot_delta_maximum', - 'value': hot_maximum_delta[-1], - }, { - 'cost_function': 'discomfort_hot_delta_average', - 'value': hot_average_delta[-1], - }, { - 'cost_function': 'one_minus_thermal_resilience_proportion', - 'value': CostFunction.one_minus_thermal_resilience(power_outage=b.power_outage_signal, **discomfort_kwargs)[-1], - }, { - 'cost_function': 'power_outage_normalized_unserved_energy_total', - 'value': CostFunction.normalized_unserved_energy(expected_energy, served_energy, power_outage=b.power_outage_signal)[-1] - }, { - 'cost_function': 'annual_normalized_unserved_energy_total', - 'value': CostFunction.normalized_unserved_energy(expected_energy, served_energy)[-1] - }]) - building_level_['name'] = b.name - building_level.append(building_level_) - - building_level = pd.concat(building_level, ignore_index=True) - building_level['level'] = 'building' - - ## district level - # set default evaluation conditions - control_condition = EvaluationCondition.WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV if control_condition is None else control_condition - baseline_condition = EvaluationCondition.WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV if baseline_condition is None else baseline_condition - - # District-level normalized KPIs with safe division to avoid 0/0 or div-by-zero - ramp_c = CostFunction.ramping(get_net_electricity_consumption(self, control_condition))[-1] - ramp_b = CostFunction.ramping(get_net_electricity_consumption(self, baseline_condition))[-1] - dlf24_c = CostFunction.one_minus_load_factor(get_net_electricity_consumption(self, control_condition), window=24)[-1] - dlf24_b = CostFunction.one_minus_load_factor(get_net_electricity_consumption(self, baseline_condition), window=24)[-1] - dlf730_c = CostFunction.one_minus_load_factor(get_net_electricity_consumption(self, control_condition), window=730)[-1] - dlf730_b = CostFunction.one_minus_load_factor(get_net_electricity_consumption(self, baseline_condition), window=730)[-1] - peak24_c = CostFunction.peak(get_net_electricity_consumption(self, control_condition), window=24)[-1] - peak24_b = CostFunction.peak(get_net_electricity_consumption(self, baseline_condition), window=24)[-1] - peak_all_c = CostFunction.peak(get_net_electricity_consumption(self, control_condition), window=self.time_steps)[-1] - peak_all_b = CostFunction.peak(get_net_electricity_consumption(self, baseline_condition), window=self.time_steps)[-1] - - district_level = pd.DataFrame([{ - 'cost_function': 'ramping_average', - 'value': _safe_div(ramp_c, ramp_b), - }, { - 'cost_function': 'daily_one_minus_load_factor_average', - 'value': _safe_div(dlf24_c, dlf24_b), - },{ - 'cost_function': 'monthly_one_minus_load_factor_average', - 'value': _safe_div(dlf730_c, dlf730_b), - }, { - 'cost_function': 'daily_peak_average', - 'value': _safe_div(peak24_c, peak24_b), - }, { - 'cost_function': 'all_time_peak_average', - 'value': _safe_div(peak_all_c, peak_all_b), - }]) - - district_level = pd.concat([district_level, building_level], ignore_index=True, sort=False) - district_level = district_level.groupby(['cost_function'])[['value']].mean().reset_index() - district_level['name'] = 'District' - district_level['level'] = 'district' - cost_functions = pd.concat([district_level, building_level], ignore_index=True, sort=False) - - return cost_functions - - def next_time_step(self): - r"""Advance all buildings to next `time_step`.""" - if getattr(self, 'render_enabled', False): - if self.render_mode == 'during': - self.render() - elif self.render_mode == 'end': - self._defer_render_flush = True - try: - self.render() - finally: - self._defer_render_flush = False - for building in self.buildings: - building.next_time_step() - - # Advance electric vehicles to the next time step. This function is used as EVs exist even without being connected to any building (e.g. when they are being used to commute) - # As such, this function simulates the EV to the next time step. - for electric_vehicle in self.electric_vehicles: - electric_vehicle.next_time_step() - - super().next_time_step() - - # Apply battery SOC simulation for EVs that are NOT connected - self.simulate_unconnected_ev_soc() - - #This function is here so that, when the new time step is reached, the first thing to do is plug in/out the EVs according to their individual dataset - #It basicly associates an EV to a Building.Charger - self.associate_chargers_to_electric_vehicles() - - def associate_chargers_to_electric_vehicles(self): - r"""Associate charger to its corresponding electric_vehicle based on charger simulation state.""" - def _resolve_arrival_soc(simulation: ChargerSimulation, step: int, prev_state: float, prev_id: Union[str, None], ev_identifier: str) -> Union[float, None]: - """Return expected SOC (as fraction) for an EV connecting at `step`, or ``None`` when unavailable.""" + @property + def render_start_date(self) -> datetime.date: + """Date used as the origin for rendered timestamps.""" - candidate_index = None + return self._render_start_date - if prev_state in (2, 3) and step > 0: - if isinstance(prev_id, str) and prev_id.strip() not in {"", "nan"} and prev_id != ev_identifier: - raise ValueError( - f"Charger dataset EV mismatch: expected '{ev_identifier}' but found '{prev_id}' at time step {step - 1}." - ) - candidate_index = step - 1 + @property + def schema(self) -> Mapping[str, Any]: + """`dict` object of CityLearn schema.""" - elif 0 <= step < len(simulation.electric_vehicle_estimated_soc_arrival): - candidate_index = step + return self.__schema - soc_value = None + @property + def render_enabled(self) -> bool: + """Whether environment rendering/logging is enabled.""" - if candidate_index is not None and 0 <= candidate_index < len(simulation.electric_vehicle_estimated_soc_arrival): - candidate = simulation.electric_vehicle_estimated_soc_arrival[candidate_index] - if isinstance(candidate, (float, np.floating)) and not np.isnan(candidate) and candidate >= 0: - soc_value = float(candidate) + return getattr(self, '_CityLearnEnv__render_enabled', False) - if soc_value is None and 0 <= step < len(simulation.electric_vehicle_required_soc_departure): - fallback = simulation.electric_vehicle_required_soc_departure[step] - if isinstance(fallback, (float, np.floating)) and not np.isnan(fallback) and fallback >= 0: - soc_value = float(fallback) + @property + def export_kpis_on_episode_end(self) -> bool: + """Whether KPIs are exported automatically when an episode terminates.""" - return soc_value + return getattr(self, '_CityLearnEnv__export_kpis_on_episode_end', False) - for building in self.buildings: - if building.electric_vehicle_chargers is None: - continue - - for charger in building.electric_vehicle_chargers: - sim = charger.charger_simulation - state = sim.electric_vehicle_charger_state[self.time_step] - - if np.isnan(state) or state not in [1, 2]: - continue # Skip if no EV is connected or incoming - - ev_id = sim.electric_vehicle_id[self.time_step] - prev_state = np.nan - prev_ev_id = None - if self.time_step > 0: - idx = self.time_step - 1 - if idx < len(sim.electric_vehicle_charger_state): - prev_state = sim.electric_vehicle_charger_state[idx] - if idx < len(sim.electric_vehicle_id): - prev_ev_id = sim.electric_vehicle_id[idx] - - if isinstance(ev_id, str) and ev_id.strip() not in ["", "nan"]: - for ev in self.electric_vehicles: - if ev.name == ev_id: - if state == 1: - charger.plug_car(ev) - is_new_connection = ( - prev_state != 1 - or not isinstance(prev_ev_id, str) - or prev_ev_id != ev_id - ) - if is_new_connection: - soc_value = _resolve_arrival_soc(sim, self.time_step, prev_state, prev_ev_id, ev_id) - if soc_value is not None: - ev.battery.force_set_soc(soc_value) - elif state == 2: - charger.associate_incoming_car(ev) + @property + def root_directory(self) -> Union[str, Path]: + """Absolute path to directory that contains the data files including the schema.""" - def simulate_unconnected_ev_soc(self): - """Simulate SOC changes for EVs that are not under charger control at t+1.""" - t = self.time_step - if t + 1 >= self.episode_tracker.episode_time_steps: - return - - for ev in self.electric_vehicles: - ev_id = ev.name - found_in_charger = False - - for building in self.buildings: - for charger in building.electric_vehicle_chargers or []: - sim : ChargerSimulation = charger.charger_simulation - - curr_id = sim.electric_vehicle_id[t] if t < len(sim.electric_vehicle_id) else "" - next_id = sim.electric_vehicle_id[t + 1] if t + 1 < len(sim.electric_vehicle_id) else "" - curr_state = sim.electric_vehicle_charger_state[t] if t < len(sim.electric_vehicle_charger_state) else np.nan - next_state = sim.electric_vehicle_charger_state[t + 1] if t + 1 < len(sim.electric_vehicle_charger_state) else np.nan - - currently_connected = isinstance(curr_id, str) and curr_id == ev_id and curr_state == 1 - if currently_connected: - found_in_charger = True - break - - is_connecting = ( - isinstance(next_id, str) - and next_id == ev_id - and next_state == 1 - and curr_state != 1 - ) - is_incoming = isinstance(curr_id, str) and curr_id == ev_id and curr_state == 2 - - if is_connecting: - found_in_charger = True - # Priority 1: current soc_arrival if incoming at t - if is_incoming: - if t < len(sim.electric_vehicle_estimated_soc_arrival): - soc = sim.electric_vehicle_estimated_soc_arrival[t] - else: - soc = np.nan - else: - if t + 1 < len(sim.electric_vehicle_estimated_soc_arrival): - soc = sim.electric_vehicle_estimated_soc_arrival[t + 1] - else: - soc = np.nan - - if 0 <= soc <= 1: - ev.battery.force_set_soc(soc) - break - - if found_in_charger: - break - - if not found_in_charger: - # Not being connected or incoming in a valid charger — apply SOC drift - if t > 0: - last_soc = ev.battery.soc[t - 1] - variability = np.clip(np.random.normal(1.0, 0.2), 0.6, 1.4) - new_soc = np.clip(last_soc * variability, 0.0, 1.0) - ev.battery.force_set_soc(new_soc) - - def export_final_kpis(self, model: 'citylearn.agents.base.Agent' = None, filepath: str = "exported_kpis.csv"): - """Export episode KPIs to csv. + return self.__root_directory - Parameters - ---------- - model: citylearn.agents.base.Agent, optional - Agent whose environment should be evaluated. Defaults to the current environment. - filepath: str, default: ``"exported_kpis.csv"`` - Output filename placed inside :pyattr:`new_folder_path`. + @property + def buildings(self) -> List[Building]: + """Buildings in CityLearn environment.""" + + return self.__buildings + + @property + def electric_vehicles(self) -> List[ElectricVehicle]: + """Electric Vehicles in CityLearn environment.""" + + return self.__electric_vehicles + + @property + def time_steps(self) -> int: + """Number of time steps in current episode split.""" + + return self.episode_tracker.episode_time_steps + + @property + def episode_time_steps(self) -> Union[int, List[Tuple[int, int]]]: + """If type is `int`, it is the number of time steps in an episode. If type is `List[Tuple[int, int]]]` is provided, it is a list of + episode start and end time steps between `simulation_start_time_step` and `simulation_end_time_step`. Defaults to (`simulation_end_time_step` + - `simulation_start_time_step`) + 1. Will ignore `rolling_episode_split` if `episode_splits` is of type `List[Tuple[int, int]]]`.""" + + return self.__episode_time_steps + + @property + def rolling_episode_split(self) -> bool: + """True if episode sequences are split such that each time step is a candidate for `episode_start_time_step` otherwise, + False to split episodes in steps of `episode_time_steps`.""" + + return self.__rolling_episode_split + + @property + def random_episode_split(self) -> bool: + """True if episode splits are to be selected at random during training otherwise, False to select sequentially.""" + + return self.__random_episode_split + + @property + def episode(self) -> int: + """Current episode index.""" + + return self.episode_tracker.episode + + @property + def reward_function(self) -> RewardFunction: + """Reward function class instance.""" + + return self.__reward_function + + @property + def rewards(self) -> List[List[float]]: + """Reward time series""" + + return self.__rewards + + @property + def episode_rewards(self) -> List[Mapping[str, Union[float, List[float]]]]: + """Reward summary statistics for elapsed episodes.""" + + return self.__episode_rewards + + @property + def central_agent(self) -> bool: + """Expect 1 central agent to control all buildings.""" + + return self.__central_agent + + @property + def shared_observations(self) -> List[str]: + """Names of common observations across all buildings i.e. observations that have the same value irrespective of the building.""" + + return self.__shared_observations + + @property + def terminated(self) -> bool: + """Check if simulation has reached completion.""" + + return self.time_step >= self.time_steps - 1 + + @property + def truncated(self) -> bool: + """Check if episode truncates due to a time limit or a reason that is not defined as part of the task MDP.""" + + return False + + @property + def observation_space(self) -> List[spaces.Box]: + """Controller(s) observation spaces. + + Returns + ------- + observation_space : List[spaces.Box] + List of agent(s) observation spaces. + + Notes + ----- + If `central_agent` is True, a list of 1 `spaces.Box` object is returned that contains all buildings' limits with the limits in the same order as `buildings`. + The `shared_observations` limits are only included in the first building's limits. If `central_agent` is False, a list of `space.Box` objects as + many as `buildings` is returned in the same order as `buildings`. """ - # Ensure output directory exists even if rendering was disabled - self._ensure_render_output_dir() - file_path = os.path.join(self.new_folder_path, filepath) - if model is not None and getattr(model, 'env', None) is not None: - kpis = model.env.evaluate() + + if self.central_agent: + low_limit = [] + high_limit = [] + shared_observations = [] + + for i, b in enumerate(self.buildings): + for l, h, s in zip(b.observation_space.low, b.observation_space.high, b.active_observations): + if i == 0 or s not in self.shared_observations or s not in shared_observations: + low_limit.append(l) + high_limit.append(h) + + else: + pass + + if s in self.shared_observations and s not in shared_observations: + shared_observations.append(s) + + else: + pass + + observation_space = [spaces.Box(low=np.array(low_limit), high=np.array(high_limit), dtype=np.float32)] + else: - kpis = self.evaluate() - kpis = kpis.pivot(index='cost_function', columns='name', values='value').round(3) - kpis = kpis.dropna(how='all') - kpis = kpis.fillna('') - kpis = kpis.reset_index() - kpis = kpis.rename(columns={'cost_function': 'KPI'}) - kpis.to_csv(file_path, index=False, encoding='utf-8') - self._final_kpis_exported = True - - def render(self): - """ - Renders the current state of the CityLearn environment, logging data into separate CSV files. - Organizes files by episode number when simulation spans multiple episodes. - """ - if not getattr(self, 'render_enabled', False): - return - if self.render_mode == 'end' and not getattr(self, '_defer_render_flush', False): - self._flush_render_buffer() - return - # Ensure the output directory is prepared - self._ensure_render_output_dir() - iso_timestamp = self._get_iso_timestamp() - os.makedirs(self.new_folder_path, exist_ok=True) - - episode_num = self.episode_tracker.episode - - # Save community data - add episode number to filename - self._save_to_csv(f"exported_data_community_ep{episode_num}.csv", - {"timestamp": iso_timestamp, **self.as_dict()}) - - # Save building data - for idx, building in enumerate(self.buildings): - building_filename = f"exported_data_{building.name.lower()}_ep{episode_num}.csv" - self._save_to_csv(building_filename, - {"timestamp": iso_timestamp, **building.as_dict()}) - - # Battery data - battery = building.electrical_storage # save battery to render - battery_filename = f"exported_data_{building.name.lower()}_battery_ep{episode_num}.csv" - self._save_to_csv(battery_filename, - {"timestamp": iso_timestamp, **battery.as_dict()}) - - # Chargers - for charger_idx, charger in enumerate(building.electric_vehicle_chargers): - charger_filename = f"exported_data_{building.name.lower()}_{charger.charger_id}_ep{episode_num}.csv" - self._save_to_csv(charger_filename, - {"timestamp": iso_timestamp, **charger.as_dict()}) - - # Pricing data - pricing_filename = f"exported_data_pricing_ep{episode_num}.csv" - self._save_to_csv(pricing_filename, - {"timestamp": iso_timestamp, **self.buildings[0].pricing.as_dict(self.time_step)}) - - # EV data - for idx, ev in enumerate(self.__electric_vehicles): - ev_filename = f"exported_data_{ev.name.lower()}_ep{episode_num}.csv" - self._save_to_csv(ev_filename, - {"timestamp": iso_timestamp, **ev.as_dict()}) - - def _save_to_csv(self, filename, data): + observation_space = [b.observation_space for b in self.buildings] + + return observation_space + + @property + def action_space(self) -> List[spaces.Box]: + """Controller(s) action spaces. + + Returns + ------- + action_space : List[spaces.Box] + List of agent(s) action spaces. + + Notes + ----- + If `central_agent` is True, a list of 1 `spaces.Box` object is returned that contains all buildings' limits with the limits in the same order as `buildings`. + If `central_agent` is False, a list of `space.Box` objects as many as `buildings` is returned in the same order as `buildings`. """ - Saves data to a CSV file, appending it if the file exists. When `render_mode='end'`, - rows may be buffered in memory until a flush is requested. + + if self.central_agent: + low_limit = [v for b in self.buildings for v in b.action_space.low] + high_limit = [v for b in self.buildings for v in b.action_space.high] + action_space = [spaces.Box(low=np.array(low_limit), high=np.array(high_limit), dtype=np.float32)] + else: + action_space = [b.action_space for b in self.buildings] + + return action_space + + @property + def observations(self) -> List[List[float]]: + """Observations at current time step. + + Notes + ----- + If `central_agent` is True, a list of 1 sublist containing all building observation values is returned in the same order as `buildings`. + The `shared_observations` values are only included in the first building's observation values. If `central_agent` is False, a list of sublists + is returned where each sublist is a list of 1 building's observation values and the sublist in the same order as `buildings`. """ - if self._buffer_render and getattr(self, '_defer_render_flush', False): - self._render_buffer[filename].append(dict(data)) - return + if self._observations_cache is not None and self._observations_cache_time_step == self.time_step: + return self._observations_cache - self._write_render_rows(filename, [dict(data)]) + building_observations = [ + b.observations( + normalize=False, + periodic_normalization=False, + check_limits=self.check_observation_limits, + ) for b in self.buildings + ] - def _flush_render_buffer(self): - """Write any buffered render rows to disk.""" - if not getattr(self, '_render_buffer', None): - return + if self.central_agent: + observations = [] + shared_observations = set() + shared_observation_names = self._shared_observations_set - has_pending_rows = any(self._render_buffer.values()) - if not has_pending_rows: - self._render_buffer.clear() - return + for i, b_observations in enumerate(building_observations): + for k, v in b_observations.items(): + if i == 0 or k not in shared_observation_names or k not in shared_observations: + observations.append(v) - try: - target_dir = Path(self.new_folder_path) - except Exception: - target_dir = None + if k in shared_observation_names: + shared_observations.add(k) - if target_dir is not None: - print(f"Writing buffered render exports to {target_dir} ...") + observations = [observations] - original_defer = self._defer_render_flush - original_buffer_state = self._buffer_render - self._defer_render_flush = False - self._buffer_render = False + else: + observations = [list(o.values()) for o in building_observations] - try: - for filename, rows in list(self._render_buffer.items()): - if rows: - self._write_render_rows(filename, rows) - finally: - self._render_buffer.clear() - self._buffer_render = original_buffer_state - self._defer_render_flush = original_defer + self._observations_cache = observations + self._observations_cache_time_step = self.time_step - def _write_render_rows(self, filename: str, rows: List[Mapping[str, Any]]): - """Write one or more render rows to disk with minimal rewrites.""" + return observations - file_path = Path(self.new_folder_path) / filename - file_path.parent.mkdir(parents=True, exist_ok=True) - if not rows: - return + @property + def observation_names(self) -> List[List[str]]: + """Names of returned observations. + + Notes + ----- + If `central_agent` is True, a list of 1 sublist containing all building observation names is returned in the same order as `buildings`. + The `shared_observations` names are only included in the first building's observation names. If `central_agent` is False, a list of sublists + is returned where each sublist is a list of 1 building's observation names and the sublist in the same order as `buildings`. + """ + + if self.central_agent: + observation_names = [] + + for i, b in enumerate(self.buildings): + for k, _ in b.observations(normalize=False, periodic_normalization=False).items(): + if i == 0 or k not in self.shared_observations or k not in observation_names: + observation_names.append(k) + + else: + pass + + observation_names = [observation_names] + + else: + observation_names = [list(b.observations().keys()) for b in self.buildings] + + return observation_names + + @property + def action_names(self) -> List[List[str]]: + """Names of received actions. + + Notes + ----- + If `central_agent` is True, a list of 1 sublist containing all building action names is returned in the same order as `buildings`. + If `central_agent` is False, a list of sublists is returned where each sublist is a list of 1 building's action names and the sublist + in the same order as `buildings`. + """ + + if self.central_agent: + action_names = [] + + for b in self.buildings: + action_names += b.active_actions + + action_names = [action_names] + + else: + action_names = [b.active_actions for b in self.buildings] + + return action_names + + def _refresh_action_cache(self): + self._active_actions_cache = [list(b.active_actions) for b in self.buildings] + self._expected_central_action_count = sum(len(actions) for actions in self._active_actions_cache) + + @property + def net_electricity_consumption_emission_without_storage_and_partial_load_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_emission_without_storage_and_partial_load_and_pv` time series, in [kg_co2].""" + + return pd.DataFrame([ + b.net_electricity_consumption_emission_without_storage_and_partial_load_and_pv + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_emission_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_cost_without_storage_and_partial_load_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_cost_without_storage_and_partial_load_and_pv` time series, in [$].""" + + return pd.DataFrame([ + b.net_electricity_consumption_cost_without_storage_and_partial_load_and_pv + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_cost_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_without_storage_and_partial_load_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_without_storage_and_partial_load_and_pv` time series, in [kWh].""" + + return pd.DataFrame([ + b.net_electricity_consumption_without_storage_and_partial_load_and_pv + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + + @property + def net_electricity_consumption_emission_without_storage_and_partial_load(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_emission_without_storage_and_partial_load` time series, in [kg_co2].""" + + return pd.DataFrame([ + b.net_electricity_consumption_emission_without_storage_and_partial_load + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_emission_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_cost_without_storage_and_partial_load(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_cost_without_storage_and_partial_load` time series, in [$].""" + + return pd.DataFrame([ + b.net_electricity_consumption_cost_without_storage_and_partial_load + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_cost_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_without_storage_and_partial_load(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_without_storage_and_partial_load` time series, in [kWh].""" + + return pd.DataFrame([ + b.net_electricity_consumption_without_storage_and_partial_load + if isinstance(b, DynamicsBuilding) else b.net_electricity_consumption_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_emission_without_storage_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_emission_without_storage_and_pv` time series, in [kg_co2].""" + + return pd.DataFrame([ + b.net_electricity_consumption_emission_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_cost_without_storage_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_cost_without_storage_and_pv` time series, in [$].""" + + return pd.DataFrame([ + b.net_electricity_consumption_cost_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_without_storage_and_pv(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_without_storage_and_pv` time series, in [kWh].""" + + return pd.DataFrame([ + b.net_electricity_consumption_without_storage_and_pv + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + + @property + def net_electricity_consumption_emission_without_storage(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_emission_without_storage` time series, in [kg_co2].""" + + return pd.DataFrame([ + b.net_electricity_consumption_emission_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_cost_without_storage(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_cost_without_storage` time series, in [$].""" + + return pd.DataFrame([ + b.net_electricity_consumption_cost_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_without_storage(self) -> np.ndarray: + """Summed `Building.net_electricity_consumption_without_storage` time series, in [kWh].""" + + return pd.DataFrame([ + b.net_electricity_consumption_without_storage + for b in self.buildings + ]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def net_electricity_consumption_emission(self) -> List[float]: + """Summed `Building.net_electricity_consumption_emission` time series, in [kg_co2].""" + + return self.__net_electricity_consumption_emission + + @property + def net_electricity_consumption_cost(self) -> List[float]: + """Summed `Building.net_electricity_consumption_cost` time series, in [$].""" + + return self.__net_electricity_consumption_cost + + @property + def net_electricity_consumption(self) -> List[float]: + """Summed `Building.net_electricity_consumption` time series, in [kWh].""" + + return self.__net_electricity_consumption + + @property + def cooling_electricity_consumption(self) -> np.ndarray: + """Summed `Building.cooling_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.cooling_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def heating_electricity_consumption(self) -> np.ndarray: + """Summed `Building.heating_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.heating_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def dhw_electricity_consumption(self) -> np.ndarray: + """Summed `Building.dhw_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.dhw_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def cooling_storage_electricity_consumption(self) -> np.ndarray: + """Summed `Building.cooling_storage_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.cooling_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def heating_storage_electricity_consumption(self) -> np.ndarray: + """Summed `Building.heating_storage_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.heating_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def dhw_storage_electricity_consumption(self) -> np.ndarray: + """Summed `Building.dhw_storage_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.dhw_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def electrical_storage_electricity_consumption(self) -> np.ndarray: + """Summed `Building.electrical_storage_electricity_consumption` time series, in [kWh].""" + + return pd.DataFrame([b.electrical_storage_electricity_consumption for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_cooling_device_to_cooling_storage(self) -> np.ndarray: + """Summed `Building.energy_from_cooling_device_to_cooling_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_cooling_device_to_cooling_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_heating_device_to_heating_storage(self) -> np.ndarray: + """Summed `Building.energy_from_heating_device_to_heating_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_heating_device_to_heating_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_dhw_device_to_dhw_storage(self) -> np.ndarray: + """Summed `Building.energy_from_dhw_device_to_dhw_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_dhw_device_to_dhw_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_to_electrical_storage(self) -> np.ndarray: + """Summed `Building.energy_to_electrical_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_to_electrical_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_cooling_device(self) -> np.ndarray: + """Summed `Building.energy_from_cooling_device` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_cooling_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_heating_device(self) -> np.ndarray: + """Summed `Building.energy_from_heating_device` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_heating_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_dhw_device(self) -> np.ndarray: + """Summed `Building.energy_from_dhw_device` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_dhw_device for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_to_non_shiftable_load(self) -> np.ndarray: + """Summed `Building.energy_to_non_shiftable_load` time series, in [kWh].""" + + return pd.DataFrame([b.energy_to_non_shiftable_load for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_cooling_storage(self) -> np.ndarray: + """Summed `Building.energy_from_cooling_storage` time series, in [kWh].""" - buffered_fieldnames = list( - dict.fromkeys(field for row in rows for field in row.keys()) + return pd.DataFrame([b.energy_from_cooling_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + + @property + def total_self_consumption(self) -> np.ndarray: + """Total self-consumption from electrical and thermal storage, in [kWh].""" + return ( + self.energy_from_electrical_storage + + self.energy_from_cooling_storage + + self.energy_from_heating_storage + + self.energy_from_dhw_storage ) - if not file_path.exists(): - fieldnames = buffered_fieldnames - with file_path.open('w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() - for row in rows: - writer.writerow({field: row.get(field, '') for field in fieldnames}) + @property + def energy_from_heating_storage(self) -> np.ndarray: + """Summed `Building.energy_from_heating_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_heating_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_dhw_storage(self) -> np.ndarray: + """Summed `Building.energy_from_dhw_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_dhw_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def energy_from_electrical_storage(self) -> np.ndarray: + """Summed `Building.energy_from_electrical_storage` time series, in [kWh].""" + + return pd.DataFrame([b.energy_from_electrical_storage for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def cooling_demand(self) -> np.ndarray: + """Summed `Building.cooling_demand`, in [kWh].""" + + return pd.DataFrame([b.cooling_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def heating_demand(self) -> np.ndarray: + """Summed `Building.heating_demand`, in [kWh].""" + + return pd.DataFrame([b.heating_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def dhw_demand(self) -> np.ndarray: + """Summed `Building.dhw_demand`, in [kWh].""" + + return pd.DataFrame([b.dhw_demand for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def non_shiftable_load(self) -> np.ndarray: + """Summed `Building.non_shiftable_load`, in [kWh].""" + + return pd.DataFrame([b.non_shiftable_load for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def solar_generation(self) -> np.ndarray: + """Summed `Building.solar_generation, in [kWh]`.""" + + return pd.DataFrame([b.solar_generation for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy() + + @property + def power_outage(self) -> np.ndarray: + """Time series of number of buildings experiencing power outage.""" + + return pd.DataFrame([b.power_outage_signal for b in self.buildings]).sum(axis = 0, min_count = 1).to_numpy()[:self.time_step + 1] + + @schema.setter + def schema(self, schema: Union[str, Path, Mapping[str, Any]]): + dataset = DataSet() + + if isinstance(schema, (str, Path)) and os.path.isfile(schema): + schema_filepath = Path(schema) if isinstance(schema, str) else schema + schema = FileHandler.read_json(schema) + schema['root_directory'] = os.path.split(schema_filepath.absolute())[0] if schema['root_directory'] is None \ + else schema['root_directory'] + + elif isinstance(schema, str) and schema in dataset.get_dataset_names(): + schema = dataset.get_schema(schema) + schema['root_directory'] = '' if schema['root_directory'] is None else schema['root_directory'] + + elif isinstance(schema, dict): + schema = deepcopy(schema) + schema['root_directory'] = '' if schema['root_directory'] is None else schema['root_directory'] + + else: + raise UnknownSchemaError() + + self.__schema = schema + + @render_enabled.setter + def render_enabled(self, enabled: bool): + self.__render_enabled = bool(enabled) + + @export_kpis_on_episode_end.setter + def export_kpis_on_episode_end(self, enabled: bool): + self.__export_kpis_on_episode_end = bool(enabled) + + @root_directory.setter + def root_directory(self, root_directory: Union[str, Path]): + self.__root_directory = root_directory + + @buildings.setter + def buildings(self, buildings: List[Building]): + self.__buildings = buildings + + @electric_vehicles.setter + def electric_vehicles(self, electric_vehicles: List[ElectricVehicle]): + self.__electric_vehicles = electric_vehicles + + @Environment.episode_tracker.setter + def episode_tracker(self, episode_tracker: EpisodeTracker): + Environment.episode_tracker.fset(self, episode_tracker) + + for b in self.buildings: + b.episode_tracker = self.episode_tracker + + @episode_time_steps.setter + def episode_time_steps(self, episode_time_steps: Union[int, List[Tuple[int, int]]]): + self.__episode_time_steps = self.episode_tracker.simulation_time_steps if episode_time_steps is None else episode_time_steps + + @rolling_episode_split.setter + def rolling_episode_split(self, rolling_episode_split: bool): + self.__rolling_episode_split = False if rolling_episode_split is None else rolling_episode_split + + @random_episode_split.setter + def random_episode_split(self, random_episode_split: bool): + self.__random_episode_split = False if random_episode_split is None else random_episode_split + + @reward_function.setter + def reward_function(self, reward_function: RewardFunction): + self.__reward_function = reward_function + + @central_agent.setter + def central_agent(self, central_agent: bool): + self.__central_agent = central_agent + + @shared_observations.setter + def shared_observations(self, shared_observations: List[str]): + self.__shared_observations = self.get_default_shared_observations() if shared_observations is None else shared_observations + self._shared_observations_set = set(self.__shared_observations) + + @Environment.random_seed.setter + def random_seed(self, seed: int): + Environment.random_seed.fset(self, seed) + + for b in self.buildings: + b.random_seed = self.random_seed + + @Environment.time_step_ratio.setter + def time_step_ratio(self, time_step_ratio: int): + Environment.time_step_ratio.fset(self, time_step_ratio) + + for b in self.buildings: + b.time_step_ratio = self.time_step_ratio + + def get_metadata(self) -> Mapping[str, Any]: + return { + **super().get_metadata(), + 'reward_function': self.reward_function.__class__.__name__, + 'central_agent': self.central_agent, + 'shared_observations': self.shared_observations, + 'community_market': { + 'enabled': self.community_market_enabled, + 'intra_community_sell_ratio': self.community_market_sell_ratio, + 'grid_export_price': self.community_market_grid_export_price, + 'matching_granularity': 'aggregate_building', + }, + 'buildings': [b.get_metadata() for b in self.buildings], + } + + @staticmethod + def get_default_shared_observations() -> List[str]: + """Names of default common observations across all buildings i.e. observations that have the same value irrespective of the building. + + Notes + ----- + May be used to assigned :attr:`shared_observations` value during `CityLearnEnv` object initialization. + """ + + return [ + 'month', 'day_type', 'hour', 'minutes', 'daylight_savings_status', + 'outdoor_dry_bulb_temperature', 'outdoor_dry_bulb_temperature_predicted_1', + 'outdoor_dry_bulb_temperature_predicted_2', 'outdoor_dry_bulb_temperature_predicted_3', + 'outdoor_relative_humidity', 'outdoor_relative_humidity_predicted_1', + 'outdoor_relative_humidity_predicted_2', 'outdoor_relative_humidity_predicted_3', + 'diffuse_solar_irradiance', 'diffuse_solar_irradiance_predicted_1', + 'diffuse_solar_irradiance_predicted_2', 'diffuse_solar_irradiance_predicted_3', + 'direct_solar_irradiance', 'direct_solar_irradiance_predicted_1', + 'direct_solar_irradiance_predicted_2', 'direct_solar_irradiance_predicted_3', + 'carbon_intensity', 'electricity_pricing', 'electricity_pricing_predicted_1', + 'electricity_pricing_predicted_2', 'electricity_pricing_predicted_3', + ] + + def step(self, actions: List[List[float]]) -> Tuple[List[List[float]], List[float], bool, bool, dict]: + """Apply actions and advance the environment by one transition.""" + + return self._runtime_service.step(actions) + + def get_info(self) -> Mapping[Any, Any]: + """Other information to return from the `citylearn.CityLearnEnv.step` function.""" + + return {} + + def _maybe_log_periodic_metrics(self): + """Lightweight periodic metrics logging for long training runs.""" + + interval = max(0, int(self.metrics_log_interval)) + + if interval == 0: return - # File exists – inspect current header. - needs_header_extension = False - with file_path.open('r', newline='') as existing: - reader = csv.DictReader(existing) - existing_fieldnames = reader.fieldnames or [] - for field in buffered_fieldnames: - if field not in existing_fieldnames: - needs_header_extension = True - break - if needs_header_extension: - existing_rows = list(reader) - else: - existing_rows = None + if self.time_step <= 0: + return - if not needs_header_extension: - with file_path.open('a', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=existing_fieldnames) - for row in rows: - writer.writerow({field: row.get(field, '') for field in existing_fieldnames}) + if self.time_step % interval != 0 and not self.terminated: return - # Need to rewrite with the expanded header. - extended_fieldnames = list( - dict.fromkeys(existing_fieldnames + [f for f in buffered_fieldnames if f not in existing_fieldnames]) + idx = min(self.time_step - 1, len(self.__net_electricity_consumption) - 1) + + if idx < 0: + return + + LOGGER.info( + "Episode %s Step %s/%s | net_kwh=%.5f cost=%.5f co2=%.5f", + self.episode_tracker.episode, + self.time_step, + self.time_steps - 1, + float(self.__net_electricity_consumption[idx]), + float(self.__net_electricity_consumption_cost[idx]), + float(self.__net_electricity_consumption_emission[idx]), ) - existing_rows = existing_rows or [] - for row in existing_rows: - for field in extended_fieldnames: - row.setdefault(field, '') - - with file_path.open('w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=extended_fieldnames) - writer.writeheader() - writer.writerows(existing_rows) - for row in rows: - writer.writerow({field: row.get(field, '') for field in extended_fieldnames}) - - def _parse_render_start_date(self, start_date: Union[str, datetime.date]) -> datetime.date: - """Return a valid start date for rendering timestamps.""" - - if start_date is None: - return self.DEFAULT_RENDER_START_DATE - - if isinstance(start_date, datetime.datetime): - return start_date.date() - - if isinstance(start_date, datetime.date): - return start_date - - if isinstance(start_date, str): - try: - return datetime.date.fromisoformat(start_date) - except ValueError as exc: - raise ValueError( - "CityLearnEnv start_date must be in ISO format 'YYYY-MM-DD'." - ) from exc - - raise TypeError( - "CityLearnEnv start_date must be a date, datetime, or ISO format string." - ) - - def _ensure_render_output_dir(self, *, ensure_exists: bool = True): - """Prepare the render output directory and optionally create it on disk. + def _parse_actions(self, actions: List[List[float]]) -> List[Mapping[str, float]]: + """Compatibility wrapper for runtime action parsing service.""" - Parameters - ---------- - ensure_exists: bool, default: True - When ``True`` the directory tree is created (and legacy exports removed when - reusing :pyattr:`render_session_name`). When ``False`` only internal state is - updated so that paths can be materialized later on demand. - """ - base_render_path = Path(getattr(self, 'render_output_root', Path(__file__).resolve().parents[1] / 'render_logs')).expanduser() - - if ensure_exists: - try: - base_render_path.mkdir(parents=True, exist_ok=True) - except PermissionError: - fallback = (Path.cwd() / 'render_logs').resolve() - fallback.mkdir(parents=True, exist_ok=True) - self.render_output_root = fallback - base_render_path = fallback - - render_dir = getattr(self, '_render_directory_path', None) - needs_new_dir = render_dir is None - - if not needs_new_dir and ensure_exists: - render_dir = Path(render_dir) - try: - needs_new_dir = not render_dir.is_relative_to(base_render_path) - except AttributeError: - needs_new_dir = base_render_path not in render_dir.parents and render_dir != base_render_path - - if needs_new_dir: - if self.render_session_name: - render_dir = (base_render_path / Path(self.render_session_name)).expanduser().resolve() - else: - if getattr(self, '_render_timestamp', None) is None: - self._render_timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - render_dir = (base_render_path / self._render_timestamp).resolve() + return self._runtime_service.parse_actions(actions) - self._render_directory_path = render_dir - else: - render_dir = Path(self._render_directory_path) - - if ensure_exists: - render_dir.mkdir(parents=True, exist_ok=True) - if not self._render_dir_initialized: - if self.render_session_name: - for csv_file in render_dir.glob('exported_*.csv'): - try: - csv_file.unlink() - except OSError: - pass - self._render_dir_initialized = True - - self.new_folder_path = str(render_dir) - - def _get_iso_timestamp(self): - # Reset time tracking if this is the first step of a new episode - if self.time_step == 0: - self._reset_time_tracking() - energy_sim = self.buildings[0].energy_simulation - month_series = getattr(energy_sim, 'month', None) - hour_series = getattr(energy_sim, 'hour', None) - minutes_series = getattr(energy_sim, 'minutes', None) - - def _get_series_value(series, index, default): - if series is None: - return default - if index >= len(series): - return default - try: - return int(series[index]) - except (TypeError, ValueError): - return default - - month = _get_series_value(month_series, self.time_step, self.render_start_date.month) - hour = _get_series_value(hour_series, self.time_step, 1) - minutes = _get_series_value(minutes_series, self.time_step, 0) - - next_index = self.time_step + 1 - next_month = _get_series_value(month_series, next_index, month) - next_hour = _get_series_value(hour_series, next_index, hour) - next_minutes = _get_series_value(minutes_series, next_index, minutes) - - raw_hour = hour - timestamp_year = self.year - timestamp_month = month - timestamp_day = self.current_day - hour_for_timestamp = raw_hour % 24 - next_hour_mod = next_hour % 24 - next_minutes_clamped = max(0, min(59, next_minutes)) - minute_for_timestamp = max(0, min(59, minutes)) - - if raw_hour >= 24: - if next_month != month: - timestamp_month = next_month - - if next_month < month: - timestamp_year = self.year + 1 - timestamp_day = 1 - else: - # Keep the current day; the day roll-over is handled via next_day logic. - timestamp_day = self.current_day + def evaluate(self, control_condition: EvaluationCondition = None, baseline_condition: EvaluationCondition = None, comfort_band: float = None) -> pd.DataFrame: + r"""Evaluate cost functions at current time step.""" - timestamp = f"{timestamp_year:04d}-{int(timestamp_month):02d}-{timestamp_day:02d}T{hour_for_timestamp:02d}:{minute_for_timestamp:02d}:00" + return self._kpi_service.evaluate( + control_condition=control_condition, + baseline_condition=baseline_condition, + comfort_band=comfort_band, + evaluation_condition_cls=EvaluationCondition, + dynamics_building_cls=DynamicsBuilding, + ) - next_year = timestamp_year - next_day = timestamp_day + def next_time_step(self): + r"""Advance all buildings to next `time_step`.""" - if next_month != month: - if next_month < month: - next_year = timestamp_year + 1 - next_day = 1 - elif next_hour_mod <= hour_for_timestamp and next_minutes_clamped <= minute_for_timestamp: - next_day = timestamp_day + 1 + return self._runtime_service.next_time_step() - self.year = next_year - self.current_day = next_day + def associate_chargers_to_electric_vehicles(self): + r"""Associate charger to its corresponding electric_vehicle based on charger simulation state.""" - return timestamp + return self._runtime_service.associate_chargers_to_electric_vehicles() - def _override_render_time_step(self, index: int): - """Temporarily set time_step to `index` for the environment and descendants.""" + def simulate_unconnected_ev_soc(self): + """Simulate SOC changes for EVs that are not under charger control at t+1.""" + + return self._runtime_service.simulate_unconnected_ev_soc() + + def export_final_kpis(self, model: 'Agent' = None, filepath: str = "exported_kpis.csv"): + """Export episode KPIs to csv.""" + + return self._episode_exporter.export_final_kpis(model=model, filepath=filepath) - snapshot = [] + def render(self): + """Render current state of the environment to CSV outputs.""" - def _record(obj): - if hasattr(obj, 'time_step'): - snapshot.append((obj, obj.time_step)) - obj.time_step = index + return self._episode_exporter.render() - _record(self) - for building in getattr(self, 'buildings', []): - _record(building) - electrical_storage = getattr(building, 'electrical_storage', None) - if electrical_storage is not None: - _record(electrical_storage) + def _export_episode_render_data(self, final_index: int): + """Export full episode render rows in one pass for `render_mode='end'`.""" - for charger in getattr(building, 'electric_vehicle_chargers', []) or []: - _record(charger) + return self._episode_exporter.export_episode_render_data(final_index) - for washing_machine in getattr(building, 'washing_machines', []) or []: - _record(washing_machine) + def _save_to_csv(self, filename, data): + """Compatibility wrapper for tests and internal legacy calls.""" + + return self._episode_exporter.save_to_csv(filename, data) + + def _flush_render_buffer(self): + """Write any buffered render rows to disk.""" - for ev in getattr(self, 'electric_vehicles', []): - _record(ev) - battery = getattr(ev, 'battery', None) - if battery is not None: - _record(battery) + return self._episode_exporter.flush_render_buffer() - return snapshot + def _write_render_rows(self, filename: str, rows: List[Mapping[str, Any]]): + """Compatibility wrapper for tests and internal legacy calls.""" + + return self._episode_exporter.write_render_rows(filename, rows) + + def _parse_render_start_date(self, start_date: Union[str, datetime.date]) -> datetime.date: + """Return a valid start date for rendering timestamps.""" + + return EpisodeExporter.parse_render_start_date(start_date) + + def _ensure_render_output_dir(self, *, ensure_exists: bool = True): + """Prepare the render output directory and optionally create it on disk.""" + + return self._episode_exporter.ensure_output_dir(ensure_exists=ensure_exists) + + def _get_iso_timestamp(self): + return self._episode_exporter.get_iso_timestamp() + + def _override_render_time_step(self, index: int): + return self._episode_exporter.override_render_time_step(index) @staticmethod def _restore_render_time_step(snapshot): - for obj, value in snapshot: - try: - obj.time_step = value - except AttributeError: - pass - - def _reset_time_tracking(self): - """Reset all time tracking variables.""" - start_offset = getattr(self.episode_tracker, 'episode_start_time_step', 0) - base_datetime = datetime.datetime.combine(self.render_start_date, datetime.time()) - base_datetime += datetime.timedelta(seconds=start_offset * self.seconds_per_time_step) - self._render_start_datetime = base_datetime - self.year = base_datetime.year - self.current_day = base_datetime.day - # Add any other time-related variables that need resetting - - - def reset(self, seed: int = None, options: Mapping[str, Any] = None) -> Tuple[List[List[float]], dict]: - r"""Reset `CityLearnEnv` to initial state. - - Parameters - ---------- - seed: int, optional - Use to updated :code:`citylearn.CityLearnEnv.random_seed` if value is provided. - options: Mapping[str, Any], optional - Use to pass additional data to environment on reset. Not used in this base class - but included to conform to gymnasium interface. - - Returns - ------- - observations: List[List[float]] - :attr:`observations`. - info: dict - A dictionary that may contain additional information regarding the reason for a `terminated` signal. - `info` contains auxiliary diagnostic information (helpful for debugging, learning, and logging). - Override :meth"`get_info` to get custom key-value pairs in `info`. - """ - + return EpisodeExporter.restore_render_time_step(snapshot) + + def _reset_time_tracking(self): + return self._episode_exporter.reset_time_tracking() + + def reset(self, seed: int = None, options: Mapping[str, Any] = None) -> Tuple[List[List[float]], dict]: + r"""Reset `CityLearnEnv` to initial state. + + Parameters + ---------- + seed: int, optional + Use to updated :code:`citylearn.CityLearnEnv.random_seed` if value is provided. + options: Mapping[str, Any], optional + Use to pass additional data to environment on reset. Not used in this base class + but included to conform to gymnasium interface. + + Returns + ------- + observations: List[List[float]] + :attr:`observations`. + info: dict + A dictionary that may contain additional information regarding the reason for a `terminated` signal. + `info` contains auxiliary diagnostic information (helpful for debugging, learning, and logging). + Override :meth"`get_info` to get custom key-value pairs in `info`. + """ + # object reset super().reset() self._final_kpis_exported = False - - # update seed - if seed is not None: - self.random_seed = seed - else: - pass - - # update time steps for time series - self.episode_tracker.next_episode( - self.episode_time_steps, - self.rolling_episode_split, - self.random_episode_split, - self.random_seed, - ) - - for building in self.buildings: - building.reset() - - for ev in self.electric_vehicles: - ev.reset() - - self.associate_chargers_to_electric_vehicles() - - # reset reward function (does nothing by default) - self.reward_function.reset() - - # variable reset - self.__rewards = [[]] - self.__net_electricity_consumption = [] - self.__net_electricity_consumption_cost = [] - self.__net_electricity_consumption_emission = [] - self.update_variables() - - return self.observations, self.get_info() - - def update_variables(self): - for b in self.buildings: - b.update_variables() - - # Helper to set or append district-level aggregates for current timestep - def _set_or_append(lst, value): - # If list length matches current index => append - if len(lst) == self.time_step: - lst.append(value) - # If already has an entry for current timestep => overwrite - elif len(lst) == self.time_step + 1: - lst[self.time_step] = value - else: - # Out-of-sync: resize to current index and append - del lst[self.time_step + 1:] - if len(lst) < self.time_step: - # pad if needed - lst.extend([0.0] * (self.time_step - len(lst))) - lst.append(value) - - # net electricity consumption - total = sum(b.net_electricity_consumption[self.time_step] for b in self.buildings) - _set_or_append(self.__net_electricity_consumption, total) - - # net electricity consumption cost - total_cost = sum(b.net_electricity_consumption_cost[self.time_step] for b in self.buildings) - _set_or_append(self.__net_electricity_consumption_cost, total_cost) - - # net electricity consumption emission - total_emission = sum(b.net_electricity_consumption_emission[self.time_step] for b in self.buildings) - _set_or_append(self.__net_electricity_consumption_emission, total_emission) - - def load_agent(self, agent: Union[str, 'citylearn.agents.base.Agent'] = None, **kwargs) -> Union[Any, 'citylearn.agents.base.Agent']: - """Return :class:`Agent` or sub class object as defined by the `schema`. - - Parameters - ---------- - agent: Union[str, 'citylearn.agents.base.Agent], optional - Agent class or string describing path to agent class, e.g. 'citylearn.agents.base.BaselineAgent'. - If a value is not provided, defaults to the agent defined in the schema:agent:type. - - **kwargs : dict - Agent initialization attributes. For most agents e.g. CityLearn and Stable-Baselines3 agents, - an intialized :py:attr:`env` must be parsed to the agent :py:meth:`init` function. - - Returns - ------- - agent: Agent - Initialized agent. - """ - - # set agent class - if agent is not None: - agent_type = agent - - if not isinstance(agent_type, str): - agent_type = [agent_type.__module__] + [agent_type.__name__] - agent_type = '.'.join(agent_type) - - else: - pass - - # set agent init attributes - else: - agent_type = self.schema['agent']['type'] - + + # update seed + if seed is not None: + self.random_seed = seed + else: + pass + + # update time steps for time series + self.episode_tracker.next_episode( + self.episode_time_steps, + self.rolling_episode_split, + self.random_episode_split, + self.random_seed, + ) + + for building in self.buildings: + building.reset() + + for ev in self.electric_vehicles: + ev.reset() + + self.associate_chargers_to_electric_vehicles() + + # reset reward function (does nothing by default) + self.reward_function.reset() + + # variable reset + self.__rewards = [[]] + self.__net_electricity_consumption = [] + self.__net_electricity_consumption_cost = [] + self.__net_electricity_consumption_emission = [] + self._last_community_market_settlement = [] + self._community_market_settlement_history = [] + self._observations_cache = None + self._observations_cache_time_step = -1 + episode_index = int(getattr(self.episode_tracker, 'episode', 0)) + self._ev_drift_random_state = np.random.RandomState(int(self.random_seed) + episode_index) + self._render_buffer.clear() + self._refresh_action_cache() + self.update_variables() + + return self.observations, self.get_info() + + def _configure_community_market(self): + config = {} + if isinstance(self.schema, dict): + config = self.schema.get('community_market', {}) or {} + + self.community_market_enabled = parse_bool( + config.get('enabled', False), + default=False, + path='community_market.enabled', + ) + ratio = config.get('intra_community_sell_ratio', 0.8) + + try: + ratio = float(ratio) + except (TypeError, ValueError): + ratio = 0.8 + + self.community_market_sell_ratio = min(max(ratio, 0.0), 1.0) + self.community_market_grid_export_price = config.get('grid_export_price', 0.0) + + def update_variables(self): + """Update district-level aggregate variables.""" + + return self._runtime_service.update_variables() + + def load_agent(self, agent: Union[str, 'Agent'] = None, **kwargs) -> Union[Any, 'Agent']: + """Return :class:`Agent` or sub class object as defined by the `schema`. + + Parameters + ---------- + agent: Union[str, 'citylearn.agents.base.Agent], optional + Agent class or string describing path to agent class, e.g. 'citylearn.agents.base.BaselineAgent'. + If a value is not provided, defaults to the agent defined in the schema:agent:type. + + **kwargs : dict + Agent initialization attributes. For most agents e.g. CityLearn and Stable-Baselines3 agents, + an intialized :py:attr:`env` must be parsed to the agent :py:meth:`init` function. + + Returns + ------- + agent: Agent + Initialized agent. + """ + + # set agent class + if agent is not None: + agent_type = agent + + if not isinstance(agent_type, str): + agent_type = [agent_type.__module__] + [agent_type.__name__] + agent_type = '.'.join(agent_type) + + else: + pass + + # set agent init attributes + else: + agent_type = self.schema['agent']['type'] + if kwargs is not None and len(kwargs) > 0: agent_attributes = dict(kwargs) @@ -2003,718 +1304,123 @@ def load_agent(self, agent: Union[str, 'citylearn.agents.base.Agent'] = None, ** agent = agent_constructor(**agent_attributes) return agent - - def _load(self, schema: Mapping[str, Any], **kwargs) -> Tuple[Union[Path, str], List[Building], List[ElectricVehicle], Union[int, List[Tuple[int, int]]], bool, bool, float, RewardFunction, bool, List[str], EpisodeTracker]: - """Return `CityLearnEnv` and `Controller` objects as defined by the `schema`. - - Parameters - ---------- - schema: Mapping[str, Any] - N:code:`dict` object of a CityLearn schema. - - Returns - ------- - root_directory: Union[Path, str] - Absolute path to directory that contains the data files including the schema. - buildings : List[Building] - Buildings in CityLearn environment. - electric_vehicles : List[ElectricVehicle] - Electric Vehicles in CityLearn environment. - episode_time_steps: Union[int, List[Tuple[int, int]]] - Number of time steps in an episode. Defaults to (`simulation_end_time_step` - `simulation_start_time_step`) + 1. - rolling_episode_split: bool - True if episode sequences are split such that each time step is a candidate for `episode_start_time_step` otherwise, False to split episodes - in steps of `episode_time_steps`. - random_episode_split: bool - True if episode splits are to be selected at random during training otherwise, False to select sequentially. - seconds_per_time_step: float - Number of seconds in 1 `time_step` and must be set to >= 1. - reward_function : RewardFunction - Reward function class instance. - central_agent : bool - Expect 1 central agent to control all building storage device. - shared_observations : List[str] - Names of common observations across all buildings i.e. observations that have the same value irrespective of the building. - """ - - schema['root_directory'] = kwargs['root_directory'] if kwargs.get('root_directory') is not None else schema['root_directory'] - schema['random_seed'] = schema.get('random_seed', None) if kwargs.get('random_seed', None) is None else schema.get('random_seed', None) - schema['central_agent'] = kwargs['central_agent'] if kwargs.get('central_agent') is not None else schema['central_agent'] - - #Separated chargers observations to create one for each charger at each building based on active ones at the schema - schema['chargers_observations_helper'] = {key: value for key, value in schema["observations"].items() if "electric_vehicle_" in key} - schema['chargers_actions_helper'] = {key: value for key, value in schema["actions"].items() if "electric_vehicle_" in key} - schema['chargers_shared_observations_helper'] = {key: value for key, value in schema["observations"].items() - if "electric_vehicle_" in key and value.get("shared_in_central_agent", True)} - - schema['washing_machine_observations_helper'] = {key: value for key, value in schema["observations"].items() if "washing_machine_" in key} - schema['washing_machine_actions_helper'] = {key: value for key, value in schema["actions"].items() if "washing_machine" in key} - - - - schema['observations'] = { - key: value - for key, value in schema["observations"].items() - if key not in set(schema['chargers_observations_helper']) | set(schema['washing_machine_observations_helper']) - } - schema['actions'] = { - key: value - for key, value in schema['actions'].items() - if key not in set(schema['chargers_actions_helper']) | set(schema['washing_machine_actions_helper']) - } - - # Update shared observations, excluding any keys that start with 'electric_vehicle_' - schema['shared_observations'] = ( - kwargs['shared_observations'] if kwargs.get('shared_observations') is not None else [ - k for k, v in schema['observations'].items() - if not k.startswith("electric_vehicle_") - and "washing_machine" not in k - and v.get('shared_in_central_agent', False) - ] - ) - - - schema['episode_time_steps'] = kwargs['episode_time_steps'] if kwargs.get('episode_time_steps') is not None else schema.get('episode_time_steps', None) - schema['rolling_episode_split'] = kwargs['rolling_episode_split'] if kwargs.get('rolling_episode_split') is not None else schema.get('rolling_episode_split', None) - schema['random_episode_split'] = kwargs['random_episode_split'] if kwargs.get('random_episode_split') is not None else schema.get('random_episode_split', None) - schema['seconds_per_time_step'] = kwargs['seconds_per_time_step'] if kwargs.get('seconds_per_time_step') is not None else schema['seconds_per_time_step'] - - schema['simulation_start_time_step'] = kwargs['simulation_start_time_step'] \ - if kwargs.get('simulation_start_time_step') is not None else schema['simulation_start_time_step'] - schema['simulation_end_time_step'] = kwargs['simulation_end_time_step'] \ - if kwargs.get('simulation_end_time_step') is not None else schema['simulation_end_time_step'] - episode_tracker = EpisodeTracker(schema['simulation_start_time_step'], schema['simulation_end_time_step']) - - # get sizing data to reduce read time - dataset = DataSet() - pv_sizing_data = dataset.get_pv_sizing_data() - battery_sizing_data = dataset.get_battery_sizing_data() - - # get buildings to include - buildings_to_include = list(schema['buildings'].keys()) - buildings = [] - - if kwargs.get('buildings') is not None and len(kwargs['buildings']) > 0: - if isinstance(kwargs['buildings'][0], Building): - buildings: List[Building] = kwargs['buildings'] - - for b in buildings: - b.episode_tracker = episode_tracker - - buildings_to_include = [] - - elif isinstance(kwargs['buildings'][0], str): - buildings_to_include = [b for b in buildings_to_include if b in kwargs['buildings']] - - elif isinstance(kwargs['buildings'][0], int): - buildings_to_include = [buildings_to_include[i] for i in kwargs['buildings']] - - else: - raise Exception('Unknown buildings type. Allowed types are citylearn.building.Building, int and str.') - - else: - buildings_to_include = [b for b in buildings_to_include if schema['buildings'][b]['include']] - - # load buildings - for i, building_name in enumerate(buildings_to_include): - buildings.append(self._load_building(i, building_name, schema, episode_tracker, pv_sizing_data, battery_sizing_data,**kwargs)) - - # Load electric vehicles (if present in the schema) - electric_vehicles = [] - if kwargs.get('electric_vehicles_def') is not None and len(kwargs['electric_vehicles_def']) > 0: - electric_vehicle_schemas = kwargs['electric_vehicles_def'] - else: - electric_vehicle_schemas = schema.get('electric_vehicles_def', {}) - - for electric_vehicle_name, electric_vehicle_schema in electric_vehicle_schemas.items(): - if electric_vehicle_schema['include']: - time_step_ratio = buildings[0].time_step_ratio if len(buildings) > 0 else 1.0 - electric_vehicles.append(self._load_electric_vehicle(electric_vehicle_name,schema,electric_vehicle_schema,episode_tracker, time_step_ratio)) - - # set reward function - - # Extract reward configuration from schema - reward_schema = schema['reward_function'] - reward_type = reward_schema['type'] - reward_attrs = reward_schema.get('attributes', {}) - - # Determine if it's a multi-building configuration (i.e., a mapping from building names to reward types) - is_multi = isinstance(reward_type, dict) - - if is_multi: - # Fallback to 'default' reward type if one isn't specified per building - default_type = reward_type.get('default') - if default_type is None and reward_type: - default_type = next(iter(reward_type.values())) # Use the first available type if 'default' not set - - # Same fallback logic for attributes - default_attrs = reward_attrs.get('default') - if default_attrs is None and reward_attrs: - default_attrs = next(iter(reward_attrs.values())) - - reward_functions = {} - for building in buildings: - name = building.name - # Use building-specific reward type or fallback to default - r_type = reward_type.get(name, default_type) - r_attr = reward_attrs.get(name, default_attrs) or {} # Ensure it's a dict, not None - - if r_type is None: - raise ValueError(f"No reward function defined for building '{name}' and no default provided") - - # Dynamically load class from dotted path string - module_name = '.'.join(r_type.split('.')[:-1]) - class_name = r_type.split('.')[-1] - module = importlib.import_module(module_name) - constructor = getattr(module, class_name) - - # Instantiate reward function for this building - reward_functions[name] = constructor(None, **r_attr) - - # Combine individual building reward functions into a multi-building one - reward_function = MultiBuildingRewardFunction(None, reward_functions) - - else: - # Handle the single reward function case - if 'reward_function' in kwargs and kwargs['reward_function'] is not None: - reward_function_type = kwargs['reward_function'] - # If a class is passed instead of a string, convert to dotted path - if not isinstance(reward_function_type, str): - reward_function_type = f"{reward_function_type.__module__}.{reward_function_type.__name__}" - else: - reward_function_type = reward_type # Use type from schema - - # Get attributes from kwargs or schema, default to empty dict - reward_function_attributes = kwargs.get('reward_function_kwargs') or reward_attrs or {} - - # Dynamically load class from dotted path string - module_name = '.'.join(reward_function_type.split('.')[:-1]) - class_name = reward_function_type.split('.')[-1] - module = importlib.import_module(module_name) - constructor = getattr(module, class_name) - - # Instantiate the single reward function - reward_function = constructor(None, **reward_function_attributes) - - return ( - schema['root_directory'], buildings, electric_vehicles, schema['episode_time_steps'], schema['rolling_episode_split'], - schema['random_episode_split'], - schema['seconds_per_time_step'], reward_function, schema['central_agent'], schema['shared_observations'], - episode_tracker - ) - - def _load_building(self, index: int, building_name: str, schema: dict, episode_tracker: EpisodeTracker, pv_sizing_data: pd.DataFrame, battery_sizing_data: pd.DataFrame, **kwargs) -> Building: - """Initializes and returns a building model.""" - - building_schema = schema['buildings'][building_name] - building_kwargs = {} - if building_schema.get('charging_constraints') is not None: - building_kwargs['charging_constraints'] = building_schema['charging_constraints'] - seconds_per_time_step = schema['seconds_per_time_step'] - noise_std = building_schema.get('noise_std', 0.0) - - # data - energy_simulation = pd.read_csv(os.path.join(schema['root_directory'], building_schema['energy_simulation'])) - energy_simulation = EnergySimulation(**energy_simulation.to_dict('list'), seconds_per_time_step=seconds_per_time_step, noise_std=noise_std) - building_kwargs['time_step_ratio'] = energy_simulation.time_step_ratios[index] - weather = pd.read_csv(os.path.join(schema['root_directory'], building_schema['weather'])) - weather = Weather(**weather.to_dict('list'), noise_std=noise_std) - - if building_schema.get('carbon_intensity', None) is not None: - carbon_intensity = pd.read_csv(os.path.join(schema['root_directory'], building_schema['carbon_intensity'])) - carbon_intensity = CarbonIntensity(**carbon_intensity.to_dict('list'), noise_std=noise_std) - - else: - carbon_intensity = CarbonIntensity(np.zeros(energy_simulation.hour.shape[0], dtype='float32'), noise_std=noise_std) - - if building_schema.get('pricing', None) is not None: - pricing = pd.read_csv(os.path.join(schema['root_directory'], building_schema['pricing'])) - pricing = Pricing(**pricing.to_dict('list'), noise_std=noise_std) - - else: - pricing = Pricing( - np.zeros(energy_simulation.hour.shape[0], dtype='float32'), - np.zeros(energy_simulation.hour.shape[0], dtype='float32'), - np.zeros(energy_simulation.hour.shape[0], dtype='float32'), - np.zeros(energy_simulation.hour.shape[0], dtype='float32'), - noise_std=noise_std - ) - - # construct building - building_type = 'citylearn.citylearn.Building' if building_schema.get('type', None) is None else building_schema['type'] - building_type_module = '.'.join(building_type.split('.')[0:-1]) - building_type_name = building_type.split('.')[-1] - building_constructor = getattr(importlib.import_module(building_type_module), building_type_name) - - # set dynamics - if building_schema.get('dynamics', None) is not None: - dynamics_type = building_schema['dynamics']['type'] - dynamics_module = '.'.join(dynamics_type.split('.')[0:-1]) - dynamics_name = dynamics_type.split('.')[-1] - dynamics_constructor = getattr(importlib.import_module(dynamics_module), dynamics_name) - attributes = building_schema['dynamics'].get('attributes', {}) - attributes['filepath'] = os.path.join(schema['root_directory'], attributes['filename']) - _ = attributes.pop('filename') - building_kwargs[f'dynamics'] = dynamics_constructor(**attributes) - - else: - building_kwargs['dynamics'] = None - - # set occupant - if building_schema.get('occupant', None) is not None: - building_occupant = building_schema['occupant'] - occupant_type = building_occupant['type'] - occupant_module = '.'.join(occupant_type.split('.')[0:-1]) - occupant_name = occupant_type.split('.')[-1] - occupant_constructor = getattr(importlib.import_module(occupant_module), occupant_name) - attributes: dict = building_occupant.get('attributes', {}) - parameters_filepath = os.path.join(schema['root_directory'], building_occupant['parameters_filename']) - parameters = pd.read_csv(parameters_filepath) - attributes['parameters'] = LogisticRegressionOccupantParameters(**parameters.to_dict('list')) - attributes['episode_tracker'] = episode_tracker - attributes['random_seed'] = schema['random_seed'] - - for k in ['increase', 'decrease']: - attributes[f'setpoint_{k}_model_filepath'] = os.path.join(schema['root_directory'], attributes[f'setpoint_{k}_model_filename']) - _ = attributes.pop(f'setpoint_{k}_model_filename') - - building_kwargs['occupant'] = occupant_constructor(**attributes) - - else: - building_kwargs['occupant'] = None - - # set power outage model - building_schema_power_outage = building_schema.get('power_outage', {}) - simulate_power_outage = kwargs.get('simulate_power_outage') - simulate_power_outage = building_schema_power_outage.get('simulate_power_outage') if simulate_power_outage is None else simulate_power_outage - simulate_power_outage = simulate_power_outage[index] if isinstance(simulate_power_outage,list) else simulate_power_outage - stochastic_power_outage = building_schema_power_outage.get('stochastic_power_outage') - - if building_schema_power_outage.get('stochastic_power_outage_model', None) is not None: - stochastic_power_outage_model_type = building_schema_power_outage['stochastic_power_outage_model']['type'] - stochastic_power_outage_model_module = '.'.join(stochastic_power_outage_model_type.split('.')[0:-1]) - stochastic_power_outage_model_name = stochastic_power_outage_model_type.split('.')[-1] - stochastic_power_outage_model_constructor = getattr( - importlib.import_module(stochastic_power_outage_model_module), - stochastic_power_outage_model_name - ) - attributes = building_schema_power_outage.get('stochastic_power_outage_model', {}).get('attributes', {}) - stochastic_power_outage_model = stochastic_power_outage_model_constructor(**attributes) - - else: - stochastic_power_outage_model = None - - - - # ------------------ Chargers ------------------ - - # Initialize chargers list - chargers_list = [] - #Adding chargers to buildings if they exist - if building_schema.get("chargers", None) is not None: - for charger_name, charger_config in building_schema["chargers"].items(): - - noise_std = charger_config.get('noise_std', 0.0) - - charger_simulation_file = pd.read_csv( - os.path.join(schema['root_directory'], charger_config['charger_simulation']) - ).iloc[schema['simulation_start_time_step']:schema['simulation_end_time_step'] + 1].copy() - - charger_simulation = ChargerSimulation(*charger_simulation_file.values.T, noise_std=noise_std) - - charger_type = charger_config['type'] - charger_module = '.'.join(charger_type.split('.')[0:-1]) - charger_class_name = charger_type.split('.')[-1] - charger_class = getattr(importlib.import_module(charger_module), charger_class_name) - charger_attributes = charger_config.get('attributes', {}) - charger_attributes['episode_tracker'] = episode_tracker - charger_object = charger_class(charger_simulation=charger_simulation, charger_id=charger_name, **charger_attributes, seconds_per_time_step=schema['seconds_per_time_step'], time_step_ratio = building_kwargs['time_step_ratio']) - chargers_list.append(charger_object) - - washing_machines_list = [] - # Adding washing machines to buildings if they exist - if kwargs.get('washing_machines') is not None and len(kwargs['washing_machines']) > 0: - washing_machine_schemas = kwargs['washing_machines'] - else: - washing_machine_schemas = building_schema.get('washing_machines', {}) - - for washing_machine_name, washing_machine_schema in washing_machine_schemas.items(): - washing_machines_list.append(self._load_washing_machine(washing_machine_name,schema,washing_machine_schema,episode_tracker)) - - observation_metadata, action_metadata = self.process_metadata(schema, building_schema, chargers_list, washing_machines_list, index, energy_simulation,**kwargs) - - - building: Building = building_constructor( - energy_simulation=energy_simulation, - washing_machines = washing_machines_list, - electric_vehicle_chargers=chargers_list, - weather=weather, - observation_metadata=observation_metadata, - action_metadata=action_metadata, - carbon_intensity=carbon_intensity, - pricing=pricing, - name=building_name, - seconds_per_time_step=schema['seconds_per_time_step'], - random_seed=schema['random_seed'], - episode_tracker=episode_tracker, - simulate_power_outage=simulate_power_outage, - stochastic_power_outage=stochastic_power_outage, - stochastic_power_outage_model=stochastic_power_outage_model, - **building_kwargs, - ) - - # update devices - device_metadata = { - 'cooling_device': {'autosizer': building.autosize_cooling_device}, - 'heating_device': {'autosizer': building.autosize_heating_device}, - 'dhw_device': {'autosizer': building.autosize_dhw_device}, - 'dhw_storage': {'autosizer': building.autosize_dhw_storage}, - 'cooling_storage': {'autosizer': building.autosize_cooling_storage}, - 'heating_storage': {'autosizer': building.autosize_heating_storage}, - 'electrical_storage': {'autosizer': building.autosize_electrical_storage}, - 'washing_machine': {'autosizer': building.autosize_electrical_storage}, - 'pv': {'autosizer': building.autosize_pv}, - - } - solar_generation = kwargs.get('solar_generation') - solar_generation = True if solar_generation is None else solar_generation - solar_generation = solar_generation[index] if isinstance(solar_generation, list) else solar_generation - - for device_name in device_metadata: - if building_schema.get(device_name, None) is None: - device = None - - elif device_name == 'pv' and not solar_generation: - device = None - - else: - device_type: str = building_schema[device_name]['type'] - device_module = '.'.join(device_type.split('.')[0:-1]) - device_type_name = device_type.split('.')[-1] - constructor = getattr(importlib.import_module(device_module), device_type_name) - attributes = building_schema[device_name].get('attributes', {}) - attributes['seconds_per_time_step'] = schema['seconds_per_time_step'] - - # in case device technical specifications are to be randomly sampled, make sure each device per building has a unique seed - md5 = hashlib.md5() - device_random_seed = 0 - - for string in [building_name, building_type, device_name, device_type]: - md5.update(string.encode()) - hash_to_integer_base = 16 - device_random_seed += int(md5.hexdigest(), hash_to_integer_base) - - device_random_seed = int(str(device_random_seed * (schema['random_seed'] + 1))[:9]) - - attributes = { - **attributes, - 'random_seed': attributes['random_seed'] if attributes.get('random_seed', None) is not None else device_random_seed - } - device = constructor(**attributes) - autosize = False if building_schema[device_name].get('autosize', None) is None else building_schema[device_name]['autosize'] - building.__setattr__(device_name, device) - - if autosize: - autosizer = device_metadata[device_name]['autosizer'] - autosize_kwargs = {} if building_schema[device_name].get('autosize_attributes', None) is None else \ - building_schema[device_name]['autosize_attributes'] - - if isinstance(device, PV): - autosize_kwargs['epw_filepath'] = os.path.join(schema['root_directory'], autosize_kwargs['epw_filepath']) - autosize_kwargs['sizing_data'] = pv_sizing_data - - elif isinstance(device, Battery): - autosize_kwargs['sizing_data'] = battery_sizing_data - - else: - pass - - autosizer(**autosize_kwargs) - - else: - pass - - # set back the random seed to to building's random seed - device.random_seed = schema['random_seed'] - - building.observation_space = building.estimate_observation_space() - building.action_space = building.estimate_action_space() - - return building - - def process_metadata(self, schema, building_schema, chargers_list, washing_machines_list, index, energy_simulation: EnergySimulation, **kwargs): - - observation_metadata = {k: v['active'] for k, v in schema['observations'].items()} - # Since minutes is Optional, in case the schema has minutes as observation metadata and some energy simulation building csv doesn't contain minutes, remove it from observation - if 'minutes' in observation_metadata and energy_simulation.minutes is None: - observation_metadata.pop('minutes', None) - - chargers_observations_metadata_helper = {k: v['active'] for k, v in schema['chargers_observations_helper'].items()} - washing_machine_observations_metadata_helper = {k: v['active'] for k, v in schema['washing_machine_observations_helper'].items()} - - if kwargs.get('active_observations') is not None: - active_observations = kwargs['active_observations'] - active_observations = active_observations[index] if isinstance(active_observations[0], - list) else active_observations - # Update observation_metadata, ensuring that electric_vehicle_ observations are excluded - observation_metadata = { - k: True if k in active_observations else False - for k in observation_metadata - } - - # Update chargers_observations_metadata_helper, ensuring only electric_vehicle_ observations are included - chargers_observations_metadata_helper = { - k: True if k in active_observations else False - for k in chargers_observations_metadata_helper - } - washing_machine_observations_metadata_helper = { - k: True if k in active_observations else False - for k in washing_machine_observations_metadata_helper - } - else: - pass - - if kwargs.get('inactive_observations') is not None: - inactive_observations = kwargs['inactive_observations'] - inactive_observations = inactive_observations[index] if isinstance(inactive_observations[0], - list) else inactive_observations - - elif building_schema.get('inactive_observations') is not None: - inactive_observations = building_schema['inactive_observations'] - - else: - inactive_observations = [] - - # Update observation_metadata for inactive observations - observation_metadata = { - k: False if k in inactive_observations else observation_metadata[ - k] - for k in observation_metadata - } - - # Update chargers_observations_metadata_helper for inactive observations - chargers_observations_metadata_helper = { - k: False if k in inactive_observations else - chargers_observations_metadata_helper[k] - for k in chargers_observations_metadata_helper - } - - washing_machine_observations_metadata_helper = { - k: False if k in inactive_observations else - washing_machine_observations_metadata_helper[k] - for k in washing_machine_observations_metadata_helper - } - - # action metadata - action_metadata = {k: v['active'] for k, v in schema['actions'].items()} - chargers_actions_metadata_helper = {k: v['active'] for k, v in schema['chargers_actions_helper'].items()} - washing_machine_actions_metadata_helper = {k: v['active'] for k, v in schema['washing_machine_actions_helper'].items()} - - - if kwargs.get('active_actions') is not None: - active_actions = kwargs['active_actions'] - active_actions = active_actions[index] if isinstance(active_actions[0], list) else active_actions - action_metadata = {k: True if k in active_actions else False for k in action_metadata} - chargers_actions_metadata_helper = {k: True if k in active_actions else False for k in chargers_actions_metadata_helper} - washing_machine_actions_metadata_helper = {k: True if k in active_actions else False for k in washing_machine_actions_metadata_helper} - - else: - pass - - if kwargs.get('inactive_actions') is not None: - inactive_actions = kwargs['inactive_actions'] - inactive_actions = inactive_actions[index] if isinstance(inactive_actions[0], list) else inactive_actions - - elif building_schema.get('inactive_actions') is not None: - inactive_actions = building_schema['inactive_actions'] - - else: - inactive_actions = [] - - action_metadata = {k: False if k in inactive_actions else v for k, v in action_metadata.items()} - chargers_actions_metadata_helper = {k: False if k in inactive_actions else v for k, v in chargers_actions_metadata_helper.items()} - washing_machine_actions_metadata_helper = {k: False if k in inactive_actions else v for k, v in washing_machine_actions_metadata_helper.items()} - - - if len(chargers_list) > 0: - for charger in chargers_list: # If present, iterate each charger - charger_id = charger.charger_id - - #Connected - if chargers_observations_metadata_helper.get("electric_vehicle_charger_connected_state", False): - observation_metadata[f'electric_vehicle_charger_{charger_id}_connected_state'] = True - - if chargers_observations_metadata_helper.get("connected_electric_vehicle_at_charger_departure_time", False): - observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_departure_time'] = True - - if chargers_observations_metadata_helper.get("connected_electric_vehicle_at_charger_required_soc_departure", False): - observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_required_soc_departure'] = True - - if chargers_observations_metadata_helper.get("connected_electric_vehicle_at_charger_soc", False): - observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_soc'] = True - - if chargers_observations_metadata_helper.get("connected_electric_vehicle_at_charger_battery_capacity", False): - observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_battery_capacity'] = True - - #Incoming - if chargers_observations_metadata_helper.get("electric_vehicle_charger_incoming_state", False): - observation_metadata[ - f'electric_vehicle_charger_{charger_id}_incoming_state'] = True # Observations names are composed from the charger unique ID - - if chargers_observations_metadata_helper.get("incoming_electric_vehicle_at_charger_estimated_arrival_time", - False): - observation_metadata[f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_arrival_time'] = True - - if chargers_observations_metadata_helper.get( - "incoming_electric_vehicle_at_charger_estimated_soc_arrival", False): - observation_metadata[ - f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_soc_arrival'] = True - - #Actions - if chargers_actions_metadata_helper.get("electric_vehicle_storage", False): - action_metadata[f'electric_vehicle_storage_{charger.charger_id}'] = True - if len(washing_machines_list) > 0: - for washing_machine in washing_machines_list: # If present, iterate each charger - washing_machine_name = washing_machine.name - if washing_machine_observations_metadata_helper.get("washing_machine_start_time_step", False): - observation_metadata[f'{washing_machine_name}_start_time_step'] = True - - if washing_machine_observations_metadata_helper.get("washing_machine_end_time_step", False): - observation_metadata[f'{washing_machine_name}_end_time_step'] = True - - - if washing_machine_actions_metadata_helper.get("washing_machine", False): - action_metadata[f'{washing_machine_name}'] = True - - return observation_metadata, action_metadata - - - def _load_electric_vehicle(self, electric_vehicle_name: str, schema: dict, electric_vehicle_schema: dict, episode_tracker: EpisodeTracker, time_step_ratio) -> ElectricVehicle: - """Initializes and returns an electric vehicle model.""" - - # Construct the battery object - capacity = electric_vehicle_schema["battery"]["attributes"]["capacity"] - nominal_power = electric_vehicle_schema["battery"]["attributes"]["nominal_power"] - initial_soc = electric_vehicle_schema["battery"]["attributes"].get("initial_soc", random.uniform(0, 1)) - depth_of_discharge = electric_vehicle_schema["battery"]["attributes"].get("depth_of_discharge", 0.10) - - battery = Battery( - capacity=capacity, - nominal_power=nominal_power, - initial_soc=initial_soc, - seconds_per_time_step=schema['seconds_per_time_step'], - time_step_ratio=time_step_ratio, - random_seed=schema['random_seed'], - episode_tracker=episode_tracker, - depth_of_discharge=depth_of_discharge - ) - - # Get the EV constructor - electric_vehicle_type = 'citylearn.citylearn.ElectricVehicle' \ - if electric_vehicle_schema.get('type', None) is None else electric_vehicle_schema['type'] - electric_vehicle_type_module = '.'.join(electric_vehicle_type.split('.')[0:-1]) - electric_vehicle_type_name = electric_vehicle_type.split('.')[-1] - electric_vehicle_constructor = getattr(importlib.import_module(electric_vehicle_type_module), electric_vehicle_type_name) - - # Initialize EV - ev: ElectricVehicle = electric_vehicle_constructor( - battery=battery, - name=electric_vehicle_name, - seconds_per_time_step=schema['seconds_per_time_step'], - random_seed=schema['random_seed'], - episode_tracker=episode_tracker - ) - - return ev - - def _load_washing_machine( - self, - washing_machine_name: str, - schema: dict, - washing_machine_schema: dict, - episode_tracker: EpisodeTracker - ) -> WashingMachine: - """ - Load simulation data and initialize a WashingMachine instance. - - Parameters - ---------- - washing_machine_name : str - Unique identifier for the washing machine. - schema : dict - Global schema containing configuration for simulation, such as time step size and paths. - washing_machine_schema : dict - Sub-schema specific to washing machine setup (e.g., file paths for energy profiles). - episode_tracker : EpisodeTracker - Object that tracks simulation episode and time step data. - - Returns - ------- - WashingMachine - An initialized WashingMachine object using the provided simulation data. - """ - file_path = os.path.join(schema['root_directory'], washing_machine_schema['washing_machine_energy_simulation']) - - # Load CSV file and slice it to the relevant simulation range - washing_machine_simulation = pd.read_csv(file_path).iloc[ - schema['simulation_start_time_step']:schema['simulation_end_time_step'] + 1 - ].copy() - - # Convert DataFrame into a WashingMachineSimulation object - washing_machine_simulation = WashingMachineSimulation(*washing_machine_simulation.values.T) - - # Create and return the WashingMachine object - wm = WashingMachine( - washing_machine_simulation=washing_machine_simulation, - episode_tracker=episode_tracker, - name=washing_machine_name, - seconds_per_time_step=schema['seconds_per_time_step'], - random_seed=schema['random_seed'], - ) - - return wm - - def __str__(self) -> str: - """ - Return a string representation of the current simulation state. - - Useful for logging or quick inspection of internal values. - """ - return str(self.as_dict()) - - def as_dict(self) -> dict: - """ - Convert the current simulation state to a dictionary. - - This includes key performance indicators such as energy usage, emissions, - and electricity pricing at the current time step. - - Returns - ------- - dict - Dictionary with energy and environmental metrics for the current step. - """ + + def _load(self, schema: Mapping[str, Any], **kwargs) -> Tuple[Union[Path, str], List[Building], List[ElectricVehicle], Union[int, List[Tuple[int, int]]], bool, bool, float, RewardFunction, bool, List[str], EpisodeTracker]: + """Compatibility wrapper for schema loading service.""" + + return self._loading_service.load(schema, **kwargs) + + def _load_building(self, index: int, building_name: str, schema: dict, episode_tracker: EpisodeTracker, pv_sizing_data: pd.DataFrame, battery_sizing_data: pd.DataFrame, **kwargs) -> Building: + """Compatibility wrapper for building loading service.""" + + return self._loading_service.load_building( + index, + building_name, + schema, + episode_tracker, + pv_sizing_data, + battery_sizing_data, + **kwargs, + ) + + def process_metadata(self, schema, building_schema, chargers_list, washing_machines_list, index, energy_simulation: EnergySimulation, **kwargs): + """Compatibility wrapper for metadata processing service.""" + + return self._loading_service.process_metadata( + schema, + building_schema, + chargers_list, + washing_machines_list, + index, + energy_simulation, + **kwargs, + ) + + def _load_electric_vehicle(self, electric_vehicle_name: str, schema: dict, electric_vehicle_schema: dict, episode_tracker: EpisodeTracker, time_step_ratio) -> ElectricVehicle: + """Compatibility wrapper for electric vehicle loading service.""" + + return self._loading_service.load_electric_vehicle( + electric_vehicle_name, + schema, + electric_vehicle_schema, + episode_tracker, + time_step_ratio, + ) + + def _load_washing_machine( + self, + washing_machine_name: str, + schema: dict, + washing_machine_schema: dict, + episode_tracker: EpisodeTracker + ) -> WashingMachine: + """Compatibility wrapper for washing machine loading service.""" + + return self._loading_service.load_washing_machine( + washing_machine_name, + schema, + washing_machine_schema, + episode_tracker, + ) + + def __str__(self) -> str: + """ + Return a string representation of the current simulation state. + + Useful for logging or quick inspection of internal values. + """ + return str(self.as_dict()) + + def as_dict(self) -> dict: + """ + Convert the current simulation state to a dictionary. + + This includes key performance indicators such as energy usage, emissions, + and electricity pricing at the current time step. + + Returns + ------- + dict + Dictionary with energy and environmental metrics for the current step. + """ if len(self.net_electricity_consumption) == 0: idx = 0 else: idx = max(0, min(self.time_step, len(self.net_electricity_consumption) - 1)) + def _safe_value(series, index: int) -> float: + return float(series[index]) if 0 <= index < len(series) else 0.0 + + self_consumption = 0.0 + stored_energy = 0.0 + total_solar_generation = 0.0 + + for building in self.buildings: + self_consumption += ( + _safe_value(building.energy_from_electrical_storage, idx) + + _safe_value(building.energy_from_cooling_storage, idx) + + _safe_value(building.energy_from_heating_storage, idx) + + _safe_value(building.energy_from_dhw_storage, idx) + ) + stored_energy += _safe_value(building.energy_to_electrical_storage, idx) + total_solar_generation += _safe_value(building.solar_generation, idx) + return { - "Net Electricity Consumption-kWh": self.net_electricity_consumption[idx], - "Self Consumption-kWh": self.total_self_consumption[idx], - "Stored energy by community- kWh": self.energy_to_electrical_storage[idx], - "Total Solar Generation-kWh": self.solar_generation[idx], - "CO2-kg_co2": self.net_electricity_consumption_emission[idx], - "Price-$": self.net_electricity_consumption_cost[idx], + "Net Electricity Consumption-kWh": _safe_value(self.net_electricity_consumption, idx), + "Self Consumption-kWh": self_consumption, + "Stored energy by community- kWh": stored_energy, + "Total Solar Generation-kWh": total_solar_generation, + "CO2-kg_co2": _safe_value(self.net_electricity_consumption_emission, idx), + "Price-$": _safe_value(self.net_electricity_consumption_cost, idx), } -class Error(Exception): - """Base class for other exceptions.""" - -class UnknownSchemaError(Error): - """Raised when a schema is not a data set name, dict nor filepath.""" - __MESSAGE = 'Unknown schema parsed into constructor. Schema must be name of CityLearn data set,'\ - ' a filepath to JSON representation or `dict` object of a CityLearn schema.'\ - ' Call citylearn.data.DataSet.get_names() for list of available CityLearn data sets.' - - def __init__(self,message=None): - super().__init__(self.__MESSAGE if message is None else message) +class Error(Exception): + """Base class for other exceptions.""" + +class UnknownSchemaError(Error): + """Raised when a schema is not a data set name, dict nor filepath.""" + __MESSAGE = 'Unknown schema parsed into constructor. Schema must be name of CityLearn data set,'\ + ' a filepath to JSON representation or `dict` object of a CityLearn schema.'\ + ' Call citylearn.data.DataSet.get_names() for list of available CityLearn data sets.' + + def __init__(self,message=None): + super().__init__(self.__MESSAGE if message is None else message) diff --git a/citylearn/data.py b/citylearn/data.py index 9630f7fea..2dc36f0b6 100644 --- a/citylearn/data.py +++ b/citylearn/data.py @@ -20,6 +20,9 @@ MISC_DIRECTORY = os.path.join(os.path.dirname(__file__), 'misc') QUERIES_DIRECTORY = os.path.join(MISC_DIRECTORY, 'queries') SETTINGS_FILEPATH = os.path.join(MISC_DIRECTORY, 'settings.yaml') +LOCAL_DATA_MISC_DIRECTORY = os.path.normpath( + os.path.join(os.path.dirname(__file__), '..', 'data', 'misc') +) def get_settings(): directory = os.path.join(os.path.join(os.path.dirname(__file__), 'misc')) @@ -31,9 +34,9 @@ def get_settings(): class DataSet: """CityLearn input data set and schema class.""" - GITHUB_ACCOUNT = 'intelligent-environments-lab' - REPOSITORY_NAME = 'CityLearn' - REPOSITORY_TAG = f'v{__version__}' + GITHUB_ACCOUNT = os.getenv('CITYLEARN_DATASET_GITHUB_ACCOUNT', 'intelligent-environments-lab') + REPOSITORY_NAME = os.getenv('CITYLEARN_DATASET_REPOSITORY', 'CityLearn') + REPOSITORY_TAG = os.getenv('CITYLEARN_DATASET_TAG', f'v{__version__}') REPOSITORY_DATA_PATH = FileHandler.join_url('data') REPOSITORY_DATA_DATASETS_PATH = FileHandler.join_url(REPOSITORY_DATA_PATH, 'datasets') REPOSITORY_DATA_MISC_PATH = FileHandler.join_url(REPOSITORY_DATA_PATH, 'misc') @@ -201,6 +204,13 @@ def get_pv_sizing_data(self) -> pd.DataFrame: filepath = os.path.join(misc_directory, self.PV_CHOICES_FILENAME) path = FileHandler.join_url(self.misc_path) + # Prefer local repository data to avoid unnecessary network usage. + if not os.path.isfile(filepath): + local_filepath = os.path.join(LOCAL_DATA_MISC_DIRECTORY, self.PV_CHOICES_FILENAME) + + if os.path.isfile(local_filepath): + shutil.copy(local_filepath, filepath) + # check that file DNE if not os.path.isfile(filepath): LOGGER.info(f'The PV sizing data DNE in cache. Will download from ' @@ -234,6 +244,13 @@ def get_battery_sizing_data(self) -> Mapping[str, Union[float, str]]: filepath = os.path.join(misc_directory, self.BATTERY_CHOICES_FILENAME) path = FileHandler.join_url(self.misc_path) + # Prefer local repository data to avoid unnecessary network usage. + if not os.path.isfile(filepath): + local_filepath = os.path.join(LOCAL_DATA_MISC_DIRECTORY, self.BATTERY_CHOICES_FILENAME) + + if os.path.isfile(local_filepath): + shutil.copy(local_filepath, filepath) + # check that file DNE if not os.path.isfile(filepath): LOGGER.info(f'The battery sizing data DNE in cache. Will download from ' @@ -310,6 +327,35 @@ def __init__(self, variable: Iterable = None, start_time_step: int = None, end_t self.start_time_step = start_time_step self.end_time_step = end_time_step + @staticmethod + def _slice_variable(variable: Any, start_time_step: int, end_time_step: int): + if isinstance(variable, np.ndarray): + start_index = 0 if start_time_step is None else start_time_step + end_index = variable.shape[0] if end_time_step is None else end_time_step + 1 + return variable[start_index:end_index] + + if isinstance(variable, (list, tuple)): + start_index = 0 if start_time_step is None else start_time_step + end_index = len(variable) if end_time_step is None else end_time_step + 1 + return variable[start_index:end_index] + + return variable + + def __getattribute__(self, name: str): + if name.startswith('__'): + return object.__getattribute__(self, name) + + data = object.__getattribute__(self, '__dict__') + variable_name = f'_{name}' + + if variable_name in data: + start_time_step = data.get('_start_time_step') + end_time_step = data.get('_end_time_step') + variable = data[variable_name] + return self._slice_variable(variable, start_time_step, end_time_step) + + return object.__getattribute__(self, name) + def __getattr__(self, name: str, start_time_step: int = None, end_time_step: int = None): """Returns values of the named variable within the specified time steps and is useful for selecting episode-specific observation.""" @@ -319,16 +365,9 @@ def __getattr__(self, name: str, start_time_step: int = None, end_time_step: int variable = self.__dict__[f'_{name}'] except KeyError: raise AttributeError(f'_{name}') - - if isinstance(variable, Iterable): - start_time_step = self.start_time_step if start_time_step is None else start_time_step - start_index = 0 if start_time_step is None else start_time_step - end_time_step = self.end_time_step if end_time_step is None else end_time_step - end_index = len(variable) if end_time_step is None else end_time_step + 1 - return variable[start_index:end_index] - - else: - return variable + start_time_step = self.start_time_step if start_time_step is None else start_time_step + end_time_step = self.end_time_step if end_time_step is None else end_time_step + return self._slice_variable(variable, start_time_step, end_time_step) def __setattr__(self, name: str, value: Any): """Sets named variable. @@ -400,7 +439,7 @@ def __init__( self, month: Iterable[int], hour: Iterable[int], day_type: Iterable[int], indoor_dry_bulb_temperature: Iterable[float], non_shiftable_load: Iterable[float], dhw_demand: Iterable[float], cooling_demand: Iterable[float], heating_demand: Iterable[float], solar_generation: Iterable[float], - daylight_savings_status: Iterable[int] = None, average_unmet_cooling_setpoint_difference: Iterable[float] = None, indoor_relative_humidity: Iterable[float] = None, occupant_count: Iterable[int] = None, indoor_dry_bulb_temperature_cooling_set_point: Iterable[int] = None, indoor_dry_bulb_temperature_heating_set_point: Iterable[int] = None, hvac_mode: Iterable[int] = None, power_outage: Iterable[int] = None, comfort_band: Iterable[float] = None, start_time_step: int = None, end_time_step: int = None, seconds_per_time_step: int = None, minutes: Iterable[int] = None, time_step_ratios: list[int]= [], noise_std = 0.0 + daylight_savings_status: Iterable[int] = None, average_unmet_cooling_setpoint_difference: Iterable[float] = None, indoor_relative_humidity: Iterable[float] = None, occupant_count: Iterable[int] = None, indoor_dry_bulb_temperature_cooling_set_point: Iterable[int] = None, indoor_dry_bulb_temperature_heating_set_point: Iterable[int] = None, hvac_mode: Iterable[int] = None, power_outage: Iterable[int] = None, comfort_band: Iterable[float] = None, start_time_step: int = None, end_time_step: int = None, seconds_per_time_step: int = None, minutes: Iterable[int] = None, time_step_ratios: List[float] = None, noise_std = 0.0 ): super().__init__(start_time_step=start_time_step, end_time_step=end_time_step) self.noise_std = noise_std @@ -451,8 +490,9 @@ def __init__( if seconds_per_time_step and base_step_seconds else None ) - time_step_ratios.append(time_step_ratio) - self.time_step_ratios = time_step_ratios # Store the ratio for this building + ratios = [] if time_step_ratios is None else list(time_step_ratios) + ratios.append(time_step_ratio) + self.time_step_ratios = ratios self.noise_std = noise_std diff --git a/citylearn/dynamics.py b/citylearn/dynamics.py index ebc6072d0..3bbc930ca 100644 --- a/citylearn/dynamics.py +++ b/citylearn/dynamics.py @@ -1,7 +1,17 @@ +from __future__ import annotations + from pathlib import Path from typing import List, Union -import torch -import torch.nn +try: + import torch + import torch.nn +except ImportError: # pragma: no cover - optional dependency for LSTM dynamics only + torch = None + + +class _TorchNNPlaceholder: + class Module: + pass class Dynamics: """Base building dynamics model.""" @@ -12,7 +22,7 @@ def __init__(self): def reset(self): pass -class LSTMDynamics(Dynamics, torch.nn.Module): +class LSTMDynamics(Dynamics, (torch.nn if torch is not None else _TorchNNPlaceholder).Module): """LSTM building dynamics model that predicts indoor temperature based on partial cooling/heating load and other weather variables. Parameters @@ -47,6 +57,9 @@ def __init__( input_normalization_maximum: List[float], hidden_size: int, num_layers: int, lookback: int, input_size: int = None, dropout: float = None ): + if torch is None: + raise ImportError('torch is required to use LSTMDynamics.') + Dynamics.__init__(self) torch.nn.Module.__init__(self) assert len(input_observation_names) == len(input_normalization_minimum) == len(input_normalization_maximum),\ @@ -127,4 +140,4 @@ def reset(self): self._model_input = [[None]*(self.lookback + 1) for _ in self.input_observation_names] def terminate(self): - return \ No newline at end of file + return diff --git a/citylearn/electric_vehicle_charger.py b/citylearn/electric_vehicle_charger.py index 0f1195683..a3a075287 100644 --- a/citylearn/electric_vehicle_charger.py +++ b/citylearn/electric_vehicle_charger.py @@ -11,7 +11,8 @@ class Charger(Environment): def __init__( self, episode_tracker: EpisodeTracker, charger_simulation: ChargerSimulation ,charger_id: str = None, efficiency: float = None, max_charging_power: float = None, min_charging_power: float = None, max_discharging_power: float = None, min_discharging_power: float = None, charge_efficiency_curve: Dict[float, float] = None, - discharge_efficiency_curve: Dict[float, float] = None, connected_electric_vehicle: ElectricVehicle = None, incoming_electric_vehicle: ElectricVehicle = None, time_step_ratio: int = None, + discharge_efficiency_curve: Dict[float, float] = None, connected_electric_vehicle: ElectricVehicle = None, incoming_electric_vehicle: ElectricVehicle = None, + phase_connection: str = None, time_step_ratio: int = None, **kwargs ): r"""Initializes the `Electric Vehicle Charger` class with the given attributes. @@ -49,6 +50,7 @@ def __init__( self.discharge_efficiency_curve = discharge_efficiency_curve self.connected_electric_vehicle = connected_electric_vehicle self.incoming_electric_vehicle = incoming_electric_vehicle + self.phase_connection = phase_connection self.charger_simulation = charger_simulation arg_spec = inspect.getfullargspec(super().__init__) @@ -127,6 +129,12 @@ def efficiency(self) -> float: return self.__efficiency + @property + def phase_connection(self) -> str: + """Electrical phase connection assignment.""" + + return self.__phase_connection + @property def past_connected_evs(self) -> List[ElectricVehicle]: @@ -210,6 +218,10 @@ def connected_electric_vehicle(self, electric_vehicle: ElectricVehicle): def incoming_electric_vehicle(self, electric_vehicle: ElectricVehicle): self.__incoming_ev = electric_vehicle + @phase_connection.setter + def phase_connection(self, phase_connection: str): + self.__phase_connection = None if phase_connection is None else str(phase_connection) + @time_step_ratio.setter def time_step_ratio(self, time_step_ratio: float): self.__time_step_ratio = time_step_ratio @@ -289,43 +301,57 @@ def update_connected_electric_vehicle_soc(self, action_value: float): action_value : float The normalized charging or discharging action (range [-1, 1]). """ - if action_value != 0: - - - charging = action_value > 0 - efficiency = self.get_efficiency(abs(action_value), charging) # Charging if action_value > 0 + action_value = float(np.clip(action_value, -1.0, 1.0)) + if action_value == 0.0: + # Keep EV SOC state consistent at the current timestep even when no power is requested. + # Without this, SOC at `time_step` can remain the zero-initialized default. + if self.connected_electric_vehicle is not None: + self.connected_electric_vehicle.battery.charge(0.0) + self.__electricity_consumption[self.time_step] = 0 + self.__past_charging_action_values_kwh[self.time_step] = 0 + return + time_step_hours = self.algorithm_action_based_time_step_hours_ratio + charging = action_value > 0 + efficiency = self.get_efficiency(abs(action_value), charging) - if charging: - power = action_value * self.max_charging_power # Power in kW - energy = power * self.algorithm_action_based_time_step_hours_ratio # Convert to energy (kWh) - energy = max(min(energy, self.max_charging_power), self.min_charging_power) - energy_kwh = energy * efficiency # For charging + if charging: + requested_power_kw = action_value * self.max_charging_power + if self.max_charging_power <= 0.0: + applied_power_kw = 0.0 else: - power = action_value * self.max_discharging_power # Power in kW - energy = power * self.algorithm_action_based_time_step_hours_ratio # Convert to energy (kWh) - energy = max(min(energy, -self.min_discharging_power), -self.max_discharging_power) # For discharging - energy_kwh = energy / efficiency - self.__past_charging_action_values_kwh[self.time_step] = energy - - if self.connected_electric_vehicle: - electric_vehicle = self.connected_electric_vehicle - - # Charge or discharge the battery - electric_vehicle.battery.charge(energy_kwh) - - battery_energy_balance = electric_vehicle.battery.energy_balance[self.time_step] - # Store electricity consumption - - self.__electricity_consumption[self.time_step] = battery_energy_balance/efficiency if battery_energy_balance >= 0 else battery_energy_balance*efficiency + lower_bound_kw = min(self.min_charging_power, self.max_charging_power) + applied_power_kw = max(min(requested_power_kw, self.max_charging_power), lower_bound_kw) + commanded_energy_kwh = applied_power_kw * time_step_hours + battery_energy_kwh = commanded_energy_kwh * efficiency + else: + requested_power_kw = abs(action_value) * self.max_discharging_power + if self.max_discharging_power <= 0.0: + applied_power_kw = 0.0 else: - self.__electricity_consumption[self.time_step] = 0 - - + lower_bound_kw = min(self.min_discharging_power, self.max_discharging_power) + applied_power_kw = max(min(requested_power_kw, self.max_discharging_power), lower_bound_kw) + commanded_energy_kwh = -applied_power_kw * time_step_hours + battery_energy_kwh = commanded_energy_kwh / max(efficiency, ZERO_DIVISION_PLACEHOLDER) + + self.__past_charging_action_values_kwh[self.time_step] = commanded_energy_kwh + + if self.connected_electric_vehicle: + electric_vehicle = self.connected_electric_vehicle + + # Battery model expects dataset-resolution energy. Convert from control-step kWh when needed. + ratio = getattr(electric_vehicle.battery, 'time_step_ratio', None) + battery_command = battery_energy_kwh if ratio in (None, 0) else battery_energy_kwh / ratio + electric_vehicle.battery.charge(battery_command) + + battery_energy_balance = electric_vehicle.battery.energy_balance[self.time_step] + self.__electricity_consumption[self.time_step] = ( + battery_energy_balance / max(efficiency, ZERO_DIVISION_PLACEHOLDER) + if battery_energy_balance >= 0 else battery_energy_balance * efficiency + ) else: self.__electricity_consumption[self.time_step] = 0 - self.__past_charging_action_values_kwh[self.time_step] = 0 def next_time_step(self): @@ -343,7 +369,6 @@ def reset(self): self.connected_electric_vehicle = None self.incoming_electric_vehicle = None self.__electricity_consumption = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') - self.__ = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') self.__past_charging_action_values_kwh = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') self.__past_connected_evs = [None] * self.episode_tracker.episode_time_steps diff --git a/citylearn/energy_model.py b/citylearn/energy_model.py index b69c1c0c0..882b41ff7 100755 --- a/citylearn/energy_model.py +++ b/citylearn/energy_model.py @@ -130,8 +130,8 @@ def get_metadata(self) -> Mapping[str, Any]: 'nominal_power': self.nominal_power, } - def update_electricity_consumption(self, electricity_consumption: float, enforce_polarity: bool = None): - r"""Updates `electricity_consumption` at current `time_step`. + def update_electricity_consumption(self, electricity_consumption: float, enforce_polarity: bool = None): + r"""Updates `electricity_consumption` at current `time_step`. Parameters ---------- @@ -144,12 +144,38 @@ def update_electricity_consumption(self, electricity_consumption: float, enforce """ enforce_polarity = True if enforce_polarity is None else enforce_polarity - assert not enforce_polarity or electricity_consumption >= 0.0,\ - f'electricity_consumption must be >= 0 but value: {electricity_consumption} was provided.' - self.__electricity_consumption[self.time_step] += electricity_consumption - - def reset(self): - r"""Reset `ElectricDevice` to initial state and set `electricity_consumption` at `time_step` 0 to = 0.0.""" + assert not enforce_polarity or electricity_consumption >= 0.0,\ + f'electricity_consumption must be >= 0 but value: {electricity_consumption} was provided.' + self.__electricity_consumption[self.time_step] += electricity_consumption + + def set_electricity_consumption( + self, + electricity_consumption: float, + time_step: int = None, + enforce_polarity: bool = None, + ): + r"""Set `electricity_consumption` at a specific `time_step`. + + Parameters + ---------- + electricity_consumption: float + Absolute `electricity_consumption` value to store in [kWh] for `time_step`. + time_step: int, default: current `time_step` + Time step index to overwrite. + enforce_polarity: bool, default: True + Whether to allow only positive values. + """ + + enforce_polarity = True if enforce_polarity is None else enforce_polarity + assert not enforce_polarity or electricity_consumption >= 0.0, \ + f'electricity_consumption must be >= 0 but value: {electricity_consumption} was provided.' + + step = self.time_step if time_step is None else int(time_step) + ratio = self.time_step_ratio if self.time_step_ratio not in (None, 0) else 1.0 + self.__electricity_consumption[step] = float(electricity_consumption) / ratio + + def reset(self): + r"""Reset `ElectricDevice` to initial state and set `electricity_consumption` at `time_step` 0 to = 0.0.""" super().reset() self.__electricity_consumption = np.zeros(self.episode_tracker.episode_time_steps, dtype='float32') @@ -1024,19 +1050,18 @@ def get_metadata(self) -> Mapping[str, Any]: 'capacity_power_curve': self.capacity_power_curve, } - def charge(self, energy: float): - """Charges or discharges storage with respect to specified energy while considering `capacity` degradation and `soc_init` - limitations, losses to the environment quantified by `efficiency`, `power_efficiency_curve` and `capacity_power_curve`. + def charge(self, energy: float): + """Charges or discharges storage with respect to specified energy while considering `capacity` degradation and `soc_init` + limitations, losses to the environment quantified by `efficiency`, `power_efficiency_curve` and `capacity_power_curve`. Parameters - ---------- - energy : float - Energy to charge if (+) or discharge if (-) in [kWh]. - """ - energy = energy * self.time_step_ratio # Normalise energy with the time_step_ratio - action_energy = energy - - if energy >= 0: + ---------- + energy : float + Energy to charge if (+) or discharge if (-) in [kWh]. + """ + action_energy = energy + + if energy >= 0: energy_wrt_degrade = self.degraded_capacity - self.energy_init max_input_power = self.get_max_input_power() energy = min(max_input_power, self.available_nominal_power, energy_wrt_degrade, energy) @@ -1051,10 +1076,12 @@ def charge(self, energy: float): energy = max(-max_output_power, energy_limit_wrt_dod, energy) self.efficiency = self.get_current_efficiency(min(abs(action_energy), max_output_power)) - super().charge(energy) - degraded_capacity = max(self.degraded_capacity - self.degrade(), 0.0) - self._capacity_history.append(degraded_capacity) - self.update_electricity_consumption(self.energy_balance[self.time_step], enforce_polarity=False) + super().charge(energy) + degraded_capacity = max(self.degraded_capacity - self.degrade(), 0.0) + self._capacity_history.append(degraded_capacity) + ratio = self.time_step_ratio if self.time_step_ratio not in (None, 0) else 1.0 + dataset_resolution_balance = self.energy_balance[self.time_step]/ratio + self.update_electricity_consumption(dataset_resolution_balance, enforce_polarity=False) def get_max_output_power(self) -> float: r"""Get maximum output power while considering `capacity_power_curve` limitations if defined otherwise, returns `nominal_power`. @@ -1137,8 +1164,8 @@ def degrade(self) -> float: """ # Calculating the degradation of the battery: new max. capacity of the battery after charge/discharge - capacity_degrade = self.capacity_loss_coefficient*self.capacity*np.abs(self.energy_balance[self.time_step])/(2*max(self.degraded_capacity, ZERO_DIVISION_PLACEHOLDER)) - return capacity_degrade * self.time_step_ratio # Normalize with time_step_ratio (seconds_per_timestep/schema_time_delta) + capacity_degrade = self.capacity_loss_coefficient*self.capacity*np.abs(self.energy_balance[self.time_step])/(2*max(self.degraded_capacity, ZERO_DIVISION_PLACEHOLDER)) + return capacity_degrade def autosize( self, demand: float, duration: Union[float, Tuple[float, float]] = None, parallel: bool = None, safety_factor: Union[float, Tuple[float, float]] = None, diff --git a/citylearn/exporter.py b/citylearn/exporter.py new file mode 100644 index 000000000..b6d7ad592 --- /dev/null +++ b/citylearn/exporter.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from collections import defaultdict +import csv +import datetime +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Mapping, TYPE_CHECKING, Union + +import numpy as np + +if TYPE_CHECKING: + from citylearn.agents.base import Agent + from citylearn.citylearn import CityLearnEnv + from citylearn.electric_vehicle import ElectricVehicle + +LOGGER = logging.getLogger(__name__) + + +class EpisodeExporter: + """Internal helper that owns rendering/export behaviour for ``CityLearnEnv``.""" + + DEFAULT_RENDER_START_DATE = datetime.date(2024, 1, 1) + + def __init__(self, env: "CityLearnEnv"): + self.env = env + + @staticmethod + def parse_render_start_date(start_date: Union[str, datetime.date, datetime.datetime]) -> datetime.date: + """Return a valid start date for rendering timestamps.""" + + if start_date is None: + return EpisodeExporter.DEFAULT_RENDER_START_DATE + + if isinstance(start_date, datetime.datetime): + return start_date.date() + + if isinstance(start_date, datetime.date): + return start_date + + if isinstance(start_date, str): + try: + return datetime.date.fromisoformat(start_date) + except ValueError as exc: + raise ValueError( + "CityLearnEnv start_date must be in ISO format 'YYYY-MM-DD'." + ) from exc + + raise TypeError( + "CityLearnEnv start_date must be a date, datetime, or ISO format string." + ) + + def export_final_kpis(self, model: "Agent" = None, filepath: str = "exported_kpis.csv"): + """Export episode KPIs to csv.""" + + env = self.env + self.ensure_output_dir() + file_path = os.path.join(env.new_folder_path, filepath) + + if model is not None and getattr(model, 'env', None) is not None: + kpis = model.env.evaluate() + else: + kpis = env.evaluate() + + kpis = kpis.pivot(index='cost_function', columns='name', values='value').round(3) + kpis = kpis.fillna('') + kpis = kpis.reset_index() + kpis = kpis.rename(columns={'cost_function': 'KPI'}) + kpis.to_csv(file_path, index=False, encoding='utf-8') + env._final_kpis_exported = True + + def render(self): + """Render one time step to CSV outputs.""" + + env = self.env + + if not getattr(env, 'render_enabled', False): + return + + if env.render_mode == 'end' and getattr(env, '_defer_render_flush', False): + return + + if env.render_mode == 'end' and (env.terminated or env.truncated): + return + + self.ensure_output_dir() + iso_timestamp = self.get_iso_timestamp() + os.makedirs(env.new_folder_path, exist_ok=True) + + episode_num = env.episode_tracker.episode + + self.save_to_csv( + f"exported_data_community_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **env.as_dict()}, + ) + + for building in env.buildings: + self.save_to_csv( + f"exported_data_{building.name.lower()}_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **building.as_dict()}, + ) + + battery = building.electrical_storage + self.save_to_csv( + f"exported_data_{building.name.lower()}_battery_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **battery.as_dict()}, + ) + + for charger in building.electric_vehicle_chargers or []: + self.save_to_csv( + f"exported_data_{building.name.lower()}_{charger.charger_id}_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **charger.as_dict()}, + ) + + self.save_to_csv( + f"exported_data_pricing_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **env.buildings[0].pricing.as_dict(env.time_step)}, + ) + + for ev in env.electric_vehicles: + self.save_to_csv( + f"exported_data_{ev.name.lower()}_ep{episode_num}.csv", + {"timestamp": iso_timestamp, **ev.as_dict()}, + ) + + def _set_charger_render_state(self, charger, time_step: int, ev_lookup: Mapping[str, "ElectricVehicle"]): + """Set charger connected/incoming EV pointers to match schedule at a given time step.""" + + sim = charger.charger_simulation + state = sim.electric_vehicle_charger_state[time_step] if time_step < len(sim.electric_vehicle_charger_state) else np.nan + ev_id = sim.electric_vehicle_id[time_step] if time_step < len(sim.electric_vehicle_id) else None + valid_ev_id = isinstance(ev_id, str) and ev_id.strip() not in {"", "nan"} + + connected_ev = ev_lookup.get(ev_id) if valid_ev_id and state == 1 else None + incoming_ev = ev_lookup.get(ev_id) if valid_ev_id and state == 2 else None + + charger.connected_electric_vehicle = connected_ev + charger.incoming_electric_vehicle = incoming_ev + + def export_episode_render_data(self, final_index: int): + """Export full episode render rows in one pass for ``render_mode='end'``.""" + + env = self.env + + if final_index < 0: + return + + self.ensure_output_dir() + episode_num = env.episode_tracker.episode + rows_by_filename: Dict[str, List[Mapping[str, Any]]] = defaultdict(list) + ev_lookup = {ev.name: ev for ev in env.electric_vehicles} + original_charger_state = {} + time_step_snapshot = self.override_render_time_step(0) + original_year = env.year + original_day = env.current_day + original_start_datetime = getattr(env, '_render_start_datetime', None) + + try: + self.reset_time_tracking() + + for t in range(final_index + 1): + for obj, _ in time_step_snapshot: + try: + obj.time_step = t + except AttributeError: + pass + + timestamp = self.get_iso_timestamp() + rows_by_filename[f"exported_data_community_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **env.as_dict()} + ) + + for building in env.buildings: + rows_by_filename[f"exported_data_{building.name.lower()}_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **building.as_dict()} + ) + battery = building.electrical_storage + rows_by_filename[f"exported_data_{building.name.lower()}_battery_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **battery.as_dict()} + ) + + for charger in building.electric_vehicle_chargers or []: + if charger not in original_charger_state: + original_charger_state[charger] = ( + charger.connected_electric_vehicle, + charger.incoming_electric_vehicle, + ) + self._set_charger_render_state(charger, t, ev_lookup) + rows_by_filename[f"exported_data_{building.name.lower()}_{charger.charger_id}_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **charger.as_dict()} + ) + + rows_by_filename[f"exported_data_pricing_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **env.buildings[0].pricing.as_dict(t)} + ) + + for ev in env.electric_vehicles: + rows_by_filename[f"exported_data_{ev.name.lower()}_ep{episode_num}.csv"].append( + {"timestamp": timestamp, **ev.as_dict()} + ) + + finally: + for charger, state in original_charger_state.items(): + charger.connected_electric_vehicle, charger.incoming_electric_vehicle = state + + self.restore_render_time_step(time_step_snapshot) + env.year = original_year + env.current_day = original_day + env._render_start_datetime = original_start_datetime + + for filename, rows in rows_by_filename.items(): + file_path = Path(env.new_folder_path) / filename + if file_path.exists(): + file_path.unlink() + self.write_render_rows(filename, rows) + + def save_to_csv(self, filename: str, data: Mapping[str, Any]): + """Save one render row to CSV.""" + + env = self.env + + if env._buffer_render and getattr(env, '_defer_render_flush', False): + env._render_buffer[filename].append(dict(data)) + return + + self.write_render_rows(filename, [dict(data)]) + + def flush_render_buffer(self): + """Write any buffered render rows to disk.""" + + env = self.env + + if not getattr(env, '_render_buffer', None): + return + + has_pending_rows = any(env._render_buffer.values()) + if not has_pending_rows: + env._render_buffer.clear() + return + + try: + target_dir = Path(env.new_folder_path) + except Exception: + target_dir = None + + if target_dir is not None: + LOGGER.info("Writing buffered render exports to %s ...", target_dir) + + original_defer = env._defer_render_flush + original_buffer_state = env._buffer_render + env._defer_render_flush = False + env._buffer_render = False + + try: + for filename, rows in list(env._render_buffer.items()): + if rows: + self.write_render_rows(filename, rows) + finally: + env._render_buffer.clear() + env._buffer_render = original_buffer_state + env._defer_render_flush = original_defer + + def write_render_rows(self, filename: str, rows: List[Mapping[str, Any]]): + """Write one or more render rows to disk with minimal rewrites.""" + + env = self.env + file_path = Path(env.new_folder_path) / filename + file_path.parent.mkdir(parents=True, exist_ok=True) + + if not rows: + return + + buffered_fieldnames = list(dict.fromkeys(field for row in rows for field in row.keys())) + + if not file_path.exists(): + fieldnames = buffered_fieldnames + with file_path.open('w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field, '') for field in fieldnames}) + return + + with file_path.open('r', newline='') as csvfile: + reader = csv.DictReader(csvfile) + existing_rows = list(reader) + existing_fieldnames = reader.fieldnames or [] + + missing_fieldnames = [field for field in buffered_fieldnames if field not in existing_fieldnames] + if missing_fieldnames: + extended_fieldnames = [*existing_fieldnames, *missing_fieldnames] + with file_path.open('w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=extended_fieldnames) + writer.writeheader() + for row in existing_rows: + writer.writerow({field: row.get(field, '') for field in extended_fieldnames}) + for row in rows: + writer.writerow({field: row.get(field, '') for field in extended_fieldnames}) + return + + with file_path.open('a', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=existing_fieldnames) + for row in rows: + writer.writerow({field: row.get(field, '') for field in existing_fieldnames}) + + def ensure_output_dir(self, *, ensure_exists: bool = True): + """Prepare the render output directory and optionally create it on disk.""" + + env = self.env + base_render_path = Path( + getattr(env, 'render_output_root', Path(__file__).resolve().parents[1] / 'render_logs') + ).expanduser() + + if ensure_exists: + try: + base_render_path.mkdir(parents=True, exist_ok=True) + except PermissionError: + fallback = (Path.cwd() / 'render_logs').resolve() + fallback.mkdir(parents=True, exist_ok=True) + env.render_output_root = fallback + base_render_path = fallback + + render_dir = getattr(env, '_render_directory_path', None) + needs_new_dir = render_dir is None + + if not needs_new_dir and ensure_exists: + render_dir = Path(render_dir) + try: + needs_new_dir = not render_dir.is_relative_to(base_render_path) + except AttributeError: + needs_new_dir = base_render_path not in render_dir.parents and render_dir != base_render_path + + if needs_new_dir: + if env.render_session_name: + render_dir = (base_render_path / Path(env.render_session_name)).expanduser().resolve() + else: + if getattr(env, '_render_timestamp', None) is None: + env._render_timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + render_dir = (base_render_path / env._render_timestamp).resolve() + + env._render_directory_path = render_dir + else: + render_dir = Path(env._render_directory_path) + + if ensure_exists: + render_dir.mkdir(parents=True, exist_ok=True) + if not env._render_dir_initialized: + if env.render_session_name: + for csv_file in render_dir.glob('exported_*.csv'): + try: + csv_file.unlink() + except OSError: + pass + env._render_dir_initialized = True + + env.new_folder_path = str(render_dir) + + def get_iso_timestamp(self) -> str: + """Return current episode timestamp string in ISO format.""" + + env = self.env + + if env.time_step == 0 or getattr(env, '_render_start_datetime', None) is None: + self.reset_time_tracking() + + start_datetime = env._render_start_datetime + timestamp_dt = start_datetime + datetime.timedelta(seconds=env.time_step * env.seconds_per_time_step) + env.year = timestamp_dt.year + env.current_day = timestamp_dt.day + + return timestamp_dt.strftime("%Y-%m-%dT%H:%M:%S") + + def override_render_time_step(self, index: int): + """Temporarily set time_step to `index` for the environment and descendants.""" + + env = self.env + snapshot = [] + + def _record(obj): + if hasattr(obj, 'time_step'): + snapshot.append((obj, obj.time_step)) + obj.time_step = index + + _record(env) + for building in getattr(env, 'buildings', []): + _record(building) + electrical_storage = getattr(building, 'electrical_storage', None) + if electrical_storage is not None: + _record(electrical_storage) + + for charger in getattr(building, 'electric_vehicle_chargers', []) or []: + _record(charger) + + for washing_machine in getattr(building, 'washing_machines', []) or []: + _record(washing_machine) + + for ev in getattr(env, 'electric_vehicles', []): + _record(ev) + battery = getattr(ev, 'battery', None) + if battery is not None: + _record(battery) + + return snapshot + + @staticmethod + def restore_render_time_step(snapshot): + for obj, value in snapshot: + try: + obj.time_step = value + except AttributeError: + pass + + def reset_time_tracking(self): + """Reset render timestamp tracking to episode start.""" + + env = self.env + start_offset = getattr(env.episode_tracker, 'episode_start_time_step', 0) + base_datetime = datetime.datetime.combine(env.render_start_date, datetime.time()) + base_datetime += datetime.timedelta(seconds=start_offset * env.seconds_per_time_step) + env._render_start_datetime = base_datetime + env.year = base_datetime.year + env.current_day = base_datetime.day diff --git a/citylearn/internal/__init__.py b/citylearn/internal/__init__.py new file mode 100644 index 000000000..5cb4a6458 --- /dev/null +++ b/citylearn/internal/__init__.py @@ -0,0 +1 @@ +"""Internal service modules for CityLearn runtime composition.""" diff --git a/citylearn/internal/building_ops.py b/citylearn/internal/building_ops.py new file mode 100644 index 000000000..58ccbcc53 --- /dev/null +++ b/citylearn/internal/building_ops.py @@ -0,0 +1,962 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Dict, Mapping, Optional, Tuple, Union + +import numpy as np + +from citylearn.energy_model import HeatPump +from citylearn.preprocessing import Normalize, PeriodicNormalization + +if TYPE_CHECKING: + from citylearn.building import Building + +LOGGER = logging.getLogger() + + +class BuildingOpsService: + """Internal observation/action operations for `Building`.""" + + def __init__(self, building: "Building"): + self.building = building + + def observations( + self, + include_all: bool = None, + normalize: bool = None, + periodic_normalization: bool = None, + check_limits: bool = None, + ) -> Mapping[str, float]: + """Observations at current time step.""" + + building = self.building + + normalize = False if normalize is None else normalize + periodic_normalization = False if periodic_normalization is None else periodic_normalization + include_all = False if include_all is None else include_all + check_limits = False if check_limits is None else check_limits + + data = self.get_observations_data(include_all=include_all) + + if include_all: + valid_observations = list(set(data.keys()) | set(building.active_observations)) + else: + valid_observations = building.active_observations + + observations = {k: data[k] for k in valid_observations if k in data.keys()} + + observations = self.update_ev_charger_observations( + observations, + valid_observations, + building.electric_vehicle_chargers, + include_all=include_all, + ) + + observations = self.update_washing_machine_observations( + observations, + valid_observations, + building.washing_machines, + ) + + unknown_observations = set(observations.keys()).difference(set(valid_observations)) + assert len(unknown_observations) == 0, f'Unknown observations: {unknown_observations}' + + non_periodic_low_limit, non_periodic_high_limit = building.non_periodic_normalized_observation_space_limits + periodic_low_limit, periodic_high_limit = building.periodic_normalized_observation_space_limits + periodic_observations = building.get_periodic_observation_metadata() + + if check_limits: + for key in building.active_observations: + value = observations[key] + lower = non_periodic_low_limit[key] + upper = non_periodic_high_limit[key] + if not lower <= value <= upper: + report = { + 'Building': building.name, + 'episode': building.episode_tracker.episode, + 'time_step': f'{building.time_step + 1}/{building.episode_tracker.episode_time_steps}', + 'observation': key, + 'value': value, + 'lower': lower, + 'upper': upper, + } + LOGGER.debug(f'Observation outside space limit: {report}') + + if periodic_normalization: + observations_copy = {k: v for k, v in observations.items()} + observations = {} + periodic_normalizer = PeriodicNormalization(x_max=0) + + for key, value in observations_copy.items(): + if key in periodic_observations: + periodic_normalizer.x_max = max(periodic_observations[key]) + sin_x, cos_x = value * periodic_normalizer + observations[f'{key}_cos'] = cos_x + observations[f'{key}_sin'] = sin_x + else: + observations[key] = value + + if normalize: + normalizer = Normalize(0.0, 1.0) + + for key, value in observations.items(): + normalizer.x_min = periodic_low_limit[key] + normalizer.x_max = periodic_high_limit[key] + observations[key] = value * normalizer + + return observations + + def update_ev_charger_observations(self, observations, valid_observations, ev_chargers, include_all: bool = False): + """Update observations for each electric vehicle charger.""" + + building = self.building + + for charger in ev_chargers: + charger_id = charger.charger_id + sim = charger.charger_simulation + t = building.time_step + endogenous_t = t if include_all else max(t - 1, 0) + + connected_state_key = f'electric_vehicle_charger_{charger_id}_connected_state' + incoming_state_key = f'electric_vehicle_charger_{charger_id}_incoming_state' + departure_key = f'connected_electric_vehicle_at_charger_{charger_id}_departure_time' + req_soc_key = f'connected_electric_vehicle_at_charger_{charger_id}_required_soc_departure' + soc_key = f'connected_electric_vehicle_at_charger_{charger_id}_soc' + capacity_key = f'connected_electric_vehicle_at_charger_{charger_id}_battery_capacity' + arrival_key = f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_arrival_time' + soc_arrival_key = f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_soc_arrival' + + state = sim.electric_vehicle_charger_state[t] if t < len(sim.electric_vehicle_charger_state) else np.nan + + if charger.connected_electric_vehicle and state == 1: + if connected_state_key in valid_observations: + observations[connected_state_key] = 1 + if departure_key in valid_observations: + observations[departure_key] = int(sim.electric_vehicle_departure_time[t]) + if req_soc_key in valid_observations: + observations[req_soc_key] = float(sim.electric_vehicle_required_soc_departure[t]) + if soc_key in valid_observations: + observations[soc_key] = charger.connected_electric_vehicle.battery.soc[endogenous_t] + if capacity_key in valid_observations: + observations[capacity_key] = float(charger.connected_electric_vehicle.battery.capacity) + else: + if connected_state_key in valid_observations: + observations[connected_state_key] = 0 + if departure_key in valid_observations: + observations[departure_key] = -1 + if req_soc_key in valid_observations: + observations[req_soc_key] = -0.1 + if soc_key in valid_observations: + observations[soc_key] = -0.1 + if capacity_key in valid_observations: + observations[capacity_key] = -1.0 + + if charger.incoming_electric_vehicle and state == 2: + if incoming_state_key in valid_observations: + observations[incoming_state_key] = 1 + if arrival_key in valid_observations: + observations[arrival_key] = int(sim.electric_vehicle_estimated_arrival_time[t]) + if soc_arrival_key in valid_observations: + observations[soc_arrival_key] = float(sim.electric_vehicle_estimated_soc_arrival[t]) + else: + if incoming_state_key in valid_observations: + observations[incoming_state_key] = 0 + if arrival_key in valid_observations: + observations[arrival_key] = -1 + if soc_arrival_key in valid_observations: + observations[soc_arrival_key] = -0.1 + + return observations + + def update_washing_machine_observations(self, observations, valid_observations, washing_machines): + """Update observations for each washing machine.""" + + for washing_machine in washing_machines: + washing_machine_name = washing_machine.name + washing_machine_observations = washing_machine.observations() + + start_key = f'{washing_machine_name}_start_time_step' + if start_key in valid_observations: + observations[start_key] = next( + (value for key, value in washing_machine_observations.items() if '_start_time_step' in key), + -1, + ) + + end_key = f'{washing_machine_name}_end_time_step' + if end_key in valid_observations: + observations[end_key] = next( + (value for key, value in washing_machine_observations.items() if '_end_time_step' in key), + -1, + ) + return observations + + def get_observations_data(self, include_all: bool = False) -> Mapping[str, Union[float, int]]: + """Build base observation dictionary without normalization.""" + + building = self.building + + electric_vehicle_chargers_dict = {} + washing_machines_dict = {} + t = building.time_step + endogenous_t = t if include_all else max(t - 1, 0) + + for charger in building.electric_vehicle_chargers or []: + charger_id = charger.charger_id + connected_car = charger.connected_electric_vehicle + + if connected_car is not None: + last_charged_kwh = 0.0 + if 0 <= endogenous_t < len(charger.past_charging_action_values_kwh): + last_charged_kwh = float(charger.past_charging_action_values_kwh[endogenous_t]) + + battery_soc = connected_car.battery.soc[endogenous_t] + previous_battery_soc = connected_car.battery.initial_soc if endogenous_t == 0 else connected_car.battery.soc[endogenous_t - 1] + + required_soc = charger.charger_simulation.electric_vehicle_required_soc_departure[t] + hours_until_departure = charger.charger_simulation.electric_vehicle_departure_time[t] + + battery_capacity = connected_car.battery.capacity + min_capacity = (1 - connected_car.battery.depth_of_discharge) * battery_capacity + + electric_vehicle_chargers_dict[charger_id] = { + 'connected': True, + 'last_charged_kwh': last_charged_kwh, + 'previous_battery_soc': previous_battery_soc, + 'battery_soc': battery_soc, + 'battery_capacity': battery_capacity, + 'min_capacity': min_capacity, + 'required_soc': required_soc, + 'hours_until_departure': hours_until_departure, + 'max_charging_power': charger.max_charging_power, + 'max_discharging_power': charger.max_discharging_power, + } + + else: + electric_vehicle_chargers_dict[charger_id] = { + 'connected': False, + 'last_charged_kwh': 0.0, + 'previous_battery_soc': None, + 'battery_soc': None, + 'battery_capacity': None, + 'min_capacity': None, + 'required_soc': None, + 'hours_until_departure': None, + 'max_charging_power': charger.max_charging_power, + 'max_discharging_power': charger.max_discharging_power, + } + + for washing_machine in building.washing_machines or []: + washing_machine_name = washing_machine.name + + def _safe(arr, idx, default): + try: + return arr[idx] + except Exception: + return default + + start_time_step = _safe(washing_machine.washing_machine_simulation.wm_start_time_step, t, -1) + end_time_step = _safe(washing_machine.washing_machine_simulation.wm_end_time_step, t, -1) + load_profile = _safe(washing_machine.washing_machine_simulation.load_profile, t, 0.0) + + washing_machines_dict[washing_machine_name] = { + 'wm_start_time_step': start_time_step, + 'wm_end_time_step': end_time_step, + 'load_profile': load_profile, + } + + observations = {} + for key, series in building._energy_simulation_observation_sources: + if t < len(series): + observations[key] = series[t] + + for key, series in building._weather_observation_sources: + if t < len(series): + observations[key] = series[t] + + for key, series in building._pricing_observation_sources: + if t < len(series): + observations[key] = series[t] + + for key, series in building._carbon_observation_sources: + if t < len(series): + observations[key] = series[t] + + observations.update({ + 'solar_generation': abs(building.solar_generation[t]), + **{ + 'cooling_storage_soc': building.cooling_storage.soc[endogenous_t], + 'heating_storage_soc': building.heating_storage.soc[endogenous_t], + 'dhw_storage_soc': building.dhw_storage.soc[endogenous_t], + 'electrical_storage_soc': building.electrical_storage.soc[endogenous_t], + }, + 'cooling_demand': building.energy_from_cooling_device[endogenous_t] + abs(min(building.cooling_storage.energy_balance[endogenous_t], 0.0)), + 'heating_demand': building.energy_from_heating_device[endogenous_t] + abs(min(building.heating_storage.energy_balance[endogenous_t], 0.0)), + 'dhw_demand': building.energy_from_dhw_device[endogenous_t] + abs(min(building.dhw_storage.energy_balance[endogenous_t], 0.0)), + 'net_electricity_consumption': building.net_electricity_consumption[endogenous_t], + 'cooling_electricity_consumption': building.cooling_electricity_consumption[endogenous_t], + 'heating_electricity_consumption': building.heating_electricity_consumption[endogenous_t], + 'dhw_electricity_consumption': building.dhw_electricity_consumption[endogenous_t], + 'cooling_storage_electricity_consumption': building.cooling_storage_electricity_consumption[endogenous_t], + 'heating_storage_electricity_consumption': building.heating_storage_electricity_consumption[endogenous_t], + 'dhw_storage_electricity_consumption': building.dhw_storage_electricity_consumption[endogenous_t], + 'electrical_storage_electricity_consumption': building.electrical_storage_electricity_consumption[endogenous_t], + 'washing_machine_electricity_consumption': building.washing_machines_electricity_consumption[endogenous_t], + 'cooling_device_efficiency': building.cooling_device.get_cop(building.weather.outdoor_dry_bulb_temperature[t], heating=False), + 'heating_device_efficiency': building.heating_device.get_cop(building.weather.outdoor_dry_bulb_temperature[t], heating=True) + if isinstance(building.heating_device, HeatPump) else building.heating_device.efficiency, + 'dhw_device_efficiency': building.dhw_device.get_cop(building.weather.outdoor_dry_bulb_temperature[t], heating=True) + if isinstance(building.dhw_device, HeatPump) else building.dhw_device.efficiency, + 'indoor_dry_bulb_temperature_cooling_set_point': building.energy_simulation.indoor_dry_bulb_temperature_cooling_set_point[t], + 'indoor_dry_bulb_temperature_heating_set_point': building.energy_simulation.indoor_dry_bulb_temperature_heating_set_point[t], + 'indoor_dry_bulb_temperature_cooling_delta': building.energy_simulation.indoor_dry_bulb_temperature[t] - building.energy_simulation.indoor_dry_bulb_temperature_cooling_set_point[t], + 'indoor_dry_bulb_temperature_heating_delta': building.energy_simulation.indoor_dry_bulb_temperature[t] - building.energy_simulation.indoor_dry_bulb_temperature_heating_set_point[t], + 'comfort_band': building.energy_simulation.comfort_band[t], + 'occupant_count': building.energy_simulation.occupant_count[t], + 'power_outage': building.power_outage_signal[t], + 'electric_vehicles_chargers_dict': electric_vehicle_chargers_dict, + 'washing_machines_dict': washing_machines_dict, + }) + + if ( + getattr(building, '_charging_constraints_enabled', False) + and getattr(building, '_expose_charging_constraints', False) + and isinstance(building._charging_constraints_state, dict) + ): + state = building._charging_constraints_state + headroom = state.get('building_headroom_kw') + if headroom is not None: + observations['charging_building_headroom_kw'] = headroom + export_headroom = state.get('building_export_headroom_kw') + if export_headroom is not None: + observations['charging_building_export_headroom_kw'] = export_headroom + for phase_name, value in (state.get('phase_headroom_kw') or {}).items(): + if value is not None: + observations[f'charging_phase_{phase_name}_headroom_kw'] = value + for phase_name, value in (state.get('phase_export_headroom_kw') or {}).items(): + if value is not None: + observations[f'charging_phase_{phase_name}_export_headroom_kw'] = value + + if getattr(building, '_charging_constraints_enabled', False): + if getattr(building, '_expose_charging_violation', False): + observations['charging_constraint_violation_kwh'] = building._charging_constraint_last_penalty_kwh + if getattr(building, '_phase_encoding_observations', None): + observations.update(building._phase_encoding_observations) + + return observations + + def apply_actions( + self, + cooling_or_heating_device_action: float = None, + cooling_device_action: float = None, + heating_device_action: float = None, + cooling_storage_action: float = None, + heating_storage_action: float = None, + dhw_storage_action: float = None, + electrical_storage_action: float = None, + washing_machine_actions: dict = None, + electric_vehicle_storage_actions: dict = None, + ): + """Update demand and charge/discharge storage devices.""" + + building = self.building + + if electric_vehicle_storage_actions is not None: + electric_vehicle_storage_actions = dict(electric_vehicle_storage_actions) + + if 'cooling_or_heating_device' in building.active_actions: + assert 'cooling_device' not in building.active_actions and 'heating_device' not in building.active_actions, \ + 'cooling_device and heating_device actions must be set to False when cooling_or_heating_device is True.' \ + ' They will be implicitly set based on the polarity of cooling_or_heating_device.' + cooling_device_action = abs(min(cooling_or_heating_device_action, 0.0)) + heating_device_action = abs(max(cooling_or_heating_device_action, 0.0)) + + else: + assert not ('cooling_device' in building.active_actions and 'heating_device' in building.active_actions), \ + 'cooling_device and heating_device actions cannot both be set to True to avoid both actions having' \ + ' values > 0.0 in the same time step. Use cooling_or_heating_device action instead to control' \ + ' both cooling_device and heating_device in a building.' + cooling_device_action = np.nan if 'cooling_device' not in building.active_actions else cooling_device_action + heating_device_action = np.nan if 'heating_device' not in building.active_actions else heating_device_action + + cooling_storage_action = 0.0 if 'cooling_storage' not in building.active_actions else cooling_storage_action + heating_storage_action = 0.0 if 'heating_storage' not in building.active_actions else heating_storage_action + dhw_storage_action = 0.0 if 'dhw_storage' not in building.active_actions else dhw_storage_action + electrical_storage_action = 0.0 if 'electrical_storage' not in building.active_actions else electrical_storage_action + + electric_vehicle_storage_actions, electrical_storage_action = self.apply_charging_constraints_to_actions( + electric_vehicle_storage_actions, + electrical_storage_action, + ) + + actions = { + 'cooling_demand': (building.update_cooling_demand, (cooling_device_action,)), + 'heating_demand': (building.update_heating_demand, (heating_device_action,)), + 'cooling_device': (building.update_energy_from_cooling_device, ()), + 'cooling_storage': (building.update_cooling_storage, (cooling_storage_action,)), + 'heating_device': (building.update_energy_from_heating_device, ()), + 'heating_storage': (building.update_heating_storage, (heating_storage_action,)), + 'dhw_device': (building.update_energy_from_dhw_device, ()), + 'dhw_storage': (building.update_dhw_storage, (dhw_storage_action,)), + 'non_shiftable_load': (building.update_non_shiftable_load, ()), + 'electrical_storage': (building.update_electrical_storage, (electrical_storage_action,)), + } + + priority_list = list(actions.keys()) + + if electric_vehicle_storage_actions is not None: + electric_vehicle_priority_list = [] + for charger_id, action in electric_vehicle_storage_actions.items(): + action_key = f'electric_vehicle_storage_{charger_id}' + if action_key not in building.active_actions: + raise ValueError('This action should not be applied. Verify') + for charger in building.electric_vehicle_chargers: + if charger.charger_id == charger_id: + actions[action_key] = (charger.update_connected_electric_vehicle_soc, (action,)) + electric_vehicle_priority_list.append(action_key) + priority_list = priority_list + electric_vehicle_priority_list + + if washing_machine_actions is not None: + washing_machine_priority_list = [] + for washing_machine_name, action in washing_machine_actions.items(): + action_key = f'{washing_machine_name}' + if action_key not in building.active_actions: + raise ValueError('This action should not be applied. Verify') + for washing_machine in building.washing_machines: + if washing_machine.name == washing_machine_name: + actions[action_key] = (washing_machine.start_cycle, (action,)) + washing_machine_priority_list.append(action_key) + priority_list = priority_list + washing_machine_priority_list + + if electrical_storage_action < 0.0: + key = 'electrical_storage' + priority_list.remove(key) + priority_list = [key] + priority_list + + for key in ['cooling', 'heating', 'dhw']: + storage = f'{key}_storage' + device = f'{key}_device' + + if actions[storage][1][0] < 0.0: + storage_ix = priority_list.index(storage) + device_ix = priority_list.index(device) + priority_list[storage_ix] = device + priority_list[device_ix] = storage + + for key in priority_list: + func, args = actions[key] + + try: + func(*args) + except NotImplementedError: + pass + + def _safe_scalar(self, value, default: float = 0.0) -> float: + try: + scalar = float(value) + except (TypeError, ValueError): + return float(default) + + if not np.isfinite(scalar): + return float(default) + + return scalar + + def _safe_index(self, values, idx: int, default: float = 0.0) -> float: + try: + return self._safe_scalar(values[idx], default) + except Exception: + return float(default) + + def _current_phase_names(self): + building = self.building + if getattr(building, '_electrical_service_mode', 'single_phase') == 'three_phase': + return ['L1', 'L2', 'L3'] + return ['L1'] + + def _split_unassigned_power(self, power_kw: float) -> Dict[str, float]: + building = self.building + phase_names = self._current_phase_names() + + if len(phase_names) == 1: + return {'L1': float(power_kw)} + + split_mode = str(getattr(building, '_electrical_service_default_split', 'balanced')).strip().lower() + if split_mode in {'l1', 'l2', 'l3'}: + return {phase: float(power_kw if phase.lower() == split_mode else 0.0) for phase in phase_names} + + share = float(power_kw) / len(phase_names) + return {phase: share for phase in phase_names} + + def _split_power_by_connection(self, power_kw: float, phase_connection: Optional[str]) -> Dict[str, float]: + phase_names = self._current_phase_names() + + if len(phase_names) == 1: + return {'L1': float(power_kw)} + + if phase_connection in {'L1', 'L2', 'L3'}: + return {phase: float(power_kw if phase == phase_connection else 0.0) for phase in phase_names} + + if phase_connection == 'all_phases': + share = float(power_kw) / len(phase_names) + return {phase: share for phase in phase_names} + + return self._split_unassigned_power(power_kw) + + def _estimate_non_controllable_base_power(self) -> Tuple[float, Dict[str, float]]: + building = self.building + t = building.time_step + phase_names = self._current_phase_names() + + if building.power_outage: + return 0.0, {phase: 0.0 for phase in phase_names} + + temperature = self._safe_index(building.weather.outdoor_dry_bulb_temperature, t, 0.0) + + cooling_demand = self._safe_index(building.energy_from_cooling_device, t, 0.0) + self._safe_index( + building.cooling_storage.energy_balance, t, 0.0 + ) + cooling_kw = self._safe_scalar(building.cooling_device.get_input_power(cooling_demand, temperature, heating=False), 0.0) + + heating_demand = self._safe_index(building.energy_from_heating_device, t, 0.0) + self._safe_index( + building.heating_storage.energy_balance, t, 0.0 + ) + if isinstance(building.heating_device, HeatPump): + heating_kw = self._safe_scalar( + building.heating_device.get_input_power(heating_demand, temperature, heating=True), + 0.0, + ) + else: + heating_kw = self._safe_scalar(building.heating_device.get_input_power(heating_demand), 0.0) + + dhw_demand = self._safe_index(building.energy_from_dhw_device, t, 0.0) + self._safe_index( + building.dhw_storage.energy_balance, t, 0.0 + ) + if isinstance(building.dhw_device, HeatPump): + dhw_kw = self._safe_scalar(building.dhw_device.get_input_power(dhw_demand, temperature, heating=True), 0.0) + else: + dhw_kw = self._safe_scalar(building.dhw_device.get_input_power(dhw_demand), 0.0) + + non_shiftable_kw = self._safe_index(building.energy_to_non_shiftable_load, t, 0.0) + solar_kw = self._safe_index(building.solar_generation, t, 0.0) + washing_kw = sum(self._safe_index(wm.electricity_consumption, t, 0.0) for wm in building.washing_machines or []) + + base_total_kw = cooling_kw + heating_kw + dhw_kw + non_shiftable_kw + solar_kw + washing_kw + base_phase_kw = self._split_unassigned_power(base_total_kw) + + return float(base_total_kw), base_phase_kw + + def _charger_requested_power_kw(self, charger, action: float) -> float: + if action is None: + return 0.0 + + action = self._safe_scalar(action, 0.0) + action = float(np.clip(action, -1.0, 1.0)) + if action > 0.0: + max_power = self._safe_scalar(getattr(charger, 'max_charging_power', 0.0), 0.0) + return action * max_power if max_power > 0.0 else 0.0 + if action < 0.0: + max_power = self._safe_scalar(getattr(charger, 'max_discharging_power', 0.0), 0.0) + return -abs(action) * max_power if max_power > 0.0 else 0.0 + return 0.0 + + def _charger_action_from_power_kw(self, charger, target_power_kw: float) -> float: + target_power_kw = self._safe_scalar(target_power_kw, 0.0) + + if target_power_kw > 0.0: + max_power = self._safe_scalar(getattr(charger, 'max_charging_power', 0.0), 0.0) + min_power = self._safe_scalar(getattr(charger, 'min_charging_power', 0.0), 0.0) + if max_power <= 0.0: + return 0.0 + if min_power > 0.0 and target_power_kw < min_power: + return 0.0 + return float(np.clip(target_power_kw / max_power, 0.0, 1.0)) + + if target_power_kw < 0.0: + max_power = self._safe_scalar(getattr(charger, 'max_discharging_power', 0.0), 0.0) + min_power = self._safe_scalar(getattr(charger, 'min_discharging_power', 0.0), 0.0) + requested = abs(target_power_kw) + if max_power <= 0.0: + return 0.0 + if min_power > 0.0 and requested < min_power: + return 0.0 + return float(-np.clip(requested / max_power, 0.0, 1.0)) + + return 0.0 + + def _storage_requested_power_kw(self, action: Optional[float]) -> float: + building = self.building + if action is None: + return 0.0 + action = self._safe_scalar(action, 0.0) + action = float(np.clip(action, -1.0, 1.0)) + nominal_power = self._safe_scalar(getattr(building.electrical_storage, 'nominal_power', 0.0), 0.0) + return action * nominal_power if nominal_power > 0.0 else 0.0 + + def _storage_action_from_power_kw(self, target_power_kw: float) -> float: + building = self.building + nominal_power = self._safe_scalar(getattr(building.electrical_storage, 'nominal_power', 0.0), 0.0) + if nominal_power <= 0.0: + return 0.0 + return float(np.clip(target_power_kw / nominal_power, -1.0, 1.0)) + + def _compute_totals(self, base_total_kw: float, base_phase_kw: Mapping[str, float], controls, scales): + total_kw = float(base_total_kw) + phase_kw = {phase: float(value) for phase, value in base_phase_kw.items()} + + for control_id, control in controls.items(): + scale = self._safe_scalar(scales.get(control_id, 1.0), 1.0) + total_kw += control['request_total_kw'] * scale + for phase_name, value in control['request_phase_kw'].items(): + phase_kw[phase_name] = phase_kw.get(phase_name, 0.0) + (value * scale) + + return total_kw, phase_kw + + def _scale_for_import_scope(self, current_value_kw, limit_kw, controls, scales, component_getter) -> bool: + limit_kw = self._safe_scalar(limit_kw, np.nan) + current_value_kw = self._safe_scalar(current_value_kw, 0.0) + if not np.isfinite(limit_kw): + return False + if limit_kw is None or current_value_kw <= limit_kw + 1e-9: + return False + + relevant = [] + for control_id, control in controls.items(): + component_kw = component_getter(control) + if component_kw > 0.0 and self._safe_scalar(scales.get(control_id, 0.0), 0.0) > 0.0: + relevant.append((control_id, component_kw)) + + if not relevant: + return False + + current_relevant_kw = sum(scales[control_id] * component_kw for control_id, component_kw in relevant) + if current_relevant_kw <= 1e-9: + return False + + fixed_kw = current_value_kw - current_relevant_kw + allowed_kw = limit_kw - fixed_kw + factor = 0.0 if allowed_kw <= 0.0 else min(1.0, allowed_kw / current_relevant_kw) + if factor >= 1.0 - 1e-9: + return False + + for control_id, _ in relevant: + scales[control_id] *= factor + + return True + + def _scale_for_export_scope(self, current_value_kw, limit_kw, controls, scales, component_getter) -> bool: + limit_kw = self._safe_scalar(limit_kw, np.nan) + current_value_kw = self._safe_scalar(current_value_kw, 0.0) + if not np.isfinite(limit_kw): + return False + if limit_kw is None: + return False + + current_export_kw = max(-current_value_kw, 0.0) + if current_export_kw <= limit_kw + 1e-9: + return False + + relevant = [] + for control_id, control in controls.items(): + component_kw = component_getter(control) + if component_kw < 0.0 and self._safe_scalar(scales.get(control_id, 0.0), 0.0) > 0.0: + relevant.append((control_id, component_kw)) + + if not relevant: + return False + + current_relevant_export_kw = sum(scales[control_id] * abs(component_kw) for control_id, component_kw in relevant) + if current_relevant_export_kw <= 1e-9: + return False + + fixed_kw = current_value_kw + current_relevant_export_kw + allowed_export_kw = limit_kw + fixed_kw + factor = 0.0 if allowed_export_kw <= 0.0 else min(1.0, allowed_export_kw / current_relevant_export_kw) + if factor >= 1.0 - 1e-9: + return False + + for control_id, _ in relevant: + scales[control_id] *= factor + + return True + + def _apply_legacy_charging_constraints(self, actions: Optional[Mapping[str, float]]) -> Optional[Mapping[str, float]]: + building = self.building + + if not actions: + building._set_default_charging_headroom() + return actions + + positive_requests = {} + scales = {} + for charger_id, action in actions.items(): + if action is None or action <= 0.0: + continue + charger = building._charger_lookup.get(charger_id) + if charger is None: + continue + max_power = getattr(charger, 'max_charging_power', 0.0) or 0.0 + if max_power <= 0.0: + continue + positive_requests[charger_id] = action * max_power + scales[charger_id] = 1.0 + + violation_kw = 0.0 + + if positive_requests: + total_kw = sum(positive_requests.values()) + building_limit = building._building_charger_limit_kw + building_limit = self._safe_scalar(building_limit, np.nan) + if np.isfinite(building_limit) and building_limit >= 0.0 and total_kw > building_limit: + scale = 0.0 if building_limit == 0 else building_limit / total_kw + for charger_id in scales: + scales[charger_id] *= scale + violation_kw += total_kw - building_limit + + for phase in building._phase_limits: + limit = phase.get('limit_kw') + limit = self._safe_scalar(limit, np.nan) + if not np.isfinite(limit) or limit < 0.0: + continue + chargers = phase.get('chargers', []) or [] + phase_sum = sum( + positive_requests.get(charger_id, 0.0) * scales.get(charger_id, 1.0) + for charger_id in chargers + if charger_id in positive_requests + ) + if phase_sum > limit: + phase_scale = 0.0 if limit == 0 else limit / phase_sum + for charger_id in chargers: + if charger_id in scales: + scales[charger_id] *= phase_scale + violation_kw += phase_sum - limit + + scaled_positive_kw = { + charger_id: positive_requests[charger_id] * scales.get(charger_id, 1.0) + for charger_id in positive_requests + } + used_kw = sum(scaled_positive_kw.values()) + + actions = dict(actions) + for charger_id, action in list(actions.items()): + if action is None or action <= 0.0: + continue + charger = building._charger_lookup.get(charger_id) + if charger is None: + continue + max_power = getattr(charger, 'max_charging_power', 0.0) or 0.0 + if max_power <= 0.0: + actions[charger_id] = 0.0 + continue + target_kw = scaled_positive_kw.get(charger_id, 0.0) + actions[charger_id] = max(0.0, min(action, target_kw / max_power)) + + if getattr(building, '_expose_charging_constraints', False): + building_limit = self._safe_scalar(building._building_charger_limit_kw, np.nan) + building_headroom = None if not np.isfinite(building_limit) else building_limit - used_kw + phase_headroom = {} + for phase in building._phase_limits: + limit = phase.get('limit_kw') + limit = self._safe_scalar(limit, np.nan) + if not np.isfinite(limit): + phase_headroom[phase['name']] = None + else: + used = sum(scaled_positive_kw.get(charger_id, 0.0) for charger_id in phase.get('chargers', [])) + phase_headroom[phase['name']] = limit - used + + building._charging_constraints_state = { + 'building_headroom_kw': building_headroom, + 'building_export_headroom_kw': None, + 'phase_headroom_kw': phase_headroom, + 'phase_export_headroom_kw': {}, + 'total_power_kw': used_kw, + 'phase_power_kw': {}, + } + + penalty_kwh = self._safe_scalar(violation_kw * (building.seconds_per_time_step / 3600), 0.0) + building._charging_constraint_penalty_kwh = penalty_kwh + building._charging_constraint_last_penalty_kwh = penalty_kwh + phase_power = {} + if getattr(building, '_electrical_service_enabled', False): + phase_power = dict((building._charging_constraints_state or {}).get('phase_power_kw') or {}) + building._record_charging_constraint_state( + violation_kwh=penalty_kwh, + total_power_kw=float((building._charging_constraints_state or {}).get('total_power_kw', used_kw)), + phase_power_kw=phase_power, + ) + + else: + building._set_default_charging_headroom() + building._record_charging_constraint_state( + violation_kwh=0.0, + total_power_kw=0.0, + phase_power_kw={}, + ) + + return actions + + def _apply_electrical_service_constraints( + self, + actions: Optional[Mapping[str, float]], + electrical_storage_action: Optional[float], + ) -> Tuple[Optional[Mapping[str, float]], Optional[float]]: + building = self.building + phase_names = self._current_phase_names() + base_total_kw, base_phase_kw = self._estimate_non_controllable_base_power() + base_phase_kw = {phase: base_phase_kw.get(phase, 0.0) for phase in phase_names} + + controls = {} + adjusted_actions = None if actions is None else dict(actions) + + for charger_id, action in (actions or {}).items(): + charger = building._charger_lookup.get(charger_id) + if charger is None: + continue + + request_total_kw = self._charger_requested_power_kw(charger, action) + if abs(request_total_kw) <= 1e-9: + continue + + phase_connection = building._charger_phase_map.get(charger_id) + request_phase_kw = self._split_power_by_connection(request_total_kw, phase_connection) + controls[charger_id] = { + 'request_total_kw': request_total_kw, + 'request_phase_kw': request_phase_kw, + } + + storage_control_id = '__electrical_storage__' + request_storage_kw = self._storage_requested_power_kw(electrical_storage_action) + if abs(request_storage_kw) > 1e-9: + request_phase_kw = self._split_power_by_connection(request_storage_kw, building.electrical_storage_phase_connection) + controls[storage_control_id] = { + 'request_total_kw': request_storage_kw, + 'request_phase_kw': request_phase_kw, + } + + scales = {control_id: 1.0 for control_id in controls} + total_limits = building._electrical_service_limits.get('total', {}) + per_phase_limits = building._electrical_service_limits.get('per_phase', {}) + + for _ in range(8): + changed = False + total_kw, phase_kw = self._compute_totals(base_total_kw, base_phase_kw, controls, scales) + + changed |= self._scale_for_import_scope( + total_kw, + total_limits.get('import_kw'), + controls, + scales, + component_getter=lambda c: c['request_total_kw'], + ) + changed |= self._scale_for_export_scope( + total_kw, + total_limits.get('export_kw'), + controls, + scales, + component_getter=lambda c: c['request_total_kw'], + ) + + for phase_name in phase_names: + phase_limit = per_phase_limits.get(phase_name, {}) + changed |= self._scale_for_import_scope( + phase_kw.get(phase_name, 0.0), + phase_limit.get('import_kw'), + controls, + scales, + component_getter=lambda c, p=phase_name: c['request_phase_kw'].get(p, 0.0), + ) + changed |= self._scale_for_export_scope( + phase_kw.get(phase_name, 0.0), + phase_limit.get('export_kw'), + controls, + scales, + component_getter=lambda c, p=phase_name: c['request_phase_kw'].get(p, 0.0), + ) + + if not changed: + break + + total_kw, phase_kw = self._compute_totals(base_total_kw, base_phase_kw, controls, scales) + total_kw = self._safe_scalar(total_kw, 0.0) + phase_kw = {phase: self._safe_scalar(value, 0.0) for phase, value in phase_kw.items()} + + if adjusted_actions is not None: + for charger_id in adjusted_actions: + charger = building._charger_lookup.get(charger_id) + if charger is None: + continue + control = controls.get(charger_id) + target_kw = 0.0 if control is None else control['request_total_kw'] * scales.get(charger_id, 1.0) + adjusted_actions[charger_id] = self._charger_action_from_power_kw(charger, target_kw) + + adjusted_storage_action = electrical_storage_action + if electrical_storage_action is not None: + storage_control = controls.get(storage_control_id) + target_kw = 0.0 if storage_control is None else storage_control['request_total_kw'] * scales.get(storage_control_id, 1.0) + adjusted_storage_action = self._storage_action_from_power_kw(target_kw) + + violation_kw = 0.0 + import_limit = self._safe_scalar(total_limits.get('import_kw'), np.nan) + export_limit = self._safe_scalar(total_limits.get('export_kw'), np.nan) + if np.isfinite(import_limit): + violation_kw += max(total_kw - import_limit, 0.0) + if np.isfinite(export_limit): + violation_kw += max(-total_kw - export_limit, 0.0) + + phase_headroom = {} + phase_export_headroom = {} + for phase_name in phase_names: + phase_total = self._safe_scalar(phase_kw.get(phase_name, 0.0), 0.0) + phase_limit = per_phase_limits.get(phase_name, {}) + phase_import_limit = self._safe_scalar(phase_limit.get('import_kw'), np.nan) + phase_export_limit = self._safe_scalar(phase_limit.get('export_kw'), np.nan) + + phase_headroom[phase_name] = None if not np.isfinite(phase_import_limit) else (phase_import_limit - phase_total) + phase_export_headroom[phase_name] = None if not np.isfinite(phase_export_limit) else (phase_export_limit + phase_total) + + if np.isfinite(phase_import_limit): + violation_kw += max(phase_total - phase_import_limit, 0.0) + if np.isfinite(phase_export_limit): + violation_kw += max(-phase_total - phase_export_limit, 0.0) + + building_headroom = None if not np.isfinite(import_limit) else (import_limit - total_kw) + building_export_headroom = None if not np.isfinite(export_limit) else (export_limit + total_kw) + building._charging_constraints_state = { + 'building_headroom_kw': building_headroom, + 'building_export_headroom_kw': building_export_headroom, + 'phase_headroom_kw': phase_headroom, + 'phase_export_headroom_kw': phase_export_headroom, + 'total_power_kw': total_kw, + 'phase_power_kw': phase_kw, + } + + penalty_kwh = self._safe_scalar(violation_kw * (building.seconds_per_time_step / 3600.0), 0.0) + building._charging_constraint_penalty_kwh = penalty_kwh + building._charging_constraint_last_penalty_kwh = penalty_kwh + building._record_charging_constraint_state( + violation_kwh=penalty_kwh, + total_power_kw=float(total_kw), + phase_power_kw=phase_kw, + ) + + return adjusted_actions, adjusted_storage_action + + def apply_charging_constraints_to_actions( + self, + actions: Optional[Mapping[str, float]], + electrical_storage_action: Optional[float] = None, + ) -> Tuple[Optional[Mapping[str, float]], Optional[float]]: + """Apply configured electrical constraints and return adjusted EV/storage actions.""" + + building = self.building + + building._charging_constraint_penalty_kwh = 0.0 + building._charging_constraint_last_penalty_kwh = 0.0 + + if not building._charging_constraints_enabled: + return actions, electrical_storage_action + + if getattr(building, '_electrical_service_enabled', False): + return self._apply_electrical_service_constraints(actions, electrical_storage_action) + + adjusted_actions = self._apply_legacy_charging_constraints(actions) + return adjusted_actions, electrical_storage_action diff --git a/citylearn/internal/kpi.py b/citylearn/internal/kpi.py new file mode 100644 index 000000000..55d6a6520 --- /dev/null +++ b/citylearn/internal/kpi.py @@ -0,0 +1,862 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple + +import numpy as np +import pandas as pd + +from citylearn.cost_function import CostFunction +from citylearn.data import EnergySimulation, ZERO_DIVISION_PLACEHOLDER + +if TYPE_CHECKING: + from citylearn.citylearn import CityLearnEnv + + +class CityLearnKPIService: + """Internal KPI/evaluation service for `CityLearnEnv`.""" + + def __init__(self, env: "CityLearnEnv"): + self.env = env + + @staticmethod + def _to_scalar(value, default: float = 0.0) -> float: + try: + scalar = float(value) + except (TypeError, ValueError): + return float(default) + + if not np.isfinite(scalar): + return float(default) + + return scalar + + @staticmethod + def _safe_div(control_value: float, baseline_value: float): + c = CityLearnKPIService._to_scalar(control_value, 0.0) + b = CityLearnKPIService._to_scalar(baseline_value, 0.0) + eps = float(ZERO_DIVISION_PLACEHOLDER) + + if abs(b) <= eps: + return 1.0 if abs(c) <= eps else None + + return c / b + + @staticmethod + def _window_steps(window_seconds: float, seconds_per_time_step: float) -> int: + step_seconds = max(float(seconds_per_time_step), 1.0) + return max(1, int(round(float(window_seconds) / step_seconds))) + + @staticmethod + def _simulated_days(env: "CityLearnEnv") -> float: + steps = max(int(getattr(env, 'time_step', 0)), 1) + step_seconds = max(float(getattr(env, 'seconds_per_time_step', 0) or 0), 1.0) + return (steps * step_seconds) / (24.0 * 3600.0) + + @staticmethod + def _daily_average(total_value: float, simulated_days: float) -> Optional[float]: + value = CityLearnKPIService._to_scalar(total_value, np.nan) + + if not np.isfinite(value): + return None + + if simulated_days <= float(ZERO_DIVISION_PLACEHOLDER): + return None + + return float(value / simulated_days) + + @staticmethod + def _normalize_soc_target(value) -> Optional[float]: + try: + target = float(value) + except (TypeError, ValueError): + return None + + if not np.isfinite(target): + return None + + if target > 1.0 and target <= 100.0: + target = target / 100.0 + + if target < 0.0 or target > 1.0: + return None + + return float(target) + + @staticmethod + def _metric(cost_function: str, value, name: str, level: str) -> Dict[str, object]: + return { + 'cost_function': cost_function, + 'value': value, + 'name': name, + 'level': level, + } + + @staticmethod + def _sum_finite(values) -> float: + try: + series = np.array(values, dtype='float64').flatten() + except (TypeError, ValueError): + return 0.0 + + if series.size == 0: + return 0.0 + + finite = series[np.isfinite(series)] + if finite.size == 0: + return 0.0 + + return float(finite.sum()) + + @staticmethod + def _equity_relative_benefit_percent(cost_scenario: float, cost_baseline: float) -> Optional[float]: + scenario = CityLearnKPIService._to_scalar(cost_scenario, np.nan) + baseline = CityLearnKPIService._to_scalar(cost_baseline, np.nan) + + if not np.isfinite(scenario) or not np.isfinite(baseline) or baseline <= 0.0: + return None + + return float(100.0 * (baseline - scenario) / baseline) + + @staticmethod + def _equity_distribution_metrics(relative_benefits: np.ndarray) -> Dict[str, Optional[float]]: + benefits = np.array(relative_benefits, dtype='float64') + benefits = benefits[np.isfinite(benefits)] + + if benefits.size == 0: + return { + 'equity_gini_benefit': None, + 'equity_cr20_benefit': None, + 'equity_losers_percent': None, + } + + losers_percent = float(100.0 * np.count_nonzero(benefits < 0.0) / benefits.size) + benefits_plus = np.clip(benefits, 0.0, None) + total_plus = float(benefits_plus.sum()) + + if total_plus <= 0.0: + return { + 'equity_gini_benefit': None, + 'equity_cr20_benefit': None, + 'equity_losers_percent': losers_percent, + } + + n = benefits_plus.size + diff_sum = float(np.abs(benefits_plus[:, None] - benefits_plus[None, :]).sum()) + gini = float(diff_sum / (2.0 * n * total_plus)) + + k = max(1, int(np.ceil(0.2 * n))) + top_sum = float(np.sort(benefits_plus)[::-1][:k].sum()) + cr20 = float(top_sum / total_plus) + + return { + 'equity_gini_benefit': gini, + 'equity_cr20_benefit': cr20, + 'equity_losers_percent': losers_percent, + } + + @staticmethod + def _equity_bpr( + non_negative_relative_benefits: Mapping[str, float], + groups: Mapping[str, Optional[str]], + ) -> Optional[float]: + if len(non_negative_relative_benefits) == 0: + return None + + asset_poor_values = [] + asset_rich_values = [] + + for building_name, value in non_negative_relative_benefits.items(): + group = groups.get(building_name) + + if group == 'asset_poor': + asset_poor_values.append(float(value)) + elif group == 'asset_rich': + asset_rich_values.append(float(value)) + else: + return None + + if len(asset_poor_values) == 0 or len(asset_rich_values) == 0: + return None + + rich_mean = float(np.mean(asset_rich_values)) + poor_mean = float(np.mean(asset_poor_values)) + + if rich_mean <= 0.0: + return None + + return float(poor_mean / rich_mean) + + def _compute_ev_metrics(self, building) -> Dict[str, float]: + t_final = int(max(building.time_step, 0)) + departures_total = 0 + departures_met = 0 + departure_deficit_sum = 0.0 + charge_total_kwh = 0.0 + v2g_export_total_kwh = 0.0 + + for charger in building.electric_vehicle_chargers or []: + consumption = np.array(charger.electricity_consumption[0:t_final + 1], dtype='float64') + charge_total_kwh += float(np.clip(consumption, 0.0, None).sum()) + v2g_export_total_kwh += float(np.clip(-consumption, 0.0, None).sum()) + + sim = charger.charger_simulation + states = np.array(sim.electric_vehicle_charger_state, dtype='float64') + required_soc = np.array(sim.electric_vehicle_required_soc_departure, dtype='float64') + history_limit = min(t_final, len(states) - 2, len(required_soc) - 1, len(charger.past_connected_evs) - 1) + + if history_limit < 0: + continue + + for t in range(history_limit + 1): + current_state = states[t] + next_state = states[t + 1] + + if current_state != 1 or next_state == 1: + continue + + ev = charger.past_connected_evs[t] + if ev is None: + continue + + target_soc = self._normalize_soc_target(required_soc[t]) + if target_soc is None: + continue + + if t >= len(ev.battery.soc): + continue + + actual_soc = self._to_scalar(ev.battery.soc[t], np.nan) + if not np.isfinite(actual_soc): + continue + + departures_total += 1 + deficit = max(target_soc - actual_soc, 0.0) + departure_deficit_sum += deficit + if deficit <= 1e-6: + departures_met += 1 + + success_rate = None if departures_total == 0 else departures_met / departures_total + deficit_mean = None if departures_total == 0 else departure_deficit_sum / departures_total + + return { + 'departures_total': float(departures_total), + 'departures_met': float(departures_met), + 'departure_deficit_sum': float(departure_deficit_sum), + 'ev_departure_success_rate': success_rate, + 'ev_departure_soc_deficit_mean': deficit_mean, + 'ev_charge_total_kwh': float(charge_total_kwh), + 'ev_v2g_export_total_kwh': float(v2g_export_total_kwh), + } + + def _compute_bess_metrics(self, building) -> Dict[str, float]: + t_final = int(max(building.time_step, 0)) + storage = building.electrical_storage + storage_series = np.array(building.electrical_storage_electricity_consumption[0:t_final + 1], dtype='float64') + charge_total = float(np.clip(storage_series, 0.0, None).sum()) + discharge_total = float(np.clip(-storage_series, 0.0, None).sum()) + throughput_total = charge_total + discharge_total + + capacity = self._to_scalar(getattr(storage, 'capacity', 0.0), 0.0) + degraded_capacity = self._to_scalar(getattr(storage, 'degraded_capacity', capacity), capacity) + equivalent_cycles = None if capacity <= 0.0 else throughput_total / (2.0 * capacity) + fade_ratio = None if capacity <= 0.0 else (capacity - degraded_capacity) / capacity + + if fade_ratio is not None: + fade_ratio = float(np.clip(fade_ratio, 0.0, 1.0)) + + return { + 'bess_charge_total_kwh': charge_total, + 'bess_discharge_total_kwh': discharge_total, + 'bess_throughput_total_kwh': throughput_total, + 'bess_equivalent_full_cycles': equivalent_cycles, + 'bess_capacity_fade_ratio': fade_ratio, + '_bess_capacity_kwh': capacity, + '_bess_degraded_capacity_kwh': degraded_capacity, + } + + def _compute_pv_metrics(self, building) -> Dict[str, float]: + t_final = int(max(building.time_step, 0)) + solar = np.array(building.solar_generation[0:t_final + 1], dtype='float64') + net = np.array(building.net_electricity_consumption[0:t_final + 1], dtype='float64') + + generation = np.clip(-solar, 0.0, None) + export = np.clip(-net, 0.0, None) + pv_generation_total = float(generation.sum()) + pv_export_total = float(np.minimum(generation, export).sum()) + self_consumption_ratio = None if pv_generation_total <= 0.0 else (pv_generation_total - pv_export_total) / pv_generation_total + + return { + 'pv_generation_total_kwh': pv_generation_total, + 'pv_export_total_kwh': pv_export_total, + 'pv_self_consumption_ratio': self_consumption_ratio, + } + + def _compute_phase_metrics(self, building) -> Dict[str, object]: + if not getattr(building, '_electrical_service_enabled', False): + return { + 'electrical_service_violation_total_kwh': 0.0, + 'electrical_service_violation_time_step_count': 0.0, + 'phase_imbalance_ratio_average': None, + 'phase_import_peak_kw': {}, + 'phase_export_peak_kw': {}, + '_imbalance_sum': 0.0, + '_imbalance_count': 0.0, + } + + t_final = int(max(building.time_step, 0)) + violation_history = np.array(getattr(building, '_charging_constraint_violation_history', [0.0]), dtype='float64')[0:t_final + 1] + violation_total = float(np.clip(violation_history, 0.0, None).sum()) + violation_count = float(np.count_nonzero(violation_history > 1e-9)) + + phase_history = getattr(building, '_charging_phase_power_history_kw', {}) or {} + phase_import_peak = {} + phase_export_peak = {} + + for phase_name, values in phase_history.items(): + series = np.array(values[0:t_final + 1], dtype='float64') + phase_import_peak[phase_name] = float(np.clip(series, 0.0, None).max(initial=0.0)) + phase_export_peak[phase_name] = float(np.clip(-series, 0.0, None).max(initial=0.0)) + + imbalance_sum = 0.0 + imbalance_count = 0.0 + imbalance_average = None + + if getattr(building, '_electrical_service_mode', 'single_phase') == 'three_phase': + names = [n for n in ['L1', 'L2', 'L3'] if n in phase_history] + if len(names) == 3: + stacked = np.stack([np.array(phase_history[n][0:t_final + 1], dtype='float64') for n in names], axis=1) + for row in stacked: + abs_row = np.abs(row) + mean_abs = float(abs_row.mean()) + if mean_abs <= 1e-9: + ratio = 0.0 + else: + ratio = float((abs_row.max() - abs_row.min()) / mean_abs) + imbalance_sum += ratio + imbalance_count += 1.0 + + if imbalance_count > 0: + imbalance_average = imbalance_sum / imbalance_count + + return { + 'electrical_service_violation_total_kwh': violation_total, + 'electrical_service_violation_time_step_count': violation_count, + 'phase_imbalance_ratio_average': imbalance_average, + 'phase_import_peak_kw': phase_import_peak, + 'phase_export_peak_kw': phase_export_peak, + '_imbalance_sum': imbalance_sum, + '_imbalance_count': imbalance_count, + } + + def _collect_market_totals(self, building_names: List[str]) -> Tuple[Mapping[str, Mapping[str, float]], Mapping[str, float]]: + history = getattr(self.env, '_community_market_settlement_history', []) or [] + by_building = { + name: { + 'community_local_import_total_kwh': 0.0, + 'community_local_export_total_kwh': 0.0, + 'community_grid_import_after_local_total_kwh': 0.0, + 'community_grid_export_after_local_total_kwh': 0.0, + 'community_settled_cost_total_eur': 0.0, + 'community_counterfactual_cost_total_eur': 0.0, + 'community_market_savings_total_eur': 0.0, + } + for name in building_names + } + + for rows in history: + for row in rows: + name = row.get('building') + if name not in by_building: + continue + + target = by_building[name] + target['community_local_import_total_kwh'] += self._to_scalar(row.get('local_import_kwh'), 0.0) + target['community_local_export_total_kwh'] += self._to_scalar(row.get('local_export_kwh'), 0.0) + target['community_grid_import_after_local_total_kwh'] += self._to_scalar(row.get('grid_import_kwh'), 0.0) + target['community_grid_export_after_local_total_kwh'] += self._to_scalar(row.get('grid_export_kwh'), 0.0) + target['community_settled_cost_total_eur'] += self._to_scalar(row.get('settled_cost_eur', row.get('settled_cost')), 0.0) + target['community_counterfactual_cost_total_eur'] += self._to_scalar(row.get('counterfactual_cost_eur'), 0.0) + target['community_market_savings_total_eur'] += self._to_scalar(row.get('market_savings_eur'), 0.0) + + district = { + key: float(sum(values[key] for values in by_building.values())) + for key in [ + 'community_local_import_total_kwh', + 'community_local_export_total_kwh', + 'community_grid_import_after_local_total_kwh', + 'community_grid_export_after_local_total_kwh', + 'community_settled_cost_total_eur', + 'community_counterfactual_cost_total_eur', + 'community_market_savings_total_eur', + ] + } + + return by_building, district + + def evaluate( + self, + control_condition=None, + baseline_condition=None, + comfort_band: float = None, + *, + evaluation_condition_cls, + dynamics_building_cls, + ) -> pd.DataFrame: + """Evaluate cost functions at current time step.""" + + env = self.env + + get_net_electricity_consumption = lambda x, c: getattr(x, f'net_electricity_consumption{c.value}') + get_net_electricity_consumption_cost = lambda x, c: getattr(x, f'net_electricity_consumption_cost{c.value}') + get_net_electricity_consumption_emission = lambda x, c: getattr(x, f'net_electricity_consumption_emission{c.value}') + + comfort_band = EnergySimulation.DEFUALT_COMFORT_BAND if comfort_band is None else comfort_band + daily_steps = self._window_steps(24.0 * 3600.0, env.seconds_per_time_step) + monthly_steps = self._window_steps(730.0 * 3600.0, env.seconds_per_time_step) + simulated_days = self._simulated_days(env) + + legacy_building_frames: List[pd.DataFrame] = [] + extended_building_rows: List[Dict[str, object]] = [] + + ev_departures_total = 0.0 + ev_departures_met = 0.0 + ev_deficit_sum = 0.0 + ev_charge_total = 0.0 + ev_v2g_total = 0.0 + + bess_charge_total = 0.0 + bess_discharge_total = 0.0 + bess_throughput_total = 0.0 + bess_capacity_total = 0.0 + bess_capacity_loss_total = 0.0 + + pv_generation_total = 0.0 + pv_export_total = 0.0 + + phase_violation_total = 0.0 + phase_violation_count = 0.0 + phase_imbalance_sum = 0.0 + phase_imbalance_count = 0.0 + + building_names = [building.name for building in env.buildings] + equity_group_by_building = {building.name: getattr(building, 'equity_group', None) for building in env.buildings} + equity_relative_benefit_by_building: Dict[str, Optional[float]] = {} + equity_valid_benefits: Dict[str, float] = {} + + for building in env.buildings: + if isinstance(building, dynamics_building_cls): + building_control_condition = ( + evaluation_condition_cls.WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV + if control_condition is None else control_condition + ) + building_baseline_condition = ( + evaluation_condition_cls.WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV + if baseline_condition is None else baseline_condition + ) + else: + building_control_condition = ( + evaluation_condition_cls.WITH_STORAGE_AND_PV + if control_condition is None else control_condition + ) + building_baseline_condition = ( + evaluation_condition_cls.WITHOUT_STORAGE_BUT_WITH_PV + if baseline_condition is None else baseline_condition + ) + + discomfort_kwargs = { + 'indoor_dry_bulb_temperature': building.indoor_dry_bulb_temperature, + 'dry_bulb_temperature_cooling_set_point': building.indoor_dry_bulb_temperature_cooling_set_point, + 'dry_bulb_temperature_heating_set_point': building.indoor_dry_bulb_temperature_heating_set_point, + 'band': building.comfort_band if comfort_band is None else comfort_band, + 'occupant_count': building.occupant_count, + } + unmet, cold, hot, \ + cold_minimum_delta, cold_maximum_delta, cold_average_delta, \ + hot_minimum_delta, hot_maximum_delta, hot_average_delta = CostFunction.discomfort(**discomfort_kwargs) + expected_energy = building.cooling_demand + building.heating_demand + building.dhw_demand + building.non_shiftable_load + served_energy = building.energy_from_cooling_device + building.energy_from_cooling_storage \ + + building.energy_from_heating_device + building.energy_from_heating_storage \ + + building.energy_from_dhw_device + building.energy_from_dhw_storage \ + + building.energy_to_non_shiftable_load + ec_c = CostFunction.electricity_consumption(get_net_electricity_consumption(building, building_control_condition))[-1] + ec_b = CostFunction.electricity_consumption(get_net_electricity_consumption(building, building_baseline_condition))[-1] + zne_c = CostFunction.zero_net_energy(get_net_electricity_consumption(building, building_control_condition))[-1] + zne_b = CostFunction.zero_net_energy(get_net_electricity_consumption(building, building_baseline_condition))[-1] + ce_c = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(building, building_control_condition))[-1] + ce_b = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(building, building_baseline_condition))[-1] if sum(building.carbon_intensity.carbon_intensity) != 0 else 0 + control_cost_series = get_net_electricity_consumption_cost(building, building_control_condition) + baseline_cost_series = get_net_electricity_consumption_cost(building, building_baseline_condition) + cost_c_legacy = CostFunction.cost(control_cost_series)[-1] + cost_b_legacy = CostFunction.cost(baseline_cost_series)[-1] + cost_c_raw = self._sum_finite(control_cost_series) + cost_b_raw = self._sum_finite(baseline_cost_series) + equity_benefit = self._equity_relative_benefit_percent(cost_c_raw, cost_b_raw) + equity_relative_benefit_by_building[building.name] = equity_benefit + + if equity_benefit is not None: + equity_valid_benefits[building.name] = float(equity_benefit) + + legacy_building_frame = pd.DataFrame([{ + 'cost_function': 'electricity_consumption_total', + 'value': self._safe_div(ec_c, ec_b), + }, { + 'cost_function': 'zero_net_energy', + 'value': self._safe_div(zne_c, zne_b), + }, { + 'cost_function': 'carbon_emissions_total', + 'value': self._safe_div(ce_c, ce_b), + }, { + 'cost_function': 'cost_total', + 'value': self._safe_div(cost_c_legacy, cost_b_legacy), + }, { + 'cost_function': 'discomfort_proportion', + 'value': unmet[-1], + }, { + 'cost_function': 'discomfort_cold_proportion', + 'value': cold[-1], + }, { + 'cost_function': 'discomfort_hot_proportion', + 'value': hot[-1], + }, { + 'cost_function': 'discomfort_cold_delta_minimum', + 'value': cold_minimum_delta[-1], + }, { + 'cost_function': 'discomfort_cold_delta_maximum', + 'value': cold_maximum_delta[-1], + }, { + 'cost_function': 'discomfort_cold_delta_average', + 'value': cold_average_delta[-1], + }, { + 'cost_function': 'discomfort_hot_delta_minimum', + 'value': hot_minimum_delta[-1], + }, { + 'cost_function': 'discomfort_hot_delta_maximum', + 'value': hot_maximum_delta[-1], + }, { + 'cost_function': 'discomfort_hot_delta_average', + 'value': hot_average_delta[-1], + }, { + 'cost_function': 'one_minus_thermal_resilience_proportion', + 'value': CostFunction.one_minus_thermal_resilience(power_outage=building.power_outage_signal, **discomfort_kwargs)[-1], + }, { + 'cost_function': 'power_outage_normalized_unserved_energy_total', + 'value': CostFunction.normalized_unserved_energy(expected_energy, served_energy, power_outage=building.power_outage_signal)[-1], + }, { + 'cost_function': 'annual_normalized_unserved_energy_total', + 'value': CostFunction.normalized_unserved_energy(expected_energy, served_energy)[-1], + }]) + legacy_building_frame['name'] = building.name + legacy_building_frames.append(legacy_building_frame) + + extended_building_rows.extend([ + self._metric('electricity_consumption_control_total_kwh', ec_c, building.name, 'building'), + self._metric('electricity_consumption_baseline_total_kwh', ec_b, building.name, 'building'), + self._metric('electricity_consumption_delta_total_kwh', ec_c - ec_b, building.name, 'building'), + self._metric('electricity_consumption_control_daily_average_kwh', self._daily_average(ec_c, simulated_days), building.name, 'building'), + self._metric('electricity_consumption_baseline_daily_average_kwh', self._daily_average(ec_b, simulated_days), building.name, 'building'), + self._metric('electricity_consumption_delta_daily_average_kwh', self._daily_average(ec_c - ec_b, simulated_days), building.name, 'building'), + self._metric('zero_net_energy_control_total_kwh', zne_c, building.name, 'building'), + self._metric('zero_net_energy_baseline_total_kwh', zne_b, building.name, 'building'), + self._metric('zero_net_energy_delta_total_kwh', zne_c - zne_b, building.name, 'building'), + self._metric('zero_net_energy_control_daily_average_kwh', self._daily_average(zne_c, simulated_days), building.name, 'building'), + self._metric('zero_net_energy_baseline_daily_average_kwh', self._daily_average(zne_b, simulated_days), building.name, 'building'), + self._metric('zero_net_energy_delta_daily_average_kwh', self._daily_average(zne_c - zne_b, simulated_days), building.name, 'building'), + self._metric('carbon_emissions_control_total_kgco2', ce_c, building.name, 'building'), + self._metric('carbon_emissions_baseline_total_kgco2', ce_b, building.name, 'building'), + self._metric('carbon_emissions_delta_total_kgco2', ce_c - ce_b, building.name, 'building'), + self._metric('carbon_emissions_control_daily_average_kgco2', self._daily_average(ce_c, simulated_days), building.name, 'building'), + self._metric('carbon_emissions_baseline_daily_average_kgco2', self._daily_average(ce_b, simulated_days), building.name, 'building'), + self._metric('carbon_emissions_delta_daily_average_kgco2', self._daily_average(ce_c - ce_b, simulated_days), building.name, 'building'), + self._metric('cost_control_total_eur', cost_c_raw, building.name, 'building'), + self._metric('cost_baseline_total_eur', cost_b_raw, building.name, 'building'), + self._metric('cost_delta_total_eur', cost_c_raw - cost_b_raw, building.name, 'building'), + self._metric('cost_control_daily_average_eur', self._daily_average(cost_c_raw, simulated_days), building.name, 'building'), + self._metric('cost_baseline_daily_average_eur', self._daily_average(cost_b_raw, simulated_days), building.name, 'building'), + self._metric('cost_delta_daily_average_eur', self._daily_average(cost_c_raw - cost_b_raw, simulated_days), building.name, 'building'), + self._metric('equity_relative_benefit_percent', equity_benefit, building.name, 'building'), + ]) + + ev_metrics = self._compute_ev_metrics(building) + extended_building_rows.extend([ + self._metric('ev_departure_success_rate', ev_metrics['ev_departure_success_rate'], building.name, 'building'), + self._metric('ev_departure_soc_deficit_mean', ev_metrics['ev_departure_soc_deficit_mean'], building.name, 'building'), + self._metric('ev_charge_total_kwh', ev_metrics['ev_charge_total_kwh'], building.name, 'building'), + self._metric('ev_v2g_export_total_kwh', ev_metrics['ev_v2g_export_total_kwh'], building.name, 'building'), + ]) + ev_departures_total += ev_metrics['departures_total'] + ev_departures_met += ev_metrics['departures_met'] + ev_deficit_sum += ev_metrics['departure_deficit_sum'] + ev_charge_total += ev_metrics['ev_charge_total_kwh'] + ev_v2g_total += ev_metrics['ev_v2g_export_total_kwh'] + + bess_metrics = self._compute_bess_metrics(building) + extended_building_rows.extend([ + self._metric('bess_charge_total_kwh', bess_metrics['bess_charge_total_kwh'], building.name, 'building'), + self._metric('bess_discharge_total_kwh', bess_metrics['bess_discharge_total_kwh'], building.name, 'building'), + self._metric('bess_throughput_total_kwh', bess_metrics['bess_throughput_total_kwh'], building.name, 'building'), + self._metric('bess_equivalent_full_cycles', bess_metrics['bess_equivalent_full_cycles'], building.name, 'building'), + self._metric('bess_capacity_fade_ratio', bess_metrics['bess_capacity_fade_ratio'], building.name, 'building'), + ]) + bess_charge_total += bess_metrics['bess_charge_total_kwh'] + bess_discharge_total += bess_metrics['bess_discharge_total_kwh'] + bess_throughput_total += bess_metrics['bess_throughput_total_kwh'] + bess_capacity_total += bess_metrics['_bess_capacity_kwh'] + bess_capacity_loss_total += max(bess_metrics['_bess_capacity_kwh'] - bess_metrics['_bess_degraded_capacity_kwh'], 0.0) + + pv_metrics = self._compute_pv_metrics(building) + extended_building_rows.extend([ + self._metric('pv_generation_total_kwh', pv_metrics['pv_generation_total_kwh'], building.name, 'building'), + self._metric('pv_export_total_kwh', pv_metrics['pv_export_total_kwh'], building.name, 'building'), + self._metric('pv_generation_daily_average_kwh', self._daily_average(pv_metrics['pv_generation_total_kwh'], simulated_days), building.name, 'building'), + self._metric('pv_export_daily_average_kwh', self._daily_average(pv_metrics['pv_export_total_kwh'], simulated_days), building.name, 'building'), + self._metric('pv_self_consumption_ratio', pv_metrics['pv_self_consumption_ratio'], building.name, 'building'), + ]) + pv_generation_total += pv_metrics['pv_generation_total_kwh'] + pv_export_total += pv_metrics['pv_export_total_kwh'] + + phase_metrics = self._compute_phase_metrics(building) + extended_building_rows.extend([ + self._metric('electrical_service_violation_total_kwh', phase_metrics['electrical_service_violation_total_kwh'], building.name, 'building'), + self._metric('electrical_service_violation_time_step_count', phase_metrics['electrical_service_violation_time_step_count'], building.name, 'building'), + self._metric('phase_imbalance_ratio_average', phase_metrics['phase_imbalance_ratio_average'], building.name, 'building'), + ]) + for phase_name, value in phase_metrics['phase_import_peak_kw'].items(): + extended_building_rows.append(self._metric(f'phase_import_peak_kw_{phase_name}', value, building.name, 'building')) + for phase_name, value in phase_metrics['phase_export_peak_kw'].items(): + extended_building_rows.append(self._metric(f'phase_export_peak_kw_{phase_name}', value, building.name, 'building')) + + phase_violation_total += phase_metrics['electrical_service_violation_total_kwh'] + phase_violation_count += phase_metrics['electrical_service_violation_time_step_count'] + phase_imbalance_sum += phase_metrics['_imbalance_sum'] + phase_imbalance_count += phase_metrics['_imbalance_count'] + + legacy_building = pd.concat(legacy_building_frames, ignore_index=True) if legacy_building_frames else pd.DataFrame(columns=['cost_function', 'value', 'name']) + legacy_building['level'] = 'building' + + env_control_condition = ( + evaluation_condition_cls.WITH_STORAGE_AND_PARTIAL_LOAD_AND_PV + if control_condition is None else control_condition + ) + env_baseline_condition = ( + evaluation_condition_cls.WITHOUT_STORAGE_AND_PARTIAL_LOAD_BUT_WITH_PV + if baseline_condition is None else baseline_condition + ) + + ramp_c = CostFunction.ramping(get_net_electricity_consumption(env, env_control_condition))[-1] + ramp_b = CostFunction.ramping(get_net_electricity_consumption(env, env_baseline_condition))[-1] + dlf_daily_c = CostFunction.one_minus_load_factor(get_net_electricity_consumption(env, env_control_condition), window=daily_steps)[-1] + dlf_daily_b = CostFunction.one_minus_load_factor(get_net_electricity_consumption(env, env_baseline_condition), window=daily_steps)[-1] + dlf_monthly_c = CostFunction.one_minus_load_factor(get_net_electricity_consumption(env, env_control_condition), window=monthly_steps)[-1] + dlf_monthly_b = CostFunction.one_minus_load_factor(get_net_electricity_consumption(env, env_baseline_condition), window=monthly_steps)[-1] + peak_daily_c = CostFunction.peak(get_net_electricity_consumption(env, env_control_condition), window=daily_steps)[-1] + peak_daily_b = CostFunction.peak(get_net_electricity_consumption(env, env_baseline_condition), window=daily_steps)[-1] + peak_all_c = CostFunction.peak(get_net_electricity_consumption(env, env_control_condition), window=env.time_steps)[-1] + peak_all_b = CostFunction.peak(get_net_electricity_consumption(env, env_baseline_condition), window=env.time_steps)[-1] + + legacy_district_base = pd.DataFrame([{ + 'cost_function': 'ramping_average', + 'value': self._safe_div(ramp_c, ramp_b), + }, { + 'cost_function': 'daily_one_minus_load_factor_average', + 'value': self._safe_div(dlf_daily_c, dlf_daily_b), + }, { + 'cost_function': 'monthly_one_minus_load_factor_average', + 'value': self._safe_div(dlf_monthly_c, dlf_monthly_b), + }, { + 'cost_function': 'daily_peak_average', + 'value': self._safe_div(peak_daily_c, peak_daily_b), + }, { + 'cost_function': 'all_time_peak_average', + 'value': self._safe_div(peak_all_c, peak_all_b), + }]) + + legacy_district = pd.concat([legacy_district_base, legacy_building], ignore_index=True, sort=False) + legacy_district = legacy_district.groupby(['cost_function'])[['value']].mean().reset_index() + legacy_district['name'] = 'District' + legacy_district['level'] = 'district' + + legacy_cost_functions = pd.concat([legacy_district, legacy_building], ignore_index=True, sort=False) + + # Extended KPI district-level + ec_c_env = CostFunction.electricity_consumption(get_net_electricity_consumption(env, env_control_condition))[-1] + ec_b_env = CostFunction.electricity_consumption(get_net_electricity_consumption(env, env_baseline_condition))[-1] + zne_c_env = CostFunction.zero_net_energy(get_net_electricity_consumption(env, env_control_condition))[-1] + zne_b_env = CostFunction.zero_net_energy(get_net_electricity_consumption(env, env_baseline_condition))[-1] + ce_c_env = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(env, env_control_condition))[-1] + ce_b_env = CostFunction.carbon_emissions(get_net_electricity_consumption_emission(env, env_baseline_condition))[-1] + env_control_cost_series = get_net_electricity_consumption_cost(env, env_control_condition) + env_baseline_cost_series = get_net_electricity_consumption_cost(env, env_baseline_condition) + cost_c_env_raw = self._sum_finite(env_control_cost_series) + cost_b_env_raw = self._sum_finite(env_baseline_cost_series) + + extended_district_rows = [ + self._metric('electricity_consumption_control_total_kwh', ec_c_env, 'District', 'district'), + self._metric('electricity_consumption_baseline_total_kwh', ec_b_env, 'District', 'district'), + self._metric('electricity_consumption_delta_total_kwh', ec_c_env - ec_b_env, 'District', 'district'), + self._metric('electricity_consumption_control_daily_average_kwh', self._daily_average(ec_c_env, simulated_days), 'District', 'district'), + self._metric('electricity_consumption_baseline_daily_average_kwh', self._daily_average(ec_b_env, simulated_days), 'District', 'district'), + self._metric('electricity_consumption_delta_daily_average_kwh', self._daily_average(ec_c_env - ec_b_env, simulated_days), 'District', 'district'), + self._metric('zero_net_energy_control_total_kwh', zne_c_env, 'District', 'district'), + self._metric('zero_net_energy_baseline_total_kwh', zne_b_env, 'District', 'district'), + self._metric('zero_net_energy_delta_total_kwh', zne_c_env - zne_b_env, 'District', 'district'), + self._metric('zero_net_energy_control_daily_average_kwh', self._daily_average(zne_c_env, simulated_days), 'District', 'district'), + self._metric('zero_net_energy_baseline_daily_average_kwh', self._daily_average(zne_b_env, simulated_days), 'District', 'district'), + self._metric('zero_net_energy_delta_daily_average_kwh', self._daily_average(zne_c_env - zne_b_env, simulated_days), 'District', 'district'), + self._metric('carbon_emissions_control_total_kgco2', ce_c_env, 'District', 'district'), + self._metric('carbon_emissions_baseline_total_kgco2', ce_b_env, 'District', 'district'), + self._metric('carbon_emissions_delta_total_kgco2', ce_c_env - ce_b_env, 'District', 'district'), + self._metric('carbon_emissions_control_daily_average_kgco2', self._daily_average(ce_c_env, simulated_days), 'District', 'district'), + self._metric('carbon_emissions_baseline_daily_average_kgco2', self._daily_average(ce_b_env, simulated_days), 'District', 'district'), + self._metric('carbon_emissions_delta_daily_average_kgco2', self._daily_average(ce_c_env - ce_b_env, simulated_days), 'District', 'district'), + self._metric('cost_control_total_eur', cost_c_env_raw, 'District', 'district'), + self._metric('cost_baseline_total_eur', cost_b_env_raw, 'District', 'district'), + self._metric('cost_delta_total_eur', cost_c_env_raw - cost_b_env_raw, 'District', 'district'), + self._metric('cost_control_daily_average_eur', self._daily_average(cost_c_env_raw, simulated_days), 'District', 'district'), + self._metric('cost_baseline_daily_average_eur', self._daily_average(cost_b_env_raw, simulated_days), 'District', 'district'), + self._metric('cost_delta_daily_average_eur', self._daily_average(cost_c_env_raw - cost_b_env_raw, simulated_days), 'District', 'district'), + ] + + ev_success_rate = None if ev_departures_total <= 0.0 else ev_departures_met / ev_departures_total + ev_deficit_mean = None if ev_departures_total <= 0.0 else ev_deficit_sum / ev_departures_total + extended_district_rows.extend([ + self._metric('ev_departure_success_rate', ev_success_rate, 'District', 'district'), + self._metric('ev_departure_soc_deficit_mean', ev_deficit_mean, 'District', 'district'), + self._metric('ev_charge_total_kwh', ev_charge_total, 'District', 'district'), + self._metric('ev_v2g_export_total_kwh', ev_v2g_total, 'District', 'district'), + ]) + + district_bess_cycles = None if bess_capacity_total <= 0.0 else bess_throughput_total / (2.0 * bess_capacity_total) + district_bess_fade = None if bess_capacity_total <= 0.0 else bess_capacity_loss_total / bess_capacity_total + extended_district_rows.extend([ + self._metric('bess_charge_total_kwh', bess_charge_total, 'District', 'district'), + self._metric('bess_discharge_total_kwh', bess_discharge_total, 'District', 'district'), + self._metric('bess_throughput_total_kwh', bess_throughput_total, 'District', 'district'), + self._metric('bess_equivalent_full_cycles', district_bess_cycles, 'District', 'district'), + self._metric('bess_capacity_fade_ratio', district_bess_fade, 'District', 'district'), + ]) + + district_pv_ratio = None if pv_generation_total <= 0.0 else (pv_generation_total - pv_export_total) / pv_generation_total + extended_district_rows.extend([ + self._metric('pv_generation_total_kwh', pv_generation_total, 'District', 'district'), + self._metric('pv_export_total_kwh', pv_export_total, 'District', 'district'), + self._metric('pv_generation_daily_average_kwh', self._daily_average(pv_generation_total, simulated_days), 'District', 'district'), + self._metric('pv_export_daily_average_kwh', self._daily_average(pv_export_total, simulated_days), 'District', 'district'), + self._metric('pv_self_consumption_ratio', district_pv_ratio, 'District', 'district'), + ]) + + district_phase_imbalance = None if phase_imbalance_count <= 0.0 else phase_imbalance_sum / phase_imbalance_count + extended_district_rows.extend([ + self._metric('electrical_service_violation_total_kwh', phase_violation_total, 'District', 'district'), + self._metric('electrical_service_violation_time_step_count', phase_violation_count, 'District', 'district'), + self._metric('phase_imbalance_ratio_average', district_phase_imbalance, 'District', 'district'), + ]) + + phase_union = ['L1', 'L2', 'L3'] + for phase_name in phase_union: + phase_series = None + for building in env.buildings: + history_map = getattr(building, '_charging_phase_power_history_kw', {}) or {} + if phase_name not in history_map: + continue + t_final = int(max(building.time_step, 0)) + values = np.array(history_map[phase_name][0:t_final + 1], dtype='float64') + if phase_series is None: + phase_series = np.zeros_like(values) + size = min(len(phase_series), len(values)) + phase_series[:size] += values[:size] + + if phase_series is None: + continue + + extended_district_rows.append( + self._metric(f'phase_import_peak_kw_{phase_name}', float(np.clip(phase_series, 0.0, None).max(initial=0.0)), 'District', 'district') + ) + extended_district_rows.append( + self._metric(f'phase_export_peak_kw_{phase_name}', float(np.clip(-phase_series, 0.0, None).max(initial=0.0)), 'District', 'district') + ) + + market_by_building, market_district = self._collect_market_totals(building_names) + + for building_name in building_names: + totals = market_by_building.get(building_name, {}) + local_import = self._to_scalar(totals.get('community_local_import_total_kwh'), 0.0) + local_export = self._to_scalar(totals.get('community_local_export_total_kwh'), 0.0) + grid_import = self._to_scalar(totals.get('community_grid_import_after_local_total_kwh'), 0.0) + grid_export = self._to_scalar(totals.get('community_grid_export_after_local_total_kwh'), 0.0) + import_share = None if (local_import + grid_import) <= 0.0 else local_import / (local_import + grid_import) + export_share = None if (local_export + grid_export) <= 0.0 else local_export / (local_export + grid_export) + + extended_building_rows.extend([ + self._metric('community_local_import_total_kwh', local_import, building_name, 'building'), + self._metric('community_local_export_total_kwh', local_export, building_name, 'building'), + self._metric('community_grid_import_after_local_total_kwh', grid_import, building_name, 'building'), + self._metric('community_grid_export_after_local_total_kwh', grid_export, building_name, 'building'), + self._metric('community_local_import_daily_average_kwh', self._daily_average(local_import, simulated_days), building_name, 'building'), + self._metric('community_local_export_daily_average_kwh', self._daily_average(local_export, simulated_days), building_name, 'building'), + self._metric('community_grid_import_after_local_daily_average_kwh', self._daily_average(grid_import, simulated_days), building_name, 'building'), + self._metric('community_grid_export_after_local_daily_average_kwh', self._daily_average(grid_export, simulated_days), building_name, 'building'), + self._metric('community_settled_cost_total_eur', totals.get('community_settled_cost_total_eur', 0.0), building_name, 'building'), + self._metric('community_counterfactual_cost_total_eur', totals.get('community_counterfactual_cost_total_eur', 0.0), building_name, 'building'), + self._metric('community_market_savings_total_eur', totals.get('community_market_savings_total_eur', 0.0), building_name, 'building'), + self._metric('community_settled_cost_daily_average_eur', self._daily_average(totals.get('community_settled_cost_total_eur', 0.0), simulated_days), building_name, 'building'), + self._metric('community_counterfactual_cost_daily_average_eur', self._daily_average(totals.get('community_counterfactual_cost_total_eur', 0.0), simulated_days), building_name, 'building'), + self._metric('community_market_savings_daily_average_eur', self._daily_average(totals.get('community_market_savings_total_eur', 0.0), simulated_days), building_name, 'building'), + self._metric('community_local_share_of_demand', import_share, building_name, 'building'), + self._metric('community_local_share_of_export', export_share, building_name, 'building'), + ]) + + district_local_import = market_district['community_local_import_total_kwh'] + district_local_export = market_district['community_local_export_total_kwh'] + district_grid_import = market_district['community_grid_import_after_local_total_kwh'] + district_grid_export = market_district['community_grid_export_after_local_total_kwh'] + + extended_district_rows.extend([ + self._metric('community_local_import_total_kwh', district_local_import, 'District', 'district'), + self._metric('community_local_export_total_kwh', district_local_export, 'District', 'district'), + self._metric('community_grid_import_after_local_total_kwh', district_grid_import, 'District', 'district'), + self._metric('community_grid_export_after_local_total_kwh', district_grid_export, 'District', 'district'), + self._metric('community_local_import_daily_average_kwh', self._daily_average(district_local_import, simulated_days), 'District', 'district'), + self._metric('community_local_export_daily_average_kwh', self._daily_average(district_local_export, simulated_days), 'District', 'district'), + self._metric('community_grid_import_after_local_daily_average_kwh', self._daily_average(district_grid_import, simulated_days), 'District', 'district'), + self._metric('community_grid_export_after_local_daily_average_kwh', self._daily_average(district_grid_export, simulated_days), 'District', 'district'), + self._metric('community_settled_cost_total_eur', market_district['community_settled_cost_total_eur'], 'District', 'district'), + self._metric('community_counterfactual_cost_total_eur', market_district['community_counterfactual_cost_total_eur'], 'District', 'district'), + self._metric('community_market_savings_total_eur', market_district['community_market_savings_total_eur'], 'District', 'district'), + self._metric('community_settled_cost_daily_average_eur', self._daily_average(market_district['community_settled_cost_total_eur'], simulated_days), 'District', 'district'), + self._metric('community_counterfactual_cost_daily_average_eur', self._daily_average(market_district['community_counterfactual_cost_total_eur'], simulated_days), 'District', 'district'), + self._metric('community_market_savings_daily_average_eur', self._daily_average(market_district['community_market_savings_total_eur'], simulated_days), 'District', 'district'), + self._metric( + 'community_local_share_of_demand', + None if (district_local_import + district_grid_import) <= 0.0 else district_local_import / (district_local_import + district_grid_import), + 'District', + 'district', + ), + self._metric( + 'community_local_share_of_export', + None if (district_local_export + district_grid_export) <= 0.0 else district_local_export / (district_local_export + district_grid_export), + 'District', + 'district', + ), + ]) + + equity_distribution = self._equity_distribution_metrics(np.array(list(equity_valid_benefits.values()), dtype='float64')) + non_negative_benefits = {name: max(value, 0.0) for name, value in equity_valid_benefits.items()} + has_complete_manual_groups = all( + equity_group_by_building.get(name) in {'asset_rich', 'asset_poor'} + for name in building_names + ) + equity_bpr = self._equity_bpr(non_negative_benefits, equity_group_by_building) if has_complete_manual_groups else None + + extended_district_rows.extend([ + self._metric('equity_gini_benefit', equity_distribution['equity_gini_benefit'], 'District', 'district'), + self._metric('equity_cr20_benefit', equity_distribution['equity_cr20_benefit'], 'District', 'district'), + self._metric('equity_losers_percent', equity_distribution['equity_losers_percent'], 'District', 'district'), + self._metric('equity_bpr_asset_poor_over_rich', equity_bpr, 'District', 'district'), + ]) + + extended_building = pd.DataFrame(extended_building_rows) + extended_district = pd.DataFrame(extended_district_rows) + + cost_functions = pd.concat([legacy_cost_functions, extended_district, extended_building], ignore_index=True, sort=False) + + return cost_functions diff --git a/citylearn/internal/loading.py b/citylearn/internal/loading.py new file mode 100644 index 000000000..f7adbfa4f --- /dev/null +++ b/citylearn/internal/loading.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import importlib +import os +from typing import TYPE_CHECKING, Any, List, Mapping, Tuple, Union + +import numpy as np +import pandas as pd + +from citylearn.base import EpisodeTracker +from citylearn.building import Building +from citylearn.data import ( + CarbonIntensity, + ChargerSimulation, + DataSet, + EnergySimulation, + LogisticRegressionOccupantParameters, + Pricing, + WashingMachineSimulation, + Weather, +) +from citylearn.electric_vehicle import ElectricVehicle +from citylearn.energy_model import Battery, PV, WashingMachine +from citylearn.reward_function import MultiBuildingRewardFunction, RewardFunction +from citylearn.utilities import parse_bool + +if TYPE_CHECKING: + from citylearn.citylearn import CityLearnEnv + + +@dataclass +class LoadContext: + """Lightweight holder for loading inputs.""" + + schema: Mapping[str, Any] + kwargs: Mapping[str, Any] + + +class CityLearnLoadingService: + """Internal loader service that builds env components from schema.""" + + def __init__(self, env: "CityLearnEnv"): + self.env = env + + def load( + self, + schema: Mapping[str, Any], + **kwargs, + ) -> Tuple[ + Union[os.PathLike, str], + List[Building], + List[ElectricVehicle], + Union[int, List[Tuple[int, int]]], + bool, + bool, + float, + RewardFunction, + bool, + List[str], + EpisodeTracker, + ]: + """Return env objects as defined by schema.""" + + schema['root_directory'] = kwargs['root_directory'] if kwargs.get('root_directory') is not None else schema['root_directory'] + schema['random_seed'] = schema.get('random_seed', None) if kwargs.get('random_seed', None) is None else kwargs['random_seed'] + schema['central_agent'] = parse_bool( + kwargs['central_agent'] if kwargs.get('central_agent') is not None else schema['central_agent'], + default=False, + path='central_agent', + ) + + schema['chargers_observations_helper'] = {key: value for key, value in schema["observations"].items() if "electric_vehicle_" in key} + schema['chargers_actions_helper'] = {key: value for key, value in schema["actions"].items() if "electric_vehicle_" in key} + schema['chargers_shared_observations_helper'] = { + key: value + for key, value in schema["observations"].items() + if "electric_vehicle_" in key and value.get("shared_in_central_agent", True) + } + + schema['washing_machine_observations_helper'] = {key: value for key, value in schema["observations"].items() if "washing_machine_" in key} + schema['washing_machine_actions_helper'] = {key: value for key, value in schema["actions"].items() if "washing_machine" in key} + + schema['observations'] = { + key: value + for key, value in schema["observations"].items() + if key not in set(schema['chargers_observations_helper']) | set(schema['washing_machine_observations_helper']) + } + schema['actions'] = { + key: value + for key, value in schema['actions'].items() + if key not in set(schema['chargers_actions_helper']) | set(schema['washing_machine_actions_helper']) + } + + schema['shared_observations'] = ( + kwargs['shared_observations'] + if kwargs.get('shared_observations') is not None + else [ + k + for k, v in schema['observations'].items() + if not k.startswith("electric_vehicle_") + and "washing_machine" not in k + and parse_bool(v.get('shared_in_central_agent', False), default=False, path=f'observations.{k}.shared_in_central_agent') + ] + ) + + schema['episode_time_steps'] = kwargs['episode_time_steps'] if kwargs.get('episode_time_steps') is not None else schema.get('episode_time_steps', None) + schema['rolling_episode_split'] = kwargs['rolling_episode_split'] if kwargs.get('rolling_episode_split') is not None else schema.get('rolling_episode_split', None) + schema['random_episode_split'] = kwargs['random_episode_split'] if kwargs.get('random_episode_split') is not None else schema.get('random_episode_split', None) + schema['seconds_per_time_step'] = kwargs['seconds_per_time_step'] if kwargs.get('seconds_per_time_step') is not None else schema['seconds_per_time_step'] + + schema['simulation_start_time_step'] = kwargs['simulation_start_time_step'] if kwargs.get('simulation_start_time_step') is not None else schema['simulation_start_time_step'] + schema['simulation_end_time_step'] = kwargs['simulation_end_time_step'] if kwargs.get('simulation_end_time_step') is not None else schema['simulation_end_time_step'] + episode_tracker = EpisodeTracker(schema['simulation_start_time_step'], schema['simulation_end_time_step']) + + dataset = DataSet() + pv_sizing_data = dataset.get_pv_sizing_data() + battery_sizing_data = dataset.get_battery_sizing_data() + + buildings_to_include = list(schema['buildings'].keys()) + buildings: List[Building] = [] + + if kwargs.get('buildings') is not None and len(kwargs['buildings']) > 0: + if isinstance(kwargs['buildings'][0], Building): + buildings = kwargs['buildings'] + + for building in buildings: + building.episode_tracker = episode_tracker + + buildings_to_include = [] + + elif isinstance(kwargs['buildings'][0], str): + buildings_to_include = [b for b in buildings_to_include if b in kwargs['buildings']] + + elif isinstance(kwargs['buildings'][0], int): + buildings_to_include = [buildings_to_include[i] for i in kwargs['buildings']] + + else: + raise Exception('Unknown buildings type. Allowed types are citylearn.building.Building, int and str.') + + else: + buildings_to_include = [ + b for b in buildings_to_include + if parse_bool(schema['buildings'][b].get('include', True), default=True, path=f'buildings.{b}.include') + ] + + for i, building_name in enumerate(buildings_to_include): + buildings.append(self.load_building(i, building_name, schema, episode_tracker, pv_sizing_data, battery_sizing_data, **kwargs)) + + electric_vehicles: List[ElectricVehicle] = [] + if kwargs.get('electric_vehicles_def') is not None and len(kwargs['electric_vehicles_def']) > 0: + electric_vehicle_schemas = kwargs['electric_vehicles_def'] + else: + electric_vehicle_schemas = schema.get('electric_vehicles_def', {}) + + for electric_vehicle_name, electric_vehicle_schema in electric_vehicle_schemas.items(): + if parse_bool(electric_vehicle_schema.get('include', True), default=True, path=f'electric_vehicles_def.{electric_vehicle_name}.include'): + time_step_ratio = buildings[0].time_step_ratio if len(buildings) > 0 else 1.0 + electric_vehicles.append( + self.load_electric_vehicle(electric_vehicle_name, schema, electric_vehicle_schema, episode_tracker, time_step_ratio) + ) + + reward_schema = schema['reward_function'] + reward_type = reward_schema['type'] + reward_attrs = reward_schema.get('attributes', {}) + is_multi = isinstance(reward_type, dict) + + if is_multi: + default_type = reward_type.get('default') + if default_type is None and reward_type: + default_type = next(iter(reward_type.values())) + + default_attrs = reward_attrs.get('default') + if default_attrs is None and reward_attrs: + default_attrs = next(iter(reward_attrs.values())) + + reward_functions = {} + for building in buildings: + name = building.name + r_type = reward_type.get(name, default_type) + r_attr = reward_attrs.get(name, default_attrs) or {} + + if r_type is None: + raise ValueError(f"No reward function defined for building '{name}' and no default provided") + + module_name = '.'.join(r_type.split('.')[:-1]) + class_name = r_type.split('.')[-1] + module = importlib.import_module(module_name) + constructor = getattr(module, class_name) + reward_functions[name] = constructor(None, **r_attr) + + reward_function = MultiBuildingRewardFunction(None, reward_functions) + + else: + if 'reward_function' in kwargs and kwargs['reward_function'] is not None: + reward_function_type = kwargs['reward_function'] + if not isinstance(reward_function_type, str): + reward_function_type = f"{reward_function_type.__module__}.{reward_function_type.__name__}" + else: + reward_function_type = reward_type + + reward_function_attributes = kwargs.get('reward_function_kwargs') or reward_attrs or {} + + module_name = '.'.join(reward_function_type.split('.')[:-1]) + class_name = reward_function_type.split('.')[-1] + module = importlib.import_module(module_name) + constructor = getattr(module, class_name) + reward_function = constructor(None, **reward_function_attributes) + + return ( + schema['root_directory'], + buildings, + electric_vehicles, + schema['episode_time_steps'], + schema['rolling_episode_split'], + schema['random_episode_split'], + schema['seconds_per_time_step'], + reward_function, + schema['central_agent'], + schema['shared_observations'], + episode_tracker, + ) + + def load_building( + self, + index: int, + building_name: str, + schema: dict, + episode_tracker: EpisodeTracker, + pv_sizing_data: pd.DataFrame, + battery_sizing_data: pd.DataFrame, + **kwargs, + ) -> Building: + """Initialize and return a building model.""" + + building_schema = schema['buildings'][building_name] + building_kwargs = {} + if building_schema.get('charging_constraints') is not None: + building_kwargs['charging_constraints'] = building_schema['charging_constraints'] + if building_schema.get('electrical_service') is not None: + building_kwargs['electrical_service'] = building_schema['electrical_service'] + if building_schema.get('equity_group') is not None: + building_kwargs['equity_group'] = building_schema.get('equity_group') + electrical_storage_attributes = (building_schema.get('electrical_storage') or {}).get('attributes', {}) or {} + if electrical_storage_attributes.get('phase_connection') is not None: + building_kwargs['electrical_storage_phase_connection'] = electrical_storage_attributes.get('phase_connection') + seconds_per_time_step = schema['seconds_per_time_step'] + noise_std = building_schema.get('noise_std', 0.0) + + energy_simulation = pd.read_csv(os.path.join(schema['root_directory'], building_schema['energy_simulation'])) + energy_simulation = EnergySimulation(**energy_simulation.to_dict('list'), seconds_per_time_step=seconds_per_time_step, noise_std=noise_std) + ratios = getattr(energy_simulation, 'time_step_ratios', None) or [] + building_kwargs['time_step_ratio'] = ratios[-1] if len(ratios) > 0 else 1.0 + weather = pd.read_csv(os.path.join(schema['root_directory'], building_schema['weather'])) + weather = Weather(**weather.to_dict('list'), noise_std=noise_std) + + if building_schema.get('carbon_intensity', None) is not None: + carbon_intensity = pd.read_csv(os.path.join(schema['root_directory'], building_schema['carbon_intensity'])) + carbon_intensity = CarbonIntensity(**carbon_intensity.to_dict('list'), noise_std=noise_std) + else: + carbon_intensity = CarbonIntensity(np.zeros(energy_simulation.hour.shape[0], dtype='float32'), noise_std=noise_std) + + if building_schema.get('pricing', None) is not None: + pricing = pd.read_csv(os.path.join(schema['root_directory'], building_schema['pricing'])) + pricing = Pricing(**pricing.to_dict('list'), noise_std=noise_std) + else: + pricing = Pricing( + np.zeros(energy_simulation.hour.shape[0], dtype='float32'), + np.zeros(energy_simulation.hour.shape[0], dtype='float32'), + np.zeros(energy_simulation.hour.shape[0], dtype='float32'), + np.zeros(energy_simulation.hour.shape[0], dtype='float32'), + noise_std=noise_std, + ) + + building_type = 'citylearn.citylearn.Building' if building_schema.get('type', None) is None else building_schema['type'] + building_type_module = '.'.join(building_type.split('.')[0:-1]) + building_type_name = building_type.split('.')[-1] + building_constructor = getattr(importlib.import_module(building_type_module), building_type_name) + + if building_schema.get('dynamics', None) is not None: + dynamics_type = building_schema['dynamics']['type'] + dynamics_module = '.'.join(dynamics_type.split('.')[0:-1]) + dynamics_name = dynamics_type.split('.')[-1] + dynamics_constructor = getattr(importlib.import_module(dynamics_module), dynamics_name) + attributes = building_schema['dynamics'].get('attributes', {}) + attributes['filepath'] = os.path.join(schema['root_directory'], attributes['filename']) + _ = attributes.pop('filename') + building_kwargs['dynamics'] = dynamics_constructor(**attributes) + else: + building_kwargs['dynamics'] = None + + if building_schema.get('occupant', None) is not None: + building_occupant = building_schema['occupant'] + occupant_type = building_occupant['type'] + occupant_module = '.'.join(occupant_type.split('.')[0:-1]) + occupant_name = occupant_type.split('.')[-1] + occupant_constructor = getattr(importlib.import_module(occupant_module), occupant_name) + attributes: dict = building_occupant.get('attributes', {}) + parameters_filepath = os.path.join(schema['root_directory'], building_occupant['parameters_filename']) + parameters = pd.read_csv(parameters_filepath) + attributes['parameters'] = LogisticRegressionOccupantParameters(**parameters.to_dict('list')) + attributes['episode_tracker'] = episode_tracker + attributes['random_seed'] = schema['random_seed'] + + for key in ['increase', 'decrease']: + attributes[f'setpoint_{key}_model_filepath'] = os.path.join(schema['root_directory'], attributes[f'setpoint_{key}_model_filename']) + _ = attributes.pop(f'setpoint_{key}_model_filename') + + building_kwargs['occupant'] = occupant_constructor(**attributes) + else: + building_kwargs['occupant'] = None + + building_schema_power_outage = building_schema.get('power_outage', {}) + simulate_power_outage = kwargs.get('simulate_power_outage') + simulate_power_outage = building_schema_power_outage.get('simulate_power_outage') if simulate_power_outage is None else simulate_power_outage + simulate_power_outage = simulate_power_outage[index] if isinstance(simulate_power_outage, list) else simulate_power_outage + stochastic_power_outage = building_schema_power_outage.get('stochastic_power_outage') + + if building_schema_power_outage.get('stochastic_power_outage_model', None) is not None: + stochastic_power_outage_model_type = building_schema_power_outage['stochastic_power_outage_model']['type'] + stochastic_power_outage_model_module = '.'.join(stochastic_power_outage_model_type.split('.')[0:-1]) + stochastic_power_outage_model_name = stochastic_power_outage_model_type.split('.')[-1] + stochastic_power_outage_model_constructor = getattr( + importlib.import_module(stochastic_power_outage_model_module), + stochastic_power_outage_model_name, + ) + attributes = building_schema_power_outage.get('stochastic_power_outage_model', {}).get('attributes', {}) + stochastic_power_outage_model = stochastic_power_outage_model_constructor(**attributes) + else: + stochastic_power_outage_model = None + + chargers_list = [] + if building_schema.get('chargers', None) is not None: + for charger_name, charger_config in building_schema['chargers'].items(): + noise_std = charger_config.get('noise_std', 0.0) + + charger_simulation_file = pd.read_csv( + os.path.join(schema['root_directory'], charger_config['charger_simulation']) + ).iloc[schema['simulation_start_time_step']:schema['simulation_end_time_step'] + 1].copy() + + charger_simulation = ChargerSimulation(*charger_simulation_file.values.T, noise_std=noise_std) + if 'electric_vehicle_current_soc' in charger_simulation_file.columns: + current_soc_raw = pd.to_numeric(charger_simulation_file['electric_vehicle_current_soc'], errors='coerce').to_numpy(dtype='float32') + current_soc = np.full(current_soc_raw.shape[0], -0.1, dtype='float32') + valid = ~np.isnan(current_soc_raw) + + if np.any(valid): + normalized = current_soc_raw[valid] + normalized = np.where(normalized > 1.0, normalized / 100.0, normalized) + normalized = np.clip(normalized, 0.0, 1.0) + current_soc[valid] = normalized.astype('float32') + + charger_simulation.electric_vehicle_current_soc = current_soc + + charger_type = charger_config['type'] + charger_module = '.'.join(charger_type.split('.')[0:-1]) + charger_class_name = charger_type.split('.')[-1] + charger_class = getattr(importlib.import_module(charger_module), charger_class_name) + charger_attributes = dict(charger_config.get('attributes', {}) or {}) + charger_attributes['episode_tracker'] = episode_tracker + charger_object = charger_class( + charger_simulation=charger_simulation, + charger_id=charger_name, + **charger_attributes, + seconds_per_time_step=schema['seconds_per_time_step'], + time_step_ratio=building_kwargs['time_step_ratio'], + ) + chargers_list.append(charger_object) + + washing_machines_list = [] + if kwargs.get('washing_machines') is not None and len(kwargs['washing_machines']) > 0: + washing_machine_schemas = kwargs['washing_machines'] + else: + washing_machine_schemas = building_schema.get('washing_machines', {}) + + for washing_machine_name, washing_machine_schema in washing_machine_schemas.items(): + washing_machines_list.append(self.load_washing_machine(washing_machine_name, schema, washing_machine_schema, episode_tracker)) + + observation_metadata, action_metadata = self.process_metadata( + schema, + building_schema, + chargers_list, + washing_machines_list, + index, + energy_simulation, + **kwargs, + ) + + building: Building = building_constructor( + energy_simulation=energy_simulation, + washing_machines=washing_machines_list, + electric_vehicle_chargers=chargers_list, + weather=weather, + observation_metadata=observation_metadata, + action_metadata=action_metadata, + carbon_intensity=carbon_intensity, + pricing=pricing, + name=building_name, + seconds_per_time_step=schema['seconds_per_time_step'], + random_seed=schema['random_seed'], + episode_tracker=episode_tracker, + simulate_power_outage=simulate_power_outage, + stochastic_power_outage=stochastic_power_outage, + stochastic_power_outage_model=stochastic_power_outage_model, + **building_kwargs, + ) + + device_metadata = { + 'cooling_device': {'autosizer': building.autosize_cooling_device}, + 'heating_device': {'autosizer': building.autosize_heating_device}, + 'dhw_device': {'autosizer': building.autosize_dhw_device}, + 'dhw_storage': {'autosizer': building.autosize_dhw_storage}, + 'cooling_storage': {'autosizer': building.autosize_cooling_storage}, + 'heating_storage': {'autosizer': building.autosize_heating_storage}, + 'electrical_storage': {'autosizer': building.autosize_electrical_storage}, + 'washing_machine': {'autosizer': building.autosize_electrical_storage}, + 'pv': {'autosizer': building.autosize_pv}, + } + solar_generation = kwargs.get('solar_generation') + solar_generation = True if solar_generation is None else solar_generation + solar_generation = solar_generation[index] if isinstance(solar_generation, list) else solar_generation + + for device_name in device_metadata: + if building_schema.get(device_name, None) is None: + device = None + + elif device_name == 'pv' and not solar_generation: + device = None + + else: + device_type: str = building_schema[device_name]['type'] + device_module = '.'.join(device_type.split('.')[0:-1]) + device_type_name = device_type.split('.')[-1] + constructor = getattr(importlib.import_module(device_module), device_type_name) + attributes = dict(building_schema[device_name].get('attributes', {}) or {}) + if device_name == 'electrical_storage': + attributes.pop('phase_connection', None) + attributes['seconds_per_time_step'] = schema['seconds_per_time_step'] + + md5 = hashlib.md5() + device_random_seed = 0 + + for string in [building_name, building_type, device_name, device_type]: + md5.update(string.encode()) + hash_to_integer_base = 16 + device_random_seed += int(md5.hexdigest(), hash_to_integer_base) + + device_random_seed = int(str(device_random_seed * (schema['random_seed'] + 1))[:9]) + + attributes = { + **attributes, + 'random_seed': attributes['random_seed'] if attributes.get('random_seed', None) is not None else device_random_seed, + } + device = constructor(**attributes) + autosize = parse_bool( + building_schema[device_name].get('autosize', False), + default=False, + path=f'buildings.{building.name}.{device_name}.autosize', + ) + building.__setattr__(device_name, device) + + if autosize: + autosizer = device_metadata[device_name]['autosizer'] + autosize_kwargs = {} if building_schema[device_name].get('autosize_attributes', None) is None else building_schema[device_name]['autosize_attributes'] + + if isinstance(device, PV): + autosize_kwargs['epw_filepath'] = os.path.join(schema['root_directory'], autosize_kwargs['epw_filepath']) + autosize_kwargs['sizing_data'] = pv_sizing_data + + elif isinstance(device, Battery): + autosize_kwargs['sizing_data'] = battery_sizing_data + + autosizer(**autosize_kwargs) + + device.random_seed = schema['random_seed'] + + building.observation_space = building.estimate_observation_space() + building.action_space = building.estimate_action_space() + + return building + + def process_metadata( + self, + schema, + building_schema, + chargers_list, + washing_machines_list, + index, + energy_simulation: EnergySimulation, + **kwargs, + ): + """Build observation and action metadata for one building.""" + + observation_metadata = { + k: parse_bool(v.get('active', False), default=False, path=f'observations.{k}.active') + for k, v in schema['observations'].items() + } + if 'minutes' in observation_metadata and energy_simulation.minutes is None: + observation_metadata.pop('minutes', None) + + chargers_observations_metadata_helper = { + k: parse_bool(v.get('active', False), default=False, path=f'observations.{k}.active') + for k, v in schema['chargers_observations_helper'].items() + } + washing_machine_observations_metadata_helper = { + k: parse_bool(v.get('active', False), default=False, path=f'observations.{k}.active') + for k, v in schema['washing_machine_observations_helper'].items() + } + + if kwargs.get('active_observations') is not None: + active_observations = kwargs['active_observations'] + active_observations = active_observations[index] if isinstance(active_observations[0], list) else active_observations + observation_metadata = {k: True if k in active_observations else False for k in observation_metadata} + chargers_observations_metadata_helper = {k: True if k in active_observations else False for k in chargers_observations_metadata_helper} + washing_machine_observations_metadata_helper = {k: True if k in active_observations else False for k in washing_machine_observations_metadata_helper} + + if kwargs.get('inactive_observations') is not None: + inactive_observations = kwargs['inactive_observations'] + inactive_observations = inactive_observations[index] if isinstance(inactive_observations[0], list) else inactive_observations + elif building_schema.get('inactive_observations') is not None: + inactive_observations = building_schema['inactive_observations'] + else: + inactive_observations = [] + + observation_metadata = { + k: False if k in inactive_observations else observation_metadata[k] + for k in observation_metadata + } + chargers_observations_metadata_helper = { + k: False if k in inactive_observations else chargers_observations_metadata_helper[k] + for k in chargers_observations_metadata_helper + } + washing_machine_observations_metadata_helper = { + k: False if k in inactive_observations else washing_machine_observations_metadata_helper[k] + for k in washing_machine_observations_metadata_helper + } + + action_metadata = { + k: parse_bool(v.get('active', False), default=False, path=f'actions.{k}.active') + for k, v in schema['actions'].items() + } + chargers_actions_metadata_helper = { + k: parse_bool(v.get('active', False), default=False, path=f'actions.{k}.active') + for k, v in schema['chargers_actions_helper'].items() + } + washing_machine_actions_metadata_helper = { + k: parse_bool(v.get('active', False), default=False, path=f'actions.{k}.active') + for k, v in schema['washing_machine_actions_helper'].items() + } + + if kwargs.get('active_actions') is not None: + active_actions = kwargs['active_actions'] + active_actions = active_actions[index] if isinstance(active_actions[0], list) else active_actions + action_metadata = {k: True if k in active_actions else False for k in action_metadata} + chargers_actions_metadata_helper = {k: True if k in active_actions else False for k in chargers_actions_metadata_helper} + washing_machine_actions_metadata_helper = {k: True if k in active_actions else False for k in washing_machine_actions_metadata_helper} + + if kwargs.get('inactive_actions') is not None: + inactive_actions = kwargs['inactive_actions'] + inactive_actions = inactive_actions[index] if isinstance(inactive_actions[0], list) else inactive_actions + elif building_schema.get('inactive_actions') is not None: + inactive_actions = building_schema['inactive_actions'] + else: + inactive_actions = [] + + action_metadata = {k: False if k in inactive_actions else v for k, v in action_metadata.items()} + chargers_actions_metadata_helper = {k: False if k in inactive_actions else v for k, v in chargers_actions_metadata_helper.items()} + washing_machine_actions_metadata_helper = {k: False if k in inactive_actions else v for k, v in washing_machine_actions_metadata_helper.items()} + + if len(chargers_list) > 0: + for charger in chargers_list: + charger_id = charger.charger_id + + if chargers_observations_metadata_helper.get('electric_vehicle_charger_connected_state', False): + observation_metadata[f'electric_vehicle_charger_{charger_id}_connected_state'] = True + + if chargers_observations_metadata_helper.get('connected_electric_vehicle_at_charger_departure_time', False): + observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_departure_time'] = True + + if chargers_observations_metadata_helper.get('connected_electric_vehicle_at_charger_required_soc_departure', False): + observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_required_soc_departure'] = True + + if chargers_observations_metadata_helper.get('connected_electric_vehicle_at_charger_soc', False): + observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_soc'] = True + + if chargers_observations_metadata_helper.get('connected_electric_vehicle_at_charger_battery_capacity', False): + observation_metadata[f'connected_electric_vehicle_at_charger_{charger_id}_battery_capacity'] = True + + if chargers_observations_metadata_helper.get('electric_vehicle_charger_incoming_state', False): + observation_metadata[f'electric_vehicle_charger_{charger_id}_incoming_state'] = True + + if chargers_observations_metadata_helper.get('incoming_electric_vehicle_at_charger_estimated_arrival_time', False): + observation_metadata[f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_arrival_time'] = True + + if chargers_observations_metadata_helper.get('incoming_electric_vehicle_at_charger_estimated_soc_arrival', False): + observation_metadata[f'incoming_electric_vehicle_at_charger_{charger_id}_estimated_soc_arrival'] = True + + if chargers_actions_metadata_helper.get('electric_vehicle_storage', False): + action_metadata[f'electric_vehicle_storage_{charger.charger_id}'] = True + + if len(washing_machines_list) > 0: + for washing_machine in washing_machines_list: + washing_machine_name = washing_machine.name + if washing_machine_observations_metadata_helper.get('washing_machine_start_time_step', False): + observation_metadata[f'{washing_machine_name}_start_time_step'] = True + + if washing_machine_observations_metadata_helper.get('washing_machine_end_time_step', False): + observation_metadata[f'{washing_machine_name}_end_time_step'] = True + + if washing_machine_actions_metadata_helper.get('washing_machine', False): + action_metadata[f'{washing_machine_name}'] = True + + return observation_metadata, action_metadata + + def load_electric_vehicle( + self, + electric_vehicle_name: str, + schema: dict, + electric_vehicle_schema: dict, + episode_tracker: EpisodeTracker, + time_step_ratio, + ) -> ElectricVehicle: + """Initialize and return an electric vehicle model.""" + + capacity = electric_vehicle_schema['battery']['attributes']['capacity'] + nominal_power = electric_vehicle_schema['battery']['attributes']['nominal_power'] + initial_soc = electric_vehicle_schema['battery']['attributes'].get('initial_soc') + if initial_soc is None: + seed_source = f"{schema['random_seed']}:{electric_vehicle_name}:initial_soc" + deterministic_seed = int(hashlib.md5(seed_source.encode('utf-8')).hexdigest()[:8], 16) + initial_soc = float(np.random.RandomState(deterministic_seed).uniform(0.0, 1.0)) + depth_of_discharge = electric_vehicle_schema['battery']['attributes'].get('depth_of_discharge', 0.10) + + battery = Battery( + capacity=capacity, + nominal_power=nominal_power, + initial_soc=initial_soc, + seconds_per_time_step=schema['seconds_per_time_step'], + time_step_ratio=time_step_ratio, + random_seed=schema['random_seed'], + episode_tracker=episode_tracker, + depth_of_discharge=depth_of_discharge, + ) + + electric_vehicle_type = 'citylearn.citylearn.ElectricVehicle' if electric_vehicle_schema.get('type', None) is None else electric_vehicle_schema['type'] + electric_vehicle_type_module = '.'.join(electric_vehicle_type.split('.')[0:-1]) + electric_vehicle_type_name = electric_vehicle_type.split('.')[-1] + electric_vehicle_constructor = getattr(importlib.import_module(electric_vehicle_type_module), electric_vehicle_type_name) + + electric_vehicle: ElectricVehicle = electric_vehicle_constructor( + battery=battery, + name=electric_vehicle_name, + seconds_per_time_step=schema['seconds_per_time_step'], + random_seed=schema['random_seed'], + episode_tracker=episode_tracker, + ) + + return electric_vehicle + + def load_washing_machine( + self, + washing_machine_name: str, + schema: dict, + washing_machine_schema: dict, + episode_tracker: EpisodeTracker, + ) -> WashingMachine: + """Load simulation data and initialize a `WashingMachine` instance.""" + + file_path = os.path.join(schema['root_directory'], washing_machine_schema['washing_machine_energy_simulation']) + + washing_machine_simulation = pd.read_csv(file_path).iloc[ + schema['simulation_start_time_step']:schema['simulation_end_time_step'] + 1 + ].copy() + + washing_machine_simulation = WashingMachineSimulation(*washing_machine_simulation.values.T) + + washing_machine = WashingMachine( + washing_machine_simulation=washing_machine_simulation, + episode_tracker=episode_tracker, + name=washing_machine_name, + seconds_per_time_step=schema['seconds_per_time_step'], + random_seed=schema['random_seed'], + ) + + return washing_machine diff --git a/citylearn/internal/runtime.py b/citylearn/internal/runtime.py new file mode 100644 index 000000000..3c01e543b --- /dev/null +++ b/citylearn/internal/runtime.py @@ -0,0 +1,544 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Mapping, Union + +import numpy as np + +from citylearn.base import Environment +from citylearn.data import ChargerSimulation + +if TYPE_CHECKING: + from citylearn.citylearn import CityLearnEnv + + +class CityLearnRuntimeService: + """Internal runtime orchestration for `CityLearnEnv`.""" + + def __init__(self, env: "CityLearnEnv"): + self.env = env + + @staticmethod + def _ev_unconnected_drift_std(seconds_per_time_step: float) -> float: + """Return per-step drift std scaled by physical step duration.""" + + seconds = max(float(seconds_per_time_step), 1.0) + step_hours = seconds / 3600.0 + return 0.2 * np.sqrt(step_hours) + + def step(self, actions: List[List[float]]): + """Apply actions, update env variables/reward, then advance time.""" + + env = self.env + + if env.terminated or env.truncated: + raise RuntimeError('Episode has already terminated/truncated. Call reset() before calling step() again.') + + env._observations_cache = None + env._observations_cache_time_step = -1 + actions = self.parse_actions(actions) + + for building, building_actions in zip(env.buildings, actions): + building.apply_actions(**building_actions) + + self.update_variables() + + if env.debug_timing: + import time + building_observations_retrieval_start = time.perf_counter() + + reward_observations = [ + b.observations(include_all=True, normalize=False, periodic_normalization=False) for b in env.buildings + ] + if env.debug_timing: + building_observations_retrieval_end = time.perf_counter() + + reward = env.reward_function.calculate(observations=reward_observations) + env.rewards.append(reward) + + partial_render_time = self.next_time_step() + end_export_time = 0.0 + env._maybe_log_periodic_metrics() + + if env.terminated: + rewards = np.array(env.rewards[1:], dtype='float32') + env.episode_rewards.append({ + 'min': rewards.min(axis=0).tolist(), + 'max': rewards.max(axis=0).tolist(), + 'sum': rewards.sum(axis=0).tolist(), + 'mean': rewards.mean(axis=0).tolist(), + }) + if env.render_mode == 'end' and env.render_enabled: + final_index = min(env.time_steps - 1, env.time_step - 1) if env.time_step > 0 else 0 + if env.debug_timing: + import time + export_start = time.perf_counter() + env._export_episode_render_data(final_index) + if env.debug_timing: + end_export_time = time.perf_counter() - export_start + + if env.export_kpis_on_episode_end and not env._final_kpis_exported: + env.export_final_kpis() + + next_observations = env.observations + info = dict(env.get_info()) + if env.debug_timing: + info['building_observations_retrieval_time'] = building_observations_retrieval_end - building_observations_retrieval_start + info['partial_render_time'] = partial_render_time + info['end_export_time'] = end_export_time + + return next_observations, reward, env.terminated, env.truncated, info + + def parse_actions(self, actions: List[List[float]]) -> List[Mapping[str, float]]: + """Return mapping of action name to action value for each building.""" + + env = self.env + + building_actions = [] + cache = getattr(env, '_active_actions_cache', None) + cached_expected = getattr(env, '_expected_central_action_count', None) + current_actions = [list(b.active_actions) for b in env.buildings] + current_expected = sum(len(v) for v in current_actions) + + if cache is None or cached_expected != current_expected or cache != current_actions: + env._refresh_action_cache() + + def _is_scalar(value: Any) -> bool: + return bool(np.isscalar(value)) + + def _to_vector(value: Any, *, context: str) -> List[float]: + if isinstance(value, np.ndarray): + array = np.asarray(value) + if array.ndim == 1: + return array.tolist() + if array.ndim == 2 and array.shape[0] == 1: + return array[0].tolist() + raise AssertionError(f'{context} must be a 1D action vector.') + + if isinstance(value, (list, tuple)): + if len(value) == 0: + return [] + if all(_is_scalar(v) for v in value): + return list(value) + if len(value) == 1: + inner = value[0] + if isinstance(inner, (list, tuple, np.ndarray)): + return _to_vector(inner, context=context) + raise AssertionError(f'{context} must be a 1D action vector.') + + raise AssertionError(f'{context} must be a 1D action vector.') + + if env.central_agent: + actions = _to_vector(actions, context='central_agent actions') + number_of_actions = len(actions) + expected_number_of_actions = env._expected_central_action_count + assert number_of_actions == expected_number_of_actions, \ + f'Expected {expected_number_of_actions} actions but {number_of_actions} were parsed to env.step.' + + for building in env.buildings: + size = building.action_space.shape[0] + building_actions.append(actions[0:size]) + actions = actions[size:] + + else: + if isinstance(actions, np.ndarray): + array = np.asarray(actions) + if array.ndim == 2: + building_actions = [row.tolist() for row in array] + else: + raise AssertionError( + 'Expected one action vector per building when central_agent=False.' + ) + elif isinstance(actions, (list, tuple)): + building_actions = [] + for idx, action_vector in enumerate(actions): + if isinstance(action_vector, (list, tuple, np.ndarray)): + building_actions.append(_to_vector(action_vector, context=f'building action vector at index {idx}')) + else: + raise AssertionError( + 'Expected one action vector per building when central_agent=False.' + ) + else: + raise AssertionError('Expected one action vector per building when central_agent=False.') + + number_of_building_actions = len(building_actions) + expected_building_actions = len(env.buildings) + assert number_of_building_actions == expected_building_actions, \ + f'Expected {expected_building_actions} building action vectors but {number_of_building_actions} were provided.' + + for building, building_action in zip(env.buildings, building_actions): + number_of_actions = len(building_action) + expected_number_of_actions = building.action_space.shape[0] + assert number_of_actions == expected_number_of_actions, \ + f'Expected {expected_number_of_actions} for {building.name} but {number_of_actions} actions were provided.' + + active_actions = env._active_actions_cache + parsed_actions = [] + + for i, _building in enumerate(env.buildings): + action_dict = {} + electric_vehicle_actions = {} + washing_machine_actions = {} + + for action_name, action in zip(active_actions[i], building_actions[i]): + if 'electric_vehicle_storage' in action_name: + charger_id = action_name.replace('electric_vehicle_storage_', '') + electric_vehicle_actions[charger_id] = action + elif 'washing_machine' in action_name: + washing_machine_actions[action_name] = action + else: + action_dict[f'{action_name}_action'] = action + + if electric_vehicle_actions: + action_dict['electric_vehicle_storage_actions'] = electric_vehicle_actions + + if washing_machine_actions: + action_dict['washing_machine_actions'] = washing_machine_actions + + parsed_actions.append(action_dict) + + return parsed_actions + + def next_time_step(self): + r"""Advance all buildings to next `time_step`.""" + + env = self.env + current_step = int(env.time_step) + last_action_step = max(env.time_steps - 2, 0) + reached_terminal_transition = current_step >= last_action_step + + partial_render_time = 0.0 + if getattr(env, 'render_enabled', False): + if env.render_mode == 'during': + if env.debug_timing: + import time + render_start = time.perf_counter() + env.render() + partial_render_time = time.perf_counter() - render_start + else: + env.render() + + if not reached_terminal_transition: + for building in env.buildings: + building.next_time_step() + + for electric_vehicle in env.electric_vehicles: + electric_vehicle.next_time_step() + + Environment.next_time_step(env) + + if not reached_terminal_transition: + self.simulate_unconnected_ev_soc() + self.associate_chargers_to_electric_vehicles() + + return partial_render_time + + def associate_chargers_to_electric_vehicles(self): + r"""Associate charger to its corresponding EV based on charger simulation state.""" + + env = self.env + + def _resolve_arrival_soc( + simulation: ChargerSimulation, + step: int, + prev_state: float, + prev_id: Union[str, None], + ev_identifier: str, + ) -> Union[float, None]: + current_soc = getattr(simulation, 'electric_vehicle_current_soc', None) + if current_soc is not None and 0 <= step < len(current_soc): + current_value = current_soc[step] + if isinstance(current_value, (float, np.floating)) and not np.isnan(current_value) and 0.0 <= current_value <= 1.0: + return float(current_value) + + candidate_index = None + + if prev_state in (2, 3) and step > 0: + if isinstance(prev_id, str) and prev_id.strip() not in {'', 'nan'} and prev_id != ev_identifier: + raise ValueError( + f"Charger dataset EV mismatch: expected '{ev_identifier}' but found '{prev_id}' at time step {step - 1}." + ) + candidate_index = step - 1 + + elif 0 <= step < len(simulation.electric_vehicle_estimated_soc_arrival): + candidate_index = step + + soc_value = None + + if candidate_index is not None and 0 <= candidate_index < len(simulation.electric_vehicle_estimated_soc_arrival): + candidate = simulation.electric_vehicle_estimated_soc_arrival[candidate_index] + if isinstance(candidate, (float, np.floating)) and not np.isnan(candidate) and 0.0 <= candidate <= 1.0: + soc_value = float(candidate) + + if soc_value is None and 0 <= step < len(simulation.electric_vehicle_required_soc_departure): + fallback = simulation.electric_vehicle_required_soc_departure[step] + if isinstance(fallback, (float, np.floating)) and not np.isnan(fallback) and 0.0 <= fallback <= 1.0: + soc_value = float(fallback) + + return soc_value + + for building in env.buildings: + if building.electric_vehicle_chargers is None: + continue + + for charger in building.electric_vehicle_chargers: + sim = charger.charger_simulation + state = sim.electric_vehicle_charger_state[env.time_step] + + if np.isnan(state) or state not in [1, 2]: + continue + + ev_id = sim.electric_vehicle_id[env.time_step] + prev_state = np.nan + prev_ev_id = None + if env.time_step > 0: + idx = env.time_step - 1 + if idx < len(sim.electric_vehicle_charger_state): + prev_state = sim.electric_vehicle_charger_state[idx] + if idx < len(sim.electric_vehicle_id): + prev_ev_id = sim.electric_vehicle_id[idx] + + if isinstance(ev_id, str) and ev_id.strip() not in ['', 'nan']: + for ev in env.electric_vehicles: + if ev.name == ev_id: + if state == 1: + charger.plug_car(ev) + is_new_connection = ( + prev_state != 1 + or not isinstance(prev_ev_id, str) + or prev_ev_id != ev_id + ) + if is_new_connection: + soc_value = _resolve_arrival_soc(sim, env.time_step, prev_state, prev_ev_id, ev_id) + if soc_value is not None: + ev.battery.force_set_soc(soc_value) + elif state == 2: + charger.associate_incoming_car(ev) + + def simulate_unconnected_ev_soc(self): + """Simulate SOC changes for EVs that are not under charger control at t+1.""" + + env = self.env + random_state = getattr(env, '_ev_drift_random_state', None) + + if random_state is None: + episode_index = int(getattr(getattr(env, 'episode_tracker', None), 'episode', 0)) + random_state = np.random.RandomState(int(env.random_seed) + episode_index) + env._ev_drift_random_state = random_state + + t = env.time_step + if t + 1 >= env.episode_tracker.episode_time_steps: + return + + for ev in env.electric_vehicles: + ev_id = ev.name + found_in_charger = False + + for building in env.buildings: + for charger in building.electric_vehicle_chargers or []: + sim: ChargerSimulation = charger.charger_simulation + + curr_id = sim.electric_vehicle_id[t] if t < len(sim.electric_vehicle_id) else '' + next_id = sim.electric_vehicle_id[t + 1] if t + 1 < len(sim.electric_vehicle_id) else '' + curr_state = sim.electric_vehicle_charger_state[t] if t < len(sim.electric_vehicle_charger_state) else np.nan + next_state = sim.electric_vehicle_charger_state[t + 1] if t + 1 < len(sim.electric_vehicle_charger_state) else np.nan + + currently_connected = isinstance(curr_id, str) and curr_id == ev_id and curr_state == 1 + if currently_connected: + found_in_charger = True + break + + is_connecting = ( + isinstance(next_id, str) + and next_id == ev_id + and next_state == 1 + and curr_state != 1 + ) + is_incoming = isinstance(curr_id, str) and curr_id == ev_id and curr_state == 2 + + if is_connecting: + found_in_charger = True + if is_incoming: + if t < len(sim.electric_vehicle_estimated_soc_arrival): + soc = sim.electric_vehicle_estimated_soc_arrival[t] + else: + soc = np.nan + else: + if t + 1 < len(sim.electric_vehicle_estimated_soc_arrival): + soc = sim.electric_vehicle_estimated_soc_arrival[t + 1] + else: + soc = np.nan + + if 0 <= soc <= 1: + ev.battery.force_set_soc(soc) + break + + if found_in_charger: + break + + if not found_in_charger: + if t > 0: + last_soc = ev.battery.soc[t - 1] + drift_std = self._ev_unconnected_drift_std(env.seconds_per_time_step) + variability = np.clip(random_state.normal(1.0, drift_std), 0.6, 1.4) + new_soc = np.clip(last_soc * variability, 0.0, 1.0) + ev.battery.force_set_soc(new_soc) + + def update_variables(self): + """Update district aggregate series from current building states.""" + + env = self.env + + for building in env.buildings: + building.update_variables() + + if getattr(env, 'community_market_enabled', False): + self._apply_community_market_settlement() + + def _set_or_append(lst, value): + if len(lst) == env.time_step: + lst.append(value) + elif len(lst) == env.time_step + 1: + lst[env.time_step] = value + else: + del lst[env.time_step + 1:] + if len(lst) < env.time_step: + lst.extend([0.0] * (env.time_step - len(lst))) + lst.append(value) + + total = sum(building.net_electricity_consumption[env.time_step] for building in env.buildings) + _set_or_append(env.net_electricity_consumption, total) + + total_cost = sum(building.net_electricity_consumption_cost[env.time_step] for building in env.buildings) + _set_or_append(env.net_electricity_consumption_cost, total_cost) + + total_emission = sum(building.net_electricity_consumption_emission[env.time_step] for building in env.buildings) + _set_or_append(env.net_electricity_consumption_emission, total_emission) + + @staticmethod + def _to_scalar(value, default: float = 0.0) -> float: + try: + scalar = float(value) + except (TypeError, ValueError): + return float(default) + + if not np.isfinite(scalar): + return float(default) + + return scalar + + def _resolve_step_value(self, value, time_step: int, default: float = 0.0) -> float: + if isinstance(value, (list, tuple, np.ndarray)): + if len(value) == 0: + return float(default) + index = min(max(time_step, 0), len(value) - 1) + return self._to_scalar(value[index], default) + + return self._to_scalar(value, default) + + @staticmethod + def _allocate_equal_share_import(imports: np.ndarray, traded_kwh: float) -> np.ndarray: + """Allocate local traded energy equally among importers with demand caps.""" + + allocations = np.zeros_like(imports, dtype='float64') + remaining = max(float(traded_kwh), 0.0) + eps = 1e-9 + + while remaining > eps: + needs = imports - allocations + active = needs > eps + active_count = int(np.count_nonzero(active)) + + if active_count == 0: + break + + share = remaining / active_count + granted = np.minimum(share, needs[active]) + granted_total = float(granted.sum()) + + if granted_total <= eps: + break + + allocations[active] += granted + remaining -= granted_total + + return allocations + + def _apply_community_market_settlement(self): + """Apply optional intracommunity settlement and override building costs for current step.""" + + env = self.env + t = env.time_step + if len(env.buildings) == 0: + return + + ratio = self._to_scalar(getattr(env, 'community_market_sell_ratio', 0.8), 0.8) + ratio = min(max(ratio, 0.0), 1.0) + + net_values = np.array([self._to_scalar(building.net_electricity_consumption[t], 0.0) for building in env.buildings], dtype='float64') + imports = np.clip(net_values, 0.0, None) + exports = np.clip(-net_values, 0.0, None) + + total_import = float(imports.sum()) + total_export = float(exports.sum()) + traded_kwh = min(total_import, total_export) + + if total_import > 0.0 and traded_kwh > 0.0: + local_import = self._allocate_equal_share_import(imports, traded_kwh) + else: + local_import = np.zeros_like(imports, dtype='float64') + + if total_export > 0.0: + local_export = exports * (traded_kwh / total_export) + else: + local_export = np.zeros_like(exports, dtype='float64') + + grid_export_price_cfg = getattr(env, 'community_market_grid_export_price', 0.0) + market_settlement = [] + + for idx, building in enumerate(env.buildings): + grid_import_price = self._to_scalar(building.pricing.electricity_pricing[t], 0.0) + local_price = ratio * grid_import_price + grid_export_price = self._resolve_step_value(grid_export_price_cfg, t, 0.0) + counterfactual_legacy_cost = self._to_scalar(building.net_electricity_consumption_cost[t], 0.0) + + grid_import_remaining = max(imports[idx] - local_import[idx], 0.0) + grid_export_remaining = max(exports[idx] - local_export[idx], 0.0) + + cost = ( + grid_import_remaining * grid_import_price + + local_import[idx] * local_price + - local_export[idx] * local_price + - grid_export_remaining * grid_export_price + ) + savings = counterfactual_legacy_cost - cost + + building.set_net_electricity_consumption_cost(cost, time_step=t) + market_settlement.append( + { + 'building': building.name, + 'local_import_kwh': float(local_import[idx]), + 'local_export_kwh': float(local_export[idx]), + 'grid_import_kwh': float(grid_import_remaining), + 'grid_export_kwh': float(grid_export_remaining), + 'local_price': float(local_price), + 'grid_import_price': float(grid_import_price), + 'grid_export_price': float(grid_export_price), + 'counterfactual_cost_eur': float(counterfactual_legacy_cost), + 'settled_cost_eur': float(cost), + 'market_savings_eur': float(savings), + } + ) + + env._last_community_market_settlement = market_settlement + + history = getattr(env, '_community_market_settlement_history', None) + if history is not None: + if len(history) == t: + history.append(market_settlement) + elif len(history) == t + 1: + history[t] = market_settlement + else: + del history[t + 1:] + if len(history) < t: + history.extend([[] for _ in range(t - len(history))]) + history.append(market_settlement) diff --git a/citylearn/utilities.py b/citylearn/utilities.py index 4b8e147ed..59137183e 100644 --- a/citylearn/utilities.py +++ b/citylearn/utilities.py @@ -4,6 +4,55 @@ import simplejson as json import yaml + +def parse_bool(value: Any, default: Any = None, path: str = 'value'): + """Parse boolean-like values from schema/config sources. + + Accepted inputs: + - bool + - int/np.integer: 0 or 1 + - float/np.floating: 0.0 or 1.0 + - str (case-insensitive): true/false, 1/0, yes/no, on/off + + Parameters + ---------- + value: Any + Value to parse. + default: Any, optional + Fallback value if `value` is None. + path: str, default: 'value' + Config path used in validation error messages. + """ + + if value is None: + if default is None: + return None + return parse_bool(default, default=None, path=path) + + if isinstance(value, (bool, np.bool_)): + return bool(value) + + if isinstance(value, (int, np.integer)) and not isinstance(value, bool): + if int(value) in (0, 1): + return bool(int(value)) + raise ValueError(f"{path} must be one of: true/false, 1/0, yes/no, on/off.") + + if isinstance(value, (float, np.floating)): + if float(value) in (0.0, 1.0): + return bool(int(float(value))) + raise ValueError(f"{path} must be one of: true/false, 1/0, yes/no, on/off.") + + if isinstance(value, str): + token = value.strip().lower() + if token in {'true', '1', 'yes', 'on'}: + return True + if token in {'false', '0', 'no', 'off'}: + return False + raise ValueError(f"{path} must be one of: true/false, 1/0, yes/no, on/off.") + + raise ValueError(f"{path} must be one of: true/false, 1/0, yes/no, on/off.") + + class FileHandler: @staticmethod def read_json(filepath: str, **kwargs) -> dict: @@ -172,4 +221,4 @@ def generate_gaussian_noise(input_data: Union[np.ndarray, Iterable[float]], nois def generate_scaled_noise(input_data: Union[np.ndarray, Iterable[float]], noise_std: float, scale: float = 1.0) -> np.ndarray: """Generates pre-scaled noise (e.g., for percentage values).""" - return NoiseUtils.generate_gaussian_noise(input_data, noise_std) * scale \ No newline at end of file + return NoiseUtils.generate_gaussian_noise(input_data, noise_std) * scale diff --git a/citylearn/utils/file_handler.py b/citylearn/utils/file_handler.py new file mode 100644 index 000000000..2f4fe3090 --- /dev/null +++ b/citylearn/utils/file_handler.py @@ -0,0 +1,146 @@ +import pickle +from typing import Any +import simplejson as json +import yaml + +class FileHandler: + @staticmethod + def read_json(filepath: str, **kwargs) -> dict: + """Return JSON document as dictionary. + + Parameters + ---------- + filepath : str + pathname of JSON document. + + Other Parameters + ---------------- + **kwargs : dict + Other infrequently used keyword arguments to be parsed to `simplejson.load`. + + Returns + ------- + dict + JSON document converted to dictionary. + """ + + with open(filepath) as f: + json_file = json.load(f, **kwargs) + + return json_file + + @staticmethod + def write_json(filepath: str, dictionary: dict, **kwargs): + """Write dictionary to JSON file. + + Parameters + ---------- + filepath : str + pathname of JSON document. + dictionary: dict + dictionary to convert to JSON. + + Other Parameters + ---------------- + **kwargs : dict + Other infrequently used keyword arguments to be parsed to `simplejson.dump`. + """ + + kwargs = {'ignore_nan': True, 'sort_keys': False, 'default': str, 'indent': 2, **kwargs} + + with open(filepath,'w') as f: + json.dump(dictionary, f, **kwargs) + + @staticmethod + def read_yaml(filepath: str) -> dict: + """Return YAML document as dictionary. + + Parameters + ---------- + filepath : str + pathname of YAML document. + + Returns + ------- + dict + YAML document converted to dictionary. + """ + + with open(filepath, 'r') as f: + data = yaml.safe_load(f) + + return data + + @staticmethod + def write_yaml(filepath: str, dictionary: dict, **kwargs): + """Write dictionary to YAML file. + + Parameters + ---------- + filepath : str + pathname of YAML document. + dictionary: dict + dictionary to convert to YAML. + + Other Parameters + ---------------- + **kwargs : dict + Other infrequently used keyword arguments to be parsed to `yaml.safe_dump`. + """ + + kwargs = {'sort_keys': False, 'indent': 2, **kwargs} + + with open(filepath, 'w') as f: + yaml.safe_dump(dictionary, f, **kwargs) + + @staticmethod + def read_pickle(filepath: str, **kwargs) -> Any: + """Return pickle file as some Python class object. + + Parameters + ---------- + filepath : str + pathname of pickle file. + + Other Parameters + ---------------- + **kwargs : dict + Other infrequently used keyword arguments to be parsed to `pickle.load`. + + Returns + ------- + Any + Pickle file as a Python object. + """ + + with (open(filepath, 'rb')) as f: + data = pickle.load(f, **kwargs) + + return data + + @staticmethod + def write_pickle(filepath: str, data: Any, **kwargs): + """Write Python object to pickle file. + + Parameters + ---------- + filepath : str + pathname of pickle document. + data: dict + object to convert to pickle. + + Other Parameters + ---------------- + **kwargs : dict + Other infrequently used keyword arguments to be parsed to `pickle.dump`. + """ + + with open(filepath, 'wb') as f: + pickle.dump(data, f, **kwargs) + + @staticmethod + def join_url(*args: str) -> str: + url = '/'.join([a.strip('/') for a in args]) + + return url + \ No newline at end of file diff --git a/citylearn/utils/noise.py b/citylearn/utils/noise.py new file mode 100644 index 000000000..79ee87ce1 --- /dev/null +++ b/citylearn/utils/noise.py @@ -0,0 +1,31 @@ +from typing import Iterable, Union +import numpy as np + +class Noise: + @staticmethod + def generate_gaussian_noise(input_data: Union[np.ndarray, Iterable[float]], noise_std: float) -> np.ndarray: + """Generates Gaussian noise matching input shape. + + Parameters + ---------- + input_data : Union[np.ndarray, Iterable[float]] + Time series to add noise to. + noise_std : float + Noise standard deviation (ignored if <= 0) + + Returns + ------- + noise: np.ndarray + Zero-mean noise array with same shape as input + """ + + arr = np.asarray(input_data) # Handles both ndarray and Iterable + if noise_std <= 0: + return np.zeros(arr.shape) + return np.random.normal(loc=0, scale=noise_std, size=arr.shape) + + @staticmethod + def generate_scaled_noise(input_data: Union[np.ndarray, Iterable[float]], noise_std: float, scale: float = 1.0) -> np.ndarray: + """Generates pre-scaled noise (e.g., for percentage values).""" + + return Noise.generate_gaussian_noise(input_data, noise_std) * scale \ No newline at end of file diff --git a/citylearn/utils/paths.py b/citylearn/utils/paths.py new file mode 100644 index 000000000..593162ced --- /dev/null +++ b/citylearn/utils/paths.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_10.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_10.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_10.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_10.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_11.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_11.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_11.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_11.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_12.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_12.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_12.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_12.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_13.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_13.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_13.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_13.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_14.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_14.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_14.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_14.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_15.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_15.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_15.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_15.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_16.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_16.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_16.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_16.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_17.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_17.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_17.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_17.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_2.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_2.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_2.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_2.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_3.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_3.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_3.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_3.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_4.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_4.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_4.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_4.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_5.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_5.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_5.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_5.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_6.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_6.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_6.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_6.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_7.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_7.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_7.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_7.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_8.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_8.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_8.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_8.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Building_9.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Building_9.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Building_9.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Building_9.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/Washing_Machine_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/Washing_Machine_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/Washing_Machine_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/Washing_Machine_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/carbon_intensity.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/carbon_intensity.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/carbon_intensity.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/carbon_intensity.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_10_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_10_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_10_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_10_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_12_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_12_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_12_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_12_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_15_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_15_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_15_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_15_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_15_2.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_15_2.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_15_2.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_15_2.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_1_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_1_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_1_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_1_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_4_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_4_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_4_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_4_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_5_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_5_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_5_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_5_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/charger_7_1.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/charger_7_1.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/charger_7_1.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/charger_7_1.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/pricing.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/pricing.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/pricing.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/pricing.csv diff --git a/data/datasets/citylearn_charging_constraints_demo/schema.json b/data/datasets/citylearn_three_phase_electrical_service_demo/schema.json similarity index 96% rename from data/datasets/citylearn_charging_constraints_demo/schema.json rename to data/datasets/citylearn_three_phase_electrical_service_demo/schema.json index 19311e3f8..a0a843256 100644 --- a/data/datasets/citylearn_charging_constraints_demo/schema.json +++ b/data/datasets/citylearn_three_phase_electrical_service_demo/schema.json @@ -830,7 +830,8 @@ "efficiency": 0.9, "capacity_loss_coefficient": 1e-05, "loss_coefficient": 0.0, - "nominal_power": 5.0 + "nominal_power": 5.0, + "phase_connection": "all_phases" } }, "pv": { @@ -852,7 +853,8 @@ "max_charging_power": 7.4, "min_charging_power": 0, "max_discharging_power": 7.4, - "min_discharging_power": 0 + "min_discharging_power": 0, + "phase_connection": "L1" } }, "charger_15_2": { @@ -866,37 +868,40 @@ "max_charging_power": 11, "min_charging_power": 0, "max_discharging_power": 11, - "min_discharging_power": 0 + "min_discharging_power": 0, + "phase_connection": "L2" } } }, - "charging_constraints": { - "building_limit_kw": 12.0, + "electrical_service": { + "mode": "three_phase", + "default_split": "balanced", + "limits": { + "total": { + "import_kw": 12.0, + "export_kw": 12.0 + }, + "per_phase": { + "L1": { + "import_kw": 7.0, + "export_kw": 7.0 + }, + "L2": { + "import_kw": 5.0, + "export_kw": 5.0 + }, + "L3": { + "import_kw": 4.0, + "export_kw": 4.0 + } + } + }, "observations": { "headroom": true, + "headroom_export": true, "violation": true, "phase_encoding": true - }, - "phases": [ - { - "name": "phase_a", - "limit_kw": 7.0, - "chargers": [ - "charger_15_1" - ] - }, - { - "name": "phase_b", - "limit_kw": 5.0, - "chargers": [ - "charger_15_2" - ] - }, - { - "name": "phase_c", - "chargers": [] - } - ] + } } }, "Building_16": { @@ -954,4 +959,4 @@ } } } -} \ No newline at end of file +} diff --git a/data/datasets/citylearn_charging_constraints_demo/weather.csv b/data/datasets/citylearn_three_phase_electrical_service_demo/weather.csv similarity index 100% rename from data/datasets/citylearn_charging_constraints_demo/weather.csv rename to data/datasets/citylearn_three_phase_electrical_service_demo/weather.csv diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..5dede5e5a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +norecursedirs = scripts/manual +python_files = test_*.py diff --git a/requirements.txt b/requirements.txt index ce98b5d58..7cf7398fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -doe_xstock>=1.1.0 +doe_xstock>=2.0.0 gymnasium<=0.28.1 -nrel-pysam numpy<2.0.0 pandas pyyaml @@ -8,4 +7,4 @@ scikit-learn<=1.2.2 simplejson torch torchvision -openstudio<=3.3.0 +openstudio<=3.10.0 diff --git a/sample_observations.txt b/sample_observations.txt new file mode 100644 index 000000000..3c642460f --- /dev/null +++ b/sample_observations.txt @@ -0,0 +1,97 @@ +{'Net Electricity Consumption-kWh': '16.537399291992188', 'Non-shiftable Load-kWh': '2.2757999897003174', 'Non-shiftable Load Electricity Consumption-kWh': '6.827400207519531', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '8.863166809082031', 'Non-shiftable Load-kWh': '0.8511666655540466', 'Non-shiftable Load Electricity Consumption-kWh': '0.8511666655540466', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '5.689599514007568', 'Non-shiftable Load-kWh': '0.8345999717712402', 'Non-shiftable Load Electricity Consumption-kWh': '0.8345999717712402', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '5.693166255950928', 'Non-shiftable Load-kWh': '0.8381666541099548', 'Non-shiftable Load Electricity Consumption-kWh': '0.8381666541099548', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '2.8212435245513916', 'Non-shiftable Load-kWh': '1.47843337059021', 'Non-shiftable Load Electricity Consumption-kWh': '1.47843337059021', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.882153034210205', 'Non-shiftable Load-kWh': '1.2561999559402466', 'Non-shiftable Load Electricity Consumption-kWh': '1.2561999559402466', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '2.2381997108459473', 'Non-shiftable Load-kWh': '1.8695166110992432', 'Non-shiftable Load Electricity Consumption-kWh': '1.8695166110992432', 'Energy Production from PV-kWh': '-0.17404999923706055'} +{'Net Electricity Consumption-kWh': '0.0036388293374329805', 'Non-shiftable Load-kWh': '0.80881667137146', 'Non-shiftable Load Electricity Consumption-kWh': '0.80881667137146', 'Energy Production from PV-kWh': '-1.3382500305175782'} +{'Net Electricity Consumption-kWh': '-2.1706833839416504', 'Non-shiftable Load-kWh': '0.6159666776657104', 'Non-shiftable Load Electricity Consumption-kWh': '0.6159666776657104', 'Energy Production from PV-kWh': '-3.31764990234375'} +{'Net Electricity Consumption-kWh': '-5.133281707763672', 'Non-shiftable Load-kWh': '0.6272333264350891', 'Non-shiftable Load Electricity Consumption-kWh': '0.6272333264350891', 'Energy Production from PV-kWh': '-5.436299926757813'} +{'Net Electricity Consumption-kWh': '-14.265483856201172', 'Non-shiftable Load-kWh': '0.618066668510437', 'Non-shiftable Load Electricity Consumption-kWh': '0.618066668510437', 'Energy Production from PV-kWh': '-7.28355029296875'} +{'Net Electricity Consumption-kWh': '-15.507866859436035', 'Non-shiftable Load-kWh': '0.6451333165168762', 'Non-shiftable Load Electricity Consumption-kWh': '0.6451333165168762', 'Energy Production from PV-kWh': '-8.553'} +{'Net Electricity Consumption-kWh': '-15.95346736907959', 'Non-shiftable Load-kWh': '0.7644333243370056', 'Non-shiftable Load Electricity Consumption-kWh': '0.7644333243370056', 'Energy Production from PV-kWh': '-9.117900146484375'} +{'Net Electricity Consumption-kWh': '-7.982850074768066', 'Non-shiftable Load-kWh': '1.4322999715805054', 'Non-shiftable Load Electricity Consumption-kWh': '1.4322999715805054', 'Energy Production from PV-kWh': '-9.015150146484375'} +{'Net Electricity Consumption-kWh': '-6.774266719818115', 'Non-shiftable Load-kWh': '1.9018332958221436', 'Non-shiftable Load Electricity Consumption-kWh': '1.9018332958221436', 'Energy Production from PV-kWh': '-8.276099853515625'} +{'Net Electricity Consumption-kWh': '-5.645317077636719', 'Non-shiftable Load-kWh': '1.7299833297729492', 'Non-shiftable Load Electricity Consumption-kWh': '1.7299833297729492', 'Energy Production from PV-kWh': '-6.97530029296875'} +{'Net Electricity Consumption-kWh': '-4.077666282653809', 'Non-shiftable Load-kWh': '1.3914333581924438', 'Non-shiftable Load Electricity Consumption-kWh': '1.3914333581924438', 'Energy Production from PV-kWh': '-5.069099853515625'} +{'Net Electricity Consumption-kWh': '-6.291865825653076', 'Non-shiftable Load-kWh': '1.0300999879837036', 'Non-shiftable Load Electricity Consumption-kWh': '1.0300999879837036', 'Energy Production from PV-kWh': '-2.9112000732421874'} +{'Net Electricity Consumption-kWh': '-3.923083543777466', 'Non-shiftable Load-kWh': '1.3838167190551758', 'Non-shiftable Load Electricity Consumption-kWh': '1.3838167190551758', 'Energy Production from PV-kWh': '-0.9869000244140625'} +{'Net Electricity Consumption-kWh': '-3.3628835678100586', 'Non-shiftable Load-kWh': '1.031916618347168', 'Non-shiftable Load Electricity Consumption-kWh': '1.031916618347168', 'Energy Production from PV-kWh': '-0.07479999732971192'} +{'Net Electricity Consumption-kWh': '12.403982162475586', 'Non-shiftable Load-kWh': '3.6039834022521973', 'Non-shiftable Load Electricity Consumption-kWh': '3.6039834022521973', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '13.808499336242676', 'Non-shiftable Load-kWh': '5.008500099182129', 'Non-shiftable Load Electricity Consumption-kWh': '5.008500099182129', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '13.151216506958008', 'Non-shiftable Load-kWh': '3.896216630935669', 'Non-shiftable Load Electricity Consumption-kWh': '3.896216630935669', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '12.173139572143555', 'Non-shiftable Load-kWh': '3.5570833683013916', 'Non-shiftable Load Electricity Consumption-kWh': '3.5570833683013916', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '2.81278395652771', 'Non-shiftable Load-kWh': '1.4113333225250244', 'Non-shiftable Load Electricity Consumption-kWh': '1.4113333225250244', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.6041923761367798', 'Non-shiftable Load-kWh': '0.9794166684150696', 'Non-shiftable Load Electricity Consumption-kWh': '0.9794166684150696', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.4435688257217407', 'Non-shiftable Load-kWh': '0.9009749889373779', 'Non-shiftable Load Electricity Consumption-kWh': '0.9009749889373779', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.4554862976074219', 'Non-shiftable Load-kWh': '0.9224333167076111', 'Non-shiftable Load Electricity Consumption-kWh': '0.9224333167076111', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.4424303770065308', 'Non-shiftable Load-kWh': '0.9104833602905273', 'Non-shiftable Load Electricity Consumption-kWh': '0.9104833602905273', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '11.88711929321289', 'Non-shiftable Load-kWh': '1.0103000402450562', 'Non-shiftable Load Electricity Consumption-kWh': '1.0103000402450562', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.6603366136550903', 'Non-shiftable Load-kWh': '1.2976833581924438', 'Non-shiftable Load Electricity Consumption-kWh': '1.2976833581924438', 'Energy Production from PV-kWh': '-0.16915000534057617'} +{'Net Electricity Consumption-kWh': '0.40861958265304565', 'Non-shiftable Load-kWh': '1.1922667026519775', 'Non-shiftable Load Electricity Consumption-kWh': '1.1922667026519775', 'Energy Production from PV-kWh': '-1.3154500122070312'} +{'Net Electricity Consumption-kWh': '-1.4787671566009521', 'Non-shiftable Load-kWh': '1.2234666347503662', 'Non-shiftable Load Electricity Consumption-kWh': '1.2234666347503662', 'Energy Production from PV-kWh': '-3.233099853515625'} +{'Net Electricity Consumption-kWh': '-4.604245185852051', 'Non-shiftable Load-kWh': '1.0826833248138428', 'Non-shiftable Load Electricity Consumption-kWh': '1.0826833248138428', 'Energy Production from PV-kWh': '-5.362699951171875'} +{'Net Electricity Consumption-kWh': '-14.060733795166016', 'Non-shiftable Load-kWh': '0.6676666736602783', 'Non-shiftable Load Electricity Consumption-kWh': '0.6676666736602783', 'Energy Production from PV-kWh': '-7.12839990234375'} +{'Net Electricity Consumption-kWh': '-15.313650131225586', 'Non-shiftable Load-kWh': '0.666100025177002', 'Non-shiftable Load Electricity Consumption-kWh': '0.666100025177002', 'Energy Production from PV-kWh': '-8.37975'} +{'Net Electricity Consumption-kWh': '-15.966716766357422', 'Non-shiftable Load-kWh': '0.6456833481788635', 'Non-shiftable Load Electricity Consumption-kWh': '0.6456833481788635', 'Energy Production from PV-kWh': '-9.01239990234375'} +{'Net Electricity Consumption-kWh': '-8.098382949829102', 'Non-shiftable Load-kWh': '1.1646167039871216', 'Non-shiftable Load Electricity Consumption-kWh': '1.1646167039871216', 'Energy Production from PV-kWh': '-8.862999755859375'} +{'Net Electricity Consumption-kWh': '-7.1304168701171875', 'Non-shiftable Load-kWh': '1.443583369255066', 'Non-shiftable Load Electricity Consumption-kWh': '1.443583369255066', 'Energy Production from PV-kWh': '-8.174000244140625'} +{'Net Electricity Consumption-kWh': '-6.392800331115723', 'Non-shiftable Load-kWh': '0.8619499802589417', 'Non-shiftable Load Electricity Consumption-kWh': '0.8619499802589417', 'Energy Production from PV-kWh': '-6.854750244140625'} +{'Net Electricity Consumption-kWh': '-4.672500133514404', 'Non-shiftable Load-kWh': '0.7617499828338623', 'Non-shiftable Load Electricity Consumption-kWh': '0.7617499828338623', 'Energy Production from PV-kWh': '-5.034250122070312'} +{'Net Electricity Consumption-kWh': '-0.37523353099823', 'Non-shiftable Load-kWh': '2.9404666423797607', 'Non-shiftable Load Electricity Consumption-kWh': '2.9404666423797607', 'Energy Production from PV-kWh': '-2.9157000732421876'} +{'Net Electricity Consumption-kWh': '1.0572665929794312', 'Non-shiftable Load-kWh': '2.4557666778564453', 'Non-shiftable Load Electricity Consumption-kWh': '2.4557666778564453', 'Energy Production from PV-kWh': '-0.9985000305175781'} +{'Net Electricity Consumption-kWh': '0.8391194343566895', 'Non-shiftable Load-kWh': '5.264016628265381', 'Non-shiftable Load Electricity Consumption-kWh': '5.264016628265381', 'Energy Production from PV-kWh': '-0.08285000038146972'} +{'Net Electricity Consumption-kWh': '13.99293327331543', 'Non-shiftable Load-kWh': '5.192933559417725', 'Non-shiftable Load Electricity Consumption-kWh': '5.192933559417725', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '11.764150619506836', 'Non-shiftable Load-kWh': '3.9318833351135254', 'Non-shiftable Load Electricity Consumption-kWh': '3.9318833351135254', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '3.260903835296631', 'Non-shiftable Load-kWh': '1.8948500156402588', 'Non-shiftable Load Electricity Consumption-kWh': '1.8948500156402588', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.9743298292160034', 'Non-shiftable Load-kWh': '1.3533666133880615', 'Non-shiftable Load Electricity Consumption-kWh': '1.3533666133880615', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '2.3312017917633057', 'Non-shiftable Load-kWh': '1.7904499769210815', 'Non-shiftable Load Electricity Consumption-kWh': '1.7904499769210815', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '20.279449462890625', 'Non-shiftable Load-kWh': '0.9643333554267883', 'Non-shiftable Load Electricity Consumption-kWh': '0.9643333554267883', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.501621961593628', 'Non-shiftable Load-kWh': '0.9697833061218262', 'Non-shiftable Load Electricity Consumption-kWh': '0.9697833061218262', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.5118376016616821', 'Non-shiftable Load-kWh': '0.9800333380699158', 'Non-shiftable Load Electricity Consumption-kWh': '0.9800333380699158', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.5054835081100464', 'Non-shiftable Load-kWh': '0.9736833572387695', 'Non-shiftable Load Electricity Consumption-kWh': '0.9736833572387695', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.9506497383117676', 'Non-shiftable Load-kWh': '1.4188499450683594', 'Non-shiftable Load Electricity Consumption-kWh': '1.4188499450683594', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '2.1509697437286377', 'Non-shiftable Load-kWh': '1.8283666372299194', 'Non-shiftable Load Electricity Consumption-kWh': '1.8283666372299194', 'Energy Production from PV-kWh': '-0.20919998931884765'} +{'Net Electricity Consumption-kWh': '0.5472193956375122', 'Non-shiftable Load-kWh': '1.4670166969299316', 'Non-shiftable Load Electricity Consumption-kWh': '1.4670166969299316', 'Energy Production from PV-kWh': '-1.4516000061035157'} +{'Net Electricity Consumption-kWh': '-1.8880833387374878', 'Non-shiftable Load-kWh': '0.9732166528701782', 'Non-shiftable Load Electricity Consumption-kWh': '0.9732166528701782', 'Energy Production from PV-kWh': '-3.316300048828125'} +{'Net Electricity Consumption-kWh': '-4.124466419219971', 'Non-shiftable Load-kWh': '1.4841333627700806', 'Non-shiftable Load Electricity Consumption-kWh': '1.4841333627700806', 'Energy Production from PV-kWh': '-5.208599853515625'} +{'Net Electricity Consumption-kWh': '-5.474966526031494', 'Non-shiftable Load-kWh': '1.8785333633422852', 'Non-shiftable Load Electricity Consumption-kWh': '1.8785333633422852', 'Energy Production from PV-kWh': '-6.953499755859375'} +{'Net Electricity Consumption-kWh': '-6.450200080871582', 'Non-shiftable Load-kWh': '2.6247000694274902', 'Non-shiftable Load Electricity Consumption-kWh': '2.6247000694274902', 'Energy Production from PV-kWh': '-8.67489990234375'} +{'Net Electricity Consumption-kWh': '-7.1972832679748535', 'Non-shiftable Load-kWh': '2.3644165992736816', 'Non-shiftable Load Electricity Consumption-kWh': '2.3644165992736816', 'Energy Production from PV-kWh': '-9.16169970703125'} +{'Net Electricity Consumption-kWh': '-7.719449996948242', 'Non-shiftable Load-kWh': '1.7574000358581543', 'Non-shiftable Load Electricity Consumption-kWh': '1.7574000358581543', 'Energy Production from PV-kWh': '-9.07685009765625'} +{'Net Electricity Consumption-kWh': '-6.739683151245117', 'Non-shiftable Load-kWh': '2.0072667598724365', 'Non-shiftable Load Electricity Consumption-kWh': '2.0072667598724365', 'Energy Production from PV-kWh': '-8.346949951171876'} +{'Net Electricity Consumption-kWh': '-6.338382720947266', 'Non-shiftable Load-kWh': '1.0462666749954224', 'Non-shiftable Load Electricity Consumption-kWh': '1.0462666749954224', 'Energy Production from PV-kWh': '-6.984649658203125'} +{'Net Electricity Consumption-kWh': '-4.796199798583984', 'Non-shiftable Load-kWh': '0.6873999834060669', 'Non-shiftable Load Electricity Consumption-kWh': '0.6873999834060669', 'Energy Production from PV-kWh': '-5.083599975585938'} +{'Net Electricity Consumption-kWh': '-6.997683525085449', 'Non-shiftable Load-kWh': '0.6611166596412659', 'Non-shiftable Load Electricity Consumption-kWh': '0.6611166596412659', 'Energy Production from PV-kWh': '-2.9387999267578127'} +{'Net Electricity Consumption-kWh': '-3.4208502769470215', 'Non-shiftable Load-kWh': '2.2571499347686768', 'Non-shiftable Load Electricity Consumption-kWh': '2.2571499347686768', 'Energy Production from PV-kWh': '-0.9580000305175781'} +{'Net Electricity Consumption-kWh': '-2.8348305225372314', 'Non-shiftable Load-kWh': '1.5694667100906372', 'Non-shiftable Load Electricity Consumption-kWh': '1.5694667100906372', 'Energy Production from PV-kWh': '-0.06225'} +{'Net Electricity Consumption-kWh': '13.48778247833252', 'Non-shiftable Load-kWh': '4.687783241271973', 'Non-shiftable Load Electricity Consumption-kWh': '4.687783241271973', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '12.839582443237305', 'Non-shiftable Load-kWh': '4.039583206176758', 'Non-shiftable Load Electricity Consumption-kWh': '4.039583206176758', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '12.201898574829102', 'Non-shiftable Load-kWh': '2.946899890899658', 'Non-shiftable Load Electricity Consumption-kWh': '2.946899890899658', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '5.8466949462890625', 'Non-shiftable Load-kWh': '2.2458999156951904', 'Non-shiftable Load Electricity Consumption-kWh': '2.2458999156951904', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.7744239568710327', 'Non-shiftable Load-kWh': '0.9128833413124084', 'Non-shiftable Load Electricity Consumption-kWh': '0.9128833413124084', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '49.14800262451172', 'Non-shiftable Load-kWh': '0.8333500027656555', 'Non-shiftable Load Electricity Consumption-kWh': '0.8333500027656555', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.3451483249664307', 'Non-shiftable Load-kWh': '0.8093000054359436', 'Non-shiftable Load Electricity Consumption-kWh': '0.8093000054359436', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.3583216667175293', 'Non-shiftable Load-kWh': '0.8260499835014343', 'Non-shiftable Load Electricity Consumption-kWh': '0.8260499835014343', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.3524242639541626', 'Non-shiftable Load-kWh': '0.8205666542053223', 'Non-shiftable Load Electricity Consumption-kWh': '0.8205666542053223', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.7456417083740234', 'Non-shiftable Load-kWh': '1.2138333320617676', 'Non-shiftable Load Electricity Consumption-kWh': '1.2138333320617676', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '1.1819005012512207', 'Non-shiftable Load-kWh': '0.7760000228881836', 'Non-shiftable Load Electricity Consumption-kWh': '0.7760000228881836', 'Energy Production from PV-kWh': '-0.1259000015258789'} +{'Net Electricity Consumption-kWh': '0.013516632840037346', 'Non-shiftable Load-kWh': '0.8312666416168213', 'Non-shiftable Load Electricity Consumption-kWh': '0.8312666416168213', 'Energy Production from PV-kWh': '-1.3495500183105469'} +{'Net Electricity Consumption-kWh': '-1.7215499877929688', 'Non-shiftable Load-kWh': '0.5700500011444092', 'Non-shiftable Load Electricity Consumption-kWh': '0.5700500011444092', 'Energy Production from PV-kWh': '-2.7465999755859376'} +{'Net Electricity Consumption-kWh': '-4.191050052642822', 'Non-shiftable Load-kWh': '0.8452500104904175', 'Non-shiftable Load Electricity Consumption-kWh': '0.8452500104904175', 'Energy Production from PV-kWh': '-4.636300048828125'} +{'Net Electricity Consumption-kWh': '-6.396883010864258', 'Non-shiftable Load-kWh': '1.2543666362762451', 'Non-shiftable Load Electricity Consumption-kWh': '1.2543666362762451', 'Energy Production from PV-kWh': '-7.251249755859375'} +{'Net Electricity Consumption-kWh': '-8.306966781616211', 'Non-shiftable Load-kWh': '0.5339333415031433', 'Non-shiftable Load Electricity Consumption-kWh': '0.5339333415031433', 'Energy Production from PV-kWh': '-8.44089990234375'} +{'Net Electricity Consumption-kWh': '-8.922416687011719', 'Non-shiftable Load-kWh': '0.517383337020874', 'Non-shiftable Load Electricity Consumption-kWh': '0.517383337020874', 'Energy Production from PV-kWh': '-9.0397998046875'} +{'Net Electricity Consumption-kWh': '-7.852999687194824', 'Non-shiftable Load-kWh': '1.5322500467300415', 'Non-shiftable Load Electricity Consumption-kWh': '1.5322500467300415', 'Energy Production from PV-kWh': '-8.985249755859375'} +{'Net Electricity Consumption-kWh': '-7.300999641418457', 'Non-shiftable Load-kWh': '1.4186500310897827', 'Non-shiftable Load Electricity Consumption-kWh': '1.4186500310897827', 'Energy Production from PV-kWh': '-8.319649658203126'} +{'Net Electricity Consumption-kWh': '-6.8229169845581055', 'Non-shiftable Load-kWh': '0.5733833312988281', 'Non-shiftable Load Electricity Consumption-kWh': '0.5733833312988281', 'Energy Production from PV-kWh': '-6.99630029296875'} +{'Net Electricity Consumption-kWh': '-4.716850280761719', 'Non-shiftable Load-kWh': '0.7239500284194946', 'Non-shiftable Load Electricity Consumption-kWh': '0.7239500284194946', 'Energy Production from PV-kWh': '-5.040800170898438'} +{'Net Electricity Consumption-kWh': '-5.723816871643066', 'Non-shiftable Load-kWh': '1.8375333547592163', 'Non-shiftable Load Electricity Consumption-kWh': '1.8375333547592163', 'Energy Production from PV-kWh': '-2.8413499145507815'} +{'Net Electricity Consumption-kWh': '-1.3960671424865723', 'Non-shiftable Load-kWh': '4.254133224487305', 'Non-shiftable Load Electricity Consumption-kWh': '4.254133224487305', 'Energy Production from PV-kWh': '-0.9302000427246093'} +{'Net Electricity Consumption-kWh': '-0.11086375266313553', 'Non-shiftable Load-kWh': '4.292933464050293', 'Non-shiftable Load Electricity Consumption-kWh': '4.292933464050293', 'Energy Production from PV-kWh': '-0.06175000190734863'} +{'Net Electricity Consumption-kWh': '12.903348922729492', 'Non-shiftable Load-kWh': '4.1033501625061035', 'Non-shiftable Load Electricity Consumption-kWh': '4.1033501625061035', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '11.580799102783203', 'Non-shiftable Load-kWh': '2.7808001041412354', 'Non-shiftable Load Electricity Consumption-kWh': '2.7808001041412354', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '11.40288257598877', 'Non-shiftable Load-kWh': '2.147883415222168', 'Non-shiftable Load Electricity Consumption-kWh': '2.147883415222168', 'Energy Production from PV-kWh': '-0.0'} +{'Net Electricity Consumption-kWh': '0.0', 'Non-shiftable Load-kWh': '1.9812166690826416', 'Non-shiftable Load Electricity Consumption-kWh': '0.0', 'Energy Production from PV-kWh': '-0.0'} + diff --git a/scripts/ci/perf_baseline.json b/scripts/ci/perf_baseline.json new file mode 100644 index 000000000..335b2e25e --- /dev/null +++ b/scripts/ci/perf_baseline.json @@ -0,0 +1,42 @@ +{ + "metadata": { + "generated_utc": "2026-03-11T21:05:17Z", + "schema": "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json", + "episode_steps": 600, + "seconds_per_time_step": 60, + "seed": 0 + }, + "cases": { + "none": { + "render_mode": "none", + "configured_steps": 600, + "executed_steps": 599, + "seconds_per_time_step": 60, + "init_s": 3.5692, + "reset_s": 0.0051, + "rollout_s": 4.0878, + "avg_step_ms": 6.8224, + "p95_step_ms": 8.6171, + "end_export_s": 0.0 + }, + "end": { + "render_mode": "end", + "configured_steps": 600, + "executed_steps": 599, + "seconds_per_time_step": 60, + "init_s": 3.3978, + "reset_s": 0.0049, + "rollout_s": 4.5983, + "avg_step_ms": 7.6747, + "p95_step_ms": 7.7274, + "end_export_s": 0.3084 + } + }, + "thresholds": { + "none_max_ms": 30.0, + "end_max_ms": 45.0, + "ratio_max": 2.0, + "baseline_regression_ratio": 3.0, + "baseline_slack_ms": 10.0 + } +} diff --git a/scripts/ci/perf_smoke.py b/scripts/ci/perf_smoke.py new file mode 100755 index 000000000..3a04c012b --- /dev/null +++ b/scripts/ci/perf_smoke.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Lightweight performance smoke test for CI. + +This catches major rollout-loop regressions and optionally compares against a +versioned baseline checked into the repository. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +import tempfile +import time +from pathlib import Path +from typing import Any, Dict + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from citylearn.citylearn import CityLearnEnv # noqa: E402 + +SCHEMA = ROOT / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" +DEFAULT_BASELINE_FILE = ROOT / "scripts/ci/perf_baseline.json" + + +def run_case(render_mode: str, episode_steps: int, seconds_per_time_step: int, seed: int) -> Dict[str, Any]: + render_dir = Path(tempfile.mkdtemp(prefix=f"citylearn_perf_{render_mode}_")) + + kwargs = { + "central_agent": True, + "episode_time_steps": episode_steps, + "seconds_per_time_step": seconds_per_time_step, + "random_seed": seed, + "debug_timing": True, + } + + if render_mode != "none": + kwargs.update( + { + "render_mode": render_mode, + "render_directory": render_dir, + "render_session_name": f"perf_{render_mode}", + } + ) + + t0 = time.perf_counter() + env = CityLearnEnv(str(SCHEMA), **kwargs) + t1 = time.perf_counter() + + observations, _ = env.reset() + t2 = time.perf_counter() + + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + step_times = [] + end_export_s = 0.0 + + while not env.terminated: + s0 = time.perf_counter() + observations, _, terminated, truncated, info = env.step([action]) + s1 = time.perf_counter() + step_times.append(s1 - s0) + end_export_s += float(info.get("end_export_time", 0.0)) + + if terminated or truncated: + break + + t3 = time.perf_counter() + env.close() + + avg_step_ms = float(np.mean(step_times) * 1000.0) if step_times else 0.0 + p95_step_ms = float(np.percentile(step_times, 95) * 1000.0) if step_times else 0.0 + + return { + "render_mode": render_mode, + "configured_steps": episode_steps, + "executed_steps": len(step_times), + "seconds_per_time_step": seconds_per_time_step, + "init_s": round(t1 - t0, 4), + "reset_s": round(t2 - t1, 4), + "rollout_s": round(t3 - t2, 4), + "avg_step_ms": round(avg_step_ms, 4), + "p95_step_ms": round(p95_step_ms, 4), + "end_export_s": round(end_export_s, 4), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--episode-steps", type=int, default=600) + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument("--seed", type=int, default=0) + + parser.add_argument("--none-max-ms", type=float, default=30.0) + parser.add_argument("--end-max-ms", type=float, default=45.0) + parser.add_argument("--ratio-max", type=float, default=2.0) + + parser.add_argument( + "--baseline-file", + type=Path, + default=DEFAULT_BASELINE_FILE, + help="Path to versioned baseline JSON.", + ) + parser.add_argument( + "--baseline-regression-ratio", + type=float, + default=2.5, + help="Allowed multiplier over baseline metrics before failing.", + ) + parser.add_argument( + "--baseline-slack-ms", + type=float, + default=5.0, + help="Absolute millisecond slack added on top of baseline*ratio.", + ) + parser.add_argument( + "--write-baseline", + action="store_true", + help="Write a new baseline file with current measurements and exit successfully.", + ) + parser.add_argument( + "--metrics-output", + type=Path, + default=None, + help="Optional output path for JSON report artifact.", + ) + + return parser.parse_args() + + +def _build_report(args: argparse.Namespace, none_case: Dict[str, Any], end_case: Dict[str, Any]) -> Dict[str, Any]: + try: + schema_path = str(SCHEMA.relative_to(ROOT)) + except ValueError: + schema_path = str(SCHEMA) + + return { + "metadata": { + "generated_utc": dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", + "schema": schema_path, + "episode_steps": args.episode_steps, + "seconds_per_time_step": args.seconds, + "seed": args.seed, + }, + "cases": { + "none": none_case, + "end": end_case, + }, + "thresholds": { + "none_max_ms": args.none_max_ms, + "end_max_ms": args.end_max_ms, + "ratio_max": args.ratio_max, + "baseline_regression_ratio": args.baseline_regression_ratio, + "baseline_slack_ms": args.baseline_slack_ms, + }, + } + + +def _compare_to_baseline(report: Dict[str, Any], baseline: Dict[str, Any], regression_ratio: float, slack_ms: float) -> list[str]: + errors: list[str] = [] + baseline_cases = baseline.get("cases", {}) + current_cases = report.get("cases", {}) + + for case_name in ("none", "end"): + if case_name not in baseline_cases or case_name not in current_cases: + continue + + base = baseline_cases[case_name] + cur = current_cases[case_name] + + for metric in ("avg_step_ms", "p95_step_ms"): + base_value = float(base.get(metric, 0.0)) + cur_value = float(cur.get(metric, 0.0)) + allowed = (base_value * regression_ratio) + slack_ms + + if cur_value > allowed: + errors.append( + f"{case_name} {metric} regression: {cur_value:.4f} > {allowed:.4f} " + f"(baseline={base_value:.4f}, ratio={regression_ratio}, slack_ms={slack_ms})" + ) + + if case_name == "end": + base_export = float(base.get("end_export_s", 0.0)) + cur_export = float(cur.get("end_export_s", 0.0)) + allowed_export = (base_export * regression_ratio) + max(0.5, slack_ms / 10.0) + if cur_export > allowed_export: + errors.append( + f"end end_export_s regression: {cur_export:.4f} > {allowed_export:.4f} " + f"(baseline={base_export:.4f})" + ) + + return errors + + +def _validate_absolute_thresholds(report: Dict[str, Any], none_max_ms: float, end_max_ms: float, ratio_max: float) -> list[str]: + errors: list[str] = [] + none_case = report["cases"]["none"] + end_case = report["cases"]["end"] + + if none_case["executed_steps"] <= 0 or end_case["executed_steps"] <= 0: + errors.append("No steps executed in one or more perf-smoke runs.") + + if none_case["avg_step_ms"] > none_max_ms: + errors.append( + f"none avg_step_ms too high: {none_case['avg_step_ms']} > {none_max_ms}" + ) + + if end_case["avg_step_ms"] > end_max_ms: + errors.append( + f"end avg_step_ms too high: {end_case['avg_step_ms']} > {end_max_ms}" + ) + + if none_case["avg_step_ms"] > 0: + ratio = end_case["avg_step_ms"] / none_case["avg_step_ms"] + if ratio > ratio_max: + errors.append(f"end/none ratio too high: {ratio:.3f} > {ratio_max}") + + return errors + + +def _write_json(path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def main() -> int: + args = parse_args() + + none_case = run_case("none", args.episode_steps, args.seconds, args.seed) + end_case = run_case("end", args.episode_steps, args.seconds, args.seed) + report = _build_report(args, none_case, end_case) + + if args.write_baseline: + _write_json(args.baseline_file, report) + print(f"Wrote baseline to {args.baseline_file}") + if args.metrics_output is not None: + _write_json(args.metrics_output, report) + return 0 + + errors = _validate_absolute_thresholds( + report, + none_max_ms=args.none_max_ms, + end_max_ms=args.end_max_ms, + ratio_max=args.ratio_max, + ) + + baseline_loaded = False + if args.baseline_file.is_file(): + baseline_loaded = True + baseline_data = json.loads(args.baseline_file.read_text(encoding="utf-8")) + errors.extend( + _compare_to_baseline( + report, + baseline_data, + regression_ratio=args.baseline_regression_ratio, + slack_ms=args.baseline_slack_ms, + ) + ) + + report["baseline"] = { + "path": str(args.baseline_file), + "loaded": baseline_loaded, + } + + print(json.dumps(report, indent=2)) + + if args.metrics_output is not None: + _write_json(args.metrics_output, report) + + if errors: + for error in errors: + print(f"PERF_SMOKE_ERROR: {error}") + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/manual/bench_runtime.py b/scripts/manual/bench_runtime.py new file mode 100644 index 000000000..db33af46c --- /dev/null +++ b/scripts/manual/bench_runtime.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Benchmark CityLearn rollout throughput for selected time resolutions and render modes.""" + +from __future__ import annotations + +import argparse +import sys +import tempfile +import time +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +SCHEMA = ROOT / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def run_case(seconds_per_time_step: int, render_mode: str, episode_time_steps: int, seed: int): + render_directory = Path(tempfile.mkdtemp(prefix=f"citylearn_bench_{render_mode}_")) + + kwargs = { + "central_agent": True, + "episode_time_steps": episode_time_steps, + "seconds_per_time_step": seconds_per_time_step, + "random_seed": seed, + "debug_timing": True, + } + + if render_mode != "none": + kwargs.update( + { + "render_mode": render_mode, + "render_directory": render_directory, + "render_session_name": f"bench_{render_mode}_{seconds_per_time_step}s", + } + ) + + t0 = time.perf_counter() + from citylearn.citylearn import CityLearnEnv + + env = CityLearnEnv(str(SCHEMA), **kwargs) + t1 = time.perf_counter() + + observations, _ = env.reset() + t2 = time.perf_counter() + + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + step_times = [] + retrieval_total = 0.0 + render_total = 0.0 + export_total = 0.0 + + while not env.terminated: + s0 = time.perf_counter() + observations, _, terminated, truncated, info = env.step([action]) + s1 = time.perf_counter() + step_times.append(s1 - s0) + retrieval_total += float(info.get("building_observations_retrieval_time", 0.0)) + render_total += float(info.get("partial_render_time", 0.0)) + export_total += float(info.get("end_export_time", 0.0)) + + if terminated or truncated: + break + + t3 = time.perf_counter() + env.close() + + avg_step_ms = 1000.0 * float(np.mean(step_times)) if step_times else 0.0 + p95_step_ms = 1000.0 * float(np.percentile(step_times, 95)) if step_times else 0.0 + + return { + "seconds_per_time_step": seconds_per_time_step, + "render_mode": render_mode, + "configured_steps": episode_time_steps, + "executed_steps": len(step_times), + "env_init_s": round(t1 - t0, 4), + "reset_s": round(t2 - t1, 4), + "rollout_s": round(t3 - t2, 4), + "avg_step_ms": round(avg_step_ms, 4), + "p95_step_ms": round(p95_step_ms, 4), + "obs_retrieval_s": round(retrieval_total, 4), + "partial_render_s": round(render_total, 4), + "end_export_s": round(export_total, 4), + "render_dir": str(render_directory), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--seconds", + nargs="+", + type=int, + default=[5, 60], + help="Seconds per environment step (default: 5 60).", + ) + parser.add_argument( + "--render-modes", + nargs="+", + default=["none", "end"], + choices=["none", "during", "end"], + help="Render modes to benchmark (default: none end).", + ) + parser.add_argument( + "--episode-steps", + type=int, + default=1200, + help="Episode time steps for each benchmark case (default: 1200).", + ) + parser.add_argument( + "--seed", + type=int, + default=0, + help="Random seed (default: 0).", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + for seconds in args.seconds: + for render_mode in args.render_modes: + result = run_case(seconds, render_mode, args.episode_steps, args.seed) + print(result) + + +if __name__ == "__main__": + main() diff --git a/scripts/manual/bench_runtime_sweep.py b/scripts/manual/bench_runtime_sweep.py new file mode 100644 index 000000000..aa52709fd --- /dev/null +++ b/scripts/manual/bench_runtime_sweep.py @@ -0,0 +1,30 @@ +import demo_ev_rbc_export_end as obj +from pandas import DataFrame + +timesteps = [438, 876, 1314, 1752, 2190, + 2628, 3066, 3504, 3942, 4380, + 4818, 5256, 5694, 6132, 6570, + 7008, 7446, 7884, 8322, 8760] + + +data = DataFrame(columns= + ["Timesteps", "Environment Creation", "Agent Creation", "Environment Reset", + "Render", "Buildings Observations Retrieval", "Total" + ]) + + +for i in timesteps: + env_creation, agent_creation, env_reset, total_render_time, total_retrieval_time, total_time = obj.main(i) + new_values = { + "Timesteps": i, + "Environment Creation": env_creation, + "Agent Creation": agent_creation, + "Environment Reset" : env_reset, + "Render": total_render_time, + "Buildings Observations Retrieval" : total_retrieval_time, + "Total" : total_time + } + data.loc[len(data)] = new_values + data.to_csv(f"{i}-runtime.csv", index=False) + +data.to_csv("runtime.csv", index=False) diff --git a/scripts/manual/compare_equity_runs.py b/scripts/manual/compare_equity_runs.py new file mode 100644 index 000000000..b4516a98f --- /dev/null +++ b/scripts/manual/compare_equity_runs.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Compare equity KPIs between two CityLearn runs.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Dict + +import numpy as np +import pandas as pd + + +EQUITY_KPIS = [ + "equity_gini_benefit", + "equity_cr20_benefit", + "equity_losers_percent", + "equity_bpr_asset_poor_over_rich", +] + +LOWER_IS_BETTER = { + "equity_gini_benefit", + "equity_cr20_benefit", + "equity_losers_percent", +} + + +def _resolve_kpis_path(path: Path) -> Path: + if path.is_dir(): + return path / "exported_kpis.csv" + + return path + + +def _load_district_equity(path: Path) -> Dict[str, float]: + kpi_path = _resolve_kpis_path(path) + + if not kpi_path.is_file(): + raise FileNotFoundError(f"KPI file not found: {kpi_path}") + + df = pd.read_csv(kpi_path) + district = df[df["name"] == "District"].set_index("cost_function")["value"] + + values = {} + for kpi in EQUITY_KPIS: + values[kpi] = float(district[kpi]) if kpi in district.index and pd.notna(district[kpi]) else np.nan + + return values + + +def _safe_pct(delta: float, base: float) -> float: + if not np.isfinite(base) or base == 0.0: + return np.nan + + return float(100.0 * delta / base) + + +def _is_improved(kpi: str, delta: float) -> bool: + if not np.isfinite(delta): + return False + + if kpi in LOWER_IS_BETTER: + return delta < 0.0 + + return delta > 0.0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-a", required=True, type=Path, help="Run A folder or exported_kpis.csv path.") + parser.add_argument("--run-b", required=True, type=Path, help="Run B folder or exported_kpis.csv path.") + parser.add_argument("--output", type=Path, default=None, help="Optional output CSV filepath.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + run_a = _load_district_equity(args.run_a) + run_b = _load_district_equity(args.run_b) + rows = [] + + for kpi in EQUITY_KPIS: + a_value = run_a[kpi] + b_value = run_b[kpi] + delta = b_value - a_value if np.isfinite(a_value) and np.isfinite(b_value) else np.nan + rows.append( + { + "cost_function": kpi, + "run_a": a_value, + "run_b": b_value, + "delta_absolute_b_minus_a": delta, + "delta_percent_b_vs_a": _safe_pct(delta, a_value) if np.isfinite(delta) else np.nan, + "direction": "lower_is_better" if kpi in LOWER_IS_BETTER else "higher_is_better", + "improved_in_b": _is_improved(kpi, delta), + } + ) + + result = pd.DataFrame(rows) + print(result.to_string(index=False)) + + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + result.to_csv(args.output, index=False) + print(f"\nSaved comparison to: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/run_charging_constraints_export_end.py b/scripts/manual/demo_charging_constraints_export_end.py similarity index 93% rename from tests/scripts/run_charging_constraints_export_end.py rename to scripts/manual/demo_charging_constraints_export_end.py index 7497832ca..80f2622e7 100644 --- a/tests/scripts/run_charging_constraints_export_end.py +++ b/scripts/manual/demo_charging_constraints_export_end.py @@ -16,7 +16,7 @@ from citylearn.agents.rbc import BasicElectricVehicleRBC_ReferenceController as Agent # noqa: E402 from citylearn.citylearn import CityLearnEnv # noqa: E402 -SCHEMA = ROOT / "data/datasets/citylearn_charging_constraints_demo/schema.json" +SCHEMA = ROOT / "data/datasets/citylearn_three_phase_electrical_service_demo/schema.json" def main() -> None: diff --git a/tests/scripts/run_ev_rbc.py b/scripts/manual/demo_ev_rbc.py similarity index 96% rename from tests/scripts/run_ev_rbc.py rename to scripts/manual/demo_ev_rbc.py index b047171ed..39807d14b 100644 --- a/tests/scripts/run_ev_rbc.py +++ b/scripts/manual/demo_ev_rbc.py @@ -3,7 +3,7 @@ Run from the repository root: - python tests/scripts/run_ev_rbc.py + python scripts/manual/demo_ev_rbc.py """ from __future__ import annotations diff --git a/tests/scripts/run_ev_rbc_export_end.py b/scripts/manual/demo_ev_rbc_export_end.py similarity index 53% rename from tests/scripts/run_ev_rbc_export_end.py rename to scripts/manual/demo_ev_rbc_export_end.py index c9ccaf4d8..7989a972f 100644 --- a/tests/scripts/run_ev_rbc_export_end.py +++ b/scripts/manual/demo_ev_rbc_export_end.py @@ -6,7 +6,7 @@ import sys import logging from pathlib import Path - +import time import numpy as np ROOT = Path(__file__).resolve().parents[2] @@ -18,36 +18,62 @@ SCHEMA = ROOT / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" - -def main() -> None: +def main(step=96): logging.getLogger().setLevel(logging.WARNING) + print(f"TIMESTEPS ==>\t{step}\n\n") + render_root = ROOT / "SimulationData" + start = time.time(); + env_creation_start = time.time(); env = CityLearnEnv( str(SCHEMA), central_agent=True, - episode_time_steps=96, + episode_time_steps=step, render_mode="end", render_directory=render_root, render_session_name="rbc_export_end_example5", random_seed=0, + debug_timing=True, ) - + env_creation_end = time.time(); try: + print("running"); + agent_creation_start = time.time(); controller = Agent(env) + agent_creation_end = time.time(); + + env_reset_start = time.time(); observations, _ = env.reset() + env_reset_end = time.time(); + + total_retrieval_time = 0.0; + total_retrievals = 0; + total_render_time = .0; while not env.terminated: actions = controller.predict(observations, deterministic=True) - observations, _, terminated, truncated, _ = env.step(actions) + observations, _, terminated, truncated, info = env.step(actions) + total_retrieval_time += float(info.get('building_observations_retrieval_time', 0.0)) + total_render_time += float(info.get('partial_render_time', 0.0)) + total_retrievals += 1; if terminated or truncated: break + outputs_path = Path(env.new_folder_path) print(f"Exports written to: {outputs_path}") finally: env.close() + end = time.time(); + env_creation = env_creation_end - env_creation_start + agent_creation = agent_creation_end - agent_creation_start + env_reset = env_reset_end - env_reset_start + total_time = end - start + + return env_creation, agent_creation, env_reset, total_render_time, total_retrieval_time, total_time if __name__ == "__main__": + main() diff --git a/tests/scripts/run_ev_rbc_export_full.py b/scripts/manual/demo_ev_rbc_export_full.py similarity index 100% rename from tests/scripts/run_ev_rbc_export_full.py rename to scripts/manual/demo_ev_rbc_export_full.py diff --git a/tests/scripts/run_ev_rbc_export_mid.py b/scripts/manual/demo_ev_rbc_export_mid.py similarity index 100% rename from tests/scripts/run_ev_rbc_export_mid.py rename to scripts/manual/demo_ev_rbc_export_mid.py diff --git a/tests/scripts/run_ev_rbc_export_minutes.py b/scripts/manual/demo_ev_rbc_export_minutes.py similarity index 100% rename from tests/scripts/run_ev_rbc_export_minutes.py rename to scripts/manual/demo_ev_rbc_export_minutes.py diff --git a/tests/scripts/run_sac_training_export.py b/scripts/manual/demo_sac_training_export.py similarity index 100% rename from tests/scripts/run_sac_training_export.py rename to scripts/manual/demo_sac_training_export.py diff --git a/tests/scripts/stable_baselines.py b/scripts/manual/demo_stable_baselines3.py similarity index 80% rename from tests/scripts/stable_baselines.py rename to scripts/manual/demo_stable_baselines3.py index 4478a4e57..a42a70d85 100644 --- a/tests/scripts/stable_baselines.py +++ b/scripts/manual/demo_stable_baselines3.py @@ -1,5 +1,9 @@ import sys -sys.path.insert(0, '..') +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) from stable_baselines3 import SAC from stable_baselines3.common.env_checker import check_env from citylearn.citylearn import CityLearnEnv @@ -31,6 +35,6 @@ while not env.done: actions, _ = model.predict(observations, deterministic=True) - observations, reward, _, _ = env.step(actions) + observations, reward, _, _, _ = env.step(actions) -print(env.evaluate_citylearn_challenge()) \ No newline at end of file +print(env.evaluate_citylearn_challenge()) diff --git a/tests/scripts/run_occupant_override_diagnostics.py b/scripts/manual/diagnostics_occupant_override.py similarity index 100% rename from tests/scripts/run_occupant_override_diagnostics.py rename to scripts/manual/diagnostics_occupant_override.py diff --git a/tests/scripts/reward_exploration.py b/scripts/manual/experiment_reward_exploration.py similarity index 97% rename from tests/scripts/reward_exploration.py rename to scripts/manual/experiment_reward_exploration.py index f88a8bd64..99f7704d4 100644 --- a/tests/scripts/reward_exploration.py +++ b/scripts/manual/experiment_reward_exploration.py @@ -104,7 +104,7 @@ def simulate(simulation_id, schema, solar_penalty_coefficient, comfort_coefficie while not env.done: actions, _ = model.predict(observations, deterministic=True) - observations, _, _, _ = env.step(actions) + observations, _, _, _, _ = env.step(actions) save_data(env, simulation_id, simulation_output_path, start_timestamp, episodes, 'test') @@ -216,7 +216,7 @@ def set_work_order(schema, buildings, coefficient_start, coefficient_end, coeffi os.makedirs(simulation_output_path, exist_ok=True) for i, c in enumerate(coefficient_list): - work_order += f'python reward_exploration.py simulate simulation_{i} {schema} {c[0]} {c[1]} {episodes} {simulation_output_path} -b {" ".join([str(b) for b in buildings])}\n' + work_order += f'python scripts/manual/experiment_reward_exploration.py simulate simulation_{i} {schema} {c[0]} {c[1]} {episodes} {simulation_output_path} -b {" ".join([str(b) for b in buildings])}\n' filepath = 'reward_exploration.sh' if filepath is None else filepath @@ -298,4 +298,4 @@ def main(): args.func(**kwargs) if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/tests/scripts/compatibility_test.py b/scripts/manual/manual_compatibility_matrix.py similarity index 100% rename from tests/scripts/compatibility_test.py rename to scripts/manual/manual_compatibility_matrix.py diff --git a/tests/scripts/test_environment.py b/scripts/manual/manual_environment_smoke.py similarity index 100% rename from tests/scripts/test_environment.py rename to scripts/manual/manual_environment_smoke.py diff --git a/tests/scripts/test_lstm.py b/scripts/manual/manual_lstm_smoke.py similarity index 94% rename from tests/scripts/test_lstm.py rename to scripts/manual/manual_lstm_smoke.py index b23146529..8afd8c348 100644 --- a/tests/scripts/test_lstm.py +++ b/scripts/manual/manual_lstm_smoke.py @@ -20,7 +20,7 @@ while not env.terminated: actions = model.predict(observations) - observations, reward, info, terminated, truncated = env.step(actions) + observations, reward, terminated, truncated, info = env.step(actions) # test kpis = model.env.evaluate() diff --git a/tests/scripts/tacc_job.sh b/scripts/manual/tacc_job.sh similarity index 100% rename from tests/scripts/tacc_job.sh rename to scripts/manual/tacc_job.sh diff --git a/setup.py b/setup.py index bc291fbda..d25256926 100644 --- a/setup.py +++ b/setup.py @@ -31,11 +31,14 @@ def get_version(): packages=setuptools.find_packages(), include_package_data=True, install_requires=requirements, + extras_require={ + 'pysam': ['nrel-pysam'], + }, entry_points={'console_scripts': ['citylearn = citylearn.__main__:main']}, classifiers=[ 'Programming Language :: Python :: 3', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], - python_requires='>=3.7.7', + python_requires='>=3.9', ) diff --git a/test_requirements.txt b/test_requirements.txt index cdaf1c6e8..26987c080 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,3 +1,5 @@ ray[rllib] stable-baselines3 +nrel-pysam pytest +ruff diff --git a/tests/test_charging_constraints_dataset.py b/tests/test_charging_constraints_dataset.py index f49e5344b..a0aac7ae2 100644 --- a/tests/test_charging_constraints_dataset.py +++ b/tests/test_charging_constraints_dataset.py @@ -10,7 +10,7 @@ from citylearn.citylearn import CityLearnEnv -DATASET_PATH = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_charging_constraints_demo/schema.json" +DATASET_PATH = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_three_phase_electrical_service_demo/schema.json" def _find_action_index(env, charger_id: str) -> int: diff --git a/tests/test_charging_constraints_e2e.py b/tests/test_charging_constraints_e2e.py index 90e2b3829..d9c19e614 100644 --- a/tests/test_charging_constraints_e2e.py +++ b/tests/test_charging_constraints_e2e.py @@ -9,7 +9,7 @@ from citylearn.citylearn import CityLearnEnv -SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_charging_constraints_demo/schema.json" +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_three_phase_electrical_service_demo/schema.json" def _zero_actions(env: CityLearnEnv): diff --git a/tests/test_electrical_service_and_market.py b/tests/test_electrical_service_and_market.py new file mode 100644 index 000000000..af57cb361 --- /dev/null +++ b/tests/test_electrical_service_and_market.py @@ -0,0 +1,471 @@ +import json +import shutil +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +MINUTE_DATASET_DIR = Path(__file__).resolve().parents[1] / "tests" / "data" / "minute_ev_demo" + + +def _clone_minute_schema(tmp_path: Path, name: str, mutator=None) -> Path: + dataset_dir = tmp_path / name + shutil.copytree(MINUTE_DATASET_DIR, dataset_dir) + schema_path = dataset_dir / "schema.json" + + with open(schema_path, "r", encoding="utf-8") as f: + schema = json.load(f) + + if mutator is not None: + mutator(schema) + + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +def _rollout_zero_actions(env: CityLearnEnv): + env.reset() + + while not env.terminated: + action = np.zeros(len(env.action_names[0]), dtype="float32") + env.step([action]) + + +def _build_two_building_market_schema(tmp_path: Path) -> Path: + dataset_dir = tmp_path / "market_dataset" + dataset_dir.mkdir(parents=True, exist_ok=True) + + weather = pd.read_csv(MINUTE_DATASET_DIR / "weather.csv").iloc[:2].copy() + weather.to_csv(dataset_dir / "weather.csv", index=False) + + carbon = pd.read_csv(MINUTE_DATASET_DIR / "carbon_intensity.csv").iloc[:2].copy() + carbon.to_csv(dataset_dir / "carbon_intensity.csv", index=False) + + pricing = pd.DataFrame( + { + "electricity_pricing": [0.5, 0.5], + "electricity_pricing_predicted_1": [0.5, 0.5], + "electricity_pricing_predicted_2": [0.5, 0.5], + "electricity_pricing_predicted_3": [0.5, 0.5], + } + ) + pricing.to_csv(dataset_dir / "pricing.csv", index=False) + + building_a = pd.DataFrame( + { + "month": [1, 1], + "hour": [0, 1], + "minutes": [0, 0], + "day_type": [1, 1], + "daylight_savings_status": [0, 0], + "indoor_dry_bulb_temperature": [21.0, 21.0], + "average_unmet_cooling_setpoint_difference": [0.0, 0.0], + "indoor_relative_humidity": [45.0, 45.0], + "non_shiftable_load": [2.0, 2.0], + "dhw_demand": [0.0, 0.0], + "cooling_demand": [0.0, 0.0], + "heating_demand": [0.0, 0.0], + "solar_generation": [0.0, 0.0], + } + ) + building_b = building_a.copy() + building_b["non_shiftable_load"] = [0.0, 0.0] + building_b["solar_generation"] = [2000.0, 2000.0] + + building_a.to_csv(dataset_dir / "Building_A.csv", index=False) + building_b.to_csv(dataset_dir / "Building_B.csv", index=False) + + schema = { + "random_seed": 0, + "root_directory": None, + "central_agent": True, + "simulation_start_time_step": 0, + "simulation_end_time_step": 1, + "episode_time_steps": 2, + "rolling_episode_split": False, + "random_episode_split": False, + "seconds_per_time_step": 3600, + "observations": { + "month": {"active": True, "shared_in_central_agent": True}, + "hour": {"active": True, "shared_in_central_agent": True}, + "minutes": {"active": True, "shared_in_central_agent": True}, + "day_type": {"active": True, "shared_in_central_agent": True}, + "outdoor_dry_bulb_temperature": {"active": True, "shared_in_central_agent": True}, + "non_shiftable_load": {"active": True, "shared_in_central_agent": False}, + "solar_generation": {"active": True, "shared_in_central_agent": False}, + "net_electricity_consumption": {"active": True, "shared_in_central_agent": False}, + "electricity_pricing": {"active": True, "shared_in_central_agent": True}, + }, + "actions": { + "electrical_storage": {"active": False}, + }, + "reward_function": { + "type": "citylearn.reward_function.RewardFunction", + "attributes": {}, + }, + "community_market": { + "enabled": True, + "intra_community_sell_ratio": 0.8, + "grid_export_price": 0.0, + }, + "buildings": { + "Building_A": { + "include": True, + "energy_simulation": "Building_A.csv", + "weather": "weather.csv", + "carbon_intensity": "carbon_intensity.csv", + "pricing": "pricing.csv", + "inactive_observations": [], + "inactive_actions": [], + "pv": { + "type": "citylearn.energy_model.PV", + "autosize": False, + "attributes": {"nominal_power": 0.0}, + }, + }, + "Building_B": { + "include": True, + "energy_simulation": "Building_B.csv", + "weather": "weather.csv", + "carbon_intensity": "carbon_intensity.csv", + "pricing": "pricing.csv", + "inactive_observations": [], + "inactive_actions": [], + "pv": { + "type": "citylearn.energy_model.PV", + "autosize": False, + "attributes": {"nominal_power": 1.0}, + }, + }, + }, + } + + schema_path = dataset_dir / "schema.json" + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +def test_disabled_community_market_keeps_legacy_costs(tmp_path: Path): + baseline_schema = _clone_minute_schema(tmp_path, "baseline") + disabled_schema = _clone_minute_schema( + tmp_path, + "disabled_market", + mutator=lambda s: s.update( + { + "community_market": { + "enabled": False, + "intra_community_sell_ratio": 0.2, + "grid_export_price": 0.9, + } + } + ), + ) + + env_baseline = CityLearnEnv(str(baseline_schema), central_agent=True, episode_time_steps=4, random_seed=0) + env_disabled = CityLearnEnv(str(disabled_schema), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + _rollout_zero_actions(env_baseline) + _rollout_zero_actions(env_disabled) + + np.testing.assert_allclose( + np.asarray(env_baseline.net_electricity_consumption_cost, dtype="float64"), + np.asarray(env_disabled.net_electricity_consumption_cost, dtype="float64"), + rtol=1e-9, + atol=1e-9, + ) + finally: + env_baseline.close() + env_disabled.close() + + +def test_disabled_community_market_with_string_false_keeps_legacy_costs(tmp_path: Path): + baseline_schema = _clone_minute_schema(tmp_path, "baseline_string_false") + disabled_schema = _clone_minute_schema( + tmp_path, + "disabled_market_string_false", + mutator=lambda s: s.update( + { + "community_market": { + "enabled": "false", + "intra_community_sell_ratio": 0.2, + "grid_export_price": 0.9, + } + } + ), + ) + + env_baseline = CityLearnEnv(str(baseline_schema), central_agent=True, episode_time_steps=4, random_seed=0) + env_disabled = CityLearnEnv(str(disabled_schema), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + _rollout_zero_actions(env_baseline) + _rollout_zero_actions(env_disabled) + + np.testing.assert_allclose( + np.asarray(env_baseline.net_electricity_consumption_cost, dtype="float64"), + np.asarray(env_disabled.net_electricity_consumption_cost, dtype="float64"), + rtol=1e-9, + atol=1e-9, + ) + finally: + env_baseline.close() + env_disabled.close() + + +def test_single_phase_rejects_non_l1_assets(tmp_path: Path): + schema_path = _clone_minute_schema( + tmp_path, + "single_phase_invalid", + mutator=lambda s: ( + s["buildings"]["Building_1"].update( + { + "electrical_service": { + "mode": "single_phase", + "limits": {"total": {"import_kw": 8.0, "export_kw": 8.0}}, + } + } + ), + s["buildings"]["Building_1"]["chargers"]["charger_1_1"]["attributes"].update({"phase_connection": "L2"}), + ), + ) + + with pytest.raises(ValueError, match="single_phase"): + CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=0) + + +def test_three_phase_rejects_invalid_per_phase_keys(tmp_path: Path): + schema_path = _clone_minute_schema( + tmp_path, + "three_phase_invalid_key", + mutator=lambda s: s["buildings"]["Building_1"].update( + { + "electrical_service": { + "mode": "three_phase", + "limits": { + "total": {"import_kw": 8.0, "export_kw": 8.0}, + "per_phase": {"L1": {"import_kw": 3.0}, "L4": {"import_kw": 3.0}}, + }, + } + } + ), + ) + + with pytest.raises(ValueError, match="L1/L2/L3"): + CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=0) + + +def test_electrical_service_rejects_nan_limits(tmp_path: Path): + def _mutate(schema): + building = schema["buildings"]["Building_1"] + building["electrical_service"] = { + "mode": "three_phase", + "limits": { + "total": {"import_kw": "NaN", "export_kw": 10.0}, + "per_phase": {}, + }, + } + + schema_path = _clone_minute_schema(tmp_path, "electrical_service_nan_limit", mutator=_mutate) + + with pytest.raises(ValueError, match="cannot be NaN"): + CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=0) + + +def test_electrical_service_positive_infinite_limits_are_treated_as_unbounded(tmp_path: Path): + def _mutate(schema): + building = schema["buildings"]["Building_1"] + building["electrical_service"] = { + "mode": "three_phase", + "limits": { + "total": {"import_kw": "inf", "export_kw": "inf"}, + "per_phase": { + "L1": {"import_kw": "inf", "export_kw": "inf"}, + "L2": {"import_kw": "inf", "export_kw": "inf"}, + "L3": {"import_kw": "inf", "export_kw": "inf"}, + }, + }, + "observations": {"headroom": True, "violation": True}, + } + building["electrical_storage"]["attributes"]["phase_connection"] = "all_phases" + + schema_path = _clone_minute_schema(tmp_path, "electrical_service_infinite_limit", mutator=_mutate) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + action_names = env.action_names[0] + actions = np.zeros(len(action_names), dtype="float32") + actions[action_names.index("electrical_storage")] = 1.0 + ev_action_name = next(name for name in action_names if name.startswith("electric_vehicle_storage_")) + actions[action_names.index(ev_action_name)] = 1.0 + env.step([actions]) + + building = env.buildings[0] + state = building._charging_constraints_state + assert state is not None + assert state["building_headroom_kw"] is None + assert state["building_export_headroom_kw"] is None + assert state["phase_headroom_kw"]["L1"] is None + assert state["phase_headroom_kw"]["L2"] is None + assert state["phase_headroom_kw"]["L3"] is None + assert state["phase_export_headroom_kw"]["L1"] is None + assert state["phase_export_headroom_kw"]["L2"] is None + assert state["phase_export_headroom_kw"]["L3"] is None + assert np.isfinite(state["total_power_kw"]) + assert np.isfinite(building._charging_constraint_last_penalty_kwh) + assert building._charging_constraint_last_penalty_kwh == pytest.approx(0.0, abs=1e-6) + finally: + env.close() + + +def test_three_phase_limits_clip_controllable_actions(tmp_path: Path): + def _mutate(schema): + building = schema["buildings"]["Building_1"] + building["electrical_service"] = { + "mode": "three_phase", + "default_split": "balanced", + "limits": { + "total": {"import_kw": 2.5, "export_kw": 2.5}, + "per_phase": { + "L1": {"import_kw": 1.0, "export_kw": 1.0}, + "L2": {"import_kw": 1.0, "export_kw": 1.0}, + "L3": {"import_kw": 1.0, "export_kw": 1.0}, + }, + }, + "observations": {"headroom": True, "violation": True}, + } + building["chargers"]["charger_1_1"]["attributes"]["phase_connection"] = "L1" + building["electrical_storage"]["attributes"]["phase_connection"] = "all_phases" + + schema_path = _clone_minute_schema(tmp_path, "three_phase_clip", mutator=_mutate) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=1) + + try: + env.reset() + action_names = env.action_names[0] + actions = np.zeros(len(action_names), dtype="float32") + actions[action_names.index("electrical_storage")] = 1.0 + ev_action_name = next(name for name in action_names if name.startswith("electric_vehicle_storage_")) + actions[action_names.index(ev_action_name)] = 1.0 + env.step([actions]) + + building = env.buildings[0] + state = building._charging_constraints_state + assert state is not None + assert state["total_power_kw"] <= 2.5 + 1e-6 + assert state["phase_power_kw"]["L1"] <= 1.0 + 1e-6 + assert state["phase_power_kw"]["L2"] <= 1.0 + 1e-6 + assert state["phase_power_kw"]["L3"] <= 1.0 + 1e-6 + assert building._charging_constraint_last_penalty_kwh == pytest.approx(0.0, abs=1e-6) + + t = building.time_step - 1 + charger = building.electric_vehicle_chargers[0] + commanded_kwh = charger.past_charging_action_values_kwh[t] + assert commanded_kwh < (charger.max_charging_power * (building.seconds_per_time_step / 3600.0)) + finally: + env.close() + + +def test_residual_violation_when_non_controllable_exceeds_limit(tmp_path: Path): + def _mutate(schema): + building = schema["buildings"]["Building_1"] + building["electrical_service"] = { + "mode": "three_phase", + "default_split": "balanced", + "limits": { + "total": {"import_kw": 0.1, "export_kw": 2.0}, + "per_phase": {}, + }, + "observations": {"headroom": True, "violation": True}, + } + building["electrical_storage"]["attributes"]["phase_connection"] = "all_phases" + + schema_path = _clone_minute_schema(tmp_path, "residual_violation", mutator=_mutate) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=4, random_seed=2) + + try: + env.reset() + actions = np.zeros(len(env.action_names[0]), dtype="float32") + env.step([actions]) + + building = env.buildings[0] + assert building._charging_constraint_last_penalty_kwh > 0.0 + state = building._charging_constraints_state + assert state["total_power_kw"] > 0.1 + obs = building.observations(include_all=True, normalize=False, periodic_normalization=False) + assert obs["charging_constraint_violation_kwh"] > 0.0 + finally: + env.close() + + +def test_community_market_settlement_matches_expected_values(tmp_path: Path): + schema_path = _build_two_building_market_schema(tmp_path) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=2, random_seed=0) + + try: + env.reset() + env.step([np.zeros(len(env.action_names[0]), dtype="float32")]) + + building_a = next(building for building in env.buildings if building.name == "Building_A") + building_b = next(building for building in env.buildings if building.name == "Building_B") + t = env.time_step - 1 + + net_a = float(building_a.net_electricity_consumption[t]) + net_b = float(building_b.net_electricity_consumption[t]) + imports = np.array([max(net_a, 0.0), max(net_b, 0.0)], dtype="float64") + exports = np.array([max(-net_a, 0.0), max(-net_b, 0.0)], dtype="float64") + + traded = min(float(imports.sum()), float(exports.sum())) + local_import = imports * (traded / max(float(imports.sum()), 1e-12)) + local_export = exports * (traded / max(float(exports.sum()), 1e-12)) + grid_import_remaining = imports - local_import + grid_export_remaining = exports - local_export + + p_grid = 0.5 + p_local = 0.8 * p_grid + p_export = 0.0 + expected_cost_a = ( + grid_import_remaining[0] * p_grid + + local_import[0] * p_local + - local_export[0] * p_local + - grid_export_remaining[0] * p_export + ) + expected_cost_b = ( + grid_import_remaining[1] * p_grid + + local_import[1] * p_local + - local_export[1] * p_local + - grid_export_remaining[1] * p_export + ) + + assert building_a.net_electricity_consumption_cost[t] == pytest.approx(expected_cost_a, abs=1e-6) + assert building_b.net_electricity_consumption_cost[t] == pytest.approx(expected_cost_b, abs=1e-6) + assert env.net_electricity_consumption_cost[t] == pytest.approx(expected_cost_a + expected_cost_b, abs=1e-6) + finally: + env.close() + + +def test_community_market_equal_share_allocator(): + from citylearn.internal.runtime import CityLearnRuntimeService + + allocations = CityLearnRuntimeService._allocate_equal_share_import( + np.array([7.0, 7.0, 7.0, 7.0], dtype='float64'), + 20.0, + ) + np.testing.assert_allclose(allocations, np.array([5.0, 5.0, 5.0, 5.0], dtype='float64'), atol=1e-9, rtol=1e-9) + + capped_allocations = CityLearnRuntimeService._allocate_equal_share_import( + np.array([2.0, 8.0, 8.0, 8.0], dtype='float64'), + 20.0, + ) + np.testing.assert_allclose(capped_allocations, np.array([2.0, 6.0, 6.0, 6.0], dtype='float64'), atol=1e-9, rtol=1e-9) diff --git a/tests/test_ev_arrivals.py b/tests/test_ev_arrivals.py index 87d422e18..f4a63c6d4 100644 --- a/tests/test_ev_arrivals.py +++ b/tests/test_ev_arrivals.py @@ -99,3 +99,34 @@ def test_ev_kpi_evaluation_with_evs_and_chargers(): district_values = df[df["level"] == "district"]["value"] assert district_values.notna().any(), "District-level KPI values should contain finite entries when EVs are present." + + +def test_ev_current_soc_overrides_arrival_estimate_when_present(): + csv_path, transition_index = _find_transition(2) + charger_id = csv_path.split("/")[-1].replace(".csv", "") + + env = CityLearnEnv(SCHEMA_PATH, central_agent=True, random_seed=0) + env.reset() + + target_charger = None + for building in env.buildings: + for charger in building.electric_vehicle_chargers or []: + if charger.charger_id == charger_id: + target_charger = charger + break + if target_charger is not None: + break + + assert target_charger is not None, "Expected charger for transition was not found." + sim = target_charger.charger_simulation + forced_soc = 0.42 + current_soc = np.full(len(sim.electric_vehicle_charger_state), -0.1, dtype="float32") + current_soc[transition_index + 1] = forced_soc + sim.electric_vehicle_current_soc = current_soc + + for _ in range(transition_index + 1): + env.step(_zero_actions(env)) + + connected_ev = target_charger.connected_electric_vehicle + assert connected_ev is not None, "Expected EV to be connected at the transition step." + assert float(connected_ev.battery.soc[env.time_step]) == pytest.approx(forced_soc, abs=1e-6) diff --git a/tests/test_ev_soc_behavior.py b/tests/test_ev_soc_behavior.py index de6c2a179..cbecec32d 100644 --- a/tests/test_ev_soc_behavior.py +++ b/tests/test_ev_soc_behavior.py @@ -53,7 +53,7 @@ def _get_charger(building, charger_id: str): def test_soc_increases_when_connected_ev_is_charged(env: CityLearnEnv): - for _ in range(env.episode_time_steps - 2): + while env.time_step < env.episode_time_steps - 1: t = env.time_step for building in env.buildings: @@ -91,13 +91,15 @@ def test_soc_increases_when_connected_ev_is_charged(env: CityLearnEnv): assert new_soc > initial_soc + 1e-3 return + if env.time_step >= env.episode_time_steps - 1: + break env.step(_zero_actions(env)) pytest.fail("No EV remained connected long enough for SOC charging test.") def test_soc_matches_dataset_on_arrival(env: CityLearnEnv): - for _ in range(env.episode_time_steps - 2): + while env.time_step < env.episode_time_steps - 1: t = env.time_step for building in env.buildings: @@ -142,6 +144,8 @@ def test_soc_matches_dataset_on_arrival(env: CityLearnEnv): assert pytest.approx(expected_soc, rel=0, abs=1e-5) == new_soc return + if env.time_step >= env.episode_time_steps - 1: + break env.step(_zero_actions(env)) pytest.fail("No EV arrival transition observed during simulation.") diff --git a/tests/test_internal_refactor_parity.py b/tests/test_internal_refactor_parity.py new file mode 100644 index 000000000..173149607 --- /dev/null +++ b/tests/test_internal_refactor_parity.py @@ -0,0 +1,86 @@ +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def _run_fixed_episode(episode_time_steps: int = 12) -> pd.DataFrame: + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=episode_time_steps, + random_seed=0, + ) + + try: + env.reset() + action_names = env.action_names[0] + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + + ev_indices = [i for i, name in enumerate(action_names) if name.startswith("electric_vehicle_storage_")] + battery_indices = [i for i, name in enumerate(action_names) if "electrical_storage" in name] + + while not env.terminated: + rollout_action = action.copy() + if ev_indices: + rollout_action[ev_indices] = 0.6 + if battery_indices: + rollout_action[battery_indices] = 0.4 + + _, _, terminated, truncated, _ = env.step([rollout_action]) + if terminated or truncated: + break + + df = env.evaluate().sort_values(["level", "name", "cost_function"]).reset_index(drop=True) + return df + finally: + env.close() + + +def test_loading_contract_shapes_and_counts_match_schema(): + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + expected_buildings = [name for name, cfg in schema["buildings"].items() if cfg.get("include", False)] + expected_evs = [name for name, cfg in schema.get("electric_vehicles_def", {}).items() if cfg.get("include", False)] + + env = CityLearnEnv( + str(SCHEMA), + central_agent=False, + episode_time_steps=6, + random_seed=0, + ) + + try: + env.reset() + + actual_buildings = [b.name for b in env.buildings] + actual_evs = [ev.name for ev in env.electric_vehicles] + + assert actual_buildings == expected_buildings + assert actual_evs == expected_evs + + assert len(env.observation_space) == len(env.buildings) + assert len(env.action_space) == len(env.buildings) + assert len(env.observation_names) == len(env.buildings) + assert len(env.action_names) == len(env.buildings) + + for i, _ in enumerate(env.buildings): + assert env.observation_space[i].shape[0] == len(env.observation_names[i]) + assert env.action_space[i].shape[0] == len(env.action_names[i]) + finally: + env.close() + + +def test_kpi_results_are_stable_for_fixed_seed_and_actions(): + first = _run_fixed_episode(episode_time_steps=12) + second = _run_fixed_episode(episode_time_steps=12) + + pd.testing.assert_frame_equal(first, second, check_exact=False, atol=1e-10, rtol=1e-10) diff --git a/tests/test_kpi_v2.py b/tests/test_kpi_v2.py new file mode 100644 index 000000000..e5700d044 --- /dev/null +++ b/tests/test_kpi_v2.py @@ -0,0 +1,536 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv, EvaluationCondition +from citylearn.cost_function import CostFunction + + +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" +THREE_PHASE_SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_three_phase_electrical_service_demo/schema.json" +MINUTE_DATASET_DIR = Path(__file__).resolve().parents[1] / "tests" / "data" / "minute_ev_demo" + + +def _run_episode(schema: Path, seconds_per_time_step: int, episode_steps: int = 24) -> CityLearnEnv: + env = CityLearnEnv( + str(schema), + central_agent=True, + episode_time_steps=episode_steps, + seconds_per_time_step=seconds_per_time_step, + random_seed=0, + ) + env.reset() + + action_names = env.action_names[0] + base_action = np.zeros(env.action_space[0].shape[0], dtype="float32") + ev_indices = [i for i, name in enumerate(action_names) if name.startswith("electric_vehicle_storage_")] + bess_indices = [i for i, name in enumerate(action_names) if name == "electrical_storage"] + + while not env.terminated: + action = base_action.copy() + if ev_indices: + action[ev_indices] = 0.7 + if bess_indices: + action[bess_indices] = 0.5 + env.step([action]) + + return env + + +def _build_two_building_market_schema(tmp_path: Path) -> Path: + dataset_dir = tmp_path / "market_dataset" + dataset_dir.mkdir(parents=True, exist_ok=True) + + weather = pd.read_csv(MINUTE_DATASET_DIR / "weather.csv").iloc[:2].copy() + weather.to_csv(dataset_dir / "weather.csv", index=False) + + carbon = pd.read_csv(MINUTE_DATASET_DIR / "carbon_intensity.csv").iloc[:2].copy() + carbon.to_csv(dataset_dir / "carbon_intensity.csv", index=False) + + pricing = pd.DataFrame( + { + "electricity_pricing": [0.5, 0.5], + "electricity_pricing_predicted_1": [0.5, 0.5], + "electricity_pricing_predicted_2": [0.5, 0.5], + "electricity_pricing_predicted_3": [0.5, 0.5], + } + ) + pricing.to_csv(dataset_dir / "pricing.csv", index=False) + + building_a = pd.DataFrame( + { + "month": [1, 1], + "hour": [0, 1], + "minutes": [0, 0], + "day_type": [1, 1], + "daylight_savings_status": [0, 0], + "indoor_dry_bulb_temperature": [21.0, 21.0], + "average_unmet_cooling_setpoint_difference": [0.0, 0.0], + "indoor_relative_humidity": [45.0, 45.0], + "non_shiftable_load": [2.0, 2.0], + "dhw_demand": [0.0, 0.0], + "cooling_demand": [0.0, 0.0], + "heating_demand": [0.0, 0.0], + "solar_generation": [0.0, 0.0], + } + ) + building_b = building_a.copy() + building_b["non_shiftable_load"] = [0.0, 0.0] + building_b["solar_generation"] = [2000.0, 2000.0] + + building_a.to_csv(dataset_dir / "Building_A.csv", index=False) + building_b.to_csv(dataset_dir / "Building_B.csv", index=False) + + schema = { + "random_seed": 0, + "root_directory": None, + "central_agent": True, + "simulation_start_time_step": 0, + "simulation_end_time_step": 1, + "episode_time_steps": 2, + "rolling_episode_split": False, + "random_episode_split": False, + "seconds_per_time_step": 3600, + "observations": { + "month": {"active": True, "shared_in_central_agent": True}, + "hour": {"active": True, "shared_in_central_agent": True}, + "minutes": {"active": True, "shared_in_central_agent": True}, + "day_type": {"active": True, "shared_in_central_agent": True}, + "outdoor_dry_bulb_temperature": {"active": True, "shared_in_central_agent": True}, + "non_shiftable_load": {"active": True, "shared_in_central_agent": False}, + "solar_generation": {"active": True, "shared_in_central_agent": False}, + "net_electricity_consumption": {"active": True, "shared_in_central_agent": False}, + "electricity_pricing": {"active": True, "shared_in_central_agent": True}, + }, + "actions": { + "electrical_storage": {"active": False}, + }, + "reward_function": { + "type": "citylearn.reward_function.RewardFunction", + "attributes": {}, + }, + "community_market": { + "enabled": True, + "intra_community_sell_ratio": 0.8, + "grid_export_price": 0.0, + }, + "buildings": { + "Building_A": { + "include": True, + "energy_simulation": "Building_A.csv", + "weather": "weather.csv", + "carbon_intensity": "carbon_intensity.csv", + "pricing": "pricing.csv", + "inactive_observations": [], + "inactive_actions": [], + "pv": { + "type": "citylearn.energy_model.PV", + "autosize": False, + "attributes": {"nominal_power": 0.0}, + }, + }, + "Building_B": { + "include": True, + "energy_simulation": "Building_B.csv", + "weather": "weather.csv", + "carbon_intensity": "carbon_intensity.csv", + "pricing": "pricing.csv", + "inactive_observations": [], + "inactive_actions": [], + "pv": { + "type": "citylearn.energy_model.PV", + "autosize": False, + "attributes": {"nominal_power": 1.0}, + }, + }, + }, + } + + schema_path = dataset_dir / "schema.json" + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +def _build_schema_with_manual_equity_groups( + tmp_path: Path, + source_schema: Path, + *, + missing_first_group: bool, +) -> Path: + with open(source_schema, "r", encoding="utf-8") as f: + schema = json.load(f) + schema["root_directory"] = str(source_schema.parent) + + building_names = [name for name, config in schema.get("buildings", {}).items() if config.get("include", False)] + + for i, name in enumerate(building_names): + if missing_first_group and i == 0: + schema["buildings"][name].pop("equity_group", None) + continue + + schema["buildings"][name]["equity_group"] = "asset_rich" if i % 2 == 0 else "asset_poor" + + schema_path = tmp_path / "schema_with_equity_groups.json" + + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +def _build_zero_sum_pricing_schema(tmp_path: Path) -> Path: + dataset_dir = tmp_path / "zero_sum_pricing_dataset" + dataset_dir.mkdir(parents=True, exist_ok=True) + + weather = pd.read_csv(MINUTE_DATASET_DIR / "weather.csv").iloc[:3].copy() + weather.to_csv(dataset_dir / "weather.csv", index=False) + + carbon = pd.read_csv(MINUTE_DATASET_DIR / "carbon_intensity.csv").iloc[:3].copy() + carbon.to_csv(dataset_dir / "carbon_intensity.csv", index=False) + + pricing = pd.DataFrame( + { + "electricity_pricing": [1.0, -1.0, 0.0], + "electricity_pricing_predicted_1": [1.0, -1.0, 0.0], + "electricity_pricing_predicted_2": [1.0, -1.0, 0.0], + "electricity_pricing_predicted_3": [1.0, -1.0, 0.0], + } + ) + pricing.to_csv(dataset_dir / "pricing.csv", index=False) + + building = pd.DataFrame( + { + "month": [1, 1, 1], + "hour": [0, 1, 2], + "minutes": [0, 0, 0], + "day_type": [1, 1, 1], + "daylight_savings_status": [0, 0, 0], + "indoor_dry_bulb_temperature": [21.0, 21.0, 21.0], + "average_unmet_cooling_setpoint_difference": [0.0, 0.0, 0.0], + "indoor_relative_humidity": [45.0, 45.0, 45.0], + "non_shiftable_load": [1.0, 2.0, 3.0], + "dhw_demand": [0.0, 0.0, 0.0], + "cooling_demand": [0.0, 0.0, 0.0], + "heating_demand": [0.0, 0.0, 0.0], + "solar_generation": [0.0, 0.0, 0.0], + } + ) + building.to_csv(dataset_dir / "Building_1.csv", index=False) + + schema = { + "random_seed": 0, + "root_directory": None, + "central_agent": True, + "simulation_start_time_step": 0, + "simulation_end_time_step": 2, + "episode_time_steps": 3, + "rolling_episode_split": False, + "random_episode_split": False, + "seconds_per_time_step": 3600, + "observations": { + "month": {"active": True, "shared_in_central_agent": True}, + "hour": {"active": True, "shared_in_central_agent": True}, + "minutes": {"active": True, "shared_in_central_agent": True}, + "day_type": {"active": True, "shared_in_central_agent": True}, + "outdoor_dry_bulb_temperature": {"active": True, "shared_in_central_agent": True}, + "non_shiftable_load": {"active": True, "shared_in_central_agent": False}, + "net_electricity_consumption": {"active": True, "shared_in_central_agent": False}, + "electricity_pricing": {"active": True, "shared_in_central_agent": True}, + }, + "actions": {"electrical_storage": {"active": False}}, + "reward_function": {"type": "citylearn.reward_function.RewardFunction", "attributes": {}}, + "buildings": { + "Building_1": { + "include": True, + "energy_simulation": "Building_1.csv", + "weather": "weather.csv", + "carbon_intensity": "carbon_intensity.csv", + "pricing": "pricing.csv", + "inactive_observations": [], + "inactive_actions": [], + "pv": { + "type": "citylearn.energy_model.PV", + "autosize": False, + "attributes": {"nominal_power": 0.0}, + }, + } + }, + } + + schema_path = dataset_dir / "schema.json" + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +@pytest.mark.parametrize("seconds_per_time_step", [5, 10, 60, 300, 900]) +def test_daily_and_monthly_kpis_use_time_aware_windows(seconds_per_time_step: int): + env = _run_episode(SCHEMA, seconds_per_time_step=seconds_per_time_step, episode_steps=24) + + try: + control = EvaluationCondition.WITH_STORAGE_AND_PV + baseline = EvaluationCondition.WITHOUT_STORAGE_BUT_WITH_PV + df = env.evaluate(control_condition=control, baseline_condition=baseline) + + daily_steps = max(1, int(round((24 * 3600) / seconds_per_time_step))) + monthly_steps = max(1, int(round((730 * 3600) / seconds_per_time_step))) + + dlf_daily_c = CostFunction.one_minus_load_factor(env.net_electricity_consumption, window=daily_steps)[-1] + dlf_daily_b = CostFunction.one_minus_load_factor( + getattr(env, f"net_electricity_consumption{baseline.value}"), + window=daily_steps, + )[-1] + dlf_monthly_c = CostFunction.one_minus_load_factor(env.net_electricity_consumption, window=monthly_steps)[-1] + dlf_monthly_b = CostFunction.one_minus_load_factor( + getattr(env, f"net_electricity_consumption{baseline.value}"), + window=monthly_steps, + )[-1] + peak_daily_c = CostFunction.peak(env.net_electricity_consumption, window=daily_steps)[-1] + peak_daily_b = CostFunction.peak( + getattr(env, f"net_electricity_consumption{baseline.value}"), + window=daily_steps, + )[-1] + + def safe_div(c, b): + if b == 0.0: + return 1.0 if c == 0.0 else np.nan + return c / b + + expected_daily = safe_div(float(dlf_daily_c), float(dlf_daily_b)) + expected_monthly = safe_div(float(dlf_monthly_c), float(dlf_monthly_b)) + expected_peak_daily = safe_div(float(peak_daily_c), float(peak_daily_b)) + + district = df[df["name"] == "District"].set_index("cost_function")["value"] + assert float(district["daily_one_minus_load_factor_average"]) == pytest.approx(expected_daily) + assert float(district["monthly_one_minus_load_factor_average"]) == pytest.approx(expected_monthly) + assert float(district["daily_peak_average"]) == pytest.approx(expected_peak_daily) + finally: + env.close() + + +def test_cost_baseline_total_eur_not_forced_to_zero_when_price_sum_is_zero(tmp_path: Path): + schema_path = _build_zero_sum_pricing_schema(tmp_path) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=3, random_seed=0) + + try: + env.reset() + zeros = np.zeros(len(env.action_names[0]), dtype="float32") + while not env.terminated: + env.step([zeros]) + + baseline_condition = EvaluationCondition.WITHOUT_STORAGE_BUT_WITH_PV + baseline_series = getattr( + env.buildings[0], + f"net_electricity_consumption_cost{baseline_condition.value}", + ) + baseline_array = np.asarray(baseline_series, dtype="float64") + expected_baseline_total = float(baseline_array[np.isfinite(baseline_array)].sum()) + + df = env.evaluate( + control_condition=EvaluationCondition.WITH_STORAGE_AND_PV, + baseline_condition=baseline_condition, + ) + building_value = float( + df[(df["name"] == "Building_1") & (df["cost_function"] == "cost_baseline_total_eur")]["value"].iloc[0] + ) + district_value = float( + df[(df["name"] == "District") & (df["cost_function"] == "cost_baseline_total_eur")]["value"].iloc[0] + ) + + assert expected_baseline_total != 0.0 + assert building_value == pytest.approx(expected_baseline_total, abs=1e-9) + assert district_value == pytest.approx(expected_baseline_total, abs=1e-9) + finally: + env.close() + + +def test_kpi_v2_adds_domain_and_market_metrics(tmp_path: Path): + schema_path = _build_two_building_market_schema(tmp_path) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=2, random_seed=0) + + try: + env.reset() + env.step([np.zeros(len(env.action_names[0]), dtype="float32")]) + df = env.evaluate() + + expected = { + "electricity_consumption_control_total_kwh", + "cost_control_total_eur", + "cost_control_daily_average_eur", + "ev_departure_success_rate", + "bess_throughput_total_kwh", + "pv_generation_total_kwh", + "pv_export_daily_average_kwh", + "community_local_import_total_kwh", + "community_grid_export_after_local_daily_average_kwh", + "community_settled_cost_total_eur", + "community_market_savings_daily_average_eur", + "community_market_savings_total_eur", + } + assert expected.issubset(set(df["cost_function"].unique())) + + district = df[df["name"] == "District"].set_index("cost_function")["value"] + settled = float(district["community_settled_cost_total_eur"]) + counterfactual = float(district["community_counterfactual_cost_total_eur"]) + savings = float(district["community_market_savings_total_eur"]) + + assert savings == pytest.approx(counterfactual - settled, abs=1e-9) + finally: + env.close() + + +def test_daily_average_kpis_match_total_over_simulated_days(tmp_path: Path): + schema_path = _build_two_building_market_schema(tmp_path) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=2, random_seed=0) + + try: + env.reset() + env.step([np.zeros(len(env.action_names[0]), dtype="float32")]) + df = env.evaluate() + district = df[df["name"] == "District"].set_index("cost_function")["value"] + + simulated_days = max(int(env.time_step), 1) * float(env.seconds_per_time_step) / (24.0 * 3600.0) + + pairs = [ + ("cost_delta_total_eur", "cost_delta_daily_average_eur"), + ("electricity_consumption_delta_total_kwh", "electricity_consumption_delta_daily_average_kwh"), + ("pv_export_total_kwh", "pv_export_daily_average_kwh"), + ("community_grid_export_after_local_total_kwh", "community_grid_export_after_local_daily_average_kwh"), + ("community_market_savings_total_eur", "community_market_savings_daily_average_eur"), + ] + + for total_key, daily_key in pairs: + total_value = float(district[total_key]) + daily_value = float(district[daily_key]) + assert daily_value == pytest.approx(total_value / simulated_days, abs=1e-9) + finally: + env.close() + + +def test_phase_kpis_are_present_only_when_electrical_service_is_enabled(): + env_phase = _run_episode(THREE_PHASE_SCHEMA, seconds_per_time_step=60, episode_steps=8) + env_legacy = _run_episode(SCHEMA, seconds_per_time_step=60, episode_steps=8) + + try: + phase_df = env_phase.evaluate() + legacy_df = env_legacy.evaluate() + + phase_keys = set(phase_df["cost_function"].unique()) + legacy_keys = set(legacy_df["cost_function"].unique()) + + assert "phase_import_peak_kw_L1" in phase_keys + assert "phase_import_peak_kw_L2" in phase_keys + assert "phase_import_peak_kw_L3" in phase_keys + assert "electrical_service_violation_total_kwh" in phase_keys + + assert "phase_import_peak_kw_L2" not in legacy_keys + assert "phase_import_peak_kw_L3" not in legacy_keys + finally: + env_phase.close() + env_legacy.close() + + +def test_equity_kpis_are_exported_and_bpr_is_none_when_groups_are_incomplete(tmp_path: Path): + schema_path = _build_schema_with_manual_equity_groups(tmp_path, SCHEMA, missing_first_group=True) + env = _run_episode(schema_path, seconds_per_time_step=60, episode_steps=12) + + try: + df = env.evaluate() + expected = { + "equity_relative_benefit_percent", + "equity_gini_benefit", + "equity_cr20_benefit", + "equity_losers_percent", + "equity_bpr_asset_poor_over_rich", + } + assert expected.issubset(set(df["cost_function"].unique())) + + building_rows = df[ + (df["level"] == "building") + & (df["cost_function"] == "equity_relative_benefit_percent") + ] + assert len(building_rows) == len(env.buildings) + + district = df[df["name"] == "District"].set_index("cost_function")["value"] + assert pd.isna(district["equity_bpr_asset_poor_over_rich"]) + finally: + env.close() + + +def test_equity_group_is_loaded_from_schema(tmp_path: Path): + schema_path = _build_schema_with_manual_equity_groups(tmp_path, SCHEMA, missing_first_group=False) + env = CityLearnEnv( + str(schema_path), + central_agent=True, + episode_time_steps=2, + random_seed=0, + ) + + try: + env.reset() + groups = [getattr(building, "equity_group", None) for building in env.buildings] + assert all(group in {"asset_rich", "asset_poor"} for group in groups) + finally: + env.close() + + +def test_extended_cost_and_equity_use_raw_cost_series(tmp_path: Path): + schema_path = _build_two_building_market_schema(tmp_path) + env = CityLearnEnv(str(schema_path), central_agent=True, episode_time_steps=2, random_seed=0) + + try: + env.reset() + env.step([np.zeros(len(env.action_names[0]), dtype="float32")]) + + control = SimpleNamespace(value="_test_control") + baseline = SimpleNamespace(value="_test_baseline") + + control_cost = np.array([-2.0, 1.0], dtype="float64") + baseline_cost = np.array([1.0, 1.0], dtype="float64") + control_net = np.array([0.5, 0.5], dtype="float64") + baseline_net = np.array([1.0, 1.0], dtype="float64") + zeros = np.array([0.0, 0.0], dtype="float64") + + for building in env.buildings: + setattr(building, "net_electricity_consumption_test_control", control_net.copy()) + setattr(building, "net_electricity_consumption_test_baseline", baseline_net.copy()) + setattr(building, "net_electricity_consumption_emission_test_control", zeros.copy()) + setattr(building, "net_electricity_consumption_emission_test_baseline", zeros.copy()) + setattr(building, "net_electricity_consumption_cost_test_control", control_cost.copy()) + setattr(building, "net_electricity_consumption_cost_test_baseline", baseline_cost.copy()) + + env_count = len(env.buildings) + setattr(env, "net_electricity_consumption_test_control", control_net * env_count) + setattr(env, "net_electricity_consumption_test_baseline", baseline_net * env_count) + setattr(env, "net_electricity_consumption_emission_test_control", zeros.copy()) + setattr(env, "net_electricity_consumption_emission_test_baseline", zeros.copy()) + setattr(env, "net_electricity_consumption_cost_test_control", control_cost * env_count) + setattr(env, "net_electricity_consumption_cost_test_baseline", baseline_cost * env_count) + + df = env.evaluate(control_condition=control, baseline_condition=baseline) + building_name = env.buildings[0].name + building_df = df[df["name"] == building_name].set_index("cost_function")["value"] + district_df = df[df["name"] == "District"].set_index("cost_function")["value"] + + assert float(building_df["cost_control_total_eur"]) == pytest.approx(-1.0) + assert float(building_df["cost_baseline_total_eur"]) == pytest.approx(2.0) + assert float(building_df["cost_delta_total_eur"]) == pytest.approx(-3.0) + assert float(building_df["equity_relative_benefit_percent"]) == pytest.approx(150.0) + + # Legacy normalized cost still uses clipped CostFunction.cost semantics. + assert float(building_df["cost_total"]) == pytest.approx(0.5) + + assert float(district_df["cost_control_total_eur"]) == pytest.approx(-2.0) + assert float(district_df["cost_baseline_total_eur"]) == pytest.approx(4.0) + assert float(district_df["cost_delta_total_eur"]) == pytest.approx(-6.0) + finally: + env.close() diff --git a/tests/test_kpis.py b/tests/test_kpis.py index 7798efd54..b8c7eaae9 100644 --- a/tests/test_kpis.py +++ b/tests/test_kpis.py @@ -6,6 +6,8 @@ pytest.importorskip("gymnasium") from citylearn.citylearn import CityLearnEnv, EvaluationCondition +from citylearn.cost_function import CostFunction +from citylearn.data import ZERO_DIVISION_PLACEHOLDER SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" @@ -95,3 +97,101 @@ def _extract(df): assert charged_charger_consumption > base_charger_consumption, \ "EV load should increase charger electricity consumption." assert np.isfinite(charged_values.dropna()).all() + + +@pytest.mark.parametrize("seconds_per_time_step", [5, 10, 60, 300, 900]) +def test_histories_and_kpi_consistency_with_subhour_steps(seconds_per_time_step: int): + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=6, + seconds_per_time_step=seconds_per_time_step, + random_seed=0, + ) + + try: + env.reset() + names = env.action_names[0] + base_action = np.zeros(env.action_space[0].shape[0], dtype="float32") + + ev_indices = [idx for idx, name in enumerate(names) if name.startswith("electric_vehicle_storage_")] + battery_indices = [idx for idx, name in enumerate(names) if "electrical_storage" in name] + + while not env.terminated: + action = base_action.copy() + if ev_indices: + action[ev_indices] = 0.8 + if battery_indices: + action[battery_indices] = 0.5 + + env.step([action]) + t = env.time_step - 1 + + assert 1 <= len(env.net_electricity_consumption) <= env.time_step + 1 + assert 1 <= len(env.net_electricity_consumption_cost) <= env.time_step + 1 + assert 1 <= len(env.net_electricity_consumption_emission) <= env.time_step + 1 + + for building in env.buildings: + lhs = building.net_electricity_consumption[t] + rhs = ( + building.cooling_electricity_consumption[t] + + building.heating_electricity_consumption[t] + + building.dhw_electricity_consumption[t] + + building.non_shiftable_load_electricity_consumption[t] + + building.electrical_storage_electricity_consumption[t] + + building.solar_generation[t] + + building.chargers_electricity_consumption[t] + + building.washing_machines_electricity_consumption[t] + ) + assert abs(lhs - rhs) < 1e-4 + + final_t = env.time_step + committed_len = final_t if final_t > 0 else 1 + for building in env.buildings: + if building.electric_vehicle_chargers: + charger_total = float(np.sum(building.chargers_electricity_consumption)) + charger_components = float( + sum(np.sum(c.electricity_consumption[: final_t + 1]) for c in building.electric_vehicle_chargers) + ) + assert charger_total == pytest.approx(charger_components) + + assert len(building.solar_generation) == committed_len + assert np.all(np.isfinite(building.solar_generation)) + + for ev in env.electric_vehicles: + soc = ev.battery.soc[: committed_len] + assert np.all(np.isfinite(soc)) + assert np.all((soc >= 0.0) & (soc <= 1.0)) + + control = EvaluationCondition.WITH_STORAGE_AND_PV + baseline = EvaluationCondition.WITHOUT_STORAGE_BUT_WITH_PV + + def _safe_div(control_value: float, baseline_value: float): + eps = float(ZERO_DIVISION_PLACEHOLDER) + if abs(baseline_value) <= eps: + return 1.0 if abs(control_value) <= eps else np.nan + return control_value / baseline_value + + building_ratios = [] + for building in env.buildings: + ec_c = CostFunction.electricity_consumption( + np.array(getattr(building, f"net_electricity_consumption{control.value}"), dtype=float).tolist() + )[-1] + ec_b = CostFunction.electricity_consumption( + np.array(getattr(building, f"net_electricity_consumption{baseline.value}"), dtype=float).tolist() + )[-1] + building_ratios.append(_safe_div(float(ec_c), float(ec_b))) + + expected_ratio = float(np.nanmean(np.array(building_ratios, dtype=float))) + + df = env.evaluate(control_condition=control, baseline_condition=baseline) + district_value = float( + df[ + (df["level"] == "district") + & (df["cost_function"] == "electricity_consumption_total") + ]["value"].iloc[0] + ) + + assert district_value == pytest.approx(expected_ratio) + finally: + env.close() diff --git a/tests/test_reproducibility_and_agent_learn.py b/tests/test_reproducibility_and_agent_learn.py new file mode 100644 index 000000000..ab6b9a9f2 --- /dev/null +++ b/tests/test_reproducibility_and_agent_learn.py @@ -0,0 +1,168 @@ +import json +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.agents.rbc import BasicElectricVehicleRBC_ReferenceController as Agent +from citylearn.citylearn import CityLearnEnv +from citylearn.internal.runtime import CityLearnRuntimeService + + +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def test_agent_learn_handles_single_timestep_episode_without_calling_step(monkeypatch): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=1, render_mode="none", random_seed=7) + agent = Agent(env) + + step_called = False + + def _unexpected_step(_actions): + nonlocal step_called + step_called = True + raise AssertionError("env.step() should not be called when episode is terminal after reset.") + + monkeypatch.setattr(env, "step", _unexpected_step) + + try: + agent.learn(episodes=1, deterministic=True, logging_level=40) + assert not step_called + finally: + env.close() + + +def _run_rbc_episode(render_mode: str, output_root: Path, seed: int = 7): + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=128, + render_mode=render_mode, + render_directory=output_root, + random_seed=seed, + ) + agent = Agent(env) + + try: + observations, _ = env.reset() + rewards = [] + + while not env.terminated: + actions = agent.predict(observations, deterministic=True) + observations, reward, terminated, truncated, _ = env.step(actions) + rewards.append(np.array(reward, dtype="float64").reshape(-1)) + + if terminated or truncated: + break + + reward_trace = np.vstack(rewards) if rewards else np.zeros((0, len(env.action_space)), dtype="float64") + ev_soc_trace = { + ev.name: np.array(ev.battery.soc[: env.time_step + 1], dtype="float64") + for ev in env.electric_vehicles + } + return reward_trace, ev_soc_trace + finally: + env.close() + + +def test_same_seed_is_reproducible_across_render_modes_without_global_numpy_seed(tmp_path): + during_rewards, during_ev_soc = _run_rbc_episode("during", tmp_path / "during") + end_rewards, end_ev_soc = _run_rbc_episode("end", tmp_path / "end") + + assert during_rewards.shape == end_rewards.shape + assert np.allclose(during_rewards, end_rewards) + assert set(during_ev_soc.keys()) == set(end_ev_soc.keys()) + + for ev_name in during_ev_soc: + assert during_ev_soc[ev_name].shape == end_ev_soc[ev_name].shape + assert np.allclose(during_ev_soc[ev_name], end_ev_soc[ev_name]) + + +def test_runtime_random_seed_overrides_schema_seed_for_loading_defaults(): + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + missing_initial_soc = [ + name + for name, ev in (schema.get("electric_vehicles_def", {}) or {}).items() + if "initial_soc" not in ((ev.get("battery", {}) or {}).get("attributes", {}) or {}) + ] + assert missing_initial_soc, "Expected at least one EV without explicit initial_soc in schema." + + env_a = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=8, render_mode="none", random_seed=1) + env_b = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=8, render_mode="none", random_seed=2) + env_c = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=8, render_mode="none", random_seed=1) + + try: + soc_a = {ev.name: float(ev.battery.initial_soc) for ev in env_a.electric_vehicles} + soc_b = {ev.name: float(ev.battery.initial_soc) for ev in env_b.electric_vehicles} + soc_c = {ev.name: float(ev.battery.initial_soc) for ev in env_c.electric_vehicles} + + # different runtime seeds should alter default-initialized EV SOCs + assert any(not np.isclose(soc_a[name], soc_b[name]) for name in missing_initial_soc) + + # same runtime seed should reproduce the same default SOCs + assert all(np.isclose(soc_a[name], soc_c[name]) for name in missing_initial_soc) + finally: + env_a.close() + env_b.close() + env_c.close() + + +def test_ev_unconnected_drift_std_scales_with_physical_timestep(): + hourly = CityLearnRuntimeService._ev_unconnected_drift_std(3600) + minute = CityLearnRuntimeService._ev_unconnected_drift_std(60) + quarter_hour = CityLearnRuntimeService._ev_unconnected_drift_std(900) + + assert np.isclose(hourly, 0.2) + assert np.isclose(quarter_hour, 0.1) + assert minute < quarter_hour < hourly + + +def test_unconnected_ev_soc_drift_uses_time_aware_variance(): + class _RandomState: + def __init__(self): + self.calls = [] + + def normal(self, loc, scale): + self.calls.append((float(loc), float(scale))) + return float(loc + scale) + + class _Battery: + def __init__(self, soc, target_index): + self.soc = list(soc) + self._target_index = target_index + + def force_set_soc(self, value): + self.soc[self._target_index] = float(value) + + class _EV: + def __init__(self, name, battery): + self.name = name + self.battery = battery + + class _EpisodeTracker: + def __init__(self, episode_time_steps): + self.episode_time_steps = episode_time_steps + self.episode = 0 + + class _Env: + def __init__(self, seconds_per_time_step): + self.seconds_per_time_step = seconds_per_time_step + self.time_step = 1 + self.episode_tracker = _EpisodeTracker(episode_time_steps=8) + self.electric_vehicles = [_EV("EV1", _Battery([0.5, 0.0], target_index=1))] + self.buildings = [] + self.random_seed = 7 + self._ev_drift_random_state = _RandomState() + + env_hourly = _Env(3600) + env_minute = _Env(60) + CityLearnRuntimeService(env_hourly).simulate_unconnected_ev_soc() + CityLearnRuntimeService(env_minute).simulate_unconnected_ev_soc() + + hourly_scale = env_hourly._ev_drift_random_state.calls[0][1] + minute_scale = env_minute._ev_drift_random_state.calls[0][1] + + assert hourly_scale > minute_scale + assert env_hourly.electric_vehicles[0].battery.soc[1] > env_minute.electric_vehicles[0].battery.soc[1] diff --git a/tests/test_rl_temporal_semantics.py b/tests/test_rl_temporal_semantics.py new file mode 100644 index 000000000..9c39a9c83 --- /dev/null +++ b/tests/test_rl_temporal_semantics.py @@ -0,0 +1,140 @@ +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def _zero_actions(env: CityLearnEnv): + return [np.zeros(space.shape, dtype="float32") for space in env.action_space] + + +def test_agent_observations_use_lagged_endogenous_values(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + observations, _ = env.reset() + names = env.observation_names[0] + net_ix = names.index("net_electricity_consumption") + soc_ix = names.index("electrical_storage_soc") + + assert observations[0][net_ix] == pytest.approx(float(env.buildings[0].net_electricity_consumption[0])) + assert observations[0][soc_ix] == pytest.approx(float(env.buildings[0].electrical_storage.soc[0])) + + next_observations, _, _, _, _ = env.step(_zero_actions(env)) + assert env.time_step == 1 + assert next_observations[0][net_ix] == pytest.approx(float(env.buildings[0].net_electricity_consumption[0])) + assert next_observations[0][soc_ix] == pytest.approx(float(env.buildings[0].electrical_storage.soc[0])) + finally: + env.close() + + +def test_include_all_observations_remain_on_current_transition_time(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + env.step(_zero_actions(env)) + building = env.buildings[0] + t = env.time_step + lagged_t = max(t - 1, 0) + + all_obs = building.observations(include_all=True, normalize=False, periodic_normalization=False) + agent_obs = building.observations(include_all=False, normalize=False, periodic_normalization=False) + + assert all_obs["net_electricity_consumption"] == pytest.approx(float(building.net_electricity_consumption[t])) + assert agent_obs["net_electricity_consumption"] == pytest.approx(float(building.net_electricity_consumption[lagged_t])) + assert all_obs["electrical_storage_soc"] == pytest.approx(float(building.electrical_storage.soc[t])) + assert agent_obs["electrical_storage_soc"] == pytest.approx(float(building.electrical_storage.soc[lagged_t])) + finally: + env.close() + + +def test_step_after_terminal_raises_runtime_error(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=2, random_seed=0) + + try: + env.reset() + _, _, terminated, truncated, _ = env.step(_zero_actions(env)) + assert terminated + assert not truncated + + with pytest.raises(RuntimeError, match="reset"): + env.step(_zero_actions(env)) + finally: + env.close() + + +def test_step_raises_for_invalid_central_action_count(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + expected = env.action_space[0].shape[0] + invalid = np.zeros(expected + 1, dtype="float32") + + with pytest.raises(AssertionError, match="Expected"): + env.step([invalid]) + finally: + env.close() + + +def test_step_accepts_central_numpy_action_vector(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + _, _, terminated, truncated, _ = env.step(action) + assert not terminated + assert not truncated + finally: + env.close() + + +def test_step_accepts_central_single_row_numpy_action_vector(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + action = np.zeros((1, env.action_space[0].shape[0]), dtype="float32") + _, _, terminated, truncated, _ = env.step(action) + assert not terminated + assert not truncated + finally: + env.close() + + +def test_step_raises_for_missing_decentralized_action_vector(): + env = CityLearnEnv(str(SCHEMA), central_agent=False, episode_time_steps=4, random_seed=0) + + try: + env.reset() + actions = _zero_actions(env) + + if len(actions) < 2: + pytest.skip("Dataset does not expose multiple buildings for decentralized action validation.") + + with pytest.raises(AssertionError, match="building action vectors"): + env.step(actions[:-1]) + finally: + env.close() + + +def test_step_accepts_valid_decentralized_action_vectors(): + env = CityLearnEnv(str(SCHEMA), central_agent=False, episode_time_steps=4, random_seed=0) + + try: + env.reset() + actions = _zero_actions(env) + _, _, terminated, truncated, _ = env.step(actions) + assert not terminated + assert not truncated + finally: + env.close() diff --git a/tests/test_scenario_smoke.py b/tests/test_scenario_smoke.py new file mode 100644 index 000000000..49c0a8e99 --- /dev/null +++ b/tests/test_scenario_smoke.py @@ -0,0 +1,128 @@ +from pathlib import Path +import csv + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +SCHEMA = Path(__file__).resolve().parents[1] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def _charging_action(env: CityLearnEnv) -> np.ndarray: + names = env.action_names[0] + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + + ev_indices = [i for i, name in enumerate(names) if name.startswith("electric_vehicle_storage_")] + battery_indices = [i for i, name in enumerate(names) if "electrical_storage" in name] + + if ev_indices: + action[ev_indices] = 0.7 + + if battery_indices: + action[battery_indices] = 0.5 + + return action + + +@pytest.mark.parametrize("seconds_per_time_step", [5, 60]) +def test_scenario_smoke_ev_battery_pv_none_mode(seconds_per_time_step: int): + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=11, + seconds_per_time_step=seconds_per_time_step, + render_mode="none", + random_seed=0, + ) + + try: + env.reset() + + while not env.terminated: + action = _charging_action(env) + _, _, terminated, truncated, _ = env.step([action]) + + if terminated or truncated: + break + + assert env.time_step == env.episode_time_steps - 1 + assert len(env.electric_vehicles) > 0 + assert any(len(b.electric_vehicle_chargers) > 0 for b in env.buildings) + assert any(b.electrical_storage.capacity > 0 for b in env.buildings) + assert any(b.pv.nominal_power > 0 for b in env.buildings) + + final_t = env.time_step + expected_building_series_len = final_t if final_t > 0 else 1 + + for building in env.buildings: + assert len(building.net_electricity_consumption) == expected_building_series_len + storage_soc = float(building.electrical_storage.soc[building.time_step]) + assert 0.0 <= storage_soc <= 1.0 + + for ev in env.electric_vehicles: + ev_soc = float(ev.battery.soc[ev.time_step]) + assert 0.0 <= ev_soc <= 1.0 + + kpis = env.evaluate() + district_total = kpis[ + (kpis["level"] == "district") + & (kpis["cost_function"] == "electricity_consumption_total") + ]["value"] + assert not district_total.empty + assert np.isfinite(district_total.to_numpy(dtype=float)).all() + finally: + env.close() + + +def test_scenario_smoke_ev_battery_pv_end_mode_exports(tmp_path): + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=11, + seconds_per_time_step=60, + render_mode="end", + render_directory=tmp_path, + render_session_name="scenario_smoke_end", + random_seed=0, + ) + + class _Model: + pass + + model = _Model() + model.env = env + + try: + env.reset() + + while not env.terminated: + action = _charging_action(env) + _, _, terminated, truncated, _ = env.step([action]) + + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + assert outputs_path.is_dir() + + community_file = outputs_path / "exported_data_community_ep0.csv" + assert community_file.is_file() + assert any(outputs_path.glob("exported_data_*_battery_ep0.csv")) + + for ev in env.electric_vehicles: + ev_file = outputs_path / f"exported_data_{ev.name.lower()}_ep0.csv" + assert ev_file.is_file() + + with community_file.open(newline="") as handle: + rows = list(csv.reader(handle)) + + assert len(rows) > 2 + + env.export_final_kpis(model, filepath="exported_kpis_smoke.csv") + assert (outputs_path / "exported_kpis_smoke.csv").is_file() + finally: + env.close() diff --git a/tests/test_series_integrity.py b/tests/test_series_integrity.py index d2372a785..c0b0d127e 100644 --- a/tests/test_series_integrity.py +++ b/tests/test_series_integrity.py @@ -52,3 +52,100 @@ def test_series_integrity_reset_and_step(): _assert_length_consistency(env) finally: env.close() + + +def test_bess_first_step_not_double_counted(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + + offset = 0 + target_building = None + target_action_index = None + + for building in env.buildings: + action_count = len(building.active_actions) + if "electrical_storage" in building.active_actions: + local_index = building.active_actions.index("electrical_storage") + target_action_index = offset + local_index + target_building = building + break + offset += action_count + + assert target_building is not None + assert target_action_index is not None + action[target_action_index] = 0.5 + + env.step([action]) + + t = 0 + expected = target_building.electrical_storage.energy_balance[t] + actual = target_building.electrical_storage_electricity_consumption[t] + + assert abs(expected) > 1e-9 + assert actual == pytest.approx(expected, abs=1e-6) + finally: + env.close() + + +def test_non_shiftable_first_step_not_double_counted(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + action = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + env.step(action) + + for building in env.buildings: + expected = building.energy_to_non_shiftable_load[0] + actual = building.non_shiftable_load_electricity_consumption[0] + assert actual == pytest.approx(expected, abs=1e-6) + finally: + env.close() + + +def test_non_shiftable_t0_update_variables_idempotent(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=4, random_seed=0) + + try: + env.reset() + before = { + building.name: float(building.non_shiftable_load_electricity_consumption[0]) + for building in env.buildings + } + + env.update_variables() + + after = { + building.name: float(building.non_shiftable_load_electricity_consumption[0]) + for building in env.buildings + } + assert after == pytest.approx(before, abs=1e-6) + finally: + env.close() + + +def test_terminal_series_exclude_uncommitted_tail_slot(): + env = CityLearnEnv(str(SCHEMA), central_agent=True, episode_time_steps=6, random_seed=0) + + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + if terminated or truncated: + break + + assert env.time_step == env.time_steps - 1 + + for building in env.buildings: + assert building.time_step == env.time_step - 1 + assert len(building.net_electricity_consumption) == env.time_step + assert len(building.net_electricity_consumption_cost) == env.time_step + assert len(building.net_electricity_consumption_emission) == env.time_step + assert len(building.electrical_storage_electricity_consumption) == env.time_step + finally: + env.close() diff --git a/tests/unit/test_boolean_parser.py b/tests/unit/test_boolean_parser.py new file mode 100644 index 000000000..c0787b1f1 --- /dev/null +++ b/tests/unit/test_boolean_parser.py @@ -0,0 +1,39 @@ +import pytest + +from citylearn.utilities import parse_bool + + +@pytest.mark.parametrize( + "value,expected", + [ + (True, True), + (False, False), + (1, True), + (0, False), + ("true", True), + ("false", False), + ("TRUE", True), + ("FALSE", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_parse_bool_accepts_supported_inputs(value, expected): + assert parse_bool(value, path="test.value") is expected + + +def test_parse_bool_uses_default_for_none(): + assert parse_bool(None, default="false", path="test.value") is False + assert parse_bool(None, default=1, path="test.value") is True + + +def test_parse_bool_rejects_invalid_tokens(): + with pytest.raises(ValueError, match="test.value"): + parse_bool("maybe", path="test.value") + + with pytest.raises(ValueError, match="test.value"): + parse_bool(2, path="test.value") diff --git a/tests/unit/test_citylearn_property_contract.py b/tests/unit/test_citylearn_property_contract.py new file mode 100644 index 000000000..ad0039808 --- /dev/null +++ b/tests/unit/test_citylearn_property_contract.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +SCHEMA = Path(__file__).resolve().parents[2] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def test_without_storage_district_series_are_consistent_ndarrays(): + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=6, + render_mode="none", + random_seed=0, + ) + + try: + env.reset() + emissions = env.net_electricity_consumption_emission_without_storage + costs = env.net_electricity_consumption_cost_without_storage + energy = env.net_electricity_consumption_without_storage + + assert isinstance(emissions, np.ndarray) + assert isinstance(costs, np.ndarray) + assert isinstance(energy, np.ndarray) + assert emissions.shape == costs.shape == energy.shape + finally: + env.close() diff --git a/tests/unit/test_electric_vehicle_charger.py b/tests/unit/test_electric_vehicle_charger.py index 308cc1957..ceb6fd432 100644 --- a/tests/unit/test_electric_vehicle_charger.py +++ b/tests/unit/test_electric_vehicle_charger.py @@ -29,7 +29,12 @@ def _make_simulation(length: int, ev_id: str) -> ChargerSimulation: ) -def _make_battery(tracker: EpisodeTracker, initial_soc: float = 0.5) -> Battery: +def _make_battery( + tracker: EpisodeTracker, + initial_soc: float = 0.5, + seconds_per_time_step: int = 3600, + time_step_ratio: float = 1.0, +) -> Battery: battery = Battery( capacity=100.0, nominal_power=50.0, @@ -39,20 +44,31 @@ def _make_battery(tracker: EpisodeTracker, initial_soc: float = 0.5) -> Battery: capacity_loss_coefficient=0.0, power_efficiency_curve=[[0.0, 1.0], [1.0, 1.0]], capacity_power_curve=[[0.0, 1.0], [1.0, 1.0]], - seconds_per_time_step=3600, + seconds_per_time_step=seconds_per_time_step, + time_step_ratio=time_step_ratio, episode_tracker=tracker, ) battery.reset() return battery -def _make_ev(tracker: EpisodeTracker, initial_soc: float = 0.5) -> ElectricVehicle: - battery = _make_battery(tracker, initial_soc=initial_soc) +def _make_ev( + tracker: EpisodeTracker, + initial_soc: float = 0.5, + seconds_per_time_step: int = 3600, + time_step_ratio: float = 1.0, +) -> ElectricVehicle: + battery = _make_battery( + tracker, + initial_soc=initial_soc, + seconds_per_time_step=seconds_per_time_step, + time_step_ratio=time_step_ratio, + ) ev = ElectricVehicle( episode_tracker=tracker, battery=battery, name="EV-1", - seconds_per_time_step=3600, + seconds_per_time_step=seconds_per_time_step, ) ev.reset() return ev @@ -192,6 +208,22 @@ def test_no_ev_connected_records_zero_consumption(tracker: EpisodeTracker, charg assert charger.past_charging_action_values_kwh[charger.time_step] == pytest.approx(expected_energy) +def test_zero_action_keeps_connected_ev_soc_consistent(tracker: EpisodeTracker, charger_simulation: ChargerSimulation): + ev = _make_ev(tracker, initial_soc=0.6) + charger = _make_charger(tracker, charger_simulation, ev=ev) + + charger.time_step = 1 + ev.time_step = 1 + ev.battery.time_step = 1 + ev.battery.soc[0] = 0.6 + + charger.update_connected_electric_vehicle_soc(0.0) + + assert charger.past_charging_action_values_kwh[charger.time_step] == pytest.approx(0.0) + assert charger.electricity_consumption[charger.time_step] == pytest.approx(0.0) + assert ev.battery.soc[1] == pytest.approx(0.6) + + def test_past_charging_actions_track_history(charger: Charger, electric_vehicle: ElectricVehicle): actions = [0.25, -0.4, 0.0] expected = [2.5, -4.0, 0.0] @@ -215,3 +247,84 @@ def test_render_simulation_end_data_includes_consumption(charger: Charger, elect assert summary["name"] == "charger-1" assert summary["charger_data"][0]["electricity_consumption"] != 0 assert summary["charger_data"][0]["time_step"] == 0 + + +@pytest.mark.parametrize("seconds_per_time_step", [5, 10, 60, 300, 900]) +def test_subhour_energy_exchange_matches_power_times_delta_t( + tracker: EpisodeTracker, + charger_simulation: ChargerSimulation, + seconds_per_time_step: int, +): + ratio = seconds_per_time_step / 3600.0 + ev = _make_ev( + tracker, + initial_soc=0.5, + seconds_per_time_step=seconds_per_time_step, + time_step_ratio=ratio, + ) + charger = _make_charger( + tracker, + charger_simulation, + ev, + max_charging_power=10.0, + efficiency=1.0, + seconds_per_time_step=seconds_per_time_step, + ) + + charger.update_connected_electric_vehicle_soc(1.0) + expected_energy = 10.0 * (seconds_per_time_step / 3600.0) + + assert charger.past_charging_action_values_kwh[charger.time_step] == pytest.approx(expected_energy) + assert charger.electricity_consumption[charger.time_step] == pytest.approx(expected_energy) + assert ev.battery.energy_balance[ev.time_step] == pytest.approx(expected_energy) + + +def test_subhour_minimum_power_limit_is_scaled_by_timestep( + tracker: EpisodeTracker, + charger_simulation: ChargerSimulation, +): + seconds_per_time_step = 5 + ratio = seconds_per_time_step / 3600.0 + ev = _make_ev( + tracker, + initial_soc=0.5, + seconds_per_time_step=seconds_per_time_step, + time_step_ratio=ratio, + ) + charger = _make_charger( + tracker, + charger_simulation, + ev, + max_charging_power=10.0, + min_charging_power=2.0, + efficiency=1.0, + seconds_per_time_step=seconds_per_time_step, + ) + + charger.update_connected_electric_vehicle_soc(0.01) + expected_energy = 2.0 * (seconds_per_time_step / 3600.0) + + assert charger.past_charging_action_values_kwh[charger.time_step] == pytest.approx(expected_energy) + assert charger.electricity_consumption[charger.time_step] == pytest.approx(expected_energy) + + +def test_v2g_disabled_when_max_discharging_power_is_zero( + tracker: EpisodeTracker, + charger_simulation: ChargerSimulation, +): + ev = _make_ev(tracker, initial_soc=0.8) + charger = _make_charger( + tracker, + charger_simulation, + ev, + max_discharging_power=0.0, + min_discharging_power=0.0, + efficiency=1.0, + ) + + initial_soc = float(ev.battery.soc[ev.time_step]) + charger.update_connected_electric_vehicle_soc(-1.0) + + assert charger.past_charging_action_values_kwh[charger.time_step] == pytest.approx(0.0) + assert charger.electricity_consumption[charger.time_step] == pytest.approx(0.0) + assert float(ev.battery.soc[ev.time_step]) == pytest.approx(initial_soc) diff --git a/tests/unit/test_episode_tracker.py b/tests/unit/test_episode_tracker.py new file mode 100644 index 000000000..d1866bff5 --- /dev/null +++ b/tests/unit/test_episode_tracker.py @@ -0,0 +1,40 @@ +import pytest + +from citylearn.base import EpisodeTracker + + +def test_episode_time_steps_larger_than_simulation_window_raises_clear_error(): + tracker = EpisodeTracker(0, 9) + + with pytest.raises(ValueError, match='exceeds available simulation window'): + tracker.next_episode( + episode_time_steps=11, + rolling_episode_split=False, + random_episode_split=False, + random_seed=0, + ) + + +def test_random_episode_split_with_single_split_is_handled(): + tracker = EpisodeTracker(0, 9) + tracker.next_episode( + episode_time_steps=10, + rolling_episode_split=False, + random_episode_split=True, + random_seed=1, + ) + + assert tracker.episode_start_time_step == 0 + assert tracker.episode_end_time_step == 9 + + +def test_non_positive_episode_time_steps_raises_value_error(): + tracker = EpisodeTracker(0, 9) + + with pytest.raises(ValueError, match='must be >= 1'): + tracker.next_episode( + episode_time_steps=0, + rolling_episode_split=False, + random_episode_split=False, + random_seed=0, + ) diff --git a/tests/unit/test_equity_kpi_formulas.py b/tests/unit/test_equity_kpi_formulas.py new file mode 100644 index 000000000..c8fb9cc02 --- /dev/null +++ b/tests/unit/test_equity_kpi_formulas.py @@ -0,0 +1,65 @@ +import numpy as np +import pytest + +from citylearn.internal.kpi import CityLearnKPIService + + +def test_equity_relative_benefit_uses_paper_formula(): + value = CityLearnKPIService._equity_relative_benefit_percent(80.0, 100.0) + assert value == pytest.approx(20.0) + + +def test_equity_relative_benefit_is_none_for_non_positive_baseline(): + assert CityLearnKPIService._equity_relative_benefit_percent(10.0, 0.0) is None + assert CityLearnKPIService._equity_relative_benefit_percent(10.0, -5.0) is None + + +def test_safe_div_handles_near_zero_baseline(): + assert CityLearnKPIService._safe_div(1.0, 1.0e-12) is None + assert CityLearnKPIService._safe_div(0.0, 1.0e-12) == pytest.approx(1.0) + + +def test_equity_distribution_metrics_for_equal_benefits(): + metrics = CityLearnKPIService._equity_distribution_metrics(np.array([10.0, 10.0, 10.0], dtype="float64")) + assert metrics["equity_gini_benefit"] == pytest.approx(0.0) + assert metrics["equity_cr20_benefit"] == pytest.approx(1.0 / 3.0) + assert metrics["equity_losers_percent"] == pytest.approx(0.0) + + +def test_equity_distribution_metrics_for_high_concentration(): + metrics = CityLearnKPIService._equity_distribution_metrics(np.array([100.0, 0.0, 0.0, 0.0, 0.0], dtype="float64")) + assert metrics["equity_gini_benefit"] == pytest.approx(0.8) + assert metrics["equity_cr20_benefit"] == pytest.approx(1.0) + assert metrics["equity_losers_percent"] == pytest.approx(0.0) + + +def test_equity_distribution_metrics_include_losers(): + metrics = CityLearnKPIService._equity_distribution_metrics(np.array([-10.0, 5.0, 0.0, 4.0], dtype="float64")) + assert metrics["equity_losers_percent"] == pytest.approx(25.0) + + +def test_equity_bpr_uses_asset_groups(): + non_negative_benefits = { + "b1": 2.0, + "b2": 4.0, + "b3": 1.0, + "b4": 3.0, + } + groups = { + "b1": "asset_poor", + "b2": "asset_rich", + "b3": "asset_poor", + "b4": "asset_rich", + } + expected = ((2.0 + 1.0) / 2.0) / ((4.0 + 3.0) / 2.0) + + assert CityLearnKPIService._equity_bpr(non_negative_benefits, groups) == pytest.approx(expected) + + +def test_equity_bpr_returns_none_on_missing_or_invalid_groups(): + non_negative_benefits = {"b1": 2.0, "b2": 4.0} + missing = {"b1": "asset_poor"} + invalid = {"b1": "asset_poor", "b2": "unknown"} + + assert CityLearnKPIService._equity_bpr(non_negative_benefits, missing) is None + assert CityLearnKPIService._equity_bpr(non_negative_benefits, invalid) is None diff --git a/tests/unit/test_pv.py b/tests/unit/test_pv.py index c7d346324..21cb996e1 100644 --- a/tests/unit/test_pv.py +++ b/tests/unit/test_pv.py @@ -6,6 +6,12 @@ from typing import Union from citylearn.energy_model import PV +import citylearn.energy_model as energy_model + +pytestmark = pytest.mark.skipif( + getattr(energy_model, "Pvwattsv8", None) is None, + reason="PySAM/Pvwattsv8 is not installed in this environment.", +) class DummyElectricDevice: @@ -133,4 +139,3 @@ def test_autosize_use_sample_target(mock_pvwatts, mock_sizing_data): assert np.isclose(nominal_power, 8000) - diff --git a/tests/unit/test_rendering_behaviour.py b/tests/unit/test_rendering_behaviour.py index ebd70dffc..ffd95cb6d 100644 --- a/tests/unit/test_rendering_behaviour.py +++ b/tests/unit/test_rendering_behaviour.py @@ -9,6 +9,7 @@ pytest.importorskip("gymnasium") +from citylearn.agents.rbc import BasicElectricVehicleRBC_ReferenceController as Agent from citylearn.citylearn import CityLearnEnv @@ -85,6 +86,87 @@ class _Model: env.close() +def test_none_mode_does_not_auto_export_kpis_by_default(tmp_path): + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=4, + render_mode="none", + render_directory=tmp_path, + random_seed=0, + ) + + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + if terminated or truncated: + break + + assert env.new_folder_path is None + assert not env._final_kpis_exported + finally: + _cleanup_env(env) + env.close() + + +def test_none_mode_can_auto_export_kpis_when_enabled(tmp_path): + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=4, + render_mode="none", + render_directory=tmp_path, + export_kpis_on_episode_end=True, + random_seed=0, + ) + + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + assert outputs_path.is_dir() + assert (outputs_path / "exported_kpis.csv").is_file() + assert env._final_kpis_exported + finally: + _cleanup_env(env) + env.close() + + +def test_during_mode_can_disable_auto_kpi_export(tmp_path): + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=4, + render_mode="during", + render_directory=tmp_path, + export_kpis_on_episode_end=False, + random_seed=0, + ) + + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + assert (outputs_path / "exported_data_community_ep0.csv").is_file() + assert not (outputs_path / "exported_kpis.csv").exists() + assert not env._final_kpis_exported + finally: + _cleanup_env(env) + env.close() + + def test_render_directory_override(tmp_path): custom_root = tmp_path / 'custom_results' @@ -119,26 +201,10 @@ def test_default_start_date_used_for_render_timestamp(): date_part, time_part = timestamp.split('T') year_str, month_str, day_str = date_part.split('-') - energy_sim = env.buildings[0].energy_simulation - first_hour = int(energy_sim.hour[0]) - month_series = energy_sim.month - - if first_hour >= 24: - expected_month = int(month_series[1]) if len(month_series) > 1 else ((int(month_series[0]) % 12) + 1) - expected_day = 1 - else: - expected_month = int(month_series[0]) - expected_day = env.render_start_date.day - assert int(year_str) == env.render_start_date.year == 2024 - assert int(day_str) == expected_day - assert int(month_str) == expected_month - - expected_hour = first_hour % 24 - minutes_data = getattr(energy_sim, 'minutes', None) - expected_minutes = int(minutes_data[0]) if minutes_data is not None and len(minutes_data) > 0 else 0 - - assert time_part == f"{expected_hour:02d}:{expected_minutes:02d}:00" + assert int(day_str) == env.render_start_date.day + assert int(month_str) == env.render_start_date.month + assert time_part == "00:00:00" finally: env.close() @@ -155,19 +221,29 @@ def test_schema_start_date_overrides_default_timestamp_start(): date_part, _ = timestamp.split('T') year_str, month_str, day_str = date_part.split('-') - energy_sim = env.buildings[0].energy_simulation - first_hour = int(energy_sim.hour[0]) - month_series = energy_sim.month + assert (int(year_str), int(month_str), int(day_str)) == (2026, 5, 15) + finally: + env.close() - if first_hour >= 24: - expected_month = int(month_series[1]) if len(month_series) > 1 else ((int(month_series[0]) % 12) + 1) - expected_day = 1 - else: - expected_month = int(month_series[0]) - expected_day = 15 - assert (int(year_str), int(day_str)) == (2026, expected_day) - assert int(month_str) == expected_month +def test_render_timestamp_advances_with_seconds_per_time_step(): + schema = _load_schema_dict() + schema.pop('start_date', None) + + env = CityLearnEnv( + schema, + central_agent=True, + episode_time_steps=4, + seconds_per_time_step=60, + ) + + try: + env.reset() + t0 = env._get_iso_timestamp() + env.step([np.zeros(env.action_space[0].shape[0], dtype="float32")]) + t1 = env._get_iso_timestamp() + assert t0.endswith("00:00:00") + assert t1.endswith("00:01:00") finally: env.close() @@ -280,3 +356,120 @@ class _Model: finally: _cleanup_env(env) env.close() + + +def test_end_mode_exports_without_per_step_buffer_growth(tmp_path): + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=5, + render_mode="end", + render_directory=tmp_path, + random_seed=0, + ) + + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + assert not any(env._render_buffer.values()) + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + community_file = outputs_path / "exported_data_community_ep0.csv" + assert community_file.is_file() + + with community_file.open(newline="") as handle: + rows = list(csv.reader(handle)) + + # Header + one row per realized transition. + assert len(rows) == env.time_step + 1 + finally: + _cleanup_env(env) + env.close() + + +def test_end_mode_export_file_contract_matches_during_mode(tmp_path): + def _run(render_mode: str): + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=4, + render_mode=render_mode, + render_directory=tmp_path / render_mode, + random_seed=0, + ) + try: + env.reset() + zeros = [np.zeros(env.action_space[0].shape[0], dtype="float32")] + while not env.terminated: + _, _, terminated, truncated, _ = env.step(zeros) + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + export_files = sorted(p.name for p in outputs_path.glob("exported_data_*_ep0.csv")) + community_file = outputs_path / "exported_data_community_ep0.csv" + with community_file.open(newline="") as handle: + header = next(csv.reader(handle)) + + return export_files, header + finally: + _cleanup_env(env) + env.close() + + during_files, during_header = _run("during") + end_files, end_header = _run("end") + + assert end_files == during_files + assert end_header == during_header + + +def test_end_mode_ev_and_charger_content_matches_during_mode(tmp_path): + def _run(render_mode: str) -> Path: + env = CityLearnEnv( + str(DATASET), + central_agent=True, + episode_time_steps=128, + render_mode=render_mode, + render_directory=tmp_path / render_mode, + random_seed=7, + ) + agent = Agent(env) + + try: + observations, _ = env.reset() + + while not env.terminated: + actions = agent.predict(observations, deterministic=True) + observations, _, terminated, truncated, _ = env.step(actions) + + if terminated or truncated: + break + + return Path(env.new_folder_path) + finally: + env.close() + + during_dir = _run("during") + end_dir = _run("end") + + all_files = sorted(p.name for p in during_dir.glob("exported_data_*_ep0.csv")) + relevant_files = [name for name in all_files if ("charger" in name or "electric_vehicle" in name)] + assert relevant_files, "Expected EV/charger export files to be present." + + for filename in relevant_files: + during_path = during_dir / filename + end_path = end_dir / filename + assert end_path.exists(), f"Missing end-mode file: {filename}" + + with during_path.open(newline="") as handle: + during_rows = list(csv.DictReader(handle)) + + with end_path.open(newline="") as handle: + end_rows = list(csv.DictReader(handle)) + + assert during_rows == end_rows, f"Mismatch in EV/charger export data for file {filename}" diff --git a/tests/unit/test_subhour_scaling.py b/tests/unit/test_subhour_scaling.py index bcab86e6d..4268f5118 100644 --- a/tests/unit/test_subhour_scaling.py +++ b/tests/unit/test_subhour_scaling.py @@ -8,7 +8,7 @@ from citylearn.base import EpisodeTracker from citylearn.citylearn import CityLearnEnv from citylearn.data import EnergySimulation -from citylearn.energy_model import StorageDevice +from citylearn.energy_model import Battery, ElectricDevice, StorageDevice def _make_tracker(length: int) -> EpisodeTracker: @@ -62,6 +62,40 @@ def test_energy_simulation_ratio_subhour_control_from_hourly_dataset(): assert sim.time_step_ratios[0] == pytest.approx(0.25) +def test_energy_simulation_time_step_ratio_default_not_shared_across_instances(): + num_steps = 4 + + sim_hourly = EnergySimulation( + month=[1] * num_steps, + hour=[0, 1, 2, 3], + day_type=[1] * num_steps, + indoor_dry_bulb_temperature=[20.0] * num_steps, + non_shiftable_load=[1.0] * num_steps, + dhw_demand=[0.0] * num_steps, + cooling_demand=[0.0] * num_steps, + heating_demand=[0.0] * num_steps, + solar_generation=[0.0] * num_steps, + seconds_per_time_step=3600, + ) + sim_subhour = EnergySimulation( + month=[1] * num_steps, + hour=[0, 1, 2, 3], + day_type=[1] * num_steps, + indoor_dry_bulb_temperature=[20.0] * num_steps, + non_shiftable_load=[1.0] * num_steps, + dhw_demand=[0.0] * num_steps, + cooling_demand=[0.0] * num_steps, + heating_demand=[0.0] * num_steps, + solar_generation=[0.0] * num_steps, + seconds_per_time_step=900, + ) + + assert len(sim_hourly.time_step_ratios) == 1 + assert len(sim_subhour.time_step_ratios) == 1 + assert sim_hourly.time_step_ratios[0] == pytest.approx(1.0) + assert sim_subhour.time_step_ratios[0] == pytest.approx(0.25) + + def test_storage_charge_scaling_respects_time_ratio(): tracker = _make_tracker(4) storage = StorageDevice( @@ -82,6 +116,79 @@ def test_storage_charge_scaling_respects_time_ratio(): assert storage.energy_balance[0] == pytest.approx(energy_actual) +def test_electric_device_set_electricity_consumption_is_absolute(): + tracker = _make_tracker(4) + device = ElectricDevice( + nominal_power=10.0, + time_step_ratio=0.25, + seconds_per_time_step=900, + episode_tracker=tracker, + ) + device.reset() + + # Additive updates use dataset-resolution values when ratio != 1. + device.update_electricity_consumption(4.0) + assert device.electricity_consumption[0] == pytest.approx(1.0) + + # Absolute setter must overwrite, not accumulate. + device.set_electricity_consumption(2.5) + assert device.electricity_consumption[0] == pytest.approx(2.5) + + device.set_electricity_consumption(0.5) + assert device.electricity_consumption[0] == pytest.approx(0.5) + + +def test_battery_electricity_consumption_tracks_energy_balance_in_subhour(): + tracker = _make_tracker(4) + battery = Battery( + capacity=100.0, + nominal_power=50.0, + initial_soc=0.5, + efficiency=1.0, + loss_coefficient=0.0, + capacity_loss_coefficient=0.0, + power_efficiency_curve=[[0.0, 1.0], [1.0, 1.0]], + capacity_power_curve=[[0.0, 1.0], [1.0, 1.0]], + time_step_ratio=0.25, + seconds_per_time_step=900, + episode_tracker=tracker, + ) + battery.reset() + + # Dataset-resolution command corresponding to 2.5 kWh over a 15-minute step. + battery.charge(10.0) + + assert battery.energy_balance[0] == pytest.approx(2.5) + assert battery.electricity_consumption[0] == pytest.approx(2.5) + + +def test_battery_degradation_uses_step_energy_without_extra_ratio_scaling(): + tracker = _make_tracker(4) + battery = Battery( + capacity=100.0, + nominal_power=50.0, + initial_soc=0.5, + efficiency=1.0, + loss_coefficient=0.0, + capacity_loss_coefficient=1e-5, + power_efficiency_curve=[[0.0, 1.0], [1.0, 1.0]], + capacity_power_curve=[[0.0, 1.0], [1.0, 1.0]], + time_step_ratio=0.25, + seconds_per_time_step=900, + episode_tracker=tracker, + ) + battery.reset() + battery.charge(10.0) + + expected = ( + battery.capacity_loss_coefficient + * battery.capacity + * abs(battery.energy_balance[0]) + / (2.0 * max(battery.degraded_capacity, 1e-10)) + ) + assert battery.degrade() == pytest.approx(expected) + + def test_env_supports_subhour_seconds_per_time_step(): schema = 'data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json' env = CityLearnEnv( diff --git a/tests/unit/test_ui_export_contract.py b/tests/unit/test_ui_export_contract.py new file mode 100644 index 000000000..866c87ae1 --- /dev/null +++ b/tests/unit/test_ui_export_contract.py @@ -0,0 +1,216 @@ +"""UI export contract regression tests. + +These tests validate the CSV folder/file/header contract that CityLearn UI +consumes in RecDashboard/KPIs parsing logic. +""" + +from __future__ import annotations + +import csv +from datetime import datetime +from pathlib import Path +import re + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") + +from citylearn.citylearn import CityLearnEnv + + +SCHEMA = Path(__file__).resolve().parents[2] / "data/datasets/citylearn_challenge_2022_phase_all_plus_evs/schema.json" + + +def _control_action(env: CityLearnEnv) -> np.ndarray: + """Deterministic action vector with EV/BESS activity when available.""" + + names = env.action_names[0] + action = np.zeros(env.action_space[0].shape[0], dtype="float32") + + ev_indices = [i for i, name in enumerate(names) if name.startswith("electric_vehicle_storage_")] + battery_indices = [i for i, name in enumerate(names) if "electrical_storage" in name] + + if ev_indices: + action[ev_indices] = 0.7 + + if battery_indices: + action[battery_indices] = 0.4 + + return action + + +def _assert_required_columns(path: Path, required: set[str]): + with path.open(newline="") as handle: + reader = csv.DictReader(handle) + header = set(reader.fieldnames or []) + rows = list(reader) + + missing = sorted(required - header) + assert not missing, f"{path.name} is missing required UI columns: {missing}" + assert rows, f"{path.name} must contain at least one data row." + + for idx, row in enumerate(rows[:2]): + timestamp = row.get("timestamp") + assert timestamp, f"{path.name} row {idx} has empty timestamp." + datetime.fromisoformat(timestamp) + + +def test_ui_export_layout_files_and_headers_contract(tmp_path): + """Validate that export output is directly ingestible by CityLearn UI.""" + + simulation_data_root = tmp_path / "SimulationData" + simulation_name = "Simulation_Contract" + + env = CityLearnEnv( + str(SCHEMA), + central_agent=True, + episode_time_steps=48, + render_mode="end", + render_directory=simulation_data_root, + render_session_name=simulation_name, + random_seed=0, + ) + + try: + env.reset() + + while not env.terminated: + _, _, terminated, truncated, _ = env.step([_control_action(env)]) + if terminated or truncated: + break + + outputs_path = Path(env.new_folder_path) + assert outputs_path.is_dir() + assert outputs_path.parent == simulation_data_root + assert outputs_path.name == simulation_name + + data_files = sorted(outputs_path.glob("exported_data_*_ep0.csv")) + assert data_files, "Expected exported_data_*_ep0.csv files for UI dashboard import." + + categories = { + "community": [], + "building": [], + "battery": [], + "charger": [], + "ev": [], + "pricing": [], + } + + for path in data_files: + stem = path.stem + assert stem.startswith("exported_data_"), f"Unexpected data file name: {path.name}" + assert re.search(r"_ep\d+$", stem), f"Missing episode suffix in: {path.name}" + + cleaned = stem.replace("exported_data_", "").lower() + + if cleaned.startswith("community_"): + categories["community"].append(path) + elif re.match(r"^building_\d+_charger_\d+_\d+_ep\d+$", cleaned): + categories["charger"].append(path) + elif re.match(r"^building_\d+_battery_ep\d+$", cleaned): + categories["battery"].append(path) + elif re.match(r"^building_\d+_ep\d+$", cleaned): + categories["building"].append(path) + elif cleaned.startswith("electric_vehicle_"): + categories["ev"].append(path) + elif cleaned.startswith("pricing_"): + categories["pricing"].append(path) + + assert categories["community"], "UI contract requires community CSV." + assert categories["building"], "UI contract requires at least one building CSV." + assert categories["battery"], "UI contract requires at least one battery CSV." + assert categories["pricing"], "UI contract requires pricing CSV." + + if any((building.electric_vehicle_chargers or []) for building in env.buildings): + assert categories["charger"], "UI contract requires charger CSVs when chargers exist." + + if env.electric_vehicles: + assert categories["ev"], "UI contract requires EV CSVs when EVs exist." + + _assert_required_columns( + categories["community"][0], + { + "timestamp", + "Net Electricity Consumption-kWh", + "Self Consumption-kWh", + "Total Solar Generation-kWh", + }, + ) + _assert_required_columns( + categories["building"][0], + { + "timestamp", + "Net Electricity Consumption-kWh", + "Non-shiftable Load-kWh", + "Energy Production from PV-kWh", + }, + ) + _assert_required_columns( + categories["battery"][0], + { + "timestamp", + "Battery Soc-%", + "Battery (Dis)Charge-kWh", + }, + ) + _assert_required_columns( + categories["pricing"][0], + { + "timestamp", + "electricity_pricing-$/kWh", + "electricity_pricing_predicted_1-$/kWh", + "electricity_pricing_predicted_2-$/kWh", + "electricity_pricing_predicted_3-$/kWh", + }, + ) + + if categories["charger"]: + _assert_required_columns( + categories["charger"][0], + { + "timestamp", + "Charger Consumption-kWh", + "Charger Production-kWh", + "EV Required SOC Departure-%", + "EV Estimated SOC Arrival-%", + "EV Arrival Time", + "EV Departure Time", + "EV Name", + }, + ) + + if categories["ev"]: + _assert_required_columns( + categories["ev"][0], + { + "timestamp", + "name", + "Battery capacity", + "electric_vehicle_soc", + }, + ) + + kpis_path = outputs_path / "exported_kpis.csv" + assert kpis_path.is_file(), "UI KPI page expects exported_kpis.csv." + + with kpis_path.open(newline="") as handle: + reader = csv.reader(handle) + header = next(reader) + assert header and header[0] == "KPI" + + with kpis_path.open(newline="") as handle: + rows = list(csv.DictReader(handle)) + kpi_names = {row["KPI"] for row in rows} + + # Keep KPI CSV schema stable even when some metrics are undefined (all-NaN before CSV fill). + for expected_kpi in { + "equity_gini_benefit", + "equity_cr20_benefit", + "equity_bpr_asset_poor_over_rich", + "equity_losers_percent", + "equity_relative_benefit_percent", + }: + assert expected_kpi in kpi_names, f"Missing KPI row in export contract: {expected_kpi}" + finally: + env.close()