|
7 | 7 |
|
8 | 8 | import json |
9 | 9 | import logging |
| 10 | +import math |
10 | 11 | import random |
11 | 12 | from dataclasses import dataclass, field |
12 | 13 | from datetime import datetime |
13 | 14 |
|
14 | 15 | import numpy as np |
| 16 | +import torch |
| 17 | +from torch_geometric.data import Data |
15 | 18 |
|
16 | 19 | from fyp.selfplay.utils import ( |
17 | 20 | apply_scenario_transformation, |
@@ -182,6 +185,7 @@ def propose_scenario( |
182 | 185 | conditioning_samples: list[tuple[ScenarioProposal, float]] | None = None, |
183 | 186 | forecast_horizon: int = 48, |
184 | 187 | current_timestamp: datetime | None = None, |
| 188 | + graph_data: Data | None = None, |
185 | 189 | ) -> ScenarioProposal: |
186 | 190 | """Generate a new scenario conditioned on past successful scenarios. |
187 | 191 |
|
@@ -230,6 +234,10 @@ def propose_scenario( |
230 | 234 | if self.difficulty_curriculum: |
231 | 235 | proposal = self._apply_curriculum_adjustment(proposal) |
232 | 236 |
|
| 237 | + # Enrich with graph topology if available |
| 238 | + if graph_data is not None: |
| 239 | + self._enrich_with_graph_topology(proposal, graph_data) |
| 240 | + |
233 | 241 | logger.debug( |
234 | 242 | f"Proposed {scenario_type} scenario: magnitude={params['magnitude']:.2f}, " |
235 | 243 | f"duration={params['duration']}, difficulty={proposal.difficulty_score:.2f}" |
@@ -497,6 +505,170 @@ def compute_learnability_reward( |
497 | 505 |
|
498 | 506 | return np.clip(learnability, 0.0, 1.0) |
499 | 507 |
|
| 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 | + |
500 | 672 | def get_scenario_statistics(self) -> dict: |
501 | 673 | """Get statistics about generated scenarios. |
502 | 674 |
|
|
0 commit comments