77ethers commited on
Commit
bc37871
·
verified ·
1 Parent(s): 4b19e21

Upload folder using huggingface_hub

Browse files
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .DS_Store
9
+ 01-environments.md
10
+ 02-deployment.md
11
+ 03-scaling.md
12
+ 04-training.md
13
+ GRIDOPS_SPEC.md
14
+ gemini31.py
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml README.md openenv.yaml ./
6
+ COPY gridops/ gridops/
7
+ COPY server/ server/
8
+ COPY inference.py scripts/ ./
9
+
10
+ RUN pip install --no-cache-dir .
11
+
12
+ EXPOSE 8000
13
+
14
+ ENV WORKERS=1
15
+ ENV MAX_CONCURRENT_ENVS=10
16
+
17
+ CMD ["uvicorn", "gridops.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,10 +1,128 @@
1
- ---
2
- title: Gridops
3
- emoji: 🔥
4
- colorFrom: indigo
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GridOps — Community Microgrid Bridge Operator
2
+
3
+ An OpenEnv reinforcement learning environment where an AI agent operates a **100-home community microgrid** in an Indian city during summer. The agent must balance solar generation, battery storage, diesel backup, and grid trade to keep the lights on while minimizing cost and emissions.
4
+
5
+ ## Why This Matters
6
+
7
+ Community microgrid operation is a real job in India under the RDSS (Revamped Distribution Sector Scheme). IEX prosumer bidding is live. The tension between local energy independence and national grid economics creates genuine multi-day planning challenges that simple heuristics cannot solve.
8
+
9
+ ## Action Space (3 continuous dimensions)
10
+
11
+ | Action | Range | Description |
12
+ |--------|-------|-------------|
13
+ | `battery_dispatch` | -1.0 to +1.0 | Charge (-100 kW) or discharge (+100 kW) the community battery |
14
+ | `diesel_dispatch` | 0.0 to 1.0 | Diesel generator output (0-100 kW). Rs 100 startup cost. |
15
+ | `demand_shedding` | 0.0 to 1.0 | Request residents reduce usage (0-20%). 50% rebounds next hour. |
16
+
17
+ **The grid is NOT an action** — it automatically absorbs the residual (capped at ±200 kW). If demand exceeds all supply sources, that's a blackout.
18
+
19
+ ## Observation Space
20
+
21
+ | Field | Type | Description |
22
+ |-------|------|-------------|
23
+ | `hour` | float | Current hour in episode (0-72) |
24
+ | `demand_kw` | float | Current aggregate demand (kW) |
25
+ | `solar_kw` | float | Current solar generation (kW) |
26
+ | `battery_soc` | float | Battery state-of-charge (0-1) |
27
+ | `grid_price` | float | Current IEX price (Rs/kWh) |
28
+ | `diesel_fuel_remaining` | float | Diesel fuel level (0-1) |
29
+ | `diesel_is_on` | bool | Whether diesel was running last step |
30
+ | `demand_forecast_4h` | list[4] | Noisy demand forecast (±15%) |
31
+ | `solar_forecast_4h` | list[4] | Noisy solar forecast |
32
+ | `price_forecast_4h` | list[4] | Noisy price forecast |
33
+ | `cumulative_blackout_kwh` | float | Total unmet demand |
34
+ | `cumulative_cost` | float | Net cost so far (Rs) |
35
+ | `day_of_episode` | int | Current day (1-3) |
36
+
37
+ ## Episode Structure
38
+
39
+ - **Duration**: 3 days (72 hours, 1-hour steps)
40
+ - **Why 3 days**: Day 1 = learn the cycle. Day 2 = heatwave hits. Day 3 = multi-day planning tested.
41
+ - **Determinism**: Seeded RNG → identical episodes per seed.
42
+
43
+ ## Tasks (Easy → Medium → Hard)
44
+
45
+ | Task | Conditions | Challenge |
46
+ |------|-----------|-----------|
47
+ | **Task 1: Normal Summer** | Clear skies, ~100 kW avg demand, Rs 3-12 prices | Battery arbitrage + evening peak management |
48
+ | **Task 2: Heatwave + Clouds** | Day 2-3 heatwave (+30% demand), intermittent clouds, price spikes Rs 18 | Multi-day battery planning under uncertainty |
49
+ | **Task 3: Extreme Crisis** | Full 3-day heatwave, -30% solar, +50% demand, Rs 8-20, limited diesel | Survival mode — all resources constrained |
50
+
51
+ ## Grading (0.0 - 1.0)
52
+
53
+ ```
54
+ score = 0.50 × cost_efficiency + 0.25 × reliability + 0.25 × green_score
55
+ ```
56
+
57
+ - **Cost efficiency**: How much cheaper than a dumb "max grid import, no intelligence" baseline
58
+ - **Reliability**: Fraction of demand met (blackouts penalized via Rs 150/kWh VoLL)
59
+ - **Green score**: 1 - (diesel_used / total_demand)
60
+
61
+ ## Baseline Scores
62
+
63
+ | Strategy | Task 1 | Task 2 | Task 3 |
64
+ |----------|--------|--------|--------|
65
+ | **Oracle** | 0.81 | 0.82 | 0.77 |
66
+ | Do-Nothing | 0.58 | 0.51 | 0.46 |
67
+ | Always-Discharge | 0.58 | 0.51 | 0.47 |
68
+ | Always-Diesel | 0.42 | 0.42 | 0.45 |
69
+
70
+ ## Key Physics
71
+
72
+ - **Battery**: 500 kWh, 100 kW max, 90% round-trip efficiency, **Rs 2.5/kWh degradation cost**
73
+ - **Diesel**: 100 kW, Rs 25/kWh, **Rs 100 startup cost** (penalizes on-off cycling)
74
+ - **Demand shedding**: Up to 20%, but **50% rebounds next hour** (no free lunch)
75
+ - **VoLL**: Rs 150/kWh blackout penalty (smooth gradient, no hard gate)
76
+
77
+ ## Setup
78
+
79
+ ```bash
80
+ # Install
81
+ pip install -e .
82
+
83
+ # Run server
84
+ uvicorn gridops.server.app:app --port 8000
85
+
86
+ # Dashboard
87
+ open http://localhost:8000/dashboard/
88
+
89
+ # Oracle test
90
+ python scripts/oracle_test.py
91
+
92
+ # Inference
93
+ export API_BASE_URL="https://router.huggingface.co/v1"
94
+ export HF_TOKEN="your-token"
95
+ export MODEL_NAME="meta-llama/Llama-3.3-70B-Instruct"
96
+ python inference.py
97
+ ```
98
+
99
+ ## Docker
100
+
101
+ ```bash
102
+ docker build -t gridops .
103
+ docker run -p 8000:8000 gridops
104
+ ```
105
+
106
+ ## Project Structure
107
+
108
+ ```
109
+ gridops/
110
+ ├── inference.py # LLM baseline (uses OpenAI client)
111
+ ├── openenv.yaml # OpenEnv manifest
112
+ ├── Dockerfile
113
+ ├── server/app.py # Root entry point (OpenEnv validate)
114
+ ├── gridops/
115
+ │ ├── models.py # GridOpsAction, GridOpsObservation (Pydantic)
116
+ │ ├── simulation/
117
+ │ │ ├── physics.py # Energy balance, battery, VoLL, degradation
118
+ │ │ └── scenarios.py # Demand/solar/price curve generators
119
+ │ ├── tasks/
120
+ │ │ ├── definitions.py # 3 task configs
121
+ │ │ └── graders.py # 0-1 grading with cost/reliability/green
122
+ │ └── server/
123
+ │ ├── app.py # FastAPI + OpenEnv create_app
124
+ │ ├── environment.py # OpenEnv Environment class
125
+ │ └── static/index.html # Interactive dashboard
126
+ └── scripts/
127
+ └── oracle_test.py # Oracle validation + determinism check
128
+ ```
gridops/__init__.py ADDED
File without changes
gridops/models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the GridOps Microgrid Environment.
3
+
4
+ Action: 3 continuous controls (battery_dispatch, diesel_dispatch, demand_shedding)
5
+ Grid is the SLACK variable — absorbs the residual up to ±200 kW.
6
+ Observation: partial observation of the microgrid state + forecasts.
7
+ """
8
+
9
+ from typing import List
10
+
11
+ from openenv.core.env_server.types import Action, Observation
12
+ from pydantic import Field
13
+
14
+
15
+ class GridOpsAction(Action):
16
+ """Agent action — three continuous knobs each step.
17
+
18
+ The grid connection is NOT an action. It passively absorbs whatever
19
+ the community needs after solar + battery + diesel - demand, clamped
20
+ to the ±200 kW transformer limit. If the grid can't cover the
21
+ residual, that's a blackout (or curtailment).
22
+ """
23
+
24
+ battery_dispatch: float = Field(
25
+ default=0.0,
26
+ ge=-1.0,
27
+ le=1.0,
28
+ description="Battery: -1 (charge 100 kW) to +1 (discharge 100 kW)",
29
+ )
30
+ diesel_dispatch: float = Field(
31
+ default=0.0,
32
+ ge=0.0,
33
+ le=1.0,
34
+ description="Diesel generator: 0 (off) to 1 (100 kW). Rs 100 startup cost if was off.",
35
+ )
36
+ demand_shedding: float = Field(
37
+ default=0.0,
38
+ ge=0.0,
39
+ le=1.0,
40
+ description="Demand response: 0 (none) to 1 (shed 20%). 50% rebounds next hour.",
41
+ )
42
+
43
+
44
+ class GridOpsObservation(Observation):
45
+ """What the agent sees each hour."""
46
+
47
+ # Current state
48
+ hour: float = Field(default=0.0, description="Hour in episode (0-72)")
49
+ demand_kw: float = Field(default=0.0, description="Current aggregate demand (kW)")
50
+ solar_kw: float = Field(default=0.0, description="Current solar generation (kW)")
51
+ battery_soc: float = Field(default=0.0, description="Battery state-of-charge (0-1)")
52
+ grid_price: float = Field(default=0.0, description="Current IEX price (Rs/kWh)")
53
+ diesel_fuel_remaining: float = Field(default=1.0, description="Diesel fuel level (0-1)")
54
+ diesel_is_on: bool = Field(default=False, description="Whether diesel was running last step")
55
+
56
+ # Noisy 4-hour forecasts
57
+ demand_forecast_4h: List[float] = Field(default_factory=list, description="Demand forecast next 4h")
58
+ solar_forecast_4h: List[float] = Field(default_factory=list, description="Solar forecast next 4h")
59
+ price_forecast_4h: List[float] = Field(default_factory=list, description="Price forecast next 4h")
60
+
61
+ # Cumulative metrics
62
+ cumulative_blackout_kwh: float = Field(default=0.0, description="Total unmet demand (kWh)")
63
+ cumulative_cost: float = Field(default=0.0, description="Net cost so far (Rs)")
64
+ day_of_episode: int = Field(default=1, description="Current day (1-3)")
65
+
66
+ # Step-level feedback
67
+ blackout_this_step: float = Field(default=0.0, description="Blackout kWh this step")
68
+ cost_this_step: float = Field(default=0.0, description="Cost incurred this step (Rs)")
69
+ grid_kw_this_step: float = Field(default=0.0, description="Grid import(+)/export(-) this step")
70
+ narration: str = Field(default="", description="Human-readable situation summary")
gridops/server/__init__.py ADDED
File without changes
gridops/server/app.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the GridOps Microgrid Environment.
3
+
4
+ Uses OpenEnv's create_app for standard /ws, /health, /schema, /web endpoints.
5
+ Adds custom STATEFUL /reset and /step endpoints for the dashboard (HTTP).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel
17
+
18
+ from openenv.core.env_server.http_server import create_app
19
+
20
+ from gridops.models import GridOpsAction, GridOpsObservation
21
+ from gridops.server.environment import GridOpsEnvironment
22
+ from gridops.tasks.definitions import TASKS
23
+
24
+ # Create the OpenEnv app (provides /ws, /health, /schema, /web, /docs)
25
+ app = create_app(
26
+ GridOpsEnvironment,
27
+ GridOpsAction,
28
+ GridOpsObservation,
29
+ env_name="gridops",
30
+ max_concurrent_envs=int(os.environ.get("MAX_CONCURRENT_ENVS", "10")),
31
+ )
32
+
33
+ # ── Shared stateful environment for HTTP dashboard ───────────────────────
34
+ # OpenEnv HTTP /reset and /step are stateless (new env per request).
35
+ # The dashboard needs persistent state between reset → step → step...
36
+ # We maintain a single shared environment instance for HTTP usage.
37
+
38
+ _dashboard_env = GridOpsEnvironment()
39
+
40
+
41
+ class ResetBody(BaseModel):
42
+ seed: int | None = 42
43
+ task_id: str = "task_1_normal"
44
+
45
+
46
+ class StepBody(BaseModel):
47
+ action: dict[str, Any]
48
+
49
+
50
+ @app.post("/api/reset")
51
+ def dashboard_reset(body: ResetBody):
52
+ """Reset the shared dashboard environment."""
53
+ obs = _dashboard_env.reset(seed=body.seed, task_id=body.task_id)
54
+ return {"observation": obs.model_dump()}
55
+
56
+
57
+ @app.post("/api/step")
58
+ def dashboard_step(body: StepBody):
59
+ """Execute one step in the shared dashboard environment."""
60
+ action = GridOpsAction(**body.action)
61
+ obs = _dashboard_env.step(action)
62
+ return {"observation": obs.model_dump()}
63
+
64
+
65
+ @app.get("/api/state")
66
+ def dashboard_state():
67
+ """Get current state of the shared dashboard environment."""
68
+ return _dashboard_env.state.model_dump()
69
+
70
+
71
+ # ── Custom endpoints ─────────────────────────────────────────────────────
72
+
73
+ @app.get("/tasks")
74
+ def list_tasks():
75
+ """List available tasks with their descriptions."""
76
+ return {
77
+ "tasks": [
78
+ {
79
+ "id": "task_1_normal",
80
+ "name": "Normal Summer",
81
+ "difficulty": "Easy",
82
+ "description": "Clear skies, standard demand, Rs 3-12 price range.",
83
+ },
84
+ {
85
+ "id": "task_2_heatwave",
86
+ "name": "Heatwave + Clouds",
87
+ "difficulty": "Medium",
88
+ "description": "Day 2-3 heatwave, +30% demand, price spikes to Rs 18.",
89
+ },
90
+ {
91
+ "id": "task_3_crisis",
92
+ "name": "Extreme Crisis",
93
+ "difficulty": "Hard",
94
+ "description": "Full 3-day heatwave, -30% solar, prices Rs 8-20, limited diesel.",
95
+ },
96
+ ]
97
+ }
98
+
99
+
100
+ # ── Serve dashboard static files ────────────────────────────────────────
101
+
102
+ STATIC_DIR = Path(__file__).parent / "static"
103
+ if STATIC_DIR.exists():
104
+ app.mount("/dashboard", StaticFiles(directory=str(STATIC_DIR), html=True), name="dashboard")
105
+
106
+
107
+ def main(host: str = "0.0.0.0", port: int = 8000):
108
+ import uvicorn
109
+ uvicorn.run(app, host=host, port=port)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ import argparse
114
+ parser = argparse.ArgumentParser()
115
+ parser.add_argument("--port", type=int, default=8000)
116
+ args = parser.parse_args()
117
+ main(port=args.port)
gridops/server/environment.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GridOps OpenEnv Environment — wires physics + scenarios + grading together.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Optional
8
+ from uuid import uuid4
9
+
10
+ import numpy as np
11
+
12
+ from openenv.core.env_server.interfaces import Environment
13
+ from openenv.core.env_server.types import EnvironmentMetadata, State
14
+
15
+ from gridops.models import GridOpsAction, GridOpsObservation
16
+ from gridops.simulation import physics, scenarios
17
+ from gridops.simulation.physics import (
18
+ BATTERY_CAPACITY_KWH,
19
+ DIESEL_TANK_KWH,
20
+ MicrogridState,
21
+ )
22
+ from gridops.simulation.scenarios import ScenarioConfig, make_forecast
23
+ from gridops.tasks.definitions import TASKS
24
+ from gridops.tasks.graders import grade_episode
25
+
26
+
27
+ class GridOpsState(State):
28
+ """Extended state exposed via GET /state."""
29
+
30
+ task_id: str = "task_1_normal"
31
+ hour: int = 0
32
+ done: bool = False
33
+ grade: dict | None = None
34
+ history: list[dict] = []
35
+
36
+
37
+ class GridOpsEnvironment(Environment):
38
+ """Community microgrid RL environment."""
39
+
40
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
41
+
42
+ def __init__(self):
43
+ super().__init__()
44
+ self._task_id = "task_1_normal"
45
+ self._cfg: ScenarioConfig = TASKS[self._task_id]
46
+ self._rng = np.random.default_rng(42)
47
+ self._demand = np.zeros(72)
48
+ self._solar = np.zeros(72)
49
+ self._price = np.zeros(72)
50
+ self._micro = MicrogridState()
51
+ self._episode_id = str(uuid4())
52
+ self._history: list[dict] = []
53
+ self._grade: dict | None = None
54
+
55
+ def reset(
56
+ self,
57
+ seed: Optional[int] = None,
58
+ episode_id: Optional[str] = None,
59
+ **kwargs: Any,
60
+ ) -> GridOpsObservation:
61
+ task_id = kwargs.get("task_id", "task_1_normal")
62
+ if task_id not in TASKS:
63
+ task_id = "task_1_normal"
64
+
65
+ self._task_id = task_id
66
+ self._cfg = TASKS[task_id]
67
+ self._episode_id = episode_id or str(uuid4())
68
+
69
+ s = seed if seed is not None else 42
70
+ self._rng = np.random.default_rng(s)
71
+
72
+ self._demand = scenarios.generate_demand(self._cfg, self._rng)
73
+ self._solar = scenarios.generate_solar(self._cfg, self._rng)
74
+ self._price = scenarios.generate_price(self._cfg, self._rng)
75
+
76
+ self._micro = MicrogridState(
77
+ diesel_fuel_kwh=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
78
+ )
79
+ self._history = []
80
+ self._grade = None
81
+
82
+ return self._make_observation(reward=0.0, done=False, narration="Episode started. Day 1 begins.")
83
+
84
+ def step(
85
+ self,
86
+ action: GridOpsAction,
87
+ timeout_s: Optional[float] = None,
88
+ **kwargs: Any,
89
+ ) -> GridOpsObservation:
90
+ if self._micro.hour >= 72:
91
+ return self._make_observation(
92
+ reward=0.0, done=True,
93
+ narration="Episode already finished.",
94
+ )
95
+
96
+ h = self._micro.hour
97
+ result = physics.step(
98
+ self._micro,
99
+ battery_dispatch_norm=action.battery_dispatch,
100
+ diesel_norm=action.diesel_dispatch,
101
+ shed_norm=action.demand_shedding,
102
+ solar_kw=float(self._solar[h]),
103
+ demand_kw=float(self._demand[h]),
104
+ grid_price=float(self._price[h]),
105
+ diesel_fuel_cap=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
106
+ )
107
+
108
+ self._history.append({
109
+ "hour": h,
110
+ "demand": float(self._demand[h]),
111
+ "solar": float(self._solar[h]),
112
+ "price": float(self._price[h]),
113
+ "battery_soc": self._micro.battery_soc_kwh / BATTERY_CAPACITY_KWH,
114
+ "blackout": result.state.last_blackout_kwh,
115
+ "cost": result.state.last_cost,
116
+ "reward": result.reward,
117
+ "grid_kw": result.state.last_grid_kw,
118
+ "battery_dispatch": action.battery_dispatch,
119
+ "diesel": action.diesel_dispatch,
120
+ "shedding": action.demand_shedding,
121
+ })
122
+
123
+ if result.done:
124
+ self._grade = grade_episode(
125
+ self._micro, self._demand, self._solar, self._price
126
+ )
127
+
128
+ obs = self._make_observation(
129
+ reward=result.reward,
130
+ done=result.done,
131
+ narration=result.narration,
132
+ )
133
+ if result.done and self._grade:
134
+ obs.metadata["grade"] = self._grade
135
+ return obs
136
+
137
+ @property
138
+ def state(self) -> GridOpsState:
139
+ return GridOpsState(
140
+ episode_id=self._episode_id,
141
+ step_count=self._micro.hour,
142
+ task_id=self._task_id,
143
+ hour=self._micro.hour,
144
+ done=self._micro.hour >= 72,
145
+ grade=self._grade,
146
+ history=self._history,
147
+ )
148
+
149
+ def get_metadata(self) -> EnvironmentMetadata:
150
+ return EnvironmentMetadata(
151
+ name="GridOps",
152
+ description="Community microgrid bridge operator — balance solar, battery, diesel, and grid across 3-day episodes.",
153
+ version="0.2.0",
154
+ )
155
+
156
+ def _make_observation(self, reward: float, done: bool, narration: str) -> GridOpsObservation:
157
+ h = min(self._micro.hour, 71)
158
+ rng = self._rng
159
+
160
+ return GridOpsObservation(
161
+ hour=float(self._micro.hour),
162
+ demand_kw=float(self._demand[h]),
163
+ solar_kw=float(self._solar[h]),
164
+ battery_soc=self._micro.battery_soc_kwh / BATTERY_CAPACITY_KWH,
165
+ grid_price=float(self._price[h]),
166
+ diesel_fuel_remaining=self._micro.diesel_fuel_kwh / DIESEL_TANK_KWH,
167
+ diesel_is_on=self._micro.diesel_was_on,
168
+ demand_forecast_4h=make_forecast(self._demand, h, 4, self._cfg.forecast_noise, rng),
169
+ solar_forecast_4h=make_forecast(self._solar, h, 4, self._cfg.forecast_noise, rng),
170
+ price_forecast_4h=make_forecast(self._price, h, 4, self._cfg.forecast_noise, rng),
171
+ cumulative_blackout_kwh=self._micro.cumulative_blackout_kwh,
172
+ cumulative_cost=self._micro.cumulative_cost,
173
+ day_of_episode=(self._micro.hour // 24) + 1,
174
+ blackout_this_step=self._micro.last_blackout_kwh,
175
+ cost_this_step=self._micro.last_cost,
176
+ grid_kw_this_step=self._micro.last_grid_kw,
177
+ narration=narration,
178
+ done=done,
179
+ reward=reward,
180
+ )
gridops/server/static/index.html ADDED
@@ -0,0 +1,847 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GridOps — Microgrid Operator</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ /* ── Reset & Base ─────────────────────────────────────────── */
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+ :root {
12
+ --bg: #0a0e17;
13
+ --panel: #111827;
14
+ --border: #1e293b;
15
+ --cyan: #06b6d4;
16
+ --cyan-dim: #0891b2;
17
+ --red: #ef4444;
18
+ --yellow: #eab308;
19
+ --green: #22c55e;
20
+ --blue: #3b82f6;
21
+ --orange: #f97316;
22
+ --text: #e2e8f0;
23
+ --text-dim: #94a3b8;
24
+ --grid-bg: rgba(6,182,212,0.03);
25
+ }
26
+ body {
27
+ font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ min-height: 100vh;
31
+ background-image:
32
+ linear-gradient(rgba(6,182,212,0.04) 1px, transparent 1px),
33
+ linear-gradient(90deg, rgba(6,182,212,0.04) 1px, transparent 1px);
34
+ background-size: 40px 40px;
35
+ }
36
+
37
+ /* ── Header ───────────────────────────────────────────────── */
38
+ header {
39
+ display: flex; align-items: center; justify-content: space-between;
40
+ padding: 12px 24px;
41
+ border-bottom: 1px solid var(--border);
42
+ background: rgba(17,24,39,0.9);
43
+ backdrop-filter: blur(8px);
44
+ }
45
+ .logo { display: flex; align-items: center; gap: 10px; }
46
+ .logo svg { width: 28px; height: 28px; }
47
+ .logo h1 { font-size: 18px; font-weight: 600; letter-spacing: 1px; }
48
+ .logo h1 span { color: var(--cyan); }
49
+ .header-info { display: flex; gap: 20px; font-size: 12px; color: var(--text-dim); }
50
+ .header-info .tag {
51
+ padding: 3px 10px; border-radius: 4px;
52
+ border: 1px solid var(--border);
53
+ background: rgba(6,182,212,0.08);
54
+ }
55
+ .tag.easy { border-color: var(--green); color: var(--green); }
56
+ .tag.medium { border-color: var(--yellow); color: var(--yellow); }
57
+ .tag.hard { border-color: var(--red); color: var(--red); }
58
+
59
+ /* ── Layout ───────────────────────────────────────────────── */
60
+ .app { display: grid; grid-template-columns: 280px 1fr 260px; gap: 0; height: calc(100vh - 52px); }
61
+
62
+ /* ── Left Panel (Controls) ────────────────────────────────── */
63
+ .panel-left {
64
+ padding: 16px;
65
+ border-right: 1px solid var(--border);
66
+ background: var(--panel);
67
+ display: flex; flex-direction: column; gap: 14px;
68
+ overflow-y: auto;
69
+ }
70
+ .section-title {
71
+ font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
72
+ color: var(--cyan); margin-bottom: 4px;
73
+ }
74
+ .control-group { display: flex; flex-direction: column; gap: 6px; }
75
+ .control-group label {
76
+ font-size: 11px; color: var(--text-dim);
77
+ display: flex; justify-content: space-between;
78
+ }
79
+ .control-group label .val { color: var(--cyan); font-weight: 600; }
80
+ input[type="range"] {
81
+ -webkit-appearance: none; width: 100%; height: 6px;
82
+ background: var(--border); border-radius: 3px; outline: none;
83
+ }
84
+ input[type="range"]::-webkit-slider-thumb {
85
+ -webkit-appearance: none; width: 16px; height: 16px;
86
+ border-radius: 50%; background: var(--cyan); cursor: pointer;
87
+ box-shadow: 0 0 8px rgba(6,182,212,0.5);
88
+ }
89
+ .btn-step {
90
+ width: 100%; padding: 12px; margin-top: 8px;
91
+ background: linear-gradient(135deg, var(--cyan-dim), var(--cyan));
92
+ color: #000; border: none; border-radius: 6px;
93
+ font-family: inherit; font-size: 13px; font-weight: 700;
94
+ cursor: pointer; letter-spacing: 1px;
95
+ transition: all 0.15s;
96
+ }
97
+ .btn-step:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(6,182,212,0.4); }
98
+ .btn-step:active { transform: translateY(0); }
99
+ .btn-step:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
100
+ .btn-reset {
101
+ width: 100%; padding: 8px;
102
+ background: transparent; color: var(--text-dim);
103
+ border: 1px solid var(--border); border-radius: 6px;
104
+ font-family: inherit; font-size: 11px; cursor: pointer;
105
+ }
106
+ .btn-reset:hover { border-color: var(--cyan); color: var(--cyan); }
107
+ select {
108
+ width: 100%; padding: 8px; background: var(--bg); color: var(--text);
109
+ border: 1px solid var(--border); border-radius: 4px;
110
+ font-family: inherit; font-size: 12px;
111
+ }
112
+ .info-row {
113
+ display: flex; justify-content: space-between;
114
+ font-size: 11px; padding: 4px 0;
115
+ border-bottom: 1px solid rgba(30,41,59,0.5);
116
+ }
117
+ .info-row .label { color: var(--text-dim); }
118
+
119
+ /* ── Center Panel (Visualization) ─────────────────────────── */
120
+ .panel-center {
121
+ display: flex; flex-direction: column; gap: 0;
122
+ overflow: hidden;
123
+ }
124
+ .chart-container {
125
+ flex: 1; padding: 12px; min-height: 0;
126
+ position: relative;
127
+ }
128
+ .chart-container canvas { width: 100% !important; height: 100% !important; }
129
+
130
+ /* Energy flow diagram */
131
+ .flow-section {
132
+ height: 180px; padding: 12px 20px;
133
+ border-top: 1px solid var(--border);
134
+ display: flex; align-items: center; justify-content: center;
135
+ position: relative;
136
+ }
137
+ .flow-grid {
138
+ display: grid;
139
+ grid-template-columns: 1fr 1fr 1fr;
140
+ grid-template-rows: auto auto auto;
141
+ gap: 8px; width: 100%; max-width: 600px;
142
+ text-align: center; font-size: 11px;
143
+ }
144
+ .flow-node {
145
+ padding: 10px 6px; border-radius: 6px;
146
+ border: 1px solid var(--border);
147
+ background: rgba(17,24,39,0.8);
148
+ position: relative;
149
+ }
150
+ .flow-node .node-icon { font-size: 20px; display: block; margin-bottom: 2px; }
151
+ .flow-node .node-val { color: var(--cyan); font-weight: 600; font-size: 13px; }
152
+ .flow-node .node-label { color: var(--text-dim); font-size: 9px; text-transform: uppercase; letter-spacing: 1px; }
153
+ .flow-node.solar { border-color: var(--yellow); }
154
+ .flow-node.battery { border-color: var(--blue); }
155
+ .flow-node.grid { border-color: var(--green); }
156
+ .flow-node.diesel { border-color: var(--orange); }
157
+ .flow-node.community { border-color: var(--cyan); background: rgba(6,182,212,0.08); grid-column: 2; }
158
+ .flow-node.blackout { border-color: var(--red); animation: pulse-red 1s infinite; }
159
+ @keyframes pulse-red { 0%,100% { box-shadow: 0 0 0 rgba(239,68,68,0); } 50% { box-shadow: 0 0 16px rgba(239,68,68,0.4); } }
160
+ .flow-arrow { color: var(--text-dim); font-size: 14px; display: flex; align-items: center; justify-content: center; }
161
+
162
+ /* Narration bar */
163
+ .narration {
164
+ padding: 10px 20px; font-size: 12px;
165
+ border-top: 1px solid var(--border);
166
+ background: rgba(6,182,212,0.04);
167
+ color: var(--text-dim);
168
+ min-height: 36px;
169
+ }
170
+ .narration strong { color: var(--cyan); }
171
+
172
+ /* ── Right Panel (Scores) ─────────────────────────────────── */
173
+ .panel-right {
174
+ padding: 16px;
175
+ border-left: 1px solid var(--border);
176
+ background: var(--panel);
177
+ display: flex; flex-direction: column; gap: 12px;
178
+ overflow-y: auto;
179
+ }
180
+ .score-card {
181
+ padding: 12px; border-radius: 6px;
182
+ border: 1px solid var(--border);
183
+ background: rgba(10,14,23,0.6);
184
+ }
185
+ .score-card .sc-label {
186
+ font-size: 9px; text-transform: uppercase; letter-spacing: 2px;
187
+ color: var(--text-dim); margin-bottom: 4px;
188
+ }
189
+ .score-card .sc-value {
190
+ font-size: 24px; font-weight: 700;
191
+ }
192
+ .score-card .sc-bar {
193
+ height: 4px; border-radius: 2px; margin-top: 6px;
194
+ background: var(--border);
195
+ overflow: hidden;
196
+ }
197
+ .score-card .sc-bar-fill {
198
+ height: 100%; border-radius: 2px;
199
+ transition: width 0.3s;
200
+ }
201
+ .sc-value.good { color: var(--green); }
202
+ .sc-value.warn { color: var(--yellow); }
203
+ .sc-value.bad { color: var(--red); }
204
+ .sc-value.cyan { color: var(--cyan); }
205
+
206
+ .grade-box {
207
+ padding: 16px; border-radius: 8px;
208
+ border: 2px solid var(--cyan);
209
+ background: rgba(6,182,212,0.06);
210
+ text-align: center;
211
+ }
212
+ .grade-box .grade-label { font-size: 10px; text-transform: uppercase; letter-spacing: 2px; color: var(--text-dim); }
213
+ .grade-box .grade-value { font-size: 42px; font-weight: 800; color: var(--cyan); margin: 4px 0; }
214
+ .grade-box .grade-sub { font-size: 11px; color: var(--text-dim); }
215
+ .grade-box.hidden { display: none; }
216
+
217
+ /* ── Intro Modal ───────────────────────────────────────────── */
218
+ .intro-overlay {
219
+ position: fixed; inset: 0; z-index: 1000;
220
+ background: rgba(10,14,23,0.92);
221
+ backdrop-filter: blur(12px);
222
+ display: flex; align-items: center; justify-content: center;
223
+ animation: fadeIn 0.4s ease;
224
+ }
225
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
226
+ .intro-overlay.hidden { display: none; }
227
+
228
+ .intro-card {
229
+ max-width: 720px; width: 90%; max-height: 90vh;
230
+ overflow-y: auto;
231
+ padding: 40px 44px;
232
+ border-radius: 16px;
233
+ border: 1px solid var(--cyan);
234
+ background: linear-gradient(160deg, rgba(17,24,39,0.98), rgba(10,14,23,0.99));
235
+ box-shadow: 0 0 60px rgba(6,182,212,0.15), 0 0 120px rgba(6,182,212,0.05);
236
+ animation: cardIn 0.5s ease;
237
+ }
238
+ @keyframes cardIn { from { transform: scale(0.95) translateY(20px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
239
+
240
+ .intro-card .intro-bolt {
241
+ display: flex; align-items: center; gap: 14px;
242
+ margin-bottom: 24px;
243
+ }
244
+ .intro-card .intro-bolt svg { width: 42px; height: 42px; filter: drop-shadow(0 0 12px rgba(6,182,212,0.6)); }
245
+ .intro-card .intro-bolt h1 { font-size: 32px; font-weight: 800; letter-spacing: 2px; }
246
+ .intro-card .intro-bolt h1 span { color: var(--cyan); }
247
+
248
+ .intro-card .tagline {
249
+ font-size: 15px; color: var(--cyan); font-weight: 600;
250
+ margin-bottom: 20px; letter-spacing: 0.5px;
251
+ }
252
+
253
+ .intro-card .story {
254
+ font-size: 14px; line-height: 1.75; color: var(--text);
255
+ margin-bottom: 20px;
256
+ }
257
+ .intro-card .story p { margin-bottom: 12px; }
258
+ .intro-card .story strong { color: var(--cyan); font-weight: 600; }
259
+ .intro-card .story .highlight {
260
+ color: var(--yellow);
261
+ }
262
+ .intro-card .story .danger {
263
+ color: var(--red);
264
+ }
265
+
266
+ .intro-card .the-catch {
267
+ background: rgba(239,68,68,0.08);
268
+ border: 1px solid rgba(239,68,68,0.25);
269
+ border-radius: 8px;
270
+ padding: 14px 18px;
271
+ margin-bottom: 20px;
272
+ font-size: 13px; line-height: 1.7;
273
+ color: var(--text);
274
+ }
275
+ .intro-card .the-catch .catch-title {
276
+ color: var(--red); font-weight: 700; font-size: 12px;
277
+ text-transform: uppercase; letter-spacing: 2px;
278
+ margin-bottom: 6px;
279
+ }
280
+
281
+ .intro-card .controls-preview {
282
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;
283
+ margin-bottom: 22px;
284
+ }
285
+ .intro-card .cp-item {
286
+ background: rgba(6,182,212,0.06);
287
+ border: 1px solid var(--border);
288
+ border-radius: 8px;
289
+ padding: 12px;
290
+ text-align: center;
291
+ }
292
+ .cp-item .cp-icon { font-size: 22px; display: block; margin-bottom: 4px; }
293
+ .cp-item .cp-name { font-size: 11px; font-weight: 700; color: var(--cyan); text-transform: uppercase; letter-spacing: 1px; }
294
+ .cp-item .cp-desc { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
295
+
296
+ .intro-card .scoring {
297
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
298
+ margin-bottom: 24px;
299
+ }
300
+ .intro-card .sc-item {
301
+ text-align: center; padding: 10px;
302
+ border-radius: 6px;
303
+ border: 1px solid var(--border);
304
+ }
305
+ .sc-item .sc-pct { font-size: 22px; font-weight: 800; }
306
+ .sc-item .sc-what { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
307
+
308
+ .intro-card .tasks-preview {
309
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
310
+ margin-bottom: 24px;
311
+ }
312
+ .intro-card .tp-item {
313
+ padding: 10px 12px; border-radius: 6px;
314
+ border: 1px solid var(--border);
315
+ font-size: 11px;
316
+ }
317
+ .tp-item .tp-name { font-weight: 700; margin-bottom: 3px; }
318
+ .tp-item .tp-desc { color: var(--text-dim); font-size: 10px; line-height: 1.5; }
319
+ .tp-easy .tp-name { color: var(--green); }
320
+ .tp-med .tp-name { color: var(--yellow); }
321
+ .tp-hard .tp-name { color: var(--red); }
322
+
323
+ .intro-card .btn-start {
324
+ width: 100%; padding: 16px;
325
+ background: linear-gradient(135deg, var(--cyan-dim), var(--cyan));
326
+ color: #000; border: none; border-radius: 8px;
327
+ font-family: inherit; font-size: 15px; font-weight: 800;
328
+ cursor: pointer; letter-spacing: 2px;
329
+ text-transform: uppercase;
330
+ transition: all 0.2s;
331
+ }
332
+ .btn-start:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(6,182,212,0.4); }
333
+
334
+ .intro-card .shortcut-hint {
335
+ text-align: center; margin-top: 10px;
336
+ font-size: 10px; color: var(--text-dim);
337
+ }
338
+
339
+ /* ── Responsive ───────────────────────────────────────────── */
340
+ @media (max-width: 960px) {
341
+ .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
342
+ .panel-left, .panel-right { flex-direction: row; flex-wrap: wrap; border: none; border-bottom: 1px solid var(--border); }
343
+ .intro-card { padding: 24px; }
344
+ .controls-preview, .scoring, .tasks-preview { grid-template-columns: 1fr; }
345
+ }
346
+ </style>
347
+ </head>
348
+ <body>
349
+
350
+ <!-- ── Intro Overlay ───────────────────────────────────────────── -->
351
+ <div class="intro-overlay" id="introOverlay">
352
+ <div class="intro-card">
353
+ <div class="intro-bolt">
354
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2.5">
355
+ <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
356
+ </svg>
357
+ <h1>GRID<span>OPS</span></h1>
358
+ </div>
359
+
360
+ <div class="tagline">Community Microgrid Bridge Operator</div>
361
+
362
+ <div class="story">
363
+ <p>
364
+ Imagine you're in charge of keeping the lights on for <strong>100 homes</strong> in an Indian city during a brutal summer.
365
+ </p>
366
+ <p>
367
+ You have <span class="highlight">rooftop solar panels</span> (free energy from the sun!),
368
+ a big <strong>community battery</strong> (stores energy for later),
369
+ an expensive <span class="danger">diesel backup generator</span> (costs a fortune),
370
+ and a connection to the <strong>national power grid</strong> (prices change every hour).
371
+ </p>
372
+ <p>
373
+ Every hour, you make 3 decisions: How much power to <strong>buy or sell</strong> from the grid.
374
+ Whether to turn on the <span class="danger">diesel generator</span>.
375
+ And whether to ask residents to <strong>use less power</strong> for a bit.
376
+ </p>
377
+ <p>
378
+ The battery? You don't control it directly. It <strong>automatically absorbs</strong> whatever energy is left over (or fills the gap when there's not enough).
379
+ </p>
380
+ </div>
381
+
382
+ <div class="the-catch">
383
+ <div class="catch-title">The catch</div>
384
+ At <strong>8 PM every evening</strong>, everyone turns on their AC. Demand hits <strong>250 kW</strong>.
385
+ But the grid connection maxes out at <strong>200 kW</strong>. That's a <strong>50 kW gap</strong>.
386
+ Your battery can cover it &mdash; <em>if you charged it during the day</em>.
387
+ If it's empty because you discharged it for profit?
388
+ <span style="color:var(--red);font-weight:700">Blackout. Rs 150/kWh penalty.</span>
389
+ On a heatwave day, demand spikes to <strong>375 kW</strong> and you'll need <em>everything</em>
390
+ &mdash; battery, diesel, shedding &mdash; just to survive.
391
+ </div>
392
+
393
+ <div class="controls-preview">
394
+ <div class="cp-item">
395
+ <span class="cp-icon">&#128267;</span>
396
+ <div class="cp-name">Battery</div>
397
+ <div class="cp-desc">Charge or discharge. -100 to +100 kW. Rs 2.5/kWh degradation.</div>
398
+ </div>
399
+ <div class="cp-item">
400
+ <span class="cp-icon">&#9981;</span>
401
+ <div class="cp-name">Diesel</div>
402
+ <div class="cp-desc">Emergency backup. Rs 25/kWh + Rs 100 startup cost.</div>
403
+ </div>
404
+ <div class="cp-item">
405
+ <span class="cp-icon">&#127968;</span>
406
+ <div class="cp-name">Demand Shed</div>
407
+ <div class="cp-desc">Ask residents to cut usage. Up to 20%. 50% rebounds next hour.</div>
408
+ </div>
409
+ </div>
410
+
411
+ <div class="the-catch" style="border-color: rgba(6,182,212,0.3); background: rgba(6,182,212,0.05); margin-bottom: 20px;">
412
+ <div class="catch-title" style="color: var(--cyan);">The grid is your safety net</div>
413
+ The national grid <strong>automatically</strong> absorbs whatever is left over &mdash;
414
+ importing when you're short, exporting when you have surplus. But it's capped at
415
+ <strong>200 kW</strong>. If your community needs more than that, you'd better have
416
+ battery charge, diesel, or demand shedding ready.
417
+ </div>
418
+
419
+ <div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:2px;margin-bottom:8px;">Your score is graded on</div>
420
+ <div class="scoring">
421
+ <div class="sc-item">
422
+ <div class="sc-pct" style="color:var(--cyan)">60%</div>
423
+ <div class="sc-what">Cost Savings</div>
424
+ </div>
425
+ <div class="sc-item">
426
+ <div class="sc-pct" style="color:var(--green)">20%</div>
427
+ <div class="sc-what">Reliability</div>
428
+ </div>
429
+ <div class="sc-item">
430
+ <div class="sc-pct" style="color:#22d3ee">20%</div>
431
+ <div class="sc-what">Green Score</div>
432
+ </div>
433
+ </div>
434
+
435
+ <div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:2px;margin-bottom:8px;">3 escalating tasks</div>
436
+ <div class="tasks-preview">
437
+ <div class="tp-item tp-easy">
438
+ <div class="tp-name">Task 1: Normal Summer</div>
439
+ <div class="tp-desc">Clear skies. Smooth prices. Learn the daily cycle.</div>
440
+ </div>
441
+ <div class="tp-item tp-med">
442
+ <div class="tp-name">Task 2: Heatwave</div>
443
+ <div class="tp-desc">Day 2 heatwave. +30% demand. Price spikes. Battery decisions from Day 1 matter.</div>
444
+ </div>
445
+ <div class="tp-item tp-hard">
446
+ <div class="tp-name">Task 3: Crisis</div>
447
+ <div class="tp-desc">3-day heatwave. Solar drops 30%. Limited diesel. Survival mode.</div>
448
+ </div>
449
+ </div>
450
+
451
+ <button class="btn-start" id="btnStart">Start Operating</button>
452
+ <div class="shortcut-hint">Press <strong>Space</strong> or <strong>Enter</strong> to advance each hour &middot; <strong>R</strong> to reset</div>
453
+ </div>
454
+ </div>
455
+
456
+ <!-- ── Header ──────────────────────────────────────────────────── -->
457
+ <header>
458
+ <div class="logo">
459
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2">
460
+ <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
461
+ </svg>
462
+ <h1>GRID<span>OPS</span></h1>
463
+ </div>
464
+ <div class="header-info">
465
+ <span id="taskTag" class="tag easy">Task 1: Normal</span>
466
+ <span>Hour <strong id="hourDisp">0</strong>/72</span>
467
+ <span>Day <strong id="dayDisp">1</strong></span>
468
+ </div>
469
+ </header>
470
+
471
+ <div class="app">
472
+ <!-- ── Left Panel ──────────────────────────────────────────── -->
473
+ <div class="panel-left">
474
+ <div>
475
+ <div class="section-title">Task Selection</div>
476
+ <select id="taskSelect">
477
+ <option value="task_1_normal">Task 1 — Normal Summer (Easy)</option>
478
+ <option value="task_2_heatwave">Task 2 — Heatwave + Clouds (Medium)</option>
479
+ <option value="task_3_crisis">Task 3 — Extreme Crisis (Hard)</option>
480
+ </select>
481
+ </div>
482
+
483
+ <div>
484
+ <div class="section-title">Agent Controls</div>
485
+ <div class="control-group">
486
+ <label>Battery <span class="val" id="batteryVal">0 kW</span></label>
487
+ <input type="range" id="batterySlider" min="-100" max="100" value="0" step="1">
488
+ <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim)">
489
+ <span>Charge 100kW</span><span>Discharge 100kW</span>
490
+ </div>
491
+ </div>
492
+ <div class="control-group">
493
+ <label>Diesel Output <span class="val" id="dieselVal">0 kW</span></label>
494
+ <input type="range" id="dieselSlider" min="0" max="100" value="0" step="1">
495
+ <div style="font-size:9px;color:var(--text-dim)">Rs 25/kWh + Rs 100 startup</div>
496
+ </div>
497
+ <div class="control-group">
498
+ <label>Demand Shedding <span class="val" id="shedVal">0%</span></label>
499
+ <input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
500
+ <div style="font-size:9px;color:var(--text-dim)">50% rebounds next hour</div>
501
+ </div>
502
+ </div>
503
+
504
+ <button class="btn-step" id="stepBtn">NEXT HOUR →</button>
505
+ <button class="btn-reset" id="resetBtn">Reset Episode</button>
506
+
507
+ <div>
508
+ <div class="section-title">Current Conditions</div>
509
+ <div class="info-row"><span class="label">Grid Price</span><span id="priceInfo">—</span></div>
510
+ <div class="info-row"><span class="label">Demand</span><span id="demandInfo">—</span></div>
511
+ <div class="info-row"><span class="label">Solar</span><span id="solarInfo">—</span></div>
512
+ <div class="info-row"><span class="label">Battery SOC</span><span id="socInfo">—</span></div>
513
+ <div class="info-row"><span class="label">Diesel Fuel</span><span id="fuelInfo">—</span></div>
514
+ </div>
515
+
516
+ <div>
517
+ <div class="section-title">4-Hour Forecast</div>
518
+ <div class="info-row"><span class="label">Demand</span><span id="fcDemand">—</span></div>
519
+ <div class="info-row"><span class="label">Solar</span><span id="fcSolar">—</span></div>
520
+ <div class="info-row"><span class="label">Price</span><span id="fcPrice">—</span></div>
521
+ </div>
522
+ </div>
523
+
524
+ <!-- ── Center Panel ────────────────────────────────────────── -->
525
+ <div class="panel-center">
526
+ <div class="chart-container">
527
+ <canvas id="mainChart"></canvas>
528
+ </div>
529
+ <div class="flow-section">
530
+ <div class="flow-grid">
531
+ <div class="flow-node solar">
532
+ <span class="node-icon">☀️</span>
533
+ <span class="node-val" id="flowSolar">0</span>
534
+ <span class="node-label">Solar kW</span>
535
+ </div>
536
+ <div class="flow-node community" id="flowCommunity">
537
+ <span class="node-icon">🏘️</span>
538
+ <span class="node-val" id="flowDemand">0</span>
539
+ <span class="node-label">Demand kW</span>
540
+ </div>
541
+ <div class="flow-node grid">
542
+ <span class="node-icon">⚡</span>
543
+ <span class="node-val" id="flowGrid">0</span>
544
+ <span class="node-label">Grid kW</span>
545
+ </div>
546
+ <div class="flow-node battery">
547
+ <span class="node-icon">🔋</span>
548
+ <span class="node-val" id="flowBattery">50%</span>
549
+ <span class="node-label">Battery</span>
550
+ </div>
551
+ <div class="flow-node diesel">
552
+ <span class="node-icon">⛽</span>
553
+ <span class="node-val" id="flowDiesel">0</span>
554
+ <span class="node-label">Diesel kW</span>
555
+ </div>
556
+ <div class="flow-node" id="flowStatus" style="border-color:var(--green)">
557
+ <span class="node-icon" id="statusIcon">✅</span>
558
+ <span class="node-val" id="statusText" style="color:var(--green)">OK</span>
559
+ <span class="node-label">Status</span>
560
+ </div>
561
+ </div>
562
+ </div>
563
+ <div class="narration" id="narration">
564
+ <strong>Ready.</strong> Select a task and click "Next Hour" to begin.
565
+ </div>
566
+ </div>
567
+
568
+ <!-- ── Right Panel ─────────────────────────────────────────── -->
569
+ <div class="panel-right">
570
+ <div class="grade-box hidden" id="gradeBox">
571
+ <div class="grade-label">Episode Score</div>
572
+ <div class="grade-value" id="gradeValue">—</div>
573
+ <div class="grade-sub" id="gradeSub"></div>
574
+ </div>
575
+
576
+ <div class="score-card">
577
+ <div class="sc-label">Reliability</div>
578
+ <div class="sc-value good" id="relValue">100%</div>
579
+ <div class="sc-bar"><div class="sc-bar-fill" id="relBar" style="width:100%;background:var(--green)"></div></div>
580
+ </div>
581
+
582
+ <div class="score-card">
583
+ <div class="sc-label">Net Cost</div>
584
+ <div class="sc-value cyan" id="costValue">Rs 0</div>
585
+ </div>
586
+
587
+ <div class="score-card">
588
+ <div class="sc-label">Diesel Used</div>
589
+ <div class="sc-value" id="dieselUsed" style="color:var(--orange)">0 kWh</div>
590
+ </div>
591
+
592
+ <div class="score-card">
593
+ <div class="sc-label">Battery SOC</div>
594
+ <div class="sc-value cyan" id="socValue">50%</div>
595
+ <div class="sc-bar"><div class="sc-bar-fill" id="socBar" style="width:50%;background:var(--blue)"></div></div>
596
+ </div>
597
+
598
+ <div class="score-card">
599
+ <div class="sc-label">Total Blackout</div>
600
+ <div class="sc-value good" id="blackoutValue">0 kWh</div>
601
+ </div>
602
+
603
+ <div class="score-card">
604
+ <div class="sc-label">Step Reward</div>
605
+ <div class="sc-value cyan" id="rewardValue">—</div>
606
+ </div>
607
+ </div>
608
+ </div>
609
+
610
+ <script>
611
+ // ── Config ──────────────────────────────────────────────────────
612
+ const API = window.location.origin + '/api';
613
+ let obs = null;
614
+ let running = false;
615
+
616
+ // ── Chart setup ─────────────────────────────────────────────────
617
+ const chartData = {
618
+ demand: [], solar: [], price: [], soc: [], hours: [], blackout: []
619
+ };
620
+
621
+ const ctx = document.getElementById('mainChart').getContext('2d');
622
+ const chart = new Chart(ctx, {
623
+ type: 'line',
624
+ data: {
625
+ labels: chartData.hours,
626
+ datasets: [
627
+ { label: 'Demand (kW)', data: chartData.demand, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
628
+ { label: 'Solar (kW)', data: chartData.solar, borderColor: '#eab308', backgroundColor: 'rgba(234,179,8,0.1)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
629
+ { label: 'Price (Rs/kWh)', data: chartData.price, borderColor: '#22c55e', borderDash: [4,2], borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y1' },
630
+ { label: 'Battery SOC', data: chartData.soc, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.15)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, fill: true, yAxisID: 'y2' },
631
+ ]
632
+ },
633
+ options: {
634
+ responsive: true, maintainAspectRatio: false,
635
+ animation: { duration: 150 },
636
+ interaction: { intersect: false, mode: 'index' },
637
+ plugins: {
638
+ legend: { position: 'top', labels: { color: '#94a3b8', font: { size: 10, family: 'monospace' }, boxWidth: 12, padding: 8 } }
639
+ },
640
+ scales: {
641
+ x: { grid: { color: 'rgba(30,41,59,0.5)' }, ticks: { color: '#64748b', font: { size: 9 }, maxTicksLimit: 24 } },
642
+ y: { position: 'left', grid: { color: 'rgba(30,41,59,0.3)' }, ticks: { color: '#94a3b8', font: { size: 9 } }, title: { display: true, text: 'kW', color: '#64748b' }, min: 0 },
643
+ y1: { position: 'right', grid: { display: false }, ticks: { color: '#22c55e', font: { size: 9 } }, title: { display: true, text: 'Rs/kWh', color: '#22c55e' }, min: 0, max: 25 },
644
+ y2: { display: false, min: 0, max: 1 },
645
+ }
646
+ }
647
+ });
648
+
649
+ // ── Slider labels ───────────────────────────────────────────────
650
+ const batterySlider = document.getElementById('batterySlider');
651
+ const dieselSlider = document.getElementById('dieselSlider');
652
+ const shedSlider = document.getElementById('shedSlider');
653
+
654
+ batterySlider.oninput = () => {
655
+ const v = batterySlider.value;
656
+ const kw = (v / 100 * 100).toFixed(0);
657
+ const label = v < 0 ? `Charge ${Math.abs(kw)} kW` : v > 0 ? `Discharge ${kw} kW` : '0 kW';
658
+ document.getElementById('batteryVal').textContent = label;
659
+ };
660
+ dieselSlider.oninput = () => {
661
+ document.getElementById('dieselVal').textContent = (dieselSlider.value / 100 * 100).toFixed(0) + ' kW';
662
+ };
663
+ shedSlider.oninput = () => {
664
+ document.getElementById('shedVal').textContent = (shedSlider.value / 100 * 20).toFixed(0) + '%';
665
+ };
666
+
667
+ // ── API calls ───────────────────────────────────────────────────
668
+ async function apiPost(path, body) {
669
+ const res = await fetch(API + path, {
670
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
671
+ body: JSON.stringify(body)
672
+ });
673
+ return res.json();
674
+ }
675
+
676
+ async function resetEnv() {
677
+ const taskId = document.getElementById('taskSelect').value;
678
+ const body = { seed: 42, task_id: taskId };
679
+ const resp = await apiPost('/reset', body);
680
+ obs = resp.observation || resp;
681
+ chartData.demand.length = 0;
682
+ chartData.solar.length = 0;
683
+ chartData.price.length = 0;
684
+ chartData.soc.length = 0;
685
+ chartData.hours.length = 0;
686
+ chartData.blackout.length = 0;
687
+ chart.update();
688
+ updateUI();
689
+ document.getElementById('gradeBox').classList.add('hidden');
690
+ document.getElementById('stepBtn').disabled = false;
691
+ running = true;
692
+ updateTaskTag();
693
+ }
694
+
695
+ async function stepEnv() {
696
+ if (!running) return;
697
+ const action = {
698
+ battery_dispatch: batterySlider.value / 100,
699
+ diesel_dispatch: dieselSlider.value / 100,
700
+ demand_shedding: shedSlider.value / 100,
701
+ };
702
+ const resp = await apiPost('/step', { action });
703
+ obs = resp.observation || resp;
704
+
705
+ // Update chart
706
+ chartData.hours.push(obs.hour);
707
+ chartData.demand.push(obs.demand_kw);
708
+ chartData.solar.push(obs.solar_kw);
709
+ chartData.price.push(obs.grid_price);
710
+ chartData.soc.push(obs.battery_soc);
711
+ chartData.blackout.push(obs.blackout_this_step || 0);
712
+ chart.update();
713
+
714
+ updateUI();
715
+
716
+ if (obs.done) {
717
+ running = false;
718
+ document.getElementById('stepBtn').disabled = true;
719
+ // Fetch final state for grade
720
+ try {
721
+ const stateResp = await fetch(API + '/state');
722
+ const stateData = await stateResp.json();
723
+ if (stateData.grade) showGrade(stateData.grade);
724
+ else if (obs.metadata && obs.metadata.grade) showGrade(obs.metadata.grade);
725
+ } catch(e) {
726
+ if (obs.metadata && obs.metadata.grade) showGrade(obs.metadata.grade);
727
+ }
728
+ }
729
+ }
730
+
731
+ // ── UI update ───────────────────────────────────────────────────
732
+ function updateUI() {
733
+ if (!obs) return;
734
+ document.getElementById('hourDisp').textContent = Math.floor(obs.hour);
735
+ document.getElementById('dayDisp').textContent = obs.day_of_episode || Math.floor(obs.hour / 24) + 1;
736
+
737
+ // Info panel
738
+ document.getElementById('priceInfo').textContent = 'Rs ' + (obs.grid_price || 0).toFixed(1) + '/kWh';
739
+ document.getElementById('demandInfo').textContent = (obs.demand_kw || 0).toFixed(0) + ' kW';
740
+ document.getElementById('solarInfo').textContent = (obs.solar_kw || 0).toFixed(0) + ' kW';
741
+ document.getElementById('socInfo').textContent = ((obs.battery_soc || 0) * 100).toFixed(0) + '%';
742
+ document.getElementById('fuelInfo').textContent = ((obs.diesel_fuel_remaining || 0) * 100).toFixed(0) + '%';
743
+
744
+ // Forecasts
745
+ const fmt = arr => arr ? arr.map(v => v.toFixed(0)).join(' → ') : '—';
746
+ document.getElementById('fcDemand').textContent = fmt(obs.demand_forecast_4h);
747
+ document.getElementById('fcSolar').textContent = fmt(obs.solar_forecast_4h);
748
+ document.getElementById('fcPrice').textContent = obs.price_forecast_4h ? obs.price_forecast_4h.map(v => v.toFixed(1)).join(' → ') : '—';
749
+
750
+ // Flow diagram
751
+ document.getElementById('flowSolar').textContent = (obs.solar_kw || 0).toFixed(0);
752
+ document.getElementById('flowDemand').textContent = (obs.demand_kw || 0).toFixed(0);
753
+ const gridKw = obs.grid_kw_this_step || 0;
754
+ document.getElementById('flowGrid').textContent = (gridKw >= 0 ? '+' : '') + gridKw.toFixed(0) + ' (slack)';
755
+ document.getElementById('flowBattery').textContent = ((obs.battery_soc || 0) * 100).toFixed(0) + '%';
756
+ document.getElementById('flowDiesel').textContent = (dieselSlider.value / 100 * 100).toFixed(0);
757
+
758
+ // Status
759
+ const blackout = obs.blackout_this_step || 0;
760
+ const statusNode = document.getElementById('flowStatus');
761
+ const statusIcon = document.getElementById('statusIcon');
762
+ const statusText = document.getElementById('statusText');
763
+ if (blackout > 0) {
764
+ statusNode.className = 'flow-node blackout';
765
+ statusIcon.textContent = '🚨';
766
+ statusText.textContent = 'BLACKOUT';
767
+ statusText.style.color = 'var(--red)';
768
+ } else {
769
+ statusNode.className = 'flow-node';
770
+ statusNode.style.borderColor = 'var(--green)';
771
+ statusIcon.textContent = '✅';
772
+ statusText.textContent = 'OK';
773
+ statusText.style.color = 'var(--green)';
774
+ }
775
+
776
+ // Narration
777
+ document.getElementById('narration').innerHTML = '<strong>System:</strong> ' + (obs.narration || '');
778
+
779
+ // Right panel scores
780
+ const totalDemandApprox = Math.max(obs.hour * 300, 1);
781
+ const reliability = totalDemandApprox > 0 ? Math.max(0, 1 - (obs.cumulative_blackout_kwh || 0) / totalDemandApprox) : 1;
782
+ const relPct = (reliability * 100).toFixed(1);
783
+ const relEl = document.getElementById('relValue');
784
+ relEl.textContent = relPct + '%';
785
+ relEl.className = 'sc-value ' + (reliability > 0.95 ? 'good' : reliability > 0.9 ? 'warn' : 'bad');
786
+ document.getElementById('relBar').style.width = relPct + '%';
787
+ document.getElementById('relBar').style.background = reliability > 0.95 ? 'var(--green)' : reliability > 0.9 ? 'var(--yellow)' : 'var(--red)';
788
+
789
+ document.getElementById('costValue').textContent = 'Rs ' + (obs.cumulative_cost || 0).toFixed(0);
790
+ document.getElementById('blackoutValue').textContent = (obs.cumulative_blackout_kwh || 0).toFixed(1) + ' kWh';
791
+ const bvEl = document.getElementById('blackoutValue');
792
+ bvEl.className = 'sc-value ' + ((obs.cumulative_blackout_kwh || 0) < 1 ? 'good' : 'bad');
793
+
794
+ const socPct = ((obs.battery_soc || 0) * 100).toFixed(0);
795
+ document.getElementById('socValue').textContent = socPct + '%';
796
+ document.getElementById('socBar').style.width = socPct + '%';
797
+
798
+ document.getElementById('rewardValue').textContent = (obs.reward != null ? obs.reward.toFixed(3) : '—');
799
+ }
800
+
801
+ function showGrade(g) {
802
+ const box = document.getElementById('gradeBox');
803
+ box.classList.remove('hidden');
804
+ document.getElementById('gradeValue').textContent = g.score.toFixed(3);
805
+ document.getElementById('gradeSub').innerHTML =
806
+ `Reliability: ${(g.reliability*100).toFixed(1)}% · Cost savings: ${(g.cost_savings*100).toFixed(1)}% · Green: ${(g.green_score*100).toFixed(1)}%`;
807
+ }
808
+
809
+ function updateTaskTag() {
810
+ const sel = document.getElementById('taskSelect');
811
+ const tag = document.getElementById('taskTag');
812
+ const i = sel.selectedIndex;
813
+ const labels = ['Task 1: Normal', 'Task 2: Heatwave', 'Task 3: Crisis'];
814
+ const classes = ['easy', 'medium', 'hard'];
815
+ tag.textContent = labels[i];
816
+ tag.className = 'tag ' + classes[i];
817
+ }
818
+
819
+ // ── Event handlers ──────────────────────────────────────────────
820
+ document.getElementById('stepBtn').addEventListener('click', stepEnv);
821
+ document.getElementById('resetBtn').addEventListener('click', resetEnv);
822
+ document.getElementById('taskSelect').addEventListener('change', () => { updateTaskTag(); resetEnv(); });
823
+
824
+ // Keyboard shortcuts (only when intro is dismissed)
825
+ document.addEventListener('keydown', e => {
826
+ if (!document.getElementById('introOverlay').classList.contains('hidden')) return;
827
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); stepEnv(); }
828
+ if (e.key === 'r' && !e.metaKey && !e.ctrlKey) resetEnv();
829
+ });
830
+
831
+ // ── Intro dismiss ───────────────────────────────────────────────
832
+ document.getElementById('btnStart').addEventListener('click', () => {
833
+ document.getElementById('introOverlay').classList.add('hidden');
834
+ resetEnv();
835
+ });
836
+ // Also dismiss on Enter/Space while intro is visible
837
+ document.getElementById('introOverlay').addEventListener('keydown', e => {
838
+ if (e.key === 'Enter' || e.key === ' ') {
839
+ e.preventDefault();
840
+ document.getElementById('introOverlay').classList.add('hidden');
841
+ resetEnv();
842
+ }
843
+ });
844
+ document.getElementById('btnStart').focus();
845
+ </script>
846
+ </body>
847
+ </html>
gridops/simulation/__init__.py ADDED
File without changes
gridops/simulation/physics.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Energy balance engine — the core physics of the microgrid.
3
+
4
+ KEY DESIGN (per Gemini review):
5
+ - Agent controls: battery dispatch, diesel, demand shedding
6
+ - Grid is the SLACK variable (absorbs residual, capped at ±200 kW)
7
+ - VoLL penalty (Rs 150/kWh) replaces hard reliability gate
8
+ - Battery degradation cost (Rs 2.5/kWh throughput)
9
+ - Diesel startup cost (Rs 100 if was off last step)
10
+ - Demand shedding rebound (50% of shed kWh added to next hour)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+
19
+
20
+ # ── Constants ────────────────────────────────────────────────────────────
21
+
22
+ BATTERY_CAPACITY_KWH = 500.0
23
+ BATTERY_MAX_POWER_KW = 100.0
24
+ BATTERY_EFFICIENCY = 0.90 # round-trip (applied as √0.9 each way)
25
+ BATTERY_CHARGE_EFF = 0.949 # √0.90 ≈ 0.949
26
+ BATTERY_DISCHARGE_EFF = 0.949 # agent gets 94.9% of what battery releases
27
+ BATTERY_DEGRADATION_RS = 2.5 # Rs per kWh of throughput (charge or discharge)
28
+ GRID_MAX_KW = 200.0
29
+ DIESEL_MAX_KW = 100.0
30
+ DIESEL_COST_PER_KWH = 25.0
31
+ DIESEL_STARTUP_COST = 100.0 # Rs, one-time when turning on from off
32
+ DEMAND_SHED_MAX_FRAC = 0.20
33
+ SHED_REBOUND_FRAC = 0.50 # 50% of shed energy rebounds next hour
34
+ DIESEL_TANK_KWH = 2400.0 # total fuel capacity
35
+ VOLL = 150.0 # Value of Lost Load (Rs/kWh)
36
+ DT = 1.0 # 1 hour per step
37
+
38
+
39
+ @dataclass
40
+ class MicrogridState:
41
+ """Mutable internal state of the microgrid."""
42
+
43
+ hour: int = 0
44
+ battery_soc_kwh: float = 250.0 # start half-charged
45
+ diesel_fuel_kwh: float = 2400.0
46
+ diesel_was_on: bool = False # for startup cost
47
+ shed_rebound_kwh: float = 0.0 # deferred load from previous shedding
48
+ cumulative_cost: float = 0.0
49
+ cumulative_blackout_kwh: float = 0.0
50
+ cumulative_diesel_kwh: float = 0.0
51
+ cumulative_battery_throughput_kwh: float = 0.0
52
+ total_demand_kwh: float = 0.0
53
+
54
+ # Per-step bookkeeping
55
+ last_blackout_kwh: float = 0.0
56
+ last_cost: float = 0.0
57
+ last_reward: float = 0.0
58
+ last_grid_kw: float = 0.0
59
+
60
+
61
+ @dataclass
62
+ class StepResult:
63
+ """What physics.step() returns to the environment."""
64
+
65
+ state: MicrogridState
66
+ reward: float
67
+ done: bool
68
+ narration: str
69
+
70
+
71
+ def step(
72
+ state: MicrogridState,
73
+ battery_dispatch_norm: float,
74
+ diesel_norm: float,
75
+ shed_norm: float,
76
+ solar_kw: float,
77
+ demand_kw: float,
78
+ grid_price: float,
79
+ diesel_fuel_cap: float = DIESEL_TANK_KWH,
80
+ ) -> StepResult:
81
+ """
82
+ Advance the microgrid by one hour.
83
+
84
+ Actions (agent controls):
85
+ battery_dispatch_norm: -1 (charge 100kW) to +1 (discharge 100kW)
86
+ diesel_norm: 0 (off) to 1 (100kW)
87
+ shed_norm: 0 (none) to 1 (shed 20%)
88
+
89
+ Grid is the SLACK — absorbs residual up to ±200 kW.
90
+ """
91
+ # ── Scale actions ────────────────────────────────────────────────
92
+ battery_cmd_kw = float(np.clip(battery_dispatch_norm, -1, 1)) * BATTERY_MAX_POWER_KW
93
+ diesel_kw = float(np.clip(diesel_norm, 0, 1)) * DIESEL_MAX_KW
94
+ shed_frac = float(np.clip(shed_norm, 0, 1)) * DEMAND_SHED_MAX_FRAC
95
+
96
+ # ── Demand (with shedding rebound from last step) ────────────────
97
+ actual_demand = demand_kw + state.shed_rebound_kwh
98
+ effective_demand = actual_demand * (1.0 - shed_frac)
99
+ shed_kwh = actual_demand * shed_frac * DT
100
+ state.shed_rebound_kwh = shed_kwh * SHED_REBOUND_FRAC # 50% rebounds next hour
101
+
102
+ # ── Diesel fuel constraint ───────────────────────────────────────
103
+ available_diesel_kwh = state.diesel_fuel_kwh
104
+ diesel_kw = min(diesel_kw, available_diesel_kwh / DT)
105
+ diesel_kw = max(0.0, diesel_kw)
106
+
107
+ # ── Battery physics ──────────────────────────────────────────────
108
+ if battery_cmd_kw > 0:
109
+ # Discharge: agent wants power FROM battery
110
+ max_discharge = min(battery_cmd_kw, state.battery_soc_kwh / DT)
111
+ battery_kw = max(0.0, max_discharge)
112
+ delivered_kw = battery_kw * BATTERY_DISCHARGE_EFF
113
+ state.battery_soc_kwh -= battery_kw * DT
114
+ else:
115
+ # Charge: agent wants to push power INTO battery
116
+ charge_cmd = abs(battery_cmd_kw)
117
+ headroom = (BATTERY_CAPACITY_KWH - state.battery_soc_kwh) / BATTERY_CHARGE_EFF
118
+ max_charge = min(charge_cmd, headroom / DT)
119
+ battery_kw = -max(0.0, max_charge) # negative = charging
120
+ delivered_kw = battery_kw # charging consumes power (negative delivery)
121
+ state.battery_soc_kwh += abs(battery_kw) * BATTERY_CHARGE_EFF * DT
122
+
123
+ state.battery_soc_kwh = float(np.clip(state.battery_soc_kwh, 0, BATTERY_CAPACITY_KWH))
124
+ battery_throughput = abs(battery_kw) * DT
125
+ state.cumulative_battery_throughput_kwh += battery_throughput
126
+
127
+ # ── Grid as slack variable ───────────────────────────────────────
128
+ # grid_kw > 0 = import, < 0 = export
129
+ # grid_kw = what the community still needs after solar + battery + diesel
130
+ residual = effective_demand - solar_kw - delivered_kw - diesel_kw
131
+ grid_kw = float(np.clip(residual, -GRID_MAX_KW, GRID_MAX_KW))
132
+
133
+ # ── Blackout detection ───────────────────────────────────────────
134
+ # If residual exceeds grid capacity, we have unmet demand
135
+ blackout_kwh = 0.0
136
+ if residual > GRID_MAX_KW:
137
+ blackout_kwh = (residual - GRID_MAX_KW) * DT
138
+
139
+ # ── Cost accounting ──────────────────────────────────────────────
140
+ step_cost = 0.0
141
+
142
+ # Grid cost (import costs money, export earns revenue)
143
+ if grid_kw > 0:
144
+ step_cost += grid_price * grid_kw * DT
145
+ else:
146
+ step_cost -= grid_price * abs(grid_kw) * DT # revenue
147
+
148
+ # Diesel cost
149
+ step_cost += DIESEL_COST_PER_KWH * diesel_kw * DT
150
+
151
+ # Diesel startup cost
152
+ if diesel_kw > 0 and not state.diesel_was_on:
153
+ step_cost += DIESEL_STARTUP_COST
154
+ state.diesel_was_on = (diesel_kw > 0)
155
+
156
+ # Battery degradation cost
157
+ step_cost += BATTERY_DEGRADATION_RS * battery_throughput
158
+
159
+ # VoLL penalty (replaces hard reliability gate)
160
+ step_cost += VOLL * blackout_kwh
161
+
162
+ # Shedding penalty (small comfort cost — Rs 5/kWh shed)
163
+ step_cost += 5.0 * shed_kwh
164
+
165
+ # ── Fuel accounting ──────────────────────────────────────────────
166
+ state.diesel_fuel_kwh -= diesel_kw * DT
167
+ state.diesel_fuel_kwh = max(0.0, state.diesel_fuel_kwh)
168
+
169
+ # ── Cumulative tracking ──────────────────────────────────────────
170
+ state.cumulative_cost += step_cost
171
+ state.cumulative_blackout_kwh += blackout_kwh
172
+ state.cumulative_diesel_kwh += diesel_kw * DT
173
+ state.total_demand_kwh += effective_demand * DT
174
+ state.last_blackout_kwh = blackout_kwh
175
+ state.last_cost = step_cost
176
+ state.last_grid_kw = grid_kw
177
+
178
+ # ── Per-step reward (negative cost, normalized) ──────────────────
179
+ # Simple: reward = -cost / normalization. Agent minimizes total cost.
180
+ reward = -step_cost / 500.0 # normalize to roughly [-2, 0] range
181
+ state.last_reward = reward
182
+
183
+ # ── Advance clock ────────────────────────────────────────────────
184
+ state.hour += 1
185
+ done = state.hour >= 72
186
+
187
+ # ── Narration ────────────────────────────────────────────────────
188
+ narration = _narrate(state, solar_kw, actual_demand, grid_price, blackout_kwh,
189
+ diesel_kw, shed_frac, grid_kw, delivered_kw)
190
+
191
+ return StepResult(state=state, reward=reward, done=done, narration=narration)
192
+
193
+
194
+ def _narrate(
195
+ s: MicrogridState,
196
+ solar: float,
197
+ demand: float,
198
+ price: float,
199
+ blackout: float,
200
+ diesel: float,
201
+ shed: float,
202
+ grid_kw: float,
203
+ battery_kw: float,
204
+ ) -> str:
205
+ """Generate a short human-readable situation summary."""
206
+ hour_of_day = (s.hour - 1) % 24
207
+ day = (s.hour - 1) // 24 + 1
208
+ soc_pct = s.battery_soc_kwh / BATTERY_CAPACITY_KWH * 100
209
+
210
+ parts = [f"Day {day}, {hour_of_day:02d}:00."]
211
+
212
+ if blackout > 0:
213
+ parts.append(f"BLACKOUT: {blackout:.0f} kWh unmet!")
214
+ elif demand > 200:
215
+ parts.append("Peak demand period.")
216
+ elif solar > 150:
217
+ parts.append("Strong solar generation.")
218
+ elif hour_of_day >= 18:
219
+ parts.append("Evening approaching — solar fading.")
220
+ elif hour_of_day < 6:
221
+ parts.append("Night — low demand, no solar.")
222
+
223
+ if grid_kw > 150:
224
+ parts.append(f"Grid import near limit ({grid_kw:.0f}/{GRID_MAX_KW:.0f} kW).")
225
+ elif grid_kw < -50:
226
+ parts.append(f"Exporting {abs(grid_kw):.0f} kW to grid at Rs {price:.1f}.")
227
+
228
+ if price > 12:
229
+ parts.append(f"Grid price high (Rs {price:.1f}/kWh).")
230
+ elif price < 5:
231
+ parts.append(f"Grid price low (Rs {price:.1f}/kWh).")
232
+
233
+ if soc_pct < 20:
234
+ parts.append(f"Battery low ({soc_pct:.0f}%).")
235
+ elif soc_pct > 80:
236
+ parts.append(f"Battery well-charged ({soc_pct:.0f}%).")
237
+
238
+ if battery_kw > 10:
239
+ parts.append(f"Battery discharging {battery_kw:.0f} kW.")
240
+ elif battery_kw < -10:
241
+ parts.append(f"Battery charging {abs(battery_kw):.0f} kW.")
242
+
243
+ if diesel > 0:
244
+ fuel_pct = s.diesel_fuel_kwh / DIESEL_TANK_KWH * 100
245
+ parts.append(f"Diesel running ({fuel_pct:.0f}% fuel left).")
246
+
247
+ if shed > 0:
248
+ parts.append(f"Demand response active ({shed * 100:.0f}% shed).")
249
+ if s.shed_rebound_kwh > 1:
250
+ parts.append(f"Rebound: +{s.shed_rebound_kwh:.0f} kW next hour.")
251
+
252
+ return " ".join(parts)
gridops/simulation/scenarios.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scenario generators — demand, solar, and price curves for 72-hour episodes.
3
+
4
+ Each generator returns a numpy array of length 72 (one value per hour).
5
+ Scenarios are seeded for deterministic replay.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class ScenarioConfig:
15
+ """Knobs that define a task's difficulty."""
16
+
17
+ demand_multiplier: float = 1.0 # 1.0 = normal, 1.3 = heatwave
18
+ solar_multiplier: float = 1.0 # 1.0 = clear, 0.7 = haze
19
+ price_floor: float = 4.0 # Rs/kWh
20
+ price_ceiling: float = 8.0 # Rs/kWh
21
+ price_spike_hour: int | None = None # hour at which an evening spike occurs
22
+ price_spike_value: float = 15.0 # Rs/kWh at spike
23
+ heatwave_start_hour: int = 24 # when heatwave kicks in (0 = Day 1 start)
24
+ cloud_hours: list[int] | None = None # hours with intermittent clouds
25
+ diesel_fuel_capacity: float = 1.0 # 1.0 = full tank (800 kWh worth)
26
+ forecast_noise: float = 0.15 # ±15 % Gaussian noise on forecasts
27
+
28
+
29
+ # ── Demand ───────────────────────────────────────────────────────────────
30
+
31
+ def _base_demand_curve() -> np.ndarray:
32
+ """24-hour demand template for a 100-home Indian summer community.
33
+
34
+ Realistic Indian residential: ~15-20 kWh/home/day → ~100 kW avg, 250 kW peak.
35
+ Grid (200 kW) covers off-peak easily. Solar creates midday surplus.
36
+ Evening 18-22h is THE bottleneck: demand > grid cap → need battery + diesel.
37
+ """
38
+ hourly = np.array([
39
+ 50, 45, 40, 40, 45, 55, # 0-5 night (deep trough, grid surplus)
40
+ 70, 85, 100, 110, 115, 120, # 6-11 morning ramp (solar kicks in)
41
+ 125, 130, 130, 120, 115, 140, # 12-17 afternoon (solar covers, charge battery)
42
+ 200, 220, 250, 230, 180, 100, # 18-23 evening peak → 250 kW at 20:00
43
+ ], dtype=np.float64)
44
+ return hourly
45
+
46
+
47
+ def generate_demand(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
48
+ """72-hour demand with heatwave multiplier and stochastic noise."""
49
+ base = np.tile(_base_demand_curve(), 3) # 3 days
50
+ demand = base.copy()
51
+
52
+ # Apply heatwave multiplier after start hour
53
+ hw = cfg.heatwave_start_hour
54
+ if cfg.demand_multiplier != 1.0 and hw < 72:
55
+ demand[hw:] *= cfg.demand_multiplier
56
+
57
+ # ±10 % base noise
58
+ noise = 1.0 + rng.normal(0, 0.05, size=72)
59
+ demand *= noise
60
+ return np.clip(demand, 20, 500)
61
+
62
+
63
+ # ── Solar ────────────────────────────────────────────────────────────────
64
+
65
+ def _base_solar_curve() -> np.ndarray:
66
+ """24-hour solar bell curve peaking at noon, 250 kW capacity."""
67
+ hours = np.arange(24)
68
+ solar = np.maximum(0, 250 * np.sin(np.pi * (hours - 6) / 12))
69
+ solar[:6] = 0
70
+ solar[18:] = 0
71
+ return solar
72
+
73
+
74
+ def generate_solar(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
75
+ """72-hour solar with optional haze reduction and cloud dips."""
76
+ base = np.tile(_base_solar_curve(), 3)
77
+ solar = base * cfg.solar_multiplier
78
+
79
+ # Cloud cover — 50 % drop during listed hours
80
+ if cfg.cloud_hours:
81
+ for h in cfg.cloud_hours:
82
+ if 0 <= h < 72:
83
+ solar[h] *= 0.5
84
+
85
+ # Small stochastic variation
86
+ noise = 1.0 + rng.normal(0, 0.03, size=72)
87
+ solar *= noise
88
+ return np.clip(solar, 0, 200)
89
+
90
+
91
+ # ── Grid price ───────────────────────────────────────────────────────────
92
+
93
+ def _base_price_curve(floor: float, ceiling: float) -> np.ndarray:
94
+ """24-hour IEX-like price curve with clear cheap/expensive periods.
95
+
96
+ Night (0-6): near floor (cheap — battery charging window)
97
+ Morning (7-11): moderate
98
+ Afternoon (12-16): moderate-high (solar competes)
99
+ Evening (17-22): near ceiling (peak demand, solar gone — sell window)
100
+ Late night (23): dropping back
101
+ """
102
+ hours = np.arange(24)
103
+ mid = (floor + ceiling) / 2
104
+ amp = (ceiling - floor) / 2
105
+ # Strong evening peak, cheap night
106
+ price = mid + amp * (
107
+ 0.6 * np.sin(np.pi * (hours - 4) / 20) # base daily shape
108
+ + 0.4 * np.exp(-0.5 * ((hours - 20) / 2.5)**2) # evening spike
109
+ - 0.3 * np.exp(-0.5 * ((hours - 3) / 2)**2) # night trough
110
+ )
111
+ return np.clip(price, floor, ceiling)
112
+
113
+
114
+ def generate_price(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
115
+ """72-hour grid price with optional spikes."""
116
+ base = np.tile(_base_price_curve(cfg.price_floor, cfg.price_ceiling), 3)
117
+ price = base.copy()
118
+
119
+ # Evening spike
120
+ if cfg.price_spike_hour is not None:
121
+ spike_h = cfg.price_spike_hour
122
+ # Spread spike over 3 hours centered on spike_h
123
+ for offset in range(-1, 2):
124
+ h = spike_h + offset
125
+ if 0 <= h < 72:
126
+ price[h] = max(price[h], cfg.price_spike_value * (1.0 - 0.2 * abs(offset)))
127
+
128
+ # Small noise
129
+ noise = 1.0 + rng.normal(0, 0.02, size=72)
130
+ price *= noise
131
+ return np.clip(price, 3, 20)
132
+
133
+
134
+ # ── Forecasts ────────────────────────────────────────────────────────────
135
+
136
+ def make_forecast(
137
+ true_values: np.ndarray,
138
+ current_hour: int,
139
+ horizon: int,
140
+ noise_frac: float,
141
+ rng: np.random.Generator,
142
+ ) -> list[float]:
143
+ """Return a noisy forecast for the next `horizon` hours."""
144
+ forecasts = []
145
+ for i in range(1, horizon + 1):
146
+ h = current_hour + i
147
+ if h < len(true_values):
148
+ val = true_values[h]
149
+ else:
150
+ val = true_values[-1]
151
+ noisy = val * (1.0 + rng.normal(0, noise_frac))
152
+ forecasts.append(max(0.0, float(noisy)))
153
+ return forecasts
gridops/tasks/__init__.py ADDED
File without changes
gridops/tasks/definitions.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Three task configurations with escalating difficulty.
3
+
4
+ Task 1: Normal Summer (easy)
5
+ Task 2: Heatwave + Clouds (medium)
6
+ Task 3: Extreme Crisis (hard)
7
+ """
8
+
9
+ from gridops.simulation.scenarios import ScenarioConfig
10
+
11
+ TASK_1_NORMAL = ScenarioConfig(
12
+ demand_multiplier=1.0,
13
+ solar_multiplier=1.0,
14
+ price_floor=3.0,
15
+ price_ceiling=12.0,
16
+ price_spike_hour=None,
17
+ price_spike_value=12.0,
18
+ heatwave_start_hour=72, # no heatwave
19
+ cloud_hours=None,
20
+ diesel_fuel_capacity=1.0,
21
+ forecast_noise=0.15,
22
+ )
23
+
24
+ TASK_2_HEATWAVE = ScenarioConfig(
25
+ demand_multiplier=1.3,
26
+ solar_multiplier=1.0,
27
+ price_floor=5.0,
28
+ price_ceiling=15.0,
29
+ price_spike_hour=44, # Day 2, 20:00 (hour 44)
30
+ price_spike_value=18.0,
31
+ heatwave_start_hour=24, # Day 2 start
32
+ cloud_hours=[30, 31, 36, 37, 54, 55], # intermittent Day 2-3
33
+ diesel_fuel_capacity=1.0,
34
+ forecast_noise=0.15,
35
+ )
36
+
37
+ TASK_3_CRISIS = ScenarioConfig(
38
+ demand_multiplier=1.5,
39
+ solar_multiplier=0.7,
40
+ price_floor=8.0,
41
+ price_ceiling=20.0,
42
+ price_spike_hour=44,
43
+ price_spike_value=20.0,
44
+ heatwave_start_hour=0, # heatwave from the start
45
+ cloud_hours=list(range(8, 16)) + list(range(32, 40)) + list(range(56, 64)),
46
+ diesel_fuel_capacity=0.33, # 800 kWh ≈ 8 hrs at 100 kW
47
+ forecast_noise=0.15,
48
+ )
49
+
50
+ TASKS = {
51
+ "task_1_normal": TASK_1_NORMAL,
52
+ "task_2_heatwave": TASK_2_HEATWAVE,
53
+ "task_3_crisis": TASK_3_CRISIS,
54
+ }
gridops/tasks/graders.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Episode graders — deterministic 0.0-1.0 score at end of episode.
3
+
4
+ Scoring (post-Gemini review):
5
+ - No hard reliability gate. VoLL (Rs 150/kWh) in the physics makes
6
+ blackouts extremely expensive, creating a smooth gradient.
7
+ - Score = how much better than a dumb heuristic baseline.
8
+ - Baseline: "import max grid every hour, no battery/diesel/shedding"
9
+ - score = clip(1 - agent_cost / baseline_cost, 0, 1)
10
+ - Weighted: 50% cost efficiency + 25% reliability + 25% green score
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ from gridops.simulation.physics import (
18
+ BATTERY_CAPACITY_KWH,
19
+ DIESEL_COST_PER_KWH,
20
+ DIESEL_TANK_KWH,
21
+ GRID_MAX_KW,
22
+ VOLL,
23
+ MicrogridState,
24
+ )
25
+
26
+
27
+ def compute_dumb_baseline_cost(
28
+ demand_curve: np.ndarray,
29
+ solar_curve: np.ndarray,
30
+ price_curve: np.ndarray,
31
+ ) -> float:
32
+ """Cost of a dumb baseline: import max grid, no battery/diesel/shedding.
33
+
34
+ Where demand > grid + solar, apply VoLL for the blackout.
35
+ This is a realistic "no-intelligence" baseline.
36
+ """
37
+ total_cost = 0.0
38
+ for h in range(len(demand_curve)):
39
+ demand = demand_curve[h]
40
+ solar = solar_curve[h]
41
+ price = price_curve[h]
42
+
43
+ # Grid covers what it can (up to 200 kW)
44
+ needed_from_grid = max(0.0, demand - solar)
45
+ grid_import = min(needed_from_grid, GRID_MAX_KW)
46
+ total_cost += price * grid_import # grid cost
47
+
48
+ # Any excess demand is a blackout
49
+ unmet = max(0.0, needed_from_grid - GRID_MAX_KW)
50
+ total_cost += VOLL * unmet # VoLL penalty
51
+
52
+ return float(total_cost)
53
+
54
+
55
+ def grade_episode(
56
+ state: MicrogridState,
57
+ demand_curve: np.ndarray,
58
+ solar_curve: np.ndarray,
59
+ price_curve: np.ndarray,
60
+ ) -> dict:
61
+ """
62
+ Grade a completed episode. Returns dict with score 0.0-1.0.
63
+
64
+ score = 0.50 × cost_efficiency + 0.25 × reliability + 0.25 × green_score
65
+ """
66
+ total_demand = max(state.total_demand_kwh, 1.0)
67
+ total_blackout = state.cumulative_blackout_kwh
68
+
69
+ # Reliability: fraction of demand met (0-1)
70
+ reliability = (total_demand - total_blackout) / total_demand
71
+ reliability = float(np.clip(reliability, 0, 1))
72
+
73
+ # Cost efficiency: how much better than dumb baseline
74
+ baseline_cost = compute_dumb_baseline_cost(demand_curve, solar_curve, price_curve)
75
+ actual_cost = state.cumulative_cost
76
+ if baseline_cost > 0:
77
+ cost_efficiency = 1.0 - (actual_cost / baseline_cost)
78
+ else:
79
+ cost_efficiency = 0.0
80
+ cost_efficiency = float(np.clip(cost_efficiency, 0, 1))
81
+
82
+ # Green score: 1 - diesel fraction of total energy
83
+ green_score = 1.0 - (state.cumulative_diesel_kwh / max(total_demand, 1.0))
84
+ green_score = float(np.clip(green_score, 0, 1))
85
+
86
+ # Composite score (smooth, no gate)
87
+ score = 0.50 * cost_efficiency + 0.25 * reliability + 0.25 * green_score
88
+ score = float(np.clip(score, 0, 1))
89
+
90
+ return {
91
+ "score": round(score, 4),
92
+ "reliability": round(reliability, 4),
93
+ "cost_efficiency": round(cost_efficiency, 4),
94
+ "green_score": round(green_score, 4),
95
+ "baseline_cost": round(baseline_cost, 2),
96
+ "actual_cost": round(actual_cost, 2),
97
+ "total_blackout_kwh": round(total_blackout, 2),
98
+ "total_diesel_kwh": round(state.cumulative_diesel_kwh, 2),
99
+ "total_demand_kwh": round(total_demand, 2),
100
+ "battery_throughput_kwh": round(state.cumulative_battery_throughput_kwh, 2),
101
+ }
inference.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference Script — GridOps Microgrid Environment
3
+ ===================================
4
+ MANDATORY
5
+ - Before submitting, ensure the following variables are defined in your environment configuration:
6
+ API_BASE_URL The API endpoint for the LLM.
7
+ MODEL_NAME The model identifier to use for inference.
8
+ HF_TOKEN Your Hugging Face / API key.
9
+
10
+ - The inference script must be named `inference.py` and placed in the root directory of the project
11
+ - Participants must use OpenAI Client for all LLM calls using above variables
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ from openai import OpenAI
19
+
20
+ # ── Env vars (as required by hackathon) ──────────────────────────────────
21
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
22
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
23
+ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
24
+
25
+ # ── Environment import (runs in-process, no server needed) ──────────────
26
+ sys.path.insert(0, os.path.dirname(__file__))
27
+ from gridops.server.environment import GridOpsEnvironment
28
+ from gridops.models import GridOpsAction
29
+
30
+ TASKS = ["task_1_normal", "task_2_heatwave", "task_3_crisis"]
31
+ MAX_STEPS = 72
32
+ TEMPERATURE = 0.1
33
+ MAX_TOKENS = 150
34
+
35
+ SYSTEM_PROMPT = """\
36
+ You are an expert microgrid operator managing a 100-home community in India during summer.
37
+
38
+ You control three actions each hour:
39
+ - battery_dispatch: -1 (charge 100 kW from grid) to +1 (discharge 100 kW to community)
40
+ - diesel_dispatch: 0 (off) to 1 (100 kW). Costs Rs 25/kWh + Rs 100 startup if was off.
41
+ - demand_shedding: 0 (none) to 1 (shed 20% of demand). WARNING: 50% rebounds next hour.
42
+
43
+ The GRID automatically absorbs the residual (capped at ±200 kW).
44
+ If demand exceeds grid + solar + battery + diesel → BLACKOUT (Rs 150/kWh penalty!).
45
+
46
+ Key economics:
47
+ - Grid prices vary Rs 3-20/kWh. Cheap at night, expensive evening.
48
+ - Battery: 500 kWh, 100 kW max, 90% round-trip efficiency, Rs 2.5/kWh degradation.
49
+ - Solar: 250 kW peak (free!), bell curve 6AM-6PM, zero at night.
50
+ - Demand: ~100 kW avg, 250 kW evening peak. Grid cap = 200 kW → need battery for gap.
51
+
52
+ Strategy:
53
+ 1. Night (0-6h): charge battery (cheap grid, low demand)
54
+ 2. Solar (6-15h): surplus charges battery or exports
55
+ 3. Pre-peak (15-17h): ensure battery > 70%
56
+ 4. Evening peak (18-22h): discharge battery to cover gap above grid 200 kW cap
57
+ 5. Diesel: only when battery empty AND peak demand. Avoid startup costs.
58
+
59
+ Respond ONLY with valid JSON: {"battery_dispatch": float, "diesel_dispatch": float, "demand_shedding": float}"""
60
+
61
+
62
+ def format_observation(obs: dict) -> str:
63
+ """Format observation into a readable prompt for the LLM."""
64
+ return (
65
+ f"Hour {obs['hour']:.0f}/72 (Day {obs.get('day_of_episode', '?')})\n"
66
+ f"Demand: {obs['demand_kw']:.0f} kW | Solar: {obs['solar_kw']:.0f} kW\n"
67
+ f"Battery SOC: {obs['battery_soc']*100:.0f}% | Grid Price: Rs {obs['grid_price']:.1f}/kWh\n"
68
+ f"Diesel Fuel: {obs['diesel_fuel_remaining']*100:.0f}% | Diesel On: {obs.get('diesel_is_on', False)}\n"
69
+ f"Grid import last step: {obs.get('grid_kw_this_step', 0):.0f} kW\n"
70
+ f"Forecasts (next 4h):\n"
71
+ f" Demand: {[f'{v:.0f}' for v in obs.get('demand_forecast_4h', [])]}\n"
72
+ f" Solar: {[f'{v:.0f}' for v in obs.get('solar_forecast_4h', [])]}\n"
73
+ f" Price: {[f'{v:.1f}' for v in obs.get('price_forecast_4h', [])]}\n"
74
+ f"Cumulative: blackout={obs['cumulative_blackout_kwh']:.1f} kWh, cost=Rs {obs['cumulative_cost']:.0f}\n"
75
+ f"{obs.get('narration', '')}\n"
76
+ f"\nWhat action? Reply with JSON only."
77
+ )
78
+
79
+
80
+ def parse_action(text: str) -> dict:
81
+ """Extract action JSON from LLM response."""
82
+ text = text.strip()
83
+ for start, end in [("{", "}"), ("```json", "```")]:
84
+ idx = text.find(start)
85
+ if idx >= 0:
86
+ if end == "}":
87
+ eidx = text.rfind("}") + 1
88
+ else:
89
+ eidx = text.find(end, idx + len(start))
90
+ try:
91
+ return json.loads(text[idx:eidx])
92
+ except json.JSONDecodeError:
93
+ continue
94
+ return {"battery_dispatch": 0.0, "diesel_dispatch": 0.0, "demand_shedding": 0.0}
95
+
96
+
97
+ def run_task(client: OpenAI, env: GridOpsEnvironment, task_id: str, seed: int = 42) -> dict:
98
+ """Run one full episode on a task, return grade."""
99
+ obs = env.reset(seed=seed, task_id=task_id)
100
+ obs_dict = obs.model_dump()
101
+
102
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
103
+
104
+ for step_idx in range(MAX_STEPS):
105
+ user_msg = format_observation(obs_dict)
106
+ messages.append({"role": "user", "content": user_msg})
107
+
108
+ # Keep context manageable
109
+ if len(messages) > 21:
110
+ messages = [messages[0]] + messages[-20:]
111
+
112
+ try:
113
+ completion = client.chat.completions.create(
114
+ model=MODEL_NAME,
115
+ messages=messages,
116
+ temperature=TEMPERATURE,
117
+ max_tokens=MAX_TOKENS,
118
+ )
119
+ reply = completion.choices[0].message.content or ""
120
+ except Exception as e:
121
+ print(f" LLM error at step {step_idx}: {e}")
122
+ reply = "{}"
123
+
124
+ messages.append({"role": "assistant", "content": reply})
125
+
126
+ action_dict = parse_action(reply)
127
+ action = GridOpsAction(
128
+ battery_dispatch=float(action_dict.get("battery_dispatch", 0.0)),
129
+ diesel_dispatch=float(action_dict.get("diesel_dispatch", 0.0)),
130
+ demand_shedding=float(action_dict.get("demand_shedding", 0.0)),
131
+ )
132
+ obs = env.step(action)
133
+ obs_dict = obs.model_dump()
134
+
135
+ if step_idx % 24 == 0:
136
+ print(f" Hour {obs_dict['hour']:.0f}: SOC={obs_dict['battery_soc']*100:.0f}% "
137
+ f"cost=Rs {obs_dict['cumulative_cost']:.0f} "
138
+ f"blackout={obs_dict['cumulative_blackout_kwh']:.1f}")
139
+
140
+ if obs_dict.get("done", False):
141
+ break
142
+
143
+ grade = env.state.grade
144
+ return grade
145
+
146
+
147
+ def main():
148
+ print("=" * 60)
149
+ print(" GridOps — LLM Baseline Inference")
150
+ print(f" Model: {MODEL_NAME}")
151
+ print(f" API: {API_BASE_URL}")
152
+ print("=" * 60)
153
+
154
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
155
+ env = GridOpsEnvironment()
156
+
157
+ results = {}
158
+ for task_id in TASKS:
159
+ print(f"\n--- {task_id} ---")
160
+ grade = run_task(client, env, task_id)
161
+ results[task_id] = grade
162
+ if grade:
163
+ print(f" Score: {grade['score']}")
164
+ print(f" Reliability: {grade['reliability']}")
165
+ print(f" Cost Eff: {grade['cost_efficiency']}")
166
+ print(f" Green: {grade['green_score']}")
167
+ print(f" Cost: Rs {grade['actual_cost']:.0f} (baseline Rs {grade['baseline_cost']:.0f})")
168
+
169
+ print("\n" + "=" * 60)
170
+ print(" SUMMARY")
171
+ print("=" * 60)
172
+ for task_id, grade in results.items():
173
+ score = grade["score"] if grade else "ERROR"
174
+ print(f" {task_id}: {score}")
175
+ print("=" * 60)
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
openenv.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: gridops
2
+ description: "Community microgrid bridge operator — balance solar, battery, diesel, and grid trade across 3-day summer episodes in India."
3
+ version: "0.1.0"
4
+
5
+ environment:
6
+ module: gridops.server.app
7
+ class: GridOpsEnvironment
8
+ action: GridOpsAction
9
+ observation: GridOpsObservation
10
+
11
+ server:
12
+ module: gridops.server.app
13
+ app: app
14
+ host: "0.0.0.0"
15
+ port: 8000
16
+ workers: 1
17
+
18
+ tasks:
19
+ - id: task_1_normal
20
+ name: "Normal Summer"
21
+ difficulty: easy
22
+ description: "Clear skies, standard demand curve, smooth IEX prices Rs 4-8."
23
+
24
+ - id: task_2_heatwave
25
+ name: "Heatwave + Clouds"
26
+ difficulty: medium
27
+ description: "Day 2-3 heatwave (+30% demand), intermittent clouds, price spikes to Rs 18."
28
+
29
+ - id: task_3_crisis
30
+ name: "Extreme Crisis"
31
+ difficulty: hard
32
+ description: "Full 3-day heatwave, -30% solar, Rs 8-20 prices, limited diesel fuel."
33
+
34
+ metadata:
35
+ action_space: "3D continuous: grid_trade [-1,1], diesel_dispatch [0,1], demand_shedding [0,1]"
36
+ observation_space: "12 fields: hour, demand, solar, battery_soc, price, fuel, 3×4h forecasts, cumulative metrics"
37
+ episode_length: 72
38
+ step_duration: "1 hour"
39
+ grading: "Reliability-gated composite: 60% cost savings + 20% reliability + 20% green score"
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gridops"
3
+ version = "0.1.0"
4
+ description = "Community Microgrid RL Environment — OpenEnv Hackathon"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "openenv-core>=0.2.0",
9
+ "fastapi>=0.104.0",
10
+ "uvicorn[standard]>=0.24.0",
11
+ "pydantic>=2.0",
12
+ "numpy>=1.24.0",
13
+ "websockets>=12.0",
14
+ "openai>=1.0.0",
15
+ "requests>=2.28.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest", "httpx"]
20
+ inference = ["openai"]
21
+
22
+ [project.scripts]
23
+ server = "server.app:main"
24
+
25
+ [build-system]
26
+ requires = ["setuptools>=68.0"]
27
+ build-backend = "setuptools.build_meta"
scripts/oracle_test.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Oracle strategy test — validates physics + grader + strategy gaps.
3
+
4
+ New action space: battery_dispatch, diesel_dispatch, demand_shedding.
5
+ Grid is the slack variable (absorbs residual up to ±200 kW).
6
+ """
7
+
8
+ import sys
9
+ sys.path.insert(0, ".")
10
+
11
+ import numpy as np
12
+ from gridops.server.environment import GridOpsEnvironment
13
+ from gridops.models import GridOpsAction
14
+ from gridops.tasks.definitions import TASKS
15
+
16
+
17
+ def oracle_policy(obs: dict) -> GridOpsAction:
18
+ """
19
+ Smart oracle: manages battery for arbitrage + evening peak coverage.
20
+
21
+ Strategy:
22
+ - Night (cheap grid): charge battery
23
+ - Solar midday: let solar cover demand, charge battery from surplus
24
+ - Pre-peak (15-17h): top up battery
25
+ - Evening peak (18-22h): discharge battery to reduce expensive grid import
26
+ - Use diesel only when grid is at capacity AND battery is depleted
27
+ - Shed demand only as last resort during extreme peaks
28
+ """
29
+ hour_of_day = int(obs["hour"]) % 24
30
+ soc = obs["battery_soc"]
31
+ price = obs["grid_price"]
32
+ demand = obs["demand_kw"]
33
+ solar = obs["solar_kw"]
34
+ fuel = obs["diesel_fuel_remaining"]
35
+
36
+ battery = 0.0 # -1=charge, +1=discharge
37
+ diesel = 0.0
38
+ shedding = 0.0
39
+
40
+ # Net demand after solar
41
+ net = demand - solar
42
+
43
+ if hour_of_day < 6:
44
+ # Night: cheap power, charge battery aggressively
45
+ if soc < 0.9:
46
+ battery = -0.8 # charge
47
+ else:
48
+ battery = 0.0
49
+
50
+ elif 6 <= hour_of_day < 15:
51
+ # Solar hours: if solar > demand, charge battery from surplus
52
+ if solar > demand:
53
+ # Surplus — charge battery (grid absorbs the rest as export)
54
+ if soc < 0.95:
55
+ battery = -min(1.0, (solar - demand) / 100.0)
56
+ else:
57
+ battery = 0.0 # battery full, surplus exports to grid
58
+ else:
59
+ # Deficit — grid covers it. Charge battery if cheap.
60
+ if soc < 0.7 and price < 6:
61
+ battery = -0.5
62
+ else:
63
+ battery = 0.0
64
+
65
+ elif 15 <= hour_of_day < 18:
66
+ # Pre-peak: ensure battery is charged for evening
67
+ if soc < 0.8:
68
+ battery = -0.8 # charge hard
69
+ else:
70
+ battery = 0.0
71
+
72
+ elif 18 <= hour_of_day < 23:
73
+ # Evening peak: discharge battery to cover demand beyond grid cap
74
+ if net > GRID_MAX_KW and soc > 0.1:
75
+ # Need battery to cover the gap
76
+ gap = net - GRID_MAX_KW
77
+ battery = min(1.0, gap / 100.0)
78
+
79
+ # If battery can't cover full gap, use diesel
80
+ remaining = gap - battery * 100
81
+ if remaining > 0 and fuel > 0.05:
82
+ diesel = min(1.0, remaining / 100.0)
83
+
84
+ # If still short, shed demand
85
+ remaining2 = remaining - diesel * 100
86
+ if remaining2 > 0:
87
+ shedding = min(1.0, remaining2 / (demand * 0.20 + 1))
88
+ elif price > 10 and soc > 0.5:
89
+ # Expensive grid: discharge battery to save money
90
+ battery = min(0.6, (price - 8) / 10.0)
91
+ else:
92
+ battery = 0.0
93
+
94
+ else:
95
+ # Hour 23: low demand, recharge if depleted
96
+ if soc < 0.4:
97
+ battery = -0.5
98
+ else:
99
+ battery = 0.0
100
+
101
+ return GridOpsAction(
102
+ battery_dispatch=float(np.clip(battery, -1, 1)),
103
+ diesel_dispatch=float(np.clip(diesel, 0, 1)),
104
+ demand_shedding=float(np.clip(shedding, 0, 1)),
105
+ )
106
+
107
+
108
+ GRID_MAX_KW = 200.0 # for oracle calculations
109
+
110
+
111
+ def heuristic_do_nothing(obs: dict) -> GridOpsAction:
112
+ """Baseline: do nothing. Grid handles everything as slack."""
113
+ return GridOpsAction(battery_dispatch=0.0, diesel_dispatch=0.0, demand_shedding=0.0)
114
+
115
+
116
+ def heuristic_always_discharge(obs: dict) -> GridOpsAction:
117
+ """Bad: always discharge battery → empty for evening → blackout."""
118
+ return GridOpsAction(battery_dispatch=1.0, diesel_dispatch=0.0, demand_shedding=0.0)
119
+
120
+
121
+ def heuristic_always_diesel(obs: dict) -> GridOpsAction:
122
+ """Wasteful: always run diesel → hemorrhages money at Rs 25/kWh."""
123
+ return GridOpsAction(battery_dispatch=0.0, diesel_dispatch=1.0, demand_shedding=0.0)
124
+
125
+
126
+ def run_episode(env, policy_fn, task_id="task_1_normal", seed=42):
127
+ """Run a full 72-step episode, return grade dict."""
128
+ obs = env.reset(seed=seed, task_id=task_id)
129
+ obs_dict = obs.model_dump()
130
+
131
+ for _ in range(72):
132
+ action = policy_fn(obs_dict)
133
+ obs = env.step(action)
134
+ obs_dict = obs.model_dump()
135
+ if obs.done:
136
+ break
137
+
138
+ state = env.state
139
+ return state.grade
140
+
141
+
142
+ def main():
143
+ env = GridOpsEnvironment()
144
+ policies = {
145
+ "Oracle": oracle_policy,
146
+ "Do-Nothing": heuristic_do_nothing,
147
+ "Always-Discharge": heuristic_always_discharge,
148
+ "Always-Diesel": heuristic_always_diesel,
149
+ }
150
+
151
+ print("=" * 70)
152
+ print(" GridOps Oracle Test v2 — New Action Space (Battery/Diesel/Shed)")
153
+ print(" Grid is slack. VoLL = Rs 150/kWh. Degradation = Rs 2.5/kWh.")
154
+ print("=" * 70)
155
+
156
+ for task_id in TASKS:
157
+ print(f"\n--- {task_id} ---")
158
+ for name, fn in policies.items():
159
+ grade = run_episode(env, fn, task_id)
160
+ if grade:
161
+ print(f" {name:22s} score={grade['score']:.4f} "
162
+ f"reliability={grade['reliability']:.4f} "
163
+ f"cost=Rs {grade['actual_cost']:.0f} "
164
+ f"baseline=Rs {grade['baseline_cost']:.0f}")
165
+ else:
166
+ print(f" {name:22s} NO GRADE")
167
+
168
+ # Determinism check
169
+ print("\n--- Determinism Check (3 runs of Oracle on Task 1) ---")
170
+ scores = []
171
+ for i in range(3):
172
+ grade = run_episode(env, oracle_policy, "task_1_normal", seed=42)
173
+ scores.append(grade["score"])
174
+ print(f" Run {i+1}: score={grade['score']:.4f}")
175
+
176
+ if len(set(f"{s:.6f}" for s in scores)) == 1:
177
+ print(" Deterministic: identical scores across runs")
178
+ else:
179
+ print(" NON-DETERMINISTIC: scores differ!")
180
+
181
+ # Detailed oracle breakdown
182
+ print("\n--- Oracle Detailed Breakdown (Task 1) ---")
183
+ grade = run_episode(env, oracle_policy, "task_1_normal", seed=42)
184
+ for k, v in grade.items():
185
+ print(f" {k}: {v}")
186
+
187
+ print("\n" + "=" * 70)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Root-level server/app.py — required by OpenEnv validate.
3
+ Delegates to gridops.server.app for all functionality.
4
+ """
5
+
6
+ from gridops.server.app import app # noqa: F401
7
+
8
+
9
+ def main(host: str = "0.0.0.0", port: int = 8000):
10
+ import uvicorn
11
+ uvicorn.run("gridops.server.app:app", host=host, port=port)
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff