diff --git a/Cases_Defauts_28082024.odt b/Cases_Defauts_28082024.odt new file mode 100644 index 00000000..09016775 Binary files /dev/null and b/Cases_Defauts_28082024.odt differ diff --git a/Config_study_cases.odt b/Config_study_cases.odt new file mode 100644 index 00000000..2a96cb82 Binary files /dev/null and b/Config_study_cases.odt differ diff --git a/Difference_Fast_Slow_modes.odt b/Difference_Fast_Slow_modes.odt new file mode 100644 index 00000000..5477f017 Binary files /dev/null and b/Difference_Fast_Slow_modes.odt differ diff --git a/expert_backend/main.py b/expert_backend/main.py index 72bb6632..bc3672ac 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -41,6 +41,11 @@ class ConfigRequest(BaseModel): class AnalysisRequest(BaseModel): disconnected_element: str +class AnalysisStep2Request(BaseModel): + selected_overloads: list[str] + all_overloads: list[str] = [] + monitor_deselected: bool = False + class FocusedDiagramRequest(BaseModel): element_id: str depth: int = 1 @@ -142,7 +147,6 @@ def pick_path(type: str = Query("file", enum=["file", "dir"])): from fastapi.responses import StreamingResponse import json - @app.post("/api/run-analysis") async def run_analysis(request: AnalysisRequest): def event_generator(): @@ -159,6 +163,36 @@ def event_generator(): return StreamingResponse(event_generator(), media_type="application/x-ndjson") +@app.post("/api/run-analysis-step1") +async def run_analysis_step1(request: AnalysisRequest): + try: + result = recommender_service.run_analysis_step1(request.disconnected_element) + return result + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/run-analysis-step2") +async def run_analysis_step2(request: AnalysisStep2Request): + def event_generator(): + try: + for event in recommender_service.run_analysis_step2( + request.selected_overloads, + all_overloads=request.all_overloads, + monitor_deselected=request.monitor_deselected + ): + if event.get("pdf_path"): + filename = os.path.basename(event["pdf_path"]) + event["pdf_url"] = f"/results/pdf/{filename}" + + # Yield JSON line + yield json.dumps(event) + "\n" + except Exception as e: + yield json.dumps({"type": "error", "message": str(e)}) + "\n" + + return StreamingResponse(event_generator(), media_type="application/x-ndjson") + @app.get("/api/network-diagram") def get_network_diagram(): try: diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index 71ff33c1..f4a7f576 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -1,7 +1,7 @@ import expert_op4grid_recommender from expert_op4grid_recommender import config -from expert_op4grid_recommender.main import Backend, run_analysis -from expert_op4grid_recommender.data_loader import load_actions +from expert_op4grid_recommender.main import Backend, run_analysis, run_analysis_step1, run_analysis_step2 +from expert_op4grid_recommender.data_loader import load_actions, enrich_actions_lazy from expert_op4grid_recommender.utils.make_env_utils import create_olf_rte_parameter import os import glob @@ -24,9 +24,11 @@ def sanitize_for_json(obj): else: # Fallback for unknown objects try: - # Try to get dict representation if hasattr(obj, "to_dict"): - return sanitize_for_json(obj.to_dict()) + d = obj.to_dict() + if isinstance(d, dict): + return sanitize_for_json(d) + return str(obj) return sanitize_for_json(vars(obj)) except TypeError: return str(obj) @@ -40,6 +42,7 @@ def __init__(self): self._simulation_env = None self._last_disconnected_element = None self._dict_action = None + self._analysis_context = None def update_config(self, settings): # Update the global config of the package @@ -99,34 +102,30 @@ def update_config(self, settings): # Load and cache the action dictionary immediately if path changed or not loaded new_action_path = Path(settings.action_file_path) if getattr(self, '_last_action_path', None) != new_action_path or self._dict_action is None: - self._dict_action = load_actions(config.ACTION_FILE_PATH) + raw_dict_action = load_actions(config.ACTION_FILE_PATH) self._last_action_path = new_action_path - # Auto-generate disco actions if none exist - has_disco = any(k.startswith("disco_") for k in self._dict_action) + # Auto-generate disco actions if none exist in the file + has_disco = any(k.startswith("disco_") for k in raw_dict_action) if not has_disco: from expert_backend.services.network_service import network_service branches = network_service.get_disconnectable_elements() for branch in branches: action_id = f"disco_{branch}" - self._dict_action[action_id] = { + raw_dict_action[action_id] = { "description": f"Disconnection of line/transformer '{branch}'", "description_unitaire": f"Ouverture de la ligne '{branch}'", - "content": { - "set_bus": { - "lines_or_id": {branch: -1}, - "lines_ex_id": {branch: -1}, - "loads_id": {}, - "generators_id": {}, - } - }, } print(f"[RecommenderService] Auto-generated {len(branches)} disco_ actions") - - # Save the updated dictionary back to file so the core analysis engine can load it + + # Save the raw entries (without content) so the core analysis engine can read them import json with open(config.ACTION_FILE_PATH, 'w') as f: - json.dump(self._dict_action, f, indent=2) + json.dump(raw_dict_action, f, indent=2) + + # Wrap with LazyActionDict so 'content' is computed on demand from 'switches' + from expert_backend.services.network_service import network_service + self._dict_action = enrich_actions_lazy(raw_dict_action, network_service.network) else: print("Action dictionary already loaded, skipping reload.") @@ -141,6 +140,147 @@ def update_config(self, settings): if not config.SAVE_FOLDER_VISUALIZATION.exists(): config.SAVE_FOLDER_VISUALIZATION.mkdir(parents=True, exist_ok=True) + def _get_latest_pdf_path(self, analysis_start_time=None): + """Finds the latest PDF generated in the SAVE_FOLDER_VISUALIZATION.""" + save_folder = config.SAVE_FOLDER_VISUALIZATION + pdfs = glob.glob(os.path.join(save_folder, "*.pdf")) + if not pdfs: return None + + if analysis_start_time: + # Only consider PDFs modified after we started + # Use a tiny offset (1s) to be safe against filesystem drift + recent_pdfs = [p for p in pdfs if os.path.getmtime(p) >= (analysis_start_time - 1.0)] + if not recent_pdfs: return None + return max(recent_pdfs, key=os.path.getmtime) + else: + # If no start time, just get the absolute latest + return max(pdfs, key=os.path.getmtime) + + def run_analysis_step1(self, disconnected_element: str): + """Runs the first step of analysis: contingency simulation and overload detection.""" + try: + res_step1, context = run_analysis_step1( + analysis_date=config.DATE, + current_timestep=config.TIMESTEP, + current_lines_defaut=[disconnected_element], + backend=Backend.PYPOWSYBL, + fast_mode=getattr(config, 'PYPOWSYBL_FAST_MODE', True) + ) + + self._last_disconnected_element = disconnected_element + + if res_step1 is not None: + # No overloads or grid broken apart + self._analysis_context = None + return { + "lines_overloaded": res_step1.get("lines_overloaded_names", []), + "message": "No overloads detected or grid broken apart.", + "can_proceed": False + } + + self._analysis_context = context + return { + "lines_overloaded": context["lines_overloaded_names"], + "message": f"Detected {len(context['lines_overloaded_names'])} overloads.", + "can_proceed": True + } + except Exception as e: + self._analysis_context = None + raise e + + def run_analysis_step2(self, selected_overloads: list[str], all_overloads: list[str] = None, monitor_deselected: bool = False): + """Runs the second step of analysis: graph generation and action discovery.""" + if not self._analysis_context: + raise ValueError("Analysis context not found. Run step 1 first.") + + context = self._analysis_context + + # Filter overloads in context based on user selection + all_names = context["lines_overloaded_names"] + selected_indices = [i for i, name in enumerate(all_names) if name in selected_overloads] + + # Update IDs + original_ids = context["lines_overloaded_ids"] + new_ids = [original_ids[i] for i in selected_indices] + context["lines_overloaded_ids"] = new_ids + + # Update kept IDs (subset of original_ids that were also in kept) + original_kept = set(context["lines_overloaded_ids_kept"]) + new_kept = [idx for idx in new_ids if idx in original_kept] + context["lines_overloaded_ids_kept"] = new_kept + + # Update names + context["lines_overloaded_names"] = [all_names[i] for i in selected_indices] + + # When not monitoring deselected overloads, remove them from lines_we_care_about + # so they don't appear in max_rho_line calculation for action cards. + if not monitor_deselected and all_overloads: + deselected = set(all_overloads) - set(selected_overloads) + if deselected and context.get("lines_we_care_about") is not None: + care = context["lines_we_care_about"] + before_count = len(care) + if isinstance(care, set): + context["lines_we_care_about"] = care - deselected + elif isinstance(care, (list, tuple)): + context["lines_we_care_about"] = [n for n in care if n not in deselected] + else: + # Fallback: convert to set and subtract + context["lines_we_care_about"] = set(care) - deselected + after_count = len(context["lines_we_care_about"]) + print(f"[Step2] Excluded {before_count - after_count} deselected overloads from monitoring: {deselected}") + print(f"[Step2] lines_we_care_about: {before_count} -> {after_count}") + else: + print(f"[Step2] monitor_deselected={monitor_deselected}, all_overloads={all_overloads} -> NOT filtering lines_we_care_about") + + results = run_analysis_step2(context) + self._last_result = results # Store for diagram generation + + # Yield PDF event (graph is generated in Step 2) + yield {"type": "pdf", "pdf_path": self._get_latest_pdf_path()} + + # Build enriched actions the same way as run_analysis - with monitoring_factor applied and topology + monitoring_factor = getattr(config, 'MONITORING_FACTOR_THERMAL_LIMITS', 0.95) + enriched_actions = {} + for action_id, action_data in results["prioritized_actions"].items(): + rho_before_raw = action_data.get("rho_before") + rho_after_raw = action_data.get("rho_after") + max_rho_raw = action_data.get("max_rho") + + rho_before = [r * monitoring_factor for r in rho_before_raw] if rho_before_raw is not None else None + rho_after = [r * monitoring_factor for r in rho_after_raw] if rho_after_raw is not None else None + max_rho = (max_rho_raw * monitoring_factor) if max_rho_raw is not None else None + + enriched_actions[action_id] = { + "description_unitaire": action_data.get("description_unitaire") or "No description available", + "rho_before": sanitize_for_json(rho_before), + "rho_after": sanitize_for_json(rho_after), + "max_rho": sanitize_for_json(max_rho), + "max_rho_line": action_data.get("max_rho_line", ""), + "is_rho_reduction": bool(action_data.get("is_rho_reduction", False)), + } + + # Extract topology from the underlying action object + action_obj = action_data.get("action") + if action_obj is not None: + topo = {} + for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus"): + val = getattr(action_obj, field, None) + if val is None and isinstance(action_obj, dict): + val = action_obj.get(field) + topo[field] = sanitize_for_json(val) if val else {} + enriched_actions[action_id]["action_topology"] = topo + + # Yield result + yield { + "type": "result", + "actions": enriched_actions, + "action_scores": sanitize_for_json(results["action_scores"]), + "lines_overloaded": results["lines_overloaded_names"], + "pre_existing_overloads": results.get("pre_existing_overloads", []), + "message": "Analysis completed", + "dc_fallback": False, + } + def run_analysis(self, disconnected_element: str): import io import time @@ -349,7 +489,10 @@ def _get_base_network(self): else: raise FileNotFoundError(f"No .xiidm file found in {config.ENV_PATH}") - self._base_network = pp.network.load(str(network_file)) + n = pp.network.load(str(network_file)) + # Convenience method not in pypowsybl API: return line IDs as a list + n.get_line_ids = lambda: n.get_lines().index.tolist() + self._base_network = n return self._base_network def _get_n_variant(self): diff --git a/expert_backend/tests/conftest.py b/expert_backend/tests/conftest.py index 82239bec..dacd56c4 100644 --- a/expert_backend/tests/conftest.py +++ b/expert_backend/tests/conftest.py @@ -4,13 +4,20 @@ available in CI/test environments. We install lightweight mocks into ``sys.modules`` *before* any production module is imported so that collection and import succeed without the real packages. + +When the real packages *are* installed (e.g. local development), we prefer +them over mocks so that integration-style tests (TestRecommenderSimulationRealData) +can run against the real implementations. """ import sys +import importlib from unittest.mock import MagicMock # --------------------------------------------------------------------------- -# Mock heavy domain packages that are not available in test environments +# Mock heavy domain packages that are not available in test environments. +# Try to import each package first; only install a mock when the real package +# cannot be found. # --------------------------------------------------------------------------- _MOCK_MODULES = [ "pypowsybl", @@ -29,11 +36,16 @@ "expert_op4grid_recommender.environment_pypowsybl", "expert_op4grid_recommender.utils.simulation_pypowsybl", "expert_op4grid_recommender.environment", + "expert_op4grid_recommender.pypowsybl_backend", + "expert_op4grid_recommender.pypowsybl_backend.simulation_env", ] for mod_name in _MOCK_MODULES: if mod_name not in sys.modules: - sys.modules[mod_name] = MagicMock() + try: + importlib.import_module(mod_name) + except (ImportError, ModuleNotFoundError): + sys.modules[mod_name] = MagicMock() # --------------------------------------------------------------------------- # Now it is safe to import production code diff --git a/expert_backend/tests/test_api_endpoints.py b/expert_backend/tests/test_api_endpoints.py index 90f68b15..5e4ca5a1 100644 --- a/expert_backend/tests/test_api_endpoints.py +++ b/expert_backend/tests/test_api_endpoints.py @@ -185,6 +185,105 @@ def failing_analysis(element): assert error_event["type"] == "error" +class TestRunAnalysisStep1: + def test_success(self, client, mock_services): + _, mock_rs = mock_services + mock_rs.run_analysis_step1.return_value = { + "lines_overloaded": ["LINE_1", "LINE_2"], + "message": "Detected 2 overloads.", + "can_proceed": True, + } + + response = client.post( + "/api/run-analysis-step1", + json={"disconnected_element": "LINE_A"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["lines_overloaded"] == ["LINE_1", "LINE_2"] + assert data["can_proceed"] is True + + def test_error_in_service(self, client, mock_services): + _, mock_rs = mock_services + mock_rs.run_analysis_step1.side_effect = Exception("Step 1 failed") + + response = client.post( + "/api/run-analysis-step1", + json={"disconnected_element": "LINE_A"}, + ) + assert response.status_code == 400 + assert "Step 1 failed" in response.json()["detail"] + + +class TestRunAnalysisStep2: + def test_streaming_response_success(self, client, mock_services): + _, mock_rs = mock_services + + def fake_analysis_step2(selected_overloads, all_overloads=None, monitor_deselected=False): + yield {"type": "pdf", "pdf_path": "/tmp/graph.pdf"} + yield { + "type": "result", + "actions": {}, + "action_scores": {}, + "lines_overloaded": ["LINE_1"], + "message": "Analysis completed", + "dc_fallback": False, + } + + mock_rs.run_analysis_step2.side_effect = fake_analysis_step2 + + response = client.post( + "/api/run-analysis-step2", + json={ + "selected_overloads": ["LINE_1"], + "all_overloads": ["LINE_1", "LINE_2"], + "monitor_deselected": True, + }, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/x-ndjson" + + # Parse NDJSON lines + lines = [ + line for line in response.text.strip().split("\n") if line.strip() + ] + assert len(lines) == 2 + pdf_event = json.loads(lines[0]) + result_event = json.loads(lines[1]) + + assert pdf_event["type"] == "pdf" + assert pdf_event["pdf_url"] == "/results/pdf/graph.pdf" + assert result_event["type"] == "result" + assert result_event["lines_overloaded"] == ["LINE_1"] + + # Verify service call parameters + mock_rs.run_analysis_step2.assert_called_once_with( + ["LINE_1"], + all_overloads=["LINE_1", "LINE_2"], + monitor_deselected=True + ) + + def test_error_in_streaming(self, client, mock_services): + _, mock_rs = mock_services + + def failing_analysis_step2(*args, **kwargs): + raise RuntimeError("Step 2 exploded") + + mock_rs.run_analysis_step2.side_effect = failing_analysis_step2 + + response = client.post( + "/api/run-analysis-step2", + json={"selected_overloads": ["LINE_1"]}, + ) + assert response.status_code == 200 # Streaming response + lines = [ + line for line in response.text.strip().split("\n") if line.strip() + ] + error_event = json.loads(lines[0]) + assert error_event["type"] == "error" + assert "Step 2 exploded" in error_event["message"] + + class TestActionVariantDiagram: def test_success(self, client, mock_services): _, mock_rs = mock_services diff --git a/expert_backend/tests/test_compute_deltas.py b/expert_backend/tests/test_compute_deltas.py index 917db071..8698ed61 100644 --- a/expert_backend/tests/test_compute_deltas.py +++ b/expert_backend/tests/test_compute_deltas.py @@ -442,6 +442,53 @@ def test_insignificant_delta_is_grey(self): assert result["A"]["category"] == "positive" assert result["B"]["category"] == "grey" + def test_independent_q_category_positive(self): + """Branch Q gets 'positive' while P gets 'negative'.""" + # P: 200 -> 100 (delta -100, negative) + # Q: 10 -> 20 (delta +10, positive) + # Note: _make_flows requires p1, p2, q1, q2 + before = _make_flows(p1={"L": 200.0}, p2={"L": -198.0}, q1={"L": 10.0}, q2={"L": -8.0}) + after = _make_flows(p1={"L": 100.0}, p2={"L": -98.0}, q1={"L": 20.0}, q2={"L": -18.0}) + + result = self.service._compute_deltas(after, before) + assert result["flow_deltas"]["L"]["category"] == "negative" + assert result["reactive_flow_deltas"]["L"]["category"] == "positive" + + def test_independent_q_category_grey(self): + """Branch Q gets 'grey' independently of a large P delta.""" + # A: P delta 100 (max), Q delta 1 (threshold_q = 5% of max_q) + # B: P delta 1, Q delta 10 (max_q) + before = _make_flows( + p1={"A": 100.0, "B": 100.0}, + p2={"A": -98.0, "B": -98.0}, + q1={"A": 10.0, "B": 10.0}, + q2={"A": -8.0, "B": -8.0} + ) + after = _make_flows( + p1={"A": 200.0, "B": 101.0}, + p2={"A": -198.0, "B": -99.0}, + q1={"A": 11.0, "B": 20.0}, + q2={"A": -9.0, "B": -18.0} + ) + result = self.service._compute_deltas(after, before) + + # P max_abs=100 -> thresh=5. A=100 (pos), B=1 (grey) + assert result["flow_deltas"]["A"]["category"] == "positive" + assert result["flow_deltas"]["B"]["category"] == "grey" + + # Q max_abs=10 (for B) -> thresh=0.5. A=1 (pos), B=10 (pos) + # Wait, if Q max_abs is 10, then A delta 1 > 0.5 -> positive. + # Let's make A delta Q even smaller to be grey. + after_v2 = _make_flows( + p1={"A": 200.0, "B": 101.0}, + p2={"A": -198.0, "B": -99.0}, + q1={"A": 10.1, "B": 20.0} + ) + result_v2 = self.service._compute_deltas(after_v2, before) + # Q max_abs=10 -> thresh=0.5. A delta=0.1 < 0.5 -> grey + assert result_v2["reactive_flow_deltas"]["A"]["category"] == "grey" + assert result_v2["reactive_flow_deltas"]["B"]["category"] == "positive" + class TestComputeAssetDeltas: """Tests for load/generator asset delta computation.""" @@ -482,3 +529,38 @@ def test_zero_delta_is_grey_when_all_zeros(self): assert result["LOAD_B"]["category"] == "grey" assert result["LOAD_A"]["delta_p"] == 0.0 assert result["LOAD_B"]["delta_p"] == 0.0 + + def test_asset_categories_independent_p_and_q(self): + """Assets return category_p, category_q, and legacy category tracking P.""" + before = {"GEN_A": {"p": 100.0, "q": 100.0}} + after = {"GEN_A": {"p": 50.0, "q": 150.0}} # P -50 (neg), Q +50 (pos) + + result = self.service._compute_asset_deltas(after, before) + res = result["GEN_A"] + + assert res["delta_p"] == -50.0 + assert res["delta_q"] == 50.0 + assert res["category_p"] == "negative" + assert res["category_q"] == "positive" + assert res["category"] == "negative" # Legacy field follows P + + def test_asset_q_category_positive_with_p_negative(self): + """Asset independent colors where Q is positive and P is negative.""" + before = { + "GEN_A": {"p": 100.0, "q": 10.0}, + "GEN_B": {"p": 10.0, "q": 100.0} + } + after = { + "GEN_A": {"p": 50.0, "q": 15.0}, # P delta -50, Q delta +5 + "GEN_B": {"p": 15.0, "q": 50.0} # P delta +5, Q delta -50 + } + result = self.service._compute_asset_deltas(after, before) + + # GEN_A: P is major delta (-50), Q is minor (+5) + # thresholds: P=50*0.05=2.5, Q=50*0.05=2.5 + assert result["GEN_A"]["category_p"] == "negative" + assert result["GEN_A"]["category_q"] == "positive" + + # GEN_B: P is minor (+5), Q is major (-50) + assert result["GEN_B"]["category_p"] == "positive" + assert result["GEN_B"]["category_q"] == "negative" diff --git a/expert_backend/tests/test_overload_filtering.py b/expert_backend/tests/test_overload_filtering.py new file mode 100644 index 00000000..d21cc330 --- /dev/null +++ b/expert_backend/tests/test_overload_filtering.py @@ -0,0 +1,135 @@ +import pytest +from unittest.mock import patch, MagicMock +from expert_backend.services.recommender_service import RecommenderService + +class TestOverloadFiltering: + @pytest.fixture + def service(self): + return RecommenderService() + + @patch("expert_backend.services.recommender_service.run_analysis_step2") + def test_run_analysis_step2_filters_care_about(self, mock_run_step2, service): + """Verify that deselected overloads are removed from lines_we_care_about.""" + # Setup context + mock_run_step2.return_value = { + "prioritized_actions": {}, + "action_scores": {}, + "lines_overloaded_names": ["LINE_1", "LINE_2"] + } + + service._analysis_context = { + "lines_overloaded_names": ["LINE_1", "LINE_2"], + "lines_overloaded_ids": [0, 1], + "lines_overloaded_ids_kept": [0, 1], + "lines_we_care_about": {"LINE_1", "LINE_2", "LINE_3"} + } + + # Test with monitor_deselected=False (filtering happens) + list(service.run_analysis_step2( + selected_overloads=["LINE_1"], + all_overloads=["LINE_1", "LINE_2"], + monitor_deselected=False + )) + + # Verify lines_we_care_about was updated (LINE_2 was deselected) + assert service._analysis_context["lines_we_care_about"] == {"LINE_1", "LINE_3"} + assert service._analysis_context["lines_overloaded_names"] == ["LINE_1"] + + @patch("expert_backend.services.recommender_service.run_analysis_step2") + def test_run_analysis_step2_preserves_care_about_when_monitoring(self, mock_run_step2, service): + """Verify that lines_we_care_about is NOT filtered when monitor_deselected=True.""" + mock_run_step2.return_value = { + "prioritized_actions": {}, + "action_scores": {}, + "lines_overloaded_names": ["LINE_1", "LINE_2"] + } + + initial_care = {"LINE_1", "LINE_2", "LINE_3"} + service._analysis_context = { + "lines_overloaded_names": ["LINE_1", "LINE_2"], + "lines_overloaded_ids": [0, 1], + "lines_overloaded_ids_kept": [0, 1], + "lines_we_care_about": initial_care.copy() + } + + list(service.run_analysis_step2( + selected_overloads=["LINE_1"], + all_overloads=["LINE_1", "LINE_2"], + monitor_deselected=True + )) + + # Verify lines_we_care_about remains the same + assert service._analysis_context["lines_we_care_about"] == initial_care + # But resolution targets are still filtered + assert service._analysis_context["lines_overloaded_names"] == ["LINE_1"] + + @patch("expert_backend.services.recommender_service.run_analysis_step2") + def test_run_analysis_step2_handles_different_iterable_types(self, mock_run_step2, service): + """Verify filtering works for both sets and lists in lines_we_care_about.""" + mock_run_step2.return_value = { + "prioritized_actions": {}, + "action_scores": {}, + "lines_overloaded_names": ["L1", "L2"] + } + + # Case 1: List + service._analysis_context = { + "lines_overloaded_names": ["L1", "L2"], + "lines_overloaded_ids": [0, 1], + "lines_overloaded_ids_kept": [0, 1], + "lines_we_care_about": ["L1", "L2", "L3"] + } + list(service.run_analysis_step2(selected_overloads=["L1"], all_overloads=["L1", "L2"], monitor_deselected=False)) + assert service._analysis_context["lines_we_care_about"] == ["L1", "L3"] + + + @patch("expert_backend.services.recommender_service.run_analysis_step2") + def test_run_analysis_step2_handles_empty_selection(self, mock_run_step2, service): + """Verify that empty selected_overloads results in empty targets but doesn't crash.""" + mock_run_step2.return_value = { + "prioritized_actions": {}, + "action_scores": {}, + "lines_overloaded_names": ["L1", "L2"] + } + + service._analysis_context = { + "lines_overloaded_names": ["L1", "L2"], + "lines_overloaded_ids": [0, 1], + "lines_overloaded_ids_kept": [0, 1], + "lines_we_care_about": {"L1", "L2", "L3"} + } + + list(service.run_analysis_step2(selected_overloads=[], all_overloads=["L1", "L2"], monitor_deselected=False)) + + assert service._analysis_context["lines_overloaded_names"] == [] + assert service._analysis_context["lines_overloaded_ids"] == [] + assert service._analysis_context["lines_we_care_about"] == {"L3"} + + @patch("expert_backend.services.recommender_service.run_analysis_step2") + def test_run_analysis_step2_handles_invalid_names_in_selection(self, mock_run_step2, service): + """Verify that invalid names in selected_overloads are ignored.""" + mock_run_step2.return_value = { + "prioritized_actions": {}, + "action_scores": {}, + "lines_overloaded_names": ["L1", "L2"] + } + + service._analysis_context = { + "lines_overloaded_names": ["L1", "L2"], + "lines_overloaded_ids": [0, 1], + "lines_overloaded_ids_kept": [0, 1], + "lines_we_care_about": {"L1", "L2", "L3"} + } + + # Select "L1" and "INVALID" + list(service.run_analysis_step2( + selected_overloads=["L1", "INVALID"], + all_overloads=["L1", "L2"], + monitor_deselected=False + )) + + assert service._analysis_context["lines_overloaded_names"] == ["L1"] + assert service._analysis_context["lines_overloaded_ids"] == [0] + # "L2" was in all_overloads but not in selected, so it should be removed from care + assert service._analysis_context["lines_we_care_about"] == {"L1", "L3"} + diff --git a/expert_backend/tests/test_recommender_service.py b/expert_backend/tests/test_recommender_service.py index 7fda7041..9d1ac328 100644 --- a/expert_backend/tests/test_recommender_service.py +++ b/expert_backend/tests/test_recommender_service.py @@ -7,10 +7,11 @@ from expert_op4grid_recommender import config class TestRecommenderService: + @patch("expert_backend.services.recommender_service.enrich_actions_lazy") @patch("expert_backend.services.recommender_service.load_actions") @patch("expert_backend.services.network_service.network_service") @patch("builtins.open", new_callable=mock_open) - def test_update_config_applies_settings(self, mock_file, mock_network_service, mock_load_actions): + def test_update_config_applies_settings(self, mock_file, mock_network_service, mock_load_actions, mock_enrich): # Set up mocks mock_load_actions.return_value = {"existing_action": {}} mock_network_service.get_disconnectable_elements.return_value = ["line1", "line2"] @@ -65,6 +66,7 @@ def test_update_config_applies_settings(self, mock_file, mock_network_service, m def test_update_config_defaults(self): # We also want to verify that default settings work as expected with patch("expert_backend.services.recommender_service.load_actions") as mock_load, \ + patch("expert_backend.services.recommender_service.enrich_actions_lazy") as mock_enrich, \ patch("expert_backend.services.network_service.network_service") as mock_ns, \ patch("builtins.open", mock_open()): diff --git a/expert_backend/tests/test_recommender_simulation.py b/expert_backend/tests/test_recommender_simulation.py new file mode 100644 index 00000000..c249221b --- /dev/null +++ b/expert_backend/tests/test_recommender_simulation.py @@ -0,0 +1,169 @@ +import pytest +import os +from pathlib import Path +from unittest.mock import MagicMock, patch +from expert_backend.services.recommender_service import RecommenderService +from expert_op4grid_recommender import config +from expert_op4grid_recommender.data_loader import load_actions, enrich_actions_lazy + +class TestRecommenderSimulationRealData: + @classmethod + def setup_class(cls): + # Use the small test grid included in the repo + cls.test_env_path = Path(__file__).parent.parent.parent / "data" / "bare_env_small_grid_test" + cls.grid_path = cls.test_env_path / "grid.xiidm" + + # New action dictionary without pre-computed content (lazy format) + cls.action_path = Path(__file__).parent.parent.parent / "data" / "action_space" / "reduced_model_actions_test_pypowsybl.json" + + # Ensure test data exists + if not cls.grid_path.exists(): + pytest.skip(f"Test grid not found at {cls.grid_path}") + if not cls.action_path.exists(): + pytest.skip(f"Action file not found at {cls.action_path}") + + def setup_method(self): + self.service = RecommenderService() + # Point config to our test data + config.ENV_PATH = self.test_env_path + # Start with empty action dict + self.service._dict_action = {} + + def test_get_network_variants_caching_real(self): + """Verify that variants are cached and not recreated if they already exist.""" + # 1. Base network should be cached + n1 = self.service._get_base_network() + n2 = self.service._get_base_network() + assert n1 is n2 + + # 2. N variant should be created once + v_n = self.service._get_n_variant() + assert v_n == "N_state_cached" + assert v_n in n1.get_variant_ids() + + # Check that calling it again doesn't trigger another clone_variant + with patch.object(n1, "clone_variant") as mock_clone: + v_n_2 = self.service._get_n_variant() + assert v_n_2 == "N_state_cached" + mock_clone.assert_not_called() + + # 3. N-1 variant should be created once per contingency + # Let's find a valid line ID from the network + line_ids = n1.get_line_ids() + if not line_ids: + pytest.skip("No lines in test grid") + + target_line = line_ids[0] + v_n1 = self.service._get_n1_variant(target_line) + expected_v1 = f"N_1_state_{target_line}" + assert v_n1 == expected_v1 + assert v_n1 in n1.get_variant_ids() + + with patch.object(n1, "clone_variant") as mock_clone: + v_n1_repeat = self.service._get_n1_variant(target_line) + assert v_n1_repeat == expected_v1 + mock_clone.assert_not_called() + + def test_simulation_env_is_cached(self): + """Verify that _get_simulation_env returns the same instance and shares the network.""" + env1 = self.service._get_simulation_env() + env2 = self.service._get_simulation_env() + + assert env1 is env2 + # Check that it uses the base network we load + assert env1.network_manager.network is self.service._get_base_network() + + def test_load_actions_lazy_no_content(self): + """Verify that the new action dictionary has no pre-computed content fields, + and that enrich_actions_lazy wraps them so content is accessible on demand.""" + raw_actions = load_actions(self.action_path) + + # Switch-topology actions should not have pre-computed content + switch_actions = {k: v for k, v in raw_actions.items() if "switches" in v and not k.startswith("disco_")} + assert switch_actions, "Expected at least one switch-topology action in the new JSON" + for action_id, action_data in switch_actions.items(): + assert "content" not in action_data, ( + f"Action '{action_id}' should not have pre-computed content in the new JSON format" + ) + + # disco_* actions should have no content and no switches + disco_actions = {k: v for k, v in raw_actions.items() if k.startswith("disco_")} + assert disco_actions, "Expected disco_ actions in the test action dictionary" + for action_id, action_data in disco_actions.items(): + assert "content" not in action_data + assert "switches" not in action_data + + def test_enrich_actions_lazy_provides_content(self): + """Verify that enrich_actions_lazy wraps actions so 'content' key is accessible + even when absent from the raw JSON, using LazyActionDict.""" + n = self.service._get_base_network() + raw_actions = load_actions(self.action_path) + lazy_actions = enrich_actions_lazy(raw_actions, n) + + # Every action should report 'content' as present (LazyActionDict.__contains__ always True) + for action_id, action_data in lazy_actions.items(): + assert "content" in action_data, ( + f"Action '{action_id}' should expose 'content' via LazyActionDict" + ) + + # Accessing content on a switch-topology action should trigger computation + switch_actions = {k: v for k, v in lazy_actions.items() if "switches" in v and not k.startswith("disco_")} + if switch_actions: + first_id, first_action = next(iter(switch_actions.items())) + content = first_action["content"] + assert isinstance(content, dict), f"Expected content to be a dict for action '{first_id}'" + assert "set_bus" in content, f"Expected 'set_bus' key in content for action '{first_id}'" + + # Accessing content on a disco_ action should also work + disco_actions = {k: v for k, v in lazy_actions.items() if k.startswith("disco_")} + if disco_actions: + first_disco_id, first_disco = next(iter(disco_actions.items())) + content = first_disco["content"] + assert isinstance(content, dict), f"Expected content to be a dict for action '{first_disco_id}'" + + def test_simulate_manual_action_sets_correct_variant(self): + """Verify that simulate_manual_action correctly switches variants and sets obs._variant_id, + using a LazyActionDict-wrapped action with the new JSON format (switches, no pre-computed content).""" + n = self.service._get_base_network() + line_ids = n.get_line_ids() + if not line_ids: + pytest.skip("No lines in test grid") + target_line = line_ids[0] + + # Load and enrich actions from the new JSON (lazy format) + raw_actions = load_actions(self.action_path) + lazy_actions = enrich_actions_lazy(raw_actions, n) + self.service._dict_action = lazy_actions + + # Pick any non-disco action that has switches, or fall back to a disco_ action + switch_action_ids = [k for k, v in lazy_actions.items() if "switches" in v and not k.startswith("disco_")] + action_id = switch_action_ids[0] if switch_action_ids else next(iter(lazy_actions)) + + env = self.service._get_simulation_env() + + with patch.object(env, "action_space") as mock_action_space, \ + patch.object(self.service, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(self.service, "_compute_deltas", return_value={}): + + mock_obs = MagicMock() + mock_obs.simulate.return_value = (MagicMock(), None, None, {"exception": None}) + mock_obs.name_line = ["LINE_1"] + mock_obs.rho = [0.1] + + def wrapped_get_obs(): + return mock_obs + + with patch.object(env, "get_obs", side_effect=wrapped_get_obs) as mock_get_obs: + self.service.simulate_manual_action(action_id, target_line) + + # Check that it called get_obs twice (one for N, one for N-1) + assert mock_get_obs.call_count == 2 + + # Check that after the second call, the observation was tagged with the N-1 variant ID + # In the actual code: obs_simu_defaut._variant_id = n1_variant_id + # env.get_obs() uses side_effect=wrapped_get_obs which returns mock_obs; + # mock_get_obs.return_value is a separate auto-generated mock, not mock_obs. + assert mock_obs._variant_id == f"N_1_state_{target_line}" + + # Check that _last_disconnected_element was updated + assert self.service._last_disconnected_element == target_line diff --git a/expert_backend/tests/test_split_analysis.py b/expert_backend/tests/test_split_analysis.py new file mode 100644 index 00000000..12c47634 --- /dev/null +++ b/expert_backend/tests/test_split_analysis.py @@ -0,0 +1,69 @@ +import json +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + +@pytest.fixture +def mock_rs(): + """Mock the recommender service.""" + with patch("expert_backend.main.recommender_service") as mock: + yield mock + +@pytest.fixture +def client(mock_rs): + """Create a test client with mocked service.""" + from expert_backend.main import app + return TestClient(app) + +def test_run_analysis_step1_success(client, mock_rs): + """Verify Step 1 endpoint returns overloads.""" + mock_rs.run_analysis_step1.return_value = { + "lines_overloaded": ["LINE_1", "LINE_2"], + "message": "Detected 2 overloads.", + "can_proceed": True + } + + response = client.post("/api/run-analysis-step1", json={"disconnected_element": "GEN_1"}) + assert response.status_code == 200 + data = response.json() + assert data["lines_overloaded"] == ["LINE_1", "LINE_2"] + assert data["can_proceed"] is True + mock_rs.run_analysis_step1.assert_called_with("GEN_1") + +def test_run_analysis_step2_success(client, mock_rs): + """Verify Step 2 endpoint streams results and respects selection.""" + def fake_step2(overloads, **kwargs): + yield {"type": "pdf", "pdf_path": "/tmp/graph.pdf"} + yield {"type": "result", "actions": {}, "action_scores": {}, "lines_overloaded": overloads} + + mock_rs.run_analysis_step2.side_effect = fake_step2 + + response = client.post("/api/run-analysis-step2", json={ + "selected_overloads": ["LINE_1"], + "all_overloads": ["LINE_1", "LINE_2"], + "monitor_deselected": True + }) + assert response.status_code == 200 + + lines = [line for line in response.text.strip().split("\n") if line.strip()] + assert len(lines) == 2 + pdf_event = json.loads(lines[0]) + result_event = json.loads(lines[1]) + + assert pdf_event["type"] == "pdf" + assert pdf_event["pdf_url"] == "/results/pdf/graph.pdf" + assert result_event["type"] == "result" + assert result_event["lines_overloaded"] == ["LINE_1"] + mock_rs.run_analysis_step2.assert_called_with( + ["LINE_1"], + all_overloads=["LINE_1", "LINE_2"], + monitor_deselected=True + ) + +def test_run_analysis_step1_error(client, mock_rs): + """Verify Step 1 handles errors.""" + mock_rs.run_analysis_step1.side_effect = Exception("Step 1 failed") + + response = client.post("/api/run-analysis-step1", json={"disconnected_element": "GEN_1"}) + assert response.status_code == 400 + assert "Step 1 failed" in response.json()["detail"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b65afd44..540cbfc9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,7 +79,6 @@ function App() { try { // Clear previous results to ensure consistency with new settings BEFORE fetching setResult(null); - setPendingAnalysisResult(null); setNDiagram(null); setN1Diagram(null); setActionDiagram(null); @@ -153,12 +152,15 @@ function App() { // ===== Analysis State ===== const [result, setResult] = useState(null); - const [pendingAnalysisResult, setPendingAnalysisResult] = useState(null); const [selectedActionIds, setSelectedActionIds] = useState>(new Set()); const [manuallyAddedIds, setManuallyAddedIds] = useState>(new Set()); const [rejectedActionIds, setRejectedActionIds] = useState>(new Set()); const [analysisLoading, setAnalysisLoading] = useState(false); const [infoMessage, setInfoMessage] = useState(''); + + // ===== Analysis Flow State ===== + const [selectedOverloads, setSelectedOverloads] = useState>(new Set()); + const [monitorDeselected, setMonitorDeselected] = useState(false); // ===== Visualization State ===== const [activeTab, setActiveTab] = useState('n'); @@ -208,7 +210,6 @@ function App() { setNDiagram(null); setN1Diagram(null); setResult(null); - setPendingAnalysisResult(null); setSelectedActionId(null); setSelectedActionIds(new Set()); setManuallyAddedIds(new Set()); @@ -311,23 +312,74 @@ function App() { }, [selectedBranch, branches, voltageLevels.length]); // ===== Analysis ===== - const handleRunAnalysis = useCallback(async () => { + // Sync available overloads from N-1 diagram for pre-selection + useEffect(() => { + if (n1Diagram && n1Diagram.lines_overloaded) { + setSelectedOverloads(new Set(n1Diagram.lines_overloaded)); + } else { + setSelectedOverloads(new Set()); + } + }, [n1Diagram]); + + const handleRunAnalysis = async () => { if (!selectedBranch) return; setAnalysisLoading(true); setError(''); setInfoMessage(''); setSelectedActionId(null); setActionDiagram(null); - setPendingAnalysisResult(null); + setResult(null); try { - const response = await fetch('http://localhost:8000/api/run-analysis', { + // Step 1: Detection + const res1 = await api.runAnalysisStep1(selectedBranch); + if (!res1.can_proceed) { + setError(res1.message || 'Analysis cannot proceed.'); + if (res1.message) setInfoMessage(res1.message); + setAnalysisLoading(false); + return; + } + + const detected = res1.lines_overloaded || []; + + // Resolve: selected overloads focus the analysis. If monitorDeselected, also pass unselected ones. + let primaryOverloads: string[] = []; + if (selectedOverloads.size > 0) { + const stillRelevant = detected.filter(name => selectedOverloads.has(name)); + if (stillRelevant.length > 0) { + primaryOverloads = stillRelevant; + } else { + setSelectedOverloads(new Set(detected)); + primaryOverloads = detected; + } + } else { + setSelectedOverloads(new Set(detected)); + primaryOverloads = detected; + } + + // The backend knows which ones to monitor via the monitor_deselected flag. + // selected_overloads MUST only contain the ones we actually want to resolve. + const toResolve = primaryOverloads; + + if (detected.length === 0) { + setInfoMessage(res1.message || "No overloads detected."); + setAnalysisLoading(false); + return; + } + + // Step 2: Resolution + const response2 = await fetch('http://localhost:8000/api/run-analysis-step2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disconnected_element: selectedBranch }), + body: JSON.stringify({ + selected_overloads: toResolve, + all_overloads: detected, + monitor_deselected: monitorDeselected, + }), }); - if (!response.ok) throw new Error('Analysis failed'); - const reader = response.body!.getReader(); + if (!response2.ok) throw new Error('Analysis Resolution failed'); + + const reader = response2.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { @@ -341,46 +393,50 @@ function App() { try { const event = JSON.parse(line); if (event.type === 'pdf') { - setResult(p => ({ ...p!, pdf_url: event.pdf_url } as AnalysisResult)); - setActiveTab('overflow'); // Switch tab right as PDF comes in - } - else if (event.type === 'result') { - // Store analysis result as pending โ€” don't merge yet - setPendingAnalysisResult(event); + setResult((p: AnalysisResult | null) => ({ ...p!, pdf_url: event.pdf_url } as AnalysisResult)); + setActiveTab('overflow'); + } else if (event.type === 'result') { + setResult((prev: AnalysisResult | null) => { + const manualActionsData: Record = {}; + if (prev?.actions) { + for (const [id, data] of Object.entries(prev.actions)) { + if (manuallyAddedIds.has(id)) { + manualActionsData[id] = data; + } + } + } + return { + ...event, + // Preserve pdf_url set by the earlier 'pdf' event + pdf_url: prev?.pdf_url || event.pdf_url, + actions: { ...event.actions, ...manualActionsData } + } as AnalysisResult; + }); if (event.message) setInfoMessage(event.message); + } else if (event.type === 'error') { + setError('Analysis failed: ' + event.message); } - else if (event.type === 'error') setError('Analysis failed: ' + event.message); } catch (e) { console.error('Stream error:', e); } } } } catch (err: unknown) { - setError('Analysis failed: ' + (err as Error).message); + const e = err as { response?: { data?: { detail?: string } }; message?: string }; + setError('Analysis failed: ' + (e.response?.data?.detail || e.message)); } finally { setAnalysisLoading(false); } - }, [selectedBranch]); + }; - const handleDisplayPrioritizedActions = useCallback(() => { - if (!pendingAnalysisResult) return; - setResult(prev => { - // Preserve manually added / selected actions - const manualActionsData: Record = {}; - if (prev?.actions) { - for (const [id, data] of Object.entries(prev.actions)) { - if (selectedActionIds.has(id)) { - manualActionsData[id] = data; - } - } - } - return { - ...pendingAnalysisResult, - actions: { ...pendingAnalysisResult.actions, ...manualActionsData }, - }; + const handleToggleOverload = useCallback((overload: string) => { + setSelectedOverloads((prev: Set) => { + const next = new Set(prev); + if (next.has(overload)) next.delete(overload); + else next.add(overload); + return next; }); - setPendingAnalysisResult(null); - }, [pendingAnalysisResult, selectedActionIds]); + }, []); // ===== Action Selection ===== const handleActionSelect = useCallback(async (actionId: string | null) => { @@ -1081,44 +1137,27 @@ function App() { {branches.map(b => - + )} - {showMonitoringWarning && totalLinesCount > 0 && ( -
- โš ๏ธ {monitoredLinesCount} out of {totalLinesCount} lines monitored ({totalLinesCount - (monitoredLinesCount || 0)} without permanent limits). Monitoring factor: {Math.round((monitoringFactor || 0.95) * 100)}%. {Math.round((preExistingOverloadThreshold || 0.02) * 100)}% loading increase threshold for considerando worsened overload in N. - - -
- )}
setShowMonitoringWarning(false)} onOpenSettings={() => { setIsSettingsOpen(true); setSettingsTab('configurations'); }} + selectedOverloads={selectedOverloads} + onToggleOverload={handleToggleOverload} + monitorDeselected={monitorDeselected} + onToggleMonitorDeselected={() => setMonitorDeselected(prev => !prev)} />
@@ -1142,7 +1185,6 @@ function App() { selectedActionIds={selectedActionIds} rejectedActionIds={rejectedActionIds} manuallyAddedIds={manuallyAddedIds} - pendingAnalysisResult={pendingAnalysisResult} onActionSelect={handleActionSelect} onActionFavorite={handleActionFavorite} onActionReject={handleActionReject} @@ -1151,7 +1193,6 @@ function App() { edgesByEquipmentId={nMetaIndex?.edgesByEquipmentId ?? null} disconnectedElement={selectedBranch || null} onManualActionAdded={handleManualActionAdded} - onDisplayPrioritizedActions={handleDisplayPrioritizedActions} analysisLoading={analysisLoading} monitoringFactor={monitoringFactor} onVlDoubleClick={handleVlDoubleClick} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a917a6ab..9d8c878e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -128,4 +128,45 @@ export const api = { ); return response.data; }, + runAnalysisStep1: async (disconnected_element: string): Promise<{ lines_overloaded: string[]; message: string; can_proceed: boolean }> => { + const response = await axios.post(`${API_BASE_URL}/api/run-analysis-step1`, { disconnected_element }); + return response.data; + }, + runAnalysisStep2: async (selected_overloads: string[]): Promise => { + const response = await fetch(`${API_BASE_URL}/api/run-analysis-step2`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selected_overloads }), + }); + if (!response.ok) { + throw new Error(`Analysis failed: ${response.statusText}`); + } + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let result: Partial = {}; + + for (; ;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + for (const line of lines) { + if (!line.trim()) continue; + const event = JSON.parse(line); + if (event.type === 'pdf') { + result.pdf_url = event.pdf_url; + result.pdf_path = event.pdf_path; + } else if (event.type === 'result') { + result = { ...result, ...event }; + } else if (event.type === 'error') { + throw new Error(event.message); + } + } + } + + return result as AnalysisResult; + }, }; diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 65dfa46b..741deed9 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; -import type { ActionDetail, NodeMeta, EdgeMeta, AnalysisResult, AvailableAction } from '../types'; +import type { ActionDetail, NodeMeta, EdgeMeta, AvailableAction } from '../types'; import { api } from '../api'; import { getActionTargetVoltageLevel, getActionTargetLines } from '../utils/svgUtils'; @@ -10,8 +10,6 @@ interface ActionFeedProps { selectedActionId: string | null; selectedActionIds: Set; rejectedActionIds: Set; - pendingAnalysisResult: AnalysisResult | null; - onDisplayPrioritizedActions: () => void; onActionSelect: (actionId: string | null) => void; onActionFavorite: (actionId: string) => void; onActionReject: (actionId: string) => void; @@ -33,8 +31,6 @@ const ActionFeed: React.FC = ({ selectedActionId, selectedActionIds, rejectedActionIds, - pendingAnalysisResult, - onDisplayPrioritizedActions, onActionSelect, onActionFavorite, onActionReject, @@ -616,30 +612,6 @@ const ActionFeed: React.FC = ({ >Rejected Actions {rejectedEntries.length > 0 && {rejectedEntries.length}}
- {/* Display prioritized actions button inside Suggested Actions section */} - {pendingAnalysisResult && !analysisLoading && ( - - )} {/* Loading indicator shown below existing cards during analysis */} {analysisLoading && ( @@ -654,11 +626,11 @@ const ActionFeed: React.FC = ({ {suggestedTab === 'prioritized' && ( prioritizedEntries.length > 0 ? renderActionList(prioritizedEntries) : ( - !analysisLoading && !pendingAnalysisResult ? ( -

Run analysis to get action suggestions.

- ) : (!analysisLoading && pendingAnalysisResult ? ( -

No suggested actions available.

- ) : null) + !analysisLoading ? ( +

+ {Object.keys(actions).length > 0 ? 'No suggested actions available.' : 'Run analysis to get action suggestions.'} +

+ ) : null ) )} {suggestedTab === 'rejected' && ( diff --git a/frontend/src/components/OverloadPanel.tsx b/frontend/src/components/OverloadPanel.tsx index 47a0de0f..b9c82455 100644 --- a/frontend/src/components/OverloadPanel.tsx +++ b/frontend/src/components/OverloadPanel.tsx @@ -11,6 +11,10 @@ interface OverloadPanelProps { preExistingOverloadThreshold?: number; onDismissWarning?: () => void; onOpenSettings?: () => void; + selectedOverloads?: Set; + onToggleOverload?: (overload: string) => void; + monitorDeselected?: boolean; + onToggleMonitorDeselected?: () => void; } const OverloadPanel: React.FC = ({ @@ -24,6 +28,10 @@ const OverloadPanel: React.FC = ({ preExistingOverloadThreshold, onDismissWarning, onOpenSettings, + selectedOverloads, + onToggleOverload, + monitorDeselected = false, + onToggleMonitorDeselected, }) => { const clickableLinkStyle: React.CSSProperties = { background: 'none', @@ -34,39 +42,60 @@ const OverloadPanel: React.FC = ({ color: '#1e40af', fontWeight: 600, textDecoration: 'underline dotted', + textAlign: 'left', + display: 'inline', }; const renderLinks = (lines: string[], tab: 'n' | 'n-1') => { if (!lines || lines.length === 0) return None; - return lines.map((lineName, i) => ( - - {i > 0 && ', '} - - - )); + return lines.map((lineName, i) => { + const isSelected = tab === 'n-1' ? (selectedOverloads?.has(lineName) ?? true) : true; + return ( + + {i > 0 && ', '} + + + ); + }); }; + const hasDeselected = n1Overloads.some(name => !(selectedOverloads?.has(name) ?? true)); + const deselectedCount = hasDeselected && selectedOverloads ? n1Overloads.filter(name => !selectedOverloads.has(name)).length : 0; + return (
-

+

โš ๏ธ Overloads

{showMonitoringWarning && totalLinesCount && totalLinesCount > 0 && (
= ({ fontSize: '0.8rem', position: 'relative' }}> - โš ๏ธ {monitoredLinesCount} out of {totalLinesCount} lines monitored ({totalLinesCount - (monitoredLinesCount || 0)} without permanent limits). Monitoring factor: {Math.round((monitoringFactor || 0.95) * 100)}%. {Math.round((preExistingOverloadThreshold || 0.02) * 100)}% loading increase threshold for considering worsened overload in N. + โš ๏ธ {monitorDeselected ? (monitoredLinesCount || 0) : (monitoredLinesCount || 0) - (hasDeselected ? deselectedCount : 0)} out of {totalLinesCount} lines monitored ({totalLinesCount - (monitoredLinesCount || 0)} without permanent limits{hasDeselected && !monitorDeselected ? `, and ${deselectedCount} deselected` : ''}). Monitoring factor: {Math.round((monitoringFactor || 0.95) * 100)}%. {Math.round((preExistingOverloadThreshold || 0.02) * 100)}% loading increase threshold for considering worsened overload in N.
)} -
+
0 ? '#fff3cd' : 'transparent', borderLeft: nOverloads.length > 0 ? '3px solid #ffc107' : '3px solid transparent', borderBottom: '1px solid #eee' @@ -111,18 +140,61 @@ const OverloadPanel: React.FC = ({
0 ? '#f8d7da' : 'transparent', borderLeft: n1Overloads.length > 0 ? '3px solid #dc3545' : '3px solid transparent', - borderBottom: '1px solid #eee' + borderBottom: '1px solid #eee', + lineHeight: '1.6', }}> - N-1 Overloads: -
+ N-1 Overloads: + + ? + + {hasDeselected && onToggleMonitorDeselected && ( + + )} + {renderLinks(n1Overloads, 'n-1')} -
+
diff --git a/pr_body.md b/pr_body.md new file mode 100644 index 00000000..da68d08e --- /dev/null +++ b/pr_body.md @@ -0,0 +1,37 @@ +# feat: Overload Monitoring Refinement and Comprehensive Testing + +This PR consolidates several improvements to the N-1 overload monitoring flow and introduces a robust testing suite for both backend logic and frontend integration. + +## ๐Ÿš€ Key Improvements + +### 1. Focused Analysis & Action Cards +- **Deselecting Overloads**: Users can now double-click specific N-1 overloads to deselect them, focusing the analysis on the most critical issues. +- **Improved Resolution Logic**: Restored action cards when "Monitor deselected" is checked. The system now correctly monitors all overloads while only attempting to resolve those currently selected. +- **Backend Filtering**: The backend now correctly excludes deselected overloads from the resolution mask (when not monitoring deselected), ensuring they don't impact the "Max loading" metrics in action cards. + +### 2. Dynamic Monitoring Warning +- **Real-time Updates**: The warning banner in the Overloads panel now dynamically reflects the count of monitored lines, including those effectively monitored even if deselected. +- **Informative Labels**: Added detailed labels like `(effective count) ... incl. X deselected` to provide better context. + +### 3. UI/UX Optimizations +- **Horizontal Panel Layout**: Refactored the N-1 Overloads list from a vertical stack to a horizontal flow, significantly reducing the vertical footprint and improving information density. +- **Synced Interfaces**: Synchronized the React frontend with the standalone HTML interface to ensure consistency across all access methods. + +## ๐Ÿงช Comprehensive Testing Suite + +Introduced a suite of API and service-level tests to ensure stability and correctness of the split analysis flow: +- **API Integration Tests**: + - Coverage for `/api/run-analysis-step1` and `step2`. + - Verification of streaming response events (`pdf`, `result`). + - Validation of parameter propagation (`selected_overloads`, `monitor_deselected`, `all_overloads`). +- **Service & Filtering Tests**: + - Edge case handling for empty or invalid overload selections. + - Robustness checks for the new split analysis orchestration. + +## ๐Ÿ›  Technical Changes +- **Backend**: Updated `expert_backend/main.py` and `recommender_service.py` to support the refined analysis flow and fixed PDF path resolution. +- **Frontend**: Significant refactor of `App.tsx`, `OverloadPanel.tsx`, and `ActionFeed.tsx` for better state management and UI performance. +- **New Tests**: Added `test_overload_filtering.py`, `test_recommender_simulation.py`, and `test_split_analysis.py`. + +--- +*Based on work from conversations 1cff2d49-4df1-4b36-ab6c-0577cf9248dd and f046a5e1-7850-4f96-ada9-b7656b533402.* diff --git a/standalone_interface.html b/standalone_interface.html index 51e46211..b73493ac 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -1086,6 +1086,12 @@ const [infoMessage, setInfoMessage] = useState(''); const [inspectQuery, setInspectQuery] = useState(''); + // Analysis Flow State + const [availableOverloads, setAvailableOverloads] = useState([]); + const [selectedOverloads, setSelectedOverloads] = useState(new Set()); + const [monitorDeselected, setMonitorDeselected] = useState(false); + const [analysisStep, setAnalysisStep] = useState('idle'); + // Visualization State const [activeTab, setActiveTab] = useState('n'); // 'n', 'n-1', 'overflow' const activeTabRef = useRef(activeTab); @@ -1350,21 +1356,80 @@ }, [selectedBranch, branches]); // Auto-switch to overflow tab when analysis runs + // Sync available overloads from N-1 diagram for pre-selection + useEffect(() => { + if (n1Diagram && n1Diagram.lines_overloaded) { + setAvailableOverloads(n1Diagram.lines_overloaded); + setSelectedOverloads(new Set(n1Diagram.lines_overloaded)); + } else { + setAvailableOverloads([]); + setSelectedOverloads(new Set()); + } + }, [n1Diagram]); + const handleRunAnalysis = async () => { if (!selectedBranch) return; - setAnalysisLoading(true); setError(''); setInfoMessage(''); + setAnalysisLoading(true); + setError(''); + setInfoMessage(''); setAnalysisRan(false); setPendingAnalysisResult(null); - // Keep result (manual actions) intact โ€” don't clear it + setResult(null); try { - const response = await fetch(API_BASE + '/api/run-analysis', { + // Step 1: Detection + const response1 = await axios.post(API_BASE + '/api/run-analysis-step1', { + disconnected_element: selectedBranch + }); + const res1 = response1.data; + + if (!res1.can_proceed) { + setError(res1.message || 'Analysis cannot proceed.'); + if (res1.message) setInfoMessage(res1.message); + setAnalysisLoading(false); + return; + } + + const detected = res1.lines_overloaded || []; + setAvailableOverloads(detected); + + // Resolve: selected overloads focus the analysis. + let primaryOverloads = detected; + if (selectedOverloads.size > 0) { + const stillRelevant = detected.filter(name => selectedOverloads.has(name)); + if (stillRelevant.length > 0) { + primaryOverloads = stillRelevant; + } else { + setSelectedOverloads(new Set(detected)); + } + } else { + setSelectedOverloads(new Set(detected)); + } + + // The backend knows which ones to monitor via the monitor_deselected flag. + // selected_overloads MUST only contain the ones we actually want to resolve. + const toResolve = primaryOverloads; + + if (detected.length === 0) { + setInfoMessage(res1.message || "No overloads detected."); + setAnalysisRan(true); + setAnalysisLoading(false); + return; + } + + // Step 2: Resolution + const response2 = await fetch(API_BASE + '/api/run-analysis-step2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disconnected_element: selectedBranch }) + body: JSON.stringify({ + selected_overloads: toResolve, + all_overloads: detected, + monitor_deselected: monitorDeselected, + }) }); - if (!response.ok) throw new Error('Analysis failed'); - const reader = response.body.getReader(); + if (!response2.ok) throw new Error('Analysis Resolution failed'); + + const reader = response2.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { @@ -1372,18 +1437,32 @@ if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); - buffer = lines.pop(); // Keep incomplete line in buffer + buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; try { const event = JSON.parse(line); if (event.type === 'pdf') { setResult(p => ({ ...p, pdf_url: event.pdf_url })); - setActiveTab('overflow'); // Switch tab right as PDF comes in + setActiveTab('overflow'); } else if (event.type === 'result') { - // Store analysis result as pending โ€” don't merge yet - setPendingAnalysisResult(event); + setResult(prev => { + const manualActionsData = {}; + if (prev?.actions) { + for (const [id, data] of Object.entries(prev.actions)) { + if (manualActionIds.has(id)) { + manualActionsData[id] = data; + } + } + } + return { + ...event, + // Preserve pdf_url set by the earlier 'pdf' event + pdf_url: prev?.pdf_url || event.pdf_url, + actions: { ...event.actions, ...manualActionsData } + }; + }); if (event.message) setInfoMessage(event.message); } else if (event.type === 'error') setError('Analysis failed: ' + event.message); @@ -1392,8 +1471,12 @@ } } } - } catch (err) { setError('Analysis failed: ' + err.message); } - finally { setAnalysisLoading(false); setAnalysisRan(true); } + setAnalysisRan(true); + } catch (err) { + setError('Analysis failed: ' + (err.response?.data?.detail || err.message)); + } finally { + setAnalysisLoading(false); + } }; // Merge pending analysis result with existing manual actions @@ -3012,30 +3095,43 @@

๐ŸŽฏ Select Contingency setSelectedBranch(e.target.value)} placeholder="Search line/bus..." style={{ width: '100%', padding: '7px 10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box', fontSize: '0.85rem' }} /> {branches.map(b => -

)} +
-

- โš ๏ธ Overloads -

+
+

+ โš ๏ธ Overloads +

+
{showMonitoringWarning && totalLinesCount > 0 && ( -
- โš ๏ธ {monitoredLinesCount} out of {totalLinesCount} lines monitored ({totalLinesCount - monitoredLinesCount} without permanent limits). Monitoring factor applied: {Math.round(monitoringFactor * 100)}%. {Math.round(preExistingOverloadThreshold * 100)}% loading increase threshold for considering worsened overload in N. - +
+ โš ๏ธ {(() => { + const deselCount = (n1Diagram && n1Diagram.lines_overloaded && !monitorDeselected) ? n1Diagram.lines_overloaded.filter(name => !selectedOverloads.has(name)).length : 0; + const effectiveMonitored = monitoredLinesCount - deselCount; + const effectiveUnmonitored = totalLinesCount - effectiveMonitored; + const unmonitoredStatic = totalLinesCount - monitoredLinesCount; + return {effectiveMonitored} out of {totalLinesCount} lines monitored ({unmonitoredStatic} without permanent limits{deselCount > 0 && !monitorDeselected ? `, and ${deselCount} deselected` : ''}). Monitoring factor applied: {Math.round(monitoringFactor * 100)}%. {Math.round(preExistingOverloadThreshold * 100)}% loading increase threshold for considering worsened overload in N.; + })()} +
)} -
+
0 ? '#ffe0cc' : 'transparent', borderLeft: nDiagram && nDiagram.lines_overloaded && nDiagram.lines_overloaded.length > 0 ? '3px solid #ff8c00' : '3px solid transparent', borderBottom: '1px solid #eee' @@ -3056,27 +3152,96 @@

0 ? '#f8d7da' : 'transparent', borderLeft: n1Diagram && n1Diagram.lines_overloaded && n1Diagram.lines_overloaded.length > 0 ? '3px solid #dc3545' : '3px solid transparent', - borderBottom: '1px solid #eee' + borderBottom: '1px solid #eee', + lineHeight: '1.6' }}> - N-1 Overloads: -
+ N-1 Overloads: + + ? + + {n1Diagram && n1Diagram.lines_overloaded && n1Diagram.lines_overloaded.some(name => !selectedOverloads.has(name)) && ( + + )} + {n1Diagram && n1Diagram.lines_overloaded && n1Diagram.lines_overloaded.length > 0 ? ( - n1Diagram.lines_overloaded.map((lvl, i) => ( - - {i > 0 && ', '} - - - )) + n1Diagram.lines_overloaded.map((lvl, i) => { + const isSelected = selectedOverloads.has(lvl); + return ( + + {i > 0 && ', '} + + + ); + }) ) : ( - None + None )} -
+

@@ -3356,38 +3521,15 @@

Rejected Actions {rejectedEntries.length > 0 && {rejectedEntries.length}}

- {/* Display prioritized actions button inside Suggested Actions section */} - {pendingAnalysisResult && !analysisLoading && ( - - )} + {/* Suggestions area - results appear here naturally after analysis */} {suggestedTab === 'prioritized' && ( prioritizedEntries.length > 0 ? renderActionList(prioritizedEntries) : ( - !analysisLoading && !pendingAnalysisResult ? ( -

Run analysis to get action suggestions.

- ) : (!analysisLoading && pendingAnalysisResult ? ( -

No suggested actions available.

- ) : null) + !analysisLoading ? ( +

+ {analysisRan ? 'No suggested actions available.' : 'Run analysis to get action suggestions.'} +

+ ) : null ) )} {suggestedTab === 'rejected' && (