Spaces:
Running
Running
| // 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<string>; | |
| onToggle: (id: string) => void; | |
| onClearSelection: () => void; | |
| preview: CombinedAction | null; | |
| simulationFeedback: SimulationFeedback | null; | |
| sessionSimResults: Record<string, SimulationFeedback>; | |
| 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<ExplorePairsTabProps> = ({ | |
| 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<Record<string, string>>({}); | |
| return ( | |
| <div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> | |
| {/* Selection Chips Header */} | |
| <div style={{ background: colors.surfaceMuted, padding: '10px 15px', borderRadius: '6px', marginBottom: '15px', border: `1px solid ${colors.border}` }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}> | |
| <div style={{ fontSize: '12px', fontWeight: 'bold', color: colors.textSecondary }}>Selected Actions ({selectedIds.size}/2)</div> | |
| <button | |
| onClick={onClearSelection} | |
| disabled={selectedIds.size === 0} | |
| style={{ background: 'none', border: 'none', color: colors.brand, fontSize: '11px', cursor: 'pointer', padding: 0 }} | |
| >Clear All</button> | |
| </div> | |
| <div style={{ display: 'flex', gap: '8px', minHeight: '30px', flexWrap: 'wrap' }} data-testid="selection-chips"> | |
| {selectedIds.size === 0 ? ( | |
| <div style={{ color: colors.textTertiary, fontSize: '12px', fontStyle: 'italic', display: 'flex', alignItems: 'center' }}>Click rows in the table below to select...</div> | |
| ) : ( | |
| Array.from(selectedIds).map(id => ( | |
| <div key={id} data-testid={`chip-${id}`} style={{ background: colors.brandSoft, color: colors.brand, padding: '4px 10px', borderRadius: '20px', fontSize: '11px', fontWeight: 'bold', border: `1px solid ${colors.brand}`, display: 'flex', alignItems: 'center', gap: '6px' }}> | |
| {id} | |
| <span onClick={(e) => { e.stopPropagation(); onToggle(id); }} style={{ cursor: 'pointer', fontSize: '14px', lineHeight: '10px' }}>×</span> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Grouped Table — the row set is already filtered by the | |
| shared ActionFilterRings in the modal header. */} | |
| <div style={{ flex: 1, maxHeight: '350px', overflowY: 'auto', border: `1px solid ${colors.borderSubtle}`, borderRadius: '4px', marginBottom: '15px' }}> | |
| {(() => { | |
| const filteredList = scoredActionsList; | |
| const types = Array.from(new Set(filteredList.map(item => item.type))); | |
| if (filteredList.length === 0) { | |
| return ( | |
| <div style={{ textAlign: 'center', color: colors.textTertiary, fontStyle: 'italic', padding: '40px 20px' }}> | |
| No scored actions available for this filter. | |
| </div> | |
| ); | |
| } | |
| return types.map(type => ( | |
| <div key={type} style={{ marginBottom: '1px' }}> | |
| <div style={{ fontSize: '11px', fontWeight: 600, color: colors.textSecondary, backgroundColor: colors.surfaceMuted, padding: '4px 10px', borderBottom: `1px solid ${colors.border}`, display: 'flex', justifyContent: 'space-between' }}> | |
| <span>{type.replace(/_/g, ' ').toUpperCase()}</span> | |
| <span>{filteredList.filter(item => item.type === type).length} actions</span> | |
| </div> | |
| <table className="action-table" style={{ margin: 0, border: 'none' }}> | |
| <tbody> | |
| {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 ( | |
| <tr | |
| key={actionId} | |
| className={isSelected ? 'selected' : ''} | |
| onClick={() => onToggle(actionId)} | |
| style={{ cursor: 'pointer', background: isSelected ? colors.warningSoft : colors.surfaceRaised }} | |
| > | |
| <td style={{ width: '30px', padding: '8px 0 8px 12px' }}> | |
| <input type="checkbox" checked={isSelected} readOnly style={{ cursor: 'pointer' }} /> | |
| </td> | |
| <td style={{ fontWeight: 'bold', fontSize: '12px' }}>{actionId}</td> | |
| {injectionKind ? ( | |
| <td onClick={(e) => e.stopPropagation()} style={{ width: '72px', textAlign: 'right', paddingRight: '4px' }}> | |
| <input | |
| data-testid={`explore-mw-${actionId}`} | |
| type="number" | |
| step={0.1} | |
| min={injectionKind === 'ls_rc' ? 0 : undefined} | |
| max={injectionKind === 'ls_rc' ? (mwStart ?? undefined) : undefined} | |
| placeholder={injectionKind === 'redispatch' ? 'Δ' : (mwStart != null ? mwStart.toFixed(1) : '0')} | |
| title={injectionKind === 'redispatch' ? 'Signed redispatch increment (MW): raise > 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' }} | |
| /> | |
| </td> | |
| ) : ( | |
| <td style={{ width: '72px', textAlign: 'right', fontFamily: 'monospace', fontSize: '11px', color: mwStart == null ? colors.textTertiary : colors.textPrimary }}> | |
| {mwStart != null ? `${mwStart.toFixed(1)}` : 'N/A'} | |
| </td> | |
| )} | |
| <td style={{ width: '60px', textAlign: 'right' }}> | |
| <span className="metric-badge metric-score" style={{ transform: 'scale(0.9)', display: 'inline-block' }}> | |
| {score.toFixed(2)} | |
| </span> | |
| </td> | |
| <td style={{ width: '80px', textAlign: 'right' }}> | |
| {simResult ? ( | |
| <span className="metric-badge metric-rho" style={{ | |
| transform: 'scale(0.9)', | |
| display: 'inline-block', | |
| background: (simResult.max_rho ?? 0) > 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)}% | |
| </span> | |
| ) : ( | |
| <span style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '10px' }}>Untested</span> | |
| )} | |
| </td> | |
| <td onClick={(e) => e.stopPropagation()} style={{ width: '100px', textAlign: 'right', paddingRight: '12px' }}> | |
| <button | |
| onClick={() => onSimulateSingle(actionId, resolveTargetMw())} | |
| disabled={simulating} | |
| style={{ | |
| padding: '3px 10px', | |
| background: simResult ? colors.disabled : colors.brandStrong, | |
| color: colors.textOnBrand, | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: simulating ? 'not-allowed' : 'pointer', | |
| fontSize: '10px', | |
| fontWeight: 'bold', | |
| minWidth: '70px' | |
| }} | |
| > | |
| {simulating ? '...' : (simResult ? 'Re-run' : 'Simulate')} | |
| </button> | |
| </td> | |
| </tr> | |
| ); | |
| }) | |
| } | |
| </tbody> | |
| </table> | |
| </div> | |
| )); | |
| })()} | |
| </div> | |
| {/* Action Bar / Comparison Card */} | |
| <div style={{ marginTop: '5px' }}> | |
| {!preview && !simulationFeedback && !simulating && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> | |
| <button | |
| onClick={onEstimate} | |
| disabled={selectedIds.size !== 2 || loading || hasRestricted} | |
| data-testid="estimate-button" | |
| title={hasRestricted ? 'Estimation is not available when a load shedding or curtailment action is selected — use Simulate Combined instead.' : undefined} | |
| style={{ | |
| width: '100%', | |
| padding: '12px', | |
| background: (selectedIds.size === 2 && !loading && !hasRestricted) ? colors.brand : colors.surfaceMuted, | |
| color: (selectedIds.size === 2 && !loading && !hasRestricted) ? colors.textOnBrand : colors.textTertiary, | |
| border: 'none', | |
| borderRadius: '6px', | |
| cursor: (selectedIds.size !== 2 || loading || hasRestricted) ? 'not-allowed' : 'pointer', | |
| fontWeight: 'bold', | |
| fontSize: '14px', | |
| transition: 'all 0.2s', | |
| boxShadow: (selectedIds.size === 2 && !loading && !hasRestricted) ? '0 4px 6px rgba(52, 152, 219, 0.2)' : 'none' | |
| }} | |
| > | |
| {loading ? '⚙️ Estimating Combination...' : (selectedIds.size === 2 ? (hasRestricted ? 'Estimation not available for load shedding / curtailment' : 'Estimate combination effect') : 'Select 2 actions to estimate')} | |
| </button> | |
| <button | |
| onClick={onSimulate} | |
| disabled={selectedIds.size !== 2 || simulating} | |
| data-testid="simulate-combined-button" | |
| style={{ | |
| width: '100%', | |
| padding: '10px', | |
| background: (selectedIds.size === 2 && !simulating) ? colors.success : colors.surfaceMuted, | |
| color: (selectedIds.size === 2 && !simulating) ? colors.textOnBrand : colors.textTertiary, | |
| border: 'none', | |
| borderRadius: '6px', | |
| cursor: (selectedIds.size !== 2 || simulating) ? 'not-allowed' : 'pointer', | |
| fontWeight: 'bold', | |
| fontSize: '13px', | |
| transition: 'all 0.2s', | |
| boxShadow: (selectedIds.size === 2 && !simulating) ? '0 2px 4px rgba(39,174,96,0.2)' : 'none' | |
| }} | |
| > | |
| {simulating ? '⌛ Simulating...' : (selectedIds.size === 2 ? 'Simulate Combined' : 'Select 2 actions to simulate')} | |
| </button> | |
| </div> | |
| )} | |
| {preview && !simulationFeedback && ( | |
| <button | |
| onClick={onSimulate} | |
| disabled={simulating} | |
| data-testid="simulate-combined-top-button" | |
| style={{ | |
| width: '100%', | |
| padding: '10px', | |
| marginBottom: '8px', | |
| background: simulating ? colors.textSecondary : colors.success, | |
| color: colors.textOnBrand, | |
| border: 'none', | |
| borderRadius: '6px', | |
| cursor: simulating ? 'not-allowed' : 'pointer', | |
| fontWeight: 'bold', | |
| fontSize: '13px', | |
| boxShadow: simulating ? 'none' : '0 2px 4px rgba(39,174,96,0.2)', | |
| transition: 'all 0.2s' | |
| }} | |
| > | |
| {simulating ? '⌛ Simulating...' : 'Simulate Combined'} | |
| </button> | |
| )} | |
| {(preview || simulationFeedback || simulating) && ( | |
| <div style={{ | |
| padding: '15px', | |
| background: error ? colors.warningSoft : colors.infoSoft, | |
| borderRadius: '8px', | |
| borderLeft: '5px solid ' + (error ? colors.warningText : colors.info), | |
| boxShadow: '0 2px 5px rgba(0,0,0,0.05)' | |
| }} data-testid="comparison-card"> | |
| <div style={{ marginBottom: '10px' }}> | |
| {preview?.betas && ( | |
| <div style={{ marginBottom: '8px', fontSize: '11px', color: colors.textTertiary, background: 'rgba(255,255,255,0.6)', padding: '2px 8px', borderRadius: '4px', display: 'inline-block', fontWeight: 600 }}> | |
| Betas: {preview.betas.map(b => b.toFixed(3)).join(', ')} | |
| </div> | |
| )} | |
| <div style={{ fontWeight: 800, color: error ? colors.warningText : colors.infoText, fontSize: '15px' }}> | |
| {error ? '⚠️ Estimation Failed' : (preview ? 'Explore Pairs Comparison' : 'Simulation Result')} | |
| </div> | |
| </div> | |
| {!error && ( | |
| <div style={{ display: 'flex', gap: '30px', borderTop: '1px solid rgba(0,0,0,0.05)', paddingTop: '12px' }}> | |
| {preview && ( | |
| <div style={{ flex: 1, borderRight: '1px solid rgba(0,0,0,0.05)', paddingRight: '15px' }}> | |
| <div style={{ fontSize: '11px', fontWeight: 700, color: colors.textTertiary, textTransform: 'uppercase', marginBottom: '8px', letterSpacing: '0.5px' }}>Estimated Effect</div> | |
| <div style={{ fontSize: '13px', marginBottom: '4px' }}> | |
| Estimated Max Loading: <strong style={{ color: (preview.estimated_max_rho ?? preview.max_rho ?? 0) <= monitoringFactor ? colors.success : colors.warningStrong, fontSize: '16px' }}>{((preview.estimated_max_rho ?? preview.max_rho ?? 0) * 100).toFixed(1)}%</strong> | |
| {preview.is_islanded && ( | |
| <span style={{ marginLeft: '6px' }} title="Estimation suspect due to islanding">⚠️</span> | |
| )} | |
| </div> | |
| <div style={{ fontSize: '12px', color: colors.textTertiary }}> | |
| Line: {displayName(preview.estimated_max_rho_line ?? preview.max_rho_line)} | |
| </div> | |
| {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) && ( | |
| <div style={{ fontSize: '11px', color: colors.textSecondary, marginTop: '6px', padding: '4px 8px', background: 'rgba(255,255,255,0.6)', borderRadius: '4px', display: 'inline-block' }} data-testid="target-max-rho"> | |
| Target overload: <strong style={{ color: (preview.target_max_rho ?? 0) <= monitoringFactor ? colors.success : colors.warningStrong }}>{((preview.target_max_rho ?? 0) * 100).toFixed(1)}%</strong> on {displayName(preview.target_max_rho_line)} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontSize: '11px', fontWeight: 700, color: colors.textTertiary, textTransform: 'uppercase', marginBottom: '8px', letterSpacing: '0.5px' }}>Simulation Result</div> | |
| {simulating && ( | |
| <div style={{ color: colors.brandStrong, fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}> | |
| <span>⌛</span> Simulating combined action... | |
| </div> | |
| )} | |
| {!simulating && simulationFeedback && ( | |
| <div data-testid="simulation-feedback"> | |
| <div style={{ fontSize: '13px', marginBottom: '4px' }}> | |
| Actual Max Loading: <strong style={{ color: (simulationFeedback.max_rho ?? 1) <= monitoringFactor ? colors.success : colors.warningStrong, fontSize: '16px' }}>{simulationFeedback.max_rho != null ? `${(simulationFeedback.max_rho * 100).toFixed(1)}%` : 'N/A'}</strong> | |
| </div> | |
| <div style={{ fontSize: '12px', color: colors.textTertiary }}> | |
| Line: {displayName(simulationFeedback.max_rho_line)} | |
| </div> | |
| {simulationFeedback.is_islanded && ( | |
| <div style={{ fontSize: '11px', color: colors.danger, marginTop: '6px', fontWeight: 600, background: colors.dangerSoft, padding: '2px 8px', borderRadius: '4px', display: 'inline-block' }}> | |
| Islanding detected ({simulationFeedback.disconnected_mw?.toFixed(1)} MW disconnected) | |
| </div> | |
| )} | |
| {simulationFeedback.non_convergence && ( | |
| <div style={{ fontSize: '11px', color: colors.danger, marginTop: '6px', fontWeight: 600, background: colors.dangerSoft, padding: '2px 8px', borderRadius: '4px', display: 'inline-block' }}> | |
| Non-convergence: {simulationFeedback.non_convergence} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {!simulating && !simulationFeedback && ( | |
| <div style={{ color: colors.textTertiary, fontSize: '12px', fontStyle: 'italic', marginTop: '5px' }}>Click "Simulate Combined" above to run</div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div style={{ fontSize: '13px', color: colors.warningText }}>{error}</div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ExplorePairsTab; | |