77ethers commited on
Commit
e7ae456
·
verified ·
1 Parent(s): feaaefa

Deploy India case study and premium visuals

Browse files
.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

  • SHA256: f1d8677e56f3f9be4ab500549670afe71c27207d03d0b843bbea0bfefeb15aff
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
assets/case_study/capabl_neighbourhood_dispatch.webp ADDED

Git LFS Details

  • SHA256: f0b9fd73e60557143498a3284498f2c8eb782922a7cf103360982c2c7432ed81
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
assets/case_study/capabl_rooftop_infrastructure.webp ADDED

Git LFS Details

  • SHA256: b5c809fe297e5163b65aedba8cae2fa93809cae883a88448ffa1ba5ff58c7d8d
  • Pointer size: 131 Bytes
  • Size of remote file: 208 kB
assets/case_study/capabl_society_operator.webp ADDED
assets/case_study/gridops_control_room.webp ADDED

Git LFS Details

  • SHA256: fce32ef4585cfbf2410118f7468a44e3bf17373974abe0e02c764199bc24786b
  • Pointer size: 131 Bytes
  • Size of remote file: 408 kB
assets/case_study/gridops_environment_loop.webp ADDED

Git LFS Details

  • SHA256: 9afefd12b7d6de03f9ce059ded74a58db9e37bb899baa406cc71ab6e069c66b5
  • Pointer size: 131 Bytes
  • Size of remote file: 133 kB
assets/case_study/gridops_hero_microgrid.webp ADDED

Git LFS Details

  • SHA256: 8c8ad95966e234da3bdb1354c099453c77ef38c8a8bc412e305d2b144ed14408
  • Pointer size: 131 Bytes
  • Size of remote file: 772 kB
assets/case_study/gridops_impact_split.webp ADDED

Git LFS Details

  • SHA256: 472c652796d3040c01c8daa062f98d0292ace8238ff69a93be672f725d5dcc03
  • Pointer size: 131 Bytes
  • Size of remote file: 386 kB
assets/case_study/india_microgrid_operator_layer.webp ADDED

Git LFS Details

  • SHA256: b1fa2b8e160d87b060f5173bed5078b4c109f7be1c9c6f3940c01056ecf571c4
  • Pointer size: 131 Bytes
  • Size of remote file: 284 kB
assets/case_study/india_microgrid_value_flows.webp ADDED

Git LFS Details

  • SHA256: 81b8113d5919929985056744e4b59c8e6cb0a33b37465b6735d1a7541979b366
  • Pointer size: 131 Bytes
  • Size of remote file: 419 kB
assets/case_study/india_solar_society_hero.webp ADDED

Git LFS Details

  • SHA256: 5f32f6f3178c7064f2a646c7c6438286730577bef490892cbafdd192a3c3fb32
  • Pointer size: 131 Bytes
  • Size of remote file: 615 kB
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 redirect to dashboard ───────────────────────────────────────────
 
 
 
 
 
105
 
106
  @app.get("/")
107
  def root_serve():
108
  """Serve dashboard directly at root so HF Space iframe shows the UI."""
109
- from fastapi.responses import HTMLResponse
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
- from fastapi.responses import HTMLResponse
118
- index = Path(__file__).parent / "static" / "index.html"
 
 
 
 
 
 
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
- action_dict = parse_action(reply)
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.0",
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 GridOpsAction(battery_dispatch=0.0, diesel_dispatch=0.0, demand_shedding=0.0)
114
 
115
 
116
  def heuristic_always_discharge(obs: dict) -> GridOpsAction:
117
  """Bad: always discharge battery → empty for evening → blackout."""
118
- return GridOpsAction(battery_dispatch=1.0, diesel_dispatch=0.0, demand_shedding=0.0)
119
 
120
 
121
  def heuristic_always_diesel(obs: dict) -> GridOpsAction:
122
  """Wasteful: always run diesel → hemorrhages money at Rs 25/kWh."""
123
- return GridOpsAction(battery_dispatch=0.0, diesel_dispatch=1.0, demand_shedding=0.0)
124
 
125
 
126
  def run_episode(env, policy_fn, task_id="task_1_normal", seed=42):
@@ -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: