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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d15c975 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 9538a34..72b6b94 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -1,38 +1,83 @@ name: Build and publish package to PyPI on: - push: - tags: - - '*' + release: + types: [published] workflow_dispatch: inputs: target: - description: 'Deployment target. Can be "pypi" or "testpypi"' - default: "pypi" + description: 'Deployment target. Can be "pypi" or "testpypi", or left as blank to skip publishing. Default is blank.' + default: "" jobs: - publish_pypi: - name: Build wheels on ubuntu-latest + build: + name: Build sdist and wheel runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: 3.8 - - name: Install dependencies + python-version: "3.x" + + - name: Build distributions + run: pipx run build + + - name: Build, inspect, and display contents of distributions + shell: bash run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish on PyPI - if: github.event.inputs.target == 'pypi' - uses: pypa/gh-action-pypi-publish@release/v1 + mkdir -p output/sdist + tar -xf dist/*.tar.gz -C output/sdist + + echo -e '## View source distribution (SDist) contents\n' >> $GITHUB_STEP_SUMMARY + echo -e '```\n' >> $GITHUB_STEP_SUMMARY + (cd output/sdist && tree -a * | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY) + echo -e '\n```\n' >> $GITHUB_STEP_SUMMARY + + mkdir -p output/wheel + pipx run wheel unpack dist/*.whl -d output/wheel + + echo -e '## View binary distribution (wheel) contents\n' >> $GITHUB_STEP_SUMMARY + echo -e '```\n' >> $GITHUB_STEP_SUMMARY + (cd output/wheel && tree -a * | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY) + echo -e '\n```\n' >> $GITHUB_STEP_SUMMARY + + - name: Upload sdist and wheel artifacts + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: - password: ${{ secrets.PYPI_API_TOKEN }} - - name: Publish on TestPyPI + name: distributions + path: dist/* + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/bpx + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + needs: [build] + if: >- + github.event_name == 'release' && + github.event.action == 'published' || + github.event_name == 'workflow_dispatch' && + github.event.inputs.target == 'pypi' || + github.event.inputs.target == 'testpypi' + steps: + - name: Download artifacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + path: dist + merge-multiple: true + + - name: Upload to PyPI + if: github.event.inputs.target == 'pypi' || github.event_name == 'release' && github.event.action == 'published' + uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + with: + packages-dir: dist + + - name: Upload to TestPyPI if: github.event.inputs.target == 'testpypi' - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ \ No newline at end of file + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b331a94..d06f71e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,31 +3,32 @@ name: BPX-tests on: workflow_dispatch: pull_request: + push: + branches: + - main jobs: build: strategy: + fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install coverage flake8 - pip install . - - name: Lint with flake8 - run: | - flake8 . --count --exit-zero --show-source --statistics - - name: Test with unittest - run: | - coverage run -m unittest + pip install hatch "coverage[toml]" + - name: Lint with ruff + run: hatch run dev:check + - name: Run unit tests + run: hatch run dev:cov - name: Upload Coverage to Codecov if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.9 - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v5 diff --git a/.gitignore b/.gitignore index 3144cf5..cc78da8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ # Translations *.mo @@ -129,4 +130,8 @@ dmypy.json .pyre/ # IDEs -.vscode \ No newline at end of file +.vscode +.idea + +# MacOS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 676b29e..36fc511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# [v0.5.0](https://github.com/FaradayInstitution/BPX/releases/tag/v0.5.0) + +- Bug fixes for Pydantic ([#81](https://github.com/FaradayInstitution/BPX/pull/81)) +- Updated to Pydantic V2 and the hatch build system ([#79](https://github.com/FaradayInstitution/BPX/pull/79)) + # [v0.4.0](https://github.com/FaradayInstitution/BPX/releases/tag/v0.4.0) - Added five parametrisation examples (two DFN parametrisation examples from About:Energy open-source release, blended electrode definition, user-defined 0th-order hysteresis, and SPM parametrisation). ([#45](https://github.com/FaradayInstitution/BPX/pull/45)) diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 0000000..9dd134c --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,135 @@ +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html) + + +# BPX Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[bpx@faraday.ac.uk](mailto:bpx@faraday.ac.uk). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE.md b/LICENSE.txt similarity index 100% rename from LICENSE.md rename to LICENSE.txt diff --git a/README.md b/README.md index a50de69..20f79fc 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,23 @@ An implementation of the Battery Parameter eXchange (BPX) format in Pydantic. BP This repository features a Pydantic-based parser for JSON files in the BPX format, which validates your file against the schema. -To support the new open standard, [About:Energy](https://www.aboutenergy.io/) have supplied two parameters sets for an NMC and LFP cell. The BPX files and associated examples and information can be found on the [A:E BPX Parameterisation repository](https://github.com/About-Energy-OpenSource/About-Energy-BPX-Parameterisation/). +To support the new open standard, [About:Energy](https://www.aboutenergy.io/) have supplied two parameter sets for an NMC and LFP cell. The BPX files and associated examples and information can be found on the [A:E BPX Parameterisation repository](https://github.com/About-Energy-OpenSource/About-Energy-BPX-Parameterisation/). -To see how to use BPX with [PyBaMM](https://www.pybamm.org/), check out the [BPX example repository](https://github.com/pybamm-team/bpx-example). +To see how to use BPX with [PyBaMM](https://www.pybamm.org/), check out the [BPX example notebook]([https://github.com/pybamm-team/bpx-example](https://github.com/pybamm-team/PyBaMM/blob/1ead6c512a6cff3effaa35f47efb354ec4a3c8c8/docs/source/examples/notebooks/parameterization/bpx.ipynb)). ## 🚀 Installation The BPX package can be installed using pip + ```bash pip install bpx ``` +or conda + +```bash +conda install -c conda-forge bpx +``` + BPX is available on GNU/Linux, MacOS and Windows. We strongly recommend to install PyBaMM within a python [virtual environment](https://docs.python.org/3/tutorial/venv.html), in order not to alter any distribution python files. ## 💻 Usage @@ -26,7 +33,7 @@ import bpx filename = 'path/to/my/file.json' my_params = bpx.parse_bpx_file(filename) ``` -`my_params` will now be of type `BPX`, which acts like a python dataclass with the same attributes as the BPX format. To obatin example files, see the `examples` folder, the [A:E BPX Parameterisation repository](https://github.com/About-Energy-OpenSource/About-Energy-BPX-Parameterisation/), or the [BPX example repository](https://github.com/pybamm-team/bpx-example). +`my_params` will now be of type `BPX`, which acts like a python dataclass with the same attributes as the BPX format. To obtain example files, see the `examples` folder, the [A:E BPX Parameterisation repository](https://github.com/About-Energy-OpenSource/About-Energy-BPX-Parameterisation/), or the [BPX example repository](https://github.com/pybamm-team/bpx-example). Attributes of the class can be printed out using the standard Python dot notation, for example, you can print out the initial temperature of the cell using ```python @@ -39,7 +46,7 @@ my_params_dict = my_params.dict(by_alias=True) print('Initial temperature of cell:', my_params_dict["Parameterisation"]["Cell"]["Initial temperature [K]"]) ``` -The entire BPX object can be pretty printed using the `devtools` package +The entire BPX object can be pretty-printed using the `devtools` package ```python from devtools import pprint pprint(my_params) diff --git a/bpx/__init__.py b/bpx/__init__.py index fb33772..c7e2ca2 100644 --- a/bpx/__init__.py +++ b/bpx/__init__.py @@ -1,13 +1,21 @@ -"""BPX schema and parsers""" -# flake8: noqa F401 - -__version__ = "0.4.0" - - -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 +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 + +__version__ = "0.5.0" + +__all__ = [ + "BPX", + "ExpressionParser", + "Function", + "InterpolatedTable", + "check_sto_limits", + "get_electrode_concentrations", + "get_electrode_stoichiometries", + "parse_bpx_file", + "parse_bpx_obj", + "parse_bpx_str", +] 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/expression_parser.py b/bpx/expression_parser.py index 58aa87d..ceb7ff3 100644 --- a/bpx/expression_parser.py +++ b/bpx/expression_parser.py @@ -15,10 +15,9 @@ class ExpressionParser: ParseException = pp.ParseException - def __init__(self): + 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, "+-*/") @@ -31,21 +30,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 +52,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..3a350e2 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,48 @@ 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") + if '"' in v: + return cls(v) 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' @@ -56,25 +81,27 @@ def to_python_function(self, preamble: str = None) -> Callable: preamble += "\n\n" arg_names = ["x"] arg_str = ",".join(arg_names) + if '"' in self: + nested = True + else: + nested = False + if nested: + preamble += f"import {self.split('.')[0]}\n\n" function_name = "reconstructed_function" function_def = f"def {function_name}({arg_str}):\n" function_body = f" return {self}" source_code = preamble + function_def + function_body - with tempfile.NamedTemporaryFile( - suffix="{}.py".format(function_name), 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) - tmp.write((source_code).encode()) + with tempfile.NamedTemporaryFile(suffix=f"{function_name}.py", delete=False) as tmp: + tmp.write(source_code.encode()) tmp.flush() - - # Now load that file as a module spec = util.spec_from_file_location("tmp", tmp.name) module = util.module_from_spec(spec) spec.loader.exec_module(module) # return the new function object value = getattr(module, function_name) - return value + if nested: + return value(0) + else: + return value diff --git a/bpx/interpolated_table.py b/bpx/interpolated_table.py index a8c3a7b..3ada2c2 100644 --- a/bpx/interpolated_table.py +++ b/bpx/interpolated_table.py @@ -1,6 +1,6 @@ -from typing import List +from __future__ import annotations -from pydantic import BaseModel, validator +from pydantic import BaseModel, ValidationInfo, field_validator class InterpolatedTable(BaseModel): @@ -9,11 +9,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 index f0824ea..a361646 100644 --- a/bpx/parsers.py +++ b/bpx/parsers.py @@ -1,14 +1,19 @@ -from bpx import BPX +from __future__ import annotations +import json +from pathlib import Path -def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: +from .schema import BPX + + +def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: """ - A convenience function to parse a bpx file into a BPX model. + A convenience function to parse a bpx dict into a BPX model. Parameters ---------- - filename: str - a filepath to a bpx file + bpx: dict + a dict object in bpx format v_tol: float absolute tolerance in [V] to validate the voltage limits, 1 mV by default @@ -18,21 +23,22 @@ def parse_bpx_file(filename: str, v_tol: float = 0.001) -> BPX: a parsed BPX model """ if v_tol < 0: - raise ValueError("v_tol should not be negative") + error_msg = "v_tol should not be negative" + raise ValueError(error_msg) - BPX.settings.tolerances["Voltage [V]"] = v_tol + BPX.Settings.tolerances["Voltage [V]"] = v_tol - return BPX.parse_file(filename) + return BPX.model_validate(bpx) -def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: +def parse_bpx_file(filename: str | Path, v_tol: float = 0.001) -> BPX: """ - A convenience function to parse a bpx dict into a BPX model. + A convenience function to parse a bpx file into a BPX model. Parameters ---------- - bpx: dict - a dict object in bpx format + filename: str or Path + a filepath to a bpx file v_tol: float absolute tolerance in [V] to validate the voltage limits, 1 mV by default @@ -41,12 +47,16 @@ def parse_bpx_obj(bpx: dict, v_tol: float = 0.001) -> BPX: BPX: :class:`bpx.BPX` a parsed BPX model """ - if v_tol < 0: - raise ValueError("v_tol should not be negative") + if str(filename).endswith((".yml", ".yaml")): + import yaml - BPX.settings.tolerances["Voltage [V]"] = v_tol + with Path(filename).open(encoding="utf-8") as f: + bpx = yaml.safe_load(f) + else: + with Path(filename).open(encoding="utf-8") as f: + bpx = json.loads(f.read()) - return BPX.parse_obj(bpx) + return parse_bpx_obj(bpx, v_tol) def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> BPX: @@ -66,9 +76,5 @@ def parse_bpx_str(bpx: str, v_tol: float = 0.001) -> 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_raw(bpx) + bpx = json.loads(bpx) + return parse_bpx_obj(bpx, v_tol) diff --git a/bpx/schema.py b/bpx/schema.py index 612d87b..e6ccb7a 100644 --- a/bpx/schema.py +++ b/bpx/schema.py @@ -1,28 +1,16 @@ -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 warnings import warn - -FloatFunctionTable = Union[float, Function, InterpolatedTable] +from __future__ import annotations +from typing import Literal, Union, get_args +from warnings import warn -class ExtraBaseModel(BaseModel): - """ - A base model that forbids extra fields - """ +from pydantic import BaseModel, ConfigDict, Field, model_validator, root_validator - class Config: - extra = Extra.forbid +from bpx import Function, InterpolatedTable - 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 = { - "Voltage [V]": 1e-3, # Absolute tolerance in [V] to validate the voltage limits - } +FloatFunctionTable = Union[float, Function, InterpolatedTable] class Header(ExtraBaseModel): @@ -33,30 +21,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")'), ) @@ -77,73 +65,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)", ) @@ -155,39 +141,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", ) @@ -199,7 +180,7 @@ class ContactBase(ExtraBaseModel): thickness: float = Field( alias="Thickness [m]", - example=85.2e-6, + examples=[85.2e-6], description="Contact thickness", ) @@ -211,12 +192,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", ) @@ -228,66 +209,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", ) @@ -299,7 +276,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)"), ) @@ -309,15 +286,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): @@ -326,8 +301,6 @@ class ElectrodeSingleSPM(ContactBase, Particle): Particle type models. """ - pass - class ElectrodeBlendedSPM(ContactBase): """ @@ -335,14 +308,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: dict) -> None: """ Overwrite the default __init__ to convert strings to Function objects and dicts to InterpolatedTable objects @@ -354,11 +326,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 @@ -367,25 +341,25 @@ 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", ) @@ -415,11 +389,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): @@ -442,11 +412,7 @@ class ParameterisationSPM(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 BPX(ExtraBaseModel): @@ -458,13 +424,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: Union[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 = [ @@ -473,5 +438,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 index 5825b10..c39b228 100644 --- a/bpx/validators.py +++ b/bpx/validators.py @@ -1,7 +1,10 @@ from warnings import warn +import pybamm +from .base_extra_model import ExtraBaseModel -def check_sto_limits(cls, values): + +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. @@ -20,28 +23,37 @@ def check_sto_limits(cls, values): 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 + 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]"] + 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) + negative_ocp = ocp_n(sto_n_max) + if isinstance(negative_ocp, pybamm.Scalar): + negative_ocp = negative_ocp.evaluate() + positive_ocp = ocp_p(sto_p_min) + if isinstance(positive_ocp, pybamm.Scalar): + positive_ocp = positive_ocp.evaluate() + V_max_sto = positive_ocp - negative_ocp 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" + 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) + V_min_sto = positive_ocp - negative_ocp 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" + 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 + diff --git a/docs/conf.py b/docs/conf.py index d212099..17cdd5c 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 @@ -218,7 +218,7 @@ "BPX", "One line description of project.", "Miscellaneous", - ) + ), ] diff --git a/pyproject.toml b/pyproject.toml index 584f1b2..28bbd49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,52 @@ [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.9" +license = { file = "LICENSE.txt" } +keywords = [ + "bpx", + "battery", +] +authors = [ + { name = "Martin Robinson", email = "martin.robinson@dtc.ox.ac.uk" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "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 = [ - "devtools", - "pydantic<2", + "pydantic >= 2.6", "pyparsing", + "pyyaml", ] +[project.urls] +Homepage = "https://github.com/FaradayInstitution/BPX" +Repository = "https://github.com/FaradayInstitution/BPX" + [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 +55,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" + +[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 + "UP006", # non pep585 annotation + "UP007", # non pep604 annotation +] + +[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 d765f6b..1718053 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,14 +1,19 @@ +import copy import unittest import warnings -import copy -from pydantic import parse_obj_as, ValidationError +from typing import Any + +import pytest +from pydantic import TypeAdapter, ValidationError from bpx import BPX +adapter = TypeAdapter(BPX) + class TestSchema(unittest.TestCase): - def setUp(self): - self.base = { + def setUp(self) -> None: + self.base : dict[str, Any] = { "Header": { "Version": "0.1.1", "BPX": 1.0, @@ -31,9 +36,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, @@ -198,24 +201,24 @@ def setUp(self): }, } - def test_simple(self): - test = copy.copy(self.base) - parse_obj_as(BPX, test) + def test_simple(self) -> None: + test = copy.deepcopy(self.base) + adapter.validate_python(test) - def test_simple_spme(self): - test = copy.copy(self.base) + def test_simple_spme(self) -> None: + test = copy.deepcopy(self.base) test["Header"]["Model"] = "SPMe" - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_simple_spm(self): - test = copy.copy(self.base_spm) - parse_obj_as(BPX, test) + def test_simple_spm(self) -> None: + test = copy.deepcopy(self.base_spm) + adapter.validate_python(test) - def test_bad_model(self): - test = copy.copy(self.base) + def test_bad_model(self) -> None: + test = copy.deepcopy(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_missing_version(self): test = copy.copy(self.base) @@ -226,86 +229,82 @@ def test_missing_version(self): def test_bad_dfn(self): 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): - test = copy.copy(self.base_spm) + def test_bad_spme(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_bad_spm(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_table(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_bad_table(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_function(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) - test["Parameterisation"]["Electrolyte"][ - "Conductivity [S.m-1]" - ] = "1.0 * exp(x) + 3" - parse_obj_as(BPX, test) + def test_function_with_exp(self) -> None: + test = copy.deepcopy(self.base) + test["Parameterisation"]["Electrolyte"]["Conductivity [S.m-1]"] = "1.0 * exp(x) + 3" + adapter.validate_python(test) - def test_bad_function(self): - 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) + def test_bad_function(self) -> None: + test = copy.deepcopy(self.base) + 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): - test = copy.copy(self.base) + def test_to_python_function(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_bad_input(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_validation_data(self) -> None: + test = copy.deepcopy(self.base) test["Validation"] = { "Experiment 1": { "Time [s]": [0, 1000, 2000], @@ -321,74 +320,74 @@ 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 = copy.deepcopy(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): - test = copy.copy(self.base_non_blended) + def test_check_sto_limits_validator_high_voltage(self) -> None: + test = copy.deepcopy(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 = copy.deepcopy(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): - test = copy.copy(self.base_non_blended) + def test_check_sto_limits_validator_low_voltage(self) -> None: + test = copy.deepcopy(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 = copy.deepcopy(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): - test = copy.copy(self.base) + def test_user_defined(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_user_defined_table(self) -> None: + test = copy.deepcopy(self.base) test["Parameterisation"]["User-defined"] = { "a": { "x": [1.0, 2.0], "y": [2.3, 4.5], }, } - parse_obj_as(BPX, test) + adapter.validate_python(test) - def test_user_defined_function(self): - test = copy.copy(self.base) + def test_user_defined_function(self) -> None: + test = copy.deepcopy(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): - test = copy.copy(self.base) + def test_bad_user_defined(self) -> None: + test = copy.deepcopy(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",