battery-feature-lab 0.1.0__tar.gz

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 (44) hide show
  1. battery_feature_lab-0.1.0/LICENSE +21 -0
  2. battery_feature_lab-0.1.0/PKG-INFO +158 -0
  3. battery_feature_lab-0.1.0/README.md +135 -0
  4. battery_feature_lab-0.1.0/battery_feature_lab/__init__.py +8 -0
  5. battery_feature_lab-0.1.0/battery_feature_lab/analysis/__init__.py +1 -0
  6. battery_feature_lab-0.1.0/battery_feature_lab/analysis/degradation_tags.py +305 -0
  7. battery_feature_lab-0.1.0/battery_feature_lab/analysis/feature_selection.py +107 -0
  8. battery_feature_lab-0.1.0/battery_feature_lab/analysis/shap_report.py +31 -0
  9. battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/__init__.py +1 -0
  10. battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/readers.py +134 -0
  11. battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/validators.py +26 -0
  12. battery_feature_lab-0.1.0/battery_feature_lab/cli.py +87 -0
  13. battery_feature_lab-0.1.0/battery_feature_lab/constants.py +25 -0
  14. battery_feature_lab-0.1.0/battery_feature_lab/core/__init__.py +1 -0
  15. battery_feature_lab-0.1.0/battery_feature_lab/core/cycle_splitter.py +52 -0
  16. battery_feature_lab-0.1.0/battery_feature_lab/core/integration.py +74 -0
  17. battery_feature_lab-0.1.0/battery_feature_lab/core/resampling.py +55 -0
  18. battery_feature_lab-0.1.0/battery_feature_lab/core/smoothing.py +35 -0
  19. battery_feature_lab-0.1.0/battery_feature_lab/core/units.py +30 -0
  20. battery_feature_lab-0.1.0/battery_feature_lab/export/__init__.py +1 -0
  21. battery_feature_lab-0.1.0/battery_feature_lab/export/llm_json_writer.py +102 -0
  22. battery_feature_lab-0.1.0/battery_feature_lab/export/parquet_writer.py +26 -0
  23. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/__init__.py +17 -0
  24. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/base.py +22 -0
  25. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/common.py +65 -0
  26. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/cycle_summary.py +103 -0
  27. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/delta_q.py +102 -0
  28. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/eis_drt.py +82 -0
  29. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/ica_dva.py +142 -0
  30. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/relaxation.py +116 -0
  31. battery_feature_lab-0.1.0/battery_feature_lab/featurizers/stress_histogram.py +131 -0
  32. battery_feature_lab-0.1.0/battery_feature_lab/pipeline.py +109 -0
  33. battery_feature_lab-0.1.0/battery_feature_lab/schemas.py +131 -0
  34. battery_feature_lab-0.1.0/battery_feature_lab/validation/__init__.py +13 -0
  35. battery_feature_lab-0.1.0/battery_feature_lab/validation/dataset_validation.py +177 -0
  36. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/PKG-INFO +158 -0
  37. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/SOURCES.txt +42 -0
  38. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/dependency_links.txt +1 -0
  39. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/entry_points.txt +3 -0
  40. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/requires.txt +14 -0
  41. battery_feature_lab-0.1.0/battery_feature_lab.egg-info/top_level.txt +1 -0
  42. battery_feature_lab-0.1.0/pyproject.toml +37 -0
  43. battery_feature_lab-0.1.0/setup.cfg +4 -0
  44. battery_feature_lab-0.1.0/tests/test_pipeline.py +200 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BFL contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: battery-feature-lab
3
+ Version: 0.1.0
4
+ Summary: Feature extraction pipeline for battery cycling, BDS exports, and LLM-ready battery diagnostics.
5
+ Author: BFL contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy>=1.23
11
+ Requires-Dist: pandas>=1.5
12
+ Requires-Dist: scipy>=1.9
13
+ Requires-Dist: scikit-learn>=1.2
14
+ Requires-Dist: pyarrow>=10.0
15
+ Provides-Extra: explain
16
+ Requires-Dist: shap>=0.43; extra == "explain"
17
+ Requires-Dist: xgboost>=1.7; extra == "explain"
18
+ Requires-Dist: lightgbm>=4.0; extra == "explain"
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # BFL: Battery Feature Lab
25
+
26
+ BFL extracts battery cycling features from BDS-style exports and common cycler tables. It turns
27
+ raw time-series data into feature tables for SOH/RUL modeling, feature screening, explainability,
28
+ and compact diagnostic summaries.
29
+
30
+ ## Features
31
+
32
+ - Cycle summaries: capacity, energy, efficiency, C-rate, voltage, temperature, and rest time.
33
+ - Early-life curve features: `Delta Q(V)` variance, norms, quantiles, and related statistics.
34
+ - ICA/DVA features: `dQ/dV` and `dV/dQ` peaks, locations, widths, heights, and areas.
35
+ - Relaxation features: voltage drop, slopes, interpolated voltages, and exponential fits.
36
+ - Stress features: SOC, voltage, current, C-rate, temperature histograms, and high-SOC rest time.
37
+ - EIS descriptors when impedance columns are available.
38
+ - Rule-based degradation tags for LLI, LAM_PE, LAM_NE, resistance growth, and related evidence.
39
+ - LLM-ready JSONL summaries for downstream review or diagnostic workflows.
40
+
41
+ ## Installation
42
+
43
+ From PyPI, after the package is published:
44
+
45
+ ```bash
46
+ pip install battery-feature-lab
47
+ ```
48
+
49
+ From a local checkout:
50
+
51
+ ```bash
52
+ python -m pip install -e ".[dev]"
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```bash
58
+ bfl extract input.csv --output-dir out --cell-id cell_001 --nominal-capacity-ah 1.1
59
+ ```
60
+
61
+ The longer command name is also available:
62
+
63
+ ```bash
64
+ battery-features extract input.csv --output-dir out --cell-id cell_001 --nominal-capacity-ah 1.1
65
+ ```
66
+
67
+ Diagnostic thresholds are configurable:
68
+
69
+ ```bash
70
+ bfl extract input.csv \
71
+ --output-dir out \
72
+ --nominal-capacity-ah 1.1 \
73
+ --datasheet-max-discharge-c-rate 5 \
74
+ --high-soc-rest-threshold 0.25
75
+ ```
76
+
77
+ ## Input Data
78
+
79
+ BFL accepts CSV, TSV, JSON, JSONL, and Parquet files. Input data can use common cycler/BDS
80
+ column names. The reader normalizes aliases to:
81
+
82
+ ```text
83
+ time_s, voltage_v, current_a, temperature_c, charge_capacity_ah,
84
+ discharge_capacity_ah, cycle_index, step_index, step_type
85
+ ```
86
+
87
+ At minimum, the input should contain time, voltage, current, and enough cycle/step information
88
+ to identify charge, discharge, and rest periods. If `cell_id`, `cycle_index`, or `step_type` is
89
+ missing, BFL can infer or fill parts of the schema from the file name, current sign, and command
90
+ line options.
91
+
92
+ Optional EIS columns are:
93
+
94
+ ```text
95
+ frequency_hz, z_real_ohm, z_imag_ohm
96
+ ```
97
+
98
+ ## Outputs
99
+
100
+ BFL writes non-empty tables to the selected output directory:
101
+
102
+ ```text
103
+ out/
104
+ normalized_timeseries.parquet
105
+ cycle_features.parquet
106
+ delta_q_features.parquet
107
+ ica_dva_features.parquet
108
+ relaxation_features.parquet
109
+ stress_features.parquet
110
+ degradation_tags.parquet
111
+ llm_context.jsonl
112
+ run_metadata.json
113
+ ```
114
+
115
+ ## Validation
116
+
117
+ Run the offline self-test:
118
+
119
+ ```bash
120
+ python scripts/validate_on_dataset.py --synthetic 12
121
+ ```
122
+
123
+ Run validation on a folder of per-cell CSV files:
124
+
125
+ ```bash
126
+ python scripts/validate_on_dataset.py --data-dir path/to/cells --nominal-capacity-ah 1.1
127
+ ```
128
+
129
+ ## Python Usage
130
+
131
+ ```python
132
+ from pathlib import Path
133
+
134
+ from battery_feature_lab.pipeline import FeaturePipeline, PipelineConfig
135
+ from battery_feature_lab.schemas import ExportConfig, FeatureConfig, ReaderConfig
136
+
137
+ pipeline = FeaturePipeline(
138
+ PipelineConfig(
139
+ reader=ReaderConfig(cell_id="cell_001"),
140
+ features=FeatureConfig(nominal_capacity_ah=1.1),
141
+ export=ExportConfig(output_dir=Path("out")),
142
+ )
143
+ )
144
+
145
+ tables = pipeline.run("input.csv")
146
+ ```
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ python -m pip install -e ".[dev]"
152
+ python -m pytest
153
+ python -m ruff check .
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,135 @@
1
+ # BFL: Battery Feature Lab
2
+
3
+ BFL extracts battery cycling features from BDS-style exports and common cycler tables. It turns
4
+ raw time-series data into feature tables for SOH/RUL modeling, feature screening, explainability,
5
+ and compact diagnostic summaries.
6
+
7
+ ## Features
8
+
9
+ - Cycle summaries: capacity, energy, efficiency, C-rate, voltage, temperature, and rest time.
10
+ - Early-life curve features: `Delta Q(V)` variance, norms, quantiles, and related statistics.
11
+ - ICA/DVA features: `dQ/dV` and `dV/dQ` peaks, locations, widths, heights, and areas.
12
+ - Relaxation features: voltage drop, slopes, interpolated voltages, and exponential fits.
13
+ - Stress features: SOC, voltage, current, C-rate, temperature histograms, and high-SOC rest time.
14
+ - EIS descriptors when impedance columns are available.
15
+ - Rule-based degradation tags for LLI, LAM_PE, LAM_NE, resistance growth, and related evidence.
16
+ - LLM-ready JSONL summaries for downstream review or diagnostic workflows.
17
+
18
+ ## Installation
19
+
20
+ From PyPI, after the package is published:
21
+
22
+ ```bash
23
+ pip install battery-feature-lab
24
+ ```
25
+
26
+ From a local checkout:
27
+
28
+ ```bash
29
+ python -m pip install -e ".[dev]"
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ```bash
35
+ bfl extract input.csv --output-dir out --cell-id cell_001 --nominal-capacity-ah 1.1
36
+ ```
37
+
38
+ The longer command name is also available:
39
+
40
+ ```bash
41
+ battery-features extract input.csv --output-dir out --cell-id cell_001 --nominal-capacity-ah 1.1
42
+ ```
43
+
44
+ Diagnostic thresholds are configurable:
45
+
46
+ ```bash
47
+ bfl extract input.csv \
48
+ --output-dir out \
49
+ --nominal-capacity-ah 1.1 \
50
+ --datasheet-max-discharge-c-rate 5 \
51
+ --high-soc-rest-threshold 0.25
52
+ ```
53
+
54
+ ## Input Data
55
+
56
+ BFL accepts CSV, TSV, JSON, JSONL, and Parquet files. Input data can use common cycler/BDS
57
+ column names. The reader normalizes aliases to:
58
+
59
+ ```text
60
+ time_s, voltage_v, current_a, temperature_c, charge_capacity_ah,
61
+ discharge_capacity_ah, cycle_index, step_index, step_type
62
+ ```
63
+
64
+ At minimum, the input should contain time, voltage, current, and enough cycle/step information
65
+ to identify charge, discharge, and rest periods. If `cell_id`, `cycle_index`, or `step_type` is
66
+ missing, BFL can infer or fill parts of the schema from the file name, current sign, and command
67
+ line options.
68
+
69
+ Optional EIS columns are:
70
+
71
+ ```text
72
+ frequency_hz, z_real_ohm, z_imag_ohm
73
+ ```
74
+
75
+ ## Outputs
76
+
77
+ BFL writes non-empty tables to the selected output directory:
78
+
79
+ ```text
80
+ out/
81
+ normalized_timeseries.parquet
82
+ cycle_features.parquet
83
+ delta_q_features.parquet
84
+ ica_dva_features.parquet
85
+ relaxation_features.parquet
86
+ stress_features.parquet
87
+ degradation_tags.parquet
88
+ llm_context.jsonl
89
+ run_metadata.json
90
+ ```
91
+
92
+ ## Validation
93
+
94
+ Run the offline self-test:
95
+
96
+ ```bash
97
+ python scripts/validate_on_dataset.py --synthetic 12
98
+ ```
99
+
100
+ Run validation on a folder of per-cell CSV files:
101
+
102
+ ```bash
103
+ python scripts/validate_on_dataset.py --data-dir path/to/cells --nominal-capacity-ah 1.1
104
+ ```
105
+
106
+ ## Python Usage
107
+
108
+ ```python
109
+ from pathlib import Path
110
+
111
+ from battery_feature_lab.pipeline import FeaturePipeline, PipelineConfig
112
+ from battery_feature_lab.schemas import ExportConfig, FeatureConfig, ReaderConfig
113
+
114
+ pipeline = FeaturePipeline(
115
+ PipelineConfig(
116
+ reader=ReaderConfig(cell_id="cell_001"),
117
+ features=FeatureConfig(nominal_capacity_ah=1.1),
118
+ export=ExportConfig(output_dir=Path("out")),
119
+ )
120
+ )
121
+
122
+ tables = pipeline.run("input.csv")
123
+ ```
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ python -m pip install -e ".[dev]"
129
+ python -m pytest
130
+ python -m ruff check .
131
+ ```
132
+
133
+ ## License
134
+
135
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ """Battery Feature Lab.
2
+
3
+ Independent feature extraction pipeline for battery cycling data and BDS-style exports.
4
+ """
5
+
6
+ from battery_feature_lab.pipeline import FeaturePipeline, PipelineConfig
7
+
8
+ __all__ = ["FeaturePipeline", "PipelineConfig"]
@@ -0,0 +1 @@
1
+ """Analysis helpers for extracted battery features."""
@@ -0,0 +1,305 @@
1
+ """Rule-based degradation evidence tagging for LLM context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy.stats import kendalltau
8
+
9
+ from battery_feature_lab.schemas import DiagnosticConfig
10
+
11
+ def build_degradation_tags(
12
+ cycle_features: pd.DataFrame | None = None,
13
+ ica_dva_features: pd.DataFrame | None = None,
14
+ stress_features: pd.DataFrame | None = None,
15
+ relaxation_features: pd.DataFrame | None = None,
16
+ config: DiagnosticConfig | None = None,
17
+ ) -> pd.DataFrame:
18
+ """Build interpretable degradation tags from extracted feature tables."""
19
+
20
+ config = config or DiagnosticConfig()
21
+ records: list[dict[str, object]] = []
22
+ cycle_features = _empty_if_none(cycle_features)
23
+ ica_dva_features = _empty_if_none(ica_dva_features)
24
+ stress_features = _empty_if_none(stress_features)
25
+ relaxation_features = _empty_if_none(relaxation_features)
26
+
27
+ cells = set()
28
+ for table in (cycle_features, ica_dva_features, stress_features, relaxation_features):
29
+ if "cell_id" in table.columns:
30
+ cells.update(table["cell_id"].dropna().astype(str).unique())
31
+
32
+ for cell_id in sorted(cells):
33
+ cell_cycle = cycle_features[cycle_features.get("cell_id", pd.Series(dtype=str)) == cell_id]
34
+ cell_ica = ica_dva_features[ica_dva_features.get("cell_id", pd.Series(dtype=str)) == cell_id]
35
+ cell_stress = stress_features[stress_features.get("cell_id", pd.Series(dtype=str)) == cell_id]
36
+ cell_relax = relaxation_features[
37
+ relaxation_features.get("cell_id", pd.Series(dtype=str)) == cell_id
38
+ ]
39
+ records.extend(_capacity_fade_tags(cell_id, cell_cycle, config))
40
+ records.extend(_ica_shift_tags(cell_id, cell_ica, config))
41
+ records.extend(_stress_tags(cell_id, cell_stress, stress_features, config))
42
+ records.extend(_relaxation_tags(cell_id, cell_relax, config))
43
+
44
+ return pd.DataFrame(records)
45
+
46
+
47
+ def _capacity_fade_tags(
48
+ cell_id: str, cycles: pd.DataFrame, config: DiagnosticConfig
49
+ ) -> list[dict[str, object]]:
50
+ if cycles.empty or "discharge_capacity_ah" not in cycles:
51
+ return []
52
+ ordered = cycles.sort_values("cycle_index")
53
+ capacity = ordered["discharge_capacity_ah"].to_numpy(dtype=float)
54
+ cycle = ordered["cycle_index"].to_numpy(dtype=float)
55
+ valid = np.isfinite(capacity) & np.isfinite(cycle)
56
+ if valid.sum() < config.min_trend_points:
57
+ return []
58
+ normalized = capacity[valid] / capacity[valid][0] if capacity[valid][0] else capacity[valid]
59
+ trend = mann_kendall_sen_slope(cycle[valid], normalized)
60
+ if trend["sen_slope"] < 0 and trend["p_value"] < config.trend_p_value_alpha:
61
+ return [
62
+ _tag(
63
+ cell_id,
64
+ "capacity_fade_detected",
65
+ ["LLI", "LAM_PE", "LAM_NE"],
66
+ "medium",
67
+ "Mann-Kendall trend test detects monotonic capacity fade: "
68
+ f"p={trend['p_value']:.3g}, Sen slope={trend['sen_slope']:.3e} per cycle.",
69
+ )
70
+ ]
71
+ return []
72
+
73
+
74
+ def _ica_shift_tags(
75
+ cell_id: str, ica: pd.DataFrame, config: DiagnosticConfig
76
+ ) -> list[dict[str, object]]:
77
+ if ica.empty or "ica_peak_1_x" not in ica.columns:
78
+ return []
79
+ discharge = ica[ica.get("step_type", "") == "discharge"].sort_values("cycle_index")
80
+ if len(discharge) < config.min_trend_points:
81
+ return []
82
+ peak_x = discharge["ica_peak_1_x"].to_numpy(dtype=float)
83
+ cycle = discharge["cycle_index"].to_numpy(dtype=float)
84
+ valid = np.isfinite(peak_x) & np.isfinite(cycle)
85
+ if valid.sum() < config.min_trend_points:
86
+ return []
87
+ trend = mann_kendall_sen_slope(cycle[valid], peak_x[valid])
88
+ if trend["p_value"] >= config.trend_p_value_alpha or not np.isfinite(trend["sen_slope"]):
89
+ return []
90
+ mode = "LLI" if trend["sen_slope"] < 0 else "LAM_PE"
91
+ return [
92
+ _tag(
93
+ cell_id,
94
+ "ica_primary_peak_shift",
95
+ [mode],
96
+ "medium",
97
+ "Mann-Kendall trend test detects primary ICA peak drift: "
98
+ f"p={trend['p_value']:.3g}, Sen slope={trend['sen_slope']:.3e} V per cycle.",
99
+ )
100
+ ]
101
+
102
+
103
+ def _stress_tags(
104
+ cell_id: str,
105
+ stress: pd.DataFrame,
106
+ all_stress: pd.DataFrame,
107
+ config: DiagnosticConfig,
108
+ ) -> list[dict[str, object]]:
109
+ if stress.empty:
110
+ return []
111
+ row = stress.iloc[0]
112
+ tags = []
113
+
114
+ high_soc_rest = float(row.get("high_soc_rest_fraction", np.nan))
115
+ if np.isfinite(high_soc_rest):
116
+ triggered, basis = _evaluate_stress_metric(
117
+ value=high_soc_rest,
118
+ population=_column_values(all_stress, "high_soc_rest_fraction"),
119
+ absolute_threshold=config.high_soc_rest_fraction_threshold,
120
+ batch_method="percentile",
121
+ batch_param=config.stress_percentile,
122
+ )
123
+ if triggered:
124
+ tags.append(
125
+ _tag(
126
+ cell_id,
127
+ "high_soc_rest_exposure",
128
+ ["LAM_PE", "LLI"],
129
+ "medium",
130
+ f"High-SOC rest fraction {high_soc_rest:.3g} flagged by {basis}.",
131
+ )
132
+ )
133
+
134
+ c_rate_variance = float(row.get("c_rate_variance", np.nan))
135
+ if np.isfinite(c_rate_variance):
136
+ triggered, basis = _evaluate_stress_metric(
137
+ value=c_rate_variance,
138
+ population=_column_values(all_stress, "c_rate_variance"),
139
+ absolute_threshold=config.c_rate_variance_threshold,
140
+ batch_method="mad",
141
+ batch_param=config.stress_mad_threshold,
142
+ )
143
+ if triggered:
144
+ tags.append(
145
+ _tag(
146
+ cell_id,
147
+ "dynamic_current_variance",
148
+ ["LAM_NE", "resistance_growth"],
149
+ "low",
150
+ f"Normalized C-rate variance {c_rate_variance:.3g} flagged by {basis}.",
151
+ )
152
+ )
153
+
154
+ max_discharge_c = float(row.get("max_instant_discharge_c_rate", np.nan))
155
+ if (
156
+ config.datasheet_max_discharge_c_rate is not None
157
+ and np.isfinite(max_discharge_c)
158
+ and max_discharge_c
159
+ > config.datasheet_max_discharge_c_rate * config.max_discharge_c_rate_fraction
160
+ ):
161
+ tags.append(
162
+ _tag(
163
+ cell_id,
164
+ "high_instantaneous_discharge_rate",
165
+ ["LAM_NE", "resistance_growth"],
166
+ "medium",
167
+ f"Maximum instantaneous discharge C-rate {max_discharge_c:.2f} exceeds "
168
+ f"{config.max_discharge_c_rate_fraction:.0%} of datasheet limit "
169
+ f"{config.datasheet_max_discharge_c_rate:.2f}.",
170
+ )
171
+ )
172
+ return tags
173
+
174
+
175
+ def _relaxation_tags(
176
+ cell_id: str, relaxation: pd.DataFrame, config: DiagnosticConfig
177
+ ) -> list[dict[str, object]]:
178
+ if relaxation.empty or "rest_exp_tau_s" not in relaxation.columns:
179
+ return []
180
+ ordered = relaxation.sort_values("cycle_index")
181
+ tau = ordered["rest_exp_tau_s"].to_numpy(dtype=float)
182
+ cycle = ordered["cycle_index"].to_numpy(dtype=float)
183
+ valid = np.isfinite(tau) & np.isfinite(cycle)
184
+ if valid.sum() < config.min_trend_points:
185
+ return []
186
+ trend = mann_kendall_sen_slope(cycle[valid], tau[valid])
187
+ if trend["sen_slope"] > 0 and trend["p_value"] < config.trend_p_value_alpha:
188
+ return [
189
+ _tag(
190
+ cell_id,
191
+ "relaxation_tau_increase",
192
+ ["resistance_growth", "transport_limitation"],
193
+ "low",
194
+ "Mann-Kendall trend test detects increasing relaxation time constant: "
195
+ f"p={trend['p_value']:.3g}, Sen slope={trend['sen_slope']:.3e} s per cycle.",
196
+ )
197
+ ]
198
+ return []
199
+
200
+
201
+ def mann_kendall_sen_slope(x: np.ndarray, y: np.ndarray) -> dict[str, float]:
202
+ """Return the Mann-Kendall trend p-value and Sen's slope for a monotonic trend.
203
+
204
+ The Mann-Kendall test statistic is Kendall's tau between the series and its (time/cycle)
205
+ index, so ``scipy.stats.kendalltau`` computes the test directly (exact for small n, asymptotic
206
+ with tie handling otherwise). Sen's slope is the Theil-Sen median of pairwise slopes, the trend
207
+ magnitude estimator conventionally paired with Mann-Kendall. Power is low for very short
208
+ series; callers should require a minimum sample size (see ``DiagnosticConfig.min_trend_points``).
209
+ """
210
+
211
+ x = np.asarray(x, dtype=float)
212
+ y = np.asarray(y, dtype=float)
213
+ valid = np.isfinite(x) & np.isfinite(y)
214
+ x = x[valid]
215
+ y = y[valid]
216
+ if len(x) < 2:
217
+ return {"p_value": float("nan"), "sen_slope": float("nan"), "kendall_tau": float("nan")}
218
+ tau, p_value = kendalltau(x, y)
219
+ slopes: list[float] = []
220
+ for i in range(len(x) - 1):
221
+ dx = x[i + 1 :] - x[i]
222
+ dy = y[i + 1 :] - y[i]
223
+ valid_dx = np.abs(dx) > 1e-12
224
+ slopes.extend((dy[valid_dx] / dx[valid_dx]).tolist())
225
+ sen_slope = float(np.median(slopes)) if slopes else float("nan")
226
+ return {
227
+ "p_value": float(p_value) if np.isfinite(p_value) else float("nan"),
228
+ "sen_slope": sen_slope,
229
+ "kendall_tau": float(tau) if np.isfinite(tau) else float("nan"),
230
+ }
231
+
232
+
233
+ def _evaluate_stress_metric(
234
+ value: float,
235
+ population: np.ndarray,
236
+ absolute_threshold: float | None,
237
+ batch_method: str,
238
+ batch_param: float,
239
+ ) -> tuple[bool, str]:
240
+ """Dual-criterion stress evaluation.
241
+
242
+ The absolute threshold encodes domain knowledge (cell spec / application limit) and is
243
+ single-cell capable. The batch criterion is a *supplementary* outlier signal that requires
244
+ a population of cells and is reported as relative, never as an absolute physical judgment.
245
+ Returns ``(triggered, basis)`` where ``basis`` names which criterion fired.
246
+ """
247
+
248
+ if absolute_threshold is not None and value > absolute_threshold:
249
+ return True, f"absolute threshold {absolute_threshold:.3g} (config/domain knowledge)"
250
+ batch_threshold = _batch_threshold(population, batch_method, batch_param)
251
+ if np.isfinite(batch_threshold) and value > batch_threshold:
252
+ if batch_method == "percentile":
253
+ label = f"batch P{batch_param * 100:.0f}"
254
+ else:
255
+ label = f"batch median+{batch_param:g}*MAD"
256
+ return True, f"batch-relative outlier (> {label} = {batch_threshold:.3g})"
257
+ return False, ""
258
+
259
+
260
+ def _batch_threshold(population: np.ndarray, method: str, param: float) -> float:
261
+ values = np.asarray(population, dtype=float)
262
+ values = values[np.isfinite(values)]
263
+ if method == "percentile":
264
+ if len(values) < 2:
265
+ return float("nan")
266
+ return float(np.quantile(values, param))
267
+ if method == "mad":
268
+ # Robust outlier threshold: median + param * (1.4826 * MAD). The 1.4826 factor scales the
269
+ # median absolute deviation to a standard-deviation-consistent estimator under normality,
270
+ # so ``param`` is interpretable as a number of (robust) sigmas. Unlike mean+k*std this does
271
+ # not let a single extreme cell inflate the spread and mask itself.
272
+ if len(values) < 3:
273
+ return float("nan")
274
+ median = float(np.median(values))
275
+ mad = float(np.median(np.abs(values - median)))
276
+ robust_sigma = 1.4826 * mad
277
+ if robust_sigma <= 1e-12 or not np.isfinite(robust_sigma):
278
+ return float("nan")
279
+ return float(median + param * robust_sigma)
280
+ return float("nan")
281
+
282
+
283
+ def _column_values(frame: pd.DataFrame, column: str) -> np.ndarray:
284
+ series = pd.to_numeric(frame.get(column, pd.Series(dtype=float)), errors="coerce")
285
+ return series.to_numpy(dtype=float)
286
+
287
+
288
+ def _tag(
289
+ cell_id: str,
290
+ signal: str,
291
+ possible_modes: list[str],
292
+ confidence: str,
293
+ evidence: str,
294
+ ) -> dict[str, object]:
295
+ return {
296
+ "cell_id": cell_id,
297
+ "signal": signal,
298
+ "possible_modes": possible_modes,
299
+ "confidence": confidence,
300
+ "evidence": evidence,
301
+ }
302
+
303
+
304
+ def _empty_if_none(frame: pd.DataFrame | None) -> pd.DataFrame:
305
+ return frame if frame is not None else pd.DataFrame()