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} {before}% → {after}%>'
+ is_overload_attrs[edge] = True
else:
edge_x_labels[edge] = f'< {current_x_label} {before}% → {after}% >'
@@ -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
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 ``
`` 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:
Layers
+
+
+ ·
+
+
Selection
@@ -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 '';
+ 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 '';
+ if (swatch === 'red-loop') return '';
+ if (swatch === 'constrained-path') return '';
+ if (swatch === 'overload') return '';
+ if (swatch === 'monitored') return '';
+ 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
+ const edgeMemberships = new Map(); // edge id -> Array
+ 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 = ''
- + (layer.swatch ? '' : '')
- + '' + escapeHtml(layer.label) + ' (' + layer.edges.length + ')';
+ + '' + swatchInner(layer.swatch) + ''
+ + '' + escapeHtml(layer.label) + ' (' + total + ')';
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
+ ```` 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 ```` 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: ``
+ # 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']*data-source="([^"]*)"[^>]*'
+ r'data-target="([^"]*)"[^>]*>\s*([^<]*)',
+ 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