Deploy India case study and premium visuals
Browse files- .gitattributes +10 -0
- Dockerfile +2 -0
- README.md +72 -0
- assets/case_study/README.md +39 -0
- assets/case_study/capabl_energy_journey_infographic.svg +136 -0
- assets/case_study/capabl_india_microgrid_hero.webp +3 -0
- assets/case_study/capabl_neighbourhood_dispatch.webp +3 -0
- assets/case_study/capabl_rooftop_infrastructure.webp +3 -0
- assets/case_study/capabl_society_operator.webp +0 -0
- assets/case_study/gridops_control_room.webp +3 -0
- assets/case_study/gridops_environment_loop.webp +3 -0
- assets/case_study/gridops_hero_microgrid.webp +3 -0
- assets/case_study/gridops_impact_split.webp +3 -0
- assets/case_study/india_microgrid_operator_layer.webp +3 -0
- assets/case_study/india_microgrid_value_flows.webp +3 -0
- assets/case_study/india_solar_society_hero.webp +3 -0
- evals/plots/gridops_battery_throughput.png +0 -0
- evals/plots/gridops_blackout_kwh.png +0 -0
- evals/plots/gridops_holdout_scores.png +0 -0
- evals/plots/gridops_holdout_summary.json +89 -0
- evals/plots/gridops_sft_training_curve.png +0 -0
- evals/plots/gridops_sft_training_metrics.json +272 -0
- gridops/policies.py +136 -0
- gridops/prompting.py +127 -0
- gridops/server/app.py +20 -7
- gridops/server/static/case-study.html +733 -0
- gridops/server/static/index.html +7 -0
- inference.py +2 -68
- pyproject.toml +6 -2
- scripts/oracle_test.py +10 -99
.gitattributes
CHANGED
|
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/case_study/capabl_india_microgrid_hero.webp filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/case_study/capabl_neighbourhood_dispatch.webp filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
assets/case_study/capabl_rooftop_infrastructure.webp filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
assets/case_study/gridops_control_room.webp filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
assets/case_study/gridops_environment_loop.webp filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
assets/case_study/gridops_hero_microgrid.webp filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
assets/case_study/gridops_impact_split.webp filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
assets/case_study/india_microgrid_operator_layer.webp filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
assets/case_study/india_microgrid_value_flows.webp filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
assets/case_study/india_solar_society_hero.webp filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -9,6 +9,8 @@ RUN pip install --no-cache-dir numpy pydantic fastapi "uvicorn[standard]" websoc
|
|
| 9 |
# Copy app code
|
| 10 |
COPY gridops/ gridops/
|
| 11 |
COPY server/ server/
|
|
|
|
|
|
|
| 12 |
COPY inference.py openenv.yaml README.md ./
|
| 13 |
COPY scripts/ scripts/
|
| 14 |
|
|
|
|
| 9 |
# Copy app code
|
| 10 |
COPY gridops/ gridops/
|
| 11 |
COPY server/ server/
|
| 12 |
+
COPY assets/ assets/
|
| 13 |
+
COPY evals/ evals/
|
| 14 |
COPY inference.py openenv.yaml README.md ./
|
| 15 |
COPY scripts/ scripts/
|
| 16 |
|
README.md
CHANGED
|
@@ -37,6 +37,60 @@ tags:
|
|
| 37 |
|
| 38 |
---
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
## Why This Environment Exists
|
| 41 |
|
| 42 |
Community microgrid operation is a **real job** in India under the [RDSS](https://rdss.gov.in/) (Revamped Distribution Sector Scheme). IEX prosumer bidding is live. Over 50 million Indian homes will have rooftop solar by 2030, and someone — or some agent — needs to manage the battery-grid-diesel tradeoff in real time.
|
|
@@ -228,6 +282,24 @@ open http://localhost:8000/dashboard/
|
|
| 228 |
# Validate oracle + determinism
|
| 229 |
python scripts/oracle_test.py
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
# Run LLM baseline
|
| 232 |
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 233 |
export HF_TOKEN="your-token"
|
|
|
|
| 37 |
|
| 38 |
---
|
| 39 |
|
| 40 |
+
## SFT Training Pipeline Upgrade
|
| 41 |
+
|
| 42 |
+
This branch adds a CarbonAlpha-style training harness around the original GridOps environment without changing the public OpenEnv API.
|
| 43 |
+
|
| 44 |
+
| Artifact | Link |
|
| 45 |
+
|---|---|
|
| 46 |
+
| Shared prompt/action contract | [`gridops/prompting.py`](gridops/prompting.py) |
|
| 47 |
+
| Reusable oracle + adversarial policies | [`gridops/policies.py`](gridops/policies.py) |
|
| 48 |
+
| 1,200-row curriculum dataset | [`sft_traces/gridops_curriculum_1200.jsonl`](sft_traces/gridops_curriculum_1200.jsonl) |
|
| 49 |
+
| Trace generator | [`scripts/generate_sft_traces.py`](scripts/generate_sft_traces.py) |
|
| 50 |
+
| OpenRouter/DeepSeek trace generator | [`scripts/generate_openrouter_deepseek_traces.py`](scripts/generate_openrouter_deepseek_traces.py) |
|
| 51 |
+
| Trace validator | [`scripts/validate_traces.py`](scripts/validate_traces.py) |
|
| 52 |
+
| Holdout/adversarial evaluator | [`scripts/evaluate_gridops_model.py`](scripts/evaluate_gridops_model.py) |
|
| 53 |
+
| Local adapter evaluator | [`scripts/evaluate_gridops_adapter.py`](scripts/evaluate_gridops_adapter.py) |
|
| 54 |
+
| Guarded SFT script | [`scripts/hf_sft_gridops.py`](scripts/hf_sft_gridops.py) |
|
| 55 |
+
| Eval plotter | [`scripts/plot_gridops_evals.py`](scripts/plot_gridops_evals.py) |
|
| 56 |
+
| Colab-ready notebook | [`notebooks/gridops_sft_pipeline.ipynb`](notebooks/gridops_sft_pipeline.ipynb) |
|
| 57 |
+
| Model card | [`GRIDOPS_MODEL_CARD.md`](GRIDOPS_MODEL_CARD.md) |
|
| 58 |
+
|
| 59 |
+
The first milestone is **SFT only**: teach a compact model to emit valid JSON actions for each hourly observation. The first adapter passed the SFT gate on held-out seeds `7001,7002,7003`.
|
| 60 |
+
|
| 61 |
+
| Model | Avg score | Valid JSON | Task 1 | Task 2 | Task 3 |
|
| 62 |
+
|---|---:|---:|---:|---:|---:|
|
| 63 |
+
| Do-nothing | 0.5133 | 100.00% | 0.5820 | 0.5057 | 0.4522 |
|
| 64 |
+
| GridOps SFT v1 | 0.6854 | 99.85% | 0.6615 | 0.7300 | 0.6648 |
|
| 65 |
+
| Oracle | 0.7688 | 100.00% | 0.7932 | 0.8087 | 0.7046 |
|
| 66 |
+
|
| 67 |
+
| Gate | Target |
|
| 68 |
+
|---|---:|
|
| 69 |
+
| Valid JSON action rate | >= 98% |
|
| 70 |
+
| Average holdout score | >= 0.65 |
|
| 71 |
+
| No task below do-nothing baseline | required |
|
| 72 |
+
| Task 3 crisis score | >= 0.55 |
|
| 73 |
+
| Fixed-seed determinism | stable |
|
| 74 |
+
|
| 75 |
+
Final SFT v1 artifact:
|
| 76 |
+
|
| 77 |
+
```text
|
| 78 |
+
Qwen/Qwen2.5-3B-Instruct -> QLoRA SFT adapter:
|
| 79 |
+
77ethers/gridops-models/sft_qwen25_3b_gridops_mixed1418_v1
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
Evidence:
|
| 83 |
+
|
| 84 |
+
- [SFT training curve](evals/plots/gridops_sft_training_curve.png)
|
| 85 |
+
- [Holdout scores](evals/plots/gridops_holdout_scores.png)
|
| 86 |
+
- [Battery throughput](evals/plots/gridops_battery_throughput.png)
|
| 87 |
+
- [Blackout reduction](evals/plots/gridops_blackout_kwh.png)
|
| 88 |
+
- [Holdout summary JSON](evals/plots/gridops_holdout_summary.json)
|
| 89 |
+
|
| 90 |
+
The existing leaderboard remains historical. The table above is reported separately as **GridOps SFT v1**.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
## Why This Environment Exists
|
| 95 |
|
| 96 |
Community microgrid operation is a **real job** in India under the [RDSS](https://rdss.gov.in/) (Revamped Distribution Sector Scheme). IEX prosumer bidding is live. Over 50 million Indian homes will have rooftop solar by 2030, and someone — or some agent — needs to manage the battery-grid-diesel tradeoff in real time.
|
|
|
|
| 282 |
# Validate oracle + determinism
|
| 283 |
python scripts/oracle_test.py
|
| 284 |
|
| 285 |
+
# Generate and validate the SFT curriculum
|
| 286 |
+
python scripts/generate_sft_traces.py
|
| 287 |
+
python scripts/validate_traces.py sft_traces/gridops_curriculum_1200.jsonl
|
| 288 |
+
|
| 289 |
+
# Optional: generate 10-at-a-time teacher traces with DeepSeek on OpenRouter
|
| 290 |
+
export API_BASE_URL="https://openrouter.ai/api/v1"
|
| 291 |
+
export OPENROUTER_API_KEY="your-token"
|
| 292 |
+
python scripts/generate_openrouter_deepseek_traces.py --model deepseek/deepseek-v4-pro
|
| 293 |
+
|
| 294 |
+
# Evaluate reusable policies on holdout seeds
|
| 295 |
+
python scripts/evaluate_gridops_model.py --policy oracle
|
| 296 |
+
python scripts/evaluate_gridops_model.py --policy do_nothing
|
| 297 |
+
|
| 298 |
+
# Evaluate an API-hosted or HF-router model with the SFT prompt contract
|
| 299 |
+
export HF_API_TOKEN="your-token"
|
| 300 |
+
export MODEL_NAME="your-gridops-sft-endpoint-or-model"
|
| 301 |
+
python scripts/evaluate_gridops_model.py --model-name "$MODEL_NAME"
|
| 302 |
+
|
| 303 |
# Run LLM baseline
|
| 304 |
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 305 |
export HF_TOKEN="your-token"
|
assets/case_study/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GridOps Case Study Visual Assets
|
| 2 |
+
|
| 3 |
+
Generated with the Gemini API for the Capabl Machines GridOps case study.
|
| 4 |
+
The `capabl_*` assets were generated directly with Codex image generation for
|
| 5 |
+
the India-focused case-study page; the earlier `gridops_*` and `india_*` assets
|
| 6 |
+
remain as source/alternate visuals.
|
| 7 |
+
|
| 8 |
+
Source script:
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
set -a; . ./.env; set +a
|
| 12 |
+
.venv/bin/python scripts/generate_case_study_images_gemini.py
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
Official Gemini image-generation docs used:
|
| 16 |
+
|
| 17 |
+
- https://ai.google.dev/gemini-api/docs/image-generation
|
| 18 |
+
- https://ai.google.dev/gemini-api/docs/imagen
|
| 19 |
+
|
| 20 |
+
## Assets
|
| 21 |
+
|
| 22 |
+
Use the `.webp` versions in web pages. Keep the `.png` files as source-quality originals.
|
| 23 |
+
|
| 24 |
+
| Web asset | Source | Purpose |
|
| 25 |
+
|---|---|
|
| 26 |
+
| `gridops_hero_microgrid.webp` | `gridops_hero_microgrid.png` | Case-study hero: Indian community microgrid, solar, battery, grid context. |
|
| 27 |
+
| `gridops_control_room.webp` | `gridops_control_room.png` | Operational expertise visual: engineers monitoring microgrid dispatch. |
|
| 28 |
+
| `gridops_environment_loop.webp` | `gridops_environment_loop.png` | Environment/model/action loop visual for architecture sections. |
|
| 29 |
+
| `gridops_impact_split.webp` | `gridops_impact_split.png` | Impact visual contrasting do-nothing vs trained-model operation. |
|
| 30 |
+
| `india_solar_society_hero.webp` | `india_solar_society_hero.png` | India-context hero: apartment society, rooftop solar, battery, EV charging, and local dispatch. |
|
| 31 |
+
| `india_microgrid_operator_layer.webp` | `india_microgrid_operator_layer.png` | RWA/society operator supported by an AI intelligence layer. |
|
| 32 |
+
| `india_microgrid_value_flows.webp` | `india_microgrid_value_flows.png` | Visual metaphor for savings, reliability, and earning potential from local flexibility. |
|
| 33 |
+
| `capabl_india_microgrid_hero.webp` | `capabl_india_microgrid_hero.png` | Premium hero: blue-hour Indian society microgrid with rooftop solar, storage, EV charging, and text-safe negative space. |
|
| 34 |
+
| `capabl_rooftop_infrastructure.webp` | `capabl_rooftop_infrastructure.png` | Rooftop/courtyard infrastructure view showing solar, battery, transformer, and EV charging. |
|
| 35 |
+
| `capabl_society_operator.webp` | `capabl_society_operator.png` | Society manager and solar installer using a practical intelligence layer. |
|
| 36 |
+
| `capabl_neighbourhood_dispatch.webp` | `capabl_neighbourhood_dispatch.png` | Neighbourhood microgrid dispatch under evening grid stress. |
|
| 37 |
+
| `capabl_energy_journey_infographic.svg` | n/a | Deterministic horizontal infographic showing the journey from India's solar scale to local microgrid intelligence and community outcomes. |
|
| 38 |
+
|
| 39 |
+
These are editorial visuals for storytelling. For exact metrics and evidence, use the committed eval plots in `evals/plots/`.
|
assets/case_study/capabl_energy_journey_infographic.svg
ADDED
|
|
assets/case_study/capabl_india_microgrid_hero.webp
ADDED
|
Git LFS Details
|
assets/case_study/capabl_neighbourhood_dispatch.webp
ADDED
|
Git LFS Details
|
assets/case_study/capabl_rooftop_infrastructure.webp
ADDED
|
Git LFS Details
|
assets/case_study/capabl_society_operator.webp
ADDED
|
assets/case_study/gridops_control_room.webp
ADDED
|
Git LFS Details
|
assets/case_study/gridops_environment_loop.webp
ADDED
|
Git LFS Details
|
assets/case_study/gridops_hero_microgrid.webp
ADDED
|
Git LFS Details
|
assets/case_study/gridops_impact_split.webp
ADDED
|
Git LFS Details
|
assets/case_study/india_microgrid_operator_layer.webp
ADDED
|
Git LFS Details
|
assets/case_study/india_microgrid_value_flows.webp
ADDED
|
Git LFS Details
|
assets/case_study/india_solar_society_hero.webp
ADDED
|
Git LFS Details
|
evals/plots/gridops_battery_throughput.png
ADDED
|
evals/plots/gridops_blackout_kwh.png
ADDED
|
evals/plots/gridops_holdout_scores.png
ADDED
|
evals/plots/gridops_holdout_summary.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_nothing": {
|
| 3 |
+
"average_score": 0.5133,
|
| 4 |
+
"valid_action_rate": 1.0,
|
| 5 |
+
"by_task": {
|
| 6 |
+
"task_1_normal": {
|
| 7 |
+
"score": 0.582,
|
| 8 |
+
"battery_throughput_kwh": 0.0,
|
| 9 |
+
"blackout_kwh": 298.85,
|
| 10 |
+
"diesel_kwh": 0.0,
|
| 11 |
+
"cost": 72200.57
|
| 12 |
+
},
|
| 13 |
+
"task_2_heatwave": {
|
| 14 |
+
"score": 0.5057,
|
| 15 |
+
"battery_throughput_kwh": 0.0,
|
| 16 |
+
"blackout_kwh": 895.0,
|
| 17 |
+
"diesel_kwh": 0.0,
|
| 18 |
+
"cost": 185916.24
|
| 19 |
+
},
|
| 20 |
+
"task_3_crisis": {
|
| 21 |
+
"score": 0.4522,
|
| 22 |
+
"battery_throughput_kwh": 0.0,
|
| 23 |
+
"blackout_kwh": 2425.76,
|
| 24 |
+
"diesel_kwh": 0.0,
|
| 25 |
+
"cost": 478392.31
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
"sft": {
|
| 30 |
+
"average_score": 0.6854,
|
| 31 |
+
"valid_action_rate": 0.9985,
|
| 32 |
+
"by_task": {
|
| 33 |
+
"task_1_normal": {
|
| 34 |
+
"score": 0.6615,
|
| 35 |
+
"battery_throughput_kwh": 577.97,
|
| 36 |
+
"blackout_kwh": 177.57,
|
| 37 |
+
"diesel_kwh": 0.0,
|
| 38 |
+
"cost": 58685.13
|
| 39 |
+
},
|
| 40 |
+
"task_2_heatwave": {
|
| 41 |
+
"score": 0.73,
|
| 42 |
+
"battery_throughput_kwh": 1721.05,
|
| 43 |
+
"blackout_kwh": 258.3,
|
| 44 |
+
"diesel_kwh": 48.89,
|
| 45 |
+
"cost": 103310.54
|
| 46 |
+
},
|
| 47 |
+
"task_3_crisis": {
|
| 48 |
+
"score": 0.6648,
|
| 49 |
+
"battery_throughput_kwh": 2898.1,
|
| 50 |
+
"blackout_kwh": 978.99,
|
| 51 |
+
"diesel_kwh": 275.29,
|
| 52 |
+
"cost": 297079.42
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"oracle": {
|
| 57 |
+
"average_score": 0.7688,
|
| 58 |
+
"valid_action_rate": 1.0,
|
| 59 |
+
"by_task": {
|
| 60 |
+
"task_1_normal": {
|
| 61 |
+
"score": 0.7932,
|
| 62 |
+
"battery_throughput_kwh": 970.62,
|
| 63 |
+
"blackout_kwh": 15.24,
|
| 64 |
+
"diesel_kwh": 0.0,
|
| 65 |
+
"cost": 36369.09
|
| 66 |
+
},
|
| 67 |
+
"task_2_heatwave": {
|
| 68 |
+
"score": 0.8087,
|
| 69 |
+
"battery_throughput_kwh": 2075.75,
|
| 70 |
+
"blackout_kwh": 41.25,
|
| 71 |
+
"diesel_kwh": 86.2,
|
| 72 |
+
"cost": 74089.62
|
| 73 |
+
},
|
| 74 |
+
"task_3_crisis": {
|
| 75 |
+
"score": 0.7046,
|
| 76 |
+
"battery_throughput_kwh": 3170.6,
|
| 77 |
+
"blackout_kwh": 699.56,
|
| 78 |
+
"diesel_kwh": 416.56,
|
| 79 |
+
"cost": 261602.58
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
"training": {
|
| 84 |
+
"logged_points": 30,
|
| 85 |
+
"first_loss": 1.53,
|
| 86 |
+
"final_loss": 0.1478,
|
| 87 |
+
"final_mean_token_accuracy": 0.9486
|
| 88 |
+
}
|
| 89 |
+
}
|
evals/plots/gridops_sft_training_curve.png
ADDED
|
evals/plots/gridops_sft_training_metrics.json
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"loss": "1.53",
|
| 4 |
+
"grad_norm": "1.562",
|
| 5 |
+
"learning_rate": "0.000194",
|
| 6 |
+
"entropy": "1.517",
|
| 7 |
+
"num_tokens": "1.058e+05",
|
| 8 |
+
"mean_token_accuracy": "0.6596",
|
| 9 |
+
"epoch": "0.1128"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"loss": "0.2563",
|
| 13 |
+
"grad_norm": "0.2266",
|
| 14 |
+
"learning_rate": "0.0001873",
|
| 15 |
+
"entropy": "0.3015",
|
| 16 |
+
"num_tokens": "2.118e+05",
|
| 17 |
+
"mean_token_accuracy": "0.9213",
|
| 18 |
+
"epoch": "0.2257"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"loss": "0.1809",
|
| 22 |
+
"grad_norm": "0.126",
|
| 23 |
+
"learning_rate": "0.0001807",
|
| 24 |
+
"entropy": "0.1859",
|
| 25 |
+
"num_tokens": "3.175e+05",
|
| 26 |
+
"mean_token_accuracy": "0.9362",
|
| 27 |
+
"epoch": "0.3385"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"loss": "0.1726",
|
| 31 |
+
"grad_norm": "0.1465",
|
| 32 |
+
"learning_rate": "0.000174",
|
| 33 |
+
"entropy": "0.1778",
|
| 34 |
+
"num_tokens": "4.234e+05",
|
| 35 |
+
"mean_token_accuracy": "0.9372",
|
| 36 |
+
"epoch": "0.4513"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loss": "0.1631",
|
| 40 |
+
"grad_norm": "0.1143",
|
| 41 |
+
"learning_rate": "0.0001673",
|
| 42 |
+
"entropy": "0.1673",
|
| 43 |
+
"num_tokens": "5.29e+05",
|
| 44 |
+
"mean_token_accuracy": "0.9398",
|
| 45 |
+
"epoch": "0.5642"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"loss": "0.1589",
|
| 49 |
+
"grad_norm": "0.1162",
|
| 50 |
+
"learning_rate": "0.0001607",
|
| 51 |
+
"entropy": "0.1621",
|
| 52 |
+
"num_tokens": "6.347e+05",
|
| 53 |
+
"mean_token_accuracy": "0.9414",
|
| 54 |
+
"epoch": "0.677"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"loss": "0.1552",
|
| 58 |
+
"grad_norm": "0.1309",
|
| 59 |
+
"learning_rate": "0.000154",
|
| 60 |
+
"entropy": "0.16",
|
| 61 |
+
"num_tokens": "7.403e+05",
|
| 62 |
+
"mean_token_accuracy": "0.9428",
|
| 63 |
+
"epoch": "0.7898"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"loss": "0.1531",
|
| 67 |
+
"grad_norm": "0.1484",
|
| 68 |
+
"learning_rate": "0.0001473",
|
| 69 |
+
"entropy": "0.1565",
|
| 70 |
+
"num_tokens": "8.462e+05",
|
| 71 |
+
"mean_token_accuracy": "0.943",
|
| 72 |
+
"epoch": "0.9027"
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"loss": "0.1524",
|
| 76 |
+
"grad_norm": "0.1934",
|
| 77 |
+
"learning_rate": "0.0001407",
|
| 78 |
+
"entropy": "0.1575",
|
| 79 |
+
"num_tokens": "9.48e+05",
|
| 80 |
+
"mean_token_accuracy": "0.943",
|
| 81 |
+
"epoch": "1.011"
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"loss": "0.1513",
|
| 85 |
+
"grad_norm": "0.1279",
|
| 86 |
+
"learning_rate": "0.000134",
|
| 87 |
+
"entropy": "0.1565",
|
| 88 |
+
"num_tokens": "1.054e+06",
|
| 89 |
+
"mean_token_accuracy": "0.9437",
|
| 90 |
+
"epoch": "1.124"
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"loss": "0.1472",
|
| 94 |
+
"grad_norm": "0.124",
|
| 95 |
+
"learning_rate": "0.0001273",
|
| 96 |
+
"entropy": "0.1562",
|
| 97 |
+
"num_tokens": "1.159e+06",
|
| 98 |
+
"mean_token_accuracy": "0.9451",
|
| 99 |
+
"epoch": "1.237"
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"loss": "0.1503",
|
| 103 |
+
"grad_norm": "0.1235",
|
| 104 |
+
"learning_rate": "0.0001207",
|
| 105 |
+
"entropy": "0.1636",
|
| 106 |
+
"num_tokens": "1.265e+06",
|
| 107 |
+
"mean_token_accuracy": "0.9442",
|
| 108 |
+
"epoch": "1.35"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"loss": "0.1483",
|
| 112 |
+
"grad_norm": "0.1533",
|
| 113 |
+
"learning_rate": "0.000114",
|
| 114 |
+
"entropy": "0.1645",
|
| 115 |
+
"num_tokens": "1.371e+06",
|
| 116 |
+
"mean_token_accuracy": "0.9447",
|
| 117 |
+
"epoch": "1.463"
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"loss": "0.1509",
|
| 121 |
+
"grad_norm": "0.1455",
|
| 122 |
+
"learning_rate": "0.0001073",
|
| 123 |
+
"entropy": "0.175",
|
| 124 |
+
"num_tokens": "1.477e+06",
|
| 125 |
+
"mean_token_accuracy": "0.9439",
|
| 126 |
+
"epoch": "1.575"
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"loss": "0.149",
|
| 130 |
+
"grad_norm": "0.1641",
|
| 131 |
+
"learning_rate": "0.0001007",
|
| 132 |
+
"entropy": "0.1695",
|
| 133 |
+
"num_tokens": "1.583e+06",
|
| 134 |
+
"mean_token_accuracy": "0.9441",
|
| 135 |
+
"epoch": "1.688"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"loss": "0.146",
|
| 139 |
+
"grad_norm": "0.1582",
|
| 140 |
+
"learning_rate": "9.4e-05",
|
| 141 |
+
"entropy": "0.1701",
|
| 142 |
+
"num_tokens": "1.688e+06",
|
| 143 |
+
"mean_token_accuracy": "0.9456",
|
| 144 |
+
"epoch": "1.801"
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"loss": "0.1422",
|
| 148 |
+
"grad_norm": "0.1445",
|
| 149 |
+
"learning_rate": "8.733e-05",
|
| 150 |
+
"entropy": "0.1639",
|
| 151 |
+
"num_tokens": "1.794e+06",
|
| 152 |
+
"mean_token_accuracy": "0.9465",
|
| 153 |
+
"epoch": "1.914"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"loss": "0.1459",
|
| 157 |
+
"grad_norm": "0.1602",
|
| 158 |
+
"learning_rate": "8.067e-05",
|
| 159 |
+
"entropy": "0.167",
|
| 160 |
+
"num_tokens": "1.896e+06",
|
| 161 |
+
"mean_token_accuracy": "0.9455",
|
| 162 |
+
"epoch": "2.023"
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"loss": "0.1413",
|
| 166 |
+
"grad_norm": "0.1318",
|
| 167 |
+
"learning_rate": "7.4e-05",
|
| 168 |
+
"entropy": "0.161",
|
| 169 |
+
"num_tokens": "2.002e+06",
|
| 170 |
+
"mean_token_accuracy": "0.947",
|
| 171 |
+
"epoch": "2.135"
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"loss": "0.1405",
|
| 175 |
+
"grad_norm": "0.1465",
|
| 176 |
+
"learning_rate": "6.733e-05",
|
| 177 |
+
"entropy": "0.1624",
|
| 178 |
+
"num_tokens": "2.107e+06",
|
| 179 |
+
"mean_token_accuracy": "0.9485",
|
| 180 |
+
"epoch": "2.248"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"loss": "0.1407",
|
| 184 |
+
"grad_norm": "0.1924",
|
| 185 |
+
"learning_rate": "6.067e-05",
|
| 186 |
+
"entropy": "0.1652",
|
| 187 |
+
"num_tokens": "2.213e+06",
|
| 188 |
+
"mean_token_accuracy": "0.9477",
|
| 189 |
+
"epoch": "2.361"
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"loss": "0.1433",
|
| 193 |
+
"grad_norm": "0.1943",
|
| 194 |
+
"learning_rate": "5.4e-05",
|
| 195 |
+
"entropy": "0.1712",
|
| 196 |
+
"num_tokens": "2.319e+06",
|
| 197 |
+
"mean_token_accuracy": "0.9472",
|
| 198 |
+
"epoch": "2.474"
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"loss": "0.1418",
|
| 202 |
+
"grad_norm": "0.1963",
|
| 203 |
+
"learning_rate": "4.733e-05",
|
| 204 |
+
"entropy": "0.1759",
|
| 205 |
+
"num_tokens": "2.425e+06",
|
| 206 |
+
"mean_token_accuracy": "0.9475",
|
| 207 |
+
"epoch": "2.587"
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"loss": "0.1405",
|
| 211 |
+
"grad_norm": "0.1865",
|
| 212 |
+
"learning_rate": "4.067e-05",
|
| 213 |
+
"entropy": "0.1795",
|
| 214 |
+
"num_tokens": "2.531e+06",
|
| 215 |
+
"mean_token_accuracy": "0.9483",
|
| 216 |
+
"epoch": "2.7"
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"loss": "0.1423",
|
| 220 |
+
"grad_norm": "0.2676",
|
| 221 |
+
"learning_rate": "3.4e-05",
|
| 222 |
+
"entropy": "0.1906",
|
| 223 |
+
"num_tokens": "2.636e+06",
|
| 224 |
+
"mean_token_accuracy": "0.9482",
|
| 225 |
+
"epoch": "2.812"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"loss": "0.146",
|
| 229 |
+
"grad_norm": "0.3164",
|
| 230 |
+
"learning_rate": "2.733e-05",
|
| 231 |
+
"entropy": "0.2049",
|
| 232 |
+
"num_tokens": "2.742e+06",
|
| 233 |
+
"mean_token_accuracy": "0.9475",
|
| 234 |
+
"epoch": "2.925"
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"loss": "0.1456",
|
| 238 |
+
"grad_norm": "0.2461",
|
| 239 |
+
"learning_rate": "2.067e-05",
|
| 240 |
+
"entropy": "0.2129",
|
| 241 |
+
"num_tokens": "2.844e+06",
|
| 242 |
+
"mean_token_accuracy": "0.948",
|
| 243 |
+
"epoch": "3.034"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"loss": "0.1444",
|
| 247 |
+
"grad_norm": "0.2578",
|
| 248 |
+
"learning_rate": "1.4e-05",
|
| 249 |
+
"entropy": "0.2143",
|
| 250 |
+
"num_tokens": "2.95e+06",
|
| 251 |
+
"mean_token_accuracy": "0.9493",
|
| 252 |
+
"epoch": "3.147"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"loss": "0.1454",
|
| 256 |
+
"grad_norm": "0.2539",
|
| 257 |
+
"learning_rate": "7.333e-06",
|
| 258 |
+
"entropy": "0.2226",
|
| 259 |
+
"num_tokens": "3.055e+06",
|
| 260 |
+
"mean_token_accuracy": "0.9483",
|
| 261 |
+
"epoch": "3.26"
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"loss": "0.1478",
|
| 265 |
+
"grad_norm": "0.3105",
|
| 266 |
+
"learning_rate": "6.667e-07",
|
| 267 |
+
"entropy": "0.2262",
|
| 268 |
+
"num_tokens": "3.161e+06",
|
| 269 |
+
"mean_token_accuracy": "0.9486",
|
| 270 |
+
"epoch": "3.372"
|
| 271 |
+
}
|
| 272 |
+
]
|
gridops/policies.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable GridOps policies for traces, baselines, and adversarial tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from gridops.models import GridOpsAction
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
GRID_MAX_KW = 200.0
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def oracle_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 14 |
+
"""Price/SOC/outage-aware operator policy used as SFT expert labels."""
|
| 15 |
+
hour_of_day = (int(obs["hour"]) + 6) % 24
|
| 16 |
+
hour = int(obs["hour"])
|
| 17 |
+
soc = float(obs["battery_soc"])
|
| 18 |
+
price = float(obs["grid_price"])
|
| 19 |
+
demand = float(obs["demand_kw"])
|
| 20 |
+
solar = float(obs["solar_kw"])
|
| 21 |
+
fuel = float(obs["diesel_fuel_remaining"])
|
| 22 |
+
demand_fc = [float(v) for v in obs.get("demand_forecast_4h", [])]
|
| 23 |
+
solar_fc = [float(v) for v in obs.get("solar_forecast_4h", [])]
|
| 24 |
+
price_fc = [float(v) for v in obs.get("price_forecast_4h", [])]
|
| 25 |
+
|
| 26 |
+
battery = 0.0
|
| 27 |
+
diesel = 0.0
|
| 28 |
+
shedding = 0.0
|
| 29 |
+
net = demand - solar
|
| 30 |
+
future_net_peak = max([net] + [d - s for d, s in zip(demand_fc, solar_fc)])
|
| 31 |
+
future_price_peak = max([price] + price_fc)
|
| 32 |
+
outage_soon = task_id == "task_3_crisis" and 26 <= hour <= 35
|
| 33 |
+
in_outage = task_id == "task_3_crisis" and 30 <= hour <= 35
|
| 34 |
+
|
| 35 |
+
if in_outage:
|
| 36 |
+
gap = max(0.0, demand - solar)
|
| 37 |
+
if soc > 0.18:
|
| 38 |
+
battery = min(1.0, gap / 100.0)
|
| 39 |
+
gap -= battery * 100.0
|
| 40 |
+
if gap > 0 and fuel > 0.04:
|
| 41 |
+
diesel = min(1.0, gap / 100.0)
|
| 42 |
+
gap -= diesel * 100.0
|
| 43 |
+
if gap > 0:
|
| 44 |
+
shedding = min(1.0, gap / max(demand * 0.20, 1.0))
|
| 45 |
+
elif outage_soon:
|
| 46 |
+
if soc < 0.9:
|
| 47 |
+
battery = -0.9
|
| 48 |
+
else:
|
| 49 |
+
battery = 0.0
|
| 50 |
+
elif hour_of_day < 6:
|
| 51 |
+
if soc < 0.9:
|
| 52 |
+
battery = -0.8
|
| 53 |
+
elif 6 <= hour_of_day < 15:
|
| 54 |
+
if solar > demand and soc < 0.95:
|
| 55 |
+
battery = -min(1.0, (solar - demand) / 100.0)
|
| 56 |
+
elif soc < 0.72 and (price < 6.0 or future_net_peak > GRID_MAX_KW):
|
| 57 |
+
battery = -0.5
|
| 58 |
+
elif 15 <= hour_of_day < 18:
|
| 59 |
+
if soc < 0.82 or future_price_peak > 14.0 or future_net_peak > GRID_MAX_KW:
|
| 60 |
+
battery = -0.8
|
| 61 |
+
elif 18 <= hour_of_day < 23:
|
| 62 |
+
if net > GRID_MAX_KW and soc > 0.1:
|
| 63 |
+
gap = net - GRID_MAX_KW
|
| 64 |
+
battery = min(1.0, gap / 100.0)
|
| 65 |
+
gap -= battery * 100.0
|
| 66 |
+
if gap > 0 and fuel > 0.05:
|
| 67 |
+
diesel = min(1.0, gap / 100.0)
|
| 68 |
+
gap -= diesel * 100.0
|
| 69 |
+
if gap > 0:
|
| 70 |
+
shedding = min(1.0, gap / max(demand * 0.20, 1.0))
|
| 71 |
+
elif price > 10.0 and soc > 0.5:
|
| 72 |
+
battery = min(0.6, (price - 8.0) / 10.0)
|
| 73 |
+
else:
|
| 74 |
+
if soc < 0.4:
|
| 75 |
+
battery = -0.5
|
| 76 |
+
|
| 77 |
+
return GridOpsAction(
|
| 78 |
+
battery_dispatch=float(np.clip(battery, -1, 1)),
|
| 79 |
+
diesel_dispatch=float(np.clip(diesel, 0, 1)),
|
| 80 |
+
demand_shedding=float(np.clip(shedding, 0, 1)),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def do_nothing_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 85 |
+
return GridOpsAction()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def always_charge_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 89 |
+
return GridOpsAction(battery_dispatch=-1.0)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def always_discharge_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 93 |
+
return GridOpsAction(battery_dispatch=1.0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def always_diesel_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 97 |
+
return GridOpsAction(diesel_dispatch=1.0)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def shed_farmer_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 101 |
+
return GridOpsAction(demand_shedding=1.0)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def diesel_chatter_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 105 |
+
return GridOpsAction(diesel_dispatch=1.0 if int(obs["hour"]) % 2 == 0 else 0.0)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def blackout_acceptor_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 109 |
+
return GridOpsAction(battery_dispatch=0.0, diesel_dispatch=0.0, demand_shedding=0.0)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def price_greedy_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 113 |
+
price = float(obs["grid_price"])
|
| 114 |
+
if price > 8.0:
|
| 115 |
+
return GridOpsAction(battery_dispatch=1.0)
|
| 116 |
+
if price < 5.0:
|
| 117 |
+
return GridOpsAction(battery_dispatch=-1.0)
|
| 118 |
+
return GridOpsAction()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def grid_only_policy(obs: dict, task_id: str | None = None) -> GridOpsAction:
|
| 122 |
+
return GridOpsAction(battery_dispatch=0.0, diesel_dispatch=0.0, demand_shedding=0.0)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
POLICIES = {
|
| 126 |
+
"oracle": oracle_policy,
|
| 127 |
+
"do_nothing": do_nothing_policy,
|
| 128 |
+
"always_charge": always_charge_policy,
|
| 129 |
+
"always_discharge": always_discharge_policy,
|
| 130 |
+
"always_diesel": always_diesel_policy,
|
| 131 |
+
"shed_farmer": shed_farmer_policy,
|
| 132 |
+
"diesel_chatter": diesel_chatter_policy,
|
| 133 |
+
"blackout_acceptor": blackout_acceptor_policy,
|
| 134 |
+
"price_greedy": price_greedy_policy,
|
| 135 |
+
"grid_only": grid_only_policy,
|
| 136 |
+
}
|
gridops/prompting.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompting and action parsing for GridOps model training/inference.
|
| 2 |
+
|
| 3 |
+
This module is the single source of truth for the JSON-only action contract
|
| 4 |
+
used by API inference, SFT traces, and rollout evaluation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from pydantic import ValidationError
|
| 13 |
+
|
| 14 |
+
from gridops.models import GridOpsAction
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
SYSTEM_PROMPT = """\
|
| 18 |
+
You are an expert microgrid operator managing a 100-home community in India during summer.
|
| 19 |
+
|
| 20 |
+
You control three actions each hour:
|
| 21 |
+
- battery_dispatch: -1 (charge 100 kW from grid) to +1 (discharge 100 kW to community)
|
| 22 |
+
- diesel_dispatch: 0 (off) to 1 (100 kW). Costs Rs 25/kWh + Rs 100 startup if was off.
|
| 23 |
+
- demand_shedding: 0 (none) to 1 (shed 20% of demand). WARNING: 100% rebounds next hour! Rs 40/kWh penalty.
|
| 24 |
+
|
| 25 |
+
The GRID automatically absorbs the residual (capped at +/-200 kW).
|
| 26 |
+
If demand exceeds grid + solar + battery + diesel, that becomes BLACKOUT (Rs 150/kWh penalty).
|
| 27 |
+
|
| 28 |
+
Key economics:
|
| 29 |
+
- Grid prices vary Rs 3-20/kWh. Cheap at night, expensive evening.
|
| 30 |
+
- Battery: 500 kWh, 100 kW max, 90% round-trip efficiency, Rs 2.5/kWh degradation.
|
| 31 |
+
- Solar: 250 kW peak, free, bell curve 6AM-6PM, zero at night.
|
| 32 |
+
- Demand: about 100 kW average, 250 kW evening peak. Grid cap = 200 kW, so the battery matters.
|
| 33 |
+
- Diesel and shedding are emergency tools, not default tools.
|
| 34 |
+
|
| 35 |
+
Strategy:
|
| 36 |
+
1. Night: charge battery when grid is cheap and demand is low.
|
| 37 |
+
2. Solar hours: use solar first; charge from surplus when available.
|
| 38 |
+
3. Pre-peak: keep enough battery for evening or outage hours.
|
| 39 |
+
4. Evening peak: discharge battery to cover demand above the grid cap.
|
| 40 |
+
5. Crisis/outage: ration battery and diesel, shed only when unavoidable.
|
| 41 |
+
|
| 42 |
+
Respond ONLY with valid JSON:
|
| 43 |
+
{"battery_dispatch": float, "diesel_dispatch": float, "demand_shedding": float}"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def format_observation(obs: dict[str, Any]) -> str:
|
| 47 |
+
"""Format a GridOps observation into the model's user prompt."""
|
| 48 |
+
return (
|
| 49 |
+
f"Hour {obs['hour']:.0f}/72 (Day {obs.get('day_of_episode', '?')})\n"
|
| 50 |
+
f"Demand: {obs['demand_kw']:.0f} kW | Solar: {obs['solar_kw']:.0f} kW\n"
|
| 51 |
+
f"Battery SOC: {obs['battery_soc'] * 100:.0f}% | Grid Price: Rs {obs['grid_price']:.1f}/kWh\n"
|
| 52 |
+
f"Diesel Fuel: {obs['diesel_fuel_remaining'] * 100:.0f}% | Diesel On: {obs.get('diesel_is_on', False)}\n"
|
| 53 |
+
f"Grid import last step: {obs.get('grid_kw_this_step', 0):.0f} kW\n"
|
| 54 |
+
f"Forecasts (next 4h):\n"
|
| 55 |
+
f" Demand: {[f'{v:.0f}' for v in obs.get('demand_forecast_4h', [])]}\n"
|
| 56 |
+
f" Solar: {[f'{v:.0f}' for v in obs.get('solar_forecast_4h', [])]}\n"
|
| 57 |
+
f" Price: {[f'{v:.1f}' for v in obs.get('price_forecast_4h', [])]}\n"
|
| 58 |
+
f"Cumulative: blackout={obs['cumulative_blackout_kwh']:.1f} kWh, cost=Rs {obs['cumulative_cost']:.0f}\n"
|
| 59 |
+
f"{obs.get('narration', '')}\n"
|
| 60 |
+
"\nWhat action? Reply with JSON only."
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def messages_for_observation(obs: dict[str, Any]) -> list[dict[str, str]]:
|
| 65 |
+
"""Return chat messages for one GridOps decision."""
|
| 66 |
+
return [
|
| 67 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 68 |
+
{"role": "user", "content": format_observation(obs)},
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def action_to_json(action: GridOpsAction) -> str:
|
| 73 |
+
"""Serialize an action as compact JSON for SFT completions."""
|
| 74 |
+
return json.dumps(
|
| 75 |
+
{
|
| 76 |
+
"battery_dispatch": round(float(action.battery_dispatch), 4),
|
| 77 |
+
"diesel_dispatch": round(float(action.diesel_dispatch), 4),
|
| 78 |
+
"demand_shedding": round(float(action.demand_shedding), 4),
|
| 79 |
+
},
|
| 80 |
+
separators=(",", ":"),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def extract_action_json(text: str) -> dict[str, Any] | None:
|
| 85 |
+
"""Extract the first JSON object from model text."""
|
| 86 |
+
text = (text or "").strip()
|
| 87 |
+
if not text:
|
| 88 |
+
return None
|
| 89 |
+
start = text.find("{")
|
| 90 |
+
end = text.rfind("}")
|
| 91 |
+
if start < 0 or end <= start:
|
| 92 |
+
return None
|
| 93 |
+
try:
|
| 94 |
+
parsed = json.loads(text[start:end + 1])
|
| 95 |
+
except json.JSONDecodeError:
|
| 96 |
+
return None
|
| 97 |
+
return parsed if isinstance(parsed, dict) else None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def parse_action(text: str, default: GridOpsAction | None = None) -> GridOpsAction:
|
| 101 |
+
"""Parse and validate a model response into a bounded GridOpsAction."""
|
| 102 |
+
payload = extract_action_json(text)
|
| 103 |
+
if payload is None:
|
| 104 |
+
return default or GridOpsAction()
|
| 105 |
+
try:
|
| 106 |
+
return GridOpsAction(**payload)
|
| 107 |
+
except (TypeError, ValueError, ValidationError):
|
| 108 |
+
return default or GridOpsAction()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def validate_completion(text: str) -> tuple[bool, str]:
|
| 112 |
+
"""Validate a JSON-only completion for trace/eval gates."""
|
| 113 |
+
payload = extract_action_json(text)
|
| 114 |
+
if payload is None:
|
| 115 |
+
return False, "missing_json"
|
| 116 |
+
stripped = text.strip()
|
| 117 |
+
if stripped != json.dumps(payload, separators=(",", ":")) and stripped != json.dumps(payload):
|
| 118 |
+
# JSON with whitespace is acceptable; prose outside JSON is not.
|
| 119 |
+
before = stripped[:stripped.find("{")].strip()
|
| 120 |
+
after = stripped[stripped.rfind("}") + 1:].strip()
|
| 121 |
+
if before or after:
|
| 122 |
+
return False, "prose_outside_json"
|
| 123 |
+
try:
|
| 124 |
+
GridOpsAction(**payload)
|
| 125 |
+
except (TypeError, ValueError, ValidationError) as exc:
|
| 126 |
+
return False, f"invalid_action:{type(exc).__name__}"
|
| 127 |
+
return True, "ok"
|
gridops/server/app.py
CHANGED
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
from fastapi import FastAPI
|
| 15 |
-
from fastapi.responses import RedirectResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
|
@@ -101,29 +101,42 @@ def list_tasks():
|
|
| 101 |
}
|
| 102 |
|
| 103 |
|
| 104 |
-
# ── Root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
@app.get("/")
|
| 107 |
def root_serve():
|
| 108 |
"""Serve dashboard directly at root so HF Space iframe shows the UI."""
|
| 109 |
-
|
| 110 |
-
index = Path(__file__).parent / "static" / "index.html"
|
| 111 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 112 |
|
| 113 |
|
| 114 |
@app.get("/web")
|
| 115 |
def web_serve():
|
| 116 |
"""Serve dashboard at /web (OpenEnv default web UI path)."""
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 120 |
|
| 121 |
|
| 122 |
# ── Serve dashboard static files ────────────────────────────────────────
|
| 123 |
|
| 124 |
-
STATIC_DIR = Path(__file__).parent / "static"
|
| 125 |
if STATIC_DIR.exists():
|
| 126 |
app.mount("/dashboard", StaticFiles(directory=str(STATIC_DIR), html=True), name="dashboard")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
|
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
from fastapi import FastAPI
|
| 15 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
|
| 104 |
+
# ── Root and project pages ───────────────────────────────────────────────
|
| 105 |
+
|
| 106 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 107 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 108 |
+
ASSETS_DIR = REPO_ROOT / "assets"
|
| 109 |
+
EVALS_DIR = REPO_ROOT / "evals"
|
| 110 |
|
| 111 |
@app.get("/")
|
| 112 |
def root_serve():
|
| 113 |
"""Serve dashboard directly at root so HF Space iframe shows the UI."""
|
| 114 |
+
index = STATIC_DIR / "index.html"
|
|
|
|
| 115 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 116 |
|
| 117 |
|
| 118 |
@app.get("/web")
|
| 119 |
def web_serve():
|
| 120 |
"""Serve dashboard at /web (OpenEnv default web UI path)."""
|
| 121 |
+
index = STATIC_DIR / "index.html"
|
| 122 |
+
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.get("/case-study")
|
| 126 |
+
def case_study_serve():
|
| 127 |
+
"""Serve the Capabl Machines GridOps project case study."""
|
| 128 |
+
index = STATIC_DIR / "case-study.html"
|
| 129 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 130 |
|
| 131 |
|
| 132 |
# ── Serve dashboard static files ────────────────────────────────────────
|
| 133 |
|
|
|
|
| 134 |
if STATIC_DIR.exists():
|
| 135 |
app.mount("/dashboard", StaticFiles(directory=str(STATIC_DIR), html=True), name="dashboard")
|
| 136 |
+
if ASSETS_DIR.exists():
|
| 137 |
+
app.mount("/assets", StaticFiles(directory=str(ASSETS_DIR)), name="assets")
|
| 138 |
+
if EVALS_DIR.exists():
|
| 139 |
+
app.mount("/evals", StaticFiles(directory=str(EVALS_DIR)), name="evals")
|
| 140 |
|
| 141 |
|
| 142 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
gridops/server/static/case-study.html
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 India Case Study | Capabl Machines</title>
|
| 7 |
+
<meta name="description" content="GridOps is a Capabl Machines case study on AI-managed community microgrids for India's fast-growing rooftop solar future.">
|
| 8 |
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M34 8 14 34h17l-3 22 22-30H33z' fill='%238ef2c3'/%3E%3C/svg%3E">
|
| 9 |
+
<style>
|
| 10 |
+
:root {
|
| 11 |
+
--ink: #15202b;
|
| 12 |
+
--muted: #5e6974;
|
| 13 |
+
--paper: #f7f7f1;
|
| 14 |
+
--white: #ffffff;
|
| 15 |
+
--line: #dce3e3;
|
| 16 |
+
--green: #138a62;
|
| 17 |
+
--mint: #86efac;
|
| 18 |
+
--teal: #0f7f8c;
|
| 19 |
+
--sun: #d9841f;
|
| 20 |
+
--indigo: #253b73;
|
| 21 |
+
--coal: #101923;
|
| 22 |
+
--shadow: 0 18px 60px rgba(21, 32, 43, 0.14);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
* { box-sizing: border-box; }
|
| 26 |
+
html { scroll-behavior: smooth; }
|
| 27 |
+
body {
|
| 28 |
+
margin: 0;
|
| 29 |
+
color: var(--ink);
|
| 30 |
+
background: var(--paper);
|
| 31 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 32 |
+
line-height: 1.5;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
a { color: inherit; text-decoration: none; }
|
| 36 |
+
img { display: block; max-width: 100%; }
|
| 37 |
+
code {
|
| 38 |
+
padding: 2px 5px;
|
| 39 |
+
border-radius: 4px;
|
| 40 |
+
color: #074b3a;
|
| 41 |
+
background: #e8f7ef;
|
| 42 |
+
font-size: 0.9em;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.nav {
|
| 46 |
+
position: fixed;
|
| 47 |
+
inset: 0 0 auto;
|
| 48 |
+
z-index: 20;
|
| 49 |
+
display: flex;
|
| 50 |
+
align-items: center;
|
| 51 |
+
justify-content: space-between;
|
| 52 |
+
gap: 24px;
|
| 53 |
+
padding: 16px clamp(18px, 4vw, 56px);
|
| 54 |
+
color: #f8fafc;
|
| 55 |
+
background: linear-gradient(180deg, rgba(8, 14, 22, 0.78), rgba(8, 14, 22, 0));
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.brand {
|
| 59 |
+
display: flex;
|
| 60 |
+
align-items: center;
|
| 61 |
+
gap: 10px;
|
| 62 |
+
font-weight: 900;
|
| 63 |
+
letter-spacing: 0.04em;
|
| 64 |
+
text-transform: uppercase;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.brand-mark {
|
| 68 |
+
width: 28px;
|
| 69 |
+
height: 28px;
|
| 70 |
+
border: 2px solid rgba(255, 255, 255, 0.92);
|
| 71 |
+
border-radius: 7px;
|
| 72 |
+
display: grid;
|
| 73 |
+
place-items: center;
|
| 74 |
+
color: var(--mint);
|
| 75 |
+
font-size: 18px;
|
| 76 |
+
line-height: 1;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.nav-links {
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
gap: 10px;
|
| 83 |
+
flex-wrap: wrap;
|
| 84 |
+
justify-content: flex-end;
|
| 85 |
+
font-size: 13px;
|
| 86 |
+
font-weight: 800;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.nav-links a {
|
| 90 |
+
padding: 9px 12px;
|
| 91 |
+
border: 1px solid rgba(255, 255, 255, 0.28);
|
| 92 |
+
border-radius: 999px;
|
| 93 |
+
background: rgba(255, 255, 255, 0.1);
|
| 94 |
+
backdrop-filter: blur(8px);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.hero {
|
| 98 |
+
position: relative;
|
| 99 |
+
min-height: 92vh;
|
| 100 |
+
display: grid;
|
| 101 |
+
align-items: end;
|
| 102 |
+
padding: 112px clamp(18px, 5vw, 72px) 64px;
|
| 103 |
+
overflow: hidden;
|
| 104 |
+
color: #f8fafc;
|
| 105 |
+
background: var(--coal);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.hero::before {
|
| 109 |
+
content: "";
|
| 110 |
+
position: absolute;
|
| 111 |
+
inset: 0;
|
| 112 |
+
background:
|
| 113 |
+
linear-gradient(90deg, rgba(7, 13, 19, 0.93), rgba(7, 13, 19, 0.48) 52%, rgba(7, 13, 19, 0.12)),
|
| 114 |
+
linear-gradient(0deg, rgba(7, 13, 19, 0.86), rgba(7, 13, 19, 0.06) 44%),
|
| 115 |
+
url("/assets/case_study/capabl_india_microgrid_hero.webp") center / cover no-repeat;
|
| 116 |
+
transform: scale(1.01);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.hero-content {
|
| 120 |
+
position: relative;
|
| 121 |
+
max-width: 980px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.eyebrow {
|
| 125 |
+
margin: 0 0 16px;
|
| 126 |
+
color: var(--mint);
|
| 127 |
+
font-size: 12px;
|
| 128 |
+
font-weight: 900;
|
| 129 |
+
letter-spacing: 0.16em;
|
| 130 |
+
text-transform: uppercase;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
h1, h2, h3, p { margin-top: 0; }
|
| 134 |
+
h1 {
|
| 135 |
+
max-width: 900px;
|
| 136 |
+
margin-bottom: 18px;
|
| 137 |
+
font-size: clamp(43px, 7.4vw, 96px);
|
| 138 |
+
line-height: 0.96;
|
| 139 |
+
letter-spacing: 0;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.hero-subtitle {
|
| 143 |
+
max-width: 790px;
|
| 144 |
+
margin-bottom: 28px;
|
| 145 |
+
color: rgba(248, 250, 252, 0.88);
|
| 146 |
+
font-size: clamp(18px, 2vw, 24px);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.hero-actions {
|
| 150 |
+
display: flex;
|
| 151 |
+
flex-wrap: wrap;
|
| 152 |
+
gap: 12px;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.button {
|
| 156 |
+
display: inline-flex;
|
| 157 |
+
align-items: center;
|
| 158 |
+
justify-content: center;
|
| 159 |
+
min-height: 46px;
|
| 160 |
+
padding: 12px 16px;
|
| 161 |
+
border-radius: 7px;
|
| 162 |
+
font-weight: 900;
|
| 163 |
+
border: 1px solid rgba(255, 255, 255, 0.28);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.button.primary {
|
| 167 |
+
color: #062218;
|
| 168 |
+
background: var(--mint);
|
| 169 |
+
border-color: var(--mint);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.button.secondary {
|
| 173 |
+
color: #f8fafc;
|
| 174 |
+
background: rgba(255, 255, 255, 0.1);
|
| 175 |
+
backdrop-filter: blur(8px);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.section {
|
| 179 |
+
padding: clamp(56px, 8vw, 98px) clamp(18px, 5vw, 72px);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.section.light { background: #fbfbf7; }
|
| 183 |
+
.section.soft { background: #edf5ed; }
|
| 184 |
+
.section.dark {
|
| 185 |
+
color: #f8fafc;
|
| 186 |
+
background: #101923;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.wrap {
|
| 190 |
+
max-width: 1180px;
|
| 191 |
+
margin: 0 auto;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.split {
|
| 195 |
+
display: grid;
|
| 196 |
+
grid-template-columns: minmax(0, 0.92fr) minmax(320px, 1.08fr);
|
| 197 |
+
gap: clamp(28px, 5vw, 72px);
|
| 198 |
+
align-items: center;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.split.reverse {
|
| 202 |
+
grid-template-columns: minmax(320px, 1.06fr) minmax(0, 0.94fr);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
h2 {
|
| 206 |
+
margin-bottom: 18px;
|
| 207 |
+
font-size: clamp(31px, 4vw, 54px);
|
| 208 |
+
line-height: 1.04;
|
| 209 |
+
letter-spacing: 0;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
h3 {
|
| 213 |
+
margin-bottom: 10px;
|
| 214 |
+
font-size: 21px;
|
| 215 |
+
line-height: 1.18;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.lede {
|
| 219 |
+
color: var(--muted);
|
| 220 |
+
font-size: clamp(17px, 1.7vw, 20px);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.dark .lede { color: rgba(248, 250, 252, 0.74); }
|
| 224 |
+
|
| 225 |
+
.stats {
|
| 226 |
+
display: grid;
|
| 227 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 228 |
+
gap: 14px;
|
| 229 |
+
margin-top: 34px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.stat {
|
| 233 |
+
min-height: 132px;
|
| 234 |
+
padding: 18px;
|
| 235 |
+
border: 1px solid var(--line);
|
| 236 |
+
border-radius: 8px;
|
| 237 |
+
background: var(--white);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.stat strong {
|
| 241 |
+
display: block;
|
| 242 |
+
margin-bottom: 8px;
|
| 243 |
+
color: var(--teal);
|
| 244 |
+
font-size: clamp(28px, 4vw, 40px);
|
| 245 |
+
line-height: 1;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.stat span {
|
| 249 |
+
color: var(--muted);
|
| 250 |
+
font-size: 14px;
|
| 251 |
+
font-weight: 800;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.image-frame {
|
| 255 |
+
overflow: hidden;
|
| 256 |
+
border-radius: 12px;
|
| 257 |
+
background: #dfe8e6;
|
| 258 |
+
box-shadow: var(--shadow);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.image-frame img {
|
| 262 |
+
width: 100%;
|
| 263 |
+
aspect-ratio: 16 / 9;
|
| 264 |
+
object-fit: cover;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.wide-image {
|
| 268 |
+
margin-top: 34px;
|
| 269 |
+
overflow: hidden;
|
| 270 |
+
border-radius: 12px;
|
| 271 |
+
background: #dfe8e6;
|
| 272 |
+
box-shadow: var(--shadow);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.wide-image img {
|
| 276 |
+
width: 100%;
|
| 277 |
+
aspect-ratio: 16 / 7;
|
| 278 |
+
object-fit: cover;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.infographic {
|
| 282 |
+
margin-top: 34px;
|
| 283 |
+
overflow: hidden;
|
| 284 |
+
border: 1px solid var(--line);
|
| 285 |
+
border-radius: 14px;
|
| 286 |
+
background: #f7f7f1;
|
| 287 |
+
box-shadow: var(--shadow);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.infographic img {
|
| 291 |
+
width: 100%;
|
| 292 |
+
min-width: 980px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.infographic-scroll {
|
| 296 |
+
overflow-x: auto;
|
| 297 |
+
-webkit-overflow-scrolling: touch;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.idea-grid {
|
| 301 |
+
display: grid;
|
| 302 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 303 |
+
gap: 15px;
|
| 304 |
+
margin-top: 30px;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.idea {
|
| 308 |
+
min-height: 186px;
|
| 309 |
+
padding: 24px;
|
| 310 |
+
border-top: 5px solid var(--green);
|
| 311 |
+
border-radius: 8px;
|
| 312 |
+
background: var(--white);
|
| 313 |
+
box-shadow: 0 10px 28px rgba(21, 32, 43, 0.06);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.idea:nth-child(2) { border-color: var(--sun); }
|
| 317 |
+
.idea:nth-child(3) { border-color: var(--indigo); }
|
| 318 |
+
|
| 319 |
+
.idea p {
|
| 320 |
+
margin: 0;
|
| 321 |
+
color: var(--muted);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.uses {
|
| 325 |
+
display: grid;
|
| 326 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 327 |
+
gap: 10px;
|
| 328 |
+
margin-top: 24px;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.use {
|
| 332 |
+
padding: 12px 14px;
|
| 333 |
+
border: 1px solid var(--line);
|
| 334 |
+
border-radius: 999px;
|
| 335 |
+
color: #284154;
|
| 336 |
+
background: #ffffff;
|
| 337 |
+
font-size: 14px;
|
| 338 |
+
font-weight: 800;
|
| 339 |
+
text-align: center;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.flow {
|
| 343 |
+
display: grid;
|
| 344 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 345 |
+
gap: 14px;
|
| 346 |
+
margin-top: 34px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.flow-step {
|
| 350 |
+
padding: 22px;
|
| 351 |
+
border-top: 4px solid var(--mint);
|
| 352 |
+
background: rgba(255, 255, 255, 0.08);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.flow-step:nth-child(2) { border-color: #7dd3fc; }
|
| 356 |
+
.flow-step:nth-child(3) { border-color: #fbbf24; }
|
| 357 |
+
.flow-step:nth-child(4) { border-color: #c4b5fd; }
|
| 358 |
+
|
| 359 |
+
.flow-step span {
|
| 360 |
+
display: block;
|
| 361 |
+
margin-bottom: 16px;
|
| 362 |
+
color: rgba(248, 250, 252, 0.54);
|
| 363 |
+
font-size: 12px;
|
| 364 |
+
font-weight: 900;
|
| 365 |
+
letter-spacing: 0.12em;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.flow-step p {
|
| 369 |
+
margin: 0;
|
| 370 |
+
color: rgba(248, 250, 252, 0.72);
|
| 371 |
+
font-size: 14px;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.table-wrap {
|
| 375 |
+
margin-top: 28px;
|
| 376 |
+
overflow-x: auto;
|
| 377 |
+
border: 1px solid var(--line);
|
| 378 |
+
border-radius: 8px;
|
| 379 |
+
background: var(--white);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
table {
|
| 383 |
+
width: 100%;
|
| 384 |
+
min-width: 720px;
|
| 385 |
+
border-collapse: collapse;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
th, td {
|
| 389 |
+
padding: 14px 16px;
|
| 390 |
+
border-bottom: 1px solid var(--line);
|
| 391 |
+
text-align: left;
|
| 392 |
+
vertical-align: top;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
th {
|
| 396 |
+
color: var(--muted);
|
| 397 |
+
font-size: 12px;
|
| 398 |
+
letter-spacing: 0.08em;
|
| 399 |
+
text-transform: uppercase;
|
| 400 |
+
background: #f0f4f4;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
tr:last-child td { border-bottom: 0; }
|
| 404 |
+
td strong { color: var(--green); }
|
| 405 |
+
|
| 406 |
+
.plots {
|
| 407 |
+
display: grid;
|
| 408 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 409 |
+
gap: 18px;
|
| 410 |
+
margin-top: 28px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.plot {
|
| 414 |
+
overflow: hidden;
|
| 415 |
+
border: 1px solid var(--line);
|
| 416 |
+
border-radius: 8px;
|
| 417 |
+
background: var(--white);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.plot img {
|
| 421 |
+
width: 100%;
|
| 422 |
+
aspect-ratio: 16 / 10;
|
| 423 |
+
object-fit: contain;
|
| 424 |
+
background: #ffffff;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.plot figcaption {
|
| 428 |
+
padding: 12px 14px 14px;
|
| 429 |
+
color: var(--muted);
|
| 430 |
+
font-size: 13px;
|
| 431 |
+
font-weight: 800;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.source-line {
|
| 435 |
+
margin-top: 18px;
|
| 436 |
+
color: var(--muted);
|
| 437 |
+
font-size: 13px;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.source-line a {
|
| 441 |
+
color: var(--teal);
|
| 442 |
+
font-weight: 800;
|
| 443 |
+
text-decoration: underline;
|
| 444 |
+
text-underline-offset: 3px;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.ledger {
|
| 448 |
+
display: grid;
|
| 449 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 450 |
+
gap: 14px;
|
| 451 |
+
margin-top: 30px;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.ledger a {
|
| 455 |
+
min-height: 118px;
|
| 456 |
+
padding: 18px;
|
| 457 |
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
| 458 |
+
border-radius: 8px;
|
| 459 |
+
background: rgba(255, 255, 255, 0.08);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.ledger strong {
|
| 463 |
+
display: block;
|
| 464 |
+
margin-bottom: 8px;
|
| 465 |
+
color: var(--mint);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.ledger span {
|
| 469 |
+
color: rgba(248, 250, 252, 0.72);
|
| 470 |
+
font-size: 14px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.footer {
|
| 474 |
+
padding: 28px clamp(18px, 5vw, 72px);
|
| 475 |
+
color: rgba(248, 250, 252, 0.64);
|
| 476 |
+
background: #070c12;
|
| 477 |
+
font-size: 14px;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.footer .wrap {
|
| 481 |
+
display: flex;
|
| 482 |
+
justify-content: space-between;
|
| 483 |
+
gap: 18px;
|
| 484 |
+
flex-wrap: wrap;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
@media (max-width: 920px) {
|
| 488 |
+
.nav { position: absolute; align-items: flex-start; }
|
| 489 |
+
.nav-links { display: none; }
|
| 490 |
+
.hero { min-height: 92vh; padding-top: 90px; }
|
| 491 |
+
.split, .split.reverse, .stats, .idea-grid, .uses, .flow, .ledger { grid-template-columns: 1fr; }
|
| 492 |
+
.plots { grid-template-columns: 1fr; }
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
@media (max-width: 560px) {
|
| 496 |
+
.hero-actions .button { width: 100%; }
|
| 497 |
+
.stat { min-height: 112px; }
|
| 498 |
+
.use { border-radius: 8px; }
|
| 499 |
+
}
|
| 500 |
+
</style>
|
| 501 |
+
</head>
|
| 502 |
+
<body>
|
| 503 |
+
<nav class="nav" aria-label="Project navigation">
|
| 504 |
+
<a class="brand" href="/case-study" aria-label="GridOps India case study">
|
| 505 |
+
<span class="brand-mark">G</span>
|
| 506 |
+
<span>GridOps</span>
|
| 507 |
+
</a>
|
| 508 |
+
<div class="nav-links">
|
| 509 |
+
<a href="#india">India context</a>
|
| 510 |
+
<a href="#machine">Capabl Machine</a>
|
| 511 |
+
<a href="#results">Results</a>
|
| 512 |
+
<a href="/dashboard/">Live demo</a>
|
| 513 |
+
</div>
|
| 514 |
+
</nav>
|
| 515 |
+
|
| 516 |
+
<main>
|
| 517 |
+
<section class="hero">
|
| 518 |
+
<div class="hero-content">
|
| 519 |
+
<p class="eyebrow">For India's next solar decade</p>
|
| 520 |
+
<h1>Every society can run like a smart power plant.</h1>
|
| 521 |
+
<p class="hero-subtitle">Solar has scaled. Now the hard problem is local dispatch: when to store, buy, shed, use backup, or sell flexibility so communities save money and keep the lights on.</p>
|
| 522 |
+
<div class="hero-actions">
|
| 523 |
+
<a class="button primary" href="/dashboard/">Open the live environment</a>
|
| 524 |
+
<a class="button secondary" href="#results">See the model evidence</a>
|
| 525 |
+
</div>
|
| 526 |
+
</div>
|
| 527 |
+
</section>
|
| 528 |
+
|
| 529 |
+
<section class="section light" id="india">
|
| 530 |
+
<div class="wrap">
|
| 531 |
+
<p class="eyebrow">Why now</p>
|
| 532 |
+
<h2>India has already built the solar base. The next layer is intelligence.</h2>
|
| 533 |
+
<p class="lede">MNRE reports 150.26 GW of cumulative solar capacity as of March 31, 2026, including 25.73 GW of grid-connected rooftop solar. PIB's India Solar Momentum note shows how fast this moved: from 3 GW in 2014 to 129.92 GW by October 2025, with nearly 24 lakh PM Surya Ghar households already solarised by December 2025.</p>
|
| 534 |
+
<div class="stats">
|
| 535 |
+
<div class="stat"><strong>150.26 GW</strong><span>solar capacity reported by MNRE</span></div>
|
| 536 |
+
<div class="stat"><strong>25.73 GW</strong><span>grid-connected rooftop solar</span></div>
|
| 537 |
+
<div class="stat"><strong>24 lakh</strong><span>PM Surya Ghar households noted by PIB</span></div>
|
| 538 |
+
<div class="stat"><strong>1 crore</strong><span>national rooftop household ambition</span></div>
|
| 539 |
+
</div>
|
| 540 |
+
<div class="wide-image">
|
| 541 |
+
<img src="/assets/case_study/capabl_rooftop_infrastructure.webp" alt="Indian apartment society rooftop solar, battery storage, EV charging, and transformer infrastructure">
|
| 542 |
+
</div>
|
| 543 |
+
<p class="source-line">Context sources: <a href="https://mnre.gov.in/en/physical-progress/">MNRE Physical Achievements</a> and <a href="https://static.pib.gov.in/WriteReadData/specificdocs/documents/2025/dec/doc2025126720001.pdf">PIB India Solar Momentum</a>.</p>
|
| 544 |
+
<div class="infographic" id="journey" aria-label="GridOps India solar intelligence journey infographic">
|
| 545 |
+
<div class="infographic-scroll">
|
| 546 |
+
<img src="/assets/case_study/capabl_energy_journey_infographic.svg" alt="Journey from India's rooftop solar scale to local microgrid complexity, Capabl Machine dispatch, and community outcomes">
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
</div>
|
| 550 |
+
</section>
|
| 551 |
+
|
| 552 |
+
<section class="section soft">
|
| 553 |
+
<div class="wrap split">
|
| 554 |
+
<div>
|
| 555 |
+
<p class="eyebrow">The new operating problem</p>
|
| 556 |
+
<h2>A rooftop solar site is no longer just a bill reducer.</h2>
|
| 557 |
+
<p class="lede">An apartment society, campus, hospital, market, cold storage unit, EV hub, or rural microgrid is becoming a small energy business. It has solar, battery, grid tariffs, backup fuel, comfort expectations, outage risk, and sometimes export or demand-response opportunity.</p>
|
| 558 |
+
<p class="lede">Running that well usually needs a capable energy manager. GridOps asks a sharper question: can we turn that expertise into a Capabl Machine?</p>
|
| 559 |
+
<div class="uses" aria-label="Example users">
|
| 560 |
+
<span class="use">Apartment societies</span>
|
| 561 |
+
<span class="use">RWAs and townships</span>
|
| 562 |
+
<span class="use">Schools and campuses</span>
|
| 563 |
+
<span class="use">Hospitals and clinics</span>
|
| 564 |
+
<span class="use">EV charging hubs</span>
|
| 565 |
+
<span class="use">Rural microgrids</span>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
<div class="image-frame">
|
| 569 |
+
<img src="/assets/case_study/capabl_society_operator.webp" alt="Indian society manager and solar installer reviewing microgrid intelligence">
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
</section>
|
| 573 |
+
|
| 574 |
+
<section class="section light" id="machine">
|
| 575 |
+
<div class="wrap split reverse">
|
| 576 |
+
<div class="image-frame">
|
| 577 |
+
<img src="/assets/case_study/capabl_neighbourhood_dispatch.webp" alt="Indian neighbourhood microgrid with solar, battery, EV charging, grid connection, and local dispatch">
|
| 578 |
+
</div>
|
| 579 |
+
<div>
|
| 580 |
+
<p class="eyebrow">What intelligence unlocks</p>
|
| 581 |
+
<h2>From solar asset to earning, resilient infrastructure.</h2>
|
| 582 |
+
<p class="lede">A model that understands local state can do more than answer questions. It can decide. Charge before a price spike. Save battery before an outage. Use diesel only when it protects critical load. Reduce peak demand. Export or shift load when the economics make sense.</p>
|
| 583 |
+
<div class="idea-grid">
|
| 584 |
+
<article class="idea">
|
| 585 |
+
<h3>Keep lights on</h3>
|
| 586 |
+
<p>Prioritise battery and backup for high-risk hours instead of wasting them early.</p>
|
| 587 |
+
</article>
|
| 588 |
+
<article class="idea">
|
| 589 |
+
<h3>Lower operating cost</h3>
|
| 590 |
+
<p>Buy less from the grid during expensive windows and avoid unnecessary diesel burn.</p>
|
| 591 |
+
</article>
|
| 592 |
+
<article class="idea">
|
| 593 |
+
<h3>Earn from flexibility</h3>
|
| 594 |
+
<p>Make solar, storage, EV chargers, and flexible loads act like a coordinated local power plant.</p>
|
| 595 |
+
</article>
|
| 596 |
+
</div>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
</section>
|
| 600 |
+
|
| 601 |
+
<section class="section dark">
|
| 602 |
+
<div class="wrap">
|
| 603 |
+
<p class="eyebrow">How we built the Capabl Machine</p>
|
| 604 |
+
<h2>Environment first. Model second. Evidence always.</h2>
|
| 605 |
+
<p class="lede">GridOps is an OpenEnv microgrid environment with a stable API. The model sees hourly observations and must output one valid action: <code>{"battery_dispatch": ..., "diesel_dispatch": ..., "demand_shedding": ...}</code>. No vague prose. No fake expertise. The environment executes the action and scores the outcome.</p>
|
| 606 |
+
<div class="flow">
|
| 607 |
+
<article class="flow-step">
|
| 608 |
+
<span>01</span>
|
| 609 |
+
<h3>Simulate reality</h3>
|
| 610 |
+
<p>Demand, solar, price, battery, fuel, heatwave stress, and outage constraints.</p>
|
| 611 |
+
</article>
|
| 612 |
+
<article class="flow-step">
|
| 613 |
+
<span>02</span>
|
| 614 |
+
<h3>Teach the curriculum</h3>
|
| 615 |
+
<p>1,418 traces across normal, heatwave, crisis, and targeted edge cases.</p>
|
| 616 |
+
</article>
|
| 617 |
+
<article class="flow-step">
|
| 618 |
+
<span>03</span>
|
| 619 |
+
<h3>Train compactly</h3>
|
| 620 |
+
<p>Qwen2.5-3B-Instruct with QLoRA SFT to learn reliable JSON actions.</p>
|
| 621 |
+
</article>
|
| 622 |
+
<article class="flow-step">
|
| 623 |
+
<span>04</span>
|
| 624 |
+
<h3>Evaluate honestly</h3>
|
| 625 |
+
<p>Heldout seeds compare do-nothing, trained model, and oracle policy.</p>
|
| 626 |
+
</article>
|
| 627 |
+
</div>
|
| 628 |
+
</div>
|
| 629 |
+
</section>
|
| 630 |
+
|
| 631 |
+
<section class="section soft" id="results">
|
| 632 |
+
<div class="wrap">
|
| 633 |
+
<p class="eyebrow">What changed after training</p>
|
| 634 |
+
<h2>The model learned useful battery behaviour, not a shortcut.</h2>
|
| 635 |
+
<p class="lede">Across heldout seeds, the SFT model beat the do-nothing baseline on every task, reached 99.85% valid JSON actions, reduced blackout energy, and used the battery heavily in the hardest crisis setting.</p>
|
| 636 |
+
<div class="table-wrap" role="region" aria-label="GridOps holdout evaluation table" tabindex="0">
|
| 637 |
+
<table>
|
| 638 |
+
<thead>
|
| 639 |
+
<tr>
|
| 640 |
+
<th>Policy</th>
|
| 641 |
+
<th>Normal</th>
|
| 642 |
+
<th>Heatwave</th>
|
| 643 |
+
<th>Crisis</th>
|
| 644 |
+
<th>Average</th>
|
| 645 |
+
<th>What it means</th>
|
| 646 |
+
</tr>
|
| 647 |
+
</thead>
|
| 648 |
+
<tbody>
|
| 649 |
+
<tr>
|
| 650 |
+
<td>Do-nothing baseline</td>
|
| 651 |
+
<td>0.5820</td>
|
| 652 |
+
<td>0.5057</td>
|
| 653 |
+
<td>0.4522</td>
|
| 654 |
+
<td>0.5133</td>
|
| 655 |
+
<td>Solar exists, but no intelligent dispatch.</td>
|
| 656 |
+
</tr>
|
| 657 |
+
<tr>
|
| 658 |
+
<td><strong>SFT action model</strong></td>
|
| 659 |
+
<td><strong>0.6615</strong></td>
|
| 660 |
+
<td><strong>0.7300</strong></td>
|
| 661 |
+
<td><strong>0.6648</strong></td>
|
| 662 |
+
<td><strong>0.6854</strong></td>
|
| 663 |
+
<td>Reliable actions, lower outage impact, better energy use.</td>
|
| 664 |
+
</tr>
|
| 665 |
+
<tr>
|
| 666 |
+
<td>Oracle policy</td>
|
| 667 |
+
<td>0.7932</td>
|
| 668 |
+
<td>0.8087</td>
|
| 669 |
+
<td>0.7046</td>
|
| 670 |
+
<td>0.7688</td>
|
| 671 |
+
<td>Reference headroom for the next training phase.</td>
|
| 672 |
+
</tr>
|
| 673 |
+
</tbody>
|
| 674 |
+
</table>
|
| 675 |
+
</div>
|
| 676 |
+
<div class="stats">
|
| 677 |
+
<div class="stat"><strong>+33%</strong><span>average score lift vs do-nothing</span></div>
|
| 678 |
+
<div class="stat"><strong>99.85%</strong><span>valid JSON action rate</span></div>
|
| 679 |
+
<div class="stat"><strong>2,898 kWh</strong><span>crisis battery throughput</span></div>
|
| 680 |
+
<div class="stat"><strong>979 kWh</strong><span>crisis blackout kWh vs 2,426 baseline</span></div>
|
| 681 |
+
</div>
|
| 682 |
+
<div class="plots">
|
| 683 |
+
<figure class="plot">
|
| 684 |
+
<img src="/evals/plots/gridops_holdout_scores.png" alt="Holdout score comparison for do-nothing, SFT, and oracle policies">
|
| 685 |
+
<figcaption>Holdout score by task.</figcaption>
|
| 686 |
+
</figure>
|
| 687 |
+
<figure class="plot">
|
| 688 |
+
<img src="/evals/plots/gridops_battery_throughput.png" alt="Battery throughput by policy and task">
|
| 689 |
+
<figcaption>Battery throughput confirms active dispatch.</figcaption>
|
| 690 |
+
</figure>
|
| 691 |
+
<figure class="plot">
|
| 692 |
+
<img src="/evals/plots/gridops_blackout_kwh.png" alt="Blackout kilowatt hours by policy and task">
|
| 693 |
+
<figcaption>Blackout energy falls sharply versus baseline.</figcaption>
|
| 694 |
+
</figure>
|
| 695 |
+
<figure class="plot">
|
| 696 |
+
<img src="/evals/plots/gridops_sft_training_curve.png" alt="SFT training loss and token accuracy curve">
|
| 697 |
+
<figcaption>Real training loss and token accuracy from the run.</figcaption>
|
| 698 |
+
</figure>
|
| 699 |
+
</div>
|
| 700 |
+
</div>
|
| 701 |
+
</section>
|
| 702 |
+
|
| 703 |
+
<section class="section dark">
|
| 704 |
+
<div class="wrap">
|
| 705 |
+
<p class="eyebrow">The larger point</p>
|
| 706 |
+
<h2>Capable people will still matter. But every site should not need one full-time.</h2>
|
| 707 |
+
<p class="lede">The opportunity is to package operational judgement into a machine layer that local teams can trust: society managers, solar installers, facility operators, EV hub owners, campus admins, and microgrid developers. That is the Capabl Machines thesis in one working environment.</p>
|
| 708 |
+
<div class="ledger">
|
| 709 |
+
<a href="https://huggingface.co/spaces/77ethers/gridops">
|
| 710 |
+
<strong>Live OpenEnv Space</strong>
|
| 711 |
+
<span>Runnable dashboard and API surface.</span>
|
| 712 |
+
</a>
|
| 713 |
+
<a href="https://huggingface.co/77ethers/gridops-models">
|
| 714 |
+
<strong>Model repository</strong>
|
| 715 |
+
<span>Adapter, model card, plots, and evaluation summary.</span>
|
| 716 |
+
</a>
|
| 717 |
+
<a href="https://github.com/capabl-machines/gridops/tree/codex/gridops-sft-pipeline">
|
| 718 |
+
<strong>Source branch</strong>
|
| 719 |
+
<span>Training scripts, validation harness, notebook, and docs.</span>
|
| 720 |
+
</a>
|
| 721 |
+
</div>
|
| 722 |
+
</div>
|
| 723 |
+
</section>
|
| 724 |
+
</main>
|
| 725 |
+
|
| 726 |
+
<footer class="footer">
|
| 727 |
+
<div class="wrap">
|
| 728 |
+
<span>GridOps by Capabl Machines</span>
|
| 729 |
+
<span>Community energy intelligence for India's solar future</span>
|
| 730 |
+
</div>
|
| 731 |
+
</footer>
|
| 732 |
+
</body>
|
| 733 |
+
</html>
|
gridops/server/static/index.html
CHANGED
|
@@ -47,6 +47,12 @@
|
|
| 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);
|
|
@@ -518,6 +524,7 @@
|
|
| 518 |
<span><strong id="hourDisp">6:00</strong></span>
|
| 519 |
<span>Day <strong id="dayDisp">1</strong></span>
|
| 520 |
<span>Step <strong id="stepDisp">0</strong>/72</span>
|
|
|
|
| 521 |
</div>
|
| 522 |
</header>
|
| 523 |
|
|
|
|
| 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 a {
|
| 51 |
+
color: var(--cyan);
|
| 52 |
+
font-weight: 700;
|
| 53 |
+
text-decoration: none;
|
| 54 |
+
}
|
| 55 |
+
.header-info a:hover { color: var(--text); }
|
| 56 |
.header-info .tag {
|
| 57 |
padding: 3px 10px; border-radius: 4px;
|
| 58 |
border: 1px solid var(--border);
|
|
|
|
| 524 |
<span><strong id="hourDisp">6:00</strong></span>
|
| 525 |
<span>Day <strong id="dayDisp">1</strong></span>
|
| 526 |
<span>Step <strong id="stepDisp">0</strong>/72</span>
|
| 527 |
+
<a href="/case-study">Case Study</a>
|
| 528 |
</div>
|
| 529 |
</header>
|
| 530 |
|
inference.py
CHANGED
|
@@ -11,7 +11,6 @@ MANDATORY
|
|
| 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 |
|
|
@@ -26,73 +25,13 @@ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
|
|
| 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 = 500 # higher to support reasoning models that emit thinking before JSON
|
| 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: 100% rebounds next hour! Rs 40/kWh penalty.
|
| 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."""
|
|
@@ -125,12 +64,7 @@ def run_task(client: OpenAI, env: GridOpsEnvironment, task_id: str, seed: int =
|
|
| 125 |
|
| 126 |
messages.append({"role": "assistant", "content": reply})
|
| 127 |
|
| 128 |
-
|
| 129 |
-
action = GridOpsAction(
|
| 130 |
-
battery_dispatch=max(-1.0, min(1.0, float(action_dict.get("battery_dispatch", 0.0)))),
|
| 131 |
-
diesel_dispatch=max(0.0, min(1.0, float(action_dict.get("diesel_dispatch", 0.0)))),
|
| 132 |
-
demand_shedding=max(0.0, min(1.0, float(action_dict.get("demand_shedding", 0.0)))),
|
| 133 |
-
)
|
| 134 |
obs = env.step(action)
|
| 135 |
obs_dict = obs.model_dump()
|
| 136 |
|
|
|
|
| 11 |
- Participants must use OpenAI Client for all LLM calls using above variables
|
| 12 |
"""
|
| 13 |
|
|
|
|
| 14 |
import os
|
| 15 |
import sys
|
| 16 |
|
|
|
|
| 25 |
sys.path.insert(0, os.path.dirname(__file__))
|
| 26 |
from gridops.server.environment import GridOpsEnvironment
|
| 27 |
from gridops.models import GridOpsAction
|
| 28 |
+
from gridops.prompting import SYSTEM_PROMPT, format_observation, parse_action
|
| 29 |
|
| 30 |
TASKS = ["task_1_normal", "task_2_heatwave", "task_3_crisis"]
|
| 31 |
MAX_STEPS = 72
|
| 32 |
TEMPERATURE = 0.1
|
| 33 |
MAX_TOKENS = 500 # higher to support reasoning models that emit thinking before JSON
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
def run_task(client: OpenAI, env: GridOpsEnvironment, task_id: str, seed: int = 42) -> dict:
|
| 37 |
"""Run one full episode on a task, return grade."""
|
|
|
|
| 64 |
|
| 65 |
messages.append({"role": "assistant", "content": reply})
|
| 66 |
|
| 67 |
+
action = parse_action(reply, default=GridOpsAction())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
obs = env.step(action)
|
| 69 |
obs_dict = obs.model_dump()
|
| 70 |
|
pyproject.toml
CHANGED
|
@@ -5,7 +5,7 @@ description = "Community Microgrid RL Environment — OpenEnv Hackathon"
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
dependencies = [
|
| 8 |
-
"openenv-core>=0.2.
|
| 9 |
"fastapi>=0.104.0",
|
| 10 |
"uvicorn[standard]>=0.24.0",
|
| 11 |
"pydantic>=2.0",
|
|
@@ -16,12 +16,16 @@ dependencies = [
|
|
| 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"
|
|
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
dependencies = [
|
| 8 |
+
"openenv-core>=0.2.3",
|
| 9 |
"fastapi>=0.104.0",
|
| 10 |
"uvicorn[standard]>=0.24.0",
|
| 11 |
"pydantic>=2.0",
|
|
|
|
| 16 |
]
|
| 17 |
|
| 18 |
[project.optional-dependencies]
|
| 19 |
+
dev = ["pytest", "httpx", "matplotlib"]
|
| 20 |
inference = ["openai"]
|
| 21 |
|
| 22 |
[project.scripts]
|
| 23 |
server = "server.app:main"
|
| 24 |
|
| 25 |
+
[tool.setuptools.packages.find]
|
| 26 |
+
include = ["gridops*", "server*"]
|
| 27 |
+
exclude = ["notebooks*", "sft_traces*", "tests*", "round_1*", "portfolio_env*"]
|
| 28 |
+
|
| 29 |
[build-system]
|
| 30 |
requires = ["setuptools>=68.0"]
|
| 31 |
build-backend = "setuptools.build_meta"
|
scripts/oracle_test.py
CHANGED
|
@@ -8,119 +8,30 @@ Grid is the slack variable (absorbs residual up to ±200 kW).
|
|
| 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"]) + 6) % 24 # episode starts at 6 AM
|
| 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
|
| 114 |
|
| 115 |
|
| 116 |
def heuristic_always_discharge(obs: dict) -> GridOpsAction:
|
| 117 |
"""Bad: always discharge battery → empty for evening → blackout."""
|
| 118 |
-
return
|
| 119 |
|
| 120 |
|
| 121 |
def heuristic_always_diesel(obs: dict) -> GridOpsAction:
|
| 122 |
"""Wasteful: always run diesel → hemorrhages money at Rs 25/kWh."""
|
| 123 |
-
return
|
| 124 |
|
| 125 |
|
| 126 |
def run_episode(env, policy_fn, task_id="task_1_normal", seed=42):
|
|
@@ -129,7 +40,7 @@ def run_episode(env, policy_fn, task_id="task_1_normal", seed=42):
|
|
| 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:
|
|
|
|
| 8 |
import sys
|
| 9 |
sys.path.insert(0, ".")
|
| 10 |
|
|
|
|
| 11 |
from gridops.server.environment import GridOpsEnvironment
|
| 12 |
from gridops.models import GridOpsAction
|
| 13 |
+
from gridops.policies import (
|
| 14 |
+
always_diesel_policy,
|
| 15 |
+
always_discharge_policy,
|
| 16 |
+
do_nothing_policy,
|
| 17 |
+
oracle_policy,
|
| 18 |
+
)
|
| 19 |
from gridops.tasks.definitions import TASKS
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def heuristic_do_nothing(obs: dict) -> GridOpsAction:
|
| 23 |
"""Baseline: do nothing. Grid handles everything as slack."""
|
| 24 |
+
return do_nothing_policy(obs)
|
| 25 |
|
| 26 |
|
| 27 |
def heuristic_always_discharge(obs: dict) -> GridOpsAction:
|
| 28 |
"""Bad: always discharge battery → empty for evening → blackout."""
|
| 29 |
+
return always_discharge_policy(obs)
|
| 30 |
|
| 31 |
|
| 32 |
def heuristic_always_diesel(obs: dict) -> GridOpsAction:
|
| 33 |
"""Wasteful: always run diesel → hemorrhages money at Rs 25/kWh."""
|
| 34 |
+
return always_diesel_policy(obs)
|
| 35 |
|
| 36 |
|
| 37 |
def run_episode(env, policy_fn, task_id="task_1_normal", seed=42):
|
|
|
|
| 40 |
obs_dict = obs.model_dump()
|
| 41 |
|
| 42 |
for _ in range(72):
|
| 43 |
+
action = policy_fn(obs_dict, task_id) if policy_fn is oracle_policy else policy_fn(obs_dict)
|
| 44 |
obs = env.step(action)
|
| 45 |
obs_dict = obs.model_dump()
|
| 46 |
if obs.done:
|