battery-data-steward 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- battery_data_standard/__init__.py +99 -0
- battery_data_standard/__main__.py +5 -0
- battery_data_standard/_version.py +3 -0
- battery_data_standard/adapters/__init__.py +5 -0
- battery_data_standard/adapters/arbin.py +84 -0
- battery_data_standard/adapters/base.py +124 -0
- battery_data_standard/adapters/basytec.py +88 -0
- battery_data_standard/adapters/biologic.py +246 -0
- battery_data_standard/adapters/generic.py +40 -0
- battery_data_standard/adapters/landt.py +36 -0
- battery_data_standard/adapters/maccor.py +122 -0
- battery_data_standard/adapters/neware.py +1733 -0
- battery_data_standard/adapters/normalization.py +912 -0
- battery_data_standard/adapters/novonix.py +29 -0
- battery_data_standard/adapters/pec.py +49 -0
- battery_data_standard/adapters/registry.py +150 -0
- battery_data_standard/adapters/repower.py +57 -0
- battery_data_standard/adapters/simple.py +27 -0
- battery_data_standard/api.py +1007 -0
- battery_data_standard/archive.py +224 -0
- battery_data_standard/audit.py +678 -0
- battery_data_standard/cli.py +550 -0
- battery_data_standard/diagnostics.py +406 -0
- battery_data_standard/doctor.py +361 -0
- battery_data_standard/eis.py +467 -0
- battery_data_standard/exceptions.py +45 -0
- battery_data_standard/export.py +483 -0
- battery_data_standard/inspection.py +329 -0
- battery_data_standard/io.py +1204 -0
- battery_data_standard/kind.py +331 -0
- battery_data_standard/profiles.py +86 -0
- battery_data_standard/py.typed +1 -0
- battery_data_standard/quality.py +420 -0
- battery_data_standard/reporting.py +1072 -0
- battery_data_standard/reports.py +125 -0
- battery_data_standard/schema.py +507 -0
- battery_data_standard/summary.py +111 -0
- battery_data_standard/time_sampling.py +399 -0
- battery_data_standard/validation.py +111 -0
- battery_data_steward-0.4.0.dist-info/METADATA +502 -0
- battery_data_steward-0.4.0.dist-info/RECORD +47 -0
- battery_data_steward-0.4.0.dist-info/WHEEL +5 -0
- battery_data_steward-0.4.0.dist-info/entry_points.txt +2 -0
- battery_data_steward-0.4.0.dist-info/licenses/LICENSE +21 -0
- battery_data_steward-0.4.0.dist-info/top_level.txt +2 -0
- bds/__init__.py +5 -0
- bds/py.typed +1 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Battery Data Steward: battery data inspection, QA, and preprocessing."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .api import (
|
|
5
|
+
batch_convert,
|
|
6
|
+
convert,
|
|
7
|
+
convert_eis,
|
|
8
|
+
convert_neware_groups,
|
|
9
|
+
detect,
|
|
10
|
+
detect_kind,
|
|
11
|
+
group_neware_files,
|
|
12
|
+
list_export_targets,
|
|
13
|
+
list_supported_formats,
|
|
14
|
+
read,
|
|
15
|
+
read_eis,
|
|
16
|
+
read_with_report,
|
|
17
|
+
validate_eis,
|
|
18
|
+
validate_file,
|
|
19
|
+
)
|
|
20
|
+
from .audit import AuditRecord, AuditReport, audit, audit_file
|
|
21
|
+
from .diagnostics import ExplainReport, explain
|
|
22
|
+
from .doctor import DoctorReport, doctor
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
AmbiguousDetectionError,
|
|
25
|
+
BatteryDataStandardError,
|
|
26
|
+
ConversionError,
|
|
27
|
+
DetectionError,
|
|
28
|
+
FileIOError,
|
|
29
|
+
UnsupportedFeatureError,
|
|
30
|
+
UnsupportedFormatError,
|
|
31
|
+
ValidationFailed,
|
|
32
|
+
)
|
|
33
|
+
from .export import to_export_frame
|
|
34
|
+
from .inspection import InspectionReport, inspect
|
|
35
|
+
from .reporting import (
|
|
36
|
+
render_explain_html,
|
|
37
|
+
write_conversion_report,
|
|
38
|
+
write_conversion_reports,
|
|
39
|
+
write_explain_report,
|
|
40
|
+
write_explain_reports,
|
|
41
|
+
write_inspection_report,
|
|
42
|
+
write_inspection_reports,
|
|
43
|
+
)
|
|
44
|
+
from .reports import ConversionReport, DetectionResult, ValidationReport
|
|
45
|
+
from .schema import BDF_SCHEMA_VERSION, BDS_SCHEMA_VERSION
|
|
46
|
+
from .summary import summarize_cycles, summarize_steps
|
|
47
|
+
from .validation import validate
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AmbiguousDetectionError",
|
|
51
|
+
"AuditRecord",
|
|
52
|
+
"AuditReport",
|
|
53
|
+
"BDF_SCHEMA_VERSION",
|
|
54
|
+
"BDS_SCHEMA_VERSION",
|
|
55
|
+
"BatteryDataStandardError",
|
|
56
|
+
"ConversionReport",
|
|
57
|
+
"ConversionError",
|
|
58
|
+
"DetectionResult",
|
|
59
|
+
"DetectionError",
|
|
60
|
+
"DoctorReport",
|
|
61
|
+
"ExplainReport",
|
|
62
|
+
"InspectionReport",
|
|
63
|
+
"FileIOError",
|
|
64
|
+
"UnsupportedFeatureError",
|
|
65
|
+
"UnsupportedFormatError",
|
|
66
|
+
"ValidationFailed",
|
|
67
|
+
"ValidationReport",
|
|
68
|
+
"__version__",
|
|
69
|
+
"batch_convert",
|
|
70
|
+
"audit",
|
|
71
|
+
"audit_file",
|
|
72
|
+
"convert",
|
|
73
|
+
"convert_eis",
|
|
74
|
+
"convert_neware_groups",
|
|
75
|
+
"detect",
|
|
76
|
+
"detect_kind",
|
|
77
|
+
"doctor",
|
|
78
|
+
"explain",
|
|
79
|
+
"group_neware_files",
|
|
80
|
+
"inspect",
|
|
81
|
+
"list_export_targets",
|
|
82
|
+
"list_supported_formats",
|
|
83
|
+
"read",
|
|
84
|
+
"read_eis",
|
|
85
|
+
"read_with_report",
|
|
86
|
+
"render_explain_html",
|
|
87
|
+
"summarize_cycles",
|
|
88
|
+
"summarize_steps",
|
|
89
|
+
"to_export_frame",
|
|
90
|
+
"validate",
|
|
91
|
+
"validate_file",
|
|
92
|
+
"validate_eis",
|
|
93
|
+
"write_explain_report",
|
|
94
|
+
"write_explain_reports",
|
|
95
|
+
"write_inspection_report",
|
|
96
|
+
"write_inspection_reports",
|
|
97
|
+
"write_conversion_report",
|
|
98
|
+
"write_conversion_reports",
|
|
99
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Arbin cycler adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .generic import GenericAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArbinAdapter(GenericAdapter):
|
|
9
|
+
id = "arbin"
|
|
10
|
+
display_name = "Arbin"
|
|
11
|
+
adapter_version = "1"
|
|
12
|
+
support_tier = "fixture-backed"
|
|
13
|
+
evidence_tier = "public-fixture-backed"
|
|
14
|
+
raw_current_sign = "charge-positive"
|
|
15
|
+
signatures = (
|
|
16
|
+
"arbin",
|
|
17
|
+
"cycle index",
|
|
18
|
+
"cycle_index",
|
|
19
|
+
"step index",
|
|
20
|
+
"step_index",
|
|
21
|
+
"test time",
|
|
22
|
+
"test_time",
|
|
23
|
+
"data point",
|
|
24
|
+
"data_point",
|
|
25
|
+
"date_time",
|
|
26
|
+
"charge_capacity",
|
|
27
|
+
"discharge_capacity",
|
|
28
|
+
"statisticbystep",
|
|
29
|
+
"statisticbycycle",
|
|
30
|
+
)
|
|
31
|
+
column_aliases = {
|
|
32
|
+
"test_time_s": (
|
|
33
|
+
"Test_Time",
|
|
34
|
+
"Test Time (s)",
|
|
35
|
+
"Test Time (min)",
|
|
36
|
+
"Test Time (h)",
|
|
37
|
+
"Test Time",
|
|
38
|
+
"Test_Time(s)",
|
|
39
|
+
"TestTime(s)",
|
|
40
|
+
),
|
|
41
|
+
"date_time": ("DateTime", "Date Time", "Date_Time", "Date_Time "),
|
|
42
|
+
"step_time_s": (
|
|
43
|
+
"Step_Time",
|
|
44
|
+
"Step Time (s)",
|
|
45
|
+
"Step Time (min)",
|
|
46
|
+
"Step Time (h)",
|
|
47
|
+
"Step_Time(s)",
|
|
48
|
+
"StepTime(s)",
|
|
49
|
+
),
|
|
50
|
+
"cycle_index": ("Cycle_Index", "Cycle Index"),
|
|
51
|
+
"step_index": ("Step_Index", "Step Index"),
|
|
52
|
+
"record_index": ("Data_Point", "Data Point", "DataPoint"),
|
|
53
|
+
"current_a": ("Current", "Current (A)", "Current(A)", "Current_mA", "Current (mA)"),
|
|
54
|
+
"voltage_v": ("Voltage", "Voltage (V)", "Voltage(V)"),
|
|
55
|
+
"charge_capacity_ah": ("Charge_Capacity", "Charge Capacity (Ah)", "Charge_Capacity(Ah)"),
|
|
56
|
+
"discharge_capacity_ah": (
|
|
57
|
+
"Discharge_Capacity",
|
|
58
|
+
"Discharge Capacity (Ah)",
|
|
59
|
+
"Discharge_Capacity(Ah)",
|
|
60
|
+
),
|
|
61
|
+
"charge_energy_wh": ("Charge_Energy", "Charge Energy (Wh)", "Charge_Energy(Wh)"),
|
|
62
|
+
"discharge_energy_wh": ("Discharge_Energy", "Discharge Energy (Wh)", "Discharge_Energy(Wh)"),
|
|
63
|
+
"power_w": ("Power", "Power (W)", "Power(W)"),
|
|
64
|
+
"internal_resistance_ohm": (
|
|
65
|
+
"Internal_Resistance",
|
|
66
|
+
"Internal Resistance",
|
|
67
|
+
"Internal Resistance (Ohm)",
|
|
68
|
+
"ACR (Ohm)",
|
|
69
|
+
"IR (Ohms)",
|
|
70
|
+
),
|
|
71
|
+
"ambient_temperature_deg_c": (
|
|
72
|
+
"Temperature",
|
|
73
|
+
"Aux_Temperature_1 (C)",
|
|
74
|
+
"Aux_Temperature_1(C)",
|
|
75
|
+
"Aux_Temperature(C)_1",
|
|
76
|
+
"Aux_Temperature(degC)_1",
|
|
77
|
+
"Aux_Temperature(°C)_1",
|
|
78
|
+
"Aux_Temperature(°C)_1",
|
|
79
|
+
"Aux_Temperature(Ąć)_1",
|
|
80
|
+
"Aux_Temperature(¡æ)_1",
|
|
81
|
+
"Aux_Temperature_2 (C)",
|
|
82
|
+
"Aux_Temperature_2(C)",
|
|
83
|
+
),
|
|
84
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Adapter base classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import polars as pl
|
|
10
|
+
|
|
11
|
+
from ..reports import ColumnProvenance, DetectionResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AdapterResult:
|
|
16
|
+
data: pl.DataFrame
|
|
17
|
+
warnings: list[str] = field(default_factory=list)
|
|
18
|
+
provenance: list[ColumnProvenance] = field(default_factory=list)
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Adapter:
|
|
23
|
+
id = "abstract"
|
|
24
|
+
display_name = "Abstract"
|
|
25
|
+
adapter_version = "1"
|
|
26
|
+
support_tier = "best_effort"
|
|
27
|
+
evidence_tier = "best-effort"
|
|
28
|
+
extensions: tuple[str, ...] = ()
|
|
29
|
+
signatures: tuple[str, ...] = ()
|
|
30
|
+
column_aliases: dict[str, tuple[str, ...]] = {}
|
|
31
|
+
raw_current_sign: str = "unknown"
|
|
32
|
+
|
|
33
|
+
def sniff(self, path: Path, sample: str) -> DetectionResult:
|
|
34
|
+
common_table_suffixes = {".csv", ".txt", ".tsv", ".xlsx", ".xls", ".mat", ".parquet"}
|
|
35
|
+
suffix = path.suffix.lower()
|
|
36
|
+
suffix_score = (
|
|
37
|
+
0.1
|
|
38
|
+
if (not self.extensions or suffix in self.extensions) and suffix not in common_table_suffixes
|
|
39
|
+
else 0.0
|
|
40
|
+
)
|
|
41
|
+
sample_lower = sample.lower()
|
|
42
|
+
hits = [token for token in self.signatures if token.lower() in sample_lower]
|
|
43
|
+
confidence = suffix_score + min(0.8, 0.2 * len(hits))
|
|
44
|
+
reason = "signature match" if hits else "extension fallback"
|
|
45
|
+
return DetectionResult(self.id, confidence, reason)
|
|
46
|
+
|
|
47
|
+
def read_raw(self, path: Path, options: dict[str, Any] | None = None) -> pl.DataFrame:
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def read_raw_with_metadata(self, path: Path, options: dict[str, Any] | None = None) -> AdapterResult:
|
|
51
|
+
return AdapterResult(self.read_raw(path, options=options))
|
|
52
|
+
|
|
53
|
+
def normalize(
|
|
54
|
+
self,
|
|
55
|
+
raw: pl.DataFrame,
|
|
56
|
+
*,
|
|
57
|
+
profile: dict[str, Any] | None = None,
|
|
58
|
+
strict: bool = True,
|
|
59
|
+
keep_raw: bool = False,
|
|
60
|
+
current_sign: str = "charge-positive",
|
|
61
|
+
repair_policy: str = "warn",
|
|
62
|
+
) -> AdapterResult:
|
|
63
|
+
from .normalization import normalize_raw_frame
|
|
64
|
+
|
|
65
|
+
return normalize_raw_frame(
|
|
66
|
+
raw,
|
|
67
|
+
adapter_id=self.id,
|
|
68
|
+
aliases=self.column_aliases,
|
|
69
|
+
profile=profile,
|
|
70
|
+
strict=strict,
|
|
71
|
+
keep_raw=keep_raw,
|
|
72
|
+
current_sign=current_sign,
|
|
73
|
+
raw_current_sign=self.raw_current_sign,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def repair(self, data: pl.DataFrame, *, repair_policy: str = "warn") -> AdapterResult:
|
|
77
|
+
from .normalization import repair_bds_frame
|
|
78
|
+
|
|
79
|
+
df, warnings = repair_bds_frame(data, policy=repair_policy)
|
|
80
|
+
return AdapterResult(df, warnings=warnings, metadata={"repair_operations": list(warnings)})
|
|
81
|
+
|
|
82
|
+
def extract_metadata(self, raw: pl.DataFrame) -> dict[str, Any]:
|
|
83
|
+
return {"source_adapter": self.id, "raw_columns": list(raw.columns)}
|
|
84
|
+
|
|
85
|
+
def process(
|
|
86
|
+
self,
|
|
87
|
+
path: Path,
|
|
88
|
+
*,
|
|
89
|
+
profile: dict[str, Any] | None = None,
|
|
90
|
+
strict: bool = True,
|
|
91
|
+
keep_raw: bool = False,
|
|
92
|
+
current_sign: str = "charge-positive",
|
|
93
|
+
repair_policy: str = "warn",
|
|
94
|
+
options: dict[str, Any] | None = None,
|
|
95
|
+
) -> AdapterResult:
|
|
96
|
+
raw_result = self.read_raw_with_metadata(path, options=options)
|
|
97
|
+
raw = raw_result.data
|
|
98
|
+
normalized = self.normalize(
|
|
99
|
+
raw,
|
|
100
|
+
profile=profile,
|
|
101
|
+
strict=strict,
|
|
102
|
+
keep_raw=keep_raw,
|
|
103
|
+
current_sign=current_sign,
|
|
104
|
+
repair_policy=repair_policy,
|
|
105
|
+
)
|
|
106
|
+
repaired = self.repair(normalized.data, repair_policy=repair_policy)
|
|
107
|
+
normalized.data = repaired.data
|
|
108
|
+
normalized.warnings[:0] = raw_result.warnings
|
|
109
|
+
normalized.warnings.extend(repaired.warnings)
|
|
110
|
+
normalized.metadata.update(raw_result.metadata)
|
|
111
|
+
normalized.metadata.update(self.extract_metadata(raw))
|
|
112
|
+
normalized.metadata.update(repaired.metadata)
|
|
113
|
+
normalized.metadata.update(
|
|
114
|
+
{
|
|
115
|
+
"source_backend": "native",
|
|
116
|
+
"raw_current_sign": self.raw_current_sign,
|
|
117
|
+
"repair_policy": repair_policy,
|
|
118
|
+
"adapter_version": self.adapter_version,
|
|
119
|
+
"support_tier": self.support_tier,
|
|
120
|
+
"evidence_tier": self.evidence_tier,
|
|
121
|
+
"output_rows": normalized.data.height,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
return normalized
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""BaSyTec cycler adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import polars as pl
|
|
10
|
+
|
|
11
|
+
from ..io import read_table_with_metadata, read_text
|
|
12
|
+
from .base import AdapterResult
|
|
13
|
+
from .generic import GenericAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BasytecAdapter(GenericAdapter):
|
|
17
|
+
id = "basytec"
|
|
18
|
+
display_name = "BaSyTec"
|
|
19
|
+
adapter_version = "1"
|
|
20
|
+
support_tier = "fixture-backed"
|
|
21
|
+
evidence_tier = "public-fixture-backed"
|
|
22
|
+
raw_current_sign = "charge-positive"
|
|
23
|
+
signatures = ("basytec", "run_time", "c_vol", "c_cur", "~time[h]", "u[v]", "i[a]")
|
|
24
|
+
column_aliases = {
|
|
25
|
+
"test_time_s": ("run_time", "time", "~Time[h]", "Time[h]", "Time[s]"),
|
|
26
|
+
"step_time_s": ("t-Step[h]", "t-Set[h]"),
|
|
27
|
+
"voltage_v": ("c_vol", "voltage", "U[V]", "U_Battery"),
|
|
28
|
+
"current_a": ("c_cur", "current", "I[A]", "I_Battery"),
|
|
29
|
+
"cycle_index": ("Cyc-Count", "Cycle", "CycCount"),
|
|
30
|
+
"step_index": ("Line", "Step", "Command"),
|
|
31
|
+
"ambient_temperature_deg_c": (
|
|
32
|
+
"c_temp",
|
|
33
|
+
"c_surf_temp",
|
|
34
|
+
"amb_temp",
|
|
35
|
+
"temperature",
|
|
36
|
+
"T1[°C]",
|
|
37
|
+
"T1[癈]",
|
|
38
|
+
),
|
|
39
|
+
"charge_capacity_ah": ("ah_ch", "Ah[Ah]"),
|
|
40
|
+
"discharge_capacity_ah": ("ah_dch",),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def read_raw_with_metadata(self, path: Path, options: dict[str, Any] | None = None) -> AdapterResult:
|
|
44
|
+
result = read_table_with_metadata(path, options=options)
|
|
45
|
+
metadata = dict(result.metadata)
|
|
46
|
+
data = result.data
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
text, _encoding = read_text(path)
|
|
50
|
+
except OSError:
|
|
51
|
+
text = ""
|
|
52
|
+
start = _parse_basytec_start_time(text)
|
|
53
|
+
if start is not None:
|
|
54
|
+
metadata["basytec_start_of_test"] = start.isoformat()
|
|
55
|
+
data = _add_datetime_column(data, start)
|
|
56
|
+
|
|
57
|
+
return AdapterResult(data, metadata=metadata)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_basytec_start_time(text: str) -> datetime | None:
|
|
61
|
+
for line in text.splitlines()[:80]:
|
|
62
|
+
if "start of test" not in line.lower():
|
|
63
|
+
continue
|
|
64
|
+
_label, _sep, value = line.partition(":")
|
|
65
|
+
value = value.strip()
|
|
66
|
+
for fmt in ("%d.%m.%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %H:%M:%S"):
|
|
67
|
+
try:
|
|
68
|
+
return datetime.strptime(value, fmt)
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _add_datetime_column(data: pl.DataFrame, start: datetime) -> pl.DataFrame:
|
|
75
|
+
if data.is_empty() or "DateTime" in data.columns:
|
|
76
|
+
return data
|
|
77
|
+
source = next(
|
|
78
|
+
(column for column in ("~Time[h]", "Time[h]", "Time[s]", "run_time") if column in data.columns), None
|
|
79
|
+
)
|
|
80
|
+
if source is None:
|
|
81
|
+
return data
|
|
82
|
+
factor = 3600.0 if "[h]" in source.lower() else 1.0
|
|
83
|
+
offsets = data[source].cast(pl.Float64, strict=False).to_list()
|
|
84
|
+
values = [
|
|
85
|
+
None if value is None else (start + timedelta(seconds=float(value) * factor)).isoformat()
|
|
86
|
+
for value in offsets
|
|
87
|
+
]
|
|
88
|
+
return data.with_columns(pl.Series("DateTime", values))
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""BioLogic EC-Lab adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
|
|
12
|
+
from ..io import read_table_with_metadata, read_text
|
|
13
|
+
from ..reports import ColumnProvenance
|
|
14
|
+
from ..schema import ALL_COLUMNS
|
|
15
|
+
from .base import AdapterResult
|
|
16
|
+
from .generic import GenericAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BiologicAdapter(GenericAdapter):
|
|
20
|
+
id = "biologic"
|
|
21
|
+
display_name = "BioLogic"
|
|
22
|
+
adapter_version = "1"
|
|
23
|
+
support_tier = "fixture-backed"
|
|
24
|
+
evidence_tier = "public-fixture-backed"
|
|
25
|
+
raw_current_sign = "charge-positive"
|
|
26
|
+
extensions = (".mpt", ".mpr", ".txt", ".csv")
|
|
27
|
+
signatures = ("biologic", "ec-lab", "time/s", "ewe/v", "ecell/v", "i/ma", "nb header lines")
|
|
28
|
+
column_aliases = {
|
|
29
|
+
"test_time_s": ("time/s", "time/s.", "Time/s"),
|
|
30
|
+
"step_time_s": ("step time/s", "Step time/s", "step_time/s"),
|
|
31
|
+
"voltage_v": ("Ewe/V", "Ecell/V", "Ewe/V vs.", "<Ewe>/V", "Ecell_V", "Ewe_V"),
|
|
32
|
+
"current_a": ("I/mA", "<I>/mA", "I/A", "<I>/A", "I_mA", "I_A"),
|
|
33
|
+
"cycle_index": ("cycle number",),
|
|
34
|
+
"step_index": ("Ns",),
|
|
35
|
+
"charge_capacity_ah": ("Q charge/mA.h",),
|
|
36
|
+
"discharge_capacity_ah": ("Q discharge/mA.h",),
|
|
37
|
+
"charge_energy_wh": ("Energy charge/W.h", "Energy charge/mW.h"),
|
|
38
|
+
"discharge_energy_wh": ("Energy discharge/W.h", "Energy discharge/mW.h"),
|
|
39
|
+
"power_w": ("P/W",),
|
|
40
|
+
"internal_resistance_ohm": ("R/Ohm", "Rapp/Ohm"),
|
|
41
|
+
"ambient_temperature_deg_c": ("Temperature/°C", "Temperature/degC", "Temperature/°C", "T/°C"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def read_raw(self, path: Path, options: dict[str, Any] | None = None) -> pl.DataFrame:
|
|
45
|
+
return super().read_raw(path, options=options)
|
|
46
|
+
|
|
47
|
+
def read_raw_with_metadata(self, path: Path, options: dict[str, Any] | None = None) -> AdapterResult:
|
|
48
|
+
result = read_table_with_metadata(path, options=options)
|
|
49
|
+
metadata = dict(result.metadata)
|
|
50
|
+
if path.suffix.lower() in {".mpt", ".txt", ".csv"}:
|
|
51
|
+
text, _encoding = read_text(path)
|
|
52
|
+
metadata.update(_parse_biologic_header_metadata(text))
|
|
53
|
+
return AdapterResult(result.data, metadata=metadata)
|
|
54
|
+
|
|
55
|
+
def normalize(
|
|
56
|
+
self,
|
|
57
|
+
raw: pl.DataFrame,
|
|
58
|
+
*,
|
|
59
|
+
profile: dict[str, Any] | None = None,
|
|
60
|
+
strict: bool = True,
|
|
61
|
+
keep_raw: bool = False,
|
|
62
|
+
current_sign: str = "charge-positive",
|
|
63
|
+
repair_policy: str = "warn",
|
|
64
|
+
) -> AdapterResult:
|
|
65
|
+
result = super().normalize(
|
|
66
|
+
raw,
|
|
67
|
+
profile=profile,
|
|
68
|
+
strict=strict,
|
|
69
|
+
keep_raw=keep_raw,
|
|
70
|
+
current_sign=current_sign,
|
|
71
|
+
repair_policy=repair_policy,
|
|
72
|
+
)
|
|
73
|
+
return _replace_step_time_within_step(result)
|
|
74
|
+
|
|
75
|
+
def process(
|
|
76
|
+
self,
|
|
77
|
+
path: Path,
|
|
78
|
+
*,
|
|
79
|
+
profile: dict[str, Any] | None = None,
|
|
80
|
+
strict: bool = True,
|
|
81
|
+
keep_raw: bool = False,
|
|
82
|
+
current_sign: str = "charge-positive",
|
|
83
|
+
repair_policy: str = "warn",
|
|
84
|
+
options: dict[str, Any] | None = None,
|
|
85
|
+
) -> AdapterResult:
|
|
86
|
+
raw_result = self.read_raw_with_metadata(path, options=options)
|
|
87
|
+
raw = raw_result.data
|
|
88
|
+
normalized = self.normalize(
|
|
89
|
+
raw,
|
|
90
|
+
profile=profile,
|
|
91
|
+
strict=strict,
|
|
92
|
+
keep_raw=keep_raw,
|
|
93
|
+
current_sign=current_sign,
|
|
94
|
+
repair_policy=repair_policy,
|
|
95
|
+
)
|
|
96
|
+
repaired = self.repair(normalized.data, repair_policy=repair_policy)
|
|
97
|
+
normalized.data = repaired.data
|
|
98
|
+
_append_datetime_from_acquisition_start(normalized, raw_result.metadata)
|
|
99
|
+
normalized.warnings[:0] = raw_result.warnings
|
|
100
|
+
normalized.warnings.extend(repaired.warnings)
|
|
101
|
+
normalized.metadata.update(raw_result.metadata)
|
|
102
|
+
normalized.metadata.update(self.extract_metadata(raw))
|
|
103
|
+
normalized.metadata.update(repaired.metadata)
|
|
104
|
+
normalized.metadata.update(
|
|
105
|
+
{
|
|
106
|
+
"source_backend": "native",
|
|
107
|
+
"raw_current_sign": self.raw_current_sign,
|
|
108
|
+
"repair_policy": repair_policy,
|
|
109
|
+
"adapter_version": self.adapter_version,
|
|
110
|
+
"support_tier": self.support_tier,
|
|
111
|
+
"evidence_tier": self.evidence_tier,
|
|
112
|
+
"output_rows": normalized.data.height,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
return normalized
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_biologic_header_metadata(text: str) -> dict[str, Any]:
|
|
119
|
+
metadata: dict[str, Any] = {}
|
|
120
|
+
header_lines: list[str] = []
|
|
121
|
+
for line in text.splitlines()[:250]:
|
|
122
|
+
if "time/s" in line.lower() and ("ewe/v" in line.lower() or "ecell/v" in line.lower()):
|
|
123
|
+
break
|
|
124
|
+
header_lines.append(line)
|
|
125
|
+
|
|
126
|
+
for line in header_lines:
|
|
127
|
+
key, separator, value = line.partition(":")
|
|
128
|
+
if not separator:
|
|
129
|
+
continue
|
|
130
|
+
clean_key = re.sub(r"\s+", " ", key).strip().lower()
|
|
131
|
+
clean_value = value.strip()
|
|
132
|
+
if not clean_value:
|
|
133
|
+
continue
|
|
134
|
+
if clean_key == "acquisition started on":
|
|
135
|
+
started = _parse_biologic_datetime(clean_value)
|
|
136
|
+
if started is not None:
|
|
137
|
+
metadata["biologic_acquisition_started_at"] = started.isoformat(timespec="milliseconds")
|
|
138
|
+
metadata["biologic_acquisition_started_at_raw"] = clean_value
|
|
139
|
+
metadata["biologic_datetime_timezone"] = "unspecified"
|
|
140
|
+
elif clean_key == "technique started on":
|
|
141
|
+
started = _parse_biologic_datetime(clean_value)
|
|
142
|
+
if started is not None:
|
|
143
|
+
metadata["biologic_technique_started_at"] = started.isoformat(timespec="milliseconds")
|
|
144
|
+
metadata["biologic_technique_started_at_raw"] = clean_value
|
|
145
|
+
elif clean_key in {
|
|
146
|
+
"device",
|
|
147
|
+
"ec-lab for windows v11.36 (software)",
|
|
148
|
+
"internet server v11.36 (firmware)",
|
|
149
|
+
"command interpretor v11.36 (firmware)",
|
|
150
|
+
"electrode material",
|
|
151
|
+
"comments",
|
|
152
|
+
"current amplifier",
|
|
153
|
+
}:
|
|
154
|
+
metadata[f"biologic_{clean_key.replace(' ', '_')}"] = clean_value
|
|
155
|
+
return metadata
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_biologic_datetime(value: str) -> datetime | None:
|
|
159
|
+
value = value.strip()
|
|
160
|
+
for fmt in (
|
|
161
|
+
"%m/%d/%Y %H:%M:%S.%f",
|
|
162
|
+
"%m/%d/%Y %H:%M:%S",
|
|
163
|
+
"%m/%d/%Y",
|
|
164
|
+
"%d/%m/%Y %H:%M:%S.%f",
|
|
165
|
+
"%d/%m/%Y %H:%M:%S",
|
|
166
|
+
"%d/%m/%Y",
|
|
167
|
+
):
|
|
168
|
+
try:
|
|
169
|
+
return datetime.strptime(value, fmt)
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _replace_step_time_within_step(result: AdapterResult) -> AdapterResult:
|
|
176
|
+
df = result.data
|
|
177
|
+
if df.is_empty() or "test_time_s" not in df.columns or "step_index" not in df.columns:
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
test_times = df["test_time_s"].cast(pl.Float64, strict=False).to_list()
|
|
181
|
+
step_counts = df["step_index"].to_list()
|
|
182
|
+
step_times: list[float | None] = []
|
|
183
|
+
current_step: Any = object()
|
|
184
|
+
step_start: float | None = None
|
|
185
|
+
|
|
186
|
+
for test_time, step_count in zip(test_times, step_counts, strict=True):
|
|
187
|
+
if test_time is None:
|
|
188
|
+
step_times.append(None)
|
|
189
|
+
continue
|
|
190
|
+
value = float(test_time)
|
|
191
|
+
if step_start is None or step_count != current_step or value < step_start:
|
|
192
|
+
current_step = step_count
|
|
193
|
+
step_start = value
|
|
194
|
+
step_times.append(value - step_start)
|
|
195
|
+
|
|
196
|
+
df = df.with_columns(pl.Series("step_time_s", step_times, dtype=pl.Float64))
|
|
197
|
+
result.data = _select_existing_bds_order(df)
|
|
198
|
+
result.provenance = [item for item in result.provenance if item.column != "step_time_s"]
|
|
199
|
+
result.provenance.append(
|
|
200
|
+
ColumnProvenance(
|
|
201
|
+
"step_time_s",
|
|
202
|
+
"time/s|Ns",
|
|
203
|
+
source_unit="s",
|
|
204
|
+
transform="derived elapsed seconds within consecutive BioLogic Ns step",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
mapped = set(result.metadata.get("mapped_columns", []))
|
|
208
|
+
mapped.update({"time/s", "Ns"})
|
|
209
|
+
result.metadata["mapped_columns"] = sorted(mapped)
|
|
210
|
+
result.metadata["biologic_step_time_source"] = "derived_from_test_time_and_Ns"
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _append_datetime_from_acquisition_start(result: AdapterResult, metadata: dict[str, Any]) -> None:
|
|
215
|
+
if "date_time" in result.data.columns or "test_time_s" not in result.data.columns:
|
|
216
|
+
return
|
|
217
|
+
start_value = metadata.get("biologic_acquisition_started_at")
|
|
218
|
+
if not start_value:
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
start = datetime.fromisoformat(str(start_value))
|
|
222
|
+
except ValueError:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
offsets = result.data["test_time_s"].cast(pl.Float64, strict=False).to_list()
|
|
226
|
+
values = [
|
|
227
|
+
None
|
|
228
|
+
if offset is None
|
|
229
|
+
else (start + timedelta(milliseconds=round(float(offset) * 1000))).isoformat(timespec="milliseconds")
|
|
230
|
+
for offset in offsets
|
|
231
|
+
]
|
|
232
|
+
result.data = _select_existing_bds_order(result.data.with_columns(pl.Series("date_time", values)))
|
|
233
|
+
result.provenance.append(
|
|
234
|
+
ColumnProvenance(
|
|
235
|
+
"date_time",
|
|
236
|
+
"Acquisition started on|test_time_s",
|
|
237
|
+
transform="derived local timestamp from BioLogic header start time plus elapsed seconds",
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
result.metadata["biologic_datetime_derived"] = True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _select_existing_bds_order(df: pl.DataFrame) -> pl.DataFrame:
|
|
244
|
+
ordered = [column for column in ALL_COLUMNS if column in df.columns]
|
|
245
|
+
ordered.extend(column for column in df.columns if column not in set(ordered))
|
|
246
|
+
return df.select(ordered)
|