Skip to content

Commit 37cff8a

Browse files
committed
feat(03-01): implement graph-aware proposer with topology cascade
- Add graph_data parameter to propose_scenario() (backward compatible) - _select_seed_nodes: LV feeder preference for COLD_SNAP/OUTAGE/EV_SPIKE - _propagate_through_neighbors: 2-hop BFS cascade with 0.7 decay - _build_adjacency: reuse CascadeLogicLayer pattern - _enrich_with_graph_topology: enriches ScenarioProposal.metadata - Cap affected nodes at 30% of graph - All 20 graph-aware tests pass, all 28 existing tests unchanged
1 parent dcb337a commit 37cff8a

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

src/fyp/selfplay/proposer.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77

88
import json
99
import logging
10+
import math
1011
import random
1112
from dataclasses import dataclass, field
1213
from datetime import datetime
1314

1415
import numpy as np
16+
import torch
17+
from torch_geometric.data import Data
1518

1619
from fyp.selfplay.utils import (
1720
apply_scenario_transformation,
@@ -182,6 +185,7 @@ def propose_scenario(
182185
conditioning_samples: list[tuple[ScenarioProposal, float]] | None = None,
183186
forecast_horizon: int = 48,
184187
current_timestamp: datetime | None = None,
188+
graph_data: Data | None = None,
185189
) -> ScenarioProposal:
186190
"""Generate a new scenario conditioned on past successful scenarios.
187191
@@ -230,6 +234,10 @@ def propose_scenario(
230234
if self.difficulty_curriculum:
231235
proposal = self._apply_curriculum_adjustment(proposal)
232236

237+
# Enrich with graph topology if available
238+
if graph_data is not None:
239+
self._enrich_with_graph_topology(proposal, graph_data)
240+
233241
logger.debug(
234242
f"Proposed {scenario_type} scenario: magnitude={params['magnitude']:.2f}, "
235243
f"duration={params['duration']}, difficulty={proposal.difficulty_score:.2f}"
@@ -497,6 +505,170 @@ def compute_learnability_reward(
497505

498506
return np.clip(learnability, 0.0, 1.0)
499507

508+
# ------------------------------------------------------------------
509+
# Graph-aware methods (Phase 3)
510+
# ------------------------------------------------------------------
511+
512+
def _enrich_with_graph_topology(
513+
self, proposal: ScenarioProposal, graph_data: Data
514+
) -> None:
515+
"""Enrich proposal with graph topology information.
516+
517+
Selects seed nodes based on scenario type and node hierarchy,
518+
propagates cascade through graph neighbors, and stores per-node
519+
magnitude information in proposal metadata.
520+
521+
Args:
522+
proposal: ScenarioProposal to enrich in-place.
523+
graph_data: PyG Data with edge_index, node_type, num_nodes.
524+
"""
525+
seed_nodes = self._select_seed_nodes(graph_data, proposal.scenario_type)
526+
affected_nodes = self._propagate_through_neighbors(
527+
seed_nodes, graph_data, num_hops=2, decay_factor=0.7
528+
)
529+
530+
# Cap affected nodes at 30% of graph
531+
max_affected = math.ceil(0.3 * graph_data.num_nodes)
532+
if len(affected_nodes) > max_affected:
533+
seed_set = set(seed_nodes.tolist())
534+
non_seeds = [k for k in affected_nodes if k not in seed_set]
535+
# Keep all seeds, randomly subsample non-seeds
536+
num_non_seeds_to_keep = max_affected - len(seed_set)
537+
if num_non_seeds_to_keep > 0:
538+
kept_non_seeds = random.sample(
539+
non_seeds, min(num_non_seeds_to_keep, len(non_seeds))
540+
)
541+
else:
542+
kept_non_seeds = []
543+
# Rebuild affected_nodes with only kept nodes
544+
capped = {}
545+
for k in seed_set:
546+
if k in affected_nodes:
547+
capped[k] = affected_nodes[k]
548+
for k in kept_non_seeds:
549+
capped[k] = affected_nodes[k]
550+
affected_nodes = capped
551+
552+
# Compute actual max cascade depth reached
553+
seed_set = set(seed_nodes.tolist())
554+
cascade_depth = 0
555+
for node, mag in affected_nodes.items():
556+
if node not in seed_set:
557+
if mag >= 0.7 - 1e-9:
558+
cascade_depth = max(cascade_depth, 1)
559+
else:
560+
cascade_depth = max(cascade_depth, 2)
561+
562+
proposal.metadata["graph_aware"] = True
563+
proposal.metadata["seed_nodes"] = seed_nodes.tolist()
564+
proposal.metadata["affected_nodes"] = affected_nodes
565+
proposal.metadata["num_hops"] = 2
566+
proposal.metadata["decay_factor"] = 0.7
567+
proposal.metadata["cascade_depth"] = cascade_depth
568+
569+
def _select_seed_nodes(
570+
self, graph_data: Data, scenario_type: str, num_seeds: int = 3
571+
) -> torch.Tensor:
572+
"""Select seed nodes for cascade based on scenario type and node hierarchy.
573+
574+
For COLD_SNAP, OUTAGE, and EV_SPIKE: prefer LV feeder nodes (type=2).
575+
For PEAK_SHIFT, MISSING_DATA: use all nodes as candidates.
576+
577+
Args:
578+
graph_data: PyG Data with node_type tensor.
579+
scenario_type: Scenario type string.
580+
num_seeds: Maximum number of seed nodes to select.
581+
582+
Returns:
583+
Tensor of selected node indices.
584+
"""
585+
node_type = graph_data.node_type
586+
587+
# Filter candidates based on scenario type
588+
lv_preferred = {"COLD_SNAP", "OUTAGE", "EV_SPIKE"}
589+
if scenario_type in lv_preferred:
590+
# Prefer LV feeders (type == 2)
591+
candidates = torch.where(node_type == 2)[0]
592+
if len(candidates) == 0:
593+
# Fallback to all nodes
594+
candidates = torch.arange(graph_data.num_nodes)
595+
else:
596+
candidates = torch.arange(graph_data.num_nodes)
597+
598+
# Randomly select seeds
599+
n_select = min(num_seeds, len(candidates))
600+
perm = torch.randperm(len(candidates))[:n_select]
601+
return candidates[perm]
602+
603+
@staticmethod
604+
def _build_adjacency(
605+
edge_index: torch.Tensor, num_nodes: int
606+
) -> dict[int, list[int]]:
607+
"""Build adjacency dict from COO edge_index.
608+
609+
Follows the exact pattern from CascadeLogicLayer._build_adjacency.
610+
611+
Args:
612+
edge_index: Shape [2, num_edges] COO format tensor.
613+
num_nodes: Total number of nodes in the graph.
614+
615+
Returns:
616+
Dict mapping each node index to a list of neighbor indices.
617+
"""
618+
adj: dict[int, list[int]] = {i: [] for i in range(num_nodes)}
619+
src = edge_index[0].tolist()
620+
dst = edge_index[1].tolist()
621+
for s, d in zip(src, dst):
622+
adj[s].append(d)
623+
return adj
624+
625+
def _propagate_through_neighbors(
626+
self,
627+
seed_nodes: torch.Tensor,
628+
graph_data: Data,
629+
num_hops: int = 2,
630+
decay_factor: float = 0.7,
631+
) -> dict[int, float]:
632+
"""Propagate cascade through graph neighbors with decay.
633+
634+
BFS from seed nodes through num_hops levels. Each hop applies
635+
decay_factor to the magnitude.
636+
637+
Args:
638+
seed_nodes: Tensor of seed node indices.
639+
graph_data: PyG Data with edge_index and num_nodes.
640+
num_hops: Maximum number of hops to propagate.
641+
decay_factor: Magnitude decay per hop (default 0.7).
642+
643+
Returns:
644+
Dict mapping node index to cascade magnitude (seed=1.0,
645+
hop1=decay, hop2=decay^2, etc.).
646+
"""
647+
adj = self._build_adjacency(graph_data.edge_index, graph_data.num_nodes)
648+
649+
# Initialize seeds at magnitude 1.0
650+
affected_nodes: dict[int, float] = {}
651+
current_frontier = set()
652+
for s in seed_nodes.tolist():
653+
affected_nodes[s] = 1.0
654+
current_frontier.add(s)
655+
656+
visited = set(current_frontier)
657+
current_decay = 1.0
658+
659+
for _hop in range(num_hops):
660+
current_decay *= decay_factor
661+
next_frontier: set[int] = set()
662+
for node in current_frontier:
663+
for neighbor in adj[node]:
664+
if neighbor not in visited:
665+
next_frontier.add(neighbor)
666+
visited.add(neighbor)
667+
affected_nodes[neighbor] = current_decay
668+
current_frontier = next_frontier
669+
670+
return affected_nodes
671+
500672
def get_scenario_statistics(self) -> dict:
501673
"""Get statistics about generated scenarios.
502674

0 commit comments

Comments
 (0)