|
| 1 | +👉 [Link](https://github.com/gridfm/gridfm-graphkit/tree/main/examples/notebooks) to the tutorial notebooks on Github |
| 2 | + |
| 3 | +--- |
| 4 | +Contingency analysis is a critical process in power system operations used to assess the impact of potential failures (e.g., line outages) on grid stability and reliability. It helps operators prepare for unexpected events by simulating scenarios such as N-1 or N-2 contingencies, where one or more components are removed from service. This analysis ensures that the grid can continue to operate within safe limits even under stressed conditions. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Dataset Generation and Model Evaluation |
| 9 | + |
| 10 | +The dataset used in this study originates from the Texas transmission grid, which includes approximately 2,000 nodes. Using the contingency mode of the `gridfm-datakit`, we simulated N-2 contingencies by removing up to two transmission lines at a time. For each scenario, we first solved the optimal power flow (OPF) problem to determine the generation dispatch. Then, we applied the contingency by removing lines and re-solved the power flow to observe the resulting grid state. |
| 11 | + |
| 12 | +This process generated around 100,000 unique scenarios. Our model, **GridFMv0.1**, was fine-tuned on this dataset to predict power flow outcomes. For demonstration purposes, we selected a subsample of 10 scenarios. The `gridfm-datakit` also computed DC power flow results, enabling a comparison between GridFMv0.1 predictions and traditional DC power flow estimates, specifically in terms of line loading accuracy. |
| 13 | + |
| 14 | +All predictions are benchmarked against the ground truth obtained from AC power flow simulations. Additionally, we analyze bus voltage violations, which GridFM can predict but are not captured by the DC solver, highlighting GridFM’s enhanced capabilities in modeling grid behavior. |
| 15 | + |
| 16 | + |
| 17 | + |
| 18 | +```python |
| 19 | +from gridfm_graphkit.datasets.postprocessing import ( |
| 20 | + compute_branch_currents_kA, |
| 21 | + compute_loading, |
| 22 | +) |
| 23 | +from gridfm_graphkit.datasets.postprocessing import create_admittance_matrix |
| 24 | +from gridfm_graphkit.utils.utils import compute_cm_metrics |
| 25 | +from gridfm_graphkit.utils.visualization import ( |
| 26 | + plot_mass_correlation_density, |
| 27 | + plot_cm, |
| 28 | + plot_loading_predictions, |
| 29 | + plot_mass_correlation_density_voltage, |
| 30 | +) |
| 31 | + |
| 32 | +import os |
| 33 | +from tqdm import tqdm |
| 34 | +import matplotlib.pyplot as plt |
| 35 | +from sklearn.metrics import f1_score |
| 36 | +import numpy as np |
| 37 | +import pandas as pd |
| 38 | + |
| 39 | +import sys |
| 40 | + |
| 41 | +if "google.colab" in sys.modules: |
| 42 | + try: |
| 43 | + !git clone https://github.com/gridfm/gridfm-graphkit.git |
| 44 | + %cd /content/gridfm-graphkit |
| 45 | + !pip install -e . |
| 46 | + !pip install -e .[dev,test] |
| 47 | + except Exception as e: |
| 48 | + |
| 49 | + print(f"Failed to start Google Collab setup, due to {e}") |
| 50 | +``` |
| 51 | + |
| 52 | +## Load Data |
| 53 | + |
| 54 | +We load both the ground truth and predicted values of the power flow solution. The predictions are generated using the `gridfm-graphkit` CLI: |
| 55 | + |
| 56 | +```bash |
| 57 | +gridfm-graphkit predict ... |
| 58 | +``` |
| 59 | + |
| 60 | +We then merge the datasets using `scenario` and `bus` as keys, allowing us to align the predicted and actual values for each grid state and bus. |
| 61 | + |
| 62 | +```python |
| 63 | +root_pred_folder = "../data/contingency_texas/" |
| 64 | +prediction_dir = "prediction_gridfm01" |
| 65 | +label_plot = "GridFM_v0.1 Fine-tuned" |
| 66 | + |
| 67 | +pf_node_GT = pd.read_csv(os.path.join(root_pred_folder, "pf_node_10_examples.csv")) |
| 68 | +pg_node_predicted = pd.read_csv( |
| 69 | + os.path.join(root_pred_folder, "predictions_10_examples.csv") |
| 70 | +) |
| 71 | + |
| 72 | +branch_idx_removed = pd.read_csv("{}branch_idx_removed.csv".format(root_pred_folder)) |
| 73 | +edge_params = pd.read_csv("{}edge_params.csv".format(root_pred_folder)) |
| 74 | +bus_params = pd.read_csv("{}bus_params.csv".format(root_pred_folder)) |
| 75 | + |
| 76 | +pf_node = pg_node_predicted.merge(pf_node_GT, on=["scenario", "bus"], how="left") |
| 77 | +``` |
| 78 | + |
| 79 | +## Create Admittance matrix |
| 80 | + |
| 81 | +```python |
| 82 | +sn_mva = 100 |
| 83 | +Yf, Yt, Vf_base_kV, Vt_base_kV = create_admittance_matrix( |
| 84 | + bus_params, edge_params, sn_mva |
| 85 | +) |
| 86 | +rate_a = edge_params["rate_a"] |
| 87 | +``` |
| 88 | + |
| 89 | +## Correct voltage predictions for GridFM and DC |
| 90 | + |
| 91 | +```python |
| 92 | +pf_node["Vm_pred_corrected"] = pf_node["VM_pred"] |
| 93 | +pf_node["Va_pred_corrected"] = pf_node["VA_pred"] |
| 94 | + |
| 95 | +pf_node.loc[pf_node.PV == 1, "Vm_pred_corrected"] = pf_node.loc[pf_node.PV == 1, "Vm"] |
| 96 | +pf_node.loc[pf_node.REF == 1, "Va_pred_corrected"] = pf_node.loc[pf_node.REF == 1, "Va"] |
| 97 | + |
| 98 | +pf_node["Vm_dc_corrected"] = pf_node["Vm_dc"] |
| 99 | +pf_node["Va_dc_corrected"] = pf_node["Va_dc"] |
| 100 | + |
| 101 | +pf_node.loc[pf_node.PV == 1, "Vm_dc_corrected"] = pf_node.loc[pf_node.PV == 1, "Vm"] |
| 102 | +pf_node.loc[pf_node.REF == 1, "Va_dc_corrected"] = pf_node.loc[pf_node.REF == 1, "Va"] |
| 103 | +``` |
| 104 | + |
| 105 | +## Compute branch current and line loading |
| 106 | + |
| 107 | +```python |
| 108 | +loadings = [] |
| 109 | +loadings_pred = [] |
| 110 | +loadings_dc = [] |
| 111 | + |
| 112 | +for scenario_idx in tqdm(pf_node.scenario.unique()): |
| 113 | + pf_node_scenario = pf_node[pf_node.scenario == scenario_idx] |
| 114 | + branch_idx_removed_scenario = ( |
| 115 | + branch_idx_removed[branch_idx_removed.scenario == scenario_idx] |
| 116 | + .iloc[:, 1:] |
| 117 | + .values |
| 118 | + ) |
| 119 | + # remove nan |
| 120 | + branch_idx_removed_scenario = branch_idx_removed_scenario[ |
| 121 | + ~np.isnan(branch_idx_removed_scenario) |
| 122 | + ].astype(np.int32) |
| 123 | + V_true = pf_node_scenario["Vm"].values * np.exp( |
| 124 | + 1j * pf_node_scenario["Va"].values * np.pi / 180 |
| 125 | + ) |
| 126 | + V_pred = pf_node_scenario["Vm_pred_corrected"].values * np.exp( |
| 127 | + 1j * pf_node_scenario["Va_pred_corrected"].values * np.pi / 180 |
| 128 | + ) |
| 129 | + V_dc = pf_node_scenario["Vm_dc_corrected"].values * np.exp( |
| 130 | + 1j * pf_node_scenario["Va_dc_corrected"].values * np.pi / 180 |
| 131 | + ) |
| 132 | + If_true, It_true = compute_branch_currents_kA( |
| 133 | + Yf, Yt, V_true, Vf_base_kV, Vt_base_kV, sn_mva |
| 134 | + ) |
| 135 | + If_pred, It_pred = compute_branch_currents_kA( |
| 136 | + Yf, Yt, V_pred, Vf_base_kV, Vt_base_kV, sn_mva |
| 137 | + ) |
| 138 | + If_dc, It_dc = compute_branch_currents_kA( |
| 139 | + Yf, Yt, V_dc, Vf_base_kV, Vt_base_kV, sn_mva |
| 140 | + ) |
| 141 | + |
| 142 | + loading_true = compute_loading(If_true, It_true, Vf_base_kV, Vt_base_kV, rate_a) |
| 143 | + loading_pred = compute_loading(If_pred, It_pred, Vf_base_kV, Vt_base_kV, rate_a) |
| 144 | + loading_dc = compute_loading(If_dc, It_dc, Vf_base_kV, Vt_base_kV, rate_a) |
| 145 | + |
| 146 | + # remove the branches that are removed from loading |
| 147 | + loading_true[branch_idx_removed_scenario] = -1 |
| 148 | + loading_pred[branch_idx_removed_scenario] = -1 |
| 149 | + loading_dc[branch_idx_removed_scenario] = -1 |
| 150 | + |
| 151 | + loadings.append(loading_true) |
| 152 | + loadings_pred.append(loading_pred) |
| 153 | + loadings_dc.append(loading_dc) |
| 154 | + |
| 155 | + |
| 156 | +loadings = np.array(loadings) |
| 157 | +loadings_pred = np.array(loadings_pred) |
| 158 | +loadings_dc = np.array(loadings_dc) |
| 159 | +removed_lines = loadings == -1 |
| 160 | +removed_lines_pred = loadings_pred == -1 |
| 161 | +removed_lines_dc = loadings_dc == -1 |
| 162 | + |
| 163 | + |
| 164 | +# assert the same lines are removed |
| 165 | +assert (removed_lines == removed_lines_pred).all() |
| 166 | +assert (removed_lines == removed_lines_dc).all() |
| 167 | + |
| 168 | +# assert the same number of lines are removed |
| 169 | +assert removed_lines.sum() == removed_lines_pred.sum() |
| 170 | +assert removed_lines.sum() == removed_lines_dc.sum() |
| 171 | + |
| 172 | +overloadings = loadings[removed_lines == False] > 1.0 |
| 173 | +overloadings_pred = loadings_pred[removed_lines == False] > 1.0 |
| 174 | +overloadings_dc = loadings_dc[removed_lines == False] > 1.0 |
| 175 | +``` |
| 176 | + |
| 177 | +## Histogram of true line loadings |
| 178 | + |
| 179 | +```python |
| 180 | +plt.hist(loadings[removed_lines == False], bins=100) |
| 181 | +plt.xlabel("Line Loadings") |
| 182 | +plt.ylabel("Frequency") |
| 183 | +# log scale |
| 184 | +plt.savefig(f"loadings_histogram_{prediction_dir}.png") |
| 185 | +plt.show() |
| 186 | +``` |
| 187 | +<p align="center"> |
| 188 | + <img src="../figs/hist_true_loadings.png" alt="True loading"/> |
| 189 | + <br/> |
| 190 | +</p> |
| 191 | + |
| 192 | + |
| 193 | +## Predicted vs True line loading |
| 194 | + |
| 195 | +```python |
| 196 | +# Valid lines |
| 197 | +valid_mask = removed_lines == False |
| 198 | + |
| 199 | +# Extract valid values |
| 200 | +true_vals = loadings[valid_mask] |
| 201 | +gfm_vals = loadings_pred[valid_mask] |
| 202 | +dc_vals = loadings_dc[valid_mask] |
| 203 | +``` |
| 204 | + |
| 205 | +```python |
| 206 | +plot_mass_correlation_density(true_vals, gfm_vals, prediction_dir, label_plot) |
| 207 | +``` |
| 208 | +<p align="center"> |
| 209 | + <img src="../figs/loading_gridfm.png" alt="Loading gridfm"/> |
| 210 | + <br/> |
| 211 | +</p> |
| 212 | + |
| 213 | +```python |
| 214 | +plot_mass_correlation_density(true_vals, dc_vals, "DC", "DC Solver") |
| 215 | +``` |
| 216 | +<p align="center"> |
| 217 | + <img src="../figs/loading_DC.png" alt="Loading DC"/> |
| 218 | + <br/> |
| 219 | +</p> |
| 220 | + |
| 221 | +```python |
| 222 | +plot_cm(TN_gridfm, FP_gridfm, FN_gridfm, TP_gridfm, prediction_dir, label_plot) |
| 223 | +``` |
| 224 | +<p align="center"> |
| 225 | + <img src="../figs/confusion_gridfm.png" alt="Confusion gridfm"/> |
| 226 | + <br/> |
| 227 | +</p> |
| 228 | + |
| 229 | +```python |
| 230 | +plot_cm(TN_dc, FP_dc, FN_dc, TP_dc, "DC", "DC Solver") |
| 231 | +``` |
| 232 | +<p align="center"> |
| 233 | + <img src="../figs/confusion_DC.png" alt="Confusion DC"/> |
| 234 | + <br/> |
| 235 | +</p> |
| 236 | + |
| 237 | + |
| 238 | +```python |
| 239 | +# Histograms of loadings |
| 240 | +plot_loading_predictions( |
| 241 | + loadings_pred[removed_lines == False], |
| 242 | + loadings_dc[removed_lines == False], |
| 243 | + loadings[removed_lines == False], |
| 244 | + prediction_dir, |
| 245 | + label_plot, |
| 246 | +) |
| 247 | +``` |
| 248 | +<p align="center"> |
| 249 | + <img src="../figs/loading_predictions.png" alt="Loading predictions"/> |
| 250 | + <br/> |
| 251 | +</p> |
| 252 | + |
| 253 | + |
| 254 | +```python |
| 255 | +# create df from loadings |
| 256 | +loadings_df = pd.DataFrame(loadings) |
| 257 | +loadings_df.columns = [f"branch_{i}" for i in range(loadings_df.shape[1])] |
| 258 | + |
| 259 | +loadings_pred_df = pd.DataFrame(loadings_pred) |
| 260 | +loadings_pred_df.columns = [f"branch_{i}" for i in range(loadings_pred_df.shape[1])] |
| 261 | + |
| 262 | +loadings_df["scenario"] = pf_node["scenario"].unique() |
| 263 | +loadings_pred_df["scenario"] = pf_node["scenario"].unique() |
| 264 | + |
| 265 | +# make bar plot of wrongly classified loadings for different bins |
| 266 | +bins = np.arange(0, 2.2, 0.2) |
| 267 | +mse_pred = [] |
| 268 | +mse_dc = [] |
| 269 | +for i in range(len(bins) - 1): |
| 270 | + idx_in_bins = (loadings[removed_lines == False] > bins[i]) & ( |
| 271 | + loadings[removed_lines == False] < bins[i + 1] |
| 272 | + ) |
| 273 | + mse_pred.append( |
| 274 | + np.mean( |
| 275 | + ( |
| 276 | + loadings_pred[removed_lines == False][idx_in_bins] |
| 277 | + - loadings[removed_lines == False][idx_in_bins] |
| 278 | + ) |
| 279 | + ** 2 |
| 280 | + ) |
| 281 | + ) |
| 282 | + mse_dc.append( |
| 283 | + np.mean( |
| 284 | + ( |
| 285 | + loadings_dc[removed_lines == False][idx_in_bins] |
| 286 | + - loadings[removed_lines == False][idx_in_bins] |
| 287 | + ) |
| 288 | + ** 2 |
| 289 | + ) |
| 290 | + ) |
| 291 | + |
| 292 | + |
| 293 | +# labels |
| 294 | +labels = [f"{bins[i]:.1f}-{bins[i + 1]:.1f}" for i in range(len(bins) - 1)] |
| 295 | +plt.bar(labels, mse_pred, label=label_plot, alpha=0.5) |
| 296 | +plt.bar(labels, mse_dc, label="DC", alpha=0.5) |
| 297 | +plt.legend() |
| 298 | +plt.xlabel("Loadings") |
| 299 | +plt.ylabel("MSE") |
| 300 | +# y log scale |
| 301 | +plt.yscale("log") |
| 302 | +# rotate x labels |
| 303 | +plt.xticks(rotation=45) |
| 304 | +plt.savefig(f"loading_mse_{prediction_dir}.png") |
| 305 | +plt.show() |
| 306 | +``` |
| 307 | + |
| 308 | +<p align="center"> |
| 309 | + <img src="../figs/MSE_loading.png" alt="MSE loading"/> |
| 310 | + <br/> |
| 311 | +</p> |
| 312 | + |
| 313 | + |
| 314 | +## Voltage violations |
| 315 | + |
| 316 | +```python |
| 317 | +# merge bus_params["vmax"] and bus_params["vmin"] with pf_node on bus_idx |
| 318 | +pf_node = pd.merge(pf_node, bus_params[["bus", "vmax", "vmin"]], on="bus", how="left") |
| 319 | + |
| 320 | +plot_mass_correlation_density_voltage(pf_node, prediction_dir, label_plot) |
| 321 | +``` |
| 322 | + |
| 323 | +<p align="center"> |
| 324 | + <img src="../figs/correlation_voltage.png" alt="Correlation voltage"/> |
| 325 | + <br/> |
| 326 | +</p> |
0 commit comments