From 602fc0cb3155c8aa02530370eb11c08bda1c5a25 Mon Sep 17 00:00:00 2001 From: Chuck Liu Date: Mon, 1 Apr 2024 17:46:08 +0800 Subject: [PATCH 01/12] feat: migrate to pydantic v2 --- .flake8 | 15 --- .github/workflows/test.yml | 8 +- .gitignore | 3 +- LICENSE.md => LICENSE.txt | 0 bpx/__init__.py | 107 +++++++++++++++-- bpx/expression_parser.py | 20 ++-- bpx/function.py | 55 ++++++--- bpx/interpolated_table.py | 18 +-- bpx/parsers.py | 74 ------------ bpx/schema.py | 235 +++++++++++++++++++++---------------- bpx/utilities.py | 16 ++- bpx/validators.py | 47 -------- docs/conf.py | 2 +- pyproject.toml | 180 ++++++++++++++++++++++++++-- tests/test_parsers.py | 50 ++++---- tests/test_schema.py | 156 ++++++++++++------------ tests/test_utilities.py | 48 ++++---- 17 files changed, 607 insertions(+), 427 deletions(-) delete mode 100644 .flake8 rename LICENSE.md => LICENSE.txt (100%) delete mode 100644 bpx/parsers.py delete mode 100644 bpx/validators.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 84ed8a4..0000000 --- a/.flake8 +++ /dev/null @@ -1,15 +0,0 @@ -[flake8] -max-line-length = 79 -filename= - *.py, - ./test -exclude= - ./dev, - ./.git, - ./.github, - ./myokit.egg-info, - ./myokit/_exec_old.py, - ./myokit/formats/python/template, - ./build, - ./venv, - ./venv2, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b331a94..59a29af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,14 +20,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install coverage flake8 + pip install hatch pip install . - - name: Lint with flake8 + - name: Lint with ruff run: | - flake8 . --count --exit-zero --show-source --statistics + hatch run dev:check - name: Test with unittest run: | - coverage run -m unittest + hatch run dev:cov - name: Upload Coverage to Codecov if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.9 uses: codecov/codecov-action@v2 diff --git a/.gitignore b/.gitignore index 3144cf5..3546960 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ # Translations *.mo @@ -129,4 +130,4 @@ dmypy.json .pyre/ # IDEs -.vscode \ No newline at end of file +.vscode diff --git a/LICENSE.md b/LICENSE.txt similarity index 100% rename from LICENSE.md rename to LICENSE.txt diff --git a/bpx/__init__.py b/bpx/__init__.py index fb33772..2c95c3d 100644 --- a/bpx/__init__.py +++ b/bpx/__init__.py @@ -1,13 +1,102 @@ -"""BPX schema and parsers""" -# flake8: noqa F401 +from .expression_parser import ExpressionParser +from .function import Function +from .interpolated_table import InterpolatedTable +from .schema import BPX, check_sto_limits +from .utilities import get_electrode_concentrations, get_electrode_stoichiometries __version__ = "0.4.0" +__all__ = [ + "BPX", + "ExpressionParser", + "Function", + "InterpolatedTable", + "check_sto_limits", + "get_electrode_concentrations", + "get_electrode_stoichiometries", + "parse_bpx_file", + "parse_bpx_obj", + "parse_bpx_str", +] -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 + +def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a bpx dict into a BPX model. + + Parameters + ---------- + bpx: dict + a dict object in bpx format + v_tol: float + absolute tolerance in [V] to validate the voltage limits, 1 mV by default + + Returns + ------- + BPX: :class:`bpx.BPX` + a parsed BPX model + """ + if v_tol < 0: + error_msg = "v_tol should not be negative" + raise ValueError(error_msg) + + BPX.Settings.tolerances["Voltage [V]"] = v_tol + + return BPX.model_validate(bpx) + + +def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a bpx file into a BPX model. + + 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: :class:`bpx.BPX` + a parsed BPX model + """ + + from pathlib import Path + + bpx = "" + if filename.endswith((".yml", ".yaml")): + import yaml + + with Path(filename).open(encoding="utf-8") as f: + bpx = yaml.safe_load(f) + else: + import orjson as json + + with Path(filename).open(encoding="utf-8") as f: + bpx = json.loads(f.read()) + + return parse_bpx_obj(bpx, v_tol) + + +def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a json formatted string in bpx format into a BPX + model. + + Parameters + ---------- + bpx: str + a json formatted string in bpx format + v_tol: float + absolute tolerance in [V] to validate the voltage limits, 1 mV by default + + Returns + ------- + BPX: + a parsed BPX model + """ + import orjson as json + + bpx = json.loads(bpx) + return parse_bpx_obj(bpx, v_tol) diff --git a/bpx/expression_parser.py b/bpx/expression_parser.py index 58aa87d..2bc1e01 100644 --- a/bpx/expression_parser.py +++ b/bpx/expression_parser.py @@ -15,7 +15,7 @@ class ExpressionParser: ParseException = pp.ParseException - def __init__(self): + def __init__(self) -> None: fnumber = ppc.number() ident = pp.Literal("x") fn_ident = pp.Literal("x") @@ -31,21 +31,15 @@ def __init__(self): expr_list = pp.delimitedList(pp.Group(expr)) - def insert_fn_argcount_tuple(t): + def insert_fn_argcount_tuple(t: tuple) -> None: fn = t.pop(0) num_args = len(t[0]) t.insert(0, (fn, num_args)) - fn_call = (fn_ident + lpar - pp.Group(expr_list) + rpar).setParseAction( - insert_fn_argcount_tuple - ) + fn_call = (fn_ident + lpar - pp.Group(expr_list) + rpar).setParseAction(insert_fn_argcount_tuple) atom = ( - addop[...] - + ( - (fn_call | fnumber | ident).set_parse_action(self.push_first) - | pp.Group(lpar + expr + rpar) - ) + addop[...] + ((fn_call | fnumber | ident).set_parse_action(self.push_first) | pp.Group(lpar + expr + rpar)) ).set_parse_action(self.push_unary_minus) # by defining exponentiation as "atom [ ^ factor ]..." instead of "atom @@ -59,16 +53,16 @@ def insert_fn_argcount_tuple(t): self.expr_stack = [] self.parser = expr - def push_first(self, toks): + def push_first(self, toks: tuple) -> None: self.expr_stack.append(toks[0]) - def push_unary_minus(self, toks): + def push_unary_minus(self, toks: tuple) -> None: for t in toks: if t == "-": self.expr_stack.append("unary -") else: break - def parse_string(self, model_str, parse_all=True): + def parse_string(self, model_str: str, *, parse_all: bool = True) -> None: self.expr_stack = [] self.parser.parseString(model_str, parseAll=parse_all) diff --git a/bpx/function.py b/bpx/function.py index 4a73643..914749d 100644 --- a/bpx/function.py +++ b/bpx/function.py @@ -1,11 +1,19 @@ from __future__ import annotations + import copy -from importlib import util import tempfile -from typing import Callable +from importlib import util +from typing import TYPE_CHECKING, Any + +from pydantic_core import CoreSchema, core_schema from bpx import ExpressionParser +if TYPE_CHECKING: + from collections.abc import Callable + + from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler + class Function(str): """ @@ -16,31 +24,47 @@ class Function(str): - single variable 'x' """ + __slots__ = () + parser = ExpressionParser() default_preamble = "from math import exp, tanh, cosh" @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(examples=["1 + x", "1.9793 * exp(-39.3631 * x)" "2 * x**2"]) + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> dict[str, Any]: + json_schema = handler(core_schema) + json_schema["examples"] = ["1 + x", "1.9793 * exp(-39.3631 * x)" "2 * x**2"] + return handler.resolve_ref_schema(json_schema) @classmethod def validate(cls, v: str) -> Function: if not isinstance(v, str): - raise TypeError("string required") + error_msg = "string required" + raise TypeError(error_msg) try: cls.parser.parse_string(v) except ExpressionParser.ParseException as e: - raise ValueError(str(e)) + raise ValueError(str(e)) from e return cls(v) - def __repr__(self): + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: str, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls.validate, + handler(str), + ) + + def __repr__(self) -> str: return f"Function({super().__repr__()})" - def to_python_function(self, preamble: str = None) -> Callable: + def to_python_function(self, preamble: str | None = None) -> Callable: """ Return a python function that can be called with a single argument 'x' @@ -61,9 +85,7 @@ def to_python_function(self, preamble: str = None) -> Callable: function_body = f" return {self}" source_code = preamble + function_def + function_body - with tempfile.NamedTemporaryFile( - suffix="{}.py".format(function_name), delete=False - ) as tmp: + with tempfile.NamedTemporaryFile(suffix=f"{function_name}.py", delete=False) as tmp: # write to a tempory file so we can # get the source later on using inspect.getsource # (as long as the file still exists) @@ -76,5 +98,4 @@ def to_python_function(self, preamble: str = None) -> Callable: spec.loader.exec_module(module) # return the new function object - value = getattr(module, function_name) - return value + return getattr(module, function_name) diff --git a/bpx/interpolated_table.py b/bpx/interpolated_table.py index a8c3a7b..1aa8b98 100644 --- a/bpx/interpolated_table.py +++ b/bpx/interpolated_table.py @@ -1,6 +1,4 @@ -from typing import List - -from pydantic import BaseModel, validator +from pydantic import BaseModel, ValidationInfo, field_validator class InterpolatedTable(BaseModel): @@ -9,11 +7,13 @@ class InterpolatedTable(BaseModel): by two lists of floats, x and y. The function is defined by interpolation. """ - x: List[float] - y: List[float] + x: list[float] + y: list[float] - @validator("y") - def same_length(cls, v: list, values: dict) -> list: - if "x" in values and len(v) != len(values["x"]): - raise ValueError("x & y should be same length") + @field_validator("y") + @classmethod + def same_length(cls, v: list, info: ValidationInfo) -> list: + if "x" in info.data and len(v) != len(info.data["x"]): + error_msg = "x & y should be same length" + raise ValueError(error_msg) return v diff --git a/bpx/parsers.py b/bpx/parsers.py deleted file mode 100644 index f0824ea..0000000 --- a/bpx/parsers.py +++ /dev/null @@ -1,74 +0,0 @@ -from bpx import BPX - - -def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a bpx file into a BPX model. - - 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: :class:`bpx.BPX` - a parsed BPX model - """ - if v_tol < 0: - raise ValueError("v_tol should not be negative") - - BPX.settings.tolerances["Voltage [V]"] = v_tol - - return BPX.parse_file(filename) - - -def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a bpx dict into a BPX model. - - Parameters - ---------- - bpx: dict - a dict object in bpx format - v_tol: float - absolute tolerance in [V] to validate the voltage limits, 1 mV by default - - Returns - ------- - BPX: :class:`bpx.BPX` - a parsed BPX model - """ - if v_tol < 0: - raise ValueError("v_tol should not be negative") - - BPX.settings.tolerances["Voltage [V]"] = v_tol - - return BPX.parse_obj(bpx) - - -def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a json formatted string in bpx format into a BPX - model. - - Parameters - ---------- - bpx: str - a json formatted string in bpx format - v_tol: float - absolute tolerance in [V] to validate the voltage limits, 1 mV by default - - Returns - ------- - BPX: - a parsed BPX model - """ - if v_tol < 0: - raise ValueError("v_tol should not be negative") - - BPX.settings.tolerances["Voltage [V]"] = v_tol - - return BPX.parse_raw(bpx) diff --git a/bpx/schema.py b/bpx/schema.py index 13b9921..3c5d735 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -1,9 +1,11 @@ -from typing import List, Literal, Union, Dict, get_args -from pydantic import BaseModel, Field, Extra, root_validator -from bpx import Function, InterpolatedTable, check_sto_limits +from typing import ClassVar, Literal, get_args from warnings import warn -FloatFunctionTable = Union[float, Function, InterpolatedTable] +from pydantic import BaseModel, ConfigDict, Field, model_validator, root_validator + +from bpx import Function, InterpolatedTable + +FloatFunctionTable = float | Function | InterpolatedTable class ExtraBaseModel(BaseModel): @@ -11,16 +13,15 @@ class ExtraBaseModel(BaseModel): A base model that forbids extra fields """ - class Config: - extra = Extra.forbid + model_config = ConfigDict(extra="forbid") - class settings: + class Settings: """ Class with BPX-related settings. It might be worth moving it to a separate file if it grows bigger. """ - tolerances = { + tolerances: ClassVar[dict] = { "Voltage [V]": 1e-3, # Absolute tolerance in [V] to validate the voltage limits } @@ -33,30 +34,30 @@ class Header(ExtraBaseModel): bpx: float = Field( alias="BPX", - example=1.0, + examples=[1.0], description="BPX format version", ) title: str = Field( None, alias="Title", - example="Parameterisation example", + examples=["Parameterisation example"], description="LGM50 battery parametrisation", ) description: str = Field( None, alias="Description", description=("May contain additional cell description such as form factor"), - example="Pouch cell (191mm x 88mm x 7.6mm)", + examples=["Pouch cell (191mm x 88mm x 7.6mm)"], ) references: str = Field( None, alias="References", description=("May contain any references"), - example="Chang-Hui Chen et al 2020 J. Electrochem. Soc. 167 080534", + examples=["Chang-Hui Chen et al 2020 J. Electrochem. Soc. 167 080534"], ) model: Literal["SPM", "SPMe", "DFN"] = Field( alias="Model", - example="DFN", + examples=["DFN"], description=('Model type ("SPM", "SPMe", "DFN")'), ) @@ -70,73 +71,71 @@ class Cell(ExtraBaseModel): electrode_area: float = Field( alias="Electrode area [m2]", description="Electrode cross-sectional area", - example=1.68e-2, + examples=[1.68e-2], ) external_surface_area: float = Field( None, alias="External surface area [m2]", - example=3.78e-2, + examples=[3.78e-2], description="External surface area of cell", ) volume: float = Field( None, alias="Volume [m3]", - example=1.27e-4, + examples=[1.27e-4], description="Volume of the cell", ) number_of_electrodes: int = Field( alias="Number of electrode pairs connected in parallel to make a cell", - example=1, + examples=[1], description=("Number of electrode pairs connected in parallel to make a cell"), ) lower_voltage_cutoff: float = Field( alias="Lower voltage cut-off [V]", description="Minimum allowed voltage", - example=2.0, + examples=[2.0], ) upper_voltage_cutoff: float = Field( alias="Upper voltage cut-off [V]", description="Maximum allowed voltage", - example=4.4, + examples=[4.4], ) nominal_cell_capacity: float = Field( alias="Nominal cell capacity [A.h]", - description=( - "Nominal cell capacity. " "Used to convert between current and C-rate." - ), - example=5.0, + description=("Nominal cell capacity. " "Used to convert between current and C-rate."), + examples=[5.0], ) ambient_temperature: float = Field( alias="Ambient temperature [K]", - example=298.15, + examples=[298.15], ) initial_temperature: float = Field( None, alias="Initial temperature [K]", - example=298.15, + examples=[298.15], ) reference_temperature: float = Field( None, alias="Reference temperature [K]", description=("Reference temperature for the Arrhenius temperature dependence"), - example=298.15, + examples=[298.15], ) density: float = Field( None, alias="Density [kg.m-3]", - example=1000.0, + examples=[1000.0], description="Density (lumped)", ) specific_heat_capacity: float = Field( None, alias="Specific heat capacity [J.K-1.kg-1]", - example=1000.0, + examples=[1000.0], description="Specific heat capacity (lumped)", ) thermal_conductivity: float = Field( None, alias="Thermal conductivity [W.m-1.K-1]", - example=1.0, + examples=[1.0], description="Thermal conductivity (lumped)", ) @@ -148,39 +147,34 @@ class Electrolyte(ExtraBaseModel): initial_concentration: float = Field( alias="Initial concentration [mol.m-3]", - example=1000, + examples=[1000], description=("Initial / rest lithium ion concentration in the electrolyte"), ) cation_transference_number: float = Field( alias="Cation transference number", - example=0.259, + examples=[0.259], description="Cation transference number", ) diffusivity: FloatFunctionTable = Field( alias="Diffusivity [m2.s-1]", - example="8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6", - description=( - "Lithium ion diffusivity in electrolyte (constant or function " - "of concentration)" - ), + examples=["8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6"], + description=("Lithium ion diffusivity in electrolyte (constant or function " "of concentration)"), ) diffusivity_activation_energy: float = Field( None, alias="Diffusivity activation energy [J.mol-1]", - example=17100, + examples=[17100], description="Activation energy for diffusivity in electrolyte", ) conductivity: FloatFunctionTable = Field( alias="Conductivity [S.m-1]", - example=1.0, - description=( - "Electrolyte conductivity (constant or function of concentration)" - ), + examples=[1.0], + description=("Electrolyte conductivity (constant or function of concentration)"), ) conductivity_activation_energy: float = Field( None, alias="Conductivity activation energy [J.mol-1]", - example=17100, + examples=[17100], description="Activation energy for conductivity in electrolyte", ) @@ -192,7 +186,7 @@ class ContactBase(ExtraBaseModel): thickness: float = Field( alias="Thickness [m]", - example=85.2e-6, + examples=[85.2e-6], description="Contact thickness", ) @@ -204,12 +198,12 @@ class Contact(ContactBase): porosity: float = Field( alias="Porosity", - example=0.47, + examples=[0.47], description="Electrolyte volume fraction (porosity)", ) transport_efficiency: float = Field( alias="Transport efficiency", - example=0.3222, + examples=[0.3222], description="Transport efficiency / inverse MacMullin number", ) @@ -221,66 +215,62 @@ class Particle(ExtraBaseModel): minimum_stoichiometry: float = Field( alias="Minimum stoichiometry", - example=0.1, + examples=[0.1], description="Minimum stoichiometry", ) maximum_stoichiometry: float = Field( alias="Maximum stoichiometry", - example=0.9, + examples=[0.9], description="Maximum stoichiometry", ) maximum_concentration: float = Field( alias="Maximum concentration [mol.m-3]", - example=63104.0, + examples=[63104.0], description="Maximum concentration of lithium ions in particles", ) particle_radius: float = Field( alias="Particle radius [m]", - example=5.86e-6, + examples=[5.86e-6], description="Particle radius", ) surface_area_per_unit_volume: float = Field( alias="Surface area per unit volume [m-1]", - example=382184, + examples=[382184], description="Particle surface area per unit of volume", ) diffusivity: FloatFunctionTable = Field( alias="Diffusivity [m2.s-1]", - example="3.3e-14", - description=( - "Lithium ion diffusivity in particle (constant or function " - "of stoichiometry)" - ), + examples=["3.3e-14"], + description=("Lithium ion diffusivity in particle (constant or function " "of stoichiometry)"), ) diffusivity_activation_energy: float = Field( None, alias="Diffusivity activation energy [J.mol-1]", - example=17800, + examples=[17800], description="Activation energy for diffusivity in particles", ) ocp: FloatFunctionTable = Field( alias="OCP [V]", - example={"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}, + examples=[{"x": [0, 0.1, 1], "y": [1.72, 1.2, 0.06]}], description=( - "Open-circuit potential (OCP) at the reference temperature, " - "function of particle stoichiometry" + "Open-circuit potential (OCP) at the reference temperature, " "function of particle stoichiometry" ), ) dudt: FloatFunctionTable = Field( None, alias="Entropic change coefficient [V.K-1]", - example={"x": [0, 0.1, 1], "y": [-9e-18, -9e-15, -1e-5]}, + examples=[{"x": [0, 0.1, 1], "y": [-9e-18, -9e-15, -1e-5]}], description=("Entropic change coefficient, function of particle stoichiometry"), ) reaction_rate_constant: float = Field( alias="Reaction rate constant [mol.m-2.s-1]", - example=1e-10, + examples=[1e-10], description="Normalised reaction rate K (see notes)", ) reaction_rate_constant_activation_energy: float = Field( None, alias="Reaction rate constant activation energy [J.mol-1]", - example=27010, + examples=[27010], description="Activation energy of reaction rate constant in particles", ) @@ -292,7 +282,7 @@ class Electrode(Contact): conductivity: float = Field( alias="Conductivity [S.m-1]", - example=0.18, + examples=[0.18], description=("Effective electronic conductivity of the porous electrode matrix (constant)"), ) @@ -302,15 +292,13 @@ class ElectrodeSingle(Electrode, Particle): Class for electrode composed of a single active material. """ - pass - class ElectrodeBlended(Electrode): """ Class for electrode composed of a blend of active materials. """ - particle: Dict[str, Particle] = Field(alias="Particle") + particle: dict[str, Particle] = Field(alias="Particle") class ElectrodeSingleSPM(ContactBase, Particle): @@ -319,8 +307,6 @@ class ElectrodeSingleSPM(ContactBase, Particle): Particle type models. """ - pass - class ElectrodeBlendedSPM(ContactBase): """ @@ -328,14 +314,13 @@ class ElectrodeBlendedSPM(ContactBase): Particle type models. """ - particle: Dict[str, Particle] = Field(alias="Particle") + particle: dict[str, Particle] = Field(alias="Particle") class UserDefined(BaseModel): - class Config: - extra = Extra.allow + model_config = ConfigDict(extra="allow") - def __init__(self, **data): + def __init__(self, **data: int) -> None: """ Overwrite the default __init__ to convert strings to Function objects and dicts to InterpolatedTable objects @@ -347,11 +332,13 @@ def __init__(self, **data): data[k] = InterpolatedTable(**v) super().__init__(**data) - @root_validator(pre=True) - def validate_extra_fields(cls, values): + @model_validator(mode="before") + @classmethod + def validate_extra_fields(cls, values: dict) -> dict: for k, v in values.items(): if not isinstance(v, get_args(FloatFunctionTable)): - raise TypeError(f"{k} must be of type 'FloatFunctionTable'") + error_msg = f"{k} must be of type 'FloatFunctionTable'" + raise TypeError(error_msg) return values @@ -360,29 +347,77 @@ class Experiment(ExtraBaseModel): A class to store experimental data (time, current, voltage, temperature). """ - time: List[float] = Field( + time: list[float] = Field( alias="Time [s]", - example=[0, 0.1, 0.2, 0.3, 0.4], + examples=[[0, 0.1, 0.2, 0.3, 0.4]], description="Time in seconds (list of floats)", ) - current: List[float] = Field( + current: list[float] = Field( alias="Current [A]", - example=[-5, -5, -5, -5, -5], + examples=[[-5, -5, -5, -5, -5]], description="Current vs time", ) - voltage: List[float] = Field( + voltage: list[float] = Field( alias="Voltage [V]", - example=[4.2, 4.1, 4.0, 3.9, 3.8], + examples=[[4.2, 4.1, 4.0, 3.9, 3.8]], description="Voltage vs time", ) - temperature: List[float] = Field( + temperature: list[float] = Field( None, alias="Temperature [K]", - example=[298, 298, 298, 298, 298], + examples=[[298, 298, 298, 298, 298]], description="Temperature vs time", ) +def check_sto_limits(cls: ExtraBaseModel, values: dict) -> dict: + """ + 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.tolerances["Voltage [V]"] + + # 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", + stacklevel=2, + ) + + # 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", + stacklevel=2, + ) + + return values + + class Parameterisation(ExtraBaseModel): """ A class to store parameterisation data for a cell. Consists of parameters for the @@ -395,10 +430,10 @@ class Parameterisation(ExtraBaseModel): electrolyte: Electrolyte = Field( alias="Electrolyte", ) - negative_electrode: Union[ElectrodeSingle, ElectrodeBlended] = Field( + negative_electrode: ElectrodeSingle | ElectrodeBlended = Field( alias="Negative electrode", ) - positive_electrode: Union[ElectrodeSingle, ElectrodeBlended] = Field( + positive_electrode: ElectrodeSingle | ElectrodeBlended = Field( alias="Positive electrode", ) separator: Contact = Field( @@ -408,11 +443,7 @@ class Parameterisation(ExtraBaseModel): None, alias="User-defined", ) - - # Reusable validators - _sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)( - check_sto_limits - ) + _sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)(check_sto_limits) class ParameterisationSPM(ExtraBaseModel): @@ -425,21 +456,17 @@ class ParameterisationSPM(ExtraBaseModel): cell: Cell = Field( alias="Cell", ) - negative_electrode: Union[ElectrodeSingleSPM, ElectrodeBlendedSPM] = Field( + negative_electrode: ElectrodeSingleSPM | ElectrodeBlendedSPM = Field( alias="Negative electrode", ) - positive_electrode: Union[ElectrodeSingleSPM, ElectrodeBlendedSPM] = Field( + positive_electrode: ElectrodeSingleSPM | ElectrodeBlendedSPM = Field( alias="Positive electrode", ) user_defined: UserDefined = Field( None, alias="User-defined", ) - - # Reusable validators - _sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)( - check_sto_limits - ) + _sto_limit_validation = root_validator(skip_on_failure=True, allow_reuse=True)(check_sto_limits) class BPX(ExtraBaseModel): @@ -451,13 +478,12 @@ class BPX(ExtraBaseModel): header: Header = Field( alias="Header", ) - parameterisation: Union[ParameterisationSPM, Parameterisation] = Field( - alias="Parameterisation" - ) - validation: Dict[str, Experiment] = Field(None, alias="Validation") + parameterisation: ParameterisationSPM | Parameterisation = Field(alias="Parameterisation") + validation: dict[str, Experiment] = Field(None, alias="Validation") @root_validator(skip_on_failure=True) - def model_based_validation(cls, values): + @classmethod + def model_based_validation(cls, values: dict) -> dict: model = values.get("header").model parameter_class_name = values.get("parameterisation").__class__.__name__ allowed_combinations = [ @@ -466,5 +492,8 @@ def model_based_validation(cls, values): ("ParameterisationSPM", "SPM"), ] if (parameter_class_name, model) not in allowed_combinations: - warn(f"The model type {model} does not correspond to the parameter set") + warn( + f"The model type {model} does not correspond to the parameter set", + stacklevel=2, + ) return values diff --git a/bpx/utilities.py b/bpx/utilities.py index 6e01e63..c609eeb 100644 --- a/bpx/utilities.py +++ b/bpx/utilities.py @@ -1,7 +1,9 @@ from warnings import warn +from bpx import BPX -def get_electrode_stoichiometries(target_soc, bpx): + +def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> tuple[float, float]: """ Calculate individual electrode stoichiometries at a particular target state of charge, given stoichiometric limits defined by bpx @@ -19,7 +21,10 @@ 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: - warn("Target SOC should be between 0 and 1") + warn( + "Target SOC should be between 0 and 1", + stacklevel=2, + ) sto_n_min = bpx.parameterisation.negative_electrode.minimum_stoichiometry sto_n_max = bpx.parameterisation.negative_electrode.maximum_stoichiometry @@ -32,7 +37,7 @@ def get_electrode_stoichiometries(target_soc, bpx): return sto_n, sto_p -def get_electrode_concentrations(target_soc, bpx): +def get_electrode_concentrations(target_soc: float, bpx: BPX) -> tuple[float, float]: """ Calculate individual electrode concentrations at a particular target state of charge, given stoichiometric limits and maximum concentrations @@ -51,7 +56,10 @@ def get_electrode_concentrations(target_soc, bpx): 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") + warn( + "Target SOC should be between 0 and 1", + stacklevel=2, + ) c_n_max = bpx.parameterisation.negative_electrode.maximum_concentration c_p_max = bpx.parameterisation.positive_electrode.maximum_concentration diff --git a/bpx/validators.py b/bpx/validators.py deleted file mode 100644 index 5825b10..0000000 --- a/bpx/validators.py +++ /dev/null @@ -1,47 +0,0 @@ -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.tolerances["Voltage [V]"] - - # 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 diff --git a/docs/conf.py b/docs/conf.py index d212099..cb21f81 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -14,6 +13,7 @@ # import os import sys + import bpx # Path for repository root diff --git a/pyproject.toml b/pyproject.toml index 584f1b2..3c14daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,51 @@ [build-system] -requires = ["flit_core >=3.2,<4"] -build-backend = "flit_core.buildapi" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "bpx" -authors = [{name = "Martin Robinson", email = "martin.robinson@dtc.ox.ac.uk"}] +dynamic = ["version"] +description = "An implementation of the Battery Parameter eXchange (BPX) format in Pydantic." readme = "README.md" -dynamic = ["version", "description"] +requires-python = ">=3.8" +license = { file = "LICENSE.txt" } +keywords = [ + "bpx", + "battery", +] +authors = [ + { name = "Martin Robinson", email = "martin.robinson@dtc.ox.ac.uk" }, + { name = "Chuck Liu", email = "liuchengkun@finalfrontier.cn" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", +] dependencies = [ - "devtools", - "pydantic<2", + "pydantic >= 2.6", "pyparsing", + "pyyaml", + "orjson", ] +[project.urls] +Homepage = "https://gitlab.finalfrontier.cn/blade/bpx.git" +Repository = "https://gitlab.finalfrontier.cn/blade/bpx.git" + [project.optional-dependencies] +# Dependencies intended for use by developers dev = [ - 'coverage', # Coverage checking - 'flake8>=3', # Style checking + "ruff", + "pre-commit", + "pyclean", + "pytest", + "coverage[toml] >= 6.5", + "devtools", +] +docs = [ "sphinx>=6", "sphinx_rtd_theme>=0.5", "pydata-sphinx-theme", @@ -25,3 +54,138 @@ dev = [ "myst-parser", "sphinx-inline-tabs", ] + +[tool.hatch.version] +path = "bpx/__init__.py" + +[tool.hatch.envs.dev] +features = [ + "dev", + "docs", +] +post-install-commands = [ + "pip install --upgrade pip", +] +[tool.hatch.envs.dev.scripts] +clean = "pyclean ." +check = "ruff check {args}" +format = "ruff format {args}" +test = "pytest {args:tests}" +test-cov = "coverage run -m pytest {args:tests}" +cov-report = [ + "- coverage combine", + "coverage report", +] +cov = [ + "test-cov", + "cov-report", +] + +[tool.hatch.envs.hatch-static-analysis] +config-path = "none" + +# https://github.com/astral-sh/ruff +[tool.ruff] +line-length = 120 + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 80 + +[tool.ruff.lint] +select = [ + "F", # pyflakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "C901", # mccabe complex-structure + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "YTT", # flake8-2020 + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-except + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "DJ", # flake8-django + "EM", # flake8-errmsg + "ICN", # flake8-import-conventiions + "PIE", # flake8-pie + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "TD", # flake8-todos + "FIX", # flake8-fixme + "ERA", # eradicate + "PD", # pandas-vet + "PL", # pylint + "TRY", # tryceratops + "FLY", # flynt + "NPY", # numpy-specific rules + "PERF", # perflint + "RUF", # ruff-specific rules +] +ignore = [ + "ANN101", # missing type self + "ANN102", # missing type cls + "COM812", # trailing comma missing +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*" = ["S101", "PLR2004"] +"docs/conf.py" = ["A001", "ERA001", "PTH100", "T201", "UP031"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "-ra", + "--strict-config", + "--strict-markers", + "--import-mode=importlib", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + "ignore::pydantic.PydanticDeprecatedSince20", +] +testpaths = [ + "tests", +] + +[tool.coverage.run] +branch = true +parallel = true +source = [ + "bpx", +] +disable_warnings = [ + "no-data-collected", +] +omit = [ + "bpx/__init__.py", +] + +[tool.coverage.report] +precision = 2 +fail_under = 0 +show_missing = true +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/tests/test_parsers.py b/tests/test_parsers.py index c278c2e..efe43a2 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -1,12 +1,14 @@ +import copy import unittest import warnings -import copy -from bpx import BPX, parse_bpx_file, parse_bpx_obj, parse_bpx_str +import pytest + +from bpx import parse_bpx_file, parse_bpx_obj, parse_bpx_str class TestParsers(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: base = """ { "Header": { @@ -33,7 +35,8 @@ def setUp(self): "Electrolyte": { "Initial concentration [mol.m-3]": 1000, "Cation transference number": 0.2594, - "Conductivity [S.m-1]": "0.1297 * (x / 1000) ** 3 - 2.51 * (x / 1000) ** 1.5 + 3.329 * (x / 1000)", + "Conductivity [S.m-1]": + "0.1297 * (x / 1000) ** 3 - 2.51 * (x / 1000) ** 1.5 + 3.329 * (x / 1000)", "Diffusivity [m2.s-1]": "8.794e-11 * (x / 1000) ** 2 - 3.972e-10 * (x / 1000) + 4.862e-10", "Conductivity activation energy [J.mol-1]": 17100, "Diffusivity activation energy [J.mol-1]": 17100 @@ -66,8 +69,10 @@ def setUp(self): "Thickness [m]": 5.23e-05, "Diffusivity [m2.s-1]": 3.2e-14, "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))", + "-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))", "Entropic change coefficient [V.K-1]": -1e-4, "Conductivity [S.m-1]": 0.789, "Surface area per unit volume [m-1]": 432072, @@ -90,34 +95,39 @@ def setUp(self): """ self.base = base.replace("\n", "") - def test_negative_v_tol_file(self): - with self.assertRaisesRegex( + @pytest.fixture(autouse=True) + def _temp_bpx_file(self, tmp_path: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + tmp_path.joinpath("test.json").write_text("{}") + + def test_negative_v_tol_file(self) -> None: + with pytest.raises( ValueError, - "v_tol should not be negative", + match="v_tol should not be negative", ): - parse_bpx_file("filename", v_tol=-0.001) + parse_bpx_file("test.json", v_tol=-0.001) - def test_negative_v_tol_object(self): + def test_negative_v_tol_object(self) -> None: bpx_obj = {"BPX": 1.0} - with self.assertRaisesRegex( + with pytest.raises( ValueError, - "v_tol should not be negative", + match="v_tol should not be negative", ): parse_bpx_obj(bpx_obj, v_tol=-0.001) - def test_negative_v_tol_string(self): - with self.assertRaisesRegex( + def test_negative_v_tol_string(self) -> None: + with pytest.raises( ValueError, - "v_tol should not be negative", + match="v_tol should not be negative", ): - parse_bpx_str("String", v_tol=-0.001) + parse_bpx_str('{"BPX": 1.0}', v_tol=-0.001) - def test_parse_string(self): + def test_parse_string(self) -> None: test = copy.copy(self.base) - with self.assertWarns(UserWarning): + with pytest.warns(UserWarning): parse_bpx_str(test) - def test_parse_string_tolerance(self): + def test_parse_string_tolerance(self) -> None: warnings.filterwarnings("error") # Treat warnings as errors test = copy.copy(self.base) parse_bpx_str(test, v_tol=0.002) diff --git a/tests/test_schema.py b/tests/test_schema.py index 588ff73..99be0a6 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,13 +1,17 @@ +import copy import unittest import warnings -import copy -from pydantic import parse_obj_as, ValidationError + +import pytest +from pydantic import TypeAdapter, ValidationError from bpx import BPX +adapter = TypeAdapter(BPX) + class TestSchema(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.base = { "Header": { "BPX": 1.0, @@ -30,9 +34,7 @@ def setUp(self): "Initial concentration [mol.m-3]": 1000, "Cation transference number": 0.259, "Conductivity [S.m-1]": 1.0, - "Diffusivity [m2.s-1]": ( - "8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6" - ), + "Diffusivity [m2.s-1]": ("8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6"), }, "Negative electrode": { "Particle radius [m]": 5.86e-6, @@ -197,107 +199,103 @@ def setUp(self): }, } - def test_simple(self): + def test_simple(self) -> None: test = copy.copy(self.base) - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_simple_spme(self): + def test_simple_spme(self) -> None: test = copy.copy(self.base) test["Header"]["Model"] = "SPMe" - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_simple_spm(self): + def test_simple_spm(self) -> None: test = copy.copy(self.base_spm) - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_bad_model(self): + def test_bad_model(self) -> None: test = copy.copy(self.base) test["Header"]["Model"] = "Wrong model type" - with self.assertRaises(ValidationError): - parse_obj_as(BPX, test) + with pytest.raises(ValidationError): + adapter.validate_python(test) - def test_bad_dfn(self): + def test_bad_dfn(self) -> None: test = copy.copy(self.base_spm) test["Header"]["Model"] = "DFN" - with self.assertWarnsRegex( + with pytest.warns( UserWarning, - "The model type DFN does not correspond to the parameter set", + match="The model type DFN does not correspond to the parameter set", ): - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_bad_spme(self): + def test_bad_spme(self) -> None: test = copy.copy(self.base_spm) test["Header"]["Model"] = "SPMe" - with self.assertWarnsRegex( + with pytest.warns( UserWarning, - "The model type SPMe does not correspond to the parameter set", + match="The model type SPMe does not correspond to the parameter set", ): - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_bad_spm(self): + def test_bad_spm(self) -> None: test = copy.copy(self.base) test["Header"]["Model"] = "SPM" - with self.assertWarnsRegex( + with pytest.warns( UserWarning, - "The model type SPM does not correspond to the parameter set", + match="The model type SPM does not correspond to the parameter set", ): - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_table(self): + def test_table(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = { "x": [1.0, 2.0], "y": [2.3, 4.5], } - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_bad_table(self): + def test_bad_table(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = { "x": [1.0, 2.0], "y": [2.3], } - with self.assertRaisesRegex( + with pytest.raises( ValidationError, - "x & y should be same length", + match="x & y should be same length", ): - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_function(self): + def test_function(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = "1.0 * x + 3" - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_function_with_exp(self): + def test_function_with_exp(self) -> None: test = copy.copy(self.base) - test["Parameterisation"]["Electrolyte"][ - "Conductivity [S.m-1]" - ] = "1.0 * exp(x) + 3" - parse_obj_as(BPX, test) + test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = "1.0 * exp(x) + 3" + adapter.validate_python(test) - def test_bad_function(self): + def test_bad_function(self) -> None: test = copy.copy(self.base) - test["Parameterisation"]["Electrolyte"][ - "Conductivity [S.m-1]" - ] = "this is not a function" - with self.assertRaises(ValidationError): - parse_obj_as(BPX, test) + test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = "this is not a function" + with pytest.raises(ValidationError): + adapter.validate_python(test) - def test_to_python_function(self): + def test_to_python_function(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = "2.0 * x" - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(test) funct = obj.parameterisation.electrolyte.conductivity pyfunct = funct.to_python_function() - self.assertEqual(pyfunct(2.0), 4.0) + assert pyfunct(2.0) == 4.0 - def test_bad_input(self): + def test_bad_input(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["Electrolyte"]["bad"] = "this shouldn't be here" - with self.assertRaises(ValidationError): - parse_obj_as(BPX, test) + with pytest.raises(ValidationError): + adapter.validate_python(test) - def test_validation_data(self): + def test_validation_data(self) -> None: test = copy.copy(self.base) test["Validation"] = { "Experiment 1": { @@ -314,52 +312,52 @@ def test_validation_data(self): }, } - def test_check_sto_limits_validator(self): + def test_check_sto_limits_validator(self) -> None: 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) + adapter.validate_python(test) - def test_check_sto_limits_validator_high_voltage(self): + def test_check_sto_limits_validator_high_voltage(self) -> None: 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) + with pytest.warns(UserWarning): + adapter.validate_python(test) - def test_check_sto_limits_validator_high_voltage_tolerance(self): + def test_check_sto_limits_validator_high_voltage_tolerance(self) -> None: 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.tolerances["Voltage [V]"] = 0.25 - parse_obj_as(BPX, test) + BPX.Settings.tolerances["Voltage [V]"] = 0.25 + adapter.validate_python(test) - def test_check_sto_limits_validator_low_voltage(self): + def test_check_sto_limits_validator_low_voltage(self) -> None: 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) + with pytest.warns(UserWarning): + adapter.validate_python(test) - def test_check_sto_limits_validator_low_voltage_tolerance(self): + def test_check_sto_limits_validator_low_voltage_tolerance(self) -> None: 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.tolerances["Voltage [V]"] = 0.35 - parse_obj_as(BPX, test) + BPX.Settings.tolerances["Voltage [V]"] = 0.35 + adapter.validate_python(test) - def test_user_defined(self): + def test_user_defined(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["User-defined"] = { "a": 1.0, "b": 2.0, "c": 3.0, } - obj = parse_obj_as(BPX, test) - self.assertEqual(obj.parameterisation.user_defined.a, 1) - self.assertEqual(obj.parameterisation.user_defined.b, 2) - self.assertEqual(obj.parameterisation.user_defined.c, 3) + obj = adapter.validate_python(test) + assert obj.parameterisation.user_defined.a == 1 + assert obj.parameterisation.user_defined.b == 2 + assert obj.parameterisation.user_defined.c == 3 - def test_user_defined_table(self): + def test_user_defined_table(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["User-defined"] = { "a": { @@ -367,21 +365,21 @@ def test_user_defined_table(self): "y": [2.3, 4.5], }, } - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_user_defined_function(self): + def test_user_defined_function(self) -> None: test = copy.copy(self.base) test["Parameterisation"]["User-defined"] = {"a": "2.0 * x"} - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_bad_user_defined(self): + def test_bad_user_defined(self) -> None: test = copy.copy(self.base) # bool not allowed type test["Parameterisation"]["User-defined"] = { "bad": True, } - with self.assertRaises(ValidationError): - parse_obj_as(BPX, test) + with pytest.raises(TypeError): + adapter.validate_python(test) if __name__ == "__main__": diff --git a/tests/test_utilities.py b/tests/test_utilities.py index e530549..6887434 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -1,12 +1,16 @@ -import unittest import copy -from pydantic import parse_obj_as +import unittest + +import pytest +from pydantic import TypeAdapter + +from bpx import BPX, get_electrode_concentrations, get_electrode_stoichiometries -from bpx import BPX, get_electrode_stoichiometries, get_electrode_concentrations +adapter = TypeAdapter(BPX) class TestUtlilities(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.base = { "Header": { "BPX": 1.0, @@ -29,9 +33,7 @@ def setUp(self): "Initial concentration [mol.m-3]": 1000, "Cation transference number": 0.259, "Conductivity [S.m-1]": 1.0, - "Diffusivity [m2.s-1]": ( - "8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6" - ), + "Diffusivity [m2.s-1]": ("8.794e-7 * x * x - 3.972e-6 * x + 4.862e-6"), }, "Negative electrode": { "Particle radius [m]": 5.86e-6, @@ -69,50 +71,50 @@ def setUp(self): }, } - def test_get_init_sto(self): + def test_get_init_sto(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(test) x, y = get_electrode_stoichiometries(0.3, obj) - self.assertAlmostEqual(x, 0.304) - self.assertAlmostEqual(y, 0.66) + assert x == pytest.approx(0.304) + assert y == pytest.approx(0.66) - def test_get_init_conc(self): + def test_get_init_conc(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(test) x, y = get_electrode_concentrations(0.7, obj) - self.assertAlmostEqual(x, 23060.568) - self.assertAlmostEqual(y, 21455.36) + assert x == pytest.approx(23060.568) + assert y == pytest.approx(21455.36) - def test_get_init_sto_negative_target_soc(self): + def test_get_init_sto_negative_target_soc(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(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): + def test_get_init_sto_bad_target_soc(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(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): + def test_get_init_conc_negative_target_soc(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(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): + def test_get_init_conc_bad_target_soc(self) -> None: test = copy.copy(self.base) - obj = parse_obj_as(BPX, test) + obj = adapter.validate_python(test) with self.assertWarnsRegex( UserWarning, "Target SOC should be between 0 and 1", From 975e75a20a8d7b72d4fb2e3c0c7b52342673fb14 Mon Sep 17 00:00:00 2001 From: Chuck Liu Date: Thu, 4 Apr 2024 13:08:12 +0800 Subject: [PATCH 02/12] fix: typo in project urls --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c14daf..8626687 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ ] [project.urls] -Homepage = "https://gitlab.finalfrontier.cn/blade/bpx.git" -Repository = "https://gitlab.finalfrontier.cn/blade/bpx.git" +Homepage = "https://github.com/FaradayInstitution/BPX" +Repository = "https://github.com/FaradayInstitution/BPX" [project.optional-dependencies] # Dependencies intended for use by developers From 46df99c1be0a76a8388a6715ac5fbda13fac1e41 Mon Sep 17 00:00:00 2001 From: Chuck Liu Date: Sun, 7 Apr 2024 11:07:55 +0800 Subject: [PATCH 03/12] fix: type hints compat python >= 3.8 --- bpx/interpolated_table.py | 8 ++++++-- bpx/schema.py | 30 ++++++++++++++++-------------- bpx/utilities.py | 5 +++-- pyproject.toml | 6 ++++++ 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/bpx/interpolated_table.py b/bpx/interpolated_table.py index 1aa8b98..33625dd 100644 --- a/bpx/interpolated_table.py +++ b/bpx/interpolated_table.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import List + from pydantic import BaseModel, ValidationInfo, field_validator @@ -7,8 +11,8 @@ class InterpolatedTable(BaseModel): by two lists of floats, x and y. The function is defined by interpolation. """ - x: list[float] - y: list[float] + x: List[float] + y: List[float] @field_validator("y") @classmethod diff --git a/bpx/schema.py b/bpx/schema.py index 3c5d735..213f73e 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -1,11 +1,13 @@ -from typing import ClassVar, Literal, get_args +from __future__ import annotations + +from typing import ClassVar, Dict, List, Literal, Union, get_args from warnings import warn from pydantic import BaseModel, ConfigDict, Field, model_validator, root_validator from bpx import Function, InterpolatedTable -FloatFunctionTable = float | Function | InterpolatedTable +FloatFunctionTable = Union[float, Function, InterpolatedTable] class ExtraBaseModel(BaseModel): @@ -298,7 +300,7 @@ class ElectrodeBlended(Electrode): Class for electrode composed of a blend of active materials. """ - particle: dict[str, Particle] = Field(alias="Particle") + particle: Dict[str, Particle] = Field(alias="Particle") class ElectrodeSingleSPM(ContactBase, Particle): @@ -314,7 +316,7 @@ class ElectrodeBlendedSPM(ContactBase): Particle type models. """ - particle: dict[str, Particle] = Field(alias="Particle") + particle: Dict[str, Particle] = Field(alias="Particle") class UserDefined(BaseModel): @@ -347,22 +349,22 @@ class Experiment(ExtraBaseModel): A class to store experimental data (time, current, voltage, temperature). """ - time: list[float] = Field( + time: List[float] = Field( alias="Time [s]", examples=[[0, 0.1, 0.2, 0.3, 0.4]], description="Time in seconds (list of floats)", ) - current: list[float] = Field( + current: List[float] = Field( alias="Current [A]", examples=[[-5, -5, -5, -5, -5]], description="Current vs time", ) - voltage: list[float] = Field( + voltage: List[float] = Field( alias="Voltage [V]", examples=[[4.2, 4.1, 4.0, 3.9, 3.8]], description="Voltage vs time", ) - temperature: list[float] = Field( + temperature: List[float] = Field( None, alias="Temperature [K]", examples=[[298, 298, 298, 298, 298]], @@ -430,10 +432,10 @@ class Parameterisation(ExtraBaseModel): electrolyte: Electrolyte = Field( alias="Electrolyte", ) - negative_electrode: ElectrodeSingle | ElectrodeBlended = Field( + negative_electrode: Union[ElectrodeSingle, ElectrodeBlended] = Field( alias="Negative electrode", ) - positive_electrode: ElectrodeSingle | ElectrodeBlended = Field( + positive_electrode: Union[ElectrodeSingle, ElectrodeBlended] = Field( alias="Positive electrode", ) separator: Contact = Field( @@ -456,10 +458,10 @@ class ParameterisationSPM(ExtraBaseModel): cell: Cell = Field( alias="Cell", ) - negative_electrode: ElectrodeSingleSPM | ElectrodeBlendedSPM = Field( + negative_electrode: Union[ElectrodeSingleSPM, ElectrodeBlendedSPM] = Field( alias="Negative electrode", ) - positive_electrode: ElectrodeSingleSPM | ElectrodeBlendedSPM = Field( + positive_electrode: Union[ElectrodeSingleSPM, ElectrodeBlendedSPM] = Field( alias="Positive electrode", ) user_defined: UserDefined = Field( @@ -478,8 +480,8 @@ class BPX(ExtraBaseModel): header: Header = Field( alias="Header", ) - parameterisation: ParameterisationSPM | Parameterisation = Field(alias="Parameterisation") - validation: dict[str, Experiment] = Field(None, alias="Validation") + parameterisation: Union[ParameterisationSPM, Parameterisation] = Field(alias="Parameterisation") + validation: Dict[str, Experiment] = Field(None, alias="Validation") @root_validator(skip_on_failure=True) @classmethod diff --git a/bpx/utilities.py b/bpx/utilities.py index c609eeb..3463a8a 100644 --- a/bpx/utilities.py +++ b/bpx/utilities.py @@ -1,9 +1,10 @@ +from typing import Tuple from warnings import warn from bpx import BPX -def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> tuple[float, float]: +def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> Tuple[float, float]: """ Calculate individual electrode stoichiometries at a particular target state of charge, given stoichiometric limits defined by bpx @@ -37,7 +38,7 @@ def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> tuple[float, f return sto_n, sto_p -def get_electrode_concentrations(target_soc: float, bpx: BPX) -> tuple[float, float]: +def get_electrode_concentrations(target_soc: float, bpx: BPX) -> Tuple[float, float]: """ Calculate individual electrode concentrations at a particular target state of charge, given stoichiometric limits and maximum concentrations diff --git a/pyproject.toml b/pyproject.toml index 8626687..0285e38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,11 @@ classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", ] dependencies = [ @@ -144,6 +148,8 @@ ignore = [ "ANN101", # missing type self "ANN102", # missing type cls "COM812", # trailing comma missing + "UP006", # non pep585 annotation + "UP007", # non pep604 annotation ] [tool.ruff.lint.per-file-ignores] From 4f1a1c3c4266f5ba12cf0da98b977c307991920f Mon Sep 17 00:00:00 2001 From: Chuck Liu Date: Sun, 7 Apr 2024 13:26:20 +0800 Subject: [PATCH 04/12] fix: clean function tempfile --- bpx/function.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bpx/function.py b/bpx/function.py index 914749d..bb0b7ba 100644 --- a/bpx/function.py +++ b/bpx/function.py @@ -3,6 +3,7 @@ import copy import tempfile from importlib import util +from pathlib import Path from typing import TYPE_CHECKING, Any from pydantic_core import CoreSchema, core_schema @@ -97,5 +98,9 @@ def to_python_function(self, preamble: str | None = None) -> Callable: module = util.module_from_spec(spec) spec.loader.exec_module(module) + # Delete + tmp.close() + Path(tmp.name).unlink(missing_ok=True) + # return the new function object return getattr(module, function_name) From 6d747c8922b1c120495581f19d8eac7816542260 Mon Sep 17 00:00:00 2001 From: Chuck Liu Date: Thu, 11 Apr 2024 09:56:45 +0800 Subject: [PATCH 05/12] fix: clean function cache --- bpx/function.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bpx/function.py b/bpx/function.py index bb0b7ba..51e2d71 100644 --- a/bpx/function.py +++ b/bpx/function.py @@ -101,6 +101,12 @@ def to_python_function(self, preamble: str | None = None) -> Callable: # Delete tmp.close() Path(tmp.name).unlink(missing_ok=True) + if module.__cached__: + cached_file = Path(module.__cached__) + cached_path = cached_file.parent + cached_file.unlink(missing_ok=True) + if not any(cached_path.iterdir()): + cached_path.rmdir() # return the new function object return getattr(module, function_name) From 0a7b2557a21a481483dc44c76328eba3daf49590 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 13:14:00 -0500 Subject: [PATCH 06/12] Fix pyproject --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 07714f7..a94df53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", ] -requires-python = ">=3.9" -dynamic = ["version", "description"] dependencies = [ "pydantic >= 2.6", "pyparsing", From a42c221d864fb51e31f5c73f6681aa08507510c0 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 14:05:15 -0500 Subject: [PATCH 07/12] Fix type errors --- bpx/interpolated_table.py | 6 ++---- bpx/schema.py | 16 ++++++++-------- bpx/utilities.py | 5 ++--- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/bpx/interpolated_table.py b/bpx/interpolated_table.py index 33625dd..3ada2c2 100644 --- a/bpx/interpolated_table.py +++ b/bpx/interpolated_table.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import List - from pydantic import BaseModel, ValidationInfo, field_validator @@ -11,8 +9,8 @@ class InterpolatedTable(BaseModel): by two lists of floats, x and y. The function is defined by interpolation. """ - x: List[float] - y: List[float] + x: list[float] + y: list[float] @field_validator("y") @classmethod diff --git a/bpx/schema.py b/bpx/schema.py index 213f73e..f7b3bf7 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import ClassVar, Dict, List, Literal, Union, get_args +from typing import ClassVar, Literal, Union, get_args from warnings import warn from pydantic import BaseModel, ConfigDict, Field, model_validator, root_validator @@ -300,7 +300,7 @@ class ElectrodeBlended(Electrode): Class for electrode composed of a blend of active materials. """ - particle: Dict[str, Particle] = Field(alias="Particle") + particle: dict[str, Particle] = Field(alias="Particle") class ElectrodeSingleSPM(ContactBase, Particle): @@ -316,7 +316,7 @@ class ElectrodeBlendedSPM(ContactBase): Particle type models. """ - particle: Dict[str, Particle] = Field(alias="Particle") + particle: dict[str, Particle] = Field(alias="Particle") class UserDefined(BaseModel): @@ -349,22 +349,22 @@ class Experiment(ExtraBaseModel): A class to store experimental data (time, current, voltage, temperature). """ - time: List[float] = Field( + time: list[float] = Field( alias="Time [s]", examples=[[0, 0.1, 0.2, 0.3, 0.4]], description="Time in seconds (list of floats)", ) - current: List[float] = Field( + current: list[float] = Field( alias="Current [A]", examples=[[-5, -5, -5, -5, -5]], description="Current vs time", ) - voltage: List[float] = Field( + voltage: list[float] = Field( alias="Voltage [V]", examples=[[4.2, 4.1, 4.0, 3.9, 3.8]], description="Voltage vs time", ) - temperature: List[float] = Field( + temperature: list[float] = Field( None, alias="Temperature [K]", examples=[[298, 298, 298, 298, 298]], @@ -481,7 +481,7 @@ class BPX(ExtraBaseModel): alias="Header", ) parameterisation: Union[ParameterisationSPM, Parameterisation] = Field(alias="Parameterisation") - validation: Dict[str, Experiment] = Field(None, alias="Validation") + validation: dict[str, Experiment] = Field(None, alias="Validation") @root_validator(skip_on_failure=True) @classmethod diff --git a/bpx/utilities.py b/bpx/utilities.py index 3463a8a..c609eeb 100644 --- a/bpx/utilities.py +++ b/bpx/utilities.py @@ -1,10 +1,9 @@ -from typing import Tuple from warnings import warn from bpx import BPX -def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> Tuple[float, float]: +def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> tuple[float, float]: """ Calculate individual electrode stoichiometries at a particular target state of charge, given stoichiometric limits defined by bpx @@ -38,7 +37,7 @@ def get_electrode_stoichiometries(target_soc: float, bpx: BPX) -> Tuple[float, f return sto_n, sto_p -def get_electrode_concentrations(target_soc: float, bpx: BPX) -> Tuple[float, float]: +def get_electrode_concentrations(target_soc: float, bpx: BPX) -> tuple[float, float]: """ Calculate individual electrode concentrations at a particular target state of charge, given stoichiometric limits and maximum concentrations From 92afe8d50bc5f505673a3079e629a4bc3485daf7 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 14:25:58 -0500 Subject: [PATCH 08/12] Revert some changes --- .gitignore | 3 +- bpx/__init__.py | 83 +------------------------------------------------ bpx/parsers.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 83 deletions(-) create mode 100644 bpx/parsers.py diff --git a/.gitignore b/.gitignore index ba0e9a3..cc78da8 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ dmypy.json # IDEs .vscode +.idea # MacOS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/bpx/__init__.py b/bpx/__init__.py index 2c95c3d..d8af5a5 100644 --- a/bpx/__init__.py +++ b/bpx/__init__.py @@ -1,6 +1,7 @@ from .expression_parser import ExpressionParser from .function import Function from .interpolated_table import InterpolatedTable +from .parsers import parse_bpx_file, parse_bpx_obj, parse_bpx_str from .schema import BPX, check_sto_limits from .utilities import get_electrode_concentrations, get_electrode_stoichiometries @@ -18,85 +19,3 @@ "parse_bpx_obj", "parse_bpx_str", ] - - -def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a bpx dict into a BPX model. - - Parameters - ---------- - bpx: dict - a dict object in bpx format - v_tol: float - absolute tolerance in [V] to validate the voltage limits, 1 mV by default - - Returns - ------- - BPX: :class:`bpx.BPX` - a parsed BPX model - """ - if v_tol < 0: - error_msg = "v_tol should not be negative" - raise ValueError(error_msg) - - BPX.Settings.tolerances["Voltage [V]"] = v_tol - - return BPX.model_validate(bpx) - - -def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a bpx file into a BPX model. - - 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: :class:`bpx.BPX` - a parsed BPX model - """ - - from pathlib import Path - - bpx = "" - if filename.endswith((".yml", ".yaml")): - import yaml - - with Path(filename).open(encoding="utf-8") as f: - bpx = yaml.safe_load(f) - else: - import orjson as json - - with Path(filename).open(encoding="utf-8") as f: - bpx = json.loads(f.read()) - - return parse_bpx_obj(bpx, v_tol) - - -def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> BPX: - """ - A convenience function to parse a json formatted string in bpx format into a BPX - model. - - Parameters - ---------- - bpx: str - a json formatted string in bpx format - v_tol: float - absolute tolerance in [V] to validate the voltage limits, 1 mV by default - - Returns - ------- - BPX: - a parsed BPX model - """ - import orjson as json - - bpx = json.loads(bpx) - return parse_bpx_obj(bpx, v_tol) diff --git a/bpx/parsers.py b/bpx/parsers.py new file mode 100644 index 0000000..e6405b4 --- /dev/null +++ b/bpx/parsers.py @@ -0,0 +1,83 @@ +from .schema import BPX + + +def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a bpx dict into a BPX model. + + Parameters + ---------- + bpx: dict + a dict object in bpx format + v_tol: float + absolute tolerance in [V] to validate the voltage limits, 1 mV by default + + Returns + ------- + BPX: :class:`bpx.BPX` + a parsed BPX model + """ + if v_tol < 0: + error_msg = "v_tol should not be negative" + raise ValueError(error_msg) + + BPX.Settings.tolerances["Voltage [V]"] = v_tol + + return BPX.model_validate(bpx) + + +def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a bpx file into a BPX model. + + 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: :class:`bpx.BPX` + a parsed BPX model + """ + + from pathlib import Path + + bpx = "" + if filename.endswith((".yml", ".yaml")): + import yaml + + with Path(filename).open(encoding="utf-8") as f: + bpx = yaml.safe_load(f) + else: + import orjson as json + + with Path(filename).open(encoding="utf-8") as f: + bpx = json.loads(f.read()) + + return parse_bpx_obj(bpx, v_tol) + + +def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> BPX: + """ + A convenience function to parse a json formatted string in bpx format into a BPX + model. + + Parameters + ---------- + bpx: str + a json formatted string in bpx format + v_tol: float + absolute tolerance in [V] to validate the voltage limits, 1 mV by default + + Returns + ------- + BPX: + a parsed BPX model + """ + import orjson as json + + bpx = json.loads(bpx) + return parse_bpx_obj(bpx, v_tol) From 78bb12e2b61d633b9ac3d78a4f0e358ea7ec2c24 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 14:54:46 -0500 Subject: [PATCH 09/12] Reorganize code --- bpx/schema.py | 71 +++------------------------------------------------ 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/bpx/schema.py b/bpx/schema.py index f7b3bf7..909929a 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -1,31 +1,16 @@ from __future__ import annotations -from typing import ClassVar, Literal, Union, get_args +from typing import Literal, Union, get_args from warnings import warn from pydantic import BaseModel, ConfigDict, Field, model_validator, root_validator from bpx import Function, InterpolatedTable -FloatFunctionTable = Union[float, Function, InterpolatedTable] - - -class ExtraBaseModel(BaseModel): - """ - A base model that forbids extra fields - """ - - model_config = ConfigDict(extra="forbid") - - class Settings: - """ - Class with BPX-related settings. - It might be worth moving it to a separate file if it grows bigger. - """ +from .base_extra_model import ExtraBaseModel +from .validators import check_sto_limits - tolerances: ClassVar[dict] = { - "Voltage [V]": 1e-3, # Absolute tolerance in [V] to validate the voltage limits - } +FloatFunctionTable = Union[float, Function, InterpolatedTable] class Header(ExtraBaseModel): @@ -372,54 +357,6 @@ class Experiment(ExtraBaseModel): ) -def check_sto_limits(cls: ExtraBaseModel, values: dict) -> dict: - """ - 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.tolerances["Voltage [V]"] - - # 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", - stacklevel=2, - ) - - # 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", - stacklevel=2, - ) - - return values - - class Parameterisation(ExtraBaseModel): """ A class to store parameterisation data for a cell. Consists of parameters for the From c987e89702fcedcdd19fd744c30302ca091dbf5e Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 14:55:15 -0500 Subject: [PATCH 10/12] Put some files back --- bpx/base_extra_model.py | 23 ++++++++++++++++++ bpx/validators.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 bpx/base_extra_model.py create mode 100644 bpx/validators.py diff --git a/bpx/base_extra_model.py b/bpx/base_extra_model.py new file mode 100644 index 0000000..cd4e708 --- /dev/null +++ b/bpx/base_extra_model.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + + +class ExtraBaseModel(BaseModel): + """ + A base model that forbids extra fields + """ + + model_config = ConfigDict(extra="forbid") + + class Settings: + """ + Class with BPX-related settings. + It might be worth moving it to a separate file if it grows bigger. + """ + + tolerances: ClassVar[dict] = { + "Voltage [V]": 1e-3, # Absolute tolerance in [V] to validate the voltage limits + } diff --git a/bpx/validators.py b/bpx/validators.py new file mode 100644 index 0000000..03cb8a4 --- /dev/null +++ b/bpx/validators.py @@ -0,0 +1,52 @@ +from warnings import warn + +from .base_extra_model import ExtraBaseModel + + +def check_sto_limits(cls: ExtraBaseModel, values: dict) -> dict: + """ + 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.tolerances["Voltage [V]"] + + # 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", + stacklevel=2, + ) + + # 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", + stacklevel=2, + ) + + return values + From 5ae991e6651c5c7c2165d932df51077842ee1342 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 15:24:25 -0500 Subject: [PATCH 11/12] Minor cleanup --- bpx/expression_parser.py | 1 - docs/conf.py | 2 +- pyproject.toml | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bpx/expression_parser.py b/bpx/expression_parser.py index 2bc1e01..ceb7ff3 100644 --- a/bpx/expression_parser.py +++ b/bpx/expression_parser.py @@ -18,7 +18,6 @@ class ExpressionParser: def __init__(self) -> None: fnumber = ppc.number() ident = pp.Literal("x") - fn_ident = pp.Literal("x") fn_ident = pp.Word(pp.alphas, pp.alphanums) plus, minus, mult, div = map(pp.Literal, "+-*/") diff --git a/docs/conf.py b/docs/conf.py index cb21f81..17cdd5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -218,7 +218,7 @@ "BPX", "One line description of project.", "Miscellaneous", - ) + ), ] diff --git a/pyproject.toml b/pyproject.toml index a94df53..16e300a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,7 +146,6 @@ select = [ ignore = [ "ANN101", # missing type self "ANN102", # missing type cls - "COM812", # trailing comma missing "UP006", # non pep585 annotation "UP007", # non pep604 annotation ] From 3a38a89065d9d81eddcbaf8346d8e0c73f977036 Mon Sep 17 00:00:00 2001 From: kratman Date: Mon, 16 Dec 2024 16:04:34 -0500 Subject: [PATCH 12/12] Fix type annotation --- bpx/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bpx/schema.py b/bpx/schema.py index 909929a..2681ca2 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -307,7 +307,7 @@ class ElectrodeBlendedSPM(ContactBase): class UserDefined(BaseModel): model_config = ConfigDict(extra="allow") - def __init__(self, **data: int) -> None: + def __init__(self, **data: dict) -> None: """ Overwrite the default __init__ to convert strings to Function objects and dicts to InterpolatedTable objects