# 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 ```` — 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 ). _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 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'' ) 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'' ) for color, pts in sorted(orphan_pts.items(), key=lambda kc: _draw_order(kc[0])): parts.append(f'') parts.append("".join(f'' for x, y in pts)) parts.append("") parts.append("") 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'') parts.append("".join(f'' for x, y in pts)) parts.append("") parts.append("") 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())