// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import React, { useState, useEffect, useMemo, useRef } from 'react'; import { api } from '../api'; import type { AnalysisResult, CombinedAction, ActionDetail, ActionOverviewFilters } from '../types'; import { interactionLogger } from '../utils/interactionLogger'; import { DEFAULT_ACTION_OVERVIEW_FILTERS, matchesActionTypeFilter, resolveRowSeverity, rowPassesActionFilters, rowPassesSeverityFilter, } from '../utils/actionTypes'; import ComputedPairsTable, { type ComputedPairEntry } from './ComputedPairsTable'; import ExplorePairsTab from './ExplorePairsTab'; import ActionFilterRings from './ActionFilterRings'; import { colors } from '../styles/tokens'; import { apiErrorMessage } from '../utils/apiError'; interface SimulationFeedback { max_rho: number | null; max_rho_line: string; is_rho_reduction: boolean; is_islanded?: boolean; disconnected_mw?: number; non_convergence?: string | null; } interface Props { isOpen: boolean; onClose: () => void; analysisResult: AnalysisResult | null; simulatedActions?: Record; /** Currently APPLIED contingency (list of element IDs). */ disconnectedElement: string[]; // Called when a COMBINED pair is simulated (green "Simulate Combined" // button). Promotes the new pair into Selected Actions. onSimulateCombined: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; // Called when a SINGLE action is (re-)simulated from the Explore Pairs // table. Updates the action in place — the action stays in its current // bucket (Suggested / Selected) and is not auto-promoted. onSimulateSingleAction: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; monitoringFactor?: number; linesOverloaded?: string[]; displayName?: (id: string) => string; } /** Canonicalize a combined action ID by sorting the parts alphabetically. */ function canonicalizeId(id: string): string { if (!id || !id.includes('+')) return id; return id.split('+').map(p => p.trim()).sort().join('+'); } const CombinedActionsModal: React.FC = ({ isOpen, onClose, analysisResult, simulatedActions = {}, disconnectedElement, onSimulateCombined, onSimulateSingleAction, monitoringFactor = 1.0, linesOverloaded = [], displayName = (id: string) => id, }) => { const [activeTab, setActiveTab] = useState<'computed' | 'explore'>('computed'); const [selectedIds, setSelectedIds] = useState>(new Set()); const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [simulating, setSimulating] = useState(false); const [simulationFeedback, setSimulationFeedback] = useState(null); // Per-pair simulation results tracked within this modal session const [sessionSimResults, setSessionSimResults] = useState>({}); const lastSelectionRef = useRef(''); // Shared severity + action-type filter for BOTH modal tabs. Modal- // local (independent of the sidebar's global overviewFilters) — the // ActionFilterRings instance lives next to the modal title. const [filters, setFilters] = useState(DEFAULT_ACTION_OVERVIEW_FILTERS); // actionId → recommender score-type, so pair constituents and // scored rows classify by type the same way the sidebar feed does. const scoreTypeByActionId = useMemo(() => { const m = new Map(); const scores = analysisResult?.action_scores; if (!scores) return m; for (const [type, data] of Object.entries(scores)) { const s = data?.scores || {}; for (const actionId of Object.keys(s)) { if (!m.has(actionId)) m.set(actionId, type); } } return m; }, [analysisResult]); // Scored actions for exploration, derived from analysisResult.action_scores const scoredActionsList = useMemo(() => { if (!analysisResult?.action_scores) return []; const list: { actionId: string; score: number; type: string; mwStart: number | null }[] = []; for (const [type, data] of Object.entries(analysisResult.action_scores)) { const scores = data?.scores || {}; const mwStartMap = data?.mw_start; for (const [actionId, score] of Object.entries(scores)) { // Filter out estimated-only combined actions from the exploration list if (actionId.includes('+')) { const detail = analysisResult.actions?.[actionId]; if (detail?.is_estimated || !detail?.rho_after || detail.rho_after.length === 0) continue; } const mwStart = mwStartMap?.[actionId] ?? null; list.push({ actionId, score, type, mwStart: mwStart != null ? Number(mwStart) : null }); } } return list.sort((a, b) => { if (a.type !== b.type) return a.type.localeCompare(b.type); return b.score - a.score; }); }, [analysisResult]); // Explore-Pairs rows after the shared filter. Severity classifies // off the SIMULATED max-loading (session or analysis result) when // available; scored-but-untested rows have no value, so they drop // out as soon as a severity bucket is deselected. const filteredScoredActionsList = useMemo(() => { return scoredActionsList.filter(item => { const sessionSim = sessionSimResults[item.actionId]; const analysisSim = analysisResult?.actions?.[item.actionId]; const simulatedDetail = sessionSim ?? (analysisSim && !analysisSim.is_estimated && analysisSim.rho_after && analysisSim.rho_after.length > 0 ? analysisSim : null); return rowPassesActionFilters(filters, { actionId: item.actionId, scoreType: item.type, simulatedMaxRho: simulatedDetail?.max_rho ?? null, estimatedMaxRho: analysisSim?.estimated_max_rho ?? null, isFault: !!(simulatedDetail?.is_islanded || simulatedDetail?.non_convergence), }, monitoringFactor); }); }, [scoredActionsList, filters, sessionSimResults, analysisResult, monitoringFactor]); // Pre-computed combined pairs from analysis const computedPairsList = useMemo(() => { const combined_actions = analysisResult?.combined_actions || {}; const combinedEntries = Object.entries(combined_actions); // Build a set of canonical keys present in combined_actions const combinedCanonicalKeys = new Set(combinedEntries.map(([id]) => canonicalizeId(id))); // Also include any simulated pairs in result.actions not in combined_actions const simulatedOnly = Object.entries(analysisResult?.actions || {}) .filter(([id]) => id.includes('+') && !combined_actions[id] && !combinedCanonicalKeys.has(canonicalizeId(id))); const allPairs = [ ...combinedEntries.map(([id, ca]) => { const cId = canonicalizeId(id); const sessionResult = sessionSimResults[id] || sessionSimResults[cId]; const parentSimData = simulatedActions[id] || simulatedActions[cId]; const analysisSimData = analysisResult?.actions?.[id] || analysisResult?.actions?.[cId]; const simData = parentSimData || analysisSimData; const isRealSim = !!sessionResult || (simData && !simData.is_estimated && simData.rho_after && simData.rho_after.length > 0); return { id, data: ca, simData: isRealSim ? (sessionResult || simData) : null }; }), ...simulatedOnly.map(([id, data]) => ({ id, data: {} as CombinedAction, simData: data })), ]; return allPairs .sort((a, b) => { const valA = (a.data.estimated_max_rho ?? a.data.max_rho) ?? 999; const valB = (b.data.estimated_max_rho ?? b.data.max_rho) ?? 999; return valA - valB; }) .map(({ id, data, simData }) => { const parts = id.split('+'); const isSimulated = !!simData; const simMaxRho = (simData as ActionDetail | SimulationFeedback)?.max_rho ?? null; const simMaxRhoLine = (simData as ActionDetail | SimulationFeedback)?.max_rho_line ?? null; const estMaxRho = data.estimated_max_rho ?? data.max_rho; const estMaxRhoLine = data.estimated_max_rho_line ?? data.max_rho_line; return { id, action1: parts[0]?.trim() || 'N/A', action2: parts[1]?.trim() || 'N/A', betas: data.betas, estimated_max_rho: estMaxRho, estimated_max_rho_line: estMaxRhoLine, target_max_rho: data.target_max_rho ?? null, target_max_rho_line: data.target_max_rho_line, is_suspect: !!data.is_islanded, isSimulated, simulated_max_rho: simMaxRho, simulated_max_rho_line: simMaxRhoLine, simData: simData }; }); }, [analysisResult, simulatedActions, sessionSimResults]); // Computed-Pairs rows after the shared filter. A pair matches the // type ring when EITHER constituent matches; severity classifies // off the pair's simulated max-loading, falling back to the // estimated value. const filteredComputedPairsList = useMemo(() => { return computedPairsList.filter(p => { const typeOk = filters.actionType === 'all' || matchesActionTypeFilter(filters.actionType, p.action1, null, scoreTypeByActionId.get(p.action1) ?? null) || matchesActionTypeFilter(filters.actionType, p.action2, null, scoreTypeByActionId.get(p.action2) ?? null); if (!typeOk) return false; const simData = p.simData as (ActionDetail | SimulationFeedback) | null; const simulatedMaxRho = p.isSimulated ? p.simulated_max_rho : null; const estimatedMaxRho = p.estimated_max_rho ?? null; const severity = resolveRowSeverity({ simulatedMaxRho, estimatedMaxRho, isFault: !!(simData?.is_islanded || simData?.non_convergence) || p.is_suspect, }, monitoringFactor); if (!rowPassesSeverityFilter(severity, filters.categories)) return false; // Max-loading threshold — same precedence as ``rowPassesActionFilters`` // (simulated first, estimated fallback), so the slider applies // consistently to unitary cards and combined pairs. const referenceRho = simulatedMaxRho ?? estimatedMaxRho; if (referenceRho != null && referenceRho > filters.threshold) return false; return true; }); }, [computedPairsList, filters, scoreTypeByActionId, monitoringFactor]); // Log modal open/close and cleanup when modal closes useEffect(() => { if (isOpen) { interactionLogger.record('combine_modal_opened'); } else { interactionLogger.record('combine_modal_closed'); setSelectedIds(new Set()); setPreview(null); setError(null); setActiveTab('computed'); setSimulationFeedback(null); setSimulating(false); setSessionSimResults({}); } }, [isOpen]); // Drive the estimation/comparison card from the current selection // and active tab only. We intentionally do NOT depend on // analysisResult here: a successful Simulate Combined mutates // analysisResult.actions through onSimulateCombined, and we must // keep the preview card visible so the user can read the simulation // result in place. The card is only reset when the user changes // their pair selection or leaves the Explore Pairs tab. useEffect(() => { const currentSelection = Array.from(selectedIds).sort().join('+'); lastSelectionRef.current = currentSelection; if (activeTab === 'explore' && selectedIds.size === 2 && disconnectedElement && disconnectedElement.length > 0) { const [id1, id2] = Array.from(selectedIds); const pairKey = [id1, id2].sort().join('+'); const preComputed = analysisResult?.combined_actions?.[pairKey]; if (preComputed) { interactionLogger.record('combine_pair_estimated', { action1_id: id1, action2_id: id2, estimated_max_rho: preComputed.estimated_max_rho ?? preComputed.max_rho, estimated_max_rho_line: preComputed.estimated_max_rho_line ?? preComputed.max_rho_line, }); setPreview(preComputed); setError(null); } else { setPreview(null); setSimulationFeedback(null); } } else { setPreview(null); setError(null); setSimulationFeedback(null); } // analysisResult is deliberately omitted — see comment above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIds, disconnectedElement, activeTab]); const handleEstimate = async () => { if (activeTab !== 'explore' || selectedIds.size !== 2 || !disconnectedElement || disconnectedElement.length === 0) return; const [id1, id2] = Array.from(selectedIds); setLoading(true); setError(null); try { const result = await api.computeSuperposition(id1, id2, disconnectedElement); if (result.error) { setError(result.error); setPreview(result); } else { interactionLogger.record('combine_pair_estimated', { action1_id: id1, action2_id: id2, estimated_max_rho: result.estimated_max_rho ?? result.max_rho, estimated_max_rho_line: result.estimated_max_rho_line ?? result.max_rho_line, }); setPreview(result); setError(null); } } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }, message?: string }; setError(apiErrorMessage(err, 'Failed to compute superposition')); setPreview(null); } finally { setLoading(false); } }; if (!isOpen) return null; // Estimation is available for every action type: the Generalized // Superposition Theorem (GST) now combines load shedding / curtailment / // redispatch (injection changes) with topology actions, so no selection is // restricted from the estimation path anymore. const hasRestricted = false; const handleToggle = (id: string) => { setSimulationFeedback(null); setError(null); const newSet = new Set(selectedIds); if (newSet.has(id)) { newSet.delete(id); interactionLogger.record('combine_pair_toggled', { action_id: id, selected: false }); } else { if (newSet.size >= 2) return; // Only allow 2 newSet.add(id); interactionLogger.record('combine_pair_toggled', { action_id: id, selected: true }); } setSelectedIds(newSet); }; const handleSimulate = async (actionId?: string, targetMw?: number) => { const idToSimulate = actionId ? (actionId.includes('+') ? canonicalizeId(actionId) : actionId) : Array.from(selectedIds).sort().join('+'); if (!idToSimulate || !disconnectedElement || disconnectedElement.length === 0) return; // Try to find estimation data to preserve it const estimationData = actionId ? (analysisResult?.combined_actions?.[idToSimulate] || analysisResult?.combined_actions?.[actionId]) : preview; // Build action_content from saved topologies let actionContent: Record | null = null; const parts = idToSimulate.includes('+') ? idToSimulate.split('+') : [idToSimulate]; const allActions = { ...simulatedActions, ...analysisResult?.actions }; const perAction: Record = {}; for (const part of parts) { const partDetail = allActions[part]; if (partDetail?.action_topology) perAction[part] = partDetail.action_topology; } if (Object.keys(perAction).length > 0) actionContent = perAction; setSimulating(true); if (!actionId || actionId.includes('+')) { setSimulationFeedback(null); } setError(null); try { const actualLinesOverloaded = (linesOverloaded && linesOverloaded.length > 0) ? linesOverloaded : null; // ``targetMw`` is only supplied for single injection-action rows // (Explore Pairs): the target shed / curtail amount, or the signed // redispatch delta. Combined-pair simulations leave it undefined. const result = await api.simulateManualAction(idToSimulate, disconnectedElement, actionContent, actualLinesOverloaded, targetMw ?? null); const feedback: SimulationFeedback = { max_rho: result.max_rho, max_rho_line: result.max_rho_line, is_rho_reduction: result.is_rho_reduction, is_islanded: result.is_islanded, disconnected_mw: result.disconnected_mw, non_convergence: result.non_convergence, }; const simParts = idToSimulate.split('+'); interactionLogger.record('combine_pair_simulated', { combined_id: idToSimulate, action1_id: simParts[0], action2_id: simParts[1], simulated_max_rho: result.max_rho, }); setSimulationFeedback(feedback); // Store per-pair result in session map so the computed pairs table // correctly reflects each pair's own simulation result setSessionSimResults(prev => ({ ...prev, [idToSimulate]: feedback })); if (!actionId || actionId.includes('+')) { setSimulationFeedback(feedback); } const detail: 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, lines_overloaded_after: result.lines_overloaded_after, load_shedding_details: result.load_shedding_details, curtailment_details: result.curtailment_details, redispatch_details: result.redispatch_details, pst_details: result.pst_details, estimated_max_rho: estimationData?.estimated_max_rho ?? estimationData?.max_rho, estimated_max_rho_line: estimationData?.estimated_max_rho_line ?? estimationData?.max_rho_line, is_estimated: false, action_topology: result.action_topology }; // A "single action" simulation is one triggered from a row in // the Explore Pairs table (actionId is passed AND does not // contain '+'). In that case the user is just previewing an // individual action to compare before combining — it should // land in Suggested Actions (not Selected). // // A combined pair simulation (no actionId, or an id containing // '+') is an explicit user action to add the pair as a new // candidate and is promoted into Selected Actions. // // In either case we deliberately DO NOT close the modal: the // updated action card and its action-variant diagram are // populated in the background so the user can keep interacting // with the modal (e.g. simulate more rows, compare another // pair) without losing their place. const isSingleActionFromExplore = actionId !== undefined && !actionId.includes('+'); if (isSingleActionFromExplore) { onSimulateSingleAction(idToSimulate, detail, result.lines_overloaded || []); } else { onSimulateCombined(idToSimulate, detail, result.lines_overloaded || []); } } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }, message?: string }; setError(apiErrorMessage(err, 'Simulation failed')); } finally { setSimulating(false); } }; return (
{/* Shared severity + action-type filter sits next to the title so both tabs (Computed / Explore Pairs) are driven by one control. */}

Combine Actions

setActiveTab('computed')} data-testid="tab-computed">Computed Pairs
setActiveTab('explore')} data-testid="tab-explore">Explore Pairs
{activeTab === 'computed' ? ( ) : ( setSelectedIds(new Set())} preview={preview} simulationFeedback={simulationFeedback} sessionSimResults={sessionSimResults} analysisResult={analysisResult} loading={loading} error={error} simulating={simulating} hasRestricted={hasRestricted} monitoringFactor={monitoringFactor} onEstimate={handleEstimate} onSimulate={() => handleSimulate()} onSimulateSingle={handleSimulate} displayName={displayName} /> )}
); }; export default CombinedActionsModal;