From e17f9f36abd862c1fd4b5327b6141c9e04568cb2 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:08:49 +0000 Subject: [PATCH 01/40] chore: add pint dependency --- pyproject.toml | 1 + uv.lock | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c9096c9f..1a020d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "streamlit>=1.41.1", "sympy>=1.13.3", "tzlocal>=5.2", + "pint>=0.25.2", ] classifiers = [ diff --git a/uv.lock b/uv.lock index 0f339bca..08a2026e 100644 --- a/uv.lock +++ b/uv.lock @@ -595,6 +595,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -1471,6 +1495,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] +[[package]] +name = "pint" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467, upload-time = "2025-11-06T22:08:09.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/88/550d41e81e6d43335603a960cd9c75c1d88f9cf01bc9d4ee8e86290aba7d/pint-0.25.2-py3-none-any.whl", hash = "sha256:ca35ab1d8eeeb6f7d9942b3cb5f34ca42b61cdd5fb3eae79531553dcca04dda7", size = 306762, upload-time = "2025-11-06T22:08:07.745Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -1887,6 +1926,7 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, { name = "pandas" }, + { name = "pint" }, { name = "plotly" }, { name = "polars" }, { name = "pydantic" }, @@ -1941,6 +1981,7 @@ requires-dist = [ { name = "nbmake", marker = "extra == 'dev'", specifier = ">=1.5.5" }, { name = "numpy", specifier = ">=1.26.4" }, { name = "pandas", specifier = ">=2.2.3" }, + { name = "pint", specifier = ">=0.25.2" }, { name = "plotly", specifier = ">=5.24.1" }, { name = "polars", specifier = ">=1.18.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" }, From 475db0d94b66b6085bb01a922ab5da4f14ea04ef Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:09:45 +0000 Subject: [PATCH 02/40] refactor: create new ColumnName class for handling column names and conversions --- pyprobe/column_name.py | 250 ++++++++++++++++++++++++++++++++++++++ tests/test_column_name.py | 192 +++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 pyprobe/column_name.py create mode 100644 tests/test_column_name.py diff --git a/pyprobe/column_name.py b/pyprobe/column_name.py new file mode 100644 index 00000000..0690c476 --- /dev/null +++ b/pyprobe/column_name.py @@ -0,0 +1,250 @@ +"""A module for parsing and converting column names with physical units.""" + +import re + +import pint + +_ureg = pint.UnitRegistry() +"""Module-level shared pint unit registry.""" + +_UNIT_ALIASES: dict[str, str] = { + "Ohms": "ohm", + "Seconds": "s", +} +"""Alias map for non-standard unit strings used in the codebase.""" + + +class ColumnName: + """Parse a column name into a quantity and unit, and perform unit conversions. + + Supports two column name formats: + + - Bracket format: ``"Quantity [unit]"`` + - Slash format: ``"Quantity / unit"`` + + Examples: + >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + >>> cn.quantity + 'Current' + >>> cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + >>> cn.quantity + 'Current' + """ + + BRACKET_FORMAT: str = r"^([\w\s]*?)(?:\s*\[([^\]]+)\])?\s*$" + """Regex pattern for bracket-style column names. + + Matches ``"Quantity [unit]"`` or a bare ``"Quantity"`` (no brackets). + Group 1 is restricted to word characters and whitespace, so separator + characters cause the match to fail for partial forms like ``"Quantity ["``. + """ + + SLASH_FORMAT: str = r"^([\w\s]*?)(?:\s*/\s*(.+?))?\s*$" + """Regex pattern for slash-style column names. + + Matches ``"Quantity / unit"`` or a bare ``"Quantity"`` (no slash). + Group 1 is restricted to word characters and whitespace, so + ``"Quantity /"`` and bracket-format names like ``"Quantity [unit]"`` + fail to match, allowing :func:`_parse_column` to fall back to + :attr:`BRACKET_FORMAT`. + """ + + @staticmethod + def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None]: + """Extract the quantity name and raw unit string from a column name. + + Bare names (no unit separator) return ``None`` as the unit string. + Names with a partial separator (e.g. ``"Step /"``) fail to match and + raise ``ValueError``. + + Args: + name: The column name string to parse. + pattern: The regex pattern to apply. Use :attr:`BRACKET_FORMAT` or + :attr:`SLASH_FORMAT`. + + Returns: + A ``(quantity, raw_unit)`` tuple where ``raw_unit`` is ``None`` for + bare names. + + Raises: + ValueError: If ``name`` does not match ``pattern``. + + Examples: + >>> ColumnName._extract_quantity_and_unit( + ... "Current [A]", ColumnName.BRACKET_FORMAT + ... ) + ('Current', 'A') + >>> ColumnName._extract_quantity_and_unit( + ... "Step", ColumnName.BRACKET_FORMAT + ... ) + ('Step', None) + """ + match = re.compile(pattern).match(name) + if match is None: + raise ValueError( + f"Column name '{name}' does not match pattern '{pattern}'." + ) + quantity = match.group(1).strip() + raw_unit: str | None = (match.group(2) or "").strip() or None + return quantity, raw_unit + + def __init__(self, name: str, pattern: str = SLASH_FORMAT) -> None: + """Parse a column name string into quantity and unit components. + + Bare names (no unit separator) are accepted and yield ``unit=None``. + A name that contains a separator but no unit (e.g. ``"Step /"``) raises + ``ValueError`` because the regex cannot match it. + + Args: + name: The column name string to parse (e.g. ``"Current [A]"`` or + ``"Step"``). + pattern: The regex pattern to apply. Use :attr:`BRACKET_FORMAT` or + :attr:`SLASH_FORMAT`. Defaults to :attr:`SLASH_FORMAT`. + + Raises: + ValueError: If the name contains a unit separator but no valid unit. + ValueError: If the unit string cannot be parsed by pint. + """ + self._name = name + self._pattern = pattern + + self._quantity, raw_unit = ColumnName._extract_quantity_and_unit(name, pattern) + + if raw_unit is None: + self._unit: pint.Unit | None = None + else: + resolved = _UNIT_ALIASES.get(raw_unit, raw_unit) + try: + self._unit = _ureg.parse_units(resolved) + except pint.errors.UndefinedUnitError as exc: + raise ValueError( + f"Unit '{raw_unit}' in column '{name}' could not be parsed: {exc}" + ) from exc + + @property + def quantity(self) -> str: + """The physical quantity name, with unit information removed. + + Returns: + The quantity string (e.g. ``"Current"``). + """ + return self._quantity + + @property + def unit(self) -> pint.Unit | None: + """The parsed pint unit, or ``None`` if the column has no unit. + + Returns: + A :class:`pint.Unit` instance, or ``None``. + """ + return self._unit + + def __str__(self) -> str: + """Return the original column name string. + + Returns: + The original name passed to the constructor. + """ + return self._name + + def conversion_factor(self, target_unit: str) -> float: + """Compute the multiplicative factor to convert this column's unit to another. + + Args: + target_unit: The target unit string (e.g. ``"mA"``). + + Returns: + The conversion factor as a float. Multiply a value in the current unit by + this factor to obtain the equivalent value in ``target_unit``. + + Raises: + ValueError: If this column has no unit (i.e. :attr:`unit` is ``None``). + ValueError: If the units are dimensionally incompatible. + + Examples: + >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + >>> cn.conversion_factor("mA") + 1000.0 + """ + if self._unit is None: + raise ValueError( + f"Column '{self._name}' has no unit; cannot compute a conversion " + "factor." + ) + resolved_target = _UNIT_ALIASES.get(target_unit, target_unit) + try: + target_pint = _ureg.parse_units(resolved_target) + magnitude: float = float((1.0 * self._unit).to(target_pint).magnitude) + except pint.errors.DimensionalityError as exc: + raise ValueError( + f"Cannot convert '{self._unit}' to '{target_unit}': {exc}" + ) from exc + return magnitude + + def with_unit(self, target_unit: str) -> "ColumnName": + """Return a new :class:`ColumnName` with a different unit. + + The quantity name is preserved; only the unit portion of the string changes. + The output format mirrors the original (bracket or slash). + + Args: + target_unit: The replacement unit string (e.g. ``"mA"``). + + Returns: + A new :class:`ColumnName` instance using ``target_unit``. + + Raises: + ValueError: If this column has no unit. + + Examples: + >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + >>> str(cn.with_unit("mA")) + 'Current [mA]' + >>> cn2 = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + >>> str(cn2.with_unit("mA")) + 'Current / mA' + """ + if self._unit is None: + raise ValueError( + f"Column '{self._name}' has no unit; cannot substitute a new unit." + ) + if self._pattern == ColumnName.BRACKET_FORMAT: + new_name = f"{self._quantity} [{target_unit}]" + else: + new_name = f"{self._quantity} / {target_unit}" + return ColumnName(new_name, self._pattern) + + @classmethod + def find_in_columns( + cls, + quantity: str, + columns: list[str], + pattern: str, + ) -> "ColumnName | None": + """Search a list of column names for one matching a given quantity. + + Args: + quantity: The quantity name to search for (e.g. ``"Current"``). + columns: The list of column name strings to search. + pattern: The regex pattern to use when parsing each column. + + Returns: + The first :class:`ColumnName` whose :attr:`quantity` equals ``quantity``, + or ``None`` if no match is found. + + Examples: + >>> cols = ["Time [s]", "Current [A]", "Voltage [V]"] + >>> result = ColumnName.find_in_columns( + ... "Current", cols, ColumnName.BRACKET_FORMAT + ... ) + >>> str(result) + 'Current [A]' + """ + for col in columns: + try: + parsed = cls(col, pattern) + except ValueError: + continue + if parsed.quantity == quantity: + return parsed + return None diff --git a/tests/test_column_name.py b/tests/test_column_name.py new file mode 100644 index 00000000..5f4f50e7 --- /dev/null +++ b/tests/test_column_name.py @@ -0,0 +1,192 @@ +"""Tests for the ColumnName class and _parse_column helper.""" + +import pytest + +from pyprobe.column_name import ColumnName, _ureg + + +class TestExtractQuantityAndUnit: + """Tests for ColumnName._extract_quantity_and_unit static method.""" + + def test_bracket_format_with_unit(self) -> None: + """'Current [A]' returns ('Current', 'A').""" + assert ColumnName._extract_quantity_and_unit( + "Current [A]", ColumnName.BRACKET_FORMAT + ) == ("Current", "A") + + def test_slash_format_with_unit(self) -> None: + """'Current / A' returns ('Current', 'A').""" + assert ColumnName._extract_quantity_and_unit( + "Current / A", ColumnName.SLASH_FORMAT + ) == ("Current", "A") + + def test_bare_name_bracket_format(self) -> None: + """Bare 'Step' with BRACKET_FORMAT returns ('Step', None).""" + assert ColumnName._extract_quantity_and_unit( + "Step", ColumnName.BRACKET_FORMAT + ) == ("Step", None) + + def test_bare_name_slash_format(self) -> None: + """Bare 'Step' with SLASH_FORMAT returns ('Step', None).""" + assert ColumnName._extract_quantity_and_unit( + "Step", ColumnName.SLASH_FORMAT + ) == ("Step", None) + + def test_partial_slash_raises(self) -> None: + """'Step /' with SLASH_FORMAT raises ValueError.""" + with pytest.raises(ValueError): + ColumnName._extract_quantity_and_unit("Step /", ColumnName.SLASH_FORMAT) + + def test_partial_bracket_raises(self) -> None: + """'Step [' with BRACKET_FORMAT raises ValueError.""" + with pytest.raises(ValueError): + ColumnName._extract_quantity_and_unit("Step [", ColumnName.BRACKET_FORMAT) + + def test_strips_whitespace_from_quantity(self) -> None: + """Surrounding whitespace is stripped from the quantity.""" + quantity, _ = ColumnName._extract_quantity_and_unit( + "Current [A]", ColumnName.BRACKET_FORMAT + ) + assert quantity == "Current" + + +class TestColumnNameParsing: + """Tests for ColumnName construction and properties.""" + + def test_bracket_format_parses_quantity(self) -> None: + """Parsing 'Current [A]' with BRACKET_FORMAT yields quantity 'Current'.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + assert cn.quantity == "Current" + + def test_bracket_format_parses_unit_as_ampere(self) -> None: + """Parsing 'Current [A]' with BRACKET_FORMAT yields ampere unit.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + assert cn.unit is not None + assert cn.unit == _ureg.parse_units("A") + + def test_slash_format_parses_quantity(self) -> None: + """Parsing 'Current / A' with SLASH_FORMAT yields quantity 'Current'.""" + cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + assert cn.quantity == "Current" + + def test_slash_format_parses_unit_as_ampere(self) -> None: + """Parsing 'Current / A' with SLASH_FORMAT yields ampere unit.""" + cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + assert cn.unit is not None + assert cn.unit == _ureg.parse_units("A") + + def test_bare_name_bracket_format_unit_is_none(self) -> None: + """Bare 'Step' with BRACKET_FORMAT yields unit=None.""" + cn = ColumnName("Step", ColumnName.BRACKET_FORMAT) + assert cn.unit is None + assert cn.quantity == "Step" + + def test_bare_name_slash_format_unit_is_none(self) -> None: + """Bare 'Step' with SLASH_FORMAT yields unit=None.""" + cn = ColumnName("Step", ColumnName.SLASH_FORMAT) + assert cn.unit is None + assert cn.quantity == "Step" + + def test_partial_slash_raises(self) -> None: + """'Step /' with SLASH_FORMAT raises ValueError.""" + with pytest.raises(ValueError): + ColumnName("Step /", ColumnName.SLASH_FORMAT) + + def test_partial_bracket_raises(self) -> None: + """'Step [' with BRACKET_FORMAT raises ValueError.""" + with pytest.raises(ValueError): + ColumnName("Step [", ColumnName.BRACKET_FORMAT) + + def test_str_returns_original_name(self) -> None: + """__str__ returns the original column name string.""" + name = "Current [A]" + cn = ColumnName(name, ColumnName.BRACKET_FORMAT) + assert str(cn) == name + + def test_alias_ohms_resolves_to_ohm(self) -> None: + """'Resistance [Ohms]' with BRACKET_FORMAT resolves unit via alias map.""" + cn = ColumnName("Resistance [Ohms]", ColumnName.BRACKET_FORMAT) + assert cn.unit is not None + assert cn.unit == _ureg.parse_units("ohm") + + def test_alias_seconds_resolves(self) -> None: + """'Time [Seconds]' with BRACKET_FORMAT resolves unit via alias map.""" + cn = ColumnName("Time [Seconds]", ColumnName.BRACKET_FORMAT) + assert cn.unit is not None + assert cn.unit == _ureg.parse_units("s") + + def test_invalid_unit_raises_value_error(self) -> None: + """A column with an unparseable unit raises ValueError.""" + with pytest.raises(ValueError, match="could not be parsed"): + ColumnName("Foo [not_a_unit_xyz]", ColumnName.BRACKET_FORMAT) + + +class TestConversionFactor: + """Tests for ColumnName.conversion_factor.""" + + def test_ampere_to_milliampere(self) -> None: + """'Current [A]' → 'mA' conversion factor is 1000.0.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + assert cn.conversion_factor("mA") == pytest.approx(1000.0) + + def test_capacity_ah_to_mah(self) -> None: + """'Capacity [Ah]' → 'mAh' conversion factor is 1000.0.""" + cn = ColumnName("Capacity [Ah]", ColumnName.BRACKET_FORMAT) + assert cn.conversion_factor("mAh") == pytest.approx(1000.0) + + def test_capacity_compound_unit_to_ah(self) -> None: + """'Capacity [A.h]' → 'Ah' conversion factor is 1.0.""" + cn = ColumnName("Capacity [A.h]", ColumnName.BRACKET_FORMAT) + assert cn.conversion_factor("Ah") == pytest.approx(1.0) + + def test_incompatible_units_raises_value_error(self) -> None: + """Converting ampere to volt raises ValueError.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + with pytest.raises(ValueError, match="Cannot convert"): + cn.conversion_factor("V") + + +class TestWithUnit: + """Tests for ColumnName.with_unit.""" + + def test_bracket_format_with_unit(self) -> None: + """with_unit on bracket-format column produces 'Current [mA]'.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + result = cn.with_unit("mA") + assert str(result) == "Current [mA]" + + def test_slash_format_with_unit(self) -> None: + """with_unit on slash-format column produces 'Current / mA'.""" + cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + result = cn.with_unit("mA") + assert str(result) == "Current / mA" + + def test_with_unit_preserves_quantity(self) -> None: + """with_unit preserves the quantity name.""" + cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + result = cn.with_unit("mA") + assert result.quantity == "Current" + + +class TestFindInColumns: + """Tests for ColumnName.find_in_columns.""" + + def test_finds_matching_column_bracket_format(self) -> None: + """find_in_columns returns 'Current [A]' when searching for 'Current'.""" + cols = ["Time [s]", "Current [A]", "Voltage [V]"] + result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) + assert result is not None + assert str(result) == "Current [A]" + + def test_returns_none_when_quantity_absent(self) -> None: + """find_in_columns returns None when quantity is not present.""" + cols = ["Time [s]", "Voltage [V]"] + result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) + assert result is None + + def test_skips_columns_that_do_not_match_pattern(self) -> None: + """find_in_columns skips columns that don't match the pattern gracefully.""" + cols = ["Step", "Current [A]"] + result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) + assert result is not None + assert str(result) == "Current [A]" From 9eaf63388505479b52fa50122127def7ae5513e2 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:54:36 +0000 Subject: [PATCH 03/40] refactor: enhance ColumnName class with improved parsing and conversion capabilities - Updated regex patterns for various column name formats. - Added support for unit aliases and enhanced unit conversion logic. - Introduced new methods for finding and resolving column names. - Expanded test coverage for parsing, conversion, and resolution functionalities. --- pyprobe/column_name.py | 335 ++++++++++++++------- tests/test_column_name.py | 610 ++++++++++++++++++++++++++------------ 2 files changed, 660 insertions(+), 285 deletions(-) diff --git a/pyprobe/column_name.py b/pyprobe/column_name.py index 0690c476..47d9d3ea 100644 --- a/pyprobe/column_name.py +++ b/pyprobe/column_name.py @@ -1,52 +1,91 @@ """A module for parsing and converting column names with physical units.""" +from __future__ import annotations + import re +from typing import TYPE_CHECKING import pint +import polars as pl + +if TYPE_CHECKING: + from pyprobe.bdf import BDFColumn + +FORMAT_REGISTRY: dict[str, str] = { + "bdf": r"^([^/]*?)(?:\s*/\s*(.+?))?\s*$", + "square_bracket": r"^([^[\]]*?)(?:\s*\[([^\]]+)\])?\s*$", + "parentheses": r"^([^()]*?)(?:\s*\(([^)]+)\))?\s*$", + "neware": r"^([^()]*?)(?:\(([^)]+)\))?\s*$", + "basytec": r"^~?([^[\]]*?)(?:\[([^\]]+)\])?\s*$", + "biologic": r"^([^/]+?)(?:/(.+))?\s*$", +} +"""Regex patterns for all known cycler column name formats. + +Each entry maps a human-readable format name to a regex pattern with exactly +two capture groups: ``(1)`` the quantity name and ``(2)`` the unit string +(which may be absent). + +Format descriptions: + +- ``"bdf"``: BDF slash format ``"Quantity / unit"``; quantity must not + contain ``/``. +- ``"square_bracket"``: Bracket format ``"Quantity [unit]"``; quantity must + not contain ``[`` or ``]``. +- ``"parentheses"``: Arbin/Novonix style ``"Quantity (unit)"`` with a + mandatory space before the opening parenthesis. +- ``"neware"``: Neware style ``"Quantity(unit)"`` — no space before the + parenthesis. +- ``"basytec"``: Basytec style ``"~Quantity[unit]"`` — optional leading + tilde. +- ``"biologic"``: Biologic style ``"Quantity/unit"`` — slash with no + surrounding spaces required. +""" _ureg = pint.UnitRegistry() """Module-level shared pint unit registry.""" +# Register non-standard unit spellings that pint does not know natively. +# Note: 'sec' and 'hr' are already recognised by pint and must NOT be +# redefined here — doing so would shadow the built-in and break equality +# checks. Only spellings that are genuinely absent from pint's default +# registry need an explicit define() call. +for _alias, _canonical in [ + ("Ohms", "ohm"), + ("Ohm", "ohm"), + ("Seconds", "s"), +]: + _ureg.define(f"{_alias} = {_canonical}") + _UNIT_ALIASES: dict[str, str] = { - "Ohms": "ohm", - "Seconds": "s", + "°C": "degC", } -"""Alias map for non-standard unit strings used in the codebase.""" +"""Alias map for unit strings that pint cannot handle natively. + +Only entries whose unit symbol contains characters that pint's +``define()`` cannot accept (e.g. the degree symbol ``°``) belong here. +All other non-standard spellings are registered directly on ``_ureg``. +""" class ColumnName: """Parse a column name into a quantity and unit, and perform unit conversions. - Supports two column name formats: + Supports any regex pattern that has exactly two capture groups: the first + for the quantity name and the second for the unit string. Patterns for + all known cycler formats are defined centrally in + :data:`FORMAT_REGISTRY`. - - Bracket format: ``"Quantity [unit]"`` - - Slash format: ``"Quantity / unit"`` + Quantity names may contain any characters except the format's separator + characters, which allows cycler column names that include characters such + as ``~``, ``<>``, and ``.``. Examples: - >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - >>> cn.quantity - 'Current' - >>> cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) + >>> from pyprobe.column_name import FORMAT_REGISTRY + >>> cn = ColumnName("Current / A", FORMAT_REGISTRY["bdf"]) >>> cn.quantity 'Current' - """ - - BRACKET_FORMAT: str = r"^([\w\s]*?)(?:\s*\[([^\]]+)\])?\s*$" - """Regex pattern for bracket-style column names. - - Matches ``"Quantity [unit]"`` or a bare ``"Quantity"`` (no brackets). - Group 1 is restricted to word characters and whitespace, so separator - characters cause the match to fail for partial forms like ``"Quantity ["``. - """ - - SLASH_FORMAT: str = r"^([\w\s]*?)(?:\s*/\s*(.+?))?\s*$" - """Regex pattern for slash-style column names. - - Matches ``"Quantity / unit"`` or a bare ``"Quantity"`` (no slash). - Group 1 is restricted to word characters and whitespace, so - ``"Quantity /"`` and bracket-format names like ``"Quantity [unit]"`` - fail to match, allowing :func:`_parse_column` to fall back to - :attr:`BRACKET_FORMAT`. + >>> cn.unit + """ @staticmethod @@ -59,8 +98,8 @@ def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None Args: name: The column name string to parse. - pattern: The regex pattern to apply. Use :attr:`BRACKET_FORMAT` or - :attr:`SLASH_FORMAT`. + pattern: A regex pattern with two capture groups + (quantity, unit). Returns: A ``(quantity, raw_unit)`` tuple where ``raw_unit`` is ``None`` for @@ -70,12 +109,13 @@ def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None ValueError: If ``name`` does not match ``pattern``. Examples: + >>> from pyprobe.column_name import FORMAT_REGISTRY >>> ColumnName._extract_quantity_and_unit( - ... "Current [A]", ColumnName.BRACKET_FORMAT + ... "Current [A]", FORMAT_REGISTRY["square_bracket"] ... ) ('Current', 'A') >>> ColumnName._extract_quantity_and_unit( - ... "Step", ColumnName.BRACKET_FORMAT + ... "Step", FORMAT_REGISTRY["square_bracket"] ... ) ('Step', None) """ @@ -88,7 +128,7 @@ def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None raw_unit: str | None = (match.group(2) or "").strip() or None return quantity, raw_unit - def __init__(self, name: str, pattern: str = SLASH_FORMAT) -> None: + def __init__(self, name: str, pattern: str) -> None: """Parse a column name string into quantity and unit components. Bare names (no unit separator) are accepted and yield ``unit=None``. @@ -98,15 +138,15 @@ def __init__(self, name: str, pattern: str = SLASH_FORMAT) -> None: Args: name: The column name string to parse (e.g. ``"Current [A]"`` or ``"Step"``). - pattern: The regex pattern to apply. Use :attr:`BRACKET_FORMAT` or - :attr:`SLASH_FORMAT`. Defaults to :attr:`SLASH_FORMAT`. + pattern: A regex pattern with two capture groups + (quantity, unit). Use a pattern from + :data:`FORMAT_REGISTRY`. Raises: ValueError: If the name contains a unit separator but no valid unit. ValueError: If the unit string cannot be parsed by pint. """ self._name = name - self._pattern = pattern self._quantity, raw_unit = ColumnName._extract_quantity_and_unit(name, pattern) @@ -147,104 +187,195 @@ def __str__(self) -> str: """ return self._name - def conversion_factor(self, target_unit: str) -> float: - """Compute the multiplicative factor to convert this column's unit to another. + def conversion_parameters(self, target_unit: str) -> tuple[float, float]: + """Compute the factor and offset to convert this column's unit to another. + + The conversion is: ``target_value = source_value * factor + offset``. + + For purely multiplicative conversions (e.g. mA → A) the offset is + ``0.0``. For affine conversions (e.g. degC → K) the offset is + non-zero. Args: - target_unit: The target unit string (e.g. ``"mA"``). + target_unit: The target unit string (e.g. ``"mA"``, ``"K"``, + ``"degC"``). Returns: - The conversion factor as a float. Multiply a value in the current unit by - this factor to obtain the equivalent value in ``target_unit``. + A ``(factor, offset)`` tuple, both as :class:`float`. For most + unit pairs the offset is ``0.0``. Raises: - ValueError: If this column has no unit (i.e. :attr:`unit` is ``None``). + ValueError: If this column has no unit (i.e. :attr:`unit` is + ``None``). ValueError: If the units are dimensionally incompatible. Examples: - >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - >>> cn.conversion_factor("mA") - 1000.0 + >>> from pyprobe.column_name import FORMAT_REGISTRY + >>> cn = ColumnName("Current [A]", FORMAT_REGISTRY["square_bracket"]) + >>> cn.conversion_parameters("mA") + (1000.0, 0.0) + >>> pat = FORMAT_REGISTRY["square_bracket"] + >>> cn_temp = ColumnName("Temperature [degC]", pat) + >>> cn_temp.conversion_parameters("K") + (1.0, 273.15) """ if self._unit is None: raise ValueError( - f"Column '{self._name}' has no unit; cannot compute a conversion " - "factor." + f"Column '{self._name}' has no unit; cannot compute conversion " + "parameters." ) resolved_target = _UNIT_ALIASES.get(target_unit, target_unit) try: target_pint = _ureg.parse_units(resolved_target) - magnitude: float = float((1.0 * self._unit).to(target_pint).magnitude) + # Convert two reference points to derive factor and offset. + zero = float(_ureg.Quantity(0, self._unit).to(target_pint).magnitude) + one = float(_ureg.Quantity(1, self._unit).to(target_pint).magnitude) except pint.errors.DimensionalityError as exc: raise ValueError( f"Cannot convert '{self._unit}' to '{target_unit}': {exc}" ) from exc - return magnitude + factor = one - zero + offset = zero + return factor, offset - def with_unit(self, target_unit: str) -> "ColumnName": - """Return a new :class:`ColumnName` with a different unit. + @staticmethod + def find( + quantity: str, + available_columns: list[str], + available_format: str, + ) -> tuple[str, ColumnName] | None: + """Find the first column whose parsed quantity matches (case-insensitive). - The quantity name is preserved; only the unit portion of the string changes. - The output format mirrors the original (bracket or slash). + Parses each column in available_columns using the regex pattern from + FORMAT_REGISTRY[available_format]. Returns the first column whose + quantity matches the given quantity string (case-insensitive, stripped). Args: - target_unit: The replacement unit string (e.g. ``"mA"``). + quantity: The quantity name to search for (e.g. ``"Current"``). + available_columns: Column name strings to search through. + available_format: Key into FORMAT_REGISTRY for parsing columns. Returns: - A new :class:`ColumnName` instance using ``target_unit``. + A ``(raw_column_string, parsed_ColumnName)`` tuple, or ``None`` + if no match is found. + """ + pattern = FORMAT_REGISTRY[available_format] + target = quantity.lower().strip() + for col in available_columns: + try: + cn = ColumnName(col, pattern) + except ValueError: + continue + if cn.quantity.lower().strip() == target: + return col, cn + return None - Raises: - ValueError: If this column has no unit. + def _to_expr(self, col_str: str, source_cn: ColumnName) -> pl.Expr: + """Build a Polars expression with unit conversion aliased to str(self). - Examples: - >>> cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - >>> str(cn.with_unit("mA")) - 'Current [mA]' - >>> cn2 = ColumnName("Current / A", ColumnName.SLASH_FORMAT) - >>> str(cn2.with_unit("mA")) - 'Current / mA' - """ - if self._unit is None: - raise ValueError( - f"Column '{self._name}' has no unit; cannot substitute a new unit." - ) - if self._pattern == ColumnName.BRACKET_FORMAT: - new_name = f"{self._quantity} [{target_unit}]" - else: - new_name = f"{self._quantity} / {target_unit}" - return ColumnName(new_name, self._pattern) + Constructs a :class:`polars.Expr` that selects ``col_str``, applies a + multiplicative unit conversion factor when necessary, and aliases the + result to the original column name string (``str(self)``). - @classmethod - def find_in_columns( - cls, - quantity: str, - columns: list[str], - pattern: str, - ) -> "ColumnName | None": - """Search a list of column names for one matching a given quantity. + When either the source or target unit is ``None`` (dimensionless column), + no conversion is applied. A conversion factor of exactly ``1.0`` is + also skipped to avoid an unnecessary cast. Args: - quantity: The quantity name to search for (e.g. ``"Current"``). - columns: The list of column name strings to search. - pattern: The regex pattern to use when parsing each column. + col_str: The raw column name string to select from the DataFrame. + source_cn: The parsed :class:`ColumnName` of the source column, + used to compute the conversion factor. Returns: - The first :class:`ColumnName` whose :attr:`quantity` equals ``quantity``, - or ``None`` if no match is found. + A :class:`polars.Expr` selecting ``col_str``, optionally scaled, + and aliased to ``str(self)``. + """ + if self.unit is None or source_cn.unit is None: + return pl.col(col_str).alias(str(self)) + factor, offset = source_cn.conversion_parameters(str(self.unit)) + if factor == 1.0 and offset == 0.0: + return pl.col(col_str).alias(str(self)) + expr = pl.col(col_str).cast(pl.Float64) + if factor != 1.0: + expr = expr * factor + if offset != 0.0: + expr = expr + offset + return expr.alias(str(self)) + + def resolve( + self, + available_columns: list[str], + available_format: str, + bdf_columns: list[BDFColumn] | None = None, + ) -> pl.Expr: + """Resolve this column name against available columns. + + Full resolution chain (tried in order): + + 1. **Exact match** — ``str(self)`` is already present in + ``available_columns``; returns ``pl.col(str(self))`` with no alias. + 2. **Direct quantity match** — searches ``available_columns`` for a + column whose parsed quantity equals ``self.quantity``; applies unit + conversion if needed. + 3. **BDF alias match** (requires ``bdf_columns``) — finds the + :class:`~pyprobe.bdf.BDFColumn` whose :meth:`matches` returns + ``True`` for ``self.quantity``, then searches all its aliases via + :meth:`find`. + 4. **BDF recipe** (requires ``bdf_columns``) — calls + ``bdf_col.try_recipes()`` to derive the column computationally. + 5. Raises :class:`ValueError` if all steps fail. + + ``column_name.py`` never imports from ``bdf.py``. The objects in + ``bdf_columns`` are accessed only via their public interface + (``.matches()``, ``.aliases``, ``.name``, ``.try_recipes()``). - Examples: - >>> cols = ["Time [s]", "Current [A]", "Voltage [V]"] - >>> result = ColumnName.find_in_columns( - ... "Current", cols, ColumnName.BRACKET_FORMAT - ... ) - >>> str(result) - 'Current [A]' + Args: + available_columns: Column name strings from the source DataFrame. + available_format: Key into FORMAT_REGISTRY for parsing columns. + bdf_columns: Optional list of BDF column objects enabling alias + and recipe resolution (use cases 3-5). When ``None``, only + use cases 1-2 are attempted. + + Returns: + A :class:`polars.Expr` that selects the matching column, applies + unit conversion if needed, and aliases to ``str(self)``. + + Raises: + ValueError: If no matching column is found after all resolution + steps are exhausted. """ - for col in columns: - try: - parsed = cls(col, pattern) - except ValueError: - continue - if parsed.quantity == quantity: - return parsed - return None + # Step 1: exact string match — fastest path, no parsing required. + if str(self) in available_columns: + return pl.col(str(self)) + + # Step 2: direct quantity match (use cases 1-3 in plan). + result = ColumnName.find(self.quantity, available_columns, available_format) + if result is not None: + return self._to_expr(*result) + + # Steps 3-4: BDF alias + recipe (use cases 4-5 in plan). + if bdf_columns is not None: + bdf_col = None + for col in bdf_columns: + if col.matches(self.quantity): + bdf_col = col + break + + if bdf_col is not None: + # Step 3: try canonical name + each alias. + for alias in [bdf_col.name] + bdf_col.aliases: + result = ColumnName.find(alias, available_columns, available_format) + if result is not None: + return self._to_expr(*result) + + # Step 4: try recipes. + expr = bdf_col.try_recipes( + available_columns, available_format, bdf_columns, str(self) + ) + if expr is not None: + return expr + + raise ValueError( + f"No column matching quantity '{self.quantity}' found in" + f" {available_columns}" + ) diff --git a/tests/test_column_name.py b/tests/test_column_name.py index 5f4f50e7..5d3da9e0 100644 --- a/tests/test_column_name.py +++ b/tests/test_column_name.py @@ -1,192 +1,436 @@ -"""Tests for the ColumnName class and _parse_column helper.""" +"""Tests for the ColumnName class.""" +import polars as pl import pytest -from pyprobe.column_name import ColumnName, _ureg - - -class TestExtractQuantityAndUnit: - """Tests for ColumnName._extract_quantity_and_unit static method.""" - - def test_bracket_format_with_unit(self) -> None: - """'Current [A]' returns ('Current', 'A').""" - assert ColumnName._extract_quantity_and_unit( - "Current [A]", ColumnName.BRACKET_FORMAT - ) == ("Current", "A") - - def test_slash_format_with_unit(self) -> None: - """'Current / A' returns ('Current', 'A').""" - assert ColumnName._extract_quantity_and_unit( - "Current / A", ColumnName.SLASH_FORMAT - ) == ("Current", "A") - - def test_bare_name_bracket_format(self) -> None: - """Bare 'Step' with BRACKET_FORMAT returns ('Step', None).""" - assert ColumnName._extract_quantity_and_unit( - "Step", ColumnName.BRACKET_FORMAT - ) == ("Step", None) - - def test_bare_name_slash_format(self) -> None: - """Bare 'Step' with SLASH_FORMAT returns ('Step', None).""" - assert ColumnName._extract_quantity_and_unit( - "Step", ColumnName.SLASH_FORMAT - ) == ("Step", None) - - def test_partial_slash_raises(self) -> None: - """'Step /' with SLASH_FORMAT raises ValueError.""" - with pytest.raises(ValueError): - ColumnName._extract_quantity_and_unit("Step /", ColumnName.SLASH_FORMAT) +from pyprobe.bdf import ALL_COLUMNS +from pyprobe.column_name import FORMAT_REGISTRY, ColumnName, _ureg + +BDF = FORMAT_REGISTRY["bdf"] +BRACKET = FORMAT_REGISTRY["square_bracket"] +PARENTHESES = FORMAT_REGISTRY["parentheses"] +NEWARE = FORMAT_REGISTRY["neware"] +BASYTEC = FORMAT_REGISTRY["basytec"] +BIOLOGIC = FORMAT_REGISTRY["biologic"] + + +def _assert_unit_converts(unit, canonical: str) -> None: + """Assert unit converts 1:1 to canonical.""" + assert _ureg.Quantity(1, unit).to(canonical).magnitude == pytest.approx(1.0) + + +PARSE_CASES = [ + # Standard formats + ("Current [A]", BRACKET, "Current", "A"), + ("Current / A", BDF, "Current", "A"), + ("Test Time (s)", PARENTHESES, "Test Time", "s"), + ("Current(A)", NEWARE, "Current", "A"), + ("~Time[s]", BASYTEC, "Time", "s"), + ("I/mA", BIOLOGIC, "I", "mA"), + ("Step", BRACKET, "Step", None), + ("Step", BDF, "Step", None), + ("Step Index", PARENTHESES, "Step Index", None), + # Unit aliases (via _ureg.define or built-in) + ("Resistance [Ohms]", BRACKET, "Resistance", "ohm"), + ("Resistance [Ohm]", BRACKET, "Resistance", "ohm"), + ("Time [Seconds]", BRACKET, "Time", "s"), + ("Temperature [°C]", BRACKET, "Temperature", "degC"), + ("Time / sec", BDF, "Time", "s"), + ("Time [hr]", BRACKET, "Time", "hour"), + ("Charge [A.h]", BRACKET, "Charge", "Ah"), + # Special characters in quantity + ("~SOC [%]", BRACKET, "~SOC", "percent"), + ("<>Temperature [degC]", BRACKET, "<>Temperature", "degC"), + ("Charge.Rate / C", BDF, "Charge.Rate", "C"), + ("~Event", BRACKET, "~Event", None), + ("Current [A]", BDF, "Current [A]", None), # bracket in slash = bare +] + +PARSE_IDS = [ + "bracket", + "bdf", + "parentheses", + "neware", + "basytec", + "biologic", + "bare_bracket", + "bare_bdf", + "bare_parentheses", + "alias_Ohms", + "alias_Ohm", + "alias_Seconds", + "alias_degC", + "alias_sec", + "alias_hr", + "alias_A.h", + "special_tilde", + "special_angles", + "special_dot", + "bare_tilde", + "bracket_in_bdf", +] + + +class TestParsing: + """ColumnName.__init__, .quantity, .unit, __str__.""" + + @pytest.mark.parametrize( + ("name", "pattern", "expected_quantity", "canonical_unit"), + PARSE_CASES, + ids=PARSE_IDS, + ) + def test_quantity_and_unit(self, name, pattern, expected_quantity, canonical_unit): + """Parse quantity and unit from column names across all formats.""" + cn = ColumnName(name, pattern) + assert cn.quantity == expected_quantity + if canonical_unit is None: + assert cn.unit is None + else: + assert cn.unit is not None + _assert_unit_converts(cn.unit, canonical_unit) + + def test_str_returns_original(self): + """__str__ returns the original column name string.""" + assert str(ColumnName("Current [A]", BRACKET)) == "Current [A]" + assert str(ColumnName("I/mA", BIOLOGIC)) == "I/mA" - def test_partial_bracket_raises(self) -> None: - """'Step [' with BRACKET_FORMAT raises ValueError.""" + def test_invalid_unit_raises(self): + """Column with unparseable unit raises ValueError.""" + with pytest.raises(ValueError, match="could not be parsed"): + ColumnName("Foo [xyz_bad]", BRACKET) + + @pytest.mark.parametrize( + ("name", "pattern"), + [("Step /", BDF), ("Step [", BRACKET)], + ids=["partial_slash", "partial_bracket"], + ) + def test_partial_separator_raises(self, name, pattern): + """Names with partial separators (missing unit) raise ValueError.""" with pytest.raises(ValueError): - ColumnName._extract_quantity_and_unit("Step [", ColumnName.BRACKET_FORMAT) - - def test_strips_whitespace_from_quantity(self) -> None: - """Surrounding whitespace is stripped from the quantity.""" - quantity, _ = ColumnName._extract_quantity_and_unit( - "Current [A]", ColumnName.BRACKET_FORMAT + ColumnName(name, pattern) + + def test_ohm_variants_equivalent(self): + """Different spellings of ohm (Ohm, Ohms) both convert to ohm.""" + cn1 = ColumnName("R [Ohm]", BRACKET) + cn2 = ColumnName("R [Ohms]", BRACKET) + _assert_unit_converts(cn1.unit, "ohm") + _assert_unit_converts(cn2.unit, "ohm") + + +class TestConversionParameters: + """ColumnName.conversion_parameters.""" + + @pytest.mark.parametrize( + ("name", "pattern", "target", "factor", "offset"), + [ + ("Current [A]", BRACKET, "mA", 1000.0, 0.0), + ("Capacity [Ah]", BRACKET, "mAh", 1000.0, 0.0), + ("Time [hr]", BRACKET, "s", 3600.0, 0.0), + ("Charge [A.h]", BRACKET, "mAh", 1000.0, 0.0), + ("Current [A]", BRACKET, "A", 1.0, 0.0), + ("Voltage [V]", BRACKET, "V", 1.0, 0.0), + ("Temperature [degC]", BRACKET, "K", 1.0, 273.15), + ("Temperature [K]", BRACKET, "degC", 1.0, -273.15), + ], + ids=[ + "A_to_mA", + "Ah_to_mAh", + "hr_to_s", + "A.h_to_mAh", + "A_to_A", + "V_to_V", + "degC_to_K", + "K_to_degC", + ], + ) + def test_conversion(self, name, pattern, target, factor, offset): + """Compute factor and offset for unit conversions.""" + f, o = ColumnName(name, pattern).conversion_parameters(target) + assert f == pytest.approx(factor) + assert o == pytest.approx(offset) + + def test_unitless_raises(self): + """conversion_parameters on unitless column raises ValueError.""" + with pytest.raises(ValueError, match="has no unit"): + ColumnName("Step", BRACKET).conversion_parameters("s") + + def test_incompatible_raises(self): + """conversion_parameters with incompatible units raises ValueError.""" + with pytest.raises(ValueError, match="Cannot convert"): + ColumnName("Current [A]", BRACKET).conversion_parameters("V") + + def test_negative_temperature_conversion(self): + """Negative temperatures convert correctly across scales.""" + cn = ColumnName("Temperature [degC]", BRACKET) + f, o = cn.conversion_parameters("K") + # -40 degC = 233.15 K + assert (-40.0 * f + o) == pytest.approx(233.15) + + +class TestFind: + """ColumnName.find static method.""" + + @pytest.mark.parametrize( + ("quantity", "columns", "fmt", "expected_qty", "expected_unit"), + [ + ( + "Current", + ["Current [A]", "Voltage [V]"], + "square_bracket", + "Current", + "A", + ), + ( + "Voltage", + ["Current [A]", "Voltage [V]"], + "square_bracket", + "Voltage", + "V", + ), + ("Current", ["Current / A"], "bdf", "Current", "A"), + ("Step", ["Step"], "square_bracket", "Step", None), + ( + "Test Time", + ["Test Time (s)"], + "parentheses", + "Test Time", + "s", + ), + ("Current", ["Current(A)"], "neware", "Current", "A"), + ("Time", ["~Time[s]"], "basytec", "Time", "s"), + ("I", ["I/mA"], "biologic", "I", "mA"), + ], + ids=[ + "bracket", + "bracket_v", + "bdf", + "bare", + "paren", + "neware", + "basytec", + "biologic", + ], + ) + def test_find_match(self, quantity, columns, fmt, expected_qty, expected_unit): + """Find a column by quantity name across formats.""" + result = ColumnName.find(quantity, columns, fmt) + assert result is not None + _, cn = result + assert cn.quantity == expected_qty + if expected_unit is None: + assert cn.unit is None + else: + _assert_unit_converts(cn.unit, expected_unit) + + @pytest.mark.parametrize( + ("quantity", "columns", "fmt"), + [ + ("Temperature", ["Current [A]"], "square_bracket"), + ("Current", [], "square_bracket"), + ("I", ["Current [A]"], "square_bracket"), + ], + ids=["not_present", "empty", "alias_no_match"], + ) + def test_find_no_match(self, quantity, columns, fmt): + """Find returns None when quantity not present or format mismatch.""" + assert ColumnName.find(quantity, columns, fmt) is None + + def test_case_insensitive(self): + """Find is case-insensitive for quantity matching.""" + assert ColumnName.find("current", ["Current [A]"], "square_bracket") is not None + assert ColumnName.find("CURRENT", ["current [A]"], "square_bracket") is not None + + def test_strips_whitespace(self): + """Find strips whitespace from search quantity.""" + assert ( + ColumnName.find(" Current ", ["Current [A]"], "square_bracket") + is not None ) - assert quantity == "Current" - - -class TestColumnNameParsing: - """Tests for ColumnName construction and properties.""" - - def test_bracket_format_parses_quantity(self) -> None: - """Parsing 'Current [A]' with BRACKET_FORMAT yields quantity 'Current'.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - assert cn.quantity == "Current" - - def test_bracket_format_parses_unit_as_ampere(self) -> None: - """Parsing 'Current [A]' with BRACKET_FORMAT yields ampere unit.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - assert cn.unit is not None - assert cn.unit == _ureg.parse_units("A") - - def test_slash_format_parses_quantity(self) -> None: - """Parsing 'Current / A' with SLASH_FORMAT yields quantity 'Current'.""" - cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) - assert cn.quantity == "Current" - - def test_slash_format_parses_unit_as_ampere(self) -> None: - """Parsing 'Current / A' with SLASH_FORMAT yields ampere unit.""" - cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) - assert cn.unit is not None - assert cn.unit == _ureg.parse_units("A") - - def test_bare_name_bracket_format_unit_is_none(self) -> None: - """Bare 'Step' with BRACKET_FORMAT yields unit=None.""" - cn = ColumnName("Step", ColumnName.BRACKET_FORMAT) - assert cn.unit is None - assert cn.quantity == "Step" - - def test_bare_name_slash_format_unit_is_none(self) -> None: - """Bare 'Step' with SLASH_FORMAT yields unit=None.""" - cn = ColumnName("Step", ColumnName.SLASH_FORMAT) - assert cn.unit is None - assert cn.quantity == "Step" - - def test_partial_slash_raises(self) -> None: - """'Step /' with SLASH_FORMAT raises ValueError.""" - with pytest.raises(ValueError): - ColumnName("Step /", ColumnName.SLASH_FORMAT) - - def test_partial_bracket_raises(self) -> None: - """'Step [' with BRACKET_FORMAT raises ValueError.""" - with pytest.raises(ValueError): - ColumnName("Step [", ColumnName.BRACKET_FORMAT) - - def test_str_returns_original_name(self) -> None: - """__str__ returns the original column name string.""" - name = "Current [A]" - cn = ColumnName(name, ColumnName.BRACKET_FORMAT) - assert str(cn) == name - - def test_alias_ohms_resolves_to_ohm(self) -> None: - """'Resistance [Ohms]' with BRACKET_FORMAT resolves unit via alias map.""" - cn = ColumnName("Resistance [Ohms]", ColumnName.BRACKET_FORMAT) - assert cn.unit is not None - assert cn.unit == _ureg.parse_units("ohm") - - def test_alias_seconds_resolves(self) -> None: - """'Time [Seconds]' with BRACKET_FORMAT resolves unit via alias map.""" - cn = ColumnName("Time [Seconds]", ColumnName.BRACKET_FORMAT) - assert cn.unit is not None - assert cn.unit == _ureg.parse_units("s") - - def test_invalid_unit_raises_value_error(self) -> None: - """A column with an unparseable unit raises ValueError.""" - with pytest.raises(ValueError, match="could not be parsed"): - ColumnName("Foo [not_a_unit_xyz]", ColumnName.BRACKET_FORMAT) - -class TestConversionFactor: - """Tests for ColumnName.conversion_factor.""" - - def test_ampere_to_milliampere(self) -> None: - """'Current [A]' → 'mA' conversion factor is 1000.0.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - assert cn.conversion_factor("mA") == pytest.approx(1000.0) - - def test_capacity_ah_to_mah(self) -> None: - """'Capacity [Ah]' → 'mAh' conversion factor is 1000.0.""" - cn = ColumnName("Capacity [Ah]", ColumnName.BRACKET_FORMAT) - assert cn.conversion_factor("mAh") == pytest.approx(1000.0) - - def test_capacity_compound_unit_to_ah(self) -> None: - """'Capacity [A.h]' → 'Ah' conversion factor is 1.0.""" - cn = ColumnName("Capacity [A.h]", ColumnName.BRACKET_FORMAT) - assert cn.conversion_factor("Ah") == pytest.approx(1.0) - - def test_incompatible_units_raises_value_error(self) -> None: - """Converting ampere to volt raises ValueError.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) + def test_returns_first_match(self): + """Find returns the first matching column.""" + result = ColumnName.find( + "Current", ["Current [A]", "Current [mA]"], "square_bracket" + ) + assert result is not None + assert result[0] == "Current [A]" + + def test_skips_unparseable(self): + """Find skips unparseable columns and continues searching.""" + result = ColumnName.find("Current", ["Step [", "Current [A]"], "square_bracket") + assert result is not None and result[1].quantity == "Current" + + +class TestResolve: + """ColumnName.resolve — all resolution steps.""" + + @pytest.mark.parametrize( + ("target", "pat", "src_cols", "src_fmt", "vals_in", "vals_out", "bdf"), + [ + # Step 1: exact match + ("Current / A", BDF, ["Current / A"], "bdf", [1.0, 2.0], [1.0, 2.0], None), + ("Step", BRACKET, ["Step"], "square_bracket", [1, 2, 3], [1, 2, 3], None), + # Step 2: cross-format, same unit + ( + "Current / A", + BDF, + ["Current [A]"], + "square_bracket", + [1.0, 2.0], + [1.0, 2.0], + None, + ), + ( + "Current [A]", + BRACKET, + ["Current / A"], + "bdf", + [1.0, 2.0], + [1.0, 2.0], + None, + ), + # Step 2: unit conversion + ( + "Current / mA", + BDF, + ["Current [A]"], + "square_bracket", + [1.0, 2.0], + [1000.0, 2000.0], + None, + ), + ( + "Capacity / Ah", + BDF, + ["Capacity [mAh]"], + "square_bracket", + [1000.0, 2500.0], + [1.0, 2.5], + None, + ), + ( + "Time / s", + BDF, + ["Time (min)"], + "parentheses", + [1.0, 2.0], + [60.0, 120.0], + None, + ), + # Step 2: temperature (affine offset) + ( + "Temperature / K", + BDF, + ["Temperature [degC]"], + "square_bracket", + [0.0, 25.0, 100.0], + [273.15, 298.15, 373.15], + None, + ), + ( + "Temperature [degC]", + BRACKET, + ["Temperature / K"], + "bdf", + [273.15, 298.15], + [0.0, 25.0], + None, + ), + ( + "Temperature / K", + BDF, + ["Temperature [degC]"], + "square_bracket", + [-40.0, -273.15], + [233.15, 0.0], + None, + ), + # Step 3: BDF alias with unit conversion + ( + "Current / A", + BDF, + ["I/mA"], + "biologic", + [1000.0, 2000.0], + [1.0, 2.0], + ALL_COLUMNS, + ), + ], + ids=[ + "exact", + "exact_unitless", + "cross_bdf_bracket", + "cross_bracket_bdf", + "A_to_mA", + "mAh_to_Ah", + "min_to_s", + "degC_to_K", + "K_to_degC", + "negative_temps", + "bdf_alias_I", + ], + ) + def test_resolve(self, target, pat, src_cols, src_fmt, vals_in, vals_out, bdf): + """Resolve column against available columns with unit conversion.""" + expr = ColumnName(target, pat).resolve(src_cols, src_fmt, bdf_columns=bdf) + output = pl.DataFrame({src_cols[0]: vals_in}).select(expr) + assert output.columns == [target] + assert output[target].to_list() == pytest.approx(vals_out) + + @pytest.mark.parametrize( + ("target", "pat", "src_cols", "src_fmt"), + [ + ("Current [A]", BRACKET, ["Current / A"], "bdf"), + ("Voltage / V", BDF, ["Voltage (V)"], "parentheses"), + ("Time(s)", NEWARE, ["Time (s)"], "parentheses"), + ], + ids=["bracket_alias", "bdf_alias", "neware_alias"], + ) + def test_output_alias(self, target, pat, src_cols, src_fmt): + """Resolve aliases result to target column name string.""" + expr = ColumnName(target, pat).resolve(src_cols, src_fmt) + assert pl.DataFrame({src_cols[0]: [1.0]}).select(expr).columns == [target] + + def test_bdf_recipe(self): + """Resolve step 4: BDF recipe derives column from dependencies.""" + expr = ColumnName("Event", BDF).resolve( + ["Step"], "bdf", bdf_columns=ALL_COLUMNS + ) + assert pl.DataFrame({"Step": [1, 1, 2, 2, 3]}).select(expr)[ + "Event" + ].to_list() == [0, 0, 1, 1, 2] + + def test_no_match_raises(self): + """Resolve raises ValueError when no column matches.""" + with pytest.raises(ValueError, match="No column matching"): + ColumnName("Temperature / degC", BDF).resolve( + ["Current [A]"], "square_bracket" + ) + + def test_alias_without_bdf_columns_raises(self): + """Resolve raises when alias needed but bdf_columns not provided.""" + with pytest.raises(ValueError, match="No column matching"): + ColumnName("Current / A", BDF).resolve(["I/mA"], "biologic") + + def test_incompatible_dimensions_raises(self): + """Resolve raises ValueError for dimensionally incompatible units.""" with pytest.raises(ValueError, match="Cannot convert"): - cn.conversion_factor("V") - - -class TestWithUnit: - """Tests for ColumnName.with_unit.""" + ColumnName("Voltage / mA", BDF).resolve(["Voltage [V]"], "square_bracket") - def test_bracket_format_with_unit(self) -> None: - """with_unit on bracket-format column produces 'Current [mA]'.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - result = cn.with_unit("mA") - assert str(result) == "Current [mA]" - - def test_slash_format_with_unit(self) -> None: - """with_unit on slash-format column produces 'Current / mA'.""" - cn = ColumnName("Current / A", ColumnName.SLASH_FORMAT) - result = cn.with_unit("mA") - assert str(result) == "Current / mA" - - def test_with_unit_preserves_quantity(self) -> None: - """with_unit preserves the quantity name.""" - cn = ColumnName("Current [A]", ColumnName.BRACKET_FORMAT) - result = cn.with_unit("mA") - assert result.quantity == "Current" - - -class TestFindInColumns: - """Tests for ColumnName.find_in_columns.""" - - def test_finds_matching_column_bracket_format(self) -> None: - """find_in_columns returns 'Current [A]' when searching for 'Current'.""" - cols = ["Time [s]", "Current [A]", "Voltage [V]"] - result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) - assert result is not None - assert str(result) == "Current [A]" - - def test_returns_none_when_quantity_absent(self) -> None: - """find_in_columns returns None when quantity is not present.""" - cols = ["Time [s]", "Voltage [V]"] - result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) - assert result is None - - def test_skips_columns_that_do_not_match_pattern(self) -> None: - """find_in_columns skips columns that don't match the pattern gracefully.""" - cols = ["Step", "Current [A]"] - result = ColumnName.find_in_columns("Current", cols, ColumnName.BRACKET_FORMAT) - assert result is not None - assert str(result) == "Current [A]" + def test_skips_unparseable(self): + """Resolve skips unparseable columns and finds valid match.""" + expr = ColumnName("Current / A", BDF).resolve( + ["Step [", "Current [A]"], "square_bracket" + ) + assert pl.DataFrame({"Current [A]": [1.0]}).select(expr)[ + "Current / A" + ].to_list() == [1.0] + + def test_complex_quantity_names(self): + """Resolve works with complex quantity names containing special chars.""" + expr = ColumnName("~Charge.Rate / C", BDF).resolve( + ["~Charge.Rate[C]"], "square_bracket" + ) + output = pl.DataFrame({"~Charge.Rate[C]": [1.0, 2.0, 3.0]}).select(expr) + assert output.columns == ["~Charge.Rate / C"] + assert output["~Charge.Rate / C"].to_list() == [1.0, 2.0, 3.0] From 20c30f46287987eb024a1aa51b750a00f259f6a0 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:59:50 +0000 Subject: [PATCH 04/40] fix: add temperature exception for the 'C' unit --- pyprobe/column_name.py | 2 ++ tests/test_column_name.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pyprobe/column_name.py b/pyprobe/column_name.py index 47d9d3ea..a35ef32f 100644 --- a/pyprobe/column_name.py +++ b/pyprobe/column_name.py @@ -154,6 +154,8 @@ def __init__(self, name: str, pattern: str) -> None: self._unit: pint.Unit | None = None else: resolved = _UNIT_ALIASES.get(raw_unit, raw_unit) + if self._quantity.lower() == "temperature" and resolved == "C": + resolved = "degC" try: self._unit = _ureg.parse_units(resolved) except pint.errors.UndefinedUnitError as exc: diff --git a/tests/test_column_name.py b/tests/test_column_name.py index 5d3da9e0..9d88f53d 100644 --- a/tests/test_column_name.py +++ b/tests/test_column_name.py @@ -35,6 +35,7 @@ def _assert_unit_converts(unit, canonical: str) -> None: ("Resistance [Ohm]", BRACKET, "Resistance", "ohm"), ("Time [Seconds]", BRACKET, "Time", "s"), ("Temperature [°C]", BRACKET, "Temperature", "degC"), + ("Temperature [C]", BRACKET, "Temperature", "degC"), ("Time / sec", BDF, "Time", "s"), ("Time [hr]", BRACKET, "Time", "hour"), ("Charge [A.h]", BRACKET, "Charge", "Ah"), @@ -60,6 +61,7 @@ def _assert_unit_converts(unit, canonical: str) -> None: "alias_Ohm", "alias_Seconds", "alias_degC", + "temperature_C_to_degC", "alias_sec", "alias_hr", "alias_A.h", @@ -116,6 +118,21 @@ def test_ohm_variants_equivalent(self): _assert_unit_converts(cn1.unit, "ohm") _assert_unit_converts(cn2.unit, "ohm") + def test_temperature_c_is_degc_not_coulombs(self): + """Temperature [C] resolves to degC, not coulombs. + + Charge [C] resolves to coulombs. + """ + # Temperature [C] should resolve to degC + temp_cn = ColumnName("Temperature [C]", BRACKET) + assert temp_cn.unit is not None + _assert_unit_converts(temp_cn.unit, "degC") + + # Charge [C] should resolve to coulombs (not affected by the special case) + charge_cn = ColumnName("Charge [C]", BRACKET) + assert charge_cn.unit is not None + _assert_unit_converts(charge_cn.unit, "coulomb") + class TestConversionParameters: """ColumnName.conversion_parameters.""" @@ -131,6 +148,7 @@ class TestConversionParameters: ("Voltage [V]", BRACKET, "V", 1.0, 0.0), ("Temperature [degC]", BRACKET, "K", 1.0, 273.15), ("Temperature [K]", BRACKET, "degC", 1.0, -273.15), + ("Temperature [C]", BRACKET, "K", 1.0, 273.15), ], ids=[ "A_to_mA", @@ -141,6 +159,7 @@ class TestConversionParameters: "V_to_V", "degC_to_K", "K_to_degC", + "C_to_K", ], ) def test_conversion(self, name, pattern, target, factor, offset): @@ -345,6 +364,16 @@ class TestResolve: [233.15, 0.0], None, ), + # Step 2: temperature format conversion (C notation to K) + ( + "Temperature / K", + BDF, + ["Temperature [C]"], + "square_bracket", + [0.0, 100.0], + [273.15, 373.15], + None, + ), # Step 3: BDF alias with unit conversion ( "Current / A", @@ -367,6 +396,7 @@ class TestResolve: "degC_to_K", "K_to_degC", "negative_temps", + "temperature_C_format", "bdf_alias_I", ], ) From 8c18a8a1a09aca81b167054a42ae69fcfa2a3805 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:59:25 +0000 Subject: [PATCH 05/40] refactor: create schema module --- pyprobe/{ => schema}/column_name.py | 29 ++++++++++++++++----- tests/{ => test_schema}/test_column_name.py | 4 +-- 2 files changed, 25 insertions(+), 8 deletions(-) rename pyprobe/{ => schema}/column_name.py (92%) rename tests/{ => test_schema}/test_column_name.py (99%) diff --git a/pyprobe/column_name.py b/pyprobe/schema/column_name.py similarity index 92% rename from pyprobe/column_name.py rename to pyprobe/schema/column_name.py index a35ef32f..1cca98ce 100644 --- a/pyprobe/column_name.py +++ b/pyprobe/schema/column_name.py @@ -1,4 +1,21 @@ -"""A module for parsing and converting column names with physical units.""" +"""Parse and convert cycler column names with physical units. + +This module provides :class:`ColumnName`, which parses cycler-specific column +name formats (Arbin, Biologic, Neware, etc.) into quantity and unit components, +and performs automatic unit conversions. + +It is the core building block of the column resolution system: +:meth:`ColumnName.resolve` implements the full resolution chain used by +:meth:`~pyprobe.schema.bdf.BDFColumn.from_columns`: + +1. Exact string match +2. Direct quantity match (with unit conversion if needed) +3. BDF alias match (requires :data:`~pyprobe.schema.bdf.ALL_COLUMNS`) +4. BDF recipe fallback (computed column, requires + :data:`~pyprobe.schema.bdf.ALL_COLUMNS`) + +All cycler format patterns are defined in :data:`FORMAT_REGISTRY`. +""" from __future__ import annotations @@ -9,7 +26,7 @@ import polars as pl if TYPE_CHECKING: - from pyprobe.bdf import BDFColumn + from pyprobe.schema.bdf import BDFColumn FORMAT_REGISTRY: dict[str, str] = { "bdf": r"^([^/]*?)(?:\s*/\s*(.+?))?\s*$", @@ -80,7 +97,7 @@ class ColumnName: as ``~``, ``<>``, and ``.``. Examples: - >>> from pyprobe.column_name import FORMAT_REGISTRY + >>> from pyprobe.schema.column_name import FORMAT_REGISTRY >>> cn = ColumnName("Current / A", FORMAT_REGISTRY["bdf"]) >>> cn.quantity 'Current' @@ -109,7 +126,7 @@ def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None ValueError: If ``name`` does not match ``pattern``. Examples: - >>> from pyprobe.column_name import FORMAT_REGISTRY + >>> from pyprobe.schema.column_name import FORMAT_REGISTRY >>> ColumnName._extract_quantity_and_unit( ... "Current [A]", FORMAT_REGISTRY["square_bracket"] ... ) @@ -212,7 +229,7 @@ def conversion_parameters(self, target_unit: str) -> tuple[float, float]: ValueError: If the units are dimensionally incompatible. Examples: - >>> from pyprobe.column_name import FORMAT_REGISTRY + >>> from pyprobe.schema.column_name import FORMAT_REGISTRY >>> cn = ColumnName("Current [A]", FORMAT_REGISTRY["square_bracket"]) >>> cn.conversion_parameters("mA") (1000.0, 0.0) @@ -320,7 +337,7 @@ def resolve( column whose parsed quantity equals ``self.quantity``; applies unit conversion if needed. 3. **BDF alias match** (requires ``bdf_columns``) — finds the - :class:`~pyprobe.bdf.BDFColumn` whose :meth:`matches` returns + :class:`~pyprobe.schema.bdf.BDFColumn` whose :meth:`matches` returns ``True`` for ``self.quantity``, then searches all its aliases via :meth:`find`. 4. **BDF recipe** (requires ``bdf_columns``) — calls diff --git a/tests/test_column_name.py b/tests/test_schema/test_column_name.py similarity index 99% rename from tests/test_column_name.py rename to tests/test_schema/test_column_name.py index 9d88f53d..c744e713 100644 --- a/tests/test_column_name.py +++ b/tests/test_schema/test_column_name.py @@ -3,8 +3,8 @@ import polars as pl import pytest -from pyprobe.bdf import ALL_COLUMNS -from pyprobe.column_name import FORMAT_REGISTRY, ColumnName, _ureg +from pyprobe.schema.bdf import ALL_COLUMNS +from pyprobe.schema.column_name import FORMAT_REGISTRY, ColumnName, _ureg BDF = FORMAT_REGISTRY["bdf"] BRACKET = FORMAT_REGISTRY["square_bracket"] From 3a39ec944cbf4eee31235c1bd4ebbf1281b7f266 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:32:39 +0000 Subject: [PATCH 06/40] refactor(column): implement bdf standard and recipe-based derivation replace multi-format column parsing with bdf-standard descriptors. use recipes to derive columns like net capacity from dependencies and columnset for context-aware resolution. --- pyprobe/column.py | 840 ++++++++++++++++++++++++++ pyprobe/schema/column_name.py | 400 ------------ tests/test_column.py | 626 +++++++++++++++++++ tests/test_schema/test_column_name.py | 466 -------------- 4 files changed, 1466 insertions(+), 866 deletions(-) create mode 100644 pyprobe/column.py delete mode 100644 pyprobe/schema/column_name.py create mode 100644 tests/test_column.py delete mode 100644 tests/test_schema/test_column_name.py diff --git a/pyprobe/column.py b/pyprobe/column.py new file mode 100644 index 00000000..d6d26686 --- /dev/null +++ b/pyprobe/column.py @@ -0,0 +1,840 @@ +"""Column abstraction for BDF-standard battery data. + +This module provides classes for working with BDF (Battery Data Format) +column names and Polars expressions: + +- :class:`Column` — pure descriptor that parses a ``"Quantity / unit"`` + string and computes unit-conversion parameters. +- :class:`BDFColumn` — subclass that adds recipe-based derivation metadata + and a linked-data IRI. +- :class:`ColumnSet` — per-DataFrame resolution context that selects and + optionally converts columns, falling back to recipe derivation for + :class:`BDFColumn` descriptors. + +Module-level instances cover 27 BDF-standard quantities (e.g. +:data:`current_ampere`, :data:`voltage_volt`) and are collected in +:data:`ALL_COLUMNS`. :data:`DEFAULT_COLUMNS` is the core subset that +PyProBE retains after ingestion. + +Typical usage:: + + from pyprobe.column import current_ampere, DEFAULT_COLUMNS, ColumnSet + + cs = ColumnSet(DEFAULT_COLUMNS) + # Select Current in milliamps from a DataFrame that has "Current / A". + expr = cs.col(current_ampere, unit="mA") +""" + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import pint +import polars as pl +from loguru import logger + +BDF_PATTERN: str = r"^([^/]*?)(?:\s*/\s*(.+?))?\s*$" +"""Regex pattern for BDF ``"Quantity / unit"`` column names. + +Two capture groups: ``(1)`` quantity name, ``(2)`` unit string (may be absent). +""" + +BDF_IRI_PREFIX: str = ( + "https://w3id.org/battery-data-alliance/ontology/battery-data-format#" +) +"""Common prefix for all BDF ontology IRIs.""" + +_ureg: pint.UnitRegistry = pint.UnitRegistry() +"""Module-level shared pint unit registry.""" + +for _alias, _canonical in [ + ("Ohm", "ohm"), +]: + _ureg.define(f"{_alias} = {_canonical}") + +DEFAULT_COLUMNS: list[str] = [ + "Test Time / s", + "Current / A", + "Voltage / V", + "Net Capacity / Ah", + "Step Count / 1", + "Step Index / 1", +] +"""Core PyProBE column subset retained after BDF ingestion. + +These are the column names (in BDF ``"Quantity / unit"`` format) that +PyProBE keeps after reducing raw cycler data to a minimal, analysis-ready +feature set. +""" + + +def _resolve_unit(raw_unit: str, quantity: str) -> str: + """Return the pint-parseable unit string, resolving temperature ambiguity. + + ``"C"`` is ambiguous between coulombs and degrees Celsius. When the + quantity contains the word ``"temperature"`` (case-insensitive) the + symbol is mapped to ``"degC"``; otherwise it is returned unchanged. + + Args: + raw_unit: The unit string as stored in a column name (e.g. ``"C"``). + quantity: The physical quantity name (e.g. ``"Ambient Temperature"``). + + Returns: + The resolved unit string (e.g. ``"degC"`` or the original value). + + Examples: + >>> _resolve_unit("C", "Ambient Temperature") + 'degC' + >>> _resolve_unit("C", "Charge") + 'C' + >>> _resolve_unit("mA", "Current") + 'mA' + """ + if raw_unit == "C" and "temperature" in quantity.lower(): + return "degC" + return raw_unit + + +def _apply_conversion( + expr: pl.Expr, + factor: float, + offset: float, + alias: str, +) -> pl.Expr: + """Apply a linear unit conversion to a Polars expression. + + Computes ``target = source * factor + offset``, casting to ``Float64`` + only when a non-trivial conversion is needed. A pure rename (factor + ``1.0``, offset ``0.0``) returns the expression aliased without any + arithmetic. + + Args: + expr: The source Polars expression (any numeric dtype). + factor: Multiplicative conversion factor. + offset: Additive conversion offset (non-zero for affine conversions + such as degC → K). + alias: Alias string applied to the returned expression. + + Returns: + A Polars expression aliased to ``alias``. + + Examples: + >>> import polars as pl + >>> e = _apply_conversion(pl.col("x"), 1.0, 0.0, "x / A") + >>> str(e) # doctest: +ELLIPSIS + '...' + """ + if factor == 1.0 and offset == 0.0: + return expr.alias(alias) + e = expr.cast(pl.Float64) + if factor != 1.0: + e = e * factor + if offset != 0.0: + e = e + offset + return e.alias(alias) + + +def _split_quantity_unit(name: str, pattern: str) -> tuple[str, str | None]: + """Extract quantity and raw unit string from a column name. + + Bare names (no unit separator) return ``None`` as the unit. + + Args: + name: The column name string to parse. + pattern: A regex pattern with two capture groups (quantity, unit). + + Returns: + A ``(quantity, raw_unit)`` tuple where ``raw_unit`` is ``None`` for + bare names. + + Raises: + ValueError: If ``name`` does not match ``pattern``. + + Examples: + >>> _split_quantity_unit("Current / A", BDF_PATTERN) + ('Current', 'A') + >>> _split_quantity_unit("Step", BDF_PATTERN) + ('Step', None) + >>> _split_quantity_unit("Step Count / 1", BDF_PATTERN) + ('Step Count', '1') + """ + match = re.compile(pattern).match(name) + if match is None: + raise ValueError(f"Column name '{name}' does not match pattern '{pattern}'.") + quantity = match.group(1).strip() + raw_unit: str | None = (match.group(2) or "").strip() or None + return quantity, raw_unit + + +class _TrackingDict(dict[Any, Any]): + """Dict subclass that records which keys are accessed via ``__getitem__``. + + Used by :meth:`Recipe.__post_init__` to validate that the compute function + accesses exactly the columns declared in ``required``. + + Attributes: + accessed: Set of BDFColumn keys that have been accessed. + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.accessed: set[BDFColumn] = set() + + def __getitem__(self, key: "BDFColumn") -> pl.Expr: + self.accessed.add(key) + return super().__getitem__(key) + + +@dataclass +class Recipe: + """A computation rule for deriving a :class:`BDFColumn` from other columns. + + A recipe declares which BDF columns are needed (``required``) and + provides a callable that maps :class:`BDFColumn` instances to resolved + Polars expressions, returning a new Polars expression. + + The ``__post_init__`` method validates that the compute function accesses + exactly the columns listed in ``required`` — no more, no fewer. + + Attributes: + required: :class:`BDFColumn` instances that must be resolvable in the + source DataFrame (e.g. ``[charging_capacity_ah, + discharging_capacity_ah]``). + compute: A callable that receives a ``{BDFColumn: pl.Expr}`` + mapping and returns a :class:`polars.Expr`. + + Examples: + >>> import polars as pl + >>> recipe = Recipe( + ... required=[charging_capacity_ah, discharging_capacity_ah], + ... compute=lambda cols: ( + ... cols[charging_capacity_ah] - cols[discharging_capacity_ah] + ... ), + ... ) + >>> len(recipe.required) + 2 + """ + + required: list["BDFColumn"] + compute: Callable[[dict["BDFColumn", pl.Expr]], pl.Expr] + + def __post_init__(self) -> None: + """Validate that compute accesses exactly the required columns. + + Raises: + ValueError: If the compute function accesses columns not in + ``required``, or if any columns in ``required`` are unused. + """ + dummy = _TrackingDict({col: pl.lit(0) for col in self.required}) + try: + self.compute(dummy) + except KeyError as exc: + raise ValueError( + f"Recipe compute accesses a column not in required: {exc}" + ) from exc + except Exception: + return + unused = set(self.required) - dummy.accessed + if unused: + raise ValueError( + f"Recipe declares unused required columns: " + f"{[c.quantity for c in unused]}" + ) + + +@dataclass(eq=False) +class Column: + """A BDF column descriptor: quantity name and unit string. + + Constructed directly or parsed from a string via :meth:`from_string`. + Supports unit conversion through :meth:`conversion_parameters`. + + Unit ``"1"`` denotes a dimensionless column. All columns have a unit; + use ``"1"`` rather than leaving it absent. + + Args: + quantity: The physical quantity name (e.g. ``"Current"``). + unit: The unit string (e.g. ``"A"``, ``"Ah"``, ``"1"``). + Defaults to ``"1"`` for dimensionless columns. + + Attributes: + quantity: The physical quantity name. + unit: The unit string. + + Examples: + >>> col = Column("Current", "A") + >>> col.column_name + 'Current / A' + >>> col = Column.from_string("Current / A") + >>> col.quantity + 'Current' + >>> col.column_name + 'Current / A' + >>> Column("Step").column_name + 'Step / 1' + """ + + quantity: str + unit: str = "1" + + @classmethod + def from_string(cls, name: str, pattern: str = BDF_PATTERN) -> "Column": + """Parse a ``"Quantity / unit"`` string into a :class:`Column`. + + Bare names (no separator) are accepted and yield ``unit="1"``. + Named columns with an explicit unit round-trip back to their original + string via :attr:`column_name`. + + Args: + name: The column name string to parse (e.g. ``"Current / A"`` or + ``"Step Count / 1"``). + pattern: A regex pattern with two capture groups (quantity, unit). + Defaults to :data:`BDF_PATTERN`. + + Returns: + A new :class:`Column` instance. + + Raises: + ValueError: If ``name`` does not match ``pattern``. + + Examples: + >>> col = Column.from_string("Current / A") + >>> col.quantity + 'Current' + >>> col.column_name + 'Current / A' + >>> col2 = Column.from_string("Step Count / 1") + >>> col2.column_name + 'Step Count / 1' + >>> col3 = Column.from_string("Step") + >>> col3.unit + '1' + >>> col3.column_name + 'Step / 1' + """ + quantity, raw_unit = _split_quantity_unit(name, pattern) + return cls(quantity, raw_unit or "1") + + @property + def column_name(self) -> str: + """BDF standard column name string (``"Quantity / unit"``). + + Returns: + The BDF column name string. + + Examples: + >>> Column("Current", "A").column_name + 'Current / A' + >>> Column("Net Capacity", "Ah").column_name + 'Net Capacity / Ah' + >>> Column("Step Count", "1").column_name + 'Step Count / 1' + >>> Column("Step").column_name + 'Step / 1' + """ + return f"{self.quantity} / {self.unit}" + + def __str__(self) -> str: + """Return the BDF column name string. + + Returns: + The same value as :attr:`column_name`. + """ + return self.column_name + + def conversion_parameters(self, target_unit: str) -> tuple[float, float]: + """Compute the factor and offset to convert this column's unit. + + The conversion formula is: + ``target_value = source_value * factor + offset``. + + For purely multiplicative conversions (e.g. mA → A) the offset is + ``0.0``. For affine conversions (e.g. degC → K) the offset is + non-zero. + + Parses the stored unit string via pint on demand. + + Args: + target_unit: The target unit string (e.g. ``"mA"``, ``"K"``). + + Returns: + A ``(factor, offset)`` tuple, both as :class:`float`. + + Raises: + ValueError: If this column is dimensionless (``unit == "1"``). + ValueError: If the units are dimensionally incompatible. + + Examples: + >>> col = Column.from_string("Current / A") + >>> col.conversion_parameters("mA") + (1000.0, 0.0) + """ + if self.unit == "1": + raise ValueError( + f"Column '{self.quantity}' is dimensionless; cannot convert." + ) + source_unit_str = _resolve_unit(self.unit, self.quantity) + target_unit_str = _resolve_unit(target_unit, self.quantity) + try: + source_pint = _ureg.parse_units(source_unit_str) + except pint.errors.UndefinedUnitError as exc: + msg = ( + f"Unit '{self.unit}' for quantity '{self.quantity}' " + f"could not be parsed: {exc}" + ) + raise ValueError(msg) from exc + try: + target_pint = _ureg.parse_units(target_unit_str) + zero = float(_ureg.Quantity(0, source_pint).to(target_pint).magnitude) + one = float(_ureg.Quantity(1, source_pint).to(target_pint).magnitude) + except pint.errors.DimensionalityError as exc: + raise ValueError( + f"Cannot convert '{self.unit}' to '{target_unit}': {exc}" + ) from exc + factor = one - zero + offset = zero + return factor, offset + + +@dataclass(eq=False) +class BDFColumn(Column): + """A BDF-standard column descriptor with recipe-based derivation metadata. + + Extends :class:`Column` with: + + - Optional :class:`Recipe` list for deriving the quantity from other + columns when no direct match exists. + - :attr:`iri` computed from quantity and unit via pint long-form names. + + Resolution of BDFColumn descriptors against actual DataFrames is handled + by :class:`ColumnSet`, which implements the two-step chain: + + 1. **Exact match** — column name already present in available columns. + 2. **Recipe fallback** — derive from dependency columns via a + :class:`Recipe`. + + Args: + quantity: The BDF quantity name (e.g. ``"Current"``). + unit: The unit string (e.g. ``"A"``, ``"Ah"``, ``"1"``). + Defaults to ``"1"`` for dimensionless columns. + recipes: Ordered list of :class:`Recipe` objects. + + Attributes: + recipes: Fallback computation rules, tried in order. + + Examples: + >>> col = BDFColumn("Current", "A") + >>> col.column_name + 'Current / A' + >>> col.iri + 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#current_ampere' + >>> col2 = BDFColumn("Step Count") + >>> col2.column_name + 'Step Count / 1' + >>> col2.iri + 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#step_count' + """ + + recipes: list[Recipe] = field(default_factory=list) + + @property + def iri(self) -> str: + """Full BDF ontology IRI, computed from quantity and unit. + + The IRI is built as :data:`BDF_IRI_PREFIX` + + ``snake_case(quantity)`` + ``_`` + ``pint_long_form(unit)``. + Dimensionless columns (unit ``"1"``) omit the unit suffix. + "Surface Temperature" quantities have the "Surface " prefix + stripped to match the BDF ontology convention. + + Returns: + The IRI string. + + Examples: + >>> BDFColumn("Voltage", "V").iri + 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#voltage_volt' + >>> BDFColumn("Step Count").iri + 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#step_count' + """ + quantity = self.quantity + if quantity.startswith("Surface "): + quantity = quantity.removeprefix("Surface ") + slug = quantity.lower().replace(" ", "_") + if self.unit == "1": + return f"{BDF_IRI_PREFIX}{slug}" + unit_long = ( + str(_ureg.parse_units(_resolve_unit(self.unit, quantity))) + .lower() + .replace(" ", "_") + ) + return f"{BDF_IRI_PREFIX}{slug}_{unit_long}" + + +class ColumnSet: + """Per-DataFrame resolved column context. + + Created with the list of column names available in a DataFrame. + Provides a single :meth:`col` method for selecting and optionally + converting columns. + + Args: + available_columns: Column name strings present in the source DataFrame. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> cs.col("Current / A") # doctest: +ELLIPSIS + + """ + + def __init__(self, available_columns: list[str]) -> None: + """Initialise a ColumnSet with the given available column names. + + Args: + available_columns: Column name strings present in the source + DataFrame. + """ + self._available: set[str] = set(available_columns) + + def col( + self, + column: str | Column, + unit: str | None = None, + ) -> pl.Expr: + """Select a column expression, optionally converting units. + + Args: + column: A column name string, :class:`Column`, or + :class:`BDFColumn`. Strings are parsed via + :meth:`Column.from_string`. + unit: Target unit for conversion (e.g. ``"mA"``). When ``None``, + returns the expression in the column's native unit. + + Returns: + A Polars expression, aliased to ``"Quantity / unit"`` when + unit conversion is applied. + + Raises: + ValueError: If the column cannot be resolved from available + columns or recipes. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> cs.col("Current / A") # doctest: +ELLIPSIS + + """ + if isinstance(column, str): + column = Column.from_string(column) + + if isinstance(column, BDFColumn): + base_expr = self._resolve_bdf(column) + else: + base_expr = pl.col(column.column_name) + + if unit is None: + return base_expr + + factor, offset = column.conversion_parameters(unit) + target_name = f"{column.quantity} / {unit}" + return _apply_conversion(base_expr, factor, offset, target_name) + + def _resolve_bdf(self, col: BDFColumn) -> pl.Expr: + """Resolve a BDFColumn via exact match or recursive recipe. + + Args: + col: The BDFColumn descriptor to resolve. + + Returns: + A Polars expression for the resolved column. + + Raises: + ValueError: If the column cannot be resolved. + """ + if col.column_name in self._available: + return pl.col(col.column_name) + + for recipe in col.recipes: + expr_map: dict[BDFColumn, pl.Expr] = {} + all_found = True + for req_col in recipe.required: + try: + expr_map[req_col] = self._resolve_bdf(req_col) + except ValueError: + all_found = False + break + if all_found: + logger.debug( + "Resolved '%s' via recipe with dependencies %s.", + col.quantity, + [c.quantity for c in expr_map], + ) + return recipe.compute(expr_map).alias(col.column_name) + + raise ValueError(f"Cannot resolve '{col.quantity}' from available columns") + + +test_time_second = BDFColumn( + quantity="Test Time", + unit="s", +) +"""BDF Test Time column (base unit: seconds).""" + +voltage_volt = BDFColumn( + quantity="Voltage", + unit="V", +) +"""BDF Voltage column (base unit: volts).""" + +current_ampere = BDFColumn( + quantity="Current", + unit="A", +) +"""BDF Current column (base unit: amperes).""" + +unix_time_second = BDFColumn( + quantity="Unix Time", + unit="s", +) +"""BDF Unix Time column (base unit: seconds).""" + +cycle_count = BDFColumn( + quantity="Cycle Count", + unit="1", +) +"""BDF Cycle Count column (dimensionless cycle index).""" + +step_count = BDFColumn( + quantity="Step Count", + unit="1", +) +"""BDF Step Count column (dimensionless integer step index).""" + +ambient_temperature_celsius = BDFColumn( + quantity="Ambient Temperature", + unit="degC", +) +"""BDF Ambient Temperature column (base unit: degrees Celsius).""" + +step_index = BDFColumn( + quantity="Step Index", + unit="1", +) +"""BDF Step Index column (dimensionless).""" + +charging_capacity_ah = BDFColumn( + quantity="Charging Capacity", + unit="Ah", +) +"""BDF Charging Capacity column (base unit: ampere-hours).""" + +discharging_capacity_ah = BDFColumn( + quantity="Discharging Capacity", + unit="Ah", +) +"""BDF Discharging Capacity column (base unit: ampere-hours).""" + +step_capacity_ah = BDFColumn( + quantity="Step Capacity", + unit="Ah", +) +"""BDF Step Capacity column (base unit: ampere-hours).""" + +net_capacity_ah = BDFColumn( + quantity="Net Capacity", + unit="Ah", +) +"""BDF Net Capacity column (base unit: ampere-hours). + +Falls back to computing net capacity from charging and discharging sub-columns +when no direct ``Net Capacity`` column is available. +""" + +cumulative_capacity_ah = BDFColumn( + quantity="Cumulative Capacity", + unit="Ah", +) +"""BDF Cumulative Capacity column (base unit: ampere-hours).""" + +charging_energy_wh = BDFColumn( + quantity="Charging Energy", + unit="Wh", +) +"""BDF Charging Energy column (base unit: watt-hours).""" + +discharging_energy_wh = BDFColumn( + quantity="Discharging Energy", + unit="Wh", +) +"""BDF Discharging Energy column (base unit: watt-hours).""" + +step_energy_wh = BDFColumn( + quantity="Step Energy", + unit="Wh", +) +"""BDF Step Energy column (base unit: watt-hours).""" + +net_energy_wh = BDFColumn( + quantity="Net Energy", + unit="Wh", +) +"""BDF Net Energy column (base unit: watt-hours).""" + +cumulative_energy_wh = BDFColumn( + quantity="Cumulative Energy", + unit="Wh", +) +"""BDF Cumulative Energy column (base unit: watt-hours).""" + +power_watt = BDFColumn( + quantity="Power", + unit="W", +) +"""BDF Power column (base unit: watts).""" + +internal_resistance_ohm = BDFColumn( + quantity="Internal Resistance", + unit="Ohm", +) +"""BDF Internal Resistance column (base unit: ohms).""" + +ambient_pressure_pa = BDFColumn( + quantity="Ambient Pressure", + unit="Pa", +) +"""BDF Ambient Pressure column (base unit: pascals).""" + +applied_pressure_pa = BDFColumn( + quantity="Applied Pressure", + unit="Pa", +) +"""BDF Applied Pressure column (base unit: pascals).""" + +temperature_t1_celsius = BDFColumn( + quantity="Surface Temperature T1", + unit="degC", +) +"""BDF Surface Temperature T1 column (base unit: degrees Celsius).""" + +temperature_t2_celsius = BDFColumn( + quantity="Surface Temperature T2", + unit="degC", +) +"""BDF Surface Temperature T2 column (base unit: degrees Celsius).""" + +temperature_t3_celsius = BDFColumn( + quantity="Surface Temperature T3", + unit="degC", +) +"""BDF Surface Temperature T3 column (base unit: degrees Celsius).""" + +temperature_t4_celsius = BDFColumn( + quantity="Surface Temperature T4", + unit="degC", +) +"""BDF Surface Temperature T4 column (base unit: degrees Celsius).""" + +temperature_t5_celsius = BDFColumn( + quantity="Surface Temperature T5", + unit="degC", +) +"""BDF Surface Temperature T5 column (base unit: degrees Celsius).""" + + +def _capacity_from_ch_dch(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: + """Derive net capacity from charging and discharging capacity columns. + + Computes incremental charge and discharge deltas, sums them, and offsets + by the maximum observed charge capacity so that the result starts near + zero. + + Args: + columns: Mapping of ``{charging_capacity_ah: expr, + discharging_capacity_ah: expr}``. + + Returns: + A :class:`polars.Expr` representing net capacity in the same unit as + the input columns. + """ + charge = columns[charging_capacity_ah].cast(pl.Float64) + discharge = columns[discharging_capacity_ah].cast(pl.Float64) + diff_charge = charge.diff().clip(lower_bound=0).fill_null(strategy="zero") + diff_discharge = discharge.diff().clip(lower_bound=0).fill_null(strategy="zero") + return (diff_charge - diff_discharge).cum_sum() + charge.max() + + +def _time_from_unix_time(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: + """Derive elapsed test time from Unix epoch time in seconds. + + Computes successive differences and accumulates them so the result + starts at zero. + + Args: + columns: Mapping of ``{unix_time_second: expr}``. + + Returns: + A :class:`polars.Expr` representing elapsed time in seconds. + """ + t = columns[unix_time_second].cast(pl.Float64) + return t - t.first() + + +def _step_count_from_step_index(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: + """Derive step count from a Step Index column. + + Increments the step count whenever the step index changes. + + Args: + columns: Mapping of ``{step_index: expr}``. + + Returns: + A :class:`polars.Expr` representing a monotonically increasing step + count (``UInt64``). + """ + return columns[step_index].diff().fill_null(0).ne(0).cum_sum().cast(pl.UInt64) + + +test_time_second.recipes = [ + Recipe(required=[unix_time_second], compute=_time_from_unix_time) +] + +net_capacity_ah.recipes = [ + Recipe( + required=[charging_capacity_ah, discharging_capacity_ah], + compute=_capacity_from_ch_dch, + ) +] + +step_count.recipes = [ + Recipe(required=[step_index], compute=_step_count_from_step_index) +] + +ALL_COLUMNS: list[BDFColumn] = [ + test_time_second, + voltage_volt, + current_ampere, + unix_time_second, + cycle_count, + step_count, + ambient_temperature_celsius, + step_index, + charging_capacity_ah, + discharging_capacity_ah, + step_capacity_ah, + net_capacity_ah, + cumulative_capacity_ah, + charging_energy_wh, + discharging_energy_wh, + step_energy_wh, + net_energy_wh, + cumulative_energy_wh, + power_watt, + internal_resistance_ohm, + ambient_pressure_pa, + applied_pressure_pa, + temperature_t1_celsius, + temperature_t2_celsius, + temperature_t3_celsius, + temperature_t4_celsius, + temperature_t5_celsius, +] +"""All 27 BDF-standard BDFColumn instances in canonical order.""" diff --git a/pyprobe/schema/column_name.py b/pyprobe/schema/column_name.py deleted file mode 100644 index 1cca98ce..00000000 --- a/pyprobe/schema/column_name.py +++ /dev/null @@ -1,400 +0,0 @@ -"""Parse and convert cycler column names with physical units. - -This module provides :class:`ColumnName`, which parses cycler-specific column -name formats (Arbin, Biologic, Neware, etc.) into quantity and unit components, -and performs automatic unit conversions. - -It is the core building block of the column resolution system: -:meth:`ColumnName.resolve` implements the full resolution chain used by -:meth:`~pyprobe.schema.bdf.BDFColumn.from_columns`: - -1. Exact string match -2. Direct quantity match (with unit conversion if needed) -3. BDF alias match (requires :data:`~pyprobe.schema.bdf.ALL_COLUMNS`) -4. BDF recipe fallback (computed column, requires - :data:`~pyprobe.schema.bdf.ALL_COLUMNS`) - -All cycler format patterns are defined in :data:`FORMAT_REGISTRY`. -""" - -from __future__ import annotations - -import re -from typing import TYPE_CHECKING - -import pint -import polars as pl - -if TYPE_CHECKING: - from pyprobe.schema.bdf import BDFColumn - -FORMAT_REGISTRY: dict[str, str] = { - "bdf": r"^([^/]*?)(?:\s*/\s*(.+?))?\s*$", - "square_bracket": r"^([^[\]]*?)(?:\s*\[([^\]]+)\])?\s*$", - "parentheses": r"^([^()]*?)(?:\s*\(([^)]+)\))?\s*$", - "neware": r"^([^()]*?)(?:\(([^)]+)\))?\s*$", - "basytec": r"^~?([^[\]]*?)(?:\[([^\]]+)\])?\s*$", - "biologic": r"^([^/]+?)(?:/(.+))?\s*$", -} -"""Regex patterns for all known cycler column name formats. - -Each entry maps a human-readable format name to a regex pattern with exactly -two capture groups: ``(1)`` the quantity name and ``(2)`` the unit string -(which may be absent). - -Format descriptions: - -- ``"bdf"``: BDF slash format ``"Quantity / unit"``; quantity must not - contain ``/``. -- ``"square_bracket"``: Bracket format ``"Quantity [unit]"``; quantity must - not contain ``[`` or ``]``. -- ``"parentheses"``: Arbin/Novonix style ``"Quantity (unit)"`` with a - mandatory space before the opening parenthesis. -- ``"neware"``: Neware style ``"Quantity(unit)"`` — no space before the - parenthesis. -- ``"basytec"``: Basytec style ``"~Quantity[unit]"`` — optional leading - tilde. -- ``"biologic"``: Biologic style ``"Quantity/unit"`` — slash with no - surrounding spaces required. -""" - -_ureg = pint.UnitRegistry() -"""Module-level shared pint unit registry.""" - -# Register non-standard unit spellings that pint does not know natively. -# Note: 'sec' and 'hr' are already recognised by pint and must NOT be -# redefined here — doing so would shadow the built-in and break equality -# checks. Only spellings that are genuinely absent from pint's default -# registry need an explicit define() call. -for _alias, _canonical in [ - ("Ohms", "ohm"), - ("Ohm", "ohm"), - ("Seconds", "s"), -]: - _ureg.define(f"{_alias} = {_canonical}") - -_UNIT_ALIASES: dict[str, str] = { - "°C": "degC", -} -"""Alias map for unit strings that pint cannot handle natively. - -Only entries whose unit symbol contains characters that pint's -``define()`` cannot accept (e.g. the degree symbol ``°``) belong here. -All other non-standard spellings are registered directly on ``_ureg``. -""" - - -class ColumnName: - """Parse a column name into a quantity and unit, and perform unit conversions. - - Supports any regex pattern that has exactly two capture groups: the first - for the quantity name and the second for the unit string. Patterns for - all known cycler formats are defined centrally in - :data:`FORMAT_REGISTRY`. - - Quantity names may contain any characters except the format's separator - characters, which allows cycler column names that include characters such - as ``~``, ``<>``, and ``.``. - - Examples: - >>> from pyprobe.schema.column_name import FORMAT_REGISTRY - >>> cn = ColumnName("Current / A", FORMAT_REGISTRY["bdf"]) - >>> cn.quantity - 'Current' - >>> cn.unit - - """ - - @staticmethod - def _extract_quantity_and_unit(name: str, pattern: str) -> tuple[str, str | None]: - """Extract the quantity name and raw unit string from a column name. - - Bare names (no unit separator) return ``None`` as the unit string. - Names with a partial separator (e.g. ``"Step /"``) fail to match and - raise ``ValueError``. - - Args: - name: The column name string to parse. - pattern: A regex pattern with two capture groups - (quantity, unit). - - Returns: - A ``(quantity, raw_unit)`` tuple where ``raw_unit`` is ``None`` for - bare names. - - Raises: - ValueError: If ``name`` does not match ``pattern``. - - Examples: - >>> from pyprobe.schema.column_name import FORMAT_REGISTRY - >>> ColumnName._extract_quantity_and_unit( - ... "Current [A]", FORMAT_REGISTRY["square_bracket"] - ... ) - ('Current', 'A') - >>> ColumnName._extract_quantity_and_unit( - ... "Step", FORMAT_REGISTRY["square_bracket"] - ... ) - ('Step', None) - """ - match = re.compile(pattern).match(name) - if match is None: - raise ValueError( - f"Column name '{name}' does not match pattern '{pattern}'." - ) - quantity = match.group(1).strip() - raw_unit: str | None = (match.group(2) or "").strip() or None - return quantity, raw_unit - - def __init__(self, name: str, pattern: str) -> None: - """Parse a column name string into quantity and unit components. - - Bare names (no unit separator) are accepted and yield ``unit=None``. - A name that contains a separator but no unit (e.g. ``"Step /"``) raises - ``ValueError`` because the regex cannot match it. - - Args: - name: The column name string to parse (e.g. ``"Current [A]"`` or - ``"Step"``). - pattern: A regex pattern with two capture groups - (quantity, unit). Use a pattern from - :data:`FORMAT_REGISTRY`. - - Raises: - ValueError: If the name contains a unit separator but no valid unit. - ValueError: If the unit string cannot be parsed by pint. - """ - self._name = name - - self._quantity, raw_unit = ColumnName._extract_quantity_and_unit(name, pattern) - - if raw_unit is None: - self._unit: pint.Unit | None = None - else: - resolved = _UNIT_ALIASES.get(raw_unit, raw_unit) - if self._quantity.lower() == "temperature" and resolved == "C": - resolved = "degC" - try: - self._unit = _ureg.parse_units(resolved) - except pint.errors.UndefinedUnitError as exc: - raise ValueError( - f"Unit '{raw_unit}' in column '{name}' could not be parsed: {exc}" - ) from exc - - @property - def quantity(self) -> str: - """The physical quantity name, with unit information removed. - - Returns: - The quantity string (e.g. ``"Current"``). - """ - return self._quantity - - @property - def unit(self) -> pint.Unit | None: - """The parsed pint unit, or ``None`` if the column has no unit. - - Returns: - A :class:`pint.Unit` instance, or ``None``. - """ - return self._unit - - def __str__(self) -> str: - """Return the original column name string. - - Returns: - The original name passed to the constructor. - """ - return self._name - - def conversion_parameters(self, target_unit: str) -> tuple[float, float]: - """Compute the factor and offset to convert this column's unit to another. - - The conversion is: ``target_value = source_value * factor + offset``. - - For purely multiplicative conversions (e.g. mA → A) the offset is - ``0.0``. For affine conversions (e.g. degC → K) the offset is - non-zero. - - Args: - target_unit: The target unit string (e.g. ``"mA"``, ``"K"``, - ``"degC"``). - - Returns: - A ``(factor, offset)`` tuple, both as :class:`float`. For most - unit pairs the offset is ``0.0``. - - Raises: - ValueError: If this column has no unit (i.e. :attr:`unit` is - ``None``). - ValueError: If the units are dimensionally incompatible. - - Examples: - >>> from pyprobe.schema.column_name import FORMAT_REGISTRY - >>> cn = ColumnName("Current [A]", FORMAT_REGISTRY["square_bracket"]) - >>> cn.conversion_parameters("mA") - (1000.0, 0.0) - >>> pat = FORMAT_REGISTRY["square_bracket"] - >>> cn_temp = ColumnName("Temperature [degC]", pat) - >>> cn_temp.conversion_parameters("K") - (1.0, 273.15) - """ - if self._unit is None: - raise ValueError( - f"Column '{self._name}' has no unit; cannot compute conversion " - "parameters." - ) - resolved_target = _UNIT_ALIASES.get(target_unit, target_unit) - try: - target_pint = _ureg.parse_units(resolved_target) - # Convert two reference points to derive factor and offset. - zero = float(_ureg.Quantity(0, self._unit).to(target_pint).magnitude) - one = float(_ureg.Quantity(1, self._unit).to(target_pint).magnitude) - except pint.errors.DimensionalityError as exc: - raise ValueError( - f"Cannot convert '{self._unit}' to '{target_unit}': {exc}" - ) from exc - factor = one - zero - offset = zero - return factor, offset - - @staticmethod - def find( - quantity: str, - available_columns: list[str], - available_format: str, - ) -> tuple[str, ColumnName] | None: - """Find the first column whose parsed quantity matches (case-insensitive). - - Parses each column in available_columns using the regex pattern from - FORMAT_REGISTRY[available_format]. Returns the first column whose - quantity matches the given quantity string (case-insensitive, stripped). - - Args: - quantity: The quantity name to search for (e.g. ``"Current"``). - available_columns: Column name strings to search through. - available_format: Key into FORMAT_REGISTRY for parsing columns. - - Returns: - A ``(raw_column_string, parsed_ColumnName)`` tuple, or ``None`` - if no match is found. - """ - pattern = FORMAT_REGISTRY[available_format] - target = quantity.lower().strip() - for col in available_columns: - try: - cn = ColumnName(col, pattern) - except ValueError: - continue - if cn.quantity.lower().strip() == target: - return col, cn - return None - - def _to_expr(self, col_str: str, source_cn: ColumnName) -> pl.Expr: - """Build a Polars expression with unit conversion aliased to str(self). - - Constructs a :class:`polars.Expr` that selects ``col_str``, applies a - multiplicative unit conversion factor when necessary, and aliases the - result to the original column name string (``str(self)``). - - When either the source or target unit is ``None`` (dimensionless column), - no conversion is applied. A conversion factor of exactly ``1.0`` is - also skipped to avoid an unnecessary cast. - - Args: - col_str: The raw column name string to select from the DataFrame. - source_cn: The parsed :class:`ColumnName` of the source column, - used to compute the conversion factor. - - Returns: - A :class:`polars.Expr` selecting ``col_str``, optionally scaled, - and aliased to ``str(self)``. - """ - if self.unit is None or source_cn.unit is None: - return pl.col(col_str).alias(str(self)) - factor, offset = source_cn.conversion_parameters(str(self.unit)) - if factor == 1.0 and offset == 0.0: - return pl.col(col_str).alias(str(self)) - expr = pl.col(col_str).cast(pl.Float64) - if factor != 1.0: - expr = expr * factor - if offset != 0.0: - expr = expr + offset - return expr.alias(str(self)) - - def resolve( - self, - available_columns: list[str], - available_format: str, - bdf_columns: list[BDFColumn] | None = None, - ) -> pl.Expr: - """Resolve this column name against available columns. - - Full resolution chain (tried in order): - - 1. **Exact match** — ``str(self)`` is already present in - ``available_columns``; returns ``pl.col(str(self))`` with no alias. - 2. **Direct quantity match** — searches ``available_columns`` for a - column whose parsed quantity equals ``self.quantity``; applies unit - conversion if needed. - 3. **BDF alias match** (requires ``bdf_columns``) — finds the - :class:`~pyprobe.schema.bdf.BDFColumn` whose :meth:`matches` returns - ``True`` for ``self.quantity``, then searches all its aliases via - :meth:`find`. - 4. **BDF recipe** (requires ``bdf_columns``) — calls - ``bdf_col.try_recipes()`` to derive the column computationally. - 5. Raises :class:`ValueError` if all steps fail. - - ``column_name.py`` never imports from ``bdf.py``. The objects in - ``bdf_columns`` are accessed only via their public interface - (``.matches()``, ``.aliases``, ``.name``, ``.try_recipes()``). - - Args: - available_columns: Column name strings from the source DataFrame. - available_format: Key into FORMAT_REGISTRY for parsing columns. - bdf_columns: Optional list of BDF column objects enabling alias - and recipe resolution (use cases 3-5). When ``None``, only - use cases 1-2 are attempted. - - Returns: - A :class:`polars.Expr` that selects the matching column, applies - unit conversion if needed, and aliases to ``str(self)``. - - Raises: - ValueError: If no matching column is found after all resolution - steps are exhausted. - """ - # Step 1: exact string match — fastest path, no parsing required. - if str(self) in available_columns: - return pl.col(str(self)) - - # Step 2: direct quantity match (use cases 1-3 in plan). - result = ColumnName.find(self.quantity, available_columns, available_format) - if result is not None: - return self._to_expr(*result) - - # Steps 3-4: BDF alias + recipe (use cases 4-5 in plan). - if bdf_columns is not None: - bdf_col = None - for col in bdf_columns: - if col.matches(self.quantity): - bdf_col = col - break - - if bdf_col is not None: - # Step 3: try canonical name + each alias. - for alias in [bdf_col.name] + bdf_col.aliases: - result = ColumnName.find(alias, available_columns, available_format) - if result is not None: - return self._to_expr(*result) - - # Step 4: try recipes. - expr = bdf_col.try_recipes( - available_columns, available_format, bdf_columns, str(self) - ) - if expr is not None: - return expr - - raise ValueError( - f"No column matching quantity '{self.quantity}' found in" - f" {available_columns}" - ) diff --git a/tests/test_column.py b/tests/test_column.py new file mode 100644 index 00000000..7db137b2 --- /dev/null +++ b/tests/test_column.py @@ -0,0 +1,626 @@ +"""Tests for the column module. + +This module provides tests for BDF column abstractions, including parsing, +unit conversion, and Polars expression generation with recipe-based fallbacks +via ColumnSet. +""" + +from __future__ import annotations + +import polars as pl +import pytest + +from pyprobe.column import ( + ALL_COLUMNS, + BDF_IRI_PREFIX, + BDF_PATTERN, + DEFAULT_COLUMNS, + BDFColumn, + Column, + ColumnSet, + Recipe, + _apply_conversion, + _capacity_from_ch_dch, + _resolve_unit, + _split_quantity_unit, + _step_count_from_step_index, + charging_capacity_ah, + current_ampere, + cycle_count, + discharging_capacity_ah, + net_capacity_ah, + step_count, + step_index, + temperature_t1_celsius, + test_time_second, + unix_time_second, + voltage_volt, +) + + +class TestColumnInit: + """Tests for Column.__init__ and basic construction.""" + + @pytest.mark.parametrize( + "quantity,unit,expected_name", + [ + ("Current", "A", "Current / A"), + ("Voltage", "V", "Voltage / V"), + ("Step Count", "1", "Step Count / 1"), + ("Net Capacity", "Ah", "Net Capacity / Ah"), + ], + ) + def test_init_creates_column_name( + self, quantity: str, unit: str, expected_name: str + ) -> None: + """Column.__init__ correctly constructs column_name.""" + col = Column(quantity, unit) + assert col.quantity == quantity + assert col.unit == unit + assert col.column_name == expected_name + + def test_init_default_unit_is_dimensionless(self) -> None: + """Column with no unit arg defaults to '1'.""" + col = Column("Step") + assert col.unit == "1" + assert col.column_name == "Step / 1" + + +class TestColumnFromString: + """Tests for Column.from_string factory method.""" + + @pytest.mark.parametrize( + "input_str,expected_quantity,expected_unit", + [ + ("Current / A", "Current", "A"), + ("Step Count / 1", "Step Count", "1"), + ("Net Capacity / Ah", "Net Capacity", "Ah"), + ("Step", "Step", "1"), + ("Current / A", "Current", "A"), + ("Net Capacity / Ah", "Net Capacity", "Ah"), + ], + ) + def test_from_string_parses_correctly( + self, input_str: str, expected_quantity: str, expected_unit: str + ) -> None: + """Parse 'Quantity / unit' string correctly.""" + col = Column.from_string(input_str) + assert col.quantity == expected_quantity + assert col.unit == expected_unit + + def test_from_string_roundtrip(self) -> None: + """Parsing and str() should roundtrip the original name.""" + original = "Net Capacity / Ah" + col = Column.from_string(original) + assert str(col) == original + + def test_from_string_invalid_unit_raises_on_conversion(self) -> None: + """Invalid unit strings raise ValueError at conversion_parameters time.""" + col = Column.from_string("Current / InvalidUnit") + with pytest.raises(ValueError, match="could not be parsed"): + col.conversion_parameters("A") + + +class TestConversionParameters: + """Tests for Column.conversion_parameters and unit math.""" + + @pytest.mark.parametrize( + "source_unit,target_unit,expected_factor,expected_offset", + [ + ("A", "mA", 1000.0, 0.0), + ("mA", "A", 0.001, 0.0), + ("Ah", "mAh", 1000.0, 0.0), + ("V", "mV", 1000.0, 0.0), + ("Wh", "mWh", 1000.0, 0.0), + ("A", "A", 1.0, 0.0), + ("W", "kW", 1 / 1000.0, 0.0), + ("mV", "V", 0.001, 0.0), + ], + ) + def test_conversion_parameters_multiplicative( + self, + source_unit: str, + target_unit: str, + expected_factor: float, + expected_offset: float, + ) -> None: + """Test multiplicative conversions for different unit pairs.""" + col = Column.from_string(f"Quantity / {source_unit}") + factor, offset = col.conversion_parameters(target_unit) + assert factor == pytest.approx(expected_factor, rel=1e-9) + assert offset == pytest.approx(expected_offset, abs=1e-9) + + def test_conversion_celsius_to_kelvin(self) -> None: + """Affine conversion degC to K: factor=1, offset=273.15.""" + col = Column.from_string("Temperature / C") + factor, offset = col.conversion_parameters("K") + assert factor == pytest.approx(1.0, rel=1e-9) + assert offset == pytest.approx(273.15, abs=0.01) + + def test_conversion_incompatible_units_raises(self) -> None: + """Converting between incompatible units raises ValueError.""" + col = Column.from_string("Current / A") + with pytest.raises(ValueError, match="Cannot convert"): + col.conversion_parameters("V") + + def test_conversion_dimensionless_raises(self) -> None: + """Converting a dimensionless column raises ValueError.""" + col = Column("Step") + with pytest.raises(ValueError, match="dimensionless"): + col.conversion_parameters("1") + + +class TestBDFColumnIRI: + """Tests for BDFColumn.iri computed property.""" + + @pytest.mark.parametrize( + "col_obj,expected_iri_suffix", + [ + (current_ampere, "current_ampere"), + (voltage_volt, "voltage_volt"), + (step_count, "step_count"), + (cycle_count, "cycle_count"), + (charging_capacity_ah, "charging_capacity_ampere_hour"), + (temperature_t1_celsius, "temperature_t1_degree_celsius"), + ], + ) + def test_iri_computed_from_quantity_and_unit( + self, col_obj: BDFColumn, expected_iri_suffix: str + ) -> None: + """IRI is computed from quantity and pint long-form unit.""" + assert col_obj.iri == f"{BDF_IRI_PREFIX}{expected_iri_suffix}" + + @pytest.mark.parametrize("col_obj", ALL_COLUMNS) + def test_all_bdf_column_iris_are_valid_urls(self, col_obj: BDFColumn) -> None: + """All BDF column IRIs are complete and properly formatted.""" + iri = col_obj.iri + assert iri.startswith(BDF_IRI_PREFIX) + assert len(iri) > len(BDF_IRI_PREFIX) + assert iri.endswith(iri.split("#")[-1]) + + +class TestRecipeComputation: + """Tests for recipe computation functions.""" + + def test_step_count_from_step_index_recipe(self) -> None: + """_step_count_from_step_index increments on step changes.""" + cs = ColumnSet(["Step Index / 1"]) + df = pl.DataFrame( + { + "Step Index / 1": [ + 1, + 1, + 2, + 2, + 3, + 3, + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + 4, + 4, + 5, + 5, + ] + } + ) + result = df.select(cs.col(step_count)) + expected = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 6, 6, 7, 7] + assert result["Step Count / 1"].to_list() == expected + + def test_col_recipe_net_capacity(self) -> None: + """Recipe resolves Net Capacity from Charging and Discharging Capacity.""" + cs = ColumnSet(["Charging Capacity / Ah", "Discharging Capacity / Ah"]) + df = pl.DataFrame( + { + "Charging Capacity / Ah": [1.0, 0.0, 0.0], + "Discharging Capacity / Ah": [0.0, 1.0, 2.0], + } + ) + result = df.select(cs.col(net_capacity_ah)) + expected = [1.0, 0.0, -1.0] + assert result["Net Capacity / Ah"].to_list() == pytest.approx(expected) + + def test_col_recipe_time_from_unix_time(self) -> None: + """Recipe resolves Test Time from Unix epoch time in seconds.""" + cs = ColumnSet(["Unix Time / s"]) + df = pl.DataFrame( + { + "Unix Time / s": [1648864360.0, 1648864361.0, 1648864362.0], + } + ) + result = df.select(cs.col(test_time_second)) + expected = [0.0, 1.0, 2.0] + assert result["Test Time / s"].to_list() == pytest.approx(expected) + + +class TestSplitQuantityUnit: + """Tests for _split_quantity_unit helper.""" + + @pytest.mark.parametrize( + "name,expected_quantity,expected_unit", + [ + ("Current / A", "Current", "A"), + ("Unix Time", "Unix Time", None), + ("Net Capacity / Ah", "Net Capacity", "Ah"), + ("Step Count / 1", "Step Count", "1"), + ], + ) + def test_split_quantity_unit( + self, name: str, expected_quantity: str, expected_unit: str | None + ) -> None: + """Split column name into quantity and unit.""" + q, u = _split_quantity_unit(name, BDF_PATTERN) + assert q == expected_quantity + assert u == expected_unit + + +class TestResolveUnit: + """Tests for _resolve_unit temperature unit resolution.""" + + @pytest.mark.parametrize( + "raw_unit,quantity,expected", + [ + ("C", "Ambient Temperature", "degC"), + ("C", "Surface Temperature T1", "degC"), + ("C", "Temperature", "degC"), + ("C", "TEMPERATURE", "degC"), + ("C", "Some Temperature", "degC"), + ("C", "tEmPeRaTuRe", "degC"), + ("C", "some_temperature_value", "degC"), + ("C", "temperatureSensor", "degC"), + ("C", "Charge", "C"), + ("C", "Current", "C"), + ("C", "Capacitance", "C"), + ("C", "Cycle Count", "C"), + ("C", "", "C"), + ("A", "Current", "A"), + ("A", "Ambient Temperature", "A"), + ("V", "Voltage", "V"), + ("V", "Temperature", "V"), + ("Ah", "Charge Capacity", "Ah"), + ("K", "Temperature", "K"), + ("degC", "Ambient Temperature", "degC"), + ], + ) + def test_resolve_unit(self, raw_unit: str, quantity: str, expected: str) -> None: + """_resolve_unit returns degC for 'C' with temperature quantities.""" + assert _resolve_unit(raw_unit, quantity) == expected + + +class TestApplyConversion: + """Tests for _apply_conversion unit conversion expression builder.""" + + @pytest.mark.parametrize( + "values,factor,offset,alias,expected", + [ + ([1.0, 2.0, 3.0], 1.0, 0.0, "result", [1.0, 2.0, 3.0]), + ([1.0, 2.0, 5.0], 1000.0, 0.0, "result", [1000.0, 2000.0, 5000.0]), + ([0.0, 25.0, 100.0], 1.0, 273.15, "result", [273.15, 298.15, 373.15]), + ([0.0, 10.0, 20.0], 2.0, 5.0, "result", [5.0, 25.0, 45.0]), + ([-1.0, 0.0, 1.0], 1000.0, 0.0, "result", [-1000.0, 0.0, 1000.0]), + ([1000.0, 2000.0, 500.0], 0.001, 0.0, "result", [1.0, 2.0, 0.5]), + ([0.0, 0.0, 0.0], 1000.0, 273.15, "result", [273.15, 273.15, 273.15]), + ([1e6, 1e7, 1e8], 0.001, 0.0, "result", [1e3, 1e4, 1e5]), + ([2.0, 4.0, 6.0], 3.0, 0.0, "result", [6.0, 12.0, 18.0]), + ([0.0, 10.0, 20.0], 1.0, 5.0, "result", [5.0, 15.0, 25.0]), + ], + ) + def test_apply_conversion( + self, + values: list[float], + factor: float, + offset: float, + alias: str, + expected: list[float], + ) -> None: + """_apply_conversion applies factor/offset and aliases the result.""" + df = pl.DataFrame({"x": values}) + result = df.select(_apply_conversion(pl.col("x"), factor, offset, alias)) + assert result.columns == [alias] + assert result[alias].to_list() == pytest.approx(expected, rel=1e-9) + + def test_apply_conversion_integer_input(self) -> None: + """Integer input is cast to Float64 before conversion.""" + df = pl.DataFrame({"x": [1, 2, 3]}) + result = df.select(_apply_conversion(pl.col("x"), 1000.0, 0.0, "result")) + assert result["result"].to_list() == pytest.approx([1000.0, 2000.0, 3000.0]) + + def test_apply_conversion_empty_dataframe(self) -> None: + """Empty DataFrame is handled correctly.""" + df = pl.DataFrame({"x": pl.Series([], dtype=pl.Float64)}) + result = df.select(_apply_conversion(pl.col("x"), 1.0, 0.0, "result")) + assert result["result"].to_list() == [] + + +class TestRecipeDataclass: + """Tests for Recipe dataclass.""" + + def test_recipe_construction(self) -> None: + """Recipe can be constructed with required BDFColumn list and compute.""" + recipe = Recipe( + required=[current_ampere], + compute=lambda cols: cols[current_ampere] * pl.lit(2), + ) + assert recipe.required == [current_ampere] + assert callable(recipe.compute) + + def test_recipe_with_multiple_dependencies(self) -> None: + """Recipe can require multiple BDFColumn instances.""" + recipe = Recipe( + required=[charging_capacity_ah, discharging_capacity_ah], + compute=_capacity_from_ch_dch, + ) + assert len(recipe.required) == 2 + assert charging_capacity_ah in recipe.required + assert discharging_capacity_ah in recipe.required + + +class TestBDFColumnInit: + """Tests for BDFColumn construction with recipes.""" + + def test_init_with_recipes(self) -> None: + """BDFColumn can be initialized with recipes list.""" + recipe = Recipe( + required=[step_index], + compute=_step_count_from_step_index, + ) + col = BDFColumn("Step Count", "1", recipes=[recipe]) + assert len(col.recipes) == 1 + + def test_init_default_recipes_is_empty_list(self) -> None: + """Default recipes is an empty list.""" + col = BDFColumn("Current", "A") + assert col.recipes == [] + + def test_recipes_are_public_attribute(self) -> None: + """Recipes is a public attribute, not private.""" + col = BDFColumn("Current", "A") + assert hasattr(col, "recipes") + col.recipes = [ + Recipe(required=[step_index], compute=_step_count_from_step_index) + ] + assert len(col.recipes) == 1 + + +class TestRecipeAttachment: + """Tests for post-definition recipe attachment pattern.""" + + def test_test_time_second_has_recipe(self) -> None: + """test_time_second has its Unix Time recipe attached.""" + assert len(test_time_second.recipes) == 1 + assert unix_time_second in test_time_second.recipes[0].required + + def test_net_capacity_ah_has_recipe(self) -> None: + """net_capacity_ah has its Charging/Discharging recipe attached.""" + assert len(net_capacity_ah.recipes) == 1 + required_quantities = { + col.quantity for col in net_capacity_ah.recipes[0].required + } + assert "Charging Capacity" in required_quantities + assert "Discharging Capacity" in required_quantities + + def test_step_count_has_recipe(self) -> None: + """step_count has its Step Index recipe attached.""" + assert len(step_count.recipes) == 1 + assert step_index in step_count.recipes[0].required + + +class TestRecipeValidation: + """Tests for recipe validation at construction time.""" + + def test_unused_required_column_raises(self) -> None: + """Recipe raises ValueError if a required column is never accessed.""" + col_a = BDFColumn("Level A", "1") + col_b = BDFColumn("Level B", "1") + + def only_uses_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + return cols[col_a] + pl.lit(10) + + with pytest.raises(ValueError, match="unused required"): + Recipe(required=[col_a, col_b], compute=only_uses_a) + + def test_undeclared_dependency_raises(self) -> None: + """Recipe raises ValueError if compute accesses a column not in required.""" + col_a = BDFColumn("Level A", "1") + col_b = BDFColumn("Level B", "1") + + def uses_b(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + return cols[col_b] + pl.lit(10) + + with pytest.raises(ValueError, match="not in required"): + Recipe(required=[col_a], compute=uses_b) + + def test_valid_recipe_construction_succeeds(self) -> None: + """Recipe construction succeeds when all required columns are used.""" + col_a = BDFColumn("Level A", "1") + + def uses_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + return cols[col_a] + pl.lit(10) + + recipe = Recipe(required=[col_a], compute=uses_a) + assert len(recipe.required) == 1 + + +class TestColumnSet: + """Tests for ColumnSet column resolution and unit conversion.""" + + def test_col_with_string(self) -> None: + """String input returns pl.col() for the parsed column name.""" + cs = ColumnSet(["Current / A"]) + expr = cs.col("Current / A") + df = pl.DataFrame({"Current / A": [1.0, 2.0]}) + result = df.select(expr).to_series().to_list() + assert result == [1.0, 2.0] + + def test_col_with_column_instance(self) -> None: + """Column descriptor input returns pl.col() expression.""" + cs = ColumnSet(["Current / A"]) + col = Column.from_string("Current / A") + expr = cs.col(col) + df = pl.DataFrame({"Current / A": [3.0]}) + result = df.select(expr).to_series().to_list() + assert result == [3.0] + + def test_col_with_bdf_column_exact_match(self) -> None: + """BDFColumn exact match returns pl.col() expression.""" + cs = ColumnSet(["Current / A"]) + expr = cs.col(current_ampere) + df = pl.DataFrame({"Current / A": [5.0]}) + result = df.select(expr).to_series().to_list() + assert result == [5.0] + + @pytest.mark.parametrize( + "source_unit,target_unit,expected_conversion", + [ + ("A", "mA", 1000.0), + ("V", "mV", 1000.0), + ], + ) + def test_col_with_unit_conversion( + self, source_unit: str, target_unit: str, expected_conversion: float + ) -> None: + """Unit conversion scales values and aliases the result.""" + col = BDFColumn("Quantity", source_unit) + cs = ColumnSet([f"Quantity / {source_unit}"]) + expr = cs.col(col, unit=target_unit) + df = pl.DataFrame({f"Quantity / {source_unit}": [1.0, 2.0]}) + result_df = df.select(expr) + assert f"Quantity / {target_unit}" in result_df.columns + assert result_df[f"Quantity / {target_unit}"].to_list() == pytest.approx( + [expected_conversion, expected_conversion * 2], rel=1e-9 + ) + + def test_col_identity_conversion(self) -> None: + """Same-unit conversion aliases without arithmetic.""" + cs = ColumnSet(["Current / A"]) + expr = cs.col(current_ampere, unit="A") + df = pl.DataFrame({"Current / A": [1.0, 2.0]}) + result_df = df.select(expr) + assert "Current / A" in result_df.columns + assert result_df["Current / A"].to_list() == [1.0, 2.0] + + def test_col_celsius_to_kelvin(self) -> None: + """Affine conversion (degC to K) adds 273.15 offset.""" + col = BDFColumn("Temperature", "degC") + cs = ColumnSet(["Temperature / degC"]) + expr = cs.col(col, unit="K") + df = pl.DataFrame({"Temperature / degC": [0.0, 100.0]}) + result = df.select(expr).to_series().to_list() + assert result == pytest.approx([273.15, 373.15], abs=0.01) + + def test_col_not_found_raises(self) -> None: + """ValueError raised when column cannot be resolved.""" + cs = ColumnSet(["Voltage / V"]) + with pytest.raises(ValueError, match="Cannot resolve"): + cs.col(current_ampere) + + def test_col_bdf_with_conversion(self) -> None: + """BDFColumn exact match combined with unit conversion.""" + cs = ColumnSet(["Voltage / V"]) + expr = cs.col(voltage_volt, unit="mV") + df = pl.DataFrame({"Voltage / V": [1.0, 2.0]}) + result_df = df.select(expr) + assert "Voltage / mV" in result_df.columns + assert result_df["Voltage / mV"].to_list() == pytest.approx( + [1000.0, 2000.0], rel=1e-9 + ) + + def test_col_empty_available_raises(self) -> None: + """Empty available_columns list raises ValueError for BDFColumn.""" + cs = ColumnSet([]) + with pytest.raises(ValueError, match="Cannot resolve"): + cs.col(current_ampere) + + def test_recursive_recipe(self) -> None: + """Recipe dependency resolved recursively via another recipe.""" + level_a = BDFColumn("Level A", "1") + level_b = BDFColumn("Level B", "1") + + def b_from_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + return cols[level_a] + pl.lit(10) + + level_b.recipes = [Recipe(required=[level_a], compute=b_from_a)] + level_c = BDFColumn("Level C", "1") + + def c_from_b(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + return cols[level_b] * pl.lit(2) + + level_c.recipes = [Recipe(required=[level_b], compute=c_from_b)] + + cs = ColumnSet(["Level A / 1"]) + expr = cs.col(level_c) + df = pl.DataFrame({"Level A / 1": [5, 10, 15]}) + result = df.select(expr).to_series().to_list() + assert result == [30, 40, 50] + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.parametrize( + "values,target_unit,expected", + [ + ([0.0, 1.0, -1.0], "mA", [0.0, 1000.0, -1000.0]), + ([1e6, 1e7], "mA", [1e9, 1e10]), + ([-5.0, -2.5], "mA", [-5000.0, -2500.0]), + ], + ) + def test_unit_conversion_edge_values( + self, values: list[float], target_unit: str, expected: list[float] + ) -> None: + """Unit conversion handles zero, large, and negative values.""" + cs = ColumnSet(["Current / A"]) + col = Column.from_string("Current / A") + df = pl.DataFrame({"Current / A": values}) + result = df.select(cs.col(col, unit=target_unit)).to_series().to_list() + assert result == pytest.approx(expected, rel=1e-9) + + def test_column_empty_dataframe(self) -> None: + """Empty DataFrame is handled correctly.""" + cs = ColumnSet(["Current / A"]) + col = Column.from_string("Current / A") + df = pl.DataFrame({"Current / A": []}) + result = df.select(cs.col(col)).to_series().to_list() + assert result == [] + + def test_columnset_with_many_rows(self) -> None: + """Large DataFrames are processed correctly.""" + cs = ColumnSet(["Current / A"]) + col = Column.from_string("Current / A") + large_data = list(range(10000)) + df = pl.DataFrame({"Current / A": large_data}) + result = df.select(cs.col(col, unit="mA")).to_series().to_list() + assert len(result) == 10000 + assert result[0] == 0.0 + assert result[-1] == pytest.approx(9999000.0, rel=1e-9) + + +class TestPublicBDFInstances: + """Tests for all 27 public BDFColumn instances.""" + + def test_all_columns_count(self) -> None: + """ALL_COLUMNS list contains exactly 27 entries.""" + assert len(ALL_COLUMNS) == 27 + + def test_default_columns_is_subset(self) -> None: + """DEFAULT_COLUMNS are all present in ALL_COLUMNS.""" + all_names = [col.column_name for col in ALL_COLUMNS] + for default_name in DEFAULT_COLUMNS: + assert default_name in all_names + + @pytest.mark.parametrize("col_obj", ALL_COLUMNS) + def test_all_instances_in_all_columns_list(self, col_obj: BDFColumn) -> None: + """All exported instances appear in ALL_COLUMNS.""" + assert col_obj in ALL_COLUMNS + + @pytest.mark.parametrize("col_obj", ALL_COLUMNS) + def test_all_instances_have_iri(self, col_obj: BDFColumn) -> None: + """All BDF-standard instances have IRI URLs starting with BDF_IRI_PREFIX.""" + assert col_obj.iri is not None + assert col_obj.iri.startswith(BDF_IRI_PREFIX) diff --git a/tests/test_schema/test_column_name.py b/tests/test_schema/test_column_name.py deleted file mode 100644 index c744e713..00000000 --- a/tests/test_schema/test_column_name.py +++ /dev/null @@ -1,466 +0,0 @@ -"""Tests for the ColumnName class.""" - -import polars as pl -import pytest - -from pyprobe.schema.bdf import ALL_COLUMNS -from pyprobe.schema.column_name import FORMAT_REGISTRY, ColumnName, _ureg - -BDF = FORMAT_REGISTRY["bdf"] -BRACKET = FORMAT_REGISTRY["square_bracket"] -PARENTHESES = FORMAT_REGISTRY["parentheses"] -NEWARE = FORMAT_REGISTRY["neware"] -BASYTEC = FORMAT_REGISTRY["basytec"] -BIOLOGIC = FORMAT_REGISTRY["biologic"] - - -def _assert_unit_converts(unit, canonical: str) -> None: - """Assert unit converts 1:1 to canonical.""" - assert _ureg.Quantity(1, unit).to(canonical).magnitude == pytest.approx(1.0) - - -PARSE_CASES = [ - # Standard formats - ("Current [A]", BRACKET, "Current", "A"), - ("Current / A", BDF, "Current", "A"), - ("Test Time (s)", PARENTHESES, "Test Time", "s"), - ("Current(A)", NEWARE, "Current", "A"), - ("~Time[s]", BASYTEC, "Time", "s"), - ("I/mA", BIOLOGIC, "I", "mA"), - ("Step", BRACKET, "Step", None), - ("Step", BDF, "Step", None), - ("Step Index", PARENTHESES, "Step Index", None), - # Unit aliases (via _ureg.define or built-in) - ("Resistance [Ohms]", BRACKET, "Resistance", "ohm"), - ("Resistance [Ohm]", BRACKET, "Resistance", "ohm"), - ("Time [Seconds]", BRACKET, "Time", "s"), - ("Temperature [°C]", BRACKET, "Temperature", "degC"), - ("Temperature [C]", BRACKET, "Temperature", "degC"), - ("Time / sec", BDF, "Time", "s"), - ("Time [hr]", BRACKET, "Time", "hour"), - ("Charge [A.h]", BRACKET, "Charge", "Ah"), - # Special characters in quantity - ("~SOC [%]", BRACKET, "~SOC", "percent"), - ("<>Temperature [degC]", BRACKET, "<>Temperature", "degC"), - ("Charge.Rate / C", BDF, "Charge.Rate", "C"), - ("~Event", BRACKET, "~Event", None), - ("Current [A]", BDF, "Current [A]", None), # bracket in slash = bare -] - -PARSE_IDS = [ - "bracket", - "bdf", - "parentheses", - "neware", - "basytec", - "biologic", - "bare_bracket", - "bare_bdf", - "bare_parentheses", - "alias_Ohms", - "alias_Ohm", - "alias_Seconds", - "alias_degC", - "temperature_C_to_degC", - "alias_sec", - "alias_hr", - "alias_A.h", - "special_tilde", - "special_angles", - "special_dot", - "bare_tilde", - "bracket_in_bdf", -] - - -class TestParsing: - """ColumnName.__init__, .quantity, .unit, __str__.""" - - @pytest.mark.parametrize( - ("name", "pattern", "expected_quantity", "canonical_unit"), - PARSE_CASES, - ids=PARSE_IDS, - ) - def test_quantity_and_unit(self, name, pattern, expected_quantity, canonical_unit): - """Parse quantity and unit from column names across all formats.""" - cn = ColumnName(name, pattern) - assert cn.quantity == expected_quantity - if canonical_unit is None: - assert cn.unit is None - else: - assert cn.unit is not None - _assert_unit_converts(cn.unit, canonical_unit) - - def test_str_returns_original(self): - """__str__ returns the original column name string.""" - assert str(ColumnName("Current [A]", BRACKET)) == "Current [A]" - assert str(ColumnName("I/mA", BIOLOGIC)) == "I/mA" - - def test_invalid_unit_raises(self): - """Column with unparseable unit raises ValueError.""" - with pytest.raises(ValueError, match="could not be parsed"): - ColumnName("Foo [xyz_bad]", BRACKET) - - @pytest.mark.parametrize( - ("name", "pattern"), - [("Step /", BDF), ("Step [", BRACKET)], - ids=["partial_slash", "partial_bracket"], - ) - def test_partial_separator_raises(self, name, pattern): - """Names with partial separators (missing unit) raise ValueError.""" - with pytest.raises(ValueError): - ColumnName(name, pattern) - - def test_ohm_variants_equivalent(self): - """Different spellings of ohm (Ohm, Ohms) both convert to ohm.""" - cn1 = ColumnName("R [Ohm]", BRACKET) - cn2 = ColumnName("R [Ohms]", BRACKET) - _assert_unit_converts(cn1.unit, "ohm") - _assert_unit_converts(cn2.unit, "ohm") - - def test_temperature_c_is_degc_not_coulombs(self): - """Temperature [C] resolves to degC, not coulombs. - - Charge [C] resolves to coulombs. - """ - # Temperature [C] should resolve to degC - temp_cn = ColumnName("Temperature [C]", BRACKET) - assert temp_cn.unit is not None - _assert_unit_converts(temp_cn.unit, "degC") - - # Charge [C] should resolve to coulombs (not affected by the special case) - charge_cn = ColumnName("Charge [C]", BRACKET) - assert charge_cn.unit is not None - _assert_unit_converts(charge_cn.unit, "coulomb") - - -class TestConversionParameters: - """ColumnName.conversion_parameters.""" - - @pytest.mark.parametrize( - ("name", "pattern", "target", "factor", "offset"), - [ - ("Current [A]", BRACKET, "mA", 1000.0, 0.0), - ("Capacity [Ah]", BRACKET, "mAh", 1000.0, 0.0), - ("Time [hr]", BRACKET, "s", 3600.0, 0.0), - ("Charge [A.h]", BRACKET, "mAh", 1000.0, 0.0), - ("Current [A]", BRACKET, "A", 1.0, 0.0), - ("Voltage [V]", BRACKET, "V", 1.0, 0.0), - ("Temperature [degC]", BRACKET, "K", 1.0, 273.15), - ("Temperature [K]", BRACKET, "degC", 1.0, -273.15), - ("Temperature [C]", BRACKET, "K", 1.0, 273.15), - ], - ids=[ - "A_to_mA", - "Ah_to_mAh", - "hr_to_s", - "A.h_to_mAh", - "A_to_A", - "V_to_V", - "degC_to_K", - "K_to_degC", - "C_to_K", - ], - ) - def test_conversion(self, name, pattern, target, factor, offset): - """Compute factor and offset for unit conversions.""" - f, o = ColumnName(name, pattern).conversion_parameters(target) - assert f == pytest.approx(factor) - assert o == pytest.approx(offset) - - def test_unitless_raises(self): - """conversion_parameters on unitless column raises ValueError.""" - with pytest.raises(ValueError, match="has no unit"): - ColumnName("Step", BRACKET).conversion_parameters("s") - - def test_incompatible_raises(self): - """conversion_parameters with incompatible units raises ValueError.""" - with pytest.raises(ValueError, match="Cannot convert"): - ColumnName("Current [A]", BRACKET).conversion_parameters("V") - - def test_negative_temperature_conversion(self): - """Negative temperatures convert correctly across scales.""" - cn = ColumnName("Temperature [degC]", BRACKET) - f, o = cn.conversion_parameters("K") - # -40 degC = 233.15 K - assert (-40.0 * f + o) == pytest.approx(233.15) - - -class TestFind: - """ColumnName.find static method.""" - - @pytest.mark.parametrize( - ("quantity", "columns", "fmt", "expected_qty", "expected_unit"), - [ - ( - "Current", - ["Current [A]", "Voltage [V]"], - "square_bracket", - "Current", - "A", - ), - ( - "Voltage", - ["Current [A]", "Voltage [V]"], - "square_bracket", - "Voltage", - "V", - ), - ("Current", ["Current / A"], "bdf", "Current", "A"), - ("Step", ["Step"], "square_bracket", "Step", None), - ( - "Test Time", - ["Test Time (s)"], - "parentheses", - "Test Time", - "s", - ), - ("Current", ["Current(A)"], "neware", "Current", "A"), - ("Time", ["~Time[s]"], "basytec", "Time", "s"), - ("I", ["I/mA"], "biologic", "I", "mA"), - ], - ids=[ - "bracket", - "bracket_v", - "bdf", - "bare", - "paren", - "neware", - "basytec", - "biologic", - ], - ) - def test_find_match(self, quantity, columns, fmt, expected_qty, expected_unit): - """Find a column by quantity name across formats.""" - result = ColumnName.find(quantity, columns, fmt) - assert result is not None - _, cn = result - assert cn.quantity == expected_qty - if expected_unit is None: - assert cn.unit is None - else: - _assert_unit_converts(cn.unit, expected_unit) - - @pytest.mark.parametrize( - ("quantity", "columns", "fmt"), - [ - ("Temperature", ["Current [A]"], "square_bracket"), - ("Current", [], "square_bracket"), - ("I", ["Current [A]"], "square_bracket"), - ], - ids=["not_present", "empty", "alias_no_match"], - ) - def test_find_no_match(self, quantity, columns, fmt): - """Find returns None when quantity not present or format mismatch.""" - assert ColumnName.find(quantity, columns, fmt) is None - - def test_case_insensitive(self): - """Find is case-insensitive for quantity matching.""" - assert ColumnName.find("current", ["Current [A]"], "square_bracket") is not None - assert ColumnName.find("CURRENT", ["current [A]"], "square_bracket") is not None - - def test_strips_whitespace(self): - """Find strips whitespace from search quantity.""" - assert ( - ColumnName.find(" Current ", ["Current [A]"], "square_bracket") - is not None - ) - - def test_returns_first_match(self): - """Find returns the first matching column.""" - result = ColumnName.find( - "Current", ["Current [A]", "Current [mA]"], "square_bracket" - ) - assert result is not None - assert result[0] == "Current [A]" - - def test_skips_unparseable(self): - """Find skips unparseable columns and continues searching.""" - result = ColumnName.find("Current", ["Step [", "Current [A]"], "square_bracket") - assert result is not None and result[1].quantity == "Current" - - -class TestResolve: - """ColumnName.resolve — all resolution steps.""" - - @pytest.mark.parametrize( - ("target", "pat", "src_cols", "src_fmt", "vals_in", "vals_out", "bdf"), - [ - # Step 1: exact match - ("Current / A", BDF, ["Current / A"], "bdf", [1.0, 2.0], [1.0, 2.0], None), - ("Step", BRACKET, ["Step"], "square_bracket", [1, 2, 3], [1, 2, 3], None), - # Step 2: cross-format, same unit - ( - "Current / A", - BDF, - ["Current [A]"], - "square_bracket", - [1.0, 2.0], - [1.0, 2.0], - None, - ), - ( - "Current [A]", - BRACKET, - ["Current / A"], - "bdf", - [1.0, 2.0], - [1.0, 2.0], - None, - ), - # Step 2: unit conversion - ( - "Current / mA", - BDF, - ["Current [A]"], - "square_bracket", - [1.0, 2.0], - [1000.0, 2000.0], - None, - ), - ( - "Capacity / Ah", - BDF, - ["Capacity [mAh]"], - "square_bracket", - [1000.0, 2500.0], - [1.0, 2.5], - None, - ), - ( - "Time / s", - BDF, - ["Time (min)"], - "parentheses", - [1.0, 2.0], - [60.0, 120.0], - None, - ), - # Step 2: temperature (affine offset) - ( - "Temperature / K", - BDF, - ["Temperature [degC]"], - "square_bracket", - [0.0, 25.0, 100.0], - [273.15, 298.15, 373.15], - None, - ), - ( - "Temperature [degC]", - BRACKET, - ["Temperature / K"], - "bdf", - [273.15, 298.15], - [0.0, 25.0], - None, - ), - ( - "Temperature / K", - BDF, - ["Temperature [degC]"], - "square_bracket", - [-40.0, -273.15], - [233.15, 0.0], - None, - ), - # Step 2: temperature format conversion (C notation to K) - ( - "Temperature / K", - BDF, - ["Temperature [C]"], - "square_bracket", - [0.0, 100.0], - [273.15, 373.15], - None, - ), - # Step 3: BDF alias with unit conversion - ( - "Current / A", - BDF, - ["I/mA"], - "biologic", - [1000.0, 2000.0], - [1.0, 2.0], - ALL_COLUMNS, - ), - ], - ids=[ - "exact", - "exact_unitless", - "cross_bdf_bracket", - "cross_bracket_bdf", - "A_to_mA", - "mAh_to_Ah", - "min_to_s", - "degC_to_K", - "K_to_degC", - "negative_temps", - "temperature_C_format", - "bdf_alias_I", - ], - ) - def test_resolve(self, target, pat, src_cols, src_fmt, vals_in, vals_out, bdf): - """Resolve column against available columns with unit conversion.""" - expr = ColumnName(target, pat).resolve(src_cols, src_fmt, bdf_columns=bdf) - output = pl.DataFrame({src_cols[0]: vals_in}).select(expr) - assert output.columns == [target] - assert output[target].to_list() == pytest.approx(vals_out) - - @pytest.mark.parametrize( - ("target", "pat", "src_cols", "src_fmt"), - [ - ("Current [A]", BRACKET, ["Current / A"], "bdf"), - ("Voltage / V", BDF, ["Voltage (V)"], "parentheses"), - ("Time(s)", NEWARE, ["Time (s)"], "parentheses"), - ], - ids=["bracket_alias", "bdf_alias", "neware_alias"], - ) - def test_output_alias(self, target, pat, src_cols, src_fmt): - """Resolve aliases result to target column name string.""" - expr = ColumnName(target, pat).resolve(src_cols, src_fmt) - assert pl.DataFrame({src_cols[0]: [1.0]}).select(expr).columns == [target] - - def test_bdf_recipe(self): - """Resolve step 4: BDF recipe derives column from dependencies.""" - expr = ColumnName("Event", BDF).resolve( - ["Step"], "bdf", bdf_columns=ALL_COLUMNS - ) - assert pl.DataFrame({"Step": [1, 1, 2, 2, 3]}).select(expr)[ - "Event" - ].to_list() == [0, 0, 1, 1, 2] - - def test_no_match_raises(self): - """Resolve raises ValueError when no column matches.""" - with pytest.raises(ValueError, match="No column matching"): - ColumnName("Temperature / degC", BDF).resolve( - ["Current [A]"], "square_bracket" - ) - - def test_alias_without_bdf_columns_raises(self): - """Resolve raises when alias needed but bdf_columns not provided.""" - with pytest.raises(ValueError, match="No column matching"): - ColumnName("Current / A", BDF).resolve(["I/mA"], "biologic") - - def test_incompatible_dimensions_raises(self): - """Resolve raises ValueError for dimensionally incompatible units.""" - with pytest.raises(ValueError, match="Cannot convert"): - ColumnName("Voltage / mA", BDF).resolve(["Voltage [V]"], "square_bracket") - - def test_skips_unparseable(self): - """Resolve skips unparseable columns and finds valid match.""" - expr = ColumnName("Current / A", BDF).resolve( - ["Step [", "Current [A]"], "square_bracket" - ) - assert pl.DataFrame({"Current [A]": [1.0]}).select(expr)[ - "Current / A" - ].to_list() == [1.0] - - def test_complex_quantity_names(self): - """Resolve works with complex quantity names containing special chars.""" - expr = ColumnName("~Charge.Rate / C", BDF).resolve( - ["~Charge.Rate[C]"], "square_bracket" - ) - output = pl.DataFrame({"~Charge.Rate[C]": [1.0, 2.0, 3.0]}).select(expr) - assert output.columns == ["~Charge.Rate / C"] - assert output["~Charge.Rate / C"].to_list() == [1.0, 2.0, 3.0] From 338aea742db3f0d47b9d13baaf6ca8847ec4d9aa Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:27:02 +0000 Subject: [PATCH 07/40] refactor(io): create io.py module to replace cycler processors --- pyprobe/io.py | 326 ++++++++++++++++ tests/test_io.py | 955 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1281 insertions(+) create mode 100644 pyprobe/io.py create mode 100644 tests/test_io.py diff --git a/pyprobe/io.py b/pyprobe/io.py new file mode 100644 index 00000000..b312d274 --- /dev/null +++ b/pyprobe/io.py @@ -0,0 +1,326 @@ +"""BDF-based cycler data import utilities for PyProBE. + +Provides :func:`process_cycler` as the primary entry point for reading raw +cycler files via the ``batterydf`` package, normalising them to BDF-standard +column names, and persisting to Parquet with attached metadata. + +Typical usage:: + + from pyprobe.io import process_cycler + + lf = process_cycler("path/to/data.xlsx") +""" + +import json +from pathlib import Path +from typing import Any, Literal + +import bdf +import polars as pl +import pyarrow.parquet as pq +from loguru import logger + +from pyprobe.column import ( + BDFColumn, + Column, + ColumnSet, + current_ampere, + net_capacity_ah, + step_count, + step_index, + test_time_second, + voltage_volt, +) + +_REQUIRED_BDF_COLUMNS: list[BDFColumn] = [ + test_time_second, + current_ampere, + voltage_volt, +] +"""BDF columns that must be resolvable; :func:`process_cycler` raises if not.""" + +_OPTIONAL_BDF_COLUMNS: list[BDFColumn] = [ + net_capacity_ah, + step_count, + step_index, +] +"""BDF columns included when available; warnings are emitted on failure.""" + + +def process_cycler( + source: str | Path, + output_dir: str | Path | None = None, + metadata: dict[str, str | int | float | bool] | None = None, + *, + plugin: str | None = None, + write_parquet: bool = True, + skip_if_exists: bool = True, + metadata_format: Literal["json", "parquet"] = "parquet", + extra_columns: dict[str, str] | None = None, +) -> pl.LazyFrame: + """Read a cycler file, normalise to BDF columns, and optionally cache. + + By default the normalised data is written as ``{source_stem}.bdx.parquet`` + in *output_dir* (defaulting to the same directory as *source*). Set + *write_parquet* to ``False`` to skip file writing and return an in-memory + LazyFrame instead. + + Args: + source: Path to the raw cycler file (any format supported by + ``batterydf``). + output_dir: Directory for the output Parquet file. Defaults to the + parent directory of *source*. Ignored when *write_parquet* is + ``False``. + metadata: Optional key-value pairs to attach to the output. Values may + be strings, ints, floats, or bools. Ignored when *write_parquet* + is ``False``. + plugin: Optional ``batterydf`` plugin name passed to ``bdf.read()``. + When ``None`` the plugin is auto-detected. + write_parquet: When ``True`` (default), write the normalised data to + a Parquet file and return a lazy scan. When ``False``, return an + in-memory LazyFrame without writing. + skip_if_exists: When ``True`` (default) and the output file already + exists, skip processing and return the cached file immediately. + Only applies when *write_parquet* is ``True``. + metadata_format: Controls how *metadata* is stored. ``"parquet"`` + (default) embeds metadata in the Parquet footer. ``"json"`` writes + a ``.json`` sidecar file instead and does **not** embed metadata in + the Parquet footer. Ignored when *write_parquet* is ``False`` or + *metadata* is ``None``. + extra_columns: Optional mapping of BDF-format output names to source + column names. These columns are read from the raw source file + (before BDF normalisation) and appended to the output. Keys must + follow the ``"Quantity / unit"`` format. Example:: + + {"Pressure / kPa": "Pressure(kPa)", "Aux Temp / degC": "T_aux[C]"} + + Returns: + A :class:`polars.LazyFrame` over the normalised BDF columns. + + Raises: + ValueError: If any required BDF column (test time, current, voltage) + cannot be resolved from the source data. + ValueError: If *metadata_format* is not ``"parquet"`` or ``"json"``. + + Examples: + Basic usage (writes ``data.bdx.parquet`` next to source):: + + lf = process_cycler("data.xlsx") + + Without writing to disk:: + + lf = process_cycler("data.xlsx", write_parquet=False) + + Output to a different directory with metadata:: + + lf = process_cycler( + "data.xlsx", + output_dir="cache/", + metadata={"cell_id": "C001"}, + ) + + With extra columns from the raw file:: + + lf = process_cycler( + "data.xlsx", + extra_columns={"Pressure / kPa": "Pressure(kPa)"}, + ) + """ + if metadata_format not in ("parquet", "json"): + raise ValueError( + f"metadata_format must be 'parquet' or 'json', got '{metadata_format}'." + ) + + source_path = Path(source) + output_path: Path | None = None + if write_parquet: + if output_dir is None: + output_dir = source_path.parent + output_path = Path(output_dir) / (source_path.stem + ".bdx.parquet") + if skip_if_exists and output_path.exists(): + logger.info("Skipping processing; using cached file '{}'.", output_path) + return pl.scan_parquet(output_path) + + logger.info("Reading cycler file '{}'.", source) + pandas_df = bdf.read(source, plugin=plugin) + df: pl.DataFrame = pl.from_pandas(pandas_df) + + column_set = ColumnSet(df.columns) + expressions: list[pl.Expr] = [] + + for bdf_col in _REQUIRED_BDF_COLUMNS: + try: + expressions.append(column_set.col(bdf_col)) + except ValueError as exc: + raise ValueError( + f"Required BDF column '{bdf_col.quantity}' could not be resolved " + f"from the source data: {exc}" + ) from exc + + for bdf_col in _OPTIONAL_BDF_COLUMNS: + try: + expressions.append(column_set.col(bdf_col)) + except ValueError: + logger.warning( + "Optional BDF column '{}' could not be resolved; skipping.", + bdf_col.quantity, + ) + + normalised: pl.DataFrame = df.select(expressions) + + if extra_columns: + raw_df = pl.from_pandas(bdf.read(source, plugin=plugin, normalize=False)) + for output_name, source_name in extra_columns.items(): + Column.from_string(output_name) # validate BDF format + if source_name not in raw_df.columns: + raise ValueError( + f"Extra column source '{source_name}' not found in data. " + f"Available: {raw_df.columns}" + ) + normalised = normalised.hstack( + [ + raw_df[source_name].alias(output_name) + for output_name, source_name in extra_columns.items() + ] + ) + + if output_path is not None: + _write_parquet( + normalised, output_path, metadata, metadata_format=metadata_format + ) + logger.info("Wrote normalised data to '{}'.", output_path) + return pl.scan_parquet(output_path) + + return normalised.lazy() + + +def _write_parquet( + df: pl.DataFrame, + path: Path, + metadata: dict[str, str | int | float | bool] | None = None, + *, + metadata_format: Literal["json", "parquet"] = "parquet", +) -> None: + """Write a Polars DataFrame to Parquet, embedding optional metadata. + + Converts *df* to an Arrow table and writes via + :func:`pyarrow.parquet.write_table`. When *metadata* is provided, it is + stored according to *metadata_format*: embedded in the Parquet footer + (``"parquet"``) or written to a ``.json`` sidecar file (``"json"``). + + Args: + df: The DataFrame to persist. + path: Destination file path. Parent directories must already exist. + metadata: Optional key-value pairs to attach. Values may be strings, + ints, floats, or bools. When *metadata_format* is ``"parquet"``, + all values are converted to strings before embedding. + metadata_format: ``"parquet"`` (default) embeds metadata in the Parquet + footer. ``"json"`` writes metadata to a ``.json`` sidecar and does + not embed anything in the footer. + """ + table = df.to_arrow() + if metadata: + if metadata_format == "parquet": + existing: dict[bytes, bytes] = table.schema.metadata or {} + encoded: dict[bytes, bytes] = { + k.encode(): str(v).encode() for k, v in metadata.items() + } + table = table.replace_schema_metadata({**existing, **encoded}) + else: + sidecar_path = path.with_suffix(".json") + sidecar_path.write_text(json.dumps(metadata, indent=2)) + pq.write_table(table, path) + + +def read_parquet_metadata(path: str | Path) -> dict[str, str]: + """Read key-value metadata from a Parquet file's footer. + + Args: + path: Path to the Parquet file. + + Returns: + A dictionary of metadata key-value pairs decoded from UTF-8. + Returns an empty dict if the file has no metadata. + + Examples: + >>> import tempfile, pathlib, polars as pl + >>> from pyprobe.io import _write_parquet, read_parquet_metadata + >>> df = pl.DataFrame({"x": [1, 2, 3]}) + >>> with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: + ... tmp = pathlib.Path(f.name) + >>> _write_parquet(df, tmp, {"cell_id": "C001", "cycler": "neware"}) + >>> meta = read_parquet_metadata(tmp) + >>> meta["cell_id"] + 'C001' + >>> meta["cycler"] + 'neware' + >>> tmp.unlink() + """ + pf = pq.ParquetFile(path) + raw: dict[bytes, bytes] = pf.schema_arrow.metadata or {} + return {k.decode(): v.decode() for k, v in raw.items()} + + +def read_metadata( + path: str | Path, + prefer: Literal["parquet", "json"] = "parquet", +) -> dict[str, str]: + """Read metadata from a Parquet file's footer or a ``.json`` sidecar. + + Checks both the Parquet footer and a ``.json`` sidecar (derived from + *path* by replacing the ``.parquet`` suffix with ``.json``). When both + sources contain metadata, *prefer* controls which is returned. When only + one source has metadata, that source is returned regardless of *prefer*. + When neither has metadata, an empty dict is returned. + + Args: + path: Path to the Parquet file. + prefer: Which source to return when both exist. ``"parquet"`` (default) + returns the Parquet footer metadata; ``"json"`` returns the sidecar + metadata. + + Returns: + A dictionary of metadata key-value pairs. Values from the Parquet + footer are always strings (decoded UTF-8). Values from the JSON sidecar + are returned as strings via JSON decoding. + + Raises: + ValueError: If *prefer* is not ``"parquet"`` or ``"json"``. + + Examples: + >>> import tempfile, pathlib, polars as pl + >>> from pyprobe.io import _write_parquet, read_metadata + >>> df = pl.DataFrame({"x": [1, 2, 3]}) + >>> with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: + ... tmp = pathlib.Path(f.name) + >>> _write_parquet(df, tmp, {"cell_id": "C001"}, metadata_format="parquet") + >>> read_metadata(tmp) + {'cell_id': 'C001'} + >>> tmp.unlink() + """ + if prefer not in ("parquet", "json"): + raise ValueError(f"prefer must be 'parquet' or 'json', got '{prefer}'.") + + parquet_path = Path(path) + json_path = parquet_path.with_suffix(".json") + + parquet_meta: dict[str, str] = read_parquet_metadata(parquet_path) + # Strip Arrow/Polars internal keys so only user metadata remains. + parquet_meta = {k: v for k, v in parquet_meta.items() if not k.startswith("pandas")} + + json_meta: dict[str, str] = {} + if json_path.exists(): + raw: Any = json.loads(json_path.read_text()) + if isinstance(raw, dict): + json_meta = {str(k): str(v) for k, v in raw.items()} + + has_parquet = bool(parquet_meta) + has_json = bool(json_meta) + + if has_parquet and has_json: + return parquet_meta if prefer == "parquet" else json_meta + if has_parquet: + return parquet_meta + if has_json: + return json_meta + return {} diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 00000000..34146fbf --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,955 @@ +"""Tests for the io module. + +This module provides tests for BDF-based cycler data import, including: +- process_cycler happy path and integration with column resolution +- process_cycler output_dir and skip_if_exists behavior +- Error handling for missing required and optional columns +- Parquet metadata write and read operations +- read_metadata function with preference logic +- process_cycler integration tests with actual sample data files +""" + +import datetime +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pandas as pd +import polars as pl +import polars.testing as pl_testing +import pytest + +from pyprobe.io import ( + _write_parquet, + process_cycler, + read_parquet_metadata, +) + + +@pytest.fixture +def bdf_df() -> pd.DataFrame: + """Pandas DataFrame with the 3 required BDF columns.""" + return pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0], + "Current / A": [1.0, -1.0, 0.5], + "Voltage / V": [3.7, 3.6, 3.8], + } + ) + + +class TestProcessCycler: + """Tests for process_cycler with minimal required columns.""" + + def test_process_cycler_required_columns_only( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler returns LazyFrame with required BDF columns.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + assert isinstance(lf, pl.LazyFrame) + result = lf.collect() + assert "Test Time / s" in result.columns + assert "Current / A" in result.columns + assert "Voltage / V" in result.columns + assert result.shape == (3, 3) + + def test_process_cycler_with_optional_columns(self, tmp_path: Path) -> None: + """process_cycler includes optional columns when available.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0], + "Current / A": [1.0, -1.0, 0.5], + "Voltage / V": [3.7, 3.6, 3.8], + "Net Capacity / Ah": [0.0, 0.1, 0.15], + "Step Index / 1": [1, 1, 2], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert "Net Capacity / Ah" in result.columns + assert "Step Index / 1" in result.columns + + def test_process_cycler_derives_step_count_from_step_index( + self, tmp_path: Path + ) -> None: + """process_cycler derives Step Count from Step Index when available.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0, 3.0], + "Current / A": [1.0, -1.0, 0.5, 0.3], + "Voltage / V": [3.7, 3.6, 3.8, 3.7], + "Step Index / 1": [1, 1, 2, 2], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert "Step Count / 1" in result.columns + step_count = result["Step Count / 1"].to_list() + assert step_count == [0, 0, 1, 1] + + def test_process_cycler_passes_plugin_to_bdf_read( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler forwards plugin parameter to bdf.read().""" + with patch("bdf.read", return_value=bdf_df) as mock_read: + process_cycler("fake.csv", output_dir=tmp_path, plugin="neware-csv") + + mock_read.assert_called_once() + call_kwargs = mock_read.call_args.kwargs + assert call_kwargs["plugin"] == "neware-csv" + + +class TestProcessCyclerOutputDir: + """Tests for process_cycler with output_dir parameter.""" + + def test_process_cycler_writes_parquet_with_output_dir( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler writes to Parquet file in output_dir.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + expected_output = tmp_path / "fake.bdx.parquet" + assert expected_output.exists() + assert isinstance(lf, pl.LazyFrame) + result = lf.collect() + assert result.shape[0] == 3 + + def test_process_cycler_output_file_naming( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler names output file as {source_stem}.bdx.parquet.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("data.xlsx", output_dir=tmp_path) + + expected_output = tmp_path / "data.bdx.parquet" + assert expected_output.exists() + assert isinstance(lf, pl.LazyFrame) + + def test_process_cycler_returns_scan_of_written_parquet( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler returns a lazy scan of the written parquet file.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert len(result) == 3 + + def test_process_cycler_output_dir_as_string( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler accepts output_dir as string.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=str(tmp_path)) + + expected_output = tmp_path / "fake.bdx.parquet" + assert expected_output.exists() + assert isinstance(lf, pl.LazyFrame) + + def test_process_cycler_output_dir_defaults_to_source_parent( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler defaults output_dir to source's parent directory.""" + source_file = tmp_path / "data.csv" + source_file.write_text("dummy") + + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler(source_file) + + expected_output = tmp_path / "data.bdx.parquet" + assert expected_output.exists() + assert isinstance(lf, pl.LazyFrame) + + +class TestProcessCyclerSkipIfExists: + """Tests for skip_if_exists parameter behavior.""" + + def test_process_cycler_skip_exists_true_skips_read( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """With skip_if_exists=True, bdf.read() is not called if file exists.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler("fake.csv", output_dir=tmp_path) + + mock_read = MagicMock() + with patch("bdf.read", side_effect=mock_read): + lf = process_cycler("fake.csv", output_dir=tmp_path, skip_if_exists=True) + + mock_read.assert_not_called() + result = lf.collect() + assert result.shape[0] == 3 + + def test_process_cycler_skip_exists_false_overwrites( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """With skip_if_exists=False, existing file is overwritten.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler("fake.csv", output_dir=tmp_path) + + new_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0, 3.0], + "Current / A": [1.0, -1.0, 0.5, 0.3], + "Voltage / V": [3.7, 3.6, 3.8, 3.7], + } + ) + with patch("bdf.read", return_value=new_df) as mock_read: + lf = process_cycler("fake.csv", output_dir=tmp_path, skip_if_exists=False) + + mock_read.assert_called_once() + result = lf.collect() + assert result.shape[0] == 4 + + def test_process_cycler_skip_exists_default_true( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """skip_if_exists defaults to True.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler("fake.csv", output_dir=tmp_path) + + with patch("bdf.read", side_effect=Exception("Should not be called")): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert result.shape[0] == 3 + + +class TestProcessCyclerMissingColumns: + """Tests for error handling when required or optional columns are missing.""" + + @pytest.mark.parametrize( + "missing_column", + ["Test Time / s", "Current / A", "Voltage / V"], + ) + def test_process_cycler_missing_required_column_raises( + self, tmp_path: Path, missing_column: str + ) -> None: + """process_cycler raises ValueError when required column is missing.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0], + "Current / A": [1.0, -1.0], + "Voltage / V": [3.7, 3.6], + } + ) + del fake_df[missing_column] + + with ( + patch("bdf.read", return_value=fake_df), + pytest.raises(ValueError, match="Required BDF column"), + ): + process_cycler("fake.csv", output_dir=tmp_path) + + def test_process_cycler_missing_optional_column_warns( + self, tmp_path: Path, bdf_df: pd.DataFrame, caplog + ) -> None: + """process_cycler logs warning via loguru when optional column missing.""" + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert result.shape[0] == 3 + assert "Net Capacity" not in result.columns + assert "Optional BDF column" in caplog.text + + +class TestMetadataRoundTrip: + """Tests for metadata round-trip: write and read cycles.""" + + def test_metadata_roundtrip_basic_strings(self, tmp_path: Path) -> None: + """Basic string metadata round-trips through parquet footer.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3], "y": [4.0, 5.0, 6.0]}) + metadata = {"cell_id": "C001", "cycler": "neware"} + + _write_parquet(df, output_file, metadata) # type: ignore + + read_meta = read_parquet_metadata(output_file) + assert read_meta["cell_id"] == "C001" + assert read_meta["cycler"] == "neware" + + def test_metadata_roundtrip_special_chars_utf8(self, tmp_path: Path) -> None: + """Metadata round-trip preserves special characters and UTF-8.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + metadata = { + "description": "Test data with spaces", + "unicode": "café", + "key_with_underscore": "value123", + } + + _write_parquet(df, output_file, metadata) # type: ignore + + read_meta = read_parquet_metadata(output_file) + assert read_meta["description"] == "Test data with spaces" + assert read_meta["unicode"] == "café" + assert read_meta["key_with_underscore"] == "value123" + + def test_metadata_roundtrip_empty_dict(self, tmp_path: Path) -> None: + """Metadata round-trip with empty dict.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + + _write_parquet(df, output_file, metadata={}) + + assert output_file.exists() + read_meta = read_parquet_metadata(output_file) + assert isinstance(read_meta, dict) + assert len(read_meta) == 0 + + def test_metadata_roundtrip_none(self, tmp_path: Path) -> None: + """Metadata round-trip with None (no metadata).""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + + _write_parquet(df, output_file, metadata=None) + + assert output_file.exists() + read_meta = read_parquet_metadata(output_file) + assert isinstance(read_meta, dict) + assert len(read_meta) == 0 + + def test_metadata_roundtrip_json_sidecar(self, tmp_path: Path) -> None: + """Metadata round-trip with metadata_format='json'.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + metadata = {"cell_id": "C001", "cycler": "neware"} + + _write_parquet(df, output_file, metadata, metadata_format="json") # type: ignore + + sidecar = tmp_path / "test.json" + assert sidecar.exists() + loaded = json.loads(sidecar.read_text()) + assert loaded == metadata + + def test_metadata_roundtrip_json_sidecar_no_metadata(self, tmp_path: Path) -> None: + """With metadata_format='json' but no metadata, no sidecar is written.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + + _write_parquet(df, output_file, metadata=None, metadata_format="json") + + sidecar = tmp_path / "test.json" + assert not sidecar.exists() + + def test_metadata_roundtrip_non_string_values_as_strings( + self, tmp_path: Path + ) -> None: + """Non-string values come back as strings from parquet footer.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + metadata = {"count": "42", "rate": "3.14", "flag": "true"} + + _write_parquet(df, output_file, metadata) # type: ignore + + read_meta = read_parquet_metadata(output_file) + assert read_meta["count"] == "42" + assert read_meta["rate"] == "3.14" + assert read_meta["flag"] == "true" + + +class TestReadMetadata: + """Tests for the read_metadata function.""" + + def test_read_metadata_only_parquet_exists(self, tmp_path: Path) -> None: + """read_metadata returns parquet metadata when only parquet exists.""" + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + metadata = {"source": "parquet_only"} + + _write_parquet(df, output_file, metadata) # type: ignore + + read_meta = read_parquet_metadata(output_file) + assert read_meta["source"] == "parquet_only" + + def test_read_metadata_only_json_exists(self, tmp_path: Path) -> None: + """read_metadata returns json metadata when only json sidecar exists.""" + output_file = tmp_path / "test.parquet" + sidecar = tmp_path / "test.json" + df = pl.DataFrame({"x": [1, 2, 3]}) + + _write_parquet(df, output_file, metadata=None) + json_metadata = {"source": "json_only"} + sidecar.write_text(json.dumps(json_metadata)) + + meta = json.loads(sidecar.read_text()) + assert meta["source"] == "json_only" + + def test_read_metadata_both_exist_prefer_parquet(self, tmp_path: Path) -> None: + """When both exist, prefer='parquet' returns parquet metadata.""" + output_file = tmp_path / "test.parquet" + sidecar = tmp_path / "test.json" + df = pl.DataFrame({"x": [1, 2, 3]}) + + parquet_metadata = {"source": "parquet"} + _write_parquet(df, output_file, parquet_metadata) # type: ignore + + json_metadata = {"source": "json"} + sidecar.write_text(json.dumps(json_metadata)) + + read_meta = read_parquet_metadata(output_file) + assert read_meta["source"] == "parquet" + + def test_read_metadata_both_exist_prefer_json(self, tmp_path: Path) -> None: + """When both exist, logic can prefer json metadata.""" + output_file = tmp_path / "test.parquet" + sidecar = tmp_path / "test.json" + df = pl.DataFrame({"x": [1, 2, 3]}) + + parquet_metadata = {"source": "parquet"} + _write_parquet(df, output_file, parquet_metadata) # type: ignore + + json_metadata = {"source": "json"} + sidecar.write_text(json.dumps(json_metadata)) + + meta = json.loads(sidecar.read_text()) + assert meta["source"] == "json" + + +class TestProcessCyclerIntegrationWithMetadata: + """Integration tests for process_cycler with metadata.""" + + def test_process_cycler_metadata_in_output_file( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler embeds metadata in output Parquet file.""" + metadata = {"experiment_id": "EXP_001", "note": "test data"} + + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler("fake.csv", output_dir=tmp_path, metadata=metadata) # type: ignore + + result = lf.collect() + assert result.shape[0] == 3 + + output_file = tmp_path / "fake.bdx.parquet" + read_meta = read_parquet_metadata(output_file) + assert read_meta["experiment_id"] == "EXP_001" + assert read_meta["note"] == "test data" + + def test_process_cycler_metadata_with_json_sidecar( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler with metadata_format='json' writes metadata to JSON.""" + metadata = {"key": "value"} + + with patch("bdf.read", return_value=bdf_df): + process_cycler( + "fake.csv", + output_dir=tmp_path, + metadata=metadata, # type: ignore + metadata_format="json", + ) + + sidecar = tmp_path / "fake.bdx.json" + assert sidecar.exists() + loaded = json.loads(sidecar.read_text()) + assert loaded == metadata + + +class TestProcessCyclerEdgeCases: + """Edge case tests for process_cycler.""" + + def test_process_cycler_empty_dataframe(self, tmp_path: Path) -> None: + """process_cycler handles empty DataFrame (0 rows).""" + fake_df = pd.DataFrame( + { + "Test Time / s": [], + "Current / A": [], + "Voltage / V": [], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert result.shape[0] == 0 + assert result.shape[1] == 3 + + def test_process_cycler_single_row(self, tmp_path: Path) -> None: + """process_cycler handles single-row DataFrame.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0], + "Current / A": [1.5], + "Voltage / V": [3.7], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert result.shape[0] == 1 + + def test_process_cycler_large_dataframe(self, tmp_path: Path) -> None: + """process_cycler handles large DataFrame efficiently.""" + n_rows = 10000 + fake_df = pd.DataFrame( + { + "Test Time / s": range(n_rows), + "Current / A": [1.0 + i * 0.001 for i in range(n_rows)], + "Voltage / V": [3.7 + i * 0.0001 for i in range(n_rows)], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert result.shape[0] == n_rows + + def test_process_cycler_numeric_columns_converted_correctly( + self, tmp_path: Path + ) -> None: + """process_cycler correctly converts pandas dtypes to Polars.""" + fake_df = pd.DataFrame( + { + "Test Time / s": pd.array([0, 1, 2], dtype="int64"), + "Current / A": pd.array([1.0, -1.0, 0.5], dtype="float64"), + "Voltage / V": pd.array([3.7, 3.6, 3.8], dtype="float32"), + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + assert len(result) == 3 + + def test_process_cycler_source_as_path_object( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """process_cycler accepts source as Path object.""" + source_file = tmp_path / "fake.csv" + source_file.write_text("dummy") + + with patch("bdf.read", return_value=bdf_df): + lf = process_cycler(source_file, output_dir=tmp_path) + + assert isinstance(lf, pl.LazyFrame) + + def test_process_cycler_negative_current_values(self, tmp_path: Path) -> None: + """process_cycler handles negative current (discharge) values.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0], + "Current / A": [1.0, -1.0, -0.5], + "Voltage / V": [3.7, 3.6, 3.5], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + currents = result["Current / A"].to_list() + assert currents[1] == -1.0 + assert currents[2] == -0.5 + + def test_process_cycler_zero_time_start(self, tmp_path: Path) -> None: + """process_cycler correctly handles time starting at zero.""" + fake_df = pd.DataFrame( + { + "Test Time / s": [0.0, 0.5, 1.0], + "Current / A": [1.0, 1.0, -1.0], + "Voltage / V": [3.7, 3.75, 3.6], + } + ) + with patch("bdf.read", return_value=fake_df): + lf = process_cycler("fake.csv", output_dir=tmp_path) + + result = lf.collect() + times = result["Test Time / s"].to_list() + assert times[0] == 0.0 + assert times[1] == 0.5 + + +class TestProcessCyclerExtraColumns: + """Tests for the extra_columns parameter.""" + + def test_extra_columns_happy_path(self, tmp_path: Path) -> None: + """Extra columns are renamed and included in the output.""" + bdf_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0], + "Current / A": [1.0, -1.0], + "Voltage / V": [3.7, 3.6], + } + ) + raw_df = pd.DataFrame( + { + "Time(s)": [0.0, 1.0], + "I(A)": [1.0, -1.0], + "V(V)": [3.7, 3.6], + "Pressure(kPa)": [101.3, 101.4], + } + ) + with patch("bdf.read", side_effect=[bdf_df, raw_df]): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={"Pressure / kPa": "Pressure(kPa)"}, + ) + result = lf.collect() + assert "Pressure / kPa" in result.columns + assert result["Pressure / kPa"].to_list() == [101.3, 101.4] + + def test_extra_columns_multiple(self, tmp_path: Path) -> None: + """Multiple extra columns are all included.""" + bdf_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0], + "Current / A": [1.0, -1.0], + "Voltage / V": [3.7, 3.6], + } + ) + raw_df = pd.DataFrame( + { + "Time(s)": [0.0, 1.0], + "P(kPa)": [101.3, 101.4], + "T_aux(C)": [25.0, 26.0], + } + ) + with patch("bdf.read", side_effect=[bdf_df, raw_df]): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={ + "Pressure / kPa": "P(kPa)", + "Aux Temp / degC": "T_aux(C)", + }, + ) + result = lf.collect() + assert "Pressure / kPa" in result.columns + assert "Aux Temp / degC" in result.columns + + def test_extra_columns_missing_source_raises(self, tmp_path: Path) -> None: + """ValueError raised when source column doesn't exist in raw data.""" + bdf_df = pd.DataFrame( + { + "Test Time / s": [0.0], + "Current / A": [1.0], + "Voltage / V": [3.7], + } + ) + raw_df = pd.DataFrame({"Time(s)": [0.0], "I(A)": [1.0]}) + with ( + patch("bdf.read", side_effect=[bdf_df, raw_df]), + pytest.raises( + ValueError, match="Extra column source 'NoSuchCol' not found" + ), + ): + process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={"Pressure / kPa": "NoSuchCol"}, + ) + + def test_extra_columns_none_is_noop( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """extra_columns=None does not change behaviour.""" + with patch("bdf.read", return_value=bdf_df) as mock_read: + lf = process_cycler("fake.csv", output_dir=tmp_path, extra_columns=None) + mock_read.assert_called_once() + assert isinstance(lf, pl.LazyFrame) + + def test_extra_columns_persisted_in_output(self, tmp_path: Path) -> None: + """Extra columns are persisted in the output parquet file.""" + bdf_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0], + "Current / A": [1.0, -1.0], + "Voltage / V": [3.7, 3.6], + } + ) + raw_df = pd.DataFrame( + { + "Time(s)": [0.0, 1.0], + "P(kPa)": [101.3, 101.4], + } + ) + with patch("bdf.read", side_effect=[bdf_df, raw_df]): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={"Pressure / kPa": "P(kPa)"}, + ) + result = lf.collect() + assert "Pressure / kPa" in result.columns + assert result["Pressure / kPa"].to_list() == [101.3, 101.4] + output_file = tmp_path / "fake.bdx.parquet" + assert output_file.exists() + lf_reload = process_cycler("fake.csv", output_dir=tmp_path, skip_if_exists=True) + result_reload = lf_reload.collect() + assert "Pressure / kPa" in result_reload.columns + + +class TestProcessCyclerIntegration: + """End-to-end integration tests using real sample data files.""" + + arbin_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2024, 9, 20, 8, 37, 5, 772000).timestamp()], + "Test Time / s": [301.214], + "Step Index / 1": [3], + "Step Count / 1": [2], + "Current / A": [2.650138], + "Voltage / V": [3.599601], + "Net Capacity / Ah": [0.0007812400999999999], + "Surface Temperature T1 / degC": [24.68785], + }, + ) + + basytec_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2023, 6, 19, 17, 58, 3, 235803).timestamp()], + "Test Time / s": [70.235804], + "Step Index / 1": [4], + "Step Count / 1": [1], + "Current / A": [0.449602], + "Voltage / V": [3.53285], + "Net Capacity / Ah": [0.001248916998009], + "Ambient Temperature / degC": [25.47953], + }, + ) + + biologic_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2024, 5, 13, 11, 19, 51, 602139).timestamp()], + "Test Time / s": [139.524007], + "Step Index / 1": [1], + "Step Count / 1": [1], + "Current / A": [-0.899826], + "Voltage / V": [3.4854481], + "Net Capacity / Ah": [-0.03237135133365209], + "Ambient Temperature / degC": [23.029291], + }, + ) + + biologic_last_row_no_header = pl.DataFrame( + { + "Test Time / s": [281792.50213], + "Step Index / 1": [0], + "Step Count / 1": [0], + "Current / A": [0.0], + "Voltage / V": [2.9814022], + "Net Capacity / Ah": [0.0], + "Ambient Temperature / degC": [24.506462], + }, + ) + + biologic_last_row_mb = pl.DataFrame( + { + "Date": [datetime.datetime(2024, 5, 13, 11, 19, 51, 858016).timestamp()], + "Test Time / s": [256016.11344], + "Step Index / 1": [5], + "Step Count / 1": [5], + "Current / A": [0.450135], + "Voltage / V": [3.062546], + "Net Capacity / Ah": [0.307727], + "Ambient Temperature / degC": [22.989878], + }, + ) + + maccor_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2023, 11, 23, 15, 56, 24, 60000).timestamp()], + "Test Time / s": [13.06], + "Step Index / 1": [2], + "Step Count / 1": [1], + "Current / A": [28.798], + "Voltage / V": [3.716], + "Net Capacity / Ah": [0.048], + "Surface Temperature T1 / degC": [22.2591], + }, + ) + + neware_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2024, 3, 6, 21, 39, 38, 591000).timestamp()], + "Test Time / s": [562749.497], + "Step Index / 1": [12], + "Step Count / 1": [61], + "Current / A": [0.0], + "Voltage / V": [3.4513], + "Net Capacity / Ah": [0.022805], + }, + ) + + novonix_last_row = pl.DataFrame( + { + "Date": [datetime.datetime(2025, 7, 19, 18, 51, 8).timestamp()], + "Test Time / s": [12287.48004], + "Step Index / 1": [1], + "Step Count / 1": [0], + "Current / A": [0.49999387], + "Voltage / V": [4.12864581], + "Net Capacity / Ah": [1.70652976], + "Surface Temperature T1 / degC": [24.792], + }, + ) + + def helper_process_cycler_integration( + self, + tmp_path: Path, + source_file: str | Path, + expected_final_row_bdf_format: pl.DataFrame | None = None, + plugin: str | None = None, + ) -> pl.LazyFrame: + """Helper function to test process_cycler against real cycler data files. + + Similar to helper_read_and_process in test_basecycler.py, but adapted for + BDF column names and the process_cycler API. + + Args: + tmp_path: Temporary directory for output Parquet files. + source_file: Path to the real cycler data file. + expected_final_row_bdf_format: Expected final row in BDF format + (with column names like "Test Time / s", "Unix Time / s", etc.). + The expected row should already be in the BDF format with timestamps + converted to seconds since epoch. + plugin: Optional batterydf plugin name. + + Returns: + The collected LazyFrame result. + """ + lf = process_cycler(source_file, output_dir=tmp_path, plugin=plugin) + + assert isinstance(lf, pl.LazyFrame) + result = lf.collect() + + # Check data integrity if expected final row is provided + if expected_final_row_bdf_format is not None: + final_row = result.tail(1) + + # Select only columns that exist in both dataframes + cols_in_both = [ + c + for c in expected_final_row_bdf_format.columns + if c in final_row.columns + ] + if cols_in_both: + expected_subset = expected_final_row_bdf_format.select(cols_in_both) + final_subset = final_row.select(cols_in_both) + + pl_testing.assert_frame_equal( + expected_subset, + final_subset, + check_column_order=False, + check_dtypes=False, + atol=1e-5, + ) + + return result + + @pytest.mark.parametrize( + "source_file, expected_final_row", + [ + ("tests/sample_data/arbin/sample_data_arbin.csv", arbin_last_row), + ("tests/sample_data/basytec/sample_data_basytec.txt", basytec_last_row), + ( + "tests/sample_data/biologic/Sample_data_biologic_CA1.txt", + biologic_last_row, + ), + ( + "tests/sample_data/biologic/Sample_data_biologic_no_header.mpt", + biologic_last_row_no_header, + ), + ("tests/sample_data/maccor/sample_data_maccor.csv", maccor_last_row), + ("tests/sample_data/neware/sample_data_neware.xlsx", neware_last_row), + ("tests/sample_data/novonix/sample_data_novonix.csv", novonix_last_row), + ], + ) + def test_read_and_process_sample_data( + self, tmp_path: Path, source_file: str, expected_final_row: pl.DataFrame + ) -> None: + """Test the full process of reading and processing real sample data files. + + This test runs process_cycler on real sample data files from different + cyclers and checks that the output contains required columns and that the + final row matches expected values (within tolerance). + + Args: + tmp_path: Temporary directory for output Parquet files. + source_file: Path to the real cycler data file to test. + expected_final_row: Expected final row in BDF format for validation. + """ + self.helper_process_cycler_integration( + tmp_path, + source_file, + expected_final_row_bdf_format=expected_final_row, + ) + + def test_process_cycler_derived_step_count_integration( + self, tmp_path: Path + ) -> None: + """process_cycler derives Step Count from Step Index with real data. + + Replicates monotonicity and derivation logic from cycler tests. + """ + result = self.helper_process_cycler_integration( + tmp_path, + "tests/sample_data/neware/sample_data_neware.xlsx", + ) + # If Step Count is derived, it should be monotonically non-decreasing + if "Step Count / 1" in result.columns: + step_index_diffs = result["Step Index / 1"].diff().drop_nulls() + step_count_diffs = result["Step Count / 1"].diff().drop_nulls() + # When Step Index changes, Step Count should increment + for i in range(len(step_index_diffs)): + if step_index_diffs[i] > 0: + assert step_count_diffs[i] >= 0, ( + "Step Count should increment when Step Index changes" + ) + + def test_process_cycler_multiple_files_integration(self, tmp_path: Path) -> None: + """process_cycler handles multiple files independently without interference. + + Tests that processing multiple files in the same output directory works. + """ + files = [ + "tests/sample_data/arbin/sample_data_arbin.csv", + "tests/sample_data/maccor/sample_data_maccor.csv", + ] + results = [] + for file in files: + result = self.helper_process_cycler_integration( + tmp_path, + file, + ) + results.append(result) + + # Both should have been processed successfully + assert all(r.shape[0] > 0 for r in results), ( + "All processed files should have rows" + ) + # Each should have its own output file + for file in files: + stem = Path(file).stem + expected_output = tmp_path / f"{stem}.bdx.parquet" + assert expected_output.exists(), ( + f"Expected parquet file {expected_output} not created" + ) + + def test_process_cycler_skip_if_exists_integration(self, tmp_path: Path) -> None: + """With skip_if_exists=True, cached files are reused with real data. + + Replicates skip_if_exists behavior with actual sample data. + """ + source = "tests/sample_data/neware/sample_data_neware.xlsx" + + # First call - creates file + lf1 = process_cycler(source, output_dir=tmp_path, skip_if_exists=True) + result1 = lf1.collect() + + # Second call - should reuse + lf2 = process_cycler(source, output_dir=tmp_path, skip_if_exists=True) + result2 = lf2.collect() + + # Results should be identical + pl_testing.assert_frame_equal(result1, result2) + assert result1.shape == result2.shape From 081e1f2d1f634d821ad9d9b779d274aab58642d3 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:47:28 +0000 Subject: [PATCH 08/40] refactor(result): revert Result and daughter classes to standard python classes --- pyprobe/cell.py | 37 +++++++++++++----- pyprobe/filters.py | 86 ++++++++++++++++++++++++++++++++++++----- pyprobe/rawdata.py | 41 ++++++++++++-------- pyprobe/result.py | 89 +++++++++++++++++++++---------------------- tests/test_filter.py | 2 +- tests/test_rawdata.py | 10 ++--- tests/test_result.py | 23 +++++++---- 7 files changed, 195 insertions(+), 93 deletions(-) diff --git a/pyprobe/cell.py b/pyprobe/cell.py index 96d5d6c3..fe14b8ff 100644 --- a/pyprobe/cell.py +++ b/pyprobe/cell.py @@ -423,8 +423,11 @@ def archive(self, path: str) -> None: zip_file = False if not os.path.exists(path): os.makedirs(path) - metadata = self.dict() - metadata["PyProBE Version"] = __version__ + metadata: dict[str, Any] = { + "info": self.info, + "procedure": {}, + "PyProBE Version": __version__, + } for procedure_name, procedure in self.procedure.items(): if isinstance(procedure.lf, pl.LazyFrame): df = procedure.lf.collect() @@ -434,8 +437,14 @@ def archive(self, path: str) -> None: filename = procedure_name + ".parquet" filepath = os.path.join(path, filename) df.write_parquet(filepath) - # update the metadata with the filename - metadata["procedure"][procedure_name]["lf"] = filename + metadata["procedure"][procedure_name] = { + "lf": filename, + "info": procedure.info, + "column_definitions": procedure.column_definitions, + "step_descriptions": procedure.step_descriptions, + "readme_dict": procedure.readme_dict, + "cycle_info": procedure.cycle_info, + } with open(os.path.join(path, "metadata.json"), "w") as f: json.dump(metadata, f) @@ -810,12 +819,22 @@ def load_archive(path: str) -> Cell: f" issues.", ) metadata.pop("PyProBE Version") - for procedure in metadata["procedure"].values(): - procedure["lf"] = os.path.join( - archive_path, - procedure["lf"], + cell = Cell(info=metadata["info"]) + for procedure_name, procedure in metadata["procedure"].items(): + readme_dict = procedure.get("readme_dict", {}) + for experiment_data in readme_dict.values(): + if "Cycles" in experiment_data: + experiment_data["Cycles"] = [ + tuple(cycle) for cycle in experiment_data["Cycles"] + ] + cell.procedure[procedure_name] = Procedure( + lf=os.path.join(archive_path, procedure["lf"]), + info=procedure.get("info", cell.info), + readme_dict=readme_dict, + column_definitions=procedure.get("column_definitions"), + step_descriptions=procedure.get("step_descriptions"), + cycle_info=procedure.get("cycle_info"), ) - cell = Cell(**metadata) return cell diff --git a/pyprobe/filters.py b/pyprobe/filters.py index f3ccae9b..f9ab29fb 100644 --- a/pyprobe/filters.py +++ b/pyprobe/filters.py @@ -312,9 +312,28 @@ class Procedure(RawData): :code:`(start step (inclusive), end step (inclusive), cycle count)`. """ - def model_post_init(self, __context: Any) -> None: + def __init__( + self, + lf: pl.LazyFrame | str, + info: dict[str, Any | None], + readme_dict: dict[str, dict[str, list[str | int | tuple[int, int, int]]]], + column_definitions: dict[str, str] | None = None, + step_descriptions: dict[str, list[str | int | None]] | None = None, + cycle_info: list[tuple[int, int, int]] | None = None, + ) -> None: + """Initialize a procedure with README-derived experiment metadata.""" + super().__init__( + lf=lf, + info=info, + column_definitions=column_definitions, + step_descriptions=step_descriptions, + ) + self.readme_dict = readme_dict + self.cycle_info = cycle_info.copy() if cycle_info is not None else [] + self._initialize_procedure() + + def _initialize_procedure(self) -> None: """Create a procedure class.""" - super().model_post_init(self) self.zero_column( "Time [s]", "Procedure Time [s]", @@ -408,7 +427,7 @@ def remove_experiment(self, *experiment_names: str) -> None: ] for experiment_name in experiment_names: self.readme_dict.pop(experiment_name) - self.model_post_init(self) + self._initialize_procedure() self.lf = self.lf.filter(conditions) @property @@ -466,9 +485,26 @@ class Experiment(RawData): :code:`(start step (inclusive), end step (inclusive), cycle count)`. """ - def model_post_init(self, __context: Any) -> None: + def __init__( + self, + lf: pl.LazyFrame | str, + info: dict[str, Any | None], + column_definitions: dict[str, str] | None = None, + step_descriptions: dict[str, list[str | int | None]] | None = None, + cycle_info: list[tuple[int, int, int]] | None = None, + ) -> None: + """Initialize an experiment view with optional cycle metadata.""" + super().__init__( + lf=lf, + info=info, + column_definitions=column_definitions, + step_descriptions=step_descriptions, + ) + self.cycle_info = cycle_info.copy() if cycle_info is not None else [] + self._initialize_experiment() + + def _initialize_experiment(self) -> None: """Create an experiment class.""" - super().model_post_init(self) self.zero_column( "Time [s]", "Experiment Time [s]", @@ -501,9 +537,26 @@ class Cycle(RawData): :code:`(start step (inclusive), end step (inclusive), cycle count)`. """ - def model_post_init(self, __context: Any) -> None: + def __init__( + self, + lf: pl.LazyFrame | str, + info: dict[str, Any | None], + column_definitions: dict[str, str] | None = None, + step_descriptions: dict[str, list[str | int | None]] | None = None, + cycle_info: list[tuple[int, int, int]] | None = None, + ) -> None: + """Initialize a cycle view with optional nested cycle metadata.""" + super().__init__( + lf=lf, + info=info, + column_definitions=column_definitions, + step_descriptions=step_descriptions, + ) + self.cycle_info = cycle_info.copy() if cycle_info is not None else [] + self._initialize_cycle() + + def _initialize_cycle(self) -> None: """Create a cycle class.""" - super().model_post_init(self) self.zero_column( "Time [s]", "Cycle Time [s]", @@ -528,9 +581,24 @@ def model_post_init(self, __context: Any) -> None: class Step(RawData): """A class for a step in a battery experimental procedure.""" - def model_post_init(self, __context: Any) -> None: + def __init__( + self, + lf: pl.LazyFrame | str, + info: dict[str, Any | None], + column_definitions: dict[str, str] | None = None, + step_descriptions: dict[str, list[str | int | None]] | None = None, + ) -> None: + """Initialize a step view with inherited metadata and definitions.""" + super().__init__( + lf=lf, + info=info, + column_definitions=column_definitions, + step_descriptions=step_descriptions, + ) + self._initialize_step() + + def _initialize_step(self) -> None: """Create a step class.""" - super().model_post_init(self) self.zero_column( "Time [s]", "Step Time [s]", diff --git a/pyprobe/rawdata.py b/pyprobe/rawdata.py index fea2bd05..3aa74d5a 100644 --- a/pyprobe/rawdata.py +++ b/pyprobe/rawdata.py @@ -1,10 +1,9 @@ """A module for the RawData class.""" -from typing import Optional +from typing import Any, Optional import polars as pl from loguru import logger -from pydantic import Field, field_validator from pyprobe.result import Result from pyprobe.units import split_quantity_unit @@ -53,30 +52,42 @@ class RawData(Result): This defines the PyProBE format. """ - column_definitions: dict[str, str] = Field( - default_factory=lambda: default_column_definitions.copy(), - ) - step_descriptions: dict[str, list[str | int | None]] = {} + step_descriptions: dict[str, list[str | int | None]] """A dictionary containing the fields 'Step' and 'Description'. - 'Step' is a list of step numbers. - 'Description' is a list of corresponding descriptions in PyBaMM Experiment format. """ - @field_validator("lf", mode="after") - @classmethod - def check_required_columns( - cls, - dataframe: pl.LazyFrame, - ) -> "RawData": - """Check if the required columns are present in the input_data.""" - columns = dataframe.collect_schema().names() + def __init__( + self, + lf: pl.LazyFrame | str, + info: dict[str, Any | None], + column_definitions: dict[str, str] | None = None, + step_descriptions: dict[str, list[str | int | None]] | None = None, + ) -> None: + """Create a RawData object with required-column validation.""" + if column_definitions is None: + column_definitions = default_column_definitions.copy() + super().__init__(lf=lf, info=info, column_definitions=column_definitions) + + if step_descriptions is None: + self.step_descriptions = {} + else: + self.step_descriptions = { + key: value.copy() for key, value in step_descriptions.items() + } + + self._check_required_columns() + + def _check_required_columns(self) -> None: + """Check if the required columns are present in the data.""" + columns = self.lf.collect_schema().names() missing_columns = [col for col in required_columns if col not in columns] if missing_columns: error_msg = f"Missing required columns: {missing_columns}" logger.error(error_msg) raise ValueError(error_msg) - return dataframe @property def data(self) -> pl.DataFrame: diff --git a/pyprobe/result.py b/pyprobe/result.py index 5f490596..2f6dd852 100644 --- a/pyprobe/result.py +++ b/pyprobe/result.py @@ -15,7 +15,6 @@ from loguru import logger from matplotlib.axes import Axes from numpy.typing import NDArray -from pydantic import BaseModel, Field, field_validator, model_validator from scipy.io import savemat from tzlocal import get_localzone @@ -52,7 +51,7 @@ def _validate_timezone(timezone: str) -> str: raise ValueError(error_msg) from e -class Result(BaseModel): +class Result: """A class for holding any data in PyProBE. A Result object is the base type for every data object in PyProBE. This class @@ -69,48 +68,43 @@ class Result(BaseModel): - :attr:`columns`: A list of column names. """ - class Config: - """Pydantic configuration.""" - - arbitrary_types_allowed = True - - lf: pl.LazyFrame - info: dict[str, Any | None] - """Dictionary containing information about the cell.""" - column_definitions: dict[str, str] = Field(default_factory=dict) - """A dictionary containing the definitions of the columns in the data.""" + def __init__( + self, + lf: pl.LazyFrame | pl.DataFrame | str, + info: dict[str, Any | None], + column_definitions: dict[str, str] | None = None, + ) -> None: + """Create a Result with explicit constructor validation. - @model_validator(mode="before") - @classmethod - def _load_base_dataframe(cls, data: Any) -> Any: - """Load the base dataframe from a file if provided as a string.""" - if "base_dataframe" in data: - data["lf"] = data.pop("base_dataframe") - warning_msg = "'base_dataframe' is deprecated. Please use 'lf' instead." - logger.warning( - warning_msg, - ) - warnings.warn( - warning_msg, - DeprecationWarning, - ) - return data + Args: + lf: A LazyFrame, DataFrame, or a path to a parquet file. + info: Dictionary containing metadata about the result. + column_definitions: Optional definitions for data columns. - @field_validator("lf", mode="before") - @classmethod - def _validate_lf(cls, data: pl.LazyFrame | pl.DataFrame) -> pl.LazyFrame: - """Validate that the base dataframe is a LazyFrame.""" - if isinstance(data, pl.DataFrame): - data = data.lazy() - return data + Raises: + ValueError: If constructor inputs do not match expected types. + """ + if isinstance(lf, str): + lf = pl.scan_parquet(lf) + if not isinstance(lf, pl.LazyFrame): + if isinstance(lf, pl.DataFrame): + lf = lf.lazy() + elif isinstance(lf, str): + lf = pl.scan_parquet(lf) + else: + raise ValueError( + "lf must be a polars DataFrame, LazyFrame, or a parquet file path." + ) + if not isinstance(info, dict): + raise ValueError("info must be a dictionary.") + if column_definitions is None: + column_definitions = {} + elif not isinstance(column_definitions, dict): + raise ValueError("column_definitions must be a dictionary.") - @model_validator(mode="before") - @classmethod - def _load_lf(cls, data: Any) -> Any: - """Load the base dataframe from a file if provided as a string.""" - if "lf" in data and isinstance(data["lf"], str): - data["lf"] = pl.scan_parquet(data["lf"]) - return data + self.lf: pl.LazyFrame = lf + self.info = info + self.column_definitions = column_definitions.copy() def collect(self) -> pl.DataFrame: """Collect the lazy dataframe into a polars DataFrame. @@ -613,6 +607,8 @@ def add_data( # Rename date column to "Date" new_data = new_data.rename({date_column_name: "Date"}) + if isinstance(new_data, pl.DataFrame): + new_data = new_data.lazy() new_result = Result(lf=new_data, info={}) if align_on is not None: @@ -887,6 +883,8 @@ def build( ) data.append(step_data) data = pl.concat(data) + if isinstance(data, pl.DataFrame): + data = data.lazy() return cls(lf=data, info=info) def export_to_mat(self, filename: str) -> None: @@ -983,11 +981,10 @@ def from_polars_io( ) """ - return Result( - lf=polars_io_func(**kwargs), - info=info, - column_definitions=column_definitions, - ) + lf = polars_io_func(**kwargs) + if isinstance(lf, pl.DataFrame): + lf = lf.lazy() + return Result(lf=lf, info=info, column_definitions=column_definitions) @property @deprecated( diff --git a/tests/test_filter.py b/tests/test_filter.py index 6b79cba8..f994f89f 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -242,7 +242,7 @@ def generic_experiment(): cycle_info = [(0, 3, 2), (0, 1, 2)] return filters.Experiment( - lf=dataframe, + lf=dataframe.lazy(), info=info, step_descriptions=step_descriptions, cycle_info=cycle_info, diff --git a/tests/test_rawdata.py b/tests/test_rawdata.py index 5679ce96..f96583aa 100644 --- a/tests/test_rawdata.py +++ b/tests/test_rawdata.py @@ -30,7 +30,7 @@ def test_init(RawData_fixture, step_descriptions_fixture): # test with incorrect data data = pl.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) with pytest.raises(ValueError): - RawData(lf=data, info={"test": 1}) + RawData(lf=data.lazy(), info={"test": 1}) def test_data(RawData_fixture): @@ -170,7 +170,7 @@ def test_pybamm_experiment(): } raw_data = RawData( - lf=test_data, + lf=test_data.lazy(), info={}, step_descriptions=step_descriptions, ) @@ -201,7 +201,7 @@ def test_pybamm_experiment_missing_descriptions(): } raw_data = RawData( - lf=test_data, + lf=test_data.lazy(), info={}, step_descriptions=step_descriptions, ) @@ -232,7 +232,7 @@ def test_pybamm_experiment_multiple_conditions(): } raw_data = RawData( - lf=test_data, + lf=test_data.lazy(), info={}, step_descriptions=step_descriptions, ) @@ -263,7 +263,7 @@ def test_pybamm_experiment_with_loops(): "Description": ["Discharge at C/10", "Rest for 1 hour"], } - data = RawData(lf=base_df, info={}, step_descriptions=step_descriptions) + data = RawData(lf=base_df.lazy(), info={}, step_descriptions=step_descriptions) expected = [ "Discharge at C/10", # Step 1 diff --git a/tests/test_result.py b/tests/test_result.py index 00b6631f..14882632 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -38,6 +38,13 @@ def test_init(Result_fixture): assert isinstance(Result_fixture.info, dict) +def test_init_accepts_dataframe(): + """Test that DataFrame input is converted to LazyFrame at construction.""" + result = Result(lf=pl.DataFrame({"a": [1, 2, 3]}), info={}) + assert isinstance(result.lf, pl.LazyFrame) + pl_testing.assert_frame_equal(result.data, pl.DataFrame({"a": [1, 2, 3]})) + + def test_df(Result_fixture): """Test the df property.""" df = Result_fixture.df @@ -972,7 +979,7 @@ def reduced_result_fixture(): }, ) return Result( - lf=data, + lf=data.lazy(), info={"test": "info"}, column_definitions={ "Voltage": "Voltage definition", @@ -1047,7 +1054,7 @@ def test_join_left(reduced_result_fixture): }, ) other_result = Result( - lf=other_data, + lf=other_data.lazy(), info={"test": "info"}, column_definitions={"Voltage": "Voltage definition"}, ) @@ -1076,7 +1083,7 @@ def test_extend(reduced_result_fixture): }, ) other_result = Result( - lf=other_data, + lf=other_data.lazy(), info={"test": "info"}, column_definitions={"Voltage": "Voltage definition"}, ) @@ -1105,7 +1112,7 @@ def test_extend_with_new_columns(reduced_result_fixture): }, ) other_result = Result( - lf=other_data, + lf=other_data.lazy(), info={"test": "info"}, column_definitions={ "Voltage": "New voltage definition", @@ -1179,11 +1186,11 @@ def test_clean_copy(reduced_result_fixture): def test_combine_results(): """Test the combine results method.""" result1 = Result( - lf=pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}), + lf=pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}).lazy(), info={"test index": 1.0}, ) result2 = Result( - lf=pl.DataFrame({"a": [7, 8, 9], "b": [10, 11, 12]}), + lf=pl.DataFrame({"a": [7, 8, 9], "b": [10, 11, 12]}).lazy(), info={"test index": 2.0}, ) combined_result = combine_results([result1, result2]) @@ -1377,7 +1384,7 @@ def test_add_data_with_alignment(): } ) - result = Result(lf=base_df, info={}) + result = Result(lf=base_df.lazy(), info={}) # Add data with alignment result.add_data( @@ -1403,7 +1410,7 @@ def test_add_data_with_alignment_error(): start_time = datetime(2023, 1, 1, 10, 0, 0) base_df = pl.DataFrame({"Date": [start_time], "Signal": [1.0]}) new_df = pl.DataFrame({"DateNew": [start_time], "SignalNew": [1.0]}) - result = Result(lf=base_df, info={}) + result = Result(lf=base_df.lazy(), info={}) # Test with missing column in base data with pytest.raises(ValueError): From 225d71a1bb0d3c81504fdad974d3f962d3b48672 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:06:23 +0000 Subject: [PATCH 09/40] refactor!: rename Result.info to Result.metadata --- pyprobe/cell.py | 14 ++--- pyprobe/filters.py | 82 ++++++++++++++++--------- pyprobe/rawdata.py | 8 ++- pyprobe/result.py | 54 +++++++++++----- tests/test_analysis/test_time_series.py | 4 +- tests/test_filter.py | 2 +- tests/test_plot.py | 16 ++--- tests/test_rawdata.py | 14 ++--- tests/test_result.py | 44 ++++++------- 9 files changed, 142 insertions(+), 96 deletions(-) diff --git a/pyprobe/cell.py b/pyprobe/cell.py index fe14b8ff..0a248df0 100644 --- a/pyprobe/cell.py +++ b/pyprobe/cell.py @@ -11,7 +11,7 @@ import polars as pl from loguru import logger -from pydantic import BaseModel, Field, ValidationError, validate_call +from pydantic import BaseModel, Field, ValidationError from pyprobe._version import __version__ from pyprobe.cyclers import ( @@ -169,7 +169,7 @@ def import_data( self.procedure[procedure_name] = Procedure( readme_dict=readme_dict, lf=pl.scan_parquet(data_path), - info=self.info, + metadata=self.info, ) def import_from_cycler( @@ -400,7 +400,7 @@ def import_pybamm_solution( # create the procedure object self.procedure[procedure_name] = Procedure( lf=lf, - info=self.info, + metadata=self.info, readme_dict=experiment_dict, ) @@ -638,7 +638,6 @@ def process_generic_file( "PyProBE format, use the import_data method.", version="2.0.1", ) - @validate_call def add_procedure( self, procedure_name: str, @@ -675,7 +674,7 @@ def add_procedure( self.procedure[procedure_name] = Procedure( readme_dict=readme.experiment_dict, lf=lf, - info=self.info, + metadata=self.info, ) @deprecated( @@ -687,7 +686,6 @@ def add_procedure( "PyProBE format, use the import_data method.", version="2.0.1", ) - @validate_call def quick_add_procedure( self, procedure_name: str, @@ -718,7 +716,7 @@ def quick_add_procedure( lf = pl.scan_parquet(output_data_path) self.procedure[procedure_name] = Procedure( lf=lf, - info=self.info, + metadata=self.info, readme_dict={}, ) @@ -829,7 +827,7 @@ def load_archive(path: str) -> Cell: ] cell.procedure[procedure_name] = Procedure( lf=os.path.join(archive_path, procedure["lf"]), - info=procedure.get("info", cell.info), + metadata=procedure.get("metadata", cell.info), readme_dict=readme_dict, column_definitions=procedure.get("column_definitions"), step_descriptions=procedure.get("step_descriptions"), diff --git a/pyprobe/filters.py b/pyprobe/filters.py index f9ab29fb..14b812d9 100644 --- a/pyprobe/filters.py +++ b/pyprobe/filters.py @@ -93,7 +93,7 @@ def _step( ) return Step( lf=lf, - info=filtered_object.info, + metadata=filtered_object.metadata, column_definitions=filtered_object.column_definitions, step_descriptions=filtered_object.step_descriptions, ) @@ -161,7 +161,7 @@ def _cycle(filtered_object: "ExperimentOrCycleType", *cycle_numbers: int) -> "Cy return Cycle( lf=lf_filtered, - info=filtered_object.info, + metadata=filtered_object.metadata, column_definitions=filtered_object.column_definitions, step_descriptions=filtered_object.step_descriptions, cycle_info=next_cycle_info, @@ -302,29 +302,29 @@ def _constant_voltage( class Procedure(RawData): """A class for a procedure in a battery experiment.""" - readme_dict: dict[str, dict[str, list[str | int | tuple[int, int, int]]]] - """A dictionary representing the data contained in the README yaml file.""" - - cycle_info: list[tuple[int, int, int]] = [] - """A list of tuples representing the cycle information from the README yaml file. - - The tuple format is - :code:`(start step (inclusive), end step (inclusive), cycle count)`. - """ - def __init__( self, - lf: pl.LazyFrame | str, - info: dict[str, Any | None], + lf: pl.LazyFrame | pl.DataFrame | str, + metadata: dict[str, Any | None], readme_dict: dict[str, dict[str, list[str | int | tuple[int, int, int]]]], column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, cycle_info: list[tuple[int, int, int]] | None = None, ) -> None: - """Initialize a procedure with README-derived experiment metadata.""" + """Initialize a procedure with README-derived experiment metadata. + + Args: + lf: A LazyFrame, DataFrame, or a path to a parquet file. + metadata: Dictionary containing metadata about the procedure and + data source. + readme_dict: Experiment definitions from README. + column_definitions: Column descriptions. + step_descriptions: Step-by-step descriptions. + cycle_info: Cycle boundary information. + """ super().__init__( lf=lf, - info=info, + metadata=metadata, column_definitions=column_definitions, step_descriptions=step_descriptions, ) @@ -401,7 +401,7 @@ def experiment(self, *experiment_names: str) -> "Experiment": return Experiment( lf=lf_filtered, - info=self.info, + metadata=self.metadata, column_definitions=self.column_definitions, step_descriptions=self.step_descriptions, cycle_info=cycles_list, @@ -487,16 +487,25 @@ class Experiment(RawData): def __init__( self, - lf: pl.LazyFrame | str, - info: dict[str, Any | None], + lf: pl.LazyFrame | pl.DataFrame | str, + metadata: dict[str, Any | None], column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, cycle_info: list[tuple[int, int, int]] | None = None, ) -> None: - """Initialize an experiment view with optional cycle metadata.""" + """Initialize an experiment view with optional cycle metadata. + + Args: + lf: A LazyFrame, DataFrame, or a path to a parquet file. + metadata: Dictionary containing metadata about the experiment and + data source. + column_definitions: Column descriptions. + step_descriptions: Step-by-step descriptions. + cycle_info: Cycle boundary information. + """ super().__init__( lf=lf, - info=info, + metadata=metadata, column_definitions=column_definitions, step_descriptions=step_descriptions, ) @@ -539,16 +548,24 @@ class Cycle(RawData): def __init__( self, - lf: pl.LazyFrame | str, - info: dict[str, Any | None], + lf: pl.LazyFrame | pl.DataFrame | str, + metadata: dict[str, Any | None], column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, cycle_info: list[tuple[int, int, int]] | None = None, ) -> None: - """Initialize a cycle view with optional nested cycle metadata.""" + """Initialize a cycle view with optional nested cycle metadata. + + Args: + lf: A LazyFrame, DataFrame, or a path to a parquet file. + metadata: Dictionary containing metadata about the cycle and data source. + column_definitions: Column descriptions. + step_descriptions: Step-by-step descriptions. + cycle_info: Cycle boundary information. + """ super().__init__( lf=lf, - info=info, + metadata=metadata, column_definitions=column_definitions, step_descriptions=step_descriptions, ) @@ -583,15 +600,22 @@ class Step(RawData): def __init__( self, - lf: pl.LazyFrame | str, - info: dict[str, Any | None], + lf: pl.LazyFrame | pl.DataFrame | str, + metadata: dict[str, Any | None], column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, ) -> None: - """Initialize a step view with inherited metadata and definitions.""" + """Initialize a step view. + + Args: + lf: A LazyFrame, DataFrame, or a path to a parquet file. + metadata: Dictionary containing metadata about the step and data source. + column_definitions: Column descriptions. + step_descriptions: Step-by-step descriptions. + """ super().__init__( lf=lf, - info=info, + metadata=metadata, column_definitions=column_definitions, step_descriptions=step_descriptions, ) diff --git a/pyprobe/rawdata.py b/pyprobe/rawdata.py index 3aa74d5a..f101a817 100644 --- a/pyprobe/rawdata.py +++ b/pyprobe/rawdata.py @@ -61,15 +61,17 @@ class RawData(Result): def __init__( self, - lf: pl.LazyFrame | str, - info: dict[str, Any | None], + lf: pl.LazyFrame | pl.DataFrame | str, + metadata: dict[str, Any | None], column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, ) -> None: """Create a RawData object with required-column validation.""" if column_definitions is None: column_definitions = default_column_definitions.copy() - super().__init__(lf=lf, info=info, column_definitions=column_definitions) + super().__init__( + lf=lf, metadata=metadata, column_definitions=column_definitions + ) if step_descriptions is None: self.step_descriptions = {} diff --git a/pyprobe/result.py b/pyprobe/result.py index 2f6dd852..0e5bb917 100644 --- a/pyprobe/result.py +++ b/pyprobe/result.py @@ -62,7 +62,8 @@ class Result: - :meth:`get`: Get a column from the data as a NumPy array. Key attributes for describing the data: - - :attr:`info`: A dictionary containing information about the cell. + - :attr:`metadata`: A dictionary containing metadata about the cell and + data source. - :attr:`column_definitions`: A dictionary of column definitions. - :meth:`print_definitions`: Print the column definitions. - :attr:`columns`: A list of column names. @@ -71,19 +72,29 @@ class Result: def __init__( self, lf: pl.LazyFrame | pl.DataFrame | str, - info: dict[str, Any | None], + metadata: dict[str, Any | None] | None = None, column_definitions: dict[str, str] | None = None, + info: dict[str, Any | None] | None = None, ) -> None: """Create a Result with explicit constructor validation. Args: lf: A LazyFrame, DataFrame, or a path to a parquet file. - info: Dictionary containing metadata about the result. + metadata: Dictionary containing metadata about the result. column_definitions: Optional definitions for data columns. + info: Deprecated. Use metadata instead. Raises: ValueError: If constructor inputs do not match expected types. """ + # Handle backward compatibility: accept both 'info' and 'metadata' + if info is not None and metadata is not None: + raise ValueError("Cannot specify both 'info' and 'metadata' parameters.") + if info is not None: + metadata = info + if metadata is None: + metadata = {} + if isinstance(lf, str): lf = pl.scan_parquet(lf) if not isinstance(lf, pl.LazyFrame): @@ -95,15 +106,15 @@ def __init__( raise ValueError( "lf must be a polars DataFrame, LazyFrame, or a parquet file path." ) - if not isinstance(info, dict): - raise ValueError("info must be a dictionary.") + if not isinstance(metadata, dict): + raise ValueError("metadata must be a dictionary.") if column_definitions is None: column_definitions = {} elif not isinstance(column_definitions, dict): raise ValueError("column_definitions must be a dictionary.") self.lf: pl.LazyFrame = lf - self.info = info + self.metadata = metadata self.column_definitions = column_definitions.copy() def collect(self) -> pl.DataFrame: @@ -129,6 +140,15 @@ def columns(self) -> list[str]: """ return self.lf.collect_schema().names() + @property + def info(self) -> dict[str, Any | None]: + """Backward compatibility alias for metadata. + + Returns: + dict: The metadata dictionary. + """ + return self.metadata + @staticmethod def _get_quantities(columns: list[str]) -> list[str]: """The quantities of the data, with unit information removed. @@ -294,7 +314,7 @@ def __getitem__(self, *column_names: str) -> "Result": self.check_columns(list(column_names)) return Result( lf=self.lf.select(*column_names), - info=self.info, + metadata=self.metadata, ) def get( @@ -386,7 +406,7 @@ def clean_copy( column_definitions = {} return Result( lf=dataframe, - info=self.info, + metadata=self.metadata, column_definitions=column_definitions, ) @@ -609,7 +629,7 @@ def add_data( new_data = new_data.rename({date_column_name: "Date"}) if isinstance(new_data, pl.DataFrame): new_data = new_data.lazy() - new_result = Result(lf=new_data, info={}) + new_result = Result(lf=new_data, metadata={}) if align_on is not None: from pyprobe.analysis.time_series import align_data @@ -885,7 +905,7 @@ def build( data = pl.concat(data) if isinstance(data, pl.DataFrame): data = data.lazy() - return cls(lf=data, info=info) + return cls(lf=data, metadata=info) def export_to_mat(self, filename: str) -> None: """Export the data to a .mat file. @@ -907,7 +927,7 @@ def export_to_mat(self, filename: str) -> None: # Replace any non-alphanumeric character with an underscore in the info # dictionary keys renamed_info = { - re.sub(r"\W", "_", key): value for key, value in self.info.items() + re.sub(r"\W", "_", key): value for key, value in self.metadata.items() } variable_dict = { @@ -952,7 +972,7 @@ def from_polars_io( result = Result.from_polars_io( pl.scan_csv, - info={"test": "test"}, + metadata={"test": "test"}, column_definitions={}, source="data.csv", ) @@ -963,7 +983,7 @@ def from_polars_io( result = Result.from_polars_io( pl.from_pandas, - info={"test": "test"}, + metadata={"test": "test"}, column_definitions={}, data=pd.DataFrame({"a": [1, 2, 3]}), ) @@ -974,7 +994,7 @@ def from_polars_io( result = Result.from_polars_io( pl.from_numpy, - info={"test": "test"}, + metadata={"test": "test"}, column_definitions={}, data=np.array([[1, 2, 3], [4, 5, 6]]), schema=["a", "b"] @@ -984,7 +1004,7 @@ def from_polars_io( lf = polars_io_func(**kwargs) if isinstance(lf, pl.DataFrame): lf = lf.lazy() - return Result(lf=lf, info=info, column_definitions=column_definitions) + return Result(lf=lf, metadata=info, column_definitions=column_definitions) @property @deprecated( @@ -1057,7 +1077,9 @@ def combine_results( Result: A new result object with the combined data. """ for result in results: - instructions = [pl.lit(result.info[key]).alias(key) for key in result.info] + instructions = [ + pl.lit(result.metadata[key]).alias(key) for key in result.metadata + ] result.lf = result.lf.with_columns(instructions) results[0].extend(results[1:], concat_method=concat_method) return results[0] diff --git a/tests/test_analysis/test_time_series.py b/tests/test_analysis/test_time_series.py index da03963a..a8c086a3 100644 --- a/tests/test_analysis/test_time_series.py +++ b/tests/test_analysis/test_time_series.py @@ -50,8 +50,8 @@ def test_align_data(): } ).lazy() - result1 = Result(lf=df1, info={}) - result2 = Result(lf=df2, info={}) + result1 = Result(lf=df1, metadata={}) + result2 = Result(lf=df2, metadata={}) r1, r2 = align_data(result1, result2, "Signal", "Signal") diff --git a/tests/test_filter.py b/tests/test_filter.py index f994f89f..fc103f80 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -243,7 +243,7 @@ def generic_experiment(): cycle_info = [(0, 3, 2), (0, 1, 2)] return filters.Experiment( lf=dataframe.lazy(), - info=info, + metadata=info, step_descriptions=step_descriptions, cycle_info=cycle_info, ) diff --git a/tests/test_plot.py b/tests/test_plot.py index fed9dc5a..12a5622a 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -12,7 +12,7 @@ def test_retrieve_relevant_columns_args(): """Test _retrieve_relevant_columns with positional arguments.""" # Set up test data data = pl.DataFrame({"col1": [1, 2, 3], "col2": [4, 5, 6], "col3": [7, 8, 9]}) - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) # Test with args only args = ["col1", "col2"] @@ -27,7 +27,7 @@ def test_retrieve_relevant_columns_args(): def test_retrieve_relevant_columns_kwargs(): """Test _retrieve_relevant_columns with keyword arguments.""" data = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9]}) - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) # Test with kwargs only args = [] @@ -42,7 +42,7 @@ def test_retrieve_relevant_columns_kwargs(): def test_retrieve_relevant_columns_mixed(): """Test _retrieve_relevant_columns with both args and kwargs.""" data = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]}) - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) args = ["a"] kwargs = {"col": "b"} @@ -56,7 +56,7 @@ def test_retrieve_relevant_columns_mixed(): def test_retrieve_relevant_columns_lazy(): """Test _retrieve_relevant_columns with LazyFrame.""" data = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}).lazy() - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) args = ["x"] kwargs = {"y_col": "y"} @@ -70,7 +70,7 @@ def test_retrieve_relevant_columns_lazy(): def test_retrieve_relevant_columns_intersection(): """Test _retrieve_relevant_columns column intersection behavior.""" data = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) # Request columns including ones that don't exist args = ["a", "nonexistent1"] @@ -85,7 +85,7 @@ def test_retrieve_relevant_columns_intersection(): def test_retrieve_relevant_columns_no_columns(): """Test _retrieve_relevant_columns with no columns.""" data = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) - result = Result(lf=data, info={}) + result = Result(lf=data, metadata={}) # Request columns that don't exist args = ["nonexistent1"] @@ -100,7 +100,7 @@ def test_retrieve_relevant_columns_with_unit_conversion(): data = pl.DataFrame({"I [A]": [1, 2, 3], "V [V]": [4, 5, 6]}) result = Result( lf=data, - info={}, + metadata={}, column_definitions={"I": "Current", "V": "Voltage"}, ) @@ -127,7 +127,7 @@ def test_seaborn_wrapper_data_conversion(mocker): sns = pytest.importorskip("seaborn") result = Result( lf=pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}), - info={}, + metadata={}, column_definitions={"x": "int", "y": "int"}, ) data = result.data.to_pandas() diff --git a/tests/test_rawdata.py b/tests/test_rawdata.py index f96583aa..b412b377 100644 --- a/tests/test_rawdata.py +++ b/tests/test_rawdata.py @@ -15,7 +15,7 @@ def RawData_fixture(lazyframe_fixture, info_fixture, step_descriptions_fixture): """Return a Result instance.""" return RawData( lf=lazyframe_fixture, - info=info_fixture, + metadata=info_fixture, step_descriptions=step_descriptions_fixture, ) @@ -30,7 +30,7 @@ def test_init(RawData_fixture, step_descriptions_fixture): # test with incorrect data data = pl.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) with pytest.raises(ValueError): - RawData(lf=data.lazy(), info={"test": 1}) + RawData(lf=data.lazy(), metadata={"test": 1}) def test_data(RawData_fixture): @@ -133,7 +133,7 @@ def test_definitions(lazyframe_fixture, info_fixture, step_descriptions_fixture) """Test that the definitions have been correctly set.""" rawdata = RawData( lf=lazyframe_fixture, - info=info_fixture, + metadata=info_fixture, step_descriptions=step_descriptions_fixture, ) definition_keys = list(rawdata.column_definitions.keys()) @@ -171,7 +171,7 @@ def test_pybamm_experiment(): raw_data = RawData( lf=test_data.lazy(), - info={}, + metadata={}, step_descriptions=step_descriptions, ) @@ -202,7 +202,7 @@ def test_pybamm_experiment_missing_descriptions(): raw_data = RawData( lf=test_data.lazy(), - info={}, + metadata={}, step_descriptions=step_descriptions, ) @@ -233,7 +233,7 @@ def test_pybamm_experiment_multiple_conditions(): raw_data = RawData( lf=test_data.lazy(), - info={}, + metadata={}, step_descriptions=step_descriptions, ) @@ -263,7 +263,7 @@ def test_pybamm_experiment_with_loops(): "Description": ["Discharge at C/10", "Rest for 1 hour"], } - data = RawData(lf=base_df.lazy(), info={}, step_descriptions=step_descriptions) + data = RawData(lf=base_df.lazy(), metadata={}, step_descriptions=step_descriptions) expected = [ "Discharge at C/10", # Step 1 diff --git a/tests/test_result.py b/tests/test_result.py index 14882632..e2562d0e 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -24,7 +24,7 @@ def Result_fixture(lazyframe_fixture, info_fixture): """Return a Result instance.""" return Result( lf=lazyframe_fixture, - info=info_fixture, + metadata=info_fixture, column_definitions={ "Current": "Current definition", }, @@ -40,7 +40,7 @@ def test_init(Result_fixture): def test_init_accepts_dataframe(): """Test that DataFrame input is converted to LazyFrame at construction.""" - result = Result(lf=pl.DataFrame({"a": [1, 2, 3]}), info={}) + result = Result(lf=pl.DataFrame({"a": [1, 2, 3]}), metadata={}) assert isinstance(result.lf, pl.LazyFrame) pl_testing.assert_frame_equal(result.data, pl.DataFrame({"a": [1, 2, 3]})) @@ -229,7 +229,7 @@ def test_add_data(): "Data 2": [4, 8, 12, 16, 20, 24], }, ) - result_object = Result(lf=existing_data, info={}) + result_object = Result(lf=existing_data, metadata={}) result_object.add_data( new_data, date_column_name="DateTime", @@ -285,7 +285,7 @@ def test_add_new_data_columns_deprecated(): "Data 1": [2, 4, 6, 8, 10, 12], }, ) - result_object = Result(lf=existing_data, info={}) + result_object = Result(lf=existing_data, metadata={}) with patch("pyprobe.utils.logger.warning") as mock_warning: result_object.add_new_data_columns(new_data, date_column_name="DateTime") @@ -306,7 +306,7 @@ def test_add_data_timezone_handling(): {"DateUTC": [datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC)], "Ext": [10]} ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data(new_data, date_column_name="DateUTC") schema = result.lf.collect_schema() @@ -322,7 +322,7 @@ def test_add_data_timezone_handling(): {"DateNew": [datetime(2023, 1, 1, 10, 0, 0)], "Ext": [10]} ) - result2 = Result(lf=existing_data_naive, info={}) + result2 = Result(lf=existing_data_naive, metadata={}) result2.add_data( new_data_naive, date_column_name="DateNew", @@ -361,7 +361,7 @@ def test_add_data_invalid_existing_timezone(): {"Date": [datetime(2023, 1, 1, 10, 0, 0)], "Value": [1]} ) new_data = pl.LazyFrame({"DateNew": [datetime(2023, 1, 1, 10, 0, 0)], "Ext": [10]}) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) with pytest.raises(ValueError, match="Invalid timezone"): result.add_data( @@ -377,7 +377,7 @@ def test_add_data_invalid_new_timezone(): {"Date": [datetime(2023, 1, 1, 10, 0, 0)], "Value": [1]} ) new_data = pl.LazyFrame({"DateNew": [datetime(2023, 1, 1, 10, 0, 0)], "Ext": [10]}) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) with pytest.raises(ValueError, match="Invalid timezone"): result.add_data( @@ -409,7 +409,7 @@ def test_add_data_uses_local_timezone_when_not_specified(): {"DateUTC": [datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC)], "Ext": [10]} ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data(new_data, date_column_name="DateUTC") schema = result.lf.collect_schema() @@ -426,7 +426,7 @@ def test_add_data_with_format(): new_data = pl.LazyFrame({"DateStr": ["2023/01/01 10:00:00"], "Ext": [10]}) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateStr", datetime_format="%Y/%m/%d %H:%M:%S" ) @@ -468,7 +468,7 @@ def test_add_data_join_strategy_keep_existing(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -520,7 +520,7 @@ def test_add_data_join_strategy_keep_new(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -569,7 +569,7 @@ def test_add_data_join_strategy_keep_both(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -632,7 +632,7 @@ def test_add_data_fill_strategy_forward_fill(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -681,7 +681,7 @@ def test_add_data_fill_strategy_backward_fill(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -733,7 +733,7 @@ def test_add_data_fill_strategy_none(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -778,7 +778,7 @@ def test_add_data_combined_strategies(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -880,7 +880,7 @@ def test_add_data_all_join_fill_strategy_combinations( }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) result.add_data( new_data, date_column_name="DateTime", @@ -921,7 +921,7 @@ def test_add_data_invalid_join_strategy_raises(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) with pytest.raises( ValueError, match=( @@ -952,7 +952,7 @@ def test_add_data_invalid_fill_strategy_raises(): }, ) - result = Result(lf=existing_data, info={}) + result = Result(lf=existing_data, metadata={}) with pytest.raises( ValueError, match=( @@ -1384,7 +1384,7 @@ def test_add_data_with_alignment(): } ) - result = Result(lf=base_df.lazy(), info={}) + result = Result(lf=base_df.lazy(), metadata={}) # Add data with alignment result.add_data( @@ -1410,7 +1410,7 @@ def test_add_data_with_alignment_error(): start_time = datetime(2023, 1, 1, 10, 0, 0) base_df = pl.DataFrame({"Date": [start_time], "Signal": [1.0]}) new_df = pl.DataFrame({"DateNew": [start_time], "SignalNew": [1.0]}) - result = Result(lf=base_df.lazy(), info={}) + result = Result(lf=base_df.lazy(), metadata={}) # Test with missing column in base data with pytest.raises(ValueError): From 63e103421df1fed7b0d9f89acbdc3587cd251fe2 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:09:08 +0000 Subject: [PATCH 10/40] refactor: update metadata handling for files in io --- pyprobe/io.py | 497 +++++++++++++++++++++++++++++++++++++---------- tests/test_io.py | 308 ++++++++++++++++++++++++++++- 2 files changed, 700 insertions(+), 105 deletions(-) diff --git a/pyprobe/io.py b/pyprobe/io.py index b312d274..fd45d09f 100644 --- a/pyprobe/io.py +++ b/pyprobe/io.py @@ -13,13 +13,17 @@ import json from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal import bdf import polars as pl +import pyarrow as pa import pyarrow.parquet as pq from loguru import logger +if TYPE_CHECKING: + from pyprobe.filters import Procedure + from pyprobe.column import ( BDFColumn, Column, @@ -32,6 +36,9 @@ voltage_volt, ) +_PARQUET_METADATA_KEY: bytes = b"bdx_metadata" +"""Key used to store user metadata in Parquet footer.""" + _REQUIRED_BDF_COLUMNS: list[BDFColumn] = [ test_time_second, current_ampere, @@ -47,10 +54,252 @@ """BDF columns included when available; warnings are emitted on failure.""" +class MetadataManager: + """Encapsulates all metadata operations for Parquet files. + + Handles reading from and writing to both Parquet footers and JSON sidecars, + with preference logic for choosing between sources and updating existing files. + + Example:: + + manager = MetadataManager(output_path, metadata_format="parquet") + existing = manager.read(metadata_format="parquet") + manager.write({"cell_id": "C001"}) + manager.update({"new_key": "new_value"}) + """ + + def __init__(self, path: Path) -> None: + """Initialize MetadataManager for a Parquet file. + + Args: + path: Path to the Parquet file. + """ + self.path = Path(path) + self.json_path = self.path.with_suffix(".json") + + def read_parquet(self) -> dict[str, Any]: + """Read metadata from the Parquet file footer. + + Returns: + Dictionary of metadata, or empty dict if missing or unreadable. + """ + try: + pf = pq.ParquetFile(self.path) + raw: dict[bytes, bytes] = pf.schema_arrow.metadata or {} + if _PARQUET_METADATA_KEY not in raw: + return {} + return json.loads(raw[_PARQUET_METADATA_KEY].decode()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.warning( + "Failed to decode metadata from '{}': {}. Returning empty metadata.", + self.path, + exc, + ) + return {} + + def read_json(self) -> dict[str, Any]: + """Read metadata from the JSON sidecar file. + + Returns: + Dictionary of metadata, or empty dict if missing or not a dict. + """ + if not self.json_path.exists(): + return {} + try: + raw: Any = json.loads(self.json_path.read_text()) + if isinstance(raw, dict): + return raw + except json.JSONDecodeError as exc: + logger.warning( + "Failed to decode JSON metadata from '{}': {}. " + "Returning empty metadata.", + self.json_path, + exc, + ) + return {} + + def read( + self, metadata_format: Literal["parquet", "json"] = "parquet" + ) -> dict[str, Any]: + """Read metadata for a specific storage format. + + Args: + metadata_format: Which format to read from. ``"parquet"`` reads from + the Parquet footer; ``"json"`` reads from the sidecar. + + Returns: + Dictionary of metadata. + """ + if metadata_format == "parquet": + return self.read_parquet() + return self.read_json() + + def read_both( + self, prefer: Literal["parquet", "json"] = "parquet" + ) -> dict[str, Any]: + """Read metadata from both sources with preference logic. + + When both sources have metadata, *prefer* controls which is returned. + When only one source has metadata, that source is returned regardless + of *prefer*. When neither has metadata, an empty dict is returned. + + Args: + prefer: Which source to prefer when both exist. + + Returns: + Dictionary of metadata from the preferred source, or the only + source that has metadata, or an empty dict. + """ + parquet_meta = self.read_parquet() + json_meta = self.read_json() + + has_parquet = bool(parquet_meta) + has_json = bool(json_meta) + + if has_parquet and has_json: + return parquet_meta if prefer == "parquet" else json_meta + if has_parquet: + return parquet_meta + if has_json: + return json_meta + return {} + + def write( + self, + metadata: dict[str, Any], + metadata_format: Literal["parquet", "json"] = "parquet", + ) -> None: + """Write metadata to a Parquet file in the specified format. + + Reads the existing Parquet file, embeds or sidecars the metadata, and + writes back. If *metadata_format* is ``"parquet"``, metadata is stored + in the Parquet footer. If ``"json"``, a sidecar file is written instead. + + Args: + metadata: Dictionary of metadata to write. + metadata_format: Where to store metadata. + + Raises: + ValueError: If the Parquet file is corrupted. + """ + table = pq.read_table(self.path) + + if metadata_format == "parquet": + existing: dict[bytes, bytes] = table.schema.metadata or {} + combined_meta = { + **existing, + _PARQUET_METADATA_KEY: json.dumps(metadata).encode(), + } + table = table.replace_schema_metadata(combined_meta) + pq.write_table(table, self.path) + else: + self.json_path.write_text(json.dumps(metadata, indent=2)) + + def update( + self, + metadata: dict[str, Any], + metadata_format: Literal["parquet", "json"] = "parquet", + ) -> None: + """Update metadata on an existing cached file without reprocessing. + + Merges *metadata* with existing metadata (new values override old ones), + then writes back in the specified format. + + Args: + metadata: Dictionary of metadata to merge in. + metadata_format: Which format to update. + + Raises: + ValueError: If the Parquet file or JSON sidecar is corrupted. + """ + existing_meta = self.read(metadata_format=metadata_format) + merged_metadata = {**existing_meta, **metadata} + self.write(merged_metadata, metadata_format=metadata_format) + + @classmethod + def create( + cls, + table: pa.Table, + path: Path, + metadata: dict[str, Any] | None = None, + metadata_format: Literal["parquet", "json"] = "parquet", + ) -> None: + """Write a new Parquet file with optional metadata. + + Embeds or sidecars metadata as specified, then writes the Arrow table + to the Parquet file. This method is for creating new files; use + :meth:`write` or :meth:`update` for existing files. + + Args: + table: Arrow table to persist. + path: Destination file path. + metadata: Optional metadata dictionary to attach. + metadata_format: Where to store metadata ("parquet" or "json"). + """ + if metadata: + if metadata_format == "parquet": + existing: dict[bytes, bytes] = table.schema.metadata or {} + combined_meta = { + **existing, + _PARQUET_METADATA_KEY: json.dumps(metadata).encode(), + } + table = table.replace_schema_metadata(combined_meta) + else: + json_path = path.with_suffix(".json") + json_path.write_text(json.dumps(metadata, indent=2)) + pq.write_table(table, path) + + +def _handle_existing_cached_file( + output_path: Path, + metadata: dict[str, Any] | None, + metadata_format: Literal["parquet", "json"], +) -> pl.LazyFrame | None: + """Handle skip_if_exists logic for cached cycler output files. + + Checks if a cached file exists and determines whether to use it or + reprocess. If the file exists and no metadata update is needed, returns + a lazy scan of the cached file. If metadata needs updating, updates it + in-place without reprocessing raw data. + + Args: + output_path: Path to the cached Parquet file. + metadata: Optional metadata to apply to the cached file. If provided + and differs from existing metadata, the cached file is updated + (without reprocessing raw data). + metadata_format: Format for storing/reading metadata. + + Returns: + A LazyFrame scanning the cached file if it exists and no reprocessing + is needed, or None if the file does not exist or reprocessing is required. + """ + if not output_path.exists(): + return None + + if metadata: + manager = MetadataManager(output_path) + existing_metadata = manager.read(metadata_format=metadata_format) + needs_update = any( + existing_metadata.get(str(k)) != v for k, v in metadata.items() + ) + if needs_update: + logger.info( + "Updating metadata on cached file '{}' without reprocessing raw data.", + output_path, + ) + manager.update( + metadata, + metadata_format=metadata_format, + ) + + logger.info("Skipping processing; using cached file '{}'.", output_path) + return pl.scan_parquet(output_path) + + def process_cycler( source: str | Path, output_dir: str | Path | None = None, - metadata: dict[str, str | int | float | bool] | None = None, + metadata: dict[str, Any] | None = None, *, plugin: str | None = None, write_parquet: bool = True, @@ -71,17 +320,18 @@ def process_cycler( output_dir: Directory for the output Parquet file. Defaults to the parent directory of *source*. Ignored when *write_parquet* is ``False``. - metadata: Optional key-value pairs to attach to the output. Values may - be strings, ints, floats, or bools. Ignored when *write_parquet* - is ``False``. - plugin: Optional ``batterydf`` plugin name passed to ``bdf.read()``. - When ``None`` the plugin is auto-detected. + metadata: Optional JSON-serializable key-value pairs to attach to the + output. Ignored when *write_parquet* is ``False``. + plugin: Optional BatteryDF plugin name to use for reading the file. + If ``None`` (default), BatteryDF auto-detects the format. write_parquet: When ``True`` (default), write the normalised data to a Parquet file and return a lazy scan. When ``False``, return an in-memory LazyFrame without writing. skip_if_exists: When ``True`` (default) and the output file already exists, skip processing and return the cached file immediately. - Only applies when *write_parquet* is ``True``. + If *metadata* is provided, requested keys are still written to the + cached output (without re-reading raw cycler data) when values are + missing or stale. Only applies when *write_parquet* is ``True``. metadata_format: Controls how *metadata* is stored. ``"parquet"`` (default) embeds metadata in the Parquet footer. ``"json"`` writes a ``.json`` sidecar file instead and does **not** embed metadata in @@ -137,9 +387,12 @@ def process_cycler( if output_dir is None: output_dir = source_path.parent output_path = Path(output_dir) / (source_path.stem + ".bdx.parquet") - if skip_if_exists and output_path.exists(): - logger.info("Skipping processing; using cached file '{}'.", output_path) - return pl.scan_parquet(output_path) + if skip_if_exists: + cached = _handle_existing_cached_file( + output_path, metadata, metadata_format + ) + if cached is not None: + return cached logger.info("Reading cycler file '{}'.", source) pandas_df = bdf.read(source, plugin=plugin) @@ -169,9 +422,20 @@ def process_cycler( normalised: pl.DataFrame = df.select(expressions) if extra_columns: + # Validate all output_name formats upfront before reading raw data. + # Valid: "Channel", "Pressure / kPa", "Flow Rate / mL/min" + # Invalid: "InvalidNoUnit", "Pressure kPa", "/ kPa", "", "Quantity //" + strict_pattern = r"^(.+?)\s*/\s*([^/]+(?:/[^/]+)*)$" + for output_name in extra_columns: + Column.from_string(output_name, pattern=strict_pattern) + + # Dual bdf.read() calls are necessary: the initial read (above) normalizes + # column names to BDF standard, while this read with normalize=False + # accesses original source column names for extra_columns mapping. + # This should be improved in future + # versions to provide a single-pass read supporting both operations. raw_df = pl.from_pandas(bdf.read(source, plugin=plugin, normalize=False)) for output_name, source_name in extra_columns.items(): - Column.from_string(output_name) # validate BDF format if source_name not in raw_df.columns: raise ValueError( f"Extra column source '{source_name}' not found in data. " @@ -197,130 +461,165 @@ def process_cycler( def _write_parquet( df: pl.DataFrame, path: Path, - metadata: dict[str, str | int | float | bool] | None = None, + metadata: dict[str, Any] | None = None, *, metadata_format: Literal["json", "parquet"] = "parquet", ) -> None: """Write a Polars DataFrame to Parquet, embedding optional metadata. - Converts *df* to an Arrow table and writes via - :func:`pyarrow.parquet.write_table`. When *metadata* is provided, it is - stored according to *metadata_format*: embedded in the Parquet footer - (``"parquet"``) or written to a ``.json`` sidecar file (``"json"``). + Converts *df* to an Arrow table and delegates to MetadataManager + for consistent metadata handling across parquet footer and JSON sidecar + formats. Args: df: The DataFrame to persist. path: Destination file path. Parent directories must already exist. - metadata: Optional key-value pairs to attach. Values may be strings, - ints, floats, or bools. When *metadata_format* is ``"parquet"``, - all values are converted to strings before embedding. - metadata_format: ``"parquet"`` (default) embeds metadata in the Parquet - footer. ``"json"`` writes metadata to a ``.json`` sidecar and does - not embed anything in the footer. + metadata: Optional JSON-serializable key-value pairs to attach. + metadata_format: ``"parquet"`` (default) embeds metadata in the + Parquet footer. ``"json"`` writes a ``.json`` sidecar instead. """ table = df.to_arrow() - if metadata: - if metadata_format == "parquet": - existing: dict[bytes, bytes] = table.schema.metadata or {} - encoded: dict[bytes, bytes] = { - k.encode(): str(v).encode() for k, v in metadata.items() - } - table = table.replace_schema_metadata({**existing, **encoded}) - else: - sidecar_path = path.with_suffix(".json") - sidecar_path.write_text(json.dumps(metadata, indent=2)) - pq.write_table(table, path) + MetadataManager.create(table, path, metadata, metadata_format) -def read_parquet_metadata(path: str | Path) -> dict[str, str]: +def read_parquet_metadata(path: str | Path) -> dict[str, Any]: """Read key-value metadata from a Parquet file's footer. + Reads metadata from the "bdx_metadata" key in the Parquet footer, which + stores a JSON-encoded object of all user metadata. + Args: path: Path to the Parquet file. Returns: - A dictionary of metadata key-value pairs decoded from UTF-8. - Returns an empty dict if the file has no metadata. + A dictionary of metadata key-value pairs. Returns an empty dict if + the file has no metadata or the "bdx_metadata" key is missing. - Examples: - >>> import tempfile, pathlib, polars as pl - >>> from pyprobe.io import _write_parquet, read_parquet_metadata - >>> df = pl.DataFrame({"x": [1, 2, 3]}) - >>> with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: - ... tmp = pathlib.Path(f.name) - >>> _write_parquet(df, tmp, {"cell_id": "C001", "cycler": "neware"}) - >>> meta = read_parquet_metadata(tmp) - >>> meta["cell_id"] - 'C001' - >>> meta["cycler"] - 'neware' - >>> tmp.unlink() + Example: + Retrieve metadata from a cached battery parquet file:: + + from pyprobe.io import read_parquet_metadata + + meta = read_parquet_metadata("data.bdx.parquet") + print(meta["cell_id"]) # 'C001' + print(meta["cycler"]) # 'neware' """ - pf = pq.ParquetFile(path) - raw: dict[bytes, bytes] = pf.schema_arrow.metadata or {} - return {k.decode(): v.decode() for k, v in raw.items()} + manager = MetadataManager(Path(path)) + return manager.read_parquet() def read_metadata( path: str | Path, prefer: Literal["parquet", "json"] = "parquet", -) -> dict[str, str]: - """Read metadata from a Parquet file's footer or a ``.json`` sidecar. +) -> dict[str, Any]: + r"""Read metadata from a Parquet file's footer or a ``.json`` sidecar. - Checks both the Parquet footer and a ``.json`` sidecar (derived from - *path* by replacing the ``.parquet`` suffix with ``.json``). When both - sources contain metadata, *prefer* controls which is returned. When only - one source has metadata, that source is returned regardless of *prefer*. - When neither has metadata, an empty dict is returned. + Checks both the Parquet footer (stored under \"bdx_metadata\") and a ``.json`` + sidecar (derived from *path* by replacing the ``.parquet`` suffix with + ``.json``). When both sources contain metadata, *prefer* controls which is + returned. When only one source has metadata, that source is returned + regardless of *prefer*. When neither has metadata, an empty dict is returned. Args: path: Path to the Parquet file. - prefer: Which source to return when both exist. ``"parquet"`` (default) - returns the Parquet footer metadata; ``"json"`` returns the sidecar + prefer: Which source to return when both exist. ``\"parquet\"`` (default) + returns the Parquet footer metadata; ``\"json\"`` returns the sidecar metadata. Returns: - A dictionary of metadata key-value pairs. Values from the Parquet - footer are always strings (decoded UTF-8). Values from the JSON sidecar - are returned as strings via JSON decoding. + A dictionary of metadata key-value pairs with their original types + preserved (via JSON round-tripping). Raises: - ValueError: If *prefer* is not ``"parquet"`` or ``"json"``. + ValueError: If *prefer* is not ``\"parquet\"`` or ``\"json\"``. - Examples: - >>> import tempfile, pathlib, polars as pl - >>> from pyprobe.io import _write_parquet, read_metadata - >>> df = pl.DataFrame({"x": [1, 2, 3]}) - >>> with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: - ... tmp = pathlib.Path(f.name) - >>> _write_parquet(df, tmp, {"cell_id": "C001"}, metadata_format="parquet") - >>> read_metadata(tmp) - {'cell_id': 'C001'} - >>> tmp.unlink() + Example: + Load metadata from a processed battery file, choosing between Parquet + footer and JSON sidecar:: + + from pyprobe.io import read_metadata + + # Prefer Parquet footer metadata (default) + meta = read_metadata("data.bdx.parquet") + print(meta["cell_id"]) # 'C001' + + # Or prefer JSON sidecar if both exist + meta = read_metadata("data.bdx.parquet", prefer="json") """ if prefer not in ("parquet", "json"): raise ValueError(f"prefer must be 'parquet' or 'json', got '{prefer}'.") - parquet_path = Path(path) - json_path = parquet_path.with_suffix(".json") - - parquet_meta: dict[str, str] = read_parquet_metadata(parquet_path) - # Strip Arrow/Polars internal keys so only user metadata remains. - parquet_meta = {k: v for k, v in parquet_meta.items() if not k.startswith("pandas")} - - json_meta: dict[str, str] = {} - if json_path.exists(): - raw: Any = json.loads(json_path.read_text()) - if isinstance(raw, dict): - json_meta = {str(k): str(v) for k, v in raw.items()} - - has_parquet = bool(parquet_meta) - has_json = bool(json_meta) - - if has_parquet and has_json: - return parquet_meta if prefer == "parquet" else json_meta - if has_parquet: - return parquet_meta - if has_json: - return json_meta - return {} + manager = MetadataManager(Path(path)) + return manager.read_both(prefer=prefer) + + +def create_procedure_from_parquet( + parquet_path: str | Path, + readme_path: str | Path | None = None, + metadata: dict[str, Any | None] | None = None, + metadata_prefer: Literal["parquet", "json"] = "parquet", +) -> "Procedure": + """Create a Procedure from a processed cycler parquet file with metadata. + + Loads metadata from the parquet footer or sidecar JSON, and optionally reads + experiment definitions from a README.yaml file. + + Args: + parquet_path: Path to the output parquet file (e.g., from process_cycler). + readme_path: Optional path to README.yaml for experiment definitions. + When None, an empty experiment dict is used. + metadata: Optional metadata dictionary to include. Merged with metadata + from parquet source. Defaults to empty dict. + metadata_prefer: Whether to prefer parquet footer or JSON sidecar metadata + when both exist. Defaults to "parquet". + + Returns: + A Procedure object with loaded data, metadata, and experiment definitions. + + Raises: + FileNotFoundError: If parquet file does not exist. + ValueError: If README exists but fails to parse. + + Example: + Load a processed battery parquet file and optionally attach experiment + definitions from a README:: + + from pyprobe.io import create_procedure_from_parquet + + # Load parquet with metadata from footer + procedure = create_procedure_from_parquet("data.bdx.parquet") + + # Include experiment definitions from README.yaml + procedure = create_procedure_from_parquet( + "data.bdx.parquet", + readme_path="experiments.yaml", + metadata={"cell_id": "Cell1"}, + ) + """ + from pyprobe.filters import Procedure + from pyprobe.readme_processor import process_readme + + parquet_path = Path(parquet_path) + if not parquet_path.exists(): + raise FileNotFoundError(f"Parquet file not found: {parquet_path}") + + lf = pl.scan_parquet(parquet_path) + parquet_metadata = read_metadata(parquet_path, prefer=metadata_prefer) + + # Merge provided metadata with parquet metadata (provided takes precedence) + merged_metadata = {**parquet_metadata, **(metadata or {})} + + readme_dict: dict[str, dict[str, Any]] = {} + if readme_path is not None: + readme_path = Path(readme_path) + if readme_path.exists(): + readme_obj = process_readme(str(readme_path)) + readme_dict = readme_obj.experiment_dict + else: + logger.warning("README path provided but not found: {}", readme_path) + + return Procedure( + lf=lf, + metadata=merged_metadata, + readme_dict=readme_dict, + ) diff --git a/tests/test_io.py b/tests/test_io.py index 34146fbf..ad55d1cf 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -17,6 +17,7 @@ import pandas as pd import polars as pl import polars.testing as pl_testing +import pyarrow.parquet as pq import pytest from pyprobe.io import ( @@ -220,6 +221,123 @@ def test_process_cycler_skip_exists_default_true( result = lf.collect() assert result.shape[0] == 3 + def test_skip_exists_true_updates_stale_parquet_metadata( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """Cached parquet metadata is updated without re-reading raw data.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler( + "fake.csv", + output_dir=tmp_path, + metadata={"cell_id": "A"}, + metadata_format="parquet", + ) + + with patch("bdf.read", side_effect=Exception("Should not be called")): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + skip_if_exists=True, + metadata={"cell_id": "B", "batch": "1"}, + metadata_format="parquet", + ) + + result = lf.collect() + assert result.shape[0] == 3 + output_file = tmp_path / "fake.bdx.parquet" + meta = read_parquet_metadata(output_file) + assert meta["cell_id"] == "B" + assert meta["batch"] == "1" + + def test_skip_exists_true_does_not_update_when_parquet_metadata_matches( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """No metadata write occurs when cached parquet metadata already matches.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler( + "fake.csv", + output_dir=tmp_path, + metadata={"cell_id": "A"}, + metadata_format="parquet", + ) + + with ( + patch("bdf.read", side_effect=Exception("Should not be called")), + patch("pyprobe.io.MetadataManager.update") as mock_update, + ): + process_cycler( + "fake.csv", + output_dir=tmp_path, + skip_if_exists=True, + metadata={"cell_id": "A"}, + metadata_format="parquet", + ) + + mock_update.assert_not_called() + + def test_skip_exists_true_updates_stale_json_metadata( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """Cached JSON sidecar metadata is updated without re-reading raw data.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler( + "fake.csv", + output_dir=tmp_path, + metadata={"cell_id": "A"}, + metadata_format="json", + ) + + with patch("bdf.read", side_effect=Exception("Should not be called")): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + skip_if_exists=True, + metadata={"cell_id": "B", "batch": "1"}, + metadata_format="json", + ) + + result = lf.collect() + assert result.shape[0] == 3 + sidecar = tmp_path / "fake.bdx.json" + loaded = json.loads(sidecar.read_text()) + assert loaded["cell_id"] == "B" + assert loaded["batch"] == "1" + + def test_skip_exists_false_overwrites_data_and_metadata( + self, tmp_path: Path, bdf_df: pd.DataFrame + ) -> None: + """With skip_if_exists=False, data and metadata are fully overwritten.""" + with patch("bdf.read", return_value=bdf_df): + process_cycler( + "fake.csv", + output_dir=tmp_path, + metadata={"cell_id": "A"}, + metadata_format="parquet", + ) + + new_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0, 2.0, 3.0], + "Current / A": [1.0, -1.0, 0.5, 0.3], + "Voltage / V": [3.7, 3.6, 3.8, 3.7], + } + ) + with patch("bdf.read", return_value=new_df) as mock_read: + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + skip_if_exists=False, + metadata={"cell_id": "B"}, + metadata_format="parquet", + ) + + mock_read.assert_called_once() + result = lf.collect() + assert result.shape[0] == 4 + output_file = tmp_path / "fake.bdx.parquet" + meta = read_parquet_metadata(output_file) + assert meta["cell_id"] == "B" + class TestProcessCyclerMissingColumns: """Tests for error handling when required or optional columns are missing.""" @@ -339,20 +457,26 @@ def test_metadata_roundtrip_json_sidecar_no_metadata(self, tmp_path: Path) -> No sidecar = tmp_path / "test.json" assert not sidecar.exists() - def test_metadata_roundtrip_non_string_values_as_strings( + def test_metadata_roundtrip_non_string_values_preserve_types( self, tmp_path: Path ) -> None: - """Non-string values come back as strings from parquet footer.""" + """JSON-serializable values preserve types through parquet metadata.""" output_file = tmp_path / "test.parquet" df = pl.DataFrame({"x": [1, 2, 3]}) - metadata = {"count": "42", "rate": "3.14", "flag": "true"} + metadata = { + "count": 42, + "rate": 3.14, + "flag": True, + "nested": {"a": 1, "b": [1, 2]}, + } _write_parquet(df, output_file, metadata) # type: ignore read_meta = read_parquet_metadata(output_file) - assert read_meta["count"] == "42" - assert read_meta["rate"] == "3.14" - assert read_meta["flag"] == "true" + assert read_meta["count"] == 42 + assert read_meta["rate"] == 3.14 + assert read_meta["flag"] is True + assert read_meta["nested"] == {"a": 1, "b": [1, 2]} class TestReadMetadata: @@ -953,3 +1077,175 @@ def test_process_cycler_skip_if_exists_integration(self, tmp_path: Path) -> None # Results should be identical pl_testing.assert_frame_equal(result1, result2) assert result1.shape == result2.shape + + +class TestCorruptedParquetMetadataRecovery: + """Tests for handling corrupted Parquet metadata gracefully.""" + + def test_metadata_manager_read_parquet_json_decode_error( + self, tmp_path: Path, caplog + ) -> None: + """MetadataManager.read_parquet() handles JSONDecodeError and logs warning.""" + from pyprobe.io import MetadataManager + + # Create a valid Parquet file with corrupted metadata + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + table = df.to_arrow() + + # Inject corrupted (non-JSON) metadata + corrupted_metadata: dict[bytes, bytes] = { + b"bdx_metadata": b"this is not valid json }{[", + } + table = table.replace_schema_metadata(corrupted_metadata) + pq.write_table(table, output_file) + + # Try to read the corrupted metadata + manager = MetadataManager(output_file) + result = manager.read_parquet() + + # Should return empty dict and log a warning + assert result == {} + assert "Failed to decode metadata" in caplog.text or len(result) == 0 + + def test_metadata_manager_read_parquet_unicode_decode_error( + self, tmp_path: Path + ) -> None: + """MetadataManager.read_parquet() handles UnicodeDecodeError gracefully.""" + from pyprobe.io import MetadataManager + + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + table = df.to_arrow() + + # Inject invalid UTF-8 sequence as metadata + corrupted_metadata: dict[bytes, bytes] = { + b"bdx_metadata": b"\x80\x81\x82\x83", + } + table = table.replace_schema_metadata(corrupted_metadata) + pq.write_table(table, output_file) + + # Try to read the corrupted metadata + manager = MetadataManager(output_file) + result = manager.read_parquet() + + # Should return empty dict without raising + assert isinstance(result, dict) + assert len(result) == 0 + + def test_metadata_manager_read_both_with_corrupted_parquet( + self, tmp_path: Path + ) -> None: + """With corrupted parquet metadata, read_both falls back to JSON sidecar.""" + from pyprobe.io import MetadataManager + + output_file = tmp_path / "test.parquet" + df = pl.DataFrame({"x": [1, 2, 3]}) + table = df.to_arrow() + + # Parquet metadata is corrupted + corrupted_metadata: dict[bytes, bytes] = { + b"bdx_metadata": b"invalid json", + } + table = table.replace_schema_metadata(corrupted_metadata) + pq.write_table(table, output_file) + + # But JSON sidecar has valid metadata + sidecar = tmp_path / "test.json" + json_metadata = {"cell_id": "C001", "source": "json"} + sidecar.write_text(json.dumps(json_metadata)) + + # read_both should return the JSON metadata + manager = MetadataManager(output_file) + result = manager.read_both(prefer="json") + + assert result == json_metadata + + +class TestExtraColumnsValidation: + """Tests for validation of BDF column format in extra_columns.""" + + @pytest.mark.parametrize( + "invalid_format", + [ + "InvalidNoUnit", # Missing " / unit" format + "Pressure kPa", # Wrong separator (space instead of " / ") + "/ kPa", # Missing quantity + "", # Empty string + "Quantity //", # Missing unit after separator + ], + ) + def test_extra_columns_invalid_bdf_format_raises_value_error( + self, tmp_path: Path, bdf_df: pd.DataFrame, invalid_format: str + ) -> None: + """process_cycler raises for invalid BDF column format in extra_columns.""" + raw_df = pd.DataFrame( + { + "Time(s)": [0.0, 1.0], + "I(A)": [1.0, -1.0], + "V(V)": [3.7, 3.6], + "Pressure(kPa)": [101.3, 101.4], + } + ) + with ( + patch("bdf.read", side_effect=[bdf_df, raw_df]), + pytest.raises(ValueError, match="does not match pattern"), + ): + process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={invalid_format: "Pressure(kPa)"}, + ) + + def test_extra_columns_valid_bdf_format_with_slash(self, tmp_path: Path) -> None: + """process_cycler accepts valid BDF format with slash separator.""" + bdf_df = pd.DataFrame( + { + "Test Time / s": [0.0, 1.0], + "Current / A": [1.0, -1.0], + "Voltage / V": [3.7, 3.6], + } + ) + raw_df = pd.DataFrame( + { + "Time(s)": [0.0, 1.0], + "Pressure(kPa)": [101.3, 101.4], + } + ) + with patch("bdf.read", side_effect=[bdf_df, raw_df]): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={"Pressure / kPa": "Pressure(kPa)"}, + ) + + result = lf.collect() + assert "Pressure / kPa" in result.columns + + @pytest.mark.parametrize( + "valid_format,source_col", + [ + ("Temperature / degC", "Temp(C)"), + ("Pressure / bar", "Press(bar)"), + ("Humidity / %", "Humid(%)"), + ("Flow Rate / mL/min", "Flow(mL/min)"), + ("Quantity / 1", "Count"), + ], + ) + def test_extra_columns_various_valid_bdf_formats( + self, tmp_path: Path, bdf_df: pd.DataFrame, valid_format: str, source_col: str + ) -> None: + """process_cycler accepts various valid BDF column formats.""" + raw_df = bdf_df.copy() + raw_df[source_col] = [1.0, 2.0, 3.0] + + with patch("bdf.read", side_effect=[bdf_df, raw_df]): + lf = process_cycler( + "fake.csv", + output_dir=tmp_path, + extra_columns={valid_format: source_col}, + ) + + result = lf.collect() + # Column should exist in result (exact name depends on BDF parsing) + assert len(result.columns) > 3 # More than just the 3 required columns From 2afabf5232a68a7dc26aaee103d7e3a85c740c42 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:51:52 +0000 Subject: [PATCH 11/40] refactor(column): migrate to bdf enum and encapsulate resolution logic move resolution logic into column classes and replace global instances with a bdf enum. rename column_name to name and add factory functions for column creation. --- pyprobe/column.py | 849 +++++++++++++++++++++++-------------------- tests/test_column.py | 829 +++++++++++++++++++++++++++++------------- 2 files changed, 1029 insertions(+), 649 deletions(-) diff --git a/pyprobe/column.py b/pyprobe/column.py index d6d26686..7167037b 100644 --- a/pyprobe/column.py +++ b/pyprobe/column.py @@ -4,31 +4,34 @@ column names and Polars expressions: - :class:`Column` — pure descriptor that parses a ``"Quantity / unit"`` - string and computes unit-conversion parameters. + string and computes unit-conversion parameters. Owns resolution logic + via :meth:`~Column.can_resolve` and :meth:`~Column.resolve`. - :class:`BDFColumn` — subclass that adds recipe-based derivation metadata - and a linked-data IRI. -- :class:`ColumnSet` — per-DataFrame resolution context that selects and - optionally converts columns, falling back to recipe derivation for - :class:`BDFColumn` descriptors. + and a linked-data IRI. Extends resolution to cover recipe derivation via + :meth:`~BDFColumn.can_resolve` and :meth:`~BDFColumn.resolve`. +- :class:`ColumnSet` — thin per-DataFrame wrapper that delegates resolution + to :class:`Column` / :class:`BDFColumn` methods. -Module-level instances cover 27 BDF-standard quantities (e.g. -:data:`current_ampere`, :data:`voltage_volt`) and are collected in -:data:`ALL_COLUMNS`. :data:`DEFAULT_COLUMNS` is the core subset that -PyProBE retains after ingestion. +The :class:`BDF` enum provides all 27 BDF-standard quantities as members +(e.g. :attr:`BDF.CURRENT_AMPERE`, :attr:`BDF.VOLTAGE_VOLT`). +:data:`DEFAULT_COLUMNS` is the core subset that PyProBE retains after +ingestion. Typical usage:: - from pyprobe.column import current_ampere, DEFAULT_COLUMNS, ColumnSet + from pyprobe.column import BDF, DEFAULT_COLUMNS, ColumnSet cs = ColumnSet(DEFAULT_COLUMNS) # Select Current in milliamps from a DataFrame that has "Current / A". - expr = cs.col(current_ampere, unit="mA") + expr = cs.resolve("Current / mA") """ import re from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass +from enum import Enum +from functools import cache +from typing import Any, cast import pint import polars as pl @@ -69,6 +72,24 @@ """ +class UnitsError(ValueError): + """Raised when unit conversion is invalid or impossible. + + This exception is raised when: + - Attempting to convert a dimensionless column (unit == "1"). + - Units are dimensionally incompatible. + - A unit string cannot be parsed. + """ + + +class ColumnResolutionError(ValueError): + """Raised when a Column cannot be resolved from available columns. + + This exception is raised when :meth:`Column.can_resolve` fails to find a + compatible column in the provided set. + """ + + def _resolve_unit(raw_unit: str, quantity: str) -> str: """Return the pint-parseable unit string, resolving temperature ambiguity. @@ -122,8 +143,8 @@ def _apply_conversion( Examples: >>> import polars as pl >>> e = _apply_conversion(pl.col("x"), 1.0, 0.0, "x / A") - >>> str(e) # doctest: +ELLIPSIS - '...' + >>> type(e).__name__ + 'Expr' """ if factor == 1.0 and offset == 0.0: return expr.alias(alias) @@ -179,16 +200,16 @@ class _TrackingDict(dict[Any, Any]): def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.accessed: set[BDFColumn] = set() + self.accessed: set[BDF] = set() - def __getitem__(self, key: "BDFColumn") -> pl.Expr: + def __getitem__(self, key: "BDF") -> pl.Expr: self.accessed.add(key) return super().__getitem__(key) @dataclass class Recipe: - """A computation rule for deriving a :class:`BDFColumn` from other columns. + """A computation rule for deriving a :class:`BDF` from other columns. A recipe declares which BDF columns are needed (``required``) and provides a callable that maps :class:`BDFColumn` instances to resolved @@ -198,26 +219,26 @@ class Recipe: exactly the columns listed in ``required`` — no more, no fewer. Attributes: - required: :class:`BDFColumn` instances that must be resolvable in the - source DataFrame (e.g. ``[charging_capacity_ah, - discharging_capacity_ah]``). - compute: A callable that receives a ``{BDFColumn: pl.Expr}`` + required: :class:`BDF` enum members that must be resolvable in the + source DataFrame (e.g. ``[BDF.CHARGING_CAPACITY_AH, + BDF.DISCHARGING_CAPACITY_AH]``). + compute: A callable that receives a ``{BDF: pl.Expr}`` mapping and returns a :class:`polars.Expr`. Examples: >>> import polars as pl >>> recipe = Recipe( - ... required=[charging_capacity_ah, discharging_capacity_ah], + ... required=[BDF.CHARGING_CAPACITY_AH, BDF.DISCHARGING_CAPACITY_AH], ... compute=lambda cols: ( - ... cols[charging_capacity_ah] - cols[discharging_capacity_ah] + ... cols[BDF.CHARGING_CAPACITY_AH] - cols[BDF.DISCHARGING_CAPACITY_AH] ... ), ... ) >>> len(recipe.required) 2 """ - required: list["BDFColumn"] - compute: Callable[[dict["BDFColumn", pl.Expr]], pl.Expr] + required: list["BDF"] + compute: Callable[[dict["BDF", pl.Expr]], pl.Expr] def __post_init__(self) -> None: """Validate that compute accesses exactly the required columns. @@ -243,12 +264,15 @@ def __post_init__(self) -> None: ) -@dataclass(eq=False) +@dataclass(frozen=True) class Column: """A BDF column descriptor: quantity name and unit string. - Constructed directly or parsed from a string via :meth:`from_string`. + Constructed directly with quantity and unit strings. For parsing column + names from strings, use :func:`column_factory_from_string`. Supports unit conversion through :meth:`conversion_parameters`. + Resolution against a list of available columns is provided by + :meth:`can_resolve` and :meth:`resolve`. Unit ``"1"`` denotes a dimensionless column. All columns have a unit; use ``"1"`` rather than leaving it absent. @@ -264,73 +288,35 @@ class Column: Examples: >>> col = Column("Current", "A") - >>> col.column_name + >>> col.name 'Current / A' - >>> col = Column.from_string("Current / A") - >>> col.quantity + >>> col_parsed = column_factory_from_string("Current / A") + >>> col_parsed.quantity 'Current' - >>> col.column_name + >>> col_parsed.name 'Current / A' - >>> Column("Step").column_name + >>> Column("Step").name 'Step / 1' """ quantity: str unit: str = "1" - @classmethod - def from_string(cls, name: str, pattern: str = BDF_PATTERN) -> "Column": - """Parse a ``"Quantity / unit"`` string into a :class:`Column`. - - Bare names (no separator) are accepted and yield ``unit="1"``. - Named columns with an explicit unit round-trip back to their original - string via :attr:`column_name`. - - Args: - name: The column name string to parse (e.g. ``"Current / A"`` or - ``"Step Count / 1"``). - pattern: A regex pattern with two capture groups (quantity, unit). - Defaults to :data:`BDF_PATTERN`. - - Returns: - A new :class:`Column` instance. - - Raises: - ValueError: If ``name`` does not match ``pattern``. - - Examples: - >>> col = Column.from_string("Current / A") - >>> col.quantity - 'Current' - >>> col.column_name - 'Current / A' - >>> col2 = Column.from_string("Step Count / 1") - >>> col2.column_name - 'Step Count / 1' - >>> col3 = Column.from_string("Step") - >>> col3.unit - '1' - >>> col3.column_name - 'Step / 1' - """ - quantity, raw_unit = _split_quantity_unit(name, pattern) - return cls(quantity, raw_unit or "1") - @property - def column_name(self) -> str: + def name(self) -> str: """BDF standard column name string (``"Quantity / unit"``). Returns: The BDF column name string. Examples: - >>> Column("Current", "A").column_name + >>> Column("Current", "A").name 'Current / A' - >>> Column("Net Capacity", "Ah").column_name + >>> Column("Net Capacity", "Ah").name 'Net Capacity / Ah' - >>> Column("Step Count", "1").column_name + >>> Column("Step Count", "1").name 'Step Count / 1' - >>> Column("Step").column_name + >>> Column("Step").name 'Step / 1' """ return f"{self.quantity} / {self.unit}" @@ -339,9 +325,9 @@ def __str__(self) -> str: """Return the BDF column name string. Returns: - The same value as :attr:`column_name`. + The same value as :attr:`name`. """ - return self.column_name + return self.name def conversion_parameters(self, target_unit: str) -> tuple[float, float]: """Compute the factor and offset to convert this column's unit. @@ -362,16 +348,16 @@ def conversion_parameters(self, target_unit: str) -> tuple[float, float]: A ``(factor, offset)`` tuple, both as :class:`float`. Raises: - ValueError: If this column is dimensionless (``unit == "1"``). - ValueError: If the units are dimensionally incompatible. + UnitsError: If this column is dimensionless (``unit == "1"``). + UnitsError: If the units are dimensionally incompatible. Examples: - >>> col = Column.from_string("Current / A") + >>> col = Column("Current", "A") >>> col.conversion_parameters("mA") (1000.0, 0.0) """ if self.unit == "1": - raise ValueError( + raise UnitsError( f"Column '{self.quantity}' is dimensionless; cannot convert." ) source_unit_str = _resolve_unit(self.unit, self.quantity) @@ -383,21 +369,101 @@ def conversion_parameters(self, target_unit: str) -> tuple[float, float]: f"Unit '{self.unit}' for quantity '{self.quantity}' " f"could not be parsed: {exc}" ) - raise ValueError(msg) from exc + raise UnitsError(msg) from exc try: target_pint = _ureg.parse_units(target_unit_str) zero = float(_ureg.Quantity(0, source_pint).to(target_pint).magnitude) one = float(_ureg.Quantity(1, source_pint).to(target_pint).magnitude) except pint.errors.DimensionalityError as exc: - raise ValueError( + raise UnitsError( f"Cannot convert '{self.unit}' to '{target_unit}': {exc}" ) from exc factor = one - zero offset = zero return factor, offset + def can_resolve(self, available: "set[Column]") -> bool: + """Check whether this column can be resolved from available columns. + + Args: + available: Set of available Column and/or BDFColumn objects. + + Returns: + True if the column can be resolved, False otherwise. + """ + try: + self.resolve(available) + return True + except ColumnResolutionError: + return False + + def _apply_unit_conversion(self, source_expr: pl.Expr, source_unit: str) -> pl.Expr: + """Convert resolved expression from source_unit to this column's unit.""" + if source_unit == self.unit: + return source_expr.alias(self.name) + source_col = Column(self.quantity, source_unit) + factor, offset = source_col.conversion_parameters(self.unit) + return _apply_conversion(source_expr, factor, offset, self.name) + + def resolve(self, available: "set[Column]") -> pl.Expr: + """Resolve this column to a Polars expression from available columns. + + Resolution strategy: + 1. Exact match: return the column if it's in available. + 2. BDF recipe lookup: if this is not a BDFColumn, try to resolve via + a BDF member's recipes (which may derive the quantity from others). + 3. Quantity scan: search available columns for matching quantity + (case-insensitive), then apply unit conversion if needed. + + Args: + available: Set of available :class:`Column` and/or + :class:`BDFColumn` objects. -@dataclass(eq=False) + Returns: + A Polars expression that evaluates to this column's values, + optionally with unit conversion applied. + + Raises: + ColumnResolutionError: If no matching column or recipe is found, + or if units are incompatible. + + Examples: + >>> col = Column("Current", "mA") + >>> expr = col.resolve({Column("Current", "A")}) + >>> type(expr).__name__ + 'Expr' + """ + if self in available: + return pl.col(self.name) + q = self.quantity.lower() + col: Column | BDF | None = None + base_expr = None + if not isinstance(self, BDFColumn): + try: + col = BDF.lookup_by_quantity(self.quantity) + base_expr = col.resolve(available) + except (KeyError, ColumnResolutionError): + pass + for c in available: + if c.quantity.lower() == q: + col = c + base_expr = pl.col(c.name) + + if col is not None and base_expr is not None: + try: + return self._apply_unit_conversion(base_expr, col.unit) + except UnitsError as exc: + raise ColumnResolutionError( + f"Found column '{c.name}' for quantity '{self.quantity}', " + f"but unit '{c.unit}' is incompatible with target unit " + f"'{self.unit}': {exc}" + ) from exc + + msg = f"Cannot resolve '{self.quantity}' from available columns" + raise ColumnResolutionError(msg) + + +@dataclass(frozen=True) class BDFColumn(Column): """A BDF-standard column descriptor with recipe-based derivation metadata. @@ -406,13 +472,8 @@ class BDFColumn(Column): - Optional :class:`Recipe` list for deriving the quantity from other columns when no direct match exists. - :attr:`iri` computed from quantity and unit via pint long-form names. - - Resolution of BDFColumn descriptors against actual DataFrames is handled - by :class:`ColumnSet`, which implements the two-step chain: - - 1. **Exact match** — column name already present in available columns. - 2. **Recipe fallback** — derive from dependency columns via a - :class:`Recipe`. + - :meth:`can_resolve` and :meth:`resolve` that implement the two-step + resolution chain: exact data-column match first, recipe fallback second. Args: quantity: The BDF quantity name (e.g. ``"Current"``). @@ -425,19 +486,17 @@ class BDFColumn(Column): Examples: >>> col = BDFColumn("Current", "A") - >>> col.column_name + >>> col.name 'Current / A' >>> col.iri 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#current_ampere' >>> col2 = BDFColumn("Step Count") - >>> col2.column_name + >>> col2.name 'Step Count / 1' >>> col2.iri 'https://w3id.org/battery-data-alliance/ontology/battery-data-format#step_count' """ - recipes: list[Recipe] = field(default_factory=list) - @property def iri(self) -> str: """Full BDF ontology IRI, computed from quantity and unit. @@ -470,277 +529,135 @@ def iri(self) -> str: ) return f"{BDF_IRI_PREFIX}{slug}_{unit_long}" + def resolve(self, available: "set[Column]") -> pl.Expr: + """Resolve this BDF column to a Polars expression. -class ColumnSet: - """Per-DataFrame resolved column context. - - Created with the list of column names available in a DataFrame. - Provides a single :meth:`col` method for selecting and optionally - converting columns. - - Args: - available_columns: Column name strings present in the source DataFrame. - - Examples: - >>> cs = ColumnSet(["Current / A", "Voltage / V"]) - >>> cs.col("Current / A") # doctest: +ELLIPSIS - - """ - - def __init__(self, available_columns: list[str]) -> None: - """Initialise a ColumnSet with the given available column names. - - Args: - available_columns: Column name strings present in the source - DataFrame. - """ - self._available: set[str] = set(available_columns) - - def col( - self, - column: str | Column, - unit: str | None = None, - ) -> pl.Expr: - """Select a column expression, optionally converting units. + Searches available data columns (skipping other :class:`BDFColumn` + entries) for a matching quantity with compatible units. If no + direct data match, checks whether at least one recipe has all its + required columns resolvable. Args: - column: A column name string, :class:`Column`, or - :class:`BDFColumn`. Strings are parsed via - :meth:`Column.from_string`. - unit: Target unit for conversion (e.g. ``"mA"``). When ``None``, - returns the expression in the column's native unit. + available: List of available :class:`Column` and/or + :class:`BDFColumn` objects. Returns: - A Polars expression, aliased to ``"Quantity / unit"`` when - unit conversion is applied. - - Raises: - ValueError: If the column cannot be resolved from available - columns or recipes. + A Polars expression that evaluates to this column's values. Examples: - >>> cs = ColumnSet(["Current / A", "Voltage / V"]) - >>> cs.col("Current / A") # doctest: +ELLIPSIS - + >>> BDF.CURRENT_AMPERE.can_resolve({Column("Current", "mA")}) + True + >>> BDF.CURRENT_AMPERE.can_resolve({Column("Voltage", "V")}) + False """ - if isinstance(column, str): - column = Column.from_string(column) - - if isinstance(column, BDFColumn): - base_expr = self._resolve_bdf(column) - else: - base_expr = pl.col(column.column_name) - - if unit is None: - return base_expr + try: + return super().resolve(available) + except ColumnResolutionError: + try: + recipes = BDF_RECIPES[cast(BDF, self)] + except KeyError: + raise ColumnResolutionError( + f"Cannot resolve '{self.quantity}' from available columns, " + f"and no recipes found." + ) from None + for recipe in recipes: + if all(req.can_resolve(available) for req in recipe.required): + expr_map: dict[BDF, pl.Expr] = { + req: req.resolve(available) for req in recipe.required + } + logger.debug( + "Resolved '%s' via recipe with dependencies %s.", + self.quantity, + [c.quantity for c in expr_map], + ) + return recipe.compute(expr_map).alias(self.name) + raise ColumnResolutionError( + f"Cannot resolve '{self.quantity}' from available columns, " + f"even via recipes with dependencies " + f"{[c.quantity for recipe in recipes for c in recipe.required]}." + ) from None + + +class BDF(BDFColumn, Enum): + """Enum of all BDF-standard columns as :class:`BDFColumn` instances.""" + + TEST_TIME_SECOND = "Test Time", "s" + VOLTAGE_VOLT = "Voltage", "V" + CURRENT_AMPERE = "Current", "A" + UNIX_TIME_SECOND = "Unix Time", "s" + CYCLE_COUNT = "Cycle Count", "1" + STEP_COUNT = "Step Count", "1" + STEP_INDEX = "Step Index", "1" + AMBIENT_TEMPERATURE_CELSIUS = "Ambient Temperature", "degC" + CHARGING_CAPACITY_AH = "Charging Capacity", "Ah" + DISCHARGING_CAPACITY_AH = "Discharging Capacity", "Ah" + STEP_CAPACITY_AH = "Step Capacity", "Ah" + NET_CAPACITY_AH = "Net Capacity", "Ah" + CUMULATIVE_CAPACITY_AH = "Cumulative Capacity", "Ah" + CHARGING_ENERGY_WH = "Charging Energy", "Wh" + DISCHARGING_ENERGY_WH = "Discharging Energy", "Wh" + STEP_ENERGY_WH = "Step Energy", "Wh" + NET_ENERGY_WH = "Net Energy", "Wh" + CUMULATIVE_ENERGY_WH = "Cumulative Energy", "Wh" + POWER_WATT = "Power", "W" + INTERNAL_RESISTANCE_OHM = "Internal Resistance", "Ohm" + AMBIENT_PRESSURE_PA = "Ambient Pressure", "Pa" + APPLIED_PRESSURE_PA = "Applied Pressure", "Pa" + TEMPERATURE_T1_CELCIUS = "Surface Temperature T1", "degC" + TEMPERATURE_T2_CELCIUS = "Surface Temperature T2", "degC" + TEMPERATURE_T3_CELCIUS = "Surface Temperature T3", "degC" + TEMPERATURE_T4_CELCIUS = "Surface Temperature T4", "degC" + TEMPERATURE_T5_CELCIUS = "Surface Temperature T5", "degC" - factor, offset = column.conversion_parameters(unit) - target_name = f"{column.quantity} / {unit}" - return _apply_conversion(base_expr, factor, offset, target_name) + @classmethod + @cache + def _build_index(cls) -> dict[str, "BDF"]: + """Builds a lookup dictionary exactly once and caches it in memory.""" + return {member.quantity: member for member in cls} - def _resolve_bdf(self, col: BDFColumn) -> pl.Expr: - """Resolve a BDFColumn via exact match or recursive recipe. + @classmethod + def get(cls, quantity: str, unit: str) -> "BDF": + """Look up a BDF column by exact quantity and unit match. Args: - col: The BDFColumn descriptor to resolve. + quantity: The physical quantity name (e.g. ``"Current"``). + unit: The unit string (e.g. ``"A"``, ``"Ah"``, ``"1"``). Returns: - A Polars expression for the resolved column. + The matching :class:`BDF` enum member. Raises: - ValueError: If the column cannot be resolved. + KeyError: If no matching BDF column is found. """ - if col.column_name in self._available: - return pl.col(col.column_name) - - for recipe in col.recipes: - expr_map: dict[BDFColumn, pl.Expr] = {} - all_found = True - for req_col in recipe.required: - try: - expr_map[req_col] = self._resolve_bdf(req_col) - except ValueError: - all_found = False - break - if all_found: - logger.debug( - "Resolved '%s' via recipe with dependencies %s.", - col.quantity, - [c.quantity for c in expr_map], - ) - return recipe.compute(expr_map).alias(col.column_name) - - raise ValueError(f"Cannot resolve '{col.quantity}' from available columns") - - -test_time_second = BDFColumn( - quantity="Test Time", - unit="s", -) -"""BDF Test Time column (base unit: seconds).""" - -voltage_volt = BDFColumn( - quantity="Voltage", - unit="V", -) -"""BDF Voltage column (base unit: volts).""" - -current_ampere = BDFColumn( - quantity="Current", - unit="A", -) -"""BDF Current column (base unit: amperes).""" - -unix_time_second = BDFColumn( - quantity="Unix Time", - unit="s", -) -"""BDF Unix Time column (base unit: seconds).""" - -cycle_count = BDFColumn( - quantity="Cycle Count", - unit="1", -) -"""BDF Cycle Count column (dimensionless cycle index).""" - -step_count = BDFColumn( - quantity="Step Count", - unit="1", -) -"""BDF Step Count column (dimensionless integer step index).""" - -ambient_temperature_celsius = BDFColumn( - quantity="Ambient Temperature", - unit="degC", -) -"""BDF Ambient Temperature column (base unit: degrees Celsius).""" - -step_index = BDFColumn( - quantity="Step Index", - unit="1", -) -"""BDF Step Index column (dimensionless).""" - -charging_capacity_ah = BDFColumn( - quantity="Charging Capacity", - unit="Ah", -) -"""BDF Charging Capacity column (base unit: ampere-hours).""" - -discharging_capacity_ah = BDFColumn( - quantity="Discharging Capacity", - unit="Ah", -) -"""BDF Discharging Capacity column (base unit: ampere-hours).""" - -step_capacity_ah = BDFColumn( - quantity="Step Capacity", - unit="Ah", -) -"""BDF Step Capacity column (base unit: ampere-hours).""" - -net_capacity_ah = BDFColumn( - quantity="Net Capacity", - unit="Ah", -) -"""BDF Net Capacity column (base unit: ampere-hours). - -Falls back to computing net capacity from charging and discharging sub-columns -when no direct ``Net Capacity`` column is available. -""" - -cumulative_capacity_ah = BDFColumn( - quantity="Cumulative Capacity", - unit="Ah", -) -"""BDF Cumulative Capacity column (base unit: ampere-hours).""" - -charging_energy_wh = BDFColumn( - quantity="Charging Energy", - unit="Wh", -) -"""BDF Charging Energy column (base unit: watt-hours).""" - -discharging_energy_wh = BDFColumn( - quantity="Discharging Energy", - unit="Wh", -) -"""BDF Discharging Energy column (base unit: watt-hours).""" - -step_energy_wh = BDFColumn( - quantity="Step Energy", - unit="Wh", -) -"""BDF Step Energy column (base unit: watt-hours).""" + quantity_match = cls.lookup_by_quantity(quantity) + if quantity_match.unit != unit: + msg = f"No BDF column for quantity '{quantity}' with unit '{unit}'" + raise KeyError(msg) + return quantity_match -net_energy_wh = BDFColumn( - quantity="Net Energy", - unit="Wh", -) -"""BDF Net Energy column (base unit: watt-hours).""" - -cumulative_energy_wh = BDFColumn( - quantity="Cumulative Energy", - unit="Wh", -) -"""BDF Cumulative Energy column (base unit: watt-hours).""" - -power_watt = BDFColumn( - quantity="Power", - unit="W", -) -"""BDF Power column (base unit: watts).""" - -internal_resistance_ohm = BDFColumn( - quantity="Internal Resistance", - unit="Ohm", -) -"""BDF Internal Resistance column (base unit: ohms).""" - -ambient_pressure_pa = BDFColumn( - quantity="Ambient Pressure", - unit="Pa", -) -"""BDF Ambient Pressure column (base unit: pascals).""" - -applied_pressure_pa = BDFColumn( - quantity="Applied Pressure", - unit="Pa", -) -"""BDF Applied Pressure column (base unit: pascals).""" - -temperature_t1_celsius = BDFColumn( - quantity="Surface Temperature T1", - unit="degC", -) -"""BDF Surface Temperature T1 column (base unit: degrees Celsius).""" + @classmethod + def lookup_by_quantity(cls, quantity: str) -> "BDF": + """Look up a BDF column by quantity name, ignoring case and unit. -temperature_t2_celsius = BDFColumn( - quantity="Surface Temperature T2", - unit="degC", -) -"""BDF Surface Temperature T2 column (base unit: degrees Celsius).""" + Args: + quantity: The physical quantity name (e.g. ``"Current"``). -temperature_t3_celsius = BDFColumn( - quantity="Surface Temperature T3", - unit="degC", -) -"""BDF Surface Temperature T3 column (base unit: degrees Celsius).""" + Returns: + The matching :class:`BDF` enum member. -temperature_t4_celsius = BDFColumn( - quantity="Surface Temperature T4", - unit="degC", -) -"""BDF Surface Temperature T4 column (base unit: degrees Celsius).""" + Raises: + KeyError: If no matching BDF column is found. + """ + index = cls._build_index() -temperature_t5_celsius = BDFColumn( - quantity="Surface Temperature T5", - unit="degC", -) -"""BDF Surface Temperature T5 column (base unit: degrees Celsius).""" + # Look up the tuple in the dictionary + match = index.get(quantity) + if match is None: + raise KeyError(f"No BDF column for quantity '{quantity}'") + return match -def _capacity_from_ch_dch(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: +def _capacity_from_ch_dch(columns: dict[BDF, pl.Expr]) -> pl.Expr: """Derive net capacity from charging and discharging capacity columns. Computes incremental charge and discharge deltas, sums them, and offsets @@ -755,14 +672,17 @@ def _capacity_from_ch_dch(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: A :class:`polars.Expr` representing net capacity in the same unit as the input columns. """ - charge = columns[charging_capacity_ah].cast(pl.Float64) - discharge = columns[discharging_capacity_ah].cast(pl.Float64) + charge = columns[BDF.CHARGING_CAPACITY_AH].cast(pl.Float64) + discharge = columns[BDF.DISCHARGING_CAPACITY_AH].cast(pl.Float64) diff_charge = charge.diff().clip(lower_bound=0).fill_null(strategy="zero") diff_discharge = discharge.diff().clip(lower_bound=0).fill_null(strategy="zero") - return (diff_charge - diff_discharge).cum_sum() + charge.max() + net_capacity = ((diff_charge - diff_discharge).cum_sum() + charge.max()).alias( + BDF.NET_CAPACITY_AH.name + ) + return net_capacity -def _time_from_unix_time(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: +def _time_from_unix_time(columns: dict[BDF, pl.Expr]) -> pl.Expr: """Derive elapsed test time from Unix epoch time in seconds. Computes successive differences and accumulates them so the result @@ -774,11 +694,11 @@ def _time_from_unix_time(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: Returns: A :class:`polars.Expr` representing elapsed time in seconds. """ - t = columns[unix_time_second].cast(pl.Float64) - return t - t.first() + t = columns[BDF.UNIX_TIME_SECOND].cast(pl.Float64) + return (t - t.first()).alias(BDF.TEST_TIME_SECOND.name) -def _step_count_from_step_index(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: +def _step_count_from_step_index(columns: dict[BDF, pl.Expr]) -> pl.Expr: """Derive step count from a Step Index column. Increments the step count whenever the step index changes. @@ -790,51 +710,194 @@ def _step_count_from_step_index(columns: dict[BDFColumn, pl.Expr]) -> pl.Expr: A :class:`polars.Expr` representing a monotonically increasing step count (``UInt64``). """ - return columns[step_index].diff().fill_null(0).ne(0).cum_sum().cast(pl.UInt64) + return ( + columns[BDF.STEP_INDEX] + .cast(pl.Int64) + .diff() + .fill_null(0) + .ne(0) + .cum_sum() + .cast(pl.UInt64) + ).alias(BDF.STEP_COUNT.name) + + +BDF_RECIPES: dict[BDF, list[Recipe]] = { + BDF.TEST_TIME_SECOND: [ + Recipe(required=[BDF.UNIX_TIME_SECOND], compute=_time_from_unix_time) + ], + BDF.NET_CAPACITY_AH: [ + Recipe( + required=[ + BDF.CHARGING_CAPACITY_AH, + BDF.DISCHARGING_CAPACITY_AH, + ], + compute=_capacity_from_ch_dch, + ) + ], + BDF.STEP_COUNT: [ + Recipe(required=[BDF.STEP_INDEX], compute=_step_count_from_step_index) + ], +} -test_time_second.recipes = [ - Recipe(required=[unix_time_second], compute=_time_from_unix_time) -] +def column_factory(quantity: str, unit: str = "1") -> "Column | BDF": + """Create a Column or return a BDF enum member if available. -net_capacity_ah.recipes = [ - Recipe( - required=[charging_capacity_ah, discharging_capacity_ah], - compute=_capacity_from_ch_dch, - ) -] + Returns a BDF enum member if one exists for the given quantity and unit, + otherwise creates a new Column. + """ + try: + return BDF.get(quantity, unit) + except KeyError: + return Column(quantity, unit) -step_count.recipes = [ - Recipe(required=[step_index], compute=_step_count_from_step_index) -] -ALL_COLUMNS: list[BDFColumn] = [ - test_time_second, - voltage_volt, - current_ampere, - unix_time_second, - cycle_count, - step_count, - ambient_temperature_celsius, - step_index, - charging_capacity_ah, - discharging_capacity_ah, - step_capacity_ah, - net_capacity_ah, - cumulative_capacity_ah, - charging_energy_wh, - discharging_energy_wh, - step_energy_wh, - net_energy_wh, - cumulative_energy_wh, - power_watt, - internal_resistance_ohm, - ambient_pressure_pa, - applied_pressure_pa, - temperature_t1_celsius, - temperature_t2_celsius, - temperature_t3_celsius, - temperature_t4_celsius, - temperature_t5_celsius, -] -"""All 27 BDF-standard BDFColumn instances in canonical order.""" +def column_factory_from_string(name: str, pattern: str = BDF_PATTERN) -> "Column | BDF": + """Parse a column name string and return a Column or BDF member. + + Splits ``name`` into quantity and unit using the two capture groups in + ``pattern``, then delegates to :func:`column_factory`. The default + ``pattern`` (:data:`BDF_PATTERN`) recognises ``"Quantity / unit"`` strings, + but any two-group regex can be supplied for other naming conventions. + + Args: + name: The column name string to parse. + pattern: A regex with two capture groups ``(quantity, unit)``. + Defaults to :data:`BDF_PATTERN`. + + Returns: + The matching :class:`BDF` member when the parsed quantity and unit + identify a BDF-standard column; otherwise a new :class:`Column`. + """ + quantity, unit = _split_quantity_unit(name, pattern) + return column_factory(quantity, unit or "1") + + +class ColumnSet: + """Per-DataFrame resolved column context. + + Thin wrapper around a list of available column names. Resolution is + delegated to :meth:`Column.can_resolve`, :meth:`Column.resolve`, + :meth:`BDFColumn.can_resolve`, and :meth:`BDFColumn.resolve`. + + Provides: + + - :meth:`resolve` — select a Polars expression with optional unit conversion. + - :meth:`can_resolve` — check whether a column can be resolved. + - :attr:`names` — list of available column name strings. + - :attr:`quantities` — list of available quantity strings. + + Args: + available_columns: Column name strings present in the source DataFrame. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> expr = cs.resolve("Current / A") + >>> type(expr).__name__ + 'Expr' + """ + + def __init__(self, available_columns: list[str]) -> None: + """Initialise a ColumnSet with the given available column names. + + Parses each column name string into a :class:`Column` or :class:`BDF` + enum member (if a BDF-standard column). The ``_columns`` list contains + the parsed descriptors used for resolution and unit conversion. + + Args: + available_columns: Column name strings present in the source + DataFrame (in BDF format, e.g. "Current / A"). + """ + self._columns: list[Column] = [ + column_factory_from_string(name) for name in available_columns + ] + + @property + def names(self) -> list[str]: + """Return the column names as a list of strings. + + Returns: + List of column name strings. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> cs.names + ['Current / A', 'Voltage / V'] + """ + return [c.name for c in self._columns] + + @property + def quantities(self) -> list[str]: + """Return the column quantities as a list of strings. + + Returns: + List of column quantity strings. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> cs.quantities + ['Current', 'Voltage'] + """ + return [c.quantity for c in self._columns] + + def resolve(self, column: str | Column) -> pl.Expr: + """Select a column expression, optionally converting units. + + String inputs are parsed via :func:`column_factory_from_string`. + An exact raw-string match short-circuits to :func:`polars.col` + directly (handling non-BDF column names like ``"Step"``). Otherwise + resolution is delegated to :meth:`Column.resolve` or + :meth:`BDFColumn.resolve`, which handle quantity matching, recipe + derivation, and unit conversion. + + Args: + column: A column name string or :class:`Column` / + :class:`BDFColumn` descriptor. Strings are parsed via + :func:`column_factory_from_string`. + + Returns: + A Polars expression producing values in the requested unit. + + Raises: + ColumnResolutionError: If no matching column can be resolved. + """ + if isinstance(column, str): + column = column_factory_from_string(column) + return column.resolve(set(self._columns)) + + def can_resolve(self, column: str | Column) -> bool: + """Check whether a column can be resolved from available data. + + Delegates to :meth:`Column.can_resolve` or + :meth:`BDFColumn.can_resolve`, which search the combined + resolution context (data columns and derivable BDF columns). + + Args: + column: A column name string or :class:`Column` / + :class:`BDFColumn` descriptor. Strings are parsed via + :meth:`Column.from_string`. + + Returns: + True if :meth:`col` would succeed for this column. + """ + if isinstance(column, str): + column = column_factory_from_string(column) + return column.can_resolve(set(self._columns)) + + def __contains__(self, item: object) -> bool: + """Check whether a column name is available. + + Args: + item: The column name to check. + + Returns: + True if the column name is present. + + Examples: + >>> cs = ColumnSet(["Current / A", "Voltage / V"]) + >>> "Current / A" in cs + True + >>> "Step Count / 1" in cs + False + """ + return item in self.names diff --git a/tests/test_column.py b/tests/test_column.py index 7db137b2..ef150f2c 100644 --- a/tests/test_column.py +++ b/tests/test_column.py @@ -7,34 +7,28 @@ from __future__ import annotations +from typing import cast + import polars as pl import pytest +from polars.testing import assert_frame_equal from pyprobe.column import ( - ALL_COLUMNS, + BDF, BDF_IRI_PREFIX, BDF_PATTERN, DEFAULT_COLUMNS, BDFColumn, Column, + ColumnResolutionError, ColumnSet, Recipe, _apply_conversion, _capacity_from_ch_dch, _resolve_unit, _split_quantity_unit, - _step_count_from_step_index, - charging_capacity_ah, - current_ampere, - cycle_count, - discharging_capacity_ah, - net_capacity_ah, - step_count, - step_index, - temperature_t1_celsius, - test_time_second, - unix_time_second, - voltage_volt, + column_factory, + column_factory_from_string, ) @@ -57,48 +51,49 @@ def test_init_creates_column_name( col = Column(quantity, unit) assert col.quantity == quantity assert col.unit == unit - assert col.column_name == expected_name + assert col.name == expected_name def test_init_default_unit_is_dimensionless(self) -> None: """Column with no unit arg defaults to '1'.""" col = Column("Step") assert col.unit == "1" - assert col.column_name == "Step / 1" + assert col.name == "Step / 1" -class TestColumnFromString: - """Tests for Column.from_string factory method.""" +class TestColumnFactory: + """Tests for the column_factory function.""" - @pytest.mark.parametrize( - "input_str,expected_quantity,expected_unit", - [ - ("Current / A", "Current", "A"), - ("Step Count / 1", "Step Count", "1"), - ("Net Capacity / Ah", "Net Capacity", "Ah"), - ("Step", "Step", "1"), - ("Current / A", "Current", "A"), - ("Net Capacity / Ah", "Net Capacity", "Ah"), - ], - ) - def test_from_string_parses_correctly( - self, input_str: str, expected_quantity: str, expected_unit: str - ) -> None: - """Parse 'Quantity / unit' string correctly.""" - col = Column.from_string(input_str) - assert col.quantity == expected_quantity - assert col.unit == expected_unit + bdf_cases = [(column.quantity, column.unit, column) for column in BDF] - def test_from_string_roundtrip(self) -> None: - """Parsing and str() should roundtrip the original name.""" - original = "Net Capacity / Ah" - col = Column.from_string(original) - assert str(col) == original + @pytest.mark.parametrize("quantity,unit,expected_col", bdf_cases) + def test_factory_returns_expected_column( + self, quantity: str, unit: str, expected_col: BDFColumn + ) -> None: + """column_factory returns the expected BDFColumn for given quantity/unit.""" + col = column_factory(quantity, unit) + assert col == expected_col - def test_from_string_invalid_unit_raises_on_conversion(self) -> None: - """Invalid unit strings raise ValueError at conversion_parameters time.""" - col = Column.from_string("Current / InvalidUnit") - with pytest.raises(ValueError, match="could not be parsed"): - col.conversion_parameters("A") + @pytest.mark.parametrize("quantity,unit,expected_col", bdf_cases) + def test_factory_from_string_returns_expected_column( + self, quantity: str, unit: str, expected_col: BDFColumn + ) -> None: + """column_factory_from_string returns the expected BDFColumn.""" + col = column_factory_from_string(f"{quantity} / {unit}") + assert col == expected_col + + non_bdf_cases = [ + ("Custom Quantity", "Custom Unit"), + ("Temperature", "degC"), + ("Current", "mA"), + ] + + @pytest.mark.parametrize("quantity,unit", non_bdf_cases) + def test_factory_non_bdf_columns(self, quantity: str, unit: str) -> None: + """column_factory can create Column instances for non-BDF quantities.""" + col = column_factory(quantity, unit) + assert isinstance(col, Column) + assert col.quantity == quantity + assert col.unit == unit class TestConversionParameters: @@ -125,21 +120,21 @@ def test_conversion_parameters_multiplicative( expected_offset: float, ) -> None: """Test multiplicative conversions for different unit pairs.""" - col = Column.from_string(f"Quantity / {source_unit}") + col = column_factory_from_string(f"Quantity / {source_unit}") factor, offset = col.conversion_parameters(target_unit) assert factor == pytest.approx(expected_factor, rel=1e-9) assert offset == pytest.approx(expected_offset, abs=1e-9) def test_conversion_celsius_to_kelvin(self) -> None: """Affine conversion degC to K: factor=1, offset=273.15.""" - col = Column.from_string("Temperature / C") + col = column_factory_from_string("Temperature / C") factor, offset = col.conversion_parameters("K") assert factor == pytest.approx(1.0, rel=1e-9) assert offset == pytest.approx(273.15, abs=0.01) def test_conversion_incompatible_units_raises(self) -> None: """Converting between incompatible units raises ValueError.""" - col = Column.from_string("Current / A") + col = column_factory_from_string("Current / A") with pytest.raises(ValueError, match="Cannot convert"): col.conversion_parameters("V") @@ -156,12 +151,12 @@ class TestBDFColumnIRI: @pytest.mark.parametrize( "col_obj,expected_iri_suffix", [ - (current_ampere, "current_ampere"), - (voltage_volt, "voltage_volt"), - (step_count, "step_count"), - (cycle_count, "cycle_count"), - (charging_capacity_ah, "charging_capacity_ampere_hour"), - (temperature_t1_celsius, "temperature_t1_degree_celsius"), + (BDF.CURRENT_AMPERE, "current_ampere"), + (BDF.VOLTAGE_VOLT, "voltage_volt"), + (BDF.STEP_COUNT, "step_count"), + (BDF.CYCLE_COUNT, "cycle_count"), + (BDF.CHARGING_CAPACITY_AH, "charging_capacity_ampere_hour"), + (BDF.TEMPERATURE_T1_CELCIUS, "temperature_t1_degree_celsius"), ], ) def test_iri_computed_from_quantity_and_unit( @@ -170,7 +165,7 @@ def test_iri_computed_from_quantity_and_unit( """IRI is computed from quantity and pint long-form unit.""" assert col_obj.iri == f"{BDF_IRI_PREFIX}{expected_iri_suffix}" - @pytest.mark.parametrize("col_obj", ALL_COLUMNS) + @pytest.mark.parametrize("col_obj", list(BDF)) def test_all_bdf_column_iris_are_valid_urls(self, col_obj: BDFColumn) -> None: """All BDF column IRIs are complete and properly formatted.""" iri = col_obj.iri @@ -209,7 +204,7 @@ def test_step_count_from_step_index_recipe(self) -> None: ] } ) - result = df.select(cs.col(step_count)) + result = df.select(cs.resolve(BDF.STEP_COUNT)) expected = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 6, 6, 7, 7] assert result["Step Count / 1"].to_list() == expected @@ -222,7 +217,7 @@ def test_col_recipe_net_capacity(self) -> None: "Discharging Capacity / Ah": [0.0, 1.0, 2.0], } ) - result = df.select(cs.col(net_capacity_ah)) + result = df.select(cs.resolve(BDF.NET_CAPACITY_AH)) expected = [1.0, 0.0, -1.0] assert result["Net Capacity / Ah"].to_list() == pytest.approx(expected) @@ -234,7 +229,7 @@ def test_col_recipe_time_from_unix_time(self) -> None: "Unix Time / s": [1648864360.0, 1648864361.0, 1648864362.0], } ) - result = df.select(cs.col(test_time_second)) + result = df.select(cs.resolve(BDF.TEST_TIME_SECOND)) expected = [0.0, 1.0, 2.0] assert result["Test Time / s"].to_list() == pytest.approx(expected) @@ -344,283 +339,605 @@ class TestRecipeDataclass: def test_recipe_construction(self) -> None: """Recipe can be constructed with required BDFColumn list and compute.""" recipe = Recipe( - required=[current_ampere], - compute=lambda cols: cols[current_ampere] * pl.lit(2), + required=[BDF.CURRENT_AMPERE], + compute=lambda cols: cols[BDF.CURRENT_AMPERE] * pl.lit(2), ) - assert recipe.required == [current_ampere] + assert recipe.required == [BDF.CURRENT_AMPERE] assert callable(recipe.compute) def test_recipe_with_multiple_dependencies(self) -> None: """Recipe can require multiple BDFColumn instances.""" recipe = Recipe( - required=[charging_capacity_ah, discharging_capacity_ah], + required=[BDF.CHARGING_CAPACITY_AH, BDF.DISCHARGING_CAPACITY_AH], compute=_capacity_from_ch_dch, ) assert len(recipe.required) == 2 - assert charging_capacity_ah in recipe.required - assert discharging_capacity_ah in recipe.required - - -class TestBDFColumnInit: - """Tests for BDFColumn construction with recipes.""" - - def test_init_with_recipes(self) -> None: - """BDFColumn can be initialized with recipes list.""" - recipe = Recipe( - required=[step_index], - compute=_step_count_from_step_index, - ) - col = BDFColumn("Step Count", "1", recipes=[recipe]) - assert len(col.recipes) == 1 - - def test_init_default_recipes_is_empty_list(self) -> None: - """Default recipes is an empty list.""" - col = BDFColumn("Current", "A") - assert col.recipes == [] - - def test_recipes_are_public_attribute(self) -> None: - """Recipes is a public attribute, not private.""" - col = BDFColumn("Current", "A") - assert hasattr(col, "recipes") - col.recipes = [ - Recipe(required=[step_index], compute=_step_count_from_step_index) - ] - assert len(col.recipes) == 1 - - -class TestRecipeAttachment: - """Tests for post-definition recipe attachment pattern.""" - - def test_test_time_second_has_recipe(self) -> None: - """test_time_second has its Unix Time recipe attached.""" - assert len(test_time_second.recipes) == 1 - assert unix_time_second in test_time_second.recipes[0].required - - def test_net_capacity_ah_has_recipe(self) -> None: - """net_capacity_ah has its Charging/Discharging recipe attached.""" - assert len(net_capacity_ah.recipes) == 1 - required_quantities = { - col.quantity for col in net_capacity_ah.recipes[0].required - } - assert "Charging Capacity" in required_quantities - assert "Discharging Capacity" in required_quantities - - def test_step_count_has_recipe(self) -> None: - """step_count has its Step Index recipe attached.""" - assert len(step_count.recipes) == 1 - assert step_index in step_count.recipes[0].required - - -class TestRecipeValidation: - """Tests for recipe validation at construction time.""" + assert BDF.CHARGING_CAPACITY_AH in recipe.required + assert BDF.DISCHARGING_CAPACITY_AH in recipe.required def test_unused_required_column_raises(self) -> None: """Recipe raises ValueError if a required column is never accessed.""" - col_a = BDFColumn("Level A", "1") - col_b = BDFColumn("Level B", "1") + col_a = cast(BDF, BDFColumn("Level A", "1")) + col_b = cast(BDF, BDFColumn("Level B", "1")) - def only_uses_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + def only_uses_a(cols: dict[BDF, pl.Expr]) -> pl.Expr: return cols[col_a] + pl.lit(10) with pytest.raises(ValueError, match="unused required"): - Recipe(required=[col_a, col_b], compute=only_uses_a) + Recipe(required=cast(list[BDF], [col_a, col_b]), compute=only_uses_a) def test_undeclared_dependency_raises(self) -> None: """Recipe raises ValueError if compute accesses a column not in required.""" - col_a = BDFColumn("Level A", "1") - col_b = BDFColumn("Level B", "1") + col_a = cast(BDF, BDFColumn("Level A", "1")) + col_b = cast(BDF, BDFColumn("Level B", "1")) - def uses_b(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + def uses_b(cols: dict[BDF, pl.Expr]) -> pl.Expr: return cols[col_b] + pl.lit(10) with pytest.raises(ValueError, match="not in required"): - Recipe(required=[col_a], compute=uses_b) + Recipe(required=cast(list[BDF], [col_a]), compute=uses_b) def test_valid_recipe_construction_succeeds(self) -> None: """Recipe construction succeeds when all required columns are used.""" - col_a = BDFColumn("Level A", "1") + col_a = cast(BDF, BDFColumn("Level A", "1")) - def uses_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: + def uses_a(cols: dict[BDF, pl.Expr]) -> pl.Expr: return cols[col_a] + pl.lit(10) - recipe = Recipe(required=[col_a], compute=uses_a) + recipe = Recipe(required=cast(list[BDF], [col_a]), compute=uses_a) assert len(recipe.required) == 1 -class TestColumnSet: - """Tests for ColumnSet column resolution and unit conversion.""" +class TestColumnSetResolve: + """Tests for ColumnSet.resolve() method.""" - def test_col_with_string(self) -> None: + def test_resolve_with_string(self) -> None: """String input returns pl.col() for the parsed column name.""" cs = ColumnSet(["Current / A"]) - expr = cs.col("Current / A") + expr = cs.resolve("Current / A") df = pl.DataFrame({"Current / A": [1.0, 2.0]}) result = df.select(expr).to_series().to_list() assert result == [1.0, 2.0] - def test_col_with_column_instance(self) -> None: + def test_resolve_with_column_instance(self) -> None: """Column descriptor input returns pl.col() expression.""" cs = ColumnSet(["Current / A"]) - col = Column.from_string("Current / A") - expr = cs.col(col) + col = column_factory_from_string("Current / A") + expr = cs.resolve(col) df = pl.DataFrame({"Current / A": [3.0]}) result = df.select(expr).to_series().to_list() assert result == [3.0] - def test_col_with_bdf_column_exact_match(self) -> None: + def test_resolve_with_bdf_column_exact_match(self) -> None: """BDFColumn exact match returns pl.col() expression.""" cs = ColumnSet(["Current / A"]) - expr = cs.col(current_ampere) + expr = cs.resolve(BDF.CURRENT_AMPERE) df = pl.DataFrame({"Current / A": [5.0]}) result = df.select(expr).to_series().to_list() assert result == [5.0] - @pytest.mark.parametrize( - "source_unit,target_unit,expected_conversion", - [ - ("A", "mA", 1000.0), - ("V", "mV", 1000.0), - ], - ) - def test_col_with_unit_conversion( - self, source_unit: str, target_unit: str, expected_conversion: float - ) -> None: - """Unit conversion scales values and aliases the result.""" - col = BDFColumn("Quantity", source_unit) - cs = ColumnSet([f"Quantity / {source_unit}"]) - expr = cs.col(col, unit=target_unit) - df = pl.DataFrame({f"Quantity / {source_unit}": [1.0, 2.0]}) + def test_resolve_unit_conversion(self) -> None: + """Unit conversion with Column descriptor scales values.""" + col = Column("Quantity", "mA") + cs = ColumnSet(["Quantity / A"]) + expr = cs.resolve(col) + df = pl.DataFrame({"Quantity / A": [1.0, 2.0]}) result_df = df.select(expr) - assert f"Quantity / {target_unit}" in result_df.columns - assert result_df[f"Quantity / {target_unit}"].to_list() == pytest.approx( - [expected_conversion, expected_conversion * 2], rel=1e-9 + assert "Quantity / mA" in result_df.columns + assert result_df["Quantity / mA"].to_list() == pytest.approx( + [1000.0, 2000.0], rel=1e-9 ) - def test_col_identity_conversion(self) -> None: + def test_resolve_identity_conversion(self) -> None: """Same-unit conversion aliases without arithmetic.""" cs = ColumnSet(["Current / A"]) - expr = cs.col(current_ampere, unit="A") + expr = cs.resolve("Current / A") df = pl.DataFrame({"Current / A": [1.0, 2.0]}) result_df = df.select(expr) assert "Current / A" in result_df.columns assert result_df["Current / A"].to_list() == [1.0, 2.0] - def test_col_celsius_to_kelvin(self) -> None: - """Affine conversion (degC to K) adds 273.15 offset.""" - col = BDFColumn("Temperature", "degC") - cs = ColumnSet(["Temperature / degC"]) - expr = cs.col(col, unit="K") - df = pl.DataFrame({"Temperature / degC": [0.0, 100.0]}) - result = df.select(expr).to_series().to_list() - assert result == pytest.approx([273.15, 373.15], abs=0.01) - - def test_col_not_found_raises(self) -> None: - """ValueError raised when column cannot be resolved.""" + def test_resolve_not_found_raises(self) -> None: + """ColumnResolutionError raised when column cannot be resolved.""" cs = ColumnSet(["Voltage / V"]) - with pytest.raises(ValueError, match="Cannot resolve"): - cs.col(current_ampere) + with pytest.raises(ColumnResolutionError, match="Cannot resolve"): + cs.resolve(BDF.CURRENT_AMPERE) - def test_col_bdf_with_conversion(self) -> None: - """BDFColumn exact match combined with unit conversion.""" - cs = ColumnSet(["Voltage / V"]) - expr = cs.col(voltage_volt, unit="mV") - df = pl.DataFrame({"Voltage / V": [1.0, 2.0]}) - result_df = df.select(expr) - assert "Voltage / mV" in result_df.columns - assert result_df["Voltage / mV"].to_list() == pytest.approx( - [1000.0, 2000.0], rel=1e-9 + def test_resolve_empty_available_raises(self) -> None: + """Empty available_columns list raises ColumnResolutionError for BDFColumn.""" + cs = ColumnSet([]) + with pytest.raises(ColumnResolutionError, match="Cannot resolve"): + cs.resolve(BDF.CURRENT_AMPERE) + + def test_resolve_recipe_with_unit_conversion(self) -> None: + """resolve() via recipe then converts the result to the requested unit.""" + df = pl.DataFrame( + { + "Charging Capacity / Ah": [0.0, 0.0, 0.0], + "Discharging Capacity / Ah": [0.1, 0.2, 0.3], + } + ) + cs = ColumnSet(df.columns) + expr = cs.resolve("Net Capacity / mAh") + base = _capacity_from_ch_dch( + { + BDF.CHARGING_CAPACITY_AH: pl.col("Charging Capacity / Ah"), + BDF.DISCHARGING_CAPACITY_AH: pl.col("Discharging Capacity / Ah"), + } + ) + assert_frame_equal( + df.select(expr), + df.select((base * 1000).alias("Net Capacity / mAh")), ) - def test_col_empty_available_raises(self) -> None: - """Empty available_columns list raises ValueError for BDFColumn.""" - cs = ColumnSet([]) - with pytest.raises(ValueError, match="Cannot resolve"): - cs.col(current_ampere) + def test_resolve_non_standard_unit_recipe_deps(self) -> None: + """resolve() works when recipe inputs are in non-standard units (mAh).""" + cs = ColumnSet(["Charging Capacity / mAh", "Discharging Capacity / mAh"]) + expr = cs.resolve("Net Capacity / mAh") + df = pl.DataFrame( + { + "Charging Capacity / mAh": [500.0, 1000.0], + "Discharging Capacity / mAh": [0.0, 0.0], + } + ) + result = df.select(expr) + assert "Net Capacity / mAh" in result.columns + assert len(result) == 2 + + def test_resolve_alias_is_converted_name(self) -> None: + """resolve() aliases the output to the requested unit name, not the source.""" + cs = ColumnSet(["Current / A"]) + df = pl.DataFrame({"Current / A": [1.0]}) + result = df.select(cs.resolve("Current / mA")) + assert "Current / mA" in result.columns + assert "Current / A" not in result.columns + + @pytest.mark.parametrize( + "values,expected", + [ + ([0.0, 1.0, -1.0], [0.0, 1000.0, -1000.0]), + ([1e6, 1e7], [1e9, 1e10]), + ([-5.0, -2.5], [-5000.0, -2500.0]), + ], + ) + def test_resolve_unit_conversion_edge_values( + self, values: list[float], expected: list[float] + ) -> None: + """Unit conversion handles zero, large, and negative values correctly.""" + cs = ColumnSet(["Current / A"]) + df = pl.DataFrame({"Current / A": values}) + result = df.select(cs.resolve(Column("Current", "mA"))).to_series().to_list() + assert result == pytest.approx(expected, rel=1e-9) + + def test_resolve_empty_dataframe(self) -> None: + """resolve() on an empty DataFrame returns an empty series.""" + cs = ColumnSet(["Current / A"]) + df = pl.DataFrame({"Current / A": pl.Series([], dtype=pl.Float64)}) + result = df.select(cs.resolve("Current / A")).to_series().to_list() + assert result == [] + + def test_resolve_custom_column_exact_match(self) -> None: + """resolve() returns exact column when custom column matches.""" + df = pl.DataFrame({"Custom Column / A": [10.0, 20.0, 30.0]}) + column_set = ColumnSet(df.columns) + resolved_expr = column_set.resolve("Custom Column / A") + expected_expr = pl.col("Custom Column / A") + assert_frame_equal(df.select(resolved_expr), df.select(expected_expr)) + + def test_resolve_custom_column_with_unit_conversion(self) -> None: + """resolve() applies unit conversion for custom columns.""" + df = pl.DataFrame({"Custom Column / A": [10.0, 20.0, 30.0]}) + column_set = ColumnSet(df.columns) + resolved_expr = column_set.resolve("Custom Column / mA") + expected_expr = (pl.col("Custom Column / A") * 1000).alias("Custom Column / mA") + assert_frame_equal(df.select(resolved_expr), df.select(expected_expr)) + + def test_resolve_bdf_column_with_unit_conversion(self) -> None: + """resolve() applies unit conversion for BDF columns.""" + df = pl.DataFrame({"Voltage / V": [3.7, 3.6, 3.5]}) + column_set = ColumnSet(df.columns) + resolved_expr = column_set.resolve("Voltage / mV") + expected_expr = (pl.col("Voltage / V") * 1000).alias("Voltage / mV") + assert_frame_equal(df.select(resolved_expr), df.select(expected_expr)) + + def test_resolve_bdf_column_via_recipe(self) -> None: + """resolve() computes BDF column via recipe when not directly available.""" + df = pl.DataFrame( + { + "Charging Capacity / Ah": [0.0, 0.0, 0.0], + "Discharging Capacity / Ah": [0.1, 0.2, 0.3], + } + ) + column_set = ColumnSet(df.columns) + resolved_expr = column_set.resolve(BDF.NET_CAPACITY_AH) + expected_expr = _capacity_from_ch_dch( + { + BDF.CHARGING_CAPACITY_AH: pl.col("Charging Capacity / Ah"), + BDF.DISCHARGING_CAPACITY_AH: pl.col("Discharging Capacity / Ah"), + } + ) + assert_frame_equal(df.select(resolved_expr), df.select(expected_expr)) + + +class TestColumnRelations: + """Tests for equality and identity between Column and BDFColumn instances.""" + + def test_equality_and_identity(self) -> None: + """BDFColumn instances with same quantity/unit are equal but not identical.""" + col1 = BDFColumn("Current", "A") + col2 = BDFColumn("Current", "A") + assert col1 == col2 + assert col1 is not col2 + + def test_equality_in_different_classes(self) -> None: + """BDFColumn and Column with same quantity/unit are not equal.""" + assert BDFColumn("Voltage", "V") != Column("Voltage", "V") + + def test_in_list_and_set(self) -> None: + """BDFColumn equality holds in lists and sets.""" + col = BDFColumn("Voltage", "V") + pool = [col, BDFColumn("Current", "A")] + ref = BDFColumn("Voltage", "V") + assert ref in pool + assert ref in {col, BDFColumn("Current", "A")} + + def test_as_dict_keys(self) -> None: + """BDFColumn instances hash and compare equal as dict keys.""" + col = BDFColumn("Net Capacity", "Ah") + other = BDFColumn("Step Count", "1") + d = {col: "Net Capacity Data", other: "Step Count Data"} + assert BDFColumn("Net Capacity", "Ah") in d + assert d[BDFColumn("Net Capacity", "Ah")] == "Net Capacity Data" + + +class TestBDFEnum: + """Tests for the BDF Enum and its 27 standard column members.""" + + def test_member_count(self) -> None: + """BDF contains exactly 27 members.""" + assert len(list(BDF)) == 27 + + def test_all_members_are_bdf_columns(self) -> None: + """Every BDF member is a BDFColumn instance.""" + for member in BDF: + assert isinstance(member, BDFColumn) + + def test_default_columns_are_in_bdf(self) -> None: + """Every entry in DEFAULT_COLUMNS matches a BDF member name.""" + bdf_names = {col.name for col in BDF} + for name in DEFAULT_COLUMNS: + assert name in bdf_names - def test_recursive_recipe(self) -> None: - """Recipe dependency resolved recursively via another recipe.""" - level_a = BDFColumn("Level A", "1") - level_b = BDFColumn("Level B", "1") + @pytest.mark.parametrize( + "quantity,unit,expected", + [ + ("Test Time", "s", BDF.TEST_TIME_SECOND), + ("Current", "A", BDF.CURRENT_AMPERE), + ("Voltage", "V", BDF.VOLTAGE_VOLT), + ], + ) + def test_get(self, quantity: str, unit: str, expected: BDF) -> None: + """BDF.get() returns the correct member for quantity/unit pairs.""" + assert BDF.get(quantity, unit) == expected - def b_from_a(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: - return cols[level_a] + pl.lit(10) + @pytest.mark.parametrize( + "quantity,unit", + [ + ("Test Time", "s"), + ("Current", "A"), + ("Voltage", "V"), + ("Net Capacity", "Ah"), + ("Step Count", "1"), + ("Step Index", "1"), + ], + ) + def test_bdf_column_membership(self, quantity: str, unit: str) -> None: + """BDFColumn instances for BDF quantities are found in the enum.""" + assert BDFColumn(quantity, unit) in BDF - level_b.recipes = [Recipe(required=[level_a], compute=b_from_a)] - level_c = BDFColumn("Level C", "1") - def c_from_b(cols: dict[BDFColumn, pl.Expr]) -> pl.Expr: - return cols[level_b] * pl.lit(2) +class TestColumnResolvability: + """Tests for can_resolve and resolve on Column and BDFColumn.""" - level_c.recipes = [Recipe(required=[level_b], compute=c_from_b)] + # ── can_resolve — positive cases ────────────────────────────────────────── - cs = ColumnSet(["Level A / 1"]) - expr = cs.col(level_c) - df = pl.DataFrame({"Level A / 1": [5, 10, 15]}) - result = df.select(expr).to_series().to_list() - assert result == [30, 40, 50] + @pytest.mark.parametrize( + "target, available", + [ + # exact same-unit match + ( + Column("Column A", "s"), + {Column("Column A", "s"), Column("Column B", "A")}, + ), + # exact match in larger set + ( + Column("Column B", "mA"), + {Column("Column A", "s"), Column("Column B", "mA")}, + ), + # BDFColumn exact equality + (BDFColumn("Net Capacity", "Ah"), {BDFColumn("Net Capacity", "Ah")}), + # BDFColumn in mixed set + ( + BDFColumn("Net Capacity", "Ah"), + {BDFColumn("Net Capacity", "Ah"), Column("Net Capacity", "mAh")}, + ), + # Column resolves from BDFColumn in available (compatible unit) + ( + Column("Net Capacity", "mAh"), + {Column("Column A", "s"), BDFColumn("Net Capacity", "Ah")}, + ), + # Column with compound pint unit resolves from BDFColumn + ( + Column("Net Capacity", "mA.h"), + {Column("Column A", "s"), BDFColumn("Net Capacity", "Ah")}, + ), + # case-insensitive quantity matching + (Column("current", "A"), {Column("CURRENT", "A")}), + # bidirectional: target A from available mA + (Column("Current", "A"), {Column("Current", "mA")}), + # bidirectional: target mA from available A + (Column("Current", "mA"), {Column("Current", "A")}), + # BDF member from plain Column (same unit) + (BDF.CURRENT_AMPERE, {Column("Current", "A")}), + # BDF member from plain Column (different compatible unit) + (BDF.CURRENT_AMPERE, {Column("Current", "mA")}), + # BDF member from BDFColumn in available (equality) + (BDF.CURRENT_AMPERE, {BDFColumn("Current", "A")}), + # BDF member from mixed available (BDF + plain Column) + (BDF.CURRENT_AMPERE, {BDFColumn("Voltage", "V"), Column("Current", "A")}), + # recipe: standard-unit deps + ( + BDF.NET_CAPACITY_AH, + { + Column("Charging Capacity", "Ah"), + Column("Discharging Capacity", "Ah"), + }, + ), + # recipe: non-standard-unit deps (mAh) + ( + BDF.NET_CAPACITY_AH, + { + Column("Charging Capacity", "mAh"), + Column("Discharging Capacity", "mAh"), + }, + ), + ], + ) + def test_can_resolve(self, target: Column, available: object) -> None: + """can_resolve returns True for all resolvable combinations.""" + assert target.can_resolve(available) is True # type: ignore[arg-type] + # ── can_resolve — negative cases ────────────────────────────────────────── + + @pytest.mark.parametrize( + "target, available", + [ + # quantity absent + ( + Column("Column A", "s"), + {Column("Column B", "A"), Column("Voltage", "V")}, + ), + ( + Column("Column B", "mA"), + {Column("Column A", "s"), Column("Voltage", "V")}, + ), + ( + Column("Net Capacity", "mAh"), + {Column("Column A", "s"), Column("Column B", "A")}, + ), + # incompatible unit + (Column("Column A", "s"), {Column("Column A", "A")}), + (BDFColumn("Current", "V"), {Column("Current", "A")}), + # wrong quantity alongside BDFColumn + (Column("Voltage", "A"), {BDFColumn("Current", "A")}), + # BDF recipe with missing deps + (BDF.NET_CAPACITY_AH, {Column("Voltage", "V")}), + ], + ) + def test_cannot_resolve(self, target: Column, available: object) -> None: + """can_resolve returns False for unresolvable combinations.""" + assert target.can_resolve(available) is False # type: ignore[arg-type] -class TestEdgeCases: - """Tests for edge cases and boundary conditions.""" + # ── resolve — BDF recipe with exact value checks ─────────────────────────── @pytest.mark.parametrize( - "values,target_unit,expected", + "requested, available, expected_scale, df_data", [ - ([0.0, 1.0, -1.0], "mA", [0.0, 1000.0, -1000.0]), - ([1e6, 1e7], "mA", [1e9, 1e10]), - ([-5.0, -2.5], "mA", [-5000.0, -2500.0]), + # BDF target, Ah deps → base unit (scale 1) + ( + BDF.NET_CAPACITY_AH, + {BDF.DISCHARGING_CAPACITY_AH, BDF.CHARGING_CAPACITY_AH}, + 1.0, + { + "Charging Capacity / Ah": [0, 0, 0], + "Discharging Capacity / Ah": [0.1, 0.2, 0.3], + }, + ), + # Column("mAh") target, Ah deps → unit conversion on result + ( + Column("Net Capacity", "mAh"), + {BDF.DISCHARGING_CAPACITY_AH, BDF.CHARGING_CAPACITY_AH}, + 1000.0, + { + "Charging Capacity / Ah": [0, 0, 0], + "Discharging Capacity / Ah": [0.1, 0.2, 0.3], + }, + ), + # BDF target, kAh deps → unit conversion of inputs (scale 1000) + ( + BDF.NET_CAPACITY_AH, + { + Column("Discharging Capacity", "kAh"), + Column("Charging Capacity", "kAh"), + }, + 1000.0, + { + "Charging Capacity / kAh": [0, 0, 0], + "Discharging Capacity / kAh": [0.1, 0.2, 0.3], + }, + ), ], ) - def test_unit_conversion_edge_values( - self, values: list[float], target_unit: str, expected: list[float] + def test_resolve_bdf_recipe( + self, + requested: Column, + available: object, + expected_scale: float, + df_data: dict[str, object], ) -> None: - """Unit conversion handles zero, large, and negative values.""" - cs = ColumnSet(["Current / A"]) - col = Column.from_string("Current / A") - df = pl.DataFrame({"Current / A": values}) - result = df.select(cs.col(col, unit=target_unit)).to_series().to_list() - assert result == pytest.approx(expected, rel=1e-9) + """resolve() via recipe returns correctly computed expression.""" + df = pl.DataFrame(df_data) + expr = requested.resolve(available) # type: ignore[arg-type] + base = pl.DataFrame({"Net Capacity / Ah": [0.0, -0.1, -0.2]}) + expected = base.select( + (pl.col("Net Capacity / Ah") * expected_scale).alias(requested.name) + ) + assert_frame_equal(df.select(expr), expected) - def test_column_empty_dataframe(self) -> None: - """Empty DataFrame is handled correctly.""" - cs = ColumnSet(["Current / A"]) - col = Column.from_string("Current / A") - df = pl.DataFrame({"Current / A": []}) - result = df.select(cs.col(col)).to_series().to_list() - assert result == [] + @pytest.mark.parametrize( + "bdf_column", + [ + BDFColumn("Net Capacity", "Ah"), # BDFColumn with no matching recipe key + BDF.TEMPERATURE_T1_CELCIUS, # BDF member with no recipe defined + ], + ) + def test_cannot_resolve_bdf_recipe(self, bdf_column: BDFColumn) -> None: + """resolve() raises ColumnResolutionError when no recipe matches.""" + with pytest.raises(ColumnResolutionError): + bdf_column.resolve({BDF.CHARGING_CAPACITY_AH, BDF.DISCHARGING_CAPACITY_AH}) + + def test_resolve_raises_for_missing_quantity(self) -> None: + """resolve() raises ColumnResolutionError when quantity is absent.""" + with pytest.raises(ColumnResolutionError, match="Cannot resolve"): + Column("Current", "A").resolve({Column("Voltage", "V")}) + + def test_resolve_raises_for_incompatible_unit(self) -> None: + """resolve() raises ColumnResolutionError for incompatible units.""" + with pytest.raises(ColumnResolutionError): + Column("Current", "V").resolve({Column("Current", "A")}) + + def test_resolve_case_insensitive(self) -> None: + """resolve() matches quantity case-insensitively.""" + expr = Column("current", "A").resolve({Column("CURRENT", "A")}) + df = pl.DataFrame({"CURRENT / A": [3.0]}) + assert df.select(expr).to_series().to_list() == [3.0] + + def test_resolve_bdf_via_bdf_equality(self) -> None: + """BDF.resolve() with matching BDFColumn available.""" + expr = BDF.CURRENT_AMPERE.resolve({BDFColumn("Current", "A")}) + df = pl.DataFrame({"Current / A": [5.0]}) + assert df.select(expr).to_series().to_list() == [5.0] + + def test_resolve_bdf_non_standard_unit_deps_outputs_base_unit(self) -> None: + """Recipe with mAh deps still outputs Net Capacity / Ah (base unit).""" + available = { + Column("Charging Capacity", "mAh"), + Column("Discharging Capacity", "mAh"), + } + expr = BDF.NET_CAPACITY_AH.resolve(available) + df = pl.DataFrame( + { + "Charging Capacity / mAh": [1000.0, 2000.0], + "Discharging Capacity / mAh": [0.0, 0.0], + } + ) + result = df.select(expr) + assert "Net Capacity / Ah" in result.columns + assert len(result) == 2 + + +class TestColumnSetInit: + """Tests for ColumnSet initialisation and introspection.""" - def test_columnset_with_many_rows(self) -> None: - """Large DataFrames are processed correctly.""" + @pytest.mark.parametrize( + "available, expected", + [ + (["Column A / s"], {Column("Column A", "s")}), + (["Current / A", "Voltage / V"], {BDF.CURRENT_AMPERE, BDF.VOLTAGE_VOLT}), + (["Current / mA"], {Column("Current", "mA")}), + ( + ["Discharging Capacity / Ah", "Charging Capacity / Ah"], + {BDF.DISCHARGING_CAPACITY_AH, BDF.CHARGING_CAPACITY_AH}, + ), + ( + ["Discharging Capacity / mAh", "Charging Capacity / mAh"], + { + Column("Discharging Capacity", "mAh"), + Column("Charging Capacity", "mAh"), + }, + ), + ( + ["Discharging Capacity / Ah", "Charging Capacity / kAh"], + {BDF.DISCHARGING_CAPACITY_AH, Column("Charging Capacity", "kAh")}, + ), + ], + ) + def test_internal_columns( + self, available: list[str], expected: set[Column | BDFColumn] + ) -> None: + """_columns contains the expected Column/BDF instances after init.""" + assert set(ColumnSet(available)._columns) == expected + + def test_names_property(self) -> None: + """Names returns column name strings in order.""" + cs = ColumnSet(["Current / A", "Voltage / V"]) + assert cs.names == ["Current / A", "Voltage / V"] + + def test_quantities_property(self) -> None: + """Quantities returns quantity strings in order.""" + cs = ColumnSet(["Current / A", "Voltage / V"]) + assert cs.quantities == ["Current", "Voltage"] + + def test_contains(self) -> None: + """__contains__ checks by column name string.""" + cs = ColumnSet(["Current / A", "Voltage / V"]) + assert "Current / A" in cs + assert "Power / W" not in cs + + @pytest.mark.parametrize( + "column, expected", + [ + ("Current / A", True), # string — direct hit + ("Current / mA", True), # string — unit conversion + ("Voltage / V", False), # string — missing + (Column("Current", "A"), True), # Column — direct hit + (Column("Current", "mA"), True), # Column — unit conversion + (Column("Voltage", "V"), False), # Column — missing + (BDF.CURRENT_AMPERE, True), # BDF member — via unit conversion + ], + ) + def test_can_resolve(self, column: object, expected: bool) -> None: + """can_resolve returns correct boolean values.""" cs = ColumnSet(["Current / A"]) - col = Column.from_string("Current / A") - large_data = list(range(10000)) - df = pl.DataFrame({"Current / A": large_data}) - result = df.select(cs.col(col, unit="mA")).to_series().to_list() - assert len(result) == 10000 - assert result[0] == 0.0 - assert result[-1] == pytest.approx(9999000.0, rel=1e-9) - - -class TestPublicBDFInstances: - """Tests for all 27 public BDFColumn instances.""" - - def test_all_columns_count(self) -> None: - """ALL_COLUMNS list contains exactly 27 entries.""" - assert len(ALL_COLUMNS) == 27 - - def test_default_columns_is_subset(self) -> None: - """DEFAULT_COLUMNS are all present in ALL_COLUMNS.""" - all_names = [col.column_name for col in ALL_COLUMNS] - for default_name in DEFAULT_COLUMNS: - assert default_name in all_names - - @pytest.mark.parametrize("col_obj", ALL_COLUMNS) - def test_all_instances_in_all_columns_list(self, col_obj: BDFColumn) -> None: - """All exported instances appear in ALL_COLUMNS.""" - assert col_obj in ALL_COLUMNS - - @pytest.mark.parametrize("col_obj", ALL_COLUMNS) - def test_all_instances_have_iri(self, col_obj: BDFColumn) -> None: - """All BDF-standard instances have IRI URLs starting with BDF_IRI_PREFIX.""" - assert col_obj.iri is not None - assert col_obj.iri.startswith(BDF_IRI_PREFIX) + assert cs.can_resolve(column) is expected # type: ignore[arg-type] + + @pytest.mark.parametrize( + "available, column, expected", + [ + # recipe resolvable (standard units) + ( + ["Charging Capacity / Ah", "Discharging Capacity / Ah"], + "Net Capacity / Ah", + True, + ), + # recipe resolvable (non-standard units) + ( + ["Charging Capacity / mAh", "Discharging Capacity / mAh"], + "Net Capacity / Ah", + True, + ), + # recipe + unit conversion on result + ( + ["Charging Capacity / mAh", "Discharging Capacity / mAh"], + "Net Capacity / mAh", + True, + ), + # recipe not resolvable (wrong deps) + (["Voltage / V"], "Net Capacity / Ah", False), + ], + ) + def test_can_resolve_recipe( + self, available: list[str], column: str, expected: bool + ) -> None: + """can_resolve handles recipe-based BDF columns correctly.""" + assert ColumnSet(available).can_resolve(column) is expected From 2837ad767e3be85aae0aa40b3134c332e6510982 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:58:59 +0000 Subject: [PATCH 12/40] refactor(io): update io module to use new BDF Enum --- pyprobe/io.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/pyprobe/io.py b/pyprobe/io.py index fd45d09f..442fc5ec 100644 --- a/pyprobe/io.py +++ b/pyprobe/io.py @@ -25,31 +25,25 @@ from pyprobe.filters import Procedure from pyprobe.column import ( - BDFColumn, - Column, + BDF, ColumnSet, - current_ampere, - net_capacity_ah, - step_count, - step_index, - test_time_second, - voltage_volt, + column_factory_from_string, ) _PARQUET_METADATA_KEY: bytes = b"bdx_metadata" """Key used to store user metadata in Parquet footer.""" -_REQUIRED_BDF_COLUMNS: list[BDFColumn] = [ - test_time_second, - current_ampere, - voltage_volt, +_REQUIRED_BDF_COLUMNS: list[BDF] = [ + BDF.TEST_TIME_SECOND, + BDF.CURRENT_AMPERE, + BDF.VOLTAGE_VOLT, ] """BDF columns that must be resolvable; :func:`process_cycler` raises if not.""" -_OPTIONAL_BDF_COLUMNS: list[BDFColumn] = [ - net_capacity_ah, - step_count, - step_index, +_OPTIONAL_BDF_COLUMNS: list[BDF] = [ + BDF.NET_CAPACITY_AH, + BDF.STEP_COUNT, + BDF.STEP_INDEX, ] """BDF columns included when available; warnings are emitted on failure.""" @@ -403,7 +397,7 @@ def process_cycler( for bdf_col in _REQUIRED_BDF_COLUMNS: try: - expressions.append(column_set.col(bdf_col)) + expressions.append(column_set.resolve(bdf_col)) except ValueError as exc: raise ValueError( f"Required BDF column '{bdf_col.quantity}' could not be resolved " @@ -412,7 +406,7 @@ def process_cycler( for bdf_col in _OPTIONAL_BDF_COLUMNS: try: - expressions.append(column_set.col(bdf_col)) + expressions.append(column_set.resolve(bdf_col)) except ValueError: logger.warning( "Optional BDF column '{}' could not be resolved; skipping.", @@ -427,7 +421,7 @@ def process_cycler( # Invalid: "InvalidNoUnit", "Pressure kPa", "/ kPa", "", "Quantity //" strict_pattern = r"^(.+?)\s*/\s*([^/]+(?:/[^/]+)*)$" for output_name in extra_columns: - Column.from_string(output_name, pattern=strict_pattern) + column_factory_from_string(output_name, pattern=strict_pattern) # Dual bdf.read() calls are necessary: the initial read (above) normalizes # column names to BDF standard, while this read with normalize=False From 08cd4e36f4e0cdb8b60de6d6a6e4710cc13341c9 Mon Sep 17 00:00:00 2001 From: Tom Holland <137503955+tomjholland@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:13:05 +0000 Subject: [PATCH 13/40] refactor(*): use BDF enum and ColumnSet for column resolution integrate BDF-aware column resolution and unit conversion into the Result class hierarchy via ColumnSet. replace hardcoded column strings with BDF enum references across filters and rawdata modules. simplify metadata management and remove automatic column zeroing from filtered objects. introduce a Procedure.load method for simplified initialization. --- pyprobe/filters.py | 324 ++++++++---------- pyprobe/plot.py | 59 +--- pyprobe/rawdata.py | 280 +++++++-------- pyprobe/result.py | 186 +++++----- tests/conftest.py | 12 +- .../neware/sample_data_neware.bdx.parquet | Bin 0 -> 9207123 bytes tests/test_filter.py | 107 +++--- tests/test_plot.py | 272 ++++++++++++--- tests/test_rawdata.py | 105 ++---- tests/test_result.py | 175 ++++++---- 10 files changed, 785 insertions(+), 735 deletions(-) create mode 100644 tests/sample_data/neware/sample_data_neware.bdx.parquet diff --git a/pyprobe/filters.py b/pyprobe/filters.py index 14b812d9..7f369a92 100644 --- a/pyprobe/filters.py +++ b/pyprobe/filters.py @@ -1,15 +1,17 @@ """A module for the filtering classes.""" import warnings -from typing import TYPE_CHECKING, Any, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast import polars as pl from pyprobe import utils +from pyprobe.column import BDF, ColumnSet from pyprobe.rawdata import RawData if TYPE_CHECKING: - from pyprobe.pyprobe_types import ( # , FilterToStepType + from pyprobe.pyprobe_types import ( ExperimentOrCycleType, FilterToCycleType, ) @@ -20,15 +22,15 @@ def _filter_numerical( dataframe: pl.LazyFrame | pl.DataFrame, - column: str, + column: str | pl.Expr, indices: tuple[int | range, ...], ) -> pl.LazyFrame | pl.DataFrame: - """Filter a polars Lazyframe or Dataframe by a numerical condition. + """Filter a polars LazyFrame or DataFrame by a numerical condition. Args: - dataframe (pl.LazyFrame | pl.DataFrame): A LazyFrame or DataFrame to filter. - column (str): The column to filter on. - indices (Tuple[Union[int, range], ...]): A tuple of index values to filter by. + dataframe: A LazyFrame or DataFrame to filter. + column: The column name or expression to filter on. + indices: A tuple of index values to filter by. Returns: pl.LazyFrame | pl.DataFrame: A filtered LazyFrame or DataFrame. @@ -44,13 +46,14 @@ def _filter_numerical( index_list.extend([index]) if len(index_list) > 0: + col_expr = pl.col(column) if isinstance(column, str) else column if all(item >= 0 for item in index_list): index_list = [item + 1 for item in index_list] - return dataframe.filter(pl.col(column).rank("dense").is_in(index_list)) + return dataframe.filter(col_expr.rank("dense").is_in(index_list)) elif all(item < 0 for item in index_list): index_list = [item * -1 for item in index_list] return dataframe.filter( - pl.col(column).rank("dense", descending=True).is_in(index_list), + col_expr.rank("dense", descending=True).is_in(index_list), ) else: error_msg = "Indices must be all positive or all negative." @@ -65,30 +68,28 @@ def _step( *step_numbers: int | range, condition: pl.Expr | None = None, ) -> "Step": - """Return a step object. Filters to a numerical condition on the Event column. + """Return a step object. Filters to a numerical condition on the Step Index column. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - step_numbers (int | range): - Variable-length argument list of step indices or a range object. - condition (pl.Expr, optional): - A polars expression to filter the step before applying the numerical filter. - Defaults to None. + filtered_object: A filter object that this method is called on. + step_numbers: Variable-length argument list of step indices or a range object. + condition: A polars expression to filter the step before applying the numerical + filter. Defaults to None. Returns: Step: A step object. """ + step_index_expr = filtered_object.columns.resolve(BDF.STEP_INDEX) if condition is not None: lf = _filter_numerical( filtered_object.lf.filter(condition), - "Event", + step_index_expr, step_numbers, ) else: lf = _filter_numerical( filtered_object.lf, - "Event", + step_index_expr, step_numbers, ) return Step( @@ -102,62 +103,69 @@ def _step( def get_cycle_column( filtered_object: "FilterToCycleType", ) -> pl.DataFrame | pl.LazyFrame: - """Adds a cycle column to the data. + """Add a Cycle Count column to the data. - If cycle details have been provided in the README, the cycle column will be created - by checking for the last step of the cycle. For nested cycles, the "outer" cycle - will be created first. Subsequent filtering with the cycle method will then allow - for filtering on the "inner" cycles. + If cycle details have been provided in the README, the cycle column will be + created by checking for the last step of the cycle. For nested cycles, the + "outer" cycle will be created first; subsequent filtering with the cycle method + allows for filtering on the "inner" cycles. - If no cycle details have been provided, the cycle column will be created by - identifying the last step of the cycle by checking for a decrease in the step - number. + If no cycle details have been provided, the cycle column will be inferred from + a decrease in the step count. Args: filtered_object: The experiment or cycle object. Returns: - pl.DataFrame | pl.LazyFrame: The data with a cycle column. + pl.DataFrame | pl.LazyFrame: The data with a cycle count column. """ + step_expr = filtered_object.columns.resolve(BDF.STEP_INDEX) + cycle_col_name = BDF.CYCLE_COUNT.name if len(filtered_object.cycle_info) > 0: - cycle_ends = (pl.col("Step").shift() == filtered_object.cycle_info[0][1]) & ( - pl.col("Step") != filtered_object.cycle_info[0][1] - ).fill_null(strategy="zero").cast(pl.Int16) - cycle_column = cycle_ends.cum_sum().fill_null(strategy="zero").alias("Cycle") + cycle_ends = ( + ( + (step_expr.shift() == filtered_object.cycle_info[0][1]) + & (step_expr != filtered_object.cycle_info[0][1]) + ) + .fill_null(strategy="zero") + .cast(pl.Int16) + ) + cycle_column = ( + cycle_ends.cum_sum().fill_null(strategy="zero").alias(cycle_col_name) + ) else: warnings.warn( "No cycle information provided. Cycles will be inferred from the step " "numbers.", ) cycle_column = ( - (pl.col("Step").cast(pl.Int64) - pl.col("Step").cast(pl.Int64).shift() < 0) + (step_expr.cast(pl.Int64) - step_expr.cast(pl.Int64).shift() < 0) .fill_null(strategy="zero") .cum_sum() - .alias("Cycle") + .alias(cycle_col_name) ) return filtered_object.lf.with_columns(cycle_column) def _cycle(filtered_object: "ExperimentOrCycleType", *cycle_numbers: int) -> "Cycle": - """Return a cycle object. Filters on the Cycle column. + """Return a cycle object. Filters on the Cycle Count column. Args: - filtered_object (FilterToExperimentType): - A filter object that this method is called on. - cycle_numbers (int | range): - Variable-length argument list of cycle indices or a range object. + filtered_object: A filter object that this method is called on. + cycle_numbers: Variable-length argument list of cycle indices or a range object. Returns: Cycle: A cycle object. """ df = get_cycle_column(filtered_object) - if len(filtered_object.cycle_info) > 1: next_cycle_info = filtered_object.cycle_info[1:] else: next_cycle_info = [] - lf_filtered = _filter_numerical(df, "Cycle", cycle_numbers) + df_column_set = ColumnSet(df.collect_schema().names()) + cycle_expr = df_column_set.resolve(BDF.CYCLE_COUNT) + lf_filtered = _filter_numerical(df, cycle_expr, cycle_numbers) return Cycle( lf=lf_filtered, @@ -175,15 +183,15 @@ def _charge( """Return a charge step. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - charge_numbers (int | range): - Variable-length argument list of charge indices or a range object. + filtered_object: A filter object that this method is called on. + charge_numbers: Variable-length argument list of charge indices or a range + object. Returns: Step: A charge step object. """ - condition = pl.col("Current [A]") > pl.col("Current [A]").abs().max() / 10e4 + current_expr = filtered_object.columns.resolve(BDF.CURRENT_AMPERE) + condition = current_expr > current_expr.abs().max() / 10e4 return filtered_object.step(*charge_numbers, condition=condition) @@ -194,15 +202,15 @@ def _discharge( """Return a discharge step. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - discharge_numbers (int | range): - Variable-length argument list of discharge indices or a range object. + filtered_object: A filter object that this method is called on. + discharge_numbers: Variable-length argument list of discharge indices or a range + object. Returns: Step: A discharge step object. """ - condition = pl.col("Current [A]") < -pl.col("Current [A]").abs().max() / 10e4 + current_expr = filtered_object.columns.resolve(BDF.CURRENT_AMPERE) + condition = current_expr < -current_expr.abs().max() / 10e4 return filtered_object.step(*discharge_numbers, condition=condition) @@ -213,19 +221,16 @@ def _chargeordischarge( """Return a charge or discharge step. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - chargeordischarge_numbers (int | range): - Variable-length argument list of charge or discharge indices or a range - object. + filtered_object: A filter object that this method is called on. + chargeordischarge_numbers: Variable-length argument list of charge or discharge + indices or a range object. Returns: Step: A charge or discharge step object. """ - charge_condition = pl.col("Current [A]") > pl.col("Current [A]").abs().max() / 10e4 - discharge_condition = ( - pl.col("Current [A]") < -pl.col("Current [A]").abs().max() / 10e4 - ) + current_expr = filtered_object.columns.resolve(BDF.CURRENT_AMPERE) + charge_condition = current_expr > current_expr.abs().max() / 10e4 + discharge_condition = current_expr < -current_expr.abs().max() / 10e4 condition = charge_condition | discharge_condition return filtered_object.step(*chargeordischarge_numbers, condition=condition) @@ -234,15 +239,14 @@ def _rest(filtered_object: "FilterToCycleType", *rest_numbers: int | range) -> " """Return a rest step object. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - rest_numbers (int | range): - Variable-length argument list of rest indices or a range object. + filtered_object: A filter object that this method is called on. + rest_numbers: Variable-length argument list of rest indices or a range object. Returns: Step: A rest step object. """ - condition = pl.col("Current [A]") == 0 + current_expr = filtered_object.columns.resolve(BDF.CURRENT_AMPERE) + condition = current_expr == 0 return filtered_object.step(*rest_numbers, condition=condition) @@ -253,24 +257,18 @@ def _constant_current( """Return a constant current step object. Args: - filtered_object (FilterToCycleType): - A filter object that this method is called on. - constant_current_numbers (int | range): - Variable-length argument list of constant current indices or a range object. + filtered_object: A filter object that this method is called on. + constant_current_numbers: Variable-length argument list of constant current + indices or a range object. Returns: Step: A constant current step object. """ + current_expr = filtered_object.columns.resolve(BDF.CURRENT_AMPERE) condition = ( - (pl.col("Current [A]") != 0) - & ( - pl.col("Current [A]").abs() - > 0.999 * pl.col("Current [A]").abs().round_sig_figs(4).mode() - ) - & ( - pl.col("Current [A]").abs() - < 1.001 * pl.col("Current [A]").abs().round_sig_figs(4).mode() - ) + (current_expr != 0) + & (current_expr.abs() > 0.999 * current_expr.abs().round_sig_figs(4).mode()) + & (current_expr.abs() < 1.001 * current_expr.abs().round_sig_figs(4).mode()) ) return filtered_object.step(*constant_current_numbers, condition=condition) @@ -283,19 +281,16 @@ def _constant_voltage( Args: filtered_object: A filter object that this method is called on. - *constant_voltage_numbers: - Variable-length argument list of constant voltage indices or a range object. + *constant_voltage_numbers: Variable-length argument list of constant voltage + indices or a range object. Returns: Step: A constant voltage step object. """ + voltage_expr = filtered_object.columns.resolve(BDF.VOLTAGE_VOLT) condition = ( - pl.col("Voltage [V]").abs() - > 0.999 * pl.col("Voltage [V]").abs().round_sig_figs(4).mode() - ) & ( - pl.col("Voltage [V]").abs() - < 1.001 * pl.col("Voltage [V]").abs().round_sig_figs(4).mode() - ) + voltage_expr.abs() > 0.999 * voltage_expr.abs().round_sig_figs(4).mode() + ) & (voltage_expr.abs() < 1.001 * voltage_expr.abs().round_sig_figs(4).mode()) return filtered_object.step(*constant_voltage_numbers, condition=condition) @@ -330,21 +325,10 @@ def __init__( ) self.readme_dict = readme_dict self.cycle_info = cycle_info.copy() if cycle_info is not None else [] - self._initialize_procedure() - - def _initialize_procedure(self) -> None: - """Create a procedure class.""" - self.zero_column( - "Time [s]", - "Procedure Time [s]", - "Time elapsed since beginning of procedure.", - ) + self._populate_step_descriptions() - self.zero_column( - "Capacity [Ah]", - "Procedure Capacity [Ah]", - "The net charge passed since beginning of procedure.", - ) + def _populate_step_descriptions(self) -> None: + """Populate step_descriptions from readme_dict.""" self.step_descriptions = {"Step": [], "Description": []} for experiment in self.readme_dict: steps = cast(list[int], self.readme_dict[experiment]["Steps"]) @@ -357,6 +341,61 @@ def _initialize_procedure(self) -> None: self.step_descriptions["Step"].extend(steps) self.step_descriptions["Description"].extend(descriptions) + @classmethod + def load( + cls, + parquet_path: str | Path, + readme_path: str | Path | None = None, + metadata: dict[str, Any | None] | None = None, + metadata_prefer: Literal["parquet", "json"] = "parquet", + ) -> "Procedure": + """Load a Procedure from a processed .bdx.parquet file. + + Args: + parquet_path: Path to a ``.bdx.parquet`` file (e.g. from + :func:`~pyprobe.io.process_cycler`). + readme_path: Optional path to a README.yaml for experiment definitions. + When None, no experiment filtering is available. + metadata: Optional metadata dict merged with parquet-stored metadata. + Provided values take precedence over parquet-stored values. + metadata_prefer: Whether to prefer ``"parquet"`` footer or ``"json"`` + sidecar when both sources have metadata. + + Returns: + Procedure with BDF-format columns and optional experiment definitions. + + Raises: + FileNotFoundError: If *parquet_path* does not exist. + + Example:: + + from pyprobe.io import process_cycler + from pyprobe.filters import Procedure + + path = process_cycler("data.xlsx") + procedure = Procedure.load(path, readme_path="README.yaml") + """ + from pyprobe.io import read_metadata + from pyprobe.readme_processor import process_readme + + parquet_path = Path(parquet_path) + if not parquet_path.exists(): + raise FileNotFoundError(f"Parquet file not found: {parquet_path}") + + lf = pl.scan_parquet(parquet_path) + parquet_metadata = read_metadata(parquet_path, prefer=metadata_prefer) + merged: dict[str, Any | None] = {**parquet_metadata, **(metadata or {})} + + readme_dict: dict[str, dict[str, Any]] = {} + if readme_path is not None: + rp = Path(readme_path) + if rp.exists(): + readme_dict = process_readme(str(rp)).experiment_dict + else: + logger.warning("README path provided but not found: {}", readme_path) + + return cls(lf=lf, metadata=merged, readme_dict=readme_dict) + step = _step cycle = _cycle charge = _charge @@ -370,8 +409,7 @@ def experiment(self, *experiment_names: str) -> "Experiment": """Return an experiment object from the procedure. Args: - experiment_names (str): - Variable-length argument list of experiment names. + experiment_names: Variable-length argument list of experiment names. Returns: Experiment: An experiment object from the procedure. @@ -385,7 +423,7 @@ def experiment(self, *experiment_names: str) -> "Experiment": steps_idx.append(self.readme_dict[experiment_name]["Steps"]) flattened_steps = utils.flatten_list(steps_idx) conditions = [ - pl.col("Step").is_in(flattened_steps), + pl.col(BDF.STEP_INDEX.name).is_in(flattened_steps), ] lf_filtered = self.lf.filter(conditions) cycles_list: list[tuple[int, int, int]] = [] @@ -395,9 +433,7 @@ def experiment(self, *experiment_names: str) -> "Experiment": "the step numbers.", ) elif "Cycles" in self.readme_dict[experiment_names[0]]: - # ignore type on below line due to persistent mypy warnings about - # incompatible types - cycles_list = self.readme_dict[experiment_names[0]]["Cycles"] # type: ignore + cycles_list = self.readme_dict[experiment_names[0]]["Cycles"] # type: ignore[assignment] return Experiment( lf=lf_filtered, @@ -411,8 +447,7 @@ def remove_experiment(self, *experiment_names: str) -> None: """Remove an experiment from the procedure. Args: - experiment_names (str): - Variable-length argument list of experiment names. + experiment_names: Variable-length argument list of experiment names. """ steps_idx = [] for experiment_name in experiment_names: @@ -423,11 +458,11 @@ def remove_experiment(self, *experiment_names: str) -> None: steps_idx.append(self.readme_dict[experiment_name]["Steps"]) flattened_steps = utils.flatten_list(steps_idx) conditions = [ - pl.col("Step").is_in(flattened_steps).not_(), + pl.col(BDF.STEP_INDEX.name).is_in(flattened_steps).not_(), ] for experiment_name in experiment_names: self.readme_dict.pop(experiment_name) - self._initialize_procedure() + self._populate_step_descriptions() self.lf = self.lf.filter(conditions) @property @@ -451,28 +486,12 @@ def add_external_data( ) -> None: """Add data from another source to the procedure. - The data must be timestamped, with a column that can be interpreted in - DateTime format. The data will be interpolated to the procedure's time. - Args: - filepath (str): The path to the external file. - importing_columns (List[str] | dict[str, str]): - The columns to import from the external file. If a list, the columns - will be imported as is. If a dict, the keys are the columns in the data - you want to import and the values are the columns you want to rename - them to. - date_column_name (str, optional): - The name of the date column in the external data. Defaults to "Date". + filepath: The path to the external file. + importing_columns: The columns to import from the external file. + date_column_name: The name of the date column in the external data. """ - external_data = self.load_external_file(filepath) - if isinstance(importing_columns, dict): - external_data = external_data.select( - [date_column_name] + list(importing_columns.keys()), - ) - external_data = external_data.rename(importing_columns) - elif isinstance(importing_columns, list): - external_data = external_data.select([date_column_name] + importing_columns) - self.add_new_data_columns(external_data, date_column_name) + self.add_data(filepath, date_column_name, importing_columns=importing_columns) class Experiment(RawData): @@ -510,21 +529,6 @@ def __init__( step_descriptions=step_descriptions, ) self.cycle_info = cycle_info.copy() if cycle_info is not None else [] - self._initialize_experiment() - - def _initialize_experiment(self) -> None: - """Create an experiment class.""" - self.zero_column( - "Time [s]", - "Experiment Time [s]", - "Time elapsed since beginning of experiment.", - ) - - self.zero_column( - "Capacity [Ah]", - "Experiment Capacity [Ah]", - "The net charge passed since beginning of experiment.", - ) step = _step cycle = _cycle @@ -570,21 +574,6 @@ def __init__( step_descriptions=step_descriptions, ) self.cycle_info = cycle_info.copy() if cycle_info is not None else [] - self._initialize_cycle() - - def _initialize_cycle(self) -> None: - """Create a cycle class.""" - self.zero_column( - "Time [s]", - "Cycle Time [s]", - "Time elapsed since beginning of cycle.", - ) - - self.zero_column( - "Capacity [Ah]", - "Cycle Capacity [Ah]", - "The net charge passed since beginning of cycle.", - ) step = _step charge = _charge @@ -619,21 +608,6 @@ def __init__( column_definitions=column_definitions, step_descriptions=step_descriptions, ) - self._initialize_step() - - def _initialize_step(self) -> None: - """Create a step class.""" - self.zero_column( - "Time [s]", - "Step Time [s]", - "Time elapsed since beginning of step.", - ) - - self.zero_column( - "Capacity [Ah]", - "Step Capacity [Ah]", - "The net charge passed since beginning of step.", - ) step = _step constant_current = _constant_current diff --git a/pyprobe/plot.py b/pyprobe/plot.py index ccd45e2e..951ac3fd 100644 --- a/pyprobe/plot.py +++ b/pyprobe/plot.py @@ -4,52 +4,8 @@ from functools import wraps from typing import TYPE_CHECKING, Any -import polars as pl - if TYPE_CHECKING: - from pyprobe.result import Result - -from pyprobe.units import split_quantity_unit - - -def _retrieve_relevant_columns( - result_obj: "Result", - args: tuple[Any, ...], - kwargs: dict[Any, Any], -) -> pl.DataFrame: - """Retrieve relevant columns from a Result object for plotting. - - This function analyses the arguments passed to a plotting function and retrieves the - used columns from the Result object. - - Args: - result_obj: The Result object. - args: The positional arguments passed to the plotting function. - kwargs: The keyword arguments passed to the plotting function. - - Returns: - A dataframe containing the relevant columns from the Result object. - """ - kwargs_values = [ - v for k, v in kwargs.items() if isinstance(v, str) and k != "label" - ] - args_values = [v for v in args if isinstance(v, str)] - all_args = set(kwargs_values + args_values) - relevant_columns = [] - for arg in all_args: - try: - quantity, _ = split_quantity_unit(arg) - - except ValueError: - continue - if quantity in result_obj.quantities: - relevant_columns.append(arg) - if len(relevant_columns) == 0: - raise ValueError( - f"None of the columns in {all_args} are present in the Result object.", - ) - result_obj.check_columns(relevant_columns) - return result_obj.lf.select(*relevant_columns).collect() + pass try: @@ -97,11 +53,14 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: The result of the wrapped function. """ if "data" in kwargs: - kwargs["data"] = _retrieve_relevant_columns( - kwargs["data"], - args, - kwargs, - ).to_pandas() + kwargs["data"] = ( + kwargs["data"] + .get_plotting_data( + args, + kwargs, + ) + .to_pandas() + ) if func.__name__ == "lineplot" and "estimator" not in kwargs: kwargs["estimator"] = None return func(*args, **kwargs) diff --git a/pyprobe/rawdata.py b/pyprobe/rawdata.py index f101a817..6f0c2921 100644 --- a/pyprobe/rawdata.py +++ b/pyprobe/rawdata.py @@ -5,57 +5,42 @@ import polars as pl from loguru import logger +from pyprobe.column import BDF from pyprobe.result import Result -from pyprobe.units import split_quantity_unit from pyprobe.utils import deprecated -required_columns = [ - "Time [s]", - "Step", - "Event", - "Current [A]", - "Voltage [V]", - "Capacity [Ah]", -] - -default_column_definitions = { - "Date": "The timestamp of the data point. Type: datetime.", - "Time": "The time passed from the start of the procedure.", - "Step": "The step number.", - "Cycle": "The cycle number.", - "Event": "The event number. Counts the changes in cycles and steps.", - "Current": "The current through the cell.", - "Voltage": "The terminal voltage.", - "Capacity": "The net charge passed since the start of the procedure.", - "Temperature": "The temperature of the cell.", -} +_REQUIRED_BDF: list[BDF] = [BDF.TEST_TIME_SECOND, BDF.CURRENT_AMPERE, BDF.VOLTAGE_VOLT] +"""BDF columns that must be resolvable; RawData raises ValueError if not.""" + +_OPTIONAL_BDF: list[BDF] = [BDF.NET_CAPACITY_AH, BDF.STEP_COUNT, BDF.STEP_INDEX] +"""BDF columns included when available; warnings emitted on failure.""" class RawData(Result): - """A class for holding data in the PyProBE format. + """A class for holding battery cycler data in BDF-standard column format. This is the default object returned when data is loaded into PyProBE with the - standard methods of the `pyprobe.cell.Cell` class. It is a subclass of the - `pyprobe.result.Result` class so can be used in the same way as other result - objects. - - The RawData object is stricter than the `pyprobe.result.Result` object in that it - requires the presence of specific columns in the data. These columns are: - - `Time [s]` - - `Step` - - `Cycle` - - `Event` - - `Current [A]` - - `Voltage [V]` - - `Capacity [Ah]` - - This defines the PyProBE format. + standard methods of the :class:`~pyprobe.cell.Cell` class. It is a subclass of + :class:`~pyprobe.result.Result` and can be used in the same way. + + The RawData object validates that the three required BDF columns are resolvable + from the data via :class:`~pyprobe.column.ColumnSet`: + + - ``Test Time / s`` + - ``Current / A`` + - ``Voltage / V`` + + The following BDF columns are optional but emit a warning if absent: + + - ``Net Capacity / Ah`` + - ``Step Count / 1`` + - ``Step Index / 1`` """ step_descriptions: dict[str, list[str | int | None]] """A dictionary containing the fields 'Step' and 'Description'. - - 'Step' is a list of step numbers. + - 'Step' is a list of step numbers (from the README). - 'Description' is a list of corresponding descriptions in PyBaMM Experiment format. """ @@ -66,9 +51,7 @@ def __init__( column_definitions: dict[str, str] | None = None, step_descriptions: dict[str, list[str | int | None]] | None = None, ) -> None: - """Create a RawData object with required-column validation.""" - if column_definitions is None: - column_definitions = default_column_definitions.copy() + """Create a RawData object with BDF-column validation.""" super().__init__( lf=lf, metadata=metadata, column_definitions=column_definitions ) @@ -83,55 +66,53 @@ def __init__( self._check_required_columns() def _check_required_columns(self) -> None: - """Check if the required columns are present in the data.""" - columns = self.lf.collect_schema().names() - missing_columns = [col for col in required_columns if col not in columns] - if missing_columns: - error_msg = f"Missing required columns: {missing_columns}" - logger.error(error_msg) - raise ValueError(error_msg) + """Validate that required and optional BDF columns are resolvable. - @property - def data(self) -> pl.DataFrame: - """Return the data as a polars DataFrame. - - Returns: - pl.DataFrame: The data as a polars DataFrame. + Required columns must be resolvable from the data (either as a direct + data column or via a recipe derivation). Optional columns emit a warning + if unavailable but do not raise an error. Raises: - ValueError: If no data exists for this filter. + ValueError: If any required BDF column (Test Time, Current, Voltage) + cannot be resolved from available data. """ - dataframe = super().data - unsorted_columns = set(dataframe.collect_schema().names()) - set( - required_columns, - ) - sorted_columns = list(required_columns) + list(unsorted_columns) - return dataframe.select(sorted_columns) + col_set = self.columns + for bdf_col in _REQUIRED_BDF: + if not col_set.can_resolve(bdf_col): + error_msg = ( + f"Required BDF column '{bdf_col.name}' is not resolvable " + f"from available columns." + ) + logger.error(error_msg) + raise ValueError(error_msg) + for bdf_col in _OPTIONAL_BDF: + if not col_set.can_resolve(bdf_col): + logger.warning( + "Optional BDF column '%s' is not resolvable; some features may " + "be unavailable.", + bdf_col.name, + ) def zero_column( self, column: str, - new_column_name: str, - new_column_definition: str | None = None, + definition: str | None = None, ) -> None: - """Set the first value of a column to zero. + """Zero a column relative to the start of this data slice. + + Modifies *column* in-place so it starts at zero at the first row of + this slice. Args: - column (str): The column to zero. - new_column_name (str): The new column name. - new_column_definition (Optional[str]): The new column definition. + column: The column name to zero. + definition: Optional description for the column definition. """ self.lf = self.lf.with_columns( - (pl.col(column) - pl.col(column).first()).alias(new_column_name), + (pl.col(column) - pl.col(column).first()).alias(column), ) - new_column_quantity, _ = split_quantity_unit(new_column_name) - if new_column_definition is not None: - self.define_column(new_column_quantity, new_column_definition) - else: - self.define_column( - new_column_quantity, - f"{column} with first value zeroed.", - ) + if definition is not None: + quantity = column.split(" / ")[0] if " / " in column else column + self.define_column(quantity, definition) @property def capacity(self) -> float: @@ -140,7 +121,11 @@ def capacity(self) -> float: Returns: float: The net capacity passed. """ - return abs(self.data["Capacity [Ah]"].max() - self.data["Capacity [Ah]"].min()) + col = BDF.NET_CAPACITY_AH.name + result = self.lf.select( + (pl.col(col).max() - pl.col(col).min()).abs().alias("_cap") + ).collect() + return float(result["_cap"][0]) # type: ignore[index] def set_soc( self, @@ -149,58 +134,51 @@ def set_soc( ) -> None: """Add an SOC column to the data. - Apply this method on a filtered data object to add an `SOC` column to the data. + Apply this method on a filtered data object to add an ``SOC`` column. This column remains with the data if the object is filtered further. - The SOC column is calculated either relative to a provided reference capacity value, a reference charge (provided as a RawData object), or the maximum capacity delta across the data in the RawData object upon which this method is called. Args: - reference_capacity (Optional[float]): The reference capacity value. - reference_charge (Optional[RawData]): - A RawData object containing a charge to use as a reference. + reference_capacity: The reference capacity value. + reference_charge: A RawData object containing a charge to use as a + reference. """ + cap_col = BDF.NET_CAPACITY_AH.name if reference_capacity is None: - reference_capacity = ( - pl.col("Capacity [Ah]").max() - pl.col("Capacity [Ah]").min() + reference_capacity = float( + self.lf.select( + (pl.col(cap_col).max() - pl.col(cap_col).min()).alias("_ref") + ) + .collect() + .item() ) if reference_charge is None: self.lf = self.lf.with_columns( ( - ( - pl.col("Capacity [Ah]") - - pl.col("Capacity [Ah]").max() - + reference_capacity - ) + (pl.col(cap_col) - pl.col(cap_col).max() + reference_capacity) / reference_capacity ).alias("SOC"), ) else: - reference_charge_data = reference_charge.lf.select( - "Time [s]", - "Capacity [Ah]", - ) + time_col = BDF.TEST_TIME_SECOND.name + reference_charge_data = reference_charge.lf.select(time_col, cap_col) self.lf = self.lf.join( reference_charge_data, - on="Time [s]", + on=time_col, how="left", ) - self.lf = self.lf.with_columns( - pl.col("Capacity [Ah]_right") - .max() - .alias("Full charge reference capacity"), - ).drop("Capacity [Ah]_right") - + right_col = cap_col + "_right" + full_ref = float( + self.lf.select(pl.col(right_col).max().alias("_fc")).collect().item() + ) + self.lf = self.lf.drop(right_col) self.lf = self.lf.with_columns( ( - ( - pl.col("Capacity [Ah]") - - pl.col("Full charge reference capacity") - + reference_capacity - ) + (pl.col(cap_col) - full_ref + reference_capacity) / reference_capacity ).alias("SOC"), ) @@ -217,77 +195,67 @@ def set_SOC( # noqa: N802 ) -> None: """Add an SOC column to the data. - Apply this method on a filtered data object to add an `SOC` column to the data. - This column remains with the data if the object is filtered further. - - - The SOC column is calculated either relative to a provided reference capacity - value, a reference charge (provided as a RawData object), or the maximum - capacity delta across the data in the RawData object upon which this method - is called. - Args: - reference_capacity (Optional[float]): The reference capacity value. - reference_charge (Optional[RawData]): - A RawData object containing a charge to use as a reference. + reference_capacity: The reference capacity value. + reference_charge: A RawData object containing a charge to use as a + reference. """ self.set_soc(reference_capacity, reference_charge) def set_reference_capacity(self, reference_capacity: float | None = None) -> None: """Fix the capacity to a reference value. - Apply this method on a filtered data object to fix the capacity to a reference. - This calculates a permanent column named `Capacity - Referenced [Ah]` in the - data, which remains if this object is filtered further. - - The reference value is either the maximum capacity delta across the data in the - RawData object upon which this method is called or a user-specified value. + Apply this method on a filtered data object to fix the capacity to a + reference. This calculates a permanent column named + ``Capacity - Referenced / Ah`` in the data. Args: - reference_capacity (Optional[float]): The reference capacity value. + reference_capacity: The reference capacity value. """ + cap_col = BDF.NET_CAPACITY_AH.name if reference_capacity is None: - reference_capacity = ( - pl.col("Capacity [Ah]").max() - pl.col("Capacity [Ah]").min() + reference_capacity = float( + self.lf.select( + (pl.col(cap_col).max() - pl.col(cap_col).min()).alias("_ref") + ) + .collect() + .item() ) self.lf = self.lf.with_columns( - ( - pl.col("Capacity [Ah]") - - pl.col("Capacity [Ah]").max() - + reference_capacity - ).alias("Capacity - Referenced [Ah]"), + (pl.col(cap_col) - pl.col(cap_col).max() + reference_capacity).alias( + "Capacity - Referenced / Ah" + ), ) @property def pybamm_experiment(self) -> list[str | tuple[str]]: """Return a list of operating conditions for a PyBaMM experiment object. - These can be passed directly to pybamm.Experiment() to create an experiment - for use with PyBaMM. - - PyProBE does not check the validity of the operating condition strings. When - creating the Experiment object, PyBaMM will raise an error if the operating - conditions are not valid. The user should then modify the step descriptions - in the readme file accordingly. + These can be passed directly to ``pybamm.Experiment()`` to create an + experiment for use with PyBaMM. Returns: The PyBaMM operating conditions. """ - # reduce the full dataframe to only the steps as they appear in order in - # the data - only_steps = ( + step_index_col = BDF.STEP_INDEX.name + step_count_col = BDF.STEP_COUNT.name + only_steps: pl.DataFrame = ( self.lf.with_row_index() - .group_by("Event", maintain_order=True) - .agg(pl.col("Step").first()) + .group_by(step_count_col, maintain_order=True) + .agg(pl.col(step_index_col).first()) + .collect() ) - if isinstance(only_steps, pl.LazyFrame): - only_steps = only_steps.collect() - step_description_df = pl.DataFrame(self.step_descriptions) + step_description_df = pl.DataFrame( + { + step_index_col: self.step_descriptions.get("Step", []), + "Description": self.step_descriptions.get("Description", []), + } + ) no_step_descriptions = step_description_df.filter( pl.col("Description").is_null(), ) - missing_steps = no_step_descriptions.select("Step").to_numpy().flatten() + missing_steps = no_step_descriptions.select(step_index_col).to_numpy().flatten() if len(missing_steps) > 0: error_msg = ( f"Descriptions for steps {str(missing_steps)} are missing." @@ -298,14 +266,16 @@ def pybamm_experiment(self) -> list[str | tuple[str]]: logger.error(error_msg) raise ValueError(error_msg) - # match the step with its description - all_steps_with_descriptions = only_steps.join( - step_description_df, - on="Step", - how="left", - ).select("Description") - # form a list of all the descriptions - all_steps_with_descriptions = all_steps_with_descriptions.to_numpy().flatten() + all_steps_with_descriptions = ( + only_steps.join( + step_description_df, + on=step_index_col, + how="left", + ) + .select("Description") + .to_numpy() + .flatten() + ) description_list = [] for description in all_steps_with_descriptions: line = description.split(",") diff --git a/pyprobe/result.py b/pyprobe/result.py index 0e5bb917..30ccde74 100644 --- a/pyprobe/result.py +++ b/pyprobe/result.py @@ -18,8 +18,7 @@ from scipy.io import savemat from tzlocal import get_localzone -from pyprobe.plot import _retrieve_relevant_columns -from pyprobe.units import get_unit_scaling, split_quantity_unit +from pyprobe.column import ColumnSet from pyprobe.utils import catch_pydantic_validation, deprecated try: @@ -66,15 +65,16 @@ class Result: data source. - :attr:`column_definitions`: A dictionary of column definitions. - :meth:`print_definitions`: Print the column definitions. - - :attr:`columns`: A list of column names. + - :attr:`columns`: A :class:`~pyprobe.column.ColumnSet` object providing + column name access (via ``.names``) and BDF-aware resolution (via + ``.resolve()`` and ``.can_resolve()``). """ def __init__( self, lf: pl.LazyFrame | pl.DataFrame | str, - metadata: dict[str, Any | None] | None = None, + metadata: dict[str, Any | None] = {}, column_definitions: dict[str, str] | None = None, - info: dict[str, Any | None] | None = None, ) -> None: """Create a Result with explicit constructor validation. @@ -82,19 +82,10 @@ def __init__( lf: A LazyFrame, DataFrame, or a path to a parquet file. metadata: Dictionary containing metadata about the result. column_definitions: Optional definitions for data columns. - info: Deprecated. Use metadata instead. Raises: ValueError: If constructor inputs do not match expected types. """ - # Handle backward compatibility: accept both 'info' and 'metadata' - if info is not None and metadata is not None: - raise ValueError("Cannot specify both 'info' and 'metadata' parameters.") - if info is not None: - metadata = info - if metadata is None: - metadata = {} - if isinstance(lf, str): lf = pl.scan_parquet(lf) if not isinstance(lf, pl.LazyFrame): @@ -132,13 +123,32 @@ def collect(self) -> pl.DataFrame: return lf @property - def columns(self) -> list[str]: - """The columns in the data. + def columns(self) -> ColumnSet: + """The columns in the data as a ColumnSet. + + Returns a :class:`~pyprobe.column.ColumnSet` object that provides + both simple column name access and BDF-aware resolution: + + - :attr:`~pyprobe.column.ColumnSet.names`: list of column name strings. + - :attr:`~pyprobe.column.ColumnSet.quantities`: list of quantity strings. + - :meth:`~pyprobe.column.ColumnSet.resolve`: resolve a column by name + or quantity, with optional unit conversion. + - :meth:`~pyprobe.column.ColumnSet.can_resolve`: check if a column + or BDF quantity is available. Returns: - List[str]: The columns in the data. + ColumnSet: A column introspection and resolution object. + + Examples: + >>> import polars as pl + >>> from pyprobe.result import Result + >>> r = Result(lf=pl.LazyFrame({"Current / A": [1.0]})) + >>> r.columns.names + ['Current / A'] + >>> r.columns.quantities + ['Current'] """ - return self.lf.collect_schema().names() + return ColumnSet(self.lf.collect_schema().names()) @property def info(self) -> dict[str, Any | None]: @@ -149,34 +159,6 @@ def info(self) -> dict[str, Any | None]: """ return self.metadata - @staticmethod - def _get_quantities(columns: list[str]) -> list[str]: - """The quantities of the data, with unit information removed. - - Args: - columns (List[str]): The columns to get the quantities of. - - Returns: - List[str]: The quantities of the data. - """ - _quantities: set[str] = set() - for _, column in enumerate(columns): - try: - quantity, _ = split_quantity_unit(column) - _quantities.add(quantity) - except ValueError: - continue - return list(_quantities) - - @property - def quantities(self) -> list[str]: - """The quantities of the data, with unit information removed. - - Returns: - List[str]: The quantities of the data. - """ - return self._get_quantities(self.columns) - @property def df(self) -> pl.DataFrame: """Return the data as a Polars DataFrame. @@ -195,36 +177,6 @@ def df(self, dataframe: pl.DataFrame) -> None: """ self.lf = dataframe.lazy() - def check_columns(self, columns: list[str]) -> None: - """Check whether a column exists in the data. - - Convert units if selected quantity exists in data with different unit. - - Args: - columns (List[str]): The columns to check. - - Raises: - ValueError: If a column does not exist in the data. - """ - missing_columns = set(columns) - set(self.columns) - if missing_columns: - logger.info("Missing columns: {}", missing_columns) - # check if missing columns can be converted from existing quantities - quantities = set(self._get_quantities(list(missing_columns))) - missing_quantities = set(quantities) - set(self.quantities) - if missing_quantities: - raise ValueError(f"Quantities {missing_quantities} not in data.") - # convert missing columns to requested units - for col in missing_columns: - quantity, unit = split_quantity_unit(col) - if unit == "": - continue - _, base_unit = get_unit_scaling(unit) - self.lf = self.lf.with_columns( - (pl.col(f"{quantity} [{base_unit}]").units.to_unit(unit)), - ) - logger.info(f"Converted column {col} from {base_unit} to {unit}.") - @property def data(self) -> pl.DataFrame: """Return the data as a polars DataFrame. @@ -243,7 +195,7 @@ def data(self) -> pl.DataFrame: @wraps(pd.DataFrame.plot) def plot(self, *args: Any, **kwargs: Any) -> Axes | NDArray[Axes]: """Wrapper for plotting using the pandas library.""" - data_to_plot = _retrieve_relevant_columns(self, args, kwargs) + data_to_plot = self.get_plotting_data(args, kwargs) return data_to_plot.to_pandas().plot(*args, **kwargs) plot.__doc__ = """Plot the data using the pandas plot method. @@ -265,7 +217,7 @@ def plot(self, *args: Any, **kwargs: Any) -> Axes | NDArray[Axes]: @wraps(hvplot.hvPlot) def hvplot(self, *args: Any, **kwargs: Any) -> Any: """Wrapper for plotting using the hvplot library.""" - data_to_plot = _retrieve_relevant_columns(self, args, kwargs) + data_to_plot = self.get_plotting_data(args, kwargs) return data_to_plot.hvplot(*args, **kwargs) else: @@ -311,9 +263,10 @@ def __getitem__(self, *column_names: str) -> "Result": Returns: Result: A new result object with the specified columns. """ - self.check_columns(list(column_names)) + col_set = self.columns + exprs = [col_set.resolve(name) for name in column_names] return Result( - lf=self.lf.select(*column_names), + lf=self.lf.select(*exprs), metadata=self.metadata, ) @@ -338,8 +291,9 @@ def get( error_msg = "At least one column name must be provided." logger.error(error_msg) raise ValueError(error_msg) - self.check_columns(list(column_names)) - array = self.lf.select(*column_names).collect().to_numpy() + col_set = self.columns + exprs = [col_set.resolve(name) for name in column_names] + array = self.lf.select(*exprs).collect().to_numpy() if len(column_names) == 1: return array.T[0] else: @@ -369,6 +323,56 @@ def get_only(self, column_name: str) -> NDArray[np.float64]: raise ValueError(error_msg) return column + def get_plotting_data( + self, + args: tuple[Any, ...], + kwargs: dict[Any, Any], + ) -> pl.DataFrame: + """Extract and resolve columns for plotting from function arguments. + + This method analyzes the arguments passed to a plotting function and + retrieves the used columns as a DataFrame. It extracts column names from + positional and keyword arguments, resolves them using the ColumnSet + (which handles unit conversions and BDF-aware resolution), and returns + a collected DataFrame suitable for passing to plotting libraries. + + Args: + args: Positional arguments from the plotting function. + kwargs: Keyword arguments from the plotting function. + + Returns: + pl.DataFrame: A collected DataFrame containing the requested columns. + + Raises: + ValueError: If none of the requested columns are present in the data. + + Examples: + >>> result = Result(lf=pl.LazyFrame({"Current / A": [1.0, 2.0]})) + >>> df = result.get_plotting_data(["Current / mA"], {}) + >>> df.shape + (2, 1) + """ + kwargs_values = [ + v for k, v in kwargs.items() if isinstance(v, str) and k != "label" + ] + args_values = [v for v in args if isinstance(v, str)] + all_args = set(kwargs_values + args_values) + relevant_columns = [] + col_set = self.columns + + for arg in all_args: + if col_set.can_resolve(arg): + relevant_columns.append(arg) + + if len(relevant_columns) == 0: + raise ValueError( + f"None of the columns in {all_args} are present in the Result object.", + ) + + # Resolve columns using ColumnSet to handle unit conversions + exprs = [col_set.resolve(col) for col in relevant_columns] + return self.lf.select(*exprs).collect() + def define_column(self, column_name: str, definition: str) -> None: """Define a new column when it is added to the dataframe. @@ -910,8 +914,8 @@ def build( def export_to_mat(self, filename: str) -> None: """Export the data to a .mat file. - This method will export the data and info dictionary to a .mat file. The - variables in the .mat file will be named 'data' and 'info'. Column names and + This method will export the data and metadata dictionary to a .mat file. The + variables in the .mat file will be named 'data' and 'metadata'. Column names and dictionary keys will have any non-alphanumeric characters replaced with an underscore, to comply with MATLAB variable naming rules. @@ -924,15 +928,15 @@ def export_to_mat(self, filename: str) -> None: {col: re.sub(r"\W", "_", col) for col in self.data.columns}, ) - # Replace any non-alphanumeric character with an underscore in the info + # Replace any non-alphanumeric character with an underscore in the metadata # dictionary keys - renamed_info = { + renamed_metadata = { re.sub(r"\W", "_", key): value for key, value in self.metadata.items() } variable_dict = { "data": renamed_data.to_dict(), - "info": renamed_info, + "metadata": renamed_metadata, } savemat(filename, variable_dict, oned_as="column") @@ -940,7 +944,7 @@ def export_to_mat(self, filename: str) -> None: @staticmethod def from_polars_io( polars_io_func: Callable[..., pl.DataFrame | pl.LazyFrame], - info: dict[str, Any | None] = {}, + metadata: dict[str, Any | None] = {}, column_definitions: dict[str, str] = {}, **kwargs: Any, ) -> "Result": @@ -956,8 +960,8 @@ def from_polars_io( Args: polars_io_func (Callable[..., pl.DataFrame | pl.LazyFrame]): The Polars IO function to use to create the data. - info (dict[str, Any | None]): - The info dictionary for the new Result object. Empty by default. + metadata (dict[str, Any | None]): + The metadata dictionary for the new Result object. Empty by default. column_definitions (dict[str, str]): The column definitions for the new Result object. Empty by default. **kwargs: The keyword arguments to pass to the Polars IO function. @@ -1004,7 +1008,7 @@ def from_polars_io( lf = polars_io_func(**kwargs) if isinstance(lf, pl.DataFrame): lf = lf.lazy() - return Result(lf=lf, metadata=info, column_definitions=column_definitions) + return Result(lf=lf, metadata=metadata, column_definitions=column_definitions) @property @deprecated( diff --git a/tests/conftest.py b/tests/conftest.py index 4794ec89..9891bea1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from loguru import logger from pyprobe.cell import Cell +from pyprobe.filters import Procedure @pytest.fixture @@ -31,7 +32,7 @@ def info_fixture(): @pytest.fixture def lazyframe_fixture(): """Pytest fixture for example lazyframe.""" - return pl.scan_parquet("tests/sample_data/neware/sample_data_neware_ref.parquet") + return pl.scan_parquet("tests/sample_data/neware/sample_data_neware.bdx.parquet") @pytest.fixture @@ -112,13 +113,10 @@ def cell_fixture(info_fixture): @pytest.fixture def procedure_fixture(info_fixture): """Pytest fixture for example procedure.""" - cell = Cell(info=info_fixture) - cell.add_procedure( - "Sample", - "tests/sample_data/neware/", - "sample_data_neware.parquet", + return Procedure.load( + "tests/sample_data/neware/sample_data_neware.bdx.parquet", + "tests/sample_data/neware/README.yaml", ) - return cell.procedure["Sample"] @pytest.fixture(scope="function") diff --git a/tests/sample_data/neware/sample_data_neware.bdx.parquet b/tests/sample_data/neware/sample_data_neware.bdx.parquet new file mode 100644 index 0000000000000000000000000000000000000000..b03332c533412bc3f886ab09ffbf700e00017cfa GIT binary patch literal 9207123 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