EcoGrid / env /dynamics.py
Loosebag's picture
fix: stabilize deployment, action safety, and benchmark reproducibility
30bdd62
Raw
History Blame Contribute Delete
9.73 kB
"""
EcoGrid-OpenEnv — Deterministic Physics Helpers
All functions are pure, stateless, and deterministic given a seeded RNG.
They use lightweight numpy math for speed (>10,000 episodes/second).
"""
from __future__ import annotations
import numpy as np
from numpy.random import Generator
def solar_output(time_step: int, noise_level: float, rng: Generator) -> float:
"""Compute solar generation capacity for a given timestep.
Uses a sinusoidal day/night cycle (period = 24 steps) with Gaussian noise.
Peak solar at step 12 (noon), zero at step 0 and 24 (midnight).
Args:
time_step: Current simulation step (maps to hour of day via modulo 24).
noise_level: Standard deviation of Gaussian noise (0 = deterministic).
rng: Seeded numpy random generator for reproducibility.
Returns:
Solar capacity as a float in [0, 1].
"""
hour = time_step % 24
# Sinusoidal curve: peaks at hour 12, trough at 0/24
base = max(0.0, np.sin(np.pi * hour / 24.0))
noise = rng.normal(0, noise_level) if noise_level > 0 else 0.0
return float(np.clip(base + noise, 0.0, 1.0))
def wind_output(
time_step: int,
previous_wind: float,
noise_level: float,
rng: Generator,
) -> float:
"""Compute wind generation capacity using a smooth random walk.
Wind is modelled as a mean-reverting random walk around 0.4,
providing realistic temporal correlation.
Args:
time_step: Current simulation step (used for base variation).
previous_wind: Wind capacity from the previous step.
noise_level: Scale of random walk step.
rng: Seeded numpy random generator.
Returns:
Wind capacity as a float in [0, 1].
"""
# Mean-reverting around 0.4 with slow sinusoidal drift
mean = 0.4 + 0.1 * np.sin(2 * np.pi * time_step / 48.0)
# Random walk step with mean reversion
reversion_strength = 0.1
step_noise = rng.normal(0, noise_level * 0.15) if noise_level > 0 else 0.0
new_wind = (
previous_wind
+ reversion_strength * (mean - previous_wind)
+ step_noise
)
return float(np.clip(new_wind, 0.0, 1.0))
def demand_curve(
time_step: int,
base_demand: float,
volatility: float,
rng: Generator,
) -> float:
"""Compute energy demand for a given timestep.
Realistic demand profile with morning and evening peaks,
plus random spikes based on volatility.
Args:
time_step: Current simulation step.
base_demand: Base demand level in MWh (typically 80).
volatility: Probability multiplier for demand spikes.
rng: Seeded numpy random generator.
Returns:
Demand in MWh, clamped to [0, 200].
"""
hour = time_step % 24
# Morning peak (around hour 8)
morning = 30.0 * np.exp(-((hour - 8) ** 2) / 8.0)
# Evening peak (around hour 18)
evening = 40.0 * np.exp(-((hour - 18) ** 2) / 8.0)
# Weekly operational cycle (business-day effect)
day_of_week = (time_step // 24) % 7
weekday_multiplier = 1.0 if day_of_week < 5 else 0.92
# Correlated weather/event noise on demand.
# Deterministic under the seeded RNG.
stochastic_component = rng.normal(0, 4.0 * max(0.2, volatility))
# Random spike (occurs with probability proportional to volatility)
spike = 0.0
if volatility > 0 and rng.random() < 0.05 * volatility:
spike = rng.uniform(10, 40) * volatility
demand = (base_demand + morning + evening + stochastic_component + spike) * weekday_multiplier
return float(np.clip(demand, 0.0, 200.0))
def update_battery(
level: float,
action: float,
capacity: float,
charge_rate: float = 0.15,
charge_efficiency: float = 0.94,
discharge_efficiency: float = 0.94,
) -> float:
"""Update battery state of charge.
Args:
level: Current battery level [0, 1].
action: Charge/discharge action [-1, +1].
capacity: Battery capacity (0 = disabled, 1 = full capacity).
charge_rate: Max charge/discharge per step.
Returns:
New battery level, clamped to [0, 1].
"""
if capacity <= 0:
return 0.0
if action >= 0:
delta = action * charge_rate * capacity * charge_efficiency
else:
# Discharging removes more SoC than delivered energy because of losses.
eff = max(discharge_efficiency, 1e-6)
delta = action * charge_rate * capacity / eff
return float(np.clip(level + delta, 0.0, 1.0))
def compute_blackout_risk(demand: float, supply: float) -> float:
"""Compute blackout risk based on supply-demand gap.
Args:
demand: Current demand in MWh.
supply: Total supply from all sources in MWh.
Returns:
Blackout risk as float in [0, 1]. 0 = no risk, 1 = total blackout.
"""
if demand <= 0:
return 0.0
gap = max(0.0, demand - supply)
risk = gap / demand
return float(np.clip(risk, 0.0, 1.0))
def carbon_emission(
fossil_ratio: float,
demand: float,
emission_factor: float = 0.5,
) -> float:
"""Compute carbon emissions from fossil fuel usage.
Args:
fossil_ratio: Fraction of demand met by fossil fuels [0, 1].
demand: Current demand in MWh.
emission_factor: kg CO₂ per MWh of fossil generation.
Returns:
Carbon emissions in kgCO₂.
"""
return float(fossil_ratio * demand * emission_factor)
def compute_supply(
action_renewable: float,
action_fossil: float,
battery_action: float,
solar_cap: float,
wind_cap: float,
battery_level: float,
battery_capacity: float,
demand: float,
previous_fossil_ratio: float | None = None,
fossil_ramp_limit: float | None = None,
discharge_efficiency: float = 0.94,
) -> tuple[float, float, float, float, float]:
"""Compute total energy supply from all sources.
Args:
action_renewable: Fraction allocated to renewables.
action_fossil: Fraction allocated to fossil.
battery_action: Battery charge/discharge action.
solar_cap: Current solar capacity [0, 1].
wind_cap: Current wind capacity [0, 1].
battery_level: Current battery level [0, 1].
battery_capacity: Battery storage capacity.
demand: Current demand in MWh.
Returns:
Tuple of (renewable_supply, fossil_supply, battery_supply, total_supply) in MWh.
"""
# Renewable supply is limited by actual capacity
avg_renewable_cap = (solar_cap + wind_cap) / 2.0
renewable_supply = action_renewable * demand * min(1.0, avg_renewable_cap / max(action_renewable, 0.01))
# Fossil ramp-rate constraints emulate thermal plant limitations.
effective_fossil_ratio = action_fossil
if previous_fossil_ratio is not None and fossil_ramp_limit is not None:
lower = max(0.0, previous_fossil_ratio - fossil_ramp_limit)
upper = min(1.0, previous_fossil_ratio + fossil_ramp_limit)
effective_fossil_ratio = float(np.clip(action_fossil, lower, upper))
# Fossil supply (dispatchable but ramp-limited when configured)
fossil_supply = effective_fossil_ratio * demand
# Battery can supplement supply when discharging
battery_supply = 0.0
if battery_action < 0 and battery_capacity > 0:
# Discharging: supply is proportional to discharge rate and level
battery_supply = (
abs(battery_action)
* battery_level
* battery_capacity
* demand
* 0.2
* discharge_efficiency
)
total = renewable_supply + fossil_supply + battery_supply
return (
float(renewable_supply),
float(fossil_supply),
float(battery_supply),
float(total),
float(effective_fossil_ratio),
)
def compute_price_signal(
time_step: int,
demand: float,
supply: float,
rng: Generator,
) -> float:
"""Compute spot electricity price based on supply-demand dynamics.
Args:
time_step: Current timestep.
demand: Demand in MWh.
supply: Total supply in MWh.
rng: Seeded random generator.
Returns:
Price signal in $/MWh, clamped to [0, 300].
"""
# Base price follows demand pattern
base_price = 50.0 + (demand / 200.0) * 100.0
# Scarcity premium when supply < demand
if supply < demand and demand > 0:
scarcity = (demand - supply) / demand
base_price += scarcity * 150.0
# Small random noise
noise = rng.normal(0, 5.0)
return float(np.clip(base_price + noise, 0.0, 300.0))
def compute_grid_stability(
demand: float,
supply: float,
renewable_ratio: float,
previous_stability: float,
) -> float:
"""Compute grid stability metric.
Stability is affected by:
- Supply-demand balance (main factor)
- High renewable penetration (slight instability due to variability)
- Momentum from previous stability (grid has inertia)
Args:
demand: Current demand.
supply: Total supply.
renewable_ratio: Fraction of supply from renewables.
previous_stability: Stability from previous step.
Returns:
Grid stability in [0, 1].
"""
# Balance factor: how well supply matches demand
if demand > 0:
balance = 1.0 - abs(demand - supply) / demand
else:
balance = 1.0
balance = max(0.0, balance)
# Renewable variability penalty (mild)
variability_penalty = renewable_ratio * 0.05
# Current stability
current = balance - variability_penalty
# Momentum: 70% current, 30% previous
stability = 0.7 * current + 0.3 * previous_stability
return float(np.clip(stability, 0.0, 1.0))