Upload folder using huggingface_hub
Browse files- gridops/server/environment.py +7 -1
- gridops/server/static/index.html +1 -1
- gridops/simulation/physics.py +17 -10
- gridops/simulation/scenarios.py +1 -0
- gridops/tasks/definitions.py +18 -8
- gridops/tasks/graders.py +10 -8
- openenv.yaml +1 -1
gridops/server/environment.py
CHANGED
|
@@ -107,6 +107,10 @@ class GridOpsEnvironment(Environment):
|
|
| 107 |
)
|
| 108 |
|
| 109 |
h = self._micro.hour
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
result = physics.step(
|
| 111 |
self._micro,
|
| 112 |
battery_dispatch_norm=action.battery_dispatch,
|
|
@@ -116,6 +120,7 @@ class GridOpsEnvironment(Environment):
|
|
| 116 |
demand_kw=float(self._demand[h]),
|
| 117 |
grid_price=float(self._price[h]),
|
| 118 |
diesel_fuel_cap=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
|
|
|
|
| 119 |
)
|
| 120 |
|
| 121 |
self._last_flows = result.flows
|
|
@@ -137,7 +142,8 @@ class GridOpsEnvironment(Environment):
|
|
| 137 |
|
| 138 |
if result.done:
|
| 139 |
self._grade = grade_episode(
|
| 140 |
-
self._micro, self._demand, self._solar, self._price
|
|
|
|
| 141 |
)
|
| 142 |
|
| 143 |
obs = self._make_observation(
|
|
|
|
| 107 |
)
|
| 108 |
|
| 109 |
h = self._micro.hour
|
| 110 |
+
# Check if grid is available this hour (outage = islanding mode)
|
| 111 |
+
outage_hours = self._cfg.grid_outage_hours or []
|
| 112 |
+
grid_up = h not in outage_hours
|
| 113 |
+
|
| 114 |
result = physics.step(
|
| 115 |
self._micro,
|
| 116 |
battery_dispatch_norm=action.battery_dispatch,
|
|
|
|
| 120 |
demand_kw=float(self._demand[h]),
|
| 121 |
grid_price=float(self._price[h]),
|
| 122 |
diesel_fuel_cap=self._cfg.diesel_fuel_capacity * DIESEL_TANK_KWH,
|
| 123 |
+
grid_available=grid_up,
|
| 124 |
)
|
| 125 |
|
| 126 |
self._last_flows = result.flows
|
|
|
|
| 142 |
|
| 143 |
if result.done:
|
| 144 |
self._grade = grade_episode(
|
| 145 |
+
self._micro, self._demand, self._solar, self._price,
|
| 146 |
+
grid_outage_hours=self._cfg.grid_outage_hours,
|
| 147 |
)
|
| 148 |
|
| 149 |
obs = self._make_observation(
|
gridops/server/static/index.html
CHANGED
|
@@ -577,7 +577,7 @@
|
|
| 577 |
<div class="control-group">
|
| 578 |
<label>🏠 Ask residents to cut usage <span class="val" id="shedVal">none</span></label>
|
| 579 |
<input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
|
| 580 |
-
<div style="font-size:9px;color:var(--yellow)">
|
| 581 |
</div>
|
| 582 |
</div>
|
| 583 |
|
|
|
|
| 577 |
<div class="control-group">
|
| 578 |
<label>🏠 Ask residents to cut usage <span class="val" id="shedVal">none</span></label>
|
| 579 |
<input type="range" id="shedSlider" min="0" max="100" value="0" step="1">
|
| 580 |
+
<div style="font-size:9px;color:var(--yellow)">100% rebounds next hour! Rs 40/kWh penalty.</div>
|
| 581 |
</div>
|
| 582 |
</div>
|
| 583 |
|
gridops/simulation/physics.py
CHANGED
|
@@ -30,7 +30,7 @@ DIESEL_MAX_KW = 100.0
|
|
| 30 |
DIESEL_COST_PER_KWH = 25.0
|
| 31 |
DIESEL_STARTUP_COST = 100.0 # Rs, one-time when turning on from off
|
| 32 |
DEMAND_SHED_MAX_FRAC = 0.20
|
| 33 |
-
SHED_REBOUND_FRAC =
|
| 34 |
DIESEL_TANK_KWH = 2400.0 # total fuel capacity
|
| 35 |
VOLL = 150.0 # Value of Lost Load (Rs/kWh)
|
| 36 |
DT = 1.0 # 1 hour per step
|
|
@@ -102,6 +102,7 @@ def step(
|
|
| 102 |
demand_kw: float,
|
| 103 |
grid_price: float,
|
| 104 |
diesel_fuel_cap: float = DIESEL_TANK_KWH,
|
|
|
|
| 105 |
) -> StepResult:
|
| 106 |
"""
|
| 107 |
Advance the microgrid by one hour.
|
|
@@ -119,7 +120,7 @@ def step(
|
|
| 119 |
shed_frac = float(np.clip(shed_norm, 0, 1)) * DEMAND_SHED_MAX_FRAC
|
| 120 |
|
| 121 |
# ── Demand (with shedding rebound from last step) ────────────────
|
| 122 |
-
actual_demand = demand_kw + state.shed_rebound_kwh
|
| 123 |
effective_demand = actual_demand * (1.0 - shed_frac)
|
| 124 |
shed_kwh = actual_demand * shed_frac * DT
|
| 125 |
state.shed_rebound_kwh = shed_kwh * SHED_REBOUND_FRAC # 50% rebounds next hour
|
|
@@ -152,16 +153,17 @@ def step(
|
|
| 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, -
|
| 157 |
|
| 158 |
# ── Blackout / curtailment detection ─────────────────────────────
|
| 159 |
blackout_kwh = 0.0
|
| 160 |
curtailed_kw = 0.0
|
| 161 |
-
if residual >
|
| 162 |
-
blackout_kwh = (residual -
|
| 163 |
-
elif residual < -
|
| 164 |
-
curtailed_kw = abs(residual) -
|
| 165 |
|
| 166 |
# ── Build flow snapshot ──────────────────────────────────────────
|
| 167 |
grid_import = max(0.0, grid_kw)
|
|
@@ -208,8 +210,9 @@ def step(
|
|
| 208 |
# VoLL penalty (replaces hard reliability gate)
|
| 209 |
step_cost += VOLL * blackout_kwh
|
| 210 |
|
| 211 |
-
# Shedding penalty (
|
| 212 |
-
|
|
|
|
| 213 |
|
| 214 |
# ── Fuel accounting ──────────────────────────────────────────────
|
| 215 |
state.diesel_fuel_kwh -= diesel_kw * DT
|
|
@@ -235,7 +238,7 @@ def step(
|
|
| 235 |
|
| 236 |
# ── Narration ────────────────────────────────────────────────────
|
| 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 |
|
|
@@ -250,6 +253,7 @@ def _narrate(
|
|
| 250 |
shed: float,
|
| 251 |
grid_kw: float,
|
| 252 |
battery_kw: float,
|
|
|
|
| 253 |
) -> str:
|
| 254 |
"""Generate a short human-readable situation summary."""
|
| 255 |
START_HOUR = 6
|
|
@@ -260,6 +264,9 @@ def _narrate(
|
|
| 260 |
|
| 261 |
parts = [f"Day {day}, {hour_of_day:02d}:00."]
|
| 262 |
|
|
|
|
|
|
|
|
|
|
| 263 |
if blackout > 0:
|
| 264 |
parts.append(f"BLACKOUT: {blackout:.0f} kWh unmet!")
|
| 265 |
elif demand > 200:
|
|
|
|
| 30 |
DIESEL_COST_PER_KWH = 25.0
|
| 31 |
DIESEL_STARTUP_COST = 100.0 # Rs, one-time when turning on from off
|
| 32 |
DEMAND_SHED_MAX_FRAC = 0.20
|
| 33 |
+
SHED_REBOUND_FRAC = 1.00 # 100% of shed energy rebounds next hour (deferred, not destroyed)
|
| 34 |
DIESEL_TANK_KWH = 2400.0 # total fuel capacity
|
| 35 |
VOLL = 150.0 # Value of Lost Load (Rs/kWh)
|
| 36 |
DT = 1.0 # 1 hour per step
|
|
|
|
| 102 |
demand_kw: float,
|
| 103 |
grid_price: float,
|
| 104 |
diesel_fuel_cap: float = DIESEL_TANK_KWH,
|
| 105 |
+
grid_available: bool = True,
|
| 106 |
) -> StepResult:
|
| 107 |
"""
|
| 108 |
Advance the microgrid by one hour.
|
|
|
|
| 120 |
shed_frac = float(np.clip(shed_norm, 0, 1)) * DEMAND_SHED_MAX_FRAC
|
| 121 |
|
| 122 |
# ── Demand (with shedding rebound from last step) ────────────────
|
| 123 |
+
actual_demand = demand_kw + state.shed_rebound_kwh / DT # rebound is kWh, convert to kW
|
| 124 |
effective_demand = actual_demand * (1.0 - shed_frac)
|
| 125 |
shed_kwh = actual_demand * shed_frac * DT
|
| 126 |
state.shed_rebound_kwh = shed_kwh * SHED_REBOUND_FRAC # 50% rebounds next hour
|
|
|
|
| 153 |
# ── Grid as slack variable ───────────────────────────────────────
|
| 154 |
# residual = what the community still needs after solar + battery + diesel
|
| 155 |
# positive → grid must import; negative → surplus exported
|
| 156 |
+
grid_cap = GRID_MAX_KW if grid_available else 0.0
|
| 157 |
residual = effective_demand - solar_kw - delivered_kw - diesel_kw
|
| 158 |
+
grid_kw = float(np.clip(residual, -grid_cap, grid_cap))
|
| 159 |
|
| 160 |
# ── Blackout / curtailment detection ─────────────────────────────
|
| 161 |
blackout_kwh = 0.0
|
| 162 |
curtailed_kw = 0.0
|
| 163 |
+
if residual > grid_cap:
|
| 164 |
+
blackout_kwh = (residual - grid_cap) * DT
|
| 165 |
+
elif residual < -grid_cap:
|
| 166 |
+
curtailed_kw = abs(residual) - grid_cap # excess that can't be exported
|
| 167 |
|
| 168 |
# ── Build flow snapshot ──────────────────────────────────────────
|
| 169 |
grid_import = max(0.0, grid_kw)
|
|
|
|
| 210 |
# VoLL penalty (replaces hard reliability gate)
|
| 211 |
step_cost += VOLL * blackout_kwh
|
| 212 |
|
| 213 |
+
# Shedding penalty (comfort + political cost — Rs 40/kWh shed)
|
| 214 |
+
# More expensive than diesel (Rs 25), so only used as true emergency
|
| 215 |
+
step_cost += 40.0 * shed_kwh
|
| 216 |
|
| 217 |
# ── Fuel accounting ──────────────────────────────────────────────
|
| 218 |
state.diesel_fuel_kwh -= diesel_kw * DT
|
|
|
|
| 238 |
|
| 239 |
# ── Narration ────────────────────────────────────────────────────
|
| 240 |
narration = _narrate(state, solar_kw, actual_demand, grid_price, blackout_kwh,
|
| 241 |
+
diesel_kw, shed_frac, grid_kw, delivered_kw, grid_available)
|
| 242 |
|
| 243 |
return StepResult(state=state, reward=reward, done=done, narration=narration, flows=flows)
|
| 244 |
|
|
|
|
| 253 |
shed: float,
|
| 254 |
grid_kw: float,
|
| 255 |
battery_kw: float,
|
| 256 |
+
grid_available: bool = True,
|
| 257 |
) -> str:
|
| 258 |
"""Generate a short human-readable situation summary."""
|
| 259 |
START_HOUR = 6
|
|
|
|
| 264 |
|
| 265 |
parts = [f"Day {day}, {hour_of_day:02d}:00."]
|
| 266 |
|
| 267 |
+
if not grid_available:
|
| 268 |
+
parts.append("GRID OUTAGE — islanding mode! No grid import/export.")
|
| 269 |
+
|
| 270 |
if blackout > 0:
|
| 271 |
parts.append(f"BLACKOUT: {blackout:.0f} kWh unmet!")
|
| 272 |
elif demand > 200:
|
gridops/simulation/scenarios.py
CHANGED
|
@@ -27,6 +27,7 @@ class ScenarioConfig:
|
|
| 27 |
cloud_hours: list[int] | None = None # hours with intermittent clouds
|
| 28 |
diesel_fuel_capacity: float = 1.0 # 1.0 = full tank (800 kWh worth)
|
| 29 |
forecast_noise: float = 0.15 # ±15 % Gaussian noise on forecasts
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
# ── Demand ───────────────────────────────────────────────────────────────
|
|
|
|
| 27 |
cloud_hours: list[int] | None = None # hours with intermittent clouds
|
| 28 |
diesel_fuel_capacity: float = 1.0 # 1.0 = full tank (800 kWh worth)
|
| 29 |
forecast_noise: float = 0.15 # ±15 % Gaussian noise on forecasts
|
| 30 |
+
grid_outage_hours: list[int] | None = None # hours where grid cap drops to 0 (islanding)
|
| 31 |
|
| 32 |
|
| 33 |
# ── Demand ───────────────────────────────────────────────────────────────
|
gridops/tasks/definitions.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
Three task configurations with escalating difficulty.
|
| 3 |
|
| 4 |
-
Task 1: Normal Summer (easy)
|
| 5 |
-
Task 2: Heatwave +
|
| 6 |
-
Task 3: Extreme Crisis (hard)
|
| 7 |
"""
|
| 8 |
|
| 9 |
from gridops.simulation.scenarios import ScenarioConfig
|
|
@@ -19,21 +19,30 @@ TASK_1_NORMAL = ScenarioConfig(
|
|
| 19 |
cloud_hours=None,
|
| 20 |
diesel_fuel_capacity=1.0,
|
| 21 |
forecast_noise=0.15,
|
|
|
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
TASK_2_HEATWAVE = ScenarioConfig(
|
| 25 |
demand_multiplier=1.3,
|
| 26 |
solar_multiplier=1.0,
|
| 27 |
-
price_floor=
|
| 28 |
price_ceiling=15.0,
|
| 29 |
-
price_spike_hour=
|
| 30 |
-
price_spike_value=
|
| 31 |
heatwave_start_hour=24, # Day 2 start
|
| 32 |
-
cloud_hours=[30, 31, 36, 37, 54, 55],
|
| 33 |
diesel_fuel_capacity=1.0,
|
| 34 |
forecast_noise=0.15,
|
|
|
|
| 35 |
)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
| 37 |
TASK_3_CRISIS = ScenarioConfig(
|
| 38 |
demand_multiplier=1.5,
|
| 39 |
solar_multiplier=0.7,
|
|
@@ -41,10 +50,11 @@ TASK_3_CRISIS = ScenarioConfig(
|
|
| 41 |
price_ceiling=20.0,
|
| 42 |
price_spike_hour=44,
|
| 43 |
price_spike_value=20.0,
|
| 44 |
-
heatwave_start_hour=0,
|
| 45 |
cloud_hours=list(range(8, 16)) + list(range(32, 40)) + list(range(56, 64)),
|
| 46 |
diesel_fuel_capacity=0.33, # 800 kWh ≈ 8 hrs at 100 kW
|
| 47 |
forecast_noise=0.15,
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
TASKS = {
|
|
|
|
| 1 |
"""
|
| 2 |
Three task configurations with escalating difficulty.
|
| 3 |
|
| 4 |
+
Task 1: Normal Summer (easy) — basic arbitrage
|
| 5 |
+
Task 2: Heatwave + Price Spike (medium) — forces forecasting / temporal planning
|
| 6 |
+
Task 3: Extreme Crisis + Grid Outage (hard) — forces islanding / constraint management
|
| 7 |
"""
|
| 8 |
|
| 9 |
from gridops.simulation.scenarios import ScenarioConfig
|
|
|
|
| 19 |
cloud_hours=None,
|
| 20 |
diesel_fuel_capacity=1.0,
|
| 21 |
forecast_noise=0.15,
|
| 22 |
+
grid_outage_hours=None,
|
| 23 |
)
|
| 24 |
|
| 25 |
+
# Task 2: Heatwave + severe evening price spikes on Day 2-3.
|
| 26 |
+
# Mid-day prices dip (solar glut), then spike to Rs 18-20 at evening.
|
| 27 |
+
# A greedy agent discharges battery mid-day; an RL agent reads the
|
| 28 |
+
# 4-hour forecast and HOLDS charge for the evening spike.
|
| 29 |
TASK_2_HEATWAVE = ScenarioConfig(
|
| 30 |
demand_multiplier=1.3,
|
| 31 |
solar_multiplier=1.0,
|
| 32 |
+
price_floor=3.0,
|
| 33 |
price_ceiling=15.0,
|
| 34 |
+
price_spike_hour=36, # Day 2, ~18:00 (step 36 = hour 12 of day 2)
|
| 35 |
+
price_spike_value=20.0, # severe spike — hold battery for this
|
| 36 |
heatwave_start_hour=24, # Day 2 start
|
| 37 |
+
cloud_hours=[30, 31, 36, 37, 54, 55],
|
| 38 |
diesel_fuel_capacity=1.0,
|
| 39 |
forecast_noise=0.15,
|
| 40 |
+
grid_outage_hours=None,
|
| 41 |
)
|
| 42 |
|
| 43 |
+
# Task 3: Full crisis + 6-hour grid outage on Day 2 evening.
|
| 44 |
+
# Grid cap drops to 0 kW during outage — agent must survive on
|
| 45 |
+
# battery + diesel + shedding alone. Tests true islanding capability.
|
| 46 |
TASK_3_CRISIS = ScenarioConfig(
|
| 47 |
demand_multiplier=1.5,
|
| 48 |
solar_multiplier=0.7,
|
|
|
|
| 50 |
price_ceiling=20.0,
|
| 51 |
price_spike_hour=44,
|
| 52 |
price_spike_value=20.0,
|
| 53 |
+
heatwave_start_hour=0,
|
| 54 |
cloud_hours=list(range(8, 16)) + list(range(32, 40)) + list(range(56, 64)),
|
| 55 |
diesel_fuel_capacity=0.33, # 800 kWh ≈ 8 hrs at 100 kW
|
| 56 |
forecast_noise=0.15,
|
| 57 |
+
grid_outage_hours=list(range(30, 36)), # 6-hour outage: Day 2, ~12:00-18:00
|
| 58 |
)
|
| 59 |
|
| 60 |
TASKS = {
|
gridops/tasks/graders.py
CHANGED
|
@@ -28,26 +28,27 @@ def compute_dumb_baseline_cost(
|
|
| 28 |
demand_curve: np.ndarray,
|
| 29 |
solar_curve: np.ndarray,
|
| 30 |
price_curve: np.ndarray,
|
|
|
|
| 31 |
) -> float:
|
| 32 |
"""Cost of a dumb baseline: import max grid, no battery/diesel/shedding.
|
| 33 |
|
| 34 |
Where demand > grid + solar, apply VoLL for the blackout.
|
| 35 |
-
|
| 36 |
"""
|
|
|
|
| 37 |
total_cost = 0.0
|
| 38 |
for h in range(len(demand_curve)):
|
| 39 |
demand = demand_curve[h]
|
| 40 |
solar = solar_curve[h]
|
| 41 |
price = price_curve[h]
|
|
|
|
| 42 |
|
| 43 |
-
# Grid covers what it can (up to 200 kW)
|
| 44 |
needed_from_grid = max(0.0, demand - solar)
|
| 45 |
-
grid_import = min(needed_from_grid,
|
| 46 |
-
total_cost += price * grid_import
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
total_cost += VOLL * unmet # VoLL penalty
|
| 51 |
|
| 52 |
return float(total_cost)
|
| 53 |
|
|
@@ -57,6 +58,7 @@ def grade_episode(
|
|
| 57 |
demand_curve: np.ndarray,
|
| 58 |
solar_curve: np.ndarray,
|
| 59 |
price_curve: np.ndarray,
|
|
|
|
| 60 |
) -> dict:
|
| 61 |
"""
|
| 62 |
Grade a completed episode. Returns dict with score 0.0-1.0.
|
|
@@ -71,7 +73,7 @@ def grade_episode(
|
|
| 71 |
reliability = float(np.clip(reliability, 0, 1))
|
| 72 |
|
| 73 |
# Cost efficiency: how much better than dumb baseline
|
| 74 |
-
baseline_cost = compute_dumb_baseline_cost(demand_curve, solar_curve, price_curve)
|
| 75 |
actual_cost = state.cumulative_cost
|
| 76 |
if baseline_cost > 0:
|
| 77 |
cost_efficiency = 1.0 - (actual_cost / baseline_cost)
|
|
|
|
| 28 |
demand_curve: np.ndarray,
|
| 29 |
solar_curve: np.ndarray,
|
| 30 |
price_curve: np.ndarray,
|
| 31 |
+
grid_outage_hours: list[int] | None = None,
|
| 32 |
) -> float:
|
| 33 |
"""Cost of a dumb baseline: import max grid, no battery/diesel/shedding.
|
| 34 |
|
| 35 |
Where demand > grid + solar, apply VoLL for the blackout.
|
| 36 |
+
During grid outages, all non-solar demand is blackout.
|
| 37 |
"""
|
| 38 |
+
outages = set(grid_outage_hours or [])
|
| 39 |
total_cost = 0.0
|
| 40 |
for h in range(len(demand_curve)):
|
| 41 |
demand = demand_curve[h]
|
| 42 |
solar = solar_curve[h]
|
| 43 |
price = price_curve[h]
|
| 44 |
+
grid_cap = 0.0 if h in outages else GRID_MAX_KW
|
| 45 |
|
|
|
|
| 46 |
needed_from_grid = max(0.0, demand - solar)
|
| 47 |
+
grid_import = min(needed_from_grid, grid_cap)
|
| 48 |
+
total_cost += price * grid_import
|
| 49 |
|
| 50 |
+
unmet = max(0.0, needed_from_grid - grid_cap)
|
| 51 |
+
total_cost += VOLL * unmet
|
|
|
|
| 52 |
|
| 53 |
return float(total_cost)
|
| 54 |
|
|
|
|
| 58 |
demand_curve: np.ndarray,
|
| 59 |
solar_curve: np.ndarray,
|
| 60 |
price_curve: np.ndarray,
|
| 61 |
+
grid_outage_hours: list[int] | None = None,
|
| 62 |
) -> dict:
|
| 63 |
"""
|
| 64 |
Grade a completed episode. Returns dict with score 0.0-1.0.
|
|
|
|
| 73 |
reliability = float(np.clip(reliability, 0, 1))
|
| 74 |
|
| 75 |
# Cost efficiency: how much better than dumb baseline
|
| 76 |
+
baseline_cost = compute_dumb_baseline_cost(demand_curve, solar_curve, price_curve, grid_outage_hours)
|
| 77 |
actual_cost = state.cumulative_cost
|
| 78 |
if baseline_cost > 0:
|
| 79 |
cost_efficiency = 1.0 - (actual_cost / baseline_cost)
|
openenv.yaml
CHANGED
|
@@ -36,4 +36,4 @@ metadata:
|
|
| 36 |
observation_space: "12 fields: hour, demand, solar, battery_soc, price, fuel, 3×4h forecasts, cumulative metrics"
|
| 37 |
episode_length: 72
|
| 38 |
step_duration: "1 hour"
|
| 39 |
-
grading: "
|
|
|
|
| 36 |
observation_space: "12 fields: hour, demand, solar, battery_soc, price, fuel, 3×4h forecasts, cumulative metrics"
|
| 37 |
episode_length: 72
|
| 38 |
step_duration: "1 hour"
|
| 39 |
+
grading: "Composite: 50% cost efficiency + 25% reliability + 25% green score. VoLL Rs 150/kWh blackout penalty."
|