// 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 } from 'react'; import type { CombinedAction, AnalysisResult } from '../types'; import { colors } from '../styles/tokens'; interface SimulationFeedback { max_rho: number | null; max_rho_line: string; is_rho_reduction: boolean; is_islanded?: boolean; disconnected_mw?: number; non_convergence?: string | null; } export interface ScoredActionEntry { actionId: string; score: number; type: string; mwStart: number | null; } interface ExplorePairsTabProps { scoredActionsList: ScoredActionEntry[]; selectedIds: Set; onToggle: (id: string) => void; onClearSelection: () => void; preview: CombinedAction | null; simulationFeedback: SimulationFeedback | null; sessionSimResults: Record; analysisResult: AnalysisResult | null; loading: boolean; error: string | null; simulating: boolean; hasRestricted: boolean; monitoringFactor: number; onEstimate: () => void; onSimulate: () => void; /** Simulate a single action from a row. ``targetMw`` carries the * edited injection setpoint: the target shed / curtail amount for * load-shedding / curtailment, or the signed delta for redispatch. * Left undefined for non-injection rows (and injection rows whose * input is blank, which fall back to the recommender default). */ onSimulateSingle: (actionId: string, targetMw?: number) => void; displayName?: (id: string) => string; } /** Injection action types whose MW setpoint is editable before * simulating: load shedding (target shed amount), renewable * curtailment (target curtail amount) and redispatch (signed delta). */ const classifyInjection = (type: string): 'ls_rc' | 'redispatch' | null => { const t = type.toLowerCase(); if (t.includes('redispatch')) return 'redispatch'; if (t.includes('load_shedding') || t.includes('renewable_curtailment') || t.includes('curtail')) return 'ls_rc'; return null; }; const ExplorePairsTab: React.FC = ({ scoredActionsList, selectedIds, onToggle, onClearSelection, preview, simulationFeedback, sessionSimResults, analysisResult, loading, error, simulating, hasRestricted, monitoringFactor, onEstimate, onSimulate, onSimulateSingle, displayName = (id: string) => id, }) => { // Per-row editable injection setpoint (target MW for LS/curtailment, // signed delta for redispatch), keyed by action id. Drives both the // input value and the MW passed to onSimulateSingle. const [editMw, setEditMw] = useState>({}); return (
{/* Selection Chips Header */}
Selected Actions ({selectedIds.size}/2)
{selectedIds.size === 0 ? (
Click rows in the table below to select...
) : ( Array.from(selectedIds).map(id => (
{id} { e.stopPropagation(); onToggle(id); }} style={{ cursor: 'pointer', fontSize: '14px', lineHeight: '10px' }}>×
)) )}
{/* Grouped Table — the row set is already filtered by the shared ActionFilterRings in the modal header. */}
{(() => { const filteredList = scoredActionsList; const types = Array.from(new Set(filteredList.map(item => item.type))); if (filteredList.length === 0) { return (
No scored actions available for this filter.
); } return types.map(type => (
{type.replace(/_/g, ' ').toUpperCase()} {filteredList.filter(item => item.type === type).length} actions
{filteredList .filter(item => item.type === type) .map(({ actionId, score, mwStart }) => { const isSelected = selectedIds.has(actionId); const simResult = sessionSimResults[actionId] || (analysisResult?.actions?.[actionId]?.rho_after ? analysisResult.actions[actionId] : null); // Injection rows (LS / curtailment / redispatch) expose an // editable MW setpoint: the target shed / curtail amount, or // the signed redispatch delta. Default to the simulated value // when the action has already been computed. const injectionKind = classifyInjection(type); const computedDetail = analysisResult?.actions?.[actionId]; const storedMw = injectionKind === 'redispatch' ? (computedDetail?.redispatch_details?.[0]?.delta_mw ?? null) : injectionKind === 'ls_rc' ? (computedDetail?.load_shedding_details?.[0]?.shedded_mw ?? computedDetail?.curtailment_details?.[0]?.curtailed_mw ?? null) : null; const effectiveMwStr = editMw[actionId] ?? (storedMw != null ? storedMw.toFixed(1) : ''); const resolveTargetMw = (): number | undefined => { if (!injectionKind || effectiveMwStr === '') return undefined; const v = parseFloat(effectiveMwStr); return Number.isNaN(v) ? undefined : v; }; return ( onToggle(actionId)} style={{ cursor: 'pointer', background: isSelected ? colors.warningSoft : colors.surfaceRaised }} > {injectionKind ? ( ) : ( )} ); }) }
{actionId} e.stopPropagation()} style={{ width: '72px', textAlign: 'right', paddingRight: '4px' }}> 0, lower < 0.' : 'Target MW to shed / curtail.'} value={effectiveMwStr} onChange={(e) => setEditMw(prev => ({ ...prev, [actionId]: e.target.value }))} style={{ width: '64px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${injectionKind === 'redispatch' ? colors.info : colors.border}`, borderRadius: '3px', textAlign: 'right' }} /> {mwStart != null ? `${mwStart.toFixed(1)}` : 'N/A'} {score.toFixed(2)} {simResult ? ( monitoringFactor ? colors.dangerSoft : colors.successSoft, color: (simResult.max_rho ?? 0) > monitoringFactor ? colors.dangerText : colors.successStrong, border: '1px solid currentColor' }}> {((simResult.max_rho ?? 0) * 100).toFixed(1)}% ) : ( Untested )} e.stopPropagation()} style={{ width: '100px', textAlign: 'right', paddingRight: '12px' }}>
)); })()}
{/* Action Bar / Comparison Card */}
{!preview && !simulationFeedback && !simulating && (
)} {preview && !simulationFeedback && ( )} {(preview || simulationFeedback || simulating) && (
{preview?.betas && (
Betas: {preview.betas.map(b => b.toFixed(3)).join(', ')}
)}
{error ? '⚠️ Estimation Failed' : (preview ? 'Explore Pairs Comparison' : 'Simulation Result')}
{!error && (
{preview && (
Estimated Effect
Estimated Max Loading: {((preview.estimated_max_rho ?? preview.max_rho ?? 0) * 100).toFixed(1)}% {preview.is_islanded && ( ⚠️ )}
Line: {displayName(preview.estimated_max_rho_line ?? preview.max_rho_line)}
{preview.target_max_rho != null && preview.target_max_rho_line && preview.target_max_rho_line !== 'N/A' && preview.target_max_rho_line !== (preview.estimated_max_rho_line ?? preview.max_rho_line) && (
Target overload: {((preview.target_max_rho ?? 0) * 100).toFixed(1)}% on {displayName(preview.target_max_rho_line)}
)}
)}
Simulation Result
{simulating && (
Simulating combined action...
)} {!simulating && simulationFeedback && (
Actual Max Loading: {simulationFeedback.max_rho != null ? `${(simulationFeedback.max_rho * 100).toFixed(1)}%` : 'N/A'}
Line: {displayName(simulationFeedback.max_rho_line)}
{simulationFeedback.is_islanded && (
Islanding detected ({simulationFeedback.disconnected_mw?.toFixed(1)} MW disconnected)
)} {simulationFeedback.non_convergence && (
Non-convergence: {simulationFeedback.non_convergence}
)}
)} {!simulating && !simulationFeedback && (
Click "Simulate Combined" above to run
)}
)} {error && (
{error}
)}
)}
); }; export default ExplorePairsTab;