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.
Files changed (47) hide show
  1. battery_data_standard/__init__.py +99 -0
  2. battery_data_standard/__main__.py +5 -0
  3. battery_data_standard/_version.py +3 -0
  4. battery_data_standard/adapters/__init__.py +5 -0
  5. battery_data_standard/adapters/arbin.py +84 -0
  6. battery_data_standard/adapters/base.py +124 -0
  7. battery_data_standard/adapters/basytec.py +88 -0
  8. battery_data_standard/adapters/biologic.py +246 -0
  9. battery_data_standard/adapters/generic.py +40 -0
  10. battery_data_standard/adapters/landt.py +36 -0
  11. battery_data_standard/adapters/maccor.py +122 -0
  12. battery_data_standard/adapters/neware.py +1733 -0
  13. battery_data_standard/adapters/normalization.py +912 -0
  14. battery_data_standard/adapters/novonix.py +29 -0
  15. battery_data_standard/adapters/pec.py +49 -0
  16. battery_data_standard/adapters/registry.py +150 -0
  17. battery_data_standard/adapters/repower.py +57 -0
  18. battery_data_standard/adapters/simple.py +27 -0
  19. battery_data_standard/api.py +1007 -0
  20. battery_data_standard/archive.py +224 -0
  21. battery_data_standard/audit.py +678 -0
  22. battery_data_standard/cli.py +550 -0
  23. battery_data_standard/diagnostics.py +406 -0
  24. battery_data_standard/doctor.py +361 -0
  25. battery_data_standard/eis.py +467 -0
  26. battery_data_standard/exceptions.py +45 -0
  27. battery_data_standard/export.py +483 -0
  28. battery_data_standard/inspection.py +329 -0
  29. battery_data_standard/io.py +1204 -0
  30. battery_data_standard/kind.py +331 -0
  31. battery_data_standard/profiles.py +86 -0
  32. battery_data_standard/py.typed +1 -0
  33. battery_data_standard/quality.py +420 -0
  34. battery_data_standard/reporting.py +1072 -0
  35. battery_data_standard/reports.py +125 -0
  36. battery_data_standard/schema.py +507 -0
  37. battery_data_standard/summary.py +111 -0
  38. battery_data_standard/time_sampling.py +399 -0
  39. battery_data_standard/validation.py +111 -0
  40. battery_data_steward-0.4.0.dist-info/METADATA +502 -0
  41. battery_data_steward-0.4.0.dist-info/RECORD +47 -0
  42. battery_data_steward-0.4.0.dist-info/WHEEL +5 -0
  43. battery_data_steward-0.4.0.dist-info/entry_points.txt +2 -0
  44. battery_data_steward-0.4.0.dist-info/licenses/LICENSE +21 -0
  45. battery_data_steward-0.4.0.dist-info/top_level.txt +2 -0
  46. bds/__init__.py +5 -0
  47. 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,5 @@
1
+ """Run the CLI with ``python -m battery_data_standard``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,5 @@
1
+ """Adapter registry exports."""
2
+
3
+ from .registry import all_adapters, detect_adapter, get_adapter
4
+
5
+ __all__ = ["all_adapters", "detect_adapter", "get_adapter"]
@@ -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)