/** * SimulationPanel — Advanced Battery Lifecycle Simulation * * Workflow: * 1. User configures batteries & parameters * 2. Click "Run Simulation" → POST /api/v2/simulate (or local physics fallback) * 3. Full trajectories returned for ALL batteries at once * 4. Timer ticks advance playIndex through pre-computed data * 5. All charts + 3D view re-render from pre-computed histories[playIndex] */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Canvas, useFrame } from "@react-three/fiber"; import { OrbitControls, Text } from "@react-three/drei"; import * as THREE from "three"; import { AreaChart, Area, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, ReferenceLine, } from "recharts"; import { Play, Pause, SkipBack, SkipForward, RotateCcw, FastForward, Plus, Trash2, Settings2, Pencil, ChevronRight, BatteryFull, BatteryLow, Activity, Zap, Thermometer, TrendingDown, AlertOctagon, Clock, BarChart3, GitBranch, Layers, Gauge, Cpu, TableProperties, ScrollText, CheckCircle2, WifiOff, Server, FlaskConical, Copy, Download, } from "lucide-react"; import { simulateBatteries, BatterySimConfig, BatterySimResult } from "../api"; import { useToast } from "./Toast"; // ── Constants ───────────────────────────────────────────────────────────── const CHART_COLORS = [ "#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16", "#f97316", "#a78bfa", ]; const TIME_UNITS = [ { key: "cycle", label: "Cycles" }, { key: "hour", label: "Hours" }, { key: "day", label: "Days" }, { key: "week", label: "Weeks" }, { key: "month", label: "Months" }, { key: "year", label: "Years" }, ]; // 7 default batteries with varied real-world profiles const DEFAULT_BATTERIES: BatterySimConfig[] = [ { battery_id: "BAT-001", label: "Normal Lab", initial_soh: 100, start_cycle: 1, ambient_temperature: 24, avg_current: 1.82, peak_voltage: 4.19, min_voltage: 2.61, avg_temp: 32, temp_rise: 14, cycle_duration: 3690, Re: 0.045, Rct: 0.069, delta_capacity: -0.004, }, { battery_id: "BAT-002", label: "Hot Climate", initial_soh: 98, start_cycle: 1, ambient_temperature: 38, avg_current: 1.82, peak_voltage: 4.19, min_voltage: 2.61, avg_temp: 42, temp_rise: 16, cycle_duration: 3690, Re: 0.048, Rct: 0.072, delta_capacity: -0.005, }, { battery_id: "BAT-003", label: "High Current", initial_soh: 95, start_cycle: 1, ambient_temperature: 24, avg_current: 2.8, peak_voltage: 4.19, min_voltage: 2.61, avg_temp: 36, temp_rise: 20, cycle_duration: 3200, Re: 0.046, Rct: 0.070, delta_capacity: -0.006, }, { battery_id: "BAT-004", label: "Aged Cell", initial_soh: 82, start_cycle: 1, ambient_temperature: 24, avg_current: 1.82, peak_voltage: 4.10, min_voltage: 2.70, avg_temp: 31, temp_rise: 12, cycle_duration: 3690, Re: 0.068, Rct: 0.105, delta_capacity: -0.005, }, { battery_id: "BAT-005", label: "Cold Climate", initial_soh: 97, start_cycle: 1, ambient_temperature: 8, avg_current: 1.50, peak_voltage: 4.15, min_voltage: 2.61, avg_temp: 20, temp_rise: 10, cycle_duration: 4200, Re: 0.052, Rct: 0.085, delta_capacity: -0.003, }, { battery_id: "BAT-006", label: "Overcharged", initial_soh: 90, start_cycle: 1, ambient_temperature: 28, avg_current: 1.82, peak_voltage: 4.32, min_voltage: 2.61, avg_temp: 34, temp_rise: 18, cycle_duration: 3800, Re: 0.050, Rct: 0.078, delta_capacity: -0.006, }, { battery_id: "BAT-007", label: "Near EOL", initial_soh: 74, start_cycle: 1, ambient_temperature: 30, avg_current: 2.20, peak_voltage: 4.20, min_voltage: 2.71, avg_temp: 38, temp_rise: 22, cycle_duration: 3690, Re: 0.085, Rct: 0.130, delta_capacity: -0.008, }, ]; // ── Local physics fallback (mirrors backend Arrhenius model exactly) ────── const EA_OVER_R = 6200; const Q_NOM = 2.0; const T_REF_C = 24; const I_REF = 1.82; const V_REF = 4.19; export function sohColor(soh: number): string { if (soh >= 90) return "#22c55e"; if (soh >= 80) return "#eab308"; if (soh >= 70) return "#f97316"; return "#ef4444"; } function degradeState(soh: number): string { if (soh >= 90) return "Healthy"; if (soh >= 80) return "Moderate"; if (soh >= 70) return "Degraded"; return "End-of-Life"; } function computeStressFactors(b: BatterySimConfig) { const Tk = 273.15 + (b.ambient_temperature ?? T_REF_C); const TrK = 273.15 + T_REF_C; const tempF = Math.max(0.15, Math.min(Math.exp(EA_OVER_R * (1 / TrK - 1 / Tk)), 25)); const currF = 1 + Math.max(0, ((b.avg_current ?? I_REF) - I_REF) * 0.18); const voltF = 1 + Math.max(0, ((b.peak_voltage ?? V_REF) - V_REF) * 0.55); return { tempF: +tempF.toFixed(3), currF: +currF.toFixed(3), voltF: +voltF.toFixed(3), total: +(tempF * currF * voltF).toFixed(3) }; } function runLocalSimulation( batteries: BatterySimConfig[], steps: number, timeUnit: string, eolThr = 70, ): BatterySimResult[] { const TU_SEC: Record = { cycle: null, second: 1, minute: 60, hour: 3600, day: 86400, week: 604800, month: 2592000, year: 31536000, }; const tuSec = TU_SEC[timeUnit] ?? 86400; return batteries.map((b) => { let soh = b.initial_soh ?? 100; let re = b.Re ?? 0.045; let rct = b.Rct ?? 0.069; const soh_h: number[] = [], rul_h: number[] = [], rul_t_h: number[] = []; const re_h: number[] = [], rct_h: number[] = []; const cyc_h: number[] = [], time_h: number[] = []; const deg_h: string[] = [], color_h: string[] = []; let eol_cycle: number | null = null; let eol_time: number | null = null; let totalDeg = 0; for (let step = 0; step < steps; step++) { const cycle = (b.start_cycle ?? 1) + step; const rateBase = Math.max( 0.005, Math.min(Math.abs(b.delta_capacity ?? -0.005) / Q_NOM * 100, 1.5), ); const Tk = 273.15 + (b.ambient_temperature ?? T_REF_C); const TrK = 273.15 + T_REF_C; const tempF = Math.max(0.15, Math.min(Math.exp(EA_OVER_R * (1 / TrK - 1 / Tk)), 25)); const currF = 1 + Math.max(0, ((b.avg_current ?? I_REF) - I_REF) * 0.18); const voltF = 1 + Math.max(0, ((b.peak_voltage ?? V_REF) - V_REF) * 0.55); const ageF = 1 + (soh < 85 ? 0.08 : 0) + (soh < 75 ? 0.12 : 0); const degRate = Math.min(rateBase * tempF * currF * voltF * ageF, 2.0); soh = Math.max(0, soh - degRate); re = Math.min(re + 0.00012 * tempF * (1 + step * 5e-5), 2.0); rct = Math.min(rct + 0.00018 * tempF * (1 + step * 8e-5) * (soh < 80 ? 1.3 : 1), 3.0); totalDeg += degRate; const rulCycles = soh > eolThr && degRate > 0 ? (soh - eolThr) / degRate : 0; const cycleDur = b.cycle_duration ?? 3690; const elapsedS = cycle * cycleDur; const elapsedT = tuSec ? elapsedS / tuSec : cycle; const rulT = tuSec ? rulCycles * cycleDur / tuSec : rulCycles; if (soh <= eolThr && eol_cycle === null) { eol_cycle = cycle; eol_time = +elapsedT.toFixed(3); } soh_h.push(+soh.toFixed(3)); rul_h.push(+rulCycles.toFixed(1)); rul_t_h.push(+rulT.toFixed(2)); re_h.push(+re.toFixed(6)); rct_h.push(+rct.toFixed(6)); cyc_h.push(cycle); time_h.push(+elapsedT.toFixed(3)); deg_h.push(degradeState(soh)); color_h.push(sohColor(soh)); } return { battery_id: b.battery_id, label: b.label ?? b.battery_id, soh_history: soh_h, rul_history: rul_h, rul_time_history: rul_t_h, re_history: re_h, rct_history: rct_h, cycle_history: cyc_h, time_history: time_h, degradation_history: deg_h, color_history: color_h, eol_cycle, eol_time, final_soh: soh_h.length ? soh_h[soh_h.length - 1] : soh, final_rul: rul_h.length ? rul_h[rul_h.length - 1] : 0, deg_rate_avg: +(totalDeg / steps).toFixed(6), }; }); } // ── 3D Battery Cell ──────────────────────────────────────────────────────── function BatteryCell({ position, batteryId, label, soh, color, selected, isRunning, onClick, onDblClick, }: { position: [number, number, number]; batteryId: string; label: string; soh: number; color: string; selected: boolean; isRunning: boolean; onClick: () => void; onDblClick: () => void; }) { const bodyRef = useRef(null); const fillRef = useRef(null); const ringRef = useRef(null); const [hovered, setHovered] = useState(false); const fillColor = useMemo(() => new THREE.Color(color), [color]); const fillH = Math.max(0.06, (Math.min(100, Math.max(0, soh)) / 100) * 1.82); useFrame((state, dt) => { if (!bodyRef.current) return; const target = selected ? 1.14 : hovered ? 1.06 : 1.0; bodyRef.current.scale.lerp(new THREE.Vector3(target, target, target), dt * 9); if (fillRef.current) { const mat = fillRef.current.material as THREE.MeshStandardMaterial; const t = state.clock.elapsedTime; const pulse = isRunning ? 0.3 + Math.sin(t * 3.5) * 0.18 : 0.2 + Math.sin(t * 0.8) * 0.08; mat.emissiveIntensity = selected ? 0.9 : hovered ? 0.7 : pulse; } if (ringRef.current && selected) { ringRef.current.rotation.y += dt * 2; } }); return ( { e.stopPropagation(); onClick(); }} onDoubleClick={(e) => { e.stopPropagation(); onDblClick(); }} onPointerOver={() => { setHovered(true); document.body.style.cursor = "pointer"; }} onPointerOut={() => { setHovered(false); document.body.style.cursor = "auto"; }} > {/* Outer glass shell */} {/* Metal band bottom */} {/* Metal band top */} {/* SOH fill */} {/* Positive terminal */} {/* Negative plate */} {/* Wrap stripe */} {/* Selection orbit ring */} {selected && ( )} {/* Labels */} {batteryId} {soh.toFixed(1)}% {label !== batteryId && ( {label} )} {soh < 70 && ( )} ); } // ── 3D Battery Pack ──────────────────────────────────────────────────────── function BatteryPack({ batteries, selected, onSelect, onOpenConfig, isRunning, }: { batteries: { id: string; label: string; soh: number; color: string }[]; selected: string | null; onSelect: (id: string) => void; onOpenConfig: (id: string) => void; isRunning: boolean; }) { const groupRef = useRef(null); useFrame((_, dt) => { if (groupRef.current && !isRunning) { groupRef.current.rotation.y += dt * 0.035; } }); const cols = Math.min(4, Math.ceil(Math.sqrt(batteries.length))); const rows = Math.ceil(batteries.length / cols); const gap = 1.32; return ( {/* Base plate */} {/* Bus bars */} {Array.from({ length: rows }, (_, r) => ( ))} {batteries.map((b, i) => { const col = i % cols; const row = Math.floor(i / cols); const x = (col - (cols - 1) / 2) * gap; const z = (row - (rows - 1) / 2) * gap; return ( onSelect(b.id)} onDblClick={() => onOpenConfig(b.id)} /> ); })} ); } // ── Config Modal ─────────────────────────────────────────────────────────── const PARAM_FIELDS: { key: keyof BatterySimConfig; label: string; step: string; unit: string }[] = [ { key: "initial_soh", label: "Initial SOH", step: "0.1", unit: "%" }, { key: "ambient_temperature", label: "Ambient Temp", step: "0.5", unit: "°C" }, { key: "peak_voltage", label: "Peak Voltage", step: "0.01", unit: "V" }, { key: "min_voltage", label: "Min Voltage", step: "0.01", unit: "V" }, { key: "avg_current", label: "Avg Current", step: "0.1", unit: "A" }, { key: "avg_temp", label: "Cell Temp", step: "0.5", unit: "°C" }, { key: "temp_rise", label: "Temp Rise/cycle", step: "0.5", unit: "°C" }, { key: "cycle_duration", label: "Cycle Duration", step: "60", unit: "s" }, { key: "Re", label: "Re (Electrolyte Ω)", step: "0.001", unit: "Ω" }, { key: "Rct", label: "Rct (Charge-Xfer Ω)", step: "0.001", unit: "Ω" }, { key: "delta_capacity", label: "ΔCapacity/cycle", step: "0.001", unit: "Ah" }, ]; function ConfigModal({ battery, color, onSave, onClose, }: { battery: BatterySimConfig; color: string; onSave: (b: BatterySimConfig) => void; onClose: () => void; }) { const [form, setForm] = useState({ ...battery }); const getNum = (k: keyof BatterySimConfig) => (form[k] as number) ?? 0; const setNum = (k: keyof BatterySimConfig, v: string) => setForm(p => ({ ...p, [k]: parseFloat(v) || 0 })); const stress = computeStressFactors(form); return (
e.stopPropagation()} > {/* Header */}
{form.battery_id}
Edit battery configuration
{/* Live stress preview */}
{[ { label: "Temp Stress", value: stress.tempF, color: "#f59e0b" }, { label: "Current Stress", value: stress.currF, color: "#ef4444" }, { label: "Voltage Stress", value: stress.voltF, color: "#8b5cf6" }, { label: "Total Stress", value: stress.total, color: "#ec4899" }, ].map((s) => (
{s.label}
{s.value}×
))}
{/* Identity fields */}
setForm(p => ({ ...p, battery_id: e.target.value }))} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500" />
setForm(p => ({ ...p, label: e.target.value }))} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500" />
{/* Parameter grid */}
{PARAM_FIELDS.map((f) => (
setNum(f.key, e.target.value)} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500" />
))}
{/* Action buttons */}
); } // ── Stat Card ───────────────────────────────────────────────────────────── function StatCard({ label, value, color = "text-green-400", sub, icon: Icon, }: { label: string; value: string | number; color?: string; sub?: string; icon?: React.ElementType; }) { const IconEl = Icon as React.FC<{ className?: string }>; return (
{Icon && } {label}
{value}
{sub &&
{sub}
}
); } // ── Custom recharts tooltip ──────────────────────────────────────────────── const DarkTooltip = ({ active, payload, label, unit = "" }: any) => { if (!active || !payload?.length) return null; return (

{label}{unit ? ` ${unit}` : ""}

{payload.map((p: any) => (
{p.name} {typeof p.value === "number" ? p.value.toFixed(3) : p.value}
))}
); }; // ── Chart metric config ──────────────────────────────────────────────────── const CHART_METRICS = [ { key: "soh", label: "SOH (%)", color: "#22c55e" }, { key: "rul", label: "RUL (cycles)", color: "#3b82f6" }, { key: "rul_t", label: "RUL (time)", color: "#06b6d4" }, { key: "re", label: "Re (Ω)", color: "#f59e0b" }, { key: "rct", label: "Rct (Ω)", color: "#8b5cf6" }, ]; const DEG_COLORS: Record = { Healthy: "#22c55e", Moderate: "#eab308", Degraded: "#f97316", "End-of-Life": "#ef4444", }; type ChartTab = "fleet" | "trajectories" | "stress" | "impedance" | "capacity" | "distribution" | "eol" | "log"; const CHART_TABS: { key: ChartTab; label: string; icon: React.ElementType }[] = [ { key: "fleet", label: "Fleet", icon: Layers }, { key: "trajectories", label: "Trajectories", icon: GitBranch }, { key: "stress", label: "Stress", icon: Thermometer }, { key: "impedance", label: "Impedance", icon: Activity }, { key: "capacity", label: "Capacity", icon: BatteryFull }, { key: "distribution", label: "Distribution", icon: BarChart3 }, { key: "eol", label: "EOL", icon: AlertOctagon }, { key: "log", label: "Log", icon: ScrollText }, ]; // ── Main component ───────────────────────────────────────────────────────── export default function SimulationPanel() { const { toast } = useToast(); // --- Config state --- const [batteryConfigs, setBatteryConfigs] = useState(DEFAULT_BATTERIES); const [steps, setSteps] = useState(300); const [timeUnit, setTimeUnit] = useState("day"); const [eolThreshold, setEolThreshold] = useState(70); // --- Simulation results --- const [results, setResults] = useState([]); const [timeUnitLabel, setTimeUnitLabel] = useState("Days"); const [isSimulating, setIsSimulating] = useState(false); const [modelUsed, setModelUsed] = useState(null); const [selectedModel, setSelectedModel] = useState("best_ensemble"); // --- Playback --- const [playIndex, setPlayIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [playSpeed, setPlaySpeed] = useState(10); const playRef = useRef | null>(null); const playIdxRef = useRef(0); // --- UI --- const [selected3D, setSelected3D] = useState(null); const [configTarget, setConfigTarget] = useState(null); const [activeChart, setActiveChart] = useState("fleet"); const [chartMetric, setChartMetric] = useState("soh"); const [simLog, setSimLog] = useState<{ t: string; msg: string; type: "info" | "warn" | "ok" | "err" }[]>([]); const totalSteps = results.length > 0 ? results[0].soh_history.length : 0; // current per-battery snapshot at playIndex const currentSohs = useMemo(() => results.map((r) => ({ id: r.battery_id, label: r.label ?? r.battery_id, soh: r.soh_history[playIndex] ?? 100, rul: r.rul_history[playIndex] ?? 0, re: r.re_history[playIndex] ?? 0, rct: r.rct_history[playIndex] ?? 0, color: r.color_history[playIndex] ?? sohColor(r.soh_history[playIndex] ?? 100), deg: r.degradation_history[playIndex] ?? "Healthy", })), [results, playIndex], ); const avgSoh = currentSohs.length ? currentSohs.reduce((s, b) => s + b.soh, 0) / currentSohs.length : 0; const bestBat = currentSohs.reduce((a, b) => (!a || b.soh > a.soh) ? b : a, null); const worstBat = currentSohs.reduce((a, b) => (!a || b.soh < a.soh) ? b : a, null); const eolCount = results.filter((r) => r.eol_cycle !== null).length; const elapsedTime = useMemo(() => { if (!results.length) return "—"; const t = results[0]?.time_history?.[playIndex] ?? 0; return `${t.toFixed(1)} ${timeUnitLabel}`; }, [results, playIndex, timeUnitLabel]); // --- Playback loop --- useEffect(() => { if (isPlaying && totalSteps > 0) { const iv = Math.max(16, Math.round(1000 / playSpeed)); playRef.current = setInterval(() => { playIdxRef.current += 1; if (playIdxRef.current >= totalSteps) { playIdxRef.current = totalSteps - 1; setIsPlaying(false); } setPlayIndex(playIdxRef.current); }, iv); return () => { if (playRef.current) clearInterval(playRef.current); }; } }, [isPlaying, playSpeed, totalSteps]); // --- Log helpers --- const addLog = useCallback( (msg: string, type: "info" | "warn" | "ok" | "err" = "info") => { const t = new Date().toLocaleTimeString(); setSimLog((prev) => [{ t, msg, type }, ...prev.slice(0, 199)]); }, [], ); // --- Run simulation --- const runSimulation = useCallback(async () => { if (!batteryConfigs.length) return; setIsSimulating(true); setIsPlaying(false); if (playRef.current) clearInterval(playRef.current); setPlayIndex(0); playIdxRef.current = 0; addLog(`Starting: ${batteryConfigs.length} batteries × ${steps} steps (${timeUnit})`, "info"); try { const resp = await simulateBatteries({ batteries: batteryConfigs, steps, time_unit: timeUnit, eol_threshold: eolThreshold, model_name: selectedModel, use_ml: true, }); setResults(resp.results); setTimeUnitLabel(resp.time_unit_label); const usedModel = resp.model_used ?? selectedModel; setModelUsed(usedModel); addLog(`Backend OK — ${resp.results.length} trajectories | model: ${usedModel}`, "ok"); toast({ type: "success", title: "Simulation complete", message: `${resp.results.length} batteries × ${steps} steps | ${usedModel}` }); resp.results.forEach((r) => { if (r.eol_cycle !== null) { addLog(`${r.battery_id} EOL @ cycle ${r.eol_cycle} (${r.eol_time?.toFixed(1)} ${resp.time_unit_label})`, "warn"); } }); } catch (err) { const msg = err instanceof Error ? err.message : "Connection refused"; addLog(`Backend unavailable (${msg}) — running local Arrhenius physics`, "warn"); toast({ type: "warning", title: "Backend unavailable", message: "Simulation is running entirely on local Arrhenius physics — API is offline or unreachable.", duration: 6000, }); const local = runLocalSimulation(batteryConfigs, steps, timeUnit, eolThreshold); setResults(local); setTimeUnitLabel(TIME_UNITS.find((u) => u.key === timeUnit)?.label ?? "Days"); setModelUsed(null); addLog(`Local physics OK — ${local.length} trajectories`, "ok"); local.forEach((r) => { if (r.eol_cycle !== null) { addLog(`${r.battery_id} EOL @ cycle ${r.eol_cycle} (${r.eol_time?.toFixed(1)} ${timeUnit})`, "warn"); } }); } finally { setIsSimulating(false); } }, [batteryConfigs, steps, timeUnit, eolThreshold, selectedModel, addLog, toast]); // --- Playback controls --- const togglePlay = useCallback(() => { if (!results.length) return; if (playIdxRef.current >= totalSteps - 1) { setPlayIndex(0); playIdxRef.current = 0; } setIsPlaying((p) => !p); }, [results.length, totalSteps]); const stepFwd = useCallback(() => { const n = Math.min(playIndex + 1, totalSteps - 1); setPlayIndex(n); playIdxRef.current = n; }, [playIndex, totalSteps]); const stepBwd = useCallback(() => { const n = Math.max(playIndex - 1, 0); setPlayIndex(n); playIdxRef.current = n; }, [playIndex]); const resetPlay = useCallback(() => { setIsPlaying(false); if (playRef.current) clearInterval(playRef.current); setPlayIndex(0); playIdxRef.current = 0; }, []); // --- Battery management --- const addBattery = useCallback(() => { const id = `BAT-${String(batteryConfigs.length + 1).padStart(3, "0")}`; const newBat: BatterySimConfig = { ...DEFAULT_BATTERIES[0], battery_id: id, label: "New Battery", initial_soh: 100 }; setBatteryConfigs((p) => [...p, newBat]); setConfigTarget(id); }, [batteryConfigs.length]); const duplicateBattery = useCallback((id: string) => { const src = batteryConfigs.find((b) => b.battery_id === id); if (!src) return; const newId = `BAT-${String(batteryConfigs.length + 1).padStart(3, "0")}`; setBatteryConfigs((p) => [...p, { ...src, battery_id: newId, label: `${src.label ?? id} (copy)` }]); toast({ type: "info", title: "Battery duplicated", message: `${src.label ?? id} → ${newId}` }); }, [batteryConfigs, toast]); const removeBattery = useCallback((id: string) => { setBatteryConfigs((p) => p.filter((b) => b.battery_id !== id)); if (selected3D === id) setSelected3D(null); }, [selected3D]); const saveConfig = useCallback((updated: BatterySimConfig) => { setBatteryConfigs((p) => p.map((b) => (b.battery_id === configTarget ? updated : b))); toast({ type: "success", title: "Configuration saved", message: `${updated.label ?? updated.battery_id} updated — re-run simulation to apply.` }); setConfigTarget(null); }, [configTarget, toast]); const configBattery = useMemo( () => (configTarget ? batteryConfigs.find((b) => b.battery_id === configTarget) : null), [configTarget, batteryConfigs], ); // --- 3D display data --- const display3D = useMemo( () => batteryConfigs.map((b) => { const cur = currentSohs.find((s) => s.id === b.battery_id); return { id: b.battery_id, label: b.label ?? b.battery_id, soh: cur?.soh ?? (b.initial_soh ?? 100), color: cur?.color ?? sohColor(b.initial_soh ?? 100), }; }), [batteryConfigs, currentSohs], ); // --- Chart data helpers --- const sample = (n: number) => Math.max(1, Math.floor(n / 200)); const fleetData = useMemo(() => { if (!results.length) return []; const r0 = results[0]; const s = sample(r0.soh_history.length); return r0.soh_history .map((_, i) => { if (i % s !== 0 && i !== r0.soh_history.length - 1) return null; const sohs = results.map((r) => r.soh_history[i] ?? 0); return { t: +r0.time_history[i].toFixed(2), avg: +(sohs.reduce((a, b) => a + b, 0) / sohs.length).toFixed(2), min: +Math.min(...sohs).toFixed(2), max: +Math.max(...sohs).toFixed(2), }; }) .filter(Boolean) as { t: number; avg: number; min: number; max: number }[]; }, [results]); const trajectoryData = useMemo(() => { if (!results.length) return { points: [] as any[], keys: [] as string[] }; const r0 = results[0]; const n = r0.soh_history.length; const s = Math.max(1, Math.floor(n / 150)); const getHist = (r: BatterySimResult): number[] => { if (chartMetric === "soh") return r.soh_history; if (chartMetric === "rul") return r.rul_history; if (chartMetric === "rul_t") return r.rul_time_history; if (chartMetric === "re") return r.re_history; return r.rct_history; }; const points = r0.time_history .map((t, i) => { if (i % s !== 0 && i !== n - 1) return null; const row: any = { t: +t.toFixed(2) }; results.forEach((r) => { row[r.battery_id] = +(getHist(r)[i] ?? 0).toFixed(4); }); return row; }) .filter(Boolean); return { points, keys: results.map((r) => r.battery_id) }; }, [results, chartMetric]); const comparisonData = useMemo( () => currentSohs.map((b) => ({ name: b.id.replace("BAT-", "B"), soh: +b.soh.toFixed(1), rul: +b.rul.toFixed(0), re: +b.re.toFixed(4), rct: +b.rct.toFixed(4), color: b.color, })), [currentSohs], ); const degDist = useMemo(() => { const counts: Record = { Healthy: 0, Moderate: 0, Degraded: 0, "End-of-Life": 0 }; currentSohs.forEach((b) => { counts[b.deg] = (counts[b.deg] ?? 0) + 1; }); return Object.entries(counts).filter(([, v]) => v > 0).map(([name, value]) => ({ name, value })); }, [currentSohs]); const selectedResult = useMemo( () => results.find((r) => r.battery_id === selected3D), [results, selected3D], ); const impedanceData = useMemo(() => { if (!selectedResult) return []; const n = selectedResult.re_history.length; const s = Math.max(1, Math.floor(n / 150)); return selectedResult.re_history .map((re, i) => { if (i % s !== 0 && i !== n - 1) return null; return { t: +(selectedResult.time_history[i] ?? i).toFixed(2), re: +re.toFixed(5), rct: +(selectedResult.rct_history[i] ?? 0).toFixed(5), }; }) .filter(Boolean); }, [selectedResult]); const eolScatter = useMemo( () => results.filter((r) => r.eol_time !== null).map((r) => ({ id: r.battery_id, deg_rate: +(r.deg_rate_avg * 100).toFixed(4), eol_time: +(r.eol_time ?? 0).toFixed(2), })), [results], ); // Stress analysis data — computed from config, not simulation results const stressData = useMemo( () => batteryConfigs.map((b, i) => { const sf = computeStressFactors(b); return { name: b.battery_id.replace("BAT-", "B"), label: b.label ?? b.battery_id, temp: sf.tempF, curr: sf.currF, volt: sf.voltF, total: sf.total, color: CHART_COLORS[i % CHART_COLORS.length], }; }), [batteryConfigs], ); // Stress radar (top-level factors across batteries) const stressRadar = useMemo( () => ["Temp", "Current", "Voltage", "Total"].map((factor) => { const row: any = { factor }; batteryConfigs.forEach((b, i) => { const sf = computeStressFactors(b); const key = b.label ?? b.battery_id; row[key] = factor === "Temp" ? sf.tempF : factor === "Current" ? sf.currF : factor === "Voltage" ? sf.voltF : sf.total; }); return row; }), [batteryConfigs], ); // Capacity fade data (Ah over time) const capacityData = useMemo(() => { if (!results.length) return { points: [] as any[], keys: [] as string[] }; const r0 = results[0]; const n = r0.soh_history.length; const s = Math.max(1, Math.floor(n / 150)); const points = r0.time_history .map((t, i) => { if (i % s !== 0 && i !== n - 1) return null; const row: any = { t: +t.toFixed(2) }; results.forEach((r) => { // Capacity in Ah = Q_NOM × soh/100 row[r.battery_id] = +(Q_NOM * (r.soh_history[i] ?? 0) / 100).toFixed(4); }); return row; }) .filter(Boolean); return { points, keys: results.map((r) => r.battery_id) }; }, [results]); // ── Render ───────────────────────────────────────────────────────────── return (
{/* Config Modal */} {configBattery && ( setConfigTarget(null)} /> )} {/* ── Setup bar ──────────────────────────────────────────────────── */}
Simulation Setup {modelUsed !== null && (
{modelUsed}
)} {modelUsed === null && results.length > 0 && (
Local Fallback
)}
{/* Steps */}
setSteps(Math.max(10, Math.min(5000, +e.target.value)))} className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500" />
{/* Time Unit */}
{/* EOL threshold */}
setEolThreshold(+e.target.value)} className="w-20 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500" /> %
{/* ML Model */}
{/* Action buttons */}
{/* ── Playback bar ───────────────────────────────────────────────── */} {results.length > 0 && (
{/* Transport buttons */}
{/* Speed */}
{[0.5, 1, 2, 5, 10, 20, 50].map((s) => ( ))}
{/* Scrubber */}
{ const v = +e.target.value; setPlayIndex(v); playIdxRef.current = v; }} className="w-full accent-green-500" />
{/* Time display */}
Elapsed
{elapsedTime}
Step
{playIndex + 1}/{totalSteps}
{/* Progress bar */}
0 ? ((playIndex + 1) / totalSteps) * 100 : 0}%` }} />
)} {/* ── Stats ──────────────────────────────────────────────────────── */}
= 90 ? "text-green-400" : avgSoh >= 80 ? "text-yellow-400" : avgSoh >= 70 ? "text-orange-400" : "text-red-400"} /> 0 ? "text-red-400" : "text-gray-500"} />
{/* ── 3D view + fleet sidebar ─────────────────────────────────────── */}
{/* 3D canvas */}
setSelected3D((prev) => (prev === id ? null : id))} onOpenConfig={(id) => setConfigTarget(id)} isRunning={isPlaying} /> {/* HUD */}
{isPlaying ? ( <> Animating ) : results.length > 0 ? ( <>Click cell to inspect · Double-click to configure ) : ( Configure batteries and click Run Simulation )}
{/* SOH legend */}
{([["≥ 90%", "#22c55e", "Healthy"], ["80–90%", "#eab308", "Moderate"], ["70–80%", "#f97316", "Degraded"], ["< 70%", "#ef4444", "EOL"]] as const).map(([range, color, lbl]) => (
{range} — {lbl}
))}
{/* Fleet Sidebar */}
{/* Sidebar header */}
Fleet
{batteryConfigs.length} cells
{/* Selected cell detail */} {selected3D && (() => { const b = currentSohs.find((s) => s.id === selected3D); const cfg = batteryConfigs.find((c) => c.battery_id === selected3D); if (!b || !cfg) return null; return (
{b.id}
{b.label}
{([ ["SOH", `${b.soh.toFixed(1)}%`, b.color], ["State", b.deg, DEG_COLORS[b.deg] ?? "#666"], ["RUL", `${b.rul.toFixed(0)} cyc`, "#3b82f6"], ["Re", `${b.re.toFixed(4)} Ω`, "#f59e0b"], ["Rct", `${b.rct.toFixed(4)} Ω`, "#8b5cf6"], ["Temp", `${cfg.ambient_temperature}°C`, "#06b6d4"], ] as const).map(([lbl, val, clr]) => (
{lbl}
{val}
))}
{/* SOH sparkline */} {selectedResult && selectedResult.soh_history.length > 1 && (
SOH trend
i % Math.max(1, Math.floor(selectedResult.soh_history.length / 50)) === 0).map((s, i) => ({ i, s }))}>
)}
); })()} {/* Battery list — scrollable */}
{batteryConfigs.map((cfg) => { const cur = currentSohs.find((s) => s.id === cfg.battery_id); const soh = cur?.soh ?? cfg.initial_soh ?? 100; const color = cur?.color ?? sohColor(soh); const isSelected = selected3D === cfg.battery_id; return (
setSelected3D((p) => (p === cfg.battery_id ? null : cfg.battery_id))} > {cfg.label ?? cfg.battery_id}
{soh.toFixed(1)}% {/* Edit button — visible on hover or when selected */}
); })}
{/* EOL micro-table */} {results.length > 0 && (
EOL Status
{results.map((r) => (
{r.battery_id} {r.eol_time !== null ? ( {r.eol_time.toFixed(1)} {timeUnitLabel} ) : ( Safe )}
))}
)}
{/* ── Analytics section ──────────────────────────────────────────── */} {results.length > 0 && (
{/* Tab bar */}
{CHART_TABS.map((tab) => { const Icon = tab.icon as React.FC<{ className?: string }>; return ( ); })}
{activeChart === "trajectories" && (
Metric: {CHART_METRICS.map((m) => ( ))}
)}
{/* ── Fleet Overview ─── */} {activeChart === "fleet" && (
{/* SOH band */}

Fleet SOH Over Time

X axis: {timeUnitLabel}
} />
{/* Current SOH comparison */}

Current SOH Snapshot

} /> {comparisonData.map((d) => )}
{/* RUL comparison */}

Remaining Useful Life

} /> {comparisonData.map((d) => )}
)} {/* ── Trajectories ─── */} {activeChart === "trajectories" && (

Individual Trajectories — {CHART_METRICS.find((m) => m.key === chartMetric)?.label}

X: {timeUnitLabel}
} /> {chartMetric === "soh" && ( )} {trajectoryData.keys.map((id, i) => ( b.battery_id === id)?.label ?? id} stroke={CHART_COLORS[i % CHART_COLORS.length]} dot={false} strokeWidth={selected3D === id ? 3 : 1.5} strokeOpacity={selected3D && selected3D !== id ? 0.22 : 1} /> ))}
)} {/* ── Stress Analysis ─── */} {activeChart === "stress" && (
{/* Grouped bar — stress factors */}

Stress Factors per Battery

1.0 = baseline
} />
{/* Total stress radar */}

Multi-Factor Stress Radar

{batteryConfigs.slice(0, 7).map((b, i) => ( ))}
{/* Total stress bar */}

Combined Stress Factor × Degradation Rate

{ const r = results.find((r) => r.battery_id === batteryConfigs.find((b) => b.battery_id.replace("BAT-", "B") === s.name || (b.label ?? b.battery_id) === s.label)?.battery_id); return { ...s, deg: r ? +(r.deg_rate_avg * 100).toFixed(4) : 0, }; })} margin={{ top: 5, right: 20, bottom: 5, left: 0 }} > } />
)} {/* ── Impedance ─── */} {activeChart === "impedance" && (

Impedance Growth — {selected3D ?? "select a cell"}

{impedanceData.length > 0 ? ( } /> ) : (
Select a battery cell in the 3D pack above
)}
{/* Re / Rct current snapshot comparison */}

Re / Rct Fleet Snapshot

} />
{/* Nyquist-style Re vs Rct scatter */}

Nyquist-style: Re vs Rct (current step)

Bubble size proportional to SOH
typeof val === "number" ? val.toFixed(4) : val} labelFormatter={(_, payload) => payload?.[0]?.payload?.name || ""} /> ({ ...d, name: d.name }))} shape={(props: any) => { const { cx, cy, r, fill } = props; return ; }} > {comparisonData.map((d, i) => )}
)} {/* ── Capacity Fade ─── */} {activeChart === "capacity" && (
{/* Capacity over time */}

Capacity Fade (Ah) Over Time

Q = Q_nom × SOH/100
} /> {capacityData.keys.map((id, i) => ( b.battery_id === id)?.label ?? id} stroke={CHART_COLORS[i % CHART_COLORS.length]} dot={false} strokeWidth={selected3D === id ? 3 : 1.5} strokeOpacity={selected3D && selected3D !== id ? 0.22 : 1} /> ))}
{/* Current capacity snapshot */}

Current Capacity (Ah)

({ name: b.id.replace("BAT-", "B"), cap: +(Q_NOM * b.soh / 100).toFixed(3), color: b.color, }))} margin={{ top: 5, right: 20, bottom: 5, left: 0 }} > } /> {currentSohs.map((b, i) => )}
{/* Degradation rate per battery */}

Avg Degradation Rate (%/cycle)

({ name: r.battery_id.replace("BAT-", "B"), rate: +(r.deg_rate_avg * 100).toFixed(4) }))} margin={{ top: 5, right: 20, bottom: 5, left: 0 }} > } /> {results.map((r, i) => )}
)} {/* ── Distribution ─── */} {activeChart === "distribution" && (

Fleet Health Distribution

`${name}: ${value} (${((percent ?? 0) * 100).toFixed(0)}%)`} labelLine={{ stroke: "#4b5563" }}> {degDist.map((e) => )}

Avg Degradation Rate (%/cycle)

({ name: r.battery_id.replace("BAT-", "B"), rate: +(r.deg_rate_avg * 100).toFixed(4) }))} margin={{ top: 5, right: 20, bottom: 5, left: 0 }} > } /> {results.map((r, i) => )}
)} {/* ── EOL Analysis ─── */} {activeChart === "eol" && (

EOL Time vs Degradation Rate

{eolScatter.length > 0 ? ( typeof val === "number" ? val.toFixed(3) : val} /> {eolScatter.map((e, i) => )} ) : (
No batteries reached EOL yet.
Increase steps or lower the threshold.
)}
{/* Summary table */}

Battery Summary

{["ID", "Label", "Final SOH", "RUL", "EOL Time", "Deg Rate"].map((h) => ( ))} {results.map((r) => ( setSelected3D(r.battery_id)} > ))}
{h}
{r.battery_id} {r.label} {r.final_soh.toFixed(1)}% {r.final_rul.toFixed(0)} cyc {r.eol_time !== null ? `${r.eol_time.toFixed(1)} ${timeUnitLabel}` : "—"} {(r.deg_rate_avg * 100).toFixed(4)}%
)} {/* ── Log ─── */} {activeChart === "log" && (

Simulation Log

{simLog.length} events
{simLog.length === 0 ? ( No events. Configure batteries and click Run Simulation. ) : ( simLog.map((e, i) => (
{e.t} {e.msg}
)) )}
)}
)} {/* Empty state — before first simulation */} {!results.length && !isSimulating && (
No simulation data yet
Configure your battery fleet above, then click Run Simulation
)}
); }