Skip to content

Commit ef9fead

Browse files
authored
Add hotstart creation and resume (#194)
- [x] Create a hotstart archive from a running simulation - [x] Store metadata as JSON - [x] Store raster domain state as `.npz` - [x] Store SWMM hotstart state when drainage is enabled - [x] Restore a simulation from a hotstart archive - [x] Validate archive structure and metadata - [x] Validate raster/SWMM payload integrity with hashes - [x] Validate domain congruence on reload - [x] Validate mask congruence on reload - [x] Validate drainage presence/absence compatibility - [x] Restore scheduler/runtime state (`sim_time`, `dt`, `next_ts`, counters, continuity state) - [x] Restore raster domain state - [x] Restore drainage coupling state needed after SWMM hotstart reload - [x] Handle SWMM elapsed time / resumed start datetime for resumed runs - [x] Add unit tests for writer/loader validation - [x] Add integration tests for core hotstart round-trip - [x] Add drainage/EA8b hotstart integration tests
1 parent 83556de commit ef9fead

22 files changed

+3387
-592
lines changed

AGENTS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Itzi flood model
2+
3+
## Common commands
4+
- Run a single test: `uv run pytest tests/my_test.py`
5+
- The GRASS-based tests need `--forked` so each tests run in a separate process: `uv run pytest --forked tests/grass`.
6+
- It is not necessary to run core tests with `--forked`. Additionally, `--forked` prevents the display of print statements and other outputs.
7+
- Enforce code formatting: `uvx ruff format .`
8+
9+
## Code style
10+
- Use python type hints. When a function that does not yet use hints is substantially edited, take the opportunity to add type hints.
11+
- Since the arguments types and return types are already documented by the hints, there's no need to duplicate this information in the docstrings.
12+
- Apart from particular cases, use pydantic BaseModel instead of dataclass
13+
- Place imports at the top of the file. Only break this rule to prevent heavy imports in a rarely used function (for example, CLI options).
14+
15+
## General comments
16+
- The project uses `uv`. To run a command in the correct environment, use `uv run`
17+
- Running the whole test suite is slow. Do it only after all the specific tests are passing, as a final check.

src/itzi/data_containers.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Copyright (C) 2025 Laurent Courty
2+
Copyright (C) 2025-2026 Laurent Courty
33
44
This program is free software; you can redistribute it and/or
55
modify it under the terms of the GNU General Public License
@@ -22,6 +22,7 @@
2222
from pydantic import BaseModel, ConfigDict
2323

2424
from itzi.const import DefaultValues, TemporalType, InfiltrationModelType
25+
from itzi.providers.domain_data import DomainData
2526

2627
if TYPE_CHECKING:
2728
from itzi.drainage import DrainageNode
@@ -216,6 +217,52 @@ class SimulationConfig(BaseModel):
216217
free_weir_coeff: float = DefaultValues.FREE_WEIR_COEFF
217218
submerged_weir_coeff: float = DefaultValues.SUBMERGED_WEIR_COEFF
218219

220+
def as_str_dict(self) -> Dict:
221+
"""Convert the configuration to a dictionary with string representations."""
222+
raw_dict = self.model_dump()
223+
raw_dict["start_time"] = self.start_time.isoformat()
224+
raw_dict["end_time"] = self.end_time.isoformat()
225+
raw_dict["record_step"] = self.record_step.total_seconds()
226+
return raw_dict
227+
228+
229+
class HotstartSimulationState(BaseModel):
230+
"""Runtime state to be restored from a hotstart file."""
231+
232+
model_config = ConfigDict(frozen=True)
233+
234+
sim_time: str # ISO format datetime
235+
dt: float # seconds
236+
next_ts: Dict[str, str] # ISO format datetimes
237+
time_steps_counters: Dict[str, int]
238+
accum_update_time: Dict[str, str] # ISO format datetimes
239+
old_domain_volume: float
240+
# Hashes are computed by HotstartWriter and injected before serialization;
241+
# callers building the state before archive creation leave them as empty defaults.
242+
raster_domain_hash: str = ""
243+
swmm_hotstart_hash: str | None = None
244+
# SWMM elapsed time in seconds at the hotstart point.
245+
# Required to correctly initialise DrainageSimulation.elapsed_time so that
246+
# the first swmm_step() after hotstart restoration computes the correct _dt.
247+
swmm_elapsed_time: float | None = None
248+
249+
250+
class HotstartMetadata(BaseModel):
251+
"""Metadata schema for hotstart archive files.
252+
253+
Provides a single source of truth for hotstart metadata structure,
254+
enabling validation during both creation and loading.
255+
"""
256+
257+
model_config = ConfigDict(frozen=True)
258+
259+
creation_date: datetime
260+
itzi_version: str
261+
hotstart_version: int
262+
domain_data: DomainData
263+
simulation_config: SimulationConfig
264+
simulation_state: HotstartSimulationState
265+
219266

220267
class GrassParams(BaseModel):
221268
"""Parameters for GRASS GIS session."""

src/itzi/drainage.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Copyright (C) 2016-2025 Laurent Courty
2+
Copyright (C) 2016-2026 Laurent Courty
33
44
This program is free software; you can redistribute it and/or
55
modify it under the terms of the GNU General Public License
@@ -12,9 +12,14 @@
1212
GNU General Public License for more details.
1313
"""
1414

15+
from __future__ import annotations
16+
17+
from typing import TYPE_CHECKING
1518
import math
1619
from datetime import timedelta
1720
from enum import StrEnum
21+
from io import BytesIO
22+
import tempfile
1823

1924
import pyswmm
2025
import numpy as np
@@ -29,6 +34,10 @@
2934
DrainageNodeAttributes,
3035
)
3136

37+
if TYPE_CHECKING:
38+
from datetime import datetime
39+
from pyswmm.swmm5 import PySWMM
40+
3241

3342
class CouplingTypes(StrEnum):
3443
NOT_COUPLED = "not coupled"
@@ -41,27 +50,72 @@ class CouplingTypes(StrEnum):
4150
class DrainageSimulation:
4251
"""manage simulation of the pipe network"""
4352

44-
def __init__(self, pyswmm_sim, nodes_list, links_list):
53+
def __init__(
54+
self,
55+
pyswmm_sim: pyswmm.Simulation,
56+
nodes_list: list[DrainageNode],
57+
links_list: list[DrainageLink],
58+
hotstart_filename: str | None = None,
59+
hotstart_start_datetime: datetime | None = None,
60+
):
61+
"""Initialize the drainage simulation.
62+
63+
Args:
64+
pyswmm_sim: A pyswmm Simulation object.
65+
nodes_list: List of DrainageNode objects.
66+
links_list: List of DrainageLink objects.
67+
hotstart_filename: Path to SWMM hotstart file (.hsf) to restore state from.
68+
hotstart_start_datetime: datetime to set as SWMM's start time after hotstart
69+
restore. This is used to ensure SWMM reads timeseries from the correct
70+
point after resuming from a hotstart.
71+
"""
4572
# A list of DrainageNode object
4673
self.nodes = nodes_list
4774
# A list of DrainageLink objects
4875
self.links = links_list
4976
# create swmm object, open files and start simulation
5077
self.swmm_sim = pyswmm_sim
51-
self.swmm_model = self.swmm_sim._model
78+
self.swmm_model: PySWMM = self.swmm_sim._model
5279
# Check if the unit is m3/s
5380
if self.swmm_sim.flow_units != "CMS":
5481
msgr.fatal("SWMM simulation unit must be CMS")
82+
# Start model
83+
if hotstart_filename:
84+
self.swmm_model.swmm_use_hotstart(hotstart_filename)
85+
if hotstart_start_datetime is not None:
86+
self.swmm_model.setSimulationDateTime(
87+
pyswmm.toolkitapi.SimulationTime.StartDateTime, hotstart_start_datetime
88+
)
5589
self.swmm_model.swmm_start()
5690
# allow ponding
5791
# TODO: check if allowing ponding is necessary
5892
self._dt = 0.0
5993
self.elapsed_time = 0.0
94+
self._closed = False
6095

6196
def __del__(self):
6297
"""Make sure the swmm simulation is ended and closed properly."""
63-
self.swmm_model.swmm_report()
64-
self.swmm_model.swmm_close()
98+
self.close()
99+
100+
def close(self) -> None:
101+
if self._closed:
102+
return
103+
try:
104+
self.swmm_model.swmm_end()
105+
except Exception:
106+
pass
107+
try:
108+
self.swmm_model.swmm_report()
109+
except Exception:
110+
pass
111+
try:
112+
self.swmm_sim.close()
113+
except Exception:
114+
try:
115+
self.swmm_model.swmm_close()
116+
except Exception:
117+
pass
118+
self._closed = True
65119

66120
@property
67121
def dt(self):
@@ -106,6 +160,19 @@ def get_drainage_network_data(self) -> DrainageNetworkData:
106160
links_data.append(link.get_data())
107161
return DrainageNetworkData(nodes=tuple(nodes_data), links=tuple(links_data))
108162

163+
def get_hotstart(self) -> BytesIO:
164+
"""Save a temp SWMM hotstart, return a binary object."""
165+
with tempfile.NamedTemporaryFile(suffix=".hsf", delete_on_close=False) as tmp:
166+
temp_hotstart = tmp.name
167+
# Close the file handle so SWMM can open it exclusively
168+
tmp.close()
169+
self.swmm_model.swmm_save_hotstart(temp_hotstart)
170+
with open(temp_hotstart, "rb") as f:
171+
buffer = BytesIO(f.read())
172+
buffer.seek(0)
173+
return buffer
174+
# File is automatically deleted on context manager exit, even on exception
175+
109176

110177
class DrainageNode(object):
111178
"""A wrapper around the pyswmm node object.

0 commit comments

Comments
 (0)