77ethers commited on
Commit
fcb451b
·
verified ·
1 Parent(s): 2611bbc

Upload folder using huggingface_hub

Browse files
gridops/models.py CHANGED
@@ -68,3 +68,16 @@ class GridOpsObservation(Observation):
68
  cost_this_step: float = Field(default=0.0, description="Cost incurred this step (Rs)")
69
  grid_kw_this_step: float = Field(default=0.0, description="Grid import(+)/export(-) this step")
70
  narration: str = Field(default="", description="Human-readable situation summary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  cost_this_step: float = Field(default=0.0, description="Cost incurred this step (Rs)")
69
  grid_kw_this_step: float = Field(default=0.0, description="Grid import(+)/export(-) this step")
70
  narration: str = Field(default="", description="Human-readable situation summary")
71
+
72
+ # Detailed energy flows (kW, this step)
73
+ flow_solar: float = Field(default=0.0, description="Solar supply kW")
74
+ flow_grid_import: float = Field(default=0.0, description="Grid import kW")
75
+ flow_grid_export: float = Field(default=0.0, description="Grid export kW")
76
+ flow_battery_discharge: float = Field(default=0.0, description="Battery discharge kW (delivered)")
77
+ flow_battery_charge: float = Field(default=0.0, description="Battery charge kW (consumed)")
78
+ flow_diesel: float = Field(default=0.0, description="Diesel supply kW")
79
+ flow_demand: float = Field(default=0.0, description="Effective demand kW")
80
+ flow_blackout: float = Field(default=0.0, description="Unmet demand kW")
81
+ flow_shed: float = Field(default=0.0, description="Demand shed kW")
82
+ flow_total_supply: float = Field(default=0.0, description="Total supply kW")
83
+ flow_total_consumption: float = Field(default=0.0, description="Total consumption kW")
gridops/server/environment.py CHANGED
@@ -18,6 +18,7 @@ from gridops.simulation.physics import (
18
  BATTERY_CAPACITY_KWH,
19
  DIESEL_TANK_KWH,
20
  MicrogridState,
 
21
  )
22
  from gridops.simulation.scenarios import ScenarioConfig, make_forecast
23
  from gridops.tasks.definitions import TASKS
@@ -79,7 +80,19 @@ class GridOpsEnvironment(Environment):
79
  self._history = []
80
  self._grade = None
81
 
82
- return self._make_observation(reward=0.0, done=False, narration="Episode started. Day 1 begins.")
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  def step(
85
  self,
@@ -105,6 +118,8 @@ class GridOpsEnvironment(Environment):
105
  diesel_fuel_cap=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
106
  )
107
 
 
 
108
  self._history.append({
109
  "hour": h,
110
  "demand": float(self._demand[h]),
@@ -175,6 +190,17 @@ class GridOpsEnvironment(Environment):
175
  cost_this_step=self._micro.last_cost,
176
  grid_kw_this_step=self._micro.last_grid_kw,
177
  narration=narration,
 
 
 
 
 
 
 
 
 
 
 
178
  done=done,
179
  reward=reward,
180
  )
 
18
  BATTERY_CAPACITY_KWH,
19
  DIESEL_TANK_KWH,
20
  MicrogridState,
21
+ StepFlows,
22
  )
23
  from gridops.simulation.scenarios import ScenarioConfig, make_forecast
24
  from gridops.tasks.definitions import TASKS
 
80
  self._history = []
81
  self._grade = None
82
 
83
+ # Compute initial flows so dashboard shows real data on reset
84
+ h0_demand = float(self._demand[0])
85
+ h0_solar = float(self._solar[0])
86
+ h0_grid = max(0.0, h0_demand - h0_solar)
87
+ self._last_flows = StepFlows(
88
+ solar_kw=h0_solar,
89
+ grid_import_kw=h0_grid,
90
+ effective_demand_kw=h0_demand,
91
+ total_supply_kw=h0_solar + h0_grid,
92
+ total_consumption_kw=h0_demand,
93
+ )
94
+
95
+ return self._make_observation(reward=0.0, done=False, narration="Episode started. Day 1, 06:00. Make your first decision.")
96
 
97
  def step(
98
  self,
 
118
  diesel_fuel_cap=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
119
  )
120
 
121
+ self._last_flows = result.flows
122
+
123
  self._history.append({
124
  "hour": h,
125
  "demand": float(self._demand[h]),
 
190
  cost_this_step=self._micro.last_cost,
191
  grid_kw_this_step=self._micro.last_grid_kw,
192
  narration=narration,
193
+ flow_solar=self._last_flows.solar_kw,
194
+ flow_grid_import=self._last_flows.grid_import_kw,
195
+ flow_grid_export=self._last_flows.grid_export_kw,
196
+ flow_battery_discharge=self._last_flows.battery_discharge_kw,
197
+ flow_battery_charge=self._last_flows.battery_charge_kw,
198
+ flow_diesel=self._last_flows.diesel_kw,
199
+ flow_demand=self._last_flows.effective_demand_kw,
200
+ flow_blackout=self._last_flows.blackout_kw,
201
+ flow_shed=self._last_flows.shed_kw,
202
+ flow_total_supply=self._last_flows.total_supply_kw,
203
+ flow_total_consumption=self._last_flows.total_consumption_kw,
204
  done=done,
205
  reward=reward,
206
  )
gridops/server/static/index.html CHANGED
@@ -115,6 +115,14 @@
115
  border-bottom: 1px solid rgba(30,41,59,0.5);
116
  }
117
  .info-row .label { color: var(--text-dim); }
 
 
 
 
 
 
 
 
118
 
119
  /* ── Center Panel (Visualization) ─────────────────────────── */
120
  .panel-center {
@@ -122,17 +130,17 @@
122
  overflow: hidden;
123
  }
124
  .chart-container {
125
- flex: 1; padding: 12px; min-height: 0;
126
  position: relative;
127
  }
128
  .chart-container canvas { width: 100% !important; height: 100% !important; }
129
 
130
  /* Energy flow diagram */
131
  .flow-section {
132
- height: 180px; padding: 12px 20px;
133
  border-top: 1px solid var(--border);
134
  display: flex; align-items: center; justify-content: center;
135
- position: relative;
136
  }
137
  .flow-grid {
138
  display: grid;
@@ -150,22 +158,50 @@
150
  .flow-node .node-icon { font-size: 20px; display: block; margin-bottom: 2px; }
151
  .flow-node .node-val { color: var(--cyan); font-weight: 600; font-size: 13px; }
152
  .flow-node .node-label { color: var(--text-dim); font-size: 9px; text-transform: uppercase; letter-spacing: 1px; }
153
- .flow-node.solar { border-color: var(--yellow); }
154
- .flow-node.battery { border-color: var(--blue); }
155
- .flow-node.grid { border-color: var(--green); }
156
- .flow-node.diesel { border-color: var(--orange); }
157
- .flow-node.community { border-color: var(--cyan); background: rgba(6,182,212,0.08); grid-column: 2; }
158
- .flow-node.blackout { border-color: var(--red); animation: pulse-red 1s infinite; }
159
  @keyframes pulse-red { 0%,100% { box-shadow: 0 0 0 rgba(239,68,68,0); } 50% { box-shadow: 0 0 16px rgba(239,68,68,0.4); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  .flow-arrow { color: var(--text-dim); font-size: 14px; display: flex; align-items: center; justify-content: center; }
161
 
162
  /* Narration bar */
163
  .narration {
164
- padding: 10px 20px; font-size: 12px;
165
  border-top: 1px solid var(--border);
166
  background: rgba(6,182,212,0.04);
167
  color: var(--text-dim);
168
- min-height: 36px;
169
  }
170
  .narration strong { color: var(--cyan); }
171
 
@@ -361,21 +397,19 @@
361
 
362
  <div class="story">
363
  <p>
364
- Imagine you're in charge of keeping the lights on for <strong>100 homes</strong> in an Indian city during a brutal summer.
365
  </p>
366
- <p>
367
- You have <span class="highlight">rooftop solar panels</span> (free energy from the sun!),
368
- a big <strong>community battery</strong> (stores energy for later),
369
- an expensive <span class="danger">diesel backup generator</span> (costs a fortune),
370
- and a connection to the <strong>national power grid</strong> (prices change every hour).
371
  </p>
372
- <p>
373
- Every hour, you make 3 decisions: How much power to <strong>buy or sell</strong> from the grid.
374
- Whether to turn on the <span class="danger">diesel generator</span>.
375
- And whether to ask residents to <strong>use less power</strong> for a bit.
376
- </p>
377
- <p>
378
- The battery? You don't control it directly. It <strong>automatically absorbs</strong> whatever energy is left over (or fills the gap when there's not enough).
379
  </p>
380
  </div>
381
 
@@ -463,8 +497,9 @@
463
  </div>
464
  <div class="header-info">
465
  <span id="taskTag" class="tag easy">Task 1: Normal</span>
466
- <span>Hour <strong id="hourDisp">0</strong>/72</span>
467
  <span>Day <strong id="dayDisp">1</strong></span>
 
468
  </div>
469
  </header>
470
 
@@ -472,7 +507,51 @@
472
  <!-- ── Left Panel ──────────────────────────────────────────── -->
473
  <div class="panel-left">
474
  <div>
475
- <div class="section-title">Task Selection</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  <select id="taskSelect">
477
  <option value="task_1_normal">Task 1 — Normal Summer (Easy)</option>
478
  <option value="task_2_heatwave">Task 2 — Heatwave + Clouds (Medium)</option>
@@ -481,44 +560,28 @@
481
  </div>
482
 
483
  <div>
484
- <div class="section-title">Agent Controls</div>
485
  <div class="control-group">
486
- <label>Battery <span class="val" id="batteryVal">0 kW</span></label>
487
  <input type="range" id="batterySlider" min="-100" max="100" value="0" step="1">
488
  <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim)">
489
- <span>Charge 100kW</span><span>Discharge 100kW</span>
490
  </div>
491
  </div>
492
  <div class="control-group">
493
- <label>Diesel Output <span class="val" id="dieselVal">0 kW</span></label>
494
  <input type="range" id="dieselSlider" min="0" max="100" value="0" step="1">
495
- <div style="font-size:9px;color:var(--text-dim)">Rs 25/kWh + Rs 100 startup</div>
496
  </div>
497
  <div class="control-group">
498
- <label>Demand Shedding <span class="val" id="shedVal">0%</span></label>
499
  <input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
500
- <div style="font-size:9px;color:var(--text-dim)">50% rebounds next hour</div>
501
  </div>
502
  </div>
503
 
504
- <button class="btn-step" id="stepBtn">NEXT HOUR </button>
505
- <button class="btn-reset" id="resetBtn">Reset Episode</button>
506
-
507
- <div>
508
- <div class="section-title">Current Conditions</div>
509
- <div class="info-row"><span class="label">Grid Price</span><span id="priceInfo">—</span></div>
510
- <div class="info-row"><span class="label">Demand</span><span id="demandInfo">—</span></div>
511
- <div class="info-row"><span class="label">Solar</span><span id="solarInfo">—</span></div>
512
- <div class="info-row"><span class="label">Battery SOC</span><span id="socInfo">—</span></div>
513
- <div class="info-row"><span class="label">Diesel Fuel</span><span id="fuelInfo">—</span></div>
514
- </div>
515
-
516
- <div>
517
- <div class="section-title">4-Hour Forecast</div>
518
- <div class="info-row"><span class="label">Demand</span><span id="fcDemand">—</span></div>
519
- <div class="info-row"><span class="label">Solar</span><span id="fcSolar">—</span></div>
520
- <div class="info-row"><span class="label">Price</span><span id="fcPrice">—</span></div>
521
- </div>
522
  </div>
523
 
524
  <!-- ── Center Panel ────────────────────────────────────────── -->
@@ -526,82 +589,113 @@
526
  <div class="chart-container">
527
  <canvas id="mainChart"></canvas>
528
  </div>
529
- <div class="flow-section">
530
- <div class="flow-grid">
531
- <div class="flow-node solar">
532
- <span class="node-icon">☀️</span>
533
- <span class="node-val" id="flowSolar">0</span>
534
- <span class="node-label">Solar kW</span>
535
- </div>
536
- <div class="flow-node community" id="flowCommunity">
537
- <span class="node-icon">🏘️</span>
538
- <span class="node-val" id="flowDemand">0</span>
539
- <span class="node-label">Demand kW</span>
540
- </div>
541
- <div class="flow-node grid">
542
- <span class="node-icon">⚡</span>
543
- <span class="node-val" id="flowGrid">0</span>
544
- <span class="node-label">Grid kW</span>
545
- </div>
546
- <div class="flow-node battery">
547
- <span class="node-icon">🔋</span>
548
- <span class="node-val" id="flowBattery">50%</span>
549
- <span class="node-label">Battery</span>
550
- </div>
551
- <div class="flow-node diesel">
552
- <span class="node-icon"></span>
553
- <span class="node-val" id="flowDiesel">0</span>
554
- <span class="node-label">Diesel kW</span>
 
 
 
 
 
 
 
 
555
  </div>
556
- <div class="flow-node" id="flowStatus" style="border-color:var(--green)">
557
- <span class="node-icon" id="statusIcon">✅</span>
558
- <span class="node-val" id="statusText" style="color:var(--green)">OK</span>
559
- <span class="node-label">Status</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  </div>
 
561
  </div>
562
  </div>
563
  <div class="narration" id="narration">
564
- <strong>Ready.</strong> Select a task and click "Next Hour" to begin.
565
  </div>
566
  </div>
567
 
568
  <!-- ── Right Panel ─────────────────────────────────────────── -->
569
  <div class="panel-right">
570
  <div class="grade-box hidden" id="gradeBox">
571
- <div class="grade-label">Episode Score</div>
572
  <div class="grade-value" id="gradeValue">—</div>
573
  <div class="grade-sub" id="gradeSub"></div>
574
  </div>
575
 
 
 
576
  <div class="score-card">
577
- <div class="sc-label">Reliability</div>
578
  <div class="sc-value good" id="relValue">100%</div>
579
  <div class="sc-bar"><div class="sc-bar-fill" id="relBar" style="width:100%;background:var(--green)"></div></div>
 
580
  </div>
581
 
582
  <div class="score-card">
583
- <div class="sc-label">Net Cost</div>
584
  <div class="sc-value cyan" id="costValue">Rs 0</div>
 
585
  </div>
586
 
587
  <div class="score-card">
588
- <div class="sc-label">Diesel Used</div>
589
- <div class="sc-value" id="dieselUsed" style="color:var(--orange)">0 kWh</div>
590
- </div>
591
-
592
- <div class="score-card">
593
- <div class="sc-label">Battery SOC</div>
594
  <div class="sc-value cyan" id="socValue">50%</div>
595
  <div class="sc-bar"><div class="sc-bar-fill" id="socBar" style="width:50%;background:var(--blue)"></div></div>
 
596
  </div>
597
 
598
  <div class="score-card">
599
- <div class="sc-label">Total Blackout</div>
600
  <div class="sc-value good" id="blackoutValue">0 kWh</div>
 
601
  </div>
602
 
603
  <div class="score-card">
604
- <div class="sc-label">Step Reward</div>
 
 
 
 
 
 
605
  <div class="sc-value cyan" id="rewardValue">—</div>
606
  </div>
607
  </div>
@@ -615,7 +709,7 @@ let running = false;
615
 
616
  // ── Chart setup ─────────────────────────────────────────────────
617
  const chartData = {
618
- demand: [], solar: [], price: [], soc: [], hours: [], blackout: []
619
  };
620
 
621
  const ctx = document.getElementById('mainChart').getContext('2d');
@@ -625,9 +719,10 @@ const chart = new Chart(ctx, {
625
  labels: chartData.hours,
626
  datasets: [
627
  { label: 'Demand (kW)', data: chartData.demand, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
628
- { label: 'Solar (kW)', data: chartData.solar, borderColor: '#eab308', backgroundColor: 'rgba(234,179,8,0.1)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
629
- { label: 'Price (Rs/kWh)', data: chartData.price, borderColor: '#22c55e', borderDash: [4,2], borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y1' },
630
- { label: 'Battery SOC', data: chartData.soc, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.15)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, fill: true, yAxisID: 'y2' },
 
631
  ]
632
  },
633
  options: {
@@ -653,15 +748,17 @@ const shedSlider = document.getElementById('shedSlider');
653
 
654
  batterySlider.oninput = () => {
655
  const v = batterySlider.value;
656
- const kw = (v / 100 * 100).toFixed(0);
657
- const label = v < 0 ? `Charge ${Math.abs(kw)} kW` : v > 0 ? `Discharge ${kw} kW` : '0 kW';
658
  document.getElementById('batteryVal').textContent = label;
659
  };
660
  dieselSlider.oninput = () => {
661
- document.getElementById('dieselVal').textContent = (dieselSlider.value / 100 * 100).toFixed(0) + ' kW';
 
662
  };
663
  shedSlider.oninput = () => {
664
- document.getElementById('shedVal').textContent = (shedSlider.value / 100 * 20).toFixed(0) + '%';
 
665
  };
666
 
667
  // ── API calls ───────────────────────────────────────────────────
@@ -673,6 +770,17 @@ async function apiPost(path, body) {
673
  return res.json();
674
  }
675
 
 
 
 
 
 
 
 
 
 
 
 
676
  async function resetEnv() {
677
  const taskId = document.getElementById('taskSelect').value;
678
  const body = { seed: 42, task_id: taskId };
@@ -684,6 +792,9 @@ async function resetEnv() {
684
  chartData.soc.length = 0;
685
  chartData.hours.length = 0;
686
  chartData.blackout.length = 0;
 
 
 
687
  chart.update();
688
  updateUI();
689
  document.getElementById('gradeBox').classList.add('hidden');
@@ -703,12 +814,7 @@ async function stepEnv() {
703
  obs = resp.observation || resp;
704
 
705
  // Update chart
706
- chartData.hours.push(obs.hour);
707
- chartData.demand.push(obs.demand_kw);
708
- chartData.solar.push(obs.solar_kw);
709
- chartData.price.push(obs.grid_price);
710
- chartData.soc.push(obs.battery_soc);
711
- chartData.blackout.push(obs.blackout_this_step || 0);
712
  chart.update();
713
 
714
  updateUI();
@@ -731,46 +837,187 @@ async function stepEnv() {
731
  // ── UI update ───────────────────────────────────────────────────
732
  function updateUI() {
733
  if (!obs) return;
734
- document.getElementById('hourDisp').textContent = Math.floor(obs.hour);
735
- document.getElementById('dayDisp').textContent = obs.day_of_episode || Math.floor(obs.hour / 24) + 1;
 
 
 
 
 
 
736
 
737
  // Info panel
738
- document.getElementById('priceInfo').textContent = 'Rs ' + (obs.grid_price || 0).toFixed(1) + '/kWh';
739
- document.getElementById('demandInfo').textContent = (obs.demand_kw || 0).toFixed(0) + ' kW';
740
- document.getElementById('solarInfo').textContent = (obs.solar_kw || 0).toFixed(0) + ' kW';
741
- document.getElementById('socInfo').textContent = ((obs.battery_soc || 0) * 100).toFixed(0) + '%';
742
- document.getElementById('fuelInfo').textContent = ((obs.diesel_fuel_remaining || 0) * 100).toFixed(0) + '%';
743
-
744
- // Forecasts
745
- const fmt = arr => arr ? arr.map(v => v.toFixed(0)).join(' ') : '—';
746
- document.getElementById('fcDemand').textContent = fmt(obs.demand_forecast_4h);
747
- document.getElementById('fcSolar').textContent = fmt(obs.solar_forecast_4h);
748
- document.getElementById('fcPrice').textContent = obs.price_forecast_4h ? obs.price_forecast_4h.map(v => v.toFixed(1)).join(' ') : '—';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
 
750
  // Flow diagram
751
- document.getElementById('flowSolar').textContent = (obs.solar_kw || 0).toFixed(0);
752
- document.getElementById('flowDemand').textContent = (obs.demand_kw || 0).toFixed(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  const gridKw = obs.grid_kw_this_step || 0;
754
- document.getElementById('flowGrid').textContent = (gridKw >= 0 ? '+' : '') + gridKw.toFixed(0) + ' (slack)';
755
- document.getElementById('flowBattery').textContent = ((obs.battery_soc || 0) * 100).toFixed(0) + '%';
756
- document.getElementById('flowDiesel').textContent = (dieselSlider.value / 100 * 100).toFixed(0);
757
 
758
- // Status
759
  const blackout = obs.blackout_this_step || 0;
760
- const statusNode = document.getElementById('flowStatus');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  const statusIcon = document.getElementById('statusIcon');
762
  const statusText = document.getElementById('statusText');
763
- if (blackout > 0) {
764
- statusNode.className = 'flow-node blackout';
765
- statusIcon.textContent = '🚨';
766
- statusText.textContent = 'BLACKOUT';
 
 
 
 
 
 
767
  statusText.style.color = 'var(--red)';
 
 
768
  } else {
769
- statusNode.className = 'flow-node';
770
- statusNode.style.borderColor = 'var(--green)';
771
- statusIcon.textContent = '✅';
772
- statusText.textContent = 'OK';
773
  statusText.style.color = 'var(--green)';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  }
775
 
776
  // Narration
@@ -786,13 +1033,15 @@ function updateUI() {
786
  document.getElementById('relBar').style.width = relPct + '%';
787
  document.getElementById('relBar').style.background = reliability > 0.95 ? 'var(--green)' : reliability > 0.9 ? 'var(--yellow)' : 'var(--red)';
788
 
789
- document.getElementById('costValue').textContent = 'Rs ' + (obs.cumulative_cost || 0).toFixed(0);
 
790
  document.getElementById('blackoutValue').textContent = (obs.cumulative_blackout_kwh || 0).toFixed(1) + ' kWh';
791
  const bvEl = document.getElementById('blackoutValue');
792
  bvEl.className = 'sc-value ' + ((obs.cumulative_blackout_kwh || 0) < 1 ? 'good' : 'bad');
793
 
794
  const socPct = ((obs.battery_soc || 0) * 100).toFixed(0);
795
- document.getElementById('socValue').textContent = socPct + '%';
 
796
  document.getElementById('socBar').style.width = socPct + '%';
797
 
798
  document.getElementById('rewardValue').textContent = (obs.reward != null ? obs.reward.toFixed(3) : '—');
@@ -803,7 +1052,8 @@ function showGrade(g) {
803
  box.classList.remove('hidden');
804
  document.getElementById('gradeValue').textContent = g.score.toFixed(3);
805
  document.getElementById('gradeSub').innerHTML =
806
- `Reliability: ${(g.reliability*100).toFixed(1)}% · Cost savings: ${(g.cost_savings*100).toFixed(1)}% · Green: ${(g.green_score*100).toFixed(1)}%`;
 
807
  }
808
 
809
  function updateTaskTag() {
 
115
  border-bottom: 1px solid rgba(30,41,59,0.5);
116
  }
117
  .info-row .label { color: var(--text-dim); }
118
+ .fc-cell {
119
+ padding: 2px 4px; border-radius: 3px; font-weight: 600; font-size: 10px;
120
+ background: rgba(30,41,59,0.4);
121
+ }
122
+ .fc-cell.high { color: var(--red); background: rgba(239,68,68,0.1); }
123
+ .fc-cell.med { color: var(--yellow); background: rgba(234,179,8,0.08); }
124
+ .fc-cell.low { color: var(--green); background: rgba(34,197,94,0.08); }
125
+ .fc-cell.bright { color: var(--yellow); background: rgba(234,179,8,0.1); }
126
 
127
  /* ── Center Panel (Visualization) ─────────────────────────── */
128
  .panel-center {
 
130
  overflow: hidden;
131
  }
132
  .chart-container {
133
+ flex: 6; padding: 12px; min-height: 0;
134
  position: relative;
135
  }
136
  .chart-container canvas { width: 100% !important; height: 100% !important; }
137
 
138
  /* Energy flow diagram */
139
  .flow-section {
140
+ padding: 10px 20px;
141
  border-top: 1px solid var(--border);
142
  display: flex; align-items: center; justify-content: center;
143
+ overflow-y: auto;
144
  }
145
  .flow-grid {
146
  display: grid;
 
158
  .flow-node .node-icon { font-size: 20px; display: block; margin-bottom: 2px; }
159
  .flow-node .node-val { color: var(--cyan); font-weight: 600; font-size: 13px; }
160
  .flow-node .node-label { color: var(--text-dim); font-size: 9px; text-transform: uppercase; letter-spacing: 1px; }
161
+ .flow-node.solar, .flow-node.battery, .flow-node.grid, .flow-node.diesel { border-color: var(--border); }
162
+ .flow-node.community { border-color: var(--cyan); background: rgba(6,182,212,0.08); }
 
 
 
 
163
  @keyframes pulse-red { 0%,100% { box-shadow: 0 0 0 rgba(239,68,68,0); } 50% { box-shadow: 0 0 16px rgba(239,68,68,0.4); } }
164
+
165
+ /* House grid */
166
+ .flow-chip {
167
+ padding: 3px 8px; border-radius: 4px; font-size: 9px;
168
+ border: 1px solid var(--border); white-space: nowrap;
169
+ }
170
+ .flow-chip b { color: var(--cyan); font-size: 10px; margin-left: 2px; }
171
+ .flow-chip.supply { background: rgba(34,197,94,0.06); }
172
+ .flow-chip.supply b { color: var(--green); }
173
+ .flow-chip.demand { background: rgba(234,179,8,0.06); }
174
+ .flow-chip.demand b { color: var(--yellow); }
175
+ .flow-chip.danger { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); }
176
+ .flow-chip.danger b { color: var(--red); }
177
+
178
+ .house-grid {
179
+ display: grid; grid-template-columns: repeat(20, 1fr); gap: 2px;
180
+ width: fit-content;
181
+ }
182
+ .house-cell {
183
+ width: 14px; height: 14px; border-radius: 2px; font-size: 5px;
184
+ display: flex; align-items: center; justify-content: center;
185
+ transition: background 0.2s, box-shadow 0.2s;
186
+ }
187
+ .house-cell.powered {
188
+ background: rgba(34,197,94,0.25);
189
+ box-shadow: inset 0 0 3px rgba(34,197,94,0.3);
190
+ }
191
+ .house-cell.dark {
192
+ background: rgba(239,68,68,0.35);
193
+ box-shadow: inset 0 0 3px rgba(239,68,68,0.4);
194
+ animation: pulse-red 1.5s infinite;
195
+ }
196
  .flow-arrow { color: var(--text-dim); font-size: 14px; display: flex; align-items: center; justify-content: center; }
197
 
198
  /* Narration bar */
199
  .narration {
200
+ padding: 8px 20px; font-size: 11px;
201
  border-top: 1px solid var(--border);
202
  background: rgba(6,182,212,0.04);
203
  color: var(--text-dim);
204
+ flex-shrink: 0;
205
  }
206
  .narration strong { color: var(--cyan); }
207
 
 
397
 
398
  <div class="story">
399
  <p>
400
+ Imagine you're in charge of keeping the lights on for <strong>100 homes</strong> in an Indian city during a brutal summer. You have four energy sources:
401
  </p>
402
+ <p style="margin-left:8px; line-height: 2.0;">
403
+ <span class="highlight">&#9728;&#65039; Rooftop solar</span> &mdash; free energy, but only when the sun is up<br>
404
+ <strong>&#128267; Community battery</strong> &mdash; 500 kWh storage, you decide when to charge or discharge<br>
405
+ <span class="danger">&#9981; Diesel generator</span> &mdash; Rs 25/kWh + Rs 100 startup. Last resort.<br>
406
+ <strong>&#9889; National grid</strong> &mdash; auto-imports/exports what's left over (capped at 200 kW)
407
  </p>
408
+ <p>Every hour you make <strong>3 decisions</strong>:</p>
409
+ <p style="margin-left:8px; line-height: 2.0;">
410
+ &#128267; <strong>Battery</strong> &mdash; charge it (store cheap energy) or discharge it (cover peaks)<br>
411
+ &#9981; <strong>Diesel</strong> &mdash; turn it on for emergency power (expensive!)<br>
412
+ &#127968; <strong>Demand shed</strong> &mdash; ask residents to use less (but 50% rebounds next hour)
 
 
413
  </p>
414
  </div>
415
 
 
497
  </div>
498
  <div class="header-info">
499
  <span id="taskTag" class="tag easy">Task 1: Normal</span>
500
+ <span><strong id="hourDisp">6:00</strong></span>
501
  <span>Day <strong id="dayDisp">1</strong></span>
502
+ <span>Step <strong id="stepDisp">0</strong>/72</span>
503
  </div>
504
  </header>
505
 
 
507
  <!-- ── Left Panel ──────────────────────────────────────────── -->
508
  <div class="panel-left">
509
  <div>
510
+ <div class="section-title">Coming Up Next</div>
511
+ <div class="info-row"><span class="label">&#128336; Time</span><span id="timeInfo" style="font-weight:700;color:var(--cyan)">06:00 · Day 1</span></div>
512
+ <div class="info-row"><span class="label">&#127968; Homes Will Need</span><span id="demandInfo">—</span></div>
513
+ <div class="info-row"><span class="label">&#9728;&#65039; Solar Expected</span><span id="solarInfo">—</span></div>
514
+ <div class="info-row"><span class="label">&#128176; Grid Price</span><span id="priceInfo" style="font-weight:700">—</span></div>
515
+ <div class="info-row"><span class="label">&#128267; Battery Charge</span><span id="socInfo">—</span></div>
516
+ <div class="info-row"><span class="label">&#9981; Diesel Fuel Left</span><span id="fuelInfo">—</span></div>
517
+ </div>
518
+
519
+ <div>
520
+ <div class="section-title">After That <span style="text-transform:none;letter-spacing:0;color:var(--text-dim);font-size:9px">(±15% noisy forecast)</span></div>
521
+ <div id="forecastTable" style="font-size:10px;">
522
+ <div style="display:grid;grid-template-columns:52px repeat(4,1fr);gap:2px;margin-bottom:3px;color:var(--text-dim);font-size:8px;text-transform:uppercase;letter-spacing:1px;">
523
+ <div></div>
524
+ <div id="fcH1" style="text-align:center">+1h</div>
525
+ <div id="fcH2" style="text-align:center">+2h</div>
526
+ <div id="fcH3" style="text-align:center">+3h</div>
527
+ <div id="fcH4" style="text-align:center">+4h</div>
528
+ </div>
529
+ <div style="display:grid;grid-template-columns:52px repeat(4,1fr);gap:2px;padding:3px 0;border-bottom:1px solid rgba(30,41,59,0.5);">
530
+ <div style="color:var(--text-dim);font-size:9px;">&#127968; Need</div>
531
+ <div id="fcD1" class="fc-cell" style="text-align:center">—</div>
532
+ <div id="fcD2" class="fc-cell" style="text-align:center">—</div>
533
+ <div id="fcD3" class="fc-cell" style="text-align:center">—</div>
534
+ <div id="fcD4" class="fc-cell" style="text-align:center">—</div>
535
+ </div>
536
+ <div style="display:grid;grid-template-columns:52px repeat(4,1fr);gap:2px;padding:3px 0;border-bottom:1px solid rgba(30,41,59,0.5);">
537
+ <div style="color:var(--text-dim);font-size:9px;">&#9728;&#65039; Sun</div>
538
+ <div id="fcS1" class="fc-cell" style="text-align:center">—</div>
539
+ <div id="fcS2" class="fc-cell" style="text-align:center">—</div>
540
+ <div id="fcS3" class="fc-cell" style="text-align:center">—</div>
541
+ <div id="fcS4" class="fc-cell" style="text-align:center">—</div>
542
+ </div>
543
+ <div style="display:grid;grid-template-columns:52px repeat(4,1fr);gap:2px;padding:3px 0;">
544
+ <div style="color:var(--text-dim);font-size:9px;">&#128176; Price</div>
545
+ <div id="fcP1" class="fc-cell" style="text-align:center">—</div>
546
+ <div id="fcP2" class="fc-cell" style="text-align:center">—</div>
547
+ <div id="fcP3" class="fc-cell" style="text-align:center">—</div>
548
+ <div id="fcP4" class="fc-cell" style="text-align:center">—</div>
549
+ </div>
550
+ </div>
551
+ </div>
552
+
553
+ <div>
554
+ <div class="section-title">Scenario</div>
555
  <select id="taskSelect">
556
  <option value="task_1_normal">Task 1 — Normal Summer (Easy)</option>
557
  <option value="task_2_heatwave">Task 2 — Heatwave + Clouds (Medium)</option>
 
560
  </div>
561
 
562
  <div>
563
+ <div class="section-title">Your 3 Decisions</div>
564
  <div class="control-group">
565
+ <label>&#128267; Battery <span class="val" id="batteryVal">idle</span></label>
566
  <input type="range" id="batterySlider" min="-100" max="100" value="0" step="1">
567
  <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim)">
568
+ <span>&#8592; Store energy</span><span>Use stored &#8594;</span>
569
  </div>
570
  </div>
571
  <div class="control-group">
572
+ <label>&#9981; Diesel <span class="val" id="dieselVal">off</span></label>
573
  <input type="range" id="dieselSlider" min="0" max="100" value="0" step="1">
574
+ <div style="font-size:9px;color:var(--orange)">Rs 25/kWh + Rs 100 to start</div>
575
  </div>
576
  <div class="control-group">
577
+ <label>&#127968; Ask residents to cut usage <span class="val" id="shedVal">none</span></label>
578
  <input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
579
+ <div style="font-size:9px;color:var(--yellow)">50% of shed demand rebounds next hour!</div>
580
  </div>
581
  </div>
582
 
583
+ <button class="btn-step" id="stepBtn">ADVANCE 1 HOUR &#8594;</button>
584
+ <button class="btn-reset" id="resetBtn">Restart Episode</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  </div>
586
 
587
  <!-- ── Center Panel ────────────────────────────────────────── -->
 
589
  <div class="chart-container">
590
  <canvas id="mainChart"></canvas>
591
  </div>
592
+ <div class="flow-section" style="flex:4; padding:10px 16px; min-height:0; flex-direction:column; align-items:stretch;">
593
+ <!-- Row 1: Supply bar + Verdict + House grid -->
594
+ <div style="display:flex; gap:12px; align-items:stretch; flex:1; min-height:0;">
595
+
596
+ <!-- LEFT: Supply → Demand breakdown -->
597
+ <div style="flex:1.2; display:flex; flex-direction:column; gap:3px; font-size:10px; min-width:0;">
598
+ <div style="font-size:9px;color:var(--cyan);font-weight:600;margin-bottom:2px;" id="flowHourLabel">Last Hour's Energy Balance</div>
599
+ <div style="font-size:8px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;">Supply IN (kW)</div>
600
+ <div style="display:flex; gap:3px; flex-wrap:wrap;">
601
+ <div class="flow-chip supply" id="chipSolar">&#9728;&#65039; Solar <b>0</b></div>
602
+ <div class="flow-chip supply" id="chipGridIn">&#9889; Grid Import <b>0</b></div>
603
+ <div class="flow-chip supply" id="chipBattOut">&#128267; Batt Out <b>0</b></div>
604
+ <div class="flow-chip supply" id="chipDiesel">&#9981; Diesel <b>0</b></div>
605
+ </div>
606
+ <div style="display:flex;align-items:center;gap:6px;margin:2px 0;">
607
+ <div style="font-size:9px;color:var(--cyan);font-weight:700;" id="flowTotalSupply">= 0 kW supply</div>
608
+ </div>
609
+
610
+ <div style="font-size:8px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-top:2px;">Consumption OUT (kW)</div>
611
+ <div style="display:flex; gap:3px; flex-wrap:wrap;">
612
+ <div class="flow-chip demand" id="chipDemand">&#127968; Homes <b>0</b></div>
613
+ <div class="flow-chip demand" id="chipGridOut">&#9889; Grid Export <b>0</b></div>
614
+ <div class="flow-chip demand" id="chipBattIn">&#128267; Batt Charge <b>0</b></div>
615
+ <div class="flow-chip danger" id="chipBlackout" style="display:none">&#9888;&#65039; Blackout <b>0</b></div>
616
+ </div>
617
+ <div style="display:flex;align-items:center;gap:6px;margin:2px 0;">
618
+ <div style="font-size:9px;color:var(--yellow);font-weight:700;" id="flowTotalConsume">= 0 kW consumption</div>
619
+ </div>
620
+
621
+ <!-- Net balance -->
622
+ <div style="padding:4px 8px;border-radius:4px;border:1px solid var(--border);display:flex;align-items:center;gap:8px;margin-top:auto;">
623
+ <span id="balanceVerdict" style="font-size:10px;font-weight:800;">&#9679; BALANCED</span>
624
+ <span id="balanceDetail" style="font-size:9px;color:var(--text-dim);"></span>
625
+ </div>
626
  </div>
627
+
628
+ <!-- RIGHT: 100 Homes grid -->
629
+ <div style="flex:0.8; min-width:0; display:flex; flex-direction:column;" id="flowCommunity">
630
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
631
+ <div style="font-size:8px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;">100 Homes</div>
632
+ <div style="display:flex;align-items:center;gap:4px;">
633
+ <span id="statusIcon" style="font-size:12px">&#9989;</span>
634
+ <span id="statusText" style="font-size:10px;font-weight:700;color:var(--green)">ALL POWERED</span>
635
+ </div>
636
+ </div>
637
+ <div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;font-size:10px;color:var(--text-dim);">
638
+ Need <span class="node-val" id="flowDemand" style="font-size:12px;margin:0 2px;">0 kW</span>
639
+ <span id="flowBlackoutHint" style="color:var(--red);font-weight:600;font-size:9px;display:none;"></span>
640
+ </div>
641
+ <div class="house-grid" id="houseGrid" style="flex:1;"></div>
642
+ <div style="display:flex;gap:10px;margin-top:3px;font-size:7px;color:var(--text-dim);">
643
+ <span><span style="display:inline-block;width:6px;height:6px;border-radius:1px;background:rgba(34,197,94,0.35);vertical-align:middle;margin-right:2px;"></span>Powered</span>
644
+ <span><span style="display:inline-block;width:6px;height:6px;border-radius:1px;background:rgba(239,68,68,0.45);vertical-align:middle;margin-right:2px;"></span>Blackout</span>
645
+ </div>
646
  </div>
647
+
648
  </div>
649
  </div>
650
  <div class="narration" id="narration">
651
+ <strong>Ready.</strong> Select a task and click "Advance 1 Hour" to begin.
652
  </div>
653
  </div>
654
 
655
  <!-- ── Right Panel ─────────────────────────────────────────── -->
656
  <div class="panel-right">
657
  <div class="grade-box hidden" id="gradeBox">
658
+ <div class="grade-label">Final Episode Score</div>
659
  <div class="grade-value" id="gradeValue">—</div>
660
  <div class="grade-sub" id="gradeSub"></div>
661
  </div>
662
 
663
+ <div style="font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--cyan);">Performance</div>
664
+
665
  <div class="score-card">
666
+ <div class="sc-label">&#127968; Lights On (Reliability)</div>
667
  <div class="sc-value good" id="relValue">100%</div>
668
  <div class="sc-bar"><div class="sc-bar-fill" id="relBar" style="width:100%;background:var(--green)"></div></div>
669
+ <div style="font-size:9px;color:var(--text-dim);margin-top:4px">Blackout = Rs 150/kWh penalty</div>
670
  </div>
671
 
672
  <div class="score-card">
673
+ <div class="sc-label">&#128176; Total Spend</div>
674
  <div class="sc-value cyan" id="costValue">Rs 0</div>
675
+ <div style="font-size:9px;color:var(--text-dim);margin-top:4px">Lower is better. Grid + diesel + penalties.</div>
676
  </div>
677
 
678
  <div class="score-card">
679
+ <div class="sc-label">&#128267; Battery</div>
 
 
 
 
 
680
  <div class="sc-value cyan" id="socValue">50%</div>
681
  <div class="sc-bar"><div class="sc-bar-fill" id="socBar" style="width:50%;background:var(--blue)"></div></div>
682
+ <div style="font-size:9px;color:var(--text-dim);margin-top:4px">Save charge for evening peak!</div>
683
  </div>
684
 
685
  <div class="score-card">
686
+ <div class="sc-label">&#9888;&#65039; Blackouts</div>
687
  <div class="sc-value good" id="blackoutValue">0 kWh</div>
688
+ <div style="font-size:9px;color:var(--text-dim);margin-top:4px">Unmet demand. Must be near zero to score well.</div>
689
  </div>
690
 
691
  <div class="score-card">
692
+ <div class="sc-label">&#9981; Diesel Burned</div>
693
+ <div class="sc-value" id="dieselUsed" style="color:var(--orange)">0 kWh</div>
694
+ <div style="font-size:9px;color:var(--text-dim);margin-top:4px">Hurts green score + very expensive.</div>
695
+ </div>
696
+
697
+ <div class="score-card">
698
+ <div class="sc-label">This Hour's Reward</div>
699
  <div class="sc-value cyan" id="rewardValue">—</div>
700
  </div>
701
  </div>
 
709
 
710
  // ── Chart setup ─────────────────────────────────────────────────
711
  const chartData = {
712
+ demand: [], solar: [], price: [], soc: [], hours: [], blackout: [], gridImport: []
713
  };
714
 
715
  const ctx = document.getElementById('mainChart').getContext('2d');
 
719
  labels: chartData.hours,
720
  datasets: [
721
  { label: 'Demand (kW)', data: chartData.demand, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
722
+ { label: 'Solar (kW)', data: chartData.solar, borderColor: '#eab308', backgroundColor: 'rgba(234,179,8,0.08)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y' },
723
+ { label: 'Grid Import (kW)', data: chartData.gridImport, borderColor: '#06b6d4', backgroundColor: 'rgba(6,182,212,0.08)', borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true, yAxisID: 'y' },
724
+ { label: 'Price (₹/kWh)', data: chartData.price, borderColor: '#22c55e', borderDash: [4,2], borderWidth: 1.5, pointRadius: 0, tension: 0.3, yAxisID: 'y1' },
725
+ { label: 'Battery SOC', data: chartData.soc, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1.5, pointRadius: 0, tension: 0.3, fill: true, yAxisID: 'y2' },
726
  ]
727
  },
728
  options: {
 
748
 
749
  batterySlider.oninput = () => {
750
  const v = batterySlider.value;
751
+ const kw = Math.abs((v / 100 * 100).toFixed(0));
752
+ const label = v < 0 ? `storing ${kw} kW` : v > 0 ? `using ${kw} kW` : 'idle';
753
  document.getElementById('batteryVal').textContent = label;
754
  };
755
  dieselSlider.oninput = () => {
756
+ const kw = (dieselSlider.value / 100 * 100).toFixed(0);
757
+ document.getElementById('dieselVal').textContent = kw > 0 ? kw + ' kW' : 'off';
758
  };
759
  shedSlider.oninput = () => {
760
+ const pct = (shedSlider.value / 100 * 20).toFixed(0);
761
+ document.getElementById('shedVal').textContent = pct > 0 ? pct + '% cut' : 'none';
762
  };
763
 
764
  // ── API calls ───────────────────────────────────────────────────
 
770
  return res.json();
771
  }
772
 
773
+ function pushChartPoint(o) {
774
+ const clockLabel = String((Math.floor(o.hour) + 6) % 24).padStart(2,'0') + 'h';
775
+ chartData.hours.push(clockLabel);
776
+ chartData.demand.push(o.demand_kw);
777
+ chartData.solar.push(o.solar_kw);
778
+ chartData.price.push(o.grid_price);
779
+ chartData.soc.push(o.battery_soc);
780
+ chartData.blackout.push(o.blackout_this_step || 0);
781
+ chartData.gridImport.push(o.flow_grid_import || 0);
782
+ }
783
+
784
  async function resetEnv() {
785
  const taskId = document.getElementById('taskSelect').value;
786
  const body = { seed: 42, task_id: taskId };
 
792
  chartData.soc.length = 0;
793
  chartData.hours.length = 0;
794
  chartData.blackout.length = 0;
795
+ chartData.gridImport.length = 0;
796
+ // Push initial state so chart has a starting point
797
+ pushChartPoint(obs);
798
  chart.update();
799
  updateUI();
800
  document.getElementById('gradeBox').classList.add('hidden');
 
814
  obs = resp.observation || resp;
815
 
816
  // Update chart
817
+ pushChartPoint(obs);
 
 
 
 
 
818
  chart.update();
819
 
820
  updateUI();
 
837
  // ── UI update ───────────────────────────────────────────────────
838
  function updateUI() {
839
  if (!obs) return;
840
+ const START_HOUR = 6;
841
+ const clockHour = (Math.floor(obs.hour) + START_HOUR) % 24;
842
+ const clockDay = Math.floor((Math.floor(obs.hour) + START_HOUR) / 24) + 1;
843
+ const clockStr = String(clockHour).padStart(2,'0') + ':00';
844
+ document.getElementById('hourDisp').textContent = clockStr;
845
+ document.getElementById('dayDisp').textContent = clockDay;
846
+ document.getElementById('stepDisp').textContent = Math.floor(obs.hour);
847
+ document.getElementById('timeInfo').textContent = clockStr + ' · Day ' + clockDay + ' · Step ' + Math.floor(obs.hour) + '/72';
848
 
849
  // Info panel
850
+ const price = (obs.grid_price || 0);
851
+ const priceEl = document.getElementById('priceInfo');
852
+ priceEl.textContent = '₹' + price.toFixed(1) + '/kWh';
853
+ priceEl.style.color = price > 10 ? 'var(--red)' : price > 6 ? 'var(--yellow)' : 'var(--green)';
854
+
855
+ const demKw = (obs.demand_kw || 0);
856
+ const demEl = document.getElementById('demandInfo');
857
+ demEl.textContent = demKw.toFixed(0) + ' kW';
858
+ demEl.style.color = demKw > 200 ? 'var(--red)' : demKw > 120 ? 'var(--yellow)' : 'var(--text)';
859
+
860
+ const solKw = (obs.solar_kw || 0);
861
+ const solEl = document.getElementById('solarInfo');
862
+ solEl.textContent = solKw.toFixed(0) + ' kW';
863
+ solEl.style.color = solKw > 100 ? 'var(--yellow)' : 'var(--text-dim)';
864
+
865
+ const socPctRaw = ((obs.battery_soc || 0) * 100);
866
+ const socInfoEl = document.getElementById('socInfo');
867
+ socInfoEl.textContent = socPctRaw.toFixed(0) + '% (' + (socPctRaw * 5).toFixed(0) + ' kWh)';
868
+ socInfoEl.style.color = socPctRaw < 20 ? 'var(--red)' : socPctRaw > 70 ? 'var(--green)' : 'var(--text)';
869
+
870
+ const fuelPct = ((obs.diesel_fuel_remaining || 0) * 100);
871
+ document.getElementById('fuelInfo').textContent = fuelPct.toFixed(0) + '%';
872
+
873
+ // Forecasts — color-coded grid
874
+ const curStep = Math.floor(obs.hour);
875
+ for (let i = 0; i < 4; i++) {
876
+ const h = (curStep + i + 1 + START_HOUR) % 24;
877
+ document.getElementById('fcH' + (i+1)).textContent = String(h).padStart(2,'0') + ':00';
878
+ }
879
+ // Demand forecast
880
+ const dfc = obs.demand_forecast_4h || [];
881
+ for (let i = 0; i < 4; i++) {
882
+ const el = document.getElementById('fcD' + (i+1));
883
+ if (dfc[i] != null) {
884
+ const v = dfc[i];
885
+ el.innerHTML = v.toFixed(0) + '<span style="font-size:8px;opacity:0.6"> kW</span>';
886
+ el.className = 'fc-cell ' + (v > 200 ? 'high' : v > 120 ? 'med' : 'low');
887
+ } else { el.textContent = '—'; el.className = 'fc-cell'; }
888
+ }
889
+ // Solar forecast
890
+ const sfc = obs.solar_forecast_4h || [];
891
+ for (let i = 0; i < 4; i++) {
892
+ const el = document.getElementById('fcS' + (i+1));
893
+ if (sfc[i] != null) {
894
+ const v = sfc[i];
895
+ el.innerHTML = v.toFixed(0) + '<span style="font-size:8px;opacity:0.6"> kW</span>';
896
+ el.className = 'fc-cell ' + (v > 100 ? 'bright' : v > 0 ? 'low' : '');
897
+ } else { el.textContent = '—'; el.className = 'fc-cell'; }
898
+ }
899
+ // Price forecast
900
+ const pfc = obs.price_forecast_4h || [];
901
+ for (let i = 0; i < 4; i++) {
902
+ const el = document.getElementById('fcP' + (i+1));
903
+ if (pfc[i] != null) {
904
+ const v = pfc[i];
905
+ el.innerHTML = '₹' + v.toFixed(1) + '<span style="font-size:7px;opacity:0.6">/kWh</span>';
906
+ el.className = 'fc-cell ' + (v > 10 ? 'high' : v > 6 ? 'med' : 'low');
907
+ } else { el.textContent = '—'; el.className = 'fc-cell'; }
908
+ }
909
 
910
  // Flow diagram
911
+ // Flow chips from real physics data
912
+ const f = {
913
+ solar: obs.flow_solar || 0,
914
+ gridIn: obs.flow_grid_import || 0,
915
+ gridOut: obs.flow_grid_export || 0,
916
+ battOut: obs.flow_battery_discharge || 0,
917
+ battIn: obs.flow_battery_charge || 0,
918
+ diesel: obs.flow_diesel || 0,
919
+ demand: obs.flow_demand || obs.demand_kw || 0,
920
+ blackout: obs.flow_blackout || 0,
921
+ shed: obs.flow_shed || 0,
922
+ totalSupply: obs.flow_total_supply || 0,
923
+ totalConsume: obs.flow_total_consumption || 0,
924
+ };
925
+
926
+ // Label which hour the flows are from
927
+ const prevClock = (Math.floor(obs.hour) - 1 + 6 + 24) % 24;
928
+ document.getElementById('flowHourLabel').textContent = String(prevClock).padStart(2,'0') + ':00 Energy Balance (last hour)';
929
+
930
+ document.getElementById('chipSolar').innerHTML = '&#9728;&#65039; Solar <b>' + f.solar.toFixed(0) + '</b>';
931
+ document.getElementById('chipGridIn').innerHTML = '&#9889; Grid In <b>' + f.gridIn.toFixed(0) + '</b>';
932
+ document.getElementById('chipBattOut').innerHTML = '&#128267; Batt Out <b>' + f.battOut.toFixed(0) + '</b>';
933
+ document.getElementById('chipDiesel').innerHTML = '&#9981; Diesel <b>' + f.diesel.toFixed(0) + '</b>';
934
+ document.getElementById('flowTotalSupply').textContent = '= ' + f.totalSupply.toFixed(0) + ' kW supply';
935
+
936
+ document.getElementById('chipDemand').innerHTML = '&#127968; Homes <b>' + f.demand.toFixed(0) + '</b>';
937
+ document.getElementById('chipGridOut').innerHTML = '&#9889; Grid Out <b>' + f.gridOut.toFixed(0) + '</b>';
938
+ document.getElementById('chipBattIn').innerHTML = '&#128267; Batt Charge <b>' + f.battIn.toFixed(0) + '</b>';
939
+ document.getElementById('flowTotalConsume').textContent = '= ' + f.totalConsume.toFixed(0) + ' kW consumption';
940
+
941
+ const chipBlackout = document.getElementById('chipBlackout');
942
+ if (f.blackout > 0) {
943
+ chipBlackout.innerHTML = '&#9888;&#65039; Blackout <b>' + f.blackout.toFixed(0) + '</b>';
944
+ chipBlackout.style.display = '';
945
+ } else {
946
+ chipBlackout.style.display = 'none';
947
+ }
948
+
949
+ // Shed display in chips
950
+ if (f.shed > 0) {
951
+ document.getElementById('chipDemand').innerHTML = '&#127968; Homes <b>' + f.demand.toFixed(0) + '</b> <span style="font-size:8px;color:var(--yellow)">(-' + f.shed.toFixed(0) + ' shed)</span>';
952
+ }
953
+
954
+ document.getElementById('flowDemand').textContent = f.demand.toFixed(0) + ' kW';
955
  const gridKw = obs.grid_kw_this_step || 0;
 
 
 
956
 
957
+ // Status + house grid
958
  const blackout = obs.blackout_this_step || 0;
959
+
960
+ // Energy balance verdict
961
+ const verdictEl = document.getElementById('balanceVerdict');
962
+ const detailEl = document.getElementById('balanceDetail');
963
+ const stepCost = obs.cost_this_step || 0;
964
+ const net = f.totalSupply - f.totalConsume;
965
+ if (f.blackout > 0) {
966
+ verdictEl.innerHTML = '&#9888;&#65039; DEFICIT';
967
+ verdictEl.style.color = 'var(--red)';
968
+ detailEl.textContent = f.blackout.toFixed(0) + ' kW unmet | ₹' + (150 * blackout).toFixed(0) + ' penalty';
969
+ } else if (f.gridOut > 10) {
970
+ verdictEl.innerHTML = '&#10022; SURPLUS';
971
+ verdictEl.style.color = 'var(--green)';
972
+ detailEl.textContent = 'Exporting ' + f.gridOut.toFixed(0) + ' kW | Earning ₹' + (f.gridOut * (obs.grid_price||0)).toFixed(0);
973
+ } else if (f.gridIn > 150) {
974
+ verdictEl.innerHTML = '&#9889; TIGHT';
975
+ verdictEl.style.color = 'var(--yellow)';
976
+ detailEl.textContent = 'Grid ' + f.gridIn.toFixed(0) + '/200 kW | Cost ₹' + stepCost.toFixed(0) + '/hr';
977
+ } else {
978
+ verdictEl.innerHTML = '&#9679; BALANCED';
979
+ verdictEl.style.color = 'var(--cyan)';
980
+ detailEl.textContent = 'Grid ' + f.gridIn.toFixed(0) + ' kW | Cost ₹' + stepCost.toFixed(0) + '/hr';
981
+ }
982
  const statusIcon = document.getElementById('statusIcon');
983
  const statusText = document.getElementById('statusText');
984
+ const blackoutHint = document.getElementById('flowBlackoutHint');
985
+
986
+ // Calculate how many homes are dark (proportional to blackout vs demand)
987
+ const demandNow = obs.demand_kw || 1;
988
+ const blackoutFrac = Math.min(1, blackout / (demandNow + 0.01));
989
+ const darkHomes = Math.round(blackoutFrac * 100);
990
+
991
+ if (darkHomes > 0) {
992
+ statusIcon.textContent = '\u{1F6A8}';
993
+ statusText.textContent = darkHomes + ' HOMES DARK';
994
  statusText.style.color = 'var(--red)';
995
+ blackoutHint.textContent = darkHomes + ' homes without power!';
996
+ blackoutHint.style.display = 'inline';
997
  } else {
998
+ statusIcon.textContent = '\u2705';
999
+ statusText.textContent = 'ALL POWERED';
 
 
1000
  statusText.style.color = 'var(--green)';
1001
+ blackoutHint.style.display = 'none';
1002
+ }
1003
+
1004
+ // Update 10x10 house grid
1005
+ const grid = document.getElementById('houseGrid');
1006
+ if (!grid.children.length) {
1007
+ for (let i = 0; i < 100; i++) {
1008
+ const cell = document.createElement('div');
1009
+ cell.className = 'house-cell powered';
1010
+ cell.textContent = '\u{1F3E0}';
1011
+ grid.appendChild(cell);
1012
+ }
1013
+ }
1014
+ for (let i = 0; i < 100; i++) {
1015
+ const cell = grid.children[i];
1016
+ if (i < (100 - darkHomes)) {
1017
+ cell.className = 'house-cell powered';
1018
+ } else {
1019
+ cell.className = 'house-cell dark';
1020
+ }
1021
  }
1022
 
1023
  // Narration
 
1033
  document.getElementById('relBar').style.width = relPct + '%';
1034
  document.getElementById('relBar').style.background = reliability > 0.95 ? 'var(--green)' : reliability > 0.9 ? 'var(--yellow)' : 'var(--red)';
1035
 
1036
+ const cost = (obs.cumulative_cost || 0);
1037
+ document.getElementById('costValue').textContent = '₹' + cost.toLocaleString('en-IN', {maximumFractionDigits:0});
1038
  document.getElementById('blackoutValue').textContent = (obs.cumulative_blackout_kwh || 0).toFixed(1) + ' kWh';
1039
  const bvEl = document.getElementById('blackoutValue');
1040
  bvEl.className = 'sc-value ' + ((obs.cumulative_blackout_kwh || 0) < 1 ? 'good' : 'bad');
1041
 
1042
  const socPct = ((obs.battery_soc || 0) * 100).toFixed(0);
1043
+ const socKwh = ((obs.battery_soc || 0) * 500).toFixed(0);
1044
+ document.getElementById('socValue').textContent = socPct + '% (' + socKwh + ' kWh)';
1045
  document.getElementById('socBar').style.width = socPct + '%';
1046
 
1047
  document.getElementById('rewardValue').textContent = (obs.reward != null ? obs.reward.toFixed(3) : '—');
 
1052
  box.classList.remove('hidden');
1053
  document.getElementById('gradeValue').textContent = g.score.toFixed(3);
1054
  document.getElementById('gradeSub').innerHTML =
1055
+ `Lights on: ${(g.reliability*100).toFixed(1)}% · Cost efficiency: ${(g.cost_efficiency*100).toFixed(0)}% · Green: ${(g.green_score*100).toFixed(0)}%<br>` +
1056
+ `Spent ₹${g.actual_cost.toLocaleString('en-IN',{maximumFractionDigits:0})} vs baseline ₹${g.baseline_cost.toLocaleString('en-IN',{maximumFractionDigits:0})}`;
1057
  }
1058
 
1059
  function updateTaskTag() {
gridops/simulation/physics.py CHANGED
@@ -58,6 +58,30 @@ class MicrogridState:
58
  last_grid_kw: float = 0.0
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  @dataclass
62
  class StepResult:
63
  """What physics.step() returns to the environment."""
@@ -66,6 +90,7 @@ class StepResult:
66
  reward: float
67
  done: bool
68
  narration: str
 
69
 
70
 
71
  def step(
@@ -125,16 +150,40 @@ def step(
125
  state.cumulative_battery_throughput_kwh += battery_throughput
126
 
127
  # ── Grid as slack variable ───────────────────────────────────────
128
- # grid_kw > 0 = import, < 0 = export
129
- # grid_kw = what the community still needs after solar + battery + diesel
130
  residual = effective_demand - solar_kw - delivered_kw - diesel_kw
131
  grid_kw = float(np.clip(residual, -GRID_MAX_KW, GRID_MAX_KW))
132
 
133
- # ── Blackout detection ───────────────────────────────────────────
134
- # If residual exceeds grid capacity, we have unmet demand
135
  blackout_kwh = 0.0
 
136
  if residual > GRID_MAX_KW:
137
  blackout_kwh = (residual - GRID_MAX_KW) * DT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  # ── Cost accounting ──────────────────────────────────────────────
140
  step_cost = 0.0
@@ -188,7 +237,7 @@ def step(
188
  narration = _narrate(state, solar_kw, actual_demand, grid_price, blackout_kwh,
189
  diesel_kw, shed_frac, grid_kw, delivered_kw)
190
 
191
- return StepResult(state=state, reward=reward, done=done, narration=narration)
192
 
193
 
194
  def _narrate(
@@ -203,8 +252,10 @@ def _narrate(
203
  battery_kw: float,
204
  ) -> str:
205
  """Generate a short human-readable situation summary."""
206
- hour_of_day = (s.hour - 1) % 24
207
- day = (s.hour - 1) // 24 + 1
 
 
208
  soc_pct = s.battery_soc_kwh / BATTERY_CAPACITY_KWH * 100
209
 
210
  parts = [f"Day {day}, {hour_of_day:02d}:00."]
 
58
  last_grid_kw: float = 0.0
59
 
60
 
61
+ @dataclass
62
+ class StepFlows:
63
+ """Detailed energy flows for one step (all in kW)."""
64
+
65
+ # Supply side (positive = providing power to the bus)
66
+ solar_kw: float = 0.0
67
+ grid_import_kw: float = 0.0 # grid importing INTO community
68
+ battery_discharge_kw: float = 0.0 # power delivered from battery (after efficiency loss)
69
+ diesel_kw: float = 0.0
70
+
71
+ # Demand side (positive = consuming power from the bus)
72
+ effective_demand_kw: float = 0.0 # demand after shedding + rebound
73
+ grid_export_kw: float = 0.0 # surplus exported to grid
74
+ battery_charge_kw: float = 0.0 # power consumed to charge battery (before efficiency)
75
+ blackout_kw: float = 0.0 # unmet demand
76
+ curtailed_kw: float = 0.0 # excess supply that goes nowhere
77
+
78
+ # Derived
79
+ total_supply_kw: float = 0.0
80
+ total_consumption_kw: float = 0.0
81
+ shed_kw: float = 0.0 # how much was shed
82
+ rebound_kw: float = 0.0 # how much rebounded from last step
83
+
84
+
85
  @dataclass
86
  class StepResult:
87
  """What physics.step() returns to the environment."""
 
90
  reward: float
91
  done: bool
92
  narration: str
93
+ flows: StepFlows = None
94
 
95
 
96
  def step(
 
150
  state.cumulative_battery_throughput_kwh += battery_throughput
151
 
152
  # ── Grid as slack variable ───────────────────────────────────────
153
+ # residual = what the community still needs after solar + battery + diesel
154
+ # positive grid must import; negative surplus exported
155
  residual = effective_demand - solar_kw - delivered_kw - diesel_kw
156
  grid_kw = float(np.clip(residual, -GRID_MAX_KW, GRID_MAX_KW))
157
 
158
+ # ── Blackout / curtailment detection ─────────────────────────────
 
159
  blackout_kwh = 0.0
160
+ curtailed_kw = 0.0
161
  if residual > GRID_MAX_KW:
162
  blackout_kwh = (residual - GRID_MAX_KW) * DT
163
+ elif residual < -GRID_MAX_KW:
164
+ curtailed_kw = abs(residual) - GRID_MAX_KW # excess that can't be exported
165
+
166
+ # ── Build flow snapshot ──────────────────────────────────────────
167
+ grid_import = max(0.0, grid_kw)
168
+ grid_export = max(0.0, -grid_kw)
169
+ batt_discharge = max(0.0, delivered_kw)
170
+ batt_charge = max(0.0, -delivered_kw) # power drawn from bus to charge
171
+
172
+ flows = StepFlows(
173
+ solar_kw=solar_kw,
174
+ grid_import_kw=grid_import,
175
+ battery_discharge_kw=batt_discharge,
176
+ diesel_kw=diesel_kw,
177
+ effective_demand_kw=effective_demand,
178
+ grid_export_kw=grid_export,
179
+ battery_charge_kw=batt_charge,
180
+ blackout_kw=blackout_kwh / DT,
181
+ curtailed_kw=curtailed_kw,
182
+ total_supply_kw=solar_kw + grid_import + batt_discharge + diesel_kw,
183
+ total_consumption_kw=effective_demand + grid_export + batt_charge,
184
+ shed_kw=actual_demand * shed_frac,
185
+ rebound_kw=state.shed_rebound_kwh / SHED_REBOUND_FRAC if shed_frac == 0 else 0,
186
+ )
187
 
188
  # ── Cost accounting ──────────────────────────────────────────────
189
  step_cost = 0.0
 
237
  narration = _narrate(state, solar_kw, actual_demand, grid_price, blackout_kwh,
238
  diesel_kw, shed_frac, grid_kw, delivered_kw)
239
 
240
+ return StepResult(state=state, reward=reward, done=done, narration=narration, flows=flows)
241
 
242
 
243
  def _narrate(
 
252
  battery_kw: float,
253
  ) -> str:
254
  """Generate a short human-readable situation summary."""
255
+ START_HOUR = 6
256
+ clock = (s.hour - 1) + START_HOUR # absolute hour since midnight Day 1
257
+ hour_of_day = clock % 24
258
+ day = clock // 24 + 1
259
  soc_pct = s.battery_soc_kwh / BATTERY_CAPACITY_KWH * 100
260
 
261
  parts = [f"Day {day}, {hour_of_day:02d}:00."]
gridops/simulation/scenarios.py CHANGED
@@ -10,6 +10,9 @@ from dataclasses import dataclass
10
  import numpy as np
11
 
12
 
 
 
 
13
  @dataclass
14
  class ScenarioConfig:
15
  """Knobs that define a task's difficulty."""
@@ -44,9 +47,14 @@ def _base_demand_curve() -> np.ndarray:
44
  return hourly
45
 
46
 
 
 
 
 
 
47
  def generate_demand(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
48
  """72-hour demand with heatwave multiplier and stochastic noise."""
49
- base = np.tile(_base_demand_curve(), 3) # 3 days
50
  demand = base.copy()
51
 
52
  # Apply heatwave multiplier after start hour
@@ -73,7 +81,7 @@ def _base_solar_curve() -> np.ndarray:
73
 
74
  def generate_solar(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
75
  """72-hour solar with optional haze reduction and cloud dips."""
76
- base = np.tile(_base_solar_curve(), 3)
77
  solar = base * cfg.solar_multiplier
78
 
79
  # Cloud cover — 50 % drop during listed hours
@@ -113,7 +121,7 @@ def _base_price_curve(floor: float, ceiling: float) -> np.ndarray:
113
 
114
  def generate_price(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
115
  """72-hour grid price with optional spikes."""
116
- base = np.tile(_base_price_curve(cfg.price_floor, cfg.price_ceiling), 3)
117
  price = base.copy()
118
 
119
  # Evening spike
 
10
  import numpy as np
11
 
12
 
13
+ START_HOUR = 6 # Episodes begin at 6 AM
14
+
15
+
16
  @dataclass
17
  class ScenarioConfig:
18
  """Knobs that define a task's difficulty."""
 
47
  return hourly
48
 
49
 
50
+ def _rotate(curve_24h: np.ndarray) -> np.ndarray:
51
+ """Rotate a 24-hour curve so index 0 = START_HOUR (6 AM)."""
52
+ return np.roll(curve_24h, -START_HOUR)
53
+
54
+
55
  def generate_demand(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
56
  """72-hour demand with heatwave multiplier and stochastic noise."""
57
+ base = np.tile(_rotate(_base_demand_curve()), 3) # 3 days starting at 6 AM
58
  demand = base.copy()
59
 
60
  # Apply heatwave multiplier after start hour
 
81
 
82
  def generate_solar(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
83
  """72-hour solar with optional haze reduction and cloud dips."""
84
+ base = np.tile(_rotate(_base_solar_curve()), 3)
85
  solar = base * cfg.solar_multiplier
86
 
87
  # Cloud cover — 50 % drop during listed hours
 
121
 
122
  def generate_price(cfg: ScenarioConfig, rng: np.random.Generator) -> np.ndarray:
123
  """72-hour grid price with optional spikes."""
124
+ base = np.tile(_rotate(_base_price_curve(cfg.price_floor, cfg.price_ceiling)), 3)
125
  price = base.copy()
126
 
127
  # Evening spike
scripts/oracle_test.py CHANGED
@@ -26,7 +26,7 @@ def oracle_policy(obs: dict) -> GridOpsAction:
26
  - Use diesel only when grid is at capacity AND battery is depleted
27
  - Shed demand only as last resort during extreme peaks
28
  """
29
- hour_of_day = int(obs["hour"]) % 24
30
  soc = obs["battery_soc"]
31
  price = obs["grid_price"]
32
  demand = obs["demand_kw"]
 
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"]