Spaces:
Sleeping
Sleeping
| """ | |
| NexusGrid — Dense Reward Calculator. | |
| Per-tick reward computation with positive signals, penalties, | |
| graduated frequency deviation bands, and the stability bonus. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any, Dict, List, Optional | |
| # --------------------------------------------------------------------------- | |
| # Reward signal values | |
| # --------------------------------------------------------------------------- | |
| # Positive signals | |
| REWARD_FAULT_ISOLATION = 0.20 # Isolating a fault without dropping critical nodes | |
| REWARD_CYBER_DETECTION = 0.15 # Correctly classifying + quarantining a spoofed sensor | |
| REWARD_FREQUENCY_STABLE = 0.10 # Per-tick frequency in nominal band (59.7–60.3Hz) | |
| REWARD_PROACTIVE_DISPATCH = 0.08 # Dispatch before frequency deviation | |
| REWARD_REASONING_ORDER = 0.05 # Reading packet logs before state estimation | |
| REWARD_STABILITY_BONUS = 0.03 # Frequency within ±0.1Hz of 60.0Hz (tightest band) | |
| # Negative signals | |
| PENALTY_OVERLOAD_ROUTING = -0.20 # Routing through ≥95% capacity line | |
| PENALTY_QUARANTINE_NO_EST = -0.15 # Quarantine without preceding state estimation | |
| PENALTY_REDUNDANT_ESTIMATION = -0.05 # State estimation twice on same subgraph without action | |
| # Graduated frequency deviation penalties | |
| PENALTY_FREQ_59_2_TO_59_5 = -0.05 # Per tick in 59.2–59.5Hz band | |
| PENALTY_FREQ_59_0_TO_59_2 = -0.15 # Per tick in 59.0–59.2Hz band | |
| class RewardCalculator: | |
| """ | |
| Computes per-tick rewards for the NexusGrid environment. | |
| Tracks action history to determine reward eligibility. | |
| Returns a breakdown dict and total for each tick. | |
| """ | |
| def __init__(self): | |
| self._total_reward = 0.0 | |
| self._has_read_logs = False | |
| self._has_run_estimation = False | |
| self._estimation_subgraphs: List[set] = [] | |
| self._last_action_was_estimation = False | |
| self._actions_since_estimation = 0 | |
| self._fault_isolation_awarded = False | |
| def reset(self) -> None: | |
| """Reset reward state for a new episode.""" | |
| self._total_reward = 0.0 | |
| self._has_read_logs = False | |
| self._has_run_estimation = False | |
| self._estimation_subgraphs = [] | |
| self._last_action_was_estimation = False | |
| self._actions_since_estimation = 0 | |
| self._fault_isolation_awarded = False | |
| def compute_tick_reward( | |
| self, | |
| action_type: str, | |
| action_params: Dict[str, Any], | |
| frequency_hz: float, | |
| overloaded_edges: List[str], | |
| critical_nodes_shed: int, | |
| is_proactive: bool, | |
| spoof_detected: bool, | |
| fault_isolated: bool, | |
| has_read_logs_before_estimation: bool, | |
| ) -> Dict[str, float]: | |
| """ | |
| Compute all reward signals for a single tick. | |
| Returns: | |
| Dict with signal names as keys and values as floats. | |
| Includes 'total' key with the sum. | |
| """ | |
| breakdown: Dict[str, float] = { | |
| "fault_isolation": 0.0, | |
| "cyber_detection": 0.0, | |
| "frequency_stable": 0.0, | |
| "proactive_dispatch": 0.0, | |
| "reasoning_order": 0.0, | |
| "stability_bonus": 0.0, | |
| "penalties": 0.0, | |
| } | |
| # --- Positive signals --- | |
| # Fault isolation (toggle_circuit_breaker that isolates without dropping critical) | |
| if fault_isolated and critical_nodes_shed == 0 and not self._fault_isolation_awarded: | |
| breakdown["fault_isolation"] = REWARD_FAULT_ISOLATION | |
| self._fault_isolation_awarded = True | |
| # Cyber detection (quarantine after state estimation found violation) | |
| if spoof_detected and action_type == "quarantine_scada_node": | |
| if self._has_run_estimation: | |
| breakdown["cyber_detection"] = REWARD_CYBER_DETECTION | |
| # Frequency stability — nominal band | |
| if 59.7 <= frequency_hz <= 60.3: | |
| breakdown["frequency_stable"] = REWARD_FREQUENCY_STABLE | |
| # Proactive dispatch | |
| if action_type == "dispatch_generation" and is_proactive: | |
| breakdown["proactive_dispatch"] = REWARD_PROACTIVE_DISPATCH | |
| # Reasoning order — read logs before estimation | |
| if action_type == "run_state_estimation" and has_read_logs_before_estimation: | |
| breakdown["reasoning_order"] = REWARD_REASONING_ORDER | |
| # Stability bonus — tight band | |
| if 59.9 <= frequency_hz <= 60.1: | |
| breakdown["stability_bonus"] = REWARD_STABILITY_BONUS | |
| # --- Negative signals (penalties) --- | |
| penalties = 0.0 | |
| # Overload routing | |
| if overloaded_edges and action_type in ("dispatch_generation", "toggle_circuit_breaker"): | |
| penalties += PENALTY_OVERLOAD_ROUTING | |
| # Quarantine without state estimation | |
| if action_type == "quarantine_scada_node" and not self._has_run_estimation: | |
| penalties += PENALTY_QUARANTINE_NO_EST | |
| # Redundant state estimation | |
| if action_type == "run_state_estimation": | |
| subgraph_set = set(action_params.get("subgraph", [])) | |
| if self._last_action_was_estimation and subgraph_set in self._estimation_subgraphs: | |
| if self._actions_since_estimation == 0: | |
| penalties += PENALTY_REDUNDANT_ESTIMATION | |
| self._estimation_subgraphs.append(subgraph_set) | |
| self._last_action_was_estimation = True | |
| self._has_run_estimation = True | |
| self._actions_since_estimation = 0 | |
| else: | |
| self._last_action_was_estimation = False | |
| if self._has_run_estimation: | |
| self._actions_since_estimation += 1 | |
| # Graduated frequency deviation penalties | |
| if 59.2 <= frequency_hz < 59.5: | |
| penalties += PENALTY_FREQ_59_2_TO_59_5 | |
| elif 59.0 <= frequency_hz < 59.2: | |
| penalties += PENALTY_FREQ_59_0_TO_59_2 | |
| breakdown["penalties"] = penalties | |
| # Total | |
| total = sum(breakdown.values()) | |
| breakdown["total"] = total | |
| self._total_reward += total | |
| return breakdown | |
| def total_reward(self) -> float: | |
| """Get accumulated total reward.""" | |
| return self._total_reward | |
| def mark_logs_read(self) -> None: | |
| """Mark that packet logs have been read (for reasoning order check).""" | |
| self._has_read_logs = True | |
| def has_read_logs(self) -> bool: | |
| """Whether packet logs have been read.""" | |
| return self._has_read_logs | |