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.
+