// 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 } from 'react'; import { api } from '../api'; import type { ModelDescriptor, UserConfig } from '../api'; import type { SettingsBackup } from '../types'; import { interactionLogger } from '../utils/interactionLogger'; export interface SettingsState { configFilePath: string; setConfigFilePath: (v: string) => void; lastActiveConfigFilePath: string; changeConfigFilePath: (newPath: string) => Promise; configRequestFromUserConfig: (cfg: UserConfig) => ReturnType; networkPath: string; setNetworkPath: (v: string) => void; actionPath: string; setActionPath: (v: string) => void; layoutPath: string; setLayoutPath: (v: string) => void; outputFolderPath: string; setOutputFolderPath: (v: string) => void; minLineReconnections: number; setMinLineReconnections: (v: number) => void; minCloseCoupling: number; setMinCloseCoupling: (v: number) => void; minOpenCoupling: number; setMinOpenCoupling: (v: number) => void; minLineDisconnections: number; setMinLineDisconnections: (v: number) => void; nPrioritizedActions: number; setNPrioritizedActions: (v: number) => void; minPst: number; setMinPst: (v: number) => void; minLoadShedding: number; setMinLoadShedding: (v: number) => void; minRenewableCurtailmentActions: number; setMinRenewableCurtailmentActions: (v: number) => void; minRedispatch: number; setMinRedispatch: (v: number) => void; allowedActionTypes: string[]; setAllowedActionTypes: (v: string[]) => void; ignoreReconnections: boolean; setIgnoreReconnections: (v: boolean) => void; recommenderModel: string; setRecommenderModel: (v: string) => void; computeOverflowGraph: boolean; setComputeOverflowGraph: (v: boolean) => void; availableModels: ModelDescriptor[]; linesMonitoringPath: string; setLinesMonitoringPath: (v: string) => void; monitoredLinesCount: number; setMonitoredLinesCount: (v: number) => void; totalLinesCount: number; setTotalLinesCount: (v: number) => void; showMonitoringWarning: boolean; setShowMonitoringWarning: (v: boolean) => void; monitoringFactor: number; setMonitoringFactor: (v: number) => void; preExistingOverloadThreshold: number; setPreExistingOverloadThreshold: (v: number) => void; pypowsyblFastMode: boolean; setPypowsyblFastMode: (v: boolean) => void; actionDictFileName: string | null; setActionDictFileName: (v: string | null) => void; actionDictStats: { reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number } | null; setActionDictStats: (v: { reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number } | null) => void; isSettingsOpen: boolean; setIsSettingsOpen: (v: boolean) => void; settingsTab: 'recommender' | 'configurations' | 'paths'; setSettingsTab: (v: 'recommender' | 'configurations' | 'paths') => void; settingsBackup: SettingsBackup | null; setSettingsBackup: (v: SettingsBackup | null) => void; pickSettingsPath: (type: 'file' | 'dir', setter: (path: string) => void) => Promise; handleOpenSettings: (tab?: 'recommender' | 'configurations' | 'paths') => void; handleCloseSettings: () => void; buildConfigRequest: () => { network_path: string; action_file_path: string; layout_path: string; min_line_reconnections: number; min_close_coupling: number; min_open_coupling: number; min_line_disconnections: number; min_pst: number; min_load_shedding: number; min_renewable_curtailment_actions: number; min_redispatch: number; allowed_action_types: string[]; n_prioritized_actions: number; lines_monitoring_path: string; monitoring_factor: number; pre_existing_overload_threshold: number; ignore_reconnections: boolean; pypowsybl_fast_mode: boolean; model: string; compute_overflow_graph: boolean; }; applyConfigResponse: (configRes: Record) => void; createCurrentBackup: () => SettingsBackup; } export function useSettings(): SettingsState { const [configFilePath, setConfigFilePath] = useState(''); const lastConfigFilePathRef = useRef(''); const [networkPath, setNetworkPath] = useState(localStorage.getItem('networkPath') || ''); const [actionPath, setActionPath] = useState(localStorage.getItem('actionPath') || ''); const [layoutPath, setLayoutPath] = useState(localStorage.getItem('layoutPath') || ''); const [outputFolderPath, setOutputFolderPath] = useState(localStorage.getItem('outputFolderPath') || ''); const [minLineReconnections, setMinLineReconnections] = useState(2.0); const [minCloseCoupling, setMinCloseCoupling] = useState(3.0); const [minOpenCoupling, setMinOpenCoupling] = useState(2.0); const [minLineDisconnections, setMinLineDisconnections] = useState(3.0); const [nPrioritizedActions, setNPrioritizedActions] = useState(10); const [minPst, setMinPst] = useState(1.0); const [minLoadShedding, setMinLoadShedding] = useState(0.0); const [minRenewableCurtailmentActions, setMinRenewableCurtailmentActions] = useState(0.0); const [minRedispatch, setMinRedispatch] = useState(0.0); const [allowedActionTypes, setAllowedActionTypes] = useState([]); const [ignoreReconnections, setIgnoreReconnections] = useState(false); const [recommenderModel, setRecommenderModel] = useState(localStorage.getItem('recommenderModel') || 'expert'); const [computeOverflowGraph, setComputeOverflowGraph] = useState( (localStorage.getItem('computeOverflowGraph') ?? 'true') !== 'false' ); const [availableModels, setAvailableModels] = useState([]); const [linesMonitoringPath, setLinesMonitoringPath] = useState(''); const [monitoredLinesCount, setMonitoredLinesCount] = useState(0); const [totalLinesCount, setTotalLinesCount] = useState(0); const [showMonitoringWarning, setShowMonitoringWarning] = useState(false); const [monitoringFactor, setMonitoringFactor] = useState(0.95); const [preExistingOverloadThreshold, setPreExistingOverloadThreshold] = useState(0.02); const [pypowsyblFastMode, setPypowsyblFastMode] = useState(true); const [actionDictFileName, setActionDictFileName] = useState(null); const [actionDictStats, setActionDictStats] = useState<{ reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number } | null>(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsTab, setSettingsTab] = useState<'recommender' | 'configurations' | 'paths'>('paths'); const [settingsBackup, setSettingsBackup] = useState(null); const configLoadedRef = useRef(false); const applyLoadedConfig = useCallback((cfg: UserConfig) => { if (cfg.network_path !== undefined) setNetworkPath(cfg.network_path); if (cfg.action_file_path !== undefined) setActionPath(cfg.action_file_path); if (cfg.layout_path !== undefined) setLayoutPath(cfg.layout_path); if (cfg.output_folder_path !== undefined) setOutputFolderPath(cfg.output_folder_path); if (cfg.lines_monitoring_path !== undefined) setLinesMonitoringPath(cfg.lines_monitoring_path); if (cfg.min_line_reconnections !== undefined) setMinLineReconnections(cfg.min_line_reconnections); if (cfg.min_close_coupling !== undefined) setMinCloseCoupling(cfg.min_close_coupling); if (cfg.min_open_coupling !== undefined) setMinOpenCoupling(cfg.min_open_coupling); if (cfg.min_line_disconnections !== undefined) setMinLineDisconnections(cfg.min_line_disconnections); if (cfg.min_pst !== undefined) setMinPst(cfg.min_pst); if (cfg.min_load_shedding !== undefined) setMinLoadShedding(cfg.min_load_shedding); if (cfg.min_renewable_curtailment_actions !== undefined) setMinRenewableCurtailmentActions(cfg.min_renewable_curtailment_actions); if (cfg.min_redispatch !== undefined) setMinRedispatch(cfg.min_redispatch); if (cfg.allowed_action_types !== undefined) setAllowedActionTypes(cfg.allowed_action_types ?? []); if (cfg.n_prioritized_actions !== undefined) setNPrioritizedActions(cfg.n_prioritized_actions); if (cfg.monitoring_factor !== undefined) setMonitoringFactor(cfg.monitoring_factor); if (cfg.pre_existing_overload_threshold !== undefined) setPreExistingOverloadThreshold(cfg.pre_existing_overload_threshold); if (cfg.ignore_reconnections !== undefined) setIgnoreReconnections(cfg.ignore_reconnections); if (cfg.pypowsybl_fast_mode !== undefined) setPypowsyblFastMode(cfg.pypowsybl_fast_mode); if (cfg.model !== undefined && cfg.model) setRecommenderModel(cfg.model); if (cfg.compute_overflow_graph !== undefined) setComputeOverflowGraph(cfg.compute_overflow_graph); }, []); useEffect(() => { Promise.all([api.getUserConfig(), api.getConfigFilePath()]) .then(([cfg, cfgPath]) => { applyLoadedConfig(cfg); setConfigFilePath(cfgPath); lastConfigFilePathRef.current = cfgPath; configLoadedRef.current = true; }) .catch(() => { console.warn('Failed to load user config from backend, using defaults'); configLoadedRef.current = true; }); // Guard against test stubs of the `api` module that don't define // `getModels` (most pre-existing test suites only mock the methods // they exercise). Without this check the effect throws // `api.getModels is not a function` on first render and the whole // App tree fails to mount. if (typeof api.getModels === 'function') { api.getModels() .then(({ models }) => setAvailableModels(models)) .catch(() => { console.warn('Failed to load recommender models, using static defaults'); setAvailableModels([{ name: 'expert', label: 'Expert system', requires_overflow_graph: true, is_default: true, params: [], }]); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Keep ``computeOverflowGraph`` consistent with the active model's // ``requires_overflow_graph`` flag: when the operator switches to a // model that needs the graph, force the toggle on so the saved // user-config matches what the backend will actually do. The UI also // greys out the checkbox for these models — this effect is the // belt-and-suspenders that makes sure persisted state stays correct // when the user toggled it off on a previous (non-requiring) model and // then picked a requiring one. useEffect(() => { if (!availableModels.length) return; const active = availableModels.find(m => m.name === recommenderModel); if (active?.requires_overflow_graph && !computeOverflowGraph) { setComputeOverflowGraph(true); } }, [recommenderModel, availableModels, computeOverflowGraph]); // Push the active recommender model to the running RecommenderService // whenever the operator changes it (from Settings OR from the new // dropdown above the Analyze & Suggest button). Without this the // backend keeps the previously-applied model (set on /api/config) and // a re-run produces suggestions from the OLD model regardless of // the dropdown choice. Cheap: the endpoint only updates two // attributes — no network reload, no action dictionary rebuild. const lastPushedModelRef = useRef<{ model: string; computeOverflowGraph: boolean } | null>(null); useEffect(() => { if (!configLoadedRef.current) return; const last = lastPushedModelRef.current; if (last && last.model === recommenderModel && last.computeOverflowGraph === computeOverflowGraph) return; lastPushedModelRef.current = { model: recommenderModel, computeOverflowGraph }; if (typeof api.setRecommenderModel !== 'function') return; api.setRecommenderModel(recommenderModel, computeOverflowGraph).catch(err => { console.warn('Failed to push recommender-model to backend', err); }); }, [recommenderModel, computeOverflowGraph]); const changeConfigFilePath = useCallback(async (newPath: string): Promise => { const result = await api.setConfigFilePath(newPath); setConfigFilePath(result.config_file_path); lastConfigFilePathRef.current = result.config_file_path; configLoadedRef.current = false; applyLoadedConfig(result.config); configLoadedRef.current = true; return result.config; }, [applyLoadedConfig]); const configRequestFromUserConfig = useCallback((cfg: UserConfig) => ({ network_path: cfg.network_path, action_file_path: cfg.action_file_path, layout_path: cfg.layout_path ?? '', min_line_reconnections: cfg.min_line_reconnections, min_close_coupling: cfg.min_close_coupling, min_open_coupling: cfg.min_open_coupling, min_line_disconnections: cfg.min_line_disconnections, min_pst: cfg.min_pst, min_load_shedding: cfg.min_load_shedding, min_renewable_curtailment_actions: cfg.min_renewable_curtailment_actions, min_redispatch: cfg.min_redispatch, allowed_action_types: cfg.allowed_action_types ?? [], n_prioritized_actions: cfg.n_prioritized_actions, lines_monitoring_path: cfg.lines_monitoring_path ?? '', monitoring_factor: cfg.monitoring_factor, pre_existing_overload_threshold: cfg.pre_existing_overload_threshold, ignore_reconnections: cfg.ignore_reconnections, pypowsybl_fast_mode: cfg.pypowsybl_fast_mode, model: cfg.model ?? 'expert', compute_overflow_graph: cfg.compute_overflow_graph ?? true, }), []); useEffect(() => { if (!configLoadedRef.current) return; const configToSave: UserConfig = { network_path: networkPath, action_file_path: actionPath, layout_path: layoutPath, output_folder_path: outputFolderPath, lines_monitoring_path: linesMonitoringPath, 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, monitoring_factor: monitoringFactor, pre_existing_overload_threshold: preExistingOverloadThreshold, ignore_reconnections: ignoreReconnections, pypowsybl_fast_mode: pypowsyblFastMode, model: recommenderModel, compute_overflow_graph: computeOverflowGraph, }; localStorage.setItem('networkPath', networkPath); localStorage.setItem('actionPath', actionPath); localStorage.setItem('layoutPath', layoutPath); localStorage.setItem('outputFolderPath', outputFolderPath); localStorage.setItem('recommenderModel', recommenderModel); localStorage.setItem('computeOverflowGraph', String(computeOverflowGraph)); api.saveUserConfig(configToSave).catch(() => { console.warn('Failed to persist user config to backend'); }); }, [networkPath, actionPath, layoutPath, outputFolderPath, linesMonitoringPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, recommenderModel, computeOverflowGraph]); const pickSettingsPath = useCallback(async (type: 'file' | 'dir', setter: (path: string) => void) => { try { const path = await api.pickPath(type); if (path) { interactionLogger.record('path_picked', { type, path }); setter(path); } } catch (e) { const baseMessage = e instanceof Error ? e.message : 'Failed to open file picker'; const axiosErr = e as { response?: { status?: number }, code?: string }; const status = axiosErr?.response?.status; const isNetworkError = !axiosErr?.response && (axiosErr?.code === 'ERR_NETWORK' || /Network Error/i.test(baseMessage)); const isMissingEndpoint = status === 404; const hint = (isNetworkError || isMissingEndpoint) ? '\n\nIs the Co-Study4Grid backend running on http://127.0.0.1:8000? ' + 'If it is, it may be an outdated instance — restart it after pulling the latest code.' : ''; console.error('Failed to open file picker:', e); alert( `Unable to open the native file picker:\n${baseMessage}${hint}\n\n` + 'Paste the path into the text field manually instead.' ); } }, []); const createCurrentBackup = useCallback((): SettingsBackup => ({ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, }), [networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode]); const handleOpenSettings = useCallback((tab: 'recommender' | 'configurations' | 'paths' = 'paths') => { interactionLogger.record('settings_opened', { tab }); setSettingsBackup(createCurrentBackup()); setSettingsTab(tab); setIsSettingsOpen(true); }, [createCurrentBackup]); const handleCloseSettings = useCallback(() => { interactionLogger.record('settings_cancelled'); if (settingsBackup) { if (settingsBackup.networkPath !== undefined) setNetworkPath(settingsBackup.networkPath); if (settingsBackup.actionPath !== undefined) setActionPath(settingsBackup.actionPath); if (settingsBackup.layoutPath !== undefined) setLayoutPath(settingsBackup.layoutPath); if (settingsBackup.outputFolderPath !== undefined) setOutputFolderPath(settingsBackup.outputFolderPath); setMinLineReconnections(settingsBackup.minLineReconnections); setMinCloseCoupling(settingsBackup.minCloseCoupling); setMinOpenCoupling(settingsBackup.minOpenCoupling); setMinLineDisconnections(settingsBackup.minLineDisconnections); setMinLoadShedding(settingsBackup.minLoadShedding ?? 0.0); setMinRenewableCurtailmentActions(settingsBackup.minRenewableCurtailmentActions ?? 0.0); setMinRedispatch(settingsBackup.minRedispatch ?? 0.0); setAllowedActionTypes(settingsBackup.allowedActionTypes ?? []); setNPrioritizedActions(settingsBackup.nPrioritizedActions); setLinesMonitoringPath(settingsBackup.linesMonitoringPath); setMonitoringFactor(settingsBackup.monitoringFactor); setPreExistingOverloadThreshold(settingsBackup.preExistingOverloadThreshold); setIgnoreReconnections(settingsBackup.ignoreReconnections ?? false); setPypowsyblFastMode(settingsBackup.pypowsyblFastMode ?? true); } setIsSettingsOpen(false); }, [settingsBackup]); const buildConfigRequest = useCallback(() => ({ network_path: networkPath, action_file_path: actionPath, layout_path: layoutPath, 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, model: recommenderModel, compute_overflow_graph: computeOverflowGraph, }), [networkPath, actionPath, layoutPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, recommenderModel, computeOverflowGraph]); const applyConfigResponse = useCallback((configRes: Record) => { if (configRes && configRes.total_lines_count !== undefined) { setMonitoredLinesCount(configRes.monitored_lines_count as number); setTotalLinesCount(configRes.total_lines_count as number); if ((configRes.monitored_lines_count as number) < (configRes.total_lines_count as number)) { setShowMonitoringWarning(true); } } if (configRes?.action_dict_file_name) setActionDictFileName(configRes.action_dict_file_name as string); if (configRes?.action_dict_stats) setActionDictStats(configRes.action_dict_stats as { reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number }); }, []); return useMemo(() => ({ configFilePath, setConfigFilePath, changeConfigFilePath, lastActiveConfigFilePath: lastConfigFilePathRef.current, networkPath, setNetworkPath, actionPath, setActionPath, layoutPath, setLayoutPath, outputFolderPath, setOutputFolderPath, minLineReconnections, setMinLineReconnections, minCloseCoupling, setMinCloseCoupling, minOpenCoupling, setMinOpenCoupling, minLineDisconnections, setMinLineDisconnections, nPrioritizedActions, setNPrioritizedActions, minPst, setMinPst, minLoadShedding, setMinLoadShedding, minRenewableCurtailmentActions, setMinRenewableCurtailmentActions, minRedispatch, setMinRedispatch, allowedActionTypes, setAllowedActionTypes, ignoreReconnections, setIgnoreReconnections, recommenderModel, setRecommenderModel, computeOverflowGraph, setComputeOverflowGraph, availableModels, linesMonitoringPath, setLinesMonitoringPath, monitoredLinesCount, setMonitoredLinesCount, totalLinesCount, setTotalLinesCount, showMonitoringWarning, setShowMonitoringWarning, monitoringFactor, setMonitoringFactor, preExistingOverloadThreshold, setPreExistingOverloadThreshold, pypowsyblFastMode, setPypowsyblFastMode, actionDictFileName, setActionDictFileName, actionDictStats, setActionDictStats, isSettingsOpen, setIsSettingsOpen, settingsTab, setSettingsTab, settingsBackup, setSettingsBackup, pickSettingsPath, handleOpenSettings, handleCloseSettings, buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup, }), [ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, ignoreReconnections, recommenderModel, computeOverflowGraph, availableModels, linesMonitoringPath, monitoredLinesCount, totalLinesCount, showMonitoringWarning, monitoringFactor, preExistingOverloadThreshold, pypowsyblFastMode, actionDictFileName, actionDictStats, isSettingsOpen, settingsTab, settingsBackup, configFilePath, changeConfigFilePath, pickSettingsPath, handleOpenSettings, handleCloseSettings, buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup ]); }