diff --git a/alphaDeesp/core/graphs/overflow_graph.py b/alphaDeesp/core/graphs/overflow_graph.py index 171b98f..60cd4b3 100644 --- a/alphaDeesp/core/graphs/overflow_graph.py +++ b/alphaDeesp/core/graphs/overflow_graph.py @@ -21,9 +21,14 @@ logger = logging.getLogger(__name__) -# Penwidth thresholds used by build_edges_from_df +# Penwidth thresholds used by build_edges_from_df. +# The floor is dynamic: at least the width equivalent to 1 MW of flow +# (``scaling_factor`` applied to 1.0), and at least 10 % of the largest +# rendered penwidth, so low / zero-flow edges (reconnectable, +# non-reconnectable, null-flow) remain visible without zooming. _TARGET_MAX_PENWIDTH = 15.0 -_MIN_PENWIDTH = 0.1 +_MIN_PENWIDTH_FLOW_MW = 1.0 +_MIN_PENWIDTH_FRACTION = 0.10 class OverFlowGraph(NullFlowGraphMixin, GraphConsolidationMixin, PowerFlowGraph): @@ -63,6 +68,10 @@ def build_edges_from_df(self, g: nx.MultiDiGraph, lines_to_cut: List[int]) -> No """Add one coloured edge per row of self.df to g.""" max_abs_flow = self.df["delta_flows"].abs().max() scaling_factor = _TARGET_MAX_PENWIDTH / max_abs_flow if max_abs_flow > 0 else 1.0 + min_penwidth = max( + _MIN_PENWIDTH_FLOW_MW * scaling_factor, + _MIN_PENWIDTH_FRACTION * _TARGET_MAX_PENWIDTH, + ) cols = ("idx_or", "idx_ex", "delta_flows", "gray_edges", "line_name") for i, (origin, extremity, reported_flow, gray_edge, line_name) in enumerate( @@ -71,6 +80,7 @@ def build_edges_from_df(self, g: nx.MultiDiGraph, lines_to_cut: List[int]) -> No g, origin, extremity, reported_flow, line_name, color=self._edge_color(i, reported_flow, gray_edge, lines_to_cut), scaling_factor=scaling_factor, + min_penwidth=min_penwidth, is_constrained=(i in lines_to_cut)) @staticmethod @@ -93,11 +103,12 @@ def _add_overflow_edge( line_name: str, color: str, scaling_factor: float, + min_penwidth: float, is_constrained: bool, ) -> None: """Add a single styled overflow edge to g.""" fp = self.float_precision - penwidth = max(float(fp % (fabs(reported_flow) * scaling_factor)), _MIN_PENWIDTH) + penwidth = max(float(fp % (fabs(reported_flow) * scaling_factor)), min_penwidth) attrs = { "capacity": float(fp % reported_flow), "label": fp % reported_flow, diff --git a/alphaDeesp/core/interactive_html.py b/alphaDeesp/core/interactive_html.py new file mode 100644 index 0000000..39640bb --- /dev/null +++ b/alphaDeesp/core/interactive_html.py @@ -0,0 +1,485 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of ExpertOp4Grid, an expert system approach to solve flow congestions in power grids + +"""Build an interactive HTML viewer around a Graphviz-rendered SVG. + +The viewer keeps the exact dot-computed positions and bezier-curved edges +(it reuses the SVG produced by Graphviz verbatim) and layers JS-driven +interactions on top: pan/zoom, hover tooltip, click-to-highlight +neighborhood, search, layer toggles by edge color/style. + +The companion JSON (``dot -Tjson`` flavour) is parsed in Python so the +embedded data structure exposed to the browser is a flat node/edge model +with a pre-computed adjacency map — interactions stay O(1) at runtime. +""" + +from __future__ import annotations + +import html as html_mod +import json +import logging +import re +from typing import Any, Dict, List, Optional, Tuple + +import pydot + +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. +_LAYER_LABELS: Dict[str, str] = { + "black": "Constrained line", + "coral": "Positive overflow", + "blue": "Negative flow", + "gray": "Low / inactive", + "dimgray": "Null-flow", + "darkred": "Highlighted loading", +} + +# Edge style → layer label (orthogonal to color). +_STYLE_LAYERS: Dict[str, str] = { + "dotted": "Non-reconnectable", + "dashed": "Reconnectable", + "tapered": "Swapped flow", +} + + +def _decode_title(text: str) -> str: + """Graphviz HTML-escapes node/edge titles in SVG (``A->B``).""" + return html_mod.unescape(text or "") + + +def _split_edge_title(title: str) -> Tuple[str, str]: + """Edge titles are ``"->["`` (digraph) or ``"--"``.""" + title = _decode_title(title) + for sep in ("->", "--"): + if sep in title: + src, dst = title.split(sep, 1) + return src.strip(), dst.strip() + return title, "" + + +def _color_to_layer_key(color: str) -> Optional[str]: + """Map a (possibly compound or hex) color to a known layer key.""" + if not color: + return None + base = color.split(":", 1)[0].strip().strip('"').lower() + if base in _LAYER_LABELS: + return base + return None + + +def _normalize_attrs(raw: Dict[str, Any]) -> Dict[str, Any]: + """Strip Graphviz-internal _draw_/_ldraw_ keys and quoted strings.""" + out: Dict[str, Any] = {} + for k, v in raw.items(): + if k.startswith("_") or k in ("nodes", "edges", "objects", "subgraphs"): + continue + if isinstance(v, str): + v = v.strip().strip('"') + out[k] = v + return out + + +def _model_from_dot_json(dot_json: bytes) -> Dict[str, Any]: + """Parse ``dot -Tjson`` into a flat node/edge model + adjacency map.""" + data = json.loads(dot_json.decode("utf-8")) + objects: List[Dict[str, Any]] = data.get("objects", []) + raw_edges: List[Dict[str, Any]] = data.get("edges", []) + + nodes: List[Dict[str, Any]] = [] + name_by_gvid: Dict[int, str] = {} + for i, obj in enumerate(objects): + if "nodes" in obj or "subgraphs" in obj: + # cluster/subgraph entry — skip in v1 + continue + name = obj.get("name", f"node{i}") + name_by_gvid[obj.get("_gvid", i)] = name + nodes.append({ + "name": name, + "attrs": _normalize_attrs(obj), + }) + + edges: List[Dict[str, Any]] = [] + adjacency: Dict[str, List[Dict[str, str]]] = {n["name"]: [] for n in nodes} + for j, edge in enumerate(raw_edges): + src = name_by_gvid.get(edge.get("tail")) + dst = name_by_gvid.get(edge.get("head")) + if src is None or dst is None: + continue + attrs = _normalize_attrs(edge) + edges.append({ + "id": f"edge{j + 1}", # matches Graphviz SVG id naming + "source": src, + "target": dst, + "attrs": attrs, + }) + 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) + return { + "nodes": nodes, + "edges": edges, + "adjacency": adjacency, + "layers": layers, + } + + +def _build_layer_index(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Group edges by color/style so the UI can offer toggles.""" + by_color: Dict[str, List[str]] = {} + by_style: Dict[str, List[str]] = {} + for e in edges: + color_key = _color_to_layer_key(e["attrs"].get("color", "")) + if color_key: + by_color.setdefault(color_key, []).append(e["id"]) + style = (e["attrs"].get("style") or "").lower() + if style in _STYLE_LAYERS: + by_style.setdefault(style, []).append(e["id"]) + + layers: List[Dict[str, Any]] = [] + for key, ids in by_color.items(): + layers.append({ + "key": f"color:{key}", + "label": _LAYER_LABELS[key], + "swatch": key, + "edges": ids, + }) + for key, ids in by_style.items(): + layers.append({ + "key": f"style:{key}", + "label": _STYLE_LAYERS[key], + "swatch": "", + "edges": ids, + }) + return layers + + +def _inject_svg_data_attrs(svg_bytes: bytes, model: Dict[str, Any]) -> str: + """Annotate Graphviz SVG nodes/edges with stable data-* attributes. + + Graphviz emits ``NAME``; we + rely on the title to look up our model entries and append data-* + attributes (which the JS uses for selectors and tooltips). + """ + svg = svg_bytes.decode("utf-8") + edge_by_id = {e["id"]: e for e in model["edges"]} + node_by_name = {n["name"]: n for n in model["nodes"]} + + def _attrs_to_data(prefix: str, attrs: Dict[str, Any]) -> str: + out: List[str] = [] + for k, v in attrs.items(): + safe_v = html_mod.escape(str(v), quote=True) + out.append(f' data-{prefix}-{k}="{safe_v}"') + return "".join(out) + + def _node_repl(match: re.Match) -> str: + gid = match.group(1) + title = match.group(2) + name = _decode_title(title) + node = node_by_name.get(name) + if not node: + return match.group(0) + data = _attrs_to_data("attr", node["attrs"]) + return ( + f'{title}' + ) + + def _edge_repl(match: re.Match) -> str: + gid = match.group(1) + title = match.group(2) + edge = edge_by_id.get(gid) + if not edge: + return match.group(0) + src, dst = edge["source"], edge["target"] + layers: List[str] = [] + color_key = _color_to_layer_key(edge["attrs"].get("color", "")) + if color_key: + layers.append(f"color:{color_key}") + style = (edge["attrs"].get("style") or "").lower() + if style in _STYLE_LAYERS: + layers.append(f"style:{style}") + data = _attrs_to_data("attr", edge["attrs"]) + layer_attr = f' data-layers="{html_mod.escape(" ".join(layers), quote=True)}"' if layers else "" + return ( + f'{title}' + ) + + svg = re.sub( + r'\s*([^<]*)', + _node_repl, + svg, + ) + svg = re.sub( + r'\s*([^<]*)', + _edge_repl, + svg, + ) + return svg + + +# Self-contained HTML template — keeps the SVG inline so the file can be +# opened directly from disk (no web server, no CDN). The JS is a tiny +# ~150-line bundle: pan/zoom, hover tooltip, click-highlight, search, +# layer toggles. All state is driven by CSS classes for cheap toggling. +_HTML_TEMPLATE = """ + + + +__TITLE__ + + + +
+ +
+
+ + + +
+ __SVG__ +
+
+
+ + + +""" + + +def build_interactive_html( + pydot_graph: pydot.Graph, + prog: Any = "dot", + title: str = "ExpertOp4Grid — interactive overflow graph", +) -> str: + """Render ``pydot_graph`` to interactive HTML. + + Returns the HTML string. Caller decides where to write it. + """ + 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) + annotated_svg = _inject_svg_data_attrs(svg_bytes, model) + return ( + _HTML_TEMPLATE + .replace("__TITLE__", html_mod.escape(title)) + .replace("__SVG__", annotated_svg) + .replace("__MODEL_JSON__", json.dumps(model)) + ) diff --git a/alphaDeesp/core/printer.py b/alphaDeesp/core/printer.py index 71e6eef..9254497 100755 --- a/alphaDeesp/core/printer.py +++ b/alphaDeesp/core/printer.py @@ -16,6 +16,7 @@ import networkx as nx +from alphaDeesp.core.interactive_html import build_interactive_html from alphaDeesp.core.twin_nodes import twin_node_id logger = logging.getLogger(__name__) @@ -149,12 +150,27 @@ def display_geo( if custom_layout is None: cmd_line = 'dot -Tpdf "' + str(filename_dot) + '" -o "' + str(filename_pdf) + '"'#'neato -Tpdf "' + str(filename_dot) + '" -o "' + str(filename_pdf) + '"' + html_prog = "dot" else: cmd_line = 'neato -n -Tpdf "' + str(filename_dot) + '" -o "' + str(filename_pdf) + '"' + html_prog = ["neato", "-n", "-x"] logger.debug("we print the cmd line = %s", cmd_line) assert execute_command(cmd_line) + # Also emit a self-contained interactive HTML viewer alongside the + # PDF. Failure here must not break the existing PDF flow — the HTML + # viewer is an additive convenience, not a hard dependency. + try: + filename_html = filename_pdf[:-4] + ".html" if filename_pdf.endswith(".pdf") else filename_pdf + ".html" + pydot_graph = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pydot_graph, prog=html_prog, title=os.path.basename(filename_html)) + with open(filename_html, "w", encoding="utf-8") as fh: + fh.write(html) + logger.debug("interactive HTML written to %s", filename_html) + except Exception as exc: # pragma: no cover - defensive + logger.warning("interactive HTML export failed: %s", exc) + #cmd_line = f"evince {str(filename_pdf)} &" #os.system(cmd_line) diff --git a/alphaDeesp/tests/test_expert_op.py b/alphaDeesp/tests/test_expert_op.py index 83e017e..fb0d36b 100644 --- a/alphaDeesp/tests/test_expert_op.py +++ b/alphaDeesp/tests/test_expert_op.py @@ -58,9 +58,10 @@ def test_expertOp(self): print("check if files generated") filenames=os.listdir(graphs_folder) - assert len(filenames)==4 + assert len(filenames)==5 assert np.any(["g_overflow" in filename for filename in filenames]) assert np.any([".dot" in filename for filename in filenames]) + assert np.any([".html" in filename for filename in filenames]) assert np.any(["g_pow" in filename for filename in filenames]) assert np.any(["g_pow_prime" in filename for filename in filenames]) diff --git a/alphaDeesp/tests/test_interactive_html.py b/alphaDeesp/tests/test_interactive_html.py new file mode 100644 index 0000000..f1c9619 --- /dev/null +++ b/alphaDeesp/tests/test_interactive_html.py @@ -0,0 +1,120 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of ExpertOp4Grid, an expert system approach to solve flow congestions in power grids + +"""Unit tests for the interactive HTML viewer builder. + +These tests exercise ``build_interactive_html`` against a small handcrafted +graph so they do not need a Grid2op backend or chronics on disk. They are +the smoke contract: the HTML must contain the SVG, the model JSON, the +expected data-* attributes, and the layer index derived from edge colors. +""" + +import json +import re + +import networkx as nx +import pytest + +pydot = pytest.importorskip("pydot") + +from alphaDeesp.core.interactive_html import ( + _build_layer_index, + _color_to_layer_key, + _model_from_dot_json, + _split_edge_title, + build_interactive_html, +) + + +def _toy_graph() -> nx.MultiDiGraph: + g = nx.MultiDiGraph() + g.add_node("VALDI", color="red", shape="oval", penwidth=3) + g.add_node("CHEVI", color="green", shape="oval", penwidth=3) + g.add_node("MARSI", color="blue", shape="circle", penwidth=3) + g.add_edge("VALDI", "CHEVI", color="coral", label="42", + name="line_1", penwidth=4, capacity=42.0) + g.add_edge("CHEVI", "MARSI", color="black", label="-100", + name="line_2", penwidth=8, constrained=True) + g.add_edge("VALDI", "MARSI", color="gray", label="0", + name="line_3", penwidth=0.5, style="dotted") + return g + + +def test_split_edge_title_handles_directed_and_html_escaped(): + assert _split_edge_title("A->B") == ("A", "B") + assert _split_edge_title("VALDI->CHEVI") == ("VALDI", "CHEVI") + assert _split_edge_title("X--Y") == ("X", "Y") + + +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("#abcdef") is None + + +def test_model_from_dot_json_extracts_nodes_edges_adjacency(): + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + model = _model_from_dot_json(pg.create(prog="dot", format="json")) + + assert {n["name"] for n in model["nodes"]} == {"VALDI", "CHEVI", "MARSI"} + assert len(model["edges"]) == 3 + # adjacency is undirected for highlight-neighbourhood UX + assert sorted(n["node"] for n in model["adjacency"]["VALDI"]) == ["CHEVI", "MARSI"] + assert sorted(n["node"] for n in model["adjacency"]["MARSI"]) == ["CHEVI", "VALDI"] + + +def test_layer_index_groups_by_color_and_style(): + 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"]} + + assert "color:coral" in layers + assert "color:black" in layers + assert "color:gray" in layers + assert "style:dotted" in layers + assert len(layers["color:coral"]["edges"]) == 1 + assert len(layers["style:dotted"]["edges"]) == 1 + + +def test_build_interactive_html_is_self_contained_and_has_data_attrs(): + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + html = build_interactive_html(pg, title="toy") + + # Self-contained: no external script/style references. + assert "