Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions expert_backend/services/network_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions expert_backend/tests/test_network_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<network/>"):
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"<network/>"

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"<network/>"
assert not out.startswith(str(tmp_path)) # landed in a temp dir


class TestGetDisconnectableElements:
def test_network_not_loaded(self):
service = NetworkService()
Expand Down
1 change: 1 addition & 0 deletions frontend/public/game/preview-tht.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/game/GameConfigScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
33 changes: 27 additions & 6 deletions frontend/src/game/GameConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const PREVIEW_SRC: Record<Difficulty, string> = {
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],
Expand Down Expand Up @@ -310,12 +314,29 @@ export default function GameConfigScreen({ onStart }: GameConfigScreenProps) {
</div>

{mode === 'tht' ? (
<p data-testid="game-tht-summary" style={{ color: colors.textSecondary, fontSize: text.sm }}>
{thtCases} <strong>{thtDifficulty}</strong> 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.
</p>
<>
<p data-testid="game-tht-summary" style={{ color: colors.textSecondary, fontSize: text.sm }}>
{thtCases} <strong>{thtDifficulty}</strong> 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.
</p>
{!previewError && (
<figure data-testid="game-tht-preview" style={{ margin: `${space[3]} 0 0` }}>
<div style={{
border: `1px solid ${colors.borderSubtle}`, borderRadius: radius.md,
background: colors.surface, padding: space[2], overflow: 'hidden',
}}>
<img src={RTE7000_PREVIEW_SRC} alt="France THT (RTE7000) network map"
loading="lazy" onError={() => setPreviewError(true)}
style={{ display: 'block', width: '100%', height: 'auto', maxHeight: 320, objectFit: 'contain' }} />
</div>
<figcaption style={{ color: colors.textTertiary, fontSize: text.xs, marginTop: space[1] }}>
The France THT network — the 400 kV backbone in red, 225 kV in green.
</figcaption>
</figure>
)}
</>
) : (
<>
{studies.length === 0 ? (
Expand Down
12 changes: 12 additions & 0 deletions scripts/game_mode/gen_network_previews.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"""
from __future__ import annotations

import base64
import gzip
import json
import re
import zipfile
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading