Co-Study4Grid / frontend /src /hooks /useAnalysis.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
17.7 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import { 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<SetStateAction<AnalysisResult | null>>;
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<string>;
setSelectedOverloads: (v: Set<string>) => 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<string>;
setAdditionalLinesToCut: Dispatch<SetStateAction<Set<string>>>;
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<string>;
setCommittedAdditionalLinesToCut: Dispatch<SetStateAction<Set<string>>>;
// Ref to previous result for merge logic
prevResultRef: MutableRefObject<AnalysisResult | null>;
handleRunAnalysis: (
selectedContingency: string[],
clearContingencyState: () => void,
setSuggestedByRecommenderIds: Dispatch<SetStateAction<Set<string>>>,
setActiveTab?: (tab: TabId) => void
) => Promise<void>;
handleDisplayPrioritizedActions: (selectedActionIds: Set<string>, 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<AnalysisResult | null>(null);
const prevResultRef = useRef<AnalysisResult | null>(result);
useEffect(() => { prevResultRef.current = result; }, [result]);
const [pendingAnalysisResult, setPendingAnalysisResult] = useState<AnalysisResult | null>(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<Set<string>>(new Set());
const [monitorDeselected, setMonitorDeselected] = useState(false);
const [additionalLinesToCut, setAdditionalLinesToCut] = useState<Set<string>>(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<Set<string>>(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<AbortController | null>(null);
const cancelAnalysis = useCallback(() => {
abortRef.current?.abort();
}, []);
const handleRunAnalysis = useCallback(async (
selectedContingency: string[],
clearContingencyState: () => void,
setSuggestedByRecommenderIds: (fn: (prev: Set<string>) => Set<string>) => 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<AnalysisResult> & { 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<ActionDetail>;
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<string>) => {
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<string>, 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<string, ActionDetail> = {};
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<ActionDetail>;
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<string>) => {
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,
]);
}