// 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 // Manual-simulation orchestration extracted from App.tsx (D4, 2026-07). // Owns the two operator-driven "simulate this action now" flows plus the // interactive SLD-edit state they share: // - `handleSimulateUnsimulatedAction` — double-click an un-simulated // recommender pin on the Action Overview → stream a variant + card; // - the `useSldTopologyEdit` state, the implicit edit-mode sync, the // debounced target-topology preview, and `handleSimulateSldEdit` — // turn staged breaker toggles + injection retunes into one action. // // Both consume the shared `parseNdjsonStream` reader (D5) and route // failures through the notification store via the injected `setError`. // App keeps ownership of the collaborators (diagrams, the analysis // result, the manual-action-added wrapper) and passes them in, so this // stays a thin orchestrator rather than a second state hub. import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { api } from '../api'; import { parseNdjsonStream } from '../utils/ndjsonStream'; import { interactionLogger } from '../utils/interactionLogger'; import { apiErrorMessage } from '../utils/apiError'; import { useSldTopologyEdit, type SldTopologyEditState } from './useSldTopologyEdit'; import type { ActionDetail, AnalysisResult, DiagramData } from '../types'; import type { DiagramsState } from './useDiagrams'; export interface ManualSimulationParams { diagrams: DiagramsState; selectedContingency: string[]; result: AnalysisResult | null; voltageLevels: string[]; wrappedManualActionAdded: ( actionId: string, detail: ActionDetail, linesOverloaded: string[], origin?: string, ) => void; /** Notification-store error adapter (raise / clear). */ setError: (message: string) => void; } export interface ManualSimulationState { /** Interactive SLD-edit state (breaker toggles + injection retunes). */ sldTopologyEdit: SldTopologyEditState; /** True while a staged SLD edit is being simulated. */ sldEditBusy: boolean; /** Canonical id of the action the open SLD is a variant of (`null` on N/N-1). */ sldEditBaseActionId: string | null; /** Debounced target-topology preview SVG (null when nothing is staged). */ sldPreview: { svg: string; metadata: string | null } | null; sldPreviewLoading: boolean; /** Double-click an un-simulated overview pin → stream a variant + card. */ handleSimulateUnsimulatedAction: (actionId: string) => Promise; /** Simulate the staged SLD breaker/injection edit as one manual action. */ handleSimulateSldEdit: () => Promise; } export function useManualSimulation(params: ManualSimulationParams): ManualSimulationState { const { diagrams, selectedContingency, result, voltageLevels, wrappedManualActionAdded, setError } = params; const handleSimulateUnsimulatedAction = useCallback( async (actionId: string) => { if (selectedContingency.length === 0) { setError('Select a contingency first.'); return; } try { const response = await api.simulateAndVariantDiagramStream({ action_id: actionId, disconnected_elements: selectedContingency, action_content: null, lines_overloaded: result?.lines_overloaded ?? null, target_mw: null, target_tap: null, }); let metrics: Awaited> | null = null; let streamErr: string | null = null; for await (const raw of parseNdjsonStream(response)) { const event = raw as Record; if (event.type === 'metrics') { const { type: _t, ...rest } = event; void _t; metrics = rest as Awaited>; } else if (event.type === 'diagram') { const { type: _t, ...rest } = event; void _t; diagrams.primeActionDiagram(actionId, rest as unknown as DiagramData & { svg: string }, voltageLevels.length); } else if (event.type === 'error') { streamErr = (event.message as string) || 'stream error'; } } if (streamErr) throw new Error(streamErr); if (!metrics) throw new Error('Stream ended without metrics event'); const detail: ActionDetail = { description_unitaire: metrics.description_unitaire, rho_before: metrics.rho_before, rho_after: metrics.rho_after, max_rho: metrics.max_rho, max_rho_line: metrics.max_rho_line, is_rho_reduction: metrics.is_rho_reduction, is_islanded: metrics.is_islanded, n_components: metrics.n_components, disconnected_mw: metrics.disconnected_mw, non_convergence: metrics.non_convergence, lines_overloaded_after: metrics.lines_overloaded_after, half_open_overloads: metrics.half_open_overloads, action_topology: metrics.action_topology, load_shedding_details: metrics.load_shedding_details, curtailment_details: metrics.curtailment_details, redispatch_details: metrics.redispatch_details, pst_details: metrics.pst_details, }; // An unsimulated pin is a scored-but-not-yet-materialised // action from the recommender's score table — the operator // only triggered its simulation, so its provenance is the // model that scored it, NOT "user". wrappedManualActionAdded( actionId, detail, metrics.lines_overloaded || [], result?.active_model || 'expert', ); } catch (e: unknown) { console.error('Unsimulated pin simulation failed:', e); setError(apiErrorMessage(e, 'Simulation failed')); } }, [selectedContingency, result?.lines_overloaded, result?.active_model, diagrams, voltageLevels.length, wrappedManualActionAdded, setError], ); // Interactive SLD topology edit (useSldTopologyEdit). The baseline is // the ``switch_states`` map the backend stamps on every SLD response; // the hook drops stale toggles when it changes (VL switch, tab switch, // action variant change). const sldTopologyEdit = useSldTopologyEdit(diagrams.vlOverlay?.switch_states, diagrams.vlOverlay?.injections); const [sldEditBusy, setSldEditBusy] = useState(false); // Edit mode is implicit: an open SLD on an editable tab (operable // switches or editable injections, never the N state) is always // editable, and closing the overlay returns it to read-only. There is // no user-facing toggle — the operator manipulates breakers / loads / // generators straight from the opened diagram. useEffect(() => { const ov = diagrams.vlOverlay; // Don't churn editMode during a re-fetch flash (a tab switch sets // `loading` true before the new switch_states / injections arrive). if (ov && ov.loading) return; const editable = !!ov && ov.tab !== 'n' && ( (!!ov.switch_states && Object.keys(ov.switch_states).length > 0) || (!!ov.injections && Object.keys(ov.injections).length > 0) ); if (editable && !sldTopologyEdit.editMode) sldTopologyEdit.setEditMode(true); else if (!editable && sldTopologyEdit.editMode) sldTopologyEdit.setEditMode(false); // `setEditMode` is a stable useCallback; `editMode` is read to avoid a // redundant set. Depending on the whole `sldTopologyEdit` object // (recreated each render) would only add churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [diagrams.vlOverlay, sldTopologyEdit.setEditMode, sldTopologyEdit.editMode]); const sldEditBaseActionId = useMemo(() => { const overlay = diagrams.vlOverlay; if (!overlay) return null; if (overlay.tab !== 'action') return null; return overlay.actionId && overlay.actionId.length > 0 ? overlay.actionId : null; }, [diagrams.vlOverlay]); // Target-topology preview: when the operator has staged switch toggles, // fetch a re-rendered SLD (target switch states + topological-colouring // connectivity, no load flow) and show it in place of the baseline. // Debounced so dragging through several toggles fires one request; a // sequence guard drops stale responses. const [sldPreview, setSldPreview] = useState<{ svg: string; metadata: string | null } | null>(null); const [sldPreviewLoading, setSldPreviewLoading] = useState(false); const sldPreviewSeqRef = useRef(0); const changedSwitchesKey = useMemo( () => JSON.stringify(sldTopologyEdit.changedSwitches), [sldTopologyEdit.changedSwitches], ); useEffect(() => { const overlay = diagrams.vlOverlay; const switches = sldTopologyEdit.changedSwitches; const vlName = overlay?.vlName; if (!overlay || !sldTopologyEdit.editMode || !vlName || Object.keys(switches).length === 0) { setSldPreview(null); setSldPreviewLoading(false); return; } const seq = ++sldPreviewSeqRef.current; setSldPreviewLoading(true); const timer = setTimeout(async () => { try { const res = await api.getSldTopologyPreview({ voltageLevelId: vlName, disconnectedElements: selectedContingency, switches, baseActionId: sldEditBaseActionId, }); if (seq !== sldPreviewSeqRef.current) return; setSldPreview({ svg: res.svg, metadata: res.sld_metadata ?? null }); } catch (e) { if (seq !== sldPreviewSeqRef.current) return; console.error('SLD topology preview failed:', e); setSldPreview(null); setError(apiErrorMessage(e, 'Topology preview failed')); } finally { if (seq === sldPreviewSeqRef.current) setSldPreviewLoading(false); } }, 280); return () => clearTimeout(timer); // changedSwitchesKey captures the switches dict by value; vlName + // editMode + baseAction are the other inputs. // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedSwitchesKey, sldTopologyEdit.editMode, diagrams.vlOverlay?.vlName, sldEditBaseActionId, selectedContingency]); const handleSimulateSldEdit = useCallback(async () => { const overlay = diagrams.vlOverlay; const switches = sldTopologyEdit.changedSwitches; const injections = sldTopologyEdit.changedInjections; const hasSwitches = Object.keys(switches).length > 0; const hasInjections = Object.keys(injections).length > 0; if (!overlay || (!hasSwitches && !hasInjections)) return; if (selectedContingency.length === 0) { setError('Select a contingency first.'); return; } const vlName = overlay.vlName; const ts = Date.now(); const userPart = `user_topo_${vlName}_${ts}`; const baseActionId = sldEditBaseActionId; const actionId = baseActionId ? `${baseActionId}+${userPart}` : userPart; // Split the staged injection retunes into the per-kind content keys the // backend expects (gens_p / loads_p → set_gen_p / set_load_p). const gensP: Record = {}; const loadsP: Record = {}; for (const ic of sldTopologyEdit.injectionChanges) { if (ic.kind === 'generator') gensP[ic.equipmentId] = ic.targetP; else loadsP[ic.equipmentId] = ic.targetP; } const actionContent: { switches?: Record; gens_p?: Record; loads_p?: Record; } = {}; if (hasSwitches) actionContent.switches = switches; if (Object.keys(gensP).length > 0) actionContent.gens_p = gensP; if (Object.keys(loadsP).length > 0) actionContent.loads_p = loadsP; interactionLogger.record('sld_topology_simulated', { voltage_level_id: vlName, switches, injections, combined_with: baseActionId, }); setSldEditBusy(true); try { const response = await api.simulateAndVariantDiagramStream({ action_id: actionId, disconnected_elements: selectedContingency, action_content: actionContent, lines_overloaded: result?.lines_overloaded ?? null, target_mw: null, target_tap: null, voltage_level_id: vlName, }); let metrics: Awaited> | null = null; let streamErr: string | null = null; for await (const raw of parseNdjsonStream(response)) { const event = raw as Record; if (event.type === 'metrics') { const { type: _t, ...rest } = event; void _t; metrics = rest as Awaited>; } else if (event.type === 'diagram') { const { type: _t, ...rest } = event; void _t; // The backend canonicalises combined ids (sorts the "+" // parts), so register the diagram under the id it actually // stored — taken from the metrics event that always precedes // the diagram event — not the raw request id. const registeredId = metrics?.action_id ?? actionId; diagrams.primeActionDiagram(registeredId, rest as unknown as DiagramData & { svg: string }, voltageLevels.length); } else if (event.type === 'error') { streamErr = (event.message as string) || 'stream error'; } } if (streamErr) throw new Error(streamErr); if (!metrics) throw new Error('Stream ended without metrics event'); const m = metrics as Awaited>; // Backend-canonical id (sorted "+" parts) — use it for the card // and the action-tab focus so every later lookup matches. const registeredId = m.action_id || actionId; const detail: ActionDetail = { description_unitaire: m.description_unitaire, rho_before: m.rho_before, rho_after: m.rho_after, max_rho: m.max_rho, max_rho_line: m.max_rho_line, is_rho_reduction: m.is_rho_reduction, is_islanded: m.is_islanded, n_components: m.n_components, disconnected_mw: m.disconnected_mw, non_convergence: m.non_convergence, lines_overloaded_after: m.lines_overloaded_after, half_open_overloads: m.half_open_overloads, // Carry the topology so the SLD/NAD highlight marks EVERY affected // feeder of a combined manual action (e.g. a generator redispatch // AND a load shedding at the same VL get highlighted, not just one). action_topology: m.action_topology, load_shedding_details: m.load_shedding_details, curtailment_details: m.curtailment_details, redispatch_details: m.redispatch_details, pst_details: m.pst_details, }; wrappedManualActionAdded(registeredId, detail, m.lines_overloaded || [], 'user'); sldTopologyEdit.reset(); sldTopologyEdit.setEditMode(false); // Switch the SLD overlay straight to the ACTION tab for the // freshly-computed action so the operator sees the post-action // state instead of staying on N-1. diagrams.handleVlDoubleClick(registeredId, vlName, 'action'); } catch (e: unknown) { console.error('SLD topology edit simulation failed:', e); setError(apiErrorMessage(e, 'Simulation failed')); } finally { setSldEditBusy(false); } }, [ diagrams, sldTopologyEdit, selectedContingency, sldEditBaseActionId, result?.lines_overloaded, voltageLevels.length, wrappedManualActionAdded, setError, ]); return { sldTopologyEdit, sldEditBusy, sldEditBaseActionId, sldPreview, sldPreviewLoading, handleSimulateUnsimulatedAction, handleSimulateSldEdit, }; }