Skip to content

Commit 0bd6114

Browse files
authored
Implement voltage cutoffs (#14)
* Implement voltage cutoffs * New approach with StopSimulation * Fix for DFN * Fix * Bump pathsim to 0.22
1 parent 9dcb165 commit 0bd6114

3 files changed

Lines changed: 195 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ classifiers = [
2525
"Topic :: Scientific/Engineering",
2626
]
2727
dependencies = [
28-
"pathsim>=0.18",
28+
"pathsim>=0.22",
2929
"pybamm>=25.12",
3030
]
3131

src/pathsim_batt/cells/pybamm_cell.py

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import numpy.typing as npt
1515
import pybamm
1616
from pathsim.blocks import DynamicalSystem, Wrapper
17+
from pathsim.exceptions import StopSimulation
1718

1819
# HELPERS =============================================================================
1920

@@ -22,6 +23,11 @@
2223
"Ambient temperature [K]": 298.15,
2324
}
2425

26+
# The PyBaMM variable name used for voltage cut-off detection.
27+
# Both base classes locate it by name in ``_pybamm_output_vars`` at construction
28+
# time, so subclasses may place it at any position in the list.
29+
_TERMINAL_VOLTAGE_VAR = "Terminal voltage [V]"
30+
2531

2632
def _prepare_parameter_values(
2733
parameter_values: pybamm.ParameterValues | None,
@@ -58,7 +64,9 @@ class _CellBase(DynamicalSystem):
5864
5965
Subclasses set ``_thermal_option`` and ``_pybamm_output_vars`` to select the
6066
thermal sub-model and define which PyBaMM variables map to the block's
61-
output ports (SOC is always appended last).
67+
output ports (SOC is always appended last). ``_pybamm_output_vars`` must
68+
contain ``_TERMINAL_VOLTAGE_VAR``; its position in the list is found
69+
dynamically.
6270
"""
6371

6472
_thermal_option: str = ""
@@ -74,12 +82,29 @@ def __init__(
7482
) -> None:
7583
self._initial_soc = float(initial_soc)
7684

85+
try:
86+
self._v_idx = self._pybamm_output_vars.index(_TERMINAL_VOLTAGE_VAR)
87+
except ValueError:
88+
raise TypeError(
89+
f"{type(self).__name__}._pybamm_output_vars must contain "
90+
f"'{_TERMINAL_VOLTAGE_VAR}'."
91+
) from None
92+
7793
if model is None:
7894
model = pybamm.lithium_ion.SPMe(
7995
options={"thermal": self._thermal_option, **self._thermal_extra_options}
8096
)
8197

8298
self._parameter_values = _prepare_parameter_values(parameter_values)
99+
try:
100+
self._v_lower = float(self._parameter_values["Lower voltage cut-off [V]"])
101+
self._v_upper = float(self._parameter_values["Upper voltage cut-off [V]"])
102+
except KeyError as exc:
103+
raise ValueError(
104+
f"parameter_values is missing a voltage cut-off entry: {exc}. "
105+
"Ensure your parameter set defines both 'Lower voltage cut-off [V]' "
106+
"and 'Upper voltage cut-off [V]'."
107+
) from exc
83108

84109
pybamm_solver = pybamm_solver or pybamm.CasadiSolver(mode="safe")
85110

@@ -143,13 +168,22 @@ def jac_dyn(x, u, t):
143168
p = _pack(u)
144169
return np.array(jac_fn(t, xv, p))
145170

171+
v_lower = self._v_lower
172+
v_upper = self._v_upper
173+
v_idx = self._v_idx
174+
146175
def func_alg(x, u, t):
147176
xv = casadi.DM(x.reshape(-1, 1))
148177
p = _pack(u)
149178
outputs = [float(out_var_fns[n](t, xv, p)) for n in pybamm_output_vars]
150179
q_dis = float(out_var_fns["Discharge capacity [A.h]"](t, xv, p))
151180
soc = max(0.0, min(1.0, initial_soc_val - q_dis / q_nominal))
152181
outputs.append(soc)
182+
V = outputs[v_idx]
183+
if V <= v_lower:
184+
raise StopSimulation(f"undervoltage: V={V:.4f} V <= {v_lower} V")
185+
if V >= v_upper:
186+
raise StopSimulation(f"overvoltage: V={V:.4f} V >= {v_upper} V")
153187
return np.array(outputs)
154188

155189
x0_fn = casadi.Function("x0", [p_sym], [casadi_objs["x0"]])
@@ -180,6 +214,8 @@ class _CoSimCellBase(Wrapper):
180214
differential-algebraic solve internally.
181215
182216
Subclasses set ``_thermal_option``, ``_pybamm_output_vars`` and port labels.
217+
``_pybamm_output_vars`` must contain ``_TERMINAL_VOLTAGE_VAR``; its position
218+
in the list is found dynamically.
183219
"""
184220

185221
_thermal_option: str = ""
@@ -195,6 +231,14 @@ def __init__(
195231
dt: float = 1.0,
196232
) -> None:
197233
self._initial_soc = float(initial_soc)
234+
235+
try:
236+
self._v_idx = self._pybamm_output_vars.index(_TERMINAL_VOLTAGE_VAR)
237+
except ValueError:
238+
raise TypeError(
239+
f"{type(self).__name__}._pybamm_output_vars must contain "
240+
f"'{_TERMINAL_VOLTAGE_VAR}'."
241+
) from None
198242
self._dt = float(dt)
199243
if self._dt <= 0.0:
200244
raise ValueError("dt must be positive")
@@ -206,6 +250,15 @@ def __init__(
206250

207251
self._model = model
208252
self._parameter_values = _prepare_parameter_values(parameter_values)
253+
try:
254+
self._v_lower = float(self._parameter_values["Lower voltage cut-off [V]"])
255+
self._v_upper = float(self._parameter_values["Upper voltage cut-off [V]"])
256+
except KeyError as exc:
257+
raise ValueError(
258+
f"parameter_values is missing a voltage cut-off entry: {exc}. "
259+
"Ensure your parameter set defines both 'Lower voltage cut-off [V]' "
260+
"and 'Upper voltage cut-off [V]'."
261+
) from exc
209262
self._pybamm_solver = pybamm_solver or pybamm.IDAKLUSolver()
210263
self._q_nominal = float(self._parameter_values["Nominal cell capacity [A.h]"])
211264

@@ -229,15 +282,48 @@ def _build_sim(self) -> pybamm.Simulation:
229282
return sim
230283

231284
def _initial_outputs(self) -> npt.NDArray[np.float64]:
232-
"""Return placeholder outputs for t=0 before the first solver step.
233-
234-
The co-simulation takes its first real sample at t=dt, so this
235-
placeholder is only held for one macro-step. All outputs are zero
236-
except SOC, which is set to the user-supplied initial value.
285+
"""Compute outputs at t=0 from the built PyBaMM model using default inputs.
286+
287+
Uses the same CasADi export approach as ``_CellBase`` to evaluate each
288+
output variable at the initial state vector. The evaluation uses
289+
``_DEFAULT_INPUTS`` (0 A current, 298.15 K ambient temperature) because
290+
the wired input ports are not yet available at construction time. The
291+
resulting open-circuit voltage is therefore physically meaningful but does
292+
not account for a non-default initial temperature or a non-zero current at
293+
t=0.
237294
"""
238-
out = np.zeros(len(self._pybamm_output_vars) + 1, dtype=np.float64)
239-
out[-1] = self._initial_soc # SOC is always the last output
240-
return out
295+
all_out_vars = self._pybamm_output_vars + ["Discharge capacity [A.h]"]
296+
casadi_objs = self._sim.built_model.export_casadi_objects(
297+
all_out_vars,
298+
input_parameter_order=list(_DEFAULT_INPUTS.keys()),
299+
)
300+
t_sym = casadi_objs["t"]
301+
x_sym = casadi_objs["x"]
302+
z_sym = casadi_objs["z"]
303+
p_sym = casadi_objs["inputs"]
304+
p0 = casadi.DM(list(_DEFAULT_INPUTS.values()))
305+
x0 = casadi.Function("x0", [p_sym], [casadi_objs["x0"]])(p0)
306+
# Algebraic initial conditions (empty for ODE models such as SPMe;
307+
# non-empty for DAE models such as DFN).
308+
z0 = casadi.Function("z0", [p_sym], [casadi_objs["z0"]])(p0)
309+
310+
outputs: list[float] = []
311+
for name in self._pybamm_output_vars:
312+
fn = casadi.Function(
313+
"v", [t_sym, x_sym, z_sym, p_sym], [casadi_objs["variables"][name]]
314+
)
315+
outputs.append(float(fn(0.0, x0, z0, p0)))
316+
317+
q_dis_fn = casadi.Function(
318+
"q",
319+
[t_sym, x_sym, z_sym, p_sym],
320+
[casadi_objs["variables"]["Discharge capacity [A.h]"]],
321+
)
322+
q_dis = float(q_dis_fn(0.0, x0, z0, p0))
323+
soc = max(0.0, min(1.0, self._initial_soc - q_dis / self._q_nominal))
324+
outputs.append(soc)
325+
326+
return np.array(outputs, dtype=np.float64)
241327

242328
def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64]:
243329
inputs = {
@@ -253,10 +339,13 @@ def _discrete_step(self, current: float, t_amb: float) -> npt.NDArray[np.float64
253339
outputs.append(soc)
254340

255341
self._last_outputs = np.array(outputs, dtype=np.float64)
256-
return self._last_outputs
257-
258-
def update(self, t: float) -> None:
259342
self.outputs.update_from_array(self._last_outputs)
343+
V = outputs[self._v_idx]
344+
if V <= self._v_lower:
345+
raise StopSimulation(f"undervoltage: V={V:.4f} V <= {self._v_lower} V")
346+
if V >= self._v_upper:
347+
raise StopSimulation(f"overvoltage: V={V:.4f} V >= {self._v_upper} V")
348+
return self._last_outputs
260349

261350
def __len__(self) -> int:
262351
return len(self._pybamm_output_vars) + 1

tests/cells/test_pybamm_cell.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,5 +555,98 @@ def _run_and_get_T_cell(T_amb):
555555
)
556556

557557

558+
class TestTerminationEvents(unittest.TestCase):
559+
"""Tests that voltage cut-offs automatically stop the PathSim simulation.
560+
561+
Both the non-CoSim (``_CellBase``) and CoSim (``_CoSimCellBase``) families
562+
are tested. A very low initial SOC combined with a high discharge current
563+
ensures the lower voltage cut-off is reached quickly.
564+
565+
Cut-offs are enforced by raising ``StopSimulation`` inside the block — no
566+
user wiring is required.
567+
568+
PyBaMM's ``Chen2020`` parameter set has:
569+
* Lower voltage cut-off: 2.5 V
570+
* Upper voltage cut-off: 4.2 V
571+
"""
572+
573+
# -- helpers ---------------------------------------------------------------
574+
575+
def _run(self, cell, current, T_input_port, T_value, cosim_dt=None):
576+
"""Build a Simulation and run for up to 600 s."""
577+
I_src = Constant(current)
578+
T_src = Constant(T_value)
579+
dt_ps = cosim_dt if cosim_dt is not None else 5.0
580+
sim = Simulation(
581+
blocks=[I_src, T_src, cell],
582+
connections=[
583+
Connection(I_src, cell["I"]),
584+
Connection(T_src, cell[T_input_port]),
585+
],
586+
dt=dt_ps,
587+
Solver=ESDIRK43,
588+
)
589+
sim.run(600)
590+
return sim
591+
592+
# -- non-CoSim (CellElectrical) --------------------------------------------
593+
594+
def test_non_cosim_stops_before_negative_voltage(self):
595+
"""CellElectrical must stop automatically before V goes negative.
596+
597+
``StopSimulation`` is raised from ``func_alg`` the moment voltage
598+
reaches the lower cut-off, so PathSim halts without any user wiring.
599+
"""
600+
cell = CellElectrical(initial_soc=0.02)
601+
sim = self._run(cell, current=10.0, T_input_port="T_cell", T_value=298.15)
602+
V_final = float(cell.outputs[0])
603+
self.assertLess(sim.time, 600.0, "simulation did not stop early")
604+
self.assertGreater(
605+
V_final,
606+
0.0,
607+
f"terminal voltage went negative ({V_final:.3f} V)",
608+
)
609+
self.assertGreaterEqual(
610+
V_final,
611+
cell._v_lower - 0.5,
612+
f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V",
613+
)
614+
615+
def test_non_cosim_cutoff_values_match_parameter_values(self):
616+
"""_v_lower/_v_upper must match the Chen2020 parameter set."""
617+
pv = pybamm.ParameterValues("Chen2020")
618+
cell = CellElectrical(parameter_values=pv)
619+
self.assertAlmostEqual(cell._v_lower, float(pv["Lower voltage cut-off [V]"]))
620+
self.assertAlmostEqual(cell._v_upper, float(pv["Upper voltage cut-off [V]"]))
621+
622+
# -- CoSim (CellCoSimElectrical) -------------------------------------------
623+
624+
def test_cosim_stops_at_cutoff(self):
625+
"""CellCoSimElectrical must stop automatically when voltage hits the cut-off.
626+
627+
``StopSimulation`` is raised from ``_discrete_step`` as soon as PyBaMM
628+
clamps to the lower cut-off, so PathSim halts instead of running forever
629+
with frozen output.
630+
"""
631+
cell = CellCoSimElectrical(initial_soc=0.02, dt=10.0)
632+
sim = self._run(
633+
cell, current=10.0, T_input_port="T_cell", T_value=298.15, cosim_dt=10.0
634+
)
635+
V_final = float(cell.outputs[0])
636+
self.assertLess(sim.time, 600.0, "CoSim simulation did not stop early")
637+
self.assertGreaterEqual(
638+
V_final,
639+
cell._v_lower - 0.5,
640+
f"terminal voltage {V_final:.3f} V is far below cut-off {cell._v_lower} V",
641+
)
642+
643+
def test_cosim_cutoff_values_match_parameter_values(self):
644+
"""_v_lower/_v_upper must match the Chen2020 parameter set (CoSim block)."""
645+
pv = pybamm.ParameterValues("Chen2020")
646+
cell = CellCoSimElectrical(parameter_values=pv, dt=1.0)
647+
self.assertAlmostEqual(cell._v_lower, float(pv["Lower voltage cut-off [V]"]))
648+
self.assertAlmostEqual(cell._v_upper, float(pv["Upper voltage cut-off [V]"]))
649+
650+
558651
if __name__ == "__main__":
559652
unittest.main()

0 commit comments

Comments
 (0)