Co-Study4Grid / frontend /src /hooks /useManualSimulation.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
17.8 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
// 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<void>;
/** Simulate the staged SLD breaker/injection edit as one manual action. */
handleSimulateSldEdit: () => Promise<void>;
}
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<ReturnType<typeof api.simulateManualAction>> | null = null;
let streamErr: string | null = null;
for await (const raw of parseNdjsonStream(response)) {
const event = raw as Record<string, unknown>;
if (event.type === 'metrics') {
const { type: _t, ...rest } = event;
void _t;
metrics = rest as Awaited<ReturnType<typeof api.simulateManualAction>>;
} 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<string, number> = {};
const loadsP: Record<string, number> = {};
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<string, boolean>;
gens_p?: Record<string, number>;
loads_p?: Record<string, number>;
} = {};
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<ReturnType<typeof api.simulateManualAction>> | null = null;
let streamErr: string | null = null;
for await (const raw of parseNdjsonStream(response)) {
const event = raw as Record<string, unknown>;
if (event.type === 'metrics') {
const { type: _t, ...rest } = event;
void _t;
metrics = rest as Awaited<ReturnType<typeof api.simulateManualAction>>;
} 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<ReturnType<typeof api.simulateManualAction>>;
// 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,
};
}