Upload folder using huggingface_hub
Browse files- gridops/models.py +13 -0
- gridops/server/environment.py +27 -1
- gridops/server/static/index.html +388 -138
- gridops/simulation/physics.py +58 -7
- gridops/simulation/scenarios.py +11 -3
- scripts/oracle_test.py +1 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 126 |
position: relative;
|
| 127 |
}
|
| 128 |
.chart-container canvas { width: 100% !important; height: 100% !important; }
|
| 129 |
|
| 130 |
/* Energy flow diagram */
|
| 131 |
.flow-section {
|
| 132 |
-
|
| 133 |
border-top: 1px solid var(--border);
|
| 134 |
display: flex; align-items: center; justify-content: center;
|
| 135 |
-
|
| 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(--
|
| 154 |
-
.flow-node.
|
| 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:
|
| 165 |
border-top: 1px solid var(--border);
|
| 166 |
background: rgba(6,182,212,0.04);
|
| 167 |
color: var(--text-dim);
|
| 168 |
-
|
| 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 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
</p>
|
| 372 |
-
<p>
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 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>
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 485 |
<div class="control-group">
|
| 486 |
-
<label>Battery <span class="val" id="batteryVal">
|
| 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>
|
| 490 |
</div>
|
| 491 |
</div>
|
| 492 |
<div class="control-group">
|
| 493 |
-
<label>Diesel
|
| 494 |
<input type="range" id="dieselSlider" min="0" max="100" value="0" step="1">
|
| 495 |
-
<div style="font-size:9px;color:var(--
|
| 496 |
</div>
|
| 497 |
<div class="control-group">
|
| 498 |
-
<label>
|
| 499 |
<input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
|
| 500 |
-
<div style="font-size:9px;color:var(--
|
| 501 |
</div>
|
| 502 |
</div>
|
| 503 |
|
| 504 |
-
<button class="btn-step" id="stepBtn">
|
| 505 |
-
<button class="btn-reset" id="resetBtn">
|
| 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 |
-
<
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
<
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
<
|
| 543 |
-
<
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
<
|
| 548 |
-
<
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
<
|
| 554 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
</div>
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
</div>
|
|
|
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
<div class="narration" id="narration">
|
| 564 |
-
<strong>Ready.</strong> Select a task and click "
|
| 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">
|
| 584 |
<div class="sc-value cyan" id="costValue">Rs 0</div>
|
|
|
|
| 585 |
</div>
|
| 586 |
|
| 587 |
<div class="score-card">
|
| 588 |
-
<div class="sc-label">
|
| 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">
|
| 600 |
<div class="sc-value good" id="blackoutValue">0 kWh</div>
|
|
|
|
| 601 |
</div>
|
| 602 |
|
| 603 |
<div class="score-card">
|
| 604 |
-
<div class="sc-label">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 629 |
-
{ label: '
|
| 630 |
-
{ label: '
|
|
|
|
| 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 ? `
|
| 658 |
document.getElementById('batteryVal').textContent = label;
|
| 659 |
};
|
| 660 |
dieselSlider.oninput = () => {
|
| 661 |
-
|
|
|
|
| 662 |
};
|
| 663 |
shedSlider.oninput = () => {
|
| 664 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 735 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
// Info panel
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
|
| 750 |
// Flow diagram
|
| 751 |
-
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
const statusIcon = document.getElementById('statusIcon');
|
| 762 |
const statusText = document.getElementById('statusText');
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
statusText.style.color = 'var(--red)';
|
|
|
|
|
|
|
| 768 |
} else {
|
| 769 |
-
|
| 770 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
`
|
|
|
|
| 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">☀️ Rooftop solar</span> — free energy, but only when the sun is up<br>
|
| 404 |
+
<strong>🔋 Community battery</strong> — 500 kWh storage, you decide when to charge or discharge<br>
|
| 405 |
+
<span class="danger">⛽ Diesel generator</span> — Rs 25/kWh + Rs 100 startup. Last resort.<br>
|
| 406 |
+
<strong>⚡ National grid</strong> — 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 |
+
🔋 <strong>Battery</strong> — charge it (store cheap energy) or discharge it (cover peaks)<br>
|
| 411 |
+
⛽ <strong>Diesel</strong> — turn it on for emergency power (expensive!)<br>
|
| 412 |
+
🏠 <strong>Demand shed</strong> — 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">🕐 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">🏠 Homes Will Need</span><span id="demandInfo">—</span></div>
|
| 513 |
+
<div class="info-row"><span class="label">☀️ Solar Expected</span><span id="solarInfo">—</span></div>
|
| 514 |
+
<div class="info-row"><span class="label">💰 Grid Price</span><span id="priceInfo" style="font-weight:700">—</span></div>
|
| 515 |
+
<div class="info-row"><span class="label">🔋 Battery Charge</span><span id="socInfo">—</span></div>
|
| 516 |
+
<div class="info-row"><span class="label">⛽ 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;">🏠 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;">☀️ 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;">💰 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>🔋 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>← Store energy</span><span>Use stored →</span>
|
| 569 |
</div>
|
| 570 |
</div>
|
| 571 |
<div class="control-group">
|
| 572 |
+
<label>⛽ 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>🏠 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 →</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">☀️ Solar <b>0</b></div>
|
| 602 |
+
<div class="flow-chip supply" id="chipGridIn">⚡ Grid Import <b>0</b></div>
|
| 603 |
+
<div class="flow-chip supply" id="chipBattOut">🔋 Batt Out <b>0</b></div>
|
| 604 |
+
<div class="flow-chip supply" id="chipDiesel">⛽ 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">🏠 Homes <b>0</b></div>
|
| 613 |
+
<div class="flow-chip demand" id="chipGridOut">⚡ Grid Export <b>0</b></div>
|
| 614 |
+
<div class="flow-chip demand" id="chipBattIn">🔋 Batt Charge <b>0</b></div>
|
| 615 |
+
<div class="flow-chip danger" id="chipBlackout" style="display:none">⚠️ 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;">● 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">✅</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">🏠 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">💰 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">🔋 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">⚠️ 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">⛽ 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 = '☀️ Solar <b>' + f.solar.toFixed(0) + '</b>';
|
| 931 |
+
document.getElementById('chipGridIn').innerHTML = '⚡ Grid In <b>' + f.gridIn.toFixed(0) + '</b>';
|
| 932 |
+
document.getElementById('chipBattOut').innerHTML = '🔋 Batt Out <b>' + f.battOut.toFixed(0) + '</b>';
|
| 933 |
+
document.getElementById('chipDiesel').innerHTML = '⛽ Diesel <b>' + f.diesel.toFixed(0) + '</b>';
|
| 934 |
+
document.getElementById('flowTotalSupply').textContent = '= ' + f.totalSupply.toFixed(0) + ' kW supply';
|
| 935 |
+
|
| 936 |
+
document.getElementById('chipDemand').innerHTML = '🏠 Homes <b>' + f.demand.toFixed(0) + '</b>';
|
| 937 |
+
document.getElementById('chipGridOut').innerHTML = '⚡ Grid Out <b>' + f.gridOut.toFixed(0) + '</b>';
|
| 938 |
+
document.getElementById('chipBattIn').innerHTML = '🔋 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 = '⚠️ 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 = '🏠 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 = '⚠️ 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 = '✦ 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 = '⚡ 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 = '● 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 |
-
#
|
| 129 |
-
#
|
| 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 |
-
|
| 207 |
-
|
|
|
|
|
|
|
| 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"]
|