Spaces:
Sleeping
Sleeping
| # 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. | |
| """Issue 3 — annotate the charging-current loading of a half-open overloaded line. | |
| When an action leaves a still-"overloaded" line open at ONE end, the line is out | |
| of service for active power (the SLD / NAD show p = 0) but its capacitance keeps | |
| drawing reactive charging current from the live end, so its current-based loading | |
| stays non-zero (the reported ~33 %). This is physically correct, not a bug — so | |
| we keep the value and surface the live-end reactive power, letting the ActionCard | |
| explain it instead of suppressing it. | |
| Covers the variant-reading seam ``half_open_branch_reactive_from_obs`` and the | |
| gating in ``half_open_overload_notes``. The pure ``build_half_open_reactive`` | |
| df→dict is covered in ``test_simulation_helpers.py``. | |
| """ | |
| from unittest.mock import MagicMock | |
| import pandas as pd | |
| from expert_backend.services.simulation_helpers import ( | |
| half_open_branch_reactive_from_obs, | |
| half_open_overload_notes, | |
| ) | |
| def _lines_df(rows: dict) -> pd.DataFrame: | |
| return pd.DataFrame.from_dict(rows, orient="index") | |
| def _empty_trafos() -> pd.DataFrame: | |
| return pd.DataFrame(columns=["name", "connected1", "connected2", "q1", "q2"]) | |
| def _obs_with_variant(lines_df): | |
| obs = MagicMock() | |
| obs._variant_id = "action_var" | |
| network = MagicMock() | |
| network.get_working_variant_id.return_value = "orig" | |
| network.get_lines.return_value = lines_df | |
| network.get_2_windings_transformers.return_value = _empty_trafos() | |
| nm = MagicMock() | |
| nm.network = network | |
| obs._network_manager = nm | |
| return obs, nm | |
| class TestHalfOpenBranchReactiveFromObs: | |
| def test_reads_live_end_reactive_by_id_and_name(self): | |
| obs, nm = _obs_with_variant( | |
| _lines_df( | |
| { | |
| "relation_8423569-225": { | |
| "name": "MARSIL61PRAGN", | |
| "connected1": True, "connected2": False, | |
| "q1": -16.8, "q2": 0.0, | |
| }, | |
| "L2": {"name": "L2NAME", "connected1": True, "connected2": True, | |
| "q1": 1.0, "q2": -1.0}, | |
| } | |
| ) | |
| ) | |
| out = half_open_branch_reactive_from_obs(obs) | |
| assert out["relation_8423569-225"] == 16.8 | |
| assert out["MARSIL61PRAGN"] == 16.8 | |
| assert "L2" not in out # fully connected → not half open | |
| def test_switches_to_action_variant_then_restores(self): | |
| obs, nm = _obs_with_variant( | |
| _lines_df({"L1": {"name": "L1", "connected1": True, "connected2": True, | |
| "q1": 0.0, "q2": 0.0}}) | |
| ) | |
| half_open_branch_reactive_from_obs(obs) | |
| nm.set_working_variant.assert_any_call("action_var") | |
| # Shared network always restored to its prior working variant. | |
| assert nm.set_working_variant.call_args_list[-1].args == ("orig",) | |
| def test_missing_network_manager_returns_empty(self): | |
| obs = MagicMock() | |
| obs._network_manager = None | |
| obs._variant_id = "action_var" | |
| assert half_open_branch_reactive_from_obs(obs) == {} | |
| class TestHalfOpenOverloadNotes: | |
| def test_annotates_half_open_overloaded_line_above_1pct(self): | |
| obs, _ = _obs_with_variant( | |
| _lines_df( | |
| { | |
| "MARSIL61PRAGN": { | |
| "name": "MARSIL61PRAGN", | |
| "connected1": True, "connected2": False, | |
| "q1": -16.8, "q2": 0.0, | |
| }, | |
| } | |
| ) | |
| ) | |
| # MARSIL61PRAGN overloaded, now at 33 % (charging current) → annotate. | |
| notes = half_open_overload_notes( | |
| obs, ["MARSIL61PRAGN", "L2"], rho_after=[0.333, 0.4] | |
| ) | |
| assert notes == {"MARSIL61PRAGN": 16.8} | |
| def test_loading_at_or_below_1pct_is_not_annotated(self): | |
| obs, _ = _obs_with_variant( | |
| _lines_df( | |
| { | |
| "L": {"name": "L", "connected1": True, "connected2": False, | |
| "q1": -0.2, "q2": 0.0}, | |
| } | |
| ) | |
| ) | |
| # Half open but loading is only 0.5 % → below the 1 % info threshold. | |
| notes = half_open_overload_notes(obs, ["L"], rho_after=[0.005]) | |
| assert notes == {} | |
| def test_fully_connected_overloaded_line_is_not_annotated(self): | |
| obs, _ = _obs_with_variant( | |
| _lines_df( | |
| {"L": {"name": "L", "connected1": True, "connected2": True, | |
| "q1": 5.0, "q2": -5.0}} | |
| ) | |
| ) | |
| notes = half_open_overload_notes(obs, ["L"], rho_after=[1.2]) | |
| assert notes == {} | |
| def test_no_overloads_returns_empty(self): | |
| obs, _ = _obs_with_variant(_empty_trafos()) | |
| assert half_open_overload_notes(obs, [], rho_after=[]) == {} | |