Co-Study4Grid / scripts /game_mode /gen_network_previews.py
github-actions[bot]
Deploy f7b8bf6
1c730d1
Raw
History Blame Contribute Delete
12.8 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Generate the Game Mode config-screen network preview maps.
Each difficulty tier plays on one grid; the landing page shows a small map of
that grid so a participant can see the network they are about to work on. This
renders the same thing the app's "Network (N)" NAD shows fully zoomed out —
voltage levels positioned from ``grid_layout.json`` with the lines between them
drawn as edges, the >= 350 kV backbone red and everything below green — into a
compact, dependency-free SVG committed under ``frontend/public/game/``.
The line topology (which two voltage levels each line/transformer connects) is
read straight from ``network.xiidm`` — no pypowsybl needed, just the
``voltageLevelId1`` / ``voltageLevelId2`` attributes. When the network file is
absent (e.g. a grid whose ``network.xiidm.zip`` is an un-smudged Git-LFS
pointer in this checkout), the map degrades to a node-only scatter, and running
this again where the file IS present upgrades it to the full edge map.
Re-run after a grid's layout / network changes (and on any host that has the
network files smudged — the Space Docker build extracts them):
python scripts/game_mode/gen_network_previews.py
"""
from __future__ import annotations
import base64
import gzip
import json
import re
import zipfile
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_OUT_DIR = _REPO_ROOT / "frontend" / "public" / "game"
# (tier, grid directory, output filename). Mirrors DIFFICULTY_TIERS in
# frontend/src/game/presets.ts.
_GRIDS = [
("medium", "data/pypsa_eur_eur220_225_380_400", "preview-medium.svg"),
("high", "data/pypsa_eur_fr225_400", "preview-high.svg"),
# All 4 France THT grids share the RTE7000 topology, so one preview map
# (from any of them) represents the whole family.
("tht", "data/rte7000_tht/grids/grid_e4e81e29", "preview-tht.svg"),
# The 4 MATPOWER RTE cases are 4 operating points of one grid family, so
# one map covers them too. case6515rte is the largest, hence the fullest
# picture of the family's topology.
("matpower", "data/rte_matpower/grids/grid_6be3a179", "preview-matpower.svg"),
]
# Voltage colouring: the >= 350 kV backbone (380 / 400 kV) is red, everything
# below (220 / 225 kV) is green. Each voltage level's nominal kV is read from
# the network's ``<voltageLevel nominalV=...>`` — the substation IDs (RTE codes
# like ``1ARGIP7``) do NOT carry a 3-digit kV, so a regex over the id would put
# the whole map on one colour.
#
# The two shades are chosen for red-green colour-blind viewers: a warm
# vermillion vs a cool blue-leaning teal that separate on the BLUE channel and
# on LUMINANCE (the teal is markedly darker), not only on the red-green axis
# that deuteranopes/protanopes cannot use. As a belt-and-braces redundant cue —
# so the backbone is legible even in total colour-blindness / greyscale — the
# HV backbone is also drawn THICKER and fully opaque, the LV layer thinner and
# slightly translucent. Both read on the light AND dark config-screen card
# (the SVG is a themeless <img>).
_HV_THRESHOLD_KV = 350
_HV_COLOR = "#d55e00" # >= 350 kV — warm vermillion "red"
_LV_COLOR = "#166a5a" # < 350 kV — dark teal "green" (darker + bluer than the
# old #009e73, for a wider luminance + blue-channel gap)
_WIDTH = 900
_PADDING = 24
_NODE_RADIUS = 1.6
_HV_EDGE_WIDTH = 2.3 # backbone drawn heavier — a non-colour cue on top of hue
_LV_EDGE_WIDTH = 1.2
_MAX_NODES = 2600 # stride-sample nodes in the fallback (no-edge) scatter
_KV_RE = re.compile(r"-(\d{3})(?:\D|$)")
_BRANCH_TAG_RE = re.compile(r"<(?:\w+:)?(?:line|twoWindingsTransformer)\b([^>]*)>")
_V1_RE = re.compile(r'voltageLevelId1="([^"]+)"')
_V2_RE = re.compile(r'voltageLevelId2="([^"]+)"')
_VL_TAG_RE = re.compile(
r'<(?:\w+:)?voltageLevel\b[^>]*\bid="([^"]+)"[^>]*\bnominalV="([^"]+)"')
def _nominal_kv_map(xml: str) -> dict[str, int]:
"""{voltageLevelId: nominal kV} from the network's <voltageLevel> tags."""
out: dict[str, int] = {}
for vid, v in _VL_TAG_RE.findall(xml):
try:
out[vid] = round(float(v))
except ValueError:
continue
return out
def _kv_of(node_id: str, vmap: dict[str, int] | None = None) -> int:
if vmap is not None and node_id in vmap:
return vmap[node_id]
m = _KV_RE.search(node_id)
return int(m.group(1)) if m else 0
def _color_of(kv: int) -> str:
return _HV_COLOR if kv >= _HV_THRESHOLD_KV else _LV_COLOR
def _draw_order(color: str) -> int:
"""Draw the LV (green) layer first so the HV (red) backbone sits on top."""
return 1 if color == _HV_COLOR else 0
def _load_network_xml(grid_dir: Path) -> str | None:
"""Network XIIDM text, or None when it isn't available in this checkout."""
direct = grid_dir / "network.xiidm"
if direct.is_file():
return direct.read_text(encoding="utf-8", errors="replace")
zipped = grid_dir / "network.xiidm.zip"
if zipped.is_file():
# An un-smudged Git-LFS pointer is a tiny text file — not a real zip.
if zipped.read_bytes()[:40].startswith(b"version https://git-lfs"):
return None
try:
with zipfile.ZipFile(zipped) as zf:
name = next((n for n in zf.namelist() if n.endswith(".xiidm")), None)
if name:
return zf.read(name).decode("utf-8", errors="replace")
except zipfile.BadZipFile:
return None
# France THT grids ship compressed + text-encoded as network.xiidm.gz.b64.
b64 = grid_dir / "network.xiidm.gz.b64"
if b64.is_file():
try:
return gzip.decompress(base64.b64decode(b64.read_bytes())).decode("utf-8", errors="replace")
except (ValueError, OSError):
return None
return None
def _edges(xml: str) -> list[tuple[str, str]]:
"""(voltageLevelId1, voltageLevelId2) for every line + 2-winding transformer."""
out: list[tuple[str, str]] = []
for m in _BRANCH_TAG_RE.finditer(xml):
attrs = m.group(1)
a, b = _V1_RE.search(attrs), _V2_RE.search(attrs)
if a and b and a.group(1) != b.group(1):
out.append((a.group(1), b.group(1)))
return out
def _projector(layout: dict):
xs = [c[0] for c in layout.values() if isinstance(c, (list, tuple)) and len(c) >= 2]
ys = [c[1] for c in layout.values() if isinstance(c, (list, tuple)) and len(c) >= 2]
min_x, max_x, min_y, max_y = min(xs), max(xs), min(ys), max(ys)
span_x = (max_x - min_x) or 1.0
span_y = (max_y - min_y) or 1.0
inner_w = _WIDTH - 2 * _PADDING
inner_h = inner_w * (span_y / span_x)
height = inner_h + 2 * _PADDING
def project(node_id: str):
coords = layout.get(node_id)
if not (isinstance(coords, (list, tuple)) and len(coords) >= 2):
return None
x, y = coords[0], coords[1]
px = _PADDING + (x - min_x) / span_x * inner_w
# The layout's y already increases SOUTHWARD (north = smaller y — LILLE
# sits at a large negative y, TOULOUSE at a large positive one), which
# is the same sense as the screen's y-down axis, so map it directly. A
# flip here would render the network upside down.
py = _PADDING + (y - min_y) / span_y * inner_h
return round(px, 1), round(py, 1)
return project, height
def _svg_header(height: float) -> str:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {round(height, 1)}" '
f'role="img" aria-label="Network map" preserveAspectRatio="xMidYMid meet">'
)
def _build_edge_map(layout: dict, edges: list[tuple[str, str]],
vmap: dict[str, int] | None = None) -> str:
project, height = _projector(layout)
# Group edges by colour (max kV of the two endpoints → HV backbone on top).
# The edges carry the whole structure — no separate node layer, which would
# roughly double the file for no visible gain (the endpoints are where the
# lines already meet).
edge_paths: dict[str, list[str]] = {}
connected: set[str] = set()
for vl1, vl2 in edges:
p1, p2 = project(vl1), project(vl2)
if p1 is None or p2 is None:
continue
connected.add(vl1)
connected.add(vl2)
color = _color_of(max(_kv_of(vl1, vmap), _kv_of(vl2, vmap)))
edge_paths.setdefault(color, []).append(f"M{p1[0]} {p1[1]}L{p2[0]} {p2[1]}")
# Only VLs with no line at all get a dot (so islanded substations don't
# vanish); everything else is implied by its edges.
orphan_pts: dict[str, list[tuple[float, float]]] = {}
for node_id in layout:
if node_id in connected:
continue
p = project(node_id)
if p is None:
continue
orphan_pts.setdefault(_color_of(_kv_of(node_id, vmap)), []).append(p)
parts = [_svg_header(height)]
for color, segs in sorted(edge_paths.items(), key=lambda kc: _draw_order(kc[0])):
# Redundant (non-colour) encoding: the HV backbone is drawn thicker and
# fully opaque so it stands out by weight too, not by hue alone.
is_hv = color == _HV_COLOR
width = _HV_EDGE_WIDTH if is_hv else _LV_EDGE_WIDTH
opacity = 0.95 if is_hv else 0.7
parts.append(
f'<path d="{"".join(segs)}" fill="none" stroke="{color}" '
f'stroke-width="{width}" stroke-opacity="{opacity}" '
f'stroke-linecap="round" stroke-linejoin="round"/>'
)
for color, pts in sorted(orphan_pts.items(), key=lambda kc: _draw_order(kc[0])):
parts.append(f'<g fill="{color}" fill-opacity="0.9">')
parts.append("".join(f'<circle cx="{x}" cy="{y}" r="{_NODE_RADIUS}"/>' for x, y in pts))
parts.append("</g>")
parts.append("</svg>")
return "".join(parts)
def _build_node_scatter(layout: dict, vmap: dict[str, int] | None = None) -> str:
"""Fallback when the network topology isn't available: nodes only."""
project, height = _projector(layout)
ids = list(layout)
stride = max(1, len(ids) // _MAX_NODES)
by_color: dict[str, list[tuple[float, float]]] = {}
for node_id in ids[::stride]:
p = project(node_id)
if p is None:
continue
by_color.setdefault(_color_of(_kv_of(node_id, vmap)), []).append(p)
parts = [_svg_header(height)]
for color, pts in sorted(by_color.items(), key=lambda kc: _draw_order(kc[0])):
parts.append(f'<g fill="{color}" fill-opacity="0.82">')
parts.append("".join(f'<circle cx="{x}" cy="{y}" r="2.3"/>' for x, y in pts))
parts.append("</g>")
parts.append("</svg>")
return "".join(parts)
def main() -> int:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
for tier, grid_rel, out_name in _GRIDS:
grid_dir = _REPO_ROOT / grid_rel
layout_path = grid_dir / "grid_layout.json"
if not layout_path.is_file():
print(f"skip {tier}: {grid_rel}/grid_layout.json missing")
continue
layout = json.loads(layout_path.read_text(encoding="utf-8"))
out_path = _OUT_DIR / out_name
xml = _load_network_xml(grid_dir)
if xml:
vmap = _nominal_kv_map(xml)
edges = _edges(xml)
svg = _build_edge_map(layout, edges, vmap)
hv = sum(1 for v in vmap.values() if v >= _HV_THRESHOLD_KV)
out_path.write_text(svg, encoding="utf-8")
print(f"{tier}: {len(layout)} nodes ({hv} HV ≥{_HV_THRESHOLD_KV}kV) → "
f"{out_path.relative_to(_REPO_ROOT)} "
f"[edge map, {len(edges)} lines, {len(svg) // 1024} KB]")
elif out_path.is_file():
# Network file is an un-smudged LFS pointer here. Never downgrade a
# committed edge map to a node scatter — re-run on a host where
# network.xiidm is smudged to (re)generate the real edge map.
print(f"{tier}: network file unavailable (LFS) — keeping existing "
f"{out_path.relative_to(_REPO_ROOT)}")
else:
svg = _build_node_scatter(layout)
out_path.write_text(svg, encoding="utf-8")
print(f"{tier}: {len(layout)} nodes → {out_path.relative_to(_REPO_ROOT)} "
f"[node scatter fallback — network file unavailable, {len(svg) // 1024} KB]")
return 0
if __name__ == "__main__":
raise SystemExit(main())