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
67 changes: 61 additions & 6 deletions expert_backend/services/recommender_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,38 +947,88 @@ def _get_monitoring_parameters(self, obs):

return lines_we_care_about, branches_with_limits

def _generate_diagram(self, network):
def _load_layout(self):
"""Load layout DataFrame from grid_layout.json if available."""
import pandas as pd
import json

layout_file = getattr(config, 'LAYOUT_FILE_PATH', None)
if layout_file and layout_file.exists():
try:
with open(layout_file, 'r') as f:
layout_data = json.load(f)
records = [{'id': k, 'x': v[0], 'y': v[1]} for k, v in layout_data.items()]
return pd.DataFrame(records).set_index('id')
except Exception as e:
print(f"Warning: Could not load layout: {e}")
return None

def _default_nad_parameters(self):
"""Return default NadParameters for diagram generation."""
from pypowsybl.network import NadParameters
return NadParameters(
edge_name_displayed=False,
id_displayed=False,
edge_info_along_edge=True,
power_value_precision=1,
angle_value_precision=0,
current_value_precision=1,
voltage_value_precision=0,
bus_legend=True,
substation_description_displayed=True
)

def _generate_diagram(self, network, voltage_level_ids=None, depth=0):
"""Generate NAD and return svg + metadata dict."""
from pypowsybl_jupyter.util import _get_svg_string, _get_svg_metadata
import time

print(f"[RECO] Generating diagram (VLs={voltage_level_ids}, depth={depth})...")
t0 = time.time()

diagram = network.get_network_area_diagram()
df_layout = self._load_layout()
npars = self._default_nad_parameters()

kwargs = dict(nad_parameters=npars)
if df_layout is not None:
kwargs['fixed_positions'] = df_layout
if voltage_level_ids is not None:
kwargs['voltage_level_ids'] = voltage_level_ids
kwargs['depth'] = depth

diagram = network.get_network_area_diagram(**kwargs)
t1 = time.time()

svg = _get_svg_string(diagram)
t2 = time.time()

meta = _get_svg_metadata(diagram)
t3 = time.time()

print(f"[RECO] Diagram generated: NAD {t1-t0:.2f}s, SVG {t2-t1:.2f}s, Meta {t3-t2:.2f}s (SVG length={len(svg)})")

return {
"svg": svg,
"metadata": meta,
}

def get_network_diagram(self):
def get_network_diagram(self, voltage_level_ids=None, depth=0):
import pypowsybl as pp
n = self._get_base_network()
original_variant = n.get_working_variant_id()
n_variant_id = self._get_n_variant()
n.set_working_variant(n_variant_id)

try:
diagram = self._generate_diagram(n)
diagram = self._generate_diagram(n, voltage_level_ids=voltage_level_ids, depth=depth)
diagram["lines_overloaded"] = self._get_overloaded_lines(n, lines_we_care_about=self._get_lines_we_care_about())
# Cache N-state element currents for N-1 comparison
self._n_state_currents = self._get_element_max_currents(n)
return diagram
finally:
n.set_working_variant(original_variant) # Restore original variant

def get_n1_diagram(self, disconnected_element: str):
def get_n1_diagram(self, disconnected_element: str, voltage_level_ids=None, depth=0):
import pypowsybl as pp
import time

Expand All @@ -991,14 +1041,19 @@ def get_n1_diagram(self, disconnected_element: str):
n.set_working_variant(n1_variant_id)

try:
# Check convergence — partial AC results are still better than DC
# (DC only computes angles/power, not voltage magnitudes).
# We need to re-run AC to get the results object for status
t0 = time.time()
params = create_olf_rte_parameter()
results = self._run_ac_with_fallback(n, params)
converged = any(r.status.name == 'CONVERGED' for r in results)
lf_status = results[0].status.name if results else "UNKNOWN"
if not converged:
print(f"Warning: AC load flow did not converge for N-1 ({disconnected_element}): {lf_status}")
print(f"[RECO] N-1 LF check {disconnected_element}: {time.time()-t0:.2f}s")

diagram = self._generate_diagram(n)
diagram = self._generate_diagram(n, voltage_level_ids=voltage_level_ids, depth=depth)
diagram["lf_converged"] = converged
diagram["lf_status"] = lf_status

Expand Down
86 changes: 86 additions & 0 deletions expert_backend/tests/test_recommender_regressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# 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 unittest
from unittest.mock import MagicMock, patch
import pandas as pd
import json
from pathlib import Path

# Add the project path to sys.path
import sys
project_root = "/Users/antoine/Dev/Co-Study4Grid"
if project_root not in sys.path:
sys.path.append(project_root)

# Mock config BEFORE importing
mock_config = MagicMock()
mock_config.LAYOUT_FILE_PATH = Path("/tmp/grid_layout.json")
sys.modules["expert_op4grid_recommender.config"] = mock_config

from expert_backend.services.recommender_service import RecommenderService

class TestRecommenderRegressions(unittest.TestCase):

def setUp(self):
self.service = RecommenderService()

def test_load_layout_exists(self):
"""Test that _load_layout is present and handles missing file."""
self.assertTrue(hasattr(self.service, '_load_layout'))
# With non-existent file path
mock_config.LAYOUT_FILE_PATH = Path("/non/existent/path")
layout = self.service._load_layout()
self.assertIsNone(layout)

def test_default_nad_parameters_exists(self):
"""Test that _default_nad_parameters is present."""
self.assertTrue(hasattr(self.service, '_default_nad_parameters'))
with patch('pypowsybl.network.NadParameters', return_value=MagicMock()) as mock_nad:
params = self.service._default_nad_parameters()
self.assertIsNotNone(params)
mock_nad.assert_called_once()

def test_generate_diagram_parameters(self):
"""Test that _generate_diagram passes parameters correctly."""
mock_network = MagicMock()

# Patch dependencies
with patch.object(self.service, '_load_layout', return_value=None), \
patch.object(self.service, '_default_nad_parameters', return_value=MagicMock()), \
patch('pypowsybl_jupyter.util._get_svg_string', return_value="<svg>"), \
patch('pypowsybl_jupyter.util._get_svg_metadata', return_value={}):

# Case 1: Minimal call
self.service._generate_diagram(mock_network)
_, kwargs = mock_network.get_network_area_diagram.call_args
self.assertIn('nad_parameters', kwargs)
self.assertNotIn('voltage_level_ids', kwargs)

# Case 2: With VL filtering
self.service._generate_diagram(mock_network, voltage_level_ids=["VL1"], depth=2)
_, kwargs = mock_network.get_network_area_diagram.call_args
self.assertEqual(kwargs['voltage_level_ids'], ["VL1"])
self.assertEqual(kwargs['depth'], 2)

def test_diagram_getters_signatures(self):
"""Test that getters have the correct signatures to prevent regression."""
import inspect

# get_network_diagram(self, voltage_level_ids=None, depth=0)
sig = inspect.signature(self.service.get_network_diagram)
self.assertIn('voltage_level_ids', sig.parameters)
self.assertIn('depth', sig.parameters)

# get_n1_diagram(self, disconnected_element: str, voltage_level_ids=None, depth=0)
sig = inspect.signature(self.service.get_n1_diagram)
self.assertIn('disconnected_element', sig.parameters)
self.assertIn('voltage_level_ids', sig.parameters)
self.assertIn('depth', sig.parameters)

if __name__ == '__main__':
unittest.main()
33 changes: 33 additions & 0 deletions expert_backend/tests/test_ui_regressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# 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 unittest
import os

class TestUIRegressions(unittest.TestCase):

def test_critical_ui_strings(self):
"""Verify that standalone_interface.html contains fixed regressions."""
# Use relative path from this test file
test_dir = os.path.dirname(os.path.abspath(__file__))
ui_file = os.path.abspath(os.path.join(test_dir, "../../standalone_interface.html"))
self.assertTrue(os.path.exists(ui_file), f"File not found: {ui_file}")

with open(ui_file, 'r') as f:
content = f.read()

# 1. Flow deltas check
self.assertIn('!flowDeltas && !assetDeltas', content, "Regression: check for assetDeltas missing in standalone_interface.html")

# 2. Action highlight class
self.assertIn('sld-highlight-action', content, "Regression: highlight class 'sld-highlight-action' missing in standalone_interface.html")

# 3. Placeholder text
self.assertIn('Select an action card to view its network variant', content, "Regression: placeholder text 'Select an action card to view its network variant' missing in standalone_interface.html")

if __name__ == '__main__':
unittest.main()
4 changes: 2 additions & 2 deletions standalone_interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -2766,7 +2766,7 @@
const reactiveDeltas = vlOverlay.reactive_flow_deltas;
const assetDeltas = vlOverlay.asset_deltas;

if (!flowDeltas) return;
if (!flowDeltas && !assetDeltas) return;

// Build equipmentId -> [svgId, ...] mapping from SLD metadata.
// pypowsybl SLD metadata uses 'nodes' (for lines, transformers, breakers,
Expand Down Expand Up @@ -4260,7 +4260,7 @@
<div className="svg-container" ref={actionSvgContainerRef} dangerouslySetInnerHTML={{ __html: actionDiagram.svg }} />
: (selectedActionId ?
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#999' }}>Failed to load diagram for action {selectedActionId}</div>
: <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#999' }}>Select an action to view its variant diagram</div>
: <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#999' }}>Select an action card to view its network variant</div>
)
)
}
Expand Down
Loading