From b05e4d63c02197f50d045e5d2e4c56ac663178eb Mon Sep 17 00:00:00 2001 From: marota Date: Mon, 4 May 2026 21:48:34 +0200 Subject: [PATCH] Add semantic source-of-truth tagging + grouped layer toggles The overflow graph now carries explicit boolean flags driving the HTML viewer's layer toggles, replacing brittle colour/shape heuristics. The viewer surfaces three labelled sections (Structural Paths / Individual entities properties / Flow redispatch values) with a Select-all / Unselect-all row and dim-instead-of-hide behaviour so unchecked elements stay visible at low opacity. OverflowGraph: - set_hubs_shape sets is_hub on hub nodes (also tagged as in_red_loop AND on_constrained_path so they show under those layers regardless of recommender list contents). - New tag_red_loops(lines, nodes) and tag_constrained_path(lines, nodes) helpers driven by the recommender's lists. - highlight_significant_line_loading sets is_monitored on every monitored line and is_overload on the strict subset of overloaded contingency lines (is_overload is a strict subset of is_monitored). Interactive HTML viewer: - _build_layer_index scans data-attr-* on nodes AND edges and emits semantic layers (Hubs, Red-loop paths, Constrained path, Overloads, Low margin lines). - Layers are grouped into three sections via _LAYER_SECTIONS and rendered in _SECTION_ORDER with section headers. - Membership-based dim model: an element is visible iff at least one of its claiming layers is checked; opacity drops to 0.12 otherwise (no display:none, so click/hover stay live). - Endpoint-node inclusion: edge-only layers also dim/highlight the bbox-endpoint nodes of their claimed edges. - Select-all / Unselect-all link row above the layer list. - _align_edge_ids_with_svg() regression guard: re-keys the JSON edge model from SVG endpoints so the data-source / data-target attributes always match the (src, dst) endpoints graphviz baked into the title. Tests: - test_overflow_graph.py covers the new is_hub / in_red_loop / is_monitored / is_overload / on_constrained_path attributes, the strict overload-subset semantics, and the constrained-path / red-loop taggers. - test_interactive_html.py covers the new semantic layer keys, section grouping, membership dim model, endpoint-node inclusion, edge-id alignment guard, and the Select-all / Unselect-all row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- alphaDeesp/core/graphs/overflow_graph.py | 151 +++++++- alphaDeesp/core/interactive_html.py | 438 ++++++++++++++++++++-- alphaDeesp/tests/graphs_test_helpers.py | 6 + alphaDeesp/tests/test_interactive_html.py | 295 ++++++++++++++- alphaDeesp/tests/test_overflow_graph.py | 303 +++++++++++++++ 5 files changed, 1159 insertions(+), 34 deletions(-) diff --git a/alphaDeesp/core/graphs/overflow_graph.py b/alphaDeesp/core/graphs/overflow_graph.py index 60cd4b3..3681f61 100644 --- a/alphaDeesp/core/graphs/overflow_graph.py +++ b/alphaDeesp/core/graphs/overflow_graph.py @@ -136,11 +136,35 @@ def keep_overloads_components(self) -> None: self.g[u][v][key]["color"] = "gray" def set_hubs_shape(self, hubs: Iterable[Any], shape_hub: str = "circle") -> None: - """Distinguish hub nodes with a custom shape.""" + """Distinguish hub nodes with a custom shape. + + Also stamps a ``is_hub`` boolean node attribute (source-of-truth flag) + so downstream consumers — notably the interactive HTML viewer — can + identify hubs without reinterpreting the visual shape. + + Hubs are by definition both **on the constrained path** AND + **inside red-loop paths** (they are the converging substations + feeding the overloaded lines, surrounded by positive-flow + redispatch loops). Those flags are propagated here so a viewer + layer toggle stays consistent regardless of which other tagging + method (``tag_constrained_path`` / ``collapse_red_loops``) has + already run. + """ dict_shapes = {node: "oval" for node in self.g.nodes} - for hub in hubs: + hubs_set = set(hubs) + for hub in hubs_set: dict_shapes[hub] = shape_hub nx.set_node_attributes(self.g, dict_shapes, "shape") + nx.set_node_attributes( + self.g, {node: (node in hubs_set) for node in self.g.nodes}, "is_hub" + ) + if hubs_set: + nx.set_node_attributes( + self.g, {h: True for h in hubs_set}, "on_constrained_path" + ) + nx.set_node_attributes( + self.g, {h: True for h in hubs_set}, "in_red_loop" + ) def highlight_swapped_flows(self, lines_swapped: List[Any]) -> None: """Draw lines whose flow direction has swapped in a tapered style.""" @@ -150,13 +174,29 @@ def highlight_swapped_flows(self, lines_swapped: List[Any]) -> None: nx.set_edge_attributes(self.g, {edge: value for edge in swapped_edges}, attr_name) def highlight_significant_line_loading(self, dict_line_loading: Dict[Any, Any]) -> None: - """Augment edge labels with loading rates for monitored lines.""" + """Augment edge labels with loading rates for monitored lines. + + Also stamps source-of-truth flags so the interactive viewer can + toggle them as semantic layers without scraping the compound + ``"X:yellow:X"`` colour: + + * ``is_monitored=True`` — every edge in ``dict_line_loading`` + (i.e. every line whose loading rate is high enough to be + flagged as a "low-margin" line by the recommender). + * ``is_overload=True`` — the strict subset of monitored edges + that are overloaded contingency lines (current colour was + ``black`` before the highlight). Overloads are therefore a + subset of low-margin lines, not a disjoint category. + """ edge_names = nx.get_edge_attributes(self.g, "name") edge_colors = nx.get_edge_attributes(self.g, "color") edge_x_labels = nx.get_edge_attributes(self.g, "label") label_font_color = {edge: "black" for edge in edge_names.keys()} color_label_highlight = "darkred" + is_overload_attrs: Dict[Any, bool] = {} + is_monitored_attrs: Dict[Any, bool] = {} + for edge, edge_name in edge_names.items(): if edge_name not in dict_line_loading: continue @@ -165,8 +205,12 @@ def highlight_significant_line_loading(self, dict_line_loading: Dict[Any, Any]) before = dict_line_loading[edge_name]["before"] after = dict_line_loading[edge_name]["after"] + # Every entry in dict_line_loading is a monitored / low- + # margin line; the black ones are additionally overloads. + is_monitored_attrs[edge] = True if current_edge_color == "black": edge_x_labels[edge] = f'< {current_x_label} <BR/> <B>{before}%</B> → {after}%>' + is_overload_attrs[edge] = True else: edge_x_labels[edge] = f'< {current_x_label} <BR/> {before}% → <B>{after}%</B> >' @@ -176,6 +220,10 @@ def highlight_significant_line_loading(self, dict_line_loading: Dict[Any, Any]) nx.set_edge_attributes(self.g, edge_x_labels, "label") nx.set_edge_attributes(self.g, label_font_color, "fontcolor") nx.set_edge_attributes(self.g, edge_colors, "color") + if is_overload_attrs: + nx.set_edge_attributes(self.g, is_overload_attrs, "is_overload") + if is_monitored_attrs: + nx.set_edge_attributes(self.g, is_monitored_attrs, "is_monitored") def plot( self, @@ -209,7 +257,15 @@ def rename_nodes(self, mapping: Dict[Any, Any]) -> None: self.df["idx_ex"] = [mapping[idx_or] for idx_or in self.df["idx_ex"]] def collapse_red_loops(self) -> None: - """Collapse purely-coral, non-hub nodes to point shapes.""" + """Collapse purely-coral, non-hub nodes to point shapes. + + This is purely a visual heuristic for the rendered graph + (point markers vs ovals). The semantic ``in_red_loop`` flag is + no longer derived from this collapse — it is set explicitly by + :meth:`tag_red_loops` from the recommender's + ``get_dispatch_edges_nodes(only_loop_paths=True)`` source-of- + truth list. + """ shapes = nx.get_node_attributes(self.g, "shape") peripheries = nx.get_node_attributes(self.g, "peripheries") edge_colors = nx.get_edge_attributes(self.g, "color") @@ -227,6 +283,93 @@ def collapse_red_loops(self) -> None: nx.set_node_attributes(self.g, nodes_to_collapse, "shape") + def tag_red_loops( + self, + lines_red_loops: Optional[Iterable[str]] = None, + nodes_red_loops: Optional[Iterable[Any]] = None, + ) -> None: + """Tag the source-of-truth ``in_red_loop`` flag on the edges + and nodes that form the dispatch loop paths identified upstream + by ``Structured_Overload_Distribution_Graph.get_dispatch_edges_ + nodes(only_loop_paths=True)``. + + This replaces the previous ad-hoc heuristics (collapse-based, + connected-component-based) that produced false positives on + coral "exit" branches such as the CHALOP6→CHALOP3 transformers + the user reported. The recommender already computes the actual + cycle paths from the structured analysis — we just propagate + them as graph attributes. + + Edges are matched by their ``name`` attribute (the line name + used everywhere else in the codebase). Nodes are matched by + identity. + """ + if lines_red_loops: + wanted = set(lines_red_loops) + edge_names = nx.get_edge_attributes(self.g, "name") + edge_attrs = { + edge: True for edge, name in edge_names.items() if name in wanted + } + if edge_attrs: + nx.set_edge_attributes(self.g, edge_attrs, "in_red_loop") + if nodes_red_loops: + wanted_nodes = set(nodes_red_loops) + node_attrs = { + node: True for node in self.g.nodes if node in wanted_nodes + } + if node_attrs: + nx.set_node_attributes(self.g, node_attrs, "in_red_loop") + + def tag_constrained_path( + self, + lines_constrained_path: Optional[Iterable[str]] = None, + nodes_constrained_path: Optional[Iterable[Any]] = None, + ) -> None: + """Tag the source-of-truth ``on_constrained_path`` flag on the + edges and nodes that form the constrained path identified + upstream by the recommender's distribution graph analysis. + + Edges are matched by their ``name`` attribute (the line name + used everywhere else in the codebase). Nodes are matched by + identity in the graph. + + **Coral edges are skipped** even when their ``name`` matches a + constrained-path entry. The constrained path is, by definition, + the network of black (overloaded) and blue (negative-flow) + edges that funnel current into the overloads. The overflow + ``MultiDiGraph`` may carry both flow directions of a single + physical line under the same ``name`` — only the negative one + is on the constrained path; including the coral counterpart + would surface positive-overflow edges in the layer toggle and + confuse the operator. + """ + if lines_constrained_path: + wanted = set(lines_constrained_path) + edge_names = nx.get_edge_attributes(self.g, "name") + edge_colors = nx.get_edge_attributes(self.g, "color") + edge_attrs: Dict[Any, bool] = {} + for edge, name in edge_names.items(): + if name not in wanted: + continue + color = edge_colors.get(edge, "") + base_color = ( + color.split(":", 1)[0].strip().strip('"').lower() + if isinstance(color, str) + else "" + ) + if base_color == "coral": + continue + edge_attrs[edge] = True + if edge_attrs: + nx.set_edge_attributes(self.g, edge_attrs, "on_constrained_path") + if nodes_constrained_path: + wanted_nodes = set(nodes_constrained_path) + node_attrs = { + node: True for node in self.g.nodes if node in wanted_nodes + } + if node_attrs: + nx.set_node_attributes(self.g, node_attrs, "on_constrained_path") + @staticmethod def _all_edges_coral_no_dash( all_edges: List[Any], diff --git a/alphaDeesp/core/interactive_html.py b/alphaDeesp/core/interactive_html.py index 39640bb..4512080 100644 --- a/alphaDeesp/core/interactive_html.py +++ b/alphaDeesp/core/interactive_html.py @@ -30,17 +30,21 @@ logger = logging.getLogger(__name__) -# Edge color → human-readable layer label. The set is small and maps the -# semantic palette used across the codebase (overflow_graph.py, -# null_flow_graph.py): black=constrained, coral=positive overflow, -# blue=negative flow, gray=low/inactive, dimgray=null-flow recoloured. +# Section names rendered as <h3> headers in the sidebar layer list. +# Layers carry their section in the model so the JS just groups by it. +_SECTION_STRUCTURAL = "Structural Paths" +_SECTION_PROPERTIES = "Individual entities properties" +_SECTION_FLOWS = "Flow redispatch values" + +# Edge color → human-readable layer label. Restricted to the three flow +# polarities (positive / negative / null). The historical "black" / +# "gray" / "darkred" buckets are dropped because they are redundant +# with the explicit semantic flags (is_overload / is_monitored) or +# carry no operational meaning on their own. _LAYER_LABELS: Dict[str, str] = { - "black": "Constrained line", - "coral": "Positive overflow", - "blue": "Negative flow", - "gray": "Low / inactive", - "dimgray": "Null-flow", - "darkred": "Highlighted loading", + "coral": "Positive", + "blue": "Negative", + "dimgray": "Null", } # Edge style → layer label (orthogonal to color). @@ -50,6 +54,55 @@ "tapered": "Swapped flow", } +# Source-of-truth attribute layers — values produced upstream by +# alphaDeesp / expert_op4grid_recommender as explicit boolean flags on +# nodes and/or edges. The viewer scans for them and exposes a layer +# toggle for each. Defining them here (rather than guessing from edge +# colours / shapes) keeps the layer list semantically stable when the +# visual palette evolves. +# +# Each entry: +# key — `data-attr-*` flag scanned on node and edge groups +# label — human-readable sidebar label +# swatch — special-case identifier consumed by the JS +# template to render an inline SVG glyph (no colour +# chip — these layers cut across the colour palette) +# scope — "node", "edge", or "both" +_SEMANTIC_LAYERS: List[Dict[str, str]] = [ + {"key": "on_constrained_path", "label": "Constrained path", "swatch": "constrained-path", "scope": "both"}, + {"key": "in_red_loop", "label": "Red-loop paths", "swatch": "red-loop", "scope": "both"}, + {"key": "is_overload", "label": "Overloads", "swatch": "overload", "scope": "edge"}, + {"key": "is_monitored", "label": "Low margin lines", "swatch": "monitored", "scope": "edge"}, + {"key": "is_hub", "label": "Hubs", "swatch": "diamond", "scope": "node"}, +] + +# Per-layer-key section assignment. The JS renders one ``<h3>`` per +# section in the order the sections are first encountered. +_LAYER_SECTIONS: Dict[str, str] = { + # Structural paths — multi-edge structures. + "semantic:on_constrained_path": _SECTION_STRUCTURAL, + "semantic:in_red_loop": _SECTION_STRUCTURAL, + # Individual entities properties — per-edge / per-node flags. + "semantic:is_overload": _SECTION_PROPERTIES, + "semantic:is_monitored": _SECTION_PROPERTIES, + "semantic:is_hub": _SECTION_PROPERTIES, + "style:dashed": _SECTION_PROPERTIES, + "style:dotted": _SECTION_PROPERTIES, + "style:tapered": _SECTION_PROPERTIES, + # Flow polarity buckets. + "color:coral": _SECTION_FLOWS, + "color:blue": _SECTION_FLOWS, + "color:dimgray": _SECTION_FLOWS, +} + +# Render order: sections appear top-to-bottom in this order; layers +# within a section appear in the order the model emits them. +_SECTION_ORDER: List[str] = [ + _SECTION_STRUCTURAL, + _SECTION_PROPERTIES, + _SECTION_FLOWS, +] + def _decode_title(text: str) -> str: """Graphviz HTML-escapes node/edge titles in SVG (``A->B``).""" @@ -124,7 +177,7 @@ def _model_from_dot_json(dot_json: bytes) -> Dict[str, Any]: adjacency.setdefault(src, []).append({"node": dst, "edge": f"edge{j + 1}"}) adjacency.setdefault(dst, []).append({"node": src, "edge": f"edge{j + 1}"}) - layers = _build_layer_index(edges) + layers = _build_layer_index(edges, nodes) return { "nodes": nodes, "edges": edges, @@ -133,10 +186,31 @@ def _model_from_dot_json(dot_json: bytes) -> Dict[str, Any]: } -def _build_layer_index(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Group edges by color/style so the UI can offer toggles.""" +def _is_truthy_flag(value: Any) -> bool: + """Check whether a graph attribute represents a True boolean flag. + + Boolean attributes round-trip through pydot/graphviz/dot-json as + string ``"True"``. We accept the native Python ``True`` for + in-process callers and the string form for the JSON path. + """ + if value is True: + return True + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def _build_layer_index( + edges: List[Dict[str, Any]], + nodes: List[Dict[str, Any]] | None = None, +) -> List[Dict[str, Any]]: + """Group edges & nodes by colour / style / semantic flag so the UI + can offer toggles. Each layer carries both ``nodes`` and ``edges`` + id lists (either may be empty). + """ by_color: Dict[str, List[str]] = {} by_style: Dict[str, List[str]] = {} + edge_id_lookup = {e["id"]: e for e in edges} for e in edges: color_key = _color_to_layer_key(e["attrs"].get("color", "")) if color_key: @@ -145,21 +219,113 @@ def _build_layer_index(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: if style in _STYLE_LAYERS: by_style.setdefault(style, []).append(e["id"]) - layers: List[Dict[str, Any]] = [] + # Semantic flags scanned on both nodes and edges. Only emit a layer + # entry if at least one element carries the flag — otherwise the + # checkbox would be useless and noise. + semantic_buckets: Dict[str, Dict[str, List[str]]] = { + cfg["key"]: {"nodes": [], "edges": []} for cfg in _SEMANTIC_LAYERS + } + if nodes: + for n in nodes: + for cfg in _SEMANTIC_LAYERS: + if cfg["scope"] in ("node", "both") and _is_truthy_flag( + n["attrs"].get(cfg["key"]) + ): + semantic_buckets[cfg["key"]]["nodes"].append(n["name"]) + for e in edges: + for cfg in _SEMANTIC_LAYERS: + if cfg["scope"] in ("edge", "both") and _is_truthy_flag( + e["attrs"].get(cfg["key"]) + ): + semantic_buckets[cfg["key"]]["edges"].append(e["id"]) + + # For each colour / style layer, the endpoint nodes of every + # claimed edge are also added to the layer so toggling, e.g., + # "Positive overflow" alone keeps the substations the coral edges + # connect visible (the operator can still read the topology around + # the highlighted edges instead of seeing them float in dimmed + # space). We dedupe while preserving first-seen order. + edge_id_to_endpoints: Dict[str, Tuple[str, str]] = { + e["id"]: (e["source"], e["target"]) for e in edges + } + + def _endpoint_nodes(edge_ids: List[str]) -> List[str]: + seen: Dict[str, None] = {} + for eid in edge_ids: + ends = edge_id_to_endpoints.get(eid) + if not ends: + continue + for n in ends: + if n not in seen: + seen[n] = None + return list(seen.keys()) + + # Edge-only semantic layers (Overloads, Low margin lines) carry + # their edges' endpoints too — same UX rationale as colour/style + # layers: when the operator ticks "Overloads" alone the affected + # substations stay visible. + _EDGE_ONLY_SEMANTIC_KEYS = { + cfg["key"] for cfg in _SEMANTIC_LAYERS if cfg["scope"] == "edge" + } + + def _merge_dedup(base: List[str], extra: List[str]) -> List[str]: + seen: Dict[str, None] = {n: None for n in base} + for n in extra: + if n not in seen: + seen[n] = None + return list(seen.keys()) + + raw_layers: List[Dict[str, Any]] = [] for key, ids in by_color.items(): - layers.append({ + raw_layers.append({ "key": f"color:{key}", "label": _LAYER_LABELS[key], "swatch": key, + "nodes": _endpoint_nodes(ids), "edges": ids, }) for key, ids in by_style.items(): - layers.append({ + raw_layers.append({ "key": f"style:{key}", "label": _STYLE_LAYERS[key], "swatch": "", + "nodes": _endpoint_nodes(ids), "edges": ids, }) + for cfg in _SEMANTIC_LAYERS: + bucket = semantic_buckets[cfg["key"]] + if not bucket["nodes"] and not bucket["edges"]: + continue + nodes_for_layer = bucket["nodes"] + if cfg["key"] in _EDGE_ONLY_SEMANTIC_KEYS: + nodes_for_layer = _merge_dedup( + nodes_for_layer, _endpoint_nodes(bucket["edges"]) + ) + raw_layers.append({ + "key": f"semantic:{cfg['key']}", + "label": cfg["label"], + "swatch": cfg["swatch"], + "nodes": nodes_for_layer, + "edges": bucket["edges"], + }) + + # Drop layers without a section assignment (e.g. ``color:black``, + # ``color:gray``, ``color:darkred`` — historically redundant + # buckets). Then group by section in the canonical order so the + # JS can render them with section headers. + sectioned: Dict[str, List[Dict[str, Any]]] = {s: [] for s in _SECTION_ORDER} + for layer in raw_layers: + section = _LAYER_SECTIONS.get(layer["key"]) + if section is None: + continue + layer["section"] = section + sectioned.setdefault(section, []).append(layer) + + layers: List[Dict[str, Any]] = [] + for section in _SECTION_ORDER: + layers.extend(sectioned.get(section, [])) + # Silence unused-var warning; lookup retained for future hover xref. + del edge_id_lookup return layers @@ -253,9 +419,15 @@ def _edge_repl(match: re.Match) -> str: } #sidebar h1 { font-size: 14px; margin: 0 0 6px; } #sidebar h2 { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 14px 0 6px; letter-spacing: 0.04em; } + #sidebar .layer-section-header { font-size: 11px; text-transform: uppercase; color: var(--muted); margin: 10px 0 4px; letter-spacing: 0.03em; font-weight: 600; border-top: 1px solid var(--border); padding-top: 6px; } + #sidebar .layer-section-header:first-of-type { border-top: none; padding-top: 0; margin-top: 4px; } #sidebar input[type=text] { width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px; } #sidebar label { display: flex; align-items: center; gap: 6px; padding: 3px 0; cursor: pointer; } - #sidebar label .swatch { width: 12px; height: 12px; border-radius: 2px; border: 1px solid #ccc; flex-shrink: 0; } + #sidebar label .swatch { width: 12px; height: 12px; border-radius: 2px; border: 1px solid #ccc; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; } + #sidebar label .swatch svg { width: 10px; height: 10px; display: block; } + #sidebar .layer-controls { display: flex; gap: 8px; font-size: 11px; margin-bottom: 4px; } + #sidebar .layer-controls button { background: transparent; border: none; padding: 2px 4px; cursor: pointer; color: var(--accent); font-size: 11px; text-decoration: underline; } + #sidebar .layer-controls button:hover { background: #eef; } #sidebar .hint { color: var(--muted); font-size: 11px; line-height: 1.5; margin-top: 6px; } #info { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 11px; white-space: pre-wrap; word-break: break-word; background: #f6f8fa; border-radius: 4px; padding: 8px; min-height: 60px; } #stage { flex: 1; position: relative; overflow: hidden; background: var(--bg); } @@ -272,7 +444,11 @@ def _edge_repl(match: re.Match) -> str: .graph .node.selected ellipse, .graph .node.selected polygon, .graph .node.selected circle { stroke-width: 5px; } - .graph .edge.layer-off { display: none; } + /* Unchecked layers stay rendered but recede to a near-transparent + light-grey so the user retains spatial context. Applied to both + nodes and edges. Pointer-events kept enabled so hover/click still + works on dimmed elements. */ + .graph .layer-off { opacity: 0.12; } .graph.has-search .node:not(.match) { opacity: var(--dim-opacity); } .graph .node.match ellipse, .graph .node.match polygon, @@ -289,6 +465,11 @@ def _edge_repl(match: re.Match) -> str: <input type="text" id="search" placeholder="filter nodes by name…" autocomplete="off"> <h2>Layers</h2> + <div class="layer-controls"> + <button id="layers-select-all" type="button" title="Show every layer">Select all</button> + <span style="color: var(--muted)">·</span> + <button id="layers-select-none" type="button" title="Dim every layer">Unselect all</button> + </div> <div id="layers"></div> <h2>Selection</h2> @@ -439,21 +620,141 @@ def _edge_repl(match: re.Match) -> str: document.getElementById('search').addEventListener('input', applySearch); // ---- Layer toggles ---- + // Membership-based dim model: every node and edge knows which + // layers claim it. An element is **visible** iff at least one of + // its claiming layers is currently checked. Elements with no + // memberships at all are dimmed whenever any layer toggle differs + // from the default "all checked" state — that matches the + // user-facing intent that ticking a single layer focuses the view + // on that layer only and recedes everything else. const layersEl = document.getElementById('layers'); + function swatchInner(swatch) { + if (!swatch) return '<span style="display:block;width:100%;height:100%;border:1px dashed #999"></span>'; + const COLORED = {coral:1, blue:1, black:1, gray:1, dimgray:1, darkred:1, red:1, green:1}; + if (COLORED[swatch]) return ''; + if (swatch === 'diamond') return '<svg viewBox="0 0 10 10"><polygon points="5,0 10,5 5,10 0,5" fill="#444"/></svg>'; + if (swatch === 'red-loop') return '<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="2" fill="coral"/><circle cx="5" cy="5" r="4" fill="none" stroke="coral" stroke-width="1"/></svg>'; + if (swatch === 'constrained-path') return '<svg viewBox="0 0 14 6"><line x1="0" y1="3" x2="14" y2="3" stroke="black" stroke-width="2"/></svg>'; + if (swatch === 'overload') return '<svg viewBox="0 0 14 6"><line x1="0" y1="3" x2="14" y2="3" stroke="black" stroke-width="2.5"/><line x1="0" y1="3" x2="14" y2="3" stroke="yellow" stroke-width="0.8"/></svg>'; + if (swatch === 'monitored') return '<svg viewBox="0 0 14 6"><line x1="0" y1="3" x2="14" y2="3" stroke="coral" stroke-width="2.5"/><line x1="0" y1="3" x2="14" y2="3" stroke="yellow" stroke-width="0.8"/></svg>'; + return ''; + } + function swatchStyle(swatch) { + const COLORED = {coral:1, blue:1, black:1, gray:1, dimgray:1, darkred:1, red:1, green:1}; + if (COLORED[swatch]) return 'background:' + swatch; + return 'background:#fff'; + } + + // Build per-element layer membership maps once. These are consulted + // by `applyAllLayers()` on every checkbox change so an element + // claimed by multiple layers never gets stuck in `layer-off` + // because an unrelated checkbox flipped the wrong way. + const nodeMemberships = new Map(); // data-name -> Array<layerIndex> + const edgeMemberships = new Map(); // edge id -> Array<layerIndex> + for (let i = 0; i < MODEL.layers.length; i++) { + const layer = MODEL.layers[i]; + for (const n of (layer.nodes || [])) { + const arr = nodeMemberships.get(n); + if (arr) arr.push(i); else nodeMemberships.set(n, [i]); + } + for (const e of (layer.edges || [])) { + const arr = edgeMemberships.get(e); + if (arr) arr.push(i); else edgeMemberships.set(e, [i]); + } + } + + const layerCheckboxes = []; + let lastSection = null; for (const layer of MODEL.layers) { + if (layer.section && layer.section !== lastSection) { + const header = document.createElement('h3'); + header.className = 'layer-section-header'; + header.textContent = layer.section; + layersEl.appendChild(header); + lastSection = layer.section; + } const id = 'layer-' + layer.key.replace(/[^a-z0-9]/gi, '-'); const wrap = document.createElement('label'); + const total = (layer.nodes ? layer.nodes.length : 0) + (layer.edges ? layer.edges.length : 0); wrap.innerHTML = '<input type="checkbox" id="' + id + '" checked>' - + (layer.swatch ? '<span class="swatch" style="background:' + layer.swatch + '"></span>' : '<span class="swatch" style="border-style:dashed"></span>') - + '<span>' + escapeHtml(layer.label) + ' <span style="color:var(--muted)">(' + layer.edges.length + ')</span></span>'; + + '<span class="swatch" style="' + swatchStyle(layer.swatch) + '">' + swatchInner(layer.swatch) + '</span>' + + '<span>' + escapeHtml(layer.label) + ' <span style="color:var(--muted)">(' + total + ')</span></span>'; layersEl.appendChild(wrap); - wrap.querySelector('input').addEventListener('change', (e) => { - for (const eid of layer.edges) { - const el = document.getElementById(eid); - if (el) el.classList.toggle('layer-off', !e.target.checked); + const cb = wrap.querySelector('input'); + layerCheckboxes.push(cb); + cb.addEventListener('change', (e) => { + applyAllLayers(); + window.parent.postMessage({ + type: 'cs4g:overflow-layer-toggled', + key: layer.key, + label: layer.label, + visible: e.target.checked + }, '*'); + }); + } + + function applyAllLayers() { + const checkedSet = new Set(); + let allChecked = true; + for (let i = 0; i < layerCheckboxes.length; i++) { + if (layerCheckboxes[i].checked) checkedSet.add(i); + else allChecked = false; + } + function shouldDim(memberships) { + if (allChecked) return false; + if (!memberships || memberships.length === 0) return true; + for (const idx of memberships) { + if (checkedSet.has(idx)) return false; } + return true; + } + root.querySelectorAll('.node').forEach((el) => { + const name = el.getAttribute('data-name'); + el.classList.toggle('layer-off', shouldDim(nodeMemberships.get(name))); + }); + root.querySelectorAll('.edge').forEach((el) => { + const id = el.getAttribute('id'); + el.classList.toggle('layer-off', shouldDim(edgeMemberships.get(id))); }); } + // Expose for testability and external triggers (e.g. parent app + // requesting a full repaint after dynamic content updates). + window.__cs4gApplyAllLayers = applyAllLayers; + + function setAllLayers(visible) { + let changed = false; + for (let i = 0; i < layerCheckboxes.length; i++) { + if (layerCheckboxes[i].checked !== visible) { + layerCheckboxes[i].checked = visible; + changed = true; + } + } + if (changed) applyAllLayers(); + window.parent.postMessage({ + type: 'cs4g:overflow-select-all-layers', + visible: visible + }, '*'); + } + document.getElementById('layers-select-all').addEventListener('click', () => setAllLayers(true)); + document.getElementById('layers-select-none').addEventListener('click', () => setAllLayers(false)); + + // Double-click on a graph node bubbles up to the parent window, + // which is responsible for opening the corresponding Single-Line + // Diagram view. The node `data-name` is the voltage-level (or + // substation, depending on backend) identifier the parent will + // resolve to a SLD endpoint. + root.addEventListener('dblclick', (ev) => { + const g = ev.target.closest('.node'); + if (!g) return; + ev.preventDefault(); + ev.stopPropagation(); + const name = g.getAttribute('data-name') || ''; + if (!name) return; + window.parent.postMessage({ + type: 'cs4g:overflow-node-double-clicked', + name: name + }, '*'); + }); document.getElementById('stats').textContent = MODEL.nodes.length + ' nodes, ' + MODEL.edges.length + ' edges'; @@ -464,6 +765,91 @@ def _edge_repl(match: re.Match) -> str: """ +def _align_edge_ids_with_svg(svg_bytes: bytes, model: Dict[str, Any]) -> Dict[str, Any]: + """Re-key edges in ``model`` so their ``id`` field matches the SVG's + ``<g id="edgeN" class="edge">`` for the SAME (src, dst) endpoints. + + Background + ---------- + Graphviz emits edge IDs ``edgeN`` in **two independent orderings** for + the SVG and the JSON outputs of the same graph. ``_model_from_dot_json`` + assigns IDs by JSON-edge index but the SVG element with the same + ``edgeN`` id often refers to a different edge (different (src, dst) + pair). The downstream JS dim layer queries SVG elements **by id** — + so a mismatch makes the wrong edges dim/highlight when a layer + toggle is flipped (this is exactly the user-reported confusion + SSV.OP7→GROSNP7 ↔ SSV.OP7→CREYSP7 / CHALOP6→CPVANP6 ↔ + CHALOP6→CHALOP3 in the small-grid scenario). + + Fix + --- + Walk the SVG, parse each edge's ``<title>`` to extract its true + (src, dst), and greedily pair it with a JSON-side edge of matching + endpoints. Each JSON edge is consumed at most once (parallel edges + are paired in their relative order, which is stable across SVG and + JSON). The model's edge IDs are updated in place; adjacency and + layer membership lists are remapped through the same dict. + + Returns the updated model. + """ + svg = svg_bytes.decode("utf-8") + # Walk SVG edges in document order: ``<g id="edgeN" class="edge"> + # <title>SRC->DST``. Graphviz HTML-escapes the title. + pattern = re.compile( + r'\s*([^<]*)' + ) + svg_edges_in_order: List[Tuple[str, str, str]] = [] + for m in pattern.finditer(svg): + gid = m.group(1) + src, dst = _split_edge_title(m.group(2)) + svg_edges_in_order.append((gid, src, dst)) + + # Build a per-(src, dst) FIFO of JSON edges keeping their original order. + json_edges = model["edges"] + by_pair: Dict[Tuple[str, str], List[int]] = {} + for i, e in enumerate(json_edges): + by_pair.setdefault((e["source"], e["target"]), []).append(i) + + # Greedily match each SVG edge to the next un-consumed JSON edge of the + # same endpoints. The remap dict translates "old (JSON-order) id" → "new + # (SVG-order) id". + remap: Dict[str, str] = {} + for svg_id, s, t in svg_edges_in_order: + candidates = by_pair.get((s, t)) or by_pair.get((t, s)) + if not candidates: + continue + json_idx = candidates.pop(0) + old_id = json_edges[json_idx]["id"] + if old_id == svg_id: + continue + remap[old_id] = svg_id + + if not remap: + return model + + # Apply the remap. ``remap`` may contain swaps (a→b and b→a). To avoid + # collisions we materialise the new IDs through a fresh dict in two + # passes: first relabel each JSON edge to its SVG-aligned id, then walk + # adjacency / layers to substitute the references. + edge_id_lookup = {e["id"]: e for e in json_edges} + for old_id, new_id in remap.items(): + edge = edge_id_lookup[old_id] + edge["id"] = new_id + + # Adjacency entries reference edge ids by string — apply the same + # substitution there. + for entries in model.get("adjacency", {}).values(): + for entry in entries: + if entry.get("edge") in remap: + entry["edge"] = remap[entry["edge"]] + + # Layer membership lists use the same string ids. + for layer in model.get("layers", []): + layer["edges"] = [remap.get(eid, eid) for eid in layer.get("edges", [])] + + return model + + def build_interactive_html( pydot_graph: pydot.Graph, prog: Any = "dot", @@ -476,6 +862,10 @@ def build_interactive_html( svg_bytes = pydot_graph.create(prog=prog, format="svg") json_bytes = pydot_graph.create(prog=prog, format="json") model = _model_from_dot_json(json_bytes) + # Align JSON edge ids with the SVG element ids — graphviz emits the + # two orderings independently and the downstream JS toggles SVG + # elements by id, so a mismatch silently dims the wrong edges. + model = _align_edge_ids_with_svg(svg_bytes, model) annotated_svg = _inject_svg_data_attrs(svg_bytes, model) return ( _HTML_TEMPLATE diff --git a/alphaDeesp/tests/graphs_test_helpers.py b/alphaDeesp/tests/graphs_test_helpers.py index 3998a0a..010f126 100644 --- a/alphaDeesp/tests/graphs_test_helpers.py +++ b/alphaDeesp/tests/graphs_test_helpers.py @@ -104,4 +104,10 @@ def make_ofg_with_graph(g): obj.g = g obj.keep_overloads_components = OverFlowGraph.keep_overloads_components.__get__(obj) obj.collapse_red_loops = OverFlowGraph.collapse_red_loops.__get__(obj) + obj.set_hubs_shape = OverFlowGraph.set_hubs_shape.__get__(obj) + obj.highlight_significant_line_loading = ( + OverFlowGraph.highlight_significant_line_loading.__get__(obj) + ) + obj.tag_constrained_path = OverFlowGraph.tag_constrained_path.__get__(obj) + obj.tag_red_loops = OverFlowGraph.tag_red_loops.__get__(obj) return obj diff --git a/alphaDeesp/tests/test_interactive_html.py b/alphaDeesp/tests/test_interactive_html.py index f1c9619..c70c099 100644 --- a/alphaDeesp/tests/test_interactive_html.py +++ b/alphaDeesp/tests/test_interactive_html.py @@ -16,6 +16,7 @@ import json import re +from typing import List import networkx as nx import pytest @@ -54,7 +55,11 @@ def test_split_edge_title_handles_directed_and_html_escaped(): def test_color_to_layer_key_handles_compound_colors(): assert _color_to_layer_key("coral") == "coral" assert _color_to_layer_key('"coral:yellow:coral"') == "coral" - assert _color_to_layer_key("BLACK") == "black" + assert _color_to_layer_key("BLUE") == "blue" + # Black / gray / darkred are no longer recognised colour layers + # (their semantic role moved onto the explicit ``is_overload`` / + # ``is_monitored`` flags). + assert _color_to_layer_key("black") is None assert _color_to_layer_key("#abcdef") is None @@ -74,9 +79,14 @@ def test_layer_index_groups_by_color_and_style(): model = _model_from_dot_json(pg.create(prog="dot", format="json")) layers = {l["key"]: l for l in model["layers"]} + # After the section restructure only the three flow polarities + # remain as colour layers (positive/negative/null). The historical + # ``black`` / ``gray`` / ``darkred`` buckets are dropped — their + # semantic role is carried by the ``is_overload`` / ``is_monitored`` + # flags instead. assert "color:coral" in layers - assert "color:black" in layers - assert "color:gray" in layers + assert "color:black" not in layers + assert "color:gray" not in layers assert "style:dotted" in layers assert len(layers["color:coral"]["edges"]) == 1 assert len(layers["style:dotted"]["edges"]) == 1 @@ -97,9 +107,10 @@ def test_build_interactive_html_is_self_contained_and_has_data_attrs(): assert 'data-name="VALDI"' in html assert re.search(r'data-source="(VALDI|CHEVI)"', html) assert 'data-attr-name="line_1"' in html - # Layer attribute composed from color + style. - assert 'data-layers="color:gray style:dotted"' in html or \ - 'data-layers="style:dotted color:gray"' in html + # Layer attribute composed from color + style. ``gray`` is no + # longer recognised as a colour layer so the dotted edge advertises + # only its style layer. + assert 'data-layers="style:dotted"' in html # Embedded JS model is valid JSON and contains adjacency + layers. m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) assert m, "expected MODEL constant to be embedded as JSON" @@ -118,3 +129,275 @@ def test_layer_index_ignores_unknown_colors(): keys = {l["key"] for l in layers} assert "color:coral" in keys assert not any(k.startswith("color:#") for k in keys) + + +def test_layer_index_emits_semantic_layers_from_source_flags(): + """Source-of-truth attributes drive new semantic layer toggles.""" + edges = [ + {"id": "edge1", "source": "A", "target": "B", + "attrs": {"color": "coral", "is_monitored": True, "in_red_loop": True}}, + {"id": "edge2", "source": "B", "target": "C", + "attrs": {"color": "black", "is_overload": "True", + "on_constrained_path": True}}, + ] + nodes = [ + {"name": "A", "attrs": {"is_hub": True}}, + {"name": "B", "attrs": {"in_red_loop": True, + "on_constrained_path": "True"}}, + {"name": "C", "attrs": {}}, + ] + layers = _build_layer_index(edges, nodes) + by_key = {l["key"]: l for l in layers} + + # Hubs is node-only. + assert by_key["semantic:is_hub"]["nodes"] == ["A"] + assert by_key["semantic:is_hub"]["edges"] == [] + # Red-loop spans both. + assert set(by_key["semantic:in_red_loop"]["nodes"]) == {"B"} + assert set(by_key["semantic:in_red_loop"]["edges"]) == {"edge1"} + # Constrained path spans both. + assert set(by_key["semantic:on_constrained_path"]["nodes"]) == {"B"} + assert set(by_key["semantic:on_constrained_path"]["edges"]) == {"edge2"} + # Overloads / monitored are edge-driven but include the edge + # endpoint nodes so the substations remain visible when those + # layers are toggled on alone. + assert by_key["semantic:is_overload"]["edges"] == ["edge2"] + assert set(by_key["semantic:is_overload"]["nodes"]) == {"B", "C"} + assert by_key["semantic:is_monitored"]["edges"] == ["edge1"] + assert set(by_key["semantic:is_monitored"]["nodes"]) == {"A", "B"} + + +def test_layer_index_skips_semantic_layer_when_no_match(): + """No noise: empty semantic buckets do NOT produce a layer entry.""" + edges = [ + {"id": "edge1", "source": "A", "target": "B", + "attrs": {"color": "coral"}}, + ] + nodes = [{"name": "A", "attrs": {}}, {"name": "B", "attrs": {}}] + keys = {l["key"] for l in _build_layer_index(edges, nodes)} + assert "semantic:is_hub" not in keys + assert "semantic:in_red_loop" not in keys + assert "color:coral" in keys + + +def test_layer_index_node_arg_is_optional_for_legacy_callers(): + layers = _build_layer_index([ + {"id": "edge1", "source": "A", "target": "B", + "attrs": {"color": "coral"}}, + ]) + # All emitted layers gain the new ``nodes`` field for shape uniformity. + for layer in layers: + assert "nodes" in layer and "edges" in layer + + +def test_template_uses_dim_class_not_display_none(): + """Unchecked layers must DIM elements (not hide them) so spatial + context is preserved.""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + html = build_interactive_html(pg, title="toy") + # The historical hide rule is gone… + assert ".graph .edge.layer-off { display: none; }" not in html + # …replaced by a dim rule that applies to both nodes & edges. + assert ".graph .layer-off { opacity: 0.12; }" in html + + +def test_template_has_select_all_unselect_all_buttons(): + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + html = build_interactive_html(pg, title="toy") + assert 'id="layers-select-all"' in html + assert 'id="layers-select-none"' in html + + +def test_build_interactive_html_propagates_semantic_attrs_to_svg(): + """Boolean source-of-truth flags must round-trip into ``data-attr-*`` + on the resulting SVG so the HTML viewer's JS can scan them.""" + g = nx.MultiDiGraph() + g.add_node("HUB1", color="red", shape="diamond", penwidth=3, is_hub=True) + g.add_node("REGN", color="green", shape="oval", penwidth=3) + g.add_edge("HUB1", "REGN", color="coral", label="42", + name="line_1", penwidth=4, capacity=42.0, + is_monitored=True, on_constrained_path=True) + pg = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pg, title="semantic") + + assert 'data-attr-is_hub="True"' in html + assert 'data-attr-is_monitored="True"' in html + assert 'data-attr-on_constrained_path="True"' in html + + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + assert m + model = json.loads(m.group(1)) + layer_keys = {l["key"] for l in model["layers"]} + assert "semantic:is_hub" in layer_keys + assert "semantic:is_monitored" in layer_keys + assert "semantic:on_constrained_path" in layer_keys + + +def test_edge_ids_align_with_svg_titles_after_alignment_pass(): + """Regression for the user-reported visual bug: graphviz emits SVG + and JSON edge IDs in independent orders, so JSON-derived IDs may + point at the wrong SVG element. After ``_align_edge_ids_with_svg``, + every SVG ```` must carry data-* that + agree with its own ````.""" + g = nx.MultiDiGraph() + # Nodes + for n in ["A", "B", "C", "D"]: + g.add_node(n, color="red", shape="oval", penwidth=3) + # Mix of single + parallel edges to exercise greedy matching. + g.add_edge("A", "B", color="coral", name="L_AB1", capacity=1.0) + g.add_edge("A", "B", color="coral", name="L_AB2", capacity=2.0) + g.add_edge("A", "C", color="blue", name="L_AC", capacity=-3.0) + g.add_edge("B", "C", color="black", name="L_BC", capacity=-4.0) + g.add_edge("C", "D", color="coral", name="L_CD", capacity=5.0) + g.add_edge("D", "A", color="blue", name="L_DA", capacity=-6.0) + pg = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pg, title="alignment") + + # For every SVG edge group, the parsed title src/dst must match + # the data-source / data-target attrs. + edge_blocks = re.findall( + r'<g id="(edge\d+)" class="edge"[^>]*data-source="([^"]*)"[^>]*' + r'data-target="([^"]*)"[^>]*>\s*<title>([^<]*)', + html, + ) + assert edge_blocks, "no edge blocks found in rendered HTML" + for gid, src, tgt, title in edge_blocks: + title_src, title_dst = _split_edge_title(title) + assert (src, tgt) == (title_src, title_dst), ( + f"{gid}: data ({src!r}, {tgt!r}) ≠ title ({title_src!r}, {title_dst!r})" + ) + + +def test_alignment_preserves_layer_membership_through_remap(): + """An edge that is in (say) ``color:blue`` before the alignment + pass must still be in ``color:blue`` after, just under its new + SVG-aligned id.""" + g = nx.MultiDiGraph() + g.add_node("X", color="red", shape="oval") + g.add_node("Y", color="green", shape="oval") + g.add_edge("X", "Y", color="blue", name="negative", capacity=10.0) + g.add_edge("Y", "X", color="coral", name="redispatch", capacity=10.0) + pg = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pg, title="layer-remap") + + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + model = json.loads(m.group(1)) + layers = {l["key"]: l for l in model["layers"]} + edges_by_id = {e["id"]: e for e in model["edges"]} + + # Each layer's edge ids must correspond to edges whose attrs match + # the layer's semantic. + if "color:blue" in layers: + for eid in layers["color:blue"]["edges"]: + assert edges_by_id[eid]["attrs"].get("color") == "blue" + if "color:coral" in layers: + for eid in layers["color:coral"]["edges"]: + assert edges_by_id[eid]["attrs"].get("color") == "coral" + + # AND the SVG element with id == layer's edge id must have the + # title that references the correct endpoints. + for layer_key in ("color:blue", "color:coral"): + layer = layers.get(layer_key) + if not layer: + continue + for eid in layer["edges"]: + patt = re.compile( + r']*>\s*([^<]*)' + ) + mm = patt.search(html) + assert mm, f"layer {layer_key!r} references missing SVG element {eid}" + t_src, t_dst = _split_edge_title(mm.group(1)) + edge = edges_by_id[eid] + assert (edge["source"], edge["target"]) == (t_src, t_dst) + + +def test_color_layers_include_edge_endpoint_nodes(): + """When the user toggles a colour layer (e.g. "Positive overflow" + coral) alone, the endpoint substations of those edges should also + stay visible — not just the edges themselves. The layer index + therefore embeds those endpoints in its `nodes` list.""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + model = _model_from_dot_json(pg.create(prog="dot", format="json")) + layers = {l["key"]: l for l in model["layers"]} + + coral_layer = layers["color:coral"] + assert set(coral_layer["nodes"]) == {"VALDI", "CHEVI"}, ( + f"coral layer endpoints: {coral_layer['nodes']}" + ) + # ``color:black`` is no longer a colour layer (its semantic role + # moved to the ``is_overload`` flag) so it must be absent. + assert "color:black" not in layers + + +def test_style_layers_include_edge_endpoint_nodes(): + """Same contract for style layers (dashed / dotted / tapered).""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + model = _model_from_dot_json(pg.create(prog="dot", format="json")) + layers = {l["key"]: l for l in model["layers"]} + + dotted_layer = layers["style:dotted"] + # The toy graph's only dotted edge goes VALDI->MARSI. + assert set(dotted_layer["nodes"]) == {"VALDI", "MARSI"} + + +def test_layers_carry_section_field_in_canonical_order(): + """Layers must declare a ``section`` field and be ordered so that + Structural Paths appear before Individual entities properties, + which appear before Flow redispatch values.""" + edges = [ + {"id": "edge1", "source": "A", "target": "B", + "attrs": {"color": "coral", "is_monitored": True, + "in_red_loop": True}}, + {"id": "edge2", "source": "B", "target": "C", + "attrs": {"color": "blue", "is_overload": True, + "on_constrained_path": True}}, + ] + nodes = [ + {"name": "A", "attrs": {"is_hub": True}}, + {"name": "B", "attrs": {}}, + {"name": "C", "attrs": {}}, + ] + layers = _build_layer_index(edges, nodes) + # Every emitted layer must carry a section. + for layer in layers: + assert "section" in layer, f"layer {layer['key']} missing section" + + section_seq = [l["section"] for l in layers] + canonical = ["Structural Paths", + "Individual entities properties", + "Flow redispatch values"] + # The first occurrence of each section must respect canonical order. + seen_sections: List[str] = [] + for s in section_seq: + if s not in seen_sections: + seen_sections.append(s) + assert seen_sections == [s for s in canonical if s in seen_sections] + + +def test_html_embeds_section_field_and_inserts_section_headers(): + """The embedded MODEL must carry a ``section`` on every layer, + AND the bundled JS must include the header-insertion logic that + will emit one ``

`` per section.""" + g = nx.MultiDiGraph() + g.add_node("HUB", color="red", shape="oval", is_hub=True) + g.add_node("LO", color="green", shape="oval", on_constrained_path=True) + g.add_node("HI", color="blue", shape="oval", in_red_loop=True) + g.add_edge("HUB", "LO", color="coral", name="lA", + on_constrained_path=True) + g.add_edge("LO", "HI", color="blue", name="lB", in_red_loop=True, + is_overload=True) + pg = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pg, title="sections") + + # JS template owns the header-insertion logic. + assert "layer-section-header" in html + assert "createElement('h3')" in html + + # Embedded model must surface the three section names. + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + assert m + model = json.loads(m.group(1)) + sections = {layer.get("section") for layer in model["layers"]} + assert "Structural Paths" in sections + assert "Individual entities properties" in sections + assert "Flow redispatch values" in sections diff --git a/alphaDeesp/tests/test_overflow_graph.py b/alphaDeesp/tests/test_overflow_graph.py index dd3c79c..4092848 100644 --- a/alphaDeesp/tests/test_overflow_graph.py +++ b/alphaDeesp/tests/test_overflow_graph.py @@ -523,3 +523,306 @@ def test_classify_routes_non_reconnectable_to_the_right_set(self): paths = obj._collect_paths_of_interest(g_c, prepared, sssp, max_null_flow_path_length=7) rec, non_rec = obj._classify_paths_by_reconnectability(prepared, paths) assert edge_mt in non_rec and edge_mt not in rec + + +# ────────────────────────────────────────────────────────────────────── +# Source-of-truth attribute tagging — feeds the interactive HTML viewer +# layer toggles (hubs, red-loops, constrained path, overloads, monitored). +# ────────────────────────────────────────────────────────────────────── + +class TestSetHubsShapeAttributeFlag: + def test_is_hub_flag_is_set_on_hub_nodes_only(self): + g = nx.MultiDiGraph() + g.add_node("A", shape="oval") + g.add_node("B", shape="oval") + g.add_node("C", shape="oval") + ofg = make_ofg_with_graph(g) + ofg.set_hubs_shape(["A", "C"], shape_hub="diamond") + assert ofg.g.nodes["A"]["is_hub"] is True + assert ofg.g.nodes["B"]["is_hub"] is False + assert ofg.g.nodes["C"]["is_hub"] is True + assert ofg.g.nodes["A"]["shape"] == "diamond" + + +class TestCollapseRedLoopsIsPurelyVisual: + """``collapse_red_loops`` is now a purely visual heuristic (point + shape vs oval). Semantic ``in_red_loop`` tagging is handled by + :meth:`tag_red_loops` which consumes the source-of-truth list + from the recommender's + ``Structured_Overload_Distribution_Graph.get_dispatch_edges_nodes``. + """ + + def test_collapse_does_not_set_in_red_loop(self): + g = nx.MultiDiGraph() + g.add_node("N1", shape="oval") + g.add_node("N2", shape="oval") + g.add_edge("N1", "N2", color="coral", name="line_1") + ofg = make_ofg_with_graph(g) + ofg.collapse_red_loops() + # N1 (purely-coral) collapses visually but no semantic flag. + assert ofg.g.nodes["N1"]["shape"] == "point" + assert "in_red_loop" not in ofg.g.nodes["N1"] + assert "in_red_loop" not in ofg.g.nodes["N2"] + for _, _, _, data in ofg.g.edges(keys=True, data=True): + assert "in_red_loop" not in data + + def test_collapse_does_not_set_in_red_loop_for_blue_only(self): + g = nx.MultiDiGraph() + g.add_node("N1", shape="oval") + g.add_node("N2", shape="oval") + g.add_edge("N1", "N2", color="blue") + ofg = make_ofg_with_graph(g) + ofg.collapse_red_loops() + assert "in_red_loop" not in ofg.g.nodes["N1"] + assert "in_red_loop" not in ofg.g.nodes["N2"] + + +class TestHighlightSignificantLineLoadingFlags: + def _make_graph_with_named_edges(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_node("C") + g.add_edge("A", "B", name="line_overload", color="black", label="100") + g.add_edge("B", "C", name="line_monitored", color="coral", label="50") + g.add_edge("A", "C", name="line_quiet", color="gray", label="10") + return g + + def test_overload_flag_on_black_edges(self): + g = self._make_graph_with_named_edges() + ofg = make_ofg_with_graph(g) + ofg.highlight_significant_line_loading({ + "line_overload": {"before": 95, "after": 110}, + "line_monitored": {"before": 78, "after": 92}, + }) + # Find edges by name and check flags. + edge_flags = { + data.get("name"): { + "is_overload": data.get("is_overload"), + "is_monitored": data.get("is_monitored"), + } + for _, _, _, data in ofg.g.edges(keys=True, data=True) + } + # Overloads are a strict subset of monitored / low-margin + # lines: every entry in dict_line_loading is monitored, and + # the black ones are additionally overloads. + assert edge_flags["line_overload"]["is_overload"] is True + assert edge_flags["line_overload"]["is_monitored"] is True + assert edge_flags["line_monitored"]["is_monitored"] is True + assert edge_flags["line_monitored"]["is_overload"] is None + # Untagged line keeps neither flag. + assert edge_flags["line_quiet"]["is_overload"] is None + assert edge_flags["line_quiet"]["is_monitored"] is None + + +class TestTagConstrainedPath: + def test_tags_edges_by_name_and_nodes_by_identity(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_node("C") + g.add_edge("A", "B", name="L1") + g.add_edge("B", "C", name="L2") + g.add_edge("A", "C", name="L3") + ofg = make_ofg_with_graph(g) + ofg.tag_constrained_path( + lines_constrained_path=["L1", "L2"], + nodes_constrained_path=["A", "B"], + ) + edges_on = { + data.get("name"): data.get("on_constrained_path") + for _, _, _, data in ofg.g.edges(keys=True, data=True) + } + assert edges_on["L1"] is True + assert edges_on["L2"] is True + assert edges_on["L3"] is None + assert ofg.g.nodes["A"].get("on_constrained_path") is True + assert ofg.g.nodes["B"].get("on_constrained_path") is True + assert ofg.g.nodes["C"].get("on_constrained_path") is None + + def test_no_op_when_inputs_empty(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", name="loop") + ofg = make_ofg_with_graph(g) + ofg.tag_constrained_path(None, None) + ofg.tag_constrained_path([], []) + for _, _, _, data in ofg.g.edges(keys=True, data=True): + assert "on_constrained_path" not in data + assert "on_constrained_path" not in ofg.g.nodes["A"] + + +# ────────────────────────────────────────────────────────────────────── +# Layer-toggle bug fixes (v2): hubs auto-membership, broader red loops, +# coral filtering on constrained path, no-op on coral-only constrained +# path entries. +# ────────────────────────────────────────────────────────────────────── + +class TestSetHubsShapeAlsoTagsRedLoopAndConstrainedPath: + """Hubs are by definition both on the constrained path AND + inside red-loop paths — those flags must be set alongside `is_hub`. + """ + + def test_hubs_get_on_constrained_path_flag(self): + g = nx.MultiDiGraph() + g.add_node("HUB", shape="oval") + g.add_node("OTHER", shape="oval") + ofg = make_ofg_with_graph(g) + ofg.set_hubs_shape(["HUB"], shape_hub="diamond") + assert ofg.g.nodes["HUB"].get("on_constrained_path") is True + assert "on_constrained_path" not in ofg.g.nodes["OTHER"] + + def test_hubs_get_in_red_loop_flag(self): + g = nx.MultiDiGraph() + g.add_node("HUB", shape="oval") + g.add_node("OTHER", shape="oval") + ofg = make_ofg_with_graph(g) + ofg.set_hubs_shape(["HUB"], shape_hub="diamond") + assert ofg.g.nodes["HUB"].get("in_red_loop") is True + assert "in_red_loop" not in ofg.g.nodes["OTHER"] + + +class TestTagRedLoops: + """``tag_red_loops`` propagates the source-of-truth lists from + ``Structured_Overload_Distribution_Graph.get_dispatch_edges_nodes( + only_loop_paths=True)`` onto graph attributes. The viewer's + "Red-loop paths" layer reads those flags directly — there is no + heuristic involved. + """ + + def test_tags_only_lines_in_provided_list(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_node("C") + g.add_edge("A", "B", name="loop_line", color="coral") + g.add_edge("B", "C", name="exit_line", color="coral") + ofg = make_ofg_with_graph(g) + ofg.tag_red_loops( + lines_red_loops=["loop_line"], + nodes_red_loops=["A", "B"], + ) + edges_on = { + data["name"]: data.get("in_red_loop") + for _, _, _, data in ofg.g.edges(keys=True, data=True) + } + assert edges_on["loop_line"] is True + # exit_line is NOT in the source-of-truth list → not tagged + # (this is the user-reported CHALOY633 invariant). + assert edges_on["exit_line"] is None + assert ofg.g.nodes["A"].get("in_red_loop") is True + assert ofg.g.nodes["B"].get("in_red_loop") is True + assert "in_red_loop" not in ofg.g.nodes["C"] + + def test_no_op_when_inputs_empty(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", name="self_loop", color="coral") + ofg = make_ofg_with_graph(g) + ofg.tag_red_loops(None, None) + ofg.tag_red_loops([], []) + for _, _, _, data in ofg.g.edges(keys=True, data=True): + assert "in_red_loop" not in data + assert "in_red_loop" not in ofg.g.nodes["A"] + + def test_tags_compound_color_edges_when_in_source_list(self): + # A monitored coral edge ("coral:yellow:coral") that the + # recommender included in the dispatch loop list MUST be + # tagged — name match is colour-agnostic. (The previous + # heuristic-based logic already handled compound colours; + # this test pins the explicit-list contract too.) + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_edge("A", "B", name="L_MON", color='"coral:yellow:coral"') + ofg = make_ofg_with_graph(g) + ofg.tag_red_loops(lines_red_loops=["L_MON"], nodes_red_loops=["A", "B"]) + edge_data = list(ofg.g.edges(keys=True, data=True))[0][3] + assert edge_data.get("in_red_loop") is True + + def test_chalop6_chalop3_style_exit_branch_is_NOT_tagged(self): + """Regression for the user-reported CHALOP6→CHALOP3 case: + the recommender's ``get_dispatch_edges_nodes(only_loop_paths + =True)`` does NOT include such transformer "exit" branches — + because their endpoints are not in any cycle path. The + explicit-list approach therefore leaves them un-tagged. + """ + g = nx.MultiDiGraph() + g.add_node("CHALOP6") + g.add_node("CHALOP3") + g.add_node("LOUHAP3") + g.add_edge("CHALOP6", "CHALOP3", name="CHALOY633", color="coral") + g.add_edge("CHALOP6", "CHALOP3", name="CHALOY631", color="coral") + g.add_edge("CHALOP6", "CHALOP3", name="CHALOY632", color="coral") + g.add_edge("CHALOP3", "LOUHAP3", name="CHALOL31LOUHA", + color="coral", style="dashed") + ofg = make_ofg_with_graph(g) + # Recommender returns an empty dispatch loop list because none + # of these nodes participate in a true cycle path. + ofg.tag_red_loops(lines_red_loops=[], nodes_red_loops=[]) + for _, _, _, data in ofg.g.edges(keys=True, data=True): + assert "in_red_loop" not in data, ( + f"edge {data['name']} wrongly tagged in_red_loop" + ) + for n in ("CHALOP6", "CHALOP3", "LOUHAP3"): + assert "in_red_loop" not in ofg.g.nodes[n], ( + f"node {n} wrongly tagged in_red_loop" + ) + + +class TestTagConstrainedPathSkipsCoralEdges: + """The constrained path is, by definition, the network of black + (overloaded) and blue (negative-flow) edges. Coral edges that share + a name with a constrained-path entry (because the + ``MultiDiGraph`` carries both flow directions of one physical + line) must NOT end up tagged. + """ + + def test_coral_edge_with_matching_name_is_skipped(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + # Same `name` for both directions: blue (negative) + coral (positive). + g.add_edge("A", "B", name="L1", color="blue") + g.add_edge("B", "A", name="L1", color="coral") + ofg = make_ofg_with_graph(g) + ofg.tag_constrained_path(lines_constrained_path=["L1"]) + flagged_colors = [ + data.get("color") + for _, _, _, data in ofg.g.edges(keys=True, data=True) + if data.get("on_constrained_path") + ] + assert flagged_colors == ["blue"] + + def test_compound_color_string_with_coral_base_is_skipped(self): + # After `highlight_significant_line_loading` the `color` may be + # a graphviz compound `"coral:yellow:coral"`. The split-on-':' + # heuristic must still classify it as coral and skip it. + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_edge("A", "B", name="L1", color='"coral:yellow:coral"') + g.add_edge("B", "A", name="L1", color="black") + ofg = make_ofg_with_graph(g) + ofg.tag_constrained_path(lines_constrained_path=["L1"]) + flagged = [ + (data.get("color"), data.get("on_constrained_path")) + for _, _, _, data in ofg.g.edges(keys=True, data=True) + ] + # The black one is tagged, the compound-coral is skipped. + assert ('"coral:yellow:coral"', None) in [(c, t) for c, t in flagged] \ + or ('"coral:yellow:coral"', None) in flagged + assert any(c == "black" and t is True for c, t in flagged) + assert all(not (c == '"coral:yellow:coral"' and t) for c, t in flagged) + + def test_black_and_blue_edges_with_matching_name_are_tagged(self): + g = nx.MultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_node("C") + g.add_edge("A", "B", name="L1", color="black") + g.add_edge("B", "C", name="L2", color="blue") + ofg = make_ofg_with_graph(g) + ofg.tag_constrained_path(lines_constrained_path=["L1", "L2"]) + for _, _, _, data in ofg.g.edges(keys=True, data=True): + assert data.get("on_constrained_path") is True