Co-Study4Grid / frontend /src /components /CombinedActionsModal.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
27.7 kB
// 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<string, ActionDetail>;
/** 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<Props> = ({
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<Set<string>>(new Set());
const [preview, setPreview] = useState<CombinedAction | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [simulating, setSimulating] = useState(false);
const [simulationFeedback, setSimulationFeedback] = useState<SimulationFeedback | null>(null);
// Per-pair simulation results tracked within this modal session
const [sessionSimResults, setSessionSimResults] = useState<Record<string, SimulationFeedback>>({});
const lastSelectionRef = useRef<string>('');
// 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<ActionOverviewFilters>(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<string, string>();
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<string, unknown> | null = null;
const parts = idToSimulate.includes('+') ? idToSimulate.split('+') : [idToSimulate];
const allActions = { ...simulatedActions, ...analysisResult?.actions };
const perAction: Record<string, unknown> = {};
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 (
<div style={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 10000,
// Anchor the modal card to a fixed viewport offset
// (``alignItems: flex-start`` + a top margin set on the
// card) instead of centering it verticallyotherwise the
// title + filter header hops up and down every time the
// Computed / Explore Pairs body changes height (chip
// filter, tab switch, action-type drilldown).
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center'
}}>
<div
data-testid="combine-modal-card"
style={{
background: colors.surface,
borderRadius: '12px',
// Use (almost) the full viewport width instead of a
// fixed 950px so wide tables in the Computed / Explore
// Pairs tabs fit without forcing a horizontal
// scrollbar on the modal itself.
width: '95vw',
maxWidth: '95vw',
marginTop: '7.5vh',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 10px 25px rgba(0,0,0,0.25)',
overflow: 'hidden'
}}
>
<div style={{ padding: '15px 24px', borderBottom: `1px solid ${colors.borderSubtle}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', background: colors.surfaceRaised }}>
{/* Shared severity + action-type filter sits next to
the title so both tabs (Computed / Explore Pairs)
are driven by one control. */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Combine Actions</h2>
<ActionFilterRings filters={filters} onFiltersChange={setFilters} />
</div>
<button onClick={onClose} style={{ border: 'none', background: 'none', fontSize: '24px', cursor: 'pointer', color: colors.textTertiary }}>&times;</button>
</div>
<div style={{ display: 'flex', borderBottom: `1px solid ${colors.border}`, background: colors.surfaceRaised, padding: '0 24px' }}>
<div className={`modal-tab ${activeTab === 'computed' ? 'active' : ''}`} onClick={() => setActiveTab('computed')} data-testid="tab-computed">Computed Pairs</div>
<div className={`modal-tab ${activeTab === 'explore' ? 'active' : ''}`} onClick={() => setActiveTab('explore')} data-testid="tab-explore">Explore Pairs</div>
</div>
<div
data-testid="combine-modal-body"
style={{
padding: '20px 24px',
overflowY: 'auto',
// Tables inside the modal manage their own scroll;
// never let horizontal overflow escape to the
// modal level.
overflowX: 'hidden',
flex: 1,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
}}
>
{activeTab === 'computed' ? (
<ComputedPairsTable
computedPairsList={filteredComputedPairsList as ComputedPairEntry[]}
monitoringFactor={monitoringFactor}
simulating={simulating}
onSimulate={handleSimulate}
displayName={displayName}
/>
) : (
<ExplorePairsTab
scoredActionsList={filteredScoredActionsList}
selectedIds={selectedIds}
onToggle={handleToggle}
onClearSelection={() => 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}
/>
)}
</div>
<div style={{ padding: '16px 24px', borderTop: `1px solid ${colors.borderSubtle}`, display: 'flex', justifyContent: 'flex-end', gap: '12px', background: colors.surfaceRaised }}>
<button onClick={onClose} style={{ padding: '10px 20px', background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: '6px', cursor: 'pointer', fontWeight: 500, color: colors.textTertiary }}>Close</button>
</div>
</div>
</div>
);
};
export default CombinedActionsModal;