Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

## Bug fixes

- Fixed the BPX 1.1 hysteresis fields being mishandled when importing a BPX file. `OCP (lithiation) [V]` / `OCP (delithiation) [V]` now produce `<Domain> electrode lithiation OCP [V]` / `<Domain> electrode delithiation OCP [V]` instead of being left under the literal BPX alias, and the scalar `OCP hysteresis decay constant` is expanded into both `<Domain> particle lithiation hysteresis decay rate` and `<Domain> particle delithiation hysteresis decay rate` (the two FunctionParameters that PyBaMM's Axen one-state hysteresis OCP submodel evaluates). The BPX `Initial hysteresis state: Positive electrode` / `Initial hysteresis state: Negative electrode` fields — previously dropped entirely — are extracted into the corresponding `[<Phase>: ]Initial hysteresis state in <domain> electrode` PyBaMM parameters, including the per-particle-phase dict form used by blended electrodes. The BPX-supplied `Total heat transfer coefficient [W.m-2.K-1]` is no longer silently clobbered back to `0`.

## Breaking changes

- `target_soc` is deprecated in `ParameterValues.create_from_bpx` and `create_from_bpx_obj`. Passing `target_soc` now emits a `DeprecationWarning`; pairing it with a BPX file that has blended electrodes raises `NotImplementedError` (previously the call silently logged a warning and left initial concentrations unset, which then broke downstream total-Li calculations). When `target_soc` is not passed, `create_from_bpx[_obj]` now always returns parameter values with initial concentrations at 100% SOC (negative phases at θ_max, positive phases at θ_min) for both non-blended and blended electrodes — the BPX file's `State.Initial state-of-charge` field is no longer auto-applied. Call `param.set_initial_state(...)` after creating the ParameterValues to use a different initial SOC.
- `ProcessedVariableComputed.__init__` now defers the heavy `initialise_*` work (numpy concatenate/flatten, xarray `DataArray` build) until `entries` or `_xr_data_array` is actually read. On a 500-cycle SPM with `output_variables` set and `save_at_cycles=N`, solve wall time dropped from ~30 s to ~2.5 s (~12×); per-step `__init__` cost dropped from 47% of solve to 1.7%.
- `IDAKLUSolver` now records `Solution.closest_event_idx` after a SUNDIALS root return, so `BaseSolver.get_termination_reason` can short-circuit instead of re-walking every TERMINATION event's symbolic expression on the Python side. On a 1000-cycle SPM with `output_variables` set, cumulative allocations dropped 25% (~445 MB) and wall time 16%; the eliminated path was hot in long event-terminated cycling experiments. ([#5502](https://github.com/pybamm-team/PyBaMM/pull/5502))
- Fixed `Serialise.serialise_experiment` / `deserialise_experiment` dropping every constructor argument other than per-step `value` / `duration` / `terminations` / `temperature`. The top-level `period`, `temperature`, and `termination` arguments to `pybamm.Experiment` and the per-step `period`, `tags`, `description`, `start_time`, `direction`, and `skip_ok` arguments to `BaseStep` are now written by `to_config()` and parsed back by `from_config()`, so JSON round-tripped experiments preserve user intent. The `Resistance` step type was also missing from the deserialiser's step-type map and now round-trips correctly.
Expand Down
100 changes: 77 additions & 23 deletions src/pybamm/parameters/bpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,30 @@ def bpx_to_param_dict(bpx: BPX) -> dict:
ic.initial_electrolyte_concentration
)
pybamm_dict["Initial temperature [K]"] = ic.initial_temperature
for domain_str, bpx_value, bpx_electrode in (
(
"negative",
ic.initial_hysteresis_state_negative,
bpx.parameterisation.negative_electrode,
),
(
"positive",
ic.initial_hysteresis_state_positive,
bpx.parameterisation.positive_electrode,
),
):
target = f"Initial hysteresis state in {domain_str} electrode"
if not isinstance(bpx_value, dict):
pybamm_dict[target] = bpx_value
continue
phase_prefixes = domain_phases[f"{domain_str} electrode"]
if phase_prefixes == [""]:
continue
for bpx_phase, pybamm_prefix in zip(
bpx_electrode.particle.keys(), phase_prefixes, strict=True
):
if bpx_phase in bpx_value:
pybamm_dict[f"{pybamm_prefix}{target}"] = bpx_value[bpx_phase]
# Thermal environment
te = bpx.state.thermal_environment
pybamm_dict["Ambient temperature [K]"] = te.ambient_temperature
Expand Down Expand Up @@ -223,8 +247,8 @@ def bpx_to_param_dict(bpx: BPX) -> dict:
pybamm_dict[domain.pre_name + "thickness [m]"] = 0
pybamm_dict[domain.pre_name + "conductivity [S.m-1]"] = 4e7

# add a default heat transfer coefficient
pybamm_dict.update({"Total heat transfer coefficient [W.m-2.K-1]": 0})
# setdefault, not update — preserve any BPX State.thermal_environment value
pybamm_dict.setdefault("Total heat transfer coefficient [W.m-2.K-1]", 0)

# transport efficiency
# Compute Bruggeman coefficient from BPX-specified porosity and transport efficiency
Expand Down Expand Up @@ -424,32 +448,58 @@ def _conductivity(c_e, T, Ea, sigma_ref, constant=False):

def _get_pybamm_name(pybamm_name, domain):
"""
Process pybamm name to include domain name and handle special cases
Map a BPX field alias to one or more PyBaMM parameter names.

Returns a list. Most BPX fields map 1:1; ``"OCP hysteresis decay constant"``
is the exception and fans out to two PyBaMM parameters with the same value:

* ``<Domain> particle lithiation hysteresis decay rate``
* ``<Domain> particle delithiation hysteresis decay rate``
"""
pybamm_name_lower = pybamm_name[:1].lower() + pybamm_name[1:]
if pybamm_name.startswith("Initial concentration") or pybamm_name.startswith(
"Maximum concentration"
):
init_len = len("Initial concentration ")
pybamm_name = (
return [
pybamm_name[:init_len]
+ "in "
+ domain.pre_name.lower()
+ pybamm_name[init_len:]
]
if pybamm_name.startswith("Particle radius"):
return [domain.short_pre_name + pybamm_name_lower]
if pybamm_name in _BPX_OCP_HYSTERESIS_RENAMES:
return [domain.pre_name + _BPX_OCP_HYSTERESIS_RENAMES[pybamm_name]]
if pybamm_name == "OCP hysteresis decay constant":
particle = (
negative_particle
if domain.name == "negative electrode"
else positive_particle
)
elif pybamm_name.startswith("Particle radius"):
pybamm_name = domain.short_pre_name + pybamm_name_lower
elif pybamm_name.startswith("OCP"):
pybamm_name = domain.pre_name + pybamm_name
elif pybamm_name.startswith("Entropic change"):
pybamm_name = domain.pre_name + pybamm_name.replace(
"Entropic change coefficient", "OCP entropic change"
)
elif pybamm_name.startswith("Cation transference number"):
pybamm_name = pybamm_name
elif domain.pre_name != "":
pybamm_name = domain.pre_name + pybamm_name_lower
return pybamm_name
return [
particle.pre_name + "lithiation hysteresis decay rate",
particle.pre_name + "delithiation hysteresis decay rate",
]
if pybamm_name.startswith("OCP"):
return [domain.pre_name + pybamm_name]
if pybamm_name.startswith("Entropic change"):
return [
domain.pre_name
+ pybamm_name.replace("Entropic change coefficient", "OCP entropic change")
]
if pybamm_name.startswith("Cation transference number"):
return [pybamm_name]
if domain.pre_name != "":
return [domain.pre_name + pybamm_name_lower]
return [pybamm_name]


_BPX_OCP_HYSTERESIS_RENAMES = {
"OCP (lithiation) [V]": "lithiation OCP [V]",
"OCP (delithiation) [V]": "delithiation OCP [V]",
}
_OPTIONAL_HYSTERESIS_FIELDS = {"ocp_lith", "ocp_delith", "gamma_hys"}


def _bpx_to_domain_param_dict(instance: BPX, pybamm_dict: dict, domain: Domain) -> dict:
Expand All @@ -472,14 +522,18 @@ def _bpx_to_domain_param_dict(instance: BPX, pybamm_dict: dict, domain: Domain)
# Loop over fields in phase instance and add to pybamm dictionary
for name_to_add, field_to_add in phase_instance.model_fields.items():
value = getattr(phase_instance, name_to_add)
pybamm_name = PHASE_NAMES[i] + _get_pybamm_name(
field_to_add.alias, domain
)
if value is None and name_to_add in _OPTIONAL_HYSTERESIS_FIELDS:
continue
pybamm_names = _get_pybamm_name(field_to_add.alias, domain)
value = process_float_function_table(value, name_to_add)
pybamm_dict[pybamm_name] = value
for pybamm_name in pybamm_names:
pybamm_dict[PHASE_NAMES[i] + pybamm_name] = value
# Handle other fields, which correspond directly to parameters
else:
pybamm_name = _get_pybamm_name(field.alias, domain)
if value is None and name in _OPTIONAL_HYSTERESIS_FIELDS:
continue
pybamm_names = _get_pybamm_name(field.alias, domain)
value = process_float_function_table(value, name)
pybamm_dict[pybamm_name] = value
for pybamm_name in pybamm_names:
pybamm_dict[pybamm_name] = value
return pybamm_dict
99 changes: 77 additions & 22 deletions src/pybamm/parameters/parameter_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def store(self) -> ParameterStore:
# Factory methods
@classmethod
def create_from_bpx(
cls, filename: str | Path, target_soc: float = 1.0
cls, filename: str | Path, target_soc: float | None = None
) -> ParameterValues:
"""
Create ParameterValues from a BPX file.
Expand All @@ -131,7 +131,15 @@ def create_from_bpx(
filename : str or Path
The filename of the `BPX <https://bpxstandard.com/>`_ file.
target_soc : float, optional
Target state of charge. Must be between 0 and 1. Default is 1.
.. deprecated:: 26.5
Passing ``target_soc`` is deprecated. The returned
``ParameterValues`` always has initial concentrations set at
100% SOC (negative phases at their maximum stoichiometry,
positive phases at their minimum stoichiometry). Use
:meth:`ParameterValues.set_initial_state` after creation to
set a different initial SOC. If ``target_soc`` is passed, the
previous behaviour is preserved for non-blended electrodes;
blended electrodes raise ``NotImplementedError``.

Returns
-------
Expand All @@ -141,16 +149,18 @@ def create_from_bpx(
Examples
--------
>>> param = pybamm.ParameterValues.create_from_bpx("battery_params.json") # doctest: +SKIP
>>> param = pybamm.ParameterValues.create_from_bpx("battery_params.json", target_soc=0.5) # doctest: +SKIP
>>> param.set_initial_state(0.5) # doctest: +SKIP
"""
from bpx import parse_bpx_file

if target_soc is not None:
cls._warn_target_soc_deprecation()
bpx = parse_bpx_file(str(filename))
return cls._create_from_bpx(bpx, target_soc)

@classmethod
def create_from_bpx_obj(
cls, bpx_obj: dict, target_soc: float = 1.0
cls, bpx_obj: dict, target_soc: float | None = None
) -> ParameterValues:
"""
Create ParameterValues from a BPX dictionary object.
Expand All @@ -161,7 +171,10 @@ def create_from_bpx_obj(
A dictionary containing the parameters in the
`BPX <https://bpxstandard.com/>`_ format.
target_soc : float, optional
Target state of charge. Must be between 0 and 1. Default is 1.
.. deprecated:: 26.5
See :meth:`ParameterValues.create_from_bpx`. Pass nothing
(the default) and use :meth:`ParameterValues.set_initial_state`
to set a non-100%-SOC initial state.

Returns
-------
Expand All @@ -172,24 +185,39 @@ def create_from_bpx_obj(
--------
>>> bpx_dict = {"Header": {...}, "Cell": {...}, "Parameterisation": {...}} # doctest: +SKIP
>>> param = pybamm.ParameterValues.create_from_bpx_obj(bpx_dict) # doctest: +SKIP
>>> param = pybamm.ParameterValues.create_from_bpx_obj(bpx_dict, target_soc=0.8) # doctest: +SKIP

>>> param.set_initial_state(0.5) # doctest: +SKIP
"""
from bpx import parse_bpx_obj

if target_soc is not None:
cls._warn_target_soc_deprecation()
bpx = parse_bpx_obj(bpx_obj)
return cls._create_from_bpx(bpx, target_soc)

@staticmethod
def _warn_target_soc_deprecation() -> None:
warn(
"Passing 'target_soc' to ParameterValues.create_from_bpx / "
"create_from_bpx_obj is deprecated. The returned ParameterValues "
"now has initial concentrations set at 100% SOC by default; call "
"param.set_initial_state(...) afterwards to set a different "
"initial SOC.",
DeprecationWarning,
stacklevel=3,
)

@classmethod
def _create_from_bpx(cls, bpx, target_soc: float) -> ParameterValues:
def _create_from_bpx(cls, bpx, target_soc: float | None) -> ParameterValues:
"""Internal method to create ParameterValues from a parsed BPX object."""
from bpx import get_electrode_concentrations
from bpx.schema import ElectrodeBlended, ElectrodeBlendedSPM

from .bpx import bpx_to_param_dict

if target_soc < 0 or target_soc > 1:
raise ValueError("Target SOC should be between 0 and 1")
from .bpx import (
_get_phase_names,
bpx_to_param_dict,
negative_electrode,
positive_electrode,
)

pybamm_dict = bpx_to_param_dict(bpx)

Expand All @@ -212,17 +240,44 @@ def _create_from_bpx(cls, bpx, target_soc: float) -> ParameterValues:
stacklevel=2,
)

# Get initial concentrations based on SOC
bpx_neg = bpx.parameterisation.negative_electrode
bpx_pos = bpx.parameterisation.positive_electrode
if isinstance(bpx_neg, ElectrodeBlended | ElectrodeBlendedSPM) or isinstance(
bpx_pos, ElectrodeBlended | ElectrodeBlendedSPM
):
pybamm.logger.warning(
"Initial concentrations cannot be set using stoichiometry limits for "
"blend electrodes. Please set the initial concentrations manually."
)
if target_soc is None:
# Full charge: negative phases at theta_max, positive at theta_min.
for bpx_electrode, domain, sto_bound in (
(
bpx.parameterisation.negative_electrode,
negative_electrode,
"maximum",
),
(
bpx.parameterisation.positive_electrode,
positive_electrode,
"minimum",
),
):
for phase in _get_phase_names(bpx_electrode):
sto = pybamm_dict[
f"{phase}{domain.pre_name}{sto_bound} stoichiometry"
]
c_max = pybamm_dict[
f"{phase}Maximum concentration in {domain.name} [mol.m-3]"
]
pybamm_dict[
f"{phase}Initial concentration in {domain.name} [mol.m-3]"
] = sto * c_max
else:
if target_soc < 0 or target_soc > 1:
raise ValueError("Target SOC should be between 0 and 1")
bpx_neg = bpx.parameterisation.negative_electrode
bpx_pos = bpx.parameterisation.positive_electrode
if isinstance(
bpx_neg, ElectrodeBlended | ElectrodeBlendedSPM
) or isinstance(bpx_pos, ElectrodeBlended | ElectrodeBlendedSPM):
raise NotImplementedError(
"Setting 'target_soc' is not supported for BPX files with "
"blended electrodes. Call create_from_bpx / "
"create_from_bpx_obj without 'target_soc', then use "
"param.set_initial_state(...) to set the initial SOC."
)
c_n_init, c_p_init = get_electrode_concentrations(target_soc, bpx)
pybamm_dict["Initial concentration in negative electrode [mol.m-3]"] = (
c_n_init
Expand Down
Loading
Loading