From 7c0b21280f55325ff7ca983c9e679680e8c0fa26 Mon Sep 17 00:00:00 2001 From: marota Date: Tue, 10 Mar 2026 21:19:25 +0100 Subject: [PATCH 01/16] feat: include pst_tap in enriched action topology This commit updates RecommenderService to extract and include PST tap information in the action_topology field of enriched actions, ensuring visibility in the UI. --- expert_backend/services/recommender_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index 1e0a4c79..990991a4 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -91,7 +91,7 @@ def _enrich_actions(self, prioritized_actions_dict): if action_obj is not None: topo = {} # pypowsybl Actions use these fields - for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "substations", "switches"): + for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches"): val = getattr(action_obj, field, None) if val is None and isinstance(action_obj, dict): val = action_obj.get(field) From 1c71730eaf381a642164f19deb2de12796cd5909 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 07:23:29 +0000 Subject: [PATCH 02/16] feat: add Save Results button to export session as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a '💾 Save Results' button in the header that assembles the full analysis session into a structured JSON file and triggers a browser download. The exported file includes: - Configuration: all settings (network/action paths, thresholds, monitoring factor, flags, etc.) - Contingency: disconnected element, selected overloads, monitor_deselected flag - Overload lists: N overloads, N-1 overloads, and resolved overloads - Overflow graph: PDF URL and path - Analysis: message, DC fallback flag, action score table, and all action cards with rho_before/rho_after arrays, max_rho, topology, convergence status, plus per-action status tags: - is_selected (starred by user) - is_suggested (recommended by the expert_op4grid engine) - is_rejected (explicitly rejected) - is_manually_simulated (added via manual search) Also adds a new `SessionResult` TypeScript type hierarchy in types.ts (`SessionResult`, `SavedActionEntry`, `SavedActionStatus`). The button is disabled until at least a contingency is selected. The filename includes the contingency name and an ISO timestamp. https://claude.ai/code/session_01JgtV5Uj3vk9J41XbDSFc7g --- frontend/src/App.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++- frontend/src/types.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c228938..ed0773ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { getIdMap, invalidateIdMapCache, } from './utils/svgUtils'; import { processSvgAsync } from './utils/svgWorkerClient'; -import type { ActionDetail, AnalysisResult, DiagramData, ViewBox, MetadataIndex, TabId, SettingsBackup, VlOverlay, SldTab, FlowDelta, AssetDelta } from './types'; +import type { ActionDetail, AnalysisResult, DiagramData, ViewBox, MetadataIndex, TabId, SettingsBackup, VlOverlay, SldTab, FlowDelta, AssetDelta, SessionResult } from './types'; function App() { // ===== Configuration State ===== @@ -668,6 +668,87 @@ function App() { setActionViewMode(mode); }, []); + // ===== Save Results ===== + const handleSaveResults = useCallback(() => { + const savedActions: SessionResult['analysis'] = result + ? { + message: result.message, + dc_fallback: result.dc_fallback, + action_scores: result.action_scores, + actions: Object.fromEntries( + Object.entries(result.actions).map(([id, detail]) => [ + id, + { + description_unitaire: detail.description_unitaire, + rho_before: detail.rho_before, + rho_after: detail.rho_after, + max_rho: detail.max_rho, + max_rho_line: detail.max_rho_line, + is_rho_reduction: detail.is_rho_reduction, + non_convergence: detail.non_convergence, + action_topology: detail.action_topology, + status: { + is_selected: selectedActionIds.has(id), + is_suggested: !manuallyAddedIds.has(id), + is_rejected: rejectedActionIds.has(id), + is_manually_simulated: manuallyAddedIds.has(id), + }, + }, + ]) + ), + } + : null; + + const session: SessionResult = { + saved_at: new Date().toISOString(), + configuration: { + network_path: networkPath, + action_file_path: actionPath, + min_line_reconnections: minLineReconnections, + min_close_coupling: minCloseCoupling, + min_open_coupling: minOpenCoupling, + min_line_disconnections: minLineDisconnections, + n_prioritized_actions: nPrioritizedActions, + lines_monitoring_path: linesMonitoringPath, + monitoring_factor: monitoringFactor, + pre_existing_overload_threshold: preExistingOverloadThreshold, + ignore_reconnections: ignoreReconnections, + pypowsybl_fast_mode: pypowsyblFastMode, + }, + contingency: { + disconnected_element: selectedBranch, + selected_overloads: Array.from(selectedOverloads), + monitor_deselected: monitorDeselected, + }, + overloads: { + n_overloads: nDiagram?.lines_overloaded || [], + n1_overloads: n1Diagram?.lines_overloaded || [], + resolved_overloads: result?.lines_overloaded || [], + }, + overflow_graph: result?.pdf_url + ? { pdf_url: result.pdf_url, pdf_path: result.pdf_path ?? null } + : null, + analysis: savedActions, + }; + + const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const contingencyLabel = selectedBranch ? `_${selectedBranch.replace(/[^a-zA-Z0-9_-]/g, '_')}` : ''; + a.download = `expertassist_session${contingencyLabel}_${ts}.json`; + a.click(); + URL.revokeObjectURL(url); + }, [ + result, selectedActionIds, manuallyAddedIds, rejectedActionIds, + networkPath, actionPath, minLineReconnections, minCloseCoupling, minOpenCoupling, + minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, + preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, + selectedBranch, selectedOverloads, monitorDeselected, + nDiagram, n1Diagram, + ]); + // ===== SLD Overlay ===== const [vlOverlay, setVlOverlay] = useState(null); @@ -1198,6 +1279,21 @@ function App() { {configLoading ? 'âŗ Loading...' : '🔄 Load Study'} + + - - - + {settingsTab === 'paths' && ( +
+
+ +
Synchronized with the banner field
+
+ setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + +
+
+
+ +
+ setActionPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', 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 #ccc', borderRadius: '4px' }} /> + +
+
+
+ )} + {settingsTab === 'recommender' && (
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dfd3e5ef..5fd609bc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -108,6 +108,18 @@ export const api = { ); return response.data.path; }, + saveSession: async (params: { + session_name: string; + json_content: string; + pdf_path: string | null; + output_folder_path: string; + }): Promise<{ session_folder: string }> => { + const response = await axios.post<{ session_folder: string }>( + `${API_BASE_URL}/api/save-session`, + params + ); + return response.data; + }, getNSld: async (voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string }> => { const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string }>( `${API_BASE_URL}/api/n-sld`, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4351668c..5fd04702 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -122,11 +122,17 @@ export interface MetadataIndex { export type TabId = 'n' | 'n-1' | 'action' | 'overflow'; export interface SettingsBackup { + // Paths tab (new) + networkPath?: string; + actionPath?: string; + outputFolderPath?: string; + // Recommender tab minLineReconnections: number; minCloseCoupling: number; minOpenCoupling: number; minLineDisconnections: number; nPrioritizedActions: number; + // Configurations tab linesMonitoringPath: string; monitoringFactor: number; preExistingOverloadThreshold: number; diff --git a/standalone_interface.html b/standalone_interface.html index eda879f8..e2f932c7 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -989,6 +989,7 @@ function App() { const [networkPath, setNetworkPath] = useState(localStorage.getItem('networkPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z_dijon_only'); const [actionPath, setActionPath] = useState(localStorage.getItem('actionPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/action_space/reduced_model_actions_20240828T0100Z_new_dijon.json'); + const [outputFolderPath, setOutputFolderPath] = useState(localStorage.getItem('outputFolderPath') || ''); const [linesMonitoringPath, setLinesMonitoringPath] = useState(localStorage.getItem('linesMonitoringPath') || ''); const initialMountRef = useRef(true); @@ -1017,21 +1018,27 @@ const [showMonitoringWarning, setShowMonitoringWarning] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [settingsTab, setSettingsTab] = useState('recommender'); + const [settingsTab, setSettingsTab] = useState('paths'); const [settingsBackup, setSettingsBackup] = useState(null); const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisRan, setAnalysisRan] = useState(false); const [manualActionIds, setManualActionIds] = useState(new Set()); const [manuallyAddedIds, setManuallyAddedIds] = useState(new Set()); const [rejectedActionIds, setRejectedActionIds] = useState(new Set()); + // Tracks every action ID ever returned by the recommender — separate from manuallyAddedIds + // so an action can be both is_suggested AND is_manually_simulated. + const [suggestedByRecommenderIds, setSuggestedByRecommenderIds] = useState(new Set()); const [suggestedTab, setSuggestedTab] = useState('prioritized'); const [dismissedSelectedWarning, setDismissedSelectedWarning] = useState(false); const [dismissedRejectedWarning, setDismissedRejectedWarning] = useState(false); const [dismissedRecommenderWarning, setDismissedRecommenderWarning] = useState(false); const [pendingAnalysisResult, setPendingAnalysisResult] = useState(null); - const handleOpenSettings = (tab = 'recommender') => { + const handleOpenSettings = (tab = 'paths') => { setSettingsBackup({ + networkPath, + actionPath, + outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, @@ -1049,6 +1056,9 @@ const handleCloseSettings = () => { if (settingsBackup) { + if (settingsBackup.networkPath !== undefined) setNetworkPath(settingsBackup.networkPath); + if (settingsBackup.actionPath !== undefined) setActionPath(settingsBackup.actionPath); + if (settingsBackup.outputFolderPath !== undefined) setOutputFolderPath(settingsBackup.outputFolderPath); setMinLineReconnections(settingsBackup.minLineReconnections); setMinCloseCoupling(settingsBackup.minCloseCoupling); setMinOpenCoupling(settingsBackup.minOpenCoupling); @@ -1076,7 +1086,7 @@ setOriginalViewBox(null); setResult(null); setPendingAnalysisResult(null); setSelectedActionId(null); - setManualActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); + setManualActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); setSuggestedByRecommenderIds(new Set()); setAnalysisLoading(false); setAnalysisRan(false); setSelectedOverloads(new Set()); setMonitorDeselected(false); setActiveTab('n'); setActionViewMode('network'); @@ -1113,6 +1123,9 @@ } setSettingsBackup({ + networkPath, + actionPath, + outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, @@ -1213,6 +1226,7 @@ useEffect(() => { localStorage.setItem('networkPath', networkPath); }, [networkPath]); useEffect(() => { localStorage.setItem('actionPath', actionPath); }, [actionPath]); + useEffect(() => { localStorage.setItem('outputFolderPath', outputFolderPath); }, [outputFolderPath]); useEffect(() => { localStorage.setItem('linesMonitoringPath', linesMonitoringPath); }, [linesMonitoringPath]); useEffect(() => { localStorage.setItem('ignoreReconnections', ignoreReconnections); }, [ignoreReconnections]); useEffect(() => { localStorage.setItem('pypowsyblFastMode', pypowsyblFastMode); }, [pypowsyblFastMode]); @@ -1239,7 +1253,7 @@ // Analysis setResult(null); setPendingAnalysisResult(null); setSelectedActionId(null); - setManualActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); + setManualActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); setSuggestedByRecommenderIds(new Set()); setAnalysisLoading(false); setAnalysisRan(false); // Analysis flow setSelectedOverloads(new Set()); setMonitorDeselected(false); @@ -1398,6 +1412,7 @@ setManualActionIds(new Set()); setManuallyAddedIds(new Set()); setRejectedActionIds(new Set()); + setSuggestedByRecommenderIds(new Set()); setActionDiagram(null); setN1Diagram(null); setActiveTab('n'); @@ -1469,10 +1484,99 @@ } }, [n1Diagram]); + const handleSaveSession = async () => { + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const contingencyLabel = selectedBranch ? `_${selectedBranch.replace(/[^a-zA-Z0-9_-]/g, '_')}` : ''; + const sessionName = `expertassist_session${contingencyLabel}_${ts}`; + + // Build the session JSON (same structure as the React app) + const session = { + saved_at: new Date().toISOString(), + configuration: { + network_path: networkPath, + action_file_path: actionPath, + min_line_reconnections: minLineReconnections, + min_close_coupling: minCloseCoupling, + min_open_coupling: minOpenCoupling, + min_line_disconnections: minLineDisconnections, + n_prioritized_actions: nPrioritizedActions, + lines_monitoring_path: linesMonitoringPath, + monitoring_factor: monitoringFactor, + pre_existing_overload_threshold: preExistingOverloadThreshold, + ignore_reconnections: ignoreReconnections, + pypowsybl_fast_mode: pypowsyblFastMode, + }, + contingency: { + disconnected_element: selectedBranch, + selected_overloads: Array.from(selectedOverloads), + monitor_deselected: monitorDeselected, + }, + overloads: { + n_overloads: nDiagram?.lines_overloaded ?? [], + n1_overloads: n1Diagram?.lines_overloaded ?? [], + resolved_overloads: result?.lines_overloaded ?? [], + }, + overflow_graph: result?.pdf_url + ? { pdf_url: result.pdf_url, pdf_path: result.pdf_path ?? null } + : null, + analysis: result + ? { + message: result.message, + dc_fallback: result.dc_fallback, + action_scores: result.action_scores, + actions: Object.fromEntries( + Object.entries(result.actions || {}).map(([id, detail]) => [ + id, + { + description_unitaire: detail.description_unitaire, + rho_before: detail.rho_before, + rho_after: detail.rho_after, + max_rho: detail.max_rho, + max_rho_line: detail.max_rho_line, + is_rho_reduction: detail.is_rho_reduction, + non_convergence: detail.non_convergence, + action_topology: detail.action_topology, + status: { + is_selected: manualActionIds.has(id), + is_suggested: suggestedByRecommenderIds.has(id), + is_rejected: rejectedActionIds.has(id), + is_manually_simulated: manuallyAddedIds.has(id), + }, + }, + ]) + ), + } + : null, + }; + + if (outputFolderPath) { + try { + const res = await axios.post(API_BASE + '/api/save-session', { + session_name: sessionName, + json_content: JSON.stringify(session, null, 2), + pdf_path: result?.pdf_path ?? null, + output_folder_path: outputFolderPath, + }); + setInfoMessage(`Session saved to: ${res.data.session_folder}`); + } catch (err) { + setError('Failed to save session: ' + (err.response?.data?.detail || err.message)); + } + } else { + // Fallback: browser download of JSON + const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${sessionName}.json`; + a.click(); + URL.revokeObjectURL(url); + } + }; + const handleRunAnalysis = async () => { if (!selectedBranch) return; - setAnalysisLoading(true); - setError(''); + setAnalysisLoading(true); + setError(''); setInfoMessage(''); setAnalysisRan(false); setPendingAnalysisResult(null); @@ -1561,6 +1665,7 @@ for (const id in actionsWithFlags) { actionsWithFlags[id].is_manual = false; } + setSuggestedByRecommenderIds(prev => new Set([...prev, ...Object.keys(actionsWithFlags)])); setPendingAnalysisResult({ ...event, actions: actionsWithFlags }); if (event.message) setInfoMessage(event.message); } @@ -3159,16 +3264,17 @@

⚡ Co-Study

-
- -
- setActionPath(e.target.value)} style={{ flex: 1, minWidth: 0 }} /> - -
-
+ @@ -3187,6 +3293,17 @@

⚡ Co-Study

Recommender Settings

+
+ {settingsTab === 'paths' && ( +
+
+ +
Synchronized with the banner field
+
+ setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + +
+
+
+ +
+ setActionPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', 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 #ccc', borderRadius: '4px' }} /> + +
+
+
+ )} + {settingsTab === 'recommender' && (
From 538b406a2f4c53406d095c963d5fbebfa97388d1 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 11:42:36 +0100 Subject: [PATCH 05/16] made pst action filtering more robust not to consider a reconnection or disconnection simply of the related network branch --- frontend/src/components/ActionFeed.test.tsx | 117 +++++++++++++++++++- frontend/src/components/ActionFeed.tsx | 81 +++++++++++--- standalone_interface.html | 71 +++++++++--- 3 files changed, 236 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index 4804f46a..52776c1b 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; // Mocking dependencies vi.mock('../api', () => ({ @@ -20,6 +20,7 @@ vi.mock('../utils/svgUtils', () => ({ })); import ActionFeed from './ActionFeed'; +import { api } from '../api'; import type { ActionDetail, AnalysisResult } from '../types'; describe('ActionFeed', () => { @@ -263,4 +264,118 @@ describe('ActionFeed', () => { expect(goodIndex).toBeLessThan(badIndex); }); + + it('filters PST actions based on the PST checkbox', async () => { + const pstAction = { id: 'pst_tap_up', description: 'PST action', type: 'pst_tap_change' }; + const regularAction = { id: 'line_reco_1', description: 'Regular action', type: 'line_reconnection' }; + + // Mock API to return both actions + (api.getAvailableActions as any).mockResolvedValueOnce([pstAction, regularAction]); + + render(); + + // Open search + fireEvent.click(screen.getByText('+ Manual Selection')); + + // Both should be visible initially (PST filter is true by default) + expect(await screen.findByText('pst_tap_up')).toBeInTheDocument(); + expect(screen.getByText('line_reco_1')).toBeInTheDocument(); + + // Find and click the PST checkbox to uncheck it + const pstCheckbox = screen.getByLabelText('PST'); + fireEvent.click(pstCheckbox); + + // PST action should be hidden, regular action should remain + expect(screen.queryByText('pst_tap_up')).not.toBeInTheDocument(); + expect(screen.getByText('line_reco_1')).toBeInTheDocument(); + + // Check it again + fireEvent.click(pstCheckbox); + expect(await screen.findByText('pst_tap_up')).toBeInTheDocument(); + }); + + it('hides PST actions matching search query when PST filter is unchecked', async () => { + const pstAction = { id: 'pst_tap_up', description: 'PST action' }; + + (api.getAvailableActions as any).mockResolvedValueOnce([pstAction]); + + render(); + + // Open search + fireEvent.click(screen.getByText('+ Manual Selection')); + + // Uncheck PST filter + const pstCheckbox = screen.getByLabelText('PST'); + fireEvent.click(pstCheckbox); + + // Type "pst" in search + const searchInput = screen.getByPlaceholderText(/Search action/); + fireEvent.change(searchInput, { target: { value: 'pst' } }); + + // Wait for loading to finish if it hasn't already + await waitFor(() => { + expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument(); + }); + + // PST action should NOT be visible even if it matches search query + expect(screen.queryByText('pst_tap_up')).not.toBeInTheDocument(); + expect(screen.getByText('No matching actions')).toBeInTheDocument(); + }); + + it('shows ONLY PST actions when only PST filter is checked', async () => { + const pstAction = { id: 'pst_1', description: 'PST action' }; + const discoAction = { id: 'abc', description: 'Ouverture de ligne' }; // Should be recognized as disco + const unknownAction = { id: 'xyz', description: 'Some unknown action' }; + + (api.getAvailableActions as any).mockResolvedValueOnce([pstAction, discoAction, unknownAction]); + + render(); + + // Open search + fireEvent.click(screen.getByText('+ Manual Selection')); + + // Uncheck everything except PST + fireEvent.click(screen.getByLabelText('Disconnections')); + fireEvent.click(screen.getByLabelText('Reconnections')); + fireEvent.click(screen.getByLabelText('Open coupling')); + fireEvent.click(screen.getByLabelText('Close coupling')); + + // PST should be visible + expect(await screen.findByText('pst_1')).toBeInTheDocument(); + + // Disco and Unknown should NOT be visible + expect(screen.queryByText('abc')).not.toBeInTheDocument(); + expect(screen.queryByText('xyz')).not.toBeInTheDocument(); + }); + + it('hides disconnections on PST branches when Disconnections filter is off but PST is on', async () => { + const pstDiscoAction = { + id: 'disco_pst_branch', + description: 'Ouverture de la branche PST', + type: 'pst_tap_change' // Simulating backend tagging it as PST + }; + + (api.getAvailableActions as any).mockResolvedValueOnce([pstDiscoAction]); + + render(); + + // Open search + fireEvent.click(screen.getByText('+ Manual Selection')); + + // Ensure Disconnections is unchecked, PST is checked + const discoCheckbox = screen.getByLabelText('Disconnections') as HTMLInputElement; + if (discoCheckbox.checked) fireEvent.click(discoCheckbox); + + const pstCheckbox = screen.getByLabelText('PST') as HTMLInputElement; + if (!pstCheckbox.checked) fireEvent.click(pstCheckbox); + + // PST branch disco should NOT be visible because it's a disconnection + // even if it has "pst" in id/type + await waitFor(() => { + expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('disco_pst_branch')).not.toBeInTheDocument(); + expect(screen.getByText('All actions already added')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index 3df7e6bc..82ed1ad0 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -68,7 +68,7 @@ const ActionFeed: React.FC = ({ const [loadingActions, setLoadingActions] = useState(false); const [simulating, setSimulating] = useState(null); const [error, setError] = useState(null); - const [typeFilters, setTypeFilters] = useState({ disco: true, reco: true, open: true, close: true }); + const [typeFilters, setTypeFilters] = useState({ disco: true, reco: true, open: true, close: true, pst: true }); const searchInputRef = useRef(null); const dropdownRef = useRef(null); const [tooltip, setTooltip] = useState<{ content: React.ReactNode; x: number; y: number } | null>(null); @@ -110,15 +110,24 @@ const ActionFeed: React.FC = ({ return availableActions .filter(a => !alreadyShown.has(a.id)) .filter(a => { - const t = a.type || 'unknown'; - if ((t.includes('disco') || t.includes('open_line') || t.includes('open_load')) && !typeFilters.disco) return false; - if ((t.includes('reco') || t.includes('close_line') || t.includes('close_load')) && !typeFilters.reco) return false; - if (t === 'open_coupling' && !typeFilters.open) return false; - if (t === 'close_coupling' && !typeFilters.close) return false; - // If it's truly 'unknown' or something else, only show if all are checked or perhaps none? - // For now, if all filters are unchecked, return false. - if (!typeFilters.disco && !typeFilters.reco && !typeFilters.open && !typeFilters.close) return false; - return true; + const t = (a.type || 'unknown').toLowerCase(); + const actionId = a.id.toLowerCase(); + const actionDesc = (a.description || '').toLowerCase(); + + const isDisco = t.includes('disco') || t.includes('open_line') || t.includes('open_load') || actionDesc.includes('ouverture'); + const isReco = t.includes('reco') || t.includes('close_line') || t.includes('close_load') || actionDesc.includes('fermeture'); + const isOpenCoupling = t.includes('open_coupling'); + const isCloseCoupling = t.includes('close_coupling'); + const isPstAction = (actionId.includes('pst') || actionDesc.includes('pst') || t.includes('pst')) && !isDisco && !isReco && !isOpenCoupling && !isCloseCoupling; + + if (isDisco) return typeFilters.disco; + if (isReco) return typeFilters.reco; + if (isOpenCoupling) return typeFilters.open; + if (isCloseCoupling) return typeFilters.close; + if (isPstAction) return typeFilters.pst; + + // Handle unknown or categories not explicitly listed above + return typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst; }) .filter(a => a.id.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q)) .slice(0, 20); @@ -129,14 +138,50 @@ const ActionFeed: React.FC = ({ if (!actionScores) return []; const list: { type: string; actionId: string; score: number }[] = []; for (const [type, data] of Object.entries(actionScores)) { - // Apply filtering - if (type === 'line_disconnection' && !typeFilters.disco) continue; - if (type === 'line_reconnection' && !typeFilters.reco) continue; - if (type === 'open_coupling' && !typeFilters.open) continue; - if (type === 'close_coupling' && !typeFilters.close) continue; + // Apply filtering logic consistent with the search dropdown + const isDiscoType = type === 'line_disconnection'; + const isRecoType = type === 'line_reconnection'; + const isOpenType = type === 'open_coupling'; + const isCloseType = type === 'close_coupling'; + const isPstType = type === 'pst_tap_change' || type.includes('pst'); + + if (isDiscoType && !typeFilters.disco) continue; + if (isRecoType && !typeFilters.reco) continue; + if (isOpenType && !typeFilters.open) continue; + if (isCloseType && !typeFilters.close) continue; + if (isPstType && !typeFilters.pst) continue; + + // If it's a known type but its filter is off, it's already skipped. + // If it's an unknown type, we show it only if ALL filters are active. + const isKnownType = isDiscoType || isRecoType || isOpenType || isCloseType || isPstType; + if (!isKnownType && !(typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst)) { + continue; + } const scores = data?.scores || {}; for (const [actionId, score] of Object.entries(scores)) { + // Determine if this specific action should be filtered out + const actionDetail = actions[actionId]; + const actionDesc = (actionDetail?.description_unitaire || '').toLowerCase(); + const aid = actionId.toLowerCase(); + const t = type.toLowerCase(); + + const isDisco = t.includes('disco') || t.includes('open_line') || t.includes('open_load') || actionDesc.includes('ouverture'); + const isReco = t.includes('reco') || t.includes('close_line') || t.includes('close_load') || actionDesc.includes('fermeture'); + const isOpenCoupling = t.includes('open_coupling'); + const isCloseCoupling = t.includes('close_coupling'); + const isPstAction = (aid.includes('pst') || actionDesc.includes('pst') || t.includes('pst')) && !isDisco && !isReco && !isOpenCoupling && !isCloseCoupling; + + let shouldShow = false; + if (isDisco) shouldShow = typeFilters.disco; + else if (isReco) shouldShow = typeFilters.reco; + else if (isOpenCoupling) shouldShow = typeFilters.open; + else if (isCloseCoupling) shouldShow = typeFilters.close; + else if (isPstAction) shouldShow = typeFilters.pst; + else shouldShow = typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst; + + if (!shouldShow) continue; + list.push({ type, actionId, score: Number(score) }); } } @@ -450,12 +495,12 @@ const ActionFeed: React.FC = ({
{/* Action type filter checkboxes */}
- {([['disco', 'Disconnections'], ['reco', 'Reconnections'], ['open', 'Open coupling'], ['close', 'Close coupling']] as const).map(([key, label]) => ( + {([['disco', 'Disconnections'], ['reco', 'Reconnections'], ['pst', 'PST'], ['open', 'Open coupling'], ['close', 'Close coupling']] as const).map(([key, label]) => (
- {[['disco', 'Disconnections'], ['reco', 'Reconnections'], ['open', 'Open coupling'], ['close', 'Close coupling']].map(([key, label]) => ( + {[['disco', 'Disconnections'], ['reco', 'Reconnections'], ['pst', 'PST'], ['open', 'Open coupling'], ['close', 'Close coupling']].map(([key, label]) => (
- +
Synchronized with the banner field
- setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} />
- +
- setActionPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + setActionPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} />
diff --git a/standalone_interface.html b/standalone_interface.html index 740844ef..0668994d 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -1050,7 +1050,7 @@ ignoreReconnections, pypowsyblFastMode }); - setSettingsTab(tab); + setSettingsTab('paths'); setIsSettingsOpen(true); }; @@ -1122,6 +1122,28 @@ } } + // Fetch study-related data (branches, nominal voltages etc.) + const [branchesRes, vlRes, nomVRes] = await Promise.all([ + axios.get(API_BASE + '/api/branches'), + axios.get(API_BASE + '/api/voltage-levels'), + axios.get(API_BASE + '/api/nominal-voltages') + ]); + + setBranches(branchesRes.data.branches); + setVoltageLevels(vlRes.data.voltage_levels); + setSelectedBranch(''); + + // Set up nominal voltage filter + const nvMap = nomVRes.data.mapping; + const uniqueKv = nomVRes.data.unique_kv; + setNominalVoltageMap(nvMap); + setUniqueVoltages(uniqueKv); + if (uniqueKv.length > 0) { + setVoltageRange([uniqueKv[0], uniqueKv[uniqueKv.length - 1]]); + } + + fetchBaseDiagram(vlRes.data.voltage_levels.length); + setSettingsBackup({ networkPath, actionPath, From 95801e475a0be2468ea22bca84e195db9fe42d34 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 16:00:41 +0100 Subject: [PATCH 09/16] feat: direct .xiidm loading and persistent layout path in settings --- expert_backend/main.py | 1 + .../services/recommender_service.py | 20 ++++--- .../tests/test_recommender_service.py | 6 +- frontend/src/App.test.tsx | 33 ++++++++++- frontend/src/App.tsx | 59 +++++++++++++------ frontend/src/types.ts | 3 + frontend/src/utils/sessionUtils.ts | 4 +- standalone_interface.html | 29 ++++++--- 8 files changed, 118 insertions(+), 37 deletions(-) diff --git a/expert_backend/main.py b/expert_backend/main.py index 7fe62147..6359b95b 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -37,6 +37,7 @@ class ConfigRequest(BaseModel): pre_existing_overload_threshold: float = 0.02 ignore_reconnections: bool = False pypowsybl_fast_mode: bool = True + layout_path: str | None = None class AnalysisRequest(BaseModel): disconnected_element: str diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index 9814b296..8fa6037a 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -114,7 +114,7 @@ def _enrich_actions(self, prioritized_actions_dict): def update_config(self, settings): # Update the global config of the package path_obj = Path(settings.network_path) - config.ENV_NAME = path_obj.name + config.ENV_NAME = path_obj.stem config.ENV_FOLDER = path_obj.parent config.ENV_PATH = path_obj @@ -134,6 +134,12 @@ def update_config(self, settings): config.IGNORE_RECONNECTIONS = settings.ignore_reconnections if hasattr(settings, 'pypowsybl_fast_mode') and settings.pypowsybl_fast_mode is not None: config.PYPOWSYBL_FAST_MODE = settings.pypowsybl_fast_mode + + # New layout file path + if hasattr(settings, 'layout_path') and settings.layout_path: + config.LAYOUT_FILE_PATH = Path(settings.layout_path) + else: + config.LAYOUT_FILE_PATH = None # Force the requested global flags config.MAX_RHO_BOTH_EXTREMITIES = True @@ -495,13 +501,9 @@ def _get_base_network(self): import pypowsybl as pp - network_file = config.ENV_PATH / "grid.xiidm" + network_file = config.ENV_PATH if not network_file.exists(): - xiidm_files = list(config.ENV_PATH.glob("*.xiidm")) - if xiidm_files: - network_file = xiidm_files[0] - else: - raise FileNotFoundError(f"No .xiidm file found in {config.ENV_PATH}") + raise FileNotFoundError(f"Network file not found: {config.ENV_PATH}") n = pp.network.load(str(network_file)) # Convenience method not in pypowsybl API: return line IDs as a list @@ -592,8 +594,8 @@ def _load_layout(self): import pandas as pd import json - layout_file = config.ENV_PATH / "grid_layout.json" - if layout_file.exists(): + layout_file = getattr(config, 'LAYOUT_FILE_PATH', None) + if layout_file and layout_file.exists(): try: with open(layout_file, 'r') as f: layout_data = json.load(f) diff --git a/expert_backend/tests/test_recommender_service.py b/expert_backend/tests/test_recommender_service.py index 9d1ac328..1218fbe9 100644 --- a/expert_backend/tests/test_recommender_service.py +++ b/expert_backend/tests/test_recommender_service.py @@ -20,13 +20,15 @@ def test_update_config_applies_settings(self, mock_file, mock_network_service, m service = RecommenderService() # Test paths - network_path = "/tmp/test_network" + network_path = "/tmp/test_network.xiidm" action_path = "/tmp/test_actions.json" + layout_path = "/tmp/test_layout.json" # Create a sample config request with test data paths and custom values settings = ConfigRequest( network_path=network_path, action_file_path=action_path, + layout_path=layout_path, min_line_reconnections=5.0, min_close_coupling=4.0, min_open_coupling=3.0, @@ -41,9 +43,11 @@ def test_update_config_applies_settings(self, mock_file, mock_network_service, m service.update_config(settings) # Verify that config module was updated correctly + assert config.ENV_PATH == Path(network_path) assert config.ENV_NAME == "test_network" assert config.ENV_FOLDER == Path("/tmp") assert config.ACTION_FILE_PATH == Path(action_path) + assert config.LAYOUT_FILE_PATH == Path(layout_path) assert config.MIN_LINE_RECONNECTIONS == 5.0 assert config.MIN_CLOSE_COUPLING == 4.0 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b08c0002..6d26bf2e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -664,6 +664,7 @@ describe('Save Results button', () => { expect(session).toHaveProperty('saved_at'); expect(session).toHaveProperty('configuration'); expect(session.configuration).toHaveProperty('network_path'); + expect(session.configuration).toHaveProperty('layout_path'); expect(session.configuration).toHaveProperty('monitoring_factor'); expect(session).toHaveProperty('contingency'); expect(session.contingency.disconnected_element).toBe('BRANCH_A'); @@ -718,7 +719,8 @@ describe('Settings Modal Enhancements', () => { it('defaults to the Paths tab when opened', async () => { await openSettings(); // The Paths tab should be active. We can check for a path-specific input. - expect(screen.getByLabelText(/Network Path/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Network File Path/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Layout File Path/i)).toBeInTheDocument(); }); it('auto-closes and fetches study data on Apply', async () => { @@ -750,4 +752,33 @@ describe('Settings Modal Enhancements', () => { expect(mockApi.getNetworkDiagram).toHaveBeenCalled(); }); }); + + it('updates configuration with new network and layout paths', async () => { + await openSettings(); + + const networkInput = screen.getByLabelText(/Network File Path/i); + const layoutInput = screen.getByLabelText(/Layout File Path/i); + + await userEvent.clear(networkInput); + await userEvent.type(networkInput, '/path/to/network.xiidm'); + await userEvent.clear(layoutInput); + await userEvent.type(layoutInput, '/path/to/layout.json'); + + mockApi.updateConfig.mockResolvedValue({ + monitored_lines_count: 5, + total_lines_count: 5 + }); + + const applyBtn = screen.getByText('Apply'); + await act(async () => { + await userEvent.click(applyBtn); + }); + + await waitFor(() => { + expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ + network_path: '/path/to/network.xiidm', + layout_path: '/path/to/layout.json' + })); + }); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f261eb00..52e8c717 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,15 +16,11 @@ import { buildSessionResult } from './utils/sessionUtils'; function App() { // ===== Configuration State ===== - const [networkPath, setNetworkPath] = useState( - localStorage.getItem('networkPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z_dijon_only' - ); - const [actionPath, setActionPath] = useState( - localStorage.getItem('actionPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/action_space/reduced_model_actions_20240828T0100Z_new_dijon.json' - ); - const [outputFolderPath, setOutputFolderPath] = useState( - localStorage.getItem('outputFolderPath') || '' - ); + const [networkPath, setNetworkPath] = useState(''); + const [actionPath, setActionPath] = useState(''); + const [layoutPath, setLayoutPath] = useState(''); + const [outputFolderPath, setOutputFolderPath] = useState(''); + const [branches, setBranches] = useState([]); const [voltageLevels, setVoltageLevels] = useState([]); const [selectedBranch, setSelectedBranch] = useState(''); @@ -135,6 +131,7 @@ function App() { setSettingsBackup({ networkPath, actionPath, + layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, @@ -149,12 +146,13 @@ function App() { }); setSettingsTab('paths'); setIsSettingsOpen(true); - }, [networkPath, actionPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode]); + }, [networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode]); const handleCloseSettings = useCallback(() => { 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); @@ -204,6 +202,7 @@ function App() { const configRes = await api.updateConfig({ network_path: networkPath, action_file_path: actionPath, + layout_path: layoutPath, min_line_reconnections: minLineReconnections, min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, @@ -246,6 +245,7 @@ function App() { setSettingsBackup({ networkPath, actionPath, + layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, @@ -263,12 +263,28 @@ function App() { const e = err as { response?: { data?: { detail?: string } }; message?: string }; setError('Failed to apply settings: ' + (e.response?.data?.detail || e.message)); } - }, [networkPath, actionPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, fetchBaseDiagram]); + }, [networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, fetchBaseDiagram]); + + // Load paths from localStorage on initial mount + useEffect(() => { + const savedNetwork = localStorage.getItem('networkPath'); + const savedAction = localStorage.getItem('actionPath'); + const savedLayout = localStorage.getItem('layoutPath'); + const savedOutput = localStorage.getItem('outputFolderPath'); + + setNetworkPath(savedNetwork || '/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z_dijon_only'); + setActionPath(savedAction || '/home/marotant/dev/Expert_op4grid_recommender/data/action_space/reduced_model_actions_20240828T0100Z_new_dijon.json'); + setLayoutPath(savedLayout || ''); + setOutputFolderPath(savedOutput || ''); + }, []); // Persist paths to localStorage - useEffect(() => { localStorage.setItem('networkPath', networkPath); }, [networkPath]); - useEffect(() => { localStorage.setItem('actionPath', actionPath); }, [actionPath]); - useEffect(() => { localStorage.setItem('outputFolderPath', outputFolderPath); }, [outputFolderPath]); + useEffect(() => { + localStorage.setItem('networkPath', networkPath); + localStorage.setItem('actionPath', actionPath); + localStorage.setItem('layoutPath', layoutPath); + localStorage.setItem('outputFolderPath', outputFolderPath); + }, [networkPath, actionPath, layoutPath, outputFolderPath]); // ===== Contingency Change Confirmation Helpers ===== // Check if there is any analysis state that would be lost on contingency change @@ -717,7 +733,9 @@ function App() { // ===== Save Results ===== const handleSaveResults = useCallback(async () => { const session = buildSessionResult({ - networkPath, actionPath, + networkPath, + actionPath, + layoutPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, @@ -1354,11 +1372,11 @@ function App() { {settingsTab === 'paths' && (
- +
Synchronized with the banner field
setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> - +
@@ -1368,6 +1386,13 @@ function App() {
+
+ +
+ setLayoutPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + +
+
Session folders (JSON + PDF) are saved here. Leave empty to download JSON to browser.
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5fd04702..f2afbf7f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -11,6 +11,7 @@ export interface ConfigRequest { pre_existing_overload_threshold?: number; ignore_reconnections?: boolean; pypowsybl_fast_mode?: boolean; + layout_path?: string; } export interface AnalysisRequest { @@ -138,6 +139,7 @@ export interface SettingsBackup { preExistingOverloadThreshold: number; ignoreReconnections?: boolean; pypowsyblFastMode?: boolean; + layoutPath?: string; } export interface AvailableAction { @@ -206,6 +208,7 @@ export interface SessionResult { pre_existing_overload_threshold: number; ignore_reconnections: boolean; pypowsybl_fast_mode: boolean; + layout_path: string; }; contingency: { disconnected_element: string; diff --git a/frontend/src/utils/sessionUtils.ts b/frontend/src/utils/sessionUtils.ts index fb1495e8..bacba5f9 100644 --- a/frontend/src/utils/sessionUtils.ts +++ b/frontend/src/utils/sessionUtils.ts @@ -9,6 +9,7 @@ export interface SessionInput { // Configuration / paths networkPath: string; actionPath: string; + layoutPath: string; minLineReconnections: number; minCloseCoupling: number; minOpenCoupling: number; @@ -51,7 +52,7 @@ export interface SessionInput { */ export function buildSessionResult(input: SessionInput): SessionResult { const { - networkPath, actionPath, + networkPath, actionPath, layoutPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, @@ -97,6 +98,7 @@ export function buildSessionResult(input: SessionInput): SessionResult { configuration: { network_path: networkPath, action_file_path: actionPath, + layout_path: layoutPath, min_line_reconnections: minLineReconnections, min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, diff --git a/standalone_interface.html b/standalone_interface.html index 0668994d..dab8fd76 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -987,16 +987,16 @@ }; function App() { - const [networkPath, setNetworkPath] = useState(localStorage.getItem('networkPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z_dijon_only'); - const [actionPath, setActionPath] = useState(localStorage.getItem('actionPath') || '/home/marotant/dev/Expert_op4grid_recommender/data/action_space/reduced_model_actions_20240828T0100Z_new_dijon.json'); - const [outputFolderPath, setOutputFolderPath] = useState(localStorage.getItem('outputFolderPath') || ''); - const [linesMonitoringPath, setLinesMonitoringPath] = useState(localStorage.getItem('linesMonitoringPath') || ''); - const initialMountRef = useRef(true); - const [branches, setBranches] = useState([]); const [voltageLevels, setVoltageLevels] = useState([]); + const initialMountRef = useRef(true); // Nominal voltage filter state + 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 [linesMonitoringPath, setLinesMonitoringPath] = useState(localStorage.getItem('linesMonitoringPath') || ''); const [nominalVoltageMap, setNominalVoltageMap] = useState({}); // {vl_id: kv} const [uniqueVoltages, setUniqueVoltages] = useState([]); // sorted [kv1, kv2, ...] const [voltageRange, setVoltageRange] = useState([0, 400]); // [minKv, maxKv] cursor positions @@ -1038,6 +1038,7 @@ setSettingsBackup({ networkPath, actionPath, + layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, @@ -1058,6 +1059,7 @@ 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); @@ -1102,6 +1104,7 @@ const configRes = await axios.post(API_BASE + '/api/config', { network_path: networkPath, action_file_path: actionPath, + layout_path: layoutPath, min_line_reconnections: minLineReconnections, min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, @@ -1147,6 +1150,7 @@ setSettingsBackup({ networkPath, actionPath, + layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, @@ -1248,6 +1252,7 @@ useEffect(() => { localStorage.setItem('networkPath', networkPath); }, [networkPath]); useEffect(() => { localStorage.setItem('actionPath', actionPath); }, [actionPath]); + useEffect(() => { localStorage.setItem('layoutPath', layoutPath); }, [layoutPath]); useEffect(() => { localStorage.setItem('outputFolderPath', outputFolderPath); }, [outputFolderPath]); useEffect(() => { localStorage.setItem('linesMonitoringPath', linesMonitoringPath); }, [linesMonitoringPath]); useEffect(() => { localStorage.setItem('ignoreReconnections', ignoreReconnections); }, [ignoreReconnections]); @@ -1296,6 +1301,7 @@ const configRes = await axios.post(API_BASE + '/api/config', { network_path: networkPath, action_file_path: actionPath, + layout_path: layoutPath, min_line_reconnections: minLineReconnections, min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, @@ -3378,11 +3384,11 @@

- +
Synchronized with the banner field
setNetworkPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> - +
@@ -3392,6 +3398,13 @@

pickPath('file', setActionPath)} style={{ padding: '8px', background: '#7f8c8d', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>📄

+
+ +
+ setLayoutPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> + +
+
Session folders (JSON + PDF) are saved here. Leave empty to download JSON to browser.
From b7dc90b6613ca16459a207c274593be6e212c258 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 16:07:55 +0100 Subject: [PATCH 10/16] feat: path warning banner in network visualization panel --- frontend/src/App.test.tsx | 63 ++++++++++++++++++- frontend/src/App.tsx | 3 + .../src/components/VisualizationPanel.tsx | 62 ++++++++++++++++++ standalone_interface.html | 55 ++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6d26bf2e..45b72cf6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -7,7 +8,25 @@ import App from './App'; // Mock child components to avoid their complexity vi.mock('./components/VisualizationPanel', () => ({ - default: () =>
, + default: ({ nDiagram, configLoading, networkPath, layoutPath }: any) => { + const [showPathWarning, setShowPathWarning] = React.useState(true); + React.useEffect(() => { + if (nDiagram?.svg) setShowPathWarning(false); + }, [nDiagram?.svg]); + + return ( +
+ {!nDiagram?.svg && !configLoading && showPathWarning && ( +
+
Configuration Paths
+ +
Layout Path: {layoutPath}
+
Output Folder: {networkPath}
+
+ )} +
+ ); + }, })); vi.mock('./components/ActionFeed', () => ({ default: () =>
, @@ -781,4 +800,46 @@ describe('Settings Modal Enhancements', () => { })); }); }); + + it('shows path warning when no network is loaded and allows dismissal', async () => { + // Initial render should show the warning since no diagram is loaded + render(); + + await waitFor(() => { + expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument(); + }); + + expect(screen.getByText(/Layout Path:/i)).toBeInTheDocument(); + expect(screen.getByText(/Output Folder:/i)).toBeInTheDocument(); + + // Dismiss the warning + const closeBtn = screen.getByText('✕'); + await userEvent.click(closeBtn); + + await waitFor(() => { + expect(screen.queryByText(/Configuration Paths/i)).not.toBeInTheDocument(); + }); + }); + + it('hides path warning when a network diagram is loaded', async () => { + render(); + + // Warning should be present initially + await waitFor(() => { + expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument(); + }); + + // Simulate loading a study which will set nDiagram + const settingsBtn = screen.getByTitle('Settings'); + await userEvent.click(settingsBtn); + + const applyBtn = screen.getByText('Apply'); + await userEvent.click(applyBtn); + + // After apply, the warning should disappear because a diagram (nDiagram) will be "loaded" + // (mocked in our tests) + await waitFor(() => { + expect(screen.queryByText(/Configuration Paths/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52e8c717..9bd60f2a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1607,6 +1607,9 @@ function App() { onOverlaySldTabChange={handleOverlaySldTabChange} voltageLevels={voltageLevels} onVlOpen={(vlName) => handleVlDoubleClick(activeTab === 'action' ? selectedActionId || '' : '', vlName)} + networkPath={networkPath} + layoutPath={layoutPath} + onOpenSettings={handleOpenSettings} />
diff --git a/frontend/src/components/VisualizationPanel.tsx b/frontend/src/components/VisualizationPanel.tsx index a0a27482..a41bb53c 100644 --- a/frontend/src/components/VisualizationPanel.tsx +++ b/frontend/src/components/VisualizationPanel.tsx @@ -34,6 +34,9 @@ interface VisualizationPanelProps { onOverlaySldTabChange: (tab: SldTab) => void; voltageLevels: string[]; onVlOpen: (vlName: string) => void; + networkPath: string; + layoutPath: string; + onOpenSettings: (tab?: string) => void; } // ===== SLD Overlay sub-component ===== @@ -541,7 +544,19 @@ const VisualizationPanel: React.FC = ({ onOverlaySldTabChange, voltageLevels, onVlOpen, + networkPath, + layoutPath, + onOpenSettings, }) => { + const [showPathWarning, setShowPathWarning] = useState(true); + + // Hide warning as soon as a diagram is rendered + useEffect(() => { + if (nDiagram?.svg || n1Diagram?.svg || actionDiagram?.svg) { + setShowPathWarning(false); + } + }, [nDiagram?.svg, n1Diagram?.svg, actionDiagram?.svg]); + const showViewModeToggle = activeTab !== 'overflow' && ( (activeTab === 'n' && !!nDiagram?.svg) || (activeTab === 'n-1' && !!n1Diagram?.svg) || @@ -648,6 +663,53 @@ const VisualizationPanel: React.FC = ({
)} + {/* Path Warning Banner */} + {!nDiagram?.svg && !configLoading && showPathWarning && ( +
+ )} + {/* Overflow Container */} {activeTab === 'overflow' && (
{ + if (nDiagram?.svg || n1Diagram?.svg || actionDiagram?.svg) { + setShowPathWarning(false); + } + }, [nDiagram?.svg, n1Diagram?.svg, actionDiagram?.svg]); const showScoreTooltip = (e, content) => { const rect = e.currentTarget.getBoundingClientRect(); @@ -4105,6 +4113,53 @@

+ {/* Path Warning Banner */} + {!nDiagram?.svg && !configLoading && showPathWarning && ( +
+
+
+ âš ī¸ Configuration Paths +
+ +
+
+ Layout Path: {layoutPath || 'Not set'} +
+
+ Output Folder: {networkPath ? networkPath.substring(0, networkPath.lastIndexOf('/')) : 'Not set'} +
+ +
+ )} + {/* View Mode Overlay */} {(activeTab !== 'overflow' && activeTab !== 'n' && ( (activeTab === 'n-1' && !!n1Diagram?.svg) || From 2bae973a92c15ee7e8533bd35ddbf74e6898fdb3 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 16:30:11 +0100 Subject: [PATCH 11/16] feat: refine UI icons, add banner placeholder, and fix settings link bug - Switched folder icons to file icons for path-based fields. - Added placeholder to network file input in settings and main banner. - Fixed 'Change in settings' link in standalone interface. - Updated frontend tests to 33 passing tests with UI verification. --- frontend/src/App.test.tsx | 83 ++++++++++++++++++- frontend/src/App.tsx | 32 +++---- .../src/components/VisualizationPanel.tsx | 2 +- standalone_interface.html | 12 +-- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 45b72cf6..67be789b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -8,7 +8,7 @@ import App from './App'; // Mock child components to avoid their complexity vi.mock('./components/VisualizationPanel', () => ({ - default: ({ nDiagram, configLoading, networkPath, layoutPath }: any) => { + default: ({ nDiagram, configLoading, networkPath, layoutPath, onOpenSettings }: any) => { const [showPathWarning, setShowPathWarning] = React.useState(true); React.useEffect(() => { if (nDiagram?.svg) setShowPathWarning(false); @@ -21,7 +21,8 @@ vi.mock('./components/VisualizationPanel', () => ({
Configuration Paths
Layout Path: {layoutPath}
-
Output Folder: {networkPath}
+
Output Folder: {networkPath ? (networkPath.includes('/') ? networkPath.substring(0, networkPath.lastIndexOf('/')) : networkPath) : 'Not set'}
+
)}

@@ -842,4 +843,82 @@ describe('Settings Modal Enhancements', () => { expect(screen.queryByText(/Configuration Paths/i)).not.toBeInTheDocument(); }); }); + + it('opens settings on Paths tab when "Change in settings" is clicked in warning banner', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument(); + }); + + const changeLink = screen.getByText(/Change in settings/i); + await userEvent.click(changeLink); + + // Should open settings modal + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Should be on Paths tab (default for settings modal anyway, but let's be sure) + expect(screen.getByLabelText(/Network File Path/i)).toBeInTheDocument(); + }); + + it('correctly derives and displays output folder from network path in warning banner', async () => { + // We need to render App with a specific network path + // But networkPath is state initialized from localStorage or empty + // Let's just check the existing banner in the initial render if we can mock the initial state + // Or just check if the warning banner (which we already test) shows the derived path + + // In our tests, we can clear localStorage and then render + localStorage.setItem('networkPath', '/home/user/data/grid.xiidm'); + render(); + + await waitFor(() => { + expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument(); + }); + + // The output folder should be /home/user/data + expect(screen.getByText(/Output Folder:/i)).toHaveTextContent('/home/user/data'); + }); + + it('displays correct icons and placeholders in settings modal', async () => { + await openSettings(); + + const networkInput = screen.getByLabelText(/Network File Path/i); + expect(networkInput).toHaveAttribute('placeholder', 'load your grid xiidm file path'); + + // Check icons (they are emojis in buttons) + // Network button icon + const networkBtn = networkInput.parentElement?.querySelector('button'); + expect(networkBtn?.textContent).toBe('📄'); + + // Layout button icon + const layoutInput = screen.getByLabelText(/Layout File Path/i); + const layoutBtn = layoutInput.parentElement?.querySelector('button'); + expect(layoutBtn?.textContent).toBe('📄'); + + // Action button icon (was already 📄 but good to check) + const actionInput = screen.getByLabelText(/Action Dictionary File Path/i); + const actionBtn = actionInput.parentElement?.querySelector('button'); + expect(actionBtn?.textContent).toBe('📄'); + + // Output folder icon (should be 📂) + const outputInput = screen.getByPlaceholderText('e.g. /home/user/sessions'); + const outputBtn = outputInput.parentElement?.querySelector('button'); + expect(outputBtn?.textContent).toBe('📂'); + }); + + it('displays file icon for network path in main banner', async () => { + render(); + // In main banner (header) + const labels = screen.getAllByText(/Network Path/i); + // Find the one in the header + const headerBannerLabel = labels.find(l => { + const parent = l.parentElement; + return parent && parent.style.flex.startsWith('1 1 200px'); + }); + const bannerBtn = headerBannerLabel?.parentElement?.querySelector('button'); + expect(bannerBtn?.textContent).toBe('📄'); + + const bannerInput = headerBannerLabel?.parentElement?.querySelector('input'); + expect(bannerInput).toHaveAttribute('placeholder', 'load your grid xiidm file path'); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9bd60f2a..1c1edc9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,7 @@ function App() { const [ignoreReconnections, setIgnoreReconnections] = useState(false); const [pypowsyblFastMode, setPypowsyblFastMode] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [settingsTab, setSettingsTab] = useState<'recommender' | 'configurations' | 'paths'>('recommender'); + const [settingsTab, setSettingsTab] = useState<'recommender' | 'configurations' | 'paths'>('paths'); const [settingsBackup, setSettingsBackup] = useState(null); // Confirmation dialog state for contingency change / load study @@ -127,7 +127,7 @@ function App() { } }, []); - const handleOpenSettings = useCallback(() => { + const handleOpenSettings = useCallback((tab: 'recommender' | 'configurations' | 'paths' = 'paths') => { setSettingsBackup({ networkPath, actionPath, @@ -144,7 +144,7 @@ function App() { ignoreReconnections, pypowsyblFastMode }); - setSettingsTab('paths'); + setSettingsTab(tab); setIsSettingsOpen(true); }, [networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode]); @@ -1279,13 +1279,14 @@ function App() {
setNetworkPath(e.target.value)} + placeholder="load your grid xiidm file path" style={{ flex: 1, minWidth: 0, padding: '5px 8px', border: '1px solid rgba(255,255,255,0.3)', borderRadius: '4px', background: 'rgba(255,255,255,0.1)', color: 'white', fontSize: '0.8rem' }} />
@@ -1313,7 +1314,7 @@ function App() { + setNetworkPath(e.target.value)} placeholder="load your grid xiidm file path" style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> +
@@ -1390,7 +1394,7 @@ function App() {
setLayoutPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> - +
diff --git a/frontend/src/components/VisualizationPanel.tsx b/frontend/src/components/VisualizationPanel.tsx index a41bb53c..9173ac96 100644 --- a/frontend/src/components/VisualizationPanel.tsx +++ b/frontend/src/components/VisualizationPanel.tsx @@ -36,7 +36,7 @@ interface VisualizationPanelProps { onVlOpen: (vlName: string) => void; networkPath: string; layoutPath: string; - onOpenSettings: (tab?: string) => void; + onOpenSettings: (tab?: 'recommender' | 'configurations' | 'paths') => void; } // ===== SLD Overlay sub-component ===== diff --git a/standalone_interface.html b/standalone_interface.html index f444cc9b..5cd74187 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -3321,8 +3321,8 @@

⚡ Co-Study
- setNetworkPath(e.target.value)} style={{ flex: 1, minWidth: 0 }} /> - + setNetworkPath(e.target.value)} placeholder="load your grid xiidm file path" style={{ flex: 1, minWidth: 0 }} /> +
+ setNetworkPath(e.target.value)} placeholder="load your grid xiidm file path" style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> +

@@ -3410,7 +3410,7 @@

Layout File Path (.json)
setLayoutPath(e.target.value)} style={{ flex: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} /> - +

@@ -4151,7 +4151,7 @@

{ e.preventDefault(); setSettingsOpen(true); }} + onClick={(e) => { e.preventDefault(); setSettingsTab('paths'); setIsSettingsOpen(true); }} style={{ color: '#0056b3', textDecoration: 'underline', fontWeight: 500 }} > Change in settings From 08d6ed15c9fd3458b8cb16151d3c1582ddc22c04 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 16:52:37 +0100 Subject: [PATCH 12/16] feat: action dict stats warning, min_pst config, remove duplicate processing indicator --- expert_backend/main.py | 24 +++++- .../services/recommender_service.py | 2 + frontend/src/App.tsx | 19 +++++ frontend/src/components/ActionFeed.test.tsx | 80 ++++++++++++++++++- frontend/src/components/ActionFeed.tsx | 47 ++++++++--- frontend/src/types.ts | 1 + standalone_interface.html | 39 +++++++-- 7 files changed, 190 insertions(+), 22 deletions(-) diff --git a/expert_backend/main.py b/expert_backend/main.py index 6359b95b..6ec04a8e 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -31,6 +31,7 @@ class ConfigRequest(BaseModel): min_close_coupling: float = 3.0 min_open_coupling: float = 2.0 min_line_disconnections: float = 3.0 + min_pst: float = 1.0 n_prioritized_actions: int = 10 lines_monitoring_path: str | None = None monitoring_factor: float = 0.95 @@ -88,12 +89,31 @@ def update_config(config: ConfigRequest): monitored_lines = len(network_service.get_monitored_elements()) else: monitored_lines = getattr(recommender_config, 'MONITORED_LINES_COUNT', total_lines) - + + # Compute action dictionary statistics + import os as _os + action_dict = recommender_service._dict_action or {} + action_file_name = _os.path.basename(config.action_file_path) + n_reco = sum(1 for k in action_dict if k.lower().startswith('reco_')) + n_disco = sum(1 for k in action_dict if k.lower().startswith('disco_')) + n_pst = sum(1 for k in action_dict if 'pst' in k.lower() and not k.lower().startswith('reco_') and not k.lower().startswith('disco_')) + n_open_coupling = sum(1 for k in action_dict if 'open_coupling' in k.lower()) + n_close_coupling = sum(1 for k in action_dict if 'close_coupling' in k.lower()) + return { "status": "success", "message": "Configuration updated and network loaded", "total_lines_count": total_lines, - "monitored_lines_count": monitored_lines + "monitored_lines_count": monitored_lines, + "action_dict_file_name": action_file_name, + "action_dict_stats": { + "reco": n_reco, + "disco": n_disco, + "pst": n_pst, + "open_coupling": n_open_coupling, + "close_coupling": n_close_coupling, + "total": len(action_dict) + } } except Exception as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index 8fa6037a..a9f768df 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -134,6 +134,8 @@ def update_config(self, settings): config.IGNORE_RECONNECTIONS = settings.ignore_reconnections if hasattr(settings, 'pypowsybl_fast_mode') and settings.pypowsybl_fast_mode is not None: config.PYPOWSYBL_FAST_MODE = settings.pypowsybl_fast_mode + if hasattr(settings, 'min_pst') and settings.min_pst is not None: + config.MIN_PST = settings.min_pst # New layout file path if hasattr(settings, 'layout_path') and settings.layout_path: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c1edc9f..fd45c1b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,9 @@ function App() { const [preExistingOverloadThreshold, setPreExistingOverloadThreshold] = useState(0.02); const [ignoreReconnections, setIgnoreReconnections] = useState(false); const [pypowsyblFastMode, setPypowsyblFastMode] = useState(true); + const [minPst, setMinPst] = useState(1.0); + 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); @@ -207,6 +210,7 @@ function App() { min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, min_line_disconnections: minLineDisconnections, + min_pst: minPst, n_prioritized_actions: nPrioritizedActions, lines_monitoring_path: linesMonitoringPath, monitoring_factor: monitoringFactor, @@ -222,6 +226,9 @@ function App() { setShowMonitoringWarning(true); } } + if (configRes?.action_dict_file_name) setActionDictFileName(configRes.action_dict_file_name); + if (configRes?.action_dict_stats) setActionDictStats(configRes.action_dict_stats); + // Fetch study-related data (branches, nominal voltages etc.) const [branchesList, vlRes, nomVRes] = await Promise.all([ @@ -362,6 +369,7 @@ function App() { min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, min_line_disconnections: minLineDisconnections, + min_pst: minPst, n_prioritized_actions: nPrioritizedActions, lines_monitoring_path: linesMonitoringPath, monitoring_factor: monitoringFactor, @@ -377,6 +385,9 @@ function App() { setShowMonitoringWarning(true); } } + if (configRes?.action_dict_file_name) setActionDictFileName(configRes.action_dict_file_name); + if (configRes?.action_dict_stats) setActionDictStats(configRes.action_dict_stats); + const [branchesList, vlRes, nomVRes] = await Promise.all([ api.getBranches(), @@ -1426,6 +1437,10 @@ function App() { setMinLineDisconnections(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px', border: '1px solid #ccc', borderRadius: '4px' }} />

+
+ + setMinPst(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px', border: '1px solid #ccc', borderRadius: '4px' }} /> +
setNPrioritizedActions(parseInt(e.target.value, 10))} style={{ width: '80px', padding: '5px', border: '1px solid #ccc', borderRadius: '4px' }} /> @@ -1572,8 +1587,12 @@ function App() { minCloseCoupling={minCloseCoupling} minOpenCoupling={minOpenCoupling} minLineDisconnections={minLineDisconnections} + minPst={minPst} nPrioritizedActions={nPrioritizedActions} ignoreReconnections={ignoreReconnections} + actionDictFileName={actionDictFileName} + actionDictStats={actionDictStats} + onOpenSettings={handleOpenSettings} />
diff --git a/frontend/src/components/ActionFeed.test.tsx b/frontend/src/components/ActionFeed.test.tsx index 0f53b989..f3925be7 100644 --- a/frontend/src/components/ActionFeed.test.tsx +++ b/frontend/src/components/ActionFeed.test.tsx @@ -49,11 +49,16 @@ describe('ActionFeed', () => { minCloseCoupling: 3, minOpenCoupling: 2, minLineDisconnections: 3, + minPst: 1, nPrioritizedActions: 10, ignoreReconnections: false, pendingAnalysisResult: null as AnalysisResult | null, + onOpenSettings: vi.fn(), + actionDictFileName: null as string | null, + actionDictStats: null as { reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number } | null, }; + it('renders "Scored Actions" heading when search is opened and actions are present', async () => { const actionId = 'act_1'; const props = { @@ -92,7 +97,8 @@ describe('ActionFeed', () => { render(); expect(screen.queryByText('Suggested Action')).not.toBeInTheDocument(); - expect(screen.getByText('âš™ī¸ Processing analysis...')).toBeInTheDocument(); + // Processing indicator is now only under Suggested Actions tab, not top-level + expect(screen.queryByText('âš™ī¸ Processing analysis...')).not.toBeInTheDocument(); }); it('shows manual actions while analysis is loading', () => { @@ -117,7 +123,8 @@ describe('ActionFeed', () => { render(); expect(screen.getByText('Manual Action')).toBeInTheDocument(); - expect(screen.getByText('âš™ī¸ Processing analysis...')).toBeInTheDocument(); + // Processing indicator moved to Suggested Actions only; not duplicated at top + expect(screen.queryByText('âš™ī¸ Processing analysis...')).not.toBeInTheDocument(); }); it('shows display prioritized actions button when pending results exist and loading is false', () => { @@ -378,4 +385,73 @@ describe('ActionFeed', () => { expect(screen.queryByText('disco_pst_branch')).not.toBeInTheDocument(); expect(screen.getByText('All actions already added')).toBeInTheDocument(); }); + it('shows action dict stats warning when actionDictFileName and actionDictStats are provided', () => { + const props = { + ...defaultProps, + actionDictFileName: 'actions.json', + actionDictStats: { reco: 3, disco: 5, pst: 2, open_coupling: 1, close_coupling: 1, total: 12 }, + }; + render(); + + expect(screen.getByText(/Action dictionary/)).toBeInTheDocument(); + expect(screen.getByText(/actions.json/)).toBeInTheDocument(); + expect(screen.getByText(/Reco:/)).toBeInTheDocument(); + expect(screen.getByText(/Disco:/)).toBeInTheDocument(); + expect(screen.getByText(/PST:/)).toBeInTheDocument(); + expect(screen.getByText(/Open coupling:/)).toBeInTheDocument(); + expect(screen.getByText(/Close coupling:/)).toBeInTheDocument(); + }); + + it('dismisses action dict stats warning when close button is clicked', () => { + const props = { + ...defaultProps, + actionDictFileName: 'actions.json', + actionDictStats: { reco: 3, disco: 5, pst: 2, open_coupling: 1, close_coupling: 1, total: 12 }, + }; + render(); + + expect(screen.getByText(/Action dictionary/)).toBeInTheDocument(); + fireEvent.click(screen.getByTitle('Dismiss')); + expect(screen.queryByText(/Action dictionary/)).not.toBeInTheDocument(); + }); + + it('hides action dict warning while analysis is loading', () => { + const props = { + ...defaultProps, + actionDictFileName: 'actions.json', + actionDictStats: { reco: 3, disco: 5, pst: 2, open_coupling: 1, close_coupling: 1, total: 12 }, + analysisLoading: true, + }; + render(); + expect(screen.queryByText(/Action dictionary/)).not.toBeInTheDocument(); + }); + + it('includes minPst in the recommender settings warning', () => { + const props = { + ...defaultProps, + minPst: 2, + minLineReconnections: 3, + minLineDisconnections: 4, + }; + render(); + // The recommender settings warning appears when no analysis has been run + expect(screen.getByText(/2 PST/)).toBeInTheDocument(); + }); + + it('shows change in settings link in action dict warning calling paths tab', () => { + const onOpenSettings = vi.fn(); + const props = { + ...defaultProps, + actionDictFileName: 'actions.json', + actionDictStats: { reco: 3, disco: 5, pst: 2, open_coupling: 1, close_coupling: 1, total: 12 }, + onOpenSettings, + }; + render(); + + // "Change in settings" button in the action dict warning + const changeLinks = screen.getAllByText('Change in settings'); + // Click the first one (action dict warning) + fireEvent.click(changeLinks[0]); + expect(onOpenSettings).toHaveBeenCalledWith('paths'); + }); }); diff --git a/frontend/src/components/ActionFeed.tsx b/frontend/src/components/ActionFeed.tsx index a0d3a351..89b466c1 100644 --- a/frontend/src/components/ActionFeed.tsx +++ b/frontend/src/components/ActionFeed.tsx @@ -29,8 +29,11 @@ interface ActionFeedProps { minOpenCoupling: number; minLineDisconnections: number; nPrioritizedActions: number; + minPst: number; ignoreReconnections: boolean; - onOpenSettings?: (tab?: 'recommender' | 'configurations') => void; + onOpenSettings?: (tab?: 'recommender' | 'configurations' | 'paths') => void; + actionDictFileName?: string | null; + actionDictStats?: { reco: number; disco: number; pst: number; open_coupling: number; close_coupling: number; total: number } | null; } const ActionFeed: React.FC = ({ @@ -58,9 +61,12 @@ const ActionFeed: React.FC = ({ minCloseCoupling, minOpenCoupling, minLineDisconnections, + minPst, nPrioritizedActions, ignoreReconnections, onOpenSettings, + actionDictFileName, + actionDictStats, }) => { const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -75,6 +81,7 @@ const ActionFeed: React.FC = ({ const [suggestedTab, setSuggestedTab] = useState<'prioritized' | 'rejected'>('prioritized'); const [dismissedSelectedWarning, setDismissedSelectedWarning] = useState(false); const [dismissedRejectedWarning, setDismissedRejectedWarning] = useState(false); + const [showActionDictWarning, setShowActionDictWarning] = useState(true); const showTooltip = (e: React.MouseEvent, content: React.ReactNode) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); @@ -670,6 +677,30 @@ const ActionFeed: React.FC = ({ )} + {/* Action Dict Info Warning */} + {showActionDictWarning && !simulating && !analysisLoading && actionDictFileName && actionDictStats && ( +
+
+
â„šī¸ Action dictionary: {actionDictFileName}
+
+ 🔄 Reco: {actionDictStats.reco} + ⛔ Disco: {actionDictStats.disco} + 📐 PST: {actionDictStats.pst} + 🔓 Open coupling: {actionDictStats.open_coupling} + 🔒 Close coupling: {actionDictStats.close_coupling} +
+ {onOpenSettings && ( + + )} +
+ +
+ )}

Selected Actions @@ -706,16 +737,8 @@ const ActionFeed: React.FC = ({

- {/* Loading indicator shown below existing cards during analysis */} - {analysisLoading && ( -
- âš™ī¸ Processing analysis... -
- )} + {/* Processing indicator during analysis - removed duplicate, keeping one under Suggested Actions */} + {/* Display prioritized actions button inside Suggested Actions section */} {pendingAnalysisResult && !analysisLoading && ( @@ -779,7 +802,7 @@ const ActionFeed: React.FC = ({ )} -
â€ĸ Minimum actions: {minLineReconnections} reco, {minCloseCoupling} close, {minOpenCoupling} open, {minLineDisconnections} disco
+
â€ĸ Minimum actions: {minLineReconnections} reco, {minCloseCoupling} close, {minOpenCoupling} open, {minLineDisconnections} disco, {minPst} PST
â€ĸ Maximum suggestions: {nPrioritizedActions}
â€ĸ Ignore reconnections: {ignoreReconnections ? 'Yes' : 'No'}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f2afbf7f..ecba1775 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5,6 +5,7 @@ export interface ConfigRequest { min_close_coupling: number; min_open_coupling: number; min_line_disconnections: number; + min_pst?: number; n_prioritized_actions: number; lines_monitoring_path?: string; monitoring_factor: number; diff --git a/standalone_interface.html b/standalone_interface.html index 5cd74187..cea2bc00 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -1008,6 +1008,7 @@ const [minCloseCoupling, setMinCloseCoupling] = useState(3.0); const [minOpenCoupling, setMinOpenCoupling] = useState(2.0); const [minLineDisconnections, setMinLineDisconnections] = useState(3.0); + const [minPst, setMinPst] = useState(1.0); const [nPrioritizedActions, setNPrioritizedActions] = useState(10); const [monitoringFactor, setMonitoringFactor] = useState(0.95); const [preExistingOverloadThreshold, setPreExistingOverloadThreshold] = useState(0.02); @@ -1016,6 +1017,10 @@ const [monitoredLinesCount, setMonitoredLinesCount] = useState(0); const [totalLinesCount, setTotalLinesCount] = useState(0); const [showMonitoringWarning, setShowMonitoringWarning] = useState(false); + const [actionDictFileName, setActionDictFileName] = useState(null); + const [actionDictStats, setActionDictStats] = useState(null); + const [showActionDictWarning, setShowActionDictWarning] = useState(true); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsTab, setSettingsTab] = useState('paths'); @@ -1314,6 +1319,7 @@ min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, min_line_disconnections: minLineDisconnections, + min_pst: minPst, n_prioritized_actions: nPrioritizedActions, lines_monitoring_path: linesMonitoringPath, monitoring_factor: monitoringFactor, @@ -1322,6 +1328,7 @@ pypowsybl_fast_mode: pypowsyblFastMode }); + if (configRes.data && configRes.data.total_lines_count !== undefined) { setMonitoredLinesCount(configRes.data.monitored_lines_count); setTotalLinesCount(configRes.data.total_lines_count); @@ -1329,6 +1336,8 @@ setShowMonitoringWarning(true); } } + if (configRes.data?.action_dict_file_name) { setActionDictFileName(configRes.data.action_dict_file_name); setShowActionDictWarning(true); } + if (configRes.data?.action_dict_stats) setActionDictStats(configRes.data.action_dict_stats); const [branchesRes, vlRes, nomVRes] = await Promise.all([ axios.get(API_BASE + '/api/branches'), @@ -3442,10 +3451,15 @@

Min Line Disconnections setMinLineDisconnections(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px' }} /> +
+ + setMinPst(parseFloat(e.target.value))} style={{ width: '80px', padding: '5px' }} /> +
setNPrioritizedActions(parseInt(e.target.value, 10))} style={{ width: '80px', padding: '5px' }} />
+
setIgnoreReconnections(e.target.checked)} style={{ width: '18px', height: '18px', cursor: 'pointer' }} /> @@ -3874,13 +3888,26 @@

Simulated Actions

{/* View mode toggle - only visible when an action is selected */} - {/* Processing indicator during analysis */} - {analysisLoading && ( -
- âš™ī¸ Processing analysis... + {/* Processing indicator during analysis - removed duplicate, keeping one under Suggested Actions */} + + + {/* Action Dict Info Warning */} + {showActionDictWarning && !simulating && !analysisLoading && actionDictFileName && actionDictStats && ( +
+
+
â„šī¸ Action dictionary: {actionDictFileName}
+
+ 🔄 Reco: {actionDictStats.reco} + ⛔ Disco: {actionDictStats.disco} + 📐 PST: {actionDictStats.pst} + 🔓 Open coupling: {actionDictStats.open_coupling} + 🔒 Close coupling: {actionDictStats.close_coupling} +
+ +
+
)} -

Selected Actions @@ -4025,7 +4052,7 @@

-
â€ĸ Minimum actions: {minLineReconnections} reco, {minCloseCoupling} close, {minOpenCoupling} open, {minLineDisconnections} disco
+
â€ĸ Minimum actions: {minLineReconnections} reco, {minCloseCoupling} close, {minOpenCoupling} open, {minLineDisconnections} disco, {minPst} PST
â€ĸ Maximum suggestions: {nPrioritizedActions}
â€ĸ Ignore reconnections: {ignoreReconnections ? 'Yes' : 'No'}
From 40759ae7ceed689de642b81fb5fd5be0c3e9ac99 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 17:00:22 +0100 Subject: [PATCH 13/16] fix(standalone): resolve ReferenceError causing blank screen --- standalone_interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone_interface.html b/standalone_interface.html index cea2bc00..1cf67e20 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -3892,7 +3892,7 @@

Simulated Actions

{/* Action Dict Info Warning */} - {showActionDictWarning && !simulating && !analysisLoading && actionDictFileName && actionDictStats && ( + {showActionDictWarning && !simulatingActionId && !analysisLoading && actionDictFileName && actionDictStats && (
â„šī¸ Action dictionary: {actionDictFileName}
From a3f8224b96080a686c0342d05ec1c40d949ffd4a Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 11 Mar 2026 17:16:21 +0100 Subject: [PATCH 14/16] fix: correct action type count logic and resolve nItems typo in standalone --- expert_backend/main.py | 29 +++++++++++++++++++++++------ standalone_interface.html | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/expert_backend/main.py b/expert_backend/main.py index 6ec04a8e..20e87572 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -90,15 +90,32 @@ def update_config(config: ConfigRequest): else: monitored_lines = getattr(recommender_config, 'MONITORED_LINES_COUNT', total_lines) - # Compute action dictionary statistics + # Compute action dictionary statistics using same logic as frontend import os as _os + from expert_op4grid_recommender.action_evaluation.classifier import ActionClassifier + action_dict = recommender_service._dict_action or {} action_file_name = _os.path.basename(config.action_file_path) - n_reco = sum(1 for k in action_dict if k.lower().startswith('reco_')) - n_disco = sum(1 for k in action_dict if k.lower().startswith('disco_')) - n_pst = sum(1 for k in action_dict if 'pst' in k.lower() and not k.lower().startswith('reco_') and not k.lower().startswith('disco_')) - n_open_coupling = sum(1 for k in action_dict if 'open_coupling' in k.lower()) - n_close_coupling = sum(1 for k in action_dict if 'close_coupling' in k.lower()) + + n_reco = n_disco = n_pst = n_open_coupling = n_close_coupling = 0 + classifier = ActionClassifier() + + for k, v in action_dict.items(): + action_id = str(k).lower() + action_desc = str(v.get("description_unitaire", v.get("description", ""))).lower() + t = str(classifier.identify_action_type(v) or "unknown").lower() + + is_disco = 'disco' in t or 'open_line' in t or 'open_load' in t or 'ouverture' in action_desc + is_reco = 'reco' in t or 'close_line' in t or 'close_load' in t or 'fermeture' in action_desc + is_open_coupling = 'open_coupling' in t + is_close_coupling = 'close_coupling' in t + is_pst_action = ('pst' in action_id or 'pst' in action_desc or 'pst' in t) and not is_disco and not is_reco and not is_open_coupling and not is_close_coupling + + if is_disco: n_disco += 1 + if is_reco: n_reco += 1 + if is_open_coupling: n_open_coupling += 1 + if is_close_coupling: n_close_coupling += 1 + if is_pst_action: n_pst += 1 return { "status": "success", diff --git a/standalone_interface.html b/standalone_interface.html index 1cf67e20..dd93cea1 100644 --- a/standalone_interface.html +++ b/standalone_interface.html @@ -4241,7 +4241,7 @@

{/* Action Dict Info Warning */} - {showActionDictWarning && !simulating && !analysisLoading && actionDictFileName && actionDictStats && ( + {showActionDictWarning && !simulating && !pendingAnalysisResult && Object.keys(actions).length === 0 && actionDictFileName && actionDictStats && (
-
â„šī¸ Action dictionary: {actionDictFileName}
+
â„šī¸ Action dictionary: {actionDictFileName}
🔄 Reco: {actionDictStats.reco} ⛔ Disco: {actionDictStats.disco} @@ -698,7 +698,7 @@ const ActionFeed: React.FC = ({ )}
- +
)}
@@ -737,9 +737,24 @@ const ActionFeed: React.FC = ({
- {/* Processing indicator during analysis - removed duplicate, keeping one under Suggested Actions */} - - + {/* Processing indicator during analysis */} + {analysisLoading && ( +
+ âš™ī¸ Processing analysis... +
+ )} {/* Display prioritized actions button inside Suggested Actions section */} {pendingAnalysisResult && !analysisLoading && (