Skip to content

Commit b8bad31

Browse files
committed
Applied changes to simplify exports and tested for SAC runs
1 parent b0e8b9d commit b8bad31

File tree

6 files changed

+137
-72
lines changed

6 files changed

+137
-72
lines changed

citylearn/agents/rbc.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,26 @@ def predict(self, observations: List[List[float]], deterministic: bool = None) -
100100

101101
else:
102102
for m, a, n, o in zip(self.action_map, self.action_names, self.observation_names, observations):
103-
hour = o[n.index('hour')]
103+
hour_observation = o[n.index('hour')]
104+
hour = int(round(hour_observation))
105+
# Support both 0-23 and 1-24 hour encodings.
106+
hour_candidates = []
107+
108+
for candidate in (hour, hour % 24, ((hour - 1) % 24) + 1):
109+
if candidate not in hour_candidates:
110+
hour_candidates.append(candidate)
111+
104112
actions_ = []
105113

106114
for a_ in a:
107-
actions_.append(m[a_][hour])
115+
for candidate in hour_candidates:
116+
hour_map = m[a_]
117+
118+
if candidate in hour_map:
119+
actions_.append(hour_map[candidate])
120+
break
121+
else:
122+
raise KeyError(f'Hour {hour_observation} not defined in action map for action {a_}.')
108123

109124
actions.append(actions_)
110125

citylearn/citylearn.py

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,14 @@ def __init__(self,
157157
if requested_render_mode not in {'none', 'during', 'end'}:
158158
raise ValueError("render_mode must be one of {'none', 'during', 'end'}.")
159159
self.render_mode = requested_render_mode
160-
self._buffer_render = self.render_mode == 'end'
161-
self._defer_render_flush = False
162-
self._render_buffer = defaultdict(list)
163-
self._render_start_date = self._parse_render_start_date(start_date if start_date is not None else schema_start_date)
164-
self.previous_month = None
165-
self.current_day = self._render_start_date.day
166-
self.year = self._render_start_date.year
160+
self._buffer_render = self.render_mode == 'end'
161+
self._defer_render_flush = False
162+
self._render_buffer = defaultdict(list)
163+
self._render_start_date = self._parse_render_start_date(start_date if start_date is not None else schema_start_date)
164+
self.previous_month = None
165+
self.current_day = self._render_start_date.day
166+
self.year = self._render_start_date.year
167+
self._final_kpis_exported = False
167168
self.__rewards = None
168169
self.buildings = []
169170
self.random_seed = self.schema.get('random_seed', None) if random_seed is None else random_seed
@@ -1025,8 +1026,11 @@ def step(self, actions: List[List[float]]) -> Tuple[List[List[float]], List[floa
10251026
# Advance to next timestep t+1
10261027
self.next_time_step()
10271028

1028-
# store episode reward summary at the end of episode (upon reaching final timestep)
1029+
# store episode reward summary at the end of episode (upon reaching final timestep)
10291030
if self.terminated:
1031+
if self.render_mode == 'during' and self.render_enabled:
1032+
# Capture the terminal timestep snapshot that occurs after the final transition.
1033+
self.render()
10301034
rewards = np.array(self.__rewards[1:], dtype='float32')
10311035
self.__episode_rewards.append({
10321036
'min': rewards.min(axis=0).tolist(),
@@ -1046,6 +1050,9 @@ def step(self, actions: List[List[float]]) -> Tuple[List[List[float]], List[floa
10461050

10471051
self._flush_render_buffer()
10481052

1053+
if self.render_enabled and not self._final_kpis_exported:
1054+
self.export_final_kpis()
1055+
10491056
return self.observations, reward, self.terminated, self.truncated, self.get_info()
10501057

10511058
def get_info(self) -> Mapping[Any, Any]:
@@ -1467,17 +1474,30 @@ def simulate_unconnected_ev_soc(self):
14671474
new_soc = np.clip(last_soc * variability, 0.0, 1.0)
14681475
ev.battery.force_set_soc(new_soc)
14691476

1470-
def export_final_kpis(self, model: 'citylearn.agents.base.Agent', filepath="exported_kpis.csv"):
1471-
# Ensure output directory exists even if rendering was disabled
1472-
self._ensure_render_output_dir()
1473-
file_path = os.path.join(self.new_folder_path, filepath)
1474-
kpis = model.env.evaluate()
1475-
kpis = kpis.pivot(index='cost_function', columns='name', values='value').round(3)
1476-
kpis = kpis.dropna(how='all')
1477-
kpis = kpis.fillna('')
1478-
kpis = kpis.reset_index()
1479-
kpis = kpis.rename(columns={'cost_function': 'KPI'})
1480-
kpis.to_csv(file_path, index=False, encoding='utf-8')
1477+
def export_final_kpis(self, model: 'citylearn.agents.base.Agent' = None, filepath: str = "exported_kpis.csv"):
1478+
"""Export episode KPIs to csv.
1479+
1480+
Parameters
1481+
----------
1482+
model: citylearn.agents.base.Agent, optional
1483+
Agent whose environment should be evaluated. Defaults to the current environment.
1484+
filepath: str, default: ``"exported_kpis.csv"``
1485+
Output filename placed inside :pyattr:`new_folder_path`.
1486+
"""
1487+
# Ensure output directory exists even if rendering was disabled
1488+
self._ensure_render_output_dir()
1489+
file_path = os.path.join(self.new_folder_path, filepath)
1490+
if model is not None and getattr(model, 'env', None) is not None:
1491+
kpis = model.env.evaluate()
1492+
else:
1493+
kpis = self.evaluate()
1494+
kpis = kpis.pivot(index='cost_function', columns='name', values='value').round(3)
1495+
kpis = kpis.dropna(how='all')
1496+
kpis = kpis.fillna('')
1497+
kpis = kpis.reset_index()
1498+
kpis = kpis.rename(columns={'cost_function': 'KPI'})
1499+
kpis.to_csv(file_path, index=False, encoding='utf-8')
1500+
self._final_kpis_exported = True
14811501

14821502
def render(self):
14831503
"""
@@ -1719,24 +1739,25 @@ def _get_series_value(series, index, default):
17191739
next_hour = _get_series_value(hour_series, next_index, hour)
17201740
next_minutes = _get_series_value(minutes_series, next_index, minutes)
17211741

1742+
raw_hour = hour
17221743
timestamp_year = self.year
17231744
timestamp_month = month
17241745
timestamp_day = self.current_day
1725-
hour_for_timestamp = hour % 24
1746+
hour_for_timestamp = raw_hour % 24
1747+
next_hour_mod = next_hour % 24
1748+
next_minutes_clamped = max(0, min(59, next_minutes))
17261749
minute_for_timestamp = max(0, min(59, minutes))
17271750

1728-
if hour >= 24:
1729-
hour_for_timestamp = hour % 24
1730-
1751+
if raw_hour >= 24:
17311752
if next_month != month:
17321753
timestamp_month = next_month
1733-
1754+
17341755
if next_month < month:
17351756
timestamp_year = self.year + 1
17361757
timestamp_day = 1
1737-
17381758
else:
1739-
timestamp_day = self.current_day + (hour // 24)
1759+
# Keep the current day; the day roll-over is handled via next_day logic.
1760+
timestamp_day = self.current_day
17401761

17411762
timestamp = f"{timestamp_year:04d}-{int(timestamp_month):02d}-{timestamp_day:02d}T{hour_for_timestamp:02d}:{minute_for_timestamp:02d}:00"
17421763

@@ -1747,7 +1768,7 @@ def _get_series_value(series, index, default):
17471768
if next_month < month:
17481769
next_year = timestamp_year + 1
17491770
next_day = 1
1750-
elif next_hour <= hour:
1771+
elif next_hour_mod <= hour_for_timestamp and next_minutes_clamped <= minute_for_timestamp:
17511772
next_day = timestamp_day + 1
17521773

17531774
self.year = next_year
@@ -1826,8 +1847,9 @@ def reset(self, seed: int = None, options: Mapping[str, Any] = None) -> Tuple[Li
18261847
Override :meth"`get_info` to get custom key-value pairs in `info`.
18271848
"""
18281849

1829-
# object reset
1830-
super().reset()
1850+
# object reset
1851+
super().reset()
1852+
self._final_kpis_exported = False
18311853

18321854
# update seed
18331855
if seed is not None:
@@ -1929,21 +1951,24 @@ def load_agent(self, agent: Union[str, 'citylearn.agents.base.Agent'] = None, **
19291951
else:
19301952
agent_type = self.schema['agent']['type']
19311953

1932-
if kwargs is not None and len(kwargs) > 0:
1933-
agent_attributes = kwargs
1934-
1935-
elif agent is None:
1936-
agent_attributes = self.schema['agent'].get('attributes', {})
1937-
1938-
else:
1939-
agent_attributes = None
1940-
1941-
agent_module = '.'.join(agent_type.split('.')[0:-1])
1942-
agent_name = agent_type.split('.')[-1]
1943-
agent_constructor = getattr(importlib.import_module(agent_module), agent_name)
1944-
agent = agent_constructor() if agent_attributes is None else agent_constructor(**agent_attributes)
1945-
1946-
return agent
1954+
if kwargs is not None and len(kwargs) > 0:
1955+
agent_attributes = dict(kwargs)
1956+
1957+
elif agent is None:
1958+
agent_attributes = dict(self.schema['agent'].get('attributes', {}))
1959+
1960+
else:
1961+
agent_attributes = {}
1962+
1963+
if 'env' not in agent_attributes:
1964+
agent_attributes['env'] = self
1965+
1966+
agent_module = '.'.join(agent_type.split('.')[0:-1])
1967+
agent_name = agent_type.split('.')[-1]
1968+
agent_constructor = getattr(importlib.import_module(agent_module), agent_name)
1969+
agent = agent_constructor(**agent_attributes)
1970+
1971+
return agent
19471972

19481973
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]:
19491974
"""Return `CityLearnEnv` and `Controller` objects as defined by the `schema`.

docs/source/ui.rst

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ You can check a tutorial at the official CityLearn `website <https://intelligent
1818
Exporting Data From CityLearn into CityLearn UI
1919
===============================================
2020

21-
CityLearn automatically exports the folder structure expected by the UI. There are a few workflows to consider:
21+
CityLearn automatically exports the folder structure expected by the UI. The behaviour depends on :class:`citylearn.citylearn.CityLearnEnv` ``render_mode``:
2222

23-
* ``render_mode='none'`` (default): no CSVs are produced, so the UI cannot ingest data.
24-
* ``render_mode='during'``: data is exported every simulation step into timestamped folders. You may keep the default location (``<project>/SimulationData/<timestamp>``) or set ``render_directory``/``render_directory_name``/:code:`render_session_name` on :class:`citylearn.citylearn.CityLearnEnv` to choose the destination and a custom subfolder.
25-
* ``render_mode='end'`` (buffered): the environment records each step in memory and automatically flushes the complete episode to disk when the episode finishes (or when you call :meth:`citylearn.citylearn.CityLearnEnv.render`). This produces the same per-timestep CSVs as the ``'during'`` mode but defers file I/O until the end.
26-
* ``render_mode='none'`` with explicit export: keep rendering off for faster runs and call :meth:`citylearn.citylearn.CityLearnEnv.export_final_kpis` (or a custom exporter) at the end. This lazily creates the render folder and only writes KPI summaries—suitable for the UI **KPIs page** but not for the time-series dashboards.
23+
* ``render_mode='none'`` (default): no CSVs are produced. Use this for fast headless runs where you do not need UI data, or call :meth:`citylearn.citylearn.CityLearnEnv.export_final_kpis` manually afterwards to obtain the KPI table only.
24+
* ``render_mode='during'``: the environment writes a row for every simulation step directly to disk (``<project>/SimulationData/<timestamp>`` by default). Final KPIs are appended automatically once the episode ends—no extra script code is required.
25+
* ``render_mode='end'``: per-step rows are buffered in memory and flushed to disk when the episode finishes (or whenever :meth:`~citylearn.citylearn.CityLearnEnv.render` is invoked). As with ``'during'``, the KPI export is triggered automatically at the end of the episode.
26+
27+
You can customise the export location with ``render_directory``, ``render_directory_name`` and/or ``render_session_name`` when constructing the environment. When a session name is provided the folder is reused across runs; otherwise a timestamped folder is created.
2728

2829
Per-Step Export Example
2930
-----------------------
@@ -50,7 +51,7 @@ Per-Step Export Example
5051
observations, reward, terminated, truncated, info = env.step(actions)
5152
# CSV rows are appended at each step when render_mode='during'.
5253
53-
The code above writes per-step CSV files into ``outputs/ui_exports/<timestamp>/``. Omitting ``render_directory`` stores the results in ``SimulationData/<timestamp>/`` by default.
54+
The code above writes per-step CSV files into ``outputs/ui_exports/<timestamp>/``. Omitting ``render_directory`` stores the results in ``SimulationData/<timestamp>/`` by default, and the episode summary KPIs are written automatically to ``exported_kpis.csv``.
5455

5556
Export-at-the-End Example
5657
-------------------------
@@ -100,16 +101,7 @@ Buffered End-of-Run Export Example
100101
actions = [env.action_space[0].sample()]
101102
observations, reward, terminated, truncated, info = env.step(actions)
102103
103-
# Episode completion automatically flushes the buffered CSVs.
104-
# Call env.render() mid-run if you need an interim snapshot.
105-
class _Model:
106-
pass
107-
108-
model = _Model()
109-
model.env = env
110-
env.export_final_kpis(model)
111-
112-
With ``render_mode='end'`` the per-step histories accumulate in memory while the episode runs. At episode completion the environment writes the full SimulationData folder (all timesteps plus components); you may still call :meth:`~citylearn.citylearn.CityLearnEnv.render` manually if you want to force a flush earlier than that.
104+
With ``render_mode='end'`` the per-step histories accumulate in memory while the episode runs. At episode completion the environment writes the full SimulationData folder (all timesteps plus components) and the KPI summary; you may still call :meth:`~citylearn.citylearn.CityLearnEnv.render` manually if you want to force a flush earlier than that.
113105

114106
The UI consumes the directory produced by the ``'during'`` and ``'end'`` approaches. The system uses the :meth:`~citylearn.citylearn.CityLearnEnv.render` method to iterate over buildings, electric vehicles, batteries, chargers, pricing, etc., using their ``as_dict`` outputs to build CSV histories where each row corresponds to a time instant and columns include units. Timestamps are converted to calendar dates for display. You can disable step-wise exporting by keeping ``render_mode='none'`` and relying on the end-of-run exporter, but the resulting folder will only serve the KPI comparison page.
115107

tests/scripts/run_ev_rbc_export_end.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ def main() -> None:
4343
if terminated or truncated:
4444
break
4545

46-
print("Exporting simulation data. This may take a moment...")
47-
env.export_final_kpis(controller)
48-
4946
outputs_path = Path(env.new_folder_path)
5047
print(f"Exports written to: {outputs_path}")
5148
finally:

tests/scripts/run_ev_rbc_export_mid.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,14 @@ def main() -> None:
3232
try:
3333
controller = Agent(env)
3434
observations, _ = env.reset()
35-
mid_step = env.episode_tracker.episode_time_steps // 2
3635

37-
for step in range(env.episode_tracker.episode_time_steps):
36+
for _ in range(env.episode_tracker.episode_time_steps):
3837
actions = controller.predict(observations, deterministic=True)
3938
observations, _, terminated, truncated, _ = env.step(actions)
4039

41-
if step == mid_step:
42-
env.render()
43-
4440
if terminated or truncated:
4541
break
4642

47-
env.export_final_kpis(controller)
48-
4943
outputs_path = Path(env.new_folder_path)
5044
print(f"Exports written to: {outputs_path}")
5145
finally:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
"""Run a short SAC training episode and verify automatic exports."""
3+
4+
from __future__ import annotations
5+
6+
import logging
7+
import sys
8+
from pathlib import Path
9+
10+
ROOT = Path(__file__).resolve().parents[2]
11+
if str(ROOT) not in sys.path:
12+
sys.path.insert(0, str(ROOT))
13+
14+
from citylearn.citylearn import CityLearnEnv # noqa: E402
15+
16+
SCHEMA = "baeda_3dem"
17+
18+
19+
def main() -> None:
20+
logging.getLogger().setLevel(logging.WARNING)
21+
22+
render_root = ROOT / "SimulationData"
23+
env = CityLearnEnv(
24+
SCHEMA,
25+
episode_time_steps=96,
26+
render_mode="end",
27+
render_directory=render_root,
28+
render_session_name="sac_export_example",
29+
random_seed=0,
30+
)
31+
32+
try:
33+
agent = env.load_agent()
34+
agent.learn(episodes=1, deterministic_finish=False, logging_level=logging.INFO)
35+
outputs_path = Path(env.new_folder_path)
36+
print(f"Exports written to: {outputs_path}")
37+
finally:
38+
env.close()
39+
40+
41+
if __name__ == "__main__":
42+
main()

0 commit comments

Comments
 (0)