// 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, useMemo, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'; import type { ActionDetail, AnalysisResult, TabId } from '../types'; import { interactionLogger } from '../utils/interactionLogger'; import { parseNdjsonStream } from '../utils/ndjsonStream'; import { notifications, notifyError, notifyInfo } from '../utils/notifications'; export interface AnalysisState { result: AnalysisResult | null; setResult: Dispatch>; pendingAnalysisResult: AnalysisResult | null; setPendingAnalysisResult: (v: AnalysisResult | null) => void; analysisLoading: boolean; setAnalysisLoading: (v: boolean) => void; /** Raise an auto-dismissing info toast (`''` clears the info channel). */ setInfoMessage: (v: string) => void; /** Raise a sticky error toast (`''` clears the error channel). */ setError: (v: string) => void; // Analysis flow selectedOverloads: Set; setSelectedOverloads: (v: Set) => void; monitorDeselected: boolean; setMonitorDeselected: (v: boolean) => void; /** * Extra lines the operator wants the recommender to treat as * "lines to cut" beyond the detected overloads (ExpertAgent's * `additionalLinesToCut`/`ltc` semantics). Passed verbatim to * `/api/run-analysis-step2` and appended to the overflow-graph * target set on the backend. */ additionalLinesToCut: Set; setAdditionalLinesToCut: Dispatch>>; handleToggleAdditionalLineToCut: (line: string) => void; /** * Snapshot of ``additionalLinesToCut`` taken at the moment * ``handleRunAnalysis`` posted Step 2. Drives the post-run * "Additional lines integrated in overflow analysis" notice so * the operator always sees the hypothesis BAKED INTO the * current ``result`` — even if they later toggle the picker * before re-running. Cleared on * ``clearContingencyState`` / ``resetAllState``. */ committedAdditionalLinesToCut: Set; setCommittedAdditionalLinesToCut: Dispatch>>; // Ref to previous result for merge logic prevResultRef: MutableRefObject; handleRunAnalysis: ( selectedContingency: string[], clearContingencyState: () => void, setSuggestedByRecommenderIds: Dispatch>>, setActiveTab?: (tab: TabId) => void ) => Promise; handleDisplayPrioritizedActions: (selectedActionIds: Set, setActiveTab?: (tab: TabId) => void) => void; handleToggleOverload: (overload: string) => void; /** Abort the in-flight analysis run (step 1 request, step 2 fetch, and * the NDJSON stream). No-op when nothing is running. */ cancelAnalysis: () => void; } export function useAnalysis(): AnalysisState { const [result, setResult] = useState(null); const prevResultRef = useRef(result); useEffect(() => { prevResultRef.current = result; }, [result]); const [pendingAnalysisResult, setPendingAnalysisResult] = useState(null); const [analysisLoading, setAnalysisLoading] = useState(false); // Errors / info route through the shared notification store (D5) rather // than local `error` / `infoMessage` string state with a bespoke 3s // timer. These adapters keep the historical `(v: string) => void` // call-site contract: a message raises a toast; an empty string clears // that channel (the "reset before a run" gesture). const setError = useCallback((message: string) => { if (message) notifyError(message); else notifications.clearSeverity('error'); }, []); const setInfoMessage = useCallback((message: string) => { if (message) { notifyInfo(message); } else { notifications.clearSeverity('info'); notifications.clearSeverity('success'); } }, []); // Analysis flow const [selectedOverloads, setSelectedOverloads] = useState>(new Set()); const [monitorDeselected, setMonitorDeselected] = useState(false); const [additionalLinesToCut, setAdditionalLinesToCut] = useState>(new Set()); // Snapshot of ``additionalLinesToCut`` at the moment Step 2 was // posted. Drives the post-run "Additional lines integrated…" // notice so the operator keeps sight of the hypothesis the // current ``result`` was computed against. const [committedAdditionalLinesToCut, setCommittedAdditionalLinesToCut] = useState>(new Set()); // Cancellation (D5): the in-flight analysis run's AbortController, so a // visible Cancel can abort the step-1 request, the step-2 fetch, and the // NDJSON stream read. Null between runs. const abortRef = useRef(null); const cancelAnalysis = useCallback(() => { abortRef.current?.abort(); }, []); const handleRunAnalysis = useCallback(async ( selectedContingency: string[], clearContingencyState: () => void, setSuggestedByRecommenderIds: (fn: (prev: Set) => Set) => void, setActiveTab?: (tab: TabId) => void ) => { if (!selectedContingency || selectedContingency.length === 0) return; clearContingencyState(); setAnalysisLoading(true); setError(''); setInfoMessage(''); // Fresh AbortController for this run; `cancelAnalysis()` aborts it. const controller = new AbortController(); abortRef.current = controller; const step1CorrId = interactionLogger.record('analysis_step1_started', { element: selectedContingency.join('+') }); const step1StartTs = new Date().toISOString(); // Wall-clock from the "Analyze & Suggest" click until the result // event arrives (= "Display N prioritized actions" button appears). // Captures step1 + step2 backend stages + network round-trip + // NDJSON streaming overhead. const wallClockStart = performance.now(); try { // Step 1: Detection const { api } = await import('../api'); const res1 = await api.runAnalysisStep1(selectedContingency, controller.signal); if (!res1.can_proceed) { setError(res1.message || 'Analysis cannot proceed.'); if (res1.message) setInfoMessage(res1.message); setAnalysisLoading(false); return; } interactionLogger.recordCompletion('analysis_step1_completed', step1CorrId, { can_proceed: res1.can_proceed, overloads_detected: (res1.lines_overloaded || []).length, }, step1StartTs); const detected = res1.lines_overloaded || []; let primaryOverloads: string[] = []; if (detected.length > 0) { if (selectedOverloads.size > 0) { const stillRelevant = detected.filter(name => selectedOverloads.has(name)); if (stillRelevant.length > 0) { primaryOverloads = stillRelevant; } else { setSelectedOverloads(new Set(detected)); primaryOverloads = detected; } } else { setSelectedOverloads(new Set(detected)); primaryOverloads = detected; } } const toResolve = primaryOverloads; if (detected.length === 0) { setInfoMessage(res1.message || "No overloads detected."); setAnalysisLoading(false); return; } // Step 2: Resolution // Replay contract (docs/features/interaction-logging.md): // { element, selected_overloads, all_overloads, monitor_deselected, // additional_lines_to_cut } const additionalLinesArr = Array.from(additionalLinesToCut); // Stamp the committed snapshot BEFORE the network call so the // post-run notice reflects exactly what the backend is about to // process (even if the user toggles the picker mid-stream). setCommittedAdditionalLinesToCut(new Set(additionalLinesArr)); const step2CorrId = interactionLogger.record('analysis_step2_started', { element: selectedContingency.join('+'), selected_overloads: toResolve, all_overloads: detected, monitor_deselected: monitorDeselected, additional_lines_to_cut: additionalLinesArr, }); const step2StartTs = new Date().toISOString(); const response2 = await api.runAnalysisStep2Stream({ selected_overloads: toResolve, all_overloads: detected, monitor_deselected: monitorDeselected, additional_lines_to_cut: additionalLinesArr, }, controller.signal); let step2ActionsCount = 0; for await (const raw of parseNdjsonStream(response2, { signal: controller.signal })) { const event = raw as Partial & { type?: string }; if (event.type === 'pdf') { setResult((p: AnalysisResult | null) => ({ ...(p || {}), pdf_url: event.pdf_url, pdf_path: event.pdf_path, // The overflow-graph build time is emitted on the // ``pdf`` event (before discovery + assessment finish) // so the iframe can display it below its title as soon // as the file is ready. ...(typeof event.overflow_graph_time === 'number' ? { overflow_graph_time: event.overflow_graph_time } : (event.overflow_graph_time === null ? { overflow_graph_time: null } : {})), } as AnalysisResult)); if (setActiveTab) { setActiveTab('overflow'); } } else if (event.type === 'result') { const actionsWithFlags = { ...event.actions }; // Provenance for recommender-produced actions: the model // the backend actually ran (echoed as `active_model` on // the result event). Preserve an existing `origin` so a // "first guess" the operator added before analysis keeps // its `"user"` provenance even when the model re-emits // the same id. const resultModel: string = event.active_model || 'expert'; for (const id in actionsWithFlags) { const existing = (prevResultRef.current?.actions?.[id] || {}) as Partial; actionsWithFlags[id] = { ...actionsWithFlags[id], is_manual: false, origin: existing.origin ?? resultModel, is_islanded: existing.is_islanded ?? actionsWithFlags[id].is_islanded, estimated_max_rho: existing.estimated_max_rho ?? actionsWithFlags[id].max_rho, estimated_max_rho_line: existing.estimated_max_rho_line ?? actionsWithFlags[id].max_rho_line, }; } setSuggestedByRecommenderIds(prev => new Set([...prev, ...Object.keys(actionsWithFlags)])); step2ActionsCount = Object.keys(actionsWithFlags).length; const wallClockTime = (performance.now() - wallClockStart) / 1000; setPendingAnalysisResult({ ...(event as AnalysisResult), actions: actionsWithFlags, wall_clock_time: wallClockTime, }); if (event.message) setInfoMessage(event.message); } else if (event.type === 'error') { setError('Analysis failed: ' + event.message); } } // The operator cancelled mid-stream: don't record a normal // completion — surface the cancellation instead. if (controller.signal.aborted) { setInfoMessage('Analysis cancelled.'); return; } // Replay contract (docs/features/interaction-logging.md): // { n_actions, action_ids, dc_fallback, message, pdf_url }. // The full payload would require threading more state out of // the stream loop; for now we emit the most-replayed field // (n_actions) under its spec key. interactionLogger.recordCompletion('analysis_step2_completed', step2CorrId, { n_actions: step2ActionsCount, }, step2StartTs); } catch (err: unknown) { // An abort (step-1 request / step-2 fetch) surfaces as a rejection; // it's a cancellation, not a failure. if (controller.signal.aborted) { setInfoMessage('Analysis cancelled.'); } else { const message = err instanceof Error ? err.message : 'An error occurred during analysis.'; setError(message); } } finally { setAnalysisLoading(false); if (abortRef.current === controller) abortRef.current = null; } }, [selectedOverloads, monitorDeselected, additionalLinesToCut, setError, setInfoMessage]); const handleToggleAdditionalLineToCut = useCallback((line: string) => { if (!line) return; let willAdd = false; setAdditionalLinesToCut((prev: Set) => { const next = new Set(prev); if (next.has(line)) { next.delete(line); willAdd = false; } else { next.add(line); willAdd = true; } return next; }); interactionLogger.record('additional_line_to_cut_toggled', { line, selected: willAdd }); }, []); const handleDisplayPrioritizedActions = useCallback((selectedActionIds: Set, setActiveTab?: (tab: TabId) => void) => { if (!pendingAnalysisResult) return; // Replay contract (docs/features/interaction-logging.md): { n_actions: number }. interactionLogger.record('prioritized_actions_displayed', { n_actions: Object.keys(pendingAnalysisResult.actions).length, }); // Auto-switch to the Remedial Action tab so the operator sees the // action overview with pins right after pressing the button. setActiveTab?.('action'); setResult(prev => { // Preserve manually-added ("first guess") actions across the // analysis display step. We select them by the `is_manual` // flag rather than by `selectedActionIds` so a manual action // is kept even if the `selectedActionIds` set has been // trimmed by resetForAnalysisRun — mirrors the standalone // interface's handleDisplayPrioritizedActions behavior. const manualActionsData: Record = {}; if (prev?.actions) { for (const [id, data] of Object.entries(prev.actions)) { if (data.is_manual || selectedActionIds.has(id)) { manualActionsData[id] = { ...data, is_manual: true }; } } } const mergedActions = { ...pendingAnalysisResult.actions }; for (const [id, data] of Object.entries(mergedActions)) { const existing = (prev?.actions?.[id] || {}) as Partial; mergedActions[id] = { ...data, is_islanded: existing.is_islanded ?? data.is_islanded, estimated_max_rho: existing.estimated_max_rho ?? data.estimated_max_rho, estimated_max_rho_line: existing.estimated_max_rho_line ?? data.estimated_max_rho_line, // Provenance: prefer the pre-display value (a "first guess" // the operator added before analysis stays `"user"`), else // the model-stamped origin from the step-2 stream loop. origin: existing.origin ?? data.origin, }; } return { ...prev, ...pendingAnalysisResult, // Manual entries win over their analysis-suggested twins so // the user's "first guess" keeps its is_manual flag and its // variant diagram stays pinned to the Selected bucket. actions: { ...mergedActions, ...manualActionsData }, }; }); setPendingAnalysisResult(null); }, [pendingAnalysisResult]); const handleToggleOverload = useCallback((overload: string) => { // Replay contract (docs/features/interaction-logging.md): // { overload, selected }. `selected` is the state AFTER the toggle — // true if the checkbox is now checked, false otherwise. Compute the // next value from the current set BEFORE calling setSelectedOverloads // so the logger doesn't double-fire under React StrictMode (the // updater callback may run twice). const willSelect = !selectedOverloads.has(overload); interactionLogger.record('overload_toggled', { overload, selected: willSelect }); setSelectedOverloads((prev: Set) => { const next = new Set(prev); if (next.has(overload)) next.delete(overload); else next.add(overload); return next; }); }, [selectedOverloads]); return useMemo(() => ({ result, setResult, pendingAnalysisResult, setPendingAnalysisResult, analysisLoading, setAnalysisLoading, setInfoMessage, setError, selectedOverloads, setSelectedOverloads, monitorDeselected, setMonitorDeselected, additionalLinesToCut, setAdditionalLinesToCut, handleToggleAdditionalLineToCut, committedAdditionalLinesToCut, setCommittedAdditionalLinesToCut, prevResultRef, handleRunAnalysis, handleDisplayPrioritizedActions, handleToggleOverload, cancelAnalysis, }), [ result, pendingAnalysisResult, analysisLoading, setInfoMessage, setError, selectedOverloads, monitorDeselected, additionalLinesToCut, committedAdditionalLinesToCut, handleRunAnalysis, handleDisplayPrioritizedActions, handleToggleOverload, handleToggleAdditionalLineToCut, cancelAnalysis, ]); }