Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/fundamentals/pybam

# Telemetry
https://us.i.posthog.com

# Live site behind a LiteSpeed anti-bot WAF that returns 415 to CI/datacenter
# IPs (works fine from browsers and locally) — false positive, not a dead link
https://bpxstandard.com/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# [Unreleased](https://github.com/pybamm-team/PyBaMM/)

# [v26.6.1.2](https://github.com/pybamm-team/PyBaMM/tree/v26.6.1.2) - 2026-06-15

## Bug fixes

- `RegulariseSqrtAndPower` no longer regularises state-independent bases, fixing corrupted small rate constants in exchange-current density functions. ([#5600](https://github.com/pybamm-team/PyBaMM/pull/5600))
- Fixed `Serialise.load_custom_model` leaving a loaded lithium-ion model's cached `param` built from default options instead of the restored ones, so a model loaded with non-default options (e.g. composite `"particle phases"`) had parameters inconsistent with its options. ([#5599](https://github.com/pybamm-team/PyBaMM/pull/5599))
- Fixed `BatteryModelOptions` letting two invalid MSMR option sets pass validation and then crash during parameter construction with `invalid literal for int() with base 10: 'none'`; both now raise a clear `OptionError`. The all-or-nothing MSMR check now detects MSMR inside a per-electrode tuple (e.g. `"open-circuit potential": ("MSMR", "single")`), and an MSMR model must set `"number of MSMR reactions"` to a positive integer rather than the default `"none"`. ([#5599](https://github.com/pybamm-team/PyBaMM/pull/5599))
- Fixed `Serialise.serialise_custom_model` not recording a custom model's discretisation recipe (`geometry`, `var_pts`, `submesh_types`, `spatial_methods`), so a custom model reloaded via the `BaseModel` fallback (when its defining package is unavailable) was no longer discretisable. ([#5609](https://github.com/pybamm-team/PyBaMM/pull/5609))

# [v26.6.1.1](https://github.com/pybamm-team/PyBaMM/tree/v26.6.1.1) - 2026-06-11

## Bug fixes
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ keywords:
- "expression tree"
- "python"
- "symbolic differentiation"
version: "26.6.1.1"
version: "26.6.1.2"
repository-code: "https://github.com/pybamm-team/PyBaMM"
title: "Python Battery Mathematical Modelling (PyBaMM)"
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"4. **Solver solve** - The next step is to solve the equations. This is done by the numerical solver, which in most cases is one of the solvers provided by the [Sundials](https://sundials.readthedocs.io/en/latest/#) suite of solvers. The solver takes the CasADi functions generated in the previous step and uses them to solve the equations numerically.\n",
"5. **Post-processing** - The output of the previous step is the solution to the equations at a set of time points. The final step is to post-process this solution to get the variables of interest. This is done by the [`pybamm.Solution`](https://docs.pybamm.org/en/stable/source/api/solvers/solution.html) and [`pybamm.ParameterValues`](https://docs.pybamm.org/en/stable/source/api/parameters/parameter_values.html#pybamm.ParameterValues) classes, which take the solution and the parameter values and return the variables of interest interpolated at the desired time points.\n",
"\n",
"For every simulation you run with PyBaMM, these steps are always performed in order to generate each solution, even if they are hidden from you by various convenience classes like [`pybamm.Simulation`](https://docs.pybamm.org/en/stable/source/api/simulation.html) . Understanding the pipeline is important because it can help you identify where the performance bottlenecks are in your simulations, and how you can optimise them to get the best performance.\n",
"For every simulation you run with PyBaMM, these steps are always performed in order to generate each solution, even if they are hidden from you by various convenience classes like [`pybamm.Simulation`](https://docs.pybamm.org/en/stable/source/api/simulation/simulation.html) . Understanding the pipeline is important because it can help you identify where the performance bottlenecks are in your simulations, and how you can optimise them to get the best performance.\n",
"\n",
"Below is a simple example that demonstrates the different parts of the pipeline, using the DFN model. While you probably have seen something similar to this script before, the one difference is that we have split the solver setup (step 3) and solve (step 4) parts of the pipeline into two separate steps. Often these are combined into the first call to the solver, but we have split them here to make it clear that they are separate steps."
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"source": [
"# IDAKLU-JAX interface\n",
"\n",
"The IDAKLU-JAX interface requires that PyBaMM is installed with the [optional JAX solver enabled](https://docs.pybamm.org/en/stable/source/user_guide/installation/gnu-linux-mac.html#optional-jaxsolver) (`pip install pybamm[jax]`) and requires at least Python 3.10.\n",
"The IDAKLU-JAX interface requires that PyBaMM is installed with the [optional JAX solver enabled](https://docs.pybamm.org/en/stable/source/user_guide/installation/index.html#optional-jaxsolver) (`pip install pybamm[jax]`) and requires at least Python 3.10.\n",
"\n",
"PyBaMM provides two mechanisms to interface battery models with JAX. The first (JaxSolver) implements PyBaMM models directly in native JAX, and as such provides the greatest flexibility. However, these models can be very slow to compile, especially during their initial run, and can require large amounts of memory.\n",
"\n",
Expand Down
33 changes: 22 additions & 11 deletions src/pybamm/expression_tree/operations/regularise.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ class RegulariseSqrtAndPower:
"""
Callable that replaces Sqrt and Power nodes with RegPower nodes.

All Sqrt and Power nodes are replaced with RegPower. If the base of the
operation matches a symbol in the scales map, that scale is used;
otherwise the default scale (None) is used.
A Sqrt or Power node is replaced with RegPower when the base of the
operation matches a symbol in the scales map (using that scale) or when
the base is state-dependent, i.e. contains a variable, state vector or
time (using the default scale of 1). Bases that are independent of the
state (e.g. ``pybamm.Parameter`` or ``pybamm.InputParameter`` rate
constants) are left unchanged: they have no derivative to regularise, and
RegPower with the default scale of 1 corrupts values smaller than the
regularisation tolerance delta (e.g. a rate constant of ~1e-9 raised to
the power 0.5 evaluates ~470x too small).

Parameters
----------
Expand Down Expand Up @@ -100,20 +106,25 @@ def _process(self, sym, resolved_scales):

new_children = [self._process(child, resolved_scales) for child in sym.children]

if isinstance(sym, pybamm.Sqrt):
child = new_children[0]
scale = self._get_scale(child, resolved_scales)
return pybamm.RegPower(child, 0.5, scale=scale)

if isinstance(sym, pybamm.Power):
base, exponent = new_children
if isinstance(sym, pybamm.Sqrt | pybamm.Power):
base = new_children[0]
exponent = 0.5 if isinstance(sym, pybamm.Sqrt) else new_children[1]
scale = self._get_scale(base, resolved_scales)
return pybamm.RegPower(base, exponent, scale=scale)
if scale is not None or self._depends_on_state(base):
return pybamm.RegPower(base, exponent, scale=scale)

if any(n is not o for n, o in zip(new_children, sym.children, strict=True)):
return sym.create_copy(new_children=new_children)
return sym

@staticmethod
def _depends_on_state(expr):
"""Whether the expression depends on the state (or time)."""
return any(
isinstance(node, pybamm.VariableBase | pybamm.StateVectorBase | pybamm.Time)
for node in expr.pre_order()
)

def _get_scale(self, expr, resolved_scales):
"""Get scale for an expression, defaulting to None.

Expand Down
156 changes: 114 additions & 42 deletions src/pybamm/expression_tree/operations/serialise.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,15 @@ def serialise_custom_model(model: pybamm.BaseModel, compress: bool = False) -> d
},
}

# Capture the discretisation recipe (geometry / var_pts / submesh /
# spatial methods). These live on subclass ``default_*`` properties and
# are otherwise lost when the model is reloaded in an environment where
# the defining package can't be imported (the base-class fallback yields
# empty defaults), leaving the model undiscretisable.
discretisation = Serialise._serialise_discretisation(model)
if discretisation:
model_content["discretisation"] = discretisation

SCHEMA_VERSION = "1.1"
model_json = {
"schema_version": SCHEMA_VERSION,
Expand Down Expand Up @@ -628,6 +637,35 @@ def save_custom_model(
except Exception as e:
raise ValueError(f"Failed to save custom model: {e}") from e

@staticmethod
def _encode_geometry_value(value):
"""Recursively convert any :class:`pybamm.Symbol` in a geometry value to
its JSON form, descending through nested dicts/lists.

Geometry limits can nest symbols arbitrarily deep (e.g. current-collector
``tabs -> negative -> z_centre`` is a :class:`pybamm.Parameter`), which a
single-level pass leaves as raw, non-JSON-serialisable objects.
"""
if isinstance(value, pybamm.Symbol):
return convert_symbol_to_json(value)
if isinstance(value, dict):
return {k: Serialise._encode_geometry_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [Serialise._encode_geometry_value(v) for v in value]
return value

@staticmethod
def _decode_geometry_value(value):
"""Inverse of :meth:`_encode_geometry_value`: rebuild :class:`pybamm.Symbol`
objects from their JSON form at any nesting depth."""
if isinstance(value, dict):
if "$type" in value or "type" in value:
return convert_symbol_from_json(value)
return {k: Serialise._decode_geometry_value(v) for k, v in value.items()}
if isinstance(value, list):
return [Serialise._decode_geometry_value(v) for v in value]
return value

@staticmethod
def serialise_custom_geometry(geometry: pybamm.Geometry) -> dict:
"""
Expand All @@ -654,26 +692,15 @@ def serialise_custom_geometry(geometry: pybamm.Geometry) -> dict:
geometry_dict_serialized[domain]["symbol_" + key_str] = (
convert_symbol_to_json(key)
)
# Serialize the value dict
serialized_value = {}
for k, v in value.items():
if isinstance(v, pybamm.Symbol):
serialized_value[k] = convert_symbol_to_json(v)
else:
serialized_value[k] = v
geometry_dict_serialized[domain][key_str] = serialized_value
geometry_dict_serialized[domain][key_str] = (
Serialise._encode_geometry_value(value)
)
elif isinstance(key, str):
# String keys (like 'tabs') - keep as is
if isinstance(value, dict):
serialized_value = {}
for k, v in value.items():
if isinstance(v, pybamm.Symbol):
serialized_value[k] = convert_symbol_to_json(v)
else:
serialized_value[k] = v
geometry_dict_serialized[domain][key] = serialized_value
else:
geometry_dict_serialized[domain][key] = value
# String keys (like 'tabs'), whose values may nest Symbols
# arbitrarily deep (e.g. tabs -> negative -> z_centre).
geometry_dict_serialized[domain][key] = (
Serialise._encode_geometry_value(value)
)

SCHEMA_VERSION = "1.1"
geometry_json = {
Expand Down Expand Up @@ -794,29 +821,71 @@ def load_custom_geometry(filename: str | dict) -> pybamm.Geometry:
if key in symbol_keys:
# Use the reconstructed SpatialVariable as key
spatial_var = symbol_keys[key]
reconstructed_value = {}
for k, v in value.items():
if isinstance(v, dict) and ("$type" in v or "type" in v):
# Reconstruct PyBaMM Symbol using convert_symbol_from_json
reconstructed_value[k] = convert_symbol_from_json(v)
else:
reconstructed_value[k] = v
reconstructed_geometry[domain][spatial_var] = reconstructed_value
reconstructed_geometry[domain][spatial_var] = (
Serialise._decode_geometry_value(value)
)
else:
# String key (like 'tabs')
if isinstance(value, dict):
reconstructed_value = {}
for k, v in value.items():
if isinstance(v, dict) and ("$type" in v or "type" in v):
reconstructed_value[k] = convert_symbol_from_json(v)
else:
reconstructed_value[k] = v
reconstructed_geometry[domain][key] = reconstructed_value
else:
reconstructed_geometry[domain][key] = value
# String key (like 'tabs'), values may nest Symbols deep
reconstructed_geometry[domain][key] = (
Serialise._decode_geometry_value(value)
)

return pybamm.Geometry(reconstructed_geometry)

@staticmethod
def _serialise_discretisation(model) -> dict:
"""Serialise a model's discretisation recipe (geometry, var_pts, submesh
types, spatial methods) into a JSON-serialisable dict.

Returns an empty dict when the model exposes no discretisation defaults
(e.g. a plain ``pybamm.BaseModel``), so models without custom defaults
round-trip exactly as before.
"""
out: dict = {}
geometry = getattr(model, "default_geometry", None) or {}
if geometry:
out["geometry"] = Serialise.serialise_custom_geometry(
pybamm.Geometry(geometry)
)
var_pts = getattr(model, "default_var_pts", None) or {}
if var_pts:
out["var_pts"] = Serialise.serialise_var_pts(var_pts)
submesh_types = getattr(model, "default_submesh_types", None) or {}
if submesh_types:
out["submesh_types"] = Serialise.serialise_submesh_types(submesh_types)
spatial_methods = getattr(model, "default_spatial_methods", None) or {}
if spatial_methods:
out["spatial_methods"] = Serialise.serialise_spatial_methods(
spatial_methods
)
return out

@staticmethod
def _restore_discretisation(model, discretisation: dict | None) -> None:
"""Restore a discretisation recipe (from :meth:`_serialise_discretisation`)
onto ``model``, so its ``default_*`` properties return the saved values
even when the defining subclass could not be imported on load.

No-op when ``discretisation`` is falsy (older payloads or models without
custom discretisation defaults).
"""
if not discretisation:
return
if "geometry" in discretisation:
model._default_geometry = dict(
Serialise.load_custom_geometry(discretisation["geometry"])
)
if "var_pts" in discretisation:
model._default_var_pts = Serialise.load_var_pts(discretisation["var_pts"])
if "submesh_types" in discretisation:
model._default_submesh_types = Serialise.load_submesh_types(
discretisation["submesh_types"]
)
if "spatial_methods" in discretisation:
model._default_spatial_methods = Serialise.load_spatial_methods(
discretisation["spatial_methods"]
)

@staticmethod
def serialise_spatial_method_item(method) -> dict:
"""Serialise a spatial method. The class is encoded via the kernel's
Expand Down Expand Up @@ -1386,10 +1455,9 @@ def load_custom_model(filename: str | dict) -> pybamm.BaseModel:
model = base_cls()
model.name = model_data["name"]
model.schema_version = schema_version
# Restore options so round-trip serialisation produces an equivalent
# model. A JSON round-trip turns tuple-valued options (e.g. "particle
# phases": ("2", "1")) into lists, which pybamm's options validation
# rejects, so convert lists back to tuples before assigning.
# JSON turns tuple-valued options into lists (rejected by validation);
# convert back. Assigning options rebuilds the options-derived param via
# the setter, so it matches the restored options.
opts = model_data.get("options", {})
if opts is not None:
model.options = Serialise._convert_options(opts)
Expand Down Expand Up @@ -1495,6 +1563,10 @@ def load_custom_model(filename: str | dict) -> pybamm.BaseModel:
# Restore observable state
model._solution_observable = False

# Restore the discretisation recipe onto the (possibly fallback) model so
# it stays discretisable even when the defining subclass wasn't importable.
Serialise._restore_discretisation(model, model_data.get("discretisation"))

return model

@staticmethod
Expand Down
14 changes: 10 additions & 4 deletions src/pybamm/models/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,12 @@ def param(self):
def param(self, values):
self._param = values

def _rebuild_param(self):
"""Rebuild ``param`` from ``self.options``; no-op unless ``param`` is
options-derived. Called by the ``options`` setter whenever options are
(re)assigned, so subclasses keep ``param`` in sync automatically.
"""

@property
def options(self):
"""Returns the model options dictionary that allows customization of the model's behavior."""
Expand Down Expand Up @@ -682,22 +688,22 @@ def geometry(self):
@property
def default_var_pts(self):
"""Returns a dictionary of the default variable points for the model, which is empty by default."""
return {}
return getattr(self, "_default_var_pts", None) or {}

@property
def default_geometry(self):
"""Returns a dictionary of the default geometry for the model, which is empty by default."""
return {}
return getattr(self, "_default_geometry", None) or {}

@property
def default_submesh_types(self):
"""Returns a dictionary of the default submesh types for the model, which is empty by default."""
return {}
return getattr(self, "_default_submesh_types", None) or {}

@property
def default_spatial_methods(self):
"""Returns a dictionary of the default spatial methods for the model, which is empty by default."""
return {}
return getattr(self, "_default_spatial_methods", None) or {}

@property
def default_solver(self):
Expand Down
Loading
Loading