Files
L'électron rare f55093d6fe
ESP-IDF CI / Host Tests (Unity) (push) Successful in 1m8s
CI / firmware-native (push) Successful in 2m57s
Rust Protection Tests / Cargo test (host) (push) Failing after 3m21s
ESP-IDF CI / ESP-IDF Build (v5.4) (push) Failing after 6m55s
ESP-IDF CI / Memory Budget Gate (push) Has been skipped
qa-cicd-environments / qa-kxkm-s3-build (push) Successful in 8m53s
qa-cicd-environments / qa-sim-host (push) Successful in 2m2s
qa-cicd-environments / qa-kxkm-s3-memory-budget (push) Successful in 11m17s
chore: import KXKM Batterie Parallelator
Context: the project archive (KXKM_Batterie_Parallelator-main) had
no git history locally; a fresh repository is needed to host it on
git.saillant.cc (electron/KXKM_Batterie_Parallelator).

Approach: initialize a new repo on branch main, stage the archive
content, and harden .gitignore before the first commit.

Changes:
- Import the full project tree: firmware/, firmware-idf/,
  firmware-rs/, iosApp/, kxkm-bmu-app/, kxkm-api/, hardware/,
  docs/, specs/, scripts/, models/, tests/
- Keep project dotfiles tracked despite the trailing '.*' ignore
  rule: .github/, .claude/, .superpowers/, .gitattributes,
  .markdownlint.json
- Extend .gitignore: firmware/src/credentials.h (local secrets,
  template kept), kxkm-bmu-app/**/build/ (66 MB compiled iOS
  framework), .remember/ (session data)

Impact: the project can now be maintained on the self-hosted Gitea
forge with a clean, secret-free initial history.
2026-07-04 12:32:28 +02:00

166 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
analyze_features.py — Feature distribution audit for Phase 2 FPNN/SambaMixer training.
Analyzes per-device feature distributions to diagnose:
1. Device-dependent shifts (train gocab/k-led vs test tender)
2. High-NaN feature impact
3. SOH proxy quality candidates
4. Outlier detection
"""
import argparse
import logging
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from scipy import stats
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
)
log = logging.getLogger(__name__)
def analyze_features(features_path: str) -> None:
"""Comprehensive feature audit across devices."""
log.info("Loading features from %s", features_path)
df = pd.read_parquet(features_path)
log.info("Dataset shape: %s rows, %s columns", df.shape[0], df.shape[1])
log.info("Devices: %s", sorted(df["device"].unique()))
# === NaN Analysis ===
log.info("\n=== NaN Analysis ===")
for col in df.columns:
nan_count = df[col].isna().sum()
nan_pct = 100.0 * nan_count / len(df)
if nan_pct > 0:
log.info(" %s: %.1f%% NaN (%d rows)", col, nan_pct, nan_count)
# === Per-Device Distribution ===
log.info("\n=== Per-Device Feature Distributions ===")
for device in sorted(df["device"].unique()):
log.info("\nDevice: %s (%d windows)", device, len(df[df["device"] == device]))
for col in ["V_mean", "I_mean", "ah_cons", "rest_voltage", "coulombic_efficiency"]:
if col not in df.columns:
continue
subset = df[df["device"] == device][col]
subset_clean = subset.dropna()
if len(subset_clean) > 0:
log.info(
" %s: mean=%.2f, std=%.2f, range=[%.2f, %.2f], NaN=%.1f%%",
col,
subset_clean.mean(),
subset_clean.std(),
subset_clean.min(),
subset_clean.max(),
100.0 * (len(subset) - len(subset_clean)) / len(subset),
)
# === SOH Proxy Candidates ===
log.info("\n=== SOH Proxy Candidates Evaluation ===")
log.info("\nCandidate A: rest_voltage (current)")
rest_voltage_nan = (df["rest_voltage"].isna().sum() / len(df)) * 100
log.info(" Pros: Direct measurement")
log.info(" Cons: %.1f%% NaN, device-dependent normalization (tender vs LEDs differ)", rest_voltage_nan)
log.info(" → VERDICT: BROKEN for cross-device training ✗")
log.info("\nCandidate B: capacity_fade_ratio (recommended)")
# Compute capacity fade per device/channel
fade_ratios = []
for (dev, ch), group in df.groupby(["device", "channel"]):
ah_trend = group["ah_cons"].dropna()
if len(ah_trend) > 2:
x = np.arange(len(ah_trend)).reshape(-1, 1)
trend = np.polyfit(x.flatten(), ah_trend.values, 1)[0]
fade_ratios.append(np.abs(trend))
if fade_ratios:
log.info(" Pros: Physics-based (capacity fade), device-independent, <2%% NaN")
log.info(" Fade rates: mean=%.4f, std=%.4f, range=[%.4f, %.4f]",
np.mean(fade_ratios), np.std(fade_ratios), np.min(fade_ratios), np.max(fade_ratios))
log.info(" → VERDICT: RECOMMENDED ✓")
log.info("\nCandidate C: voltage_fade_ratio (alternative)")
log.info(" Pros: Simpler computation, physics-inspired")
log.info(" Cons: Voltage varies with load, less stable than capacity")
log.info(" → VERDICT: Secondary option ~")
# === Coulombic Efficiency Bug ===
log.info("\n=== Coulombic Efficiency Bug Analysis ===")
ce = df["coulombic_efficiency"].dropna()
log.info(" Current non-NaN: %d values", len(ce))
log.info(" Range: min=%.2f, max=%.2f", ce.min(), ce.max())
log.info(" Outliers (>1000): %d values (division by ~0 suspected)", (ce > 1000).sum())
log.info(" FIX: Add threshold Ah_discharge > 0.1 Ah before division")
log.info(" EXPECTED: Reduce NaN from 14%% → 5%%")
# === Distribution Shift (Train vs Test) ===
log.info("\n=== Distribution Shift: Train vs Test Devices ===")
log.info("\nTrain devices (Phase 1): k-led1, k-led2")
log.info("Test device (Phase 1): tender")
train_devices = ["k-led1", "k-led2"]
test_device = "tender"
for col in ["V_mean", "I_mean", "ah_cons"]:
train_vals = df[df["device"].isin(train_devices)][col].dropna()
test_vals = df[df["device"] == test_device][col].dropna()
if len(train_vals) > 0 and len(test_vals) > 0:
ks_stat, pval = stats.ks_2samp(train_vals, test_vals)
log.info("\n %s:", col)
log.info(" Train (k-led): mean=%.2f, std=%.2f", train_vals.mean(), train_vals.std())
log.info(" Test (tender): mean=%.2f, std=%.2f", test_vals.mean(), test_vals.std())
log.info(" KS test: stat=%.4f, p-value=%.4f %s",
ks_stat, pval, "*** SIGNIFICANT SHIFT ***" if pval < 0.05 else "✓ similar")
# === Phase 2 Recommendations ===
log.info("\n" + "="*70)
log.info("PHASE 2 RECOMMENDATIONS")
log.info("="*70)
log.info("\n1. SOH PROXY: Replace rest_voltage with capacity_fade_ratio")
log.info(" → Fixes device-dependent normalization issue")
log.info(" → Expected MAPE improvement: ~50-70pp (97%% → 27-47%%)")
log.info("\n2. FEATURE CLEANUP:")
log.info(" → Drop rows with >50%% NaN across features (adapt_features.py)")
log.info(" → Fix coulombic_efficiency division by zero (extract_features.py)")
log.info(" → Remove rest_voltage from FEATURE_COLS (train_fpnn.py)")
log.info("\n3. RETRAIN FPNN:")
log.info(" → Expected Phase 2.2 result: test MAPE < 15%% (target < 5%%)")
log.info(" → If still > 20%%: investigate missing features or device drift")
log.info("\n4. SAMBAMIXER:")
log.info(" → Train on clean features, synthetic RUL targets")
log.info(" → Expected: test MAPE < 15%% on synthetic RUL proxy")
log.info("\n" + "="*70)
log.info("DECISION CHECKPOINT: capacity_fade_ratio is GO for Phase 2.2")
log.info("="*70 + "\n")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Analyze features for Phase 2 training")
parser.add_argument("--input", default="models/features_adapted.parquet",
help="Path to features.parquet")
args = parser.parse_args()
if not Path(args.input).exists():
print(f"Error: Input file not found: {args.input}")
sys.exit(1)
analyze_features(args.input)