Spaces:
Sleeping
Sleeping
Commit ·
5fe88ef
1
Parent(s): 20ea861
fix: recommendation system
Browse files- api/routers/predict_v2.py +65 -19
- api/routers/predict_v3.py +65 -15
- frontend/src/App.tsx +6 -1
- frontend/src/components/RecommendationPanel.tsx +58 -19
api/routers/predict_v2.py
CHANGED
|
@@ -76,9 +76,8 @@ async def predict_batch_v2(req: BatchPredictRequest):
|
|
| 76 |
async def recommend_v2(req: RecommendationRequest):
|
| 77 |
"""Get operational recommendations using v2 models.
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
improvement in v1).
|
| 82 |
"""
|
| 83 |
import itertools
|
| 84 |
|
|
@@ -86,15 +85,6 @@ async def recommend_v2(req: RecommendationRequest):
|
|
| 86 |
currents = [0.5, 1.0, 2.0, 4.0]
|
| 87 |
cutoffs = [2.0, 2.2, 2.5, 2.7]
|
| 88 |
|
| 89 |
-
# v2 FIX: compute baseline RUL from user-provided current_soh
|
| 90 |
-
# Data-driven: linear degradation at a realistic rate (~0.2%/cycle)
|
| 91 |
-
EOL_THRESHOLD = 70.0
|
| 92 |
-
deg_rate = 0.2 # conservative NASA-calibrated %/cycle
|
| 93 |
-
if req.current_soh > EOL_THRESHOLD:
|
| 94 |
-
baseline_rul = (req.current_soh - EOL_THRESHOLD) / deg_rate
|
| 95 |
-
else:
|
| 96 |
-
baseline_rul = 0.0
|
| 97 |
-
|
| 98 |
base_features = {
|
| 99 |
"cycle_number": req.current_cycle,
|
| 100 |
"ambient_temperature": req.ambient_temperature,
|
|
@@ -110,22 +100,75 @@ async def recommend_v2(req: RecommendationRequest):
|
|
| 110 |
"delta_capacity": -0.005,
|
| 111 |
}
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
candidates = []
|
| 114 |
for t, c, v in itertools.product(temps, currents, cutoffs):
|
| 115 |
feat = {**base_features, "ambient_temperature": t, "avg_current": c,
|
| 116 |
"min_voltage": v, "voltage_range": 4.19 - v,
|
| 117 |
"avg_temp": t + 8.0}
|
| 118 |
result = registry_v2.predict(feat, req.model_name)
|
| 119 |
-
rul = result.get("rul_cycles", 0) or 0
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
top = candidates[: req.top_k]
|
| 124 |
|
| 125 |
recs = []
|
| 126 |
-
for rank,
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0
|
|
|
|
| 129 |
recs.append(SingleRecommendation(
|
| 130 |
rank=rank,
|
| 131 |
ambient_temperature=t,
|
|
@@ -134,7 +177,10 @@ async def recommend_v2(req: RecommendationRequest):
|
|
| 134 |
predicted_rul=rul,
|
| 135 |
rul_improvement=improvement,
|
| 136 |
rul_improvement_pct=round(pct, 1),
|
| 137 |
-
explanation=
|
|
|
|
|
|
|
|
|
|
| 138 |
))
|
| 139 |
|
| 140 |
return RecommendationResponse(
|
|
|
|
| 76 |
async def recommend_v2(req: RecommendationRequest):
|
| 77 |
"""Get operational recommendations using v2 models.
|
| 78 |
|
| 79 |
+
Ranking is based on net RUL improvement versus a model-derived baseline,
|
| 80 |
+
with small guardrail penalties for clearly harsher operating conditions.
|
|
|
|
| 81 |
"""
|
| 82 |
import itertools
|
| 83 |
|
|
|
|
| 85 |
currents = [0.5, 1.0, 2.0, 4.0]
|
| 86 |
cutoffs = [2.0, 2.2, 2.5, 2.7]
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
base_features = {
|
| 89 |
"cycle_number": req.current_cycle,
|
| 90 |
"ambient_temperature": req.ambient_temperature,
|
|
|
|
| 100 |
"delta_capacity": -0.005,
|
| 101 |
}
|
| 102 |
|
| 103 |
+
def guardrail_penalty(temp: float, current: float, cutoff: float) -> float:
|
| 104 |
+
"""Penalty in cycle-units for stress-heavy operating points.
|
| 105 |
+
|
| 106 |
+
This keeps recommendations aligned with battery-care intent even when
|
| 107 |
+
model outputs are noisy for out-of-distribution combinations.
|
| 108 |
+
"""
|
| 109 |
+
# Room temperature (~24C) is generally healthiest; high heat is penalized most.
|
| 110 |
+
temp_penalty = max(0.0, temp - 30.0) * 3.0 + max(0.0, 12.0 - temp) * 1.5
|
| 111 |
+
# Aggressive discharge currents accelerate degradation.
|
| 112 |
+
current_penalty = max(0.0, current - 1.5) * 12.0
|
| 113 |
+
# Very low cutoff voltages imply deeper discharge stress.
|
| 114 |
+
cutoff_penalty = max(0.0, 2.4 - cutoff) * 8.0
|
| 115 |
+
return temp_penalty + current_penalty + cutoff_penalty
|
| 116 |
+
|
| 117 |
+
baseline_features = {
|
| 118 |
+
**base_features,
|
| 119 |
+
"ambient_temperature": req.ambient_temperature,
|
| 120 |
+
"avg_current": 1.82,
|
| 121 |
+
"min_voltage": 2.61,
|
| 122 |
+
"voltage_range": 4.19 - 2.61,
|
| 123 |
+
"avg_temp": req.ambient_temperature + 8.0,
|
| 124 |
+
}
|
| 125 |
+
baseline_pred = registry_v2.predict(baseline_features, req.model_name)
|
| 126 |
+
baseline_rul = max(0.0, float(baseline_pred.get("rul_cycles", 0) or 0))
|
| 127 |
+
baseline_adjusted_rul = baseline_rul - guardrail_penalty(
|
| 128 |
+
req.ambient_temperature,
|
| 129 |
+
1.82,
|
| 130 |
+
2.61,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
candidates = []
|
| 134 |
for t, c, v in itertools.product(temps, currents, cutoffs):
|
| 135 |
feat = {**base_features, "ambient_temperature": t, "avg_current": c,
|
| 136 |
"min_voltage": v, "voltage_range": 4.19 - v,
|
| 137 |
"avg_temp": t + 8.0}
|
| 138 |
result = registry_v2.predict(feat, req.model_name)
|
| 139 |
+
rul = max(0.0, float(result.get("rul_cycles", 0) or 0))
|
| 140 |
+
adjusted_rul = rul - guardrail_penalty(t, c, v)
|
| 141 |
+
improvement = adjusted_rul - baseline_adjusted_rul
|
| 142 |
+
candidates.append({
|
| 143 |
+
"raw_rul": rul,
|
| 144 |
+
"adjusted_rul": adjusted_rul,
|
| 145 |
+
"improvement": improvement,
|
| 146 |
+
"temp": t,
|
| 147 |
+
"current": c,
|
| 148 |
+
"cutoff": v,
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
candidates.sort(
|
| 152 |
+
key=lambda x: (
|
| 153 |
+
x["improvement"] > 0,
|
| 154 |
+
x["improvement"],
|
| 155 |
+
x["adjusted_rul"],
|
| 156 |
+
-abs(x["temp"] - 24.0),
|
| 157 |
+
-x["current"],
|
| 158 |
+
),
|
| 159 |
+
reverse=True,
|
| 160 |
+
)
|
| 161 |
top = candidates[: req.top_k]
|
| 162 |
|
| 163 |
recs = []
|
| 164 |
+
for rank, rec in enumerate(top, 1):
|
| 165 |
+
rul = rec["raw_rul"]
|
| 166 |
+
t = rec["temp"]
|
| 167 |
+
c = rec["current"]
|
| 168 |
+
v = rec["cutoff"]
|
| 169 |
+
improvement = rec["improvement"]
|
| 170 |
pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0
|
| 171 |
+
impact = "improves" if improvement > 0 else "does not improve"
|
| 172 |
recs.append(SingleRecommendation(
|
| 173 |
rank=rank,
|
| 174 |
ambient_temperature=t,
|
|
|
|
| 177 |
predicted_rul=rul,
|
| 178 |
rul_improvement=improvement,
|
| 179 |
rul_improvement_pct=round(pct, 1),
|
| 180 |
+
explanation=(
|
| 181 |
+
f"Operate at {t}°C, {c}A, cutoff {v}V for ~{rul:.0f} cycles RUL; "
|
| 182 |
+
f"this {impact} lifespan by {improvement:+.0f} cycles vs your baseline."
|
| 183 |
+
),
|
| 184 |
))
|
| 185 |
|
| 186 |
return RecommendationResponse(
|
api/routers/predict_v3.py
CHANGED
|
@@ -73,20 +73,17 @@ async def predict_batch_v3(req: BatchPredictRequest):
|
|
| 73 |
# ── Recommendations (v3) ─────────────────────────────────────────────────────
|
| 74 |
@router.post("/recommend", response_model=RecommendationResponse)
|
| 75 |
async def recommend_v3(req: RecommendationRequest):
|
| 76 |
-
"""Get operational recommendations using v3 models.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
import itertools
|
| 78 |
|
| 79 |
temps = [4.0, 24.0, 43.0]
|
| 80 |
currents = [0.5, 1.0, 2.0, 4.0]
|
| 81 |
cutoffs = [2.0, 2.2, 2.5, 2.7]
|
| 82 |
|
| 83 |
-
EOL_THRESHOLD = 70.0
|
| 84 |
-
deg_rate = 0.2
|
| 85 |
-
if req.current_soh > EOL_THRESHOLD:
|
| 86 |
-
baseline_rul = (req.current_soh - EOL_THRESHOLD) / deg_rate
|
| 87 |
-
else:
|
| 88 |
-
baseline_rul = 0.0
|
| 89 |
-
|
| 90 |
base_features = {
|
| 91 |
"cycle_number": req.current_cycle,
|
| 92 |
"ambient_temperature": req.ambient_temperature,
|
|
@@ -102,22 +99,72 @@ async def recommend_v3(req: RecommendationRequest):
|
|
| 102 |
"delta_capacity": -0.005,
|
| 103 |
}
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
candidates = []
|
| 106 |
for t, c, v in itertools.product(temps, currents, cutoffs):
|
| 107 |
feat = {**base_features, "ambient_temperature": t, "avg_current": c,
|
| 108 |
"min_voltage": v, "voltage_range": 4.19 - v,
|
| 109 |
"avg_temp": t + 8.0}
|
| 110 |
result = registry_v3.predict(feat, req.model_name)
|
| 111 |
-
rul = result.get("rul_cycles", 0) or 0
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
top = candidates[: req.top_k]
|
| 116 |
|
| 117 |
recs = []
|
| 118 |
-
for rank,
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0
|
|
|
|
| 121 |
recs.append(SingleRecommendation(
|
| 122 |
rank=rank,
|
| 123 |
ambient_temperature=t,
|
|
@@ -126,7 +173,10 @@ async def recommend_v3(req: RecommendationRequest):
|
|
| 126 |
predicted_rul=rul,
|
| 127 |
rul_improvement=improvement,
|
| 128 |
rul_improvement_pct=round(pct, 1),
|
| 129 |
-
explanation=
|
|
|
|
|
|
|
|
|
|
| 130 |
))
|
| 131 |
|
| 132 |
return RecommendationResponse(
|
|
|
|
| 73 |
# ── Recommendations (v3) ─────────────────────────────────────────────────────
|
| 74 |
@router.post("/recommend", response_model=RecommendationResponse)
|
| 75 |
async def recommend_v3(req: RecommendationRequest):
|
| 76 |
+
"""Get operational recommendations using v3 models.
|
| 77 |
+
|
| 78 |
+
Ranking is based on net RUL improvement versus a model-derived baseline,
|
| 79 |
+
with small guardrail penalties for clearly harsher operating conditions.
|
| 80 |
+
"""
|
| 81 |
import itertools
|
| 82 |
|
| 83 |
temps = [4.0, 24.0, 43.0]
|
| 84 |
currents = [0.5, 1.0, 2.0, 4.0]
|
| 85 |
cutoffs = [2.0, 2.2, 2.5, 2.7]
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
base_features = {
|
| 88 |
"cycle_number": req.current_cycle,
|
| 89 |
"ambient_temperature": req.ambient_temperature,
|
|
|
|
| 99 |
"delta_capacity": -0.005,
|
| 100 |
}
|
| 101 |
|
| 102 |
+
def guardrail_penalty(temp: float, current: float, cutoff: float) -> float:
|
| 103 |
+
"""Penalty in cycle-units for stress-heavy operating points.
|
| 104 |
+
|
| 105 |
+
This keeps recommendations aligned with battery-care intent even when
|
| 106 |
+
model outputs are noisy for out-of-distribution combinations.
|
| 107 |
+
"""
|
| 108 |
+
temp_penalty = max(0.0, temp - 30.0) * 3.0 + max(0.0, 12.0 - temp) * 1.5
|
| 109 |
+
current_penalty = max(0.0, current - 1.5) * 12.0
|
| 110 |
+
cutoff_penalty = max(0.0, 2.4 - cutoff) * 8.0
|
| 111 |
+
return temp_penalty + current_penalty + cutoff_penalty
|
| 112 |
+
|
| 113 |
+
baseline_features = {
|
| 114 |
+
**base_features,
|
| 115 |
+
"ambient_temperature": req.ambient_temperature,
|
| 116 |
+
"avg_current": 1.82,
|
| 117 |
+
"min_voltage": 2.61,
|
| 118 |
+
"voltage_range": 4.19 - 2.61,
|
| 119 |
+
"avg_temp": req.ambient_temperature + 8.0,
|
| 120 |
+
}
|
| 121 |
+
baseline_pred = registry_v3.predict(baseline_features, req.model_name)
|
| 122 |
+
baseline_rul = max(0.0, float(baseline_pred.get("rul_cycles", 0) or 0))
|
| 123 |
+
baseline_adjusted_rul = baseline_rul - guardrail_penalty(
|
| 124 |
+
req.ambient_temperature,
|
| 125 |
+
1.82,
|
| 126 |
+
2.61,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
candidates = []
|
| 130 |
for t, c, v in itertools.product(temps, currents, cutoffs):
|
| 131 |
feat = {**base_features, "ambient_temperature": t, "avg_current": c,
|
| 132 |
"min_voltage": v, "voltage_range": 4.19 - v,
|
| 133 |
"avg_temp": t + 8.0}
|
| 134 |
result = registry_v3.predict(feat, req.model_name)
|
| 135 |
+
rul = max(0.0, float(result.get("rul_cycles", 0) or 0))
|
| 136 |
+
adjusted_rul = rul - guardrail_penalty(t, c, v)
|
| 137 |
+
improvement = adjusted_rul - baseline_adjusted_rul
|
| 138 |
+
candidates.append({
|
| 139 |
+
"raw_rul": rul,
|
| 140 |
+
"adjusted_rul": adjusted_rul,
|
| 141 |
+
"improvement": improvement,
|
| 142 |
+
"temp": t,
|
| 143 |
+
"current": c,
|
| 144 |
+
"cutoff": v,
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
candidates.sort(
|
| 148 |
+
key=lambda x: (
|
| 149 |
+
x["improvement"] > 0,
|
| 150 |
+
x["improvement"],
|
| 151 |
+
x["adjusted_rul"],
|
| 152 |
+
-abs(x["temp"] - 24.0),
|
| 153 |
+
-x["current"],
|
| 154 |
+
),
|
| 155 |
+
reverse=True,
|
| 156 |
+
)
|
| 157 |
top = candidates[: req.top_k]
|
| 158 |
|
| 159 |
recs = []
|
| 160 |
+
for rank, rec in enumerate(top, 1):
|
| 161 |
+
rul = rec["raw_rul"]
|
| 162 |
+
t = rec["temp"]
|
| 163 |
+
c = rec["current"]
|
| 164 |
+
v = rec["cutoff"]
|
| 165 |
+
improvement = rec["improvement"]
|
| 166 |
pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0
|
| 167 |
+
impact = "improves" if improvement > 0 else "does not improve"
|
| 168 |
recs.append(SingleRecommendation(
|
| 169 |
rank=rank,
|
| 170 |
ambient_temperature=t,
|
|
|
|
| 173 |
predicted_rul=rul,
|
| 174 |
rul_improvement=improvement,
|
| 175 |
rul_improvement_pct=round(pct, 1),
|
| 176 |
+
explanation=(
|
| 177 |
+
f"Operate at {t}°C, {c}A, cutoff {v}V for ~{rul:.0f} cycles RUL; "
|
| 178 |
+
f"this {impact} lifespan by {improvement:+.0f} cycles vs your baseline."
|
| 179 |
+
),
|
| 180 |
))
|
| 181 |
|
| 182 |
return RecommendationResponse(
|
frontend/src/App.tsx
CHANGED
|
@@ -81,7 +81,12 @@ export default function App() {
|
|
| 81 |
{activeTab === "simulation" && <SimulationPanel />}
|
| 82 |
{activeTab === "predict" && <PredictionForm apiVersion={apiVersion} />}
|
| 83 |
{activeTab === "graphs" && <GraphPanel />}
|
| 84 |
-
{activeTab === "recommend" &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
{activeTab === "metrics" && <MetricsPanel apiVersion={apiVersion} />}
|
| 86 |
{activeTab === "paper" && <ResearchPaper />}
|
| 87 |
</main>
|
|
|
|
| 81 |
{activeTab === "simulation" && <SimulationPanel />}
|
| 82 |
{activeTab === "predict" && <PredictionForm apiVersion={apiVersion} />}
|
| 83 |
{activeTab === "graphs" && <GraphPanel />}
|
| 84 |
+
{activeTab === "recommend" && (
|
| 85 |
+
<RecommendationPanel
|
| 86 |
+
apiVersion={apiVersion}
|
| 87 |
+
onApiVersionChange={handleVersionChange}
|
| 88 |
+
/>
|
| 89 |
+
)}
|
| 90 |
{activeTab === "metrics" && <MetricsPanel apiVersion={apiVersion} />}
|
| 91 |
{activeTab === "paper" && <ResearchPaper />}
|
| 92 |
</main>
|
frontend/src/components/RecommendationPanel.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
| 12 |
Trophy, Award, Medal, BarChart2, GitCompare, RefreshCcw, ChevronDown,
|
| 13 |
ChevronUp, Info, AlertTriangle, CheckCircle2, Sliders,
|
| 14 |
} from "lucide-react";
|
| 15 |
-
import { fetchRecommendations, fetchModels, RecommendationResponse, ModelInfo } from "../api";
|
| 16 |
|
| 17 |
const CHART_COLORS = [
|
| 18 |
"#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6",
|
|
@@ -51,6 +51,7 @@ function SliderInput({ label, value, min, max, step, unit, onChange }: {
|
|
| 51 |
|
| 52 |
type Props = {
|
| 53 |
apiVersion: "v1" | "v2" | "v3";
|
|
|
|
| 54 |
};
|
| 55 |
|
| 56 |
function Battery3D({ currentSoh, projectedSoh }: { currentSoh: number; projectedSoh: number }) {
|
|
@@ -87,7 +88,8 @@ function Battery3D({ currentSoh, projectedSoh }: { currentSoh: number; projected
|
|
| 87 |
);
|
| 88 |
}
|
| 89 |
|
| 90 |
-
export default function RecommendationPanel({ apiVersion }: Props) {
|
|
|
|
| 91 |
const [batteryId, setBatteryId] = useState("B0005");
|
| 92 |
const [currentCycle, setCurrentCycle] = useState(100);
|
| 93 |
const [currentSoh, setCurrentSoh] = useState(85);
|
|
@@ -102,12 +104,30 @@ export default function RecommendationPanel({ apiVersion }: Props) {
|
|
| 102 |
const [selectedRank, setSelectedRank] = useState<number>(1);
|
| 103 |
const [chartTab, setChartTab] = useState<"rul" | "params" | "radar">("rul");
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
// Fetch available loaded models for selector
|
| 106 |
useEffect(() => {
|
|
|
|
| 107 |
fetchModels()
|
| 108 |
-
.then((m) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
.catch(() => setModels([]));
|
| 110 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
const handleSubmit = async () => {
|
| 113 |
setLoading(true);
|
|
@@ -208,22 +228,41 @@ export default function RecommendationPanel({ apiVersion }: Props) {
|
|
| 208 |
</div>
|
| 209 |
</div>
|
| 210 |
{/* Model selector */}
|
| 211 |
-
<div>
|
| 212 |
-
<
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
<option
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
</div>
|
| 228 |
{/* Sliders */}
|
| 229 |
<div className="space-y-4">
|
|
|
|
| 12 |
Trophy, Award, Medal, BarChart2, GitCompare, RefreshCcw, ChevronDown,
|
| 13 |
ChevronUp, Info, AlertTriangle, CheckCircle2, Sliders,
|
| 14 |
} from "lucide-react";
|
| 15 |
+
import { fetchRecommendations, fetchModels, RecommendationResponse, ModelInfo, setApiVersion } from "../api";
|
| 16 |
|
| 17 |
const CHART_COLORS = [
|
| 18 |
"#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6",
|
|
|
|
| 51 |
|
| 52 |
type Props = {
|
| 53 |
apiVersion: "v1" | "v2" | "v3";
|
| 54 |
+
onApiVersionChange?: (v: "v1" | "v2" | "v3") => void;
|
| 55 |
};
|
| 56 |
|
| 57 |
function Battery3D({ currentSoh, projectedSoh }: { currentSoh: number; projectedSoh: number }) {
|
|
|
|
| 88 |
);
|
| 89 |
}
|
| 90 |
|
| 91 |
+
export default function RecommendationPanel({ apiVersion, onApiVersionChange }: Props) {
|
| 92 |
+
const [selectedApiVersion, setSelectedApiVersion] = useState<"v1" | "v2" | "v3">(apiVersion);
|
| 93 |
const [batteryId, setBatteryId] = useState("B0005");
|
| 94 |
const [currentCycle, setCurrentCycle] = useState(100);
|
| 95 |
const [currentSoh, setCurrentSoh] = useState(85);
|
|
|
|
| 104 |
const [selectedRank, setSelectedRank] = useState<number>(1);
|
| 105 |
const [chartTab, setChartTab] = useState<"rul" | "params" | "radar">("rul");
|
| 106 |
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
setSelectedApiVersion(apiVersion);
|
| 109 |
+
}, [apiVersion]);
|
| 110 |
+
|
| 111 |
// Fetch available loaded models for selector
|
| 112 |
useEffect(() => {
|
| 113 |
+
setApiVersion(selectedApiVersion);
|
| 114 |
fetchModels()
|
| 115 |
+
.then((m) => {
|
| 116 |
+
const loaded = m.filter((mo) => mo.loaded);
|
| 117 |
+
setModels(loaded);
|
| 118 |
+
setSelectedModel((prev) => (prev && loaded.some((mo) => mo.name === prev) ? prev : null));
|
| 119 |
+
})
|
| 120 |
.catch(() => setModels([]));
|
| 121 |
+
}, [selectedApiVersion]);
|
| 122 |
+
|
| 123 |
+
const handleVersionChange = (v: "v1" | "v2" | "v3") => {
|
| 124 |
+
setSelectedApiVersion(v);
|
| 125 |
+
setApiVersion(v);
|
| 126 |
+
onApiVersionChange?.(v);
|
| 127 |
+
setSelectedModel(null);
|
| 128 |
+
setResult(null);
|
| 129 |
+
setError(null);
|
| 130 |
+
};
|
| 131 |
|
| 132 |
const handleSubmit = async () => {
|
| 133 |
setLoading(true);
|
|
|
|
| 228 |
</div>
|
| 229 |
</div>
|
| 230 |
{/* Model selector */}
|
| 231 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 232 |
+
<div>
|
| 233 |
+
<label className="block text-xs text-gray-400 mb-1">Version</label>
|
| 234 |
+
<select
|
| 235 |
+
value={selectedApiVersion}
|
| 236 |
+
onChange={(e) => handleVersionChange(e.target.value as "v1" | "v2" | "v3")}
|
| 237 |
+
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
|
| 238 |
+
>
|
| 239 |
+
<option value="v1">v1</option>
|
| 240 |
+
<option value="v2">v2</option>
|
| 241 |
+
<option value="v3">v3</option>
|
| 242 |
+
</select>
|
| 243 |
+
</div>
|
| 244 |
+
<div>
|
| 245 |
+
<label className="block text-xs text-gray-400 mb-1">Model</label>
|
| 246 |
+
<select
|
| 247 |
+
value={selectedModel ?? ""}
|
| 248 |
+
onChange={(e) => setSelectedModel(e.target.value || null)}
|
| 249 |
+
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
|
| 250 |
+
>
|
| 251 |
+
<option value="">Default (best available)</option>
|
| 252 |
+
{models.map((m) => (
|
| 253 |
+
<option key={m.name} value={m.name}>
|
| 254 |
+
{m.display_name ?? m.name}
|
| 255 |
+
{m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
|
| 256 |
+
</option>
|
| 257 |
+
))}
|
| 258 |
+
</select>
|
| 259 |
+
</div>
|
| 260 |
</div>
|
| 261 |
+
{models.length === 0 && (
|
| 262 |
+
<p className="text-xs text-amber-400">
|
| 263 |
+
No loaded models found for {selectedApiVersion}. Using default endpoint behavior.
|
| 264 |
+
</p>
|
| 265 |
+
)}
|
| 266 |
</div>
|
| 267 |
{/* Sliders */}
|
| 268 |
<div className="space-y-4">
|