# 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 """Unit tests for the stateless helpers extracted from analysis_mixin. These cover the pure functions under ``services/analysis/`` in isolation — no ``RecommenderService`` instance, no network layer. """ from __future__ import annotations import os import threading from types import SimpleNamespace from unittest.mock import MagicMock import numpy as np import pytest from expert_backend.services.analysis import ( action_enrichment, analysis_runner, mw_start_scoring, pdf_watcher, ) class TestRunWithPdfPollingWatchdog: """QW22 — the legacy analysis poll loop must not hang forever if the background worker never finishes (it would wedge the D3 study gate).""" def test_raises_timeout_when_worker_never_finishes(self, tmp_path, monkeypatch): monkeypatch.setattr(analysis_runner, "_ANALYSIS_DEADLINE_S", 0.3) release = threading.Event() def blocking_runner(**kwargs): # Simulate a hung analysis: block until the test releases us so the # worker never sets shared_state['done']. release.wait(timeout=5) return None gen = analysis_runner.run_with_pdf_polling( [], str(tmp_path), runner_fn=blocking_runner ) try: with pytest.raises(TimeoutError): for _ in gen: pass finally: release.set() # let the daemon worker exit cleanly def test_completes_normally_when_worker_finishes_before_deadline(self, tmp_path, monkeypatch): monkeypatch.setattr(analysis_runner, "_ANALYSIS_DEADLINE_S", 5.0) def quick_runner(**kwargs): return {"ok": True} events = list( analysis_runner.run_with_pdf_polling([], str(tmp_path), runner_fn=quick_runner) ) final = events[-1] assert final["_final"] is True assert final["result"] == {"ok": True} # ---------------------------------------------------------------------- # pdf_watcher # ---------------------------------------------------------------------- class TestFindLatestPdf: def test_returns_none_for_empty_folder(self, tmp_path): assert pdf_watcher.find_latest_pdf(str(tmp_path)) is None def test_picks_the_newest_pdf(self, tmp_path): a = tmp_path / "old.pdf" b = tmp_path / "new.pdf" a.write_bytes(b"a") b.write_bytes(b"b") os.utime(a, (100, 100)) os.utime(b, (200, 200)) assert pdf_watcher.find_latest_pdf(str(tmp_path)) == str(b) def test_filters_by_analysis_start_time(self, tmp_path): stale = tmp_path / "stale.pdf" fresh = tmp_path / "fresh.pdf" stale.write_bytes(b"a") fresh.write_bytes(b"b") os.utime(stale, (100, 100)) os.utime(fresh, (500, 500)) # Only PDFs newer than 400 should be returned (minus 1s safety margin). assert pdf_watcher.find_latest_pdf(str(tmp_path), analysis_start_time=400) == str(fresh) def test_returns_none_when_all_pdfs_predate_start_time(self, tmp_path): p = tmp_path / "x.pdf" p.write_bytes(b"a") os.utime(p, (100, 100)) assert pdf_watcher.find_latest_pdf(str(tmp_path), analysis_start_time=500) is None def test_mtime_safety_margin_accepts_almost_new_pdfs(self, tmp_path): # A PDF written 0.5s before the start time must still be picked up # — the safety margin is 1s. p = tmp_path / "x.pdf" p.write_bytes(b"a") os.utime(p, (99.5, 99.5)) assert pdf_watcher.find_latest_pdf(str(tmp_path), analysis_start_time=100.0) == str(p) def test_picks_html_only(self, tmp_path): # VISUALIZATION_FORMAT="html" is now the default — interactive # overflow viewer lands as .html in SAVE_FOLDER_VISUALIZATION. html = tmp_path / "overflow.html" html.write_text("") os.utime(html, (200, 200)) assert pdf_watcher.find_latest_pdf(str(tmp_path)) == str(html) def test_picks_pdf_when_no_html(self, tmp_path): # Legacy fallback: if only a .pdf is present (e.g. older # expert_op4grid_recommender without HTML export), return it. pdf = tmp_path / "overflow.pdf" pdf.write_bytes(b"%PDF-1.4") os.utime(pdf, (200, 200)) assert pdf_watcher.find_latest_pdf(str(tmp_path)) == str(pdf) def test_picks_newest_html_over_older_pdf(self, tmp_path): # Mixed folder (e.g. a previous PDF-era run left a .pdf behind): # pick whichever is newest, regardless of extension. old_pdf = tmp_path / "old.pdf" new_html = tmp_path / "new.html" old_pdf.write_bytes(b"%PDF-1.4") new_html.write_text("") os.utime(old_pdf, (100, 100)) os.utime(new_html, (200, 200)) assert pdf_watcher.find_latest_pdf(str(tmp_path)) == str(new_html) def test_mixed_start_time_filter(self, tmp_path): # Start-time filter applies uniformly to both extensions. stale_html = tmp_path / "stale.html" fresh_pdf = tmp_path / "fresh.pdf" stale_html.write_text("") fresh_pdf.write_bytes(b"%PDF-1.4") os.utime(stale_html, (100, 100)) os.utime(fresh_pdf, (500, 500)) assert pdf_watcher.find_latest_pdf(str(tmp_path), analysis_start_time=400) == str(fresh_pdf) # ---------------------------------------------------------------------- # action_enrichment # ---------------------------------------------------------------------- class TestNormaliseNonConvergence: def test_none_and_empty_return_none(self): assert action_enrichment.normalise_non_convergence(None) is None assert action_enrichment.normalise_non_convergence([]) is None def test_scalar_is_stringified(self): assert action_enrichment.normalise_non_convergence(ValueError("bad")) == "bad" def test_list_is_joined(self): assert action_enrichment.normalise_non_convergence([ValueError("a"), "b"]) == "a; b" class TestDeriveNonConvergence: def test_explicit_takes_priority(self): obs = SimpleNamespace(_last_info={"exception": "from obs"}) out = action_enrichment.derive_non_convergence( {"non_convergence": "explicit", "observation": obs} ) assert out == "explicit" def test_falls_back_to_observation_last_info(self): obs = SimpleNamespace(_last_info={"exception": "LF diverged"}) assert action_enrichment.derive_non_convergence({"observation": obs}) == "LF diverged" def test_no_observation_returns_none(self): assert action_enrichment.derive_non_convergence({}) is None class TestComputeLinesOverloadedAfter: def test_raw_loa_is_returned_as_list_when_present(self): out = action_enrichment.compute_lines_overloaded_after( raw_loa=["A", "B"], rho_after_raw=[1.5], lines_overloaded_names=["X"], max_rho_raw=None, max_rho_line="", ) assert out == ["A", "B"] def test_reconstructs_from_rho_after_when_empty(self): out = action_enrichment.compute_lines_overloaded_after( raw_loa=None, rho_after_raw=[1.2, 0.5, 1.8], lines_overloaded_names=["A", "B", "C"], max_rho_raw=None, max_rho_line="", ) assert out == ["A", "C"] def test_injects_max_rho_line_when_exceeds_threshold(self): out = action_enrichment.compute_lines_overloaded_after( raw_loa=None, rho_after_raw=[0.5], lines_overloaded_names=["A"], max_rho_raw=1.1, max_rho_line="BRAND_NEW_OVERLOAD", ) assert out == ["BRAND_NEW_OVERLOAD"] def test_does_not_duplicate_max_rho_line(self): out = action_enrichment.compute_lines_overloaded_after( raw_loa=None, rho_after_raw=[1.3], lines_overloaded_names=["A"], max_rho_raw=1.3, max_rho_line="A", ) assert out == ["A"] class TestScaleRhoSeries: def test_none_returns_none(self): assert action_enrichment.scale_rho_series(None, 0.95) is None def test_scales_each_value(self): assert action_enrichment.scale_rho_series([1.0, 0.5], 0.9) == [0.9, 0.45] class TestIsRenewableGen: def _obs(self, names, types): obs = MagicMock() obs.name_gen = names obs.gen_type = types return obs def test_wind_from_observation(self): ns = MagicMock() obs = self._obs(["W_1"], ["wind"]) assert action_enrichment.is_renewable_gen("W_1", obs, ns) is True def test_solar_from_observation(self): ns = MagicMock() obs = self._obs(["S_1"], ["SOLAR"]) assert action_enrichment.is_renewable_gen("S_1", obs, ns) is True def test_fallback_to_network_service(self): ns = MagicMock() ns.get_generator_type.return_value = "wind" # obs without gen_type attribute obs = SimpleNamespace(name_gen=["W_1"]) assert action_enrichment.is_renewable_gen("W_1", obs, ns) is True def test_fallback_name_heuristic(self): ns = MagicMock() ns.get_generator_type.return_value = None obs = SimpleNamespace(name_gen=["W_1"]) assert action_enrichment.is_renewable_gen("WIND_42", obs, ns) is True assert action_enrichment.is_renewable_gen("GAS_42", obs, ns) is False class TestExtractActionTopology: def test_reads_attribute_fields(self): action = SimpleNamespace( lines_ex_bus={"L1": 1}, lines_or_bus={}, gens_bus={}, loads_bus={}, pst_tap={}, substations={}, switches={"SW1": 1}, loads_p={}, gens_p={}, ) topo = action_enrichment.extract_action_topology(action, "a", {}) assert topo["lines_ex_bus"] == {"L1": 1} assert topo["switches"] == {"SW1": 1} def test_backfills_switches_from_dict_action(self): action = SimpleNamespace( lines_ex_bus={}, lines_or_bus={}, gens_bus={}, loads_bus={}, pst_tap={}, substations={}, switches=None, loads_p={}, gens_p={}, ) dict_action = {"a": {"content": {"switches": {"SW_DICT": 1}}}} topo = action_enrichment.extract_action_topology(action, "a", dict_action) assert topo["switches"] == {"SW_DICT": 1} class TestComputeLoadSheddingDetails: def _obs(self, names, load_p): return SimpleNamespace(name_load=names, load_p=np.array(load_p)) def test_returns_none_without_action(self): assert action_enrichment.compute_load_shedding_details({}, None, MagicMock()) is None def test_returns_none_when_no_loads_affected(self): action = SimpleNamespace(loads_bus={}, loads_p={}) ns = MagicMock() out = action_enrichment.compute_load_shedding_details( {"action": action, "observation": self._obs(["L1"], [10.0])}, None, ns ) assert out is None def test_shed_mw_from_loads_p_format(self): action = SimpleNamespace(loads_bus={}, loads_p={"L1": 0.0}) obs_n1 = self._obs(["L1"], [50.0]) obs_action = self._obs(["L1"], [20.0]) ns = MagicMock() ns.get_load_voltage_level.return_value = "VL_A" out = action_enrichment.compute_load_shedding_details( {"action": action, "observation": obs_action}, obs_n1, ns ) assert out == [{"load_name": "L1", "voltage_level_id": "VL_A", "shedded_mw": 30.0}] def test_legacy_bus_format(self): action = SimpleNamespace(loads_bus={"L1": -1, "L2": 1}) obs_n1 = self._obs(["L1", "L2"], [15.0, 10.0]) obs_action = self._obs(["L1", "L2"], [0.0, 10.0]) ns = MagicMock() ns.get_load_voltage_level.return_value = None out = action_enrichment.compute_load_shedding_details( {"action": action, "observation": obs_action}, obs_n1, ns ) assert len(out) == 1 assert out[0]["load_name"] == "L1" assert out[0]["shedded_mw"] == 15.0 class TestComputeCurtailmentDetails: def _obs(self, names, gen_p): return SimpleNamespace(name_gen=names, gen_p=np.array(gen_p)) def test_returns_none_without_action(self): assert action_enrichment.compute_curtailment_details({}, None, MagicMock()) is None def test_curtailed_mw_for_renewable(self): action = SimpleNamespace(gens_bus={}, gens_p={"WIND_1": 0.0}) obs_n1 = self._obs(["WIND_1"], [100.0]) obs_action = self._obs(["WIND_1"], [30.0]) ns = MagicMock() ns.get_generator_voltage_level.return_value = "VL_W" out = action_enrichment.compute_curtailment_details( {"action": action, "observation": obs_action}, obs_n1, ns, is_renewable_fn=lambda g, obs: True, ) assert out == [{"gen_name": "WIND_1", "voltage_level_id": "VL_W", "curtailed_mw": 70.0}] def test_filters_out_non_renewable(self): action = SimpleNamespace(gens_bus={}, gens_p={"GAS_1": 0.0}) ns = MagicMock() obs = self._obs(["GAS_1"], [50.0]) out = action_enrichment.compute_curtailment_details( {"action": action, "observation": obs}, obs, ns, is_renewable_fn=lambda g, obs: False, ) assert out is None def test_disconnected_mw_fallback_when_no_observation(self): action = SimpleNamespace(gens_bus={"WIND_1": -1, "WIND_2": -1}) ns = MagicMock() ns.get_generator_voltage_level.return_value = None out = action_enrichment.compute_curtailment_details( {"action": action, "observation": None, "disconnected_mw": 100.0}, None, ns, is_renewable_fn=lambda g, obs: True, ) assert out is not None assert all(d["curtailed_mw"] == 50.0 for d in out) class TestComputePstDetails: def test_returns_none_without_action_or_content(self): assert action_enrichment.compute_pst_details({}, None) is None def test_reads_pst_tap_from_action_obj(self): action = SimpleNamespace(pst_tap={"PST_A": 5}) details = action_enrichment.compute_pst_details( {"action": action}, pst_tap_info_fn=None, ) assert details == [{"pst_name": "PST_A", "tap_position": 5, "low_tap": None, "high_tap": None}] def test_fills_bounds_from_pst_tap_info_fn(self): action = SimpleNamespace(pst_tap={"PST_A": 5}) info_fn = MagicMock(return_value={"low_tap": -5, "high_tap": 10}) details = action_enrichment.compute_pst_details( {"action": action}, pst_tap_info_fn=info_fn, ) assert details[0]["low_tap"] == -5 assert details[0]["high_tap"] == 10 def test_reads_from_content_when_action_has_no_pst_tap(self): action = SimpleNamespace(pst_tap=None) details = action_enrichment.compute_pst_details( {"action": action, "content": {"pst_tap": {"PST_B": 3}}}, pst_tap_info_fn=None, ) assert details[0]["pst_name"] == "PST_B" assert details[0]["tap_position"] == 3 # ---------------------------------------------------------------------- # mw_start_scoring # ---------------------------------------------------------------------- class TestClassifyActionType: @pytest.mark.parametrize("action_type,expected", [ ("load_shedding", "load_shedding"), ("LS_GROUP", "load_shedding"), ("renewable_curtailment", "curtail"), ("redispatch", "redispatch"), ("line_disconnection", "disco"), ("pst_tap_change", "pst"), ("open_coupling", "open"), ("unknown", "other"), ]) def test_mapping(self, action_type, expected): assert mw_start_scoring.classify_action_type(action_type) == expected class TestIsNaActionType: @pytest.mark.parametrize("t", ["line_reconnection", "reco_X", "close_coupling"]) def test_positive(self, t): assert mw_start_scoring.is_na_action_type(t) is True @pytest.mark.parametrize("t", ["line_disconnection", "pst_tap_change", "load_shedding"]) def test_negative(self, t): assert mw_start_scoring.is_na_action_type(t) is False class TestMwStartLoadShedding: def test_reads_set_load_p(self): obs = SimpleNamespace(load_p=np.array([42.0, 10.0])) entry = {"content": {"set_load_p": {"LOAD_A": 0.0}}} result = mw_start_scoring.mw_start_load_shedding( "some_id", entry, obs, {"LOAD_A": 0, "LOAD_B": 1}, ) assert result == 42.0 def test_reads_legacy_set_bus_loads(self): obs = SimpleNamespace(load_p=np.array([30.0, 20.0])) entry = {"content": {"set_bus": {"loads_id": {"LOAD_A": -1, "LOAD_B": 1}}}} result = mw_start_scoring.mw_start_load_shedding( "x", entry, obs, {"LOAD_A": 0, "LOAD_B": 1}, ) assert result == 30.0 def test_action_id_pattern_fallback(self): obs = SimpleNamespace(load_p=np.array([17.0])) result = mw_start_scoring.mw_start_load_shedding( "load_shedding_LOAD_A", None, obs, {"LOAD_A": 0}, ) assert result == 17.0 def test_returns_none_when_unmatched(self): obs = SimpleNamespace(load_p=np.array([10.0])) assert mw_start_scoring.mw_start_load_shedding( "unknown", None, obs, {"LOAD_A": 0}, ) is None class TestMwStartCurtailment: def test_reads_set_gen_p(self): obs = SimpleNamespace(gen_p=np.array([85.0, 10.0])) entry = {"content": {"set_gen_p": {"WIND_1": 0.0}}} result = mw_start_scoring.mw_start_curtailment( "x", entry, obs, {"WIND_1": 0, "WIND_2": 1}, ) assert result == 85.0 def test_falls_back_to_prod_p_when_no_gen_p(self): # Legacy observations expose prod_p instead of gen_p. obs = SimpleNamespace(prod_p=np.array([50.0])) result = mw_start_scoring.mw_start_curtailment( "curtail_WIND_1", None, obs, {"WIND_1": 0}, ) assert result == 50.0 class TestMwStartLineDisconnection: def test_reads_line_or_with_bus_minus_one(self): obs = SimpleNamespace(p_or=np.array([123.4])) entry = {"content": {"set_bus": {"lines_or_id": {"LINE_X": -1}}}} result = mw_start_scoring.mw_start_line_disconnection( entry, obs, {"LINE_X": 0}, ) assert result == 123.4 class TestMwStartOpenCouplingHelper: def test_injected_fn_is_called(self): fn = MagicMock(return_value=66.0) set_bus = { "lines_or_id": {"L1": 1}, "lines_ex_id": {"L2": 2}, } obs = SimpleNamespace(name_gen=[]) result = mw_start_scoring.mw_start_open_coupling( set_bus, obs, {"L1": 0, "L2": 1}, {}, virtual_line_flow_fn=fn, ) assert result == 66.0 fn.assert_called_once() def test_returns_none_with_no_positive_buses(self): assert mw_start_scoring.mw_start_open_coupling( {"lines_or_id": {"L1": -1}}, SimpleNamespace(name_gen=[]), {}, {}, virtual_line_flow_fn=lambda *a: 99.0, ) is None class TestGetActionMwStartDispatcher: def test_dispatches_load_shedding(self): obs = SimpleNamespace(load_p=np.array([40.0])) result = mw_start_scoring.get_action_mw_start( "load_shedding_LOAD_A", "load_shedding", obs, line_idx_map={}, load_idx_map={"LOAD_A": 0}, gen_idx_map={}, action_entry=None, ) assert result == 40.0 def test_returns_none_for_na_type_with_no_entry(self): obs = SimpleNamespace() result = mw_start_scoring.get_action_mw_start( "anything", "other", obs, {}, {}, {}, action_entry=None, ) assert result is None class TestGetPstTapStart: def test_uses_parameters_previous_tap_first(self): entry = { "content": {"pst_tap": {"PST_A": 8}}, "parameters": {"previous tap": 3}, } result = mw_start_scoring.get_pst_tap_start( entry, initial_pst_taps={"PST_A": {"low_tap": -5, "high_tap": 10}}, pst_tap_info_fn=None, ) assert result == {"pst_name": "PST_A", "tap": 3, "low_tap": -5, "high_tap": 10} def test_falls_back_to_initial_pst_taps_cache(self): entry = {"content": {"pst_tap": {"PST_B": 7}}} result = mw_start_scoring.get_pst_tap_start( entry, initial_pst_taps={"PST_B": {"tap": 4, "low_tap": -3, "high_tap": 9}}, pst_tap_info_fn=None, ) assert result == {"pst_name": "PST_B", "tap": 4, "low_tap": -3, "high_tap": 9} def test_live_info_fn_fallback(self): entry = {"content": {"pst_tap": {"PST_C": 2}}} info_fn = MagicMock(return_value={"tap": 5, "low_tap": 0, "high_tap": 10}) result = mw_start_scoring.get_pst_tap_start( entry, initial_pst_taps=None, pst_tap_info_fn=info_fn, ) assert result["tap"] == 5 def test_returns_none_when_no_entry(self): assert mw_start_scoring.get_pst_tap_start(None, None, None) is None def test_returns_none_when_no_pst_tap(self): assert mw_start_scoring.get_pst_tap_start({"content": {}}, None, None) is None # ---------------------------------------------------------------------- # analysis_runner # ---------------------------------------------------------------------- class TestDeriveAnalysisMessage: def test_returns_original_when_result_is_present(self): assert analysis_runner.derive_analysis_message( "all good", "whatever", result={"foo": "bar"}, ) == "all good" def test_detects_no_topological_solution(self): out = analysis_runner.derive_analysis_message( "", "No topological solution without load shedding", result=None, ) assert "No topological solution" in out def test_detects_grid_broken_apart(self): out = analysis_runner.derive_analysis_message( "", "Overload breaks the grid apart", result=None, ) assert "Grid instability" in out def test_generic_fallback_message(self): out = analysis_runner.derive_analysis_message("", "unrelated output", result=None) assert "no recommendations" in out class TestComputeRedispatchDetails: def _obs(self, names, gen_p): return SimpleNamespace(name_gen=names, gen_p=np.array(gen_p)) def test_returns_none_without_action(self): assert action_enrichment.compute_redispatch_details({}, None, MagicMock()) is None def test_raise_direction_positive_delta(self): # Therm at 30 MW (gen_p negative convention), target 40 MW -> +10 raise. action = SimpleNamespace(gens_p={"THERM_1": 40.0}) obs_n1 = self._obs(["THERM_1"], [-30.0]) obs_action = self._obs(["THERM_1"], [-40.0]) ns = MagicMock() ns.get_generator_voltage_level.return_value = "VL_T" ns.get_generator_active_power_limits.return_value = (0.0, 100.0) out = action_enrichment.compute_redispatch_details( {"action": action, "observation": obs_action}, obs_n1, ns, ) assert out == [{ "gen_name": "THERM_1", "voltage_level_id": "VL_T", "delta_mw": 10.0, "target_mw": 40.0, "direction": "up", "current_mw": 30.0, "max_raise_mw": 70.0, # 100 (max_p) - 30 (current) "max_lower_mw": 30.0, # 30 (current) - 0 (min_p) }] def test_headroom_none_when_limits_unavailable(self): action = SimpleNamespace(gens_p={"THERM_1": 40.0}) obs_n1 = self._obs(["THERM_1"], [-30.0]) obs_action = self._obs(["THERM_1"], [-40.0]) ns = MagicMock() ns.get_generator_voltage_level.return_value = None ns.get_generator_active_power_limits.return_value = None out = action_enrichment.compute_redispatch_details( {"action": action, "observation": obs_action}, obs_n1, ns, ) assert out[0]["max_raise_mw"] is None assert out[0]["max_lower_mw"] is None assert out[0]["current_mw"] == 30.0 def test_lower_direction_negative_delta(self): action = SimpleNamespace(gens_p={"THERM_1": 20.0}) obs_n1 = self._obs(["THERM_1"], [-50.0]) obs_action = self._obs(["THERM_1"], [-20.0]) ns = MagicMock() ns.get_generator_voltage_level.return_value = None out = action_enrichment.compute_redispatch_details( {"action": action, "observation": obs_action}, obs_n1, ns, ) assert out[0]["delta_mw"] == -30.0 assert out[0]["direction"] == "down" def test_delta_uses_encoded_target_not_redistributed_simulation(self): # Regression: a 10 MW lower from 12 MW encodes target_p = 2 MW, but the # load flow may back the unit off to 0 MW. The editable delta must stay # the INTENDED -10 MW (encoded target vs current), NOT -12 MW (simulated). action = SimpleNamespace(gens_p={"G": 2.0}) obs_n1 = self._obs(["G"], [-12.0]) # producing 12 MW obs_action = self._obs(["G"], [0.0]) # slack redistribution → 0 MW ns = MagicMock() ns.get_generator_voltage_level.return_value = None out = action_enrichment.compute_redispatch_details( {"action": action, "observation": obs_action}, obs_n1, ns, ) assert out[0]["delta_mw"] == -10.0 assert out[0]["target_mw"] == 2.0 assert out[0]["current_mw"] == 12.0 assert out[0]["direction"] == "down" def test_discovery_stage_uses_target_from_content(self): # No post-action observation: fall back to the encoded set_gen_p target. obs_n1 = self._obs(["THERM_1"], [-30.0]) ns = MagicMock() ns.get_generator_voltage_level.return_value = None out = action_enrichment.compute_redispatch_details( {"action": SimpleNamespace(gens_p={}), "content": {"set_gen_p": {"THERM_1": 45.0}}, "observation": None}, obs_n1, ns, ) assert out[0]["target_mw"] == 45.0 assert out[0]["delta_mw"] == 15.0 assert out[0]["direction"] == "up" class TestMwStartRedispatch: def test_reads_set_gen_p(self): obs = SimpleNamespace(gen_p=np.array([-55.0, 10.0])) entry = {"content": {"set_gen_p": {"THERM_1": 65.0}}} result = mw_start_scoring.mw_start_redispatch( "x", entry, obs, {"THERM_1": 0, "THERM_2": 1}, ) assert result == 55.0 # current production magnitude, not the target def test_action_id_fallback(self): obs = SimpleNamespace(gen_p=np.array([-42.0])) result = mw_start_scoring.mw_start_redispatch( "redispatch_THERM_1", None, obs, {"THERM_1": 0}, ) assert result == 42.0 def test_dispatcher_routes_redispatch(self): obs = SimpleNamespace(gen_p=np.array([-33.0])) result = mw_start_scoring.get_action_mw_start( "redispatch_THERM_1", "redispatch", obs, line_idx_map={}, load_idx_map={}, gen_idx_map={"THERM_1": 0}, action_entry=None, ) assert result == 33.0 if __name__ == "__main__": raise SystemExit(pytest.main([__file__]))