diff --git a/CHANGELOG.md b/CHANGELOG.md index 17997c4cd2..09d0da5b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` electrode lithiation OCP [V]` / ` electrode delithiation OCP [V]` instead of being left under the literal BPX alias, and the scalar `OCP hysteresis decay constant` is expanded into both ` particle lithiation hysteresis decay rate` and ` 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 `[: ]Initial hysteresis state in 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. diff --git a/src/pybamm/parameters/bpx.py b/src/pybamm/parameters/bpx.py index 14f5cb58e6..8674852aa5 100644 --- a/src/pybamm/parameters/bpx.py +++ b/src/pybamm/parameters/bpx.py @@ -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 @@ -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 @@ -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: + + * `` particle lithiation hysteresis decay rate`` + * `` 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: @@ -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 diff --git a/src/pybamm/parameters/parameter_values.py b/src/pybamm/parameters/parameter_values.py index 941f6052f2..2ee1018c42 100644 --- a/src/pybamm/parameters/parameter_values.py +++ b/src/pybamm/parameters/parameter_values.py @@ -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. @@ -131,7 +131,15 @@ def create_from_bpx( filename : str or Path The filename of the `BPX `_ 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 ------- @@ -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. @@ -161,7 +171,10 @@ def create_from_bpx_obj( A dictionary containing the parameters in the `BPX `_ 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 ------- @@ -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) @@ -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 diff --git a/tests/unit/test_parameters/test_bpx.py b/tests/unit/test_parameters/test_bpx.py index 4d04613c5f..1c16212570 100644 --- a/tests/unit/test_parameters/test_bpx.py +++ b/tests/unit/test_parameters/test_bpx.py @@ -249,7 +249,10 @@ def test_table_data(self, tmp_path): def test_bpx_soc_error(self): bpx_obj = copy.deepcopy(self.base) - with pytest.raises(ValueError, match=r"Target SOC"): + with ( + pytest.warns(DeprecationWarning, match="target_soc"), + pytest.raises(ValueError, match=r"Target SOC"), + ): pybamm.ParameterValues.create_from_bpx_obj(bpx_obj, target_soc=10) def test_bpx_arrhenius(self, tmp_path): @@ -497,3 +500,272 @@ def test_bruggeman_invalid_values_raise(self): ValueError, match=r"math domain error|expected a positive input" ): # Matches log(0) error (message changed in Python 3.14) pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + @staticmethod + def _blended_positive_phase(radius, sav, *, with_hysteresis=False, decay=None): + phase = { + "Diffusivity [m2.s-1]": 3.2e-14, + "Particle radius [m]": radius, + "OCP [V]": "4.0 - 0.5 * x", + "Entropic change coefficient [V.K-1]": -1e-4, + "Surface area per unit volume [m-1]": sav, + "Reaction rate constant [mol.m-2.s-1]": 2.305e-05, + "Minimum stoichiometry": 0.42424, + "Maximum stoichiometry": 0.96210, + "Maximum concentration [mol.m-3]": 46200, + "Diffusivity activation energy [J.mol-1]": 15000, + "Reaction rate constant activation energy [J.mol-1]": 3500, + } + if with_hysteresis: + phase.update( + { + "OCP (lithiation) [V]": "4.0 - 0.5 * x", + "OCP (delithiation) [V]": "4.05 - 0.5 * x", + "OCP hysteresis decay constant": decay, + } + ) + return phase + + def _blended_positive_electrode(self, *, with_hysteresis=False): + return { + "Thickness [m]": 5.23e-05, + "Conductivity [S.m-1]": 0.789, + "Porosity": 0.277493, + "Transport efficiency": 0.1462, + "Particle": { + "Large Particles": self._blended_positive_phase( + 8e-06, 186331, with_hysteresis=with_hysteresis, decay=0.03 + ), + "Small Particles": self._blended_positive_phase( + 1e-06, 496883, with_hysteresis=with_hysteresis, decay=0.04 + ), + }, + } + + def test_bpx_hysteresis_names(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["Parameterisation"]["Negative electrode"].update( + { + "OCP (lithiation) [V]": {"x": [0, 1], "y": [0.1, 1.5]}, + "OCP (delithiation) [V]": {"x": [0, 1], "y": [0.15, 1.55]}, + "OCP hysteresis decay constant": 0.01, + } + ) + bpx_obj["Parameterisation"]["Positive electrode"].update( + { + "OCP (lithiation) [V]": {"x": [0, 1], "y": [4.2, 3.0]}, + "OCP (delithiation) [V]": {"x": [0, 1], "y": [4.25, 3.05]}, + "OCP hysteresis decay constant": 0.02, + } + ) + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + for electrode in ("Negative", "Positive"): + assert f"{electrode} electrode lithiation OCP [V]" in param + assert f"{electrode} electrode delithiation OCP [V]" in param + assert f"{electrode} particle lithiation hysteresis decay rate" in param + assert f"{electrode} particle delithiation hysteresis decay rate" in param + assert f"{electrode} electrode OCP (lithiation) [V]" not in param + assert f"{electrode} electrode OCP (delithiation) [V]" not in param + assert f"{electrode} electrode OCP hysteresis decay constant" not in param + + assert param["Negative particle lithiation hysteresis decay rate"] == 0.01 + assert param["Negative particle delithiation hysteresis decay rate"] == 0.01 + assert param["Positive particle lithiation hysteresis decay rate"] == 0.02 + assert param["Positive particle delithiation hysteresis decay rate"] == 0.02 + + def test_bpx_hysteresis_missing_fields_skipped(self): + bpx_obj = copy.deepcopy(self.base) + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + for electrode in ("Negative", "Positive"): + assert f"{electrode} electrode lithiation OCP [V]" not in param + assert f"{electrode} electrode delithiation OCP [V]" not in param + assert f"{electrode} particle lithiation hysteresis decay rate" not in param + assert ( + f"{electrode} particle delithiation hysteresis decay rate" not in param + ) + + def test_bpx_hysteresis_blended(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["Parameterisation"]["Positive electrode"] = ( + self._blended_positive_electrode(with_hysteresis=True) + ) + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Positive electrode" + ] = {"Large Particles": 0.0, "Small Particles": 0.0} + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + for phase, value in (("Primary", 0.03), ("Secondary", 0.04)): + assert ( + param[f"{phase}: Positive particle lithiation hysteresis decay rate"] + == value + ) + assert ( + param[f"{phase}: Positive particle delithiation hysteresis decay rate"] + == value + ) + assert f"{phase}: Positive electrode lithiation OCP [V]" in param + assert f"{phase}: Positive electrode delithiation OCP [V]" in param + + def test_bpx_initial_hysteresis_state_scalar(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Negative electrode" + ] = 0.5 + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Positive electrode" + ] = -0.25 + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + assert param["Initial hysteresis state in negative electrode"] == 0.5 + assert param["Initial hysteresis state in positive electrode"] == -0.25 + + def test_bpx_initial_hysteresis_state_blended(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["Parameterisation"]["Positive electrode"] = ( + self._blended_positive_electrode() + ) + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Positive electrode" + ] = {"Large Particles": 0.7, "Small Particles": 0.3} + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Negative electrode" + ] = 1.0 + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + assert param["Primary: Initial hysteresis state in positive electrode"] == 0.7 + assert param["Secondary: Initial hysteresis state in positive electrode"] == 0.3 + assert param["Initial hysteresis state in negative electrode"] == 1.0 + + def test_bpx_heat_transfer_coefficient_preserved(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["State"]["Thermal environment"][ + "Heat transfer coefficient [W.m-2.K-1]" + ] = 42.0 + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + assert param["Total heat transfer coefficient [W.m-2.K-1]"] == 42.0 + + def test_bpx_axen_hysteresis_end_to_end(self): + bpx_obj = copy.deepcopy(self.base) + for electrode in ("Negative electrode", "Positive electrode"): + ocp = bpx_obj["Parameterisation"][electrode]["OCP [V]"] + bpx_obj["Parameterisation"][electrode].update( + { + "OCP (lithiation) [V]": ocp, + "OCP (delithiation) [V]": ocp, + "OCP hysteresis decay constant": 0.01, + } + ) + + pv = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + model = pybamm.lithium_ion.SPM( + {"open-circuit potential": "one-state hysteresis"} + ) + sim = pybamm.Simulation( + model, + parameter_values=pv, + experiment=pybamm.Experiment(["Discharge at C/10 for 5 minutes"]), + ) + sol = sim.solve() + v_final = float(sol["Voltage [V]"].entries[-1]) + assert ( + pv["Lower voltage cut-off [V]"] < v_final < pv["Upper voltage cut-off [V]"] + ) + + def test_bpx_default_initial_concentrations_at_full_charge(self): + bpx_obj = copy.deepcopy(self.base) + # Set a non-1 initial SOC in the BPX file to confirm it's not auto-applied. + bpx_obj["State"]["Initial conditions"]["Initial state-of-charge"] = 0.25 + neg = bpx_obj["Parameterisation"]["Negative electrode"] + pos = bpx_obj["Parameterisation"]["Positive electrode"] + expected_neg = ( + neg["Maximum stoichiometry"] * neg["Maximum concentration [mol.m-3]"] + ) + expected_pos = ( + pos["Minimum stoichiometry"] * pos["Maximum concentration [mol.m-3]"] + ) + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + assert param["Initial concentration in negative electrode [mol.m-3]"] == ( + expected_neg + ) + assert param["Initial concentration in positive electrode [mol.m-3]"] == ( + expected_pos + ) + + def test_bpx_target_soc_is_deprecated_but_preserves_behaviour(self): + from bpx import get_electrode_concentrations, parse_bpx_obj + + bpx_obj = copy.deepcopy(self.base) + target_soc = 0.25 + expected_neg, expected_pos = get_electrode_concentrations( + target_soc, parse_bpx_obj(copy.deepcopy(bpx_obj)) + ) + + with pytest.warns(DeprecationWarning, match="target_soc"): + param = pybamm.ParameterValues.create_from_bpx_obj( + bpx_obj, target_soc=target_soc + ) + + assert ( + param["Initial concentration in negative electrode [mol.m-3]"] + == expected_neg + ) + assert ( + param["Initial concentration in positive electrode [mol.m-3]"] + == expected_pos + ) + + def _blended_bpx_obj(self): + bpx_obj = copy.deepcopy(self.base) + bpx_obj["Parameterisation"]["Positive electrode"] = ( + self._blended_positive_electrode() + ) + bpx_obj["State"]["Initial conditions"][ + "Initial hysteresis state: Positive electrode" + ] = {"Large Particles": 0.0, "Small Particles": 0.0} + return bpx_obj + + def test_bpx_blended_default_sets_per_phase_initial_concentrations(self): + bpx_obj = self._blended_bpx_obj() + pos_large = bpx_obj["Parameterisation"]["Positive electrode"]["Particle"][ + "Large Particles" + ] + pos_small = bpx_obj["Parameterisation"]["Positive electrode"]["Particle"][ + "Small Particles" + ] + + param = pybamm.ParameterValues.create_from_bpx_obj(bpx_obj) + + neg = self.base["Parameterisation"]["Negative electrode"] + assert param["Initial concentration in negative electrode [mol.m-3]"] == ( + neg["Maximum stoichiometry"] * neg["Maximum concentration [mol.m-3]"] + ) + assert param[ + "Primary: Initial concentration in positive electrode [mol.m-3]" + ] == pytest.approx( + pos_large["Minimum stoichiometry"] + * pos_large["Maximum concentration [mol.m-3]"] + ) + assert param[ + "Secondary: Initial concentration in positive electrode [mol.m-3]" + ] == pytest.approx( + pos_small["Minimum stoichiometry"] + * pos_small["Maximum concentration [mol.m-3]"] + ) + + def test_bpx_blended_with_target_soc_raises(self): + bpx_obj = self._blended_bpx_obj() + with ( + pytest.warns(DeprecationWarning, match="target_soc"), + pytest.raises(NotImplementedError, match="blended electrodes"), + ): + pybamm.ParameterValues.create_from_bpx_obj(bpx_obj, target_soc=0.5)