Source code for hiphopsllm.viz.plots

"""
hiphopsllm.viz.plots — matplotlib rendering, for environments without internet.

`draw_mermaid_png()` posts the diagram to mermaid.ink, so it needs network
access. A Kaggle notebook usually has none, and a fault tree that cannot be
drawn where the analysis runs is not much use. Everything here is matplotlib
only.

Fault trees are drawn with the conventional symbols: a rectangle for an event,
the **AND** gate (flat base, domed top) and **OR** gate (curved sides rising to
a point, concave base) beneath the event they resolve, a circle for a basic
event and a diamond for an undeveloped one. Gate symbols also carry their name,
so the distinction never rests on shape alone.

Colours are the validated design-system tokens rather than the source notebook's
palette, whose teal/apricot pair fails colour-vision separation (ΔE 5.4 protan,
against a floor of 8).
"""

from __future__ import annotations

import textwrap
from typing import Any, Dict, List, Optional, Tuple

from ..architecture.acyclic import find_back_edges
from ..faulttree.analysis import TreeAnalysis
from ..architecture.model import Role, SystemModel
from ..faulttree.synthesis import FaultTree, expand_to_tree

__all__ = [
    "TOKENS",
    "plot_fault_tree",
    "plot_architecture",
    "plot_importance",
    "plot_cutset_orders",
]

#: Design tokens (light surface). Status colours are reserved and always ship
#: with a label, never as the only signal.
TOKENS = {
    "surface": "#fcfcfb",
    "text": "#0b0b0b",
    "text_secondary": "#52514e",
    "grid": "#e1e0d9",
    "series": "#2a78d6",        # sequential hue, step 450
    "series_light": "#cde2fb",  # step 100
    "critical": "#d03b3b",
    "warning": "#fab219",
    "serious": "#ec835a",
    "good": "#0ca30c",
}

_NODE_STYLE = {
    "top": dict(face=TOKENS["critical"], edge="#8f2020", text="#ffffff", shape="rect", lw=2.0),
    "intermediate": dict(face=TOKENS["series_light"], edge=TOKENS["series"], text=TOKENS["text"],
                         shape="rect", lw=1.2),
    "basic": dict(face=TOKENS["surface"], edge=TOKENS["text_secondary"], text=TOKENS["text"],
                  shape="ellipse", lw=1.2),
    "undeveloped": dict(face=TOKENS["warning"], edge="#a8730d", text=TOKENS["text"],
                        shape="diamond", lw=1.2),
    "house": dict(face=TOKENS["good"], edge="#076f07", text="#ffffff", shape="rect", lw=1.2),
    "transfer": dict(face="#e6e5e1", edge=TOKENS["text_secondary"], text=TOKENS["text"],
                     shape="triangle", lw=1.2),
}

_ROLE_STYLE = {
    Role.LLM_AGENT: dict(face="#cde2fb", edge="#2a78d6", shape="rect"),
    Role.TOOL: dict(face="#fde3d3", edge="#eb6834", shape="rect"),
    Role.AGGREGATOR: dict(face="#cdf0e2", edge="#1baf7a", shape="rect"),
    Role.ROUTER: dict(face="#fcfcfb", edge="#52514e", shape="diamond"),
    Role.FEEDBACK: dict(face="#f3d2d2", edge="#d03b3b", shape="rect"),
    Role.TRANSFORM: dict(face="#ececea", edge="#52514e", shape="rect"),
    Role.SOURCE: dict(face="#e6e5e1", edge="#52514e", shape="pill"),
    Role.SINK: dict(face="#e6e5e1", edge="#52514e", shape="pill"),
}


# --------------------------------------------------------------------------- #
# shared layout helpers
# --------------------------------------------------------------------------- #
def _short(text: str, width: int = 22, max_lines: int = 5) -> str:
    lines: List[str] = []
    for part in (text or "").split("\n"):
        lines.extend(textwrap.wrap(part, width=width) or [""])
    if len(lines) > max_lines:
        lines = lines[: max_lines - 1] + [lines[max_lines - 1][: width - 1] + "…"]
    return "\n".join(lines)


def _spread(x: Dict[str, float], by_layer: Dict[int, List[str]],
            order: Dict[str, int], min_gap: float) -> None:
    """Push apart nodes that share a layer, then centre each layer."""
    for members in by_layer.values():
        members.sort(key=lambda n: (x[n], order.get(n, 0)))
        for i in range(1, len(members)):
            if x[members[i]] - x[members[i - 1]] < min_gap:
                x[members[i]] = x[members[i - 1]] + min_gap
        span = x[members[-1]] - x[members[0]]
        offset = -x[members[0]] - span / 2.0
        for nid in members:
            x[nid] += offset


# --------------------------------------------------------------------------- #
# fault tree layout
# --------------------------------------------------------------------------- #
def _topological(tree: FaultTree) -> List[str]:
    indeg: Dict[str, int] = {nid: 0 for nid in tree.nodes}
    for node in tree.nodes.values():
        for child in node.children:
            indeg[child] += 1
    queue = [nid for nid, d in indeg.items() if d == 0]
    out: List[str] = []
    while queue:
        nid = queue.pop(0)
        out.append(nid)
        for child in tree.nodes[nid].children:
            indeg[child] -= 1
            if indeg[child] == 0:
                queue.append(child)
    return out


def _layers(tree: FaultTree) -> Dict[str, int]:
    """Longest path from the root, so an edge never points upwards."""
    depth: Dict[str, int] = {tree.root: 0}
    for nid in _topological(tree):
        for child in tree.nodes[nid].children:
            depth[child] = max(depth.get(child, 0), depth.get(nid, 0) + 1)
    return depth


def _dfs_order(tree: FaultTree) -> List[str]:
    seen, out = set(), []
    stack = [tree.root]
    while stack:
        nid = stack.pop()
        if nid in seen:
            continue
        seen.add(nid)
        out.append(nid)
        stack.extend(reversed(tree.nodes[nid].children))
    return out


def _positions(
    tree: FaultTree, min_gap: float = 1.06, y_step: float = 1.30
) -> Tuple[Dict[str, Tuple[float, float]], int, int]:
    """Tidy layout: leaves take successive slots left to right, parents centre
    over their own children. On a strict tree that produces subtrees which never
    interleave, so no connector ever crosses another."""
    depth = _layers(tree)
    order = {nid: i for i, nid in enumerate(_dfs_order(tree))}
    by_layer: Dict[int, List[str]] = {}
    for nid, d in depth.items():
        by_layer.setdefault(d, []).append(nid)

    x: Dict[str, float] = {}
    slot = [0.0]

    def place(nid: str, seen: Tuple[str, ...] = ()) -> float:
        if nid in x:
            return x[nid]
        node = tree.nodes[nid]
        kids = [c for c in node.children if c not in seen]
        if kids:
            centres = [place(c, seen + (nid,)) for c in kids]
            x[nid] = (min(centres) + max(centres)) / 2.0
        else:
            x[nid] = slot[0]
            slot[0] += min_gap
        return x[nid]

    place(tree.root)
    for nid in sorted(depth, key=lambda n: (-depth[n], order.get(n, 0))):
        if nid not in x:                       # unreachable safety net
            x[nid] = slot[0]
            slot[0] += min_gap

    _spread(x, by_layer, order, min_gap)
    positions = {nid: (x[nid], -float(depth[nid]) * y_step) for nid in tree.nodes}
    return positions, max(len(m) for m in by_layer.values()), max(by_layer) + 1


# --------------------------------------------------------------------------- #
# gate symbols
# --------------------------------------------------------------------------- #
def _and_gate_path(cx: float, cy: float, w: float, h: float):
    """Flat base, straight sides, semicircular top — the conventional AND gate."""
    from matplotlib.path import Path

    r = w / 2.0
    straight = max(h - r, h * 0.22)
    y0 = cy - h / 2.0            # base
    y1 = y0 + straight           # where the dome starts
    k = 0.5523 * r
    verts = [
        (cx - r, y0), (cx - r, y1),
        (cx - r, y1 + k), (cx - k, y1 + r), (cx, y1 + r),
        (cx + k, y1 + r), (cx + r, y1 + k), (cx + r, y1),
        (cx + r, y0), (cx - r, y0),
    ]
    codes = [
        Path.MOVETO, Path.LINETO,
        Path.CURVE4, Path.CURVE4, Path.CURVE4,
        Path.CURVE4, Path.CURVE4, Path.CURVE4,
        Path.LINETO, Path.CLOSEPOLY,
    ]
    return Path(verts, codes)


def _or_gate_path(cx: float, cy: float, w: float, h: float):
    """Curved sides rising to a point, concave base — the conventional OR gate."""
    from matplotlib.path import Path

    r = w / 2.0
    y0 = cy - h / 2.0
    top = cy + h / 2.0
    verts = [
        (cx - r, y0),
        (cx - r, y0 + h * 0.70), (cx - r * 0.10, top), (cx, top),          # left side
        (cx + r * 0.10, top), (cx + r, y0 + h * 0.70), (cx + r, y0),       # right side
        (cx + r * 0.45, y0 + h * 0.34), (cx - r * 0.45, y0 + h * 0.34), (cx - r, y0),
        (cx - r, y0),
    ]
    codes = [
        Path.MOVETO,
        Path.CURVE4, Path.CURVE4, Path.CURVE4,
        Path.CURVE4, Path.CURVE4, Path.CURVE4,
        Path.CURVE4, Path.CURVE4, Path.CURVE4,
        Path.CLOSEPOLY,
    ]
    return Path(verts, codes)


class _GateProxy:
    """Legend stand-in so the key shows the real gate symbol, not a swatch."""

    def __init__(self, gate: str, label: str):
        self.gate = gate
        self._label = label

    def get_label(self) -> str:
        return self._label


class _GateHandler:
    """Legend handler that draws the gate outline at legend-entry size."""

    def legend_artist(self, legend, orig_handle, fontsize, handlebox):
        import matplotlib.patches as mpatches

        w = handlebox.width * 0.62
        h = handlebox.height * 1.25
        cx = handlebox.xdescent + handlebox.width / 2.0
        cy = handlebox.ydescent + handlebox.height / 2.0
        builder = _and_gate_path if orig_handle.gate == "AND" else _or_gate_path
        patch = mpatches.PathPatch(builder(cx, cy, w, h), facecolor="#ffffff",
                                   edgecolor=TOKENS["text"], lw=1.0)
        patch.set_transform(handlebox.get_transform())
        handlebox.add_artist(patch)
        return patch


# --------------------------------------------------------------------------- #
# fault tree
# --------------------------------------------------------------------------- #
[docs] def plot_fault_tree( tree: FaultTree, ax: Optional[Any] = None, title: Optional[str] = None, scale: float = 1.0, label_width: int = 22, show_legend: bool = True, as_tree: bool = True, ) -> Any: """Draw a synthesised fault tree with the conventional FTA symbols. ``as_tree`` (default) repeats shared causes so the structure is a strict tree: every subtree is then local and the connectors are plain right angles — an event, its gate beneath it, a bus, and a vertical drop to each input, exactly as a fault tree is normally drawn. Set it to ``False`` to draw the synthesised DAG instead, where a shared cause appears once and its edges run across the diagram. """ import matplotlib.patches as mpatches import matplotlib.pyplot as plt if as_tree: try: tree = expand_to_tree(tree) except ValueError: # too much sharing to repeat; draw the DAG as_tree = False y_step = 1.30 positions, _, depth = _positions(tree, y_step=y_step) box_w, box_h = 0.94, 0.62 gate_w, gate_h, gate_gap = 0.46, 0.34, 0.05 xs_all = [p[0] for p in positions.values()] x_extent = (max(xs_all) - min(xs_all)) + 2 * box_w # A wide tree gets compact leaf labels: the event id alone. The id names its # component and failure mode, and the full text is in the basic event # register, so nothing is lost that the reader cannot look up. n_leaves = sum(1 for n in tree.nodes.values() if not n.children) compact = n_leaves > 10 if ax is None: # Uniform physical scale: one data unit is always the same size on # paper, so a 6-layer tree and a 25-layer tree are equally legible and # the labels never shrink out of their boxes. fig_w = max(6.5, min(200.0, x_extent * 1.18 * scale)) fig_h = max(4.0, min(200.0, (depth * y_step + 1.6) * 0.92 * scale)) _, ax = plt.subplots(figsize=(fig_w, fig_h)) fig = ax.figure fig.patch.set_facecolor(TOKENS["surface"]) ax.set_facecolor(TOKENS["surface"]) def outlet(nid: str) -> float: """y at which this node's inputs leave it (below its gate, if any).""" _, y = positions[nid] node = tree.nodes[nid] if node.gate and node.children: return y - box_h / 2 - gate_gap - gate_h return y - box_h / 2 # ---- connectors first, so the symbols sit on top of them --------------- # # The conventional form: a stub down from the gate, one horizontal bus, and # a vertical drop into each input. Only when a child sits further down than # the next layer (possible in the DAG form) does a per-edge elbow get used. line = dict(color=TOKENS["text_secondary"], lw=1.0, zorder=1, solid_joinstyle="miter", solid_capstyle="butt") for nid, node in tree.nodes.items(): if not node.children: continue x0, _ = positions[nid] y_out = outlet(nid) tops = {c: positions[c][1] + box_h / 2 for c in node.children} level = max(tops.values()) bus_y = (y_out + level) / 2 same_level = [c for c in node.children if abs(tops[c] - level) < 1e-9] deeper = [c for c in node.children if c not in same_level] ax.plot([x0, x0], [y_out, bus_y], **line) xs_children = [positions[c][0] for c in same_level] if xs_children: left, right = min(xs_children + [x0]), max(xs_children + [x0]) if right - left > 1e-9: ax.plot([left, right], [bus_y, bus_y], **line) for child in same_level: cx = positions[child][0] ax.plot([cx, cx], [bus_y, tops[child]], **line) for child in deeper: # DAG form only cx = positions[child][0] ax.plot([x0, cx, cx], [bus_y, bus_y, tops[child]], **line) # ---- events and gates -------------------------------------------------- # for nid, node in tree.nodes.items(): x, y = positions[nid] style = _NODE_STYLE.get(node.ntype, _NODE_STYLE["intermediate"]) if style["shape"] == "ellipse": patch: Any = mpatches.Ellipse((x, y), box_w, box_h) elif style["shape"] == "diamond": patch = mpatches.Polygon( [(x, y + box_h / 2), (x + box_w / 2, y), (x, y - box_h / 2), (x - box_w / 2, y)], closed=True, ) elif style["shape"] == "triangle": # transfer symbol, point downwards patch = mpatches.Polygon( [(x - box_w / 2, y + box_h / 2), (x + box_w / 2, y + box_h / 2), (x, y - box_h / 2)], closed=True, ) else: patch = mpatches.FancyBboxPatch( (x - box_w / 2, y - box_h / 2), box_w, box_h, boxstyle="round,pad=0.012,rounding_size=0.05", ) patch.set_facecolor(style["face"]) patch.set_edgecolor(style["edge"]) patch.set_linewidth(style["lw"]) patch.set_zorder(2) ax.add_patch(patch) # an ellipse loses its corners, so its text must wrap narrower wrap = label_width - 5 if style["shape"] == "ellipse" else label_width raw_label = node.label is_leaf = node.ntype in ("basic", "undeveloped") if compact and is_leaf: raw_label = raw_label.split("\n")[0] text = _short(raw_label, width=wrap, max_lines=3 if (compact and is_leaf) else 5) if node.ntype == "transfer": # a triangle narrows downwards, so only the tag and a pointer fit text = f"▽ {node.transfer_ref}\nsee {node.component or 'above'}" elif node.transfer_ref: text = f"△ {node.transfer_ref} {text}" elif node.repeat_of: text = "↺ " + text # the same event, repeated for readability text_y = y + box_h * 0.16 if style["shape"] == "triangle" else y ax.text(x, text_y, text, ha="center", va="center", fontsize=5.9, color=style["text"], zorder=3, linespacing=1.15) if node.gate and node.children: gate_cy = y - box_h / 2 - gate_gap - gate_h / 2 path = (_and_gate_path if node.gate == "AND" else _or_gate_path)( x, gate_cy, gate_w, gate_h ) gate_patch = mpatches.PathPatch( path, facecolor="#ffffff", edgecolor=TOKENS["text"], lw=1.1, zorder=2, joinstyle="round", ) ax.add_patch(gate_patch) ax.plot([x, x], [y - box_h / 2, gate_cy + gate_h / 2], color=TOKENS["text"], lw=1.1, zorder=1) ax.text(x, gate_cy + gate_h * 0.06, node.gate, ha="center", va="center", fontsize=5.0, color=TOKENS["text"], zorder=3) ys = [p[1] for p in positions.values()] ax.set_xlim(min(xs_all) - box_w, max(xs_all) + box_w) ax.set_ylim(min(ys) - box_h, max(ys) + box_h * 1.2) ax.set_axis_off() hazard = tree.hazard fig.suptitle(title or f"{tree.id}{tree.name}", fontsize=12, color=TOKENS["text"], x=0.012, y=0.998, ha="left", va="top") if hazard is not None: ax.set_title( _short(f"severity: {hazard.severity} · top event: " f"{', '.join(str(d) for d in hazard.deviations)}", width=150, max_lines=2), fontsize=7.5, color=TOKENS["text_secondary"], loc="left", pad=6, ) if show_legend: handles = [ mpatches.Patch(facecolor=_NODE_STYLE["top"]["face"], edgecolor=_NODE_STYLE["top"]["edge"], label="top event"), mpatches.Patch(facecolor=_NODE_STYLE["intermediate"]["face"], edgecolor=_NODE_STYLE["intermediate"]["edge"], label="intermediate event (rectangle)"), mpatches.Patch(facecolor=_NODE_STYLE["basic"]["face"], edgecolor=_NODE_STYLE["basic"]["edge"], label="basic event (circle)"), mpatches.Patch(facecolor=_NODE_STYLE["undeveloped"]["face"], edgecolor=_NODE_STYLE["undeveloped"]["edge"], label="undeveloped (diamond)"), mpatches.Patch(facecolor=_NODE_STYLE["transfer"]["face"], edgecolor=_NODE_STYLE["transfer"]["edge"], label="transfer ▽A → subtree △A"), _GateProxy("AND", label="AND gate"), _GateProxy("OR", label="OR gate"), ] # a narrow figure cannot fit seven entries on one row ncol = 7 if fig.get_size_inches()[0] >= 15 else 4 fig.legend(handles=handles, loc="lower center", ncol=ncol, frameon=False, fontsize=7.5, labelcolor=TOKENS["text_secondary"], bbox_to_anchor=(0.5, 0.0), handlelength=1.6, handler_map={_GateProxy: _GateHandler()}) legend_rows = -(-len(handles) // ncol) else: legend_rows = 0 fig.tight_layout(rect=(0.0, 0.012 + 0.030 * legend_rows, 1.0, 0.965)) return ax
# --------------------------------------------------------------------------- # # architecture # --------------------------------------------------------------------------- #
[docs] def plot_architecture( system: SystemModel, ax: Optional[Any] = None, title: Optional[str] = None, show_ports: bool = False, ) -> Any: """Draw the LangGraph architecture — the offline equivalent of ``display(Image(graph.get_graph().draw_mermaid_png()))``. Feedback edges are drawn dashed in the reserved critical colour and labelled, because they are the reason the graph cannot be read as a fault tree until they are removed. """ import matplotlib.patches as mpatches import matplotlib.pyplot as plt ids = list(system.components) edges = [(c.src, c.dst, c.label, c.conditional) for c in system.connections] back = {(e[0], e[1]) for e in find_back_edges(ids, edges)} forward = [e for e in edges if (e[0], e[1]) not in back] # layer by longest path from the sources over the forward edges depth: Dict[str, int] = {nid: 0 for nid in ids} for _ in range(len(ids)): changed = False for src, dst, _lbl, _cond in forward: if depth[dst] < depth[src] + 1: depth[dst] = depth[src] + 1 changed = True if not changed: break by_layer: Dict[int, List[str]] = {} for nid in ids: by_layer.setdefault(depth[nid], []).append(nid) order = {nid: i for i, nid in enumerate(ids)} x: Dict[str, float] = {} for layer in sorted(by_layer): for i, nid in enumerate(sorted(by_layer[layer], key=lambda n: order[n])): parents = [x[s] for s, d, _l, _c in forward if d == nid and s in x] x[nid] = sum(parents) / len(parents) if parents else float(i) _spread(x, by_layer, order, min_gap=1.25) y_step = 1.25 pos = {nid: (x[nid], -depth[nid] * y_step) for nid in ids} box_w, box_h = 1.05, 0.52 # Two kinds of edge cannot be drawn straight. Feedback edges run backwards, # and an edge that skips a layer would otherwise be a vertical line hidden # behind every component it passes, with its label landing on top of one of # them. Both are routed around the right-hand side instead, each in its own # lane so that two of them never overlap. An early exit to `__end__` is # exactly this case, and it is the edge a reader most needs to see. right = max(p[0] for p in pos.values()) skips = [ (src, dst, label, conditional) for src, dst, label, conditional in edges if (src, dst) not in back and depth[dst] - depth[src] > 1 ] skip_lane = {(e[0], e[1]): right + box_w * (0.72 + 0.30 * i) for i, e in enumerate(skips)} lane = (max(skip_lane.values()) if skip_lane else right) + box_w * 0.55 if ax is None: left = min(p[0] for p in pos.values()) span = (lane if back else max([right] + list(skip_lane.values()))) - left fig_w = max(6.0, min(40.0, (span + 2 * box_w) * 1.6)) fig_h = max(3.5, min(40.0, (max(depth.values()) * y_step + 1.8) * 1.15)) _, ax = plt.subplots(figsize=(fig_w, fig_h)) fig = ax.figure fig.patch.set_facecolor(TOKENS["surface"]) ax.set_facecolor(TOKENS["surface"]) for src, dst, label, conditional in edges: x0, y0 = pos[src] x1, y1 = pos[dst] if (src, dst) in back: dip = y0 - box_h * 0.95 # run below the row, never through a node ax.plot([x0, x0, lane, lane], [y0 - box_h / 2, dip, dip, y1], color=TOKENS["critical"], lw=1.3, linestyle="dashed", zorder=1, solid_joinstyle="miter") ax.annotate("", xy=(x1 + box_w / 2, y1), xytext=(lane, y1), arrowprops=dict(arrowstyle="-|>", color=TOKENS["critical"], lw=1.3, linestyle="dashed", shrinkA=0, shrinkB=1), zorder=1) ax.text(lane + 0.06, (y0 + y1) / 2, "feedback", fontsize=7, color=TOKENS["critical"], ha="left", va="center", rotation=90) continue if (src, dst) in skip_lane: side = skip_lane[(src, dst)] drop = y0 - box_h * 0.62 ax.plot([x0, x0, side, side], [y0 - box_h / 2, drop, drop, y1], color=TOKENS["text_secondary"], lw=1.1, zorder=1, alpha=0.75, linestyle="dashed" if conditional else "solid", solid_joinstyle="miter") ax.annotate("", xy=(x1 + box_w / 2, y1), xytext=(side, y1), arrowprops=dict(arrowstyle="-|>", color=TOKENS["text_secondary"], lw=1.1, shrinkA=0, shrinkB=1), zorder=1) if label: ax.text(side + 0.05, (drop + y1) / 2, label, fontsize=7, color=TOKENS["text_secondary"], ha="left", va="center", rotation=90) continue mid = (y0 - box_h / 2 + y1 + box_h / 2) / 2 ax.plot([x0, x0, x1, x1], [y0 - box_h / 2, mid, mid, y1 + box_h / 2], color=TOKENS["text_secondary"], lw=1.1, zorder=1, linestyle="dashed" if conditional else "solid", alpha=0.75) ax.annotate("", xy=(x1, y1 + box_h / 2), xytext=(x1, mid), arrowprops=dict(arrowstyle="-|>", color=TOKENS["text_secondary"], lw=1.1, shrinkA=0, shrinkB=0), zorder=1) if label: ax.text(x1 + 0.06, mid + 0.05, label, fontsize=7, color=TOKENS["text_secondary"], ha="left", va="bottom") for nid in ids: comp = system.components[nid] cx, cy = pos[nid] style = _ROLE_STYLE.get(comp.role, _ROLE_STYLE[Role.TRANSFORM]) if style["shape"] == "diamond": patch: Any = mpatches.Polygon( [(cx, cy + box_h / 2), (cx + box_w / 2, cy), (cx, cy - box_h / 2), (cx - box_w / 2, cy)], closed=True) elif style["shape"] == "pill": patch = mpatches.FancyBboxPatch( (cx - box_w / 2 + box_h / 2, cy - box_h / 2), box_w - box_h, box_h, boxstyle=f"round,pad=0.0,rounding_size={box_h / 2}") else: patch = mpatches.FancyBboxPatch( (cx - box_w / 2, cy - box_h / 2), box_w, box_h, boxstyle="round,pad=0.012,rounding_size=0.06") patch.set_facecolor(style["face"]) patch.set_edgecolor(style["edge"]) patch.set_linewidth(1.4) patch.set_zorder(2) ax.add_patch(patch) # Materialised routers are named `<node>::router`; breaking that name on # the separator keeps the source node readable instead of splitting it # mid-word to fit the wrap width, and the role caption underneath is then # the same word twice, so it is dropped rather than crowding the shape. label = _short(comp.id.replace("::", "\n::"), width=20, max_lines=2) caption = comp.role.value.replace("_", " ") if comp.id.rsplit("::", 1)[-1] == comp.role.value: caption = "" if show_ports and comp.ports_in: caption = (caption + f" ({len(comp.ports_in)} in)").strip() ax.text(cx, cy + (0.055 if caption else 0.0), label, ha="center", va="center", fontsize=7.6, color=TOKENS["text"], zorder=3, linespacing=1.1) if caption: ax.text(cx, cy - box_h * 0.30, caption, ha="center", va="center", fontsize=6, color=TOKENS["text_secondary"], zorder=3) xs = [p[0] for p in pos.values()] ys = [p[1] for p in pos.values()] outer = max([max(xs)] + ([lane] if back else []) + list(skip_lane.values())) ax.set_xlim(min(xs) - box_w * 0.9, outer + box_w * 0.9) ax.set_ylim(min(ys) - box_h * 1.9, max(ys) + box_h * 1.4) ax.set_axis_off() fig.suptitle(title or f"Architecture — {system.name}", fontsize=12, color=TOKENS["text"], x=0.012, y=0.998, ha="left", va="top") subtitle = (f"{len(system.components)} components · {len(system.connections)} connections" + (" · dashed grey = conditional edge" if any(e[3] for e in edges) else "") + (" · dashed red = feedback, cut before analysis" if back else "")) ax.set_title(_short(subtitle, width=96, max_lines=2), fontsize=7.5, color=TOKENS["text_secondary"], loc="left", pad=6) fig.tight_layout(rect=(0.0, 0.01, 1.0, 0.965)) return ax
# --------------------------------------------------------------------------- # # contribution ranking # --------------------------------------------------------------------------- #
[docs] def plot_importance( analysis: TreeAnalysis, top_n: int = 12, ax: Optional[Any] = None, title: Optional[str] = None, ) -> Any: """Fussell-Vesely contribution per basic event, ranked. One measure, one hue. Single points of failure are marked with the reserved critical colour *and* a "single point" tag, so the distinction never rests on colour alone. """ import matplotlib.pyplot as plt rows = analysis.importance[:top_n] if not rows: raise ValueError("no importance rows to plot") if ax is None: _, ax = plt.subplots(figsize=(9.5, max(2.6, 0.42 * len(rows) + 1.4))) fig = ax.figure fig.patch.set_facecolor(TOKENS["surface"]) ax.set_facecolor(TOKENS["surface"]) labels = [r.event_id for r in rows][::-1] values = [r.fussell_vesely for r in rows][::-1] spof = [r.single_point for r in rows][::-1] colours = [TOKENS["critical"] if s else TOKENS["series"] for s in spof] ax.barh(range(len(values)), values, color=colours, height=0.62, zorder=2, linewidth=0) ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels, fontsize=7.5, color=TOKENS["text"]) ax.set_xlabel("Fussell-Vesely contribution to the top event", fontsize=8.5, color=TOKENS["text_secondary"]) headroom = 1.42 if any(spof) else 1.16 ax.set_xlim(0, max(values) * headroom if max(values) > 0 else 1) ax.tick_params(axis="x", labelsize=7.5, colors=TOKENS["text_secondary"], length=0) ax.tick_params(axis="y", length=0) ax.grid(axis="x", color=TOKENS["grid"], lw=0.8, zorder=0) ax.set_axisbelow(True) for side in ("top", "right", "left", "bottom"): ax.spines[side].set_visible(False) for i, (value, is_spof) in enumerate(zip(values, spof)): tag = f"{value:.3f}" + (" ◆ single point" if is_spof else "") ax.text(value + max(values) * 0.015, i, tag, va="center", fontsize=7.5, color=TOKENS["text_secondary"]) ax.set_title(title or f"{analysis.tree.id} — what drives «{analysis.tree.name}»", fontsize=11, color=TOKENS["text"], loc="left", pad=10) fig.tight_layout() return ax
[docs] def plot_cutset_orders( analyses: Dict[str, TreeAnalysis], ax: Optional[Any] = None, title: str = "Minimal cut sets by order", ) -> Any: """Cut-set count per order, per hazard — how much defence in depth exists. Order 1 is a single point of failure, so a tall order-1 bar is the finding. """ import matplotlib.pyplot as plt hazards = sorted(analyses) orders = sorted({len(cs) for a in analyses.values() for cs in a.cuts.sets}) if not orders: raise ValueError("no cut sets to plot") if ax is None: _, ax = plt.subplots(figsize=(9.5, 4.2)) fig = ax.figure fig.patch.set_facecolor(TOKENS["surface"]) ax.set_facecolor(TOKENS["surface"]) # one hue, stepped by order: magnitude, not identity ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#2a78d6", "#184f95"] width = 0.8 / len(orders) for j, order in enumerate(orders): counts = [sum(1 for cs in analyses[h].cuts.sets if len(cs) == order) for h in hazards] offsets = [i - 0.4 + width * (j + 0.5) for i in range(len(hazards))] colour = TOKENS["critical"] if order == 1 else ramp[min(j, len(ramp) - 1)] ax.bar(offsets, counts, width=width * 0.88, color=colour, zorder=2, label=f"order {order}" + (" (single point)" if order == 1 else "")) for x_pos, count in zip(offsets, counts): if count: ax.text(x_pos, count + 0.12, str(count), ha="center", fontsize=7, color=TOKENS["text_secondary"]) ax.set_xticks(range(len(hazards))) ax.set_xticklabels( [f"{h}\n" + "\n".join(textwrap.wrap(analyses[h].tree.name, width=22)[:2]) for h in hazards], fontsize=7.5, color=TOKENS["text"], ) ax.set_ylabel("number of minimal cut sets", fontsize=8.5, color=TOKENS["text_secondary"]) ax.tick_params(axis="y", labelsize=7.5, colors=TOKENS["text_secondary"], length=0) ax.tick_params(axis="x", length=0) ax.grid(axis="y", color=TOKENS["grid"], lw=0.8, zorder=0) ax.set_axisbelow(True) for side in ("top", "right", "left", "bottom"): ax.spines[side].set_visible(False) ax.legend(frameon=False, fontsize=7.5, ncol=len(orders), labelcolor=TOKENS["text_secondary"], loc="upper right") ax.set_title(title, fontsize=11, color=TOKENS["text"], loc="left", pad=10) fig.tight_layout() return ax