Co-Study4Grid / frontend /src /components /ExplorePairsTab.tsx
github-actions[bot]
Deploy ee81b17
cc5b83f
Raw
History Blame Contribute Delete
28.1 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 } 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' }}>&times;</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 selecteduse 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;