import argparse
import json
import math
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
import pypowsybl.loadflow as lf
import pypowsybl.network as pn
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
BASE = REPO_ROOT / "tests/data/CGMES-Test-Configurations/v3.0/MicroGrid/MicroGid-BaseCase"
BE_DIR = BASE / "MicroGrid-BE-MAS"
CIM_NS = "http://iec.ch/TC57/CIM100#"
RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
def run_gridoxide() -> dict:
proc = subprocess.run(
["cargo", "run", "--release", "--example", "cgmes_microgrid_be_dump", "--features", "cgmes"],
cwd=REPO_ROOT, capture_output=True, text=True, check=True,
)
last_line = proc.stdout.strip().splitlines()[-1]
entries = json.loads(last_line)
return {e["mrid"].lstrip("_"): e for e in entries}
def build_be_mas_zip(tmp_dir: Path) -> Path:
eq_bd = BASE / "MicroGrid-BD-MAS/20171002T0930Z_ENTSO-E_EQ_BD_2.xml"
files = [
eq_bd,
BE_DIR / "20210325T1530Z_1D_BE_EQ_001.xml",
BE_DIR / "20210325T1530Z_1D_BE_SSH_001.xml",
BE_DIR / "20210325T1530Z_1D_BE_TP_001.xml",
BE_DIR / "20210325T1530Z_1D_BE_SV_001.xml",
]
zip_path = tmp_dir / "microgrid_be.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in files:
zf.write(f, arcname=f.name)
return zip_path
def parse_tn_containers() -> dict[str, str]:
tp_path = BE_DIR / "20210325T1530Z_1D_BE_TP_001.xml"
tree = ET.parse(tp_path)
out = {}
for tn in tree.getroot().findall(f"{{{CIM_NS}}}TopologicalNode"):
tn_id = tn.get(f"{{{RDF_NS}}}ID", "").lstrip("_")
container = tn.find(f"{{{CIM_NS}}}TopologicalNode.ConnectivityNodeContainer")
if tn_id and container is not None:
container_id = container.get(f"{{{RDF_NS}}}resource", "").lstrip("#_")
out[tn_id] = container_id
return out
def run_pypowsybl(zip_path: Path, slack_bus_id: str | None = None) -> "pandas.DataFrame":
network = pn.load(str(zip_path))
provider_parameters = {"slackBusesIds": slack_bus_id} if slack_bus_id else {}
params = lf.Parameters(
voltage_init_mode=lf.VoltageInitMode.UNIFORM_VALUES,
distributed_slack=False,
use_reactive_limits=False,
phase_shifter_regulation_on=False,
transformer_voltage_control_on=False,
connected_component_mode=lf.ConnectedComponentMode.MAIN,
provider_parameters=provider_parameters,
)
result = lf.run_ac(network, parameters=params)
if result[0].status != lf.ComponentStatus.CONVERGED:
raise RuntimeError(f"pypowsybl load flow did not converge: {result[0].status_text}")
return network.get_buses()
def match_buses(gridoxide: dict, tn_to_container: dict, powsybl_buses) -> list[tuple[str, float, float, str]]:
by_container: dict[str, list[str]] = {}
for tn_id, container_id in tn_to_container.items():
if tn_id in gridoxide:
by_container.setdefault(container_id, []).append(tn_id)
powsybl_by_container: dict[str, list[tuple[str, float]]] = {}
for bus_id, v_mag in powsybl_buses["v_mag"].items():
container_id, _, _idx = bus_id.rpartition("_")
powsybl_by_container.setdefault(container_id, []).append((bus_id, v_mag))
matches = []
for container_id, tn_ids in by_container.items():
candidates = list(powsybl_by_container.get(container_id, []))
for tn_id in tn_ids:
gx_kv = gridoxide[tn_id]["voltage_mag"] * gridoxide[tn_id]["u_rated"] / 1e3
if not candidates:
continue
best = min(candidates, key=lambda c: abs(c[1] - gx_kv))
candidates.remove(best)
best_bus_id, best_v_mag = best
matches.append((tn_id, gx_kv, best_v_mag, best_bus_id))
return matches
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tol", type=float, default=0.01, help="max relative voltage-magnitude deviation (default 0.01)")
parser.add_argument("--angle-tol", type=float, default=0.3, help="max slack-relative angle deviation in degrees (default 0.3)")
args = parser.parse_args()
if not BE_DIR.exists():
print(f"skipping: {BE_DIR} not found — run "
"`git submodule update --init tests/data/CGMES-Test-Configurations`", file=sys.stderr)
return
gridoxide = run_gridoxide()
tn_to_container = parse_tn_containers()
slack_tn_id = next(tn_id for tn_id, e in gridoxide.items() if e["bus_type"] == "Slack")
with tempfile.TemporaryDirectory() as tmp:
zip_path = build_be_mas_zip(Path(tmp))
unforced_buses = run_pypowsybl(zip_path)
unforced_matches = match_buses(gridoxide, tn_to_container, unforced_buses)
slack_bus_id = next(bus_id for tn_id, _, _, bus_id in unforced_matches if tn_id == slack_tn_id)
powsybl_buses = run_pypowsybl(zip_path, slack_bus_id=slack_bus_id)
matches = match_buses(gridoxide, tn_to_container, powsybl_buses)
assert len(matches) == len(gridoxide), \
f"expected to match all {len(gridoxide)} TopologicalNodes, matched {len(matches)}"
gx_slack_ang_deg = math.degrees(gridoxide[slack_tn_id]["voltage_ang"])
ps_slack_ang_deg = powsybl_buses.loc[slack_bus_id, "v_angle"]
print(f"{'TopologicalNode':<38} {'gridoxide (kV)':>15} {'pypowsybl (kV)':>15} {'v diff':>8}"
f" {'gx angle':>9} {'ps angle':>9} {'a diff':>7}")
worst_v = 0.0
worst_a = 0.0
failures = []
for tn_id, gx_kv, ps_kv, bus_id in sorted(matches, key=lambda m: m[0]):
v_diff = abs(gx_kv - ps_kv) / gx_kv
gx_ang_deg = (math.degrees(gridoxide[tn_id]["voltage_ang"]) - gx_slack_ang_deg + 180) % 360 - 180
ps_ang_deg = (powsybl_buses.loc[bus_id, "v_angle"] - ps_slack_ang_deg + 180) % 360 - 180
a_diff = abs((gx_ang_deg - ps_ang_deg + 180) % 360 - 180)
worst_v = max(worst_v, v_diff)
worst_a = max(worst_a, a_diff)
print(f"{tn_id:<38} {gx_kv:>15.3f} {ps_kv:>15.3f} {v_diff:>7.2%}"
f" {gx_ang_deg:>8.2f}° {ps_ang_deg:>8.2f}° {a_diff:>6.2f}°")
if v_diff >= args.tol:
failures.append(f"{tn_id} voltage deviates {v_diff:.2%}, exceeding tolerance {args.tol:.0%}")
if a_diff >= args.angle_tol:
failures.append(f"{tn_id} angle deviates {a_diff:.2f}°, exceeding tolerance {args.angle_tol:.2f}°")
print(f"\nworst voltage deviation: {worst_v:.2%} (tolerance {args.tol:.0%})")
print(f"worst angle deviation: {worst_a:.2f}° (tolerance {args.angle_tol:.2f}°)")
if failures:
for msg in failures:
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(1)
print("OK: gridoxide and pypowsybl agree within tolerance on every bus")
if __name__ == "__main__":
main()