Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,17 @@ jobs:
matrix:
include:
# All Python versions against the latest PyBaMM
- {python-version: "3.10", pybamm-version: "26.6"}
- {python-version: "3.11", pybamm-version: "26.6"}
- {python-version: "3.12", pybamm-version: "26.6"}
- {python-version: "3.13", pybamm-version: "26.6"}
- {python-version: "3.14", pybamm-version: "26.6"}
- {python-version: "3.10", pybamm-version: "26.7"}
- {python-version: "3.11", pybamm-version: "26.7"}
- {python-version: "3.12", pybamm-version: "26.7"}
- {python-version: "3.13", pybamm-version: "26.7"}
- {python-version: "3.14", pybamm-version: "26.7"}
# Older PyBaMM versions against the latest Python they support
- {python-version: "3.13", pybamm-version: "25.12"}
- {python-version: "3.14", pybamm-version: "26.3"}
- {python-version: "3.14", pybamm-version: "26.4"}
- {python-version: "3.14", pybamm-version: "26.5"}
- {python-version: "3.14", pybamm-version: "26.6"}

steps:
- uses: actions/checkout@v4
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,15 @@ Thermal sub-model and heat-source options are injected automatically — pass th
| `lithium_ion.SPM` | `Chen2020` | ✅ | ✅ | ✅ | ✅ |
| `lithium_ion.SPMe` | `Chen2020` | ✅ | ✅ | ✅ | ✅ |
| `lithium_ion.DFN` | `Chen2020` | ❌ DAE | ❌ DAE | ✅ | ✅ |
| `lead_acid.LOQS` | `Sulzer2019` | ✅ | ✅ | ✅ ¹ | ✅ ¹ |
| `lead_acid.LOQS` | `Sulzer2019` | ✅ ¹ | ✅ ¹ | ✅ ² | ✅ ² |
| `lead_acid.Full` | `Sulzer2019` | ❌ DAE | ❌ DAE | ✅ | ✅ |
| `equivalent_circuit.Thevenin` | `ECM_Example` | ✅ | ✅ | ✅ ² | ✅ ² |
| `equivalent_circuit.Thevenin` | `ECM_Example` | ✅ | ✅ | ✅ ³ | ✅ ³ |

¹ Pass `pybamm_solver=pybamm.CasadiSolver(mode="safe")` — the default `IDAKLUSolver` requires a Jacobian that ODE models do not provide.
¹ PyBaMM < 26.7 only — from 26.7 on `LOQS` is a DAE, use a `CellCoSim*` block instead.

² `initial_soc=1.0` fails because PyBaMM requires event values to be strictly positive at `t=0`; the "Maximum SoC" event is zero exactly at full charge. Any value below 1.0 (e.g. `initial_soc=0.99`) works.
² PyBaMM < 26.7 only — pass `pybamm_solver=pybamm.CasadiSolver(mode="safe")`; the default `IDAKLUSolver` errors on `LOQS`. Fixed in 26.7.

³ `initial_soc=1.0` fails because PyBaMM requires event values to be strictly positive at `t=0`; the "Maximum SoC" event is zero exactly at full charge. Any value below 1.0 (e.g. `initial_soc=0.99`) works.

```python
import pybamm
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ dependencies = [
# marker resolves to a normal eager install on every real platform and
# is skipped on Emscripten so the pure-Python parts of the toolbox
# (`pathsim_batt.thermal`) still install in the browser.
"pybamm>=25.12; sys_platform != 'emscripten'",
# Upper-bounded: each new minor release is vetted before being allowed.
"pybamm>=25.12,<26.8; sys_platform != 'emscripten'",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -71,6 +72,6 @@ select = ["E", "F", "W", "I"]

[tool.mypy]
files = ["src/pathsim_batt"]
python_version = "3.10"
python_version = "3.12"
ignore_missing_imports = true
warn_unused_ignores = true
66 changes: 44 additions & 22 deletions src/pathsim_batt/cells/pybamm_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,49 +133,70 @@ def _detect_soc_direct_scale(
return 1.0 / 100.0 if raw > 1.0 else 1.0


def _inject_thermal_options(
def _inject_model_options(
model: pybamm.BaseBatteryModel,
required_options: dict[str, str],
guard_key: str | None = None,
) -> pybamm.BaseBatteryModel:
"""Return *model* with *required_options* merged into its options if needed.

Handles both the thermal sub-model selection (``"thermal": "isothermal"``
or ``"lumped"``) and ancillary flags such as
``"calculate heat source for isothermal models": "true"``. This means
users can pass a plain ``pybamm.lead_acid.LOQS()`` to
``CellElectrothermal`` without having to specify ``thermal='lumped'``
themselves — the block injects it automatically.

Models that already carry the required options are returned unchanged.
Models that have no ``"thermal"`` key in their options at all (e.g. ECM,
which manages temperature internally) are skipped silently — injection
does not apply to them. Models that expose ``"thermal"`` but whose
constructor rejects the specific option values emit a ``UserWarning``
and are returned unchanged.
If *guard_key* is given and is not a key in *model*'s options at all
(e.g. ``"thermal"`` for ECM, which manages temperature internally, or
``"voltage as a state"`` for models that don't expose that switch),
injection is skipped silently — it does not apply to them. Models that
expose the option(s) but whose constructor rejects the specific values
emit a ``UserWarning`` and are returned unchanged.
"""
if not required_options:
return model
# Models that have no "thermal" key in their options (e.g. ECM) manage
# temperature through their own internal mechanism and do not use PyBaMM's
# thermal sub-model system. Injection is not applicable; skip silently.
if "thermal" in required_options and "thermal" not in model.options:
if guard_key is not None and guard_key not in model.options:
return model
if all(model.options.get(k) == v for k, v in required_options.items()):
return model
try:
return type(model)(options={**dict(model.options), **required_options})
except (pybamm.OptionError, TypeError):
except (pybamm.OptionError, TypeError, KeyError):
import warnings

warnings.warn(
f"{type(model).__name__} does not support options {required_options}; "
"thermal behaviour may be incorrect.",
"behaviour may be incorrect.",
UserWarning,
stacklevel=4,
)
return model


def _inject_thermal_options(
model: pybamm.BaseBatteryModel,
required_options: dict[str, str],
) -> pybamm.BaseBatteryModel:
"""Return *model* with thermal *required_options* merged into its options.

Handles both the thermal sub-model selection (``"thermal": "isothermal"``
or ``"lumped"``) and ancillary flags such as
``"calculate heat source for isothermal models": "true"``. This means
users can pass a plain ``pybamm.lead_acid.LOQS()`` to
``CellElectrothermal`` without having to specify ``thermal='lumped'``
themselves — the block injects it automatically.

Models that have no ``"thermal"`` key in their options at all (e.g. ECM,
which manages temperature internally) are skipped silently.
"""
return _inject_model_options(model, required_options, guard_key="thermal")


# "surface form" isn't a valid option outside SPM/SPMe, so it's only added there.
def _inject_ode_options(model: pybamm.BaseBatteryModel) -> pybamm.BaseBatteryModel:
if "voltage as a state" not in model.options:
return model
required = {"voltage as a state": "false"}
if isinstance(model, (pybamm.lithium_ion.SPM, pybamm.lithium_ion.SPMe)):
required["surface form"] = "false"
return _inject_model_options(model, required)


def _build_simulation(
sim: pybamm.Simulation,
model: pybamm.BaseBatteryModel,
Expand Down Expand Up @@ -268,6 +289,7 @@ def __init__(
model,
{"thermal": self._thermal_option, **self._thermal_extra_options},
)
model = _inject_ode_options(model)

self._parameter_values = _prepare_parameter_values(parameter_values)
try:
Expand All @@ -280,7 +302,7 @@ def __init__(
"and 'Upper voltage cut-off [V]'."
) from exc

pybamm_solver = pybamm_solver or pybamm.CasadiSolver(mode="safe")
pybamm_solver = pybamm_solver or pybamm.IDAKLUSolver()

sim = pybamm.Simulation(
model,
Expand Down Expand Up @@ -605,7 +627,7 @@ class CellElectrical(_CellBase):
Initial state of charge (0–1). Default 1.0.
pybamm_solver : pybamm.BaseSolver or None
PyBaMM solver used only during model build / discretisation.
Defaults to ``CasadiSolver(mode="safe")``.
Defaults to ``IDAKLUSolver()``.

Inputs
------
Expand Down Expand Up @@ -654,7 +676,7 @@ class CellElectrothermal(_CellBase):
Initial state of charge (0–1). Default 1.0.
pybamm_solver : pybamm.BaseSolver or None
PyBaMM solver used only during model build / discretisation.
Defaults to ``CasadiSolver(mode="safe")``.
Defaults to ``IDAKLUSolver()``.

Inputs
------
Expand Down
39 changes: 34 additions & 5 deletions tests/cells/test_lead_acid.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Block / model matrix covered
-----------------------------
lead_acid.LOQS — ODE → all 4 blocks
lead_acid.LOQS — ODE on PyBaMM < 26.7, DAE from 26.7 on → CoSim blocks only there
lead_acid.Full — DAE → CoSim blocks only
"""

Expand All @@ -29,15 +29,24 @@
run_electrothermal,
)

# PyBaMM 26.7 registers "voltage as a state" centrally on every
# BaseBatteryModel (default "true"), and lead-acid models don't support
# disabling it, so LOQS is a DAE from 26.7 on and can no longer run in the
# monolithic (ODE-only) blocks.
_PYBAMM_VERSION = tuple(int(x) for x in pybamm.__version__.split(".")[:2])
_LOQS_IS_ODE = _PYBAMM_VERSION < (26, 7)

# ---------------------------------------------------------------------------
# lead_acid.LOQS (ODE — all 4 blocks)
# ---------------------------------------------------------------------------


class TestLeadAcidLOQS(unittest.TestCase):
"""lead_acid.LOQS with Sulzer2019 parameters (ODE model — all blocks).
"""lead_acid.LOQS with Sulzer2019 parameters.

Sulzer2019 cutoffs: lower 1.75 V, upper 2.42 V, nominal capacity 17 A·h.
ODE model (all 4 blocks) on PyBaMM < 26.7; DAE (CoSim blocks only) from
26.7 on. Sulzer2019 cutoffs: lower 1.75 V, upper 2.42 V, nominal capacity
17 A·h.
"""

def setUp(self):
Expand All @@ -48,17 +57,32 @@ def setUp(self):
def _model(self):
return pybamm.lead_acid.LOQS()

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_electrical_smoke(self):
cell = run_electrical(self._model(), self.pv, current=17.0)
assert_electrical_outputs(self, cell, self.v_lo, self.v_hi)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_electrothermal_smoke(self):
cell = run_electrothermal(self._model(), self.pv, current=17.0)
assert_electrothermal_outputs(self, cell, self.v_lo, self.v_hi)

@unittest.skipIf(_LOQS_IS_ODE, "LOQS is a pure ODE on PyBaMM < 26.7")
def test_monolithic_electrical_raises(self):
"""LOQS is a DAE on PyBaMM >= 26.7 — CellElectrical must raise."""
with self.assertRaises(NotImplementedError):
CellElectrical(model=self._model(), parameter_values=self.pv)

@unittest.skipIf(_LOQS_IS_ODE, "LOQS is a pure ODE on PyBaMM < 26.7")
def test_monolithic_electrothermal_raises(self):
"""LOQS is a DAE on PyBaMM >= 26.7 — CellElectrothermal must raise."""
with self.assertRaises(NotImplementedError):
CellElectrothermal(model=self._model(), parameter_values=self.pv)

def test_cosim_electrical_smoke(self):
# LOQS is an ODE; IDAKLUSolver (co-sim default) requires a Jacobian
# for ODE models and errors, so use CasadiSolver explicitly.
# On PyBaMM < 26.7, LOQS disables its Jacobian and IDAKLUSolver (the
# co-sim default) errors without one; CasadiSolver works on every
# pinned version (25.12-26.7), so use it explicitly here.
solver = pybamm.CasadiSolver(mode="safe")
cell = CellCoSimElectrical(
model=self._model(),
Expand Down Expand Up @@ -102,16 +126,19 @@ def test_cosim_electrothermal_smoke(self):
sim.run(2)
assert_electrothermal_outputs(self, cell, self.v_lo, self.v_hi)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_electrical_soc_decreases(self):
"""SOC must decrease under discharge current."""
cell = run_electrical(self._model(), self.pv, current=17.0, duration=60)
self.assertLess(float(cell.outputs[2]), 1.0)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_cutoff_values_match_parameter_set(self):
cell = CellElectrical(model=self._model(), parameter_values=self.pv)
self.assertAlmostEqual(cell._v_lower, self.v_lo)
self.assertAlmostEqual(cell._v_upper, self.v_hi)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_q_dot_nonzero_during_discharge(self):
"""Q_dot must be strictly positive during discharge (isothermal LOQS).

Expand All @@ -125,6 +152,7 @@ def test_q_dot_nonzero_during_discharge(self):
"Q_dot is zero — thermal model may not compute heat sources",
)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_tamb_affects_temperature(self):
"""A warmer ambient temperature must yield a higher output cell temperature."""
solver = pybamm.CasadiSolver(mode="safe")
Expand Down Expand Up @@ -155,6 +183,7 @@ def test_tamb_affects_temperature(self):
),
)

@unittest.skipUnless(_LOQS_IS_ODE, "LOQS is a DAE on PyBaMM >= 26.7")
def test_soc_scale_factor(self):
"""SOC must be well below 1.0 after sustained discharge.

Expand Down
Loading