diff --git a/CHANGELOG.md b/CHANGELOG.md index af036aa..daf684c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # [Unreleased](https://github.com/FaradayInstitution/BPX) +- Added `partial` model option to allow partial schemas to be defined in `Parameterisation`. ([#115](https://github.com/FaradayInstitution/BPX/pull/115)) - Added `State` section to the schema and moved temperature & concentration parameters from `Parameterisation` in. ([#113](https://github.com/FaradayInstitution/BPX/pull/113)) diff --git a/bpx/schema.py b/bpx/schema.py index c29fb93..24c4943 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -15,7 +15,7 @@ from bpx import Function, InterpolatedTable from .base_extra_model import ExtraBaseModel -from .schema_utils import get_materials_in_electrode, validate_section_against_electrodes +from .schema_utils import BPXSchemaError, get_materials_in_electrode, validate_section_against_electrodes from .validators import check_sto_limits FloatFunctionTable = Union[float, int, Function, InterpolatedTable] @@ -53,10 +53,10 @@ class Header(ExtraBaseModel): description=("May contain any references"), examples=["Chang-Hui Chen et al 2020 J. Electrochem. Soc. 167 080534"], ) - model: Literal["SPM", "SPMe", "DFN"] = Field( + model: Literal["SPM", "SPMe", "DFN", "Partial"] = Field( alias="Model", examples=["DFN"], - description=('Model type ("SPM", "SPMe", "DFN")'), + description=('Model type ("SPM", "SPMe", "DFN", "Partial")'), ) @field_validator("bpx", mode="before") @@ -447,6 +447,109 @@ class Experiment(ExtraBaseModel): ) +class ParameterisationPartial(ExtraBaseModel): + """ + A class to store parameterisation data for a cell. Consists of parameters for the + cell, electrolyte, negative electrode, positive electrode, and separator. + All parameter groups are optional to allow for partial parameterisations; as such either + SPM or full models can be represented (but not within the same instance). + """ + + cell: Cell = Field( + None, + alias="Cell", + ) + electrolyte: Electrolyte = Field( + None, + alias="Electrolyte", + ) + negative_electrode: Union[ + ElectrodeSingle, + ElectrodeBlended, + ElectrodeSingleSPM, + ElectrodeBlendedSPM, + ] = Field( + None, + alias="Negative electrode", + ) + positive_electrode: Union[ + ElectrodeSingle, + ElectrodeBlended, + ElectrodeSingleSPM, + ElectrodeBlendedSPM, + ] = Field( + None, + alias="Positive electrode", + ) + separator: Contact = Field( + None, + alias="Separator", + ) + user_defined: UserDefined = Field( + None, + alias="User-defined", + ) + + @field_validator("negative_electrode", "positive_electrode", mode="before") + @classmethod + def _choose_electrode_type(cls, data: dict) -> dict: + """Use the 'conductivity' parameter to differentiate between SPM and full electrode types.""" + if data.get("Particle") and data.get("Conductivity [S.m-1]"): + return ElectrodeBlended.model_validate(data) + if data.get("Particle"): + return ElectrodeBlendedSPM.model_validate(data) + if data.get("Conductivity [S.m-1]"): + return ElectrodeSingle.model_validate(data) + return ElectrodeSingleSPM.model_validate(data) + + @model_validator(mode="after") + def _check_consistent_electrode_types(self) -> Parameterisation: + if self.negative_electrode and self.positive_electrode: + neg_is_spm = isinstance( + self.negative_electrode, + (ElectrodeSingleSPM | ElectrodeBlendedSPM), + ) + pos_is_spm = isinstance( + self.positive_electrode, + (ElectrodeSingleSPM | ElectrodeBlendedSPM), + ) + if neg_is_spm != pos_is_spm: + error_msg = ( + "Negative and positive electrodes must be of consistent model types. Currently types are: " + f"Positive electrode: {type(self.positive_electrode)}, " + f"Negative electrode: {type(self.negative_electrode)}" + ) + raise ValueError(error_msg) + return self + + @model_validator(mode="after") + def _check_consistent_electrode_models(self) -> Parameterisation: + """Check that if SPM electrodes are used, no full model parameters are present.""" + neg_is_spm = self.negative_electrode and isinstance( + self.negative_electrode, + (ElectrodeSingleSPM | ElectrodeBlendedSPM), + ) + pos_is_spm = self.positive_electrode and isinstance( + self.positive_electrode, + (ElectrodeSingleSPM | ElectrodeBlendedSPM), + ) + + if any([neg_is_spm, pos_is_spm]): + full_model_params = Parameterisation.model_fields.keys() - ParameterisationSPM.model_fields.keys() + if any(field in self.model_fields_set for field in full_model_params): + error_msg = ( + f"SPM electrodes cannot be used with full model parameters {sorted(full_model_params)}. " + "Please ensure that either full model electrodes are used, or that " + "only valid SPM parameters are provided." + ) + raise ValueError(error_msg) + return self + + @model_validator(mode="after") + def _sto_limit_validation(self) -> Parameterisation: + return check_sto_limits(self) + + class Parameterisation(ExtraBaseModel): """ A class to store parameterisation data for a cell. Consists of parameters for the @@ -603,10 +706,10 @@ class BPX(ExtraBaseModel): header: Header = Field( alias="Header", ) - parameterisation: Union[ParameterisationSPM, Parameterisation] = Field( + parameterisation: Union[ParameterisationSPM, Parameterisation, ParameterisationPartial] = Field( alias="Parameterisation", ) - state: State = Field(alias="State") + state: State = Field(None, alias="State") validation: dict[str, Experiment] = Field(None, alias="Validation") @model_validator(mode="before") @@ -617,11 +720,18 @@ def _dispatch_param_subclasses(cls, data: Any) -> Any: # noqa:ANN401 header = Header.model_validate(data.get("Header")) model_type = header.model + if model_type == "Partial": + parameterisation = ParameterisationPartial.model_validate(data["Parameterisation"]) + # return validated data to stop double validation + data["Header"] = header + data["Parameterisation"] = parameterisation + return data + # Choose the expected class based on model type if model_type == "SPM": expected_cls, fallback_cls = ParameterisationSPM, Parameterisation error_msg = f"Valid parameter set does not correspond with the model type {model_type}" - else: + else: # DFN or SPMe expected_cls, fallback_cls = Parameterisation, ParameterisationSPM error_msg = f"Valid SPM parameter set does not correspond with the model type {model_type}" @@ -639,6 +749,16 @@ def _dispatch_param_subclasses(cls, data: Any) -> Any: # noqa:ANN401 data["Parameterisation"] = parameterisation return data + @model_validator(mode="after") + def _check_state_present_if_not_partial(self) -> BPX: + """ + Check that State is provided if not using a Partial parameterisation. + """ + if self.state is None and self.header.model != "Partial": + err_msg = "'State' section must be provided unless using a 'Partial' parameterisation" + raise BPXSchemaError(err_msg) + return self + @model_validator(mode="after") def _check_state_against_blended_electrodes(self) -> BPX: """ @@ -646,6 +766,9 @@ def _check_state_against_blended_electrodes(self) -> BPX: values in State are provided as such (and vice versa). """ + if self.state is None: + return self # skip if no State provided (Partial parameterisation) + param = self.parameterisation electrode_materials = { diff --git a/tests/test_schema.py b/tests/test_schema.py index 35ba375..31233ce 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -239,6 +239,63 @@ def setUp(self) -> None: }, } + # partial DFN model + self.partial: dict[str, Any] = { + "Header": { + "BPX": "1.0.0", + "Model": "Partial", + }, + "Parameterisation": { + "Negative electrode": { + "Particle radius [m]": 5.86e-6, + "Thickness [m]": 85.2e-6, + "Diffusivity [m2.s-1]": 3.3e-14, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Conductivity [S.m-1]": 215.0, + "Surface area per unit volume [m-1]": 383959, + "Porosity": 0.25, + "Transport efficiency": 0.125, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 33133, + "Minimum stoichiometry": 0.01, + "Maximum stoichiometry": 0.99, + }, + "Positive electrode": { + "Thickness [m]": 75.6e-6, + "Conductivity [S.m-1]": 0.18, + "Porosity": 0.335, + "Transport efficiency": 0.1939, + "Particle": { + "Primary": { + "Particle radius [m]": 5.22e-6, + "Diffusivity [m2.s-1]": 4.0e-15, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Surface area per unit volume [m-1]": 382184, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 63104.0, + "Minimum stoichiometry": 0.1, + "Maximum stoichiometry": 0.9, + }, + "Secondary": { + "Particle radius [m]": 10.0e-6, + "Diffusivity [m2.s-1]": 4.0e-15, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Surface area per unit volume [m-1]": 382184, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 63104.0, + "Minimum stoichiometry": 0.1, + "Maximum stoichiometry": 0.9, + }, + }, + }, + "Separator": { + "Thickness [m]": 1.2e-5, + "Porosity": 0.47, + "Transport efficiency": 0.3222, + }, + }, + } + def test_simple(self) -> None: test = copy.deepcopy(self.base) adapter.validate_python(test) @@ -261,7 +318,7 @@ def test_missing_model(self) -> None: def test_bad_model(self) -> None: test = copy.deepcopy(self.base) test["Header"]["Model"] = "Wrong model type" - with pytest.raises(ValidationError, match="Input should be 'SPM', 'SPMe' or 'DFN'"): + with pytest.raises(ValidationError, match="Input should be 'SPM', 'SPMe', 'DFN' or 'Partial'"): adapter.validate_python(test) def test_bad_dfn(self) -> None: @@ -617,6 +674,100 @@ def test_error_state_material_incorrect_keys(self) -> None: ): adapter.validate_python(test) + def test_simple_partial_set(self) -> None: + test = copy.deepcopy(self.partial) + adapter.validate_python(test) + + def test_invalid_simple_partial_set(self) -> None: + test = copy.deepcopy(self.partial) + test["Cell"] = {"bad_field": 123} + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + adapter.validate_python(test) + + def test_partial_mismatched_electrodes(self) -> None: + test = copy.deepcopy(self.partial) + # Replace with a valid SPM electrode + test["Parameterisation"]["Negative electrode"] = { + "Particle radius [m]": 5.86e-6, + "Thickness [m]": 85.2e-6, + "Diffusivity [m2.s-1]": 3.3e-14, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Surface area per unit volume [m-1]": 383959, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 33133, + "Minimum stoichiometry": 0.01, + "Maximum stoichiometry": 0.99, + } + + with pytest.raises( + ValidationError, + match=re.escape( + "Negative and positive electrodes must be of consistent model types. " + "Currently types are: Positive electrode: , " + "Negative electrode: ", + ), + ): + adapter.validate_python(test) + + # replace with valid blended SPM electrode + test["Parameterisation"]["Negative electrode"] = { + "Thickness [m]": 75.6e-6, + "Particle": { + "Primary": { + "Particle radius [m]": 5.22e-6, + "Diffusivity [m2.s-1]": 4.0e-15, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Surface area per unit volume [m-1]": 382184, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 63104.0, + "Minimum stoichiometry": 0.1, + "Maximum stoichiometry": 0.9, + }, + }, + } + + with pytest.raises( + ValidationError, + match=re.escape( + "Negative and positive electrodes must be of consistent model types. " + "Currently types are: Positive electrode: , " + "Negative electrode: ", + ), + ): + adapter.validate_python(test) + + def test_partial_dfn_with_spm_electrodes(self) -> None: + test = copy.deepcopy(self.partial) + # Replace with one valid SPM electrode + test["Parameterisation"]["Positive electrode"] = { + "Particle radius [m]": 5.86e-6, + "Thickness [m]": 85.2e-6, + "Diffusivity [m2.s-1]": 3.3e-14, + "OCP [V]": {"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + "Surface area per unit volume [m-1]": 383959, + "Reaction rate constant [mol.m-2.s-1]": 1e-10, + "Maximum concentration [mol.m-3]": 33133, + "Minimum stoichiometry": 0.01, + "Maximum stoichiometry": 0.99, + } + del test["Parameterisation"]["Negative electrode"] + + with pytest.raises( + ValidationError, + match=re.escape("SPM electrodes cannot be used with full model parameters ['electrolyte', 'separator']"), + ): + adapter.validate_python(test) + + def test_no_state_present_not_partial_error(self) -> None: + test = copy.deepcopy(self.base_spm) + del test["State"] + with pytest.raises( + ValidationError, + match=re.escape("'State' section must be provided unless using a 'Partial' parameterisation"), + ): + adapter.validate_python(test) + if __name__ == "__main__": unittest.main()