Spaces:
Paused
Paused
File size: 12,813 Bytes
1c730d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | # 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())
|