aiBatteryLifeCycle / frontend /src /components /RecommendationPanel.tsx
NeerajCodz's picture
feat: full project — ML simulation, dashboard UI, models on HF Hub
f381be8
import { useState, useMemo } from "react";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
CartesianGrid, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
Cell, ReferenceLine,
} from "recharts";
import {
Zap, Target, TrendingUp, TrendingDown, Thermometer, Activity,
Trophy, Award, Medal, BarChart2, GitCompare, RefreshCcw, ChevronDown,
ChevronUp, Info, AlertTriangle, CheckCircle2, Sliders,
} from "lucide-react";
import { fetchRecommendations, RecommendationResponse } from "../api";
const CHART_COLORS = [
"#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#ec4899", "#84cc16", "#f97316", "#6366f1",
];
const TOOLTIP_STYLE = { backgroundColor: "#111827", border: "1px solid #374151", borderRadius: "8px", fontSize: 12 };
function RankIcon({ rank }: { rank: number }) {
if (rank === 1) return <Trophy className="w-4 h-4 text-yellow-400" />;
if (rank === 2) return <Award className="w-4 h-4 text-gray-300" />;
if (rank === 3) return <Medal className="w-4 h-4 text-orange-400" />;
return <span className="text-xs font-bold text-gray-400">#{rank}</span>;
}
function SliderInput({ label, value, min, max, step, unit, onChange }: {
label: string; value: number; min: number; max: number; step: number; unit: string; onChange: (v: number) => void;
}) {
return (
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-gray-400">{label}</label>
<span className="text-xs font-mono text-green-400">{value}{unit}</span>
</div>
<input
type="range" min={min} max={max} step={step} value={value}
onChange={(e) => onChange(+e.target.value)}
className="w-full accent-green-500 h-1.5"
/>
<div className="flex justify-between text-xs text-gray-600">
<span>{min}{unit}</span><span>{max}{unit}</span>
</div>
</div>
);
}
export default function RecommendationPanel() {
const [batteryId, setBatteryId] = useState("B0005");
const [currentCycle, setCurrentCycle] = useState(100);
const [currentSoh, setCurrentSoh] = useState(85);
const [ambientTemp, setAmbientTemp] = useState(24);
const [topK, setTopK] = useState(5);
const [result, setResult] = useState<RecommendationResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const [chartTab, setChartTab] = useState<"rul" | "params" | "radar">("rul");
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
const res = await fetchRecommendations({
battery_id: batteryId,
current_cycle: currentCycle,
current_soh: currentSoh,
ambient_temperature: ambientTemp,
top_k: topK,
});
setResult(res);
} catch (e: any) {
setError(e.response?.data?.detail || e.message);
} finally {
setLoading(false);
}
};
// Derived chart data
const rulBarData = useMemo(() => {
if (!result?.recommendations) return [];
return result.recommendations.map((r) => ({
rank: `#${r.rank}`,
RUL: Math.round(r.predicted_rul),
Improvement: Math.round(r.rul_improvement),
fill: CHART_COLORS[(r.rank - 1) % CHART_COLORS.length],
}));
}, [result]);
const paramData = useMemo(() => {
if (!result?.recommendations) return [];
return result.recommendations.map((r) => ({
rank: `#${r.rank}`,
"Temp (°C)": r.ambient_temperature,
"Current (A)": r.discharge_current,
"Cutoff (V)": r.cutoff_voltage * 10,
}));
}, [result]);
const radarData = useMemo(() => {
if (!result?.recommendations || result.recommendations.length < 2) return [];
const top3 = result.recommendations.slice(0, 3);
const maxRul = Math.max(...top3.map((r) => r.predicted_rul));
const maxImp = Math.max(...top3.map((r) => Math.abs(r.rul_improvement)), 1);
return [
{ metric: "RUL", ...Object.fromEntries(top3.map((r) => [`#${r.rank}`, +((r.predicted_rul / maxRul) * 100).toFixed(1)])) },
{ metric: "Improvement", ...Object.fromEntries(top3.map((r) => [`#${r.rank}`, +(Math.max(0, r.rul_improvement) / maxImp * 100).toFixed(1)])) },
{ metric: "Low Temp", ...Object.fromEntries(top3.map((r) => [`#${r.rank}`, +(Math.max(0, 45 - r.ambient_temperature) / 45 * 100).toFixed(1)])) },
{ metric: "Low Current", ...Object.fromEntries(top3.map((r) => [`#${r.rank}`, +(Math.max(0, 3 - r.discharge_current) / 3 * 100).toFixed(1)])) },
];
}, [result]);
const baseline = result?.recommendations[0];
const bestOnly = result?.recommendations.find((r) => r.rank === 1);
return (
<div className="space-y-5">
{/* Input panel */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-3">
<Sliders className="w-4 h-4 text-green-400" />
<h2 className="text-base font-semibold">Operating Condition Optimizer</h2>
</div>
<p className="text-xs text-gray-400 mb-4">
Find optimal temperature, discharge current and cutoff voltage to maximize Remaining Useful Life.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Text inputs */}
<div className="space-y-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Battery ID</label>
<input
type="text" value={batteryId} onChange={(e) => setBatteryId(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Top K Results</label>
<input
type="number" min={1} max={20} value={topK} onChange={(e) => setTopK(+e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Current Cycle</label>
<input
type="number" value={currentCycle} onChange={(e) => setCurrentCycle(+e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
/>
</div>
</div>
</div>
{/* Sliders */}
<div className="space-y-4">
<SliderInput label="Current SOH" value={currentSoh} min={50} max={100} step={0.5} unit="%" onChange={setCurrentSoh} />
<SliderInput label="Ambient Temperature" value={ambientTemp} min={0} max={60} step={1} unit="°C" onChange={setAmbientTemp} />
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<button
onClick={handleSubmit}
disabled={loading}
className="flex items-center gap-2 bg-green-600 hover:bg-green-500 text-white font-medium px-5 py-2.5 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> Optimizing…</>
) : (
<><TrendingUp className="w-4 h-4" /> Get Recommendations</>
)}
</button>
{result && (
<button onClick={() => setResult(null)} className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors">
<RefreshCcw className="w-3.5 h-3.5" /> Clear
</button>
)}
</div>
{error && (
<div className="mt-3 flex items-start gap-2 text-sm text-red-400 bg-red-900/20 border border-red-800 p-3 rounded-lg">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
{error}
</div>
)}
</div>
{/* Results */}
{result && (
<div className="space-y-5">
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-gray-900 rounded-xl p-4 border border-green-800/40">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-green-400" />
<span className="text-xs text-gray-400">Battery</span>
</div>
<div className="text-xl font-bold text-white">{result.battery_id}</div>
<div className="text-xs text-gray-500">Current SOH: {result.current_soh}%</div>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="text-xs text-gray-400">Best RUL</span>
</div>
<div className="text-xl font-bold text-yellow-400">{bestOnly?.predicted_rul.toFixed(0)} cyc</div>
<div className="text-xs text-gray-500">Top recommendation</div>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-xs text-gray-400">Best Improvement</span>
</div>
<div className="text-xl font-bold text-blue-400">
{bestOnly && bestOnly.rul_improvement > 0 ? "+" : ""}{bestOnly?.rul_improvement.toFixed(0)} cyc
</div>
<div className="text-xs text-gray-500">{bestOnly?.rul_improvement_pct}% gain</div>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-1">
<BarChart2 className="w-4 h-4 text-purple-400" />
<span className="text-xs text-gray-400">Recommendations</span>
</div>
<div className="text-xl font-bold text-purple-400">{result.recommendations.length}</div>
<div className="text-xs text-gray-500">configurations</div>
</div>
</div>
{/* Chart tabs */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GitCompare className="w-4 h-4 text-green-400" />
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Visual Analysis</h3>
</div>
<div className="flex gap-1">
{(["rul", "params", "radar"] as const).map((t) => (
<button
key={t}
onClick={() => setChartTab(t)}
className={`px-2.5 py-1 rounded text-xs capitalize transition-colors ${chartTab === t ? "bg-green-600 text-white" : "bg-gray-800 text-gray-400 hover:bg-gray-700"}`}
>
{t === "rul" ? "RUL Comparison" : t === "params" ? "Parameters" : "Radar"}
</button>
))}
</div>
</div>
{chartTab === "rul" && (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={rulBarData} margin={{ bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis dataKey="rank" stroke="#6b7280" tick={{ fontSize: 11 }} />
<YAxis stroke="#6b7280" tick={{ fontSize: 10 }} label={{ value: "Cycles", angle: -90, position: "insideLeft", fill: "#9ca3af", fontSize: 11 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v: any, name) => [`${v} cycles`, name]} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine y={result.current_soh * 5} stroke="#6b7280" strokeDasharray="4 4" label={{ value: "Baseline", fill: "#6b7280", fontSize: 10 }} />
<Bar dataKey="RUL" name="Predicted RUL" radius={[4, 4, 0, 0]}>
{rulBarData.map((d, i) => <Cell key={i} fill={d.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
{chartTab === "params" && (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={paramData} margin={{ bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis dataKey="rank" stroke="#6b7280" tick={{ fontSize: 11 }} />
<YAxis stroke="#6b7280" tick={{ fontSize: 10 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="Temp (°C)" fill="#f59e0b" radius={[4, 4, 0, 0]} />
<Bar dataKey="Current (A)" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="Cutoff (V)" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
{chartTab === "radar" && radarData.length > 0 && (
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid stroke="#374151" />
<PolarAngleAxis dataKey="metric" tick={{ fill: "#9ca3af", fontSize: 11 }} />
<PolarRadiusAxis domain={[0, 100]} tick={{ fill: "#6b7280", fontSize: 9 }} />
{result.recommendations.slice(0, 3).map((r, i) => (
<Radar key={r.rank} name={`#${r.rank}`} dataKey={`#${r.rank}`}
stroke={CHART_COLORS[i]} fill={CHART_COLORS[i]} fillOpacity={0.15} />
))}
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
</RadarChart>
</ResponsiveContainer>
)}
</div>
{/* Recommendations table */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="text-sm font-semibold text-gray-300">
Recommendations for {result.battery_id} — SOH: {result.current_soh}%
</span>
</div>
<span className="text-xs text-gray-500">{result.recommendations.length} configs</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500 border-b border-gray-800 bg-gray-950/50">
<th className="py-2 px-3 text-left w-8">#</th>
<th className="py-2 px-3 text-right">Temp (°C)</th>
<th className="py-2 px-3 text-right">Current (A)</th>
<th className="py-2 px-3 text-right">Cutoff (V)</th>
<th className="py-2 px-3 text-right">Pred. RUL</th>
<th className="py-2 px-3 text-right">Improvement</th>
<th className="py-2 px-3 text-right">% Gain</th>
<th className="py-2 px-3 w-8" />
</tr>
</thead>
<tbody>
{result.recommendations.map((rec) => {
const expanded = expandedRow === rec.rank;
const impPositive = rec.rul_improvement > 0;
return (
<>
<tr
key={rec.rank}
className={`border-b border-gray-800/40 hover:bg-gray-800/40 transition-colors cursor-pointer ${
rec.rank === 1 ? "bg-yellow-900/10" : ""
}`}
onClick={() => setExpandedRow(expanded ? null : rec.rank)}
>
<td className="py-2.5 px-3">
<span className="flex items-center"><RankIcon rank={rec.rank} /></span>
</td>
<td className="py-2.5 px-3 text-right">
<span className="flex items-center justify-end gap-1">
<Thermometer className="w-3 h-3 text-orange-400" />{rec.ambient_temperature}
</span>
</td>
<td className="py-2.5 px-3 text-right text-blue-400">{rec.discharge_current}A</td>
<td className="py-2.5 px-3 text-right text-purple-400">{rec.cutoff_voltage}V</td>
<td className="py-2.5 px-3 text-right font-semibold text-green-400">{rec.predicted_rul.toFixed(0)}</td>
<td className="py-2.5 px-3 text-right">
<span className={impPositive ? "text-green-400" : "text-red-400"}>
{impPositive ? "+" : ""}{rec.rul_improvement.toFixed(0)}
</span>
</td>
<td className="py-2.5 px-3 text-right">
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${
impPositive ? "bg-green-900/40 text-green-400" : "bg-red-900/40 text-red-400"
}`}>
{impPositive ? "+" : ""}{rec.rul_improvement_pct}%
</span>
</td>
<td className="py-2.5 px-3 text-right">
{expanded ? <ChevronUp className="w-3.5 h-3.5 text-gray-400" /> : <ChevronDown className="w-3.5 h-3.5 text-gray-400" />}
</td>
</tr>
{expanded && (
<tr key={`${rec.rank}-exp`} className="border-b border-gray-800/40 bg-gray-950/50">
<td colSpan={8} className="px-4 py-3">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-gray-400 leading-relaxed">{rec.explanation}</p>
</div>
<div className="mt-2 flex gap-4">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Thermometer className="w-3 h-3 text-orange-400" />
Temp: <span className="text-white">{rec.ambient_temperature}°C</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Activity className="w-3 h-3 text-blue-400" />
Current: <span className="text-white">{rec.discharge_current}A</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Zap className="w-3 h-3 text-purple-400" />
Cutoff: <span className="text-white">{rec.cutoff_voltage}V</span>
</div>
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
}