Skip to content

Commit 220d406

Browse files
committed
Helper script (delete, probably)
1 parent b74356d commit 220d406

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python3
2+
"""Plot every validated monitor in a PhasorDynamics study against its reference.
3+
4+
The study file is the single source of truth: each entry in its `monitors` list
5+
that carries a `reference_file` becomes one figure of three panels -- GridKit,
6+
PowerWorld, and their difference -- all sharing a y-scale so the difference is
7+
read against the magnitude it is a difference of. Fault details are taken from
8+
the study and case files so the annotation cannot drift out of step with what
9+
was simulated.
10+
11+
Columns are matched by machine or bus rather than by position, falling back to
12+
position with a warning if the keys do not line up. Rows are matched
13+
positionally, the way compareCSV does it.
14+
15+
Usage:
16+
plot_validation.py <study.json> [--rundir DIR] [--outdir DIR]
17+
18+
Figures are written as <output-file-stem>.png, e.g. WECC240.omega.png.
19+
"""
20+
import argparse
21+
import json
22+
import sys
23+
from pathlib import Path
24+
25+
import matplotlib
26+
27+
matplotlib.use("Agg")
28+
import matplotlib.pyplot as plt
29+
import numpy as np
30+
from matplotlib.patches import Rectangle
31+
32+
SURFACE = "#fcfcfb"
33+
INK = "#0b0b0b"
34+
INK_MUTED = "#52514e"
35+
GRID = "#e3e2df"
36+
SERIES = ("#2a78d6", "#eb6834", "#1baf7a")
37+
38+
# Axis label and description per monitored variable; anything unlisted falls
39+
# back to the variable name in per-unit.
40+
FIELDS = {
41+
"omega": ("Δω (pu)", "generator speed deviation"),
42+
"speed": ("ω (pu)", "generator speed"),
43+
"delta": ("δ (rad)", "generator rotor angle"),
44+
"p": ("P (pu)", "generator real power"),
45+
"q": ("Q (pu)", "generator reactive power"),
46+
"Vm": ("|V| (pu)", "bus voltage magnitude"),
47+
"Va": ("∠V (rad)", "bus voltage angle"),
48+
}
49+
50+
51+
def sci(value, digits=3):
52+
"""Format a value as mantissa x 10^exp for mathtext."""
53+
exp = int(np.floor(np.log10(abs(value)))) if value else 0
54+
return f"{value / 10.0 ** exp:.{digits}f} \\times 10^{{{exp}}}"
55+
56+
57+
def column_key(column):
58+
"""Reduce a GridKit column to the bare machine or bus a reference names.
59+
60+
`Genrou_1032_C_genrou_omega` -> `1032 C`, `Bus_1001_Vm` -> `1001`.
61+
"""
62+
parts = column.split("_")
63+
if len(parts) < 3:
64+
return column.strip()
65+
device_class, body = parts[0], parts[1:-1]
66+
# Component ids repeat the class as a suffix; bus ids do not
67+
if body and body[-1].lower() == device_class.lower():
68+
body = body[:-1]
69+
return " ".join(body) if body else column.strip()
70+
71+
72+
def load(path, key=column_key):
73+
with open(path) as f:
74+
labels = [key(c) for c in f.readline().strip().split(",")[1:]]
75+
data = np.loadtxt(path, delimiter=",", skiprows=1)
76+
return labels, data[:, 0], data[:, 1:]
77+
78+
79+
def fault_description(study, case, study_dir):
80+
"""Where the fault is, how long it lasts, and its impedance."""
81+
on = next(e for e in study["events"] if e["type"] == "fault_on")
82+
off = next(e for e in study["events"] if e["type"] == "fault_off")
83+
84+
faults = [d for d in case["devices"] if d["class"] == "BusFault"]
85+
fault = faults[on["element_id"]]
86+
bus = next(b for b in case["buses"] if b["number"] == fault["ports"]["bus"])
87+
88+
r, x = fault["params"]["R"], fault["params"]["X"]
89+
return on["time"], off["time"], [
90+
f"bus {bus['number']} {bus['name'].strip()} ({bus['params']['kv']:g} kV)",
91+
f"{on['time']:g}–{off['time']:g} s ({(off['time'] - on['time']) * 1e3:g} ms)",
92+
f"Z = {r:g} + j{x:g} pu",
93+
]
94+
95+
96+
def metrics(err, ref, t):
97+
"""eps_rmse = sqrt(dt / (N T)) ||X - Xhat||_2 ; eps_inf = ||X - Xhat||_inf
98+
99+
Each is also normalised by itself applied to the reference. The sqrt(dt/NT)
100+
cancels in the RMSE ratio, leaving a Frobenius ratio; the L-inf ratio is
101+
what DynamicSimulation reports as "Total max" under error_type "relative".
102+
dt comes from the median positive step, since event times appear twice.
103+
"""
104+
steps = np.diff(t)
105+
dt = np.median(steps[steps > 0])
106+
duration = t[-1] - t[0]
107+
n_series = err.shape[1]
108+
109+
return {
110+
"rmse": np.sqrt(dt / (n_series * duration)) * np.linalg.norm(err),
111+
"inf": np.abs(err).max(),
112+
"rel_rmse": np.linalg.norm(err) / np.linalg.norm(ref),
113+
"rel_inf": np.abs(err).max() / np.abs(ref).max(),
114+
"dt": dt,
115+
"duration": duration,
116+
"n_series": n_series,
117+
}
118+
119+
120+
def plot_field(out_csv, ref_csv, png, title, axis_label, fault, quiet=False):
121+
gk_labels, gk_t, gk = load(out_csv)
122+
pw_labels, pw_t, pw = load(ref_csv)
123+
124+
if gk_labels != pw_labels:
125+
if sorted(gk_labels) == sorted(pw_labels):
126+
gk = gk[:, [gk_labels.index(m) for m in pw_labels]]
127+
else:
128+
print(f" note: column keys differ; matching {png.name} by position")
129+
130+
n = min(len(gk_t), len(pw_t))
131+
if len(gk_t) != len(pw_t):
132+
print(f" note: row counts differ ({len(gk_t)} vs {len(pw_t)}); using first {n}")
133+
err = gk[:n] - pw[:n]
134+
m = metrics(err, pw[:n], pw_t[:n])
135+
136+
fault_on, fault_off, fault_lines = fault
137+
138+
fig, axes = plt.subplots(3, 1, figsize=(11, 9.5), sharex=True, dpi=150)
139+
fig.patch.set_facecolor(SURFACE)
140+
141+
panels = (
142+
(gk_t, gk, SERIES[0], "GridKit"),
143+
(pw_t, pw, SERIES[1], "PowerWorld"),
144+
(pw_t[:n], err, SERIES[2], "Difference"),
145+
)
146+
147+
for ax, (t, y, color, panel_title) in zip(axes, panels):
148+
ax.set_facecolor(SURFACE)
149+
ax.axvspan(fault_on, fault_off, color=INK, alpha=0.05, lw=0)
150+
ax.plot(t, y, color=color, lw=0.6, alpha=0.35, solid_joinstyle="round")
151+
ax.set_title(panel_title, loc="left", color=INK, fontsize=12,
152+
fontweight="semibold", pad=8)
153+
ax.grid(True, color=GRID, lw=0.6)
154+
ax.set_axisbelow(True)
155+
for side in ("top", "right"):
156+
ax.spines[side].set_visible(False)
157+
for side in ("left", "bottom"):
158+
ax.spines[side].set_color(GRID)
159+
ax.tick_params(colors=INK_MUTED, labelsize=9)
160+
ax.set_ylabel(axis_label, color=INK_MUTED, fontsize=9)
161+
162+
# The power-of-ten rides on the axis as an offset, not in the label
163+
ax.ticklabel_format(axis="y", style="sci", scilimits=(0, 0), useMathText=True)
164+
ax.yaxis.get_offset_text().set(color=INK_MUTED, fontsize=9)
165+
166+
# The signal panels take the data's own range; the difference is meant to
167+
# read as flat against it, so it keeps the identical span. Where the signal
168+
# already straddles zero all three share limits outright. Where it does not
169+
# (voltage, real power) the difference panel is recentred on zero, which
170+
# holds units-per-pixel equal without dragging an unused decade of empty
171+
# axis into the signal panels.
172+
lo = min(gk.min(), pw.min())
173+
hi = max(gk.max(), pw.max())
174+
pad = 0.08 * (hi - lo)
175+
lo, hi = lo - pad, hi + pad
176+
177+
axes[0].set_ylim(lo, hi)
178+
axes[1].set_ylim(lo, hi)
179+
if lo < 0.0 < hi:
180+
axes[2].set_ylim(lo, hi)
181+
else:
182+
axes[2].set_ylim(-0.5 * (hi - lo), 0.5 * (hi - lo))
183+
184+
axes[2].set_xlim(gk_t[0], gk_t[-1])
185+
axes[2].set_xlabel("time (s)", color=INK_MUTED, fontsize=9)
186+
187+
X_LABEL, X_RMSE, X_INF = 0.578, 0.800, 0.985
188+
Y_HEAD, Y_ABS, Y_REL = 0.945, 0.845, 0.755
189+
190+
axes[2].add_patch(Rectangle(
191+
(0.558, 0.700), 0.440, 0.290, transform=axes[2].transAxes,
192+
facecolor=SURFACE, edgecolor=GRID, lw=0.8, zorder=3,
193+
))
194+
195+
cells = (
196+
(Y_HEAD, "", "$\\epsilon_{\\mathrm{RMSE}}$", "$\\epsilon_{\\infty}$", INK_MUTED),
197+
(Y_ABS, "Absolute", f"${sci(m['rmse'])}$ pu", f"${sci(m['inf'])}$ pu", INK),
198+
(Y_REL, "Relative", f"{m['rel_rmse'] * 100:.3f} %", f"{m['rel_inf'] * 100:.3f} %", INK),
199+
)
200+
for y, row_label, rmse_cell, inf_cell, color in cells:
201+
axes[2].text(X_LABEL, y, row_label, transform=axes[2].transAxes, ha="left",
202+
va="center", color=INK_MUTED, fontsize=10, zorder=4)
203+
for x, cell in ((X_RMSE, rmse_cell), (X_INF, inf_cell)):
204+
axes[2].text(x, y, cell, transform=axes[2].transAxes, ha="right",
205+
va="center", color=color, fontsize=10, zorder=4)
206+
207+
# Selective direct label: where the worst disagreement happens. The zero
208+
# line sits anywhere from mid-panel to the floor depending on the field, so
209+
# lead away from whichever edge the point is near.
210+
r, c = np.unravel_index(np.abs(err).argmax(), err.shape)
211+
y0, y1 = axes[2].get_ylim()
212+
x0, x1 = axes[2].get_xlim()
213+
below = (err[r, c] - y0) / (y1 - y0) < 0.5
214+
left = (pw_t[r] - x0) / (x1 - x0) > 0.6
215+
axes[2].annotate(
216+
f"max at {pw_labels[c]}, t = {pw_t[r]:.3f} s",
217+
xy=(pw_t[r], err[r, c]),
218+
xytext=(-18 if left else 18, 46 if below else -46),
219+
textcoords="offset points",
220+
color=INK, fontsize=9,
221+
ha="right" if left else "left",
222+
va="bottom" if below else "top",
223+
bbox=dict(facecolor=SURFACE, edgecolor="none", alpha=0.85, pad=2),
224+
arrowprops=dict(arrowstyle="-", color=INK_MUTED, lw=0.8),
225+
)
226+
227+
fig.text(0.055, 0.978, title, color=INK, fontsize=15,
228+
fontweight="semibold", va="top")
229+
fig.text(0.055, 0.947, f"{m['n_series']} series · {n} samples",
230+
color=INK_MUTED, fontsize=9.5, va="top")
231+
232+
fig.text(0.985, 0.978, "Fault", color=INK, fontsize=10,
233+
fontweight="semibold", ha="right", va="top")
234+
for i, line in enumerate(fault_lines):
235+
fig.text(0.985, 0.951 - i * 0.0225, line, color=INK_MUTED, fontsize=9.5,
236+
ha="right", va="top")
237+
238+
fig.tight_layout(rect=(0, 0, 1, 0.915))
239+
fig.savefig(png, facecolor=SURFACE)
240+
plt.close(fig)
241+
242+
if not quiet:
243+
print(f"wrote {png}")
244+
print(f" eps_rmse : {m['rmse']:.6e} pu ({m['rel_rmse'] * 100:.3f} %)")
245+
print(f" eps_inf : {m['inf']:.6e} pu ({m['rel_inf'] * 100:.3f} %)"
246+
f" at {pw_labels[c]}, t = {pw_t[r]:.3f} s")
247+
248+
249+
def main():
250+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
251+
parser.add_argument("study", type=Path, help="study (solver) JSON file")
252+
parser.add_argument("--rundir", type=Path,
253+
help="directory holding the monitor output files "
254+
"(default: the study file's directory)")
255+
parser.add_argument("--outdir", type=Path,
256+
help="directory to write figures to "
257+
"(default: the study file's directory)")
258+
args = parser.parse_args()
259+
260+
study_dir = args.study.resolve().parent
261+
rundir = args.rundir or study_dir
262+
outdir = args.outdir or study_dir
263+
outdir.mkdir(parents=True, exist_ok=True)
264+
265+
study = json.loads(args.study.read_text())
266+
case = json.loads((study_dir / study["system_model_file"]).read_text())
267+
fault = fault_description(study, case, study_dir)
268+
case_name = case.get("header", {}).get("case_name", args.study.stem)
269+
270+
validated = [m for m in study.get("monitors", []) if m.get("reference_file")]
271+
if not validated:
272+
sys.exit(f"{args.study}: no monitors carry a reference_file")
273+
274+
for mon in validated:
275+
out_csv = rundir / mon["file_name"]
276+
ref_csv = study_dir / mon["reference_file"]
277+
if not out_csv.exists():
278+
print(f"skipping {mon['file_name']}: not found in {rundir}")
279+
continue
280+
281+
# `Genrou_*_omega` -> `omega`; the variable names the field
282+
include = mon.get("include", "")
283+
patterns = [include] if isinstance(include, str) else include
284+
var = patterns[0].split("_")[-1] if patterns else Path(out_csv).stem
285+
axis_label, description = FIELDS.get(var, (f"{var} (pu)", var))
286+
287+
plot_field(out_csv, ref_csv, outdir / (out_csv.stem + ".png"),
288+
f"{case_name} · {description}", axis_label, fault)
289+
290+
291+
if __name__ == "__main__":
292+
main()

0 commit comments

Comments
 (0)