Commit ·
04d655f
1
Parent(s): 9cdb79f
fix: made download background and ui sub menu for model select
Browse files- Dockerfile +2 -2
- api/main.py +46 -13
- api/routers/predict.py +2 -2
- api/routers/predict_v2.py +2 -2
- api/routers/predict_v3.py +2 -2
- api/schemas.py +2 -0
- frontend/src/App.tsx +1 -1
- frontend/src/components/PredictionForm.tsx +18 -8
- frontend/src/components/SimulationPanel.tsx +37 -10
- frontend/src/components/VersionSelector.tsx +23 -31
Dockerfile
CHANGED
|
@@ -59,5 +59,5 @@ EXPOSE 7860
|
|
| 59 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 60 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 61 |
|
| 62 |
-
# Entrypoint:
|
| 63 |
-
CMD ["
|
|
|
|
| 59 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 60 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 61 |
|
| 62 |
+
# Entrypoint: start API immediately; model bootstrap runs in FastAPI lifespan
|
| 63 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
api/main.py
CHANGED
|
@@ -68,20 +68,11 @@ _FRONTEND_DIST = _HERE.parent / "frontend" / "dist"
|
|
| 68 |
# ── Lifespan ─────────────────────────────────────────────────────────────────
|
| 69 |
@asynccontextmanager
|
| 70 |
async def lifespan(app: FastAPI):
|
| 71 |
-
"""
|
| 72 |
log.info("Loading model registries …")
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
log.info(
|
| 77 |
-
"v3 registry ready — %d models loaded (startup top-%d set: %s)",
|
| 78 |
-
registry_v3.model_count,
|
| 79 |
-
DEFAULT_STARTUP_TOP_MODELS,
|
| 80 |
-
", ".join(startup_models),
|
| 81 |
-
)
|
| 82 |
-
else:
|
| 83 |
-
registry_v3.load_all()
|
| 84 |
-
log.warning("Top-5 selection unavailable; loaded full v3 registry")
|
| 85 |
# v1 and v2 are NOT loaded at startup — download + load on-demand via
|
| 86 |
# POST /api/versions/{v}/load to reduce startup time and memory usage.
|
| 87 |
if _version_loaded("v1"):
|
|
@@ -89,6 +80,9 @@ async def lifespan(app: FastAPI):
|
|
| 89 |
if _version_loaded("v2"):
|
| 90 |
log.info("v2 artifacts present on disk (not loaded — use API to activate)")
|
| 91 |
yield
|
|
|
|
|
|
|
|
|
|
| 92 |
log.info("Shutting down battery-lifecycle API")
|
| 93 |
|
| 94 |
|
|
@@ -195,6 +189,45 @@ async def _bg_load_version(version: str) -> None:
|
|
| 195 |
log.error("Failed to load version %s: %s", version, exc)
|
| 196 |
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
async def _bg_load_model(version: str, model_name: str) -> None:
|
| 199 |
import sys as _sys
|
| 200 |
|
|
|
|
| 68 |
# ── Lifespan ─────────────────────────────────────────────────────────────────
|
| 69 |
@asynccontextmanager
|
| 70 |
async def lifespan(app: FastAPI):
|
| 71 |
+
"""Start API immediately; bootstrap top v3 models in background."""
|
| 72 |
log.info("Loading model registries …")
|
| 73 |
+
_version_status["v3"] = "downloading"
|
| 74 |
+
app.state.v3_bootstrap_task = asyncio.create_task(_bg_bootstrap_v3())
|
| 75 |
+
log.info("v3 bootstrap started in background — API is available immediately")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# v1 and v2 are NOT loaded at startup — download + load on-demand via
|
| 77 |
# POST /api/versions/{v}/load to reduce startup time and memory usage.
|
| 78 |
if _version_loaded("v1"):
|
|
|
|
| 80 |
if _version_loaded("v2"):
|
| 81 |
log.info("v2 artifacts present on disk (not loaded — use API to activate)")
|
| 82 |
yield
|
| 83 |
+
t = getattr(app.state, "v3_bootstrap_task", None)
|
| 84 |
+
if t and not t.done():
|
| 85 |
+
t.cancel()
|
| 86 |
log.info("Shutting down battery-lifecycle API")
|
| 87 |
|
| 88 |
|
|
|
|
| 189 |
log.error("Failed to load version %s: %s", version, exc)
|
| 190 |
|
| 191 |
|
| 192 |
+
async def _bg_bootstrap_v3() -> None:
|
| 193 |
+
"""Download metadata + top startup models, then load them into memory."""
|
| 194 |
+
import sys as _sys
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
proc = await asyncio.create_subprocess_exec(
|
| 198 |
+
_sys.executable,
|
| 199 |
+
"scripts/download_models.py",
|
| 200 |
+
stdout=asyncio.subprocess.PIPE,
|
| 201 |
+
stderr=asyncio.subprocess.STDOUT,
|
| 202 |
+
)
|
| 203 |
+
await proc.wait()
|
| 204 |
+
if proc.returncode != 0:
|
| 205 |
+
_version_status["v3"] = "error"
|
| 206 |
+
log.error("Startup model download failed for v3")
|
| 207 |
+
return
|
| 208 |
+
|
| 209 |
+
startup_models = select_top_models("v3")
|
| 210 |
+
if startup_models:
|
| 211 |
+
registry_v3.load_all(only_models=set(startup_models))
|
| 212 |
+
log.info(
|
| 213 |
+
"v3 registry ready — %d models loaded (startup top-%d set: %s)",
|
| 214 |
+
registry_v3.model_count,
|
| 215 |
+
DEFAULT_STARTUP_TOP_MODELS,
|
| 216 |
+
", ".join(startup_models),
|
| 217 |
+
)
|
| 218 |
+
else:
|
| 219 |
+
registry_v3.load_all()
|
| 220 |
+
log.warning("Top model selection unavailable; loaded full v3 registry")
|
| 221 |
+
|
| 222 |
+
_version_status["v3"] = "ready"
|
| 223 |
+
except asyncio.CancelledError:
|
| 224 |
+
log.info("v3 bootstrap task cancelled")
|
| 225 |
+
raise
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
_version_status["v3"] = "error"
|
| 228 |
+
log.error("Failed during v3 bootstrap: %s", exc)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
async def _bg_load_model(version: str, model_name: str) -> None:
|
| 232 |
import sys as _sys
|
| 233 |
|
api/routers/predict.py
CHANGED
|
@@ -33,7 +33,7 @@ async def predict(req: PredictRequest):
|
|
| 33 |
features["avg_temp"] = features["ambient_temperature"] + 8.0
|
| 34 |
|
| 35 |
try:
|
| 36 |
-
result = registry.predict(features)
|
| 37 |
except Exception as exc:
|
| 38 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 39 |
|
|
@@ -225,7 +225,7 @@ async def predict_v1(req: PredictRequest):
|
|
| 225 |
if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
|
| 226 |
features["avg_temp"] = features["ambient_temperature"] + 8.0
|
| 227 |
try:
|
| 228 |
-
result = registry_v1.predict(features)
|
| 229 |
except Exception as exc:
|
| 230 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 231 |
return PredictResponse(
|
|
|
|
| 33 |
features["avg_temp"] = features["ambient_temperature"] + 8.0
|
| 34 |
|
| 35 |
try:
|
| 36 |
+
result = registry.predict(features, req.model_name)
|
| 37 |
except Exception as exc:
|
| 38 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 39 |
|
|
|
|
| 225 |
if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
|
| 226 |
features["avg_temp"] = features["ambient_temperature"] + 8.0
|
| 227 |
try:
|
| 228 |
+
result = registry_v1.predict(features, req.model_name)
|
| 229 |
except Exception as exc:
|
| 230 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 231 |
return PredictResponse(
|
api/routers/predict_v2.py
CHANGED
|
@@ -32,7 +32,7 @@ async def predict_v2(req: PredictRequest):
|
|
| 32 |
# v2 FIX: no avg_temp auto-correction — trust the user's input
|
| 33 |
|
| 34 |
try:
|
| 35 |
-
result = registry_v2.predict(features)
|
| 36 |
except Exception as exc:
|
| 37 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 38 |
|
|
@@ -115,7 +115,7 @@ async def recommend_v2(req: RecommendationRequest):
|
|
| 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)
|
| 119 |
rul = result.get("rul_cycles", 0) or 0
|
| 120 |
candidates.append((rul, t, c, v, result["soh_pct"]))
|
| 121 |
|
|
|
|
| 32 |
# v2 FIX: no avg_temp auto-correction — trust the user's input
|
| 33 |
|
| 34 |
try:
|
| 35 |
+
result = registry_v2.predict(features, req.model_name)
|
| 36 |
except Exception as exc:
|
| 37 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 38 |
|
|
|
|
| 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 |
|
api/routers/predict_v3.py
CHANGED
|
@@ -31,7 +31,7 @@ async def predict_v3(req: PredictRequest):
|
|
| 31 |
features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
|
| 32 |
|
| 33 |
try:
|
| 34 |
-
result = registry_v3.predict(features)
|
| 35 |
except Exception as exc:
|
| 36 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 37 |
|
|
@@ -107,7 +107,7 @@ async def recommend_v3(req: RecommendationRequest):
|
|
| 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)
|
| 111 |
rul = result.get("rul_cycles", 0) or 0
|
| 112 |
candidates.append((rul, t, c, v, result["soh_pct"]))
|
| 113 |
|
|
|
|
| 31 |
features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
|
| 32 |
|
| 33 |
try:
|
| 34 |
+
result = registry_v3.predict(features, req.model_name)
|
| 35 |
except Exception as exc:
|
| 36 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 37 |
|
|
|
|
| 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 |
|
api/schemas.py
CHANGED
|
@@ -25,6 +25,7 @@ class PredictRequest(BaseModel):
|
|
| 25 |
Re: float = Field(default=0.045, description="Electrolyte resistance (Ω) — training range 0.027–0.156")
|
| 26 |
Rct: float = Field(default=0.069, description="Charge transfer resistance (Ω) — training range 0.04–0.27")
|
| 27 |
delta_capacity: float = Field(default=-0.005, description="Capacity change from last cycle (Ah)")
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class PredictResponse(BaseModel):
|
|
@@ -61,6 +62,7 @@ class RecommendationRequest(BaseModel):
|
|
| 61 |
current_soh: float = Field(..., ge=0, le=100)
|
| 62 |
ambient_temperature: float = Field(default=24.0)
|
| 63 |
top_k: int = Field(default=5, ge=1, le=20)
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
class SingleRecommendation(BaseModel):
|
|
|
|
| 25 |
Re: float = Field(default=0.045, description="Electrolyte resistance (Ω) — training range 0.027–0.156")
|
| 26 |
Rct: float = Field(default=0.069, description="Charge transfer resistance (Ω) — training range 0.04–0.27")
|
| 27 |
delta_capacity: float = Field(default=-0.005, description="Capacity change from last cycle (Ah)")
|
| 28 |
+
model_name: Optional[str] = Field(default=None, description="Optional model key override")
|
| 29 |
|
| 30 |
|
| 31 |
class PredictResponse(BaseModel):
|
|
|
|
| 62 |
current_soh: float = Field(..., ge=0, le=100)
|
| 63 |
ambient_temperature: float = Field(default=24.0)
|
| 64 |
top_k: int = Field(default=5, ge=1, le=20)
|
| 65 |
+
model_name: Optional[str] = Field(default=None, description="Optional model key override")
|
| 66 |
|
| 67 |
|
| 68 |
class SingleRecommendation(BaseModel):
|
frontend/src/App.tsx
CHANGED
|
@@ -79,7 +79,7 @@ export default function App() {
|
|
| 79 |
{/* Content */}
|
| 80 |
<main className="max-w-7xl mx-auto px-4 py-6">
|
| 81 |
{activeTab === "simulation" && <SimulationPanel />}
|
| 82 |
-
{activeTab === "predict" && <PredictionForm />}
|
| 83 |
{activeTab === "graphs" && <GraphPanel />}
|
| 84 |
{activeTab === "recommend" && <RecommendationPanel apiVersion={apiVersion} />}
|
| 85 |
{activeTab === "metrics" && <MetricsPanel />}
|
|
|
|
| 79 |
{/* Content */}
|
| 80 |
<main className="max-w-7xl mx-auto px-4 py-6">
|
| 81 |
{activeTab === "simulation" && <SimulationPanel />}
|
| 82 |
+
{activeTab === "predict" && <PredictionForm apiVersion={apiVersion} />}
|
| 83 |
{activeTab === "graphs" && <GraphPanel />}
|
| 84 |
{activeTab === "recommend" && <RecommendationPanel apiVersion={apiVersion} />}
|
| 85 |
{activeTab === "metrics" && <MetricsPanel />}
|
frontend/src/components/PredictionForm.tsx
CHANGED
|
@@ -28,7 +28,11 @@ const familyColour = (family: string) => {
|
|
| 28 |
}
|
| 29 |
};
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const [form, setForm] = useState<PredictRequest>(DEFAULTS);
|
| 33 |
const [models, setModels] = useState<ModelInfo[]>([]);
|
| 34 |
const [result, setResult] = useState<PredictResponse | null>(null);
|
|
@@ -39,15 +43,22 @@ export default function PredictionForm() {
|
|
| 39 |
useEffect(() => {
|
| 40 |
fetchModels()
|
| 41 |
.then((ms) => {
|
| 42 |
-
//
|
| 43 |
-
const sorted = [...ms]
|
| 44 |
-
|
|
|
|
| 45 |
return (b.r2 ?? 0) - (a.r2 ?? 0);
|
| 46 |
-
|
| 47 |
setModels(sorted);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
})
|
| 49 |
.catch(() => {/* silently ignore — model list is optional */});
|
| 50 |
-
}, []);
|
| 51 |
|
| 52 |
const handleChange = (key: keyof PredictRequest, val: string) => {
|
| 53 |
setForm((prev) => ({
|
|
@@ -136,10 +147,9 @@ export default function PredictionForm() {
|
|
| 136 |
>
|
| 137 |
<option value="">— Registry Default —</option>
|
| 138 |
{models.map((m) => (
|
| 139 |
-
<option key={m.name} value={m.name}
|
| 140 |
{m.display_name ?? m.name}
|
| 141 |
{m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
|
| 142 |
-
{!m.loaded ? " [unavailable]" : ""}
|
| 143 |
</option>
|
| 144 |
))}
|
| 145 |
</select>
|
|
|
|
| 28 |
}
|
| 29 |
};
|
| 30 |
|
| 31 |
+
type Props = {
|
| 32 |
+
apiVersion: "v1" | "v2" | "v3";
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
export default function PredictionForm({ apiVersion }: Props) {
|
| 36 |
const [form, setForm] = useState<PredictRequest>(DEFAULTS);
|
| 37 |
const [models, setModels] = useState<ModelInfo[]>([]);
|
| 38 |
const [result, setResult] = useState<PredictResponse | null>(null);
|
|
|
|
| 43 |
useEffect(() => {
|
| 44 |
fetchModels()
|
| 45 |
.then((ms) => {
|
| 46 |
+
// Loaded-only list for stable UX
|
| 47 |
+
const sorted = [...ms]
|
| 48 |
+
.filter((m) => m.loaded)
|
| 49 |
+
.sort((a, b) => {
|
| 50 |
return (b.r2 ?? 0) - (a.r2 ?? 0);
|
| 51 |
+
});
|
| 52 |
setModels(sorted);
|
| 53 |
+
if (sorted.length > 0) {
|
| 54 |
+
setForm((prev) => {
|
| 55 |
+
const keep = prev.model_name && sorted.some((m) => m.name === prev.model_name);
|
| 56 |
+
return { ...prev, model_name: keep ? prev.model_name : null };
|
| 57 |
+
});
|
| 58 |
+
}
|
| 59 |
})
|
| 60 |
.catch(() => {/* silently ignore — model list is optional */});
|
| 61 |
+
}, [apiVersion]);
|
| 62 |
|
| 63 |
const handleChange = (key: keyof PredictRequest, val: string) => {
|
| 64 |
setForm((prev) => ({
|
|
|
|
| 147 |
>
|
| 148 |
<option value="">— Registry Default —</option>
|
| 149 |
{models.map((m) => (
|
| 150 |
+
<option key={m.name} value={m.name}>
|
| 151 |
{m.display_name ?? m.name}
|
| 152 |
{m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
|
|
|
|
| 153 |
</option>
|
| 154 |
))}
|
| 155 |
</select>
|
frontend/src/components/SimulationPanel.tsx
CHANGED
|
@@ -29,7 +29,13 @@ import {
|
|
| 29 |
TableProperties, ScrollText, CheckCircle2, WifiOff,
|
| 30 |
Server, FlaskConical, Copy, Download,
|
| 31 |
} from "lucide-react";
|
| 32 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
import { useToast } from "./Toast";
|
| 34 |
|
| 35 |
// ── Constants ─────────────────────────────────────────────────────────────
|
|
@@ -620,6 +626,7 @@ export default function SimulationPanel() {
|
|
| 620 |
const [isSimulating, setIsSimulating] = useState(false);
|
| 621 |
const [modelUsed, setModelUsed] = useState<string | null>(null);
|
| 622 |
const [selectedModel, setSelectedModel] = useState<string>("best_ensemble");
|
|
|
|
| 623 |
|
| 624 |
// --- Playback ---
|
| 625 |
const [playIndex, setPlayIndex] = useState(0);
|
|
@@ -637,6 +644,26 @@ export default function SimulationPanel() {
|
|
| 637 |
|
| 638 |
const totalSteps = results.length > 0 ? results[0].soh_history.length : 0;
|
| 639 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
// current per-battery snapshot at playIndex
|
| 641 |
const currentSohs = useMemo(() =>
|
| 642 |
results.map((r) => ({
|
|
@@ -1032,15 +1059,15 @@ export default function SimulationPanel() {
|
|
| 1032 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 1033 |
className="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"
|
| 1034 |
>
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
</select>
|
| 1045 |
</div>
|
| 1046 |
|
|
|
|
| 29 |
TableProperties, ScrollText, CheckCircle2, WifiOff,
|
| 30 |
Server, FlaskConical, Copy, Download,
|
| 31 |
} from "lucide-react";
|
| 32 |
+
import {
|
| 33 |
+
simulateBatteries,
|
| 34 |
+
BatterySimConfig,
|
| 35 |
+
BatterySimResult,
|
| 36 |
+
fetchVersionModels,
|
| 37 |
+
VersionModelInfo,
|
| 38 |
+
} from "../api";
|
| 39 |
import { useToast } from "./Toast";
|
| 40 |
|
| 41 |
// ── Constants ─────────────────────────────────────────────────────────────
|
|
|
|
| 626 |
const [isSimulating, setIsSimulating] = useState(false);
|
| 627 |
const [modelUsed, setModelUsed] = useState<string | null>(null);
|
| 628 |
const [selectedModel, setSelectedModel] = useState<string>("best_ensemble");
|
| 629 |
+
const [loadedModels, setLoadedModels] = useState<VersionModelInfo[]>([]);
|
| 630 |
|
| 631 |
// --- Playback ---
|
| 632 |
const [playIndex, setPlayIndex] = useState(0);
|
|
|
|
| 644 |
|
| 645 |
const totalSteps = results.length > 0 ? results[0].soh_history.length : 0;
|
| 646 |
|
| 647 |
+
useEffect(() => {
|
| 648 |
+
const refreshLoadedModels = () => {
|
| 649 |
+
fetchVersionModels("v3")
|
| 650 |
+
.then((rows) => {
|
| 651 |
+
const loaded = rows
|
| 652 |
+
.filter((m) => m.loaded)
|
| 653 |
+
.sort((a, b) => (b.r2 ?? -1) - (a.r2 ?? -1));
|
| 654 |
+
setLoadedModels(loaded);
|
| 655 |
+
if (loaded.length > 0 && !loaded.some((m) => m.name === selectedModel)) {
|
| 656 |
+
setSelectedModel(loaded[0].name);
|
| 657 |
+
}
|
| 658 |
+
})
|
| 659 |
+
.catch(() => setLoadedModels([]));
|
| 660 |
+
};
|
| 661 |
+
|
| 662 |
+
refreshLoadedModels();
|
| 663 |
+
const id = setInterval(refreshLoadedModels, 3000);
|
| 664 |
+
return () => clearInterval(id);
|
| 665 |
+
}, []);
|
| 666 |
+
|
| 667 |
// current per-battery snapshot at playIndex
|
| 668 |
const currentSohs = useMemo(() =>
|
| 669 |
results.map((r) => ({
|
|
|
|
| 1059 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 1060 |
className="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"
|
| 1061 |
>
|
| 1062 |
+
{loadedModels.length === 0 && (
|
| 1063 |
+
<option value={selectedModel}>No loaded models</option>
|
| 1064 |
+
)}
|
| 1065 |
+
{loadedModels.map((m) => (
|
| 1066 |
+
<option key={m.name} value={m.name}>
|
| 1067 |
+
{m.display_name}
|
| 1068 |
+
{m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
|
| 1069 |
+
</option>
|
| 1070 |
+
))}
|
| 1071 |
</select>
|
| 1072 |
</div>
|
| 1073 |
|
frontend/src/components/VersionSelector.tsx
CHANGED
|
@@ -138,6 +138,15 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 138 |
}
|
| 139 |
};
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
// Auto-switch when download completes
|
| 142 |
useEffect(() => {
|
| 143 |
if (busy) {
|
|
@@ -153,8 +162,6 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 153 |
const activeDisplay = versions.find((v) => v.id === activeVersion)?.display
|
| 154 |
?? `v${activeVersion[1]}.0`;
|
| 155 |
|
| 156 |
-
const others = versions.filter((v) => v.id !== activeVersion);
|
| 157 |
-
|
| 158 |
const versionColor = (id: string) => {
|
| 159 |
if (id === "v3") return "bg-green-600 text-white hover:bg-green-500";
|
| 160 |
if (id === "v2") return "bg-blue-600 text-white hover:bg-blue-500";
|
|
@@ -179,7 +186,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 179 |
|
| 180 |
{open && (
|
| 181 |
<div
|
| 182 |
-
className="absolute right-0 top-full mt-2 w-
|
| 183 |
bg-gray-900 border border-gray-700 rounded-xl shadow-2xl z-50
|
| 184 |
overflow-hidden"
|
| 185 |
>
|
|
@@ -187,27 +194,9 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 187 |
Model Versions
|
| 188 |
</div>
|
| 189 |
|
| 190 |
-
{
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
<span className="text-sm font-semibold text-white">{activeDisplay}</span>
|
| 194 |
-
<span className="ml-2 text-xs text-green-400">active</span>
|
| 195 |
-
</div>
|
| 196 |
-
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
| 197 |
-
</div>
|
| 198 |
-
|
| 199 |
-
{/* Other versions */}
|
| 200 |
-
{others.length === 0 && (
|
| 201 |
-
<div className="px-3 py-3 text-xs text-gray-500 text-center">
|
| 202 |
-
No other versions available
|
| 203 |
-
</div>
|
| 204 |
-
)}
|
| 205 |
-
{others.map((v) => {
|
| 206 |
-
const isDownloading = v.status === "downloading" || busy === v.id;
|
| 207 |
-
const isError = v.status === "error";
|
| 208 |
-
const isLoaded = v.loaded && v.model_count > 0;
|
| 209 |
-
const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
|
| 210 |
-
const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
|
| 211 |
const isExpanded = expandedVersion === v.id;
|
| 212 |
const models = versionModels[v.id] ?? [];
|
| 213 |
|
|
@@ -218,7 +207,10 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 218 |
hover:bg-gray-800/60 transition-colors"
|
| 219 |
>
|
| 220 |
<div className="flex-1 min-w-0">
|
| 221 |
-
<span className=
|
|
|
|
|
|
|
|
|
|
| 222 |
{v.features && (
|
| 223 |
<span className="ml-2 text-xs text-gray-500">
|
| 224 |
{v.features}f
|
|
@@ -246,7 +238,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 246 |
</div>
|
| 247 |
|
| 248 |
<div className="flex items-center gap-1 shrink-0 ml-2">
|
| 249 |
-
{isLoaded && (
|
| 250 |
<button
|
| 251 |
onClick={() => handleSwitch(v.id)}
|
| 252 |
className="px-2.5 py-1 rounded text-xs font-medium
|
|
@@ -299,10 +291,10 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 299 |
|
| 300 |
<button
|
| 301 |
onClick={() => handleExpandVersion(v.id)}
|
| 302 |
-
className="
|
| 303 |
-
title="
|
| 304 |
>
|
| 305 |
-
{isExpanded ? "
|
| 306 |
</button>
|
| 307 |
</div>
|
| 308 |
</div>
|
|
@@ -311,7 +303,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 311 |
<div className="px-3 pb-3">
|
| 312 |
<div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
|
| 313 |
<div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
|
| 314 |
-
|
| 315 |
</div>
|
| 316 |
<div className="max-h-44 overflow-auto">
|
| 317 |
{models.length === 0 && (
|
|
@@ -336,7 +328,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 336 |
</div>
|
| 337 |
</div>
|
| 338 |
<div className="flex items-center gap-1">
|
| 339 |
-
{loaded && <span className="text-[11px] text-green-400">
|
| 340 |
{!loaded && canDownload && !downloading && (
|
| 341 |
<button
|
| 342 |
onClick={() => handleLoadModel(v.id, m.name)}
|
|
|
|
| 138 |
}
|
| 139 |
};
|
| 140 |
|
| 141 |
+
const statusForVersion = (v: VersionInfo) => {
|
| 142 |
+
const isDownloading = v.status === "downloading" || busy === v.id;
|
| 143 |
+
const isError = v.status === "error";
|
| 144 |
+
const isLoaded = v.loaded && v.model_count > 0;
|
| 145 |
+
const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
|
| 146 |
+
const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
|
| 147 |
+
return { isDownloading, isError, isLoaded, isOnDisk, isNotDownloaded };
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
// Auto-switch when download completes
|
| 151 |
useEffect(() => {
|
| 152 |
if (busy) {
|
|
|
|
| 162 |
const activeDisplay = versions.find((v) => v.id === activeVersion)?.display
|
| 163 |
?? `v${activeVersion[1]}.0`;
|
| 164 |
|
|
|
|
|
|
|
| 165 |
const versionColor = (id: string) => {
|
| 166 |
if (id === "v3") return "bg-green-600 text-white hover:bg-green-500";
|
| 167 |
if (id === "v2") return "bg-blue-600 text-white hover:bg-blue-500";
|
|
|
|
| 186 |
|
| 187 |
{open && (
|
| 188 |
<div
|
| 189 |
+
className="absolute right-0 top-full mt-2 w-72
|
| 190 |
bg-gray-900 border border-gray-700 rounded-xl shadow-2xl z-50
|
| 191 |
overflow-hidden"
|
| 192 |
>
|
|
|
|
| 194 |
Model Versions
|
| 195 |
</div>
|
| 196 |
|
| 197 |
+
{versions.map((v) => {
|
| 198 |
+
const { isDownloading, isError, isLoaded, isOnDisk, isNotDownloaded } = statusForVersion(v);
|
| 199 |
+
const isActiveVersion = v.id === activeVersion;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
const isExpanded = expandedVersion === v.id;
|
| 201 |
const models = versionModels[v.id] ?? [];
|
| 202 |
|
|
|
|
| 207 |
hover:bg-gray-800/60 transition-colors"
|
| 208 |
>
|
| 209 |
<div className="flex-1 min-w-0">
|
| 210 |
+
<span className={`text-sm font-medium ${isActiveVersion ? "text-white" : "text-gray-200"}`}>{v.display}</span>
|
| 211 |
+
{isActiveVersion && (
|
| 212 |
+
<span className="ml-2 text-xs text-green-400 italic">Active Version</span>
|
| 213 |
+
)}
|
| 214 |
{v.features && (
|
| 215 |
<span className="ml-2 text-xs text-gray-500">
|
| 216 |
{v.features}f
|
|
|
|
| 238 |
</div>
|
| 239 |
|
| 240 |
<div className="flex items-center gap-1 shrink-0 ml-2">
|
| 241 |
+
{isLoaded && !isActiveVersion && (
|
| 242 |
<button
|
| 243 |
onClick={() => handleSwitch(v.id)}
|
| 244 |
className="px-2.5 py-1 rounded text-xs font-medium
|
|
|
|
| 291 |
|
| 292 |
<button
|
| 293 |
onClick={() => handleExpandVersion(v.id)}
|
| 294 |
+
className="p-1.5 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors"
|
| 295 |
+
title="Open model submenu"
|
| 296 |
>
|
| 297 |
+
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
|
| 298 |
</button>
|
| 299 |
</div>
|
| 300 |
</div>
|
|
|
|
| 303 |
<div className="px-3 pb-3">
|
| 304 |
<div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
|
| 305 |
<div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
|
| 306 |
+
{v.display} models
|
| 307 |
</div>
|
| 308 |
<div className="max-h-44 overflow-auto">
|
| 309 |
{models.length === 0 && (
|
|
|
|
| 328 |
</div>
|
| 329 |
</div>
|
| 330 |
<div className="flex items-center gap-1">
|
| 331 |
+
{loaded && <span className="text-[11px] text-green-400 italic font-medium">Active</span>}
|
| 332 |
{!loaded && canDownload && !downloading && (
|
| 333 |
<button
|
| 334 |
onClick={() => handleLoadModel(v.id, m.name)}
|