From 88d860350fad13c974171591f4cd6c9b8d83d983 Mon Sep 17 00:00:00 2001 From: David Straub Date: Mon, 11 May 2026 13:20:12 +0200 Subject: [PATCH 1/5] Implement voltage cutoffs --- src/pathsim_batt/cells/pybamm_cell.py | 175 ++++++++++++++++++++++-- tests/cells/test_pybamm_cell.py | 189 ++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 9 deletions(-) diff --git a/src/pathsim_batt/cells/pybamm_cell.py b/src/pathsim_batt/cells/pybamm_cell.py index 40ce974..551633d 100644 --- a/src/pathsim_batt/cells/pybamm_cell.py +++ b/src/pathsim_batt/cells/pybamm_cell.py @@ -14,6 +14,7 @@ import numpy.typing as npt import pybamm from pathsim.blocks import DynamicalSystem, Wrapper +from pathsim.events import ZeroCrossingDown # HELPERS ============================================================================= @@ -80,6 +81,12 @@ def __init__( ) self._parameter_values = _prepare_parameter_values(parameter_values) + self._v_lower = float( + self._parameter_values["Lower voltage cut-off [V]"] + ) + self._v_upper = float( + self._parameter_values["Upper voltage cut-off [V]"] + ) pybamm_solver = pybamm_solver or pybamm.CasadiSolver(mode="safe") @@ -166,6 +173,57 @@ def func_alg(x, u, t): def __len__(self) -> int: return len(self._pybamm_output_vars) + 1 + def termination_events(self, sim) -> list: + """Return PathSim events that mirror PyBaMM's voltage cut-off conditions. + + Registers two :class:`~pathsim.events.ZeroCrossingDown` events on this + cell's terminal voltage output (port ``V``, index 0): + + * **Under-voltage** — fires when ``V`` falls to ``Lower voltage + cut-off [V]`` (identical to PyBaMM's ``Minimum voltage [V]`` + termination event). + * **Over-voltage** — fires when ``V`` rises to ``Upper voltage + cut-off [V]`` (identical to PyBaMM's ``Maximum voltage [V]`` + termination event). + + Each event calls ``sim.stop()`` when it resolves, halting the PathSim + time-stepping loop at the crossing point. + + Parameters + ---------- + sim : pathsim.Simulation + The simulation this cell is part of. The events call + ``sim.stop()`` on resolution. + + Returns + ------- + list[ZeroCrossingDown] + Two events; add them to the simulation with + ``sim.add_event(e)`` or ``for e in cell.termination_events(sim): sim.add_event(e)``. + + Example + ------- + .. code-block:: python + + sim = Simulation(blocks=[...], connections=[...], dt=1.0, Solver=ESDIRK43) + for event in cell.termination_events(sim): + sim.add_event(event) + sim.run(3600) + """ + v_lower = self._v_lower + v_upper = self._v_upper + outputs = self.outputs + + under_voltage = ZeroCrossingDown( + func_evt=lambda t: float(outputs[0]) - v_lower, + func_act=lambda t: sim.stop(), + ) + over_voltage = ZeroCrossingDown( + func_evt=lambda t: v_upper - float(outputs[0]), + func_act=lambda t: sim.stop(), + ) + return [under_voltage, over_voltage] + def reset(self) -> None: super().reset() @@ -206,6 +264,12 @@ def __init__( self._model = model self._parameter_values = _prepare_parameter_values(parameter_values) + self._v_lower = float( + self._parameter_values["Lower voltage cut-off [V]"] + ) + self._v_upper = float( + self._parameter_values["Upper voltage cut-off [V]"] + ) self._pybamm_solver = pybamm_solver or pybamm.IDAKLUSolver() self._q_nominal = float(self._parameter_values["Nominal cell capacity [A.h]"]) @@ -213,6 +277,11 @@ def __init__( self._last_outputs: npt.NDArray[np.float64] = self._initial_outputs() + # registered stop callbacks — populated by termination_events() + self._term_callbacks: list = [] + # flag set by _discrete_step so update() knows when fresh outputs exist + self._just_stepped: bool = False + super().__init__(func=self._discrete_step, T=self._dt, tau=self._dt) # ensure outputs are valid before first scheduled sample @@ -229,15 +298,39 @@ def _build_sim(self) -> pybamm.Simulation: return sim def _initial_outputs(self) -> npt.NDArray[np.float64]: - """Return placeholder outputs for t=0 before the first solver step. - - The co-simulation takes its first real sample at t=dt, so this - placeholder is only held for one macro-step. All outputs are zero - except SOC, which is set to the user-supplied initial value. + """Compute physically correct outputs at t=0 from the built PyBaMM model. + + Uses the same CasADi export approach as ``_CellBase`` to evaluate each + output variable at the initial state vector and zero current, so that + the terminal voltage placeholder is the true open-circuit voltage. This + ensures that voltage-threshold events (``termination_events()``) have a + correct, positive starting value and can detect the first downward + crossing correctly. """ - out = np.zeros(len(self._pybamm_output_vars) + 1, dtype=np.float64) - out[-1] = self._initial_soc # SOC is always the last output - return out + all_out_vars = self._pybamm_output_vars + ["Discharge capacity [A.h]"] + casadi_objs = self._sim.built_model.export_casadi_objects( + all_out_vars, + input_parameter_order=list(_DEFAULT_INPUTS.keys()), + ) + t_sym = casadi_objs["t"] + x_sym = casadi_objs["x"] + p_sym = casadi_objs["inputs"] + p0 = casadi.DM(list(_DEFAULT_INPUTS.values())) + x0 = casadi.Function("x0", [p_sym], [casadi_objs["x0"]])(p0) + + outputs: list[float] = [] + for name in self._pybamm_output_vars: + fn = casadi.Function("v", [t_sym, x_sym, p_sym], [casadi_objs["variables"][name]]) + outputs.append(float(fn(0.0, x0, p0))) + + q_dis_fn = casadi.Function( + "q", [t_sym, x_sym, p_sym], [casadi_objs["variables"]["Discharge capacity [A.h]"]] + ) + q_dis = float(q_dis_fn(0.0, x0, p0)) + soc = max(0.0, min(1.0, self._initial_soc - q_dis / self._q_nominal)) + outputs.append(soc) + + return np.array(outputs, dtype=np.float64) def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64]: inputs = { @@ -253,18 +346,82 @@ def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64 outputs.append(soc) self._last_outputs = np.array(outputs, dtype=np.float64) + self._just_stepped = True return self._last_outputs def update(self, t: float) -> None: - self.outputs.update_from_array(self._last_outputs) + """Check voltage cut-off callbacks after each PyBaMM macro-step. + + PathSim calls ``update()`` on every block after each event resolves + (including after the internal :class:`~pathsim.events.Schedule` event + fires and updates outputs). The ``_just_stepped`` flag distinguishes + this post-step call from the earlier pre-event call where outputs are + still stale, ensuring the termination check only runs once per + macro-step and only after fresh outputs are available. + """ + if self._just_stepped: + self._just_stepped = False + V = float(self.outputs[0]) + for cb in self._term_callbacks: + cb(V) def __len__(self) -> int: return len(self._pybamm_output_vars) + 1 + def termination_events(self, sim) -> list: + """Return PathSim events that mirror PyBaMM's voltage cut-off conditions. + + For co-simulation blocks PyBaMM's own solver clamps the terminal voltage + at the cut-off and stops advancing internally, but never signals PathSim. + This method registers **post-step callbacks** (called from + :meth:`update` after each :class:`~pathsim.events.Schedule` macro-step + fires) that call ``sim.stop()`` as soon as the clamped output is + detected. Two :class:`~pathsim.events.ZeroCrossingDown` events are + also returned for API consistency with + :class:`_CellBase.termination_events` — add them to the simulation + with ``sim.add_event(e)`` as a belt-and-suspenders complement for + adaptive-stepping scenarios. + + Parameters + ---------- + sim : pathsim.Simulation + The simulation this cell is part of. + + Returns + ------- + list[ZeroCrossingDown] + Two events (under-voltage, over-voltage). + """ + v_lower = self._v_lower + v_upper = self._v_upper + + def _under_cb(V: float) -> None: + if V <= v_lower: + sim.stop() + + def _over_cb(V: float) -> None: + if V >= v_upper: + sim.stop() + + self._term_callbacks.extend([_under_cb, _over_cb]) + + # Belt-and-suspenders PathSim events (API parity with _CellBase). + outputs = self.outputs + under_voltage = ZeroCrossingDown( + func_evt=lambda t: float(outputs[0]) - v_lower, + func_act=lambda t: sim.stop(), + ) + over_voltage = ZeroCrossingDown( + func_evt=lambda t: v_upper - float(outputs[0]), + func_act=lambda t: sim.stop(), + ) + return [under_voltage, over_voltage] + def reset(self) -> None: super().reset() self._sim = self._build_sim() self._last_outputs = self._initial_outputs() + self._just_stepped = False self.outputs.update_from_array(self._last_outputs) diff --git a/tests/cells/test_pybamm_cell.py b/tests/cells/test_pybamm_cell.py index 681efe1..9a30182 100644 --- a/tests/cells/test_pybamm_cell.py +++ b/tests/cells/test_pybamm_cell.py @@ -555,5 +555,194 @@ def _run_and_get_T_cell(T_amb): ) +class TestTerminationEvents(unittest.TestCase): + """Tests that termination_events() stops the simulation at voltage cut-offs. + + Both the non-CoSim (``_CellBase``) and CoSim (``_CoSimCellBase``) families + are tested. A very low initial SOC combined with a high discharge current + ensures the lower voltage cut-off is reached quickly. Without the events + wired in, the non-CoSim cell would diverge to nonsensical negative voltages, + and the CoSim cell would continue forever at the clamped cut-off value. + + PyBaMM's ``Chen2020`` parameter set has: + * Lower voltage cut-off: 2.5 V + * Upper voltage cut-off: 4.2 V + """ + + # -- helpers --------------------------------------------------------------- + + def _run_with_events(self, cell, current, T_input_port, T_value, cosim_dt=None): + """Build a Simulation, wire termination events, and run for up to 600 s.""" + I_src = Constant(current) + T_src = Constant(T_value) + dt_ps = cosim_dt if cosim_dt is not None else 5.0 + sim = Simulation( + blocks=[I_src, T_src, cell], + connections=[ + Connection(I_src, cell["I"]), + Connection(T_src, cell[T_input_port]), + ], + dt=dt_ps, + Solver=ESDIRK43, + ) + for event in cell.termination_events(sim): + sim.add_event(event) + sim.run(600) + return sim + + def _run_without_events(self, cell, current, T_input_port, T_value, cosim_dt=None): + """Same but without registering termination events.""" + I_src = Constant(current) + T_src = Constant(T_value) + dt_ps = cosim_dt if cosim_dt is not None else 5.0 + sim = Simulation( + blocks=[I_src, T_src, cell], + connections=[ + Connection(I_src, cell["I"]), + Connection(T_src, cell[T_input_port]), + ], + dt=dt_ps, + Solver=ESDIRK43, + ) + sim.run(600) + return sim + + # -- non-CoSim (CellElectrical) -------------------------------------------- + + def test_non_cosim_stops_before_negative_voltage(self): + """With events registered, CellElectrical must stop before V < 0. + + Without events PathSim integrates through the physical cutoff and the + ODE produces nonsensical negative voltages. With events wired in the + simulation must halt at or just above the lower cut-off. + """ + cell = CellElectrical(initial_soc=0.02) + sim = self._run_with_events(cell, current=10.0, T_input_port="T_cell", T_value=298.15) + V_final = float(cell.outputs[0]) + # Simulation must have stopped early (well before 600 s) + self.assertLess(sim.time, 600.0, "simulation did not stop early") + # Voltage must be at or above the lower cut-off, never negative + self.assertGreaterEqual( + V_final, + cell._v_lower - 0.5, # small tolerance for step overshoot + f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V", + ) + self.assertGreater( + V_final, + 0.0, + f"terminal voltage went negative ({V_final:.3f} V) despite termination events", + ) + + def test_non_cosim_without_events_continues_past_cutoff(self): + """Without events, CellElectrical integrates past the cut-off (regression guard). + + This test confirms the *bug* that termination_events() is designed to fix: + PathSim does not stop on its own, and the voltage diverges wildly. + """ + cell = CellElectrical(initial_soc=0.02) + sim = self._run_without_events(cell, current=10.0, T_input_port="T_cell", T_value=298.15) + V_final = float(cell.outputs[0]) + # Without events the simulation runs the full 600 s + self.assertAlmostEqual(sim.time, 600.0, delta=10.0) + # And the voltage should have diverged to a physically impossible value + self.assertLess( + V_final, + 0.0, + "expected voltage divergence without termination events, got " + f"V={V_final:.3f} V — the bug may have been fixed elsewhere", + ) + + def test_non_cosim_termination_events_returns_two_events(self): + """termination_events() must return exactly two ZeroCrossingDown instances.""" + from pathsim.events import ZeroCrossingDown + + cell = CellElectrical() + I_src = Constant(1.0) + T_src = Constant(298.15) + sim = Simulation( + blocks=[I_src, T_src, cell], + connections=[Connection(I_src, cell["I"]), Connection(T_src, cell["T_cell"])], + dt=1.0, + Solver=ESDIRK43, + ) + events = cell.termination_events(sim) + self.assertEqual(len(events), 2) + for e in events: + self.assertIsInstance(e, ZeroCrossingDown) + + def test_non_cosim_cutoff_values_match_parameter_values(self): + """_v_lower/_v_upper must match the Chen2020 parameter set.""" + pv = pybamm.ParameterValues("Chen2020") + cell = CellElectrical(parameter_values=pv) + self.assertAlmostEqual(cell._v_lower, float(pv["Lower voltage cut-off [V]"])) + self.assertAlmostEqual(cell._v_upper, float(pv["Upper voltage cut-off [V]"])) + + # -- CoSim (CellCoSimElectrical) ------------------------------------------- + + def test_cosim_stops_before_negative_voltage(self): + """With events registered, CellCoSimElectrical must stop at the cut-off. + + Without events PyBaMM freezes internally but PathSim keeps ticking + forever with the clamped output (2.5 V). With events wired in, + PathSim must stop as soon as the voltage first touches the cut-off. + """ + cell = CellCoSimElectrical(initial_soc=0.02, dt=10.0) + sim = self._run_with_events( + cell, current=10.0, T_input_port="T_cell", T_value=298.15, cosim_dt=10.0 + ) + V_final = float(cell.outputs[0]) + self.assertLess(sim.time, 600.0, "CoSim simulation did not stop early") + self.assertGreaterEqual( + V_final, + cell._v_lower - 0.5, + f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V", + ) + + def test_cosim_without_events_runs_full_duration(self): + """Without events, CellCoSimElectrical runs the full duration with frozen output. + + This is the CoSim-side symptom of the missing stop condition: PyBaMM + clamps at the cut-off and the simulation never terminates early. + """ + cell = CellCoSimElectrical(initial_soc=0.02, dt=10.0) + sim = self._run_without_events( + cell, current=10.0, T_input_port="T_cell", T_value=298.15, cosim_dt=10.0 + ) + # Simulation must have run the full 600 s (no early stop) + self.assertAlmostEqual(sim.time, 600.0, delta=10.0) + # Voltage must be frozen at the cut-off (PyBaMM clamps, does not diverge) + self.assertAlmostEqual( + float(cell.outputs[0]), + cell._v_lower, + delta=0.1, + msg="CoSim output should be clamped at lower cut-off without events", + ) + + def test_cosim_termination_events_returns_two_events(self): + """termination_events() must return exactly two ZeroCrossingDown instances.""" + from pathsim.events import ZeroCrossingDown + + cell = CellCoSimElectrical(dt=1.0) + I_src = Constant(1.0) + T_src = Constant(298.15) + sim = Simulation( + blocks=[I_src, T_src, cell], + connections=[Connection(I_src, cell["I"]), Connection(T_src, cell["T_cell"])], + dt=1.0, + Solver=ESDIRK43, + ) + events = cell.termination_events(sim) + self.assertEqual(len(events), 2) + for e in events: + self.assertIsInstance(e, ZeroCrossingDown) + + def test_cosim_cutoff_values_match_parameter_values(self): + """_v_lower/_v_upper must match the Chen2020 parameter set (CoSim block).""" + pv = pybamm.ParameterValues("Chen2020") + cell = CellCoSimElectrical(parameter_values=pv, dt=1.0) + self.assertAlmostEqual(cell._v_lower, float(pv["Lower voltage cut-off [V]"])) + self.assertAlmostEqual(cell._v_upper, float(pv["Upper voltage cut-off [V]"])) + + if __name__ == "__main__": unittest.main() From d54efc7dc4516b25a3ce7b4f7d9af9ee7c403e3c Mon Sep 17 00:00:00 2001 From: David Straub Date: Wed, 13 May 2026 07:53:37 +0200 Subject: [PATCH 2/5] New approach with StopSimulation --- pyproject.toml | 3 +- src/pathsim_batt/cells/pybamm_cell.py | 144 +++----------------------- tests/cells/test_pybamm_cell.py | 138 ++++-------------------- 3 files changed, 39 insertions(+), 246 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2174310..33772a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "pathsim>=0.18", + # TODO: unpin once StopSimulation is released to PyPI + "pathsim @ git+https://github.com/pathsim/pathsim.git@fca3978586a887d0178422a64d48b80537856e48", "pybamm>=25.12", ] diff --git a/src/pathsim_batt/cells/pybamm_cell.py b/src/pathsim_batt/cells/pybamm_cell.py index 551633d..cd4e1f6 100644 --- a/src/pathsim_batt/cells/pybamm_cell.py +++ b/src/pathsim_batt/cells/pybamm_cell.py @@ -14,7 +14,7 @@ import numpy.typing as npt import pybamm from pathsim.blocks import DynamicalSystem, Wrapper -from pathsim.events import ZeroCrossingDown +from pathsim.exceptions import StopSimulation # HELPERS ============================================================================= @@ -150,6 +150,9 @@ def jac_dyn(x, u, t): p = _pack(u) return np.array(jac_fn(t, xv, p)) + v_lower = self._v_lower + v_upper = self._v_upper + def func_alg(x, u, t): xv = casadi.DM(x.reshape(-1, 1)) p = _pack(u) @@ -157,6 +160,11 @@ def func_alg(x, u, t): q_dis = float(out_var_fns["Discharge capacity [A.h]"](t, xv, p)) soc = max(0.0, min(1.0, initial_soc_val - q_dis / q_nominal)) outputs.append(soc) + V = outputs[0] + if V <= v_lower: + raise StopSimulation(f"undervoltage: V={V:.4f} V <= {v_lower} V") + if V >= v_upper: + raise StopSimulation(f"overvoltage: V={V:.4f} V >= {v_upper} V") return np.array(outputs) x0_fn = casadi.Function("x0", [p_sym], [casadi_objs["x0"]]) @@ -173,57 +181,6 @@ def func_alg(x, u, t): def __len__(self) -> int: return len(self._pybamm_output_vars) + 1 - def termination_events(self, sim) -> list: - """Return PathSim events that mirror PyBaMM's voltage cut-off conditions. - - Registers two :class:`~pathsim.events.ZeroCrossingDown` events on this - cell's terminal voltage output (port ``V``, index 0): - - * **Under-voltage** — fires when ``V`` falls to ``Lower voltage - cut-off [V]`` (identical to PyBaMM's ``Minimum voltage [V]`` - termination event). - * **Over-voltage** — fires when ``V`` rises to ``Upper voltage - cut-off [V]`` (identical to PyBaMM's ``Maximum voltage [V]`` - termination event). - - Each event calls ``sim.stop()`` when it resolves, halting the PathSim - time-stepping loop at the crossing point. - - Parameters - ---------- - sim : pathsim.Simulation - The simulation this cell is part of. The events call - ``sim.stop()`` on resolution. - - Returns - ------- - list[ZeroCrossingDown] - Two events; add them to the simulation with - ``sim.add_event(e)`` or ``for e in cell.termination_events(sim): sim.add_event(e)``. - - Example - ------- - .. code-block:: python - - sim = Simulation(blocks=[...], connections=[...], dt=1.0, Solver=ESDIRK43) - for event in cell.termination_events(sim): - sim.add_event(event) - sim.run(3600) - """ - v_lower = self._v_lower - v_upper = self._v_upper - outputs = self.outputs - - under_voltage = ZeroCrossingDown( - func_evt=lambda t: float(outputs[0]) - v_lower, - func_act=lambda t: sim.stop(), - ) - over_voltage = ZeroCrossingDown( - func_evt=lambda t: v_upper - float(outputs[0]), - func_act=lambda t: sim.stop(), - ) - return [under_voltage, over_voltage] - def reset(self) -> None: super().reset() @@ -277,11 +234,6 @@ def __init__( self._last_outputs: npt.NDArray[np.float64] = self._initial_outputs() - # registered stop callbacks — populated by termination_events() - self._term_callbacks: list = [] - # flag set by _discrete_step so update() knows when fresh outputs exist - self._just_stepped: bool = False - super().__init__(func=self._discrete_step, T=self._dt, tau=self._dt) # ensure outputs are valid before first scheduled sample @@ -302,10 +254,8 @@ def _initial_outputs(self) -> npt.NDArray[np.float64]: Uses the same CasADi export approach as ``_CellBase`` to evaluate each output variable at the initial state vector and zero current, so that - the terminal voltage placeholder is the true open-circuit voltage. This - ensures that voltage-threshold events (``termination_events()``) have a - correct, positive starting value and can detect the first downward - crossing correctly. + the zero-order-held output port reflects the true open-circuit voltage + before the first macro-step fires. """ all_out_vars = self._pybamm_output_vars + ["Discharge capacity [A.h]"] casadi_objs = self._sim.built_model.export_casadi_objects( @@ -346,82 +296,20 @@ def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64 outputs.append(soc) self._last_outputs = np.array(outputs, dtype=np.float64) - self._just_stepped = True + V = outputs[0] + if V <= self._v_lower: + raise StopSimulation(f"undervoltage: V={V:.4f} V <= {self._v_lower} V") + if V >= self._v_upper: + raise StopSimulation(f"overvoltage: V={V:.4f} V >= {self._v_upper} V") return self._last_outputs - def update(self, t: float) -> None: - """Check voltage cut-off callbacks after each PyBaMM macro-step. - - PathSim calls ``update()`` on every block after each event resolves - (including after the internal :class:`~pathsim.events.Schedule` event - fires and updates outputs). The ``_just_stepped`` flag distinguishes - this post-step call from the earlier pre-event call where outputs are - still stale, ensuring the termination check only runs once per - macro-step and only after fresh outputs are available. - """ - if self._just_stepped: - self._just_stepped = False - V = float(self.outputs[0]) - for cb in self._term_callbacks: - cb(V) - def __len__(self) -> int: return len(self._pybamm_output_vars) + 1 - def termination_events(self, sim) -> list: - """Return PathSim events that mirror PyBaMM's voltage cut-off conditions. - - For co-simulation blocks PyBaMM's own solver clamps the terminal voltage - at the cut-off and stops advancing internally, but never signals PathSim. - This method registers **post-step callbacks** (called from - :meth:`update` after each :class:`~pathsim.events.Schedule` macro-step - fires) that call ``sim.stop()`` as soon as the clamped output is - detected. Two :class:`~pathsim.events.ZeroCrossingDown` events are - also returned for API consistency with - :class:`_CellBase.termination_events` — add them to the simulation - with ``sim.add_event(e)`` as a belt-and-suspenders complement for - adaptive-stepping scenarios. - - Parameters - ---------- - sim : pathsim.Simulation - The simulation this cell is part of. - - Returns - ------- - list[ZeroCrossingDown] - Two events (under-voltage, over-voltage). - """ - v_lower = self._v_lower - v_upper = self._v_upper - - def _under_cb(V: float) -> None: - if V <= v_lower: - sim.stop() - - def _over_cb(V: float) -> None: - if V >= v_upper: - sim.stop() - - self._term_callbacks.extend([_under_cb, _over_cb]) - - # Belt-and-suspenders PathSim events (API parity with _CellBase). - outputs = self.outputs - under_voltage = ZeroCrossingDown( - func_evt=lambda t: float(outputs[0]) - v_lower, - func_act=lambda t: sim.stop(), - ) - over_voltage = ZeroCrossingDown( - func_evt=lambda t: v_upper - float(outputs[0]), - func_act=lambda t: sim.stop(), - ) - return [under_voltage, over_voltage] - def reset(self) -> None: super().reset() self._sim = self._build_sim() self._last_outputs = self._initial_outputs() - self._just_stepped = False self.outputs.update_from_array(self._last_outputs) diff --git a/tests/cells/test_pybamm_cell.py b/tests/cells/test_pybamm_cell.py index 9a30182..321e7d2 100644 --- a/tests/cells/test_pybamm_cell.py +++ b/tests/cells/test_pybamm_cell.py @@ -556,13 +556,14 @@ def _run_and_get_T_cell(T_amb): class TestTerminationEvents(unittest.TestCase): - """Tests that termination_events() stops the simulation at voltage cut-offs. + """Tests that voltage cut-offs automatically stop the PathSim simulation. Both the non-CoSim (``_CellBase``) and CoSim (``_CoSimCellBase``) families are tested. A very low initial SOC combined with a high discharge current - ensures the lower voltage cut-off is reached quickly. Without the events - wired in, the non-CoSim cell would diverge to nonsensical negative voltages, - and the CoSim cell would continue forever at the clamped cut-off value. + ensures the lower voltage cut-off is reached quickly. + + Cut-offs are enforced by raising ``StopSimulation`` inside the block — no + user wiring is required. PyBaMM's ``Chen2020`` parameter set has: * Lower voltage cut-off: 2.5 V @@ -571,27 +572,8 @@ class TestTerminationEvents(unittest.TestCase): # -- helpers --------------------------------------------------------------- - def _run_with_events(self, cell, current, T_input_port, T_value, cosim_dt=None): - """Build a Simulation, wire termination events, and run for up to 600 s.""" - I_src = Constant(current) - T_src = Constant(T_value) - dt_ps = cosim_dt if cosim_dt is not None else 5.0 - sim = Simulation( - blocks=[I_src, T_src, cell], - connections=[ - Connection(I_src, cell["I"]), - Connection(T_src, cell[T_input_port]), - ], - dt=dt_ps, - Solver=ESDIRK43, - ) - for event in cell.termination_events(sim): - sim.add_event(event) - sim.run(600) - return sim - - def _run_without_events(self, cell, current, T_input_port, T_value, cosim_dt=None): - """Same but without registering termination events.""" + def _run(self, cell, current, T_input_port, T_value, cosim_dt=None): + """Build a Simulation and run for up to 600 s.""" I_src = Constant(current) T_src = Constant(T_value) dt_ps = cosim_dt if cosim_dt is not None else 5.0 @@ -610,65 +592,25 @@ def _run_without_events(self, cell, current, T_input_port, T_value, cosim_dt=Non # -- non-CoSim (CellElectrical) -------------------------------------------- def test_non_cosim_stops_before_negative_voltage(self): - """With events registered, CellElectrical must stop before V < 0. + """CellElectrical must stop automatically before V goes negative. - Without events PathSim integrates through the physical cutoff and the - ODE produces nonsensical negative voltages. With events wired in the - simulation must halt at or just above the lower cut-off. + ``StopSimulation`` is raised from ``func_alg`` the moment voltage + reaches the lower cut-off, so PathSim halts without any user wiring. """ cell = CellElectrical(initial_soc=0.02) - sim = self._run_with_events(cell, current=10.0, T_input_port="T_cell", T_value=298.15) + sim = self._run(cell, current=10.0, T_input_port="T_cell", T_value=298.15) V_final = float(cell.outputs[0]) - # Simulation must have stopped early (well before 600 s) self.assertLess(sim.time, 600.0, "simulation did not stop early") - # Voltage must be at or above the lower cut-off, never negative - self.assertGreaterEqual( - V_final, - cell._v_lower - 0.5, # small tolerance for step overshoot - f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V", - ) self.assertGreater( V_final, 0.0, - f"terminal voltage went negative ({V_final:.3f} V) despite termination events", + f"terminal voltage went negative ({V_final:.3f} V)", ) - - def test_non_cosim_without_events_continues_past_cutoff(self): - """Without events, CellElectrical integrates past the cut-off (regression guard). - - This test confirms the *bug* that termination_events() is designed to fix: - PathSim does not stop on its own, and the voltage diverges wildly. - """ - cell = CellElectrical(initial_soc=0.02) - sim = self._run_without_events(cell, current=10.0, T_input_port="T_cell", T_value=298.15) - V_final = float(cell.outputs[0]) - # Without events the simulation runs the full 600 s - self.assertAlmostEqual(sim.time, 600.0, delta=10.0) - # And the voltage should have diverged to a physically impossible value - self.assertLess( + self.assertGreaterEqual( V_final, - 0.0, - "expected voltage divergence without termination events, got " - f"V={V_final:.3f} V — the bug may have been fixed elsewhere", - ) - - def test_non_cosim_termination_events_returns_two_events(self): - """termination_events() must return exactly two ZeroCrossingDown instances.""" - from pathsim.events import ZeroCrossingDown - - cell = CellElectrical() - I_src = Constant(1.0) - T_src = Constant(298.15) - sim = Simulation( - blocks=[I_src, T_src, cell], - connections=[Connection(I_src, cell["I"]), Connection(T_src, cell["T_cell"])], - dt=1.0, - Solver=ESDIRK43, + cell._v_lower - 0.5, + f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V", ) - events = cell.termination_events(sim) - self.assertEqual(len(events), 2) - for e in events: - self.assertIsInstance(e, ZeroCrossingDown) def test_non_cosim_cutoff_values_match_parameter_values(self): """_v_lower/_v_upper must match the Chen2020 parameter set.""" @@ -679,15 +621,15 @@ def test_non_cosim_cutoff_values_match_parameter_values(self): # -- CoSim (CellCoSimElectrical) ------------------------------------------- - def test_cosim_stops_before_negative_voltage(self): - """With events registered, CellCoSimElectrical must stop at the cut-off. + def test_cosim_stops_at_cutoff(self): + """CellCoSimElectrical must stop automatically when voltage hits the cut-off. - Without events PyBaMM freezes internally but PathSim keeps ticking - forever with the clamped output (2.5 V). With events wired in, - PathSim must stop as soon as the voltage first touches the cut-off. + ``StopSimulation`` is raised from ``_discrete_step`` as soon as PyBaMM + clamps to the lower cut-off, so PathSim halts instead of running forever + with frozen output. """ cell = CellCoSimElectrical(initial_soc=0.02, dt=10.0) - sim = self._run_with_events( + sim = self._run( cell, current=10.0, T_input_port="T_cell", T_value=298.15, cosim_dt=10.0 ) V_final = float(cell.outputs[0]) @@ -698,44 +640,6 @@ def test_cosim_stops_before_negative_voltage(self): f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V", ) - def test_cosim_without_events_runs_full_duration(self): - """Without events, CellCoSimElectrical runs the full duration with frozen output. - - This is the CoSim-side symptom of the missing stop condition: PyBaMM - clamps at the cut-off and the simulation never terminates early. - """ - cell = CellCoSimElectrical(initial_soc=0.02, dt=10.0) - sim = self._run_without_events( - cell, current=10.0, T_input_port="T_cell", T_value=298.15, cosim_dt=10.0 - ) - # Simulation must have run the full 600 s (no early stop) - self.assertAlmostEqual(sim.time, 600.0, delta=10.0) - # Voltage must be frozen at the cut-off (PyBaMM clamps, does not diverge) - self.assertAlmostEqual( - float(cell.outputs[0]), - cell._v_lower, - delta=0.1, - msg="CoSim output should be clamped at lower cut-off without events", - ) - - def test_cosim_termination_events_returns_two_events(self): - """termination_events() must return exactly two ZeroCrossingDown instances.""" - from pathsim.events import ZeroCrossingDown - - cell = CellCoSimElectrical(dt=1.0) - I_src = Constant(1.0) - T_src = Constant(298.15) - sim = Simulation( - blocks=[I_src, T_src, cell], - connections=[Connection(I_src, cell["I"]), Connection(T_src, cell["T_cell"])], - dt=1.0, - Solver=ESDIRK43, - ) - events = cell.termination_events(sim) - self.assertEqual(len(events), 2) - for e in events: - self.assertIsInstance(e, ZeroCrossingDown) - def test_cosim_cutoff_values_match_parameter_values(self): """_v_lower/_v_upper must match the Chen2020 parameter set (CoSim block).""" pv = pybamm.ParameterValues("Chen2020") From d7363012b2dc19984478039a97b089d79b1e71a6 Mon Sep 17 00:00:00 2001 From: David Straub Date: Wed, 13 May 2026 08:59:06 +0200 Subject: [PATCH 3/5] Fix for DFN --- src/pathsim_batt/cells/pybamm_cell.py | 58 ++++++++++++++++++--------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/pathsim_batt/cells/pybamm_cell.py b/src/pathsim_batt/cells/pybamm_cell.py index cd4e1f6..d23d998 100644 --- a/src/pathsim_batt/cells/pybamm_cell.py +++ b/src/pathsim_batt/cells/pybamm_cell.py @@ -81,12 +81,15 @@ def __init__( ) self._parameter_values = _prepare_parameter_values(parameter_values) - self._v_lower = float( - self._parameter_values["Lower voltage cut-off [V]"] - ) - self._v_upper = float( - self._parameter_values["Upper voltage cut-off [V]"] - ) + try: + self._v_lower = float(self._parameter_values["Lower voltage cut-off [V]"]) + self._v_upper = float(self._parameter_values["Upper voltage cut-off [V]"]) + except KeyError as exc: + raise ValueError( + f"parameter_values is missing a voltage cut-off entry: {exc}. " + "Ensure your parameter set defines both 'Lower voltage cut-off [V]' " + "and 'Upper voltage cut-off [V]'." + ) from exc pybamm_solver = pybamm_solver or pybamm.CasadiSolver(mode="safe") @@ -221,12 +224,15 @@ def __init__( self._model = model self._parameter_values = _prepare_parameter_values(parameter_values) - self._v_lower = float( - self._parameter_values["Lower voltage cut-off [V]"] - ) - self._v_upper = float( - self._parameter_values["Upper voltage cut-off [V]"] - ) + try: + self._v_lower = float(self._parameter_values["Lower voltage cut-off [V]"]) + self._v_upper = float(self._parameter_values["Upper voltage cut-off [V]"]) + except KeyError as exc: + raise ValueError( + f"parameter_values is missing a voltage cut-off entry: {exc}. " + "Ensure your parameter set defines both 'Lower voltage cut-off [V]' " + "and 'Upper voltage cut-off [V]'." + ) from exc self._pybamm_solver = pybamm_solver or pybamm.IDAKLUSolver() self._q_nominal = float(self._parameter_values["Nominal cell capacity [A.h]"]) @@ -250,12 +256,15 @@ def _build_sim(self) -> pybamm.Simulation: return sim def _initial_outputs(self) -> npt.NDArray[np.float64]: - """Compute physically correct outputs at t=0 from the built PyBaMM model. + """Compute outputs at t=0 from the built PyBaMM model using default inputs. Uses the same CasADi export approach as ``_CellBase`` to evaluate each - output variable at the initial state vector and zero current, so that - the zero-order-held output port reflects the true open-circuit voltage - before the first macro-step fires. + output variable at the initial state vector. The evaluation uses + ``_DEFAULT_INPUTS`` (0 A current, 298.15 K ambient temperature) because + the wired input ports are not yet available at construction time. The + resulting open-circuit voltage is therefore physically meaningful but does + not account for a non-default initial temperature or a non-zero current at + t=0. """ all_out_vars = self._pybamm_output_vars + ["Discharge capacity [A.h]"] casadi_objs = self._sim.built_model.export_casadi_objects( @@ -264,19 +273,27 @@ def _initial_outputs(self) -> npt.NDArray[np.float64]: ) t_sym = casadi_objs["t"] x_sym = casadi_objs["x"] + z_sym = casadi_objs["z"] p_sym = casadi_objs["inputs"] p0 = casadi.DM(list(_DEFAULT_INPUTS.values())) x0 = casadi.Function("x0", [p_sym], [casadi_objs["x0"]])(p0) + # Algebraic initial conditions (empty for ODE models such as SPMe; + # non-empty for DAE models such as DFN). + z0 = casadi.Function("z0", [p_sym], [casadi_objs["z0"]])(p0) outputs: list[float] = [] for name in self._pybamm_output_vars: - fn = casadi.Function("v", [t_sym, x_sym, p_sym], [casadi_objs["variables"][name]]) - outputs.append(float(fn(0.0, x0, p0))) + fn = casadi.Function( + "v", [t_sym, x_sym, z_sym, p_sym], [casadi_objs["variables"][name]] + ) + outputs.append(float(fn(0.0, x0, z0, p0))) q_dis_fn = casadi.Function( - "q", [t_sym, x_sym, p_sym], [casadi_objs["variables"]["Discharge capacity [A.h]"]] + "q", + [t_sym, x_sym, z_sym, p_sym], + [casadi_objs["variables"]["Discharge capacity [A.h]"]], ) - q_dis = float(q_dis_fn(0.0, x0, p0)) + q_dis = float(q_dis_fn(0.0, x0, z0, p0)) soc = max(0.0, min(1.0, self._initial_soc - q_dis / self._q_nominal)) outputs.append(soc) @@ -296,6 +313,7 @@ def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64 outputs.append(soc) self._last_outputs = np.array(outputs, dtype=np.float64) + self.outputs.update_from_array(self._last_outputs) V = outputs[0] if V <= self._v_lower: raise StopSimulation(f"undervoltage: V={V:.4f} V <= {self._v_lower} V") From 5ee6a951783e265733d87ae150d6b985b980e281 Mon Sep 17 00:00:00 2001 From: David Straub Date: Wed, 13 May 2026 09:37:35 +0200 Subject: [PATCH 4/5] Fix --- src/pathsim_batt/cells/pybamm_cell.py | 32 ++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/pathsim_batt/cells/pybamm_cell.py b/src/pathsim_batt/cells/pybamm_cell.py index d23d998..3ec88c4 100644 --- a/src/pathsim_batt/cells/pybamm_cell.py +++ b/src/pathsim_batt/cells/pybamm_cell.py @@ -23,6 +23,11 @@ "Ambient temperature [K]": 298.15, } +# The PyBaMM variable name used for voltage cut-off detection. +# Both base classes locate it by name in ``_pybamm_output_vars`` at construction +# time, so subclasses may place it at any position in the list. +_TERMINAL_VOLTAGE_VAR = "Terminal voltage [V]" + def _prepare_parameter_values( parameter_values: pybamm.ParameterValues | None, @@ -59,7 +64,9 @@ class _CellBase(DynamicalSystem): Subclasses set ``_thermal_option`` and ``_pybamm_output_vars`` to select the thermal sub-model and define which PyBaMM variables map to the block's - output ports (SOC is always appended last). + output ports (SOC is always appended last). ``_pybamm_output_vars`` must + contain ``_TERMINAL_VOLTAGE_VAR``; its position in the list is found + dynamically. """ _thermal_option: str = "" @@ -75,6 +82,14 @@ def __init__( ) -> None: self._initial_soc = float(initial_soc) + try: + self._v_idx = self._pybamm_output_vars.index(_TERMINAL_VOLTAGE_VAR) + except ValueError: + raise TypeError( + f"{type(self).__name__}._pybamm_output_vars must contain " + f"'{_TERMINAL_VOLTAGE_VAR}'." + ) from None + if model is None: model = pybamm.lithium_ion.SPMe( options={"thermal": self._thermal_option, **self._thermal_extra_options} @@ -155,6 +170,7 @@ def jac_dyn(x, u, t): v_lower = self._v_lower v_upper = self._v_upper + v_idx = self._v_idx def func_alg(x, u, t): xv = casadi.DM(x.reshape(-1, 1)) @@ -163,7 +179,7 @@ def func_alg(x, u, t): q_dis = float(out_var_fns["Discharge capacity [A.h]"](t, xv, p)) soc = max(0.0, min(1.0, initial_soc_val - q_dis / q_nominal)) outputs.append(soc) - V = outputs[0] + V = outputs[v_idx] if V <= v_lower: raise StopSimulation(f"undervoltage: V={V:.4f} V <= {v_lower} V") if V >= v_upper: @@ -198,6 +214,8 @@ class _CoSimCellBase(Wrapper): differential-algebraic solve internally. Subclasses set ``_thermal_option``, ``_pybamm_output_vars`` and port labels. + ``_pybamm_output_vars`` must contain ``_TERMINAL_VOLTAGE_VAR``; its position + in the list is found dynamically. """ _thermal_option: str = "" @@ -213,6 +231,14 @@ def __init__( dt: float = 1.0, ) -> None: self._initial_soc = float(initial_soc) + + try: + self._v_idx = self._pybamm_output_vars.index(_TERMINAL_VOLTAGE_VAR) + except ValueError: + raise TypeError( + f"{type(self).__name__}._pybamm_output_vars must contain " + f"'{_TERMINAL_VOLTAGE_VAR}'." + ) from None self._dt = float(dt) if self._dt <= 0.0: raise ValueError("dt must be positive") @@ -314,7 +340,7 @@ def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64 self._last_outputs = np.array(outputs, dtype=np.float64) self.outputs.update_from_array(self._last_outputs) - V = outputs[0] + V = outputs[self._v_idx] if V <= self._v_lower: raise StopSimulation(f"undervoltage: V={V:.4f} V <= {self._v_lower} V") if V >= self._v_upper: From 1cccc7ef003c83b482254e7b730b7c1fc5a3dbda Mon Sep 17 00:00:00 2001 From: David Straub Date: Thu, 14 May 2026 15:52:32 +0200 Subject: [PATCH 5/5] Bump pathsim to 0.22 --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 33772a8..4b44d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,7 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - # TODO: unpin once StopSimulation is released to PyPI - "pathsim @ git+https://github.com/pathsim/pathsim.git@fca3978586a887d0178422a64d48b80537856e48", + "pathsim>=0.22", "pybamm>=25.12", ]