From efe179cd3ed6769ed7eaeb3729a6802bf3b488dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 08:08:40 +0000 Subject: [PATCH 01/14] Support new power reduction format (loads_p/gens_p) for load shedding and curtailment actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Expert_op4grid_recommender library (PR #74) now uses active power setpoint changes (set_load_p/set_gen_p) instead of bus disconnection (bus=-1) for load shedding and curtailment actions. This update adds support for both the new and legacy formats across the full stack: Backend (recommender_service.py): - _compute_load_shedding_details: detect loads_p in addition to loads_bus - _compute_curtailment_details: detect gens_p in addition to gens_bus - _mw_start_load_shedding: check set_load_p in content - _mw_start_curtailment: check set_gen_p in content - Dynamic action creation: use loads_p/gens_p topology format - simulate_manual_action: detect affected assets from both formats - _build_action_entry_from_topology: map loads_p→set_load_p, gens_p→set_gen_p - _enrich_actions: extract loads_p/gens_p from action objects Frontend: - ActionTopology type: add optional loads_p/gens_p fields - ActionFeed: include loads_p/gens_p equipment IDs in badge display - VisualizationPanel: include loads_p/gens_p in target equipment IDs - svgUtils: account for loads_p/gens_p in target detection and reconnection check https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- .../services/recommender_service.py | 274 ++++++++++++------ frontend/src/components/ActionFeed.tsx | 2 + .../src/components/VisualizationPanel.tsx | 2 + frontend/src/types.ts | 2 + frontend/src/utils/svgUtils.ts | 8 +- 5 files changed, 202 insertions(+), 86 deletions(-) diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index 57e6b939..cffc006a 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -168,8 +168,8 @@ def _enrich_actions(self, prioritized_actions_dict): action_obj = action_data.get("action") if action_obj is not None: topo = {} - # pypowsybl Actions use these fields - for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches"): + # pypowsybl Actions use these fields (including new power reduction fields) + for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches", "loads_p", "gens_p"): val = getattr(action_obj, field, None) if val is None and isinstance(action_obj, dict): val = action_obj.get(field) @@ -207,20 +207,37 @@ def _compute_load_shedding_details(self, action_data): """Compute per-load shedding details by comparing N-1 and action observations. Returns a list of {load_name, voltage_level_id, shedded_mw} or None. + Supports both legacy bus disconnection (bus = -1) and new power reduction + (loads_p / set_load_p) formats from expert_op4grid_recommender. """ action_obj = action_data.get("action") if action_obj is None: return None - # Get loads_bus from the action topology to identify affected loads + # Collect affected load names from both legacy and new formats + shed_load_names = [] + + # Legacy format: loads_bus with bus = -1 loads_bus = getattr(action_obj, "loads_bus", None) if loads_bus is None and isinstance(action_obj, dict): loads_bus = action_obj.get("loads_bus") - if not loads_bus: - return None + if loads_bus: + shed_load_names.extend(name for name, bus in loads_bus.items() if bus == -1) + + # New power reduction format: loads_p (active power setpoint changes) + loads_p = getattr(action_obj, "loads_p", None) + if loads_p is None and isinstance(action_obj, dict): + loads_p = action_obj.get("loads_p") + if not loads_p: + # Also check inside action content for set_load_p + content = action_data.get("content") + if isinstance(content, dict): + loads_p = content.get("set_load_p") + if loads_p and isinstance(loads_p, dict): + for name in loads_p: + if name not in shed_load_names: + shed_load_names.append(name) - # Filter to loads that are being disconnected (bus = -1) - shed_load_names = [name for name, bus in loads_bus.items() if bus == -1] if not shed_load_names: return None @@ -267,39 +284,56 @@ def _compute_curtailment_details(self, action_data): """Compute per-generator curtailment details by comparing N-1 and action observations. Returns a list of {gen_name, voltage_level_id, curtailed_mw} or None. + Supports both legacy bus disconnection (bus = -1) and new power reduction + (gens_p / set_gen_p) formats from expert_op4grid_recommender. """ action_obj = action_data.get("action") if action_obj is None: return None - # Get gens_bus from the action topology to identify affected generators + # Collect affected generator names from both legacy and new formats + curtailed_gen_names = [] + + # Legacy format: gens_bus with bus = -1 gens_bus = getattr(action_obj, "gens_bus", None) if gens_bus is None and isinstance(action_obj, dict): gens_bus = action_obj.get("gens_bus") - if not gens_bus: - return None + if gens_bus: + curtailed_gen_names.extend(name for name, bus in gens_bus.items() if bus == -1) + + # New power reduction format: gens_p (active power setpoint changes) + gens_p = getattr(action_obj, "gens_p", None) + if gens_p is None and isinstance(action_obj, dict): + gens_p = action_obj.get("gens_p") + if not gens_p: + # Also check inside action content for set_gen_p + content = action_data.get("content") + if isinstance(content, dict): + gens_p = content.get("set_gen_p") + if gens_p and isinstance(gens_p, dict): + for name in gens_p: + if name not in curtailed_gen_names: + curtailed_gen_names.append(name) - # Filter to generators that are being disconnected (bus = -1) - curtailed_gen_names = [name for name, bus in gens_bus.items() if bus == -1] if not curtailed_gen_names: return None obs_action = action_data.get("observation") if obs_action is None: - # If we don't have an observation (e.g. discovery stage), use shedded_mw if available + # If we don't have an observation (e.g. discovery stage), use disconnected_mw if available disconnected_mw = action_data.get("disconnected_mw") if not disconnected_mw: return None - + # Simple fallback: distribute disconnected_mw among gens if multiple (rare for curtailment) mw_per_gen = disconnected_mw / len(curtailed_gen_names) - + details = [] from expert_backend.services.network_service import network_service for gen_name in curtailed_gen_names: if not self._is_renewable_gen(gen_name, obs=obs_action): continue - + vl_id = None try: vl_id = network_service.get_generator_voltage_level(gen_name) @@ -489,13 +523,27 @@ def _mw_start_load_shedding(self, action_id, action_entry, obs_n1, load_idx_map) """Compute MW at start for a load shedding action. Tries multiple strategies: - 1. Parse content.set_bus.loads_id for loads with bus=-1 + 1a. Parse content.set_load_p for power reduction actions (new format) + 1b. Parse content.set_bus.loads_id for loads with bus=-1 (legacy format) 2. Extract load name from the action ID pattern load_shedding_ """ - # Strategy 1: parse content.set_bus.loads_id if action_entry is not None: content = action_entry.get("content", {}) if content: + # Strategy 1a: new power reduction format — set_load_p + set_load_p = content.get("set_load_p", {}) + if set_load_p: + total_mw = 0.0 + found = False + for load_name in set_load_p: + if load_name in load_idx_map: + idx = load_idx_map[load_name] + total_mw += abs(float(obs_n1.load_p[idx])) + found = True + if found: + return round(total_mw, 1) + + # Strategy 1b: legacy bus disconnection format — set_bus.loads_id set_bus = content.get("set_bus", {}) loads = set_bus.get("loads_id", {}) total_mw = 0.0 @@ -519,10 +567,31 @@ def _mw_start_load_shedding(self, action_id, action_entry, obs_n1, load_idx_map) return None def _mw_start_curtailment(self, action_id, action_entry, obs_n1, gen_idx_map): - """Compute MW at start for a renewable curtailment action.""" + """Compute MW at start for a renewable curtailment action. + + Supports both new power reduction (set_gen_p) and legacy (set_bus.generators_id) + formats. + """ if action_entry is not None: content = action_entry.get("content", {}) if content: + # New power reduction format — set_gen_p + set_gen_p = content.get("set_gen_p", {}) + if set_gen_p: + total_mw = 0.0 + found = False + for gen_name in set_gen_p: + if gen_name in gen_idx_map: + idx = gen_idx_map[gen_name] + if hasattr(obs_n1, "gen_p"): + total_mw += abs(float(obs_n1.gen_p[idx])) + else: + total_mw += abs(float(obs_n1.prod_p[idx])) + found = True + if found: + return round(total_mw, 1) + + # Legacy bus disconnection format — set_bus.generators_id set_bus = content.get("set_bus", {}) gens = set_bus.get("generators_id", {}) total_mw = 0.0 @@ -2012,6 +2081,15 @@ def _build_action_entry_from_topology(action_id, topo): if pst_tap: content["pst_tap"] = pst_tap + # Power reduction actions: set_load_p / set_gen_p (new format) + loads_p = topo.get("loads_p") or {} + if loads_p and isinstance(loads_p, dict): + content["set_load_p"] = {name: float(p) for name, p in loads_p.items()} + + gens_p = topo.get("gens_p") or {} + if gens_p and isinstance(gens_p, dict): + content["set_gen_p"] = {name: float(p) for name, p in gens_p.items()} + entry["content"] = content if content else {} return entry @@ -2042,7 +2120,7 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, if action_content: # Normalize: if it looks like a topology dict (has topology keys), # wrap it as {action_id: topology} for uniform handling. - topo_keys = {"lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "substations", "switches"} + topo_keys = {"lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "substations", "switches", "loads_p", "gens_p"} if any(k in action_content for k in topo_keys): # Single topology — apply to all unknown action_ids per_action = {aid: action_content for aid in action_ids} @@ -2066,9 +2144,10 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, if aid.startswith("curtail_"): gen_name = aid[len("curtail_"):] - topo = {"gens_bus": {gen_name: -1}} + # New power reduction format: set generator output to 0 MW + topo = {"gens_p": {gen_name: 0.0}} entry = self._build_action_entry_from_topology(aid, topo) - + # Align with suggested action description format to help frontend discovery vl_id = None try: @@ -2082,15 +2161,16 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, else: entry["description"] = f"Renewable curtailment on generator '{gen_name}'" entry["description_unitaire"] = f"Effacement '{gen_name}'" - + self._dict_action[aid] = entry print(f"[simulate_manual_action] Created dynamic curtailment action '{aid}'") elif aid.startswith("load_shedding_"): load_name = aid[len("load_shedding_"):] - topo = {"loads_bus": {load_name: -1}} + # New power reduction format: set load consumption to 0 MW + topo = {"loads_p": {load_name: 0.0}} entry = self._build_action_entry_from_topology(aid, topo) - + vl_id = None try: from expert_backend.services.network_service import network_service as ns @@ -2103,7 +2183,7 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, else: entry["description"] = f"Load shedding on '{load_name}'" entry["description_unitaire"] = f"Effacement '{load_name}'" - + self._dict_action[aid] = entry print(f"[simulate_manual_action] Created dynamic load shedding action '{aid}'") @@ -2357,7 +2437,7 @@ def get_desc(aid): # Store the observation so get_action_variant_diagram can generate the NAD # Refresh topo and content for the combined result dictionary topo = {} - for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches"): + for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches", "loads_p", "gens_p"): val = getattr(action, field, None) if val: topo[field] = sanitize_for_json(val) @@ -2377,10 +2457,10 @@ def get_desc(aid): # Manually inject topology for heuristic actions (Standard Grid2Op actions don't have these attributes) if action_id.startswith("curtail_"): gen_name = action_id.replace("curtail_", "") - topo["gens_bus"] = {gen_name: -1} + topo["gens_p"] = {gen_name: 0.0} elif action_id.startswith("load_shedding_"): load_name = action_id.replace("load_shedding_", "") - topo["loads_bus"] = {load_name: -1} + topo["loads_p"] = {load_name: 0.0} # Retrieve the full description and content from the dictionary if available description = description_unitaire @@ -2432,69 +2512,95 @@ def get_desc(aid): # Compute load shedding details for this action + # Supports both legacy (loads_bus with bus=-1) and new power reduction (loads_p) formats load_shedding_details = None + shed_load_names = [] loads_bus = getattr(action, "loads_bus", None) - if loads_bus and obs_simu_action is not None: - shed_load_names = [name for name, bus in loads_bus.items() if bus == -1] - if shed_load_names: - load_shedding_details = [] - for load_name in shed_load_names: + if loads_bus: + shed_load_names.extend(name for name, bus in loads_bus.items() if bus == -1) + loads_p = getattr(action, "loads_p", None) + if loads_p and isinstance(loads_p, dict): + for name in loads_p: + if name not in shed_load_names: + shed_load_names.append(name) + # Also check topo dict for loads_p (heuristic/dynamic actions) + if not shed_load_names and topo.get("loads_p"): + for name in topo["loads_p"]: + if name not in shed_load_names: + shed_load_names.append(name) + + if shed_load_names and obs_simu_action is not None: + load_shedding_details = [] + for load_name in shed_load_names: + shedded_mw = 0.0 + try: + load_idx = list(obs_simu_action.name_load).index(load_name) + p_before = float(obs_simu_defaut.load_p[load_idx]) + p_after = float(obs_simu_action.load_p[load_idx]) + shedded_mw = abs(p_before - p_after) + except (ValueError, IndexError): shedded_mw = 0.0 - try: - load_idx = list(obs_simu_action.name_load).index(load_name) - p_before = float(obs_simu_defaut.load_p[load_idx]) - p_after = float(obs_simu_action.load_p[load_idx]) - shedded_mw = abs(p_before - p_after) - except (ValueError, IndexError): - shedded_mw = 0.0 - vl_id = None - try: - from expert_backend.services.network_service import network_service as ns - vl_id = ns.get_load_voltage_level(load_name) - except Exception: - pass - load_shedding_details.append({ - "load_name": load_name, - "voltage_level_id": vl_id, - "shedded_mw": round(shedded_mw, 1), - }) + vl_id = None + try: + from expert_backend.services.network_service import network_service as ns + vl_id = ns.get_load_voltage_level(load_name) + except Exception: + pass + load_shedding_details.append({ + "load_name": load_name, + "voltage_level_id": vl_id, + "shedded_mw": round(shedded_mw, 1), + }) # Compute curtailment details for this action + # Supports both legacy (gens_bus with bus=-1) and new power reduction (gens_p) formats curtailment_details = None + curtailed_gen_names = [] gens_bus = getattr(action, "gens_bus", None) - if gens_bus and obs_simu_action is not None: - curtailed_gen_names = [name for name, bus in gens_bus.items() if bus == -1] - if curtailed_gen_names: - curtailment_details = [] - for gen_name in curtailed_gen_names: - # Use a safe renewable check - if not self._is_renewable_gen(gen_name, obs=obs_simu_action or obs_simu_defaut): - continue + if gens_bus: + curtailed_gen_names.extend(name for name, bus in gens_bus.items() if bus == -1) + gens_p = getattr(action, "gens_p", None) + if gens_p and isinstance(gens_p, dict): + for name in gens_p: + if name not in curtailed_gen_names: + curtailed_gen_names.append(name) + # Also check topo dict for gens_p (heuristic/dynamic actions) + if not curtailed_gen_names and topo.get("gens_p"): + for name in topo["gens_p"]: + if name not in curtailed_gen_names: + curtailed_gen_names.append(name) + + if curtailed_gen_names and obs_simu_action is not None: + curtailment_details = [] + for gen_name in curtailed_gen_names: + # Use a safe renewable check + if not self._is_renewable_gen(gen_name, obs=obs_simu_action or obs_simu_defaut): + continue + curtailed_mw = 0.0 + try: + gen_idx = list(obs_simu_action.name_gen).index(gen_name) + if hasattr(obs_simu_defaut, "gen_p"): + p_before = float(obs_simu_defaut.gen_p[gen_idx]) + p_after = float(obs_simu_action.gen_p[gen_idx]) + else: + p_before = float(obs_simu_defaut.prod_p[gen_idx]) + p_after = float(obs_simu_action.prod_p[gen_idx]) + curtailed_mw = abs(p_before - p_after) + except (ValueError, IndexError, AttributeError): curtailed_mw = 0.0 - try: - gen_idx = list(obs_simu_action.name_gen).index(gen_name) - if hasattr(obs_simu_defaut, "gen_p"): - p_before = float(obs_simu_defaut.gen_p[gen_idx]) - p_after = float(obs_simu_action.gen_p[gen_idx]) - else: - p_before = float(obs_simu_defaut.prod_p[gen_idx]) - p_after = float(obs_simu_action.prod_p[gen_idx]) - curtailed_mw = abs(p_before - p_after) - except (ValueError, IndexError, AttributeError): - curtailed_mw = 0.0 - - vl_id = None - try: - from expert_backend.services.network_service import network_service as ns - vl_id = ns.get_generator_voltage_level(gen_name) - except Exception: - pass - - curtailment_details.append({ - "gen_name": gen_name, - "voltage_level_id": vl_id, - "curtailed_mw": round(curtailed_mw, 1), - }) + + vl_id = None + try: + from expert_backend.services.network_service import network_service as ns + vl_id = ns.get_generator_voltage_level(gen_name) + except Exception: + pass + + curtailment_details.append({ + "gen_name": gen_name, + "voltage_level_id": vl_id, + "curtailed_mw": round(curtailed_mw, 1), + }) result = { "action_id": action_id, diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 789e847c..f51b59b6 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -564,6 +564,8 @@ const ActionFeed: React.FC = ({ const equipNames = Array.from(new Set([ ...Object.keys(topo?.gens_bus || {}), ...Object.keys(topo?.loads_bus || {}), + ...Object.keys(topo?.loads_p || {}), + ...Object.keys(topo?.gens_p || {}), ])); equipNames.forEach(name => { badges.push(badgeBtn(name, '#dbeafe', '#1e40af', `Zoom to ${name}`)); diff --git a/frontend/src/components/VisualizationPanel.tsx b/frontend/src/components/VisualizationPanel.tsx index ada6e1d0..fd894114 100644 --- a/frontend/src/components/VisualizationPanel.tsx +++ b/frontend/src/components/VisualizationPanel.tsx @@ -562,6 +562,8 @@ const SldOverlay: React.FC = ({ for (const id of Object.keys(topo.gens_bus || {})) targetEquipIds.add(id); for (const id of Object.keys(topo.loads_bus || {})) targetEquipIds.add(id); for (const id of Object.keys(topo.pst_tap || {})) targetEquipIds.add(id); + for (const id of Object.keys(topo.loads_p || {})) targetEquipIds.add(id); + for (const id of Object.keys(topo.gens_p || {})) targetEquipIds.add(id); for (const equipId of targetEquipIds) { const cell = findCellForEquipment(equipId); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b6dc664c..5c1a1779 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,6 +36,8 @@ export interface ActionTopology { pst_tap?: Record; substations?: Record; switches?: Record; + loads_p?: Record; + gens_p?: Record; } export interface LoadSheddingDetail { diff --git a/frontend/src/utils/svgUtils.ts b/frontend/src/utils/svgUtils.ts index 9b8bf03a..16754a98 100644 --- a/frontend/src/utils/svgUtils.ts +++ b/frontend/src/utils/svgUtils.ts @@ -287,8 +287,11 @@ export const getActionTargetLines = ( ]); const genKeys = Object.keys(topo.gens_bus || {}); const loadKeys = Object.keys(topo.loads_bus || {}); + const loadsPKeys = Object.keys(topo.loads_p || {}); + const gensPKeys = Object.keys(topo.gens_p || {}); - if (lineKeys.size > 0 && genKeys.length === 0 && loadKeys.length === 0) { + if (lineKeys.size > 0 && genKeys.length === 0 && loadKeys.length === 0 + && loadsPKeys.length === 0 && gensPKeys.length === 0) { lineKeys.forEach(l => targets.add(l)); } else { const allValues = [ @@ -382,7 +385,8 @@ export const getActionTargetVoltageLevels = ( const topo = actionDetail?.action_topology; const isCoupling = isCouplingAction(actionId, actionDetail?.description_unitaire); const isLineReconnection = !isCoupling && !!topo - && (Object.keys(topo.gens_bus || {}).length === 0 && Object.keys(topo.loads_bus || {}).length === 0) + && (Object.keys(topo.gens_bus || {}).length === 0 && Object.keys(topo.loads_bus || {}).length === 0 + && Object.keys(topo.loads_p || {}).length === 0 && Object.keys(topo.gens_p || {}).length === 0) && ([...Object.values(topo.lines_ex_bus || {}), ...Object.values(topo.lines_or_bus || {})] as number[]).some(v => v >= 0); if (actionId && !isLineReconnection) { From 43eaa8dda8ad4225f801106f8279fa64bfaf5159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 08:34:10 +0000 Subject: [PATCH 02/14] Add tests for power reduction format and update existing tests for new topology Backend (23 new tests in test_power_reduction_format.py): - _compute_load_shedding_details with loads_p attribute and set_load_p content - _compute_curtailment_details with gens_p attribute and set_gen_p content - _mw_start_load_shedding with set_load_p content format - _mw_start_curtailment with set_gen_p content format - _build_action_entry_from_topology mapping loads_p/gens_p - Dynamic action creation with new power reduction format - Backwards compatibility with legacy bus=-1 format Updated existing backend tests: - test_dynamic_actions: expect set_load_p instead of set_bus.loads_id - test_manual_action_enrichment: expect loads_p/gens_p in topology - test_recommender_regressions: update mock actions for new format Frontend (6 new tests): - ActionFeed: load shedding/curtailment details with loads_p/gens_p topology - svgUtils: target line detection excludes power reduction actions - svgUtils: reconnection check accounts for loads_p/gens_p presence Added CLAUDE.md documenting the full test structure for both suites. https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- expert_backend/tests/CLAUDE.md | 201 +++++++ expert_backend/tests/test_dynamic_actions.py | 4 +- .../tests/test_manual_action_enrichment.py | 10 +- .../tests/test_power_reduction_format.py | 553 ++++++++++++++++++ .../tests/test_recommender_regressions.py | 6 +- frontend/src/components/ActionFeed.test.tsx | 102 ++++ frontend/src/utils/svgUtils.test.ts | 65 ++ 7 files changed, 934 insertions(+), 7 deletions(-) create mode 100644 expert_backend/tests/CLAUDE.md create mode 100644 expert_backend/tests/test_power_reduction_format.py diff --git a/expert_backend/tests/CLAUDE.md b/expert_backend/tests/CLAUDE.md new file mode 100644 index 00000000..20aa9af1 --- /dev/null +++ b/expert_backend/tests/CLAUDE.md @@ -0,0 +1,201 @@ +# Tests — Co-Study4Grid + +## Overview + +Co-Study4Grid has two test suites: **backend** (Python/pytest) and **frontend** (TypeScript/Vitest). Both run without the heavy domain packages (`pypowsybl`, `expert_op4grid_recommender`, `grid2op`) — a mock layer in `conftest.py` stubs them out. + +## Running Tests + +### Backend (pytest) + +```bash +# From project root: +pytest # Run all backend tests +pytest expert_backend/tests/test_mw_start.py # Single file +pytest -k "TestMwStart" # By class/pattern +pytest -x # Stop on first failure +``` + +Configuration in `pytest.ini`: +- `testpaths = expert_backend/tests` +- `python_files = test_*.py` + +### Frontend (Vitest) + +```bash +cd frontend +npm run test # Run all frontend tests (Vitest) +npx vitest run # Non-interactive mode +npx vitest run src/components/ActionFeed.test.tsx # Single file +``` + +Configuration in `frontend/vite.config.ts` (Vitest plugin). + +## Backend Test Structure + +### conftest.py — Shared Setup + +- Installs mock modules for `pypowsybl`, `expert_op4grid_recommender`, and submodules into `sys.modules` (only when the real package is unavailable) +- Provides fixtures: `mock_network` (pypowsybl network with lines/transformers/VLs), `mock_network_service`, `recommender_service_instance` +- `reset_config` (autouse) — snapshots and restores `expert_op4grid_recommender.config` after each test + +### Test Files by Domain + +#### API & Service Layer +| File | Description | +|------|-------------| +| `test_api_endpoints.py` | FastAPI endpoint testing with TestClient and mocked services | +| `test_recommender_service.py` | RecommenderService config updates and action enrichment | +| `test_network_service.py` | NetworkService initialization, loading, and element lookup | + +#### Analysis & Simulation +| File | Description | +|------|-------------| +| `test_recommender_simulation.py` | Real data simulation with small test grid | +| `test_split_analysis.py` | Two-step analysis workflow (step1 overload detect, step2 resolve) | +| `test_combined_actions_integration.py` | Combined action workflow integration | +| `test_combined_actions_scenario.py` | Real-world combined action scenarios | +| `test_stream_pdf_integration.py` | Streaming NDJSON + PDF event integration | + +#### Load Shedding & Curtailment +| File | Description | +|------|-------------| +| `test_power_reduction_format.py` | New `loads_p`/`gens_p` power reduction format + legacy `bus=-1` compat | +| `test_renewable_curtailment.py` | Curtailment detail computation and config updates | +| `test_manual_action_enrichment.py` | Manual action enrichment (topology, description, details) | +| `test_dynamic_actions.py` | On-the-fly action creation for `load_shedding_*`, `curtail_*`, `pst_*` | +| `test_mw_start.py` | MW Start computation for scoring (line disco, PST, load shedding, open coupling) | + +#### Core Computation +| File | Description | +|------|-------------| +| `test_compute_deltas.py` | Power flow delta calculation with terminal-aware conventions | +| `test_sanitize.py` | JSON serialization of NumPy types | +| `test_overload_filtering.py` | Overload detection and line selection | +| `test_recommender_filtering.py` | Combined action filtering logic | + +#### Monitoring & Network Analysis +| File | Description | +|------|-------------| +| `test_monitoring_consistency.py` | Monitoring parameter prioritization | +| `test_vectorized_monitoring.py` | Vectorized monitoring with masking and operational limits | +| `test_environment_detection.py` | Non-reconnectable element detection with analysis_date | + +#### Superposition +| File | Description | +|------|-------------| +| `test_superposition_accuracy.py` | Superposition vs simulation discrepancy detection | +| `test_superposition_filtering_regression.py` | Max rho filtering for heavily loaded lines | +| `test_superposition_service.py` | On-demand superposition computation | + +#### Performance & Regression +| File | Description | +|------|-------------| +| `test_performance_budgets.py` | Benchmarks for large observations (2000+ lines) | +| `test_recommender_regressions.py` | MW calculation and curtailment robustness | +| `test_recommender_non_convergence.py` | Power flow convergence failure handling | +| `test_ui_regressions.py` | Critical UI string and class presence | + +#### Infrastructure +| File | Description | +|------|-------------| +| `test_cache_synchronization.py` | Observation caching for N/N-1 calls | +| `test_islanding_mw_recommender.py` | Disconnected MW calculation on islanding | +| `test_early_pdf_reporting.py` | PDF event delivery before result event | +| `test_direct_file_loading.py` | Direct file loading configuration | +| `test_config_persistence.py` | Configuration file persistence | +| `test_sld_highlight.py` | SLD highlight and switch change computation | + +## Frontend Test Structure + +### Test Files by Domain + +#### App Integration +| File | Description | +|------|-------------| +| `App.contingency.test.tsx` | Contingency analysis workflow (step1 -> step2) | +| `App.session.test.tsx` | Session save/load and interaction | +| `App.settings.test.tsx` | Settings panel and configuration | +| `App.import.test.tsx` | Module import sanity check | + +#### Components +| File | Description | +|------|-------------| +| `ActionFeed.test.tsx` | Action card rendering, filtering, load shedding/curtailment details, MW Start, badges | +| `CombinedActionsModal.test.tsx` | Combined action pair computation modal | +| `OverloadPanel.test.tsx` | Two-step overload analysis panel | +| `VisualizationPanel.test.tsx` | SVG diagram rendering and pan/zoom | + +#### Hooks +| File | Description | +|------|-------------| +| `useActions.test.ts` | Action state management and manual action handling | +| `useAnalysis.test.ts` | Analysis streaming (step1/step2) | +| `useDiagrams.test.ts` | Diagram fetching and variant management | +| `usePanZoom.test.tsx` | Pan/zoom viewBox optimization | +| `useSession.test.ts` | Session persistence and restoration | +| `useSettings.test.ts` | Settings state and interaction logging | + +#### Utilities +| File | Description | +|------|-------------| +| `svgUtils.test.ts` | SVG processing, target detection, highlights, metadata indexing | +| `sessionUtils.test.ts` | Session snapshot building and serialization | +| `interactionLogger.test.ts` | Interaction event logger | +| `standaloneInterface.test.ts` | Standalone HTML interface serialization | +| `mergeAnalysisResult.test.ts` | Analysis result field merging | +| `fileRegistry.test.ts` | Project structure regression (removed workers) | +| `cssRegression.test.ts` | Critical CSS property guards | + +## Common Testing Patterns + +### Backend +- **Heavy mocking**: `unittest.mock.MagicMock`, `@patch` decorators +- **Fixtures**: `@pytest.fixture` for service instances, environments, mock observations +- **MockAction class**: Simulates grid2op action objects with `loads_bus`, `gens_bus`, `loads_p`, `gens_p` attributes +- **Observation mocks**: NumPy arrays for `rho`, `load_p`, `gen_p`, `p_or` with `name_line`/`name_load`/`name_gen` lists +- **Pattern**: Create service -> inject mock context -> call method -> assert fields + +### Frontend +- **Component testing**: React Testing Library (`render`, `screen`, `fireEvent`, `waitFor`) +- **Mock modules**: `vi.mock('../api')`, `vi.mock('../utils/svgUtils')` +- **Props-based**: Construct `defaultProps` -> override specific fields -> render -> assert DOM +- **Async assertions**: `await screen.findByText()` for dynamic content +- **Pattern**: Build props with test data -> `render()` -> query DOM + +## Key Data Structures in Tests + +```typescript +// ActionTopology (frontend) +{ lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, + loads_p?: { LOAD_1: 0.0 }, gens_p?: { WIND_1: 0.0 } } + +// LoadSheddingDetail +{ load_name: 'LOAD_1', voltage_level_id: 'VL_ALPHA', shedded_mw: 42.5 } + +// CurtailmentDetail +{ gen_name: 'WIND_1', voltage_level_id: 'VL_WIND', curtailed_mw: 80.0 } +``` + +```python +# Backend action content — new power reduction format +{"set_load_p": {"LOAD_1": 0.0}} +{"set_gen_p": {"WIND_1": 0.0}} + +# Backend action content — legacy bus disconnection format +{"set_bus": {"loads_id": {"LOAD_1": -1}}} +{"set_bus": {"generators_id": {"WIND_1": -1}}} + +# Action topology (new format) +{"loads_p": {"LOAD_1": 0.0}, "gens_p": {"WIND_1": 0.0}} + +# Action topology (legacy format) +{"loads_bus": {"LOAD_1": -1}, "gens_bus": {"WIND_1": -1}} +``` + +## Notes + +- Backend tests run without `pypowsybl` or `expert_op4grid_recommender` installed — `conftest.py` stubs them +- Some integration tests (e.g., `test_recommender_simulation.py`, `test_stream_pdf_integration.py`) use real test data from the `expert_op4grid_recommender` package when available +- Frontend tests require `npm install` in the `frontend/` directory +- Root-level `test_*.py` files (`test_backend.py`, `test_api_stream.py`, etc.) are ad-hoc integration scripts that require a running backend — they are not part of the pytest suite diff --git a/expert_backend/tests/test_dynamic_actions.py b/expert_backend/tests/test_dynamic_actions.py index d53e2f4e..8d632c56 100644 --- a/expert_backend/tests/test_dynamic_actions.py +++ b/expert_backend/tests/test_dynamic_actions.py @@ -52,6 +52,8 @@ def __init__(self, content): self.lines_ex_bus = set_bus.get("lines_ex_id", {}) self.lines_or_bus = set_bus.get("lines_or_id", {}) self.pst_tap = content.get("pst_tap", {}) + self.loads_p = content.get("set_load_p", {}) + self.gens_p = content.get("set_gen_p", {}) def mock_action_space_func(content): return MockAction(content) @@ -79,7 +81,7 @@ def test_simulate_manual_load_shedding_dynamic(self, service, mock_env): assert action_id in service._dict_action action_entry = service._dict_action[action_id] assert "content" in action_entry - assert action_entry["content"]["set_bus"]["loads_id"]["LOAD123"] == -1 + assert action_entry["content"]["set_load_p"]["LOAD123"] == 0.0 def test_simulate_manual_pst_tap_inc_dynamic(self, service, mock_env): """Test that a non-existent pst_tap_..._incX action is created on the fly.""" diff --git a/expert_backend/tests/test_manual_action_enrichment.py b/expert_backend/tests/test_manual_action_enrichment.py index e36d289e..775ec78a 100644 --- a/expert_backend/tests/test_manual_action_enrichment.py +++ b/expert_backend/tests/test_manual_action_enrichment.py @@ -49,6 +49,8 @@ def __init__(self, content): self.lines_ex_bus = set_bus.get("lines_ex_id", {}) self.lines_or_bus = set_bus.get("lines_or_id", {}) self.switches = set_bus.get("switches_id", {}) + self.loads_p = content.get("set_load_p", {}) + self.gens_p = content.get("set_gen_p", {}) env.action_space.side_effect = lambda content: MockAction(content) yield env @@ -64,9 +66,9 @@ def test_curtailment_enrichment(self, mock_get_vl, service, mock_env): result = service.simulate_manual_action(action_id, "CONTINGENCY") - # Check topology in immediate response + # Check topology in immediate response (new power reduction format) assert "action_topology" in result - assert result["action_topology"]["gens_bus"] == {"GEN_1": -1} + assert result["action_topology"]["gens_p"] == {"GEN_1": 0.0} # Check description (quoted and VL-aware) assert "on generator 'GEN_1'" in service._dict_action[action_id]["description"] @@ -89,9 +91,9 @@ def test_load_shedding_enrichment(self, mock_get_vl, service, mock_env): result = service.simulate_manual_action(action_id, "CONTINGENCY") - # Check topology in immediate response + # Check topology in immediate response (new power reduction format) assert "action_topology" in result - assert result["action_topology"]["loads_bus"] == {"LOAD_1": -1} + assert result["action_topology"]["loads_p"] == {"LOAD_1": 0.0} # Check description (quoted and VL-aware) assert "on 'LOAD_1'" in service._dict_action[action_id]["description"] diff --git a/expert_backend/tests/test_power_reduction_format.py b/expert_backend/tests/test_power_reduction_format.py new file mode 100644 index 00000000..79a6ec98 --- /dev/null +++ b/expert_backend/tests/test_power_reduction_format.py @@ -0,0 +1,553 @@ +# 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. + +"""Tests for new power reduction format (loads_p/gens_p) support. + +The Expert_op4grid_recommender library now uses active power setpoint changes +(set_load_p/set_gen_p) instead of bus disconnection (bus=-1) for load shedding +and curtailment actions. These tests verify that Co-Study4Grid handles both +the legacy and new formats correctly. +""" + +import numpy as np +import pytest +from unittest.mock import MagicMock, patch +from expert_backend.services.recommender_service import RecommenderService + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_obs(name_line, p_or, name_load=None, load_p=None, + name_gen=None, gen_p=None): + obs = MagicMock() + obs.name_line = name_line + obs.p_or = np.array(p_or, dtype=float) + obs.name_load = name_load or [] + obs.load_p = np.array(load_p or [], dtype=float) + obs.name_gen = name_gen or [] + obs.gen_p = np.array(gen_p or [], dtype=float) + obs.rho = np.array([0.1] * len(name_line)) + return obs + + +def _make_service_with_context(obs_n1, dict_action=None): + svc = RecommenderService() + svc._analysis_context = {"obs_simu_defaut": obs_n1} + svc._dict_action = dict_action or {} + return svc + + +# =========================================================================== +# _compute_load_shedding_details — new power reduction format +# =========================================================================== + +class TestComputeLoadSheddingPowerReduction: + """Test _compute_load_shedding_details with loads_p (new format).""" + + def test_detects_load_from_loads_p_attribute(self): + """Action object with loads_p attribute should be detected.""" + service = RecommenderService() + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], ["LOAD_1"], [50.0]) + } + + action_obj = MagicMock() + action_obj.loads_bus = {} + action_obj.loads_p = {"LOAD_1": 0.0} + + obs_action = MagicMock() + obs_action.name_load = ["LOAD_1"] + obs_action.load_p = np.array([0.0]) # reduced to 0 + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_load_voltage_level.return_value = "VL_ALPHA" + details = service._compute_load_shedding_details(action_data) + + assert details is not None + assert len(details) == 1 + assert details[0]["load_name"] == "LOAD_1" + assert details[0]["shedded_mw"] == 50.0 + assert details[0]["voltage_level_id"] == "VL_ALPHA" + + def test_detects_load_from_content_set_load_p(self): + """Fallback: loads_p not on action object but in content.set_load_p.""" + service = RecommenderService() + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], ["MY_LOAD"], [30.0]) + } + + action_obj = MagicMock() + action_obj.loads_bus = {} + action_obj.loads_p = None # not on the object + + obs_action = MagicMock() + obs_action.name_load = ["MY_LOAD"] + obs_action.load_p = np.array([5.0]) # reduced from 30 to 5 + + action_data = { + "action": action_obj, + "observation": obs_action, + "content": {"set_load_p": {"MY_LOAD": 5.0}}, + } + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_load_voltage_level.return_value = "VL_X" + details = service._compute_load_shedding_details(action_data) + + assert details is not None + assert len(details) == 1 + assert details[0]["load_name"] == "MY_LOAD" + assert details[0]["shedded_mw"] == 25.0 + + def test_no_duplicates_when_both_formats_present(self): + """If same load appears in both loads_bus and loads_p, report once.""" + service = RecommenderService() + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], ["LOAD_A"], [100.0]) + } + + action_obj = MagicMock() + action_obj.loads_bus = {"LOAD_A": -1} + action_obj.loads_p = {"LOAD_A": 0.0} + + obs_action = MagicMock() + obs_action.name_load = ["LOAD_A"] + obs_action.load_p = np.array([0.0]) + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_load_voltage_level.return_value = "VL_1" + details = service._compute_load_shedding_details(action_data) + + assert details is not None + assert len(details) == 1 # not 2 + + def test_returns_none_when_neither_format_present(self): + service = RecommenderService() + action_obj = MagicMock() + action_obj.loads_bus = {} + action_obj.loads_p = {} + + action_data = { + "action": action_obj, + "observation": MagicMock(), + } + details = service._compute_load_shedding_details(action_data) + assert details is None + + def test_legacy_format_still_works(self): + """Legacy bus=-1 format should still be detected (backwards compat).""" + service = RecommenderService() + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], ["LOAD_LEG"], [60.0]) + } + + action_obj = MagicMock() + action_obj.loads_bus = {"LOAD_LEG": -1} + action_obj.loads_p = None + + obs_action = MagicMock() + obs_action.name_load = ["LOAD_LEG"] + obs_action.load_p = np.array([0.0]) + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_load_voltage_level.return_value = "VL_LEG" + details = service._compute_load_shedding_details(action_data) + + assert details is not None + assert details[0]["load_name"] == "LOAD_LEG" + assert details[0]["shedded_mw"] == 60.0 + + +# =========================================================================== +# _compute_curtailment_details — new power reduction format +# =========================================================================== + +class TestComputeCurtailmentPowerReduction: + """Test _compute_curtailment_details with gens_p (new format).""" + + def test_detects_gen_from_gens_p_attribute(self): + service = RecommenderService() + service._is_renewable_gen = MagicMock(return_value=True) + service._analysis_context = { + "obs_simu_defaut": _make_obs( + [], [], name_gen=["WIND_1"], gen_p=[80.0] + ) + } + + action_obj = MagicMock() + action_obj.gens_bus = {} + action_obj.gens_p = {"WIND_1": 0.0} + + obs_action = MagicMock() + obs_action.name_gen = ["WIND_1"] + obs_action.gen_p = np.array([0.0]) + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_generator_voltage_level.return_value = "VL_WIND" + details = service._compute_curtailment_details(action_data) + + assert details is not None + assert len(details) == 1 + assert details[0]["gen_name"] == "WIND_1" + assert details[0]["curtailed_mw"] == 80.0 + assert details[0]["voltage_level_id"] == "VL_WIND" + + def test_detects_gen_from_content_set_gen_p(self): + service = RecommenderService() + service._is_renewable_gen = MagicMock(return_value=True) + service._analysis_context = { + "obs_simu_defaut": _make_obs( + [], [], name_gen=["SOLAR_1"], gen_p=[50.0] + ) + } + + action_obj = MagicMock() + action_obj.gens_bus = {} + action_obj.gens_p = None + + obs_action = MagicMock() + obs_action.name_gen = ["SOLAR_1"] + obs_action.gen_p = np.array([10.0]) + + action_data = { + "action": action_obj, + "observation": obs_action, + "content": {"set_gen_p": {"SOLAR_1": 10.0}}, + } + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_generator_voltage_level.return_value = "VL_SOLAR" + details = service._compute_curtailment_details(action_data) + + assert details is not None + assert details[0]["gen_name"] == "SOLAR_1" + assert details[0]["curtailed_mw"] == 40.0 + + def test_no_duplicates_when_both_formats_present(self): + service = RecommenderService() + service._is_renewable_gen = MagicMock(return_value=True) + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], name_gen=["GEN_A"], gen_p=[70.0]) + } + + action_obj = MagicMock() + action_obj.gens_bus = {"GEN_A": -1} + action_obj.gens_p = {"GEN_A": 0.0} + + obs_action = MagicMock() + obs_action.name_gen = ["GEN_A"] + obs_action.gen_p = np.array([0.0]) + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_generator_voltage_level.return_value = "VL_1" + details = service._compute_curtailment_details(action_data) + + assert details is not None + assert len(details) == 1 + + def test_legacy_gens_bus_still_works(self): + service = RecommenderService() + service._is_renewable_gen = MagicMock(return_value=True) + service._analysis_context = { + "obs_simu_defaut": _make_obs([], [], name_gen=["EOL_1"], gen_p=[90.0]) + } + + action_obj = MagicMock() + action_obj.gens_bus = {"EOL_1": -1} + action_obj.gens_p = None + + obs_action = MagicMock() + obs_action.name_gen = ["EOL_1"] + obs_action.gen_p = np.array([0.0]) + + action_data = {"action": action_obj, "observation": obs_action} + + with patch("expert_backend.services.network_service.network_service") as mock_ns: + mock_ns.get_generator_voltage_level.return_value = "VL_EOL" + details = service._compute_curtailment_details(action_data) + + assert details is not None + assert details[0]["gen_name"] == "EOL_1" + assert details[0]["curtailed_mw"] == 90.0 + + +# =========================================================================== +# _mw_start_load_shedding — new set_load_p content format +# =========================================================================== + +class TestMwStartLoadSheddingPowerReduction: + def test_extracts_load_p_from_set_load_p_content(self): + obs = _make_obs([], [], name_load=["LOAD_1", "LOAD_2"], load_p=[150.0, 80.0]) + dict_action = { + "load_shedding_LOAD_1": { + "content": { + "set_load_p": {"LOAD_1": 0.0} + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"load_shedding": {"scores": {"load_shedding_LOAD_1": 0.4}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["load_shedding"]["mw_start"]["load_shedding_LOAD_1"] == pytest.approx(150.0, abs=0.1) + + def test_sums_multiple_loads_from_set_load_p(self): + obs = _make_obs([], [], name_load=["L1", "L2", "L3"], load_p=[60.0, 40.0, 20.0]) + dict_action = { + "ls_multi": { + "content": { + "set_load_p": {"L1": 0.0, "L2": 0.0} # L3 not reduced + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"load_shedding": {"scores": {"ls_multi": 0.3}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["load_shedding"]["mw_start"]["ls_multi"] == pytest.approx(100.0, abs=0.1) + + def test_prefers_set_load_p_over_set_bus(self): + """When both set_load_p and set_bus.loads_id exist, set_load_p takes priority.""" + obs = _make_obs([], [], name_load=["LOAD_X"], load_p=[200.0]) + dict_action = { + "ls_both": { + "content": { + "set_load_p": {"LOAD_X": 0.0}, + "set_bus": {"loads_id": {"LOAD_X": -1}}, + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"load_shedding": {"scores": {"ls_both": 0.5}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["load_shedding"]["mw_start"]["ls_both"] == pytest.approx(200.0, abs=0.1) + + def test_legacy_set_bus_still_works(self): + obs = _make_obs([], [], name_load=["LOAD_OLD"], load_p=[75.0]) + dict_action = { + "ls_legacy": { + "content": { + "set_bus": {"loads_id": {"LOAD_OLD": -1}} + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"load_shedding": {"scores": {"ls_legacy": 0.4}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["load_shedding"]["mw_start"]["ls_legacy"] == pytest.approx(75.0, abs=0.1) + + +# =========================================================================== +# _mw_start_curtailment — new set_gen_p content format +# =========================================================================== + +class TestMwStartCurtailmentPowerReduction: + def test_extracts_gen_p_from_set_gen_p_content(self): + obs = _make_obs([], [], name_gen=["WIND_1"], gen_p=[120.0]) + dict_action = { + "curtail_WIND_1": { + "content": { + "set_gen_p": {"WIND_1": 0.0} + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"renewable_curtailment": {"scores": {"curtail_WIND_1": 0.6}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["renewable_curtailment"]["mw_start"]["curtail_WIND_1"] == pytest.approx(120.0, abs=0.1) + + def test_prefers_set_gen_p_over_set_bus(self): + obs = _make_obs([], [], name_gen=["GEN_Y"], gen_p=[95.0]) + dict_action = { + "curtail_GEN_Y": { + "content": { + "set_gen_p": {"GEN_Y": 0.0}, + "set_bus": {"generators_id": {"GEN_Y": -1}}, + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"renewable_curtailment": {"scores": {"curtail_GEN_Y": 0.5}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["renewable_curtailment"]["mw_start"]["curtail_GEN_Y"] == pytest.approx(95.0, abs=0.1) + + def test_legacy_set_bus_still_works(self): + obs = _make_obs([], [], name_gen=["GEN_OLD"], gen_p=[88.0]) + dict_action = { + "curtail_GEN_OLD": { + "content": { + "set_bus": {"generators_id": {"GEN_OLD": -1}} + } + } + } + svc = _make_service_with_context(obs, dict_action) + + scores = {"renewable_curtailment": {"scores": {"curtail_GEN_OLD": 0.7}}} + result = svc._compute_mw_start_for_scores(scores) + + assert result["renewable_curtailment"]["mw_start"]["curtail_GEN_OLD"] == pytest.approx(88.0, abs=0.1) + + +# =========================================================================== +# _build_action_entry_from_topology — new loads_p/gens_p mapping +# =========================================================================== + +class TestBuildActionEntryPowerReduction: + def test_maps_loads_p_to_set_load_p(self): + topo = {"loads_p": {"LOAD_1": 0.0, "LOAD_2": 5.0}} + entry = RecommenderService._build_action_entry_from_topology("test_ls", topo) + + assert "set_load_p" in entry["content"] + assert entry["content"]["set_load_p"]["LOAD_1"] == 0.0 + assert entry["content"]["set_load_p"]["LOAD_2"] == 5.0 + + def test_maps_gens_p_to_set_gen_p(self): + topo = {"gens_p": {"WIND_1": 0.0}} + entry = RecommenderService._build_action_entry_from_topology("test_curtail", topo) + + assert "set_gen_p" in entry["content"] + assert entry["content"]["set_gen_p"]["WIND_1"] == 0.0 + + def test_legacy_topo_still_produces_set_bus(self): + topo = {"loads_bus": {"LOAD_1": -1}} + entry = RecommenderService._build_action_entry_from_topology("test_legacy", topo) + + assert "set_bus" in entry["content"] + assert entry["content"]["set_bus"]["loads_id"]["LOAD_1"] == -1 + + def test_mixed_topo_includes_both(self): + topo = { + "loads_bus": {"LOAD_1": -1}, + "loads_p": {"LOAD_2": 0.0}, + "gens_p": {"GEN_1": 0.0}, + } + entry = RecommenderService._build_action_entry_from_topology("test_mixed", topo) + + assert entry["content"]["set_bus"]["loads_id"]["LOAD_1"] == -1 + assert entry["content"]["set_load_p"]["LOAD_2"] == 0.0 + assert entry["content"]["set_gen_p"]["GEN_1"] == 0.0 + + def test_empty_loads_p_not_included(self): + topo = {"loads_p": {}} + entry = RecommenderService._build_action_entry_from_topology("test_empty", topo) + + assert "set_load_p" not in entry.get("content", {}) + + +# =========================================================================== +# Dynamic action creation — new format +# =========================================================================== + +class TestDynamicActionsPowerReduction: + @pytest.fixture + def service(self): + s = RecommenderService() + s._dict_action = {"dummy": {}} + return s + + @pytest.fixture + def mock_env(self): + with patch("expert_backend.services.recommender_service.RecommenderService._get_simulation_env") as mock_get_env: + env = MagicMock() + mock_get_env.return_value = env + + obs = MagicMock() + obs.n_components = 1 + obs.main_component_load_mw = 1000.0 + obs.name_line = ["LINE_1"] + obs.rho = np.array([0.1]) + obs.name_load = ["LOAD_DYN"] + obs.load_p = np.array([50.0]) + obs.name_gen = ["GEN_DYN"] + obs.gen_p = np.array([80.0]) + + sim_obs = MagicMock() + sim_obs.n_components = 1 + sim_obs.main_component_load_mw = 1000.0 + sim_obs.rho = np.array([0.05]) + sim_obs.name_line = ["LINE_1"] + sim_obs.name_load = ["LOAD_DYN"] + sim_obs.load_p = np.array([0.0]) + sim_obs.name_gen = ["GEN_DYN"] + sim_obs.gen_p = np.array([0.0]) + + obs.simulate.return_value = (sim_obs, 0.5, False, {"exception": None}) + env.get_obs.return_value = obs + + class MockAction: + def __init__(self, content): + set_bus = content.get("set_bus", {}) + self.loads_bus = set_bus.get("loads_id", {}) + self.gens_bus = set_bus.get("generators_id", {}) + self.lines_ex_bus = set_bus.get("lines_ex_id", {}) + self.lines_or_bus = set_bus.get("lines_or_id", {}) + self.pst_tap = content.get("pst_tap", {}) + # New power reduction fields + self.loads_p = content.get("set_load_p", {}) + self.gens_p = content.get("set_gen_p", {}) + + env.action_space.side_effect = lambda content: MockAction(content) + yield env + + def test_dynamic_load_shedding_uses_loads_p_format(self, service, mock_env): + """Dynamic load_shedding_ actions should use loads_p topology.""" + action_id = "load_shedding_LOAD_DYN" + service._dict_action = {"dummy": {}} + + with patch.object(service, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(service, "_compute_deltas", return_value={}): + result = service.simulate_manual_action(action_id, "CONTINGENCY") + + # Topology should contain loads_p, not loads_bus with -1 + assert "action_topology" in result + assert result["action_topology"].get("loads_p") == {"LOAD_DYN": 0.0} + + # Content should use set_load_p format + action_entry = service._dict_action[action_id] + assert "set_load_p" in action_entry["content"] + assert action_entry["content"]["set_load_p"]["LOAD_DYN"] == 0.0 + + @patch("expert_backend.services.network_service.network_service.get_generator_voltage_level") + def test_dynamic_curtailment_uses_gens_p_format(self, mock_get_vl, service, mock_env): + """Dynamic curtail_ actions should use gens_p topology.""" + mock_get_vl.return_value = "VL_GEN" + service._is_renewable_gen = MagicMock(return_value=True) + action_id = "curtail_GEN_DYN" + service._dict_action = {"dummy": {}} + + with patch.object(service, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(service, "_compute_deltas", return_value={}): + result = service.simulate_manual_action(action_id, "CONTINGENCY") + + assert "action_topology" in result + assert result["action_topology"].get("gens_p") == {"GEN_DYN": 0.0} + + action_entry = service._dict_action[action_id] + assert "set_gen_p" in action_entry["content"] + assert action_entry["content"]["set_gen_p"]["GEN_DYN"] == 0.0 diff --git a/expert_backend/tests/test_recommender_regressions.py b/expert_backend/tests/test_recommender_regressions.py index 0fd32a97..8180a797 100644 --- a/expert_backend/tests/test_recommender_regressions.py +++ b/expert_backend/tests/test_recommender_regressions.py @@ -78,8 +78,10 @@ def test_dynamic_curtailment_reconstruction(self): # Mock action object returned by action_space mock_action = MagicMock() - mock_action.gens_bus = {"GEN_TEST": -1} + mock_action.gens_bus = {} mock_action.loads_bus = {} + mock_action.gens_p = {"GEN_TEST": 0.0} + mock_action.loads_p = {} mock_env.action_space.return_value = mock_action with patch.object(self.service, "_get_simulation_env", return_value=mock_env), \ @@ -101,7 +103,7 @@ def test_dynamic_curtailment_reconstruction(self): # Verify it was dynamically created and injected assert "curtail_GEN_TEST" in self.service._dict_action entry = self.service._dict_action["curtail_GEN_TEST"] - assert entry["content"]["set_bus"]["generators_id"]["GEN_TEST"] == -1 + assert entry["content"]["set_gen_p"]["GEN_TEST"] == 0.0 assert "Renewable curtailment" in entry["description"] # Verify enriched result contains curtailment_details diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index 9b2b3622..aa799f74 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -965,4 +965,106 @@ describe('ActionFeed', () => { expect(await screen.findByText('75.0')).toBeInTheDocument(); }); + + // ── Power reduction format (loads_p / gens_p) ────────────────────────── + + it('displays load shedding details with new loads_p topology format', () => { + const actionId = 'load_shed_new_format'; + const props = { + ...defaultProps, + actions: { + [actionId]: { + description_unitaire: 'Load shedding (power reduction)', + rho_before: [1.0], + rho_after: [0.8], + max_rho: 0.8, + max_rho_line: 'LINE_A', + is_rho_reduction: true, + action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, loads_p: { LOAD_PR: 0.0 } }, + load_shedding_details: [ + { load_name: 'LOAD_PR', voltage_level_id: 'VL_PR', shedded_mw: 55.3 }, + ], + } + }, + selectedActionIds: new Set([actionId]), + }; + render(); + + expect(screen.getByText(/55\.3 MW/)).toBeInTheDocument(); + expect(screen.getByText('LOAD_PR')).toBeInTheDocument(); + const vlButtons = screen.getAllByText('VL_PR'); + expect(vlButtons.length).toBeGreaterThanOrEqual(1); + }); + + it('displays curtailment details with new gens_p topology format', () => { + const actionId = 'curtail_new_format'; + const props = { + ...defaultProps, + actions: { + [actionId]: { + description_unitaire: 'Curtailment (power reduction)', + rho_before: [1.0], + rho_after: [0.85], + max_rho: 0.85, + max_rho_line: 'LINE_A', + is_rho_reduction: true, + action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, gens_p: { WIND_1: 0.0 } }, + curtailment_details: [ + { gen_name: 'WIND_1', voltage_level_id: 'VL_WIND', curtailed_mw: 80.0 }, + ], + } + }, + selectedActionIds: new Set([actionId]), + }; + render(); + + expect(screen.getByText(/80\.0 MW/)).toBeInTheDocument(); + expect(screen.getByText('WIND_1')).toBeInTheDocument(); + const vlButtons = screen.getAllByText('VL_WIND'); + expect(vlButtons.length).toBeGreaterThanOrEqual(1); + }); + + it('displays load shedding with loads_p and curtailment with gens_p in same action list', () => { + const lsId = 'ls_new_1'; + const rcId = 'rc_new_1'; + const props = { + ...defaultProps, + actions: { + [lsId]: { + description_unitaire: 'Load shedding power reduction', + rho_before: [1.0], + rho_after: [0.8], + max_rho: 0.8, + max_rho_line: 'LINE_A', + is_rho_reduction: true, + action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, loads_p: { LOAD_NEW: 0.0 } }, + load_shedding_details: [ + { load_name: 'LOAD_NEW', voltage_level_id: 'VL_LS', shedded_mw: 33.0 }, + ], + }, + [rcId]: { + description_unitaire: 'Curtailment power reduction', + rho_before: [1.0], + rho_after: [0.85], + max_rho: 0.85, + max_rho_line: 'LINE_A', + is_rho_reduction: true, + action_topology: { lines_ex_bus: {}, lines_or_bus: {}, gens_bus: {}, loads_bus: {}, gens_p: { GEN_NEW: 0.0 } }, + curtailment_details: [ + { gen_name: 'GEN_NEW', voltage_level_id: 'VL_RC', curtailed_mw: 66.0 }, + ], + } + }, + selectedActionIds: new Set([lsId, rcId]), + }; + render(); + + // Load shedding details with new format + expect(screen.getByText(/33 MW/)).toBeInTheDocument(); + expect(screen.getByText('LOAD_NEW')).toBeInTheDocument(); + + // Curtailment details with new format + expect(screen.getByText(/66\.0 MW/)).toBeInTheDocument(); + expect(screen.getByText('GEN_NEW')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/utils/svgUtils.test.ts b/frontend/src/utils/svgUtils.test.ts index 4e50c530..81d1e210 100644 --- a/frontend/src/utils/svgUtils.test.ts +++ b/frontend/src/utils/svgUtils.test.ts @@ -271,6 +271,49 @@ describe('getActionTargetLines', () => { expect(result).toEqual([]); }); + it('does not return lines when loads_p is present (power reduction action)', () => { + const detail: ActionDetail = { + description_unitaire: 'Load shedding via power reduction', + rho_before: null, + rho_after: null, + max_rho: null, + max_rho_line: '', + is_rho_reduction: false, + action_topology: { + lines_ex_bus: { LINE_A: 1 }, + lines_or_bus: { LINE_A: 1 }, + gens_bus: {}, + loads_bus: {}, + loads_p: { LOAD_1: 0.0 }, + }, + }; + + // loads_p present means this isn't a pure line action + const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A')); + expect(result).toEqual([]); + }); + + it('does not return lines when gens_p is present (curtailment power reduction)', () => { + const detail: ActionDetail = { + description_unitaire: 'Curtailment via power reduction', + rho_before: null, + rho_after: null, + max_rho: null, + max_rho_line: '', + is_rho_reduction: false, + action_topology: { + lines_ex_bus: { LINE_A: 1 }, + lines_or_bus: { LINE_A: 1 }, + gens_bus: {}, + loads_bus: {}, + gens_p: { WIND_1: 0.0 }, + }, + }; + + const result = getActionTargetLines(detail, null, makeEdgeMap('LINE_A')); + expect(result).toEqual([]); + }); + it('handles combined action IDs with + separator', () => { const detail: ActionDetail = { description_unitaire: 'Multiple lines', @@ -488,6 +531,28 @@ describe('getActionTargetVoltageLevels', () => { expect(result).toEqual([]); }); + it('does not skip action ID fallback when loads_p is present (not a reconnection)', () => { + const detail: ActionDetail = { + description_unitaire: 'Power reduction on load', + rho_before: null, + rho_after: null, + max_rho: null, + max_rho_line: '', + is_rho_reduction: false, + action_topology: { + lines_ex_bus: { LINE_A: 1 }, + lines_or_bus: { LINE_A: 2 }, + gens_bus: {}, + loads_bus: {}, + loads_p: { LOAD_1: 0.0 }, + }, + }; + + // Even though lines have bus >= 0, loads_p presence means it's NOT a pure reconnection + const result = getActionTargetVoltageLevels(detail, 'action_VL1', makeNodeMap('VL1')); + expect(result).toEqual(['VL1']); + }); + it('strips _inc/_dec suffixes in fallback ID parsing', () => { const detail: ActionDetail = { description_unitaire: 'No description available', From b528dfcebe39614a9af8fd3e64fc48a15f336653 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 08:34:58 +0000 Subject: [PATCH 03/14] Add node_modules/ to root .gitignore https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ae824c1c..72a88bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ Overflow_Graph/ config.json config_path.txt +# Node modules +node_modules/ + # Virtual environments venv/ venv*/ From 4037ba47e82ce6780ce34e09f5f59fa31c73be28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 08:45:07 +0000 Subject: [PATCH 04/14] Update curtailment-actions.md to reflect power reduction format changes Rewrites the spec document to reflect the current implementation state: - Documents the new set_load_p/set_gen_p format from recommender PR #74 - Describes dual-format detection (legacy bus=-1 + new power reduction) - Documents dynamic action creation, MW start computation, and topology mapping - Lists all relevant tests covering the new format - Adds planned configurable MW slider section https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- docs/curtailment-actions.md | 248 ++++++++++++++++++++++++++---------- 1 file changed, 178 insertions(+), 70 deletions(-) diff --git a/docs/curtailment-actions.md b/docs/curtailment-actions.md index 454e34e9..3fa552e5 100644 --- a/docs/curtailment-actions.md +++ b/docs/curtailment-actions.md @@ -1,79 +1,187 @@ -# Renewable Curtailment Actions — Revised Implementation Specification +# Renewable Curtailment & Load Shedding — Implementation Status -This document outlines the changes required to integrate renewable curtailment actions into ExpertAssist. Renewable curtailment is the generation-side counterpart to load shedding, and its implementation follows the same architectural patterns. +This document describes the implementation of renewable curtailment and load shedding actions in Co-Study4Grid, including the recent migration to the **power reduction format** (`set_load_p`/`set_gen_p`) introduced in [Expert_op4grid_recommender PR #74](https://github.com/marota/Expert_op4grid_recommender/pull/74). -## 1. Library Interface (expert_op4grid_recommender) +## 1. Action Format Evolution -The implementation depends on functional parity in the `expert_op4grid_recommender` library (implementing PR #71 requirements). +### Legacy Format (bus disconnection) + +The original implementation disconnected loads/generators from the grid by setting their bus to -1: + +```python +# Load shedding +{"set_bus": {"loads_id": {"LOAD_1": -1}}} +# Topology: loads_bus = {"LOAD_1": -1} + +# Renewable curtailment +{"set_bus": {"generators_id": {"WIND_1": -1}}} +# Topology: gens_bus = {"WIND_1": -1} +``` + +This completely disconnects the element, losing voltage support and reactive power. + +### New Format (power reduction) + +PR #74 introduces active power setpoint changes that keep elements electrically connected: + +```python +# Load shedding — reduce load to target MW +{"set_load_p": {"LOAD_1": 0.0}} +# Topology: loads_p = {"LOAD_1": 0.0} + +# Renewable curtailment — reduce generation to target MW +{"set_gen_p": {"WIND_1": 0.0}} +# Topology: gens_p = {"WIND_1": 0.0} +``` + +The target MW can be any value between 0 and the current production/consumption, enabling **partial** load shedding and curtailment (configurable MW slider — planned feature). + +### New Metadata Fields + +The recommender library now provides explicit metadata on power reduction actions: +- `target_p_MW`: The target power setpoint value +- `reduction_MW`: The actual amount of power reduced +- `mw_required`: The required MW reduction amount + +## 2. Library Interface (expert_op4grid_recommender) ### Configuration -`config.py` must include: -- `MIN_RENEWABLE_CURTAILMENT` (float): Minimum score threshold for renewable curtailment actions (default: 0.0). +- `config.MIN_LOAD_SHEDDING` (int): Minimum number of load shedding actions to recommend. +- `config.MIN_RENEWABLE_CURTAILMENT_ACTIONS` (int): Minimum number of curtailment actions to recommend. ### Action Classification -`ActionClassifier.identify_grid2op_action_type()` must return `"open_gen"` for actions that disconnect one or more generators (where `gen_set_bus == -1` and no other elements except switches/nodes are modified). +`ActionClassifier` classifies actions as: +- `"load_shedding"` / `"load_power_reduction"` — actions that reduce load consumption +- `"renewable_curtailment"` / `"gen_power_reduction"` / `"open_gen"` — actions that reduce renewable generation ### Recommendation Results -The analysis output now includes a `renewable_curtailment` key in `action_scores`, containing: -- `scores`: Map of `action_id` to its score. -- `mw_start`: Map of `action_id` to the MW value of the curtailed generator(s) at the start of the action (N-state or N-1 state). -- `params`: Relevant scoring parameters (e.g., energy source type). - -## 2. ExpertAssist Backend - -### Configuration Models -Update `expert_backend/main.py`: -- Add `min_renewable_curtailment: float = 0.0` to `ConfigRequest`. -- Update `get_config()` to return the new parameter. -- Update `action_dict_stats` to include a count of `"open_gen"` actions. - -### Network Service -Update `expert_backend/services/network_service.py`: -- Implement `get_generator_voltage_level(gen_id: str)` to resolve generator IDs to their voltage levels via pypowsybl. - -### Recommender Service -Update `expert_backend/services/recommender_service.py`: -- **Config Mapping**: Propagate `min_renewable_curtailment` from `Settings` to the library's `config.MIN_RENEWABLE_CURTAILMENT`. -- **Curtailment Enrichment**: - ```python - def _compute_curtailment_details(self, action_topology): - # 1. Identify disconnected generators from gens_bus - # 2. Compute curtailed MW (pre-action production vs post-action production) - # 3. Resolve voltage_level_id & energy_source - # 4. Return list of CurtailmentDetail objects - ``` -- **MW lookup**: Implement `_get_action_mw_start` for `renewable_curtailment` / `open_gen` to fetch pre-action production values from the observation. - -## 3. ExpertAssist Frontend - -### Types -Update `frontend/src/types.ts`: -- Add `CurtailmentDetail` interface: - ```typescript - export interface CurtailmentDetail { - generator_name: string; - voltage_level_id: string | null; - energy_source: string; - curtailed_mw: number; - } - ``` -- Add `curtailment_details?: CurtailmentDetail[]` to `ActionDetail` and `SavedActionEntry`. -- Add `min_renewable_curtailment: number` to `ConfigRequest` and `SettingsBackup`. - -### Settings UI -Update `SettingsModal.tsx`: -- Add a "Renewable Curtailment" numeric input in the Recommender tab, following "Min Load Shedding". - -### Action Feed & Filtering -Update `ActionFeed.tsx`: -- Add a `curtail` checkbox to the manual selection search dropdown. -- Add logic to identify "curtailment" actions in `filteredActions` and `scoredActionsList` (types containing `"curtail"` or `"open_gen"`). -- Render `curtailment_details` in the action card, styled with a distinct color (greenish) to differ from load shedding. - -### Combined Actions -Update `CombinedActionsModal.tsx`: -- Add a `CURTAIL` filter button to the exploration tab. -- Ensure combined actions involving curtailment propagate their details correctly. - -## 4. Session Persistence -Ensure `session_service.py` and frontend session saving logic include `min_renewable_curtailment` and `curtailment_details`. +The analysis output includes: +- `action_scores["load_shedding"]` — scores and `mw_start` for load shedding actions +- `action_scores["renewable_curtailment"]` — scores and `mw_start` for curtailment actions + +## 3. Backend Implementation + +### Configuration (`main.py`) +- `ConfigRequest` includes `min_load_shedding: int` and `min_renewable_curtailment_actions: int` +- Propagated to `config.MIN_LOAD_SHEDDING` and `config.MIN_RENEWABLE_CURTAILMENT_ACTIONS` + +### Network Service (`network_service.py`) +- `get_load_voltage_level(load_id)` — resolves load ID to its voltage level +- `get_generator_voltage_level(gen_id)` — resolves generator ID to its voltage level + +### Recommender Service (`recommender_service.py`) + +#### Action Enrichment (`_enrich_actions`) +Extracts topology fields from action objects, including both legacy and new fields: +```python +for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", + "pst_tap", "substations", "switches", "loads_p", "gens_p"): +``` + +#### Load Shedding Details (`_compute_load_shedding_details`) +Detects affected loads from **both** formats: +1. Legacy: `loads_bus` entries with `bus == -1` +2. New: `loads_p` attribute on the action object, or `set_load_p` in action content + +Returns `[{load_name, voltage_level_id, shedded_mw}]` computed by comparing N-1 and post-action observations. + +#### Curtailment Details (`_compute_curtailment_details`) +Same dual-format detection for generators: +1. Legacy: `gens_bus` entries with `bus == -1` +2. New: `gens_p` attribute on the action object, or `set_gen_p` in action content + +Returns `[{gen_name, voltage_level_id, curtailed_mw}]`. Only includes generators identified as renewable by `_is_renewable_gen()`. + +#### MW Start Computation +`_mw_start_load_shedding()` and `_mw_start_curtailment()` check for both formats: +1. New format first: `content.set_load_p` / `content.set_gen_p` +2. Legacy fallback: `content.set_bus.loads_id` / `content.set_bus.generators_id` with `bus == -1` +3. Action ID pattern fallback: `load_shedding_` / `curtail_` + +#### Dynamic Action Creation +When a user manually simulates `load_shedding_` or `curtail_`, the backend creates the action on-the-fly using the **new format**: +```python +# load_shedding_LOAD_1 → topology: {"loads_p": {"LOAD_1": 0.0}} +# curtail_WIND_1 → topology: {"gens_p": {"WIND_1": 0.0}} +``` + +#### Topology Reconstruction (`_build_action_entry_from_topology`) +Maps topology fields to action content: +- `loads_p` → `content.set_load_p` +- `gens_p` → `content.set_gen_p` +- Legacy `loads_bus` → `content.set_bus.loads_id` (unchanged) + +## 4. Frontend Implementation + +### Types (`types.ts`) +```typescript +interface ActionTopology { + lines_ex_bus: Record; + lines_or_bus: Record; + gens_bus: Record; + loads_bus: Record; + pst_tap?: Record; + substations?: Record; + switches?: Record; + loads_p?: Record; // New power reduction + gens_p?: Record; // New power reduction +} + +interface LoadSheddingDetail { + load_name: string; + voltage_level_id: string | null; + shedded_mw: number; +} + +interface CurtailmentDetail { + gen_name: string; + voltage_level_id: string | null; + curtailed_mw: number; +} +``` + +### Action Feed (`ActionFeed.tsx`) +- **Filtering**: `Load Shedding` and `Renewable Curtailment` checkbox filters in manual selection search +- **Score table**: Shows `MW Start` column (current load/generation MW before action) +- **Action cards**: + - Load shedding details: amber background (`#fef3c7`), shows shedded MW and load name per load + - Curtailment details: light blue background (`#e0f2fe`), shows curtailed MW and generator name + - Clickable voltage level badges for navigation +- **Equipment badges**: Extracted from `loads_p`/`gens_p` in addition to `loads_bus`/`gens_bus` + +### SVG Utilities (`svgUtils.ts`) +- Target detection accounts for `loads_p`/`gens_p` when determining affected equipment +- Line reconnection check excludes actions with `loads_p`/`gens_p` (not pure reconnections) + +### Visualization Panel (`VisualizationPanel.tsx`) +- Target equipment IDs include elements from `loads_p`/`gens_p` for highlighting + +### Session Persistence +- `sessionUtils.ts` serializes `action_topology` (including `loads_p`/`gens_p`) and `load_shedding_details`/`curtailment_details` in session snapshots +- Session restore reconstructs actions via `_build_action_entry_from_topology()` which handles both formats + +## 5. Testing + +### Backend (`expert_backend/tests/`) +- `test_power_reduction_format.py` — 23 tests covering all new format scenarios: + - Detection of loads_p/gens_p in compute details functions + - MW start extraction from set_load_p/set_gen_p + - Topology-to-content mapping + - Dynamic action creation with new format + - Backwards compatibility with legacy format +- `test_renewable_curtailment.py` — curtailment detail computation and config updates +- `test_manual_action_enrichment.py` — enrichment with new topology format +- `test_dynamic_actions.py` — on-the-fly action creation +- `test_mw_start.py` — MW start computation for all action types + +### Frontend (`frontend/src/`) +- `ActionFeed.test.tsx` — rendering of load shedding/curtailment details with both topology formats +- `svgUtils.test.ts` — target detection with loads_p/gens_p fields + +## 6. Planned: Configurable MW Slider + +A future enhancement will allow users to choose the MW reduction target when manually simulating load shedding or curtailment actions: +- Input range: 0 to `mw_start` (current load/generation MW) +- Requires new `target_mw` parameter in `simulateManualAction` API +- Backend will pass the target MW to the action content (`set_load_p`/`set_gen_p`) +- Enables partial load shedding and partial curtailment scenarios From 3b116bbe7fa8c1a1d8c221e7266281a0163f89cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 14:57:22 +0000 Subject: [PATCH 05/14] Add configurable MW reduction for load shedding and curtailment actions Users can now choose partial MW reduction instead of full shedding/curtailment: - Backend: target_mw parameter in simulate_manual_action computes setpoint from N-1 observation (remaining = current_mw - target_mw, clamped >= 0) - Frontend: Target MW input column in score table for LS/RC action types - Frontend: Editable MW + Re-simulate button in action card detail boxes - 8 new backend tests, 6 new frontend tests covering the feature - Updated curtailment-actions.md documentation https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- docs/curtailment-actions.md | 61 +++- expert_backend/main.py | 2 + .../services/recommender_service.py | 77 +++++- expert_backend/tests/test_configurable_mw.py | 260 ++++++++++++++++++ frontend/src/api.ts | 4 +- frontend/src/components/ActionFeed.test.tsx | 154 ++++++++++- frontend/src/components/ActionFeed.tsx | 133 ++++++++- 7 files changed, 665 insertions(+), 26 deletions(-) create mode 100644 expert_backend/tests/test_configurable_mw.py diff --git a/docs/curtailment-actions.md b/docs/curtailment-actions.md index 3fa552e5..70eba24e 100644 --- a/docs/curtailment-actions.md +++ b/docs/curtailment-actions.md @@ -173,15 +173,62 @@ interface CurtailmentDetail { - `test_manual_action_enrichment.py` — enrichment with new topology format - `test_dynamic_actions.py` — on-the-fly action creation - `test_mw_start.py` — MW start computation for all action types +- `test_configurable_mw.py` — 8 tests for configurable MW reduction: + - Partial load shedding with target_mw (setpoint = current - target, clamped >= 0) + - Partial curtailment with target_mw + - Full shedding/curtailment when target_mw is omitted + - Clamping when target_mw exceeds current MW + - Re-simulation of existing actions with updated target_mw + - Content unchanged when target_mw is not provided on re-simulation ### Frontend (`frontend/src/`) -- `ActionFeed.test.tsx` — rendering of load shedding/curtailment details with both topology formats +- `ActionFeed.test.tsx` — rendering of load shedding/curtailment details with both topology formats, Target MW input in score table, editable MW + re-simulate in action cards - `svgUtils.test.ts` — target detection with loads_p/gens_p fields -## 6. Planned: Configurable MW Slider +## 6. Configurable MW Reduction -A future enhancement will allow users to choose the MW reduction target when manually simulating load shedding or curtailment actions: -- Input range: 0 to `mw_start` (current load/generation MW) -- Requires new `target_mw` parameter in `simulateManualAction` API -- Backend will pass the target MW to the action content (`set_load_p`/`set_gen_p`) -- Enables partial load shedding and partial curtailment scenarios +Users can choose how much MW to reduce when simulating load shedding or curtailment actions, enabling **partial** reductions instead of full disconnection. + +### Semantics + +- **Target MW** = the amount of MW to reduce (not the remaining setpoint) +- **Setpoint** = `current_mw - target_mw` (clamped to >= 0) +- When `target_mw` is omitted, full reduction is applied (setpoint = 0) + +### API + +`POST /api/simulate-manual-action` accepts an optional `target_mw` field: + +```json +{ + "action_id": "load_shedding_LOAD_1", + "disconnected_element": "LINE_X", + "target_mw": 30.0 +} +``` + +### Backend (`recommender_service.py`) + +#### Dynamic Action Creation +When creating `load_shedding_` or `curtail_` actions on-the-fly: +1. Look up current MW from the N-1 observation (`obs_n1.load_p` / `obs_n1.gen_p`) +2. Compute `remaining = max(0, current_mw - target_mw)` +3. Use `remaining` as the setpoint in `loads_p` / `gens_p` + +#### Re-simulation of Existing Actions +When `target_mw` is provided for an action already in `_dict_action`: +1. Update `set_load_p` or `set_gen_p` entries in the content with the new setpoint +2. The updated content is then used by `env.action_space()` for simulation + +### Frontend (`ActionFeed.tsx`) + +#### Score Table (Manual Selection) +- **Target MW column**: Shown for `load_shedding` and `renewable_curtailment` score table sections +- Input field: number input (0 to MW Start), pre-filled with placeholder = MW Start +- Clicking a row passes the entered `target_mw` to `simulateManualAction` + +#### Action Card Detail Boxes +- **Reduction MW input**: Editable number field in load shedding (amber) and curtailment (blue) detail boxes +- **Re-simulate button**: Triggers `simulateManualAction` with the new `target_mw` value +- Default value: the total shedded/curtailed MW from the current simulation result +- The action card updates in-place with the new simulation results diff --git a/expert_backend/main.py b/expert_backend/main.py index ab8383f6..87e3b2c6 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -183,6 +183,7 @@ class ManualActionRequest(BaseModel): disconnected_element: str action_content: dict | None = None # Optional switches dict for actions not in the dictionary lines_overloaded: list[str] | None = None # Optional overloaded line names from saved session + target_mw: float | None = None # Optional MW reduction amount for load shedding / curtailment class SaveSessionRequest(BaseModel): session_name: str @@ -662,6 +663,7 @@ def simulate_manual_action(request: ManualActionRequest): request.action_id, request.disconnected_element, action_content=request.action_content, lines_overloaded=request.lines_overloaded, + target_mw=request.target_mw, ) return result except Exception as e: diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index cffc006a..1e42ca84 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -2093,7 +2093,7 @@ def _build_action_entry_from_topology(action_id, topo): entry["content"] = content if content else {} return entry - def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, action_content=None, lines_overloaded=None): + def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, action_content=None, lines_overloaded=None, target_mw=None): """Simulate a single or combined action and return its impact. raw_action_id can be a single ID or multiple IDs combined with '+' (e.g. 'act1+act2'). @@ -2102,6 +2102,9 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, lines_overloaded: optional list of overloaded line names from the saved session, used when _analysis_context is missing (e.g. after session reload) to determine which lines to report rho_before/rho_after for. + target_mw: optional MW reduction amount for load shedding / curtailment actions. + When provided, the action reduces power by this amount instead of fully + shedding/curtailing. The resulting setpoint is (current_mw - target_mw). """ if not self._dict_action: raise ValueError("No action dictionary loaded. Load a config first.") @@ -2137,6 +2140,43 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, self._dict_action[aid] = entry print(f"[simulate_manual_action] Injected restored action '{aid}' into dict") + # Helper: compute the power setpoint for a load/gen given target_mw reduction + def _compute_setpoint(element_name, element_type, target_mw_val): + """Compute remaining MW setpoint: current_mw - target_mw, clamped to >= 0. + + element_type: 'load' or 'gen' + Returns the setpoint (remaining MW after reduction). + If the N-1 observation is unavailable, falls back to 0.0 (full reduction). + """ + if target_mw_val is None: + return 0.0 + # Get N-1 observation to find current MW + try: + env_tmp = self._get_simulation_env() + nm_tmp = env_tmp.network_manager + n_tmp = nm_tmp.network + n1_var = self._get_n1_variant(disconnected_element) + if self._cached_obs_n1 is not None and self._cached_obs_n1_id == n1_var: + obs_n1 = self._cached_obs_n1 + else: + n_tmp.set_working_variant(n1_var) + obs_n1 = env_tmp.get_obs() + self._cached_obs_n1 = obs_n1 + self._cached_obs_n1_id = n1_var + + if element_type == 'load': + idx = list(obs_n1.name_load).index(element_name) + current_mw = float(obs_n1.load_p[idx]) + else: + idx = list(obs_n1.name_gen).index(element_name) + current_mw = float(obs_n1.gen_p[idx]) + + remaining = max(0.0, abs(current_mw) - float(target_mw_val)) + return round(remaining, 2) + except Exception as e: + print(f"[simulate_manual_action] Could not compute setpoint for {element_name}: {e}, falling back to 0.0") + return 0.0 + # Handle dynamic action creation for special prefixes (load shedding, PST) for aid in action_ids: if aid not in self._dict_action and aid not in recent_actions: @@ -2144,8 +2184,9 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, if aid.startswith("curtail_"): gen_name = aid[len("curtail_"):] - # New power reduction format: set generator output to 0 MW - topo = {"gens_p": {gen_name: 0.0}} + # New power reduction format: set generator output to target MW + setpoint = _compute_setpoint(gen_name, 'gen', target_mw) + topo = {"gens_p": {gen_name: setpoint}} entry = self._build_action_entry_from_topology(aid, topo) # Align with suggested action description format to help frontend discovery @@ -2163,12 +2204,13 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, entry["description_unitaire"] = f"Effacement '{gen_name}'" self._dict_action[aid] = entry - print(f"[simulate_manual_action] Created dynamic curtailment action '{aid}'") + print(f"[simulate_manual_action] Created dynamic curtailment action '{aid}' (setpoint={setpoint} MW)") elif aid.startswith("load_shedding_"): load_name = aid[len("load_shedding_"):] - # New power reduction format: set load consumption to 0 MW - topo = {"loads_p": {load_name: 0.0}} + # New power reduction format: set load consumption to target MW + setpoint = _compute_setpoint(load_name, 'load', target_mw) + topo = {"loads_p": {load_name: setpoint}} entry = self._build_action_entry_from_topology(aid, topo) vl_id = None @@ -2185,7 +2227,7 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, entry["description_unitaire"] = f"Effacement '{load_name}'" self._dict_action[aid] = entry - print(f"[simulate_manual_action] Created dynamic load shedding action '{aid}'") + print(f"[simulate_manual_action] Created dynamic load shedding action '{aid}' (setpoint={setpoint} MW)") elif aid.startswith("pst_tap_") or aid.startswith("pst_"): @@ -2299,6 +2341,25 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, lines_overloaded_ids = np.where(mask)[0].tolist() lines_overloaded_names = action_names[mask].tolist() + # If target_mw is provided for an existing action, update its content + # with the new setpoint before building the action object + if target_mw is not None: + for aid in action_ids: + if aid in self._dict_action: + content = self._dict_action[aid].get("content", {}) + # Update set_load_p entries + if "set_load_p" in content: + for load_name in content["set_load_p"]: + sp = _compute_setpoint(load_name, 'load', target_mw) + content["set_load_p"][load_name] = sp + print(f"[simulate_manual_action] Updated set_load_p[{load_name}] = {sp} MW") + # Update set_gen_p entries + if "set_gen_p" in content: + for gen_name in content["set_gen_p"]: + sp = _compute_setpoint(gen_name, 'gen', target_mw) + content["set_gen_p"][gen_name] = sp + print(f"[simulate_manual_action] Updated set_gen_p[{gen_name}] = {sp} MW") + # Build the action object try: action = None @@ -2307,7 +2368,7 @@ def simulate_manual_action(self, raw_action_id: str, disconnected_element: str, a_obj = env.action_space(self._dict_action[aid]["content"]) else: a_obj = recent_actions[aid]["action"] - + if action is None: action = a_obj else: diff --git a/expert_backend/tests/test_configurable_mw.py b/expert_backend/tests/test_configurable_mw.py new file mode 100644 index 00000000..ad919f7a --- /dev/null +++ b/expert_backend/tests/test_configurable_mw.py @@ -0,0 +1,260 @@ +# 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. + +"""Tests for configurable MW reduction in load shedding and curtailment actions. + +When target_mw is provided to simulate_manual_action, the backend should: +1. For dynamic actions: compute setpoint = current_mw - target_mw (clamped >= 0) +2. For existing actions: update set_load_p / set_gen_p before simulation +""" + +import numpy as np +import pytest +from unittest.mock import MagicMock, patch +from expert_backend.services.recommender_service import RecommenderService + + +class MockAction: + """Minimal mock matching the shape of a grid2op action object.""" + def __init__(self, content): + set_bus = content.get("set_bus", {}) + self.loads_bus = set_bus.get("loads_id", {}) + self.generators_bus = set_bus.get("generators_id", {}) + self.lines_ex_bus = set_bus.get("lines_ex_id", {}) + self.lines_or_bus = set_bus.get("lines_or_id", {}) + self.pst_tap = content.get("pst_tap", {}) + self.loads_p = content.get("set_load_p", {}) + self.gens_p = content.get("set_gen_p", {}) + + +def _setup_env_mock(name_load=None, load_p=None, name_gen=None, gen_p=None): + """Create a patched environment mock and return (patcher, env).""" + patcher = patch( + "expert_backend.services.recommender_service.RecommenderService._get_simulation_env" + ) + mock_get_env = patcher.start() + env = MagicMock() + mock_get_env.return_value = env + + obs = MagicMock() + obs.n_components = 1 + obs.main_component_load_mw = 1000.0 + obs.name_line = ["LINE_1"] + obs.rho = np.array([0.1]) + obs.name_load = name_load or ["LOAD_A"] + obs.load_p = np.array(load_p or [100.0]) + obs.name_gen = name_gen or ["GEN_WIND"] + obs.gen_p = np.array(gen_p or [80.0]) + + sim_obs = MagicMock() + sim_obs.n_components = 1 + sim_obs.main_component_load_mw = 1000.0 + sim_obs.rho = np.array([0.05]) + sim_obs.name_line = ["LINE_1"] + sim_obs.name_load = name_load or ["LOAD_A"] + sim_obs.load_p = np.array([0.0]) + sim_obs.name_gen = name_gen or ["GEN_WIND"] + sim_obs.gen_p = np.array([0.0]) + + obs.simulate.return_value = (sim_obs, 0.5, False, {"exception": None}) + env.get_obs.return_value = obs + env.action_space.side_effect = lambda c: MockAction(c) + + return patcher, env + + +class TestDynamicLoadSheddingWithTargetMw: + """Dynamic load_shedding_ actions with configurable target_mw.""" + + def test_partial_load_shedding(self): + """target_mw=30 on a 100 MW load should set loads_p to 70 MW.""" + svc = RecommenderService() + svc._dict_action = {"dummy": {}} + + patcher, env = _setup_env_mock( + name_load=["LOAD_A"], load_p=[100.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + result = svc.simulate_manual_action( + "load_shedding_LOAD_A", "LINE_X", target_mw=30.0 + ) + + entry = svc._dict_action["load_shedding_LOAD_A"] + assert entry["content"]["set_load_p"]["LOAD_A"] == 70.0 + finally: + patcher.stop() + + def test_full_load_shedding_no_target(self): + """Without target_mw, load should be fully shed (setpoint = 0).""" + svc = RecommenderService() + svc._dict_action = {"dummy": {}} + + patcher, env = _setup_env_mock( + name_load=["LOAD_A"], load_p=[100.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "load_shedding_LOAD_A", "LINE_X" + ) + + entry = svc._dict_action["load_shedding_LOAD_A"] + assert entry["content"]["set_load_p"]["LOAD_A"] == 0.0 + finally: + patcher.stop() + + def test_target_mw_exceeds_current_clamps_to_zero(self): + """target_mw > current MW should clamp setpoint to 0.""" + svc = RecommenderService() + svc._dict_action = {"dummy": {}} + + patcher, env = _setup_env_mock( + name_load=["LOAD_A"], load_p=[50.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "load_shedding_LOAD_A", "LINE_X", target_mw=80.0 + ) + + entry = svc._dict_action["load_shedding_LOAD_A"] + assert entry["content"]["set_load_p"]["LOAD_A"] == 0.0 + finally: + patcher.stop() + + +class TestDynamicCurtailmentWithTargetMw: + """Dynamic curtail_ actions with configurable target_mw.""" + + def test_partial_curtailment(self): + """target_mw=20 on an 80 MW gen should set gens_p to 60 MW.""" + svc = RecommenderService() + svc._dict_action = {"dummy": {}} + + patcher, env = _setup_env_mock( + name_gen=["GEN_WIND"], gen_p=[80.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "curtail_GEN_WIND", "LINE_X", target_mw=20.0 + ) + + entry = svc._dict_action["curtail_GEN_WIND"] + assert entry["content"]["set_gen_p"]["GEN_WIND"] == 60.0 + finally: + patcher.stop() + + def test_full_curtailment_no_target(self): + """Without target_mw, gen should be fully curtailed (setpoint = 0).""" + svc = RecommenderService() + svc._dict_action = {"dummy": {}} + + patcher, env = _setup_env_mock( + name_gen=["GEN_WIND"], gen_p=[80.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "curtail_GEN_WIND", "LINE_X" + ) + + entry = svc._dict_action["curtail_GEN_WIND"] + assert entry["content"]["set_gen_p"]["GEN_WIND"] == 0.0 + finally: + patcher.stop() + + +class TestExistingActionResimulationWithTargetMw: + """Re-simulating existing actions with a new target_mw value.""" + + def test_resimulate_load_shedding_with_target_mw(self): + """Existing action with set_load_p should be updated with new setpoint.""" + svc = RecommenderService() + # Pre-existing action that was previously fully shed + svc._dict_action = { + "load_shedding_LOAD_A": { + "content": {"set_load_p": {"LOAD_A": 0.0}}, + "description": "Load shedding on LOAD_A", + "description_unitaire": "Effacement 'LOAD_A'", + } + } + + patcher, env = _setup_env_mock( + name_load=["LOAD_A"], load_p=[100.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "load_shedding_LOAD_A", "LINE_X", target_mw=40.0 + ) + + # Content should be updated in-place + content = svc._dict_action["load_shedding_LOAD_A"]["content"] + assert content["set_load_p"]["LOAD_A"] == 60.0 + finally: + patcher.stop() + + def test_resimulate_curtailment_with_target_mw(self): + """Existing action with set_gen_p should be updated with new setpoint.""" + svc = RecommenderService() + svc._dict_action = { + "curtail_GEN_WIND": { + "content": {"set_gen_p": {"GEN_WIND": 0.0}}, + "description": "Curtailment on GEN_WIND", + "description_unitaire": "Effacement 'GEN_WIND'", + } + } + + patcher, env = _setup_env_mock( + name_gen=["GEN_WIND"], gen_p=[80.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "curtail_GEN_WIND", "LINE_X", target_mw=25.0 + ) + + content = svc._dict_action["curtail_GEN_WIND"]["content"] + assert content["set_gen_p"]["GEN_WIND"] == 55.0 + finally: + patcher.stop() + + def test_resimulate_without_target_mw_leaves_content_unchanged(self): + """Re-simulating without target_mw should NOT change the content.""" + svc = RecommenderService() + svc._dict_action = { + "load_shedding_LOAD_A": { + "content": {"set_load_p": {"LOAD_A": 30.0}}, + "description": "Load shedding on LOAD_A", + "description_unitaire": "Effacement 'LOAD_A'", + } + } + + patcher, env = _setup_env_mock( + name_load=["LOAD_A"], load_p=[100.0] + ) + try: + with patch.object(svc, "_get_monitoring_parameters", return_value=(set(), set())), \ + patch.object(svc, "_compute_deltas", return_value={}): + svc.simulate_manual_action( + "load_shedding_LOAD_A", "LINE_X" + ) + + # Content should be unchanged since target_mw was not provided + content = svc._dict_action["load_shedding_LOAD_A"]["content"] + assert content["set_load_p"]["LOAD_A"] == 30.0 + finally: + patcher.stop() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ebbdad1f..03aa6501 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -90,7 +90,7 @@ export const api = { ); return response.data.actions; }, - simulateManualAction: async (actionId: string, disconnectedElement: string, actionContent?: Record | null, linesOverloaded?: string[] | null): Promise<{ + simulateManualAction: async (actionId: string, disconnectedElement: string, actionContent?: Record | null, linesOverloaded?: string[] | null, targetMw?: number | null): Promise<{ action_id: string; description_unitaire: string; rho_before: number[] | null; @@ -110,7 +110,7 @@ export const api = { const response = await axios.post( `${API_BASE_URL}/api/simulate-manual-action`, - { action_id: actionId, disconnected_element: disconnectedElement, action_content: actionContent ?? null, lines_overloaded: linesOverloaded ?? null } + { action_id: actionId, disconnected_element: disconnectedElement, action_content: actionContent ?? null, lines_overloaded: linesOverloaded ?? null, target_mw: targetMw ?? null } ); return response.data; }, diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index aa799f74..5769e70e 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -66,6 +66,7 @@ describe('ActionFeed', () => { minLineDisconnections: 3, minPst: 1, minLoadShedding: 0, + minRenewableCurtailmentActions: 0, nPrioritizedActions: 10, ignoreReconnections: false, pendingAnalysisResult: null as AnalysisResult | null, @@ -436,7 +437,8 @@ describe('ActionFeed', () => { 'custom_action_123', 'LINE_1', null, - ['LINE_1'] + ['LINE_1'], + undefined, ); }); @@ -1067,4 +1069,154 @@ describe('ActionFeed', () => { expect(screen.getByText(/66\.0 MW/)).toBeInTheDocument(); expect(screen.getByText('GEN_NEW')).toBeInTheDocument(); }); + + it('shows Target MW column in score table for load shedding type', async () => { + const props = { + ...defaultProps, + actionScores: { + load_shedding: { + scores: { 'load_shedding_LOAD_1': 5.0 }, + params: {}, + mw_start: { 'load_shedding_LOAD_1': 100.0 }, + } + }, + }; + render(); + fireEvent.click(screen.getByText('+ Manual Selection')); + expect(await screen.findByText('Target MW')).toBeInTheDocument(); + expect(screen.getByTestId('target-mw-load_shedding_LOAD_1')).toBeInTheDocument(); + }); + + it('shows Target MW column in score table for renewable curtailment type', async () => { + const props = { + ...defaultProps, + actionScores: { + renewable_curtailment: { + scores: { 'curtail_GEN_1': 3.0 }, + params: {}, + mw_start: { 'curtail_GEN_1': 80.0 }, + } + }, + }; + render(); + fireEvent.click(screen.getByText('+ Manual Selection')); + expect(await screen.findByText('Target MW')).toBeInTheDocument(); + expect(screen.getByTestId('target-mw-curtail_GEN_1')).toBeInTheDocument(); + }); + + it('does NOT show Target MW column for line_disconnection type', async () => { + const props = { + ...defaultProps, + actionScores: { + line_disconnection: { + scores: { 'disco_LINE_1': 7.0 }, + params: {}, + mw_start: { 'disco_LINE_1': null }, + } + }, + }; + render(); + fireEvent.click(screen.getByText('+ Manual Selection')); + await screen.findByText('LINE DISCONNECTION'); + expect(screen.queryByText('Target MW')).not.toBeInTheDocument(); + }); + + it('shows editable MW and re-simulate button on load shedding action card', () => { + const actionId = 'load_shedding_LOAD_X'; + const props = { + ...defaultProps, + actions: { + [actionId]: { + description_unitaire: 'Load shedding on LOAD_X', + rho_before: [0.95], + rho_after: [0.70], + max_rho: 0.70, + max_rho_line: 'LINE_1', + is_rho_reduction: true, + non_convergence: null, + load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }], + action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } }, + } as ActionDetail, + }, + }; + render(); + expect(screen.getByTestId(`edit-mw-${actionId}`)).toBeInTheDocument(); + expect(screen.getByTestId(`resimulate-${actionId}`)).toBeInTheDocument(); + expect(screen.getByTestId(`resimulate-${actionId}`)).toHaveTextContent('Re-simulate'); + }); + + it('shows editable MW and re-simulate button on curtailment action card', () => { + const actionId = 'curtail_GEN_Y'; + const props = { + ...defaultProps, + actions: { + [actionId]: { + description_unitaire: 'Curtailment on GEN_Y', + rho_before: [0.95], + rho_after: [0.80], + max_rho: 0.80, + max_rho_line: 'LINE_1', + is_rho_reduction: true, + non_convergence: null, + curtailment_details: [{ gen_name: 'GEN_Y', voltage_level_id: 'VL_W', curtailed_mw: 60.0 }], + action_topology: { ...emptyTopo, gens_p: { GEN_Y: 0.0 } }, + } as ActionDetail, + }, + }; + render(); + expect(screen.getByTestId(`edit-mw-${actionId}`)).toBeInTheDocument(); + expect(screen.getByTestId(`resimulate-${actionId}`)).toBeInTheDocument(); + }); + + it('calls simulateManualAction with target_mw when re-simulate is clicked', async () => { + const actionId = 'load_shedding_LOAD_X'; + const mockResult = { + action_id: actionId, + description_unitaire: 'Load shedding on LOAD_X', + rho_before: [0.95], + rho_after: [0.60], + max_rho: 0.60, + max_rho_line: 'LINE_1', + is_rho_reduction: true, + non_convergence: null, + lines_overloaded: ['LINE_1'], + load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 25.0 }], + }; + (api.simulateManualAction as ReturnType).mockResolvedValueOnce(mockResult); + + const props = { + ...defaultProps, + actions: { + [actionId]: { + description_unitaire: 'Load shedding on LOAD_X', + rho_before: [0.95], + rho_after: [0.70], + max_rho: 0.70, + max_rho_line: 'LINE_1', + is_rho_reduction: true, + non_convergence: null, + load_shedding_details: [{ load_name: 'LOAD_X', voltage_level_id: 'VL_A', shedded_mw: 42.5 }], + action_topology: { ...emptyTopo, loads_p: { LOAD_X: 0.0 } }, + } as ActionDetail, + }, + }; + render(); + + // Change the MW input value + const mwInput = screen.getByTestId(`edit-mw-${actionId}`); + fireEvent.change(mwInput, { target: { value: '25' } }); + + // Click re-simulate + fireEvent.click(screen.getByTestId(`resimulate-${actionId}`)); + + await waitFor(() => { + expect(api.simulateManualAction).toHaveBeenCalledWith( + actionId, + 'LINE_1', + expect.anything(), + ['LINE_1'], + 25, + ); + }); + }); }); diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index f51b59b6..227d79fb 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -99,6 +99,11 @@ const ActionFeed: React.FC = ({ const [tooltip, setTooltip] = useState<{ content: React.ReactNode; x: number; y: number } | null>(null); const [suggestedTab, setSuggestedTab] = useState<'prioritized' | 'rejected'>('prioritized'); const [dismissedSelectedWarning, setDismissedSelectedWarning] = useState(false); + // Per-action target MW inputs for score table rows (keyed by actionId) + const [scoreTargetMw, setScoreTargetMw] = useState>({}); + // Per-action editable MW for action card re-simulation (keyed by actionId) + const [cardEditMw, setCardEditMw] = useState>({}); + const [resimulating, setResimulating] = useState(null); const [dismissedRejectedWarning, setDismissedRejectedWarning] = useState(false); const [showActionDictWarning, setShowActionDictWarning] = useState(true); const [showRecommenderWarning, setShowRecommenderWarning] = useState(true); @@ -248,7 +253,7 @@ const ActionFeed: React.FC = ({ return () => document.removeEventListener('mousedown', handler); }, [searchOpen]); - const handleAddAction = async (actionId: string) => { + const handleAddAction = async (actionId: string, targetMw?: number) => { const trimmedId = actionId.trim(); if (!disconnectedElement) { setError('Select a contingency first.'); @@ -278,7 +283,7 @@ const ActionFeed: React.FC = ({ } } - const result = await api.simulateManualAction(trimmedId, disconnectedElement, actionContent, linesOverloaded); + const result = await api.simulateManualAction(trimmedId, disconnectedElement, actionContent, linesOverloaded, targetMw); const detail: ActionDetail = { description_unitaire: result.description_unitaire, rho_before: result.rho_before, @@ -306,6 +311,36 @@ const ActionFeed: React.FC = ({ } }; + // Re-simulate an existing action with a new target MW value + const handleResimulate = async (actionId: string, newTargetMw: number) => { + if (!disconnectedElement) return; + setResimulating(actionId); + try { + const detail = actions[actionId]; + const actionContent = detail?.action_topology ? detail.action_topology as unknown as Record : null; + const result = await api.simulateManualAction(actionId, disconnectedElement, actionContent, linesOverloaded, newTargetMw); + const newDetail: ActionDetail = { + description_unitaire: result.description_unitaire, + rho_before: result.rho_before, + rho_after: result.rho_after, + max_rho: result.max_rho, + max_rho_line: result.max_rho_line, + is_rho_reduction: result.is_rho_reduction, + is_islanded: result.is_islanded, + n_components: result.n_components, + disconnected_mw: result.disconnected_mw, + non_convergence: result.non_convergence, + load_shedding_details: result.load_shedding_details, + curtailment_details: result.curtailment_details, + }; + onManualActionAdded(actionId, newDetail, result.lines_overloaded || []); + } catch (e: unknown) { + console.error('Re-simulation failed:', e); + } finally { + setResimulating(null); + } + }; + const clickableLinkStyle: React.CSSProperties = { background: 'none', border: 'none', @@ -463,6 +498,31 @@ const ActionFeed: React.FC = ({ )} ))} +
+ + s + ls.shedded_mw, 0).toFixed(1)} + onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} + /> + +
)} {details.curtailment_details && details.curtailment_details.length > 0 && ( @@ -481,6 +541,31 @@ const ActionFeed: React.FC = ({ )} ))} +
+ + s + rc.curtailed_mw, 0).toFixed(1)} + onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} + /> + +
)} {details.non_convergence && ( @@ -739,6 +824,7 @@ const ActionFeed: React.FC = ({ const isPerActionParams = paramsKeys.length > 0 && paramsKeys.some((k: string) => scoresKeys.includes(k)); const globalParams = isPerActionParams ? null : (paramsKeys.length > 0 ? typeData.params : null); + const isLsOrRcType = type === 'load_shedding' || type.includes('load_shedding') || type === 'renewable_curtailment' || type.includes('renewable_curtailment'); return (
@@ -763,17 +849,25 @@ const ActionFeed: React.FC = ({ - - - + + + {isLsOrRcType && } + {scoredActionsList.filter(item => item.type === type).map(item => { const isComputed = !!actions[item.actionId]; + const targetVal = scoreTargetMw[item.actionId]; + const parsedTarget = targetVal !== undefined ? parseFloat(targetVal) : null; + const isValidTarget = parsedTarget !== null && !isNaN(parsedTarget) && parsedTarget >= 0 && (item.mwStart == null || parsedTarget <= item.mwStart); return ( !isComputed && handleAddAction(item.actionId)} + onClick={() => { + if (isComputed || simulating) return; + const mw = isLsOrRcType && isValidTarget ? parsedTarget! : undefined; + handleAddAction(item.actionId, mw); + }} style={{ borderBottom: '1px solid #eee', cursor: (isComputed || simulating) ? 'not-allowed' : 'pointer', @@ -801,12 +895,12 @@ const ActionFeed: React.FC = ({
Parameters
{typeData.non_convergence?.[item.actionId] && (
- ⚠️ Non-convergence: {typeData.non_convergence[item.actionId]} + Non-convergence: {typeData.non_convergence[item.actionId]}
)} {(actions[item.actionId]?.is_islanded) && (
- 🏝️ Islanding: {actions[item.actionId].n_components} components + Islanding: {actions[item.actionId].n_components} components
)} {Object.entries(typeData.params![item.actionId]).map(([k, v]) => ( @@ -823,6 +917,29 @@ const ActionFeed: React.FC = ({
+ {isLsOrRcType && ( + + )} From 2058af7e6036f0c3f903303c116cdb808ba7ee83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 19:08:46 +0000 Subject: [PATCH 06/14] Update standalone_interface.html with configurable MW reduction UI Mirror the React frontend changes: - Score table: Target MW input column for load_shedding/renewable_curtailment - Action cards: Editable Reduction MW input + Re-simulate button - handleAddManualAction accepts targetMw param, passes to API - handleResimulate function for re-simulating with new MW values https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- standalone_interface.html | 140 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 5 deletions(-) diff --git a/standalone_interface.html b/standalone_interface.html index 1cc01ef9..fde24304 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -1710,6 +1710,11 @@ const [simulatingActionId, setSimulatingActionId] = useState(null); const [searchError, setSearchError] = useState(''); const [typeFilters, setTypeFilters] = useState({ disco: true, reco: true, open: true, close: true, pst: true, ls: true, rc: true }); + // Per-action target MW inputs for score table rows (keyed by actionId) + const [scoreTargetMw, setScoreTargetMw] = useState({}); + // Per-action editable MW for action card re-simulation (keyed by actionId) + const [cardEditMw, setCardEditMw] = useState({}); + const [resimulatingId, setResimulatingId] = useState(null); const searchDropdownRef = useRef(null); const searchInputRef = useRef(null); const [scoreTooltip, setScoreTooltip] = useState(null); @@ -3573,7 +3578,7 @@ return trimmed.split('+').map(p => p.trim()).sort().join('+'); }; - const handleAddManualAction = async (rawActionId) => { + const handleAddManualAction = async (rawActionId, targetMw) => { const actionId = canonicalizeId(rawActionId); if (!selectedBranch) { setSearchError('Run an analysis first'); return; } if (simulatingActionId) return; // Prevent concurrent simulations @@ -3605,6 +3610,7 @@ disconnected_element: selectedBranch, action_content: actionContent, lines_overloaded: result?.lines_overloaded?.length > 0 ? result.lines_overloaded : null, + target_mw: targetMw != null ? targetMw : null, }); console.log('[handleAddManualAction] Simulation result received:', res.data); const detail = res.data; @@ -3674,6 +3680,53 @@ } }; + // Re-simulate an existing action with a new target MW value + const handleResimulate = async (actionId, newTargetMw) => { + if (!selectedBranch) return; + setResimulatingId(actionId); + try { + const actionDetail = result?.actions?.[actionId]; + const actionContent = actionDetail?.action_topology || null; + const res = await axios.post(API_BASE + '/api/simulate-manual-action', { + action_id: actionId, + disconnected_element: selectedBranch, + action_content: actionContent, + lines_overloaded: result?.lines_overloaded?.length > 0 ? result.lines_overloaded : null, + target_mw: newTargetMw, + }); + const detail = res.data; + setResult(prev => { + const base = prev || { actions: {}, lines_overloaded: [] }; + return { + ...base, + actions: { + ...(base.actions || {}), + [actionId]: { + ...(base.actions?.[actionId] || {}), + description_unitaire: detail.description_unitaire, + rho_before: detail.rho_before, + rho_after: detail.rho_after, + max_rho: detail.max_rho, + max_rho_line: detail.max_rho_line, + is_rho_reduction: detail.is_rho_reduction, + is_islanded: detail.is_islanded, + n_components: detail.n_components, + disconnected_mw: detail.disconnected_mw, + load_shedding_details: detail.load_shedding_details, + curtailment_details: detail.curtailment_details, + action_topology: detail.action_topology, + non_convergence: detail.non_convergence, + } + } + }; + }); + } catch (err) { + console.error('Re-simulation failed:', err); + } finally { + setResimulatingId(null); + } + }; + const handleOpenCombineModal = () => { interactionLogger.record('combine_modal_opened'); setSelectedCombineActionIds([]); @@ -5007,6 +5060,29 @@ )} ))} +
+ + s + ls.shedded_mw, 0).toFixed(1)} + onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} + /> + +
) } @@ -5026,6 +5102,29 @@ )} ))} +
+ + s + rc.curtailed_mw, 0).toFixed(1)} + onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} + /> + +
) } @@ -5627,6 +5726,7 @@

Simulated Actions

const paramsKeys = Object.keys(typeData.params || {}); const isPerActionParams = paramsKeys.length > 0 && paramsKeys.some(k => scoresKeys.includes(k)); const globalParams = isPerActionParams ? null : (paramsKeys.length > 0 ? typeData.params : null); + const isLsOrRcType = type === 'load_shedding' || type.includes('load_shedding') || type === 'renewable_curtailment' || type.includes('renewable_curtailment'); return (
@@ -5651,17 +5751,25 @@

Simulated Actions

ActionMW StartScoreActionMW StartTarget MWScore
{item.mwStart != null ? item.mwStart.toFixed(1) : 'N/A'} e.stopPropagation()}> + setScoreTargetMw(prev => ({ ...prev, [item.actionId]: e.target.value }))} + style={{ + width: '60px', + fontSize: '11px', + fontFamily: 'monospace', + padding: '2px 4px', + border: '1px solid #ccc', + borderRadius: '3px', + textAlign: 'right', + }} + /> + {item.score.toFixed(2)}
- - - + + + {isLsOrRcType && } + {scoredActionsList.filter(item => item.type === type).map(item => { const isComputed = !!(result?.actions && result.actions[item.actionId]); + const targetVal = scoreTargetMw[item.actionId]; + const parsedTarget = targetVal !== undefined ? parseFloat(targetVal) : null; + const isValidTarget = parsedTarget !== null && !isNaN(parsedTarget) && parsedTarget >= 0 && (item.mwStart == null || parsedTarget <= item.mwStart); return ( !isComputed && handleAddManualAction(item.actionId)} + onClick={() => { + if (isComputed || simulatingActionId) return; + const mw = isLsOrRcType && isValidTarget ? parsedTarget : undefined; + handleAddManualAction(item.actionId, mw); + }} style={{ borderBottom: '1px solid #eee', cursor: (isComputed || simulatingActionId) ? 'not-allowed' : 'pointer', @@ -5698,6 +5806,28 @@

Simulated Actions

+ {isLsOrRcType && ( + + )} From 85f7bd1d78c03043bfbbdcc1ff6f44d4dc583d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 19:21:07 +0000 Subject: [PATCH 07/14] Simplify load shedding and curtailment action card UI Merge description and editable MW into a single compact line: - "Load shedding on BEON3 TR311 in MW: [input] [Re-simulate]" - "Renewable curtailment on VIELMIN3 in MW: [input] [Re-simulate]" Removes the verbose two-line format that had separate description text and "Reduction MW" row. Both React frontend and standalone_interface.html updated. Tests adapted for new format. https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- frontend/src/components/ActionFeed.test.tsx | 24 ++-- frontend/src/components/ActionFeed.tsx | 120 ++++++++------------ standalone_interface.html | 110 +++++++----------- 3 files changed, 107 insertions(+), 147 deletions(-) diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index 5769e70e..b9586061 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -786,11 +786,11 @@ describe('ActionFeed', () => { }; render(); - // Should show load shedding description - expect(screen.getByText(/42\.5 MW/)).toBeInTheDocument(); + // Should show load shedding description with asset name + expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_1')).toBeInTheDocument(); - // VL should be rendered as clickable buttons (one in description, one as badge) + // VL should be rendered as clickable badge button const vlButtons = screen.getAllByText('VL_ALPHA'); expect(vlButtons.length).toBeGreaterThanOrEqual(1); expect(vlButtons.every(el => el.tagName === 'BUTTON')).toBe(true); @@ -848,10 +848,12 @@ describe('ActionFeed', () => { }; render(); - expect(screen.getByText(/20 MW/)).toBeInTheDocument(); - expect(screen.getByText(/15\.3 MW/)).toBeInTheDocument(); + // Both load names should be shown in the simplified format expect(screen.getByText('LOAD_A')).toBeInTheDocument(); expect(screen.getByText('LOAD_B')).toBeInTheDocument(); + // Both entries should have "Load shedding on" text + const lsTexts = screen.getAllByText(/Load shedding on/); + expect(lsTexts.length).toBe(2); }); it('clicking VL button in load shedding description triggers onAssetClick', () => { @@ -992,8 +994,10 @@ describe('ActionFeed', () => { }; render(); - expect(screen.getByText(/55\.3 MW/)).toBeInTheDocument(); + // Simplified format: "Load shedding on LOAD_PR in MW: [input]" + expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_PR')).toBeInTheDocument(); + // VL badge should still be rendered const vlButtons = screen.getAllByText('VL_PR'); expect(vlButtons.length).toBeGreaterThanOrEqual(1); }); @@ -1020,8 +1024,10 @@ describe('ActionFeed', () => { }; render(); - expect(screen.getByText(/80\.0 MW/)).toBeInTheDocument(); + // Simplified format: "Renewable curtailment on WIND_1 in MW: [input]" + expect(screen.getByText(/Renewable curtailment on/)).toBeInTheDocument(); expect(screen.getByText('WIND_1')).toBeInTheDocument(); + // VL badge should still be rendered const vlButtons = screen.getAllByText('VL_WIND'); expect(vlButtons.length).toBeGreaterThanOrEqual(1); }); @@ -1062,11 +1068,11 @@ describe('ActionFeed', () => { render(); // Load shedding details with new format - expect(screen.getByText(/33 MW/)).toBeInTheDocument(); + expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_NEW')).toBeInTheDocument(); // Curtailment details with new format - expect(screen.getByText(/66\.0 MW/)).toBeInTheDocument(); + expect(screen.getByText(/Renewable curtailment on/)).toBeInTheDocument(); expect(screen.getByText('GEN_NEW')).toBeInTheDocument(); }); diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 227d79fb..238cd291 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -485,87 +485,63 @@ const ActionFeed: React.FC = ({ {details.load_shedding_details && details.load_shedding_details.length > 0 && (
{details.load_shedding_details.map((ls, i) => ( -
- {i > 0 &&
} - Load shedding of {ls.shedded_mw} MW on load {ls.load_name} - {ls.voltage_level_id && ( - <> at voltage level - )} +
0 ? '4px' : 0 }}> + Load shedding on {ls.load_name} in MW: + { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} + /> +
))} -
- - s + ls.shedded_mw, 0).toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} - style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} - /> - -
)} {details.curtailment_details && details.curtailment_details.length > 0 && (
{details.curtailment_details.map((rc, i) => ( -
- {i > 0 &&
} - Renewable curtailment of {rc.curtailed_mw.toFixed(1)} MW on generator {rc.gen_name} - {rc.voltage_level_id && ( - <> at voltage level - )} +
0 ? '4px' : 0 }}> + Renewable curtailment on {rc.gen_name} in MW: + { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} + /> +
))} -
- - s + rc.curtailed_mw, 0).toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} - style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} - /> - -
)} {details.non_convergence && ( diff --git a/standalone_interface.html b/standalone_interface.html index fde24304..3cf03de1 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -5048,41 +5048,30 @@ details.load_shedding_details && details.load_shedding_details.length > 0 && (
{details.load_shedding_details.map((ls, i) => ( -
- Load shedding of {ls.shedded_mw} MW on load {ls.load_name} - {ls.voltage_level_id && ( - <> at voltage level - )} +
0 ? '4px' : 0 }}> + Load shedding on {ls.load_name} in MW: + { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} + /> +
))} -
- - s + ls.shedded_mw, 0).toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} - style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} - /> - -
) } @@ -5090,41 +5079,30 @@ details.curtailment_details && details.curtailment_details.length > 0 && (
{details.curtailment_details.map((rc, i) => ( -
- Renewable curtailment of {rc.curtailed_mw.toFixed(1)} MW on generator {rc.gen_name} - {rc.voltage_level_id && ( - <> at voltage level - )} +
0 ? '4px' : 0 }}> + Renewable curtailment on {rc.gen_name} in MW: + { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} + onClick={(e) => e.stopPropagation()} + style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} + /> +
))} -
- - s + rc.curtailed_mw, 0).toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} - style={{ width: '70px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} - /> - -
) } From fd1fc8a56d8c4b83170a11a46c1d6f15fc9d44bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 19:29:45 +0000 Subject: [PATCH 08/14] Shorten labels and allow re-simulation from score table - Shorten "Load shedding" to "Shedding", "Renewable curtailment" to "Curtailment" - Score table: clicking a computed LS/RC row with a Target MW value now triggers re-simulation instead of being blocked - Both React frontend and standalone_interface.html updated https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- frontend/src/components/ActionFeed.test.tsx | 16 ++++++++-------- frontend/src/components/ActionFeed.tsx | 20 +++++++++++++------- standalone_interface.html | 20 +++++++++++++------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index b9586061..35902386 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -787,7 +787,7 @@ describe('ActionFeed', () => { render(); // Should show load shedding description with asset name - expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); + expect(screen.getByText(/Shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_1')).toBeInTheDocument(); // VL should be rendered as clickable badge button @@ -852,7 +852,7 @@ describe('ActionFeed', () => { expect(screen.getByText('LOAD_A')).toBeInTheDocument(); expect(screen.getByText('LOAD_B')).toBeInTheDocument(); // Both entries should have "Load shedding on" text - const lsTexts = screen.getAllByText(/Load shedding on/); + const lsTexts = screen.getAllByText(/Shedding on/); expect(lsTexts.length).toBe(2); }); @@ -994,8 +994,8 @@ describe('ActionFeed', () => { }; render(); - // Simplified format: "Load shedding on LOAD_PR in MW: [input]" - expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); + // Simplified format: "Shedding on LOAD_PR in MW: [input]" + expect(screen.getByText(/Shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_PR')).toBeInTheDocument(); // VL badge should still be rendered const vlButtons = screen.getAllByText('VL_PR'); @@ -1024,8 +1024,8 @@ describe('ActionFeed', () => { }; render(); - // Simplified format: "Renewable curtailment on WIND_1 in MW: [input]" - expect(screen.getByText(/Renewable curtailment on/)).toBeInTheDocument(); + // Simplified format: "Curtailment on WIND_1 in MW: [input]" + expect(screen.getByText(/Curtailment on/)).toBeInTheDocument(); expect(screen.getByText('WIND_1')).toBeInTheDocument(); // VL badge should still be rendered const vlButtons = screen.getAllByText('VL_WIND'); @@ -1068,11 +1068,11 @@ describe('ActionFeed', () => { render(); // Load shedding details with new format - expect(screen.getByText(/Load shedding on/)).toBeInTheDocument(); + expect(screen.getByText(/Shedding on/)).toBeInTheDocument(); expect(screen.getByText('LOAD_NEW')).toBeInTheDocument(); // Curtailment details with new format - expect(screen.getByText(/Renewable curtailment on/)).toBeInTheDocument(); + expect(screen.getByText(/Curtailment on/)).toBeInTheDocument(); expect(screen.getByText('GEN_NEW')).toBeInTheDocument(); }); diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 238cd291..83a09513 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -486,7 +486,7 @@ const ActionFeed: React.FC = ({
{details.load_shedding_details.map((ls, i) => (
0 ? '4px' : 0 }}> - Load shedding on {ls.load_name} in MW: + Shedding on {ls.load_name} in MW: = ({
{details.curtailment_details.map((rc, i) => (
0 ? '4px' : 0 }}> - Renewable curtailment on {rc.gen_name} in MW: + Curtailment on {rc.gen_name} in MW: = ({ const targetVal = scoreTargetMw[item.actionId]; const parsedTarget = targetVal !== undefined ? parseFloat(targetVal) : null; const isValidTarget = parsedTarget !== null && !isNaN(parsedTarget) && parsedTarget >= 0 && (item.mwStart == null || parsedTarget <= item.mwStart); + const canResimulate = isLsOrRcType && isComputed && isValidTarget; return (
{ - if (isComputed || simulating) return; + if (simulating || resimulating) return; + if (canResimulate) { + handleResimulate(item.actionId, parsedTarget!); + return; + } + if (isComputed) return; const mw = isLsOrRcType && isValidTarget ? parsedTarget! : undefined; handleAddAction(item.actionId, mw); }} style={{ borderBottom: '1px solid #eee', - cursor: (isComputed || simulating) ? 'not-allowed' : 'pointer', - color: isComputed ? '#888' : 'inherit', - opacity: simulating === item.actionId ? 0.7 : 1, - background: simulating === item.actionId ? '#e7f1ff' : 'transparent', + cursor: (simulating || resimulating) ? 'wait' : (isComputed && !canResimulate) ? 'not-allowed' : 'pointer', + color: (isComputed && !canResimulate) ? '#888' : 'inherit', + opacity: (simulating === item.actionId || resimulating === item.actionId) ? 0.7 : 1, + background: (simulating === item.actionId || resimulating === item.actionId) ? '#e7f1ff' : 'transparent', }}> { - if (isComputed || simulatingActionId) return; + if (simulatingActionId || resimulatingId) return; + if (canResimulate) { + handleResimulate(item.actionId, parsedTarget); + return; + } + if (isComputed) return; const mw = isLsOrRcType && isValidTarget ? parsedTarget : undefined; handleAddManualAction(item.actionId, mw); }} style={{ borderBottom: '1px solid #eee', - cursor: (isComputed || simulatingActionId) ? 'not-allowed' : 'pointer', - color: isComputed ? '#888' : 'inherit', - opacity: simulatingActionId === item.actionId ? 0.7 : 1, - background: simulatingActionId === item.actionId ? '#e7f1ff' : 'transparent', + cursor: (simulatingActionId || resimulatingId) ? 'wait' : (isComputed && !canResimulate) ? 'not-allowed' : 'pointer', + color: (isComputed && !canResimulate) ? '#888' : 'inherit', + opacity: (simulatingActionId === item.actionId || resimulatingId === item.actionId) ? 0.7 : 1, + background: (simulatingActionId === item.actionId || resimulatingId === item.actionId) ? '#e7f1ff' : 'transparent', }}>
ActionMW StartScoreActionMW StartTarget MWScore
{item.mwStart != null ? item.mwStart.toFixed(1) : 'N/A'} e.stopPropagation()}> + setScoreTargetMw(prev => ({ ...prev, [item.actionId]: e.target.value }))} + style={{ + width: '60px', + fontSize: '11px', + fontFamily: 'monospace', + padding: '2px 4px', + border: '1px solid #ccc', + borderRadius: '3px', + textAlign: 'right', + }} + /> + {item.score.toFixed(2)}
{item.actionId} diff --git a/standalone_interface.html b/standalone_interface.html index 3cf03de1..53af3048 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -5049,7 +5049,7 @@
{details.load_shedding_details.map((ls, i) => (
0 ? '4px' : 0 }}> - Load shedding on {ls.load_name} in MW: + Shedding on {ls.load_name} in MW: {details.curtailment_details.map((rc, i) => (
0 ? '4px' : 0 }}> - Renewable curtailment on {rc.gen_name} in MW: + Curtailment on {rc.gen_name} in MW: Simulated Actions const targetVal = scoreTargetMw[item.actionId]; const parsedTarget = targetVal !== undefined ? parseFloat(targetVal) : null; const isValidTarget = parsedTarget !== null && !isNaN(parsedTarget) && parsedTarget >= 0 && (item.mwStart == null || parsedTarget <= item.mwStart); + const canResimulate = isLsOrRcType && isComputed && isValidTarget; return (
{item.actionId} From 69bb85e62852a3bbf1e15882b399c905f6d6efc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 19:43:47 +0000 Subject: [PATCH 09/14] Fix action card re-simulation: single click and MW value refresh - Add onClick+onMouseDown stopPropagation on the detail box container so clicks on input/button don't trigger card selection first - Clear cardEditMw state after successful re-simulation so the input shows the updated shedded/curtailed MW from the new results - Both React frontend and standalone_interface.html updated https://claude.ai/code/session_01NLhamrxroa2pNuKHDfEuUM --- frontend/src/components/ActionFeed.tsx | 22 ++++++++++++---------- standalone_interface.html | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 83a09513..bf2943a2 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -334,6 +334,12 @@ const ActionFeed: React.FC = ({ curtailment_details: result.curtailment_details, }; onManualActionAdded(actionId, newDetail, result.lines_overloaded || []); + // Clear the edit input so it picks up the new shedded/curtailed MW from results + setCardEditMw(prev => { + const next = { ...prev }; + delete next[actionId]; + return next; + }); } catch (e: unknown) { console.error('Re-simulation failed:', e); } finally { @@ -483,7 +489,7 @@ const ActionFeed: React.FC = ({

{details.description_unitaire}

{details.load_shedding_details && details.load_shedding_details.length > 0 && ( -
+
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} style={{ fontSize: '12px', background: '#fef3c7', color: '#92400e', padding: '6px 10px', marginTop: '5px', borderRadius: '4px', border: '1px solid #fcd34d', fontWeight: 500 }}> {details.load_shedding_details.map((ls, i) => (
0 ? '4px' : 0 }}> Shedding on {ls.load_name} in MW: @@ -493,14 +499,12 @@ const ActionFeed: React.FC = ({ min={0} step={0.1} value={cardEditMw[id] ?? ls.shedded_mw.toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} + onChange={(e) => setCardEditMw(prev => ({ ...prev, [id]: e.target.value }))} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} />
)} {details.curtailment_details && details.curtailment_details.length > 0 && ( -
+
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} style={{ fontSize: '12px', background: '#e0f2fe', color: '#075985', padding: '6px 10px', marginTop: '5px', borderRadius: '4px', border: '1px solid #7dd3fc', fontWeight: 500 }}> {details.curtailment_details.map((rc, i) => (
0 ? '4px' : 0 }}> Curtailment on {rc.gen_name} in MW: @@ -524,14 +528,12 @@ const ActionFeed: React.FC = ({ min={0} step={0.1} value={cardEditMw[id] ?? rc.curtailed_mw.toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} + onChange={(e) => setCardEditMw(prev => ({ ...prev, [id]: e.target.value }))} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #0284c7', borderRadius: '3px', textAlign: 'right' }} />
{ details.load_shedding_details && details.load_shedding_details.length > 0 && ( -
+
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} style={{ fontSize: '12px', background: '#fef3c7', color: '#92400e', padding: '6px 10px', marginTop: '5px', borderRadius: '4px', border: '1px solid #fcd34d', fontWeight: 500 }}> {details.load_shedding_details.map((ls, i) => (
0 ? '4px' : 0 }}> Shedding on {ls.load_name} in MW: @@ -5055,13 +5061,11 @@ min={0} step={0.1} value={cardEditMw[id] != null ? cardEditMw[id] : ls.shedded_mw.toFixed(1)} - onChange={(e) => { e.stopPropagation(); setCardEditMw(prev => ({ ...prev, [id]: e.target.value })); }} - onClick={(e) => e.stopPropagation()} + onChange={(e) => setCardEditMw(prev => ({ ...prev, [id]: e.target.value }))} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: '1px solid #d97706', borderRadius: '3px', textAlign: 'right' }} />