Spaces:
Sleeping
Sleeping
| // 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 { useState, useEffect, useCallback, useRef, useMemo } from 'react'; | |
| import './App.css'; | |
| import VisualizationPanel from './components/VisualizationPanel'; | |
| import ActionFeed from './components/ActionFeed'; | |
| import AntennaNotice from './components/AntennaNotice'; | |
| import Header from './components/Header'; | |
| import AppSidebar from './components/AppSidebar'; | |
| import NotificationHost from './components/NotificationHost'; | |
| import type { Notice } from './components/NoticesPanel'; | |
| import SettingsModal from './components/modals/SettingsModal'; | |
| import ReloadSessionModal from './components/modals/ReloadSessionModal'; | |
| import ConfirmationDialog from './components/modals/ConfirmationDialog'; | |
| import type { ConfirmDialogState } from './components/modals/ConfirmationDialog'; | |
| import { api } from './api'; | |
| import type { ActionDetail, ActionOverviewFilters, TabId, MetadataIndex, RecommenderDisplayConfig, UnsimulatedActionScoreInfo } from './types'; | |
| import { useSettings } from './hooks/useSettings'; | |
| import { useActions } from './hooks/useActions'; | |
| import { useAnalysis } from './hooks/useAnalysis'; | |
| import { useDiagrams } from './hooks/useDiagrams'; | |
| import { useSession } from './hooks/useSession'; | |
| import { useDetachedTabs } from './hooks/useDetachedTabs'; | |
| import { useTiedTabsSync, type PZInstance } from './hooks/useTiedTabsSync'; | |
| import { useContingencyFetch } from './hooks/useContingencyFetch'; | |
| import { useDiagramHighlights } from './hooks/useDiagramHighlights'; | |
| import { useManualSimulation } from './hooks/useManualSimulation'; | |
| import { interactionLogger } from './utils/interactionLogger'; | |
| import { gameBridge } from './game/gameBridge'; | |
| import type { GameStudy } from './game/types'; | |
| import { DEFAULT_ACTION_OVERVIEW_FILTERS } from './utils/actionTypes'; | |
| import { apiErrorMessage } from './utils/apiError'; | |
| import { notifyError, notifications } from './utils/notifications'; | |
| import { attachVlInteractions } from './utils/svgUtils'; | |
| import { | |
| buildOverflowPinPayload, | |
| buildOverflowUnsimulatedPinPayload, | |
| } from './utils/svg/overflowPinPayload'; | |
| function App() { | |
| // ===== Settings Hook ===== | |
| const settings = useSettings(); | |
| const { | |
| // Paths and values used in App-level logic (handleApplySettings, handleLoadConfig, wrappedSaveResults/RestoreSession) | |
| configFilePath, changeConfigFilePath, lastActiveConfigFilePath, | |
| networkPath, setNetworkPath, actionPath, setActionPath, | |
| layoutPath, setLayoutPath, outputFolderPath, | |
| minLineReconnections, setMinLineReconnections, | |
| minCloseCoupling, setMinCloseCoupling, | |
| minOpenCoupling, setMinOpenCoupling, | |
| minLineDisconnections, setMinLineDisconnections, | |
| nPrioritizedActions, setNPrioritizedActions, | |
| minPst, setMinPst, | |
| minLoadShedding, setMinLoadShedding, | |
| minRenewableCurtailmentActions, setMinRenewableCurtailmentActions, | |
| minRedispatch, setMinRedispatch, | |
| allowedActionTypes, setAllowedActionTypes, | |
| ignoreReconnections, setIgnoreReconnections, | |
| linesMonitoringPath, setLinesMonitoringPath, | |
| monitoredLinesCount, totalLinesCount, | |
| showMonitoringWarning, setShowMonitoringWarning, | |
| monitoringFactor, setMonitoringFactor, | |
| preExistingOverloadThreshold, setPreExistingOverloadThreshold, | |
| pypowsyblFastMode, setPypowsyblFastMode, | |
| actionDictFileName, actionDictStats, | |
| setIsSettingsOpen, setSettingsTab, | |
| pickSettingsPath, | |
| handleOpenSettings, | |
| recommenderModel, setRecommenderModel, availableModels, | |
| buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup, setSettingsBackup | |
| } = settings; | |
| /** | |
| * Currently APPLIED contingency — ordered list of element IDs to | |
| * disconnect simultaneously. Empty list means N state. Single-item | |
| * list is the legacy N-1 case; longer lists drive N-K studies. | |
| * The user builds the list via ``pendingContingency`` and commits | |
| * it with the Apply button in the Header. | |
| */ | |
| const [selectedContingency, setSelectedContingency] = useState<string[]>([]); | |
| /** | |
| * Pending element IDs the user is composing in the Header before | |
| * pressing Apply. Confirming applies the list to | |
| * ``selectedContingency`` and triggers the diagram fetch. | |
| */ | |
| const [pendingContingency, setPendingContingency] = useState<string[]>([]); | |
| const [branches, setBranches] = useState<string[]>([]); | |
| const [voltageLevels, setVoltageLevels] = useState<string[]>([]); | |
| /** ID → human-readable name for branches (lines + transformers) and VLs. */ | |
| const [nameMap, setNameMap] = useState<Record<string, string>>({}); | |
| /** VL id → substation id. Loaded once after config-load and used to | |
| * anchor the action-overview pins on the overflow graph (whose | |
| * nodes are pypowsybl substation ids). */ | |
| const [vlToSubstation, setVlToSubstation] = useState<Record<string, string>>({}); | |
| /** Whether the user has switched ON the pin overlay on the overflow | |
| * graph. Default OFF; toggle is disabled until Step 2 has produced | |
| * a non-empty `result.actions` map. */ | |
| const [overflowPinsEnabled, setOverflowPinsEnabled] = useState(false); | |
| const [configLoading, setConfigLoading] = useState(false); | |
| // Errors surface through the shared notification store (D5). This | |
| // adapter preserves the historical `setError(msg)` / `setError('')` | |
| // (raise / clear) contract used across App and threaded into hooks. | |
| const setError = useCallback((message: string) => { | |
| if (message) notifyError(message); | |
| else notifications.clearSeverity('error'); | |
| }, []); | |
| /** Resolve an element or VL ID to its display name. Falls back to the ID. */ | |
| const displayName = useCallback((id: string) => nameMap[id] || id, [nameMap]); | |
| // ===== Detached Visualization Tabs (must be instantiated BEFORE useDiagrams | |
| // so that the detached-tabs map can be threaded into useDiagrams → usePanZoom, | |
| // keeping a detached tab interactive even when it's not the main `activeTab`.) | |
| const detachedTabsHook = useDetachedTabs({ | |
| onPopupBlocked: () => setError('Popup blocked by the browser. Please allow popups for this site to detach tabs.'), | |
| }); | |
| const { detachedTabs, detach: detachTab, reattach: reattachTab, focus: focusDetachedTab } = detachedTabsHook; | |
| const diagrams = useDiagrams(branches, voltageLevels, selectedContingency, detachedTabs); | |
| // ===== Action Overview PZ (for tied-tab sync) ===== | |
| // The action overview has its own independent usePanZoom instance | |
| // (it renders the N-1 NAD as a background with pins). We need to | |
| // include it in the tie system so that when the action tab is | |
| // detached and showing the overview (no selectedActionId), zoom / | |
| // focus changes are mirrored to the main window. | |
| // | |
| // This MUST be React state (not a ref) so that when the overview's | |
| // viewBox changes inside ActionOverviewDiagram, the new PZ instance | |
| // propagates up to App via the onPzChange callback, triggering a | |
| // re-render. That re-render updates `actionVb` inside | |
| // useTiedTabsSync's deps, letting it detect the change and mirror | |
| // it to the main window. A ref would silently hold the new value | |
| // without triggering the sync hook — making detached→main sync | |
| // one-directional. | |
| const [overviewPz, setOverviewPz] = useState<PZInstance | null>(null); | |
| const handleOverviewPzChange = useCallback((pz: PZInstance) => { | |
| setOverviewPz(pz); | |
| }, []); | |
| // When the overview is visible (no selected action), use its PZ | |
| // for the 'action' slot in the tie map. Otherwise fall back to | |
| // the action-variant diagram's PZ. | |
| const actionPZForTie = (!diagrams.selectedActionId && overviewPz) | |
| ? overviewPz | |
| : diagrams.actionPZ; | |
| // ===== Tied Detached Tabs ===== | |
| // When a detached tab is "tied", its viewBox is mirrored one-way | |
| // into the main window's active tab on every pan/zoom change — | |
| // supporting side-by-side comparison workflows. See | |
| // docs/features/detachable-viz-tabs.md#tied-detached-tabs for the full | |
| // design rationale. | |
| const tiedTabsHook = useTiedTabsSync( | |
| { 'n': diagrams.nPZ, 'contingency': diagrams.n1PZ, 'action': actionPZForTie }, | |
| diagrams.activeTab, | |
| detachedTabs, | |
| ); | |
| const { isTied: isTabTied, toggleTie: toggleTabTie } = tiedTabsHook; | |
| // Confirmation dialog state for contingency change / load study / | |
| // apply settings / change network path. | |
| const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState>(null); | |
| // Collapsed mode for the left sidebar. When true the sidebar shrinks | |
| // to a thin strip and the visualization panel takes the full width. | |
| // Toggleable from the Clear/Collapse caret rendered by `AppSidebar`. | |
| const [sidebarCollapsed, setSidebarCollapsed] = useState(false); | |
| const handleToggleSidebarCollapsed = useCallback(() => { | |
| setSidebarCollapsed(c => { | |
| const next = !c; | |
| interactionLogger.record('sidebar_collapsed_toggled', { collapsed: next }); | |
| return next; | |
| }); | |
| }, []); | |
| // Path of the network file the currently-loaded study was loaded from. | |
| // Updated after every successful handleLoadConfig / applySettings, used | |
| // by requestNetworkPathChange to detect "user is switching to a | |
| // different network while a study is already loaded" and prompt for | |
| // confirmation before silently dropping the in-flight work. | |
| const committedNetworkPathRef = useRef(''); | |
| // ===== Hook integrations ===== | |
| const actionsHook = useActions(); | |
| const { | |
| selectedActionIds, manuallyAddedIds, rejectedActionIds, suggestedByRecommenderIds | |
| } = actionsHook; | |
| const analysis = useAnalysis(); | |
| const { | |
| result, setResult, pendingAnalysisResult, analysisLoading, | |
| selectedOverloads, monitorDeselected, | |
| additionalLinesToCut, committedAdditionalLinesToCut, | |
| } = analysis; | |
| const { | |
| activeTab, nDiagram, n1Diagram, n1Loading, | |
| selectedActionId, actionDiagram, actionDiagramLoading, actionViewMode, | |
| inspectQuery, uniqueVoltages, voltageRange, | |
| vlOverlay, handleViewModeChange, handleManualZoomIn, handleManualZoomOut, | |
| handleManualReset, handleVlDoubleClick, handleOverlaySldTabChange, handleOverlayClose, | |
| inspectableItems, | |
| nSvgContainerRef, n1SvgContainerRef, actionSvgContainerRef, | |
| showVoltageLevelNames, setShowVoltageLevelNames, | |
| } = diagrams; | |
| // When a pin on the overview is single-clicked we want the sidebar | |
| // action feed to scroll to the matching card without selecting it | |
| // (which would drill into the action-variant view). This counter- | |
| // based state lets ActionFeed react on every click even if the same | |
| // pin is tapped twice in a row (a plain id string would not trigger | |
| // a re-render on the second identical value). | |
| const [scrollTarget, setScrollTarget] = useState<{ id: string; seq: number } | null>(null); | |
| const scrollSeqRef = useRef(0); | |
| const handlePinPreview = useCallback((actionId: string) => { | |
| scrollSeqRef.current += 1; | |
| setScrollTarget({ id: actionId, seq: scrollSeqRef.current }); | |
| }, []); | |
| // Shared filter state for the Remedial Action overview. The same | |
| // `ActionOverviewFilters` drives (a) the pin visibility + dimmed | |
| // un-simulated pins on ActionOverviewDiagram and (b) the card | |
| // visibility in the sidebar ActionFeed, so both views stay in | |
| // lock-step regardless of which entry point the operator uses. | |
| const [overviewFilters, setOverviewFilters] = useState<ActionOverviewFilters>(DEFAULT_ACTION_OVERVIEW_FILTERS); | |
| // Flat list of action ids that appear in `action_scores` but are | |
| // not yet simulated. Feeds ActionOverviewDiagram's un-simulated pin | |
| // layer. We dedupe across action_scores.<type>.scores to avoid | |
| // pinning the same id twice. Computed alongside `unsimulatedActionInfo` | |
| // so the two structures always stay in sync. | |
| const { unsimulatedActionIds, unsimulatedActionInfo } = useMemo(() => { | |
| const scores = analysis.result?.action_scores; | |
| if (!scores) return { unsimulatedActionIds: [] as string[], unsimulatedActionInfo: {} as Record<string, UnsimulatedActionScoreInfo> }; | |
| const simulated = new Set(Object.keys(analysis.result?.actions ?? {})); | |
| const ids: string[] = []; | |
| const info: Record<string, UnsimulatedActionScoreInfo> = {}; | |
| const seen = new Set<string>(); | |
| for (const [type, rawData] of Object.entries(scores)) { | |
| const data = rawData as { | |
| scores?: Record<string, number>; | |
| mw_start?: Record<string, number | null>; | |
| tap_start?: Record<string, { pst_name: string; tap: number; low_tap: number | null; high_tap: number | null } | null>; | |
| }; | |
| const per = data.scores ?? {}; | |
| const mwStartMap = data.mw_start ?? {}; | |
| const tapStartMap = data.tap_start ?? {}; | |
| // Rank is assigned by descending score so the operator sees | |
| // the top-scoring un-simulated candidate as "rank 1". | |
| const rankedEntries = Object.entries(per).sort(([, a], [, b]) => b - a); | |
| const maxScoreInType = rankedEntries.length > 0 ? rankedEntries[0][1] : 0; | |
| const countInType = rankedEntries.length; | |
| for (let i = 0; i < rankedEntries.length; i++) { | |
| const [id, score] = rankedEntries[i]; | |
| if (simulated.has(id) || seen.has(id)) continue; | |
| seen.add(id); | |
| ids.push(id); | |
| info[id] = { | |
| type, | |
| score, | |
| mwStart: mwStartMap[id] ?? null, | |
| tapStart: tapStartMap[id] ?? null, | |
| rankInType: i + 1, | |
| countInType, | |
| maxScoreInType, | |
| }; | |
| } | |
| } | |
| return { unsimulatedActionIds: ids, unsimulatedActionInfo: info }; | |
| }, [analysis.result?.action_scores, analysis.result?.actions]); | |
| const recommenderConfig = useMemo<RecommenderDisplayConfig>(() => ({ | |
| minLineReconnections, minCloseCoupling, minOpenCoupling, | |
| minLineDisconnections, minPst, minLoadShedding, | |
| minRenewableCurtailmentActions, minRedispatch, nPrioritizedActions, ignoreReconnections, | |
| }), [ | |
| minLineReconnections, minCloseCoupling, minOpenCoupling, | |
| minLineDisconnections, minPst, minLoadShedding, | |
| minRenewableCurtailmentActions, minRedispatch, nPrioritizedActions, ignoreReconnections, | |
| ]); | |
| const session = useSession(); | |
| const { | |
| showReloadModal, setShowReloadModal, sessionList, sessionListLoading, sessionRestoring | |
| } = session; | |
| // ===== Detached Visualization Tabs ===== | |
| // `useDetachedTabs` is instantiated higher up so its map can be passed | |
| // into `useDiagrams` (see above). Here we wire the detach/reattach | |
| // callbacks that depend on diagrams (activeTab fallback logic) and | |
| // the interaction logger. | |
| const handleDetachTab = useCallback((tabId: TabId) => { | |
| interactionLogger.record('tab_detached', { tab: tabId }); | |
| const entry = detachTab(tabId); | |
| // If the user detached the currently-active tab, switch the main | |
| // window to any other available tab so the main panel doesn't show | |
| // an empty slot by default. Prefers the first tab that is not itself | |
| // detached; falls back to 'n' (which is always available). | |
| if (entry && diagrams.activeTab === tabId) { | |
| const order: TabId[] = ['n', 'contingency', 'action', 'overflow']; | |
| const fallback = order.find(t => t !== tabId && !detachedTabs[t]); | |
| diagrams.setActiveTab(fallback ?? 'n'); | |
| } | |
| }, [detachTab, diagrams, detachedTabs]); | |
| const handleReattachTab = useCallback((tabId: TabId) => { | |
| interactionLogger.record('tab_reattached', { tab: tabId }); | |
| reattachTab(tabId); | |
| }, [reattachTab]); | |
| // ===== Cross-Hook Wiring wrappers (all memoized) ===== | |
| // Clear all contingency-related analysis state (preserves network/config) | |
| const clearContingencyState = useCallback(() => { | |
| analysis.setResult(null); | |
| analysis.setPendingAnalysisResult(null); | |
| analysis.setSelectedOverloads(new Set()); | |
| analysis.setMonitorDeselected(false); | |
| analysis.setAdditionalLinesToCut(new Set()); | |
| analysis.setCommittedAdditionalLinesToCut(new Set()); | |
| actionsHook.clearActionState(); | |
| diagrams.setSelectedActionId(null); | |
| diagrams.setActionDiagram(null); | |
| // Do NOT reset activeTab to 'n' here — the caller (fetchN1) sets | |
| // it to 'contingency' immediately. Resetting to 'n' interfered with the | |
| // auto-zoom effect on the second contingency change. | |
| diagrams.setVlOverlay(null); | |
| // Fresh contingency / study starts in hierarchical mode so the | |
| // toggle matches the backend's freshly-cleared overflow cache. | |
| diagrams.setOverflowLayoutMode('hierarchical'); | |
| // Clear both notification channels so a failed-analysis error or a | |
| // stale info toast doesn't outlive the contingency it described. | |
| setError(''); | |
| analysis.setInfoMessage(''); | |
| diagrams.setInspectQuery(''); | |
| // Do NOT reset lastZoomState here. Resetting it causes the auto-zoom | |
| // effect to detect a spurious "branch change" during the same render | |
| // cycle in which the old n1Diagram SVG is still mounted, firing the | |
| // zoom on stale data and consuming the intent before the new diagram | |
| // loads. Leaving lastZoomState intact lets the natural selectedContingency | |
| // change trigger the zoom correctly after the new SVG is ready. | |
| }, [setError, actionsHook, analysis, diagrams]); | |
| // Narrower reset used when re-running the analysis on the SAME | |
| // contingency. Unlike `clearContingencyState`, this preserves any | |
| // manually-added ("first guess") actions so they stay in the | |
| // Selected Actions section through the analysis run — mirroring the | |
| // standalone interface, which filters result.actions down to the | |
| // is_manual=true subset on Analyze & Suggest instead of wiping | |
| // everything. | |
| // | |
| // Specifically: keeps manuallyAddedIds, keeps the selected-action | |
| // set restricted to manually-added IDs, and filters result.actions | |
| // to the is_manual subset (with pdf / lines_overloaded cleared so | |
| // the UI correctly shows the "analysis in progress" state). | |
| const resetForAnalysisRun = useCallback(() => { | |
| // Keep entries the operator has invested in across a re-run: | |
| // - manually-added "first guess" actions (`is_manual=true`), | |
| // - starred recommender suggestions (handleActionFavorite stamps | |
| // `is_manual=true` on those too — but the `selectedActionIds` | |
| // trim below also has to keep them for the post-step2 merge | |
| // to recognise them as Selected), | |
| // - rejected recommender suggestions (so the operator's veto | |
| // survives the re-run — the new step2 may re-emit the id but | |
| // the rejected-id set still rules). | |
| // Wipe pdf / lines_overloaded so the UI renders the | |
| // "analysis in progress" state. | |
| analysis.setResult(prev => { | |
| if (!prev) return null; | |
| const kept: Record<string, import('./types').ActionDetail> = {}; | |
| for (const [id, data] of Object.entries(prev.actions || {})) { | |
| const userTouched = | |
| data.is_manual | |
| || actionsHook.selectedActionIds.has(id) | |
| || actionsHook.rejectedActionIds.has(id) | |
| || actionsHook.manuallyAddedIds.has(id); | |
| if (userTouched) kept[id] = data; | |
| } | |
| return { | |
| ...prev, | |
| actions: kept, | |
| lines_overloaded: [], | |
| pdf_url: null, | |
| pdf_path: null, | |
| }; | |
| }); | |
| analysis.setPendingAnalysisResult(null); | |
| analysis.setMonitorDeselected(false); | |
| // Preserve the full starred / rejected sets — only the | |
| // recommender-only suggestions set is wiped (step2 rebuilds it). | |
| actionsHook.setSuggestedByRecommenderIds(new Set()); | |
| // Don't wipe selectedActionId if it points to an action the | |
| // operator has invested in — keeps the variant diagram mounted | |
| // through the re-run. | |
| const sel = diagrams.selectedActionId; | |
| if ( | |
| sel | |
| && !actionsHook.manuallyAddedIds.has(sel) | |
| && !actionsHook.selectedActionIds.has(sel) | |
| ) { | |
| diagrams.setSelectedActionId(null); | |
| diagrams.setActionDiagram(null); | |
| } | |
| diagrams.setVlOverlay(null); | |
| setError(''); | |
| analysis.setInfoMessage(''); | |
| diagrams.setInspectQuery(''); | |
| }, [setError, actionsHook, analysis, diagrams]); | |
| // Full reset: contingency state + network/diagram state | |
| const resetAllState = useCallback(() => { | |
| clearContingencyState(); | |
| diagrams.setActiveTab('n'); | |
| diagrams.setNDiagram(null); | |
| diagrams.setN1Diagram(null); | |
| diagrams.setOriginalViewBox(null); | |
| diagrams.setActionViewMode('network'); | |
| diagrams.setShowVoltageLevelNames(true); | |
| diagrams.setN1Loading(false); | |
| diagrams.setActionDiagramLoading(false); | |
| diagrams.committedBranchRef.current = []; | |
| diagrams.actionSyncSourceRef.current = null; | |
| diagrams.lastZoomState.current = { query: '', branch: '' }; | |
| setSelectedContingency([]); | |
| setPendingContingency([]); | |
| setShowMonitoringWarning(false); | |
| setVlToSubstation({}); | |
| setOverflowPinsEnabled(false); | |
| // Dismiss any in-flight confirmation dialog. A stale "Change | |
| // Contingency?" dialog left over from a prior gesture (or a | |
| // session reload race that briefly flipped the ref window) must | |
| // not survive a fresh reset. | |
| setConfirmDialog(null); | |
| }, [clearContingencyState, diagrams, setShowMonitoringWarning]); | |
| // Sidebar visibility gates. The picker card and the action feed | |
| // flip together at the moment the operator has played a contingency. | |
| // From that point on the sticky banner echoes the contingency + | |
| // overload labels with its own Clear shortcut, so the picker would | |
| // just duplicate state — and the feed becomes useful (Analyze & | |
| // Suggest, manual selection, …). Pre-trigger states keep the picker | |
| // visible and the feed hidden so the sidebar stays focused. | |
| const hasCommittedContingency = selectedContingency.length > 0; | |
| const sidebarSwitchedToFeed = hasCommittedContingency; | |
| // Pre-compute the pin descriptors posted to the overflow-graph | |
| // iframe. Memoised so unrelated re-renders don't churn the iframe | |
| // postMessage. The toggle is gated on Step 2 having delivered at | |
| // least one action — the iframe overlay is otherwise useless. | |
| const overflowPinsAvailable = useMemo( | |
| () => !analysisLoading && !!result?.actions && Object.keys(result.actions).length > 0, | |
| [analysisLoading, result?.actions], | |
| ); | |
| const overflowPins = useMemo( | |
| () => overflowPinsAvailable | |
| ? buildOverflowPinPayload( | |
| result?.actions ?? null, | |
| diagrams.n1MetaIndex ?? null, | |
| vlToSubstation, | |
| monitoringFactor, | |
| selectedActionIds, | |
| rejectedActionIds, | |
| undefined, | |
| overviewFilters, | |
| ) | |
| : [], | |
| [overflowPinsAvailable, result?.actions, diagrams.n1MetaIndex, vlToSubstation, | |
| monitoringFactor, selectedActionIds, rejectedActionIds, overviewFilters], | |
| ); | |
| // Un-simulated overflow pins. Built only when the operator has | |
| // ticked ``Show unsimulated`` in the Action-Overview filter row | |
| // (which is mirrored in the iframe sidebar's filter panel). | |
| // Identical contract to the Action Overview pin layer: | |
| // - dimmed grey pin with '?' label, | |
| // - dblclick triggers a manual simulation rather than the SLD | |
| // drill-down, | |
| // - skipped when the id is already in ``result.actions``. | |
| const overflowUnsimulatedPins = useMemo( | |
| () => (overflowPinsAvailable && overviewFilters?.showUnsimulated) | |
| ? buildOverflowUnsimulatedPinPayload( | |
| unsimulatedActionIds, | |
| new Set(Object.keys(result?.actions ?? {})), | |
| diagrams.n1MetaIndex ?? null, | |
| vlToSubstation, | |
| unsimulatedActionInfo, | |
| undefined, | |
| overviewFilters, | |
| ) | |
| : [], | |
| [overflowPinsAvailable, overviewFilters, | |
| unsimulatedActionIds, unsimulatedActionInfo, | |
| result?.actions, diagrams.n1MetaIndex, vlToSubstation], | |
| ); | |
| // Pin payload posted to the iframe is the union of simulated + | |
| // un-simulated pins. The overlay differentiates them via the | |
| // ``unsimulated`` flag on each pin. | |
| const allOverflowPins = useMemo( | |
| () => [...overflowPins, ...overflowUnsimulatedPins], | |
| [overflowPins, overflowUnsimulatedPins], | |
| ); | |
| // Auto-disable the toggle when the gate goes away (e.g. user | |
| // applied new settings, which clears the result). Without this, | |
| // the toggle would stay ON but the toolbar would show 'OFF' style | |
| // because `overflowPinsAvailable` is false. | |
| useEffect(() => { | |
| if (!overflowPinsAvailable && overflowPinsEnabled) { | |
| setOverflowPinsEnabled(false); | |
| } | |
| }, [overflowPinsAvailable, overflowPinsEnabled]); | |
| const wrappedActionSelect = useCallback( | |
| (actionId: string | null) => | |
| diagrams.handleActionSelect(actionId, result, selectedContingency, voltageLevels.length, setResult, setError), | |
| [diagrams, result, selectedContingency, voltageLevels.length, setResult, setError] | |
| ); | |
| // Overflow Analysis tab's Hierarchical / Geo toggle — the hook's | |
| // handler needs `setResult` / `setError` to merge the new | |
| // pdf_url back into `analysisHook.result`, same pattern as | |
| // `handleActionSelect` above. | |
| const wrappedOverflowLayoutChange = useCallback( | |
| (mode: 'hierarchical' | 'geo') => | |
| diagrams.handleOverflowLayoutChange(mode, setResult, setError), | |
| [diagrams, setResult, setError] | |
| ); | |
| // Force-select variant used after a (re)simulation. This skips the | |
| // "already selected → deselect" toggle path in handleActionSelect so the | |
| // newly-simulated action diagram is always re-fetched. | |
| const wrappedForcedActionSelect = useCallback( | |
| (actionId: string | null) => | |
| diagrams.handleActionSelect(actionId, result, selectedContingency, voltageLevels.length, setResult, setError, true), | |
| [diagrams, result, selectedContingency, voltageLevels.length, setResult, setError] | |
| ); | |
| const wrappedActionFavorite = useCallback( | |
| (actionId: string) => { | |
| // Game Mode caps the number of committed (starred) actions per study. | |
| // Block starring a *new* action once the cap is reached; un-starring | |
| // and re-starring an existing pick stays allowed. | |
| if ( | |
| !actionsHook.selectedActionIds.has(actionId) && | |
| !gameBridge.canStarAnotherAction(actionsHook.selectedActionIds.size) | |
| ) { | |
| setError( | |
| `Game Mode: at most ${gameBridge.getMaxActions()} actions per study — un-star one to pick another.`, | |
| ); | |
| return; | |
| } | |
| actionsHook.handleActionFavorite(actionId, setResult); | |
| }, | |
| [actionsHook, setResult, setError] | |
| ); | |
| // Manually-added (first-time simulated) action. Same SLD refresh | |
| // rationale as `wrappedActionResimulated` below: the new detail | |
| // carries fresh `load_shedding_details` / `curtailment_details` / | |
| // `pst_details` arrays which the SLD highlight pass needs to see. | |
| const wrappedManualActionAdded = useCallback( | |
| (actionId: string, detail: ActionDetail, linesOverloaded: string[], origin: string = 'user') => { | |
| actionsHook.handleManualActionAdded(actionId, detail, linesOverloaded, setResult, wrappedForcedActionSelect, origin); | |
| diagrams.refreshSldIfAction(actionId); | |
| }, | |
| [actionsHook, setResult, wrappedForcedActionSelect, diagrams] | |
| ); | |
| // Manual-simulation flows (double-click an un-simulated overview pin + | |
| // the interactive SLD topology/injection edit) live in | |
| // useManualSimulation (D4). App owns the collaborators and passes them | |
| // in; the hook returns the SLD-edit state + the two simulate handlers | |
| // the JSX below consumes. | |
| const { | |
| sldTopologyEdit, | |
| sldEditBusy, | |
| sldEditBaseActionId, | |
| sldPreview, | |
| sldPreviewLoading, | |
| handleSimulateUnsimulatedAction, | |
| handleSimulateSldEdit, | |
| } = useManualSimulation({ | |
| diagrams, | |
| selectedContingency, | |
| result, | |
| voltageLevels, | |
| wrappedManualActionAdded, | |
| setError, | |
| }); | |
| // Re-simulation of an already-present action (edit Target MW / tap on a | |
| // suggested card). Does NOT move the action into the selected bucket. | |
| // | |
| // When the SLD overlay is open on this action, refresh it so the | |
| // per-equipment load-shedding / curtailment / PST highlights (and the | |
| // flow deltas baked into the backend SLD response) reflect the new | |
| // simulation result instead of the pre-resimulation snapshot. | |
| // Covers all three editable action families: MW edits on load-shedding | |
| // and renewable-curtailment, and tap edits on PST — all three flow | |
| // through `onActionResimulated` in ActionFeed.tsx, so one refresh | |
| // hook-up covers them. | |
| const wrappedActionResimulated = useCallback( | |
| (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => { | |
| actionsHook.handleActionResimulated(actionId, detail, linesOverloaded, setResult, wrappedForcedActionSelect); | |
| diagrams.refreshSldIfAction(actionId); | |
| }, | |
| [actionsHook, setResult, wrappedForcedActionSelect, diagrams] | |
| ); | |
| const handleUpdateCombinedEstimation = useCallback( | |
| (pairId: string, estimation: { estimated_max_rho: number; estimated_max_rho_line: string }) => { | |
| console.log('[handleUpdateCombinedEstimation] called with pairId:', pairId, 'estimation:', estimation); | |
| setResult(prev => { | |
| console.log('[handleUpdateCombinedEstimation] prev combined_actions keys:', | |
| prev?.combined_actions ? Object.keys(prev.combined_actions) : 'null', | |
| 'pairId exists:', !!prev?.combined_actions?.[pairId]); | |
| if (!prev?.combined_actions?.[pairId]) return prev; | |
| return { | |
| ...prev, | |
| combined_actions: { | |
| ...prev.combined_actions, | |
| [pairId]: { ...prev.combined_actions[pairId], ...estimation }, | |
| }, | |
| }; | |
| }); | |
| }, | |
| [setResult] | |
| ); | |
| const wrappedRunAnalysis = useCallback( | |
| () => analysis.handleRunAnalysis(selectedContingency, resetForAnalysisRun, actionsHook.setSuggestedByRecommenderIds, diagrams.setActiveTab), | |
| [analysis, selectedContingency, resetForAnalysisRun, actionsHook.setSuggestedByRecommenderIds, diagrams.setActiveTab] | |
| ); | |
| // Wipe recommender-produced suggestions the operator has NOT | |
| // interacted with. Keeps starred (selectedActionIds), rejected | |
| // (rejectedActionIds) and manually-added (manuallyAddedIds / | |
| // is_manual) entries intact so the user can re-run with a | |
| // different model without losing their decisions. Tracking | |
| // ``suggestedByRecommenderIds`` (the source-of-truth set populated | |
| // during the step-2 stream) keeps us from accidentally dropping | |
| // manual-only entries that happen to share an id with a previous | |
| // recommender suggestion. This does NOT re-run the analysis — the | |
| // operator clears, optionally swaps the model, then presses | |
| // Analyze & Suggest themselves. | |
| const performClearSuggested = useCallback(() => { | |
| interactionLogger.record('suggested_actions_cleared', { | |
| n_cleared: Array.from(suggestedByRecommenderIds).filter(id => | |
| !selectedActionIds.has(id) && !rejectedActionIds.has(id) && !manuallyAddedIds.has(id) | |
| ).length, | |
| }); | |
| setResult(prev => { | |
| if (!prev?.actions) return prev; | |
| const filtered: Record<string, import('./types').ActionDetail> = {}; | |
| for (const [id, data] of Object.entries(prev.actions)) { | |
| const userTouched = selectedActionIds.has(id) || rejectedActionIds.has(id) || manuallyAddedIds.has(id) || data.is_manual; | |
| const isSuggested = suggestedByRecommenderIds.has(id); | |
| if (userTouched || !isSuggested) filtered[id] = data; | |
| } | |
| return { ...prev, actions: filtered, active_model: undefined }; | |
| }); | |
| actionsHook.setSuggestedByRecommenderIds(prev => { | |
| const next = new Set<string>(); | |
| for (const id of prev) { | |
| if (selectedActionIds.has(id) || rejectedActionIds.has(id) || manuallyAddedIds.has(id)) next.add(id); | |
| } | |
| return next; | |
| }); | |
| analysis.setPendingAnalysisResult(null); | |
| }, [setResult, actionsHook, analysis, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds]); | |
| // The Clear button opens a confirmation dialog first (reusing the | |
| // shared <ConfirmationDialog/> template) so the operator sees | |
| // exactly what is removed and what is kept before committing. | |
| const requestClearSuggested = useCallback(() => { | |
| setConfirmDialog({ type: 'clearSuggested' }); | |
| }, []); | |
| const wrappedDisplayPrioritized = useCallback( | |
| () => analysis.handleDisplayPrioritizedActions(selectedActionIds, diagrams.setActiveTab), | |
| [analysis, selectedActionIds, diagrams.setActiveTab] | |
| ); | |
| const wrappedAssetClick = useCallback( | |
| (actionId: string, assetName: string, tab: 'action' | 'n' | 'contingency' = 'action') => | |
| diagrams.handleAssetClick(actionId, assetName, tab, diagrams.selectedActionId, wrappedActionSelect), | |
| [diagrams, wrappedActionSelect] | |
| ); | |
| // Zoom the currently-active diagram tab on a named asset without | |
| // switching tabs. Used by the sticky Contingency and Overloads | |
| // sections: operators want to keep the view they're on (N / N-1 / | |
| // Action) and just focus the clicked line in place. | |
| const handleZoomOnActiveTab = useCallback((assetName: string) => { | |
| if (!assetName) return; | |
| const tab = diagrams.activeTab; | |
| if (tab === 'overflow') return; | |
| interactionLogger.record('asset_clicked', { action_id: '', asset_name: assetName, tab }); | |
| // Update inspectQuery (so the inspect overlay, if open, reflects | |
| // the focus) AND call zoomToElement directly — the auto-zoom effect | |
| // skips no-op query changes, whereas we want re-clicking the same | |
| // line to re-center the view. | |
| diagrams.setInspectQueryForTab(tab, assetName); | |
| diagrams.zoomToElement(assetName, tab); | |
| }, [diagrams]); | |
| const saveParams = useMemo(() => ({ | |
| networkPath, actionPath, layoutPath, outputFolderPath, | |
| minLineReconnections, minCloseCoupling, minOpenCoupling, | |
| minLineDisconnections, minPst, minLoadShedding, | |
| minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, | |
| linesMonitoringPath, monitoringFactor, | |
| preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, | |
| selectedBranch: selectedContingency.join('+'), | |
| selectedContingency, | |
| selectedOverloads, monitorDeselected, | |
| committedAdditionalLinesToCut, | |
| nOverloads: nDiagram?.lines_overloaded ?? [], | |
| n1Overloads: n1Diagram?.lines_overloaded ?? [], | |
| nOverloadsRho: nDiagram?.lines_overloaded_rho, | |
| n1OverloadsRho: n1Diagram?.lines_overloaded_rho, | |
| result, selectedActionIds, rejectedActionIds, | |
| manuallyAddedIds, suggestedByRecommenderIds, | |
| setError, | |
| }), [ | |
| networkPath, actionPath, layoutPath, outputFolderPath, | |
| minLineReconnections, minCloseCoupling, minOpenCoupling, | |
| minLineDisconnections, minPst, minLoadShedding, | |
| minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, | |
| linesMonitoringPath, monitoringFactor, | |
| preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, | |
| selectedContingency, selectedOverloads, monitorDeselected, | |
| committedAdditionalLinesToCut, | |
| nDiagram, n1Diagram, | |
| result, selectedActionIds, rejectedActionIds, | |
| manuallyAddedIds, suggestedByRecommenderIds, | |
| setError, | |
| ]); | |
| const wrappedSaveResults = useCallback( | |
| () => session.handleSaveResults(saveParams), | |
| [session, saveParams] | |
| ); | |
| const wrappedOpenReloadModal = useCallback( | |
| () => session.handleOpenReloadModal(outputFolderPath, setError), | |
| [session, outputFolderPath, setError] | |
| ); | |
| const restoreContext = useMemo(() => ({ | |
| outputFolderPath, | |
| setNetworkPath, setActionPath, setLayoutPath, | |
| setMinLineReconnections, setMinCloseCoupling, setMinOpenCoupling, | |
| setMinLineDisconnections, setMinPst, setMinLoadShedding, | |
| setMinRenewableCurtailmentActions, setMinRedispatch, setAllowedActionTypes, setNPrioritizedActions, | |
| setLinesMonitoringPath, setMonitoringFactor, setPreExistingOverloadThreshold, | |
| setIgnoreReconnections, setPypowsyblFastMode, | |
| setMonitorDeselected: analysis.setMonitorDeselected, | |
| setSelectedOverloads: analysis.setSelectedOverloads, | |
| setCommittedAdditionalLinesToCut: analysis.setCommittedAdditionalLinesToCut, | |
| setResult, | |
| setSelectedActionIds: actionsHook.setSelectedActionIds, | |
| setRejectedActionIds: actionsHook.setRejectedActionIds, | |
| setManuallyAddedIds: actionsHook.setManuallyAddedIds, | |
| setSuggestedByRecommenderIds: actionsHook.setSuggestedByRecommenderIds, | |
| setSelectedContingency, | |
| setPendingContingency, | |
| resetAllState, | |
| restoringSessionRef: diagrams.restoringSessionRef, | |
| committedBranchRef: diagrams.committedBranchRef, | |
| committedNetworkPathRef, | |
| setError, | |
| applyConfigResponse, setBranches, setVoltageLevels, setNameMap, | |
| setNominalVoltageMap: diagrams.setNominalVoltageMap, | |
| setUniqueVoltages: diagrams.setUniqueVoltages, | |
| fetchBaseDiagram: diagrams.fetchBaseDiagram, | |
| ingestBaseDiagram: diagrams.ingestBaseDiagram, | |
| setVoltageRange: diagrams.setVoltageRange, | |
| }), [ | |
| outputFolderPath, | |
| setNetworkPath, setActionPath, setLayoutPath, | |
| setMinLineReconnections, setMinCloseCoupling, setMinOpenCoupling, | |
| setMinLineDisconnections, setMinPst, setMinLoadShedding, | |
| setMinRenewableCurtailmentActions, setMinRedispatch, setAllowedActionTypes, setNPrioritizedActions, | |
| setLinesMonitoringPath, setMonitoringFactor, setPreExistingOverloadThreshold, | |
| setIgnoreReconnections, setPypowsyblFastMode, | |
| analysis, actionsHook, setResult, setSelectedContingency, setPendingContingency, resetAllState, | |
| diagrams, setError, applyConfigResponse, setBranches, setVoltageLevels, setNameMap, | |
| ]); | |
| const wrappedRestoreSession = useCallback( | |
| (sessionName: string) => session.handleRestoreSession(sessionName, restoreContext), | |
| [session, restoreContext] | |
| ); | |
| // Check if there is any analysis state that would be lost on contingency change | |
| const hasAnalysisState = useCallback(() => { | |
| return !!(result || pendingAnalysisResult || selectedActionId || actionDiagram || manuallyAddedIds.size > 0 || selectedActionIds.size > 0 || rejectedActionIds.size > 0); | |
| }, [result, pendingAnalysisResult, selectedActionId, actionDiagram, manuallyAddedIds, selectedActionIds, rejectedActionIds]); | |
| // Sidebar-banner Clear button: drops the current contingency and | |
| // returns to the picker. Routes through the contingency confirmation | |
| // dialog when analysis state would otherwise be lost; clears | |
| // directly otherwise (mirroring the picker-driven flow's gating). | |
| const requestClearContingency = useCallback(() => { | |
| interactionLogger.record('contingency_clear_requested', { | |
| had_analysis_state: hasAnalysisState(), | |
| }); | |
| if (hasAnalysisState()) { | |
| setConfirmDialog({ type: 'contingency', pendingBranch: '' }); | |
| return; | |
| } | |
| clearContingencyState(); | |
| setSelectedContingency([]); | |
| setPendingContingency([]); | |
| }, [hasAnalysisState, clearContingencyState]); | |
| // Full-fidelity snapshot of every parameter an agent would need to | |
| // replay a config-loaded / settings-applied gesture. Per the | |
| // interaction-logging replay contract each event must carry ALL | |
| // inputs — "click Load Study" alone is not enough, the agent has | |
| // to know which paths and recommender thresholds to type in first. | |
| const buildConfigInteractionDetails = useCallback((): Record<string, unknown> => ({ | |
| network_path: networkPath, | |
| action_file_path: actionPath, | |
| layout_path: layoutPath, | |
| output_folder_path: outputFolderPath, | |
| min_line_reconnections: minLineReconnections, | |
| min_close_coupling: minCloseCoupling, | |
| min_open_coupling: minOpenCoupling, | |
| min_line_disconnections: minLineDisconnections, | |
| min_pst: minPst, | |
| min_load_shedding: minLoadShedding, | |
| min_renewable_curtailment_actions: minRenewableCurtailmentActions, | |
| min_redispatch: minRedispatch, | |
| allowed_action_types: allowedActionTypes, | |
| n_prioritized_actions: nPrioritizedActions, | |
| lines_monitoring_path: linesMonitoringPath, | |
| monitoring_factor: monitoringFactor, | |
| pre_existing_overload_threshold: preExistingOverloadThreshold, | |
| ignore_reconnections: ignoreReconnections, | |
| pypowsybl_fast_mode: pypowsyblFastMode, | |
| }), [ | |
| networkPath, actionPath, layoutPath, outputFolderPath, | |
| minLineReconnections, minCloseCoupling, minOpenCoupling, | |
| minLineDisconnections, minPst, minLoadShedding, | |
| minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, | |
| linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, | |
| ignoreReconnections, pypowsyblFastMode, | |
| ]); | |
| const applySettingsImmediate = useCallback(async () => { | |
| // settings_applied carries the full settings payload so a replay | |
| // agent can populate every field before clicking Apply. It's | |
| // treated as a wait-point by consumers of the log: the next | |
| // action must wait until the network reload (network, branches, | |
| // voltage levels) has finished. | |
| interactionLogger.record('settings_applied', buildConfigInteractionDetails()); | |
| try { | |
| resetAllState(); | |
| if (!networkPath || !actionPath) { | |
| setSettingsBackup(createCurrentBackup()); | |
| setIsSettingsOpen(false); | |
| return; | |
| } | |
| // If the config file path changed, load the new file FIRST and use | |
| // the resolved `UserConfig` directly to drive `api.updateConfig` — | |
| // the React state queued by `applyLoadedConfig` inside | |
| // `changeConfigFilePath` has not flushed yet, so reading via | |
| // `buildConfigRequest()` here would replay the previous render's | |
| // values and silently send the OLD config to the backend (which | |
| // the auto-save effect would then persist back into the loaded | |
| // file, undoing the operator's selection — see the regression | |
| // test in `configUpload.repro.test.tsx`, fixed 2026-05-08). | |
| let freshlyLoadedCfg: import('./api').UserConfig | null = null; | |
| if (configFilePath && configFilePath !== lastActiveConfigFilePath) { | |
| freshlyLoadedCfg = await changeConfigFilePath(configFilePath); | |
| } | |
| const configRequest = freshlyLoadedCfg | |
| ? configRequestFromUserConfig(freshlyLoadedCfg) | |
| : buildConfigRequest(); | |
| const configRes = await api.updateConfig(configRequest); | |
| applyConfigResponse(configRes as Record<string, unknown>); | |
| // Fire the 4 post-config XHRs in parallel. The base-diagram call is | |
| // the slowest (~6-7s pypowsybl NAD on large grids) and previously | |
| // only started after branches resolved — wasting the ~0.8s branches | |
| // gap off the critical path of the initial load. | |
| // See docs/performance/history/loading-parallel.md. | |
| const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ | |
| api.getBranches(), | |
| api.getVoltageLevels(), | |
| api.getNominalVoltages(), | |
| api.getNetworkDiagram(), | |
| // Cheap query (~1 ms even on PyPSA-EUR France); pulled in | |
| // parallel so it never extends the critical path. Used to | |
| // anchor overflow-graph action pins on substations. | |
| api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record<string, string> })), | |
| ]); | |
| setBranches(branchRes.branches); | |
| setVoltageLevels(vlRes.voltage_levels); | |
| // Merge element + VL name maps into a single lookup | |
| setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); | |
| setSelectedContingency([]); | |
| setPendingContingency([]); | |
| diagrams.setNominalVoltageMap(nomVRes.mapping); | |
| diagrams.setUniqueVoltages(nomVRes.unique_kv); | |
| if (nomVRes.unique_kv.length > 0) { | |
| diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); | |
| } | |
| diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); | |
| setVlToSubstation(vlSubRes.mapping || {}); | |
| committedNetworkPathRef.current = networkPath; | |
| interactionLogger.record('config_loaded', buildConfigInteractionDetails()); | |
| setSettingsBackup(createCurrentBackup()); | |
| setIsSettingsOpen(false); | |
| } catch (err: unknown) { | |
| const e = err as { response?: { data?: { detail?: string } }; message?: string }; | |
| setError('Failed to apply settings: ' + apiErrorMessage(e)); | |
| } | |
| }, [networkPath, actionPath, buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup, setError, setSettingsBackup, setIsSettingsOpen, diagrams, configFilePath, lastActiveConfigFilePath, changeConfigFilePath, resetAllState, buildConfigInteractionDetails]); | |
| // Apply Settings entry point used by the Settings modal. If a study | |
| // is already loaded — whether or not analysis has been run yet — we | |
| // route through the same confirmation dialog as the "Load Study" | |
| // button. Applying any settings (in particular changing the config | |
| // file path) silently reloads the network and drops the in-flight | |
| // study, so the user must be warned even when only a base network | |
| // is loaded with no analysis state. | |
| const handleApplySettingsClick = useCallback(() => { | |
| if (hasAnalysisState() || committedNetworkPathRef.current) { | |
| setConfirmDialog({ type: 'applySettings' }); | |
| return; | |
| } | |
| applySettingsImmediate(); | |
| }, [hasAnalysisState, applySettingsImmediate]); | |
| const handleLoadConfig = useCallback(async () => { | |
| setConfigLoading(true); | |
| resetAllState(); | |
| try { | |
| // Same stale-closure trap as applySettingsImmediate — see the long | |
| // comment there. After `changeConfigFilePath` resolves, the fresh | |
| // `UserConfig` it returns is the source of truth for | |
| // `api.updateConfig`; `buildConfigRequest()` would silently replay | |
| // the previous render's React state. | |
| let freshlyLoadedCfg: import('./api').UserConfig | null = null; | |
| if (configFilePath && configFilePath !== lastActiveConfigFilePath) { | |
| freshlyLoadedCfg = await changeConfigFilePath(configFilePath); | |
| } | |
| const configRequest = freshlyLoadedCfg | |
| ? configRequestFromUserConfig(freshlyLoadedCfg) | |
| : buildConfigRequest(); | |
| const configRes = await api.updateConfig(configRequest); | |
| applyConfigResponse(configRes as Record<string, unknown>); | |
| // See the sibling call site in `applySettingsImmediate` for context: | |
| // fire 4 XHRs in parallel so the slow base-diagram call overlaps | |
| // with branches/voltage-levels/nominal-voltages. | |
| const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ | |
| api.getBranches(), | |
| api.getVoltageLevels(), | |
| api.getNominalVoltages(), | |
| api.getNetworkDiagram(), | |
| // Cheap query (~1 ms even on PyPSA-EUR France); pulled in | |
| // parallel so it never extends the critical path. Used to | |
| // anchor overflow-graph action pins on substations. | |
| api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record<string, string> })), | |
| ]); | |
| setBranches(branchRes.branches); | |
| setVoltageLevels(vlRes.voltage_levels); | |
| setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); | |
| setSelectedContingency([]); | |
| setPendingContingency([]); | |
| diagrams.setNominalVoltageMap(nomVRes.mapping); | |
| diagrams.setUniqueVoltages(nomVRes.unique_kv); | |
| if (nomVRes.unique_kv.length > 0) { | |
| diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); | |
| } | |
| diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); | |
| setVlToSubstation(vlSubRes.mapping || {}); | |
| committedNetworkPathRef.current = networkPath; | |
| interactionLogger.record('config_loaded', buildConfigInteractionDetails()); | |
| } catch (err: unknown) { | |
| const e = err as { response?: { data?: { detail?: string } }; message?: string }; | |
| setError('Failed to load config: ' + apiErrorMessage(e)); | |
| } finally { | |
| setConfigLoading(false); | |
| } | |
| }, [buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, setError, diagrams, networkPath, configFilePath, lastActiveConfigFilePath, changeConfigFilePath, resetAllState, buildConfigInteractionDetails]); | |
| const handleLoadStudyClick = useCallback(() => { | |
| if (hasAnalysisState()) { | |
| setConfirmDialog({ type: 'loadStudy' }); | |
| } else { | |
| handleLoadConfig(); | |
| } | |
| }, [hasAnalysisState, handleLoadConfig]); | |
| // Network path commit pipeline used by the Header (file picker AND | |
| // input blur). When a study is already loaded and the path is being | |
| // changed to a different value, prompt for confirmation before | |
| // silently dropping the in-flight study. The setNetworkPath call is | |
| // optimistic — it makes the input immediately reflect the new path | |
| // even while the dialog is open — and is reverted by | |
| // handleCancelDialog if the user backs out. | |
| const requestNetworkPathChange = useCallback((newPath: string) => { | |
| setNetworkPath(newPath); | |
| const trimmed = newPath.trim(); | |
| if (!trimmed) return; | |
| if (trimmed === committedNetworkPathRef.current) return; | |
| // Only warn once a study has actually been loaded — initial path | |
| // entry on an empty session must not trigger the dialog. | |
| if (!committedNetworkPathRef.current) return; | |
| setConfirmDialog({ type: 'changeNetwork', pendingNetworkPath: trimmed }); | |
| }, [setNetworkPath]); | |
| const handleConfirmDialog = useCallback(() => { | |
| if (!confirmDialog) return; | |
| // `clearSuggested` is not a study-reset gesture — it keeps the | |
| // network, the contingency, and the operator's decisions. It logs | |
| // its own `suggested_actions_cleared` event inside | |
| // `performClearSuggested`, so skip the `contingency_confirmed` log. | |
| if (confirmDialog.type === 'clearSuggested') { | |
| performClearSuggested(); | |
| setConfirmDialog(null); | |
| return; | |
| } | |
| interactionLogger.record('contingency_confirmed', { type: confirmDialog.type, pending_branch: confirmDialog.pendingBranch }); | |
| if (confirmDialog.type === 'contingency') { | |
| clearContingencyState(); | |
| const pending = confirmDialog.pendingBranch | |
| ? confirmDialog.pendingBranch.split('+').filter(Boolean) | |
| : []; | |
| setSelectedContingency(pending); | |
| setPendingContingency(pending); | |
| } else if (confirmDialog.type === 'applySettings') { | |
| applySettingsImmediate(); | |
| } else if (confirmDialog.type === 'changeNetwork') { | |
| // pendingNetworkPath was already setNetworkPath'd by | |
| // requestNetworkPathChange. Reload the config so the backend | |
| // picks up the new file. | |
| handleLoadConfig(); | |
| } else { | |
| handleLoadConfig(); | |
| } | |
| setConfirmDialog(null); | |
| }, [confirmDialog, clearContingencyState, handleLoadConfig, applySettingsImmediate, performClearSuggested]); | |
| // ===== App-Level Effects ===== | |
| useEffect(() => { | |
| diagrams.selectedContingencyForSld.current = selectedContingency; | |
| }, [selectedContingency, diagrams.selectedContingencyForSld]); | |
| useContingencyFetch({ | |
| selectedContingency, | |
| branches, | |
| voltageLevelsLength: voltageLevels.length, | |
| diagrams, | |
| analysisLoading, | |
| hasAnalysisState, | |
| clearContingencyState, | |
| setSelectedContingency, | |
| setConfirmDialog, | |
| setError, | |
| }); | |
| // ===== Game Mode integration ===== | |
| // Drives the workspace from the Game shell (load a study's network + | |
| // contingency) and publishes the physical result back so the shell can | |
| // score it. The whole block is inert unless launched with `?game=1`. | |
| // Load a study: swap network + action catalogue, then arm its contingency. | |
| // Mirrors handleLoadConfig but builds the config request explicitly from | |
| // the study so it doesn't depend on not-yet-flushed settings state. | |
| const loadGameStudy = useCallback(async (study: GameStudy) => { | |
| setConfigLoading(true); | |
| resetAllState(); | |
| // Reflect the study paths in Settings so save/persist stays coherent. | |
| setNetworkPath(study.networkPath); | |
| setActionPath(study.actionFilePath); | |
| if (study.layoutPath !== undefined) setLayoutPath(study.layoutPath); | |
| if (study.linesMonitoringPath !== undefined) setLinesMonitoringPath(study.linesMonitoringPath); | |
| try { | |
| // Recommender settings (per-type minima, model, monitoring factor, …) | |
| // must come from the active persisted environment config, NOT the | |
| // in-memory `useSettings` state. GameShell mounts <App/> and fires this | |
| // loader before useSettings' async `getUserConfig()` effect has resolved, | |
| // so `buildConfigRequest()` here would replay the hardcoded defaults | |
| // (min_redispatch=0, n_prioritized_actions=10, allowed_action_types=[], …) | |
| // and the environment's real config (e.g. min_redispatch=2) would be | |
| // silently dropped — the same stale-closure trap `handleLoadConfig` | |
| // guards against. The study only dictates the network / action / layout | |
| // PATHS + contingency; everything else comes from the env config. | |
| const freshCfg = await api.getUserConfig().catch(() => null); | |
| const baseConfig = freshCfg | |
| ? configRequestFromUserConfig(freshCfg) | |
| : buildConfigRequest(); | |
| const configRequest = { | |
| ...baseConfig, | |
| network_path: study.networkPath, | |
| action_file_path: study.actionFilePath, | |
| layout_path: study.layoutPath ?? '', | |
| ...(study.linesMonitoringPath !== undefined | |
| ? { lines_monitoring_path: study.linesMonitoringPath } | |
| : {}), | |
| }; | |
| const configRes = await api.updateConfig(configRequest); | |
| applyConfigResponse(configRes as Record<string, unknown>); | |
| const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ | |
| api.getBranches(), | |
| api.getVoltageLevels(), | |
| api.getNominalVoltages(), | |
| api.getNetworkDiagram(), | |
| api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record<string, string> })), | |
| ]); | |
| setBranches(branchRes.branches); | |
| setVoltageLevels(vlRes.voltage_levels); | |
| setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); | |
| setPendingContingency([]); | |
| diagrams.setNominalVoltageMap(nomVRes.mapping); | |
| diagrams.setUniqueVoltages(nomVRes.unique_kv); | |
| if (nomVRes.unique_kv.length > 0) { | |
| diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); | |
| } | |
| diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); | |
| setVlToSubstation(vlSubRes.mapping || {}); | |
| committedNetworkPathRef.current = study.networkPath; | |
| interactionLogger.record('config_loaded', buildConfigInteractionDetails()); | |
| // Arm the contingency — useContingencyFetch picks this up and fetches | |
| // the N-1 diagram (no analysis state yet, so no confirm dialog). | |
| if (study.contingencyElementId) { | |
| setSelectedContingency([study.contingencyElementId]); | |
| } | |
| } catch (err: unknown) { | |
| const e = err as { response?: { data?: { detail?: string } }; message?: string }; | |
| const msg = 'Failed to load study: ' + apiErrorMessage(e); | |
| setError(msg); | |
| throw new Error(msg); | |
| } finally { | |
| setConfigLoading(false); | |
| } | |
| }, [ | |
| buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, | |
| resetAllState, diagrams, | |
| setNetworkPath, setActionPath, setLayoutPath, setLinesMonitoringPath, | |
| setBranches, setVoltageLevels, setNameMap, setPendingContingency, | |
| setVlToSubstation, setSelectedContingency, setConfigLoading, setError, | |
| buildConfigInteractionDetails, | |
| ]); | |
| useEffect(() => { | |
| if (!gameBridge.isGameMode()) return; | |
| gameBridge.registerLoader(loadGameStudy); | |
| }, [loadGameStudy]); | |
| // Publish the physical result of the current study to the Game shell. | |
| useEffect(() => { | |
| if (!gameBridge.isGameMode()) return; | |
| const chosenActions = [...selectedActionIds].map((id) => { | |
| const d = result?.actions[id]; | |
| const maxRho = d?.max_rho ?? null; | |
| const after = d?.lines_overloaded_after; | |
| const solved = maxRho != null && maxRho < 1.0 && (!after || after.length === 0); | |
| return { | |
| actionId: id, | |
| description: d?.description_unitaire, | |
| maxRho, | |
| linesOverloadedAfter: after, | |
| solved, | |
| }; | |
| }); | |
| const rhoArr = n1Diagram?.lines_overloaded_rho; | |
| const baselineMaxRho = rhoArr && rhoArr.length ? Math.max(...rhoArr) : null; | |
| gameBridge.publishSnapshot({ | |
| contingencyElementIds: selectedContingency, | |
| baselineMaxRho, | |
| chosenActions, | |
| }); | |
| }, [result, selectedActionIds, selectedContingency, n1Diagram]); | |
| // Re-seed selectedOverloads with the full N-1 overload list only when a | |
| // new n1Diagram is loaded. Comparing against the live selectedOverloads | |
| // would clobber user-initiated double-click unselects: the analysis memo | |
| // refreshes on every toggle, retriggering this effect and re-adding the | |
| // overload the user just removed. | |
| const prevN1DiagramRef = useRef<typeof n1Diagram>(null); | |
| useEffect(() => { | |
| if (prevN1DiagramRef.current === n1Diagram) return; | |
| prevN1DiagramRef.current = n1Diagram; | |
| const nextSet = n1Diagram?.lines_overloaded ? new Set(n1Diagram.lines_overloaded) : new Set<string>(); | |
| analysis.setSelectedOverloads(nextSet); | |
| }, [n1Diagram, analysisLoading, n1Loading, analysis]); | |
| const { viewModeForTab, handleViewModeChangeForTab } = useDiagramHighlights({ | |
| diagrams, | |
| result, | |
| selectedContingency, | |
| selectedOverloads, | |
| monitoringFactor, | |
| detachedTabs, | |
| }); | |
| // ===== Extracted JSX callbacks (stable references for React.memo) ===== | |
| /** | |
| * Replace the pending list with whatever the multi-select dropdown | |
| * currently shows. We diff against the previous pending list to | |
| * emit a single ``contingency_element_added`` / | |
| * ``contingency_element_removed`` event per change so the | |
| * interaction log stays replay-friendly even when the user picks | |
| * several elements before pressing Apply. | |
| */ | |
| const handlePendingContingencyChange = useCallback((next: string[]) => { | |
| const prev = pendingContingency; | |
| const prevSet = new Set(prev); | |
| const nextSet = new Set(next); | |
| for (const id of next) { | |
| if (!prevSet.has(id)) interactionLogger.record('contingency_element_added', { element: id }); | |
| } | |
| for (const id of prev) { | |
| if (!nextSet.has(id)) interactionLogger.record('contingency_element_removed', { element: id }); | |
| } | |
| setPendingContingency(next); | |
| }, [pendingContingency]); | |
| /** | |
| * Commit the pending list. Triggers the contingency-state confirm | |
| * dialog when an analysis already exists (same routing as today's | |
| * single-element flow). | |
| */ | |
| const handleContingencyApply = useCallback(() => { | |
| const next = [...pendingContingency]; | |
| interactionLogger.record('contingency_applied', { elements: next }); | |
| setSelectedContingency(next); | |
| }, [pendingContingency]); | |
| const handleDismissWarning = useCallback(() => { | |
| setShowMonitoringWarning(false); | |
| }, [setShowMonitoringWarning]); | |
| const handleOpenConfigSettings = useCallback(() => { | |
| setIsSettingsOpen(true); | |
| setSettingsTab('configurations'); | |
| }, [setIsSettingsOpen, setSettingsTab]); | |
| const handleToggleMonitorDeselected = useCallback(() => { | |
| analysis.setMonitorDeselected(!analysis.monitorDeselected); | |
| }, [analysis]); | |
| const handleTabChange = useCallback((tab: TabId) => { | |
| interactionLogger.record('diagram_tab_changed', { tab }); | |
| diagrams.setActiveTab(tab); | |
| }, [diagrams]); | |
| const handleVoltageRangeChange = useCallback((range: [number, number]) => { | |
| interactionLogger.record('voltage_range_changed', { min: range[0], max: range[1] }); | |
| diagrams.setVoltageRange(range); | |
| }, [diagrams]); | |
| const handleInspectQueryChange = useCallback((q: string) => { | |
| interactionLogger.record('inspect_query_changed', { query: q }); | |
| diagrams.setInspectQuery(q); | |
| }, [diagrams]); | |
| const handleToggleVoltageLevelNames = useCallback((show: boolean) => { | |
| interactionLogger.record('vl_names_toggled', { show }); | |
| setShowVoltageLevelNames(show); | |
| }, [setShowVoltageLevelNames]); | |
| // Per-tab inspect variant. Lets a detached tab's overlay zoom its | |
| // own tab rather than the main-window activeTab — see | |
| // useDiagrams.setInspectQueryForTab for the full story. | |
| const handleInspectQueryChangeFor = useCallback((targetTab: TabId, q: string) => { | |
| interactionLogger.record('inspect_query_changed', { query: q, target_tab: targetTab }); | |
| diagrams.setInspectQueryForTab(targetTab, q); | |
| }, [diagrams]); | |
| const handleVlOpen = useCallback((vlName: string) => { | |
| // Always carry the currently-selected action id into the SLD | |
| // overlay — NOT just when activeTab === 'action'. The SLD's | |
| // internal sub-tab buttons let the user switch to the "action" | |
| // sub-tab from any tab, and if we open the overlay with an | |
| // empty actionId the backend rejects the switch with | |
| // "Action '' not found in last analysis result". | |
| handleVlDoubleClick(selectedActionId || '', vlName); | |
| }, [handleVlDoubleClick, selectedActionId]); | |
| // Clicking a (relabelled) feeder name on the SLD jumps to the far-end VL's | |
| // SLD, keeping the current sub-tab so the same contingency / overload stays | |
| // in view from the other extremity. | |
| const handleSldNavigateToVl = useCallback((vlId: string) => { | |
| if (!vlOverlay) return; | |
| handleVlDoubleClick(vlOverlay.actionId || selectedActionId || '', vlId, vlOverlay.tab); | |
| }, [vlOverlay, handleVlDoubleClick, selectedActionId]); | |
| // Keep the VL-disk interaction callbacks in refs so the delegated | |
| // listeners below re-bind ONLY when a diagram / its metadata actually | |
| // changes — never on an unrelated App render, which would needlessly | |
| // tear down the listeners and (worse) cancel an in-flight single-click | |
| // timer mid-window. | |
| const vlSelectRef = useRef(handleInspectQueryChangeFor); | |
| const vlOpenSldRef = useRef(handleVlOpen); | |
| const vlDisplayNameRef = useRef(displayName); | |
| useEffect(() => { | |
| vlSelectRef.current = handleInspectQueryChangeFor; | |
| vlOpenSldRef.current = handleVlOpen; | |
| vlDisplayNameRef.current = displayName; | |
| }); | |
| // Voltage-level disk interactions on every NAD tab: hover shows the VL | |
| // name while the static labels are hidden, single-click selects the VL | |
| // (drives the Inspect field + auto-zoom), double-click opens its SLD. | |
| // Delegated listeners only — see `attachVlInteractions` for the | |
| // performance contract (no per-node / per-frame work; idle during | |
| // pan/zoom gestures because `.svg-interacting` disables SVG hit- | |
| // testing). Re-binds only on a diagram / metadata-index refresh. | |
| useEffect(() => { | |
| const targets: Array<readonly [HTMLElement | null, MetadataIndex | null, TabId]> = [ | |
| [nSvgContainerRef.current, diagrams.nMetaIndex, 'n'], | |
| [n1SvgContainerRef.current, diagrams.n1MetaIndex, 'contingency'], | |
| [actionSvgContainerRef.current, diagrams.actionMetaIndex, 'action'], | |
| ]; | |
| const teardowns = targets.map(([container, metaIndex, tab]) => | |
| attachVlInteractions(container, metaIndex, { | |
| displayName: (id) => vlDisplayNameRef.current(id), | |
| onSelect: (vlId) => vlSelectRef.current(tab, vlId), | |
| onOpenSld: (vlId) => vlOpenSldRef.current(vlId), | |
| }), | |
| ); | |
| return () => teardowns.forEach((teardown) => teardown()); | |
| }, [ | |
| nDiagram, n1Diagram, actionDiagram, | |
| diagrams.nMetaIndex, diagrams.n1MetaIndex, diagrams.actionMetaIndex, | |
| nSvgContainerRef, n1SvgContainerRef, actionSvgContainerRef, | |
| ]); | |
| // Double-click on an action pin in the overflow graph drills into | |
| // that substation's SLD on the post-action ('action') sub-tab. | |
| // Guarded on the action being known to the analysis result — | |
| // double-clicks on stale or unknown pins are silently ignored. | |
| const handleOverflowPinDoubleClick = useCallback((actionId: string, substation: string) => { | |
| if (!actionId || !substation) return; | |
| const knownAction = !!result?.actions?.[actionId]; | |
| if (!knownAction) return; | |
| interactionLogger.record('overflow_pin_double_clicked', { | |
| actionId, substation, | |
| }); | |
| handleVlDoubleClick(actionId, substation, 'action'); | |
| }, [handleVlDoubleClick, result?.actions]); | |
| const handleCancelDialog = useCallback(() => { | |
| // Cancelling a "Change Network?" dialog must roll back the | |
| // optimistic networkPath update done by requestNetworkPathChange, | |
| // otherwise the Header field would silently diverge from the | |
| // currently-loaded study's path. | |
| if (confirmDialog?.type === 'changeNetwork') { | |
| setNetworkPath(committedNetworkPathRef.current); | |
| } | |
| setConfirmDialog(null); | |
| }, [confirmDialog, setNetworkPath]); | |
| // ===== Tiered notice system ===== | |
| // The previous UI stacked up to five concurrent yellow banners in | |
| // the sidebar. They now feed a single "Notices" pill at the top | |
| // of the sidebar. Each notice owns its dismissal state via the | |
| // existing showXxxWarning state (kept where it lived to preserve | |
| // the reset-on-apply-settings behaviour). | |
| const [showActionDictNotice, setShowActionDictNotice] = useState(true); | |
| const [showRecommenderNotice, setShowRecommenderNotice] = useState(true); | |
| // Re-arm the notices whenever a fresh study is loaded — same | |
| // semantics as the local state ActionFeed used to own. | |
| useEffect(() => { | |
| setShowActionDictNotice(true); | |
| setShowRecommenderNotice(true); | |
| }, [networkPath, actionPath]); | |
| const sidebarNotices = useMemo(() => { | |
| const list: Notice[] = []; | |
| // Action-dictionary info — shown until the operator dismisses | |
| // it manually. The previous "auto-clear once an action has been | |
| // simulated" rule was removed: notices live in the discrete | |
| // sidebar pill that no longer overloads the main window | |
| // visually, so the operator should decide when each notice has | |
| // been read — not the lifecycle of the analysis. | |
| if (showActionDictNotice && actionDictFileName && actionDictStats) { | |
| list.push({ | |
| id: 'action-dict', | |
| severity: 'info', | |
| title: 'Action dictionary', | |
| body: ( | |
| <> | |
| <code style={{ fontFamily: 'monospace', padding: '0 4px' }}>{actionDictFileName}</code> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '4px' }}> | |
| <span>🔄 Reco: <strong>{actionDictStats.reco}</strong></span> | |
| <span>⛔ Disco: <strong>{actionDictStats.disco}</strong></span> | |
| <span>📐 PST: <strong>{actionDictStats.pst}</strong></span> | |
| <span>🔓 Open coupling: <strong>{actionDictStats.open_coupling}</strong></span> | |
| <span>🔒 Close coupling: <strong>{actionDictStats.close_coupling}</strong></span> | |
| </div> | |
| </> | |
| ), | |
| action: { label: 'Change in settings', onClick: () => handleOpenSettings('paths') }, | |
| onDismiss: () => setShowActionDictNotice(false), | |
| }); | |
| } | |
| // Monitoring coverage warning — surfaces the reduced monitoring | |
| // scope before the operator runs an analysis. | |
| if (showMonitoringWarning && totalLinesCount && totalLinesCount > 0) { | |
| const monitored = monitoredLinesCount || 0; | |
| list.push({ | |
| id: 'monitoring-coverage', | |
| severity: 'warning', | |
| title: 'Monitoring coverage', | |
| body: ( | |
| <> | |
| <strong>{monitored}</strong> of <strong>{totalLinesCount}</strong> lines monitored | |
| {' '}({totalLinesCount - monitored} without permanent limits). Monitoring factor: | |
| {' '}{Math.round((monitoringFactor || 0.95) * 100)}%, pre-existing overload threshold: | |
| {' '}{Math.round((preExistingOverloadThreshold || 0.02) * 100)}%. | |
| </> | |
| ), | |
| action: { label: 'Change in settings', onClick: handleOpenConfigSettings }, | |
| onDismiss: handleDismissWarning, | |
| }); | |
| } | |
| // Recommender thresholds — shown until the operator dismisses | |
| // it manually. The previous "auto-hide once analysis kicks off" | |
| // rule was removed for consistency with the action-dict notice: | |
| // the sidebar pill is opt-in and doesn't compete for attention | |
| // with the main window, so the operator owns the dismiss | |
| // gesture. | |
| if (showRecommenderNotice) { | |
| list.push({ | |
| id: 'recommender-thresholds', | |
| severity: 'info', | |
| title: 'Recommender thresholds', | |
| body: ( | |
| <> | |
| <div>• Minimum actions: {recommenderConfig.minLineReconnections} reco, {recommenderConfig.minCloseCoupling} close, {recommenderConfig.minOpenCoupling} open, {recommenderConfig.minLineDisconnections} disco, {recommenderConfig.minPst} PST, {recommenderConfig.minLoadShedding} load shedding, {recommenderConfig.minRenewableCurtailmentActions} RC, {recommenderConfig.minRedispatch} redispatch</div> | |
| <div>• Maximum suggestions: {recommenderConfig.nPrioritizedActions}</div> | |
| <div>• Ignore reconnections: {recommenderConfig.ignoreReconnections ? 'Yes' : 'No'}</div> | |
| </> | |
| ), | |
| action: { label: 'Change in settings', onClick: () => handleOpenSettings('recommender') }, | |
| onDismiss: () => setShowRecommenderNotice(false), | |
| }); | |
| } | |
| // Additional "lines to prevent flow increase" — two complementary | |
| // notices, one per analysis lifecycle phase: | |
| // | |
| // PRE-RUN (no result yet, picker non-empty): warning that | |
| // surfaces the EXTRA targets the next Analyze & Suggest run | |
| // is about to pass to the recommender. | |
| // POST-RUN (result present, committed snapshot non-empty): | |
| // info that surfaces the EXTRA targets baked into the | |
| // CURRENT result, so the operator never loses track of the | |
| // hypothesis the recommendations were computed against — | |
| // even if the picker has since been edited and a new run is | |
| // pending. The committed snapshot is taken inside | |
| // ``useAnalysis`` at the moment Step 2 was posted. | |
| if (additionalLinesToCut.size > 0 && !pendingAnalysisResult && !result) { | |
| const lines = Array.from(additionalLinesToCut); | |
| list.push({ | |
| id: 'additional-lines-to-cut', | |
| severity: 'warning', | |
| title: 'Additional lines to prevent flow increase', | |
| body: ( | |
| <> | |
| <div> | |
| The next Analyze & Suggest run will treat{' '} | |
| <strong>{lines.length}</strong> extra line{lines.length === 1 ? '' : 's'} as | |
| {' '}targets to prevent flow increase on (simulated as disconnected, not rendered as overloads): | |
| </div> | |
| <div style={{ marginTop: 4, wordBreak: 'break-word' }}> | |
| {lines.map(displayName).join(', ')} | |
| </div> | |
| </> | |
| ), | |
| }); | |
| } | |
| if (committedAdditionalLinesToCut.size > 0 && !!result) { | |
| const lines = Array.from(committedAdditionalLinesToCut); | |
| list.push({ | |
| id: 'additional-lines-to-cut-committed', | |
| severity: 'info', | |
| title: 'Additional lines integrated in overflow analysis', | |
| body: ( | |
| <> | |
| <div> | |
| <strong>{lines.length}</strong> additional line | |
| {lines.length === 1 ? '' : 's'} integrated in overflow | |
| analysis on which to prevent powerflow increase: | |
| </div> | |
| <div style={{ marginTop: 4, wordBreak: 'break-word' }}> | |
| {lines.map(displayName).join(', ')} | |
| </div> | |
| </> | |
| ), | |
| }); | |
| } | |
| return list; | |
| }, [ | |
| showActionDictNotice, actionDictFileName, actionDictStats, result, | |
| showMonitoringWarning, monitoredLinesCount, totalLinesCount, | |
| monitoringFactor, preExistingOverloadThreshold, | |
| showRecommenderNotice, pendingAnalysisResult, recommenderConfig, | |
| handleOpenSettings, handleOpenConfigSettings, handleDismissWarning, | |
| additionalLinesToCut, committedAdditionalLinesToCut, displayName, | |
| ]); | |
| return ( | |
| <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}> | |
| <Header | |
| networkPath={networkPath} | |
| setNetworkPath={setNetworkPath} | |
| onCommitNetworkPath={requestNetworkPathChange} | |
| configLoading={configLoading} | |
| result={result} | |
| selectedContingency={selectedContingency} | |
| sessionRestoring={sessionRestoring} | |
| onPickSettingsPath={pickSettingsPath} | |
| onLoadStudy={handleLoadStudyClick} | |
| onSaveResults={wrappedSaveResults} | |
| onOpenReloadModal={wrappedOpenReloadModal} | |
| onOpenSettings={handleOpenSettings} | |
| notices={sidebarNotices} | |
| /> | |
| {/* Settings Modal */} | |
| <SettingsModal settings={settings} onApply={handleApplySettingsClick} /> | |
| <ReloadSessionModal | |
| showReloadModal={showReloadModal} | |
| setShowReloadModal={setShowReloadModal} | |
| outputFolderPath={outputFolderPath} | |
| sessionListLoading={sessionListLoading} | |
| sessionList={sessionList} | |
| sessionRestoring={sessionRestoring} | |
| onRestoreSession={wrappedRestoreSession} | |
| /> | |
| <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}> | |
| <AppSidebar | |
| selectedContingency={selectedContingency} | |
| pendingContingency={pendingContingency} | |
| branches={branches} | |
| nameMap={nameMap} | |
| n1LinesOverloaded={n1Diagram?.lines_overloaded} | |
| n1LinesOverloadedRho={n1Diagram?.lines_overloaded_rho} | |
| nLinesOverloaded={nDiagram?.lines_overloaded} | |
| nLinesOverloadedRho={nDiagram?.lines_overloaded_rho} | |
| selectedOverloads={selectedOverloads} | |
| onPendingContingencyChange={handlePendingContingencyChange} | |
| onContingencyApply={handleContingencyApply} | |
| displayName={displayName} | |
| onContingencyZoom={handleZoomOnActiveTab} | |
| onOverloadClick={wrappedAssetClick as (actionId: string, assetName: string, tab: 'n' | 'contingency') => void} | |
| onToggleOverload={analysis.handleToggleOverload} | |
| monitorDeselected={monitorDeselected} | |
| onToggleMonitorDeselected={handleToggleMonitorDeselected} | |
| monitoringHint={ | |
| showMonitoringWarning && totalLinesCount && totalLinesCount > 0 | |
| ? `${monitoredLinesCount || 0}/${totalLinesCount} lines monitored — see Notices for details.` | |
| : null | |
| } | |
| onClearContingency={requestClearContingency} | |
| hideContingencyPicker={sidebarSwitchedToFeed} | |
| collapsed={sidebarCollapsed} | |
| onToggleCollapsed={handleToggleSidebarCollapsed} | |
| overviewFilters={overviewFilters} | |
| onOverviewFiltersChange={setOverviewFilters} | |
| hasActions={Object.keys(result?.actions || {}).length > 0} | |
| > | |
| {sidebarSwitchedToFeed && result?.antenna_meta && ( | |
| <AntennaNotice meta={result.antenna_meta} /> | |
| )} | |
| {sidebarSwitchedToFeed && ( | |
| <ActionFeed | |
| actions={result?.actions || {}} | |
| actionScores={result?.action_scores} | |
| // Prefer the analysis-result overload list when it carries | |
| // entries (step1 / session reload populate it with the | |
| // pypowsybl-style friendly identifiers the rest of the UI | |
| // is wired against), and fall back to the N-1 diagram's | |
| // authoritative list otherwise. The fallback matters for | |
| // the pre-analysis manual-simulation flow: without it the | |
| // card stack inherits ``simulate_manual_action``'s | |
| // vectorised obs-based names — grid2op's synthetic | |
| // ``line_<i>`` strings that ``displayName`` cannot resolve. | |
| linesOverloaded={ | |
| result?.lines_overloaded && result.lines_overloaded.length > 0 | |
| ? result.lines_overloaded | |
| : (n1Diagram?.lines_overloaded || []) | |
| } | |
| selectedActionId={selectedActionId} | |
| scrollTarget={scrollTarget} | |
| selectedActionIds={selectedActionIds} | |
| rejectedActionIds={rejectedActionIds} | |
| manuallyAddedIds={manuallyAddedIds} | |
| combinedActions={result?.combined_actions ?? null} | |
| pendingAnalysisResult={pendingAnalysisResult} | |
| onDisplayPrioritizedActions={wrappedDisplayPrioritized} | |
| onRunAnalysis={wrappedRunAnalysis} | |
| onCancelAnalysis={analysis.cancelAnalysis} | |
| canRunAnalysis={selectedContingency.length > 0 && !analysisLoading} | |
| onActionSelect={wrappedActionSelect} | |
| onActionFavorite={wrappedActionFavorite} | |
| onActionReject={actionsHook.handleActionReject} | |
| onAssetClick={wrappedAssetClick} | |
| nodesByEquipmentId={diagrams.nMetaIndex?.nodesByEquipmentId ?? null} | |
| edgesByEquipmentId={diagrams.nMetaIndex?.edgesByEquipmentId ?? null} | |
| disconnectedElement={selectedContingency} | |
| onManualActionAdded={wrappedManualActionAdded} | |
| onActionResimulated={wrappedActionResimulated} | |
| analysisLoading={analysisLoading} | |
| monitoringFactor={monitoringFactor} | |
| onVlDoubleClick={handleVlDoubleClick} | |
| onUpdateCombinedEstimation={handleUpdateCombinedEstimation} | |
| displayName={displayName} | |
| onActionDiagramPrimed={diagrams.primeActionDiagram} | |
| voltageLevelsLength={voltageLevels.length} | |
| overviewFilters={overviewFilters} | |
| onOverviewFiltersChange={setOverviewFilters} | |
| additionalLines={{ | |
| branches: branches, | |
| additionalLinesToCut: additionalLinesToCut, | |
| onToggleAdditionalLineToCut: analysis.handleToggleAdditionalLineToCut, | |
| n1Overloads: n1Diagram?.lines_overloaded || [], | |
| }} | |
| modelSelector={{ | |
| recommenderModel: recommenderModel, | |
| setRecommenderModel: setRecommenderModel, | |
| availableModels: availableModels, | |
| activeModelLabel: result?.active_model | |
| ? (availableModels?.find(m => m.name === result.active_model)?.label || result.active_model) | |
| : null, | |
| }} | |
| timing={{ | |
| overflowGraphTime: result?.overflow_graph_time ?? null, | |
| actionPredictionTime: result?.action_prediction_time ?? null, | |
| assessmentTime: result?.assessment_time ?? null, | |
| step1Time: result?.step1_time ?? null, | |
| enrichmentTime: result?.enrichment_time ?? null, | |
| wallClockTime: result?.wall_clock_time ?? null, | |
| }} | |
| onClearSuggested={requestClearSuggested} | |
| /> | |
| )} | |
| </AppSidebar> | |
| <div style={{ flex: 1, background: 'white', display: 'flex', flexDirection: 'column' }}> | |
| <VisualizationPanel | |
| activeTab={activeTab} | |
| configLoading={configLoading} | |
| onTabChange={handleTabChange} | |
| nDiagram={nDiagram} | |
| n1Diagram={n1Diagram} | |
| n1Loading={n1Loading} | |
| actionDiagram={actionDiagram} | |
| actionDiagramLoading={actionDiagramLoading} | |
| selectedActionId={selectedActionId} | |
| result={result} | |
| analysisLoading={analysisLoading} | |
| nSvgContainerRef={nSvgContainerRef} | |
| n1SvgContainerRef={n1SvgContainerRef} | |
| actionSvgContainerRef={actionSvgContainerRef} | |
| uniqueVoltages={uniqueVoltages} | |
| voltageRange={voltageRange} | |
| onVoltageRangeChange={handleVoltageRangeChange} | |
| actionViewMode={actionViewMode} | |
| onViewModeChange={handleViewModeChange} | |
| viewModeForTab={viewModeForTab} | |
| onViewModeChangeForTab={handleViewModeChangeForTab} | |
| overflow={{ | |
| overflowLayoutMode: diagrams.overflowLayoutMode, | |
| overflowLayoutLoading: diagrams.overflowLayoutLoading, | |
| onOverflowLayoutChange: wrappedOverflowLayoutChange, | |
| onOverflowPinPreview: handlePinPreview, | |
| onOverflowPinDoubleClick: handleOverflowPinDoubleClick, | |
| overflowPins: allOverflowPins, | |
| overflowPinsEnabled: overflowPinsEnabled, | |
| onOverflowPinsToggle: setOverflowPinsEnabled, | |
| }} | |
| inspectQuery={inspectQuery} | |
| onInspectQueryChange={handleInspectQueryChange} | |
| onInspectQueryChangeFor={handleInspectQueryChangeFor} | |
| inspectableItems={inspectableItems} | |
| onResetView={handleManualReset} | |
| onZoomIn={handleManualZoomIn} | |
| onZoomOut={handleManualZoomOut} | |
| hasBranches={branches.length > 0} | |
| selectedContingency={selectedContingency} | |
| vlOverlay={vlOverlay} | |
| onOverlayClose={handleOverlayClose} | |
| onOverlaySldTabChange={handleOverlaySldTabChange} | |
| sldEdit={{ | |
| sldEditMode: sldTopologyEdit.editMode, | |
| onSldEditModeChange: sldTopologyEdit.setEditMode, | |
| sldEditPendingSwitches: sldTopologyEdit.pendingStates, | |
| sldEditPendingChanges: sldTopologyEdit.pendingChanges, | |
| onSldSwitchClick: sldTopologyEdit.toggleSwitch, | |
| sldEditPendingInjections: sldTopologyEdit.pendingInjections, | |
| sldEditInjectionChanges: sldTopologyEdit.injectionChanges, | |
| onSldInjectionStage: sldTopologyEdit.setInjection, | |
| onSldInjectionRemove: sldTopologyEdit.removeInjection, | |
| onSldEditSimulate: handleSimulateSldEdit, | |
| onSldEditReset: sldTopologyEdit.reset, | |
| sldEditBusy: sldEditBusy, | |
| sldEditCombinedWithActionId: sldEditBaseActionId, | |
| sldPreviewSvg: sldPreview?.svg ?? null, | |
| sldPreviewMetadata: sldPreview?.metadata ?? null, | |
| sldPreviewStale: !!sldPreview, | |
| sldPreviewLoading: sldPreviewLoading, | |
| sldFocusedSwitchId: sldTopologyEdit.focusedSwitchId, | |
| onSldSwitchFocus: sldTopologyEdit.setFocusedSwitch, | |
| onSldSwitchRemove: sldTopologyEdit.removeSwitch, | |
| onSldSwitchRemoveMany: sldTopologyEdit.removeSwitches, | |
| onSldNavigateToVl: handleSldNavigateToVl, | |
| }} | |
| voltageLevels={voltageLevels} | |
| onVlOpen={handleVlOpen} | |
| networkPath={networkPath} | |
| layoutPath={layoutPath} | |
| onOpenSettings={handleOpenSettings} | |
| detach={{ | |
| detachedTabs: detachedTabs, | |
| onDetachTab: handleDetachTab, | |
| onReattachTab: handleReattachTab, | |
| onFocusDetachedTab: focusDetachedTab, | |
| isTabTied: isTabTied, | |
| onToggleTabTie: toggleTabTie, | |
| }} | |
| actionOverview={{ | |
| n1MetaIndex: diagrams.n1MetaIndex, | |
| onActionSelect: wrappedActionSelect, | |
| onActionFavorite: wrappedActionFavorite, | |
| onActionReject: actionsHook.handleActionReject, | |
| selectedActionIds: selectedActionIds, | |
| rejectedActionIds: rejectedActionIds, | |
| onPinPreview: handlePinPreview, | |
| onOverviewPzChange: handleOverviewPzChange, | |
| monitoringFactor: monitoringFactor, | |
| displayName: displayName, | |
| overviewFilters: overviewFilters, | |
| onOverviewFiltersChange: setOverviewFilters, | |
| sidebarCollapsed: sidebarCollapsed, | |
| hasActions: Object.keys(result?.actions || {}).length > 0, | |
| unsimulatedActionIds: unsimulatedActionIds, | |
| unsimulatedActionInfo: unsimulatedActionInfo, | |
| onSimulateUnsimulatedAction: handleSimulateUnsimulatedAction, | |
| }} | |
| showVoltageLevelNames={showVoltageLevelNames} | |
| onToggleVoltageLevelNames={handleToggleVoltageLevelNames} | |
| /> | |
| </div> | |
| </div> | |
| {/* Confirmation Dialog for contingency change / load study */} | |
| <ConfirmationDialog | |
| confirmDialog={confirmDialog} | |
| onCancel={handleCancelDialog} | |
| onConfirm={handleConfirmDialog} | |
| /> | |
| <NotificationHost /> | |
| </div> | |
| ); | |
| } | |
| export default App; | |