From 7fe914f0264f2de7586009fdb1ba6083d5968dc5 Mon Sep 17 00:00:00 2001 From: mkisuule Date: Sat, 11 Apr 2026 01:46:36 +0100 Subject: [PATCH 1/2] Add files via upload A topology-aware voltage loss detector taskf for the GridFM Signed-off-by: mkisuule --- gridfm_graphkit PR-VLDloss.zip | Bin 0 -> 71167 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 gridfm_graphkit PR-VLDloss.zip diff --git a/gridfm_graphkit PR-VLDloss.zip b/gridfm_graphkit PR-VLDloss.zip new file mode 100644 index 0000000000000000000000000000000000000000..244841a8139866137805226f8b2e0a83d984fc98 GIT binary patch literal 71167 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 L+l$KEyMO&Zao^Z1 literal 0 HcmV?d00001 From 9514fe2ce3c460c1e70ae70b537bbc504e915d4b Mon Sep 17 00:00:00 2001 From: Mikka Kisuule Date: Thu, 30 Apr 2026 17:46:03 +0100 Subject: [PATCH 2/2] Topology-aware task to predict interrupted buses after a grid failure Signed-off-by: Mikka Kisuule --- gridfm_graphkit/datasets/__init__.py | 7 + gridfm_graphkit/datasets/globals.py | 13 + .../datasets/hetero_powergrid_datamodule.py | 142 ++++------- gridfm_graphkit/datasets/masking.py | 73 +++++- gridfm_graphkit/datasets/normalizers.py | 12 +- .../datasets/powergrid_hetero_dataset.py | 65 ++++- gridfm_graphkit/datasets/task_transforms.py | 26 +- gridfm_graphkit/datasets/transforms.py | 46 +++- gridfm_graphkit/datasets/utils.py | 29 --- gridfm_graphkit/io/registries.py | 1 - gridfm_graphkit/models/__init__.py | 6 + .../models/gnn_heterogeneous_gns.py | 35 ++- gridfm_graphkit/models/utils.py | 36 ++- gridfm_graphkit/tasks/__init__.py | 7 +- gridfm_graphkit/tasks/base_task.py | 14 -- .../tasks/compute_ac_dc_metrics.py | 227 ++++++++++++++++++ gridfm_graphkit/tasks/opf_task.py | 163 ++----------- gridfm_graphkit/tasks/pf_task.py | 64 +++-- gridfm_graphkit/tasks/reconstruction_tasks.py | 1 - gridfm_graphkit/tasks/se_task.py | 1 - gridfm_graphkit/tasks/utils.py | 41 +--- gridfm_graphkit/tasks/vld_task.py | 158 ++++++++++++ gridfm_graphkit/training/__init__.py | 2 + gridfm_graphkit/training/callbacks.py | 47 +--- gridfm_graphkit/training/loss.py | 214 ++++++++++++----- gridfm_graphkit/utils/visualization.py | 1 - 26 files changed, 938 insertions(+), 493 deletions(-) create mode 100644 gridfm_graphkit/tasks/compute_ac_dc_metrics.py create mode 100644 gridfm_graphkit/tasks/vld_task.py diff --git a/gridfm_graphkit/datasets/__init__.py b/gridfm_graphkit/datasets/__init__.py index fee635f5..739b00a2 100644 --- a/gridfm_graphkit/datasets/__init__.py +++ b/gridfm_graphkit/datasets/__init__.py @@ -5,6 +5,10 @@ PowerFlowTransforms, OptimalPowerFlowTransforms, StateEstimationTransforms, + ############## + VoltageLossDetectionTransforms + ############# + ) __all__ = [ @@ -12,4 +16,7 @@ "PowerFlowTransforms", "OptimalPowerFlowTransforms", "StateEstimationTransforms", + ################### + "VoltageLossDetectionTransforms" + ################## ] diff --git a/gridfm_graphkit/datasets/globals.py b/gridfm_graphkit/datasets/globals.py index ab3c7e3d..8bf5690a 100644 --- a/gridfm_graphkit/datasets/globals.py +++ b/gridfm_graphkit/datasets/globals.py @@ -17,6 +17,10 @@ BS = 13 # Shunt susceptance (p.u.) VN_KV = 14 # Nominal voltage +##ADDITIONAL INPUT FEATURES OF BUS STATUS +BUS_BASE_STATUS_H = 15 # bus ON/OFF status in the pre-contingency(base topology) +BUS_CONT_H = 16 # bus contingency to be applied + # ========================= # === OUTPUT FEATURE INDICES == # ========================= @@ -26,6 +30,10 @@ QG_OUT = 3 PG_OUT_GEN = 0 +##ADDITIONAL OUTPUT FEATURE OF BUS STATUS +PHYSICAL_BUS_DIM = 4 # Physical bus outputs predicted by the model for VLD tasks +BUS_STATUS_TARGET = 5 # post-contingency energized/de-energized target +BUS_STATUS_LOGIT_OUT = 4 # # Extra model output columns for VLD tasks # ================================ # === GENERATOR FEATURE INDICES == @@ -52,3 +60,8 @@ ANG_MAX = 8 # Angle max (deg) RATE_A = 9 # Thermal limit B_ON = 10 # Branch on/off + +##ADDITIONAL INPUT FEATURES OF BUS STATUS +BRANCH_BASE_STATUS_E = 11 # branch ON/OFF status in the pre-contingency(base topology) +BRANCH_CONT_E = 12 # branch contingency to be applied + diff --git a/gridfm_graphkit/datasets/hetero_powergrid_datamodule.py b/gridfm_graphkit/datasets/hetero_powergrid_datamodule.py index e5374970..54db39c7 100644 --- a/gridfm_graphkit/datasets/hetero_powergrid_datamodule.py +++ b/gridfm_graphkit/datasets/hetero_powergrid_datamodule.py @@ -14,14 +14,12 @@ from gridfm_graphkit.datasets.utils import ( split_dataset, split_dataset_by_load_scenario_idx, - split_from_existing_files, ) from gridfm_graphkit.datasets.powergrid_hetero_dataset import HeteroGridDatasetDisk import numpy as np import random import warnings import lightning as L -from pathlib import Path from typing import List from lightning.pytorch.loggers import MLFlowLogger @@ -103,11 +101,6 @@ def __init__( "split_by_load_scenario_idx", False, ) - self.split_from_existing_files = getattr( - args.data, - "split_from_existing_files", - None, - ) self.args = args self.normalizer_stats_path = normalizer_stats_path self.data_normalizers = [] @@ -120,15 +113,6 @@ def __init__( self.test_scenario_ids: List[List[int]] = [] self._is_setup_done = False - if self.split_by_load_scenario_idx: - assert self.split_from_existing_files is None, " either `split_by_load_scenario_idx` or `split_from_existing_files` may be used, not both" - - if self.split_from_existing_files is not None: - assert isinstance(self.split_from_existing_files, str), "`split_from_existing_files` must be an existing folder in string format" - self.split_from_existing_files = Path(self.split_from_existing_files) - assert self.split_from_existing_files.is_dir(), "`split_from_existing_files` must be an existing folder in string format" - - def setup(self, stage: str): if self._is_setup_done: print(f"Setup already done for stage={stage}, skipping...") @@ -183,94 +167,54 @@ def setup(self, stage: str): # Create a subset all_indices = list(range(len(dataset))) + # Random seed set before every shuffle for reproducibility in case the power grid datasets are analyzed in a different order + random.seed(self.args.seed) + random.shuffle(all_indices) + subset_indices = all_indices[:num_scenarios] + # load_scenario for each scenario in the subset + load_scenarios = dataset.load_scenarios[subset_indices] - if self.split_from_existing_files is not None: - warnings.warn( - "`data.scenarios` is ignored when `split_from_existing_files` is set; " - "train/val/test scenario ids are loaded from the provided split files.", - ) + dataset = Subset(dataset, subset_indices) - if self.dataset_wrapper is not None: - wrapper_cls = DATASET_WRAPPER_REGISTRY.get(self.dataset_wrapper) - dataset = wrapper_cls( - dataset, - cache_dir=self.dataset_wrapper_cache_dir, - ) + if self.dataset_wrapper is not None: + wrapper_cls = DATASET_WRAPPER_REGISTRY.get(self.dataset_wrapper) + dataset = wrapper_cls(dataset, cache_dir=self.dataset_wrapper_cache_dir) - (train_dataset, val_dataset, test_dataset), subset_indices = ( - split_from_existing_files( - dataset, - self.split_from_existing_files, - ) - ) - train_scenario_ids = subset_indices["train"] - val_scenario_ids = subset_indices["val"] - test_scenario_ids = subset_indices["test"] - num_scenarios = int( - np.unique( - train_scenario_ids + val_scenario_ids + test_scenario_ids, - ).shape[0], - ) - else: - # Random seed set before every shuffle for reproducibility in case the power grid datasets are analyzed in a different order - random.seed(self.args.seed) - random.shuffle(all_indices) - subset_indices = all_indices[:num_scenarios] - - load_scenarios = None - if self.split_by_load_scenario_idx: - if not hasattr(dataset, "load_scenarios"): - raise ValueError( - "`data.split_by_load_scenario_idx=true` requires " - "`load_scenario_idx` in raw bus data so " - "`processed/load_scenarios.pt` can be created.", - ) - # load_scenario for each scenario in the subset - load_scenarios = dataset.load_scenarios[subset_indices] - - - dataset = Subset(dataset, subset_indices) - - if self.dataset_wrapper is not None: - wrapper_cls = DATASET_WRAPPER_REGISTRY.get(self.dataset_wrapper) - dataset = wrapper_cls(dataset, cache_dir=self.dataset_wrapper_cache_dir) - - - # Random seed set before every split, same as above - np.random.seed(self.args.seed) - if self.split_by_load_scenario_idx: - train_dataset, val_dataset, test_dataset = ( - split_dataset_by_load_scenario_idx( - dataset, - self.data_dir, - load_scenarios, - self.args.data.val_ratio, - self.args.data.test_ratio, - ) - ) - else: - train_dataset, val_dataset, test_dataset = split_dataset( + # Random seed set before every split, same as above + np.random.seed(self.args.seed) + if self.split_by_load_scenario_idx: + train_dataset, val_dataset, test_dataset = ( + split_dataset_by_load_scenario_idx( dataset, self.data_dir, + load_scenarios, self.args.data.val_ratio, self.args.data.test_ratio, ) - - # Extract scenario IDs for each split - train_scenario_ids = self._extract_scenario_ids( - train_dataset, - subset_indices, - ) - val_scenario_ids = self._extract_scenario_ids( - val_dataset, - subset_indices, ) - test_scenario_ids = self._extract_scenario_ids( - test_dataset, - subset_indices, + else: + train_dataset, val_dataset, test_dataset = split_dataset( + dataset, + self.data_dir, + self.args.data.val_ratio, + self.args.data.test_ratio, ) + # Extract scenario IDs for each split + train_scenario_ids = self._extract_scenario_ids( + train_dataset, + subset_indices, + ) + val_scenario_ids = self._extract_scenario_ids( + val_dataset, + subset_indices, + ) + test_scenario_ids = self._extract_scenario_ids( + test_dataset, + subset_indices, + ) + # Fit normalizer: restore from saved stats only for fit_on_train # normalizers (global baseMVA must match the model's training run). # fit_on_dataset normalizers compute per-scenario stats and must @@ -425,12 +369,11 @@ def _dataloader_kwargs(self): pin_memory=torch.cuda.is_available(), persistent_workers=num_workers > 0, ) - # Use 'fork' on Linux. It avoids the forkserver intermediary pipe which - # is fragile when the process has many threads (e.g. OpenBLAS). In - # container environments (Kubernetes) fork works correctly. On - # traditional HPC systems with strict fd-passing restrictions the - # original 'forkserver' may be needed, but the pipe truncation it - # produces under thread pressure is worse than the ancdata warning. + # On Linux some HPC environments restrict passing open file descriptors + # via Unix socket ancillary data (SCM_RIGHTS), which causes + # "received 0 items of ancdata" with the default 'fork' start method. + # 'forkserver' avoids fd-passing by having a dedicated server process + # that re-opens shared memory objects by name instead. if ( num_workers > 0 and torch.multiprocessing.get_start_method(allow_none=True) != "spawn" @@ -438,11 +381,10 @@ def _dataloader_kwargs(self): import platform if platform.system() == "Linux": - kwargs["multiprocessing_context"] = "fork" + kwargs["multiprocessing_context"] = "forkserver" return kwargs def train_dataloader(self): - print("creating train dataloader for rank ", dist.get_rank() if dist.is_available() and dist.is_initialized() else "not distributed") return DataLoader( self.train_dataset_multi, batch_size=self.batch_size, diff --git a/gridfm_graphkit/datasets/masking.py b/gridfm_graphkit/datasets/masking.py index df2f657c..c783e26f 100644 --- a/gridfm_graphkit/datasets/masking.py +++ b/gridfm_graphkit/datasets/masking.py @@ -157,7 +157,6 @@ def forward(self, data): class BusToGenBroadcaster(MessagePassing): - """Broadcast per-bus values to connected generators via graph propagation.""" def __init__(self, aggr="add"): super().__init__(aggr=aggr) @@ -175,7 +174,6 @@ def message(self, x_j): class SimulateMeasurements(BaseTransform): - """Add configurable noise/outliers and masks to simulate measured quantities.""" def __init__(self, args): super().__init__() self.measurements = args.task.measurements @@ -323,3 +321,74 @@ def forward(self, data): } return data + +####################### +class AddVLDHeteroMask(BaseTransform): + """ + PF-like masking for VLD: + - keeps PF bus-type masks required by the physics decoder + - masks only physical reconstruction channels + - leaves appended topology/status metadata unmasked + """ + + def __init__(self): + super().__init__() + + def forward(self, data): + bus_x = data.x_dict["bus"] + gen_x = data.x_dict["gen"] + + mask_PQ = bus_x[:, PQ_H] == 1 + mask_PV = bus_x[:, PV_H] == 1 + mask_REF = bus_x[:, REF_H] == 1 + + mask_bus = torch.zeros_like(bus_x, dtype=torch.bool) + mask_gen = torch.zeros_like(gen_x, dtype=torch.bool) + + # Keep same physical masking pattern as PF + mask_bus[:, MIN_VM_H] = True + mask_bus[:, MAX_VM_H] = True + mask_bus[:, MIN_QG_H] = True + mask_bus[:, MAX_QG_H] = True + mask_bus[:, VN_KV] = True + + mask_gen[:, MIN_PG] = True + mask_gen[:, MAX_PG] = True + mask_gen[:, C0_H] = True + mask_gen[:, C1_H] = True + mask_gen[:, C2_H] = True + + mask_bus[mask_PQ, VM_H] = True + mask_bus[mask_PQ, VA_H] = True + + mask_bus[mask_PV, VA_H] = True + mask_bus[mask_PV, QG_H] = True + + mask_bus[mask_REF, VM_H] = True + mask_bus[mask_REF, QG_H] = True + + gen_bus_edges = data.edge_index_dict[("gen", "connected_to", "bus")] + gen_indices, bus_indices = gen_bus_edges + ref_gens = gen_indices[mask_REF[bus_indices]] + mask_gen[ref_gens, PG_H] = True + + mask_branch = torch.zeros_like( + data.edge_attr_dict[("bus", "connects", "bus")], + dtype=torch.bool, + ) + mask_branch[:, P_E] = True + mask_branch[:, Q_E] = True + mask_branch[:, ANG_MIN] = True + mask_branch[:, ANG_MAX] = True + mask_branch[:, RATE_A] = True + + data.mask_dict = { + "bus": mask_bus, + "gen": mask_gen, + "branch": mask_branch, + "PQ": mask_PQ, + "PV": mask_PV, + "REF": mask_REF, + } + return data +####################### \ No newline at end of file diff --git a/gridfm_graphkit/datasets/normalizers.py b/gridfm_graphkit/datasets/normalizers.py index eb5652d7..11601a66 100644 --- a/gridfm_graphkit/datasets/normalizers.py +++ b/gridfm_graphkit/datasets/normalizers.py @@ -228,8 +228,8 @@ def transform(self, data: HeteroData): data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MIN] *= torch.pi / 180.0 data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MAX] *= torch.pi / 180.0 data.edge_attr_dict[("bus", "connects", "bus")][:, RATE_A] /= self.baseMVA - data.baseMVA = torch.tensor(self.baseMVA, dtype=data.x_dict["bus"].dtype) # # needs to be float32 for MPS - data.is_normalized = torch.tensor(True, dtype=torch.bool) # needs to be bool for MPS + data.baseMVA = self.baseMVA + data.is_normalized = True def inverse_transform(self, data: HeteroData): if self.baseMVA is None or self.baseMVA == 0: @@ -299,7 +299,7 @@ def inverse_transform(self, data: HeteroData): data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MAX] *= 180.0 / torch.pi data.edge_attr_dict[("bus", "connects", "bus")][:, RATE_A] *= self.baseMVA - data.is_normalized = torch.tensor(False, dtype=torch.bool) # needs to be bool for MPS + data.is_normalized = False def inverse_output(self, output, batch): bus_output = output["bus"] @@ -510,10 +510,10 @@ def transform(self, data: HeteroData): data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MIN] *= torch.pi / 180.0 data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MAX] *= torch.pi / 180.0 data.edge_attr_dict[("bus", "connects", "bus")][:, RATE_A] /= e_b - data.is_normalized = torch.tensor(True, dtype=torch.bool) # needs to be bool for MPS + data.is_normalized = True def inverse_transform(self, data: HeteroData): - """Undo per-unit normalization (multiply by baseMVA, inverse log1p for cost coeffs).""" + """Undo per-unit normalization (multiply by baseMVA, rad->deg, inverse log1p for cost coeffs).""" if self._baseMVA_lookup is None: raise ValueError("Normalizer not fitted or lookups not loaded") if not data.is_normalized.all(): @@ -573,7 +573,7 @@ def inverse_transform(self, data: HeteroData): data.edge_attr_dict[("bus", "connects", "bus")][:, ANG_MAX] *= 180.0 / torch.pi data.edge_attr_dict[("bus", "connects", "bus")][:, RATE_A] *= e_b - data.is_normalized = torch.tensor(False, dtype=torch.bool) # needs to be bool for MPS + data.is_normalized = False def inverse_output(self, output, batch): """ diff --git a/gridfm_graphkit/datasets/powergrid_hetero_dataset.py b/gridfm_graphkit/datasets/powergrid_hetero_dataset.py index 82f57a57..b1be1b00 100644 --- a/gridfm_graphkit/datasets/powergrid_hetero_dataset.py +++ b/gridfm_graphkit/datasets/powergrid_hetero_dataset.py @@ -10,6 +10,19 @@ from torch_geometric.data import HeteroData from gridfm_graphkit.datasets.globals import VA_H, PG_H +##################### +def _optional_cols(df, cols, default_value): + """ + Return a list of columns guaranteed to exist in df. + Missing columns are created and filled with the corresponding default value. + """ + if len(default_value) < len(cols): + raise ValueError(f"default_value has length {len(default_value)} but cols has length {len(cols)}") + for i, col in enumerate(cols): + if col not in df.columns: + df[col] = default_value[i] + return cols +##################### class HeteroGridDatasetDisk(Dataset): """ @@ -55,6 +68,7 @@ def processed_done_file(self): @property def processed_file_names(self): return [ + "load_scenarios.pt", self.processed_done_file, ] @@ -71,11 +85,11 @@ def process(self): bus_data["scenario"].min() == 0 and bus_data["scenario"].max() == len(bus_data["scenario"].unique()) - 1 ) - if "load_scenario_idx" in bus_data.columns: - load_scenarios = torch.tensor( - bus_data.groupby("scenario", sort=True)["load_scenario_idx"].first().values, - ) - torch.save(load_scenarios, osp.join(self.processed_dir, "load_scenarios.pt")) + + load_scenarios = torch.tensor( + bus_data.groupby("scenario", sort=True)["load_scenario_idx"].first().values, + ) + torch.save(load_scenarios, osp.join(self.processed_dir, "load_scenarios.pt")) agg_gen = ( gen_data.groupby(["scenario", "bus"])[["min_q_mvar", "max_q_mvar"]] @@ -107,6 +121,12 @@ def process(self): "vn_kv", ] + ##################### + # Optional VLD bus input columns + vld_bus_input_features = _optional_cols(bus_data,["bus_base_status", "bus_contingency"],default_value=[1.0, 0.0]) + bus_features = bus_features + vld_bus_input_features + ##################### + gen_features = [ "p_mw", "min_p_mw", @@ -118,6 +138,13 @@ def process(self): ] common_branch_features = ["tap", "ang_min", "ang_max", "rate_a", "br_status"] + + ##################### + # Optional VLD branch topology columns + vld_branch_features = _optional_cols(branch_data,["branch_base_status", "branch_contingency"], default_value=[1.0, 0.0]) + common_branch_features = common_branch_features + vld_branch_features + ##################### + forward_branch_features = [ "pf", "qf", @@ -135,9 +162,13 @@ def process(self): "Ytf_i", ] + common_branch_features + ##################### + # Optional VLD target column on bus.y + vld_bus_target_features = _optional_cols(bus_data,["bus_status_target"],default_value=[1.0]) + ##################### + # Group by scenario - bus_groups = bus_data.groupby("scenario") # Groupby preserves the order of rows within each group. - # https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.groupby.html + bus_groups = bus_data.groupby("scenario") gen_groups = gen_data.groupby("scenario") branch_groups = branch_data.groupby("scenario") @@ -158,19 +189,27 @@ def process(self): # Bus nodes bus_df = bus_groups.get_group(scenario) - # assert that the buses are in increasing order - assert (bus_df["bus"].values == torch.arange(len(bus_df))).all(), "Buses are not in increasing order" - #todo: we should remove this assert and store the bus idx in the tensors - # right now we need the increasing order for e.g. the predict step that uses torch.arange(n_nodes) to index the buses. data["bus"].x = torch.tensor(bus_df[bus_features].values, dtype=torch.float) # Generator nodes gen_df = gen_groups.get_group(scenario).reset_index() data["gen"].x = torch.tensor(gen_df[gen_features].values, dtype=torch.float) gen_df["gen_index"] = gen_df.index # Use actual index as generator ID - # todo: change this to instead use the generator id as the index - data["bus"].y = data["bus"].x[:, : (VA_H + 1)].clone() + ##################### + #data["bus"].y = data["bus"].x[:, : (VA_H + 1)].clone() + # Append the optional VLD target column. + bus_y_base = torch.tensor( + bus_df[["Pd", "Qd", "Qg", "Vm", "Va"]].values, + dtype=torch.float, + ) + bus_y_vld = torch.tensor( + bus_df[vld_bus_target_features].values, + dtype=torch.float, + ) + data["bus"].y = torch.cat([bus_y_base, bus_y_vld], dim=1) + ##################### + data["gen"].y = data["gen"].x[:, : (PG_H + 1)].clone() # Bus-Bus edges diff --git a/gridfm_graphkit/datasets/task_transforms.py b/gridfm_graphkit/datasets/task_transforms.py index dffb66cb..479b1d0a 100644 --- a/gridfm_graphkit/datasets/task_transforms.py +++ b/gridfm_graphkit/datasets/task_transforms.py @@ -4,18 +4,23 @@ RemoveInactiveGenerators, ApplyMasking, LoadGridParamsFromPath, + ################### + RemoveInactiveBranchesKeepTopology, + ################### ) from gridfm_graphkit.datasets.masking import ( AddOPFHeteroMask, AddPFHeteroMask, SimulateMeasurements, + ################### + AddVLDHeteroMask, + ################### ) from gridfm_graphkit.io.registries import TRANSFORM_REGISTRY @TRANSFORM_REGISTRY.register("PowerFlow") class PowerFlowTransforms(Compose): - """Compose preprocessing and masking transforms for PowerFlow datasets.""" def __init__(self, args): transforms = [] @@ -30,7 +35,6 @@ def __init__(self, args): @TRANSFORM_REGISTRY.register("OptimalPowerFlow") class OptimalPowerFlowTransforms(Compose): - """Compose preprocessing and masking transforms for OptimalPowerFlow datasets.""" def __init__(self, args): transforms = [] @@ -45,7 +49,6 @@ def __init__(self, args): @TRANSFORM_REGISTRY.register("StateEstimation") class StateEstimationTransforms(Compose): - """Compose preprocessing and measurement transforms for StateEstimation datasets.""" def __init__(self, args): transforms = [] @@ -58,3 +61,20 @@ def __init__(self, args): # Pass the list of transforms to Compose super().__init__(transforms) + +############################ +@TRANSFORM_REGISTRY.register("VoltageLossDetection") +class VoltageLossDetectionTransforms(Compose): + def __init__(self, args): + transforms = [] + + if hasattr(args.task, "grid_path"): + transforms.append(LoadGridParamsFromPathVLD(args)) + + transforms.append(RemoveInactiveBranchesKeepTopology()) + transforms.append(RemoveInactiveGenerators()) + transforms.append(AddVLDHeteroMask()) + transforms.append(ApplyMasking(args=args)) + + super().__init__(transforms) +########################## \ No newline at end of file diff --git a/gridfm_graphkit/datasets/transforms.py b/gridfm_graphkit/datasets/transforms.py index c6891dc2..57cebfc9 100644 --- a/gridfm_graphkit/datasets/transforms.py +++ b/gridfm_graphkit/datasets/transforms.py @@ -96,7 +96,6 @@ def forward(self, data): class LoadGridParamsFromPath(BaseTransform): - """Inject static grid parameters from a saved grid template into each sample.""" def __init__(self, args): super().__init__() self.grid_path = args.task.grid_path @@ -124,3 +123,48 @@ def forward(self, data): ].edge_attr[:, cols] data["gen"].x[:, G_ON] = grid_data["gen"].x[:, G_ON] return data + +################## +class RemoveInactiveBranchesKeepTopology(BaseTransform): + """ + Removes inactive branches using B_ON, but preserves all edge_attr columns, + including appended VLD topology columns after B_ON. + """ + + def forward(self, data): + et = ("bus", "connects", "bus") + active_mask = data[et].edge_attr[:, B_ON] == 1 + + data[et].edge_index = data[et].edge_index[:, active_mask] + data[et].edge_attr = data[et].edge_attr[active_mask] + data[et].y = data[et].y[active_mask] + return data + + +class LoadGridParamsFromPathVLD(BaseTransform): + def __init__(self, args): + super().__init__() + self.grid_path = args.task.grid_path + self.grid_data = HeteroData.from_dict( + torch.load(self.grid_path, weights_only=True) + ) + self.normalizer = HeteroDataMVANormalizer(args) + self.normalizer.vn_kv_max = 1 + + def forward(self, data): + if hasattr(data, "is_normalized"): + self.normalizer.baseMVA = data.baseMVA + grid_data = deepcopy(self.grid_data) + self.normalizer.transform(grid_data) + else: + grid_data = deepcopy(self.grid_data) + + cols = [YFF_TT_R, YFF_TT_I, YFT_TF_R, YFT_TF_I] + data[("bus", "connects", "bus")].edge_attr[:, cols] = grid_data[ + ("bus", "connects", "bus") + ].edge_attr[:, cols] + + data["gen"].x[:, G_ON] = grid_data["gen"].x[:, G_ON] + return data +################## + diff --git a/gridfm_graphkit/datasets/utils.py b/gridfm_graphkit/datasets/utils.py index 65b34f4e..f330d496 100644 --- a/gridfm_graphkit/datasets/utils.py +++ b/gridfm_graphkit/datasets/utils.py @@ -3,7 +3,6 @@ from typing import Tuple from torch import Tensor import torch -from pathlib import Path def split_dataset( @@ -59,7 +58,6 @@ def split_dataset_by_load_scenario_idx( val_ratio: float = 0.1, test_ratio: float = 0.1, ) -> Tuple[Subset, Subset, Subset]: - """Split dataset by unique load-scenario IDs to avoid scenario leakage.""" if val_ratio + test_ratio >= 1: raise ValueError("The sum of val_ratio and test_ratio must be less than 1.") @@ -92,30 +90,3 @@ def split_dataset_by_load_scenario_idx( test_dataset = Subset(dataset, test_indices) return train_dataset, val_dataset, test_dataset - - -def split_from_existing_files( - dataset, - splits_folder: Path, -) -> Tuple[Subset, Subset, Subset]: - """Build train/val/test subsets from split index files. - - Expects `train.pt`, `val.pt`, and `test.pt` inside `splits_folder`. - Returns both the dataset subsets and the raw scenario ids per split. - """ - output = [] - - indices = {} - - for split in ["train", "val", "test"]: - split_file = splits_folder / f"{split}.pt" - assert split_file.is_file(), f"{str(split_file)} does not exist" - split_indices = torch.load(str(split_file), weights_only=True) - split_dataset = Subset(dataset, split_indices) - output.append(split_dataset) - split_indices = list(split_indices) - print(f'{split=} {len(split_indices)=}') - indices[split]=[int(t.item()) for t in split_indices] - - output = tuple(output) - return output, indices \ No newline at end of file diff --git a/gridfm_graphkit/io/registries.py b/gridfm_graphkit/io/registries.py index 65d596a9..32feb20a 100644 --- a/gridfm_graphkit/io/registries.py +++ b/gridfm_graphkit/io/registries.py @@ -1,5 +1,4 @@ class Registry: - """Simple name-to-object registry with decorator-based registration.""" def __init__(self, name: str): self._name = name self._registry = {} diff --git a/gridfm_graphkit/models/__init__.py b/gridfm_graphkit/models/__init__.py index f8245352..956f9213 100644 --- a/gridfm_graphkit/models/__init__.py +++ b/gridfm_graphkit/models/__init__.py @@ -3,6 +3,9 @@ PhysicsDecoderOPF, PhysicsDecoderPF, PhysicsDecoderSE, + ############### + PhysicsDecoderVLD + ############## ) __all__ = [ @@ -10,4 +13,7 @@ "PhysicsDecoderOPF", "PhysicsDecoderPF", "PhysicsDecoderSE", + ############### + "PhysicsDecoderVLD", + ############## ] diff --git a/gridfm_graphkit/models/gnn_heterogeneous_gns.py b/gridfm_graphkit/models/gnn_heterogeneous_gns.py index 10735603..93366db7 100644 --- a/gridfm_graphkit/models/gnn_heterogeneous_gns.py +++ b/gridfm_graphkit/models/gnn_heterogeneous_gns.py @@ -19,6 +19,9 @@ # Output feature indices VM_OUT, PG_OUT_GEN, + ############## + BUS_STATUS_LOGIT_OUT, + ############## # Generator feature indices PG_H, MIN_PG, @@ -49,6 +52,9 @@ def __init__(self, args) -> None: self.heads = args.model.attention_head self.task = args.task.task_name self.dropout = getattr(args.model, "dropout", 0.0) + #################### + self.is_vld_task = self.task == "VoltageLossDetection" + #################### # projections for each node type self.input_proj_bus = nn.Sequential( @@ -129,6 +135,15 @@ def __init__(self, args) -> None: nn.Linear(self.hidden_dim, self.output_bus_dim), ) + ############################# + self.bus_status_head = nn.Sequential( + nn.Linear(self.hidden_dim * self.heads, self.hidden_dim), + nn.LayerNorm(self.hidden_dim), + nn.LeakyReLU(), + nn.Linear(self.hidden_dim, 1), + ) + ############################ + self.mlp_gen = nn.Sequential( nn.Linear(self.hidden_dim * self.heads, self.hidden_dim), nn.LayerNorm(self.hidden_dim), @@ -156,6 +171,9 @@ def forward(self, x_dict, edge_index_dict, edge_attr_dict, mask_dict): """ self.layer_residuals = {} + ##################### + self.latest_x_dict = x_dict + #################### # 1) initial projections h_bus = self.input_proj_bus(x_dict["bus"]) # [num_bus, hidden_dim] @@ -199,6 +217,10 @@ def forward(self, x_dict, edge_index_dict, edge_attr_dict, mask_dict): bus_temp = self.mlp_bus(h_bus) # [Nb, 2] -> Vm, Va gen_temp = self.mlp_gen(h_gen) # [Ng, 1] -> Pg + ####################### + status_logit = self.bus_status_head(h_bus) + ####################### + if self.task == "StateEstimation": if i == self.num_layers - 1: Pft, Qft = self.branch_flow_layer( @@ -278,4 +300,15 @@ def forward(self, x_dict, edge_index_dict, edge_attr_dict, mask_dict): ).mean() h_bus = h_bus + self.physics_mlp(bus_residuals) - return {"bus": output_temp, "gen": gen_temp} + ####################### + #return {"bus": output_temp, "gen": gen_temp} + if self.is_vld_task: + output_bus = torch.cat([output_temp, status_logit], dim=1) + else: + output_bus = output_temp + + return {"bus": output_bus, "gen": gen_temp} + ##################### + + + diff --git a/gridfm_graphkit/models/utils.py b/gridfm_graphkit/models/utils.py index bc4b9bfa..3a9203e8 100644 --- a/gridfm_graphkit/models/utils.py +++ b/gridfm_graphkit/models/utils.py @@ -73,7 +73,6 @@ def forward(self, Pft, Qft, edge_index, num_bus): def compute_shunt_power(bus_data_pred, bus_data_orig): - """Compute active/reactive shunt power contributions per bus.""" p_shunt = -bus_data_orig[:, GS] * bus_data_pred[:, VM_OUT] ** 2 q_shunt = bus_data_orig[:, BS] * bus_data_pred[:, VM_OUT] ** 2 return p_shunt, q_shunt @@ -81,7 +80,6 @@ def compute_shunt_power(bus_data_pred, bus_data_orig): @PHYSICS_DECODER_REGISTRY.register("OptimalPowerFlow") class PhysicsDecoderOPF(nn.Module): - """Map network outputs to OPF-consistent bus states using physics constraints.""" def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): mask_pv = mask_dict["PV"] mask_ref = mask_dict["REF"] @@ -116,7 +114,6 @@ def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): @PHYSICS_DECODER_REGISTRY.register("PowerFlow") class PhysicsDecoderPF(nn.Module): - """Map network outputs to PF-consistent bus states using physics constraints.""" def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): """ PF decoder: @@ -164,7 +161,6 @@ def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): @PHYSICS_DECODER_REGISTRY.register("StateEstimation") class PhysicsDecoderSE(nn.Module): - """Map network outputs to SE targets via bus power-balance relations.""" def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): p_shunt, q_shunt = compute_shunt_power(bus_data_pred, bus_data_orig) Vm_out = bus_data_pred[:, VM_OUT] @@ -172,6 +168,37 @@ def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): output = torch.stack([Vm_out, Va_out, P_in - p_shunt, Q_in - q_shunt], dim=1) return output +######################### +@PHYSICS_DECODER_REGISTRY.register("VoltageLossDetection") +class PhysicsDecoderVLD(nn.Module): + """ + VLD decoder: + Use the same physical decoding rule as PowerFlow for the bus outputs + [Vm, Va, Pg, Qg]. The VLD-specific bus-status logit is produced by a + separate model head and concatenated later in the model forward pass. + """ + + def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig, agg_bus, mask_dict): + mask_pv = mask_dict["PV"] + mask_ref = mask_dict["REF"] + mask_pvref = mask_pv | mask_ref + + p_shunt, q_shunt = compute_shunt_power(bus_data_pred, bus_data_orig) + + Pd = bus_data_orig[:, PD_H] + Qd = bus_data_orig[:, QD_H] + + Qg_new = torch.where(mask_pvref, Q_in + Qd - q_shunt, torch.zeros_like(Q_in)) + + Pg_ref = torch.where(mask_ref, P_in + Pd - p_shunt, torch.zeros_like(P_in)) + Pg_new = torch.where(mask_pv, agg_bus, Pg_ref) + + Vm_out = bus_data_pred[:, VM_OUT] + Va_out = bus_data_pred[:, VA_OUT] + + output = torch.stack([Vm_out, Va_out, Pg_new, Qg_new], dim=1) + return output +######################## class ComputeNodeResiduals(nn.Module): """Compute net residuals per bus combining branch flows, generators, loads, and shunts.""" @@ -188,5 +215,4 @@ def forward(self, P_in, Q_in, bus_data_pred, bus_data_orig): def bound_with_sigmoid(pred, low, high): - """Squash unconstrained predictions into [low, high] with a sigmoid map.""" return low + (high - low) * torch.sigmoid(pred) diff --git a/gridfm_graphkit/tasks/__init__.py b/gridfm_graphkit/tasks/__init__.py index 8ed9b137..d9ce68e9 100644 --- a/gridfm_graphkit/tasks/__init__.py +++ b/gridfm_graphkit/tasks/__init__.py @@ -1,5 +1,10 @@ from gridfm_graphkit.tasks.pf_task import PowerFlowTask from gridfm_graphkit.tasks.opf_task import OptimalPowerFlowTask from gridfm_graphkit.tasks.se_task import StateEstimationTask +################ +from gridfm_graphkit.tasks.vld_task import VoltageLossDetectionTask +############### -__all__ = ["PowerFlowTask", "OptimalPowerFlowTask", "StateEstimationTask"] +############## +__all__ = ["PowerFlowTask", "OptimalPowerFlowTask", "StateEstimationTask", "VoltageLossDetectionTask" ] +############# \ No newline at end of file diff --git a/gridfm_graphkit/tasks/base_task.py b/gridfm_graphkit/tasks/base_task.py index fc2b95e3..90c8f7b5 100644 --- a/gridfm_graphkit/tasks/base_task.py +++ b/gridfm_graphkit/tasks/base_task.py @@ -20,20 +20,6 @@ def __init__(self, args, data_normalizers): self.data_normalizers = data_normalizers self.save_hyperparameters() - def transfer_batch_to_device(self, batch, device, dataloader_idx): - """Pre-cast float64 tensors before moving batches onto MPS. - - PyTorch MPS does not support float64 tensors. Some PyG metadata fields can - get collated as float64 even when model inputs are float32, so coerce them - first and then delegate to Lightning's standard device transfer. - """ - if getattr(device, "type", None) == "mps" and hasattr(batch, "stores"): - for store in batch.stores: - for key, val in store.items(): - if isinstance(val, torch.Tensor) and val.dtype == torch.float64: - store[key] = val.to(torch.float32) - return super().transfer_batch_to_device(batch, device, dataloader_idx) - def on_after_batch_transfer(self, batch, dataloader_idx: int): """Cast float tensors in HeteroData batches to the model's parameter dtype. diff --git a/gridfm_graphkit/tasks/compute_ac_dc_metrics.py b/gridfm_graphkit/tasks/compute_ac_dc_metrics.py new file mode 100644 index 00000000..8dcfc8c0 --- /dev/null +++ b/gridfm_graphkit/tasks/compute_ac_dc_metrics.py @@ -0,0 +1,227 @@ +"""Compute AC/DC power balance residuals and runtime statistics on test splits.""" + +import json +import os +import numpy as np +import pandas as pd +from gridfm_datakit.utils.power_balance import ( + compute_branch_powers_vectorized, + compute_bus_balance, +) + +N_SCENARIO_PER_PARTITION = 200 +NUM_PROCESSES = 64 + + +def _load_test_data(data_dir: str, test_scenario_ids: list[int]): + partitions = sorted(set(s // N_SCENARIO_PER_PARTITION for s in test_scenario_ids)) + test_set = set(test_scenario_ids) + partition_filter = [("scenario_partition", "in", partitions)] + + bus_df = pd.read_parquet( + os.path.join(data_dir, "bus_data.parquet"), + filters=partition_filter, + ) + branch_df = pd.read_parquet( + os.path.join(data_dir, "branch_data.parquet"), + filters=partition_filter, + ) + runtime_df = pd.read_parquet( + os.path.join(data_dir, "runtime_data.parquet"), + filters=partition_filter, + ) + + bus_df = bus_df[bus_df["scenario"].isin(test_set)].reset_index(drop=True) + branch_df = branch_df[branch_df["scenario"].isin(test_set)].reset_index(drop=True) + runtime_df = runtime_df[runtime_df["scenario"].isin(test_set)].reset_index( + drop=True, + ) + + print( + f" Loaded {len(bus_df)} bus rows, {len(branch_df)} branch rows, " + f"{len(runtime_df)} runtime rows for {len(test_set)} test scenarios", + ) + return bus_df, branch_df, runtime_df + + +def _compute_residual_stats(balance_df: pd.DataFrame, dc: bool) -> dict: + grouped = balance_df.groupby("scenario") + + if dc: + P_mis = balance_df["P_mis_dc"].to_numpy() + nan_scenarios = int(grouped["P_mis_dc"].apply(lambda x: x.isna().all()).sum()) + return { + "Avg. active res. (MW)": float(np.nanmean(np.abs(P_mis))), + "DC NaN scenarios": nan_scenarios, + } + + P_mis = balance_df["P_mis_ac"].to_numpy() + Q_mis = balance_df["Q_mis_ac"].to_numpy() + pbe = np.sqrt(P_mis**2 + Q_mis**2) + + pbe_per_scenario_mean = grouped.apply( + lambda g: np.nanmean( + np.sqrt(g["P_mis_ac"].to_numpy() ** 2 + g["Q_mis_ac"].to_numpy() ** 2), + ), + include_groups=False, + ) + + return { + "Avg. active res. (MW)": float(np.nanmean(np.abs(P_mis))), + "Avg. reactive res. (MVar)": float(np.nanmean(np.abs(Q_mis))), + "PBE Mean": float(np.nanmean(pbe_per_scenario_mean)), + "PBE Max": float(np.nanmax(pbe)), + } + + +def _compute_runtime_stats(runtime_df: pd.DataFrame) -> dict: + results = {} + for mode in ["ac", "dc"]: + if mode not in runtime_df.columns: + continue + + rt_ms = runtime_df[mode].to_numpy(dtype=float) * 1000.0 / NUM_PROCESSES + valid = rt_ms[~np.isnan(rt_ms)] + + results[f"runtime_{mode}_mean_ms_with_{NUM_PROCESSES}_cores"] = float( + np.mean(valid), + ) + results[f"runtime_{mode}_median_ms_with_{NUM_PROCESSES}_cores"] = float( + np.median(valid), + ) + results[f"runtime_{mode}_std_ms_with_{NUM_PROCESSES}_cores"] = float( + np.std(valid), + ) + results[f"runtime_{mode}_max_ms_with_{NUM_PROCESSES}_cores"] = float( + np.max(valid), + ) + + return results + + +def compute_ac_dc_metrics( + artifacts_dir: str, + data_dir: str, + grid_name: str, + sn_mva: float, +) -> bool: + """Compute AC/DC ground-truth power balance and runtime metrics, save results. + + Saves: + - Aggregated metrics (CSV) + - AC per-bus residuals (Parquet) + - DC per-bus residuals (Parquet) + + Returns: + True if metrics were computed, False if splits JSON was not found. + """ + + splits_json = os.path.join( + artifacts_dir, + "stats", + f"{grid_name}_scenario_splits.json", + ) + if not os.path.exists(splits_json): + print(f" Skipping: no splits JSON found at {splits_json}") + return False + + with open(splits_json) as f: + test_ids = json.load(f)["test"] + + print(f" Test split: {len(test_ids)} scenarios") + + bus_df, branch_df, runtime_df = _load_test_data(data_dir, test_ids) + + # ========================= + # AC residuals + # ========================= + print(" Computing AC power balance...") + balance_ac = compute_bus_balance( + bus_df, + branch_df, + branch_df[["pf", "qf", "pt", "qt"]], + dc=False, + sn_mva=sn_mva, + ) + + ac_stats = _compute_residual_stats(balance_ac, dc=False) + + # ========================= + # DC residuals + # ========================= + print(" Computing DC power balance...") + pf_dc, _, pt_dc, _ = compute_branch_powers_vectorized( + branch_df, + bus_df, + dc=True, + sn_mva=sn_mva, + ) + + balance_dc = compute_bus_balance( + bus_df, + branch_df, + pd.DataFrame( + {"pf_dc": pf_dc, "pt_dc": pt_dc}, + index=branch_df.index, + ), + dc=True, + sn_mva=sn_mva, + ) + + dc_stats = _compute_residual_stats(balance_dc, dc=True) + + # ========================= + # Save per-bus residuals (PARQUET) + # ========================= + out_dir = os.path.join(artifacts_dir, "test") + os.makedirs(out_dir, exist_ok=True) + + # AC: active + reactive + ac_bus_residuals = ( + balance_ac[["scenario", "bus", "P_mis_ac", "Q_mis_ac"]] + .copy() + .rename( + columns={ + "P_mis_ac": "active res. (MW)", + "Q_mis_ac": "reactive res. (MVar)", + }, + ) + ) + ac_residuals_path = os.path.join(out_dir, f"{grid_name}_ac_bus_residuals.parquet") + ac_bus_residuals.to_parquet(ac_residuals_path, index=False) + print(f" AC per-bus residuals saved to {ac_residuals_path}") + + # DC: active only + dc_bus_residuals = ( + balance_dc[["scenario", "bus", "P_mis_dc"]] + .copy() + .rename( + columns={ + "P_mis_dc": "DC active res. (MW)", + }, + ) + ) + + dc_residuals_path = os.path.join(out_dir, f"{grid_name}_dc_bus_residuals.parquet") + dc_bus_residuals.to_parquet(dc_residuals_path, index=False) + print(f" DC per-bus residuals saved to {dc_residuals_path}") + + # ========================= + # Save aggregated metrics (CSV) + # ========================= + runtime_stats = _compute_runtime_stats(runtime_df) + + rows = [] + for key, val in ac_stats.items(): + rows.append({"Metric": f"AC {key}", "Value": val}) + for key, val in dc_stats.items(): + rows.append({"Metric": f"DC {key}", "Value": val}) + for key, val in runtime_stats.items(): + rows.append({"Metric": key, "Value": val}) + + metrics_path = os.path.join(out_dir, f"{grid_name}_ac_dc_metrics.csv") + pd.DataFrame(rows).to_csv(metrics_path, index=False) + + print(f" Aggregated metrics saved to {metrics_path}") + + return True diff --git a/gridfm_graphkit/tasks/opf_task.py b/gridfm_graphkit/tasks/opf_task.py index dbb1baab..06d938df 100644 --- a/gridfm_graphkit/tasks/opf_task.py +++ b/gridfm_graphkit/tasks/opf_task.py @@ -1,12 +1,8 @@ from gridfm_graphkit.datasets.globals import ( # Bus feature indices - PD_H, - QD_H, QG_H, VM_H, VA_H, - MIN_VM_H, - MAX_VM_H, MIN_QG_H, MAX_QG_H, # Output feature indices @@ -16,8 +12,6 @@ QG_OUT, # Generator feature indices PG_H, - MIN_PG, - MAX_PG, C0_H, C1_H, C2_H, @@ -34,8 +28,8 @@ plot_residuals_histograms, residual_stats_by_type, ) +from pytorch_lightning.utilities import rank_zero_only import torch -import torch.distributed as dist import torch.nn.functional as F from torch_scatter import scatter_add from gridfm_graphkit.models.utils import ( @@ -87,14 +81,14 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): c2 = batch.x_dict["gen"][:, C2_H] target_pg = batch.y_dict["gen"].squeeze() pred_pg = output["gen"].squeeze() - gen_cost_gt = (c0 + c1 * target_pg + c2 * target_pg**2) # assumes all branches are on! - gen_cost_pred = (c0 + c1 * pred_pg + c2 * pred_pg**2) # assumes all branches are on! + gen_cost_gt = c0 + c1 * target_pg + c2 * target_pg**2 + gen_cost_pred = c0 + c1 * pred_pg + c2 * pred_pg**2 gen_batch = batch.batch_dict["gen"] # shape: [N_gen_total] cost_gt = scatter_add(gen_cost_gt, gen_batch, dim=0) cost_pred = scatter_add(gen_cost_pred, gen_batch, dim=0) - + optimality_gap = torch.mean(torch.abs((cost_pred - cost_gt) / cost_gt * 100)) agg_gen_on_bus = scatter_add( @@ -118,7 +112,7 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): # output["bus"] = target Pft, Qft = branch_flow_layer(output["bus"], bus_edge_index, bus_edge_attr) - # Compute branch thermal limits violations + # Compute branch termal limits violations Sft = torch.sqrt(Pft**2 + Qft**2) # apparent power flow per branch branch_thermal_limits = bus_edge_attr[:, RATE_A] branch_thermal_excess = F.relu(Sft - branch_thermal_limits) @@ -138,14 +132,13 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): bus_angles = output["bus"][:, VA_OUT] # in degrees from_bus = bus_edge_index[0] to_bus = bus_edge_index[1] - angle_diff = bus_angles[from_bus] - bus_angles[to_bus] # keep sign - angle_diff = (angle_diff + torch.pi) % (2 * torch.pi) - torch.pi # wrap to [-pi, pi] - angle_excess_low = F.relu(angle_min - angle_diff) - angle_excess_high = F.relu(angle_diff - angle_max) + angle_diff = torch.abs(bus_angles[from_bus] - bus_angles[to_bus]) - branch_angle_violation_mean = torch.mean( - angle_excess_low + angle_excess_high - ) # mean of the abs violation + angle_excess_low = F.relu(angle_min - angle_diff) # violation if too small + angle_excess_high = F.relu(angle_diff - angle_max) # violation if too large + branch_angle_violation_mean = ( + torch.mean(angle_excess_low + angle_excess_high) * 180.0 / torch.pi + ) P_in, Q_in = node_injection_layer(Pft, Qft, bus_edge_index, num_bus) residual_P, residual_Q = node_residuals_layer( @@ -174,8 +167,6 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): mean_Qg_violation_PV = Qg_violation_amount[mask_PV].mean() mean_Qg_violation_REF = Qg_violation_amount[mask_REF].mean() - mask_PV_REF = mask_PV | mask_REF # PV or REF buses - mean_Qg_violation = Qg_violation_amount[mask_PV_REF].mean() # if self.args.verbose: mean_res_P_PQ, max_res_P_PQ = residual_stats_by_type( @@ -270,10 +261,8 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): loss_dict["Branch voltage angle difference violations"] = ( branch_angle_violation_mean ) - loss_dict["Mean Qg violation PV buses"] = mean_Qg_violation_PV # mean of the abs violation over the entire batch (all oines in the batch). - # this is then overaged over all the batches and gives same weight to all batches despite them possibly having varying number of branches + loss_dict["Mean Qg violation PV buses"] = mean_Qg_violation_PV loss_dict["Mean Qg violation REF buses"] = mean_Qg_violation_REF - loss_dict["Mean Qg violation"] = mean_Qg_violation loss_dict["MSE PQ nodes - PG"] = mse_PQ[PG_OUT] loss_dict["MSE PV nodes - PG"] = mse_PV[PG_OUT] @@ -304,25 +293,8 @@ def test_step(self, batch, batch_idx, dataloader_idx=0): ) return + @rank_zero_only def on_test_end(self): - # In DDP, gather verbose test outputs from all ranks to rank 0 - # so that plots and detailed analysis cover the full test set. - if self.args.verbose and dist.is_available() and dist.is_initialized(): - world_size = dist.get_world_size() - gathered = [None] * world_size if dist.get_rank() == 0 else None - dist.gather_object(self.test_outputs, gathered, dst=0) - if dist.get_rank() == 0: - merged = {i: [] for i in range(len(self.args.data.networks))} - for rank_data in gathered: - for dl_idx, batches in rank_data.items(): - merged[dl_idx].extend(batches) - self.test_outputs = merged - - # Only rank 0 proceeds with logging, CSV writing, and plotting - if dist.is_available() and dist.is_initialized() and dist.get_rank() != 0: - self.test_outputs.clear() - return - if isinstance(self.logger, MLFlowLogger): artifact_dir = os.path.join( self.logger.save_dir, @@ -369,10 +341,10 @@ def on_test_end(self): rmse_gen = metrics.get("MSE PG", 0) ** 0.5 optimality_gap = metrics.get("Opt gap", " ") branch_thermal_violation_from = metrics.get( - "Branch thermal violation from", + "Branch termal violation from", " ", ) - branch_thermal_violation_to = metrics.get("Branch thermal violation to", " ") + branch_thermal_violation_to = metrics.get("Branch termal violation to", " ") branch_angle_violation = metrics.get( "Branch voltage angle difference violations", " ", @@ -382,7 +354,6 @@ def on_test_end(self): "Mean Qg violation REF buses", " ", ) - mean_qg_violation = metrics.get("Mean Qg violation", " ") # --- Main RMSE metrics file --- data_main = { @@ -401,12 +372,11 @@ def on_test_end(self): "Avg. reactive res. (MVar)", "RMSE PG generators (MW)", "Mean optimality gap (%)", - "Mean branch thermal violation from (MVA)", - "Mean branch thermal violation to (MVA)", + "Mean branch termal violation from (MVA)", + "Mean branch termal violation to (MVA)", "Mean branch angle difference violation (radians)", "Mean Qg violation PV buses", "Mean Qg violation REF buses", - "Mean Qg violation", ], "Value": [ avg_active_res, @@ -418,7 +388,6 @@ def on_test_end(self): branch_angle_violation, mean_qg_violation_PV_buses, mean_qg_violation_REF_buses, - mean_qg_violation, ], } df_residuals = pd.DataFrame(data_residuals) @@ -513,100 +482,4 @@ def on_test_end(self): self.test_outputs.clear() def predict_step(self, batch, batch_idx, dataloader_idx=0): - output, _ = self.shared_step(batch) - - self.data_normalizers[dataloader_idx].inverse_transform(batch) - self.data_normalizers[dataloader_idx].inverse_output(output, batch) - - branch_flow_layer = ComputeBranchFlow() - node_injection_layer = ComputeNodeInjection() - node_residuals_layer = ComputeNodeResiduals() - - num_bus = batch.x_dict["bus"].size(0) - bus_edge_index = batch.edge_index_dict[("bus", "connects", "bus")] - bus_edge_attr = batch.edge_attr_dict[("bus", "connects", "bus")] - - Pft, Qft = branch_flow_layer(output["bus"], bus_edge_index, bus_edge_attr) - P_in, Q_in = node_injection_layer(Pft, Qft, bus_edge_index, num_bus) - residual_P, residual_Q = node_residuals_layer( - P_in, - Q_in, - output["bus"], - batch.x_dict["bus"], - ) - residual_P = torch.abs(residual_P) - residual_Q = torch.abs(residual_Q) - residual_mva = torch.sqrt(residual_P**2 + residual_Q**2) - - bus_batch = batch.batch_dict["bus"] - scenario_ids = batch["scenario_id"][bus_batch] - local_bus_idx = torch.cat( - [ - torch.arange(c, device=bus_batch.device) - for c in torch.bincount(bus_batch) - ], - ) # this works because the order of the buses is preserved by the groupby in the dataset wrapper and datakit data has buses in increasing order. - - bus_x = batch.x_dict["bus"] - bus_y = batch.y_dict["bus"] - mask_PQ = batch.mask_dict["PQ"] - mask_PV = batch.mask_dict["PV"] - mask_REF = batch.mask_dict["REF"] - - _, gen_to_bus_index = batch.edge_index_dict[("gen", "connected_to", "bus")] - agg_gen_on_bus = scatter_add( - batch.y_dict["gen"], - gen_to_bus_index, - dim=0, - dim_size=num_bus, - ) - gen_batch = batch.batch_dict["gen"] - gen_scenario_ids = batch["scenario_id"][gen_batch] - local_gen_idx = torch.cat( - [ - torch.arange(c, device=gen_batch.device) - for c in torch.bincount(gen_batch) - ], - ) - gen_x = batch.x_dict["gen"] - gen_target = batch.y_dict["gen"].reshape(-1) - gen_pred = output["gen"].reshape(-1) - - return { - "bus": { - "scenario": scenario_ids.cpu().numpy(), - "bus": local_bus_idx.cpu().numpy(), - "Pd": bus_x[:, PD_H].cpu().numpy(), - "Qd": bus_x[:, QD_H].cpu().numpy(), - "Vm_min": bus_x[:, MIN_VM_H].cpu().numpy(), - "Vm_max": bus_x[:, MAX_VM_H].cpu().numpy(), - "Qg_min": bus_x[:, MIN_QG_H].cpu().numpy(), - "Qg_max": bus_x[:, MAX_QG_H].cpu().numpy(), - "Vm_target": bus_y[:, VM_H].cpu().numpy(), - "Va_target": bus_y[:, VA_H].cpu().numpy(), - "Pg_target": agg_gen_on_bus.squeeze().cpu().numpy(), - "Qg_target": bus_y[:, QG_H].cpu().numpy(), - "PQ": mask_PQ.cpu().numpy().astype(int), - "PV": mask_PV.cpu().numpy().astype(int), - "REF": mask_REF.cpu().numpy().astype(int), - "Vm_pred": output["bus"][:, VM_OUT].detach().cpu().numpy(), - "Va_pred": output["bus"][:, VA_OUT].detach().cpu().numpy(), - "Pg_pred": output["bus"][:, PG_OUT].detach().cpu().numpy(), - "Qg_pred": output["bus"][:, QG_OUT].detach().cpu().numpy(), - "active res. (MW)": residual_P.detach().cpu().numpy(), - "reactive res. (MVar)": residual_Q.detach().cpu().numpy(), - "PBE": residual_mva.detach().cpu().numpy(), - }, - "gen": { - "scenario": gen_scenario_ids.cpu().numpy(), - "idx": local_gen_idx.cpu().numpy(), - "bus": local_bus_idx[gen_to_bus_index].cpu().numpy(), - "p_mw_target": gen_target.cpu().numpy(), - "p_mw_pred": gen_pred.detach().cpu().numpy(), - "min_p_mw": gen_x[:, MIN_PG].cpu().numpy(), - "max_p_mw": gen_x[:, MAX_PG].cpu().numpy(), - "cp0_eur": gen_x[:, C0_H].cpu().numpy(), - "cp1_eur_per_mw": gen_x[:, C1_H].cpu().numpy(), - "cp2_eur_per_mw2": gen_x[:, C2_H].cpu().numpy(), - }, - } + raise NotImplementedError diff --git a/gridfm_graphkit/tasks/pf_task.py b/gridfm_graphkit/tasks/pf_task.py index 948a25e0..cdc9d646 100644 --- a/gridfm_graphkit/tasks/pf_task.py +++ b/gridfm_graphkit/tasks/pf_task.py @@ -5,10 +5,6 @@ QG_H, VM_H, VA_H, - MIN_VM_H, - MAX_VM_H, - MIN_QG_H, - MAX_QG_H, # Output feature indices VM_OUT, VA_OUT, @@ -245,7 +241,6 @@ def on_test_end(self): # Only rank 0 proceeds with logging, CSV writing, and plotting if dist.is_available() and dist.is_initialized() and dist.get_rank() != 0: - self.test_outputs.clear() # clear the test outputs for other ranks return if isinstance(self.logger, MLFlowLogger): @@ -356,22 +351,22 @@ def on_test_end(self): self.test_outputs.clear() def predict_step(self, batch, batch_idx, dataloader_idx=0): - output, _ = self.shared_step(batch) # get the predicted output from the model + output, _ = self.shared_step(batch) - self.data_normalizers[dataloader_idx].inverse_transform(batch) # normalize the batch data back to the original scale - self.data_normalizers[dataloader_idx].inverse_output(output, batch) # inverse transform the predicted output back to the original scale + self.data_normalizers[dataloader_idx].inverse_transform(batch) + self.data_normalizers[dataloader_idx].inverse_output(output, batch) - branch_flow_layer = ComputeBranchFlow() # layer to compute the branch flows - node_injection_layer = ComputeNodeInjection() # layer to compute the node injections - node_residuals_layer = ComputeNodeResiduals() # layer to compute the node residuals + branch_flow_layer = ComputeBranchFlow() + node_injection_layer = ComputeNodeInjection() + node_residuals_layer = ComputeNodeResiduals() - num_bus = batch.x_dict["bus"].size(0) # number of buses in the batch - bus_edge_index = batch.edge_index_dict[("bus", "connects", "bus")] # from and to buses - bus_edge_attr = batch.edge_attr_dict[("bus", "connects", "bus")] # edge attributes (admittance) of the bus connections + num_bus = batch.x_dict["bus"].size(0) + bus_edge_index = batch.edge_index_dict[("bus", "connects", "bus")] + bus_edge_attr = batch.edge_attr_dict[("bus", "connects", "bus")] - Pft, Qft = branch_flow_layer(output["bus"], bus_edge_index, bus_edge_attr) # compute the branch flows - P_in, Q_in = node_injection_layer(Pft, Qft, bus_edge_index, num_bus) # compute the node injections - residual_P, residual_Q = node_residuals_layer( # compute the node residuals + Pft, Qft = branch_flow_layer(output["bus"], bus_edge_index, bus_edge_attr) + P_in, Q_in = node_injection_layer(Pft, Qft, bus_edge_index, num_bus) + residual_P, residual_Q = node_residuals_layer( P_in, Q_in, output["bus"], @@ -388,9 +383,8 @@ def predict_step(self, batch, batch_idx, dataloader_idx=0): torch.arange(c, device=bus_batch.device) for c in torch.bincount(bus_batch) ], - ) # this is based on the assumptions that the buses within a graph are ordered and indexed as 0 ... n_nodes-1. - # todo: we should remove this assert and store the bus idx in the tensors - # right now we need the increasing order and we have an assert in the dataset to check it. + ) + bus_x = batch.x_dict["bus"] bus_y = batch.y_dict["bus"] mask_PQ = batch.mask_dict["PQ"] @@ -408,23 +402,19 @@ def predict_step(self, batch, batch_idx, dataloader_idx=0): return { "scenario": scenario_ids.cpu().numpy(), "bus": local_bus_idx.cpu().numpy(), - "Pd": bus_x[:, PD_H].cpu().numpy(), - "Qd": bus_x[:, QD_H].cpu().numpy(), - "Vm_min": bus_x[:, MIN_VM_H].cpu().numpy(), - "Vm_max": bus_x[:, MAX_VM_H].cpu().numpy(), - "Qg_min": bus_x[:, MIN_QG_H].cpu().numpy(), - "Qg_max": bus_x[:, MAX_QG_H].cpu().numpy(), - "Vm_target": bus_y[:, VM_H].cpu().numpy(), - "Va_target": bus_y[:, VA_H].cpu().numpy(), - "Pg_target": agg_gen_on_bus.squeeze().cpu().numpy(), - "Qg_target": bus_y[:, QG_H].cpu().numpy(), - "PQ": mask_PQ.cpu().numpy().astype(int), - "PV": mask_PV.cpu().numpy().astype(int), - "REF": mask_REF.cpu().numpy().astype(int), - "Vm_pred": output["bus"][:, VM_OUT].detach().cpu().numpy(), - "Va_pred": output["bus"][:, VA_OUT].detach().cpu().numpy(), - "Pg_pred": output["bus"][:, PG_OUT].detach().cpu().numpy(), - "Qg_pred": output["bus"][:, QG_OUT].detach().cpu().numpy(), + "pd_mw": bus_x[:, PD_H].cpu().numpy(), + "qd_mvar": bus_x[:, QD_H].cpu().numpy(), + "vm_pu_target": bus_y[:, VM_H].cpu().numpy(), + "va_target": bus_y[:, VA_H].cpu().numpy(), + "pg_mw_target": agg_gen_on_bus.squeeze().cpu().numpy(), + "qg_mvar_target": bus_y[:, QG_H].cpu().numpy(), + "is_pq": mask_PQ.cpu().numpy().astype(int), + "is_pv": mask_PV.cpu().numpy().astype(int), + "is_ref": mask_REF.cpu().numpy().astype(int), + "vm_pu": output["bus"][:, VM_OUT].detach().cpu().numpy(), + "va": output["bus"][:, VA_OUT].detach().cpu().numpy(), + "pg_mw": output["bus"][:, PG_OUT].detach().cpu().numpy(), + "qg_mvar": output["bus"][:, QG_OUT].detach().cpu().numpy(), "active res. (MW)": residual_P.detach().cpu().numpy(), "reactive res. (MVar)": residual_Q.detach().cpu().numpy(), "PBE": residual_mva.detach().cpu().numpy(), diff --git a/gridfm_graphkit/tasks/reconstruction_tasks.py b/gridfm_graphkit/tasks/reconstruction_tasks.py index 45975aee..8742646b 100644 --- a/gridfm_graphkit/tasks/reconstruction_tasks.py +++ b/gridfm_graphkit/tasks/reconstruction_tasks.py @@ -57,7 +57,6 @@ def shared_step(self, batch): batch.edge_attr_dict, batch.mask_dict, model=self.model, - x_dict=batch.x_dict, ) return output, loss_dict diff --git a/gridfm_graphkit/tasks/se_task.py b/gridfm_graphkit/tasks/se_task.py index 36667ad2..5e45182d 100644 --- a/gridfm_graphkit/tasks/se_task.py +++ b/gridfm_graphkit/tasks/se_task.py @@ -26,7 +26,6 @@ @TASK_REGISTRY.register("StateEstimation") class StateEstimationTask(ReconstructionTask): - """State-estimation task with evaluation plots for masked and noisy measurements.""" def __init__(self, args, data_normalizers): super().__init__(args, data_normalizers) diff --git a/gridfm_graphkit/tasks/utils.py b/gridfm_graphkit/tasks/utils.py index 273d79f5..c77a9953 100644 --- a/gridfm_graphkit/tasks/utils.py +++ b/gridfm_graphkit/tasks/utils.py @@ -7,25 +7,10 @@ def residual_stats_by_type(residual, mask, bus_batch): - """Return per-graph mean and max absolute residuals for a masked bus subset.""" residual_masked = residual[mask] batch_masked = bus_batch[mask] - abs_residual = torch.abs(residual_masked) - - # torch_scatter on MPS can dispatch into a CPU-only path for scatter_max. - # Compute the grouped stats on CPU and move the results back so verbose - # evaluation works without changing the torch/torch_scatter stack. - if abs_residual.device.type == "mps": - abs_residual_cpu = abs_residual.cpu() - batch_masked_cpu = batch_masked.cpu() - mean_res = scatter_mean(abs_residual_cpu, batch_masked_cpu, dim=0).to( - abs_residual.device, - ) - max_res, _ = scatter_max(abs_residual_cpu, batch_masked_cpu, dim=0) - max_res = max_res.to(abs_residual.device) - else: - mean_res = scatter_mean(abs_residual, batch_masked, dim=0) - max_res, _ = scatter_max(abs_residual, batch_masked, dim=0) + mean_res = scatter_mean(torch.abs(residual_masked), batch_masked, dim=0) + max_res, _ = scatter_max(torch.abs(residual_masked), batch_masked, dim=0) return mean_res, max_res @@ -45,27 +30,19 @@ def plot_residuals_histograms(outputs, dataset_name, plot_dir): for stat_key, title in stats: # Gather all data first to compute common bin edges - all_data = ( - torch.cat( - [ - torch.cat([d[f"{stat_key}_{bus_type}"] for d in outputs]) - for bus_type in bus_types - ], - ) - .float() - .numpy() - ) + all_data = torch.cat( + [ + torch.cat([d[f"{stat_key}_{bus_type}"] for d in outputs]) + for bus_type in bus_types + ], + ).numpy() # Define bins across the entire data range bins = np.linspace(all_data.min(), all_data.max(), 61) # 30 bins of equal width plt.figure(figsize=(10, 6)) for bus_type, color in zip(bus_types, colors): - data = ( - torch.cat([d[f"{stat_key}_{bus_type}"] for d in outputs]) - .float() - .numpy() - ) + data = torch.cat([d[f"{stat_key}_{bus_type}"] for d in outputs]).numpy() plt.hist(data, bins=bins, alpha=0.6, label=bus_type, color=color) plt.title(f"{title} per Bus Type in {dataset_name}") diff --git a/gridfm_graphkit/tasks/vld_task.py b/gridfm_graphkit/tasks/vld_task.py new file mode 100644 index 00000000..2d5ebd3e --- /dev/null +++ b/gridfm_graphkit/tasks/vld_task.py @@ -0,0 +1,158 @@ +import os +import torch +import torch.distributed as dist +import pandas as pd +from lightning.pytorch.loggers import MLFlowLogger + +from gridfm_graphkit.io.registries import TASK_REGISTRY +from gridfm_graphkit.tasks.reconstruction_tasks import ReconstructionTask +from gridfm_graphkit.datasets.globals import ( + VM_OUT, + VM_H, + BUS_STATUS_TARGET, + BUS_STATUS_LOGIT_OUT, +) + +@TASK_REGISTRY.register("VoltageLossDetection") +class VoltageLossDetectionTask(ReconstructionTask): + """ + Topology-aware voltage loss detection task. + + Uses the standard ReconstructionTask training/validation flow and adds + VLD-specific test/predict metrics for bus status and Vm behavior. + """ + + def __init__(self, args, data_normalizers): + super().__init__(args, data_normalizers) + + def test_step(self, batch, batch_idx, dataloader_idx=0): + output, loss_dict = self.shared_step(batch) + dataset_name = self.args.data.networks[dataloader_idx] + + bus_pred = output["bus"] + bus_target = batch.y_dict["bus"] + + status_prob = torch.sigmoid(bus_pred[:, BUS_STATUS_LOGIT_OUT]) + status_pred = (status_prob >= 0.5).float() + status_true = bus_target[:, BUS_STATUS_TARGET].float() + + vm_pred = bus_pred[:, VM_OUT] + vm_true = bus_target[:, VM_H] + + status_acc = (status_pred == status_true).float().mean() + + off_mask = status_true < 0.5 + on_mask = status_true >= 0.5 + + off_vm_mae = ( + vm_pred[off_mask].abs().mean() + if off_mask.any() + else torch.tensor(0.0, device=vm_pred.device) + ) + on_vm_rmse = ( + torch.sqrt(torch.mean((vm_pred[on_mask] - vm_true[on_mask]) ** 2)) + if on_mask.any() + else torch.tensor(0.0, device=vm_pred.device) + ) + + loss_dict["Status Accuracy"] = status_acc.detach() + loss_dict["OFF Vm MAE"] = off_vm_mae.detach() + loss_dict["ON Vm RMSE"] = on_vm_rmse.detach() + + loss_dict["Test loss"] = loss_dict.pop("loss").detach() + + for metric, value in loss_dict.items(): + metric_name = f"{dataset_name}/{metric}" + self.log( + metric_name, + value, + batch_size=batch.num_graphs, + add_dataloader_idx=False, + sync_dist=True, + logger=False, + ) + + self.test_outputs[dataloader_idx].append( + { + "dataset": dataset_name, + "status_prob": status_prob.detach().cpu(), + "status_pred": status_pred.detach().cpu(), + "status_true": status_true.detach().cpu(), + "vm_pred": vm_pred.detach().cpu(), + "vm_true": vm_true.detach().cpu(), + } + ) + + def on_test_end(self): + if dist.is_available() and dist.is_initialized(): + world_size = dist.get_world_size() + gathered = [None] * world_size if dist.get_rank() == 0 else None + dist.gather_object(self.test_outputs, gathered, dst=0) + if dist.get_rank() == 0: + merged = {i: [] for i in range(len(self.args.data.networks))} + for rank_data in gathered: + for dl_idx, batches in rank_data.items(): + merged[dl_idx].extend(batches) + self.test_outputs = merged + + if dist.is_available() and dist.is_initialized() and dist.get_rank() != 0: + return + + if isinstance(self.logger, MLFlowLogger): + artifact_dir = os.path.join( + self.logger.save_dir, + self.logger.experiment_id, + self.logger.run_id, + "artifacts", + ) + else: + artifact_dir = self.logger.save_dir + + test_dir = os.path.join(artifact_dir, "test") + os.makedirs(test_dir, exist_ok=True) + + for dataset_idx, outputs in self.test_outputs.items(): + if not outputs: + continue + + dataset_name = self.args.data.networks[dataset_idx] + status_prob = torch.cat([o["status_prob"] for o in outputs]).numpy() + status_pred = torch.cat([o["status_pred"] for o in outputs]).numpy() + status_true = torch.cat([o["status_true"] for o in outputs]).numpy() + vm_pred = torch.cat([o["vm_pred"] for o in outputs]).numpy() + vm_true = torch.cat([o["vm_true"] for o in outputs]).numpy() + + df = pd.DataFrame( + { + "status_prob": status_prob, + "status_pred": status_pred, + "status_true": status_true, + "vm_pred": vm_pred, + "vm_true": vm_true, + } + ) + df.to_csv(os.path.join(test_dir, f"{dataset_name}_vld_predictions.csv"), index=False) + + self.test_outputs.clear() + + def predict_step(self, batch, batch_idx, dataloader_idx=0): + output, _ = self.shared_step(batch) + + bus_pred = output["bus"] + status_prob = torch.sigmoid(bus_pred[:, BUS_STATUS_LOGIT_OUT]) + + bus_batch = batch.batch_dict["bus"] + scenario_ids = batch["scenario_id"][bus_batch] + + local_bus_idx = torch.cat( + [torch.arange(c, device=bus_batch.device) for c in torch.bincount(bus_batch)] + ) + + return { + "scenario": scenario_ids.cpu().numpy(), + "bus": local_bus_idx.cpu().numpy(), + "vm_pred": bus_pred[:, VM_OUT].detach().cpu().numpy(), + "status_prob": status_prob.detach().cpu().numpy(), + "status_pred": (status_prob >= 0.5).float().detach().cpu().numpy(), + "status_true": batch.y_dict["bus"][:, BUS_STATUS_TARGET].detach().cpu().numpy(), + } \ No newline at end of file diff --git a/gridfm_graphkit/training/__init__.py b/gridfm_graphkit/training/__init__.py index 15452eec..146b834f 100644 --- a/gridfm_graphkit/training/__init__.py +++ b/gridfm_graphkit/training/__init__.py @@ -4,6 +4,7 @@ LayeredWeightedPhysicsLoss, MaskedBusMSE, MaskedGenMSE, + VLDTopologyLoss ) __all__ = [ @@ -13,4 +14,5 @@ "MaskedBusMSE", "MaskedGenMSE", "LossPerDim", + "VLDTopologyLoss", ] diff --git a/gridfm_graphkit/training/callbacks.py b/gridfm_graphkit/training/callbacks.py index ba7a4049..e755133f 100644 --- a/gridfm_graphkit/training/callbacks.py +++ b/gridfm_graphkit/training/callbacks.py @@ -2,46 +2,10 @@ from pytorch_lightning.utilities.rank_zero import rank_zero_only from lightning.pytorch.loggers import MLFlowLogger import os -import time import torch -class EpochTimerCallback(Callback): - """Records wall-clock duration and iteration rate of every training epoch.""" - - def __init__(self): - self.epoch_times: list[float] = [] - self._epoch_start: float | None = None - self._batch_count: int = 0 - self._last_batch_count: int = 0 - - def on_train_epoch_start(self, trainer, pl_module): - self._epoch_start = time.perf_counter() - self._batch_count = 0 - - def on_train_batch_end(self, trainer, pl_module, outputs, batch, batch_idx): - self._batch_count += 1 - - def on_train_epoch_end(self, trainer, pl_module): - if self._epoch_start is not None: - self.epoch_times.append(time.perf_counter() - self._epoch_start) - self._last_batch_count = self._batch_count - self._epoch_start = None - - @property - def last_epoch_time(self) -> float | None: - return self.epoch_times[-1] if self.epoch_times else None - - @property - def last_epoch_iters_per_sec(self) -> float | None: - t = self.last_epoch_time - if t is None or t == 0 or self._last_batch_count == 0: - return None - return self._last_batch_count / t - - class SaveBestModelStateDict(Callback): - """Persist the best model state_dict according to a monitored validation metric.""" def __init__( self, monitor: str, @@ -53,15 +17,6 @@ def __init__( self.filename = filename self.best_score = float("inf") if mode == "min" else -float("inf") - @staticmethod - def _canonical_state_dict(pl_module): - """Return a state dict with compile wrappers removed from key names.""" - state_dict = pl_module.state_dict() - return { - key.replace("model._orig_mod.", "model."): value - for key, value in state_dict.items() - } - @rank_zero_only def on_validation_end(self, trainer, pl_module): current = trainer.callback_metrics.get(self.monitor) @@ -91,4 +46,4 @@ def on_validation_end(self, trainer, pl_module): # Save the model's state_dict model_path = os.path.join(model_dir, self.filename) - torch.save(self._canonical_state_dict(pl_module), model_path) + torch.save(pl_module.state_dict(), model_path) diff --git a/gridfm_graphkit/training/loss.py b/gridfm_graphkit/training/loss.py index a0521fc2..d7f8257c 100644 --- a/gridfm_graphkit/training/loss.py +++ b/gridfm_graphkit/training/loss.py @@ -19,9 +19,16 @@ PG_OUT, # Generator feature indices PG_H, - # Qg Limits - MIN_QG_H, - MAX_QG_H, + ##################### + ## Indices of features of the VLD task + # Bus feature indices + BUS_BASE_STATUS_H, + BUS_CONT_H, + B_ON, + # Branch feature indices + BRANCH_BASE_STATUS_E, + BRANCH_CONT_E, + ##################### ) @@ -39,7 +46,6 @@ def forward( edge_attr=None, mask=None, model=None, - x_dict=None, ): """ Compute the loss. @@ -76,7 +82,6 @@ def forward( edge_attr=None, mask=None, model=None, - x_dict=None, ): loss = F.mse_loss(pred[mask], target[mask], reduction=self.reduction) return {"loss": loss, "Masked MSE loss": loss.detach()} @@ -84,7 +89,6 @@ def forward( @LOSS_REGISTRY.register("MaskedGenMSE") class MaskedGenMSE(torch.nn.Module): - """Compute MSE on generator targets restricted to generator mask entries.""" def __init__(self, loss_args, args): super().__init__() self.reduction = "mean" @@ -97,7 +101,6 @@ def forward( edge_attr, mask_dict, model=None, - x_dict=None, ): loss = F.mse_loss( pred_dict["gen"][mask_dict["gen"][:, : (PG_H + 1)]], @@ -109,7 +112,6 @@ def forward( @LOSS_REGISTRY.register("MaskedBusMSE") class MaskedBusMSE(torch.nn.Module): - """Compute MSE on selected bus targets, respecting task-specific output columns.""" def __init__(self, loss_args, args): super().__init__() self.reduction = "mean" @@ -123,7 +125,6 @@ def forward( edge_attr, mask_dict, model=None, - x_dict=None, ): if self.args.task == "OptimalPowerFlow": pred_cols = [VM_OUT, VA_OUT, QG_OUT] @@ -161,7 +162,6 @@ def forward( edge_attr=None, mask=None, model=None, - x_dict=None, ): loss = F.mse_loss(pred, target, reduction=self.reduction) return {"loss": loss, "MSE loss": loss.detach()} @@ -195,7 +195,6 @@ def forward( edge_attr=None, mask=None, model=None, - x_dict=None, ): """ Compute the weighted sum of all specified losses. @@ -222,7 +221,6 @@ def forward( edge_attr, mask, model, - x_dict, ) # Assume each loss function returns a dictionary with a "loss" key @@ -239,9 +237,10 @@ def forward( return loss_details + + @LOSS_REGISTRY.register("LayeredWeightedPhysics") class LayeredWeightedPhysicsLoss(BaseLoss): - """Combine intermediate physics residuals using normalized geometric weights.""" def __init__(self, loss_args, args) -> None: super().__init__() self.base_weight = loss_args.base_weight @@ -254,7 +253,6 @@ def forward( edge_attr=None, mask=None, model=None, - x_dict=None, ): total_loss = 0.0 loss_details = {} @@ -282,7 +280,6 @@ def forward( @LOSS_REGISTRY.register("LossPerDim") class LossPerDim(BaseLoss): - """Compute MAE/MSE for one named physical dimension of bus outputs.""" def __init__(self, loss_args, args): super(LossPerDim, self).__init__() self.reduction = "mean" @@ -306,7 +303,6 @@ def forward( edge_attr, mask_dict, model=None, - x_dict=None, ): if self.dim == "VM": temp_pred = pred_dict["bus"][:, VM_OUT] @@ -339,56 +335,166 @@ def forward( f"MAE loss {self.dim}": mae_loss.detach(), } +####################### +@LOSS_REGISTRY.register("VLDTopologyLoss") +class VLDTopologyLoss(BaseLoss): + """ + Topology-first voltage loss detection objective. -@LOSS_REGISTRY.register("QgViolationPenalty") -class QgViolationPenaltyLoss(BaseLoss): - """Standard Mean Squared Error loss.""" + Expected bus tensor layouts: + pred_dict["bus"] : [Vm, Va, Pg, Qg, status_logit] + target_dict["bus"] : [Pd, Qd, Qg, Vm, Va, bus_status_target] + x_dict["bus"] : standard bus features + [bus_base_status, bus_contingency] + edge_attr : standard edge attrs + [branch_base_status, branch_contingency] + """ def __init__(self, loss_args, args): super().__init__() + self.args = args + self.input_state_threshold = getattr(loss_args, "input_state_threshold", 0.5) + self.prediction_threshold = getattr(loss_args, "prediction_threshold", 0.5) + self.topology_weight = getattr(loss_args, "topology_weight", 1.0) + self.target_anchor_weight = getattr(loss_args, "target_anchor_weight", 0.25) + self.off_vm_weight = getattr(loss_args, "off_vm_weight", 1.0) + self.on_vm_weight = getattr(loss_args, "on_vm_weight", 0.5) + self.unreachable_l1_weight = getattr(loss_args, "unreachable_l1_weight", 1.0) + self.topology_confidence_gamma = getattr(loss_args, "topology_confidence_gamma", 10.0) + + @staticmethod + def _build_graph_reachability( + num_bus, + edge_index, + edge_attr, + bus_x, + device, + threshold=0.5, + ): + """ + Build hard reachability labels from base-status and contingency indicators. + + A bus is considered initially available if base_status is on and it is not + directly hit by contingency. A branch is traversable if base branch status + is on and it is not hit by contingency. + """ + bus_base = (bus_x[:, BUS_BASE_STATUS_H] > threshold) + bus_hit = (bus_x[:, BUS_CONT_H] > threshold) + bus_available = bus_base & (~bus_hit) + + src, dst = edge_index + branch_base = (edge_attr[:, BRANCH_BASE_STATUS_E] > threshold) + branch_hit = (edge_attr[:, BRANCH_CONT_E] > threshold) + branch_available = branch_base & (~branch_hit) + + reachable = torch.zeros(num_bus, dtype=torch.bool, device=device) + + # Seeds: all buses that remain available after direct contingency. + seed_nodes = torch.where(bus_available)[0] + if seed_nodes.numel() == 0: + return reachable.float(), bus_available.float() + + reachable[seed_nodes] = True + + changed = True + while changed: + prev = reachable.clone() + active_edges = branch_available & reachable[src] + reachable[dst[active_edges]] = True + changed = not torch.equal(prev, reachable) + + return reachable.float(), bus_available.float() def forward( - self, - pred, - target, - edge_index=None, - edge_attr=None, - mask=None, - model=None, - x_dict=None, + self, + pred_dict, + target_dict, + edge_index_dict, + edge_attr_dict, + mask_dict, + model=None, ): - # --- Qg limit violation mask --- - Qg_pred = pred["bus"][:, QG_OUT] - Qg_max = x_dict["bus"][:, MAX_QG_H] - Qg_min = x_dict["bus"][:, MIN_QG_H] + bus_pred = pred_dict["bus"] + bus_target = target_dict["bus"] + bus_x = model.latest_x_dict["bus"] if hasattr(model, "latest_x_dict") else None + + if bus_x is None: + raise RuntimeError( + "VLDTopologyLoss requires model.latest_x_dict['bus']. " + "Store x_dict on the model inside the forward pass." + ) - max_penalty_mask = (Qg_pred > Qg_max) - min_penalty_mask = (Qg_pred < Qg_min) + if bus_pred.size(1) <= BUS_STATUS_LOGIT_OUT: + raise ValueError( + "VLDTopologyLoss expects bus predictions to include a status logit " + f"at column {BUS_STATUS_LOGIT_OUT}." + ) - mask_PQ = mask["PQ"] # PQ buses - mask_PV = mask["PV"] # PV buses - mask_REF = mask["REF"] # Reference buses + edge_index = edge_index_dict[("bus", "connects", "bus")] + edge_attr = edge_attr_dict[("bus", "connects", "bus")] - loss = 0.0 - # where there are violations, compute penalty loss - Qg_over = F.relu(Qg_pred - Qg_max) # amount above max limit - Qg_under = F.relu(Qg_min - Qg_pred) # amount below min limit + num_bus = bus_pred.size(0) + device = bus_pred.device - Qg_over = Qg_over[max_penalty_mask].mean() - Qg_under = Qg_under[min_penalty_mask].mean() - - if Qg_over!=Qg_over: # replacing nan with 0 - Qg_over = 0.0 - if Qg_under!=Qg_under: # replacing nan with 0 - Qg_under = 0.0 + topo_target, bus_available = self._build_graph_reachability( + num_bus=num_bus, + edge_index=edge_index, + edge_attr=edge_attr, + bus_x=bus_x, + device=device, + threshold=self.input_state_threshold, + ) - penalty_loss = Qg_over + Qg_under - loss += penalty_loss + status_logit = bus_pred[:, BUS_STATUS_LOGIT_OUT] + status_prob = torch.sigmoid(status_logit) - try: - output = {"loss": loss, "Qg Violation Penalty loss": loss.detach()} - except: - output = {"loss": loss, "Qg Violation Penalty loss": loss} + target_status = bus_target[:, BUS_STATUS_TARGET].float() + target_vm = bus_target[:, VM_H].float() + pred_vm = bus_pred[:, VM_OUT].float() - return output + topology_confidence = torch.exp( + -self.topology_confidence_gamma * torch.abs(bus_available - topo_target) + ) + + topology_bce_raw = F.binary_cross_entropy_with_logits( + status_logit, + topo_target, + reduction="none", + ) + topology_bce = (topology_confidence * topology_bce_raw).mean() + target_anchor_bce = F.binary_cross_entropy_with_logits( + status_logit, + target_status, + reduction="mean", + ) + + unreachable_mask = (topo_target < 0.5).float() + reachable_mask = (topo_target >= 0.5).float() + + off_vm_l2 = ((pred_vm ** 2) * unreachable_mask).sum() / unreachable_mask.sum().clamp_min(1.0) + off_vm_l1 = (pred_vm.abs() * unreachable_mask).sum() / unreachable_mask.sum().clamp_min(1.0) + off_vm_loss = off_vm_l2 + self.unreachable_l1_weight * off_vm_l1 + + on_vm_sq = ((pred_vm - target_vm) ** 2) * reachable_mask * status_prob.detach() + on_vm_loss = on_vm_sq.sum() / (reachable_mask * status_prob.detach()).sum().clamp_min(1.0) + + total_loss = ( + self.topology_weight * topology_bce + + self.target_anchor_weight * target_anchor_bce + + self.off_vm_weight * off_vm_loss + + self.on_vm_weight * on_vm_loss + ) + + pred_status = (status_prob >= self.prediction_threshold).float() + topo_acc = (pred_status == topo_target).float().mean() + target_acc = (pred_status == target_status).float().mean() + + return { + "loss": total_loss, + "VLD Topology BCE": topology_bce.detach(), + "VLD Target Anchor BCE": target_anchor_bce.detach(), + "VLD Off Vm Loss": off_vm_loss.detach(), + "VLD On Vm Loss": on_vm_loss.detach(), + "VLD Topology Accuracy": topo_acc.detach(), + "VLD Target Accuracy": target_acc.detach(), + } +####################### \ No newline at end of file diff --git a/gridfm_graphkit/utils/visualization.py b/gridfm_graphkit/utils/visualization.py index 3a8151c8..276d403b 100644 --- a/gridfm_graphkit/utils/visualization.py +++ b/gridfm_graphkit/utils/visualization.py @@ -11,7 +11,6 @@ def visualize_error(data_point, output, node_normalizer): - """Plot node-wise active power residuals on the grid topology.""" loss = PBELoss(visualization=True) loss_dict = loss(