""" test_grid_layout.py =================== Tests for ``grid_layout.json`` — the VL-id → [x, y] lookup used by pypowsybl NAD rendering. Fixtures (``network``, ``grid_layout``, ``data_dir``) come from ``conftest.py``, so the suite runs against whatever dataset is selected via ``--pypsa-network``. Validations: 1. All keys are voltage-level IDs (format ``VL_*``), not bus IDs. 2. All network VLs are present in the layout (no missing positions). 3. Coordinate spans fall in a reasonable range (5000–20000 units). 4. Geographic orientation: north = more negative y. 5. Mercator projection logic is sound (unit tests — no I/O). Usage:: pytest scripts/pypsa_eur/test_grid_layout.py -v """ from __future__ import annotations import json import math import re import pytest # Skip everything if pypowsybl isn't installed. pytest.importorskip("pypowsybl") # ───────────────────────────────────────────────────────────────────────────── # Module-local helper fixture — resolves VL IDs from the conftest network # ───────────────────────────────────────────────────────────────────────────── @pytest.fixture(scope="session") def network_vl_ids(network): """Return all voltage-level IDs in the currently-loaded network.""" vls = network.get_voltage_levels() return set(vls.index.tolist()) # ───────────────────────────────────────────────────────────────────────────── # Tests: Layout Key Validation # ───────────────────────────────────────────────────────────────────────────── class TestLayoutKeyValidation: """Validate the format and content of layout keys.""" def test_all_keys_start_with_vl(self, grid_layout): invalid_keys = [k for k in grid_layout if not k.startswith("VL_")] assert len(invalid_keys) == 0, ( f"Found {len(invalid_keys)} keys without 'VL_' prefix " f"(these may be bus IDs): {invalid_keys[:5]}" ) def test_no_bus_id_suffixes(self, grid_layout): """Flag pure '_0' / '_1' suffixes (bus IDs) but not '_0-400' (VL IDs).""" truly_invalid = [ k for k in grid_layout if k.endswith(("_0", "_1")) and not re.search(r"_[01](-\d+)$", k) ] assert len(truly_invalid) == 0, ( f"Found {len(truly_invalid)} keys with bus-ID-like suffixes: " f"{truly_invalid[:5]}" ) def test_keys_are_valid_iidm_ids(self, grid_layout): """IIDM IDs can contain letters, digits, underscore, hyphen, dot.""" valid_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_\-\.]*$") invalid_keys = [k for k in grid_layout if not valid_pattern.match(k)] assert len(invalid_keys) == 0, ( f"Found {len(invalid_keys)} invalid IIDM IDs: {invalid_keys}" ) # ───────────────────────────────────────────────────────────────────────────── # Tests: Coverage & Completeness # ───────────────────────────────────────────────────────────────────────────── class TestLayoutCoverage: """Ensure the layout covers all network voltage levels.""" def test_all_network_vls_in_layout(self, grid_layout, network_vl_ids): layout_keys = set(grid_layout.keys()) missing_vls = network_vl_ids - layout_keys assert len(missing_vls) == 0, ( f"Found {len(missing_vls)} network VLs missing from layout: " f"{sorted(missing_vls)[:5]}" ) def test_layout_size_matches_network(self, grid_layout, network_vl_ids): assert len(grid_layout) == len(network_vl_ids), ( f"Layout has {len(grid_layout)} entries but network has " f"{len(network_vl_ids)} VLs. " f"Extra layout keys: " f"{sorted(set(grid_layout.keys()) - network_vl_ids)[:5]}" ) def test_no_extra_keys_in_layout(self, grid_layout, network_vl_ids): layout_keys = set(grid_layout.keys()) extra_keys = layout_keys - network_vl_ids assert len(extra_keys) == 0, ( f"Found {len(extra_keys)} extra keys in layout: " f"{sorted(extra_keys)[:5]}" ) # ───────────────────────────────────────────────────────────────────────────── # Tests: Coordinate Range Validation # ───────────────────────────────────────────────────────────────────────────── class TestCoordinateRanges: """Validate coordinate ranges match pypowsybl's force-layout scale.""" def test_coordinates_are_lists_of_two_numbers(self, grid_layout): for vl_id, coord in grid_layout.items(): assert isinstance(coord, list), f"VL {vl_id}: coordinate is not a list" assert len(coord) == 2, ( f"VL {vl_id}: coordinate has {len(coord)} values, expected 2" ) assert isinstance(coord[0], (int, float)), f"VL {vl_id}: x is not numeric" assert isinstance(coord[1], (int, float)), f"VL {vl_id}: y is not numeric" def test_coordinate_spans_in_reasonable_range(self, grid_layout): # The on-disk layout MUST be in raw Mercator metres (span ≈ 1.3–1.6 M # for the French grid), NOT the legacy 8 000-unit rescale — pypowsybl # emits VL outer circles at a fixed r=27.5 user-units, so a squashed # layout forces overlap on dense regions. See # docs/data/grid-layout-coordinate-scale.md (the 2026-05-08 fix). The # old [5000, 20000] assertion here enshrined the forbidden scale and # failed against the correctly-committed raw-metres layouts (D8). RAW_METRES_MIN = 1_000_000 RAW_METRES_MAX = 2_000_000 xs = [coord[0] for coord in grid_layout.values()] ys = [coord[1] for coord in grid_layout.values()] x_span = max(xs) - min(xs) y_span = max(ys) - min(ys) assert RAW_METRES_MIN <= x_span <= RAW_METRES_MAX, ( f"X-span {x_span:.0f} is outside the raw-Mercator-metres range " f"[{RAW_METRES_MIN}, {RAW_METRES_MAX}] — a span ~8000 means the " f"forbidden legacy rescale; a span ~10 means raw lon/lat. " f"X range: [{min(xs):.0f}, {max(xs):.0f}]" ) assert RAW_METRES_MIN <= y_span <= RAW_METRES_MAX, ( f"Y-span {y_span:.0f} is outside the raw-Mercator-metres range " f"[{RAW_METRES_MIN}, {RAW_METRES_MAX}]. " f"Y range: [{min(ys):.0f}, {max(ys):.0f}]" ) def test_coordinates_are_centered_near_origin(self, grid_layout): xs = [coord[0] for coord in grid_layout.values()] ys = [coord[1] for coord in grid_layout.values()] x_span = max(xs) - min(xs) y_span = max(ys) - min(ys) x_center = (min(xs) + max(xs)) / 2 y_center = (min(ys) + max(ys)) / 2 assert abs(x_center) < x_span * 0.1, ( f"X center {x_center:.0f} is too far from origin " f"(more than 10% of x_span {x_span:.0f})" ) assert abs(y_center) < y_span * 0.1, ( f"Y center {y_center:.0f} is too far from origin " f"(more than 10% of y_span {y_span:.0f})" ) # ───────────────────────────────────────────────────────────────────────────── # Tests: Geographic Orientation # ───────────────────────────────────────────────────────────────────────────── class TestGeographicOrientation: """Validate geographic orientation (north = negative y).""" def test_north_south_gradient_exists(self, grid_layout): """Y-span should be significant relative to X-span for France.""" ys = [coord[1] for coord in grid_layout.values()] xs = [coord[0] for coord in grid_layout.values()] y_span = max(ys) - min(ys) x_span = max(xs) - min(xs) # France is ~600 km N-S and ~900 km E-W. assert y_span > x_span * 0.4, ( f"Y-span {y_span:.0f} too small relative to X-span {x_span:.0f}. " "Expected geographic N-S variation." ) def test_y_range_shows_north_south_gradient(self, grid_layout): """IQR of y values should be at least 30% of full range.""" ys = [coord[1] for coord in grid_layout.values()] y_range = max(ys) - min(ys) sorted_ys = sorted(ys) q25 = sorted_ys[len(sorted_ys) // 4] q75 = sorted_ys[3 * len(sorted_ys) // 4] iq_range = q75 - q25 assert iq_range > y_range * 0.3, ( f"Y-coordinate distribution is too skewed. " f"IQR {iq_range:.0f} is less than 30% of range {y_range:.0f}" ) # ───────────────────────────────────────────────────────────────────────────── # Tests: Mercator Projection Logic (pure unit tests) # ───────────────────────────────────────────────────────────────────────────── class TestMercatorProjection: """Validate the Mercator projection used in the conversion script.""" @staticmethod def lon_lat_to_mercator(lon, lat): """Convert WGS-84 lon/lat to Web Mercator metres.""" EARTH_RADIUS = 6_378_137.0 x = math.radians(lon) * EARTH_RADIUS y = math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * EARTH_RADIUS return x, y def test_mercator_north_south_ordering(self): dunkerque_lon, dunkerque_lat = 2.38, 51.03 perpignan_lon, perpignan_lat = 2.90, 42.70 _, dunkerque_my = self.lon_lat_to_mercator(dunkerque_lon, dunkerque_lat) _, perpignan_my = self.lon_lat_to_mercator(perpignan_lon, perpignan_lat) assert dunkerque_my > perpignan_my, ( f"Mercator broken: Dunkerque y={dunkerque_my:.0f} should be " f"> Perpignan y={perpignan_my:.0f}" ) def test_mercator_east_west_ordering(self): brest_lon, brest_lat = -4.49, 48.39 strasbourg_lon, strasbourg_lat = 7.75, 48.57 brest_mx, _ = self.lon_lat_to_mercator(brest_lon, brest_lat) strasbourg_mx, _ = self.lon_lat_to_mercator(strasbourg_lon, strasbourg_lat) assert strasbourg_mx > brest_mx, ( f"Mercator broken: Strasbourg x={strasbourg_mx:.0f} should be " f"> Brest x={brest_mx:.0f}" ) def test_mercator_negation_for_screen_coordinates(self): _, dunkerque_my = self.lon_lat_to_mercator(2.38, 51.03) _, perpignan_my = self.lon_lat_to_mercator(2.90, 42.70) dunkerque_screen_y = -dunkerque_my perpignan_screen_y = -perpignan_my assert dunkerque_screen_y < perpignan_screen_y, ( f"Dunkerque (north) screen_y={dunkerque_screen_y:.0f} should be " f"< Perpignan (south) screen_y={perpignan_screen_y:.0f}" ) def test_rescaling_preserves_ordering(self): coords = [ (2.38, 51.03, "Dunkerque"), (2.90, 42.70, "Perpignan"), (-4.49, 48.39, "Brest"), (7.75, 48.57, "Strasbourg"), ] projected = [] for lon, lat, name in coords: mx, my = self.lon_lat_to_mercator(lon, lat) projected.append((mx, -my, name)) xs = [p[0] for p in projected] ys = [p[1] for p in projected] p_cx = (min(xs) + max(xs)) / 2 p_cy = (min(ys) + max(ys)) / 2 p_xrange = max(xs) - min(xs) TARGET_WIDTH = 8_000.0 scale = TARGET_WIDTH / p_xrange rescaled = [((mx - p_cx) * scale, (my - p_cy) * scale, n) for mx, my, n in projected] dunkerque_y = next(r[1] for r in rescaled if r[2] == "Dunkerque") perpignan_y = next(r[1] for r in rescaled if r[2] == "Perpignan") assert dunkerque_y < perpignan_y brest_x = next(r[0] for r in rescaled if r[2] == "Brest") strasbourg_x = next(r[0] for r in rescaled if r[2] == "Strasbourg") assert strasbourg_x > brest_x def test_mercator_coordinates_within_expected_bounds(self): france_bounds = [ (-4.49, 48.39, "Brest"), (7.75, 48.57, "Strasbourg"), (2.38, 51.03, "Dunkerque"), (2.90, 42.70, "Perpignan"), ] for lon, lat, name in france_bounds: mx, my = self.lon_lat_to_mercator(lon, lat) assert -600_000 < mx < 900_000, ( f"{name}: Mercator x={mx:.0f} outside expected range for France" ) assert 5_000_000 < my < 7_000_000, ( f"{name}: Mercator y={my:.0f} outside expected range for France" ) # ───────────────────────────────────────────────────────────────────────────── # Integration Tests # ───────────────────────────────────────────────────────────────────────────── class TestLayoutIntegration: """Integration tests combining multiple validation aspects.""" def test_layout_is_valid_json(self, data_dir): path = data_dir / "grid_layout.json" with open(path, "r") as f: data = json.load(f) assert isinstance(data, dict), "Layout should be a JSON object" def test_all_vls_have_unique_positions(self, grid_layout): positions = [tuple(coord) for coord in grid_layout.values()] unique_positions = len(set(positions)) total_vls = len(grid_layout) duplicate_count = total_vls - unique_positions assert duplicate_count <= max(1, total_vls // 100), ( f"Found {duplicate_count} duplicate coordinates out of {total_vls} VLs" ) def test_layout_statistics_reasonable(self, grid_layout, network_name): xs = [coord[0] for coord in grid_layout.values()] ys = [coord[1] for coord in grid_layout.values()] x_span = max(xs) - min(xs) y_span = max(ys) - min(ys) print(f"\nLayout Statistics for {network_name}:") print(f" Total VLs: {len(grid_layout)}") print(f" X range: [{min(xs):.0f}, {max(xs):.0f}] (span: {x_span:.0f})") print(f" Y range: [{min(ys):.0f}, {max(ys):.0f}] (span: {y_span:.0f})") aspect = x_span / y_span if y_span > 0 else float("inf") print(f" Aspect ratio (X/Y span): {aspect:.2f}") assert len(grid_layout) > 0, "Layout should have entries" assert x_span > 0, "X span should be positive" assert y_span > 0, "Y span should be positive" if __name__ == "__main__": pytest.main([__file__, "-v"])