Source code for syssimx.system.graph

"""Graph construction and execution ordering helpers for System.

This module builds annotated connection graphs for a System, detects algebraic
loops based on zero-delay direct-feedthrough dependencies, and computes a
parallelizable execution order. It also provides utilities for identifying
delayed producers and collecting zero-delay interface unknowns.

The helpers in this module mutate the following System fields:
    - graph: full connection graph with edge port annotations.
    - _dag: zero-delay direct-feedthrough dependency graph (directed acyclic graph).
    - algebraic_loops: list of strongly connected components in _dag.
    - _scc_index: component name to SCC id mapping for the _dag condensation.
    - _incoming_by_dst: incoming connection list keyed by destination component.
    - _input_sources: mapping of destination ports to their driving connections.
    - execution_order: list of component generations for execution.
    - execution_idx: component name to generation index mapping.

Typical usage:
    build_graphs(system)
    compute_execution_order(system)
    interface_inputs, driver_map = collect_global_interface_unknowns(system)
"""

from __future__ import annotations

from collections import defaultdict
from typing import TYPE_CHECKING

import networkx as nx

if TYPE_CHECKING:
    from .system import System


[docs] def collect_active_outputs(system: System) -> dict[str, set[str]]: """Collect output ports that are used by outgoing connections. Args: system: System whose connections are inspected. Returns: Mapping of component name to the set of output port names that are referenced by connections. """ used = defaultdict(set) for c in system.connections: used[c.src_comp].add(c.src_port) return used
[docs] def build_graphs(system: System) -> None: """Build annotated connection graphs and detect algebraic loops. Populates full-connection and zero-delay dependency graphs, then computes algebraic loops (SCCs) on the zero-delay dependency graph. Args: system: System to populate with graph structures. Raises: RuntimeError: If multiple connections drive the same input port. """ # 1) Clear existing gaph data system.graph.clear() system._dag.clear() system.algebraic_loops.clear() system._scc_index.clear() system._incoming_by_dst.clear() system._input_sources.clear() # 2) Add components as nodes for name in system.components: system.graph.add_node(name) system._dag.add_node(name) active_outputs = collect_active_outputs(system) # 3) Add connections as edges for c in system.connections: # a) Record incoming connections and input sources system._incoming_by_dst.setdefault(c.dst_comp, []).append(c) dst_map = system._input_sources.setdefault(c.dst_comp, {}) # b) Check for multiple drivers for same input port if c.dst_port in dst_map: raise RuntimeError( f"Multiple drivers for input port '{c.dst_comp}.{c.dst_port}' " f"from '{dst_map[c.dst_port].src_comp}.{dst_map[c.dst_port].src_port}' " f"and '{c.src_comp}.{c.src_port}'" ) dst_map[c.dst_port] = c # c) Add edges to graphs system.graph.add_edge(c.src_comp, c.dst_comp, src_port=c.src_port, dst_port=c.dst_port) # d) Determine if this is a zero-delay direct-feedthrough edge dst_comp = system.components[c.dst_comp] zero_delay = False relevant_outputs = active_outputs.get(c.dst_comp, set()) for out_port, deps in dst_comp.direct_feedthrough.items(): if out_port not in relevant_outputs: continue if deps and c.dst_port in deps: zero_delay = True break if zero_delay: system._dag.add_edge(c.src_comp, c.dst_comp, src_port=c.src_port, dst_port=c.dst_port) # 4) Identify algebraic loops (SCCs with size > 1) sccs = list(nx.strongly_connected_components(system._dag)) system.algebraic_loops = [list(scc) for scc in sccs if len(scc) > 1] # 5) Identify single-node SCCs with self-loops as well for node in system._dag.nodes: if system._dag.has_edge(node, node) and node not in system.algebraic_loops: system.algebraic_loops.append([node])
[docs] def compute_execution_order(system: System) -> None: """Compute a parallelizable execution order from zero-delay dependencies. Condenses the zero-delay graph into SCCs, orders those SCCs in topological generations, and then expands each generation back to component names. Args: system: System to populate with execution ordering data. """ # 1) Ensure graphs are built if system._dag.number_of_nodes() == 0: build_graphs(system) # 2) Condensation: nodes are SCC ids (0..k-1), edges reflect inter-SCC dependencies condensed = nx.condensation(system._dag) mapping: dict[str, int] = condensed.graph.get("mapping", {}) # original node -> scc id # 3) Reverse mapping: scc id -> list of original component names scc_members: dict[int, list[str]] = {} for comp_name, scc_id in mapping.items(): scc_members.setdefault(scc_id, []).append(comp_name) for cid in scc_members: scc_members[cid] = sorted(scc_members[cid]) system._scc_index = { name: mapping[name] for name in system.components.keys() if name in mapping } # 4) Topological generations on condensed graph to get execution order gens_c = list(nx.topological_generations(condensed)) # 5) Expand SCC ids back to component names for final execution order system.execution_order = [] system.execution_idx.clear() idx = 0 for gen in gens_c: expanded: list[str] = [] for cid in gen: members = scc_members.get(cid, []) expanded.extend(members) expanded = sorted(expanded) system.execution_order.append(expanded) for name in expanded: system.execution_idx[name] = idx idx += 1 # 6) Post-process: move 'delayed producers' to last generation move_delayed_producers_to_last_generation(system)
[docs] def is_delayed_producer(system: System, name: str) -> bool: """Check whether a component is a delayed producer. A delayed producer has no zero-delay incident edges in the _dag, but still feeds into at least one component that participates in zero-delay structure. These are often actuator-like components whose outputs influence the closed loop only through downstream state. Args: system: System containing the graph data. name: Component name to evaluate. Returns: True if the component is considered a delayed producer. """ # 1) Must NOT be involved in any zero-delay edges if system._dag.in_degree(name) > 0 or system._dag.out_degree(name) > 0: return False # 2) Precompute: nodes that *do* participate in any zero-delay structure zero_delay_nodes = { n for n in system._dag.nodes if system._dag.in_degree(n) > 0 or system._dag.out_degree(n) > 0 } if not zero_delay_nodes: return False # 3) There must be a path in the full connection graph from this node # to at least one zero-delay node. for target in zero_delay_nodes: if nx.has_path(system.graph, name, target): return True return False
[docs] def move_delayed_producers_to_last_generation(system: System) -> None: """Move delayed producers to a final execution generation. Removes delayed producers from their current generations and appends them as a final, sorted generation in the execution order. Args: system: System whose execution order is modified. """ if not system.execution_order: return # 1) Collect candidates delayed_producers = { name for name in system.components.keys() if is_delayed_producer(system, name) } if not delayed_producers: return # 2) Remove them from existing gens new_gens: list[list[str]] = [] for gen in system.execution_order: keep = [name for name in gen if name not in delayed_producers] if keep: new_gens.append(keep) # 3) Append them as the last generation last_gen = sorted(delayed_producers) new_gens.append(last_gen) # 4) Store back and rebuild index map system.execution_order = new_gens system.execution_idx.clear() for idx, gen in enumerate(system.execution_order): for name in gen: system.execution_idx[name] = idx
[docs] def collect_global_interface_unknowns( system: System, ) -> tuple[list[tuple[str, str]], dict[tuple[str, str], tuple[str, str]]]: """Collect interface inputs that participate in zero-delay couplings. Args: system: System containing the zero-delay dependency graph. Returns: A tuple containing: - interface_inputs: list of (dst_comp_name, dst_port_name). - driver_map: mapping (dst_comp, dst_port) -> (src_comp, src_port). """ interface_inputs: list[tuple[str, str]] = [] driver_map: dict[tuple[str, str], tuple[str, str]] = {} if system._dag is None: return interface_inputs, driver_map # 1) Collect interface inputs and their drivers from zero-delay edges for src, dst, data in system._dag.edges(data=True): src_port = data.get("src_port") dst_port = data.get("dst_port") key = (dst, dst_port) interface_inputs.append(key) driver_map[key] = (src, src_port) # 2) Remove duplicates and sort interface_inputs = sorted(set(interface_inputs)) return interface_inputs, driver_map