Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate the sto limits subbed into the OCPs give the correct voltage limits #32

Merged
merged 19 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

- Added validation based on models: SPM, SPMe, DFN ([#34](https://github.com/pybamm-team/BPX/pull/34)). A warning will be produced if the user-defined model type does not match the parameter set (e.g., if the model is `SPM`, but the full DFN model parameters are provided).
- Added support for well-mixed, blended electrodes that contain more than one active material ([#33](https://github.com/pybamm-team/BPX/pull/33))
- Added validation of the STO limits subbed into the OCPs vs the upper/lower cut-off voltage limits for non-blended electrodes with the OCPs defined as functions ([#32](https://github.com/FaradayInstitution/BPX/pull/32)). The user can provide a tolerance by updating the settings variable `BPX.settings.v_tol` or by passing extra option `v_tol` to `parse_bpx_file()` function. Default value of the tolerance is 1 mV. The tolerance cannot be negative.
- Added the target SOC check in `get_electrode_concentrations()` function. Raise a warning if the SOC is outside of [0,1] interval.
- In `get_electrode_stoichiometries()` function, raise a warning instead of an error if the SOC is outside of [0,1] interval.

# [v0.3.1](https://github.com/pybamm-team/BPX/releases/tag/v0.3.1)

Expand Down
1 change: 1 addition & 0 deletions bpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .interpolated_table import InterpolatedTable
from .expression_parser import ExpressionParser
from .function import Function
from .validators import check_sto_limits
from .schema import BPX
from .parsers import parse_bpx_str, parse_bpx_obj, parse_bpx_file
from .utilities import get_electrode_stoichiometries, get_electrode_concentrations
9 changes: 7 additions & 2 deletions bpx/parsers.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
from bpx import BPX


def parse_bpx_file(filename: str) -> BPX:
def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also add v_tol to parse_bpx_obj and parse_bpx_str

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit tricky because if I overwrite parse_obj in ExtraBaseModel class, I'll break other methods that call parse_obj.

What would be the most natural and 'future-proof' way of introducing parameter v_tol here?

Copy link
Collaborator

@rtimms rtimms Oct 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use have some global settings, e.g. have a file

class Settings(object):
    tolerances = {
        "Voltage [V]": 1e-3,  
    }

settings = Settings()

then expose settings as part of bpx so we can do bpx.settings.tolerances["Voltage [V]"] = v_tol to update the tolerance before reading a BPX? Maybe there is a cleaner solution.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, thanks!

I changed this setting to bpx.settings.tolerances["Voltage [V]"] but left it inside ExtraBaseModel in schema.py for now.
It's just one parameter at the moment.

"""
Parameters
----------

filename: str
a filepath to a bpx file
v_tol: float
absolute tolerance in [V] to validate the voltage limits, 1 mV by default

Returns
-------
BPX:
a parsed BPX model
"""
return BPX.parse_file(filename)
if v_tol < 0:
raise ValueError("v_tol should not be negative")

return BPX.parse_file(BPX, filename, v_tol=v_tol)


def parse_bpx_obj(bpx: dict) -> BPX:
Expand Down
24 changes: 22 additions & 2 deletions bpx/schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from typing import List, Literal, Union, Dict

from dataclasses import dataclass

from pydantic import BaseModel, Field, Extra, root_validator

from bpx import Function, InterpolatedTable
from bpx import Function, InterpolatedTable, check_sto_limits

from warnings import warn

Expand All @@ -13,6 +15,14 @@ class ExtraBaseModel(BaseModel):
class Config:
extra = Extra.forbid

@dataclass
class settings:
v_tol: float = 0.001 # Absolute tolerance in [V] to validate the voltage limits

def parse_file(self, filename, v_tol):
self.settings.v_tol = v_tol
return super().parse_file(filename)


class Header(ExtraBaseModel):
bpx: float = Field(
Expand Down Expand Up @@ -194,7 +204,7 @@ class Particle(ExtraBaseModel):
)
maximum_concentration: float = Field(
alias="Maximum concentration [mol.m-3]",
example=631040,
example=63104.0,
description="Maximum concentration of lithium ions in particles",
)
particle_radius: float = Field(
Expand Down Expand Up @@ -313,6 +323,11 @@ class Parameterisation(ExtraBaseModel):
alias="Separator",
)

# Reusable validators
_sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)(
check_sto_limits
)


class ParameterisationSPM(ExtraBaseModel):
cell: Cell = Field(
Expand All @@ -325,6 +340,11 @@ class ParameterisationSPM(ExtraBaseModel):
alias="Positive electrode",
)

# Reusable validators
_sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)(
check_sto_limits
)


class BPX(ExtraBaseModel):
header: Header = Field(
Expand Down
8 changes: 7 additions & 1 deletion bpx/utilities.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from warnings import warn


def get_electrode_stoichiometries(target_soc, bpx):
"""
Calculate individual electrode stoichiometries at a particular target
Expand All @@ -16,7 +19,7 @@ def get_electrode_stoichiometries(target_soc, bpx):
The electrode stoichiometries that give the target state of charge
"""
if target_soc < 0 or target_soc > 1:
raise ValueError("Target SOC should be between 0 and 1")
warn("Target SOC should be between 0 and 1")

sto_n_min = bpx.parameterisation.negative_electrode.minimum_stoichiometry
sto_n_max = bpx.parameterisation.negative_electrode.maximum_stoichiometry
Expand Down Expand Up @@ -47,6 +50,9 @@ def get_electrode_concentrations(target_soc, bpx):
c_n, c_p
The electrode concentrations that give the target state of charge
"""
if target_soc < 0 or target_soc > 1:
warn("Target SOC should be between 0 and 1")

c_n_max = bpx.parameterisation.negative_electrode.maximum_concentration
c_p_max = bpx.parameterisation.positive_electrode.maximum_concentration

Expand Down
47 changes: 47 additions & 0 deletions bpx/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from warnings import warn


def check_sto_limits(cls, values):
"""
Validates that the STO limits subbed into the OCPs give the correct voltage limits.
Works if both OCPs are defined as functions.
Blended electrodes are not supported.
This is a reusable validator to be used for both DFN/SPMe and SPM parameter sets.
"""

try:
ocp_n = values.get("negative_electrode").ocp.to_python_function()
ocp_p = values.get("positive_electrode").ocp.to_python_function()
except AttributeError:
# OCPs defined as interpolated tables or one of the electrodes is blended; do nothing
return values

sto_n_min = values.get("negative_electrode").minimum_stoichiometry
sto_n_max = values.get("negative_electrode").maximum_stoichiometry
sto_p_min = values.get("positive_electrode").minimum_stoichiometry
sto_p_max = values.get("positive_electrode").maximum_stoichiometry
V_min = values.get("cell").lower_voltage_cutoff
V_max = values.get("cell").upper_voltage_cutoff

# Voltage tolerance from `settings` data class
tol = cls.settings.v_tol

# Checks the maximum voltage estimated from STO
V_max_sto = ocp_p(sto_p_min) - ocp_n(sto_n_max)
if V_max_sto - V_max > tol:
warn(
f"The maximum voltage computed from the STO limits ({V_max_sto} V) "
f"is higher than the upper voltage cut-off ({V_max} V) "
f"with the absolute tolerance v_tol = {tol} V"
)

# Checks the minimum voltage estimated from STO
V_min_sto = ocp_p(sto_p_max) - ocp_n(sto_n_min)
if V_min_sto - V_min < -tol:
warn(
f"The minimum voltage computed from the STO limits ({V_min_sto} V) "
f"is less than the lower voltage cut-off ({V_min} V) "
f"with the absolute tolerance v_tol = {tol} V"
)

return values
12 changes: 12 additions & 0 deletions tests/test_parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import unittest

from bpx import parse_bpx_file


class TestParsers(unittest.TestCase):
def test_negative_v_tol(self):
with self.assertRaisesRegex(
ValueError,
"v_tol should not be negative",
):
parse_bpx_file("filename", -0.001)
89 changes: 89 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
import warnings
import copy
from pydantic import parse_obj_as, ValidationError

Expand Down Expand Up @@ -141,6 +142,61 @@ def setUp(self):
},
}

# Non-blended electrodes
self.base_non_blended = {
"Header": {
"BPX": 1.0,
"Model": "SPM",
},
"Parameterisation": {
"Cell": {
"Ambient temperature [K]": 299.0,
"Initial temperature [K]": 299.0,
"Reference temperature [K]": 299.0,
"Electrode area [m2]": 2.0,
"External surface area [m2]": 2.2,
"Volume [m3]": 1.0,
"Number of electrode pairs connected in parallel to make a cell": 1,
"Nominal cell capacity [A.h]": 5.0,
"Lower voltage cut-off [V]": 2.0,
"Upper voltage cut-off [V]": 4.0,
},
"Negative electrode": {
"Particle radius [m]": 5.86e-6,
"Thickness [m]": 85.2e-6,
"Diffusivity [m2.s-1]": 3.3e-14,
"OCP [V]": (
"9.47057878e-01 * exp(-1.59418743e+02 * x) - 3.50928033e+04 + "
"1.64230269e-01 * tanh(-4.55509094e+01 * (x - 3.24116012e-02 )) + "
"3.69968491e-02 * tanh(-1.96718868e+01 * (x - 1.68334476e-01)) + "
"1.91517003e+04 * tanh(3.19648312e+00 * (x - 1.85139824e+00)) + "
"5.42448511e+04 * tanh(-3.19009848e+00 * (x - 2.01660395e+00))"
),
"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.005504,
"Maximum stoichiometry": 0.75668,
},
"Positive electrode": {
"Particle radius [m]": 5.22e-6,
"Thickness [m]": 75.6e-6,
"Diffusivity [m2.s-1]": 4.0e-15,
"OCP [V]": (
"-3.04420906 * x + 10.04892207 - "
"0.65637536 * tanh(-4.02134095 * (x - 0.80063948)) + "
"4.24678547 * tanh(12.17805062 * (x - 7.57659337)) - "
"0.3757068 * tanh(59.33067782 * (x - 0.99784492))"
),
"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.42424,
"Maximum stoichiometry": 0.96210,
},
},
}

def test_simple(self):
test = copy.copy(self.base)
parse_obj_as(BPX, test)
Expand Down Expand Up @@ -258,6 +314,39 @@ def test_validation_data(self):
},
}

def test_check_sto_limits_validator(self):
warnings.filterwarnings("error") # Treat warnings as errors
test = copy.copy(self.base_non_blended)
test["Parameterisation"]["Cell"]["Upper voltage cut-off [V]"] = 4.3
test["Parameterisation"]["Cell"]["Lower voltage cut-off [V]"] = 2.5
parse_obj_as(BPX, test)

def test_check_sto_limits_validator_high_voltage(self):
test = copy.copy(self.base_non_blended)
test["Parameterisation"]["Cell"]["Upper voltage cut-off [V]"] = 4.0
with self.assertWarns(UserWarning):
parse_obj_as(BPX, test)

def test_check_sto_limits_validator_high_voltage_tolerance(self):
warnings.filterwarnings("error") # Treat warnings as errors
test = copy.copy(self.base_non_blended)
test["Parameterisation"]["Cell"]["Upper voltage cut-off [V]"] = 4.0
BPX.settings.v_tol = 0.25
parse_obj_as(BPX, test)

def test_check_sto_limits_validator_low_voltage(self):
test = copy.copy(self.base_non_blended)
test["Parameterisation"]["Cell"]["Lower voltage cut-off [V]"] = 3.0
with self.assertWarns(UserWarning):
parse_obj_as(BPX, test)

def test_check_sto_limits_validator_low_voltage_tolerance(self):
warnings.filterwarnings("error") # Treat warnings as errors
test = copy.copy(self.base_non_blended)
test["Parameterisation"]["Cell"]["Lower voltage cut-off [V]"] = 3.0
BPX.settings.v_tol = 0.35
parse_obj_as(BPX, test)


if __name__ == "__main__":
unittest.main()
36 changes: 36 additions & 0 deletions tests/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,42 @@ def test_get_init_conc(self):
self.assertAlmostEqual(x, 23060.568)
self.assertAlmostEqual(y, 21455.36)

def test_get_init_sto_negative_target_soc(self):
test = copy.copy(self.base)
obj = parse_obj_as(BPX, test)
with self.assertWarnsRegex(
UserWarning,
"Target SOC should be between 0 and 1",
):
get_electrode_stoichiometries(-0.1, obj)

def test_get_init_sto_bad_target_soc(self):
test = copy.copy(self.base)
obj = parse_obj_as(BPX, test)
with self.assertWarnsRegex(
UserWarning,
"Target SOC should be between 0 and 1",
):
get_electrode_stoichiometries(1.1, obj)

def test_get_init_conc_negative_target_soc(self):
test = copy.copy(self.base)
obj = parse_obj_as(BPX, test)
with self.assertWarnsRegex(
UserWarning,
"Target SOC should be between 0 and 1",
):
get_electrode_concentrations(-0.5, obj)

def test_get_init_conc_bad_target_soc(self):
test = copy.copy(self.base)
obj = parse_obj_as(BPX, test)
with self.assertWarnsRegex(
UserWarning,
"Target SOC should be between 0 and 1",
):
get_electrode_concentrations(1.05, obj)


if __name__ == "__main__":
unittest.main()