// 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 { interactionLogger } from '../../utils/interactionLogger'; import { useModalKeyboard } from '../../hooks/useModalKeyboard'; import type { SettingsState } from '../../hooks/useSettings'; import { useSmoothPanZoom, type PanZoomMode } from '../../utils/smoothPanZoom'; import { colors } from '../../styles/tokens'; import { ACTION_TYPE_FILTER_TOKENS, ACTION_TYPE_LABELS } from '../../utils/actionTypes'; import type { ActionTypeKind } from '../../utils/actionTypes'; interface SettingsModalProps { settings: SettingsState; onApply: () => void; } const SettingsModal: React.FC = ({ settings, onApply }) => { // Pure client rendering preference (localStorage, not backend config) — // read from its own singleton rather than SettingsState. const { mode: panZoomMode, setMode: setPanZoomMode } = useSmoothPanZoom(); const { isSettingsOpen, settingsTab, setSettingsTab, networkPath, setNetworkPath, actionPath, setActionPath, layoutPath, setLayoutPath, outputFolderPath, setOutputFolderPath, configFilePath, setConfigFilePath, changeConfigFilePath, 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, monitoringFactor, setMonitoringFactor, linesMonitoringPath, setLinesMonitoringPath, preExistingOverloadThreshold, setPreExistingOverloadThreshold, pypowsyblFastMode, setPypowsyblFastMode, pickSettingsPath, handleCloseSettings, } = settings; // Escape-to-close + focus trap/restore (QW20). Called before the early // return so hook order stays stable across open/closed renders. const { containerRef, dialogProps } = useModalKeyboard({ isOpen: isSettingsOpen, onClose: handleCloseSettings, }); if (!isSettingsOpen) return null; // Resolve the active model descriptor (or null when the registry is // still loading). The set of parameter names it declares drives which // recommender inputs are visible. // // ``availableModels`` may be ``undefined`` when the parent test // suite hand-builds a partial ``SettingsState`` mock that pre-dates // the pluggable recommender feature — fall back to an empty list so // those tests don't crash on a ``find`` lookup of ``undefined``. const models = availableModels ?? []; const activeModel = models.find(m => m.name === recommenderModel) ?? null; const declaredParamNames = new Set((activeModel?.params ?? []).map(p => p.name)); // When `availableModels` is empty (initial load), the active model is // unknown, OR the active model declares NO params (the degraded // fallback served when `/api/models` errored, or a params_spec failure), // fall back to showing every input — same behaviour as before the // pluggable model selector existed. Without the empty-params guard a // single backend params_spec() exception would blank out every // parameter in this tab (regression). const showAll = models.length === 0 || activeModel === null || declaredParamNames.size === 0; const showField = (name: string): boolean => showAll || declaredParamNames.has(name); // When the active model declares `requires_overflow_graph`, the step // is not optional: the checkbox is forced ON + disabled so the // operator can see WHY it's locked rather than wondering where the // toggle went. The backend mirrors this guarantee // (`needs_graph = requires_overflow_graph OR compute_overflow_graph`) // so a direct API call can't bypass it either. const graphRequired = !!activeModel?.requires_overflow_graph; const tabButton = (id: 'paths' | 'recommender' | 'configurations', label: string) => ( ); return (
{/* Tabs */}
{tabButton('paths', 'Paths')} {tabButton('recommender', 'Recommender')} {tabButton('configurations', 'Configurations')}
{/* Paths Tab */} {settingsTab === 'paths' && (
Synchronized with the banner field
setNetworkPath(e.target.value)} placeholder="load your grid xiidm file path" style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
setActionPath(e.target.value)} style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
setLayoutPath(e.target.value)} style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
Session folders (JSON + PDF) are saved here. Leave empty to download JSON to browser.
setOutputFolderPath(e.target.value)} placeholder="e.g. /home/user/sessions" style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />

Path to the config.json settings file. Change this to use a config stored outside the repository. The file will be created from defaults if it does not exist.
setConfigFilePath(e.target.value)} onBlur={e => changeConfigFilePath(e.target.value).catch(err => console.error('Failed to change config file path', err))} placeholder="e.g. /home/user/my_costudy4grid_config.json" style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
)} {/* Recommender Tab */} {settingsTab === 'recommender' && (
{/* Model selector */}
{/* Compute Overflow Graph toggle: - models that REQUIRE the graph → forced checked + disabled, label suffixed with "required by this model" so the operator understands why the choice is locked; - models that don't require it → toggle hidden entirely (no choice to make, computing it would be wasted work). */} {activeModel && graphRequired && (
)} {activeModel && !graphRequired && ( // Models that don't consume the graph: expose the toggle // as an opt-in (default off). Useful only for ops who // want to inspect the overflow analysis alongside a // graph-agnostic recommender.
setComputeOverflowGraph(e.target.checked)} style={{ width: '16px', height: '16px' }} />
)}
{/* Parameter inputs — each one is rendered only when the active model declares it in params_spec. */} {[ { label: 'Min Line Reconnections', value: minLineReconnections, setter: setMinLineReconnections, id: 'minLineReconnections', name: 'min_line_reconnections' }, { label: 'Min Close Coupling', value: minCloseCoupling, setter: setMinCloseCoupling, id: 'minCloseCoupling', name: 'min_close_coupling' }, { label: 'Min Open Coupling', value: minOpenCoupling, setter: setMinOpenCoupling, id: 'minOpenCoupling', name: 'min_open_coupling' }, { label: 'Min Line Disconnections', value: minLineDisconnections, setter: setMinLineDisconnections, id: 'minLineDisconnections', name: 'min_line_disconnections' }, { label: 'Min PST Actions', value: minPst, setter: setMinPst, id: 'minPst', name: 'min_pst' }, { label: 'Min Load Shedding', value: minLoadShedding, setter: setMinLoadShedding, id: 'minLoadShedding', name: 'min_load_shedding' }, { label: 'Min Renewable Curtailment', value: minRenewableCurtailmentActions, setter: setMinRenewableCurtailmentActions, id: 'minRenewableCurtailment', name: 'min_renewable_curtailment_actions' }, { label: 'Min Redispatch', value: minRedispatch, setter: setMinRedispatch, id: 'minRedispatch', name: 'min_redispatch' }, ].filter(({ name }) => showField(name)).map(({ label, value, setter, id }) => (
setter(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
))} {showField('n_prioritized_actions') && (
setNPrioritizedActions(parseInt(e.target.value, 10))} style={{ width: '80px', padding: '5px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
)} {showField('ignore_reconnections') && (
setIgnoreReconnections(e.target.checked)} style={{ width: '16px', height: '16px' }} />
)}
None selected = all families. Select one or more to make the recommender propose ONLY those.
{ACTION_TYPE_FILTER_TOKENS.filter(t => t !== 'all').map(token => { const active = allowedActionTypes.includes(token); return ( ); })}
)} {/* Configurations Tab */} {settingsTab === 'configurations' && (
setMonitoringFactor(parseFloat(e.target.value))} style={{ padding: '6px', width: '80px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} /> Multiplier applied to standard limits (e.g., 0.95)
setLinesMonitoringPath(e.target.value)} placeholder="Leave empty for IGNORE_LINES_MONITORING=True" style={{ flex: 1, padding: '8px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
setPreExistingOverloadThreshold(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px', border: `1px solid ${colors.border}`, borderRadius: '4px' }} />
Pre-existing overloads excluded from N-1 & max loading unless worsened by this fraction (default 2%)
setPypowsyblFastMode(e.target.checked)} style={{ width: '16px', height: '16px' }} />
Runs transformer tap-changer voltage control in the faster AFTER_GENERATOR mode (~7× fewer load-flow iterations, same currents). Applied when the analysis runs, so re-run Analyze & Suggest after changing it.
How the diagram renders during a pan/zoom gesture (opt-in, default OFF; applies on the next gesture). {' '}Default repaints the vector each frame — safe everywhere. {' '}GPU transform composites the live SVG — smoother on hardware-accelerated browsers, can stutter on software / remote-desktop / VDI. {' '}Bitmap snapshot rasterises the diagram once at gesture start and transforms that bitmap — much smoother on large grids (even in software), at the cost of a brief raster when the gesture begins.
)} {/* Footer buttons */}
); }; export default SettingsModal;