NeerajCodz commited on
Commit
5fe88ef
·
1 Parent(s): 20ea861

fix: recommendation system

Browse files
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
- v2 FIX: Uses user-provided current_soh to compute baseline RUL instead
80
- of re-predicting SOH from default features (which caused ~0 cycle
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
- candidates.append((rul, t, c, v, result["soh_pct"]))
121
-
122
- candidates.sort(reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  top = candidates[: req.top_k]
124
 
125
  recs = []
126
- for rank, (rul, t, c, v, soh) in enumerate(top, 1):
127
- improvement = rul - baseline_rul
 
 
 
 
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=f"Operate at {t}°C, {c}A, cutoff {v}V for ~{rul:.0f} cycles RUL",
 
 
 
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
- candidates.append((rul, t, c, v, result["soh_pct"]))
113
-
114
- candidates.sort(reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  top = candidates[: req.top_k]
116
 
117
  recs = []
118
- for rank, (rul, t, c, v, soh) in enumerate(top, 1):
119
- improvement = rul - baseline_rul
 
 
 
 
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=f"Operate at {t}°C, {c}A, cutoff {v}V for ~{rul:.0f} cycles RUL",
 
 
 
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" && <RecommendationPanel apiVersion={apiVersion} />}
 
 
 
 
 
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) => setModels(m.filter((mo) => mo.loaded)))
 
 
 
 
109
  .catch(() => setModels([]));
110
- }, [apiVersion]);
 
 
 
 
 
 
 
 
 
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
- <label className="block text-xs text-gray-400 mb-1">Model</label>
213
- <select
214
- value={selectedModel ?? ""}
215
- onChange={(e) => setSelectedModel(e.target.value || null)}
216
- className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white"
217
- >
218
- <option value="">Default (best available)</option>
219
- {models.map((m) => (
220
- <option key={m.name} value={m.name}>
221
- {m.display_name ?? m.name}
222
- {m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
223
- </option>
224
- ))}
225
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">