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.
- battery_feature_lab-0.1.0/LICENSE +21 -0
- battery_feature_lab-0.1.0/PKG-INFO +158 -0
- battery_feature_lab-0.1.0/README.md +135 -0
- battery_feature_lab-0.1.0/battery_feature_lab/__init__.py +8 -0
- battery_feature_lab-0.1.0/battery_feature_lab/analysis/__init__.py +1 -0
- battery_feature_lab-0.1.0/battery_feature_lab/analysis/degradation_tags.py +305 -0
- battery_feature_lab-0.1.0/battery_feature_lab/analysis/feature_selection.py +107 -0
- battery_feature_lab-0.1.0/battery_feature_lab/analysis/shap_report.py +31 -0
- battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/__init__.py +1 -0
- battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/readers.py +134 -0
- battery_feature_lab-0.1.0/battery_feature_lab/bds_adapter/validators.py +26 -0
- battery_feature_lab-0.1.0/battery_feature_lab/cli.py +87 -0
- battery_feature_lab-0.1.0/battery_feature_lab/constants.py +25 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/__init__.py +1 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/cycle_splitter.py +52 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/integration.py +74 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/resampling.py +55 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/smoothing.py +35 -0
- battery_feature_lab-0.1.0/battery_feature_lab/core/units.py +30 -0
- battery_feature_lab-0.1.0/battery_feature_lab/export/__init__.py +1 -0
- battery_feature_lab-0.1.0/battery_feature_lab/export/llm_json_writer.py +102 -0
- battery_feature_lab-0.1.0/battery_feature_lab/export/parquet_writer.py +26 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/__init__.py +17 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/base.py +22 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/common.py +65 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/cycle_summary.py +103 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/delta_q.py +102 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/eis_drt.py +82 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/ica_dva.py +142 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/relaxation.py +116 -0
- battery_feature_lab-0.1.0/battery_feature_lab/featurizers/stress_histogram.py +131 -0
- battery_feature_lab-0.1.0/battery_feature_lab/pipeline.py +109 -0
- battery_feature_lab-0.1.0/battery_feature_lab/schemas.py +131 -0
- battery_feature_lab-0.1.0/battery_feature_lab/validation/__init__.py +13 -0
- battery_feature_lab-0.1.0/battery_feature_lab/validation/dataset_validation.py +177 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/PKG-INFO +158 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/SOURCES.txt +42 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/dependency_links.txt +1 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/entry_points.txt +3 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/requires.txt +14 -0
- battery_feature_lab-0.1.0/battery_feature_lab.egg-info/top_level.txt +1 -0
- battery_feature_lab-0.1.0/pyproject.toml +37 -0
- battery_feature_lab-0.1.0/setup.cfg +4 -0
- 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 @@
|
|
|
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()
|