Spaces:
Sleeping
Sleeping
| """ | |
| API routes consumed by the React frontend. | |
| """ | |
| import os | |
| import sys | |
| import glob | |
| import numpy as np | |
| from typing import Optional, List | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel | |
| _ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) | |
| if _ROOT not in sys.path: | |
| sys.path.insert(0, _ROOT) | |
| from agent.config import AgentConfig | |
| from agent.actor_critic import SAC_Agent | |
| from server.env import BESSEnvironment | |
| from openenv.models import ActionModel | |
| from backend.api.hub import sync_hub_models, FILENAME as HUB_FILENAME | |
| router = APIRouter(prefix="/api", tags=["frontend"]) | |
| _DATA_PATH = os.path.join(_ROOT, "data", "pjm_data.csv") | |
| _MODELS_DIR = os.path.join(_ROOT, "train", "models") | |
| def _load_agent(model_name: str, task: str) -> SAC_Agent: | |
| config = AgentConfig() | |
| agent = SAC_Agent(config) | |
| # Path 1: Local .pth files | |
| model_base = os.path.join(_MODELS_DIR, model_name) | |
| if os.path.exists(model_base + "_actor.pth"): | |
| agent.load(model_base) | |
| return agent | |
| # Path 2: Safetensors bundle | |
| bundle_path = os.path.join(_MODELS_DIR, model_name if model_name.endswith(".safetensors") else model_name + ".safetensors") | |
| if os.path.exists(bundle_path): | |
| agent.load_from_bundle(bundle_path, task) | |
| return agent | |
| # Path 3: Master bundle | |
| master_bundle = os.path.join(_MODELS_DIR, HUB_FILENAME) | |
| if os.path.exists(master_bundle): | |
| agent.load_from_bundle(master_bundle, task) | |
| return agent | |
| return agent | |
| # ── Schemas ────────────────────────────────────────────────────────────────── | |
| class RunEpisodeRequest(BaseModel): | |
| task: str = "hard" | |
| seed: int = 42 | |
| model_name: str = "best_model_hard" | |
| max_steps: Optional[int] = 300 | |
| class EvaluateRequest(BaseModel): | |
| task: str = "hard" | |
| model_name: str = "best_model_hard" | |
| num_seeds: int = 10 | |
| seed_start: int = 300 | |
| class EpisodeStep(BaseModel): | |
| step: int | |
| soc: float | |
| lmp: float | |
| action_ea: float | |
| action_fr: float | |
| action_ps: float | |
| action_final: float | |
| r_ea: float | |
| r_fr: float | |
| r_ps: float | |
| reward: float | |
| baseline_load: float | |
| net_load: float | |
| class RunEpisodeResponse(BaseModel): | |
| task: str | |
| model_name: str | |
| seed: int | |
| total_reward: float | |
| steps: List[EpisodeStep] | |
| class ScoreBreakdown(BaseModel): | |
| reward: float | |
| soc_readiness: float | |
| ps_adherence: float | |
| cycle_discipline: float | |
| arb_accuracy: float | |
| consistency: float | |
| overall: float | |
| class EvaluateResponse(BaseModel): | |
| task: str | |
| model_name: str | |
| num_seeds: int | |
| reward_mean: float | |
| reward_std: float | |
| reward_min: float | |
| reward_max: float | |
| soc_at_peak_mean: float | |
| peak_violation_pct: float | |
| avg_cycles_per_ep: float | |
| arb_accuracy_pct: float | |
| avg_fr_score_per_hit: float | |
| scores: ScoreBreakdown | |
| class LLMAnalysisRequest(BaseModel): | |
| evaluation: EvaluateResponse | |
| provider: str = "GEMINI" | |
| model_name: Optional[str] = None | |
| class LLMAnalysisResponse(BaseModel): | |
| available: bool | |
| verdict: Optional[str] = None | |
| score: Optional[float] = None | |
| reward_score: Optional[float] = None | |
| summary: Optional[str] = None | |
| strengths: Optional[List[str]] = None | |
| weaknesses: Optional[List[str]] = None | |
| recommendations: Optional[List[str]] = None | |
| confidence: Optional[str] = None | |
| detailed_analysis: Optional[str] = None | |
| error: Optional[str] = None | |
| # ── Routes ──────────────────────────────────────────────────────────────────── | |
| def health(): | |
| return {"status": "ok", "service": "PowerGrid Backend"} | |
| def list_models(): | |
| # Attempt to sync from Hub first if directory is empty | |
| if not os.path.exists(_MODELS_DIR) or not os.listdir(_MODELS_DIR): | |
| sync_hub_models(_MODELS_DIR) | |
| if not os.path.isdir(_MODELS_DIR): | |
| return {"models": []} | |
| # List .pth models | |
| actor_files = glob.glob(os.path.join(_MODELS_DIR, "*_actor.pth")) | |
| pth_names = {os.path.basename(p).replace("_actor.pth", "") for p in actor_files} | |
| # List .safetensors bundles | |
| sf_files = glob.glob(os.path.join(_MODELS_DIR, "*.safetensors")) | |
| sf_names = {os.path.basename(p) for p in sf_files} | |
| names = sorted(pth_names.union(sf_names)) | |
| return {"models": names} | |
| def list_tasks(): | |
| return { | |
| "tasks": [ | |
| {"id": "easy", "label": "Easy", "description": "Energy Arbitrage only"}, | |
| {"id": "medium", "label": "Medium", "description": "Energy Arbitrage + Frequency Regulation"}, | |
| {"id": "hard", "label": "Hard", "description": "Energy Arbitrage + FR + Peak Shaving"}, | |
| ] | |
| } | |
| def run_episode(req: RunEpisodeRequest): | |
| try: | |
| agent = _load_agent(req.model_name, req.task) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to load agent: {e}") | |
| config = AgentConfig() | |
| env = BESSEnvironment(data_path=_DATA_PATH) | |
| obs = env.reset(seed=req.seed, task=req.task) | |
| state = np.array([obs.hour_of_day, obs.soc, obs.price_lmp, | |
| obs.p_avg, obs.freq_regd, obs.load_mw], dtype=np.float32) | |
| steps: List[EpisodeStep] = [] | |
| total_reward = 0.0 | |
| done = False | |
| step_idx = 0 | |
| max_steps = req.max_steps or env.max_steps | |
| while not done and step_idx < max_steps: | |
| action = np.clip(agent.select_action(state), -config.max_action, config.max_action) | |
| result = env.step(ActionModel(action=action.tolist())) | |
| info = result.info | |
| total_reward += result.reward | |
| steps.append(EpisodeStep( | |
| step=step_idx, | |
| soc=info["soc"], lmp=info["lmp"], | |
| action_ea=info["action_ea"], action_fr=info["action_fr"], | |
| action_ps=info["action_ps"], action_final=info["action_final"], | |
| r_ea=info["r_ea"], r_fr=info["r_fr"], r_ps=info["r_ps"], | |
| reward=result.reward, | |
| baseline_load=info["baseline_load"], net_load=info["net_load"], | |
| )) | |
| o = result.observation | |
| state = np.array([o.hour_of_day, o.soc, o.price_lmp, | |
| o.p_avg, o.freq_regd, o.load_mw], dtype=np.float32) | |
| done = result.terminated or result.truncated | |
| step_idx += 1 | |
| return RunEpisodeResponse(task=req.task, model_name=req.model_name, | |
| seed=req.seed, total_reward=total_reward, steps=steps) | |
| def _compute_scores(results: dict, task: str) -> dict: | |
| ceilings = {"easy": 160000, "medium": 185000, "hard": 190000} | |
| def clamp(val): | |
| return max(0.001, min(0.999, float(val))) | |
| s = { | |
| "reward": clamp(results["reward_mean"] / ceilings[task]), | |
| "soc_readiness": clamp(results["soc_at_peak_mean"] / 0.75), | |
| "ps_adherence": clamp(1.0 - results["peak_violation_pct"] / 20.0), | |
| "cycle_discipline": clamp(1.0 - results["avg_cycles_per_ep"] / 200.0), | |
| "arb_accuracy": clamp((results["arb_accuracy_pct"] - 50.0) / 50.0), | |
| "consistency": clamp(1.0 - (results["reward_std"] / max(abs(results["reward_mean"]), 1)) * 3), | |
| } | |
| weights = { | |
| "easy": {"reward": 0.35, "soc_readiness": 0.25, "ps_adherence": 0.00, "cycle_discipline": 0.15, "arb_accuracy": 0.20, "consistency": 0.05}, | |
| "medium": {"reward": 0.30, "soc_readiness": 0.20, "ps_adherence": 0.00, "cycle_discipline": 0.15, "arb_accuracy": 0.20, "consistency": 0.15}, | |
| "hard": {"reward": 0.25, "soc_readiness": 0.15, "ps_adherence": 0.20, "cycle_discipline": 0.15, "arb_accuracy": 0.15, "consistency": 0.10}, | |
| } | |
| w = weights[task] | |
| s["overall"] = sum(s[k] * w[k] for k in w) | |
| return s | |
| def evaluate(req: EvaluateRequest): | |
| config = AgentConfig() | |
| try: | |
| agent = _load_agent(req.model_name, req.task) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to load agent: {e}") | |
| env = BESSEnvironment(data_path=_DATA_PATH) | |
| seeds = list(range(req.seed_start, req.seed_start + req.num_seeds)) | |
| rewards, soc_peak, violations_pct, cycles, arb_acc, fr_sc = [], [], [], [], [], [] | |
| for seed in seeds: | |
| obs = env.reset(seed=seed, task=req.task) | |
| state = np.array([obs.hour_of_day, obs.soc, obs.price_lmp, | |
| obs.p_avg, obs.freq_regd, obs.load_mw], dtype=np.float32) | |
| done = False | |
| ep_reward = 0.0 | |
| soc_hist, hour_hist = [], [] | |
| viol = total = dir_ok = fr_sum = fr_elig = dir_changes = 0 | |
| prev_soc = None | |
| while not done: | |
| action = np.clip(agent.select_action(state), -config.max_action, config.max_action) | |
| result = env.step(ActionModel(action=action.tolist())) | |
| info = result.info | |
| ep_reward += result.reward | |
| total += 1 | |
| soc = info["soc"]; hour = int(float(state[0])) | |
| soc_hist.append(soc); hour_hist.append(hour) | |
| if info["net_load"] > 20.0: viol += 1 | |
| ps = info["lmp"] - float(state[3]) | |
| af = info["action_final"] | |
| if (ps > 1.0 and af < 0) or (ps < -1.0 and af > 0) or abs(ps) <= 1.0: dir_ok += 1 | |
| if info["r_fr"] > 0: fr_sum += info["r_fr"]; fr_elig += 1 | |
| if prev_soc is not None and prev_soc != soc: | |
| if (soc > prev_soc) != (prev_soc > 0.5): dir_changes += 1 | |
| prev_soc = soc | |
| o = result.observation | |
| state = np.array([o.hour_of_day, o.soc, o.price_lmp, | |
| o.p_avg, o.freq_regd, o.load_mw], dtype=np.float32) | |
| done = result.terminated or result.truncated | |
| rewards.append(ep_reward) | |
| violations_pct.append(viol / total * 100) | |
| pk = [soc_hist[i] for i, h in enumerate(hour_hist) if 16 <= h <= 20] | |
| if pk: soc_peak.append(float(np.mean(pk))) | |
| cycles.append(dir_changes / 2.0) | |
| arb_acc.append(dir_ok / total * 100) | |
| fr_sc.append(fr_sum / max(fr_elig, 1)) | |
| res = { | |
| "reward_mean": float(np.mean(rewards)), | |
| "reward_std": float(np.std(rewards)), | |
| "reward_min": float(np.min(rewards)), | |
| "reward_max": float(np.max(rewards)), | |
| "soc_at_peak_mean": float(np.mean(soc_peak)) if soc_peak else 0.0, | |
| "peak_violation_pct": float(np.mean(violations_pct)), | |
| "avg_cycles_per_ep": float(np.mean(cycles)), | |
| "arb_accuracy_pct": float(np.mean(arb_acc)), | |
| "avg_fr_score_per_hit": float(np.mean(fr_sc)), | |
| } | |
| scores = _compute_scores(res, req.task) | |
| return EvaluateResponse(task=req.task, model_name=req.model_name, | |
| num_seeds=req.num_seeds, **res, | |
| scores=ScoreBreakdown(**scores)) | |
| def llm_analyze(req: LLMAnalysisRequest): | |
| from backend.api.llm_evaluator import get_llm_analysis | |
| result = get_llm_analysis(req.evaluation.model_dump(), provider=req.provider, model_name=req.model_name) | |
| return LLMAnalysisResponse(**result) | |