From 463a51cf19359f2f6e61439c51ada450d3b3ecfe Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 22 Jul 2026 14:41:55 +0000 Subject: [PATCH] Fix France THT study load + add THT network preview The France THT game grids ship the network compressed + text-encoded as network.xiidm.gz.b64 (Git-LFS-free). The Docker build decodes them, but a build/dev checkout that skipped that step left only the .gz.b64, so pypowsybl's pn.load failed with 'Unsupported file format' and studies would not load. - network_service: decode a companion network.xiidm.gz.b64 on demand in _resolve_network_file (caches the decoded .xiidm next to it, falls back to a temp dir when the grid dir is read-only). Adds backend tests. - gen_network_previews: render preview-tht.svg for the France THT grid (reads the .gz.b64 when the raw .xiidm is absent). Wire it into the landing page so France THT mode shows its network map, like the European demo does. - Update the frontend/backend tests for the new THT preview + fallback. Signed-off-by: marota --- expert_backend/services/network_service.py | 30 ++++++++++ expert_backend/tests/test_network_service.py | 58 ++++++++++++++++++++ frontend/public/game/preview-tht.svg | 1 + frontend/src/game/GameConfigScreen.test.tsx | 2 + frontend/src/game/GameConfigScreen.tsx | 33 +++++++++-- scripts/game_mode/gen_network_previews.py | 12 ++++ 6 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 frontend/public/game/preview-tht.svg diff --git a/expert_backend/services/network_service.py b/expert_backend/services/network_service.py index a9c0e008..90cb1905 100644 --- a/expert_backend/services/network_service.py +++ b/expert_backend/services/network_service.py @@ -6,6 +6,8 @@ # This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import pypowsybl.network as pn +import base64 +import gzip import logging import os import tempfile @@ -130,6 +132,30 @@ def _extract_network_zip(self, zip_path: str) -> str: logger.info("Decompressed %s -> %s", zip_path, out_path) return out_path + def _decode_network_gz_b64(self, b64_path: str) -> str: + """Decode a ``*.xiidm.gz.b64`` (gzip + base64 text) companion to its raw + ``*.xiidm`` and return that path. The France THT game grids ship this way + (small + Git-LFS-free); the image build normally decodes them, but decode + here too so the grids load in local dev / any build that skipped the step. + Targets the file's own directory (cached for reuse); falls back to a temp + dir if that directory is read-only. + """ + with open(b64_path, 'rb') as f: + raw = gzip.decompress(base64.b64decode(f.read())) + out_path = b64_path[:-len('.gz.b64')] # network.xiidm.gz.b64 -> network.xiidm + if os.path.isfile(out_path): + return out_path # already decoded — reuse + try: + with open(out_path, 'wb') as f: + f.write(raw) + except OSError: + tmp_dir = tempfile.mkdtemp(prefix='cs4g_net_') + out_path = os.path.join(tmp_dir, os.path.basename(out_path)) + with open(out_path, 'wb') as f: + f.write(raw) + logger.info("Decoded %s -> %s", b64_path, out_path) + return out_path + def _resolve_network_file(self, network_path: str) -> str: """Resolve a network path to a loadable file, transparently decompressing a zip when the path is (or only exists as) a ``.zip``. @@ -161,6 +187,10 @@ def _resolve_network_file(self, network_path: str) -> str: if os.path.isfile(candidate): return self._extract_network_zip(candidate) + # …or a companion ``.gz.b64`` (the France THT game grids ship this way). + if os.path.isfile(network_path + '.gz.b64'): + return self._decode_network_gz_b64(network_path + '.gz.b64') + return network_path def load_network(self, network_path: str) -> dict: diff --git a/expert_backend/tests/test_network_service.py b/expert_backend/tests/test_network_service.py index ffdff710..5ef7b19f 100644 --- a/expert_backend/tests/test_network_service.py +++ b/expert_backend/tests/test_network_service.py @@ -156,6 +156,64 @@ def test_load_network_directory_no_xiidm(self, tmp_path): service.load_network(str(tmp_path)) +class TestLoadNetworkGzB64: + """Decode of a ``network.xiidm.gz.b64`` companion (the France THT game grids + ship the network compressed + text-encoded so it rides Git without LFS; the + Docker build normally decodes it, but the backend also decodes on demand so + the grids load in local dev / any build that skipped the decode step).""" + + @staticmethod + def _make_gz_b64(dir_path, member="network.xiidm", xml=b""): + import base64 + import gzip + b64_path = os.path.join(str(dir_path), member + ".gz.b64") + with open(b64_path, "wb") as f: + f.write(base64.b64encode(gzip.compress(xml))) + return b64_path + + @patch("expert_backend.services.network_service.pn") + def test_loads_xiidm_when_only_companion_gz_b64_exists(self, mock_pn, tmp_path): + self._make_gz_b64(tmp_path) + requested = os.path.join(str(tmp_path), "network.xiidm") # absent on disk + assert not os.path.exists(requested) + mock_pn.load.return_value = MagicMock(id="g") + + NetworkService().load_network(requested) + + loaded = mock_pn.load.call_args[0][0] + assert loaded.endswith("network.xiidm") + assert os.path.isfile(loaded) + assert open(loaded, "rb").read() == b"" + + def test_resolve_decodes_and_is_cached(self, tmp_path): + self._make_gz_b64(tmp_path) + requested = os.path.join(str(tmp_path), "network.xiidm") + svc = NetworkService() + first = svc._resolve_network_file(requested) + # Second resolve reuses the already-decoded .xiidm (no re-decode). + second = svc._resolve_network_file(requested) + assert first == second and os.path.isfile(first) + assert first.endswith("network.xiidm") + + def test_decode_falls_back_to_tempdir_when_grid_dir_readonly(self, tmp_path): + # Force the in-place write to fail (a read-only grid dir can't be + # simulated as root, so raise OSError on the target open instead). + b64_path = self._make_gz_b64(tmp_path) + out_target = os.path.join(str(tmp_path), "network.xiidm") + real_open = open + + def fake_open(path, *a, **k): + if os.path.abspath(path) == os.path.abspath(out_target) and "w" in (a[0] if a else k.get("mode", "")): + raise OSError("read-only") + return real_open(path, *a, **k) + + with patch("builtins.open", side_effect=fake_open): + out = NetworkService()._decode_network_gz_b64(b64_path) + assert os.path.isfile(out) + assert real_open(out, "rb").read() == b"" + assert not out.startswith(str(tmp_path)) # landed in a temp dir + + class TestGetDisconnectableElements: def test_network_not_loaded(self): service = NetworkService() diff --git a/frontend/public/game/preview-tht.svg b/frontend/public/game/preview-tht.svg new file mode 100644 index 00000000..6775a004 --- /dev/null +++ b/frontend/public/game/preview-tht.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/game/GameConfigScreen.test.tsx b/frontend/src/game/GameConfigScreen.test.tsx index 22d3e309..6834892b 100644 --- a/frontend/src/game/GameConfigScreen.test.tsx +++ b/frontend/src/game/GameConfigScreen.test.tsx @@ -91,6 +91,8 @@ describe('GameConfigScreen — France THT mode', () => { expect(screen.getByTestId('game-tht-difficulty')).toBeInTheDocument(); expect(screen.getByTestId('game-tht-count')).toBeInTheDocument(); expect(screen.getByTestId('game-tht-summary')).toBeInTheDocument(); + // France THT shows its own network map (the shared RTE7000 backbone). + expect(screen.getByTestId('game-tht-preview')).toBeInTheDocument(); // The demo studies list + per-network preview belong to demo mode only. expect(screen.queryByTestId('game-studies-summary')).not.toBeInTheDocument(); expect(screen.queryByTestId('game-network-preview')).not.toBeInTheDocument(); diff --git a/frontend/src/game/GameConfigScreen.tsx b/frontend/src/game/GameConfigScreen.tsx index 5dd85d70..8a5fc60b 100644 --- a/frontend/src/game/GameConfigScreen.tsx +++ b/frontend/src/game/GameConfigScreen.tsx @@ -31,6 +31,10 @@ const PREVIEW_SRC: Record = { high: '/game/preview-high.svg', }; +// France THT map (the RTE7000 400/225 kV backbone). All four snapshots share +// the same topology, so one map represents every difficulty tier. +const RTE7000_PREVIEW_SRC = '/game/preview-tht.svg'; + const card: React.CSSProperties = { background: colors.surfaceRaised, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: space[4], marginBottom: space[3], @@ -310,12 +314,29 @@ export default function GameConfigScreen({ onStart }: GameConfigScreenProps) { {mode === 'tht' ? ( -

- {thtCases} {thtDifficulty} case{thtCases === 1 ? '' : 's'} will be - drawn at random from the {thtPoolSize} available and played in sequence, spread across - the reconstructed France THT grid snapshots. Dates are hidden — each is titled by - month, weekday and time-of-day only. -

+ <> +

+ {thtCases} {thtDifficulty} case{thtCases === 1 ? '' : 's'} will be + drawn at random from the {thtPoolSize} available and played in sequence, spread across + the reconstructed France THT grid snapshots. Dates are hidden — each is titled by + month, weekday and time-of-day only. +

+ {!previewError && ( +
+
+ France THT (RTE7000) network map setPreviewError(true)} + style={{ display: 'block', width: '100%', height: 'auto', maxHeight: 320, objectFit: 'contain' }} /> +
+
+ The France THT network — the 400 kV backbone in red, 225 kV in green. +
+
+ )} + ) : ( <> {studies.length === 0 ? ( diff --git a/scripts/game_mode/gen_network_previews.py b/scripts/game_mode/gen_network_previews.py index a97230dc..85479295 100644 --- a/scripts/game_mode/gen_network_previews.py +++ b/scripts/game_mode/gen_network_previews.py @@ -26,6 +26,8 @@ """ from __future__ import annotations +import base64 +import gzip import json import re import zipfile @@ -39,6 +41,9 @@ _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"), ] # Voltage colouring: the >= 350 kV backbone (380 / 400 kV) is red, everything @@ -95,6 +100,13 @@ def _load_network_xml(grid_dir: Path) -> str | None: 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