// 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 from 'react'; import type { ActionDetail, NodeMeta, EdgeMeta } from '../types'; import type { ModelDescriptor } from '../api'; import { getActionTargetVoltageLevels, getActionTargetLines, isCouplingAction } from '../utils/svgUtils'; import { colors } from '../styles/tokens'; import { SeverityIcon, type SeverityKind } from './SeverityIcon'; interface ActionCardProps { id: string; details: ActionDetail; index: number; isViewing: boolean; isSelected: boolean; isRejected: boolean; linesOverloaded: string[]; monitoringFactor: number; nodesByEquipmentId: Map | null; edgesByEquipmentId: Map | null; cardEditMw: Record; cardEditTap: Record; resimulating: string | null; onActionSelect: (actionId: string | null) => void; onActionFavorite: (actionId: string) => void; onActionReject: (actionId: string) => void; onAssetClick: (actionId: string, assetName: string, tab?: 'action' | 'contingency') => void; onVlDoubleClick?: (actionId: string, vlName: string) => void; onCardEditMwChange: (actionId: string, value: string) => void; onCardEditTapChange: (actionId: string, value: string) => void; onResimulate: (actionId: string, newMw: number) => void; onResimulateTap: (actionId: string, newTap: number) => void; /** Resolve an element ID to its human-readable display name. Falls back to the ID. */ displayName?: (id: string) => string; /** * Registered recommender models — used to resolve `details.origin` * (a model id) to a human-readable label in the unfolded card's * "Source" row. Absent / no-match falls back to the raw id. */ availableModels?: ModelDescriptor[]; } const clickableLinkStyle: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 'inherit', color: colors.brand, fontWeight: 600, textDecoration: 'underline dotted', }; const ActionCard: React.FC = ({ id, details, index, isViewing, isSelected, isRejected, linesOverloaded, monitoringFactor, nodesByEquipmentId, edgesByEquipmentId, cardEditMw, cardEditTap, resimulating, onActionSelect, onActionFavorite, onActionReject, onAssetClick, onVlDoubleClick, onCardEditMwChange, onCardEditTapChange, onResimulate, onResimulateTap, displayName = (id: string) => id, availableModels, }) => { const maxRhoPct = details.max_rho != null ? (details.max_rho * 100).toFixed(1) : null; // Provenance label for the unfolded card's "Source" row. `origin` // is either the literal `"user"` (manual simulation) or a // recommender model id — resolved here to the model's label. const originLabel = details.origin == null ? null : details.origin === 'user' ? 'Manual simulation (user)' : (availableModels?.find(m => m.name === details.origin)?.label ?? details.origin); const severity = details.max_rho != null ? (details.max_rho > monitoringFactor ? 'red' as const : details.max_rho > (monitoringFactor - 0.05) ? 'orange' as const : 'green' as const) : (details.is_rho_reduction ? 'green' as const : 'red' as const); const severityColors = { green: { border: colors.success, badgeBg: colors.successSoft, badgeText: colors.successText, label: 'Solves overload', kind: 'solves' as SeverityKind }, orange: { border: colors.warningStrong, badgeBg: colors.warningSoft, badgeText: colors.warningText, label: 'Solved — low margin', kind: 'lowMargin' as SeverityKind }, red: { border: colors.danger, badgeBg: colors.dangerSoft, badgeText: colors.dangerText, label: details.is_rho_reduction ? 'Still overloaded' : 'No reduction', kind: 'unsolved' as SeverityKind }, }; const sc = details.non_convergence ? { border: colors.danger, badgeBg: colors.danger, badgeText: colors.textOnBrand, label: 'divergent', kind: 'divergent' as SeverityKind } : details.is_islanded ? { border: colors.danger, badgeBg: colors.danger, badgeText: colors.textOnBrand, label: 'islanded', kind: 'islanded' as SeverityKind } : severityColors[severity]; const renderRho = (arr: number[] | null, actionId: string, tab: 'action' | 'contingency' = 'action'): React.ReactNode => { if (!arr || arr.length === 0) return '—'; const halfOpen = details.half_open_overloads; return arr.map((v, i) => { const lineName = linesOverloaded[i] || `line ${i}`; // A line the action left open at one end shows a non-zero loading // that is pure capacitive charging current (the diagrams show p = 0). // Annotate it with the live-end reactive power so the operator reads // it as charging current, not a residual overload. const reactiveMvar = halfOpen ? halfOpen[lineName] : undefined; return ( {i > 0 && ', '} {`: ${(v * 100).toFixed(1)}%`} {reactiveMvar != null && ( {` — open one end · ${reactiveMvar.toFixed(1)} MVAr capacitive`} )} ); }); }; const renderBadges = () => { const badges: React.ReactNode[] = []; const badgeBtn = (name: string, bg: string, color: string, title: string, onDoubleClick?: (e: React.MouseEvent) => void) => ( ); // Collect badges from every source that applies. A combined // action like ``load_shedding_X+reco_Y`` owes a badge to BOTH // sub-actions — using an if/else-if/else here used to drop the // topology-based sub-action (reco / disco / coupling) whenever // the pair also contained a load-shedding or curtailment leg. const vlSet = new Set(); // Highest-priority signal: backend-supplied VL hint on the // topology blob. The recommender pipeline writes this for // pypowsybl switch-based / coupling actions (the dict_action // entry's ``VoltageLevelId``) so the operator gets a // clickable, double-clickable VL chip even when the action ID // is opaque (e.g. UUID-prefixed ``..._VLNAME_..._coupling``). const explicitVlHint = (details.action_topology as { voltage_level_id?: string } | undefined)?.voltage_level_id; if (explicitVlHint && !vlSet.has(explicitVlHint)) { vlSet.add(explicitVlHint); badges.push(badgeBtn(explicitVlHint, colors.successSoft, colors.successText, `Click: zoom to ${explicitVlHint} | Double-click: open SLD`, (e) => { e.stopPropagation(); onVlDoubleClick?.(id, explicitVlHint); })); } details.load_shedding_details?.forEach(ls => { if (ls.voltage_level_id && !vlSet.has(ls.voltage_level_id)) { vlSet.add(ls.voltage_level_id); badges.push(badgeBtn(ls.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${ls.voltage_level_id} | Double-click: open SLD`, (e) => { e.stopPropagation(); onVlDoubleClick?.(id, ls.voltage_level_id!); })); } }); details.curtailment_details?.forEach(rc => { if (rc.voltage_level_id && !vlSet.has(rc.voltage_level_id)) { vlSet.add(rc.voltage_level_id); badges.push(badgeBtn(rc.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${rc.voltage_level_id} | Double-click: open SLD`, (e) => { e.stopPropagation(); onVlDoubleClick?.(id, rc.voltage_level_id!); })); } }); details.redispatch_details?.forEach(rd => { if (rd.voltage_level_id && !vlSet.has(rd.voltage_level_id)) { vlSet.add(rd.voltage_level_id); badges.push(badgeBtn(rd.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${rd.voltage_level_id} | Double-click: open SLD`, (e) => { e.stopPropagation(); onVlDoubleClick?.(id, rd.voltage_level_id!); })); } }); if (nodesByEquipmentId) { const vlNames = getActionTargetVoltageLevels(details, id, nodesByEquipmentId); vlNames.forEach(vlName => { if (vlSet.has(vlName)) return; vlSet.add(vlName); badges.push(badgeBtn(vlName, colors.successSoft, colors.successText, `Click: zoom to ${vlName} | Double-click: open SLD`, (e) => { e.stopPropagation(); onVlDoubleClick?.(id, vlName); })); }); } const isCoupling = isCouplingAction(id, details.description_unitaire); const lineNames = edgesByEquipmentId ? getActionTargetLines(details, id, edgesByEquipmentId) : Array.from(new Set([ ...(isCoupling ? [] : Object.keys(details.action_topology?.lines_ex_bus || {})), ...(isCoupling ? [] : Object.keys(details.action_topology?.lines_or_bus || {})), ...Object.keys(details.action_topology?.pst_tap || {}), ])); lineNames.forEach(name => { if (badges.some(b => React.isValidElement(b) && b.key === name)) return; badges.push(badgeBtn(name, colors.brandSoft, colors.brand, `Zoom to ${name}`)); }); if (badges.length === 0) { const topo = details.action_topology; const equipNames = Array.from(new Set([ ...Object.keys(topo?.gens_bus || {}), ...Object.keys(topo?.loads_bus || {}), ...Object.keys(topo?.loads_p || {}), ...Object.keys(topo?.gens_p || {}), ])); equipNames.forEach(name => { badges.push(badgeBtn(name, colors.brandSoft, colors.brand, `Zoom to ${name}`)); }); } return (
{badges}
); }; const isFault = !!(details.non_convergence || details.is_islanded); // Higher-saturation accent stripe for the viewing card — replaces // the old vertical "VIEWING" ribbon with a quieter signal that // doesn't steal a column of horizontal space inside the card. const accentColor = isViewing ? colors.brandStrong : sc.border; const editorRowStyle: React.CSSProperties = { fontSize: '12px', padding: '6px 10px', marginTop: '5px', borderRadius: '4px', fontWeight: 500, }; return (
onActionSelect(id)}>
{/* Severity pictogram — placed before the title so the operator immediately reads the outcome colour. */}

#{index + 1} {'—'} {id}

{/* Star / reject rail — top-right, revealed on hover via CSS (.action-card-rail opacity transition). */}
{!isSelected && ( )} {!isRejected && ( )}
{/* Compact at-rest body: max loading + target badges. The row wraps so multi-VL badge stacks flow to a second line when the title is long or the badges don't fit alongside the loading metric. */}
{maxRhoPct != null ? (
Max loading: {maxRhoPct}% {details.max_rho_line && ( on )}
) : (
No loading metric
)}
{renderBadges()}
{/* Fault states (divergent / islanded) are primary signals and stay visible regardless of the viewing-state — they replace the missing max-loading indicator. */} {details.non_convergence && (
⚠️ LoadFlow failure: {details.non_convergence}
)} {details.is_islanded && (
🏝️ Islanding detected ({details.disconnected_mw?.toFixed(1)} MW disconnected)
)} {/* Progressive disclosure: description, parameter editors, and per-line "Loading after" only render on the viewing card. Keeps non-viewing cards to five fields each. */} {isViewing && (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >

{details.description_unitaire}

{details.load_shedding_details && details.load_shedding_details.length > 0 && (
{details.load_shedding_details.map((ls, i) => (
0 ? '4px' : 0 }}> Shedding on {ls.load_name} in MW: onCardEditMwChange(id, e.target.value)} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.warningStrong}`, borderRadius: '3px', textAlign: 'right' }} />
))}
)} {details.curtailment_details && details.curtailment_details.length > 0 && (
{details.curtailment_details.map((rc, i) => (
0 ? '4px' : 0 }}> Curtailment on {rc.gen_name} in MW: onCardEditMwChange(id, e.target.value)} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.info}`, borderRadius: '3px', textAlign: 'right' }} />
))}
)} {details.redispatch_details && details.redispatch_details.length > 0 && (
{details.redispatch_details.map((rd, i) => { // Signed-delta bounds from the generator's [min_p, max_p]: // a raise can go up to +max_raise_mw, a lower down to -max_lower_mw. const headroom = rd.direction === 'up' ? rd.max_raise_mw : rd.max_lower_mw; const minDelta = rd.direction === 'up' ? 0 : (rd.max_lower_mw != null ? -rd.max_lower_mw : undefined); const maxDelta = rd.direction === 'up' ? (rd.max_raise_mw ?? undefined) : 0; const clamp = (v: number) => { if (minDelta != null) v = Math.max(minDelta, v); if (maxDelta != null) v = Math.min(maxDelta, v); return v; }; return (
0 ? '4px' : 0 }}> Redispatch on {rd.gen_name} ({rd.direction === 'up' ? 'raise' : 'lower'}) in MW: onCardEditMwChange(id, e.target.value)} style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.info}`, borderRadius: '3px', textAlign: 'right' }} /> {headroom != null && ( max {rd.direction === 'up' ? 'raise' : 'lower'}: {headroom.toFixed(0)} MW )}
); })}
)} {details.pst_details && details.pst_details.length > 0 && (
{details.pst_details.map((pst, i) => (
0 ? '4px' : 0 }}> PST {pst.pst_name} tap: onCardEditTapChange(id, e.target.value)} style={{ width: '55px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.accent}`, borderRadius: '3px', textAlign: 'right' }} /> {pst.low_tap != null && pst.high_tap != null && ( [{pst.low_tap}..{pst.high_tap}] )}
))}
)} {/* "Loading before" stays in the sticky Overloads N-1 section of the left feed — no need to duplicate it per card (see git blame for the original rationale). */}
Overload loading after: {renderRho(details.rho_after, id, 'action')}
{/* Source / provenance sits at the very bottom of the unfolded card — it's a low-information attribution row that shouldn't compete with the operational fields above. */} {originLabel && (
Source: {originLabel}
)}
)}
); }; export default React.memo(ActionCard);