Skip to content

Commit f7c4e38

Browse files
authored
arbin_res was the tier-1 loader flagged as awkward - Access .res via ODBC on (#608)
1 parent 83707e7 commit f7c4e38

3 files changed

Lines changed: 210 additions & 3 deletions

File tree

cellpy/readers/instruments/arbin_res.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
import sqlalchemy as sa
4444

4545
from cellpy import prms
46-
from cellpy.exceptions import NullData
46+
from cellpy.exceptions import LoaderError, NullData
4747
from cellpy.parameters.internal_settings import HeaderDict, get_headers_normal
4848
from cellpy.readers.data_structures import (
4949
Data,
@@ -817,6 +817,79 @@ def _check_size(self):
817817
return False
818818
return True
819819

820+
def parse(self, source, **kwargs):
821+
"""Vendor stage (#560): read the .res into a frame with **Arbin** names.
822+
823+
The two-stage counterpart to :meth:`loader`, and the point where the
824+
vendor part of arbin ends: reading the Access database (ODBC on Windows,
825+
mdbtools on posix) and assembling the normal data table. Everything
826+
after — the rename to native columns, the datetime conversion — is
827+
declared and handled by ``harmonize()``.
828+
829+
This is exactly what ``loader()`` produces before ``_post_process``, so
830+
the two share one read path and cannot drift. Scoped to a single test:
831+
multi-test ``.res`` files are the switchover's concern, not the vendor
832+
stage's (``loader()`` still splits and merges them).
833+
834+
Returns:
835+
The normal table with Arbin column names — ``DateTime`` still an
836+
Excel serial, capacities as the file stores them.
837+
"""
838+
import polars as pl
839+
840+
self.name = source
841+
self.copy_to_temporary()
842+
843+
use_mdbtools = _use_subprocess or _is_posix
844+
if use_mdbtools:
845+
data = self._loader_posix(
846+
self.name,
847+
self.temp_file_path,
848+
self.temp_file_path.parent,
849+
**kwargs,
850+
)
851+
else:
852+
data = self._loader_win(self.name, self.temp_file_path, **kwargs)
853+
854+
self._parsed = True
855+
return pl.from_pandas(data.raw.reset_index(drop=True))
856+
857+
def declarations(self):
858+
"""Declarations for arbin's normal table (#560).
859+
860+
Derived, not hand-written: ``get_headers_normal()`` is already a
861+
``{cellpy attr → Arbin column}`` map — the same shape the configuration
862+
loaders carry as ``normal_headers_renaming_dict`` — so inverting it and
863+
composing with cellpy-core's legacy→native map (via
864+
``derive_column_maps``) gives Arbin → native and the provenance rule for
865+
free (``Test_ID`` is not mapped onto the framework ``test_id``).
866+
"""
867+
from cellpycore.units import CellpyUnits
868+
869+
from cellpy.readers.instruments.config_declarations import derive_column_maps
870+
from cellpy.readers.instruments.declarations import LoaderDeclarations
871+
872+
if not getattr(self, "_parsed", False):
873+
raise LoaderError(
874+
"arbin_res.declarations() was called before parse()."
875+
)
876+
877+
# cellpy attr -> Arbin column is what get_headers_normal() returns;
878+
# derive_column_maps wants attr -> vendor, which is the same thing.
879+
renaming = dict(self.arbin_headers_normal)
880+
column_map, passthrough, _ = derive_column_maps(renaming)
881+
882+
raw_units = {
883+
key: value
884+
for key, value in self.get_raw_units().items()
885+
if hasattr(CellpyUnits(), key)
886+
}
887+
return LoaderDeclarations(
888+
column_map=column_map,
889+
raw_units=CellpyUnits(**raw_units),
890+
passthrough=passthrough,
891+
)
892+
820893
def loader(
821894
self,
822895
name,

tests/test_arbin_two_stage.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""arbin_res's two-stage entry points (#560).
2+
3+
arbin_res reads an Access ``.res`` database — ODBC on Windows, mdbtools on
4+
posix — so these tests skip where no backend is available, exactly as the
5+
loader golden does. Where a backend exists (CI has mdbtools), they run.
6+
7+
The vendor stage is unusually thin here: ``get_headers_normal()`` is already a
8+
``{cellpy attr → Arbin column}`` map, so ``declarations()`` reuses the same
9+
derivation the configuration loaders use. What ``parse()`` adds is only the
10+
database read, stopping before the rename and the datetime conversion that
11+
``harmonize()`` now owns.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import warnings
17+
18+
import polars as pl
19+
import pytest
20+
21+
from cellpy.exceptions import LoaderError
22+
from cellpy.readers import data_structures as ds
23+
24+
SOURCE = "testdata/data/20160805_test001_45_cc_01.res"
25+
26+
27+
def _loader():
28+
return ds.generate_default_factory().create("arbin_res")
29+
30+
31+
def _parsed_or_skip():
32+
loader = _loader()
33+
try:
34+
with warnings.catch_warnings():
35+
warnings.simplefilter("ignore")
36+
vendor = loader.parse(SOURCE)
37+
except Exception as exc: # noqa: BLE001 — unavailable backend, not a bug
38+
pytest.skip(f"arbin_res backend unavailable here: {exc}")
39+
return loader, vendor
40+
41+
42+
@pytest.mark.essential
43+
def test_parse_returns_arbin_vendor_names():
44+
_, vendor = _parsed_or_skip()
45+
46+
# Arbin's own spellings, not native — renaming is harmonize's job.
47+
for arbin in ("Voltage", "Current", "Charge_Capacity", "Cycle_Index"):
48+
assert arbin in vendor.columns, f"{arbin} missing from the parsed frame"
49+
assert "potential" not in vendor.columns
50+
51+
52+
@pytest.mark.essential
53+
def test_declarations_derive_from_get_headers_normal():
54+
loader, _ = _parsed_or_skip()
55+
declarations = loader.declarations()
56+
57+
assert declarations.column_map["Voltage"] == "potential"
58+
assert declarations.column_map["Current"] == "current"
59+
assert (
60+
declarations.column_map["Charge_Capacity"] == "cumulative_charge_capacity"
61+
)
62+
63+
64+
@pytest.mark.essential
65+
def test_the_vendor_test_id_is_not_mapped_onto_the_framework_test_id():
66+
"""``Test_ID`` is provenance; mapping it onto native ``test_id`` (the
67+
grouping key) would let the vendor value win. The shared derivation drops
68+
it, same as for the configuration loaders."""
69+
loader, _ = _parsed_or_skip()
70+
declarations = loader.declarations()
71+
72+
assert "Test_ID" not in declarations.column_map
73+
assert "test_id" not in declarations.column_map.values()
74+
75+
76+
@pytest.mark.essential
77+
def test_declarations_before_parse_raises():
78+
loader = _loader()
79+
with pytest.raises(LoaderError, match="before parse"):
80+
loader.declarations()
81+
82+
83+
@pytest.mark.essential
84+
def test_two_stage_capacities_match_the_legacy_frame():
85+
"""The assertion the switchover rests on, through the public entry points."""
86+
import cellpy
87+
88+
loader, vendor = _parsed_or_skip()
89+
from cellpy.readers.instruments.harmonize import harmonize
90+
91+
with warnings.catch_warnings():
92+
warnings.simplefilter("ignore")
93+
raw = harmonize(vendor, loader.declarations(), strict=False)
94+
cell = cellpy.get(SOURCE, instrument="arbin_res", mass=1.0, testing=True)
95+
96+
legacy = pl.from_pandas(cell.data.raw.reset_index(drop=True))
97+
for column in (
98+
"potential",
99+
"current",
100+
"cumulative_charge_capacity",
101+
"cumulative_discharge_capacity",
102+
"test_time",
103+
):
104+
difference = (
105+
raw[column].cast(pl.Float64) - legacy[column].cast(pl.Float64)
106+
).abs().max()
107+
assert difference is not None and difference < 1e-9, (
108+
f"{column} differs by {difference}"
109+
)

tests/test_loader_port_parity.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
# pec_csv is not configuration-driven; its parse()/declarations() are
5353
# hand-written (canonical rename in parse, static column map). Plan §2.6a.
5454
("pec_csv", "testdata/data/pec.csv", {}),
55+
# arbin_res reads an Access .res database (ODBC on Windows, mdbtools on
56+
# posix). CI has mdbtools, so this runs there; environments without a
57+
# backend skip gracefully (see _skip_if_unloadable), like the golden.
58+
("arbin_res", "testdata/data/20160805_test001_45_cc_01.res", {}),
5559
)
5660

5761
#: Legacy post-processor → native columns it changes, for the ones that have no
@@ -94,8 +98,8 @@ def _parse_with_a_loader(instrument, source, kwargs):
9498
with warnings.catch_warnings():
9599
warnings.simplefilter("ignore")
96100
vendor = loader.parse(source, **parse)
97-
# config_params only exists on configuration-driven loaders; pec_csv has
98-
# none, and only the post-processor excusal (_excused) reads it.
101+
# config_params only exists on configuration-driven loaders; arbin_res and
102+
# pec_csv have none, and only the post-processor excusal reads it.
99103
return vendor, loader.declarations(), getattr(loader, "config_params", None)
100104

101105

@@ -139,9 +143,30 @@ def _excused(config_params) -> set[str]:
139143
return excused
140144

141145

146+
def _skip_if_unloadable(instrument, source, kwargs):
147+
"""Skip when the loader's backend is unavailable, as the golden does.
148+
149+
Only ``arbin_res`` needs it: its Access-database backend (ODBC/mdbtools) is
150+
not present everywhere. A missing backend is an environment fact, not a
151+
parity failure, so it skips rather than fails — matching
152+
``LoaderGoldenSpec.skip_reason``.
153+
"""
154+
import cellpy
155+
156+
try:
157+
with warnings.catch_warnings():
158+
warnings.simplefilter("ignore")
159+
cellpy.get(
160+
source, instrument=instrument, mass=1.0, testing=True, **kwargs
161+
)
162+
except Exception as exc: # noqa: BLE001 — an unavailable backend, not a bug
163+
pytest.skip(f"{instrument} loader unavailable here: {exc}")
164+
165+
142166
def _harmonized_and_legacy(instrument, source, kwargs):
143167
import cellpy
144168

169+
_skip_if_unloadable(instrument, source, kwargs)
145170
vendor, declarations, config_params = _parse_with_a_loader(
146171
instrument, source, kwargs
147172
)

0 commit comments

Comments
 (0)