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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
135 changes: 129 additions & 6 deletions bpx/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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}"

Expand All @@ -639,13 +749,26 @@ 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:
"""
Check that if blended electrodes are used, values which require per-material
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 = {
Expand Down
153 changes: 152 additions & 1 deletion tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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: <class 'bpx.schema.ElectrodeBlended'>, "
"Negative electrode: <class 'bpx.schema.ElectrodeSingleSPM'>",
),
):
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: <class 'bpx.schema.ElectrodeBlended'>, "
"Negative electrode: <class 'bpx.schema.ElectrodeBlendedSPM'>",
),
):
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()