Upload folder using huggingface_hub
Browse files- .gitignore +14 -0
- Dockerfile +17 -0
- README.md +128 -10
- gridops/__init__.py +0 -0
- gridops/models.py +70 -0
- gridops/server/__init__.py +0 -0
- gridops/server/app.py +117 -0
- gridops/server/environment.py +180 -0
- gridops/server/static/index.html +847 -0
- gridops/simulation/__init__.py +0 -0
- gridops/simulation/physics.py +252 -0
- gridops/simulation/scenarios.py +153 -0
- gridops/tasks/__init__.py +0 -0
- gridops/tasks/definitions.py +54 -0
- gridops/tasks/graders.py +101 -0
- inference.py +179 -0
- openenv.yaml +39 -0
- pyproject.toml +27 -0
- scripts/oracle_test.py +191 -0
- server/__init__.py +0 -0
- server/app.py +15 -0
- uv.lock +0 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 — <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 |
+
— battery, diesel, shedding — just to survive.
|
| 391 |
+
</div>
|
| 392 |
+
|
| 393 |
+
<div class="controls-preview">
|
| 394 |
+
<div class="cp-item">
|
| 395 |
+
<span class="cp-icon">🔋</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">⛽</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">🏠</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 —
|
| 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 · <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
|
|
|