Nexus-Grid / server /nexusgrid_environment.py
Abineshsdata's picture
Add manifest.json endpoint, update dashboard and app
74965f9
Raw
History Blame Contribute Delete
23.9 kB
"""
NexusGrid-CyberPhysEnv — Core Environment Implementation.
Full OpenEnv-compliant environment: step() / reset() / state().
Wires together: GridEngine + SpoofEngine + RewardCalculator + Graders.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from uuid import uuid4
try:
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
except ImportError:
class Environment: # type: ignore[override]
"""Lightweight fallback for local testing without openenv-core."""
class State: # type: ignore[override]
def __init__(self, episode_id: str, step_count: int):
self.episode_id = episode_id
self.step_count = step_count
try:
from ..models import GridAction, GridObservation, ActionType
except ImportError:
from models import GridAction, GridObservation, ActionType
from .grid_engine import GridEngine
from .scenarios import build_scenario, MAX_TICKS, TASK_NAMES
from .spoof_engine import SpoofEngine
from .reward import RewardCalculator
from .graders import grade_task
from .rubric import evaluate_task_rubrics
from .training_logger import TrainingLogger
class NexusgridEnvironment(Environment):
"""
NexusGrid-CyberPhysEnv: National power grid defense under cyber-physical attack.
A 20-node transmission network with DC power flow physics running alongside
a deterministic SCADA sensor spoofing engine. The agent must distinguish
real grid failures from adversarially fabricated telemetry.
OpenEnv API:
reset(seed) → GridObservation (initial state)
step(action) → GridObservation (with reward, done, info embedded)
state → GridState (full internal state for debugging)
"""
SUPPORTS_CONCURRENT_SESSIONS: bool = True
def __init__(self):
"""Initialize with default state."""
self._episode_id = str(uuid4())
self._seed = 42
self._task_id = 0
self._tick = 0
self._done = False
self._total_reward = 0.0
self._max_ticks = 3
# Core engines
self._engine: Optional[GridEngine] = None
self._spoof: Optional[SpoofEngine] = None
self._reward_calc = RewardCalculator()
# Action history for grading
self._action_history: List[Dict[str, Any]] = []
# Frequency history for grading
self._frequency_history: List[float] = []
# Spoofed telemetry cache
self._current_spoofed_telemetry: Dict[str, Dict] = {}
# Attack config from scenario
self._attack_config: Dict[str, Any] = {}
# State estimation tracking
self._state_estimation_run = False
self._last_estimation_result: Optional[Dict] = None
# Black Start tracking
self._hydro_stable_ticks = 0
self._max_island_count = 0
self._successful_mergers = 0
self._premature_mergers = 0
self._transformer_failures = 0
self._full_restoration_tick: Optional[int] = None
# Track if packet logs have been "read" (agent called advance_tick to observe)
self._logs_read_this_episode = False
self._logs_read_before_estimation = False
# Internal OpenEnv state
self._state = State(episode_id=self._episode_id, step_count=0)
def reset(self, seed: int = None, task_id: int = None, **kwargs) -> GridObservation:
"""
Reset the environment for a new episode.
Args:
seed: Episode seed for reproducibility. Defaults to 42.
task_id: Which task to run (0-5). Can also be passed in kwargs.
Returns:
Initial GridObservation with all fields freshly computed.
"""
# Handle task_id from kwargs (OpenEnv may pass it there)
if task_id is None:
task_id = kwargs.get("task_id", self._task_id)
if seed is None:
seed = kwargs.get("seed", 42)
self._seed = seed
self._task_id = task_id
self._tick = 0
self._done = False
self._total_reward = 0.0
self._episode_id = str(uuid4())
self._action_history = []
self._frequency_history = []
self._state_estimation_run = False
self._last_estimation_result = None
self._logs_read_this_episode = False
self._logs_read_before_estimation = False
self._full_restoration_tick = None
# Black Start tracking
self._hydro_stable_ticks = 0
self._max_island_count = 0
self._successful_mergers = 0
self._premature_mergers = 0
self._transformer_failures = 0
# Build scenario
scenario = build_scenario(task_id, seed)
self._engine = scenario["engine"]
self._max_ticks = scenario["max_ticks"]
self._attack_config = scenario.get("attack_config", {})
# Initialize spoof engine
self._spoof = SpoofEngine(seed)
if self._attack_config:
self._spoof.configure_attack(self._attack_config)
# Initialize reward calculator
self._reward_calc = RewardCalculator()
# Record initial frequency
self._frequency_history.append(self._engine.frequency_hz)
# Generate initial telemetry (may be spoofed)
ground_truth = self._engine.get_ground_truth_telemetry()
self._current_spoofed_telemetry = self._spoof.apply_spoofs(ground_truth, self._tick)
# Update OpenEnv state
self._state = State(episode_id=self._episode_id, step_count=0)
return self._build_observation()
def step(self, action: GridAction) -> GridObservation:
"""
Execute an action in the environment.
Returns GridObservation with reward, done, and info embedded.
"""
if self._done:
return self._build_observation(error="Episode already finished")
if self._engine is None:
return self._build_observation(error="Environment not initialized. Call reset() first.")
# Parse the action
action_type = action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type)
action_record = {
"action_type": action_type,
"tick": self._tick,
"node_id": action.node_id,
"edge_id": action.edge_id,
"mw": action.mw,
"status": action.status,
"subgraph": action.subgraph,
"hz_offset": action.hz_offset,
"duration": action.duration,
}
error_msg = None
fault_isolated = False
spoof_detected = False
pre_islands = self._engine.get_stable_islands() if self._task_id == 5 else []
# Execute the action
if action_type == "dispatch_generation":
if not action.node_id or action.mw is None:
error_msg = "dispatch_generation requires node_id and mw"
else:
result = self._engine.dispatch_generation(action.node_id, action.mw)
if not result["success"]:
error_msg = result.get("error", "dispatch failed")
action_record["result"] = result
elif action_type == "toggle_circuit_breaker":
if not action.edge_id or not action.status:
error_msg = "toggle_circuit_breaker requires edge_id and status"
else:
result = self._engine.toggle_circuit_breaker(action.edge_id, action.status)
if not result["success"]:
error_msg = result.get("error", "toggle failed")
else:
if action.status == "OPEN" and result.get("old_status") == "LIVE":
fault_isolated = True
elif action.status == "CLOSED":
self._track_black_start_merger(action.edge_id, pre_islands)
action_record["result"] = result
elif action_type == "run_state_estimation":
if not action.subgraph:
error_msg = "run_state_estimation requires subgraph (list of node IDs)"
else:
result = self._engine.run_state_estimation(
action.subgraph,
self._current_spoofed_telemetry,
)
self._state_estimation_run = True
self._last_estimation_result = result
action_record["result"] = result
if not result.get("consistent", True):
spoof_detected = True
elif action_type == "quarantine_scada_node":
if not action.node_id:
error_msg = "quarantine_scada_node requires node_id"
else:
# Check anti-hallucination gate
if not self._state_estimation_run:
error_msg = "Must run state_estimation before quarantine (anti-hallucination penalty applies)"
result = {
"success": False,
"error": error_msg,
"skipped": True,
}
else:
result = self._engine.quarantine_node(action.node_id)
if result.get("success"):
self._spoof.quarantine_node(action.node_id)
if self._last_estimation_result and not self._last_estimation_result.get("consistent", True):
if result.get("success") and not result.get("already_quarantined", False):
spoof_detected = True
action_record["result"] = result
elif action_type == "inject_counter_signal":
if not action.node_id or action.hz_offset is None or action.duration is None:
error_msg = "inject_counter_signal requires node_id, hz_offset, duration"
else:
result = self._engine.inject_counter_signal(
action.node_id, action.hz_offset, action.duration
)
if not result["success"]:
error_msg = result.get("error", "injection failed")
action_record["result"] = result
elif action_type == "advance_tick":
# Advance simulation (weather, load, frequency)
self._engine.advance_tick()
self._spoof.advance_tick()
self._logs_read_this_episode = True # Agent observes packet logs
if not self._state_estimation_run:
self._logs_read_before_estimation = True
# Apply resonance effect if active
if self._spoof.is_resonance_active():
resonance_effect = self._spoof.get_resonance_effect(self._tick)
self._engine.frequency_hz += resonance_effect
self._engine.frequency_hz = max(58.0, min(62.0, self._engine.frequency_hz))
else:
error_msg = f"Unknown action type: {action_type}"
if action_type != "advance_tick":
self._engine._record_telemetry()
# Record action
self._action_history.append(action_record)
# Advance tick counter
self._tick += 1
# Update spoofed telemetry
ground_truth = self._engine.get_ground_truth_telemetry()
self._current_spoofed_telemetry = self._spoof.apply_spoofs(ground_truth, self._tick)
# Generate packet logs
all_node_ids = list(self._engine.nodes.keys())
self._spoof.generate_packet_logs(all_node_ids, self._tick)
# Compute reward
reward_breakdown = self._reward_calc.compute_tick_reward(
action_type=action_type,
action_params={
"subgraph": action.subgraph or [],
"node_id": action.node_id,
"mw": action.mw,
},
frequency_hz=self._engine.frequency_hz,
overloaded_edges=self._engine.get_overloaded_edges(),
critical_nodes_shed=self._engine.get_critical_nodes_shed(),
is_proactive=self._engine.is_dispatch_proactive(),
spoof_detected=spoof_detected,
fault_isolated=fault_isolated,
has_read_logs_before_estimation=(
self._logs_read_this_episode
and action_type == "run_state_estimation"
),
)
tick_reward = reward_breakdown["total"]
self._total_reward += tick_reward
# Record frequency
self._frequency_history.append(self._engine.frequency_hz)
# Update Black Start tracking
self._update_black_start_tracking()
self._update_full_restoration_tracking()
# Check termination conditions
if self._engine.frequency_hz < 59.0:
self._done = True
if self._tick >= self._max_ticks:
self._done = True
# Task 4 special: turbine destruction
if self._task_id == 4:
destruction_tick = self._attack_config.get("destruction_tick", 10)
if self._tick >= destruction_tick and self._spoof.is_resonance_active():
self._done = True # Turbine destroyed
# Update OpenEnv state
self._state = State(episode_id=self._episode_id, step_count=self._tick)
return self._build_observation(
reward=tick_reward,
error=error_msg,
reward_breakdown=reward_breakdown,
)
@property
def state(self) -> State:
"""Get current OpenEnv state."""
return self._state
# ------------------------------------------------------------------
# Grading
# ------------------------------------------------------------------
def get_score(self) -> float:
"""Get the final grader score for the current episode."""
episode_state = self._build_episode_state()
return grade_task(self._task_id, self._action_history, episode_state)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _build_observation(
self,
reward: float = 0.0,
error: Optional[str] = None,
reward_breakdown: Optional[Dict] = None,
) -> GridObservation:
"""Build a GridObservation from current engine state."""
if self._engine is None:
return GridObservation(done=True, last_action_error="Not initialized")
# Build telemetry from spoofed readings for current tick
current_telemetry = []
for node_id, readings in self._current_spoofed_telemetry.items():
current_telemetry.append(readings)
# Telemetry history (includes spoofed values)
telemetry_stream = self._engine.get_telemetry_history()
# Add info dict fields
info = {
"task_id": self._task_id,
"tick": self._tick,
"grid_frequency_hz": self._engine.frequency_hz,
"active_spoofs": self._spoof.get_active_spoofs() if self._spoof else [],
"last_kirchhoff_result": self._last_estimation_result,
"episode_seed": self._seed,
"reward_breakdown": reward_breakdown or {},
"rubric_breakdown": self.get_rubric_breakdown(),
"curriculum_ready": True,
}
if self._done:
info["episode_summary"] = self.get_episode_summary()
obs = GridObservation(
topology_graph=self._engine.get_topology(),
telemetry_stream=telemetry_stream,
weather_forecast_matrix=self._engine.get_weather(),
network_packet_logs=self._spoof.get_recent_packet_logs() if self._spoof else [],
grid_frequency_hz=self._engine.frequency_hz,
tick=self._tick,
task_id=self._task_id,
done=self._done,
reward=reward,
last_action_error=error,
last_state_estimation=self._last_estimation_result,
weather_summary=self._engine.get_weather_summary(),
metadata=info,
)
return obs
def _build_episode_state(self) -> Dict[str, Any]:
"""Build episode state dict for grading."""
if self._engine is None:
return {}
# Compute load restoration fraction
total_possible = self._engine.get_total_possible_mwh()
total_served = self._engine.get_mwh_served()
load_fraction = total_served / total_possible if total_possible > 0 else 0.0
# Check if all critical nodes are restored
critical_nodes = [n for n in self._engine.nodes.values() if n["critical"]]
critical_restored = all(n["energized"] for n in critical_nodes)
recovered_above_59_5_in_3_ticks = _check_recovery(self._frequency_history, threshold=59.5, window=3)
return {
"frequency_history": self._frequency_history,
"is_proactive_dispatch": self._engine.is_dispatch_proactive(),
"critical_nodes_shed": self._engine.get_critical_nodes_shed(),
"full_restoration_tick": self._get_full_restoration_tick(),
"load_restored_fraction": load_fraction,
"hydro_stable_ticks": self._hydro_stable_ticks,
"energized_node_count": sum(
1 for n in self._engine.nodes.values() if n["energized"]
),
"max_island_count": self._max_island_count,
"successful_mergers": self._successful_mergers,
"premature_mergers": self._premature_mergers,
"transformer_failures": self._transformer_failures,
"critical_nodes_restored": critical_restored,
"logs_read_before_estimation": self._logs_read_before_estimation,
"spoof_target": self._attack_config.get("target_node"),
"recovered_above_59_5_in_3_ticks": recovered_above_59_5_in_3_ticks,
}
def _track_black_start_merger(self, edge_id: str, pre_islands: List[List[str]]) -> None:
"""Record Task 5 island merger outcomes when a breaker is closed."""
if self._task_id != 5 or self._engine is None:
return
edge = self._engine.edges.get(edge_id)
if edge is None:
return
def find_island(node_id: str) -> Optional[List[str]]:
for island in pre_islands:
if node_id in island:
return island
return None
source_island = find_island(edge["source"])
target_island = find_island(edge["target"])
if not source_island or not target_island or source_island == target_island:
return
if self._engine.check_phase_angle_compatible(source_island, target_island):
self._successful_mergers += 1
else:
self._premature_mergers += 1
self._transformer_failures += 1
def _update_black_start_tracking(self) -> None:
"""Track Black Start milestones for Task 5 grading."""
if self._task_id != 5 or self._engine is None:
return
# Track hydro stability
hydro = self._engine.nodes.get("NODE_01")
if hydro and hydro["energized"] and hydro["generation_mw"] > 0:
self._hydro_stable_ticks += 1
else:
self._hydro_stable_ticks = 0
# Track island count
islands = self._engine.get_stable_islands()
self._max_island_count = max(self._max_island_count, len(islands))
def _update_full_restoration_tracking(self) -> None:
"""Persist the first tick where load restoration crosses the grading threshold."""
if self._engine is None or self._full_restoration_tick is not None:
return
if self._task_id == 2:
isolation_ticks = [
action.get("tick", 999)
for action in self._action_history
if action.get("action_type") == "toggle_circuit_breaker"
and str(action.get("status", "")).upper() == "OPEN"
]
fault_isolated = bool(isolation_ticks)
first_isolation_tick = min(isolation_ticks) if isolation_ticks else None
restorative_dispatches = sum(
1
for action in self._action_history
if action.get("action_type") == "dispatch_generation"
and (action.get("mw") or 0) > 0
and first_isolation_tick is not None
and action.get("tick", 999) > first_isolation_tick
)
frequency_stable = (
len(self._frequency_history) >= 2
and all(freq >= 59.7 for freq in self._frequency_history[-2:])
)
if (
fault_isolated
and restorative_dispatches >= 2
and frequency_stable
and not self._engine.get_overloaded_edges()
and self._engine.get_critical_nodes_shed() == 0
):
self._full_restoration_tick = self._tick
return
total_possible = self._engine.get_total_possible_mwh()
total_served = self._engine.get_mwh_served()
if total_possible > 0 and total_served >= total_possible * 0.95:
self._full_restoration_tick = self._tick
def _get_full_restoration_tick(self) -> Optional[int]:
"""Get the tick at which full load was restored (for Task 2)."""
return self._full_restoration_tick
def get_rubric_breakdown(self) -> Dict[str, Any]:
"""Return the current rubric breakdown for the active episode."""
return evaluate_task_rubrics(self._task_id, self._action_history, self._build_episode_state())
def get_episode_summary(self) -> Dict[str, Any]:
"""Build a compact summary suitable for training logs and dashboards."""
rubric_eval = self.get_rubric_breakdown()
frequency_history = self._frequency_history or [60.0]
return {
"task_id": self._task_id,
"task_name": TASK_NAMES.get(self._task_id, f"task_{self._task_id}"),
"seed": self._seed,
"score": self.get_score(),
"rubrics": rubric_eval["rubrics"],
"weighted_rubric_score": rubric_eval["weighted_score"],
"actions_taken": [action.get("action_type", "unknown") for action in self._action_history],
"frequency_min": min(frequency_history),
"ticks_used": self._tick,
"done": self._done,
}
def emit_training_log(self, episode: int, logger: Optional[TrainingLogger] = None) -> str:
"""Write the current episode summary as one JSONL line."""
summary = self.get_episode_summary()
training_logger = logger or TrainingLogger()
record = training_logger.build_record(
episode=episode,
task_id=self._task_id,
seed=self._seed,
score=summary["score"],
rubrics=summary["rubrics"],
actions_taken=summary["actions_taken"],
frequency_min=summary["frequency_min"],
ticks_used=summary["ticks_used"],
extra={"task_name": summary["task_name"], "done": summary["done"]},
)
return training_logger.write_episode(record)
def _check_recovery(freq_history: List[float], threshold: float, window: int) -> bool:
"""Check if frequency recovered above threshold within `window` ticks after dipping."""
dip_started = False
ticks_since_dip = 0
for freq in freq_history:
if freq < threshold:
dip_started = True
ticks_since_dip = 0
elif dip_started:
ticks_since_dip += 1
if freq >= threshold and ticks_since_dip <= window:
return True
return False