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

183 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Finalize Phase 2 metrics into a machine-readable JSON artifact.
This script aggregates:
- FPNN float metrics (computed from checkpoint + features)
- FPNN quantized metrics (if ONNX quantized model exists)
- Model sizes and threshold gates
- Optional training-log values (if phase2_fpnn_train.log is present)
- SambaMixer artifact metadata
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import torch
def _kb(path: Path) -> float:
return round(path.stat().st_size / 1024.0, 2)
def _parse_train_log(log_path: Path) -> dict:
if not log_path.exists():
return {}
text = log_path.read_text(encoding="utf-8", errors="ignore")
out: dict[str, float | int] = {}
m = re.search(r"Test metrics\s*-\s*MAPE:\s*([0-9.]+)%\s*RMSE:\s*([0-9.]+)\s*R2:\s*([\-0-9.]+)", text)
if m:
out["test_mape_from_log"] = float(m.group(1))
out["test_rmse_from_log"] = float(m.group(2))
out["test_r2_from_log"] = float(m.group(3))
m = re.search(r"Best epoch:\s*([0-9]+)\s*best val_loss:\s*([0-9.]+)", text)
if m:
out["best_epoch"] = int(m.group(1))
out["best_val_loss"] = float(m.group(2))
return out
def main() -> None:
parser = argparse.ArgumentParser(description="Aggregate final Phase 2 ML metrics")
parser.add_argument("--model", default="models/fpnn_soh.pt", help="Path to FPNN checkpoint")
parser.add_argument("--features", default="models/features_adapted.parquet", help="Path to features parquet")
parser.add_argument("--quantized", default="models/fpnn_soh_v2_quantized.onnx", help="Path to quantized ONNX model")
parser.add_argument("--rul-model", default="models/rul_sambamixer.pt", help="Path to SambaMixer checkpoint")
parser.add_argument("--train-log", default="phase2_fpnn_train.log", help="Optional training log path")
parser.add_argument("--output", default="models/phase2_metrics.json", help="Output JSON path")
parser.add_argument("--gate-fpnn-mape-max", type=float, default=15.0, help="Maximum allowed float32 FPNN MAPE")
parser.add_argument("--gate-quantized-mape-max", type=float, default=15.0, help="Maximum allowed quantized MAPE")
parser.add_argument("--gate-quantized-size-max-kb", type=float, default=50.0, help="Maximum allowed quantized model size in KB")
parser.add_argument("--gate-quantized-mape-degradation-max-pp", type=float, default=5.0, help="Maximum allowed quantized MAPE degradation in percentage points")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(repo_root / "scripts" / "ml"))
import quantize_tflite as q # local script import
model_path = (repo_root / args.model).resolve() if not Path(args.model).is_absolute() else Path(args.model)
features_path = (repo_root / args.features).resolve() if not Path(args.features).is_absolute() else Path(args.features)
quantized_path = (repo_root / args.quantized).resolve() if not Path(args.quantized).is_absolute() else Path(args.quantized)
rul_model_path = (repo_root / args.rul_model).resolve() if not Path(args.rul_model).is_absolute() else Path(args.rul_model)
train_log_path = (repo_root / args.train_log).resolve() if not Path(args.train_log).is_absolute() else Path(args.train_log)
output_path = (repo_root / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output)
if not model_path.exists():
raise FileNotFoundError(f"FPNN checkpoint not found: {model_path}")
if not features_path.exists():
raise FileNotFoundError(f"Features parquet not found: {features_path}")
checkpoint = torch.load(model_path, map_location="cpu", weights_only=False)
n_features = checkpoint["n_features"]
hidden = checkpoint["hidden"]
degree = checkpoint["degree"]
dropout = checkpoint.get("dropout", 0.1)
model = q.FPNN(n_features, hidden=hidden, degree=degree, dropout=dropout)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()
X_all, y_all = q.load_data(str(features_path), checkpoint)
n_test = max(1, int(0.2 * len(X_all)))
X_test = X_all[-n_test:]
y_test = y_all[-n_test:]
pt_metrics = q.validate_pytorch(model, X_test, y_test)
quant_metrics = None
if quantized_path.exists() and quantized_path.suffix == ".onnx":
quant_metrics = q.validate_onnxrt(quantized_path, X_test, y_test)
log_metrics = _parse_train_log(train_log_path)
pt_size_kb = _kb(model_path)
quant_size_kb = _kb(quantized_path) if quantized_path.exists() else None
rul_size_kb = _kb(rul_model_path) if rul_model_path.exists() else None
mape_degradation_pp = None
if quant_metrics is not None:
mape_degradation_pp = round(quant_metrics["MAPE"] - pt_metrics["MAPE"], 4)
gates = {
"fpnn_mape_le_threshold": bool(pt_metrics["MAPE"] <= args.gate_fpnn_mape_max),
"quantized_mape_le_threshold": bool(
quant_metrics is not None and quant_metrics["MAPE"] <= args.gate_quantized_mape_max
),
"quantized_size_le_threshold_kb": bool(
quant_size_kb is not None and quant_size_kb <= args.gate_quantized_size_max_kb
),
"quantized_mape_degradation_le_threshold_pp": bool(
mape_degradation_pp is not None and abs(mape_degradation_pp) <= args.gate_quantized_mape_degradation_max_pp
),
}
payload = {
"phase": "3A Phase 2",
"status": "completed" if all(gates.values()) else "blocked",
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"inputs": {
"model": str(model_path),
"features": str(features_path),
"quantized": str(quantized_path),
"rul_model": str(rul_model_path),
"train_log": str(train_log_path),
},
"fpnn": {
"soh_mode": checkpoint.get("soh_mode", "unknown"),
"degree": degree,
"hidden": hidden,
"n_features": n_features,
"params": int(sum(p.numel() for p in model.parameters())),
"size_kb": pt_size_kb,
"metrics_test_split_20pct": {
"mape": round(float(pt_metrics["MAPE"]), 4),
"rmse": round(float(pt_metrics["RMSE"]), 6),
"r2": round(float(pt_metrics["R2"]), 6),
"n_samples": int(len(X_test)),
},
},
"fpnn_quantized": {
"exists": bool(quantized_path.exists()),
"path": str(quantized_path),
"size_kb": quant_size_kb,
"metrics_test_split_20pct": {
"mape": round(float(quant_metrics["MAPE"]), 4) if quant_metrics else None,
"rmse": round(float(quant_metrics["RMSE"]), 6) if quant_metrics else None,
"r2": round(float(quant_metrics["R2"]), 6) if quant_metrics else None,
},
"mape_degradation_pp": mape_degradation_pp,
},
"sambamixer": {
"exists": bool(rul_model_path.exists()),
"path": str(rul_model_path),
"size_kb": rul_size_kb,
},
"log_extract": log_metrics,
"thresholds": {
"fpnn_mape_max": args.gate_fpnn_mape_max,
"quantized_mape_max": args.gate_quantized_mape_max,
"quantized_size_max_kb": args.gate_quantized_size_max_kb,
"quantized_mape_degradation_max_pp": args.gate_quantized_mape_degradation_max_pp,
},
"gates": gates,
"overall_gate_pass": bool(all(gates.values())),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"Wrote metrics: {output_path}")
print(f"Overall gate pass: {payload['overall_gate_pass']}")
if __name__ == "__main__":
main()