Source code for semi_cr.core.lab.station.graphing.overlay

from dataclasses import dataclass
from typing import Any, Iterable
import networkx as nx

from semi_cr.core.lab.station.graphing.resolver import GraphNodeResolver

EDGE_METADATA_KEYS = {
    "dependency_key",
}

CANONICAL_IDENTITY_ATTRIBUTES = {
    "kind",
    "name",
    "instrument_name",
    "terminal_name",
}

def _runtime_node_attrs(
    node_id: Any,
    node_attrs: dict[str, Any],
) -> dict[str, Any]:
    attrs = dict(node_attrs)

    # Some dependency graphs store the runtime object
    # under "value", others under "obj".
    value = attrs.pop(
        "value",
        None,
    )

    if value is not None:
        attrs["obj"] = value

    obj = attrs.get("obj")

    attrs.setdefault(
        "name",
        (
            getattr(
                obj,
                "name",
                str(node_id),
            )
            if obj is not None
            else str(node_id)
        ),
    )

    return attrs

def _upsert_node(
    graph: nx.MultiDiGraph,
    canonical_id: str,
    node_id: Any,
    node_attrs: dict[str, Any],
    *,
    default_kind: str,
) -> None:
    incoming = _runtime_node_attrs(
        node_id,
        node_attrs,
    )

    if canonical_id not in graph:
        incoming.setdefault(
            "kind",
            default_kind,
        )

        graph.add_node(
            canonical_id,
            **incoming,
        )

        return

    existing = graph.nodes[
        canonical_id
    ]

    for key, value in incoming.items():
        # Preserve the identity assigned by the
        # canonical/base graph.
        if (
            key
            in CANONICAL_NODE_IDENTITY_KEYS
        ):
            continue

        # Do not erase useful canonical data with None.
        if value is None:
            continue

        existing[key] = value

def _edge_identity_attrs(
    attrs: dict[str, Any],
) -> dict[str, Any]:
    return {
        key: value
        for key, value in attrs.items()
        if key not in EDGE_METADATA_KEYS
    }

def _upsert_edge(
    graph: nx.MultiDiGraph,
    source: str,
    target: str,
    attrs: dict[str, Any],
) -> None:
    requested_identity = _edge_identity_attrs(
        attrs
    )

    existing_edges = graph.get_edge_data(
        source,
        target,
        default={},
    )

    for key, existing_attrs in (
        existing_edges.items()
    ):
        existing_identity = (
            _edge_identity_attrs(
                existing_attrs
            )
        )

        if existing_identity != requested_identity:
            continue

        # Same logical edge: enrich the existing edge.
        existing_attrs.update(attrs)
        return

    # Genuinely different parallel relation.
    graph.add_edge(
        source,
        target,
        **attrs,
    )

[docs] @dataclass(frozen=True) class DependencyOverlayBuilder: """ Overlays dependency edges from Graph graphs onto an existing MultiDiGraph (typically a containment graph). """ resolver: GraphNodeResolver edge_kind: str = "depends" # policy knobs include_edges_with_none_state: bool = True skip_disabled_edges: bool = True disabled_state_names: tuple[str, ...] = ("Edge.Disabled", "Disabled") # robust across enum/string default_node_kind: str = "logical" @staticmethod def _state_name(state: Any) -> str | None: """ Return a comparable name for string or enum-like states. """ if state is None: return None return str(state) def _should_include_edge( self, state: Any, ) -> bool: if ( state is None and not self.include_edges_with_none_state ): return False if ( self.skip_disabled_edges and self._state_name(state) in self.disabled_state_names ): return False return True
[docs] def overlay( self, base: nx.MultiDiGraph, dep_graphs: Iterable[Any], ) -> nx.MultiDiGraph: graph = base.copy() for dependency_graph in dep_graphs: # --------------------------------------------- # Merge resolved nodes # --------------------------------------------- for node_id, node_attrs in ( dependency_graph.nodes( data=True, ) ): canonical_id = self.resolver( dependency_graph, node_id, ) incoming_attrs = dict( node_attrs ) value = incoming_attrs.pop( "value", None, ) if value is not None: incoming_attrs["obj"] = value obj = incoming_attrs.get("obj") incoming_attrs.setdefault( "name", ( getattr( obj, "name", str(node_id), ) if obj is not None else str(node_id) ), ) if canonical_id not in graph: incoming_attrs.setdefault( "kind", self.default_node_kind, ) graph.add_node( canonical_id, **incoming_attrs, ) continue canonical_attrs = graph.nodes[ canonical_id ] for key, value in ( incoming_attrs.items() ): if ( key in CANONICAL_IDENTITY_ATTRIBUTES and key in canonical_attrs ): continue if value is None: continue canonical_attrs[key] = value # --------------------------------------------- # Merge enabled dependency edges # --------------------------------------------- for ( source, target, original_key, edge_attrs, ) in dependency_graph.edges( keys=True, data=True, ): state = edge_attrs.get( "state" ) if not self._should_include_edge( state ): continue canonical_source = self.resolver( dependency_graph, source, ) canonical_target = self.resolver( dependency_graph, target, ) attrs = dict(edge_attrs) attrs.setdefault( "kind", self.edge_kind, ) attrs["dependency_key"] = ( original_key ) _upsert_edge( graph, canonical_source, canonical_target, attrs, ) return graph