File size: 11,578 Bytes
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b38f47
75c7554
 
 
 
 
 
 
6b38f47
75c7554
 
6b38f47
 
 
75c7554
 
6b38f47
 
 
 
 
 
 
 
 
 
 
 
 
 
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71a770c
 
75c7554
 
 
 
 
adab34b
 
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
1fac18a
75c7554
 
 
 
6b38f47
 
 
 
75c7554
 
6b38f47
 
75c7554
6b38f47
 
 
 
 
 
 
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b38f47
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a61905
 
 
 
75c7554
9a61905
 
 
 
 
 
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b38f47
75c7554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71a770c
75c7554
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
"""
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 ────────────────────────────────────────────────────────────────────

@router.get("/health")
def health():
    return {"status": "ok", "service": "PowerGrid Backend"}


@router.get("/models")
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}


@router.get("/tasks")
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"},
        ]
    }


@router.post("/run-episode", response_model=RunEpisodeResponse)
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


@router.post("/evaluate", response_model=EvaluateResponse)
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))


@router.post("/llm-analyze", response_model=LLMAnalysisResponse)
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)