From bbd0f7755de160c310689059e85d656a9fc86ca6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 4 Mar 2026 09:18:20 +0100 Subject: [PATCH 01/15] Deprecate RFNetwork in favour of pathsim-rf package --- src/pathsim/blocks/rf.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pathsim/blocks/rf.py b/src/pathsim/blocks/rf.py index 51ac9e00..4a82c5db 100644 --- a/src/pathsim/blocks/rf.py +++ b/src/pathsim/blocks/rf.py @@ -8,13 +8,8 @@ ## ######################################################################################### -# TODO LIST -# class RFAmplifier Model amplifier in RF systems -# class Resistor/Capacitor/Inductor -# class RFMixer for mixer in RF systems? - - # IMPORTS =============================================================================== + from __future__ import annotations import numpy as np @@ -38,10 +33,17 @@ from .lti import StateSpace +from ..utils.deprecation import deprecated + # BLOCK DEFINITIONS ===================================================================== +@deprecated( + version="1.0.0", + replacement="pathsim_rf.RFNetwork", + reason="This block has moved to the pathsim-rf package: pip install pathsim-rf", +) class RFNetwork(StateSpace): """N-port RF network linear time invariant (LTI) multi input multi output (MIMO) state-space model. @@ -78,7 +80,7 @@ def __init__(self, ntwk: NetworkType | str | Path, auto_fit: bool = True, **kwar _msg = "The scikit-rf package is required to use this block -> 'pip install scikit-rf'" raise ImportError(_msg) - if isinstance(ntwk, Path) or isinstance(ntwk, str): + if isinstance(ntwk, (Path, str)): ntwk = rf.Network(ntwk) # Select the vector fitting function from scikit-rf From 0d52b5421d1edc964fec2912b09894832a48808a Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 15 Mar 2026 20:59:57 +0100 Subject: [PATCH 02/15] Add checkpoint save/load system with JSON+NPZ format --- src/pathsim/blocks/_block.py | 91 ++++++++++ src/pathsim/blocks/delay.py | 35 ++++ src/pathsim/blocks/scope.py | 42 ++++- src/pathsim/blocks/spectrum.py | 18 ++ src/pathsim/blocks/switch.py | 9 + src/pathsim/events/_event.py | 63 ++++++- src/pathsim/simulation.py | 134 +++++++++++++++ src/pathsim/solvers/_solver.py | 64 +++++++ src/pathsim/solvers/gear.py | 44 +++++ src/pathsim/utils/adaptivebuffer.py | 45 ++++- tests/pathsim/test_checkpoint.py | 256 ++++++++++++++++++++++++++++ 11 files changed, 796 insertions(+), 5 deletions(-) create mode 100644 tests/pathsim/test_checkpoint.py diff --git a/src/pathsim/blocks/_block.py b/src/pathsim/blocks/_block.py index 4b4275fb..597195a4 100644 --- a/src/pathsim/blocks/_block.py +++ b/src/pathsim/blocks/_block.py @@ -11,6 +11,7 @@ # IMPORTS =============================================================================== import inspect +from uuid import uuid4 from functools import lru_cache from ..utils.deprecation import deprecated @@ -84,6 +85,9 @@ class definition for other blocks to be inherited. def __init__(self): + #unique identifier for checkpointing and diagnostics + self.id = uuid4().hex + #registers to hold input and output values self.inputs = Register( mapping=self.input_port_labels and self.input_port_labels.copy() @@ -524,6 +528,93 @@ def state(self, val): self.engine.state = val + # checkpoint methods ---------------------------------------------------------------- + + def to_checkpoint(self, recordings=False): + """Serialize block state for checkpointing. + + Parameters + ---------- + recordings : bool + include recording data (for Scope blocks) + + Returns + ------- + json_data : dict + JSON-serializable metadata + npz_data : dict + numpy arrays keyed by path + """ + prefix = self.id + + json_data = { + "id": self.id, + "type": self.__class__.__name__, + "active": self._active, + } + + npz_data = { + f"{prefix}/inputs": self.inputs.to_array(), + f"{prefix}/outputs": self.outputs.to_array(), + } + + #solver state + if self.engine: + e_json, e_npz = self.engine.to_checkpoint(f"{prefix}/engine") + json_data["engine"] = e_json + npz_data.update(e_npz) + + #internal events + if self.events: + evt_jsons = [] + for event in self.events: + e_json, e_npz = event.to_checkpoint() + evt_jsons.append(e_json) + npz_data.update(e_npz) + json_data["events"] = evt_jsons + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz): + """Restore block state from checkpoint. + + Parameters + ---------- + json_data : dict + block metadata from checkpoint JSON + npz : dict-like + numpy arrays from checkpoint NPZ + """ + prefix = json_data["id"] + + #verify type + if json_data["type"] != self.__class__.__name__: + raise ValueError( + f"Checkpoint type mismatch: expected '{self.__class__.__name__}', " + f"got '{json_data['type']}'" + ) + + self._active = json_data["active"] + + #restore registers + inp_key = f"{prefix}/inputs" + out_key = f"{prefix}/outputs" + if inp_key in npz: + self.inputs.update_from_array(npz[inp_key]) + if out_key in npz: + self.outputs.update_from_array(npz[out_key]) + + #restore solver state + if self.engine and "engine" in json_data: + self.engine.load_checkpoint(json_data["engine"], npz, f"{prefix}/engine") + + #restore internal events + if self.events and "events" in json_data: + for event, evt_data in zip(self.events, json_data["events"]): + event.load_checkpoint(evt_data, npz) + + # methods for block output and state updates ---------------------------------------- def update(self, t): diff --git a/src/pathsim/blocks/delay.py b/src/pathsim/blocks/delay.py index 6bcbed8b..6b42614c 100644 --- a/src/pathsim/blocks/delay.py +++ b/src/pathsim/blocks/delay.py @@ -142,6 +142,41 @@ def reset(self): self._ring.extend([0.0] * self._n) + def to_checkpoint(self, recordings=False): + """Serialize Delay state including buffer data.""" + json_data, npz_data = super().to_checkpoint(recordings=recordings) + prefix = self.id + + json_data["sampling_period"] = self.sampling_period + + if self.sampling_period is None: + #continuous mode: adaptive buffer + npz_data.update(self._buffer.to_checkpoint(f"{prefix}/buffer")) + else: + #discrete mode: ring buffer + npz_data[f"{prefix}/ring"] = np.array(list(self._ring)) + json_data["_sample_next_timestep"] = self._sample_next_timestep + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz): + """Restore Delay state including buffer data.""" + super().load_checkpoint(json_data, npz) + prefix = json_data["id"] + + if self.sampling_period is None: + #continuous mode + self._buffer.load_checkpoint(npz, f"{prefix}/buffer") + else: + #discrete mode + ring_key = f"{prefix}/ring" + if ring_key in npz: + self._ring.clear() + self._ring.extend(npz[ring_key].tolist()) + self._sample_next_timestep = json_data.get("_sample_next_timestep", False) + + def update(self, t): """Evaluation of the buffer at different times via interpolation (continuous) or ring buffer lookup (discrete). diff --git a/src/pathsim/blocks/scope.py b/src/pathsim/blocks/scope.py index 4997f772..57854526 100644 --- a/src/pathsim/blocks/scope.py +++ b/src/pathsim/blocks/scope.py @@ -448,13 +448,49 @@ def save(self, path="scope.csv"): wrt.writerow(sample) + def to_checkpoint(self, recordings=False): + """Serialize Scope state including optional recording data.""" + json_data, npz_data = super().to_checkpoint(recordings=recordings) + prefix = self.id + + json_data["_incremental_idx"] = self._incremental_idx + if hasattr(self, '_sample_next_timestep'): + json_data["_sample_next_timestep"] = self._sample_next_timestep + + if recordings and self.recording_time: + npz_data[f"{prefix}/recording_time"] = np.array(self.recording_time) + npz_data[f"{prefix}/recording_data"] = np.array(self.recording_data) + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz): + """Restore Scope state including optional recording data.""" + super().load_checkpoint(json_data, npz) + prefix = json_data["id"] + + self._incremental_idx = json_data.get("_incremental_idx", 0) + if hasattr(self, '_sample_next_timestep'): + self._sample_next_timestep = json_data.get("_sample_next_timestep", False) + + #restore recordings if present + rt_key = f"{prefix}/recording_time" + rd_key = f"{prefix}/recording_data" + if rt_key in npz and rd_key in npz: + self.recording_time = npz[rt_key].tolist() + self.recording_data = [row for row in npz[rd_key]] + else: + self.recording_time = [] + self.recording_data = [] + + def update(self, t): - """update system equation for fixed point loop, + """update system equation for fixed point loop, here just setting the outputs - + Note ---- - Scope has no passthrough, so the 'update' method + Scope has no passthrough, so the 'update' method is optimized for this case (does nothing) Parameters diff --git a/src/pathsim/blocks/spectrum.py b/src/pathsim/blocks/spectrum.py index 7b3a0878..b4d37fed 100644 --- a/src/pathsim/blocks/spectrum.py +++ b/src/pathsim/blocks/spectrum.py @@ -283,6 +283,24 @@ def step(self, t, dt): return True, 0.0, None + def to_checkpoint(self, recordings=False): + """Serialize Spectrum state including integration time.""" + json_data, npz_data = super().to_checkpoint(recordings=recordings) + + json_data["time"] = self.time + json_data["t_sample"] = self.t_sample + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz): + """Restore Spectrum state including integration time.""" + super().load_checkpoint(json_data, npz) + + self.time = json_data.get("time", 0.0) + self.t_sample = json_data.get("t_sample", 0.0) + + def sample(self, t, dt): """sample time of successfull timestep for waiting period diff --git a/src/pathsim/blocks/switch.py b/src/pathsim/blocks/switch.py index dc60d887..3ce04b07 100644 --- a/src/pathsim/blocks/switch.py +++ b/src/pathsim/blocks/switch.py @@ -82,6 +82,15 @@ def select(self, switch_state=0): self.switch_state = switch_state + def to_checkpoint(self, recordings=False): + json_data, npz_data = super().to_checkpoint(recordings=recordings) + json_data["switch_state"] = self.switch_state + return json_data, npz_data + + def load_checkpoint(self, json_data, npz): + super().load_checkpoint(json_data, npz) + self.switch_state = json_data.get("switch_state", None) + def update(self, t): """Update switch output depending on inputs and switch state. diff --git a/src/pathsim/events/_event.py b/src/pathsim/events/_event.py index fd911a5b..124c99d1 100644 --- a/src/pathsim/events/_event.py +++ b/src/pathsim/events/_event.py @@ -11,6 +11,8 @@ import numpy as np +from uuid import uuid4 + from .. _constants import EVT_TOLERANCE @@ -64,6 +66,9 @@ def __init__( tolerance=EVT_TOLERANCE ): + #unique identifier for checkpointing and diagnostics + self.id = uuid4().hex + #event detection function self.func_evt = func_evt @@ -201,4 +206,60 @@ def resolve(self, t): #action function for event resolution if self.func_act is not None: - self.func_act(t) \ No newline at end of file + self.func_act(t) + + + # checkpoint methods ---------------------------------------------------------------- + + def to_checkpoint(self): + """Serialize event state for checkpointing. + + Returns + ------- + json_data : dict + JSON-serializable metadata + npz_data : dict + numpy arrays keyed by path + """ + prefix = self.id + + #extract history eval value + hist_eval, hist_time = self._history + if hist_eval is not None and hasattr(hist_eval, 'item'): + hist_eval = float(hist_eval) + + json_data = { + "id": self.id, + "type": self.__class__.__name__, + "active": self._active, + "history_eval": hist_eval, + "history_time": hist_time, + } + + npz_data = {} + if self._times: + npz_data[f"{prefix}/times"] = np.array(self._times) + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz): + """Restore event state from checkpoint. + + Parameters + ---------- + json_data : dict + event metadata from checkpoint JSON + npz : dict-like + numpy arrays from checkpoint NPZ + """ + prefix = json_data["id"] + + self._active = json_data["active"] + self._history = json_data["history_eval"], json_data["history_time"] + + times_key = f"{prefix}/times" + if times_key in npz: + self._times = npz[times_key].tolist() + else: + self._times = [] \ No newline at end of file diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index 306c7e4c..ed6edbfb 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -331,6 +331,140 @@ def plot(self, *args, **kwargs): if block: block.plot(*args, **kwargs) + # checkpoint methods ---------------------------------------------------------- + + def save_checkpoint(self, path, recordings=False): + """Save simulation state to checkpoint files (JSON + NPZ). + + Creates two files: {path}.json (structure/metadata) and + {path}.npz (numerical data). + + Parameters + ---------- + path : str + base path without extension + recordings : bool + include scope/spectrum recording data (default: False) + """ + import json + + #strip extension if provided + if path.endswith('.json') or path.endswith('.npz'): + path = path.rsplit('.', 1)[0] + + #simulation metadata + checkpoint = { + "version": "1.0.0", + "pathsim_version": __version__, + "created": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "simulation": { + "time": self.time, + "dt": self.dt, + "dt_min": self.dt_min, + "dt_max": self.dt_max, + "solver": self.Solver.__name__, + "tolerance_fpi": self.tolerance_fpi, + "iterations_max": self.iterations_max, + }, + "blocks": {}, + "events": {}, + } + + npz_data = {} + + #checkpoint all blocks (keyed by UUID) + for block in self.blocks: + b_json, b_npz = block.to_checkpoint(recordings=recordings) + checkpoint["blocks"][block.id] = b_json + npz_data.update(b_npz) + + #checkpoint external events (keyed by UUID) + for event in self.events: + e_json, e_npz = event.to_checkpoint() + checkpoint["events"][event.id] = e_json + npz_data.update(e_npz) + + #write files + with open(f"{path}.json", "w", encoding="utf-8") as f: + json.dump(checkpoint, f, indent=2, ensure_ascii=False) + + np.savez(f"{path}.npz", **npz_data) + + + def load_checkpoint(self, path): + """Load simulation state from checkpoint files (JSON + NPZ). + + Restores simulation time and all block/event states from a + previously saved checkpoint. The simulation must have the same + blocks and events as when the checkpoint was saved. + + Parameters + ---------- + path : str + base path without extension + """ + import json + import warnings + + #strip extension if provided + if path.endswith('.json') or path.endswith('.npz'): + path = path.rsplit('.', 1)[0] + + #read files + with open(f"{path}.json", "r", encoding="utf-8") as f: + checkpoint = json.load(f) + + npz = np.load(f"{path}.npz", allow_pickle=False) + + try: + #version check + cp_version = checkpoint.get("pathsim_version", "unknown") + if cp_version != __version__: + warnings.warn( + f"Checkpoint was saved with PathSim {cp_version}, " + f"current version is {__version__}" + ) + + #restore simulation state + sim_data = checkpoint["simulation"] + self.time = sim_data["time"] + self.dt = sim_data["dt"] + self.dt_min = sim_data["dt_min"] + self.dt_max = sim_data["dt_max"] + + #solver type check + if sim_data["solver"] != self.Solver.__name__: + warnings.warn( + f"Checkpoint solver '{sim_data['solver']}' differs from " + f"current solver '{self.Solver.__name__}'" + ) + + #restore blocks + block_data = checkpoint.get("blocks", {}) + for block in self.blocks: + if block.id in block_data: + block.load_checkpoint(block_data[block.id], npz) + else: + warnings.warn( + f"Block {block.__class__.__name__} (id={block.id[:8]}...) " + f"not found in checkpoint" + ) + + #restore external events + event_data = checkpoint.get("events", {}) + for event in self.events: + if event.id in event_data: + event.load_checkpoint(event_data[event.id], npz) + else: + warnings.warn( + f"Event {event.__class__.__name__} (id={event.id[:8]}...) " + f"not found in checkpoint" + ) + + finally: + npz.close() + + # adding system components ---------------------------------------------------- def add_block(self, block): diff --git a/src/pathsim/solvers/_solver.py b/src/pathsim/solvers/_solver.py index 9cf00de9..d235856e 100644 --- a/src/pathsim/solvers/_solver.py +++ b/src/pathsim/solvers/_solver.py @@ -353,6 +353,70 @@ def create(cls, initial_value, parent=None, from_engine=None, **solver_kwargs): return cls(initial_value, parent, **solver_kwargs) + # checkpoint methods --------------------------------------------------------------- + + def to_checkpoint(self, prefix): + """Serialize solver state for checkpointing. + + Parameters + ---------- + prefix : str + NPZ key prefix for this solver's arrays + + Returns + ------- + json_data : dict + JSON-serializable metadata + npz_data : dict + numpy arrays keyed by path + """ + json_data = { + "type": self.__class__.__name__, + "is_adaptive": self.is_adaptive, + "n": self.n, + "history_len": len(self.history), + "history_maxlen": self.history.maxlen, + } + + npz_data = { + f"{prefix}/x": np.atleast_1d(self.x), + f"{prefix}/initial_value": np.atleast_1d(self.initial_value), + } + + for i, h in enumerate(self.history): + npz_data[f"{prefix}/history_{i}"] = np.atleast_1d(h) + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz, prefix): + """Restore solver state from checkpoint. + + Parameters + ---------- + json_data : dict + solver metadata from checkpoint JSON + npz : dict-like + numpy arrays from checkpoint NPZ + prefix : str + NPZ key prefix for this solver's arrays + """ + self.x = npz[f"{prefix}/x"].copy() + self.initial_value = npz[f"{prefix}/initial_value"].copy() + + #restore scalar format if needed + if self._scalar_initial and self.initial_value.size == 1: + self.initial_value = self.initial_value.item() + + #restore history + maxlen = json_data.get("history_maxlen", self.history.maxlen) + self.history = deque([], maxlen=maxlen) + for i in range(json_data.get("history_len", 0)): + key = f"{prefix}/history_{i}" + if key in npz: + self.history.append(npz[key].copy()) + + # methods for adaptive timestep solvers -------------------------------------------- def error_controller(self): diff --git a/src/pathsim/solvers/gear.py b/src/pathsim/solvers/gear.py index e8cf269f..6f745371 100644 --- a/src/pathsim/solvers/gear.py +++ b/src/pathsim/solvers/gear.py @@ -210,6 +210,50 @@ def create(cls, initial_value, parent=None, from_engine=None, **solver_kwargs): return cls(initial_value, parent, **solver_kwargs) + def to_checkpoint(self, prefix): + """Serialize GEAR solver state including startup solver and timestep history.""" + json_data, npz_data = super().to_checkpoint(prefix) + + json_data["_needs_startup"] = self._needs_startup + json_data["history_dt_len"] = len(self.history_dt) + + #timestep history + for i, dt in enumerate(self.history_dt): + npz_data[f"{prefix}/history_dt_{i}"] = np.atleast_1d(dt) + + #startup solver state + if self.startup: + s_json, s_npz = self.startup.to_checkpoint(f"{prefix}/startup") + json_data["startup"] = s_json + npz_data.update(s_npz) + + return json_data, npz_data + + + def load_checkpoint(self, json_data, npz, prefix): + """Restore GEAR solver state including startup solver and timestep history.""" + super().load_checkpoint(json_data, npz, prefix) + + self._needs_startup = json_data.get("_needs_startup", True) + + #restore timestep history + self.history_dt.clear() + for i in range(json_data.get("history_dt_len", 0)): + key = f"{prefix}/history_dt_{i}" + if key in npz: + self.history_dt.append(npz[key].item()) + + #restore startup solver + if self.startup and "startup" in json_data: + self.startup.load_checkpoint(json_data["startup"], npz, f"{prefix}/startup") + + #recompute BDF coefficients from restored history + if not self._needs_startup and len(self.history_dt) > 0: + self.F, self.K = {}, {} + for n, _ in enumerate(self.history_dt, 1): + self.F[n], self.K[n] = compute_bdf_coefficients(n, np.array(self.history_dt)) + + def stages(self, t, dt): """Generator that yields the intermediate evaluation time during the timestep 't + ratio * dt'. diff --git a/src/pathsim/utils/adaptivebuffer.py b/src/pathsim/utils/adaptivebuffer.py index b24e2e5a..05dd82fa 100644 --- a/src/pathsim/utils/adaptivebuffer.py +++ b/src/pathsim/utils/adaptivebuffer.py @@ -10,6 +10,8 @@ # IMPORTS ============================================================================== +import numpy as np + from collections import deque from bisect import bisect_left @@ -120,4 +122,45 @@ def get(self, t): def clear(self): """clear the buffer, reset everything""" self.buffer_t.clear() - self.buffer_v.clear() \ No newline at end of file + self.buffer_v.clear() + + + def to_checkpoint(self, prefix): + """Serialize buffer state for checkpointing. + + Parameters + ---------- + prefix : str + NPZ key prefix + + Returns + ------- + npz_data : dict + numpy arrays keyed by path + """ + npz_data = {} + if self.buffer_t: + npz_data[f"{prefix}/buffer_t"] = np.array(list(self.buffer_t)) + npz_data[f"{prefix}/buffer_v"] = np.array(list(self.buffer_v)) + return npz_data + + + def load_checkpoint(self, npz, prefix): + """Restore buffer state from checkpoint. + + Parameters + ---------- + npz : dict-like + numpy arrays from checkpoint NPZ + prefix : str + NPZ key prefix + """ + self.clear() + t_key = f"{prefix}/buffer_t" + v_key = f"{prefix}/buffer_v" + if t_key in npz and v_key in npz: + times = npz[t_key] + values = npz[v_key] + for t, v in zip(times, values): + self.buffer_t.append(float(t)) + self.buffer_v.append(v) \ No newline at end of file diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py new file mode 100644 index 00000000..b0bcc470 --- /dev/null +++ b/tests/pathsim/test_checkpoint.py @@ -0,0 +1,256 @@ +"""Tests for checkpoint save/load functionality.""" + +import os +import json +import tempfile + +import numpy as np +import pytest + +from pathsim import Simulation, Connection +from pathsim.blocks import ( + Source, Integrator, Amplifier, Scope, Constant +) +from pathsim.blocks.delay import Delay +from pathsim.blocks.switch import Switch + + +class TestBlockCheckpoint: + """Test block-level checkpoint methods.""" + + def test_basic_block_to_checkpoint(self): + """Block produces valid checkpoint data.""" + b = Integrator(1.0) + b.inputs[0] = 3.14 + json_data, npz_data = b.to_checkpoint() + + assert json_data["type"] == "Integrator" + assert json_data["id"] == b.id + assert json_data["active"] is True + assert f"{b.id}/inputs" in npz_data + assert f"{b.id}/outputs" in npz_data + + def test_block_has_uuid(self): + """Each block gets a unique UUID.""" + b1 = Integrator() + b2 = Integrator() + assert b1.id != b2.id + assert len(b1.id) == 32 # hex UUID without dashes + + def test_block_checkpoint_roundtrip(self): + """Block state survives save/load cycle.""" + b = Integrator(2.5) + b.inputs[0] = 1.0 + b.outputs[0] = 2.5 + + json_data, npz_data = b.to_checkpoint() + + #reset block + b.reset() + assert b.inputs[0] == 0.0 + + #restore + b.load_checkpoint(json_data, npz_data) + assert np.isclose(b.inputs[0], 1.0) + assert np.isclose(b.outputs[0], 2.5) + + def test_block_type_mismatch_raises(self): + """Loading checkpoint with wrong type raises ValueError.""" + b = Integrator() + json_data, npz_data = b.to_checkpoint() + + b2 = Amplifier(1.0) + with pytest.raises(ValueError, match="type mismatch"): + b2.load_checkpoint(json_data, npz_data) + + +class TestEventCheckpoint: + """Test event-level checkpoint methods.""" + + def test_event_has_uuid(self): + from pathsim.events import ZeroCrossing + e = ZeroCrossing(func_evt=lambda t: t - 1.0) + assert len(e.id) == 32 + + def test_event_checkpoint_roundtrip(self): + from pathsim.events import ZeroCrossing + e = ZeroCrossing(func_evt=lambda t: t - 1.0) + e._history = (0.5, 0.99) + e._times = [1.0, 2.0, 3.0] + e._active = False + + json_data, npz_data = e.to_checkpoint() + + e.reset() + assert e._active is True + assert len(e._times) == 0 + + e.load_checkpoint(json_data, npz_data) + assert e._active is False + assert e._times == [1.0, 2.0, 3.0] + assert e._history == (0.5, 0.99) + + +class TestSwitchCheckpoint: + """Test Switch block checkpoint.""" + + def test_switch_state_preserved(self): + s = Switch(switch_state=2) + json_data, npz_data = s.to_checkpoint() + + s.select(None) + assert s.switch_state is None + + s.load_checkpoint(json_data, npz_data) + assert s.switch_state == 2 + + +class TestSimulationCheckpoint: + """Test simulation-level checkpoint save/load.""" + + def test_save_load_simple(self): + """Simple simulation checkpoint round-trip.""" + src = Source(lambda t: np.sin(2 * np.pi * t)) + integ = Integrator() + scope = Scope() + + sim = Simulation( + blocks=[src, integ, scope], + connections=[ + Connection(src, integ, scope), + ], + dt=0.01 + ) + + #run for 1 second + sim.run(1.0) + time_after_run = sim.time + state_after_run = integ.state.copy() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "checkpoint") + sim.save_checkpoint(path) + + #verify files exist + assert os.path.exists(f"{path}.json") + assert os.path.exists(f"{path}.npz") + + #verify JSON structure + with open(f"{path}.json") as f: + data = json.load(f) + assert data["version"] == "1.0.0" + assert data["simulation"]["time"] == time_after_run + assert integ.id in data["blocks"] + + #reset and reload + sim.time = 0.0 + integ.state = np.array([0.0]) + + sim.load_checkpoint(path) + assert sim.time == time_after_run + assert np.allclose(integ.state, state_after_run) + + def test_continue_after_load(self): + """Simulation continues correctly after checkpoint load.""" + #run continuously for 2 seconds + src1 = Source(lambda t: 1.0) + integ1 = Integrator() + sim1 = Simulation( + blocks=[src1, integ1], + connections=[Connection(src1, integ1)], + dt=0.01 + ) + sim1.run(2.0) + reference_state = integ1.state.copy() + + #run for 1 second, save, load, run 1 more second + src2 = Source(lambda t: 1.0) + integ2 = Integrator() + sim2 = Simulation( + blocks=[src2, integ2], + connections=[Connection(src2, integ2)], + dt=0.01 + ) + sim2.run(1.0) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim2.save_checkpoint(path) + sim2.load_checkpoint(path) + sim2.run(1.0) # run 1 more second (t=1 -> t=2) + + #compare results + assert np.allclose(integ2.state, reference_state, rtol=1e-6) + + def test_scope_recordings(self): + """Scope recordings are saved when recordings=True.""" + src = Source(lambda t: t) + scope = Scope() + sim = Simulation( + blocks=[src, scope], + connections=[Connection(src, scope)], + dt=0.1 + ) + sim.run(1.0) + + with tempfile.TemporaryDirectory() as tmpdir: + #without recordings + path1 = os.path.join(tmpdir, "no_rec") + sim.save_checkpoint(path1, recordings=False) + npz1 = np.load(f"{path1}.npz") + assert f"{scope.id}/recording_time" not in npz1 + npz1.close() + + #with recordings + path2 = os.path.join(tmpdir, "with_rec") + sim.save_checkpoint(path2, recordings=True) + npz2 = np.load(f"{path2}.npz") + assert f"{scope.id}/recording_time" in npz2 + npz2.close() + + def test_delay_continuous_checkpoint(self): + """Continuous delay block preserves buffer.""" + src = Source(lambda t: np.sin(t)) + delay = Delay(tau=0.1) + scope = Scope() + sim = Simulation( + blocks=[src, delay, scope], + connections=[ + Connection(src, delay, scope), + ], + dt=0.01 + ) + sim.run(0.5) + + #capture delay output + delay_output = delay.outputs[0] + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + #reset delay buffer + delay._buffer.clear() + + sim.load_checkpoint(path) + assert np.isclose(delay.outputs[0], delay_output) + + def test_delay_discrete_checkpoint(self): + """Discrete delay block preserves ring buffer.""" + src = Source(lambda t: float(t > 0)) + delay = Delay(tau=0.05, sampling_period=0.01) + sim = Simulation( + blocks=[src, delay], + connections=[Connection(src, delay)], + dt=0.01 + ) + sim.run(0.1) + + ring_before = list(delay._ring) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + delay._ring.clear() + sim.load_checkpoint(path) + assert list(delay._ring) == ring_before From 93c065cdfefa44732a63096c10855f3ab51dfb51 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:13:55 +0100 Subject: [PATCH 03/15] Replace set with ordered list + shadow set for blocks, connections, events --- src/pathsim/simulation.py | 92 +++++++++++++++++++------------- src/pathsim/subsystem.py | 49 +++++++++-------- tests/pathsim/test_simulation.py | 16 +++--- 3 files changed, 91 insertions(+), 66 deletions(-) diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index ed6edbfb..924b4817 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -153,10 +153,10 @@ class Simulation: get attributes and access to intermediate evaluation stages logger : logging.Logger global simulation logger - _blocks_dyn : set[Block] - blocks with internal ´Solver´ instances (stateful) - _blocks_evt : set[Block] - blocks with internal events (discrete time, eventful) + _blocks_dyn : list[Block] + blocks with internal ´Solver´ instances (stateful) + _blocks_evt : list[Block] + blocks with internal events (discrete time, eventful) _active : bool flag for setting the simulation as active, used for interrupts """ @@ -176,10 +176,13 @@ def __init__( **solver_kwargs ): - #system definition - self.blocks = set() - self.connections = set() - self.events = set() + #system definition (ordered lists with shadow sets for O(1) lookup) + self.blocks = [] + self._block_set = set() + self.connections = [] + self._conn_set = set() + self.events = [] + self._event_set = set() #simulation timestep and bounds self.dt = dt @@ -215,10 +218,12 @@ def __init__( self.time = 0.0 #collection of blocks with internal ODE solvers - self._blocks_dyn = set() + self._blocks_dyn = [] + self._blocks_dyn_set = set() #collection of blocks with internal events - self._blocks_evt = set() + self._blocks_evt = [] + self._blocks_evt_set = set() #flag for setting the simulation active self._active = True @@ -269,9 +274,9 @@ def __contains__(self, other): bool """ return ( - other in self.blocks or - other in self.connections or - other in self.events + other in self._block_set or + other in self._conn_set or + other in self._event_set ) @@ -480,7 +485,7 @@ def add_block(self, block): """ #check if block already in block list - if block in self.blocks: + if block in self._block_set: _msg = f"block {block} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) @@ -490,14 +495,17 @@ def add_block(self, block): #add to dynamic list if solver was initialized if block.engine: - self._blocks_dyn.add(block) + self._blocks_dyn.append(block) + self._blocks_dyn_set.add(block) #add to eventful list if internal events if block.events: - self._blocks_evt.add(block) + self._blocks_evt.append(block) + self._blocks_evt_set.add(block) #add block to global blocklist - self.blocks.add(block) + self.blocks.append(block) + self._block_set.add(block) #mark graph for rebuild if self.graph: @@ -517,19 +525,24 @@ def remove_block(self, block): """ #check if block is in block list - if block not in self.blocks: + if block not in self._block_set: _msg = f"block {block} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global blocklist - self.blocks.discard(block) + self.blocks.remove(block) + self._block_set.discard(block) #remove from dynamic list - self._blocks_dyn.discard(block) + if block in self._blocks_dyn_set: + self._blocks_dyn.remove(block) + self._blocks_dyn_set.discard(block) #remove from eventful list - self._blocks_evt.discard(block) + if block in self._blocks_evt_set: + self._blocks_evt.remove(block) + self._blocks_evt_set.discard(block) #mark graph for rebuild if self.graph: @@ -549,13 +562,14 @@ def add_connection(self, connection): """ #check if connection already in connection list - if connection in self.connections: + if connection in self._conn_set: _msg = f"{connection} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) #add connection to global connection list - self.connections.add(connection) + self.connections.append(connection) + self._conn_set.add(connection) #mark graph for rebuild if self.graph: @@ -575,13 +589,14 @@ def remove_connection(self, connection): """ #check if connection is in connection list - if connection not in self.connections: + if connection not in self._conn_set: _msg = f"{connection} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global connection list - self.connections.discard(connection) + self.connections.remove(connection) + self._conn_set.discard(connection) #mark graph for rebuild if self.graph: @@ -600,13 +615,14 @@ def add_event(self, event): """ #check if event already in event list - if event in self.events: + if event in self._event_set: _msg = f"{event} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) #add event to global event list - self.events.add(event) + self.events.append(event) + self._event_set.add(event) def remove_event(self, event): @@ -621,13 +637,14 @@ def remove_event(self, event): """ #check if event is in event list - if event not in self.events: + if event not in self._event_set: _msg = f"{event} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global event list - self.events.discard(event) + self.events.remove(event) + self._event_set.discard(event) # system assembly ------------------------------------------------------------- @@ -685,10 +702,11 @@ def _check_blocks_are_managed(self): conn_blocks.update(conn.get_blocks()) # Check subset actively managed - if not conn_blocks.issubset(self.blocks): - self.logger.warning( - f"{blk} in 'connections' but not in 'blocks'!" - ) + for blk in conn_blocks: + if blk not in self._block_set: + self.logger.warning( + f"{blk} in 'connections' but not in 'blocks'!" + ) # solver management ----------------------------------------------------------- @@ -719,13 +737,15 @@ def _set_solver(self, Solver=None, **solver_kwargs): self.engine = self.Solver() #iterate all blocks and set integration engines with tolerances - self._blocks_dyn = set() + self._blocks_dyn = [] + self._blocks_dyn_set = set() for block in self.blocks: block.set_solver(self.Solver, self.engine, **self.solver_kwargs) - + #add dynamic blocks to list if block.engine: - self._blocks_dyn.add(block) + self._blocks_dyn.append(block) + self._blocks_dyn_set.add(block) #logging message self.logger.info( diff --git a/src/pathsim/subsystem.py b/src/pathsim/subsystem.py index 2dd3640c..eedecbfe 100644 --- a/src/pathsim/subsystem.py +++ b/src/pathsim/subsystem.py @@ -181,27 +181,28 @@ def __init__(self, #internal algebraic loop solvers -> initialized later self.boosters = None - #internal connecions - self.connections = set() - if connections: - self.connections.update(connections) - + #internal connecions (ordered list with shadow set for O(1) lookup) + self.connections = list(connections) if connections else [] + self._conn_set = set(self.connections) + #collect and organize internal blocks - self.blocks = set() - self.interface = None + self.blocks = [] + self._block_set = set() + self.interface = None if blocks: for block in blocks: - if isinstance(block, Interface): - + if isinstance(block, Interface): + if self.interface is not None: #interface block is already defined raise ValueError("Subsystem can only have one 'Interface' block!") - + self.interface = block - else: + else: #regular blocks - self.blocks.add(block) + self.blocks.append(block) + self._block_set.add(block) #check if interface is defined if self.interface is None: @@ -252,7 +253,7 @@ def __contains__(self, other): ------- bool """ - return other in self.blocks or other in self.connections + return other in self._block_set or other in self._conn_set # adding and removing system components --------------------------------------------------- @@ -267,7 +268,7 @@ def add_block(self, block): block : Block block to add to the subsystem """ - if block in self.blocks: + if block in self._block_set: raise ValueError(f"block {block} already part of subsystem") #initialize solver if available @@ -276,7 +277,8 @@ def add_block(self, block): if block.engine: self._blocks_dyn.append(block) - self.blocks.add(block) + self.blocks.append(block) + self._block_set.add(block) if self.graph: self._graph_dirty = True @@ -292,10 +294,11 @@ def remove_block(self, block): block : Block block to remove from the subsystem """ - if block not in self.blocks: + if block not in self._block_set: raise ValueError(f"block {block} not part of subsystem") - self.blocks.discard(block) + self.blocks.remove(block) + self._block_set.discard(block) #remove from dynamic list if hasattr(self, '_blocks_dyn') and block in self._blocks_dyn: @@ -315,10 +318,11 @@ def add_connection(self, connection): connection : Connection connection to add to the subsystem """ - if connection in self.connections: + if connection in self._conn_set: raise ValueError(f"{connection} already part of subsystem") - self.connections.add(connection) + self.connections.append(connection) + self._conn_set.add(connection) if self.graph: self._graph_dirty = True @@ -334,10 +338,11 @@ def remove_connection(self, connection): connection : Connection connection to remove from the subsystem """ - if connection not in self.connections: + if connection not in self._conn_set: raise ValueError(f"{connection} not part of subsystem") - self.connections.discard(connection) + self.connections.remove(connection) + self._conn_set.discard(connection) if self.graph: self._graph_dirty = True @@ -386,7 +391,7 @@ def _assemble_graph(self): for block in self.blocks: block.inputs.reset() - self.graph = Graph({*self.blocks, self.interface}, self.connections) + self.graph = Graph([*self.blocks, self.interface], self.connections) self._graph_dirty = False #create boosters for loop closing connections diff --git a/tests/pathsim/test_simulation.py b/tests/pathsim/test_simulation.py index beda6aae..d370b473 100644 --- a/tests/pathsim/test_simulation.py +++ b/tests/pathsim/test_simulation.py @@ -52,9 +52,9 @@ def test_init_default(self): #test default initialization Sim = Simulation(log=False) - self.assertEqual(Sim.blocks, set()) - self.assertEqual(Sim.connections, set()) - self.assertEqual(Sim.events, set()) + self.assertEqual(Sim.blocks, []) + self.assertEqual(Sim.connections, []) + self.assertEqual(Sim.events, []) self.assertEqual(Sim.dt, SIM_TIMESTEP) self.assertEqual(Sim.dt_min, SIM_TIMESTEP_MIN) self.assertEqual(Sim.dt_max, SIM_TIMESTEP_MAX) @@ -130,12 +130,12 @@ def test_add_block(self): Sim = Simulation(log=False) - self.assertEqual(Sim.blocks, set()) + self.assertEqual(Sim.blocks, []) #test adding a block B1 = Block() Sim.add_block(B1) - self.assertEqual(Sim.blocks, {B1}) + self.assertEqual(Sim.blocks, [B1]) #test adding the same block again with self.assertRaises(ValueError): @@ -153,17 +153,17 @@ def test_add_connection(self): log=False ) - self.assertEqual(Sim.connections, {C1}) + self.assertEqual(Sim.connections, [C1]) #test adding a connection C2 = Connection(B2, B3) Sim.add_connection(C2) - self.assertEqual(Sim.connections, {C1, C2}) + self.assertEqual(Sim.connections, [C1, C2]) #test adding the same connection again with self.assertRaises(ValueError): Sim.add_connection(C2) - self.assertEqual(Sim.connections, {C1, C2}) + self.assertEqual(Sim.connections, [C1, C2]) def test_set_solver(self): From 0f6a970f43da2b3e4e6a13f50ed0215466f401ec Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:20:36 +0100 Subject: [PATCH 04/15] Use type+index matching for checkpoints instead of UUIDs --- src/pathsim/blocks/_block.py | 22 ++++----- src/pathsim/blocks/delay.py | 10 ++-- src/pathsim/blocks/scope.py | 13 ++--- src/pathsim/blocks/spectrum.py | 8 ++-- src/pathsim/blocks/switch.py | 8 ++-- src/pathsim/events/_event.py | 16 ++++--- src/pathsim/simulation.py | 82 +++++++++++++++++++++++--------- tests/pathsim/test_checkpoint.py | 46 ++++++++---------- 8 files changed, 114 insertions(+), 91 deletions(-) diff --git a/src/pathsim/blocks/_block.py b/src/pathsim/blocks/_block.py index 597195a4..347be870 100644 --- a/src/pathsim/blocks/_block.py +++ b/src/pathsim/blocks/_block.py @@ -530,11 +530,13 @@ def state(self, val): # checkpoint methods ---------------------------------------------------------------- - def to_checkpoint(self, recordings=False): + def to_checkpoint(self, prefix, recordings=False): """Serialize block state for checkpointing. Parameters ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) recordings : bool include recording data (for Scope blocks) @@ -545,10 +547,7 @@ def to_checkpoint(self, recordings=False): npz_data : dict numpy arrays keyed by path """ - prefix = self.id - json_data = { - "id": self.id, "type": self.__class__.__name__, "active": self._active, } @@ -567,8 +566,9 @@ def to_checkpoint(self, recordings=False): #internal events if self.events: evt_jsons = [] - for event in self.events: - e_json, e_npz = event.to_checkpoint() + for i, event in enumerate(self.events): + evt_prefix = f"{prefix}/evt_{i}" + e_json, e_npz = event.to_checkpoint(evt_prefix) evt_jsons.append(e_json) npz_data.update(e_npz) json_data["events"] = evt_jsons @@ -576,18 +576,18 @@ def to_checkpoint(self, recordings=False): return json_data, npz_data - def load_checkpoint(self, json_data, npz): + def load_checkpoint(self, prefix, json_data, npz): """Restore block state from checkpoint. Parameters ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) json_data : dict block metadata from checkpoint JSON npz : dict-like numpy arrays from checkpoint NPZ """ - prefix = json_data["id"] - #verify type if json_data["type"] != self.__class__.__name__: raise ValueError( @@ -611,8 +611,8 @@ def load_checkpoint(self, json_data, npz): #restore internal events if self.events and "events" in json_data: - for event, evt_data in zip(self.events, json_data["events"]): - event.load_checkpoint(evt_data, npz) + for i, (event, evt_data) in enumerate(zip(self.events, json_data["events"])): + event.load_checkpoint(f"{prefix}/evt_{i}", evt_data, npz) # methods for block output and state updates ---------------------------------------- diff --git a/src/pathsim/blocks/delay.py b/src/pathsim/blocks/delay.py index 6b42614c..4e6d0a4f 100644 --- a/src/pathsim/blocks/delay.py +++ b/src/pathsim/blocks/delay.py @@ -142,10 +142,9 @@ def reset(self): self._ring.extend([0.0] * self._n) - def to_checkpoint(self, recordings=False): + def to_checkpoint(self, prefix, recordings=False): """Serialize Delay state including buffer data.""" - json_data, npz_data = super().to_checkpoint(recordings=recordings) - prefix = self.id + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) json_data["sampling_period"] = self.sampling_period @@ -160,10 +159,9 @@ def to_checkpoint(self, recordings=False): return json_data, npz_data - def load_checkpoint(self, json_data, npz): + def load_checkpoint(self, prefix, json_data, npz): """Restore Delay state including buffer data.""" - super().load_checkpoint(json_data, npz) - prefix = json_data["id"] + super().load_checkpoint(prefix, json_data, npz) if self.sampling_period is None: #continuous mode diff --git a/src/pathsim/blocks/scope.py b/src/pathsim/blocks/scope.py index 57854526..ec980785 100644 --- a/src/pathsim/blocks/scope.py +++ b/src/pathsim/blocks/scope.py @@ -448,10 +448,9 @@ def save(self, path="scope.csv"): wrt.writerow(sample) - def to_checkpoint(self, recordings=False): + def to_checkpoint(self, prefix, recordings=False): """Serialize Scope state including optional recording data.""" - json_data, npz_data = super().to_checkpoint(recordings=recordings) - prefix = self.id + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) json_data["_incremental_idx"] = self._incremental_idx if hasattr(self, '_sample_next_timestep'): @@ -464,10 +463,9 @@ def to_checkpoint(self, recordings=False): return json_data, npz_data - def load_checkpoint(self, json_data, npz): + def load_checkpoint(self, prefix, json_data, npz): """Restore Scope state including optional recording data.""" - super().load_checkpoint(json_data, npz) - prefix = json_data["id"] + super().load_checkpoint(prefix, json_data, npz) self._incremental_idx = json_data.get("_incremental_idx", 0) if hasattr(self, '_sample_next_timestep'): @@ -479,9 +477,6 @@ def load_checkpoint(self, json_data, npz): if rt_key in npz and rd_key in npz: self.recording_time = npz[rt_key].tolist() self.recording_data = [row for row in npz[rd_key]] - else: - self.recording_time = [] - self.recording_data = [] def update(self, t): diff --git a/src/pathsim/blocks/spectrum.py b/src/pathsim/blocks/spectrum.py index b4d37fed..0dec61fe 100644 --- a/src/pathsim/blocks/spectrum.py +++ b/src/pathsim/blocks/spectrum.py @@ -283,9 +283,9 @@ def step(self, t, dt): return True, 0.0, None - def to_checkpoint(self, recordings=False): + def to_checkpoint(self, prefix, recordings=False): """Serialize Spectrum state including integration time.""" - json_data, npz_data = super().to_checkpoint(recordings=recordings) + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) json_data["time"] = self.time json_data["t_sample"] = self.t_sample @@ -293,9 +293,9 @@ def to_checkpoint(self, recordings=False): return json_data, npz_data - def load_checkpoint(self, json_data, npz): + def load_checkpoint(self, prefix, json_data, npz): """Restore Spectrum state including integration time.""" - super().load_checkpoint(json_data, npz) + super().load_checkpoint(prefix, json_data, npz) self.time = json_data.get("time", 0.0) self.t_sample = json_data.get("t_sample", 0.0) diff --git a/src/pathsim/blocks/switch.py b/src/pathsim/blocks/switch.py index 3ce04b07..8ee707be 100644 --- a/src/pathsim/blocks/switch.py +++ b/src/pathsim/blocks/switch.py @@ -82,13 +82,13 @@ def select(self, switch_state=0): self.switch_state = switch_state - def to_checkpoint(self, recordings=False): - json_data, npz_data = super().to_checkpoint(recordings=recordings) + def to_checkpoint(self, prefix, recordings=False): + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) json_data["switch_state"] = self.switch_state return json_data, npz_data - def load_checkpoint(self, json_data, npz): - super().load_checkpoint(json_data, npz) + def load_checkpoint(self, prefix, json_data, npz): + super().load_checkpoint(prefix, json_data, npz) self.switch_state = json_data.get("switch_state", None) def update(self, t): diff --git a/src/pathsim/events/_event.py b/src/pathsim/events/_event.py index 124c99d1..85a14625 100644 --- a/src/pathsim/events/_event.py +++ b/src/pathsim/events/_event.py @@ -211,9 +211,14 @@ def resolve(self, t): # checkpoint methods ---------------------------------------------------------------- - def to_checkpoint(self): + def to_checkpoint(self, prefix): """Serialize event state for checkpointing. + Parameters + ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) + Returns ------- json_data : dict @@ -221,15 +226,12 @@ def to_checkpoint(self): npz_data : dict numpy arrays keyed by path """ - prefix = self.id - #extract history eval value hist_eval, hist_time = self._history if hist_eval is not None and hasattr(hist_eval, 'item'): hist_eval = float(hist_eval) json_data = { - "id": self.id, "type": self.__class__.__name__, "active": self._active, "history_eval": hist_eval, @@ -243,18 +245,18 @@ def to_checkpoint(self): return json_data, npz_data - def load_checkpoint(self, json_data, npz): + def load_checkpoint(self, prefix, json_data, npz): """Restore event state from checkpoint. Parameters ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) json_data : dict event metadata from checkpoint JSON npz : dict-like numpy arrays from checkpoint NPZ """ - prefix = json_data["id"] - self._active = json_data["active"] self._history = json_data["history_eval"], json_data["history_time"] diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index 924b4817..279820ae 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -338,11 +338,34 @@ def plot(self, *args, **kwargs): # checkpoint methods ---------------------------------------------------------- + @staticmethod + def _checkpoint_key(type_name, type_counts): + """Generate a deterministic checkpoint key from block/event type + and occurrence index (e.g. 'Integrator_0', 'Scope_1'). + + Parameters + ---------- + type_name : str + class name of the block or event + type_counts : dict + running counter per type name, mutated in place + + Returns + ------- + key : str + deterministic checkpoint key + """ + idx = type_counts.get(type_name, 0) + type_counts[type_name] = idx + 1 + return f"{type_name}_{idx}" + + def save_checkpoint(self, path, recordings=False): """Save simulation state to checkpoint files (JSON + NPZ). Creates two files: {path}.json (structure/metadata) and - {path}.npz (numerical data). + {path}.npz (numerical data). Blocks and events are keyed by + type and insertion order for deterministic cross-instance matching. Parameters ---------- @@ -371,22 +394,28 @@ def save_checkpoint(self, path, recordings=False): "tolerance_fpi": self.tolerance_fpi, "iterations_max": self.iterations_max, }, - "blocks": {}, - "events": {}, + "blocks": [], + "events": [], } npz_data = {} - #checkpoint all blocks (keyed by UUID) + #checkpoint all blocks (keyed by type + insertion index) + type_counts = {} for block in self.blocks: - b_json, b_npz = block.to_checkpoint(recordings=recordings) - checkpoint["blocks"][block.id] = b_json + key = self._checkpoint_key(block.__class__.__name__, type_counts) + b_json, b_npz = block.to_checkpoint(key, recordings=recordings) + b_json["_key"] = key + checkpoint["blocks"].append(b_json) npz_data.update(b_npz) - #checkpoint external events (keyed by UUID) + #checkpoint external events (keyed by type + insertion index) + type_counts = {} for event in self.events: - e_json, e_npz = event.to_checkpoint() - checkpoint["events"][event.id] = e_json + key = self._checkpoint_key(event.__class__.__name__, type_counts) + e_json, e_npz = event.to_checkpoint(key) + e_json["_key"] = key + checkpoint["events"].append(e_json) npz_data.update(e_npz) #write files @@ -400,8 +429,9 @@ def load_checkpoint(self, path): """Load simulation state from checkpoint files (JSON + NPZ). Restores simulation time and all block/event states from a - previously saved checkpoint. The simulation must have the same - blocks and events as when the checkpoint was saved. + previously saved checkpoint. Matching is based on block/event + type and insertion order, so the simulation must be constructed + with the same block types in the same order. Parameters ---------- @@ -444,26 +474,32 @@ def load_checkpoint(self, path): f"current solver '{self.Solver.__name__}'" ) - #restore blocks - block_data = checkpoint.get("blocks", {}) + #index checkpoint blocks by key + block_data = {b["_key"]: b for b in checkpoint.get("blocks", [])} + + #restore blocks by type + insertion order + type_counts = {} for block in self.blocks: - if block.id in block_data: - block.load_checkpoint(block_data[block.id], npz) + key = self._checkpoint_key(block.__class__.__name__, type_counts) + if key in block_data: + block.load_checkpoint(key, block_data[key], npz) else: warnings.warn( - f"Block {block.__class__.__name__} (id={block.id[:8]}...) " - f"not found in checkpoint" + f"Block '{key}' not found in checkpoint" ) - #restore external events - event_data = checkpoint.get("events", {}) + #index checkpoint events by key + event_data = {e["_key"]: e for e in checkpoint.get("events", [])} + + #restore external events by type + insertion order + type_counts = {} for event in self.events: - if event.id in event_data: - event.load_checkpoint(event_data[event.id], npz) + key = self._checkpoint_key(event.__class__.__name__, type_counts) + if key in event_data: + event.load_checkpoint(key, event_data[key], npz) else: warnings.warn( - f"Event {event.__class__.__name__} (id={event.id[:8]}...) " - f"not found in checkpoint" + f"Event '{key}' not found in checkpoint" ) finally: diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py index b0bcc470..b8af6b0d 100644 --- a/tests/pathsim/test_checkpoint.py +++ b/tests/pathsim/test_checkpoint.py @@ -22,70 +22,61 @@ def test_basic_block_to_checkpoint(self): """Block produces valid checkpoint data.""" b = Integrator(1.0) b.inputs[0] = 3.14 - json_data, npz_data = b.to_checkpoint() + prefix = "Integrator_0" + json_data, npz_data = b.to_checkpoint(prefix) assert json_data["type"] == "Integrator" - assert json_data["id"] == b.id assert json_data["active"] is True - assert f"{b.id}/inputs" in npz_data - assert f"{b.id}/outputs" in npz_data - - def test_block_has_uuid(self): - """Each block gets a unique UUID.""" - b1 = Integrator() - b2 = Integrator() - assert b1.id != b2.id - assert len(b1.id) == 32 # hex UUID without dashes + assert f"{prefix}/inputs" in npz_data + assert f"{prefix}/outputs" in npz_data def test_block_checkpoint_roundtrip(self): """Block state survives save/load cycle.""" b = Integrator(2.5) b.inputs[0] = 1.0 b.outputs[0] = 2.5 + prefix = "Integrator_0" - json_data, npz_data = b.to_checkpoint() + json_data, npz_data = b.to_checkpoint(prefix) #reset block b.reset() assert b.inputs[0] == 0.0 #restore - b.load_checkpoint(json_data, npz_data) + b.load_checkpoint(prefix, json_data, npz_data) assert np.isclose(b.inputs[0], 1.0) assert np.isclose(b.outputs[0], 2.5) def test_block_type_mismatch_raises(self): """Loading checkpoint with wrong type raises ValueError.""" b = Integrator() - json_data, npz_data = b.to_checkpoint() + prefix = "Integrator_0" + json_data, npz_data = b.to_checkpoint(prefix) b2 = Amplifier(1.0) with pytest.raises(ValueError, match="type mismatch"): - b2.load_checkpoint(json_data, npz_data) + b2.load_checkpoint(prefix, json_data, npz_data) class TestEventCheckpoint: """Test event-level checkpoint methods.""" - def test_event_has_uuid(self): - from pathsim.events import ZeroCrossing - e = ZeroCrossing(func_evt=lambda t: t - 1.0) - assert len(e.id) == 32 - def test_event_checkpoint_roundtrip(self): from pathsim.events import ZeroCrossing e = ZeroCrossing(func_evt=lambda t: t - 1.0) e._history = (0.5, 0.99) e._times = [1.0, 2.0, 3.0] e._active = False + prefix = "ZeroCrossing_0" - json_data, npz_data = e.to_checkpoint() + json_data, npz_data = e.to_checkpoint(prefix) e.reset() assert e._active is True assert len(e._times) == 0 - e.load_checkpoint(json_data, npz_data) + e.load_checkpoint(prefix, json_data, npz_data) assert e._active is False assert e._times == [1.0, 2.0, 3.0] assert e._history == (0.5, 0.99) @@ -96,12 +87,13 @@ class TestSwitchCheckpoint: def test_switch_state_preserved(self): s = Switch(switch_state=2) - json_data, npz_data = s.to_checkpoint() + prefix = "Switch_0" + json_data, npz_data = s.to_checkpoint(prefix) s.select(None) assert s.switch_state is None - s.load_checkpoint(json_data, npz_data) + s.load_checkpoint(prefix, json_data, npz_data) assert s.switch_state == 2 @@ -140,7 +132,7 @@ def test_save_load_simple(self): data = json.load(f) assert data["version"] == "1.0.0" assert data["simulation"]["time"] == time_after_run - assert integ.id in data["blocks"] + assert any(b["_key"] == "Integrator_0" for b in data["blocks"]) #reset and reload sim.time = 0.0 @@ -198,14 +190,14 @@ def test_scope_recordings(self): path1 = os.path.join(tmpdir, "no_rec") sim.save_checkpoint(path1, recordings=False) npz1 = np.load(f"{path1}.npz") - assert f"{scope.id}/recording_time" not in npz1 + assert "Scope_0/recording_time" not in npz1 npz1.close() #with recordings path2 = os.path.join(tmpdir, "with_rec") sim.save_checkpoint(path2, recordings=True) npz2 = np.load(f"{path2}.npz") - assert f"{scope.id}/recording_time" in npz2 + assert "Scope_0/recording_time" in npz2 npz2.close() def test_delay_continuous_checkpoint(self): From 11697a068f56c09812ec8db64a82447288020586 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:22:49 +0100 Subject: [PATCH 05/15] Move json/warnings imports to top-level, fix missing trailing newlines --- src/pathsim/events/_event.py | 2 +- src/pathsim/simulation.py | 8 +++----- src/pathsim/utils/adaptivebuffer.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/pathsim/events/_event.py b/src/pathsim/events/_event.py index 85a14625..58f657b5 100644 --- a/src/pathsim/events/_event.py +++ b/src/pathsim/events/_event.py @@ -264,4 +264,4 @@ def load_checkpoint(self, prefix, json_data, npz): if times_key in npz: self._times = npz[times_key].tolist() else: - self._times = [] \ No newline at end of file + self._times = [] diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index 279820ae..64402bd7 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -10,6 +10,9 @@ # IMPORTS =============================================================================== +import json +import warnings + import numpy as np import time @@ -374,8 +377,6 @@ def save_checkpoint(self, path, recordings=False): recordings : bool include scope/spectrum recording data (default: False) """ - import json - #strip extension if provided if path.endswith('.json') or path.endswith('.npz'): path = path.rsplit('.', 1)[0] @@ -438,9 +439,6 @@ def load_checkpoint(self, path): path : str base path without extension """ - import json - import warnings - #strip extension if provided if path.endswith('.json') or path.endswith('.npz'): path = path.rsplit('.', 1)[0] diff --git a/src/pathsim/utils/adaptivebuffer.py b/src/pathsim/utils/adaptivebuffer.py index 05dd82fa..5b37fa05 100644 --- a/src/pathsim/utils/adaptivebuffer.py +++ b/src/pathsim/utils/adaptivebuffer.py @@ -163,4 +163,4 @@ def load_checkpoint(self, npz, prefix): values = npz[v_key] for t, v in zip(times, values): self.buffer_t.append(float(t)) - self.buffer_v.append(v) \ No newline at end of file + self.buffer_v.append(v) From 13189ff62023fb1470b3d01fecb7c91003fdc2bd Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:30:51 +0100 Subject: [PATCH 06/15] Add checkpoint overrides for FIR, KalmanFilter, noise blocks, RNG, Subsystem --- src/pathsim/blocks/fir.py | 15 ++++++ src/pathsim/blocks/kalman.py | 16 ++++++ src/pathsim/blocks/noise.py | 27 ++++++++++ src/pathsim/blocks/rng.py | 14 ++++++ src/pathsim/solvers/gear.py | 4 +- src/pathsim/subsystem.py | 96 ++++++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) diff --git a/src/pathsim/blocks/fir.py b/src/pathsim/blocks/fir.py index 8db1a8a3..c2766bc1 100644 --- a/src/pathsim/blocks/fir.py +++ b/src/pathsim/blocks/fir.py @@ -114,6 +114,21 @@ def reset(self): self._buffer = deque([0.0]*n, maxlen=n) + def to_checkpoint(self, prefix, recordings=False): + """Serialize FIR state including input buffer.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + npz_data[f"{prefix}/fir_buffer"] = np.array(list(self._buffer)) + return json_data, npz_data + + def load_checkpoint(self, prefix, json_data, npz): + """Restore FIR state including input buffer.""" + super().load_checkpoint(prefix, json_data, npz) + key = f"{prefix}/fir_buffer" + if key in npz: + self._buffer.clear() + self._buffer.extend(npz[key].tolist()) + + def __len__(self): """This block has no direct passthrough""" return 0 \ No newline at end of file diff --git a/src/pathsim/blocks/kalman.py b/src/pathsim/blocks/kalman.py index 783ae537..98374a38 100644 --- a/src/pathsim/blocks/kalman.py +++ b/src/pathsim/blocks/kalman.py @@ -143,6 +143,22 @@ def __len__(self): return 0 + def to_checkpoint(self, prefix, recordings=False): + """Serialize Kalman filter state estimate and covariance.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + npz_data[f"{prefix}/kf_x"] = self.x + npz_data[f"{prefix}/kf_P"] = self.P + return json_data, npz_data + + def load_checkpoint(self, prefix, json_data, npz): + """Restore Kalman filter state estimate and covariance.""" + super().load_checkpoint(prefix, json_data, npz) + if f"{prefix}/kf_x" in npz: + self.x = npz[f"{prefix}/kf_x"] + if f"{prefix}/kf_P" in npz: + self.P = npz[f"{prefix}/kf_P"] + + def _kf_update(self): """Perform one Kalman filter update step.""" diff --git a/src/pathsim/blocks/noise.py b/src/pathsim/blocks/noise.py index 101828ea..555eb5be 100644 --- a/src/pathsim/blocks/noise.py +++ b/src/pathsim/blocks/noise.py @@ -44,6 +44,17 @@ class WhiteNoise(Block): random seed for reproducibility """ + def to_checkpoint(self, prefix, recordings=False): + """Serialize WhiteNoise state including current sample.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + json_data["_current_sample"] = float(self._current_sample) + return json_data, npz_data + + def load_checkpoint(self, prefix, json_data, npz): + """Restore WhiteNoise state including current sample.""" + super().load_checkpoint(prefix, json_data, npz) + self._current_sample = json_data.get("_current_sample", 0.0) + input_port_labels = {} output_port_labels = {"out": 0} @@ -156,6 +167,22 @@ class PinkNoise(Block): random seed for reproducibility """ + def to_checkpoint(self, prefix, recordings=False): + """Serialize PinkNoise state including algorithm state.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + json_data["n_samples"] = self.n_samples + json_data["_current_sample"] = float(self._current_sample) + npz_data[f"{prefix}/octave_values"] = self.octave_values + return json_data, npz_data + + def load_checkpoint(self, prefix, json_data, npz): + """Restore PinkNoise state including algorithm state.""" + super().load_checkpoint(prefix, json_data, npz) + self.n_samples = json_data.get("n_samples", 0) + self._current_sample = json_data.get("_current_sample", 0.0) + if f"{prefix}/octave_values" in npz: + self.octave_values = npz[f"{prefix}/octave_values"] + input_port_labels = {} output_port_labels = {"out": 0} diff --git a/src/pathsim/blocks/rng.py b/src/pathsim/blocks/rng.py index 5841b5a5..974e181e 100644 --- a/src/pathsim/blocks/rng.py +++ b/src/pathsim/blocks/rng.py @@ -96,6 +96,20 @@ def sample(self, t, dt): self._sample = np.random.rand() + def to_checkpoint(self, prefix, recordings=False): + """Serialize RNG state including current sample.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + if self.sampling_period is None: + json_data["_sample"] = float(self._sample) + return json_data, npz_data + + def load_checkpoint(self, prefix, json_data, npz): + """Restore RNG state including current sample.""" + super().load_checkpoint(prefix, json_data, npz) + if self.sampling_period is None: + self._sample = json_data.get("_sample", 0.0) + + def __len__(self): """Essentially a source-like block without passthrough""" return 0 diff --git a/src/pathsim/solvers/gear.py b/src/pathsim/solvers/gear.py index 6f745371..22968194 100644 --- a/src/pathsim/solvers/gear.py +++ b/src/pathsim/solvers/gear.py @@ -250,8 +250,8 @@ def load_checkpoint(self, json_data, npz, prefix): #recompute BDF coefficients from restored history if not self._needs_startup and len(self.history_dt) > 0: self.F, self.K = {}, {} - for n, _ in enumerate(self.history_dt, 1): - self.F[n], self.K[n] = compute_bdf_coefficients(n, np.array(self.history_dt)) + for k, _ in enumerate(self.history_dt, 1): + self.F[k], self.K[k] = compute_bdf_coefficients(k, np.array(self.history_dt)) def stages(self, t, dt): diff --git a/src/pathsim/subsystem.py b/src/pathsim/subsystem.py index eedecbfe..b515bd34 100644 --- a/src/pathsim/subsystem.py +++ b/src/pathsim/subsystem.py @@ -462,6 +462,102 @@ def reset(self): block.reset() + def to_checkpoint(self, prefix, recordings=False): + """Serialize subsystem state by recursively checkpointing internal blocks. + + Parameters + ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) + recordings : bool + include recording data (for Scope blocks) + + Returns + ------- + json_data : dict + JSON-serializable metadata + npz_data : dict + numpy arrays keyed by path + """ + json_data = { + "type": self.__class__.__name__, + "active": self._active, + "blocks": [], + } + npz_data = {} + + #checkpoint interface block + if_json, if_npz = self.interface.to_checkpoint(f"{prefix}/interface", recordings=recordings) + json_data["interface"] = if_json + npz_data.update(if_npz) + + #checkpoint internal blocks by type + insertion order + type_counts = {} + for block in self.blocks: + type_name = block.__class__.__name__ + idx = type_counts.get(type_name, 0) + type_counts[type_name] = idx + 1 + key = f"{prefix}/{type_name}_{idx}" + b_json, b_npz = block.to_checkpoint(key, recordings=recordings) + b_json["_key"] = key + json_data["blocks"].append(b_json) + npz_data.update(b_npz) + + #checkpoint subsystem-level events + if self._events: + evt_jsons = [] + for i, event in enumerate(self._events): + evt_prefix = f"{prefix}/evt_{i}" + e_json, e_npz = event.to_checkpoint(evt_prefix) + evt_jsons.append(e_json) + npz_data.update(e_npz) + json_data["events"] = evt_jsons + + return json_data, npz_data + + + def load_checkpoint(self, prefix, json_data, npz): + """Restore subsystem state by recursively loading internal blocks. + + Parameters + ---------- + prefix : str + key prefix for NPZ arrays (assigned by simulation) + json_data : dict + subsystem metadata from checkpoint JSON + npz : dict-like + numpy arrays from checkpoint NPZ + """ + #verify type + if json_data["type"] != self.__class__.__name__: + raise ValueError( + f"Checkpoint type mismatch: expected '{self.__class__.__name__}', " + f"got '{json_data['type']}'" + ) + + self._active = json_data["active"] + + #restore interface block + if "interface" in json_data: + self.interface.load_checkpoint(f"{prefix}/interface", json_data["interface"], npz) + + #restore internal blocks by type + insertion order + block_data = {b["_key"]: b for b in json_data.get("blocks", [])} + type_counts = {} + for block in self.blocks: + type_name = block.__class__.__name__ + idx = type_counts.get(type_name, 0) + type_counts[type_name] = idx + 1 + key = f"{prefix}/{type_name}_{idx}" + if key in block_data: + block.load_checkpoint(key, block_data[key], npz) + + #restore subsystem-level events + if self._events and "events" in json_data: + for i, (event, evt_data) in enumerate(zip(self._events, json_data["events"])): + event.load_checkpoint(f"{prefix}/evt_{i}", evt_data, npz) + + def on(self): """Activate the subsystem and all internal blocks, sets the boolean evaluation flag to 'True'. From 00e4f30c9b3d34330b5bbc36597c434b0f64d04e Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:33:02 +0100 Subject: [PATCH 07/15] Add comprehensive checkpoint tests: cross-instance, FIR, Kalman, noise, Subsystem --- tests/pathsim/test_checkpoint.py | 280 ++++++++++++++++++++++++++++++- 1 file changed, 278 insertions(+), 2 deletions(-) diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py index b8af6b0d..ac4df056 100644 --- a/tests/pathsim/test_checkpoint.py +++ b/tests/pathsim/test_checkpoint.py @@ -7,12 +7,16 @@ import numpy as np import pytest -from pathsim import Simulation, Connection +from pathsim import Simulation, Connection, Subsystem, Interface from pathsim.blocks import ( - Source, Integrator, Amplifier, Scope, Constant + Source, Integrator, Amplifier, Scope, Constant, Function ) from pathsim.blocks.delay import Delay from pathsim.blocks.switch import Switch +from pathsim.blocks.fir import FIR +from pathsim.blocks.kalman import KalmanFilter +from pathsim.blocks.noise import WhiteNoise, PinkNoise +from pathsim.blocks.rng import RandomNumberGenerator class TestBlockCheckpoint: @@ -246,3 +250,275 @@ def test_delay_discrete_checkpoint(self): delay._ring.clear() sim.load_checkpoint(path) assert list(delay._ring) == ring_before + + def test_cross_instance_load(self): + """Checkpoint loads into a freshly constructed simulation (different UUIDs).""" + src1 = Source(lambda t: 1.0) + integ1 = Integrator() + sim1 = Simulation( + blocks=[src1, integ1], + connections=[Connection(src1, integ1)], + dt=0.01 + ) + sim1.run(1.0) + saved_time = sim1.time + saved_state = integ1.state.copy() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim1.save_checkpoint(path) + + #create entirely new simulation (new block objects, new UUIDs) + src2 = Source(lambda t: 1.0) + integ2 = Integrator() + sim2 = Simulation( + blocks=[src2, integ2], + connections=[Connection(src2, integ2)], + dt=0.01 + ) + + #UUIDs differ + assert src1.id != src2.id + assert integ1.id != integ2.id + + sim2.load_checkpoint(path) + assert sim2.time == saved_time + assert np.allclose(integ2.state, saved_state) + + def test_scope_recordings_preserved_without_flag(self): + """Loading without recordings flag does not erase existing recordings.""" + src = Source(lambda t: t) + scope = Scope() + sim = Simulation( + blocks=[src, scope], + connections=[Connection(src, scope)], + dt=0.1 + ) + sim.run(1.0) + + #scope has recordings + assert len(scope.recording_time) > 0 + rec_len = len(scope.recording_time) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path, recordings=False) + sim.load_checkpoint(path) + + #recordings should still be intact + assert len(scope.recording_time) == rec_len + + def test_multiple_same_type_blocks(self): + """Multiple blocks of the same type are matched by insertion order.""" + src = Source(lambda t: 1.0) + i1 = Integrator(1.0) + i2 = Integrator(2.0) + sim = Simulation( + blocks=[src, i1, i2], + connections=[Connection(src, i1), Connection(src, i2)], + dt=0.01 + ) + sim.run(0.5) + + state1 = i1.state.copy() + state2 = i2.state.copy() + assert not np.allclose(state1, state2) # different initial conditions + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + i1.state = np.array([0.0]) + i2.state = np.array([0.0]) + + sim.load_checkpoint(path) + assert np.allclose(i1.state, state1) + assert np.allclose(i2.state, state2) + + +class TestFIRCheckpoint: + """Test FIR block checkpoint.""" + + def test_fir_buffer_preserved(self): + """FIR filter buffer survives checkpoint round-trip.""" + fir = FIR(coeffs=[0.25, 0.5, 0.25], T=0.01) + prefix = "FIR_0" + + #simulate some input to fill the buffer + fir._buffer.appendleft(1.0) + fir._buffer.appendleft(2.0) + buffer_before = list(fir._buffer) + + json_data, npz_data = fir.to_checkpoint(prefix) + + fir._buffer.clear() + fir._buffer.extend([0.0] * 3) + + fir.load_checkpoint(prefix, json_data, npz_data) + assert list(fir._buffer) == buffer_before + + +class TestKalmanFilterCheckpoint: + """Test KalmanFilter block checkpoint.""" + + def test_kalman_state_preserved(self): + """Kalman filter state and covariance survive checkpoint.""" + F = np.array([[1.0, 0.1], [0.0, 1.0]]) + H = np.array([[1.0, 0.0]]) + Q = np.eye(2) * 0.01 + R = np.array([[0.1]]) + + kf = KalmanFilter(F, H, Q, R) + prefix = "KalmanFilter_0" + + #set some state + kf.x = np.array([3.14, -1.0]) + kf.P = np.array([[0.5, 0.1], [0.1, 0.3]]) + + json_data, npz_data = kf.to_checkpoint(prefix) + + kf.x = np.zeros(2) + kf.P = np.eye(2) + + kf.load_checkpoint(prefix, json_data, npz_data) + assert np.allclose(kf.x, [3.14, -1.0]) + assert np.allclose(kf.P, [[0.5, 0.1], [0.1, 0.3]]) + + +class TestNoiseCheckpoint: + """Test noise block checkpoints.""" + + def test_white_noise_sample_preserved(self): + """WhiteNoise current sample survives checkpoint.""" + wn = WhiteNoise(standard_deviation=2.0) + wn._current_sample = 1.234 + prefix = "WhiteNoise_0" + + json_data, npz_data = wn.to_checkpoint(prefix) + wn._current_sample = 0.0 + + wn.load_checkpoint(prefix, json_data, npz_data) + assert wn._current_sample == pytest.approx(1.234) + + def test_pink_noise_state_preserved(self): + """PinkNoise algorithm state survives checkpoint.""" + pn = PinkNoise(num_octaves=8, seed=42) + prefix = "PinkNoise_0" + + #advance the noise state + for _ in range(10): + pn._generate_sample(0.01) + + n_samples_before = pn.n_samples + octaves_before = pn.octave_values.copy() + sample_before = pn._current_sample + + json_data, npz_data = pn.to_checkpoint(prefix) + + pn.reset() + assert pn.n_samples == 0 + + pn.load_checkpoint(prefix, json_data, npz_data) + assert pn.n_samples == n_samples_before + assert np.allclose(pn.octave_values, octaves_before) + + +class TestRNGCheckpoint: + """Test RandomNumberGenerator checkpoint.""" + + def test_rng_sample_preserved(self): + """RNG current sample survives checkpoint (continuous mode).""" + rng = RandomNumberGenerator(sampling_period=None) + prefix = "RandomNumberGenerator_0" + sample_before = rng._sample + + json_data, npz_data = rng.to_checkpoint(prefix) + rng._sample = 0.0 + + rng.load_checkpoint(prefix, json_data, npz_data) + assert rng._sample == pytest.approx(sample_before) + + +class TestSubsystemCheckpoint: + """Test Subsystem checkpoint.""" + + def test_subsystem_roundtrip(self): + """Subsystem with internal blocks survives checkpoint round-trip.""" + #build a simple subsystem: two integrators in series + If = Interface() + I1 = Integrator(1.0) + I2 = Integrator(0.0) + + sub = Subsystem( + blocks=[If, I1, I2], + connections=[ + Connection(If, I1), + Connection(I1, I2), + Connection(I2, If), + ] + ) + + #embed in a simulation + src = Source(lambda t: 1.0) + scope = Scope() + sim = Simulation( + blocks=[src, sub, scope], + connections=[ + Connection(src, sub), + Connection(sub, scope), + ], + dt=0.01 + ) + + sim.run(0.5) + state_I1 = I1.state.copy() + state_I2 = I2.state.copy() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + #zero out states + I1.state = np.array([0.0]) + I2.state = np.array([0.0]) + + sim.load_checkpoint(path) + assert np.allclose(I1.state, state_I1) + assert np.allclose(I2.state, state_I2) + + def test_subsystem_cross_instance(self): + """Subsystem checkpoint loads into a fresh simulation instance.""" + If1 = Interface() + I1 = Integrator(1.0) + sub1 = Subsystem( + blocks=[If1, I1], + connections=[Connection(If1, I1), Connection(I1, If1)] + ) + src1 = Source(lambda t: 1.0) + sim1 = Simulation( + blocks=[src1, sub1], + connections=[Connection(src1, sub1)], + dt=0.01 + ) + sim1.run(0.5) + state_before = I1.state.copy() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim1.save_checkpoint(path) + + #new instance + If2 = Interface() + I2 = Integrator(1.0) + sub2 = Subsystem( + blocks=[If2, I2], + connections=[Connection(If2, I2), Connection(I2, If2)] + ) + src2 = Source(lambda t: 1.0) + sim2 = Simulation( + blocks=[src2, sub2], + connections=[Connection(src2, sub2)], + dt=0.01 + ) + sim2.load_checkpoint(path) + assert np.allclose(I2.state, state_before) From 528b26ed6b352d2bd73990b60e3d9f565da57e65 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 09:42:02 +0100 Subject: [PATCH 08/15] Clean up: remove dead UUID code, fix method placement and spacing, deduplicate checkpoint key logic --- src/pathsim/blocks/_block.py | 4 --- src/pathsim/blocks/fir.py | 3 +- src/pathsim/blocks/kalman.py | 1 + src/pathsim/blocks/noise.py | 60 +++++++++++++++++--------------- src/pathsim/blocks/rng.py | 1 + src/pathsim/blocks/switch.py | 1 + src/pathsim/events/_event.py | 5 --- src/pathsim/subsystem.py | 20 ++++++----- tests/pathsim/test_checkpoint.py | 4 --- 9 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/pathsim/blocks/_block.py b/src/pathsim/blocks/_block.py index 347be870..6a3c3e3c 100644 --- a/src/pathsim/blocks/_block.py +++ b/src/pathsim/blocks/_block.py @@ -11,7 +11,6 @@ # IMPORTS =============================================================================== import inspect -from uuid import uuid4 from functools import lru_cache from ..utils.deprecation import deprecated @@ -85,9 +84,6 @@ class definition for other blocks to be inherited. def __init__(self): - #unique identifier for checkpointing and diagnostics - self.id = uuid4().hex - #registers to hold input and output values self.inputs = Register( mapping=self.input_port_labels and self.input_port_labels.copy() diff --git a/src/pathsim/blocks/fir.py b/src/pathsim/blocks/fir.py index c2766bc1..c9dc8ff7 100644 --- a/src/pathsim/blocks/fir.py +++ b/src/pathsim/blocks/fir.py @@ -120,6 +120,7 @@ def to_checkpoint(self, prefix, recordings=False): npz_data[f"{prefix}/fir_buffer"] = np.array(list(self._buffer)) return json_data, npz_data + def load_checkpoint(self, prefix, json_data, npz): """Restore FIR state including input buffer.""" super().load_checkpoint(prefix, json_data, npz) @@ -131,4 +132,4 @@ def load_checkpoint(self, prefix, json_data, npz): def __len__(self): """This block has no direct passthrough""" - return 0 \ No newline at end of file + return 0 diff --git a/src/pathsim/blocks/kalman.py b/src/pathsim/blocks/kalman.py index 98374a38..c835a7cc 100644 --- a/src/pathsim/blocks/kalman.py +++ b/src/pathsim/blocks/kalman.py @@ -150,6 +150,7 @@ def to_checkpoint(self, prefix, recordings=False): npz_data[f"{prefix}/kf_P"] = self.P return json_data, npz_data + def load_checkpoint(self, prefix, json_data, npz): """Restore Kalman filter state estimate and covariance.""" super().load_checkpoint(prefix, json_data, npz) diff --git a/src/pathsim/blocks/noise.py b/src/pathsim/blocks/noise.py index 555eb5be..0206a1b6 100644 --- a/src/pathsim/blocks/noise.py +++ b/src/pathsim/blocks/noise.py @@ -44,17 +44,6 @@ class WhiteNoise(Block): random seed for reproducibility """ - def to_checkpoint(self, prefix, recordings=False): - """Serialize WhiteNoise state including current sample.""" - json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) - json_data["_current_sample"] = float(self._current_sample) - return json_data, npz_data - - def load_checkpoint(self, prefix, json_data, npz): - """Restore WhiteNoise state including current sample.""" - super().load_checkpoint(prefix, json_data, npz) - self._current_sample = json_data.get("_current_sample", 0.0) - input_port_labels = {} output_port_labels = {"out": 0} @@ -135,6 +124,19 @@ def update(self, t): pass + def to_checkpoint(self, prefix, recordings=False): + """Serialize WhiteNoise state including current sample.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + json_data["_current_sample"] = float(self._current_sample) + return json_data, npz_data + + + def load_checkpoint(self, prefix, json_data, npz): + """Restore WhiteNoise state including current sample.""" + super().load_checkpoint(prefix, json_data, npz) + self._current_sample = json_data.get("_current_sample", 0.0) + + class PinkNoise(Block): """Pink noise (1/f noise) source using the Voss-McCartney algorithm. @@ -167,22 +169,6 @@ class PinkNoise(Block): random seed for reproducibility """ - def to_checkpoint(self, prefix, recordings=False): - """Serialize PinkNoise state including algorithm state.""" - json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) - json_data["n_samples"] = self.n_samples - json_data["_current_sample"] = float(self._current_sample) - npz_data[f"{prefix}/octave_values"] = self.octave_values - return json_data, npz_data - - def load_checkpoint(self, prefix, json_data, npz): - """Restore PinkNoise state including algorithm state.""" - super().load_checkpoint(prefix, json_data, npz) - self.n_samples = json_data.get("n_samples", 0) - self._current_sample = json_data.get("_current_sample", 0.0) - if f"{prefix}/octave_values" in npz: - self.octave_values = npz[f"{prefix}/octave_values"] - input_port_labels = {} output_port_labels = {"out": 0} @@ -295,4 +281,22 @@ def sample(self, t, dt): def update(self, t): - pass \ No newline at end of file + pass + + + def to_checkpoint(self, prefix, recordings=False): + """Serialize PinkNoise state including algorithm state.""" + json_data, npz_data = super().to_checkpoint(prefix, recordings=recordings) + json_data["n_samples"] = self.n_samples + json_data["_current_sample"] = float(self._current_sample) + npz_data[f"{prefix}/octave_values"] = self.octave_values + return json_data, npz_data + + + def load_checkpoint(self, prefix, json_data, npz): + """Restore PinkNoise state including algorithm state.""" + super().load_checkpoint(prefix, json_data, npz) + self.n_samples = json_data.get("n_samples", 0) + self._current_sample = json_data.get("_current_sample", 0.0) + if f"{prefix}/octave_values" in npz: + self.octave_values = npz[f"{prefix}/octave_values"] \ No newline at end of file diff --git a/src/pathsim/blocks/rng.py b/src/pathsim/blocks/rng.py index 974e181e..72824107 100644 --- a/src/pathsim/blocks/rng.py +++ b/src/pathsim/blocks/rng.py @@ -103,6 +103,7 @@ def to_checkpoint(self, prefix, recordings=False): json_data["_sample"] = float(self._sample) return json_data, npz_data + def load_checkpoint(self, prefix, json_data, npz): """Restore RNG state including current sample.""" super().load_checkpoint(prefix, json_data, npz) diff --git a/src/pathsim/blocks/switch.py b/src/pathsim/blocks/switch.py index 8ee707be..f89f28ca 100644 --- a/src/pathsim/blocks/switch.py +++ b/src/pathsim/blocks/switch.py @@ -87,6 +87,7 @@ def to_checkpoint(self, prefix, recordings=False): json_data["switch_state"] = self.switch_state return json_data, npz_data + def load_checkpoint(self, prefix, json_data, npz): super().load_checkpoint(prefix, json_data, npz) self.switch_state = json_data.get("switch_state", None) diff --git a/src/pathsim/events/_event.py b/src/pathsim/events/_event.py index 58f657b5..1ebd1a9e 100644 --- a/src/pathsim/events/_event.py +++ b/src/pathsim/events/_event.py @@ -11,8 +11,6 @@ import numpy as np -from uuid import uuid4 - from .. _constants import EVT_TOLERANCE @@ -66,9 +64,6 @@ def __init__( tolerance=EVT_TOLERANCE ): - #unique identifier for checkpointing and diagnostics - self.id = uuid4().hex - #event detection function self.func_evt = func_evt diff --git a/src/pathsim/subsystem.py b/src/pathsim/subsystem.py index b515bd34..cea9740c 100644 --- a/src/pathsim/subsystem.py +++ b/src/pathsim/subsystem.py @@ -462,6 +462,16 @@ def reset(self): block.reset() + @staticmethod + def _checkpoint_key(type_name, type_counts): + """Generate a deterministic checkpoint key from block/event type + and occurrence index (e.g. 'Integrator_0', 'Scope_1'). + """ + idx = type_counts.get(type_name, 0) + type_counts[type_name] = idx + 1 + return f"{type_name}_{idx}" + + def to_checkpoint(self, prefix, recordings=False): """Serialize subsystem state by recursively checkpointing internal blocks. @@ -494,10 +504,7 @@ def to_checkpoint(self, prefix, recordings=False): #checkpoint internal blocks by type + insertion order type_counts = {} for block in self.blocks: - type_name = block.__class__.__name__ - idx = type_counts.get(type_name, 0) - type_counts[type_name] = idx + 1 - key = f"{prefix}/{type_name}_{idx}" + key = f"{prefix}/{self._checkpoint_key(block.__class__.__name__, type_counts)}" b_json, b_npz = block.to_checkpoint(key, recordings=recordings) b_json["_key"] = key json_data["blocks"].append(b_json) @@ -545,10 +552,7 @@ def load_checkpoint(self, prefix, json_data, npz): block_data = {b["_key"]: b for b in json_data.get("blocks", [])} type_counts = {} for block in self.blocks: - type_name = block.__class__.__name__ - idx = type_counts.get(type_name, 0) - type_counts[type_name] = idx + 1 - key = f"{prefix}/{type_name}_{idx}" + key = f"{prefix}/{self._checkpoint_key(block.__class__.__name__, type_counts)}" if key in block_data: block.load_checkpoint(key, block_data[key], npz) diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py index ac4df056..6a930198 100644 --- a/tests/pathsim/test_checkpoint.py +++ b/tests/pathsim/test_checkpoint.py @@ -277,10 +277,6 @@ def test_cross_instance_load(self): dt=0.01 ) - #UUIDs differ - assert src1.id != src2.id - assert integ1.id != integ2.id - sim2.load_checkpoint(path) assert sim2.time == saved_time assert np.allclose(integ2.state, saved_state) From 18e6ef10343f4d17c82612bf55787dd80252edea Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 10:21:46 +0100 Subject: [PATCH 09/15] Fix solver n restoration, add GEAR/Spectrum/Scope/event coverage tests --- src/pathsim/solvers/_solver.py | 1 + tests/pathsim/test_checkpoint.py | 223 +++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/src/pathsim/solvers/_solver.py b/src/pathsim/solvers/_solver.py index d235856e..d10bf16e 100644 --- a/src/pathsim/solvers/_solver.py +++ b/src/pathsim/solvers/_solver.py @@ -403,6 +403,7 @@ def load_checkpoint(self, json_data, npz, prefix): """ self.x = npz[f"{prefix}/x"].copy() self.initial_value = npz[f"{prefix}/initial_value"].copy() + self.n = json_data.get("n", self.n) #restore scalar format if needed if self._scalar_initial and self.initial_value.size == 1: diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py index 6a930198..db480d44 100644 --- a/tests/pathsim/test_checkpoint.py +++ b/tests/pathsim/test_checkpoint.py @@ -518,3 +518,226 @@ def test_subsystem_cross_instance(self): ) sim2.load_checkpoint(path) assert np.allclose(I2.state, state_before) + + +class TestGEARCheckpoint: + """Test GEAR solver checkpoint round-trip.""" + + def test_gear_solver_roundtrip(self): + """GEAR solver state survives checkpoint including BDF coefficients.""" + from pathsim.solvers import GEAR32 + + src = Source(lambda t: np.sin(2 * np.pi * t)) + integ = Integrator() + sim = Simulation( + blocks=[src, integ], + connections=[Connection(src, integ)], + dt=0.01, + Solver=GEAR32 + ) + + #run long enough for GEAR to exit startup phase + sim.run(0.5) + state_after = integ.state.copy() + time_after = sim.time + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + #reset state + integ.state = np.array([0.0]) + sim.time = 0.0 + + sim.load_checkpoint(path) + assert sim.time == time_after + assert np.allclose(integ.state, state_after) + + def test_gear_continue_after_load(self): + """GEAR simulation continues correctly after checkpoint load.""" + from pathsim.solvers import GEAR32 + + #reference: run 2s continuously + src1 = Source(lambda t: 1.0) + integ1 = Integrator() + sim1 = Simulation( + blocks=[src1, integ1], + connections=[Connection(src1, integ1)], + dt=0.01, + Solver=GEAR32 + ) + sim1.run(2.0) + reference = integ1.state.copy() + + #split: run 1s, save, load, run 1s more + src2 = Source(lambda t: 1.0) + integ2 = Integrator() + sim2 = Simulation( + blocks=[src2, integ2], + connections=[Connection(src2, integ2)], + dt=0.01, + Solver=GEAR32 + ) + sim2.run(1.0) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim2.save_checkpoint(path) + sim2.load_checkpoint(path) + sim2.run(1.0) + + assert np.allclose(integ2.state, reference, rtol=1e-6) + + +class TestSpectrumCheckpoint: + """Test Spectrum block checkpoint.""" + + def test_spectrum_roundtrip(self): + """Spectrum block state survives checkpoint round-trip.""" + from pathsim.blocks.spectrum import Spectrum + + src = Source(lambda t: np.sin(2 * np.pi * 10 * t)) + spec = Spectrum(freq=[5, 10, 15], t_wait=0.0) + sim = Simulation( + blocks=[src, spec], + connections=[Connection(src, spec)], + dt=0.001 + ) + sim.run(0.1) + + time_before = spec.time + t_sample_before = spec.t_sample + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + spec.time = 0.0 + spec.t_sample = 0.0 + + sim.load_checkpoint(path) + assert spec.time == pytest.approx(time_before) + assert spec.t_sample == pytest.approx(t_sample_before) + + +class TestScopeCheckpointExtended: + """Extended Scope checkpoint tests for coverage.""" + + def test_scope_with_sampling_period(self): + """Scope with sampling_period preserves _sample_next_timestep.""" + src = Source(lambda t: t) + scope = Scope(sampling_period=0.1) + sim = Simulation( + blocks=[src, scope], + connections=[Connection(src, scope)], + dt=0.01 + ) + sim.run(0.5) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + sim.load_checkpoint(path) + + #verify scope still works after load + sim.run(0.1) + assert len(scope.recording_time) > 0 + + def test_scope_recordings_roundtrip(self): + """Scope recording data round-trips with recordings=True.""" + src = Source(lambda t: t) + scope = Scope() + sim = Simulation( + blocks=[src, scope], + connections=[Connection(src, scope)], + dt=0.1 + ) + sim.run(1.0) + + rec_time = scope.recording_time.copy() + rec_data = [row.copy() for row in scope.recording_data] + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path, recordings=True) + + #clear recordings + scope.recording_time = [] + scope.recording_data = [] + + sim.load_checkpoint(path) + assert len(scope.recording_time) == len(rec_time) + assert np.allclose(scope.recording_time, rec_time) + + +class TestSimulationCheckpointExtended: + """Extended simulation checkpoint tests for coverage.""" + + def test_save_load_with_extension(self): + """Path with .json extension is handled correctly.""" + src = Source(lambda t: 1.0) + integ = Integrator() + sim = Simulation( + blocks=[src, integ], + connections=[Connection(src, integ)], + dt=0.01 + ) + sim.run(0.1) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp.json") + sim.save_checkpoint(path) + + assert os.path.exists(os.path.join(tmpdir, "cp.json")) + assert os.path.exists(os.path.join(tmpdir, "cp.npz")) + + sim.load_checkpoint(path) + assert sim.time == pytest.approx(0.1, abs=0.01) + + def test_checkpoint_with_events(self): + """Simulation with external events checkpoints correctly.""" + from pathsim.events import Schedule + + src = Source(lambda t: 1.0) + integ = Integrator() + + event_fired = [False] + def on_event(t): + event_fired[0] = True + + evt = Schedule(t_start=0.5, t_period=1.0, func_act=on_event) + + sim = Simulation( + blocks=[src, integ], + connections=[Connection(src, integ)], + events=[evt], + dt=0.01 + ) + sim.run(1.0) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) + + #verify events in JSON + with open(f"{path}.json") as f: + data = json.load(f) + assert len(data["events"]) == 1 + assert data["events"][0]["type"] == "Schedule" + + sim.load_checkpoint(path) + + def test_event_numpy_history(self): + """Event with numpy scalar in history serializes correctly.""" + from pathsim.events import ZeroCrossing + + e = ZeroCrossing(func_evt=lambda t: t - 1.0) + e._history = (np.float64(0.5), 0.99) + prefix = "ZeroCrossing_0" + + json_data, npz_data = e.to_checkpoint(prefix) + assert isinstance(json_data["history_eval"], float) + + e.reset() + e.load_checkpoint(prefix, json_data, npz_data) + assert e._history[0] == pytest.approx(0.5) From 1cb68470aacac59cc7130ef7b7852b086f63e803 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 10:43:22 +0100 Subject: [PATCH 10/15] Add checkpoint example notebook for docs --- docs/source/examples/checkpoints.ipynb | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/source/examples/checkpoints.ipynb diff --git a/docs/source/examples/checkpoints.ipynb b/docs/source/examples/checkpoints.ipynb new file mode 100644 index 00000000..e77eb6b8 --- /dev/null +++ b/docs/source/examples/checkpoints.ipynb @@ -0,0 +1,211 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Checkpoints\n", + "\n", + "PathSim supports saving and loading simulation state via checkpoints. This allows you to pause a simulation, save its complete state to disk, and resume it later from exactly where you left off.\n", + "\n", + "Checkpoints use a split format: a JSON file for metadata and structure, and an NPZ file for numerical data (block states, solver histories, etc.)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "We'll use a damped harmonic oscillator as our test system. First, let's run it continuously for 25 seconds as our reference." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from pathsim import Simulation, Connection\n", + "from pathsim.blocks import Integrator, Amplifier, Adder, Scope" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# System parameters\n", + "x0, v0 = 2, 5\n", + "m, c, k = 0.8, 0.2, 1.5\n", + "\n", + "def make_system():\n", + " \"\"\"Helper to create a fresh harmonic oscillator simulation.\"\"\"\n", + " I1 = Integrator(v0)\n", + " I2 = Integrator(x0)\n", + " A1 = Amplifier(c)\n", + " A2 = Amplifier(k)\n", + " A3 = Amplifier(-1/m)\n", + " P1 = Adder()\n", + " Sc = Scope(labels=[\"velocity\", \"position\"])\n", + "\n", + " blocks = [I1, I2, A1, A2, A3, P1, Sc]\n", + " connections = [\n", + " Connection(I1, I2, A1, Sc), \n", + " Connection(I2, A2, Sc[1]),\n", + " Connection(A1, P1), \n", + " Connection(A2, P1[1]), \n", + " Connection(P1, A3),\n", + " Connection(A3, I1)\n", + " ]\n", + "\n", + " sim = Simulation(blocks, connections, dt=0.01)\n", + " return sim, Sc" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reference Run\n", + "\n", + "Run the full simulation continuously for 25 seconds. This is our ground truth." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sim_ref, scope_ref = make_system()\n", + "sim_ref.run(25)\n", + "\n", + "time_ref, data_ref = scope_ref.read()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Save Checkpoint\n", + "\n", + "Now let's run a second simulation, but only for the first 10 seconds. Then we save a checkpoint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sim_a, scope_a = make_system()\n", + "sim_a.run(10)\n", + "\n", + "# Save checkpoint (creates checkpoint.json and checkpoint.npz)\n", + "sim_a.save_checkpoint(\"checkpoint\")\n", + "print(f\"Saved checkpoint at t = {sim_a.time:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load Checkpoint and Resume\n", + "\n", + "Create an entirely new simulation with fresh block objects, load the checkpoint, and continue for the remaining 15 seconds. The key point is that the new simulation has completely different Python objects, yet the checkpoint restores the exact state by matching blocks by type and insertion order." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sim_b, scope_b = make_system()\n", + "\n", + "# Load checkpoint into the fresh simulation\n", + "sim_b.load_checkpoint(\"checkpoint\")\n", + "print(f\"Resumed from t = {sim_b.time:.1f}s\")\n", + "\n", + "# Continue the simulation for the remaining 15 seconds\n", + "sim_b.run(15)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compare Results\n", + "\n", + "Now let's overlay the reference (continuous run) with the checkpointed run (first 10s + resumed 15s). If checkpointing works correctly, they should be identical." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Read data from both scopes\ntime_a, data_a = scope_a.read()\ntime_b, data_b = scope_b.read()\n\nfig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 5), sharex=True)\n\n# Position (channel 1)\nax1.plot(time_ref, data_ref[1], \"k-\", alpha=0.3, lw=3, label=\"reference (continuous)\")\nax1.plot(time_a, data_a[1], \"C0-\", label=\"first half (0-10s)\")\nax1.plot(time_b, data_b[1], \"C1--\", label=\"resumed (10-25s)\")\nax1.axvline(10, color=\"gray\", ls=\":\", alpha=0.5, label=\"checkpoint\")\nax1.set_ylabel(\"position\")\nax1.legend(loc=\"upper right\", fontsize=8)\n\n# Velocity (channel 0)\nax2.plot(time_ref, data_ref[0], \"k-\", alpha=0.3, lw=3, label=\"reference (continuous)\")\nax2.plot(time_a, data_a[0], \"C0-\", label=\"first half (0-10s)\")\nax2.plot(time_b, data_b[0], \"C1--\", label=\"resumed (10-25s)\")\nax2.axvline(10, color=\"gray\", ls=\":\", alpha=0.5)\nax2.set_ylabel(\"velocity\")\nax2.set_xlabel(\"time [s]\")\n\nfig.suptitle(\"Checkpoint Save / Load\")\nfig.tight_layout()\nplt.show()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The resumed simulation (dashed) seamlessly continues the reference (gray), confirming that the complete simulation state was correctly saved and restored across different Python objects." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Checkpoint File Contents\n", + "\n", + "The JSON file contains human-readable metadata about the simulation state. Let's inspect it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "with open(\"checkpoint.json\") as f:\n", + " cp = json.load(f)\n", + "\n", + "print(f\"PathSim version: {cp['pathsim_version']}\")\n", + "print(f\"Simulation time: {cp['simulation']['time']:.1f}s\")\n", + "print(f\"Solver: {cp['simulation']['solver']}\")\n", + "print(f\"Blocks saved: {len(cp['blocks'])}\")\n", + "for b in cp[\"blocks\"]:\n", + " print(f\" {b['_key']} ({b['type']})\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Blocks are matched by type and insertion order (`Integrator_0`, `Integrator_1`, etc.), which means the checkpoint can be loaded into any simulation with the same block structure, regardless of the specific Python objects." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file From 456e7cef3c14cb7a2294d15c48a23f44e5a49a44 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 10:55:55 +0100 Subject: [PATCH 11/15] Rework checkpoint notebook: driven oscillator with rollback scenarios --- docs/source/examples/checkpoints.ipynb | 100 +++---------------------- 1 file changed, 12 insertions(+), 88 deletions(-) diff --git a/docs/source/examples/checkpoints.ipynb b/docs/source/examples/checkpoints.ipynb index e77eb6b8..aaa86177 100644 --- a/docs/source/examples/checkpoints.ipynb +++ b/docs/source/examples/checkpoints.ipynb @@ -3,22 +3,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Checkpoints\n", - "\n", - "PathSim supports saving and loading simulation state via checkpoints. This allows you to pause a simulation, save its complete state to disk, and resume it later from exactly where you left off.\n", - "\n", - "Checkpoints use a split format: a JSON file for metadata and structure, and an NPZ file for numerical data (block states, solver histories, etc.)." - ] + "source": "# Checkpoints\n\nPathSim supports saving and loading simulation state via checkpoints. This allows you to pause a simulation, save its complete state to disk, and resume it later from exactly where you left off. \n\nCheckpoints also enable **rollback**, where you return to a saved state and explore different what-if scenarios by changing parameters.\n\nCheckpoints use a split format: a JSON file for metadata and structure, and an NPZ file for numerical data (block states, solver histories, etc.)." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "We'll use a damped harmonic oscillator as our test system. First, let's run it continuously for 25 seconds as our reference." - ] + "source": "## Setup\n\nWe'll simulate a driven harmonic oscillator — a mass-spring system excited by an external sinusoidal force. The system produces a sustained periodic response, making it easy to visually verify that checkpoints preserve continuity." }, { "cell_type": "code", @@ -38,126 +28,60 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# System parameters\n", - "x0, v0 = 2, 5\n", - "m, c, k = 0.8, 0.2, 1.5\n", - "\n", - "def make_system():\n", - " \"\"\"Helper to create a fresh harmonic oscillator simulation.\"\"\"\n", - " I1 = Integrator(v0)\n", - " I2 = Integrator(x0)\n", - " A1 = Amplifier(c)\n", - " A2 = Amplifier(k)\n", - " A3 = Amplifier(-1/m)\n", - " P1 = Adder()\n", - " Sc = Scope(labels=[\"velocity\", \"position\"])\n", - "\n", - " blocks = [I1, I2, A1, A2, A3, P1, Sc]\n", - " connections = [\n", - " Connection(I1, I2, A1, Sc), \n", - " Connection(I2, A2, Sc[1]),\n", - " Connection(A1, P1), \n", - " Connection(A2, P1[1]), \n", - " Connection(P1, A3),\n", - " Connection(A3, I1)\n", - " ]\n", - "\n", - " sim = Simulation(blocks, connections, dt=0.01)\n", - " return sim, Sc" - ] + "source": "import numpy as np\n\n# System parameters\nm = 1.0 # mass\nc = 0.1 # light damping\nk = 4.0 # spring stiffness\nF0 = 1.0 # forcing amplitude\nw = 1.8 # forcing frequency (near resonance for k/m=4 -> w0=2)\n\ndef make_system(damping=c, stiffness=k):\n \"\"\"Create a driven harmonic oscillator with configurable parameters.\"\"\"\n from pathsim.blocks import Source, Integrator, Amplifier, Adder, Scope\n\n Src = Source(lambda t: F0/m * np.sin(w * t)) # external acceleration\n I1 = Integrator(0.0) # velocity\n I2 = Integrator(0.5) # position (start displaced)\n Ac = Amplifier(-damping/m)\n Ak = Amplifier(-stiffness/m)\n P1 = Adder()\n Sc = Scope(labels=[\"position\"])\n\n blocks = [Src, I1, I2, Ac, Ak, P1, Sc]\n connections = [\n Connection(I1, I2, Ac), # velocity -> position integrator, damper\n Connection(I2, Ak, Sc), # position -> spring, scope\n Connection(Ac, P1), # -c/m * v -> adder\n Connection(Ak, P1[1]), # -k/m * x -> adder\n Connection(Src, P1[2]), # F/m -> adder\n Connection(P1, I1), # acceleration -> velocity integrator\n ]\n\n sim = Simulation(blocks, connections, dt=0.01)\n return sim, Sc" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Reference Run\n", - "\n", - "Run the full simulation continuously for 25 seconds. This is our ground truth." - ] + "source": "## Save Checkpoint\n\nRun the simulation for 20 seconds, then save a checkpoint. The system will be in a sustained oscillation by this point." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "sim_ref, scope_ref = make_system()\n", - "sim_ref.run(25)\n", - "\n", - "time_ref, data_ref = scope_ref.read()" - ] + "source": "sim, scope = make_system()\nsim.run(20)\n\n# Save checkpoint\nsim.save_checkpoint(\"checkpoint\")\nprint(f\"Saved checkpoint at t = {sim.time:.1f}s\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Save Checkpoint\n", - "\n", - "Now let's run a second simulation, but only for the first 10 seconds. Then we save a checkpoint." - ] + "source": "## Resume from Checkpoint\n\nLoad the checkpoint into a fresh simulation and continue for another 20 seconds. The new simulation has completely different Python objects, yet the checkpoint restores the exact state by matching blocks by type and insertion order." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "sim_a, scope_a = make_system()\n", - "sim_a.run(10)\n", - "\n", - "# Save checkpoint (creates checkpoint.json and checkpoint.npz)\n", - "sim_a.save_checkpoint(\"checkpoint\")\n", - "print(f\"Saved checkpoint at t = {sim_a.time:.1f}s\")" - ] + "source": "sim_resumed, scope_resumed = make_system()\nsim_resumed.load_checkpoint(\"checkpoint\")\nprint(f\"Resumed from t = {sim_resumed.time:.1f}s\")\n\nsim_resumed.run(20)" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Load Checkpoint and Resume\n", - "\n", - "Create an entirely new simulation with fresh block objects, load the checkpoint, and continue for the remaining 15 seconds. The key point is that the new simulation has completely different Python objects, yet the checkpoint restores the exact state by matching blocks by type and insertion order." - ] + "source": "## Rollback: What-If Scenarios\n\nThis is where checkpoints really shine. We reload the same checkpoint but with **different parameters** — increasing the damping significantly. Both branches start from the exact same state at t=20, but evolve differently." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "sim_b, scope_b = make_system()\n", - "\n", - "# Load checkpoint into the fresh simulation\n", - "sim_b.load_checkpoint(\"checkpoint\")\n", - "print(f\"Resumed from t = {sim_b.time:.1f}s\")\n", - "\n", - "# Continue the simulation for the remaining 15 seconds\n", - "sim_b.run(15)" - ] + "source": "# Scenario A: same parameters (continuation)\nsim_a, scope_a = make_system(damping=0.1)\nsim_a.load_checkpoint(\"checkpoint\")\nsim_a.run(20)\n\n# Scenario B: increased damping (what-if)\nsim_b, scope_b = make_system(damping=1.5)\nsim_b.load_checkpoint(\"checkpoint\")\nsim_b.run(20)\n\n# Scenario C: stiffer spring (what-if)\nsim_c, scope_c = make_system(stiffness=9.0)\nsim_c.load_checkpoint(\"checkpoint\")\nsim_c.run(20)" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Compare Results\n", - "\n", - "Now let's overlay the reference (continuous run) with the checkpointed run (first 10s + resumed 15s). If checkpointing works correctly, they should be identical." - ] + "source": "## Compare Results\n\nThe plot shows the original simulation (0–20s), followed by three different futures branching from the same checkpoint." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "# Read data from both scopes\ntime_a, data_a = scope_a.read()\ntime_b, data_b = scope_b.read()\n\nfig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 5), sharex=True)\n\n# Position (channel 1)\nax1.plot(time_ref, data_ref[1], \"k-\", alpha=0.3, lw=3, label=\"reference (continuous)\")\nax1.plot(time_a, data_a[1], \"C0-\", label=\"first half (0-10s)\")\nax1.plot(time_b, data_b[1], \"C1--\", label=\"resumed (10-25s)\")\nax1.axvline(10, color=\"gray\", ls=\":\", alpha=0.5, label=\"checkpoint\")\nax1.set_ylabel(\"position\")\nax1.legend(loc=\"upper right\", fontsize=8)\n\n# Velocity (channel 0)\nax2.plot(time_ref, data_ref[0], \"k-\", alpha=0.3, lw=3, label=\"reference (continuous)\")\nax2.plot(time_a, data_a[0], \"C0-\", label=\"first half (0-10s)\")\nax2.plot(time_b, data_b[0], \"C1--\", label=\"resumed (10-25s)\")\nax2.axvline(10, color=\"gray\", ls=\":\", alpha=0.5)\nax2.set_ylabel(\"velocity\")\nax2.set_xlabel(\"time [s]\")\n\nfig.suptitle(\"Checkpoint Save / Load\")\nfig.tight_layout()\nplt.show()" + "source": "time_orig, data_orig = scope.read()\ntime_a, data_a = scope_a.read()\ntime_b, data_b = scope_b.read()\ntime_c, data_c = scope_c.read()\n\nfig, ax = plt.subplots(figsize=(10, 4))\n\n# Original run (0-20s)\nax.plot(time_orig, data_orig[0], \"k-\", lw=1.5, label=\"original (c=0.1, k=4)\")\n\n# Three futures from checkpoint\nax.plot(time_a, data_a[0], \"C0-\", alpha=0.8, label=\"resumed (c=0.1, k=4)\")\nax.plot(time_b, data_b[0], \"C1-\", alpha=0.8, label=\"what-if: heavy damping (c=1.5)\")\nax.plot(time_c, data_c[0], \"C2-\", alpha=0.8, label=\"what-if: stiffer spring (k=9)\")\n\nax.axvline(20, color=\"gray\", ls=\":\", alpha=0.5, lw=2, label=\"checkpoint (t=20s)\")\nax.set_xlabel(\"time [s]\")\nax.set_ylabel(\"position\")\nax.set_title(\"Checkpoint Rollback: Three Futures from the Same State\")\nax.legend(loc=\"upper right\", fontsize=8)\nfig.tight_layout()\nplt.show()" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "The resumed simulation (dashed) seamlessly continues the reference (gray), confirming that the complete simulation state was correctly saved and restored across different Python objects." - ] + "source": "All three scenarios start from the exact same state at t=20s. The blue continuation matches the original trajectory perfectly, while the heavy damping scenario (orange) decays rapidly and the stiffer spring scenario (green) shifts to a higher natural frequency." }, { "cell_type": "markdown", From c88fc29ef9e391e1baa2d8c3acc1eebcf1f255c3 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 10:59:27 +0100 Subject: [PATCH 12/15] Rewrite checkpoint notebook: coupled oscillators, flat style, rollback demo --- docs/source/examples/checkpoints.ipynb | 229 ++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 28 deletions(-) diff --git a/docs/source/examples/checkpoints.ipynb b/docs/source/examples/checkpoints.ipynb index aaa86177..7d7f4eb7 100644 --- a/docs/source/examples/checkpoints.ipynb +++ b/docs/source/examples/checkpoints.ipynb @@ -3,12 +3,31 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Checkpoints\n\nPathSim supports saving and loading simulation state via checkpoints. This allows you to pause a simulation, save its complete state to disk, and resume it later from exactly where you left off. \n\nCheckpoints also enable **rollback**, where you return to a saved state and explore different what-if scenarios by changing parameters.\n\nCheckpoints use a split format: a JSON file for metadata and structure, and an NPZ file for numerical data (block states, solver histories, etc.)." + "source": [ + "# Checkpoints\n", + "\n", + "PathSim supports saving and loading simulation state via checkpoints. This allows you to pause a simulation, save its complete state to disk, and resume it later from exactly where you left off.\n", + "\n", + "Checkpoints also enable **rollback** — returning to a saved state and exploring different what-if scenarios by changing parameters.\n", + "\n", + "Checkpoints use a split format: a JSON file for metadata and structure, and an NPZ file for numerical data (block states, solver histories, etc.)." + ] }, { "cell_type": "markdown", "metadata": {}, - "source": "## Setup\n\nWe'll simulate a driven harmonic oscillator — a mass-spring system excited by an external sinusoidal force. The system produces a sustained periodic response, making it easy to visually verify that checkpoints preserve continuity." + "source": [ + "## Building the System\n", + "\n", + "We'll use the coupled oscillators system to demonstrate checkpoints. The energy exchange between the two oscillators produces a sustained, non-trivial response that makes it easy to visually verify checkpoint continuity." + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "First let's import the :class:`.Simulation` and :class:`.Connection` classes and the required blocks:" + ] }, { "cell_type": "code", @@ -20,7 +39,14 @@ "import matplotlib.pyplot as plt\n", "\n", "from pathsim import Simulation, Connection\n", - "from pathsim.blocks import Integrator, Amplifier, Adder, Scope" + "from pathsim.blocks import ODE, Function, Scope" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define the system parameters:" ] }, { @@ -28,68 +54,207 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "import numpy as np\n\n# System parameters\nm = 1.0 # mass\nc = 0.1 # light damping\nk = 4.0 # spring stiffness\nF0 = 1.0 # forcing amplitude\nw = 1.8 # forcing frequency (near resonance for k/m=4 -> w0=2)\n\ndef make_system(damping=c, stiffness=k):\n \"\"\"Create a driven harmonic oscillator with configurable parameters.\"\"\"\n from pathsim.blocks import Source, Integrator, Amplifier, Adder, Scope\n\n Src = Source(lambda t: F0/m * np.sin(w * t)) # external acceleration\n I1 = Integrator(0.0) # velocity\n I2 = Integrator(0.5) # position (start displaced)\n Ac = Amplifier(-damping/m)\n Ak = Amplifier(-stiffness/m)\n P1 = Adder()\n Sc = Scope(labels=[\"position\"])\n\n blocks = [Src, I1, I2, Ac, Ak, P1, Sc]\n connections = [\n Connection(I1, I2, Ac), # velocity -> position integrator, damper\n Connection(I2, Ak, Sc), # position -> spring, scope\n Connection(Ac, P1), # -c/m * v -> adder\n Connection(Ak, P1[1]), # -k/m * x -> adder\n Connection(Src, P1[2]), # F/m -> adder\n Connection(P1, I1), # acceleration -> velocity integrator\n ]\n\n sim = Simulation(blocks, connections, dt=0.01)\n return sim, Sc" + "source": [ + "# Mass parameters\n", + "m1 = 1.0\n", + "m2 = 1.5\n", + "\n", + "# Spring constants\n", + "k1 = 2.0\n", + "k2 = 3.0\n", + "k12 = 0.5 # coupling spring\n", + "\n", + "# Damping coefficients\n", + "c1 = 0.02\n", + "c2 = 0.03\n", + "\n", + "# Initial conditions [position, velocity]\n", + "x1_0 = np.array([2.0, 0.0]) # oscillator 1 displaced\n", + "x2_0 = np.array([0.0, 0.0]) # oscillator 2 at rest" + ] }, { - "cell_type": "markdown", + "cell_type": "raw", "metadata": {}, - "source": "## Save Checkpoint\n\nRun the simulation for 20 seconds, then save a checkpoint. The system will be in a sustained oscillation by this point." + "source": [ + "Define the differential equations for each oscillator using :class:`.ODE` blocks and the coupling force using a :class:`.Function` block:" + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "sim, scope = make_system()\nsim.run(20)\n\n# Save checkpoint\nsim.save_checkpoint(\"checkpoint\")\nprint(f\"Saved checkpoint at t = {sim.time:.1f}s\")" + "source": [ + "# Oscillator 1: m1*x1'' = -k1*x1 - c1*x1' - k12*(x1 - x2)\n", + "def osc1_func(x1, u, t):\n", + " f_e = u[0]\n", + " return np.array([x1[1], (-k1*x1[0] - c1*x1[1] - f_e) / m1])\n", + "\n", + "# Oscillator 2: m2*x2'' = -k2*x2 - c2*x2' + k12*(x1 - x2)\n", + "def osc2_func(x2, u, t):\n", + " f_e = u[0]\n", + " return np.array([x2[1], (-k2*x2[0] - c2*x2[1] - f_e) / m2])\n", + "\n", + "# Coupling force\n", + "def coupling_func(x1, x2):\n", + " f = k12 * (x1 - x2)\n", + " return f, -f\n", + "\n", + "# Blocks\n", + "osc1 = ODE(osc1_func, x1_0)\n", + "osc2 = ODE(osc2_func, x2_0)\n", + "fn = Function(coupling_func)\n", + "sc = Scope(labels=[r\"$x_1(t)$ - Oscillator 1\", r\"$x_2(t)$ - Oscillator 2\"])\n", + "\n", + "blocks = [osc1, osc2, fn, sc]\n", + "\n", + "# Connections\n", + "connections = [\n", + " Connection(osc1[0], fn[0], sc[0]),\n", + " Connection(osc2[0], fn[1], sc[1]),\n", + " Connection(fn[0], osc1[0]),\n", + " Connection(fn[1], osc2[0]),\n", + "]" + ] }, { - "cell_type": "markdown", + "cell_type": "raw", "metadata": {}, - "source": "## Resume from Checkpoint\n\nLoad the checkpoint into a fresh simulation and continue for another 20 seconds. The new simulation has completely different Python objects, yet the checkpoint restores the exact state by matching blocks by type and insertion order." + "source": [ + "Create the :class:`.Simulation` and run for 60 seconds:" + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "sim_resumed, scope_resumed = make_system()\nsim_resumed.load_checkpoint(\"checkpoint\")\nprint(f\"Resumed from t = {sim_resumed.time:.1f}s\")\n\nsim_resumed.run(20)" + "source": [ + "sim = Simulation(blocks, connections, dt=0.01)\n", + "\n", + "sim.run(60)\n", + "\n", + "fig, ax = sc.plot()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The two oscillators exchange energy through the coupling spring, producing a characteristic beat pattern." + ] }, { "cell_type": "markdown", "metadata": {}, - "source": "## Rollback: What-If Scenarios\n\nThis is where checkpoints really shine. We reload the same checkpoint but with **different parameters** — increasing the damping significantly. Both branches start from the exact same state at t=20, but evolve differently." + "source": [ + "## Saving a Checkpoint\n", + "\n", + "Now let's save the simulation state at t=60s. This creates two files: `coupled.json` (metadata) and `coupled.npz` (numerical data)." + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "# Scenario A: same parameters (continuation)\nsim_a, scope_a = make_system(damping=0.1)\nsim_a.load_checkpoint(\"checkpoint\")\nsim_a.run(20)\n\n# Scenario B: increased damping (what-if)\nsim_b, scope_b = make_system(damping=1.5)\nsim_b.load_checkpoint(\"checkpoint\")\nsim_b.run(20)\n\n# Scenario C: stiffer spring (what-if)\nsim_c, scope_c = make_system(stiffness=9.0)\nsim_c.load_checkpoint(\"checkpoint\")\nsim_c.run(20)" + "source": [ + "sim.save_checkpoint(\"coupled\")\n", + "print(f\"Checkpoint saved at t = {sim.time:.1f}s\")" + ] }, { "cell_type": "markdown", "metadata": {}, - "source": "## Compare Results\n\nThe plot shows the original simulation (0–20s), followed by three different futures branching from the same checkpoint." + "source": [ + "We can inspect the JSON file to see what was saved:" + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "time_orig, data_orig = scope.read()\ntime_a, data_a = scope_a.read()\ntime_b, data_b = scope_b.read()\ntime_c, data_c = scope_c.read()\n\nfig, ax = plt.subplots(figsize=(10, 4))\n\n# Original run (0-20s)\nax.plot(time_orig, data_orig[0], \"k-\", lw=1.5, label=\"original (c=0.1, k=4)\")\n\n# Three futures from checkpoint\nax.plot(time_a, data_a[0], \"C0-\", alpha=0.8, label=\"resumed (c=0.1, k=4)\")\nax.plot(time_b, data_b[0], \"C1-\", alpha=0.8, label=\"what-if: heavy damping (c=1.5)\")\nax.plot(time_c, data_c[0], \"C2-\", alpha=0.8, label=\"what-if: stiffer spring (k=9)\")\n\nax.axvline(20, color=\"gray\", ls=\":\", alpha=0.5, lw=2, label=\"checkpoint (t=20s)\")\nax.set_xlabel(\"time [s]\")\nax.set_ylabel(\"position\")\nax.set_title(\"Checkpoint Rollback: Three Futures from the Same State\")\nax.legend(loc=\"upper right\", fontsize=8)\nfig.tight_layout()\nplt.show()" + "source": [ + "import json\n", + "\n", + "with open(\"coupled.json\") as f:\n", + " cp = json.load(f)\n", + "\n", + "print(f\"PathSim version: {cp['pathsim_version']}\")\n", + "print(f\"Simulation time: {cp['simulation']['time']:.1f}s\")\n", + "print(f\"Solver: {cp['simulation']['solver']}\")\n", + "print(f\"Blocks saved:\")\n", + "for b in cp[\"blocks\"]:\n", + " print(f\" {b['_key']} ({b['type']})\")" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "Blocks are identified by type and insertion order (``ODE_0``, ``ODE_1``, etc.), so the checkpoint can be loaded into any simulation with the same block structure, regardless of the specific Python objects." + ] }, { "cell_type": "markdown", "metadata": {}, - "source": "All three scenarios start from the exact same state at t=20s. The blue continuation matches the original trajectory perfectly, while the heavy damping scenario (orange) decays rapidly and the stiffer spring scenario (green) shifts to a higher natural frequency." + "source": [ + "## Rollback: What-If Scenarios\n", + "\n", + "This is where checkpoints really shine. We'll load the same checkpoint three times with different coupling strengths to explore how the system evolves from the exact same state.\n", + "\n", + "Since the checkpoint restores all block states by type and insertion order, we just need to rebuild the simulation with the same block structure but different parameters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def run_scenario(k12_new, duration=60):\n", + " \"\"\"Load checkpoint and continue with a different coupling constant.\"\"\"\n", + " def coupling_new(x1, x2):\n", + " f = k12_new * (x1 - x2)\n", + " return f, -f\n", + "\n", + " o1 = ODE(osc1_func, x1_0)\n", + " o2 = ODE(osc2_func, x2_0)\n", + " f = Function(coupling_new)\n", + " s = Scope()\n", + "\n", + " sim = Simulation(\n", + " [o1, o2, f, s],\n", + " [Connection(o1[0], f[0], s[0]),\n", + " Connection(o2[0], f[1], s[1]),\n", + " Connection(f[0], o1[0]),\n", + " Connection(f[1], o2[0])],\n", + " dt=0.01\n", + " )\n", + " sim.load_checkpoint(\"coupled\")\n", + " sim.run(duration)\n", + " return s.read()\n", + "\n", + "# Original coupling (continuation)\n", + "t_a, d_a = run_scenario(k12_new=0.5)\n", + "\n", + "# Stronger coupling\n", + "t_b, d_b = run_scenario(k12_new=2.0)\n", + "\n", + "# Decoupled\n", + "t_c, d_c = run_scenario(k12_new=0.0)" + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Checkpoint File Contents\n", + "## Comparing the Scenarios\n", "\n", - "The JSON file contains human-readable metadata about the simulation state. Let's inspect it." + "The plot shows the original run (0-60s) followed by three different futures branching from the checkpoint at t=60s. We show oscillator 1 for clarity." ] }, { @@ -98,24 +263,32 @@ "metadata": {}, "outputs": [], "source": [ - "import json\n", + "time_orig, data_orig = sc.read()\n", "\n", - "with open(\"checkpoint.json\") as f:\n", - " cp = json.load(f)\n", + "fig, ax = plt.subplots(figsize=(10, 4))\n", "\n", - "print(f\"PathSim version: {cp['pathsim_version']}\")\n", - "print(f\"Simulation time: {cp['simulation']['time']:.1f}s\")\n", - "print(f\"Solver: {cp['simulation']['solver']}\")\n", - "print(f\"Blocks saved: {len(cp['blocks'])}\")\n", - "for b in cp[\"blocks\"]:\n", - " print(f\" {b['_key']} ({b['type']})\")" + "# Original run\n", + "ax.plot(time_orig, data_orig[0], \"k-\", lw=1.5, label=r\"original ($k_{12}=0.5$)\")\n", + "\n", + "# Three futures from checkpoint\n", + "ax.plot(t_a, d_a[0], \"C0-\", alpha=0.8, label=r\"continued ($k_{12}=0.5$)\")\n", + "ax.plot(t_b, d_b[0], \"C1-\", alpha=0.8, label=r\"stronger coupling ($k_{12}=2.0$)\")\n", + "ax.plot(t_c, d_c[0], \"C2-\", alpha=0.8, label=r\"decoupled ($k_{12}=0$)\")\n", + "\n", + "ax.axvline(60, color=\"gray\", ls=\":\", lw=2, alpha=0.5, label=\"checkpoint\")\n", + "ax.set_xlabel(\"time [s]\")\n", + "ax.set_ylabel(r\"$x_1(t)$\")\n", + "ax.set_title(\"Checkpoint Rollback: Three Futures from the Same State\")\n", + "ax.legend(loc=\"upper right\", fontsize=8)\n", + "fig.tight_layout()\n", + "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Blocks are matched by type and insertion order (`Integrator_0`, `Integrator_1`, etc.), which means the checkpoint can be loaded into any simulation with the same block structure, regardless of the specific Python objects." + "All three scenarios start from the exact same state at t=60s. The blue continuation matches the original trajectory perfectly, confirming checkpoint fidelity. The stronger coupling (orange) produces faster energy exchange, while the decoupled system (green) oscillates independently at its natural frequency." ] } ], @@ -132,4 +305,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} From 81f2cff62196a3b9ae0372fc9b88b575da41c2d5 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 11:14:34 +0100 Subject: [PATCH 13/15] Include scope recordings in checkpoints by default --- src/pathsim/simulation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index 64402bd7..ed50a5ae 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -363,7 +363,7 @@ class name of the block or event return f"{type_name}_{idx}" - def save_checkpoint(self, path, recordings=False): + def save_checkpoint(self, path, recordings=True): """Save simulation state to checkpoint files (JSON + NPZ). Creates two files: {path}.json (structure/metadata) and @@ -375,7 +375,7 @@ def save_checkpoint(self, path, recordings=False): path : str base path without extension recordings : bool - include scope/spectrum recording data (default: False) + include scope/spectrum recording data (default: True) """ #strip extension if provided if path.endswith('.json') or path.endswith('.npz'): From 64efb9168ae5eacd34bf0890ee515467b22c82fc Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 17 Mar 2026 11:18:35 +0100 Subject: [PATCH 14/15] Add test verifying recordings are included by default --- tests/pathsim/test_checkpoint.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/pathsim/test_checkpoint.py b/tests/pathsim/test_checkpoint.py index db480d44..94fed3d6 100644 --- a/tests/pathsim/test_checkpoint.py +++ b/tests/pathsim/test_checkpoint.py @@ -669,6 +669,32 @@ def test_scope_recordings_roundtrip(self): assert len(scope.recording_time) == len(rec_time) assert np.allclose(scope.recording_time, rec_time) + def test_scope_recordings_included_by_default(self): + """Default save_checkpoint includes recordings.""" + src = Source(lambda t: t) + scope = Scope() + sim = Simulation( + blocks=[src, scope], + connections=[Connection(src, scope)], + dt=0.1 + ) + sim.run(1.0) + + rec_time = scope.recording_time.copy() + assert len(rec_time) > 0 + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cp") + sim.save_checkpoint(path) # no recordings kwarg — default + + #clear recordings + scope.recording_time = [] + scope.recording_data = [] + + sim.load_checkpoint(path) + assert len(scope.recording_time) == len(rec_time) + assert np.allclose(scope.recording_time, rec_time) + class TestSimulationCheckpointExtended: """Extended simulation checkpoint tests for coverage.""" From 9fe1d2617a21a8016d7a64ca5ad571caf17ae0e1 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 19 Mar 2026 11:36:49 +0100 Subject: [PATCH 15/15] Drop shadow sets, use plain lists for blocks/connections/events --- src/pathsim/simulation.py | 43 ++++++++++++--------------------------- src/pathsim/subsystem.py | 23 ++++++++------------- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/pathsim/simulation.py b/src/pathsim/simulation.py index ed50a5ae..6f6dc9ed 100644 --- a/src/pathsim/simulation.py +++ b/src/pathsim/simulation.py @@ -179,13 +179,10 @@ def __init__( **solver_kwargs ): - #system definition (ordered lists with shadow sets for O(1) lookup) + #system definition self.blocks = [] - self._block_set = set() self.connections = [] - self._conn_set = set() self.events = [] - self._event_set = set() #simulation timestep and bounds self.dt = dt @@ -222,11 +219,9 @@ def __init__( #collection of blocks with internal ODE solvers self._blocks_dyn = [] - self._blocks_dyn_set = set() #collection of blocks with internal events self._blocks_evt = [] - self._blocks_evt_set = set() #flag for setting the simulation active self._active = True @@ -277,9 +272,9 @@ def __contains__(self, other): bool """ return ( - other in self._block_set or - other in self._conn_set or - other in self._event_set + other in self.blocks or + other in self.connections or + other in self.events ) @@ -519,7 +514,7 @@ def add_block(self, block): """ #check if block already in block list - if block in self._block_set: + if block in self.blocks: _msg = f"block {block} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) @@ -530,16 +525,13 @@ def add_block(self, block): #add to dynamic list if solver was initialized if block.engine: self._blocks_dyn.append(block) - self._blocks_dyn_set.add(block) #add to eventful list if internal events if block.events: self._blocks_evt.append(block) - self._blocks_evt_set.add(block) #add block to global blocklist self.blocks.append(block) - self._block_set.add(block) #mark graph for rebuild if self.graph: @@ -559,24 +551,21 @@ def remove_block(self, block): """ #check if block is in block list - if block not in self._block_set: + if block not in self.blocks: _msg = f"block {block} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global blocklist self.blocks.remove(block) - self._block_set.discard(block) #remove from dynamic list - if block in self._blocks_dyn_set: + if block in self._blocks_dyn: self._blocks_dyn.remove(block) - self._blocks_dyn_set.discard(block) #remove from eventful list - if block in self._blocks_evt_set: + if block in self._blocks_evt: self._blocks_evt.remove(block) - self._blocks_evt_set.discard(block) #mark graph for rebuild if self.graph: @@ -596,14 +585,13 @@ def add_connection(self, connection): """ #check if connection already in connection list - if connection in self._conn_set: + if connection in self.connections: _msg = f"{connection} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) #add connection to global connection list self.connections.append(connection) - self._conn_set.add(connection) #mark graph for rebuild if self.graph: @@ -623,14 +611,13 @@ def remove_connection(self, connection): """ #check if connection is in connection list - if connection not in self._conn_set: + if connection not in self.connections: _msg = f"{connection} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global connection list self.connections.remove(connection) - self._conn_set.discard(connection) #mark graph for rebuild if self.graph: @@ -649,14 +636,13 @@ def add_event(self, event): """ #check if event already in event list - if event in self._event_set: + if event in self.events: _msg = f"{event} already part of simulation" self.logger.error(_msg) raise ValueError(_msg) #add event to global event list self.events.append(event) - self._event_set.add(event) def remove_event(self, event): @@ -671,14 +657,13 @@ def remove_event(self, event): """ #check if event is in event list - if event not in self._event_set: + if event not in self.events: _msg = f"{event} not part of simulation" self.logger.error(_msg) raise ValueError(_msg) #remove from global event list self.events.remove(event) - self._event_set.discard(event) # system assembly ------------------------------------------------------------- @@ -737,7 +722,7 @@ def _check_blocks_are_managed(self): # Check subset actively managed for blk in conn_blocks: - if blk not in self._block_set: + if blk not in self.blocks: self.logger.warning( f"{blk} in 'connections' but not in 'blocks'!" ) @@ -772,14 +757,12 @@ def _set_solver(self, Solver=None, **solver_kwargs): #iterate all blocks and set integration engines with tolerances self._blocks_dyn = [] - self._blocks_dyn_set = set() for block in self.blocks: block.set_solver(self.Solver, self.engine, **self.solver_kwargs) #add dynamic blocks to list if block.engine: self._blocks_dyn.append(block) - self._blocks_dyn_set.add(block) #logging message self.logger.info( diff --git a/src/pathsim/subsystem.py b/src/pathsim/subsystem.py index cea9740c..6dec9691 100644 --- a/src/pathsim/subsystem.py +++ b/src/pathsim/subsystem.py @@ -181,14 +181,12 @@ def __init__(self, #internal algebraic loop solvers -> initialized later self.boosters = None - #internal connecions (ordered list with shadow set for O(1) lookup) + #internal connecions self.connections = list(connections) if connections else [] - self._conn_set = set(self.connections) #collect and organize internal blocks - self.blocks = [] - self._block_set = set() - self.interface = None + self.blocks = [] + self.interface = None if blocks: for block in blocks: @@ -202,7 +200,6 @@ def __init__(self, else: #regular blocks self.blocks.append(block) - self._block_set.add(block) #check if interface is defined if self.interface is None: @@ -253,7 +250,7 @@ def __contains__(self, other): ------- bool """ - return other in self._block_set or other in self._conn_set + return other in self.blocks or other in self.connections # adding and removing system components --------------------------------------------------- @@ -268,7 +265,7 @@ def add_block(self, block): block : Block block to add to the subsystem """ - if block in self._block_set: + if block in self.blocks: raise ValueError(f"block {block} already part of subsystem") #initialize solver if available @@ -278,7 +275,6 @@ def add_block(self, block): self._blocks_dyn.append(block) self.blocks.append(block) - self._block_set.add(block) if self.graph: self._graph_dirty = True @@ -294,11 +290,10 @@ def remove_block(self, block): block : Block block to remove from the subsystem """ - if block not in self._block_set: + if block not in self.blocks: raise ValueError(f"block {block} not part of subsystem") self.blocks.remove(block) - self._block_set.discard(block) #remove from dynamic list if hasattr(self, '_blocks_dyn') and block in self._blocks_dyn: @@ -318,11 +313,10 @@ def add_connection(self, connection): connection : Connection connection to add to the subsystem """ - if connection in self._conn_set: + if connection in self.connections: raise ValueError(f"{connection} already part of subsystem") self.connections.append(connection) - self._conn_set.add(connection) if self.graph: self._graph_dirty = True @@ -338,11 +332,10 @@ def remove_connection(self, connection): connection : Connection connection to remove from the subsystem """ - if connection not in self._conn_set: + if connection not in self.connections: raise ValueError(f"{connection} not part of subsystem") self.connections.remove(connection) - self._conn_set.discard(connection) if self.graph: self._graph_dirty = True