Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions expert_backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,52 @@ def get_focused_diagram(request: FocusedDiagramRequest):
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e))

class ActionVariantFocusedRequest(BaseModel):
action_id: str
element_id: str
depth: int = 1

@app.post("/api/action-variant-focused-diagram")
def get_action_variant_focused_diagram(request: ActionVariantFocusedRequest):
"""Generate a focused NAD for a specific VL in the post-action network state."""
try:
vl_ids = network_service.get_element_voltage_levels(request.element_id)
if not vl_ids:
raise HTTPException(status_code=404, detail=f"No voltage levels found for {request.element_id}")
diagram = recommender_service.get_action_variant_diagram(
request.action_id,
voltage_level_ids=vl_ids,
depth=request.depth,
)
diagram["voltage_level_ids"] = vl_ids
return diagram
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e))

class ActionVariantSldRequest(BaseModel):
action_id: str
voltage_level_id: str

@app.post("/api/action-variant-sld")
def get_action_variant_sld(request: ActionVariantSldRequest):
"""Generate a Single Line Diagram (SLD) for a voltage level in the post-action network state."""
try:
diagram = recommender_service.get_action_variant_sld(
request.action_id,
request.voltage_level_id,
)
return diagram
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e))

@app.get("/api/actions")
def get_actions():
"""Return all available action IDs and descriptions from the loaded dictionary."""
Expand Down
33 changes: 33 additions & 0 deletions expert_backend/services/recommender_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,39 @@ def get_action_variant_diagram(self, action_id, voltage_level_ids=None, depth=0,

return diagram

def get_action_variant_sld(self, action_id: str, voltage_level_id: str) -> dict:
"""Generate a Single Line Diagram (SLD) for a specific VL in the post-action state.

Args:
action_id: ID of the action to visualize
voltage_level_id: ID of the voltage level to diagram
"""
if not self._last_result or not self._last_result.get("prioritized_actions"):
raise ValueError("No analysis result available. Run analysis first.")

actions = self._last_result["prioritized_actions"]
if action_id not in actions:
raise ValueError(f"Action '{action_id}' not found in last analysis result.")

obs = actions[action_id]["observation"]
variant_id = obs._variant_id
nm = obs._network_manager
nm.set_working_variant(variant_id)
network = nm.network

sld = network.get_single_line_diagram(voltage_level_id)
try:
from pypowsybl_jupyter.util import _get_svg_string
svg = _get_svg_string(sld)
except Exception:
svg = str(sld)

return {
"svg": svg,
"action_id": action_id,
"voltage_level_id": voltage_level_id,
}

def _get_network_flows(self, network):
"""Extract p1/p2 flows for lines and transformers from a simulated network."""
import numpy as np
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ function App() {
setActionViewMode(mode);
}, []);

// ===== Asset Click (from action card badges / rho line names) =====
const handleAssetClick = useCallback((actionId: string, assetName: string, tab: 'action' | 'n-1' = 'action') => {
setInspectQuery(assetName);
if (tab === 'n-1') {
// Rho-before lines live in the N-1 (post-contingency) view
setActiveTab('n-1');
} else if (actionId !== selectedActionId) {
// Select the action; zoom fires once its diagram loads
handleActionSelect(actionId);
} else {
setActiveTab('action');
}
}, [selectedActionId, handleActionSelect]);

// ===== Reset View =====
const handleManualReset = useCallback(() => {
setInspectQuery('');
Expand Down Expand Up @@ -430,10 +444,20 @@ function App() {
return n;
};

const targetNode = nodesByEquipmentId.get(targetId);
const targetEdge = edgesByEquipmentId.get(targetId);
let targetNode = nodesByEquipmentId.get(targetId);
let targetEdge = edgesByEquipmentId.get(targetId);
let targetSvgId: string | undefined;

// Fallback: strip prefix before "." (e.g. GEN.PY762 → PY762) and try as VL node
if (!targetNode && !targetEdge) {
const dotIdx = targetId.indexOf('.');
if (dotIdx >= 0) {
const suffix = targetId.substring(dotIdx + 1);
targetNode = nodesByEquipmentId.get(suffix) ?? undefined;
if (!targetNode) targetEdge = edgesByEquipmentId.get(suffix) ?? undefined;
}
}

if (targetNode) {
targetSvgId = targetNode.svgId;
points.push({ x: targetNode.x, y: targetNode.y });
Expand Down Expand Up @@ -562,6 +586,9 @@ function App() {
linesOverloaded={result?.lines_overloaded || []}
selectedActionId={selectedActionId}
onActionSelect={handleActionSelect}
onAssetClick={handleAssetClick}
nodesByEquipmentId={nMetaIndex?.nodesByEquipmentId ?? null}
edgesByEquipmentId={nMetaIndex?.edgesByEquipmentId ?? null}
disconnectedElement={selectedBranch || null}
onManualActionAdded={handleManualActionAdded}
actionViewMode={actionViewMode}
Expand Down
90 changes: 83 additions & 7 deletions frontend/src/components/ActionFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import type { ActionDetail } from '../types';
import type { ActionDetail, NodeMeta, EdgeMeta } from '../types';
import { api } from '../api';
import { getActionTargetVoltageLevel, getActionTargetLines } from '../utils/svgUtils';

interface ActionFeedProps {
actions: Record<string, ActionDetail>;
actionScores?: Record<string, Record<string, unknown>>;
linesOverloaded: string[];
selectedActionId: string | null;
onActionSelect: (actionId: string | null) => void;
onAssetClick: (actionId: string, assetName: string, tab?: 'action' | 'n-1') => void;
nodesByEquipmentId: Map<string, NodeMeta> | null;
edgesByEquipmentId: Map<string, EdgeMeta> | null;
disconnectedElement: string | null;
onManualActionAdded: (actionId: string, detail: ActionDetail) => void;
actionViewMode: 'network' | 'delta';
Expand All @@ -21,6 +25,9 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
linesOverloaded,
selectedActionId,
onActionSelect,
onAssetClick,
nodesByEquipmentId,
edgesByEquipmentId,
disconnectedElement,
onManualActionAdded,
actionViewMode,
Expand Down Expand Up @@ -130,9 +137,33 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
}
};

const formatRho = (arr: number[] | null): string => {
const clickableLinkStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: 'inherit',
color: '#1e40af',
fontWeight: 600,
textDecoration: 'underline dotted',
};

const renderRho = (arr: number[] | null, actionId: string, tab: 'action' | 'n-1' = 'action'): React.ReactNode => {
if (!arr || arr.length === 0) return '\u2014';
return arr.map((v, i) => `${linesOverloaded[i] || 'line ' + i}: ${(v * 100).toFixed(1)}%`).join(', ');
return arr.map((v, i) => {
const lineName = linesOverloaded[i] || `line ${i}`;
return (
<React.Fragment key={i}>
{i > 0 && ', '}
<button
style={clickableLinkStyle}
title={`Zoom to ${lineName}`}
onClick={(e) => { e.stopPropagation(); onAssetClick(actionId, lineName, tab); }}
>{lineName}</button>
{`: ${(v * 100).toFixed(1)}%`}
</React.Fragment>
);
});
};

// Sort actions by max_rho ascending (matching standalone)
Expand Down Expand Up @@ -451,14 +482,59 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
</span>
</div>
</div>
<p style={{ fontSize: '13px' }}>{details.description_unitaire}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', margin: '4px 0 5px' }}>
<p style={{ fontSize: '13px', margin: 0, flex: 1 }}>{details.description_unitaire}</p>
{(() => {
const badgeBtn = (name: string, bg: string, color: string, title: string) => (
<button key={name}
style={{ padding: '2px 7px', borderRadius: '4px', border: 'none', cursor: 'pointer', fontSize: '11px', fontWeight: 600, textDecoration: 'underline dotted', flexShrink: 0, backgroundColor: bg, color }}
title={title}
onClick={(e) => { e.stopPropagation(); onAssetClick(id, name, 'action'); }}>
{name}
</button>
);
const vlName = nodesByEquipmentId
? getActionTargetVoltageLevel(details, id, nodesByEquipmentId)
: null;
if (vlName) return badgeBtn(vlName, '#d1fae5', '#065f46', `Zoom to voltage level ${vlName}`);
const lineNames = edgesByEquipmentId
? getActionTargetLines(details, id, edgesByEquipmentId)
: Array.from(new Set([
...Object.keys(details.action_topology?.lines_ex_bus || {}),
...Object.keys(details.action_topology?.lines_or_bus || {}),
]));
if (lineNames.length > 0) return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', flexShrink: 0 }}>
{lineNames.map(name => badgeBtn(name, '#dbeafe', '#1e40af', `Zoom to line ${name}`))}
</div>
);
// Fallback: gen/load equipment names from topology
const topo = details.action_topology;
const equipNames = [
...Object.keys(topo?.gens_bus || {}),
...Object.keys(topo?.loads_bus || {}),
];
if (equipNames.length > 0) return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', flexShrink: 0 }}>
{equipNames.map(name => badgeBtn(name, '#dbeafe', '#1e40af', `Zoom to ${name}`))}
</div>
);
return null;
})()}
</div>
<div style={{ fontSize: '12px', background: isSelected ? '#dce8f7' : '#f8f9fa', padding: '5px', marginTop: '5px' }}>
<div>Rho before: {formatRho(details.rho_before)}</div>
<div>Rho after: {formatRho(details.rho_after)}</div>
<div>Rho before: {renderRho(details.rho_before, id, 'n-1')}</div>
<div>Rho after: {renderRho(details.rho_after, id, 'action')}</div>
{maxRhoPct != null && (
<div style={{ marginTop: '3px' }}>
Max rho: <strong style={{ color: sc.border }}>{maxRhoPct}%</strong>
{details.max_rho_line && <span style={{ color: '#888' }}> on {details.max_rho_line}</span>}
{details.max_rho_line && (
<span style={{ color: '#888' }}> on <button
style={{ ...clickableLinkStyle, color: '#888' }}
title={`Zoom to ${details.max_rho_line}`}
onClick={(e) => { e.stopPropagation(); onAssetClick(id, details.max_rho_line, 'action'); }}
>{details.max_rho_line}</button></span>
)}
</div>
)}
</div>
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/utils/svgUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,31 @@ export const getActionTargetVoltageLevel = (
): string | null => {
const desc = actionDetail?.description_unitaire;
if (desc && desc !== 'No description available') {
// Try all quoted strings (last-first) — any might be the VL name
const quotedMatches = desc.match(/'([^']+)'/g);
if (quotedMatches && quotedMatches.length > 0) {
const vl = quotedMatches[quotedMatches.length - 1].replace(/'/g, '');
if (nodesByEquipmentId.has(vl)) return vl;
if (quotedMatches) {
for (let i = quotedMatches.length - 1; i >= 0; i--) {
const vl = quotedMatches[i].replace(/'/g, '');
if (nodesByEquipmentId.has(vl)) return vl;
}
}
const posteMatch = desc.match(/dans le poste\s+(\S+)/i);
// Match "dans le poste", "du poste", "au poste", etc.
const posteMatch = desc.match(/(?:dans le |du |au )?poste\s+'?(\S+?)'?(?:\s|$|,)/i);
if (posteMatch) {
const vl = posteMatch[1].replace(/['"]/g, '');
if (nodesByEquipmentId.has(vl)) return vl;
}
}
if (actionId) {

// Fallback: action ID suffix — skip for pure line reconnection actions
// (where lines are reconnected to real buses ≥ 0 with no gen/load changes),
// because the suffix can coincidentally match a VL name.
const topo = actionDetail?.action_topology;
const isLineReconnection = topo
&& (Object.keys(topo.gens_bus || {}).length === 0 && Object.keys(topo.loads_bus || {}).length === 0)
&& [...Object.values(topo.lines_ex_bus || {}), ...Object.values(topo.lines_or_bus || {})].some(v => v >= 0);

if (actionId && !isLineReconnection) {
const parts = actionId.split('_');
const candidate = parts[parts.length - 1];
if (nodesByEquipmentId.has(candidate)) return candidate;
Expand Down
Loading