1414import numpy .typing as npt
1515import pybamm
1616from pathsim .blocks import DynamicalSystem , Wrapper
17+ from pathsim .exceptions import StopSimulation
1718
1819# HELPERS =============================================================================
1920
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
2632def _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
0 commit comments