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

250 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
diagnostic_api.py — FastAPI diagnostic service for battery health narratives.
Endpoints:
GET /api/diagnostic/{battery_id} — cached daily diagnostic
POST /api/diagnostic/{battery_id} — generate fresh diagnostic (on-demand)
GET /api/diagnostic/fleet — fleet-level summary
GET /health — health check
Scheduling:
Daily digest at 06:00 — generates diagnostics for all batteries.
"""
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
import httpx
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from config import config
from inference_server import generate_diagnostic, load_model
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("api")
# ---------------------------------------------------------------------------
# Cache: battery_id -> {diagnostic, severity, generated_at}
# ---------------------------------------------------------------------------
_cache: dict[int, dict[str, Any]] = {}
_fleet_cache: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# SOH API client (Phase 2)
# ---------------------------------------------------------------------------
async def fetch_battery_scores(battery_id: int) -> dict[str, Any]:
"""Fetch ML scores from Phase 2 SOH API."""
url = f"http://localhost:8400/api/soh/battery/{battery_id}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"SOH API returned {resp.status_code}")
return resp.json()
async def fetch_fleet_scores() -> dict[str, Any]:
"""Fetch fleet scores from Phase 2 SOH API."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get("http://localhost:8400/api/soh/fleet")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"SOH API returned {resp.status_code}")
return resp.json()
async def fetch_all_battery_scores() -> list[dict[str, Any]]:
"""Fetch scores for all batteries from Phase 2 SOH API."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get("http://localhost:8400/api/soh/batteries")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"SOH API returned {resp.status_code}")
return resp.json()
# ---------------------------------------------------------------------------
# Daily digest job
# ---------------------------------------------------------------------------
async def daily_digest() -> None:
"""Generate diagnostics for all batteries (cron job)."""
global _fleet_cache
log.info("Starting daily digest...")
try:
batteries = await fetch_all_battery_scores()
for scores in batteries:
bid = scores.get("battery", -1)
try:
result = generate_diagnostic(scores)
_cache[bid] = {
**result,
"generated_at": int(time.time()),
}
except Exception as e:
log.error("Digest failed for battery %d: %s", bid, e)
# Fleet summary
fleet = await fetch_fleet_scores()
fleet_diag = generate_diagnostic({
"battery": -1,
"fleet_size": len(batteries),
"soh_score": fleet.get("fleet_health", 0) * 100,
"rul_days": min((b.get("rul_days", 999) for b in batteries), default=0),
"anomaly_score": fleet.get("outlier_score", 0),
"r_ohmic_mohm": "N/A",
"r_total_mohm": "N/A",
"r_int_trend_mohm_per_day": "N/A",
"v_avg_mv": "N/A",
"i_avg_a": "N/A",
"cycle_count": "N/A",
"fleet_health_pct": fleet.get("fleet_health", 0) * 100,
})
_fleet_cache = {
**fleet_diag,
"generated_at": int(time.time()),
"num_batteries": len(batteries),
}
log.info("Daily digest complete: %d batteries", len(batteries))
except Exception as e:
log.error("Daily digest failed: %s", e)
# ---------------------------------------------------------------------------
# App lifecycle
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load model on startup, schedule daily digest."""
load_model()
scheduler = AsyncIOScheduler()
scheduler.add_job(
daily_digest,
"cron",
hour=config.daily_digest_hour,
minute=0,
id="daily_digest",
)
scheduler.start()
log.info("Scheduled daily digest at %02d:00", config.daily_digest_hour)
yield
scheduler.shutdown()
app = FastAPI(
title="BMU LLM Diagnostic API",
version="1.0.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class DiagnosticResponse(BaseModel):
battery: int
diagnostic: str
severity: str
generated_at: int
class FleetDiagnosticResponse(BaseModel):
diagnostic: str
severity: str
generated_at: int
num_batteries: int
class HealthResponse(BaseModel):
status: str
model_loaded: bool
cache_size: int
uptime_s: float
_start_time = time.monotonic()
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/health", response_model=HealthResponse)
async def health_check():
from inference_server import _model
return HealthResponse(
status="ok",
model_loaded=_model is not None,
cache_size=len(_cache),
uptime_s=round(time.monotonic() - _start_time, 1),
)
@app.get("/api/diagnostic/fleet", response_model=FleetDiagnosticResponse)
async def get_fleet_diagnostic():
"""Return cached fleet diagnostic (from daily digest)."""
if _fleet_cache is None:
raise HTTPException(status_code=404, detail="No fleet diagnostic available. Wait for daily digest or POST to generate.")
return FleetDiagnosticResponse(**_fleet_cache)
@app.get("/api/diagnostic/{battery_id}", response_model=DiagnosticResponse)
async def get_battery_diagnostic(battery_id: int):
"""Return cached daily diagnostic for a battery."""
if battery_id not in _cache:
raise HTTPException(status_code=404, detail=f"No cached diagnostic for battery {battery_id}. POST to generate on-demand.")
entry = _cache[battery_id]
# Check staleness
age_h = (time.time() - entry["generated_at"]) / 3600
if age_h > config.cache_ttl_hours:
raise HTTPException(status_code=404, detail=f"Cached diagnostic expired ({age_h:.1f}h old). POST to refresh.")
return DiagnosticResponse(**entry)
@app.post("/api/diagnostic/{battery_id}", response_model=DiagnosticResponse)
async def generate_battery_diagnostic(battery_id: int):
"""Generate a fresh diagnostic on-demand for a battery."""
scores = await fetch_battery_scores(battery_id)
result = generate_diagnostic(scores)
entry = {
**result,
"generated_at": int(time.time()),
}
_cache[battery_id] = entry
return DiagnosticResponse(**entry)
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
import uvicorn
uvicorn.run(
"diagnostic_api:app",
host=config.api_host,
port=config.api_port,
workers=1, # Single worker — model in GPU memory
log_level="info",
)
if __name__ == "__main__":
main()