""" 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))