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