From 6a11799ac02fee601e12705b405321e082b35aaa Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 9 Mar 2025 03:04:08 +0000 Subject: [PATCH] performance improvements for spf and max-flow --- ngraph/blueprints.py | 194 +++--- ngraph/lib/algorithms/calc_capacity.py | 229 ++++--- ngraph/lib/algorithms/edge_select.py | 141 ++-- ngraph/lib/algorithms/max_flow.py | 47 +- ngraph/lib/algorithms/spf.py | 467 +++++++++---- ngraph/lib/graph.py | 76 +-- notebooks/lib_examples.ipynb | 14 +- notebooks/scenario.ipynb | 324 --------- notebooks/scenario_2.ipynb | 266 -------- notebooks/scenario_dc.ipynb | 828 +++++++++++++++++++++++ notebooks/small_demo.ipynb | 324 +++++++++ tests/lib/algorithms/test_edge_select.py | 198 +++--- tests/test_blueprints_helpers.py | 169 ++++- 13 files changed, 2138 insertions(+), 1139 deletions(-) delete mode 100644 notebooks/scenario.ipynb delete mode 100644 notebooks/scenario_2.ipynb create mode 100644 notebooks/scenario_dc.ipynb create mode 100644 notebooks/small_demo.ipynb diff --git a/ngraph/blueprints.py b/ngraph/blueprints.py index b44f3d3..c39bd69 100644 --- a/ngraph/blueprints.py +++ b/ngraph/blueprints.py @@ -1,6 +1,3 @@ -from __future__ import annotations - -import copy from dataclasses import dataclass from typing import Any, Dict, List @@ -151,33 +148,36 @@ def _expand_group( if "use_blueprint" in group_def: # Expand blueprint subgroups blueprint_name: str = group_def["use_blueprint"] - bp = ctx.blueprints.get(blueprint_name) - if not bp: - raise ValueError( - f"Group '{group_name}' references unknown blueprint '{blueprint_name}'." - ) - - param_overrides: Dict[str, Any] = group_def.get("parameters", {}) - coords = group_def.get("coords") - - # For each subgroup in the blueprint, apply overrides and expand - for bp_sub_name, bp_sub_def in bp.groups.items(): - merged_def = _apply_parameters(bp_sub_name, bp_sub_def, param_overrides) - if coords is not None and "coords" not in merged_def: - merged_def["coords"] = coords - - _expand_group( - ctx, - parent_path=effective_path, - group_name=bp_sub_name, - group_def=merged_def, - blueprint_expansion=True, - ) - - # Expand blueprint adjacency - for adj_def in bp.adjacency: - _expand_blueprint_adjacency(ctx, adj_def, effective_path) - + try: + bp = ctx.blueprints.get(blueprint_name) + if not bp: + raise ValueError( + f"Group '{group_name}' references unknown blueprint '{blueprint_name}'." + ) + + param_overrides: Dict[str, Any] = group_def.get("parameters", {}) + coords = group_def.get("coords") + + # For each subgroup in the blueprint, apply overrides and expand + for bp_sub_name, bp_sub_def in bp.groups.items(): + merged_def = _apply_parameters(bp_sub_name, bp_sub_def, param_overrides) + if coords is not None and "coords" not in merged_def: + merged_def["coords"] = coords + + _expand_group( + ctx, + parent_path=effective_path, + group_name=bp_sub_name, + group_def=merged_def, + blueprint_expansion=True, + ) + + # Expand blueprint adjacency + for adj_def in bp.adjacency: + _expand_blueprint_adjacency(ctx, adj_def, effective_path) + + except Exception as e: + raise ValueError(f"Error expanding blueprint '{blueprint_name}': {e}") else: # It's a direct node group node_count = group_def.get("node_count", 1) @@ -217,11 +217,12 @@ def _expand_blueprint_adjacency( target_rel = adj_def["target"] pattern = adj_def.get("pattern", "mesh") link_params = adj_def.get("link_params", {}) + link_count = adj_def.get("link_count", 1) src_path = _join_paths(parent_path, source_rel) tgt_path = _join_paths(parent_path, target_rel) - _expand_adjacency_pattern(ctx, src_path, tgt_path, pattern, link_params) + _expand_adjacency_pattern(ctx, src_path, tgt_path, pattern, link_params, link_count) def _expand_adjacency( @@ -239,13 +240,16 @@ def _expand_adjacency( source_path_raw = adj_def["source"] target_path_raw = adj_def["target"] pattern = adj_def.get("pattern", "mesh") + link_count = adj_def.get("link_count", 1) link_params = adj_def.get("link_params", {}) # Convert to an absolute or relative path source_path = _join_paths("", source_path_raw) target_path = _join_paths("", target_path_raw) - _expand_adjacency_pattern(ctx, source_path, target_path, pattern, link_params) + _expand_adjacency_pattern( + ctx, source_path, target_path, pattern, link_params, link_count + ) def _expand_adjacency_pattern( @@ -254,23 +258,25 @@ def _expand_adjacency_pattern( target_path: str, pattern: str, link_params: Dict[str, Any], + link_count: int = 1, ) -> None: """ Generates Link objects for the chosen adjacency pattern among matched nodes. Supported Patterns: * "mesh": Connect every node from source side to every node on target side, - skipping self-loops, and deduplicating reversed pairs. - * "one_to_one": Pair each source node with exactly one target node (wrap-around). - * "ring": (Example pattern) For demonstration, connect nodes in a ring among - the union of source + target sets (ignores directionality). + skipping self-loops and deduplicating reversed pairs. + * "one_to_one": Pair each source node with exactly one target node (wrap-around), + requiring that the larger set size is an integer multiple + of the smaller set size. Args: ctx (DSLExpansionContext): The context containing the target network. source_path (str): The path pattern identifying the source node group(s). target_path (str): The path pattern identifying the target node group(s). - pattern (str): The type of adjacency pattern (e.g., "mesh", "one_to_one", "ring"). + pattern (str): The type of adjacency pattern (e.g., "mesh", "one_to_one"). link_params (Dict[str, Any]): Additional link parameters (capacity, cost, attrs). + link_count (int): Number of parallel links to create for each adjacency. """ source_node_groups = ctx.network.select_node_groups_by_path(source_path) target_node_groups = ctx.network.select_node_groups_by_path(target_path) @@ -292,21 +298,21 @@ def _expand_adjacency_pattern( pair = tuple(sorted((sn.name, tn.name))) if pair not in dedup_pairs: dedup_pairs.add(pair) - _create_link(ctx.network, sn.name, tn.name, link_params) + _create_link(ctx.network, sn.name, tn.name, link_params, link_count) elif pattern == "one_to_one": s_count = len(source_nodes) t_count = len(target_nodes) - bigger, smaller = max(s_count, t_count), min(s_count, t_count) + bigger_count = max(s_count, t_count) + smaller_count = min(s_count, t_count) - # Basic check for wrap-around scenario - if bigger % smaller != 0: + if bigger_count % smaller_count != 0: raise ValueError( - f"one_to_one pattern requires either equal node counts " - f"or a valid wrap-around. Got {s_count} vs {t_count}." + f"one_to_one pattern requires sizes with a multiple factor. " + f"Got source={s_count}, target={t_count}." ) - for i in range(bigger): + for i in range(bigger_count): if s_count >= t_count: sn = source_nodes[i].name tn = target_nodes[i % t_count].name @@ -320,36 +326,46 @@ def _expand_adjacency_pattern( pair = tuple(sorted((sn, tn))) if pair not in dedup_pairs: dedup_pairs.add(pair) - _create_link(ctx.network, sn, tn, link_params) + _create_link(ctx.network, sn, tn, link_params, link_count) else: raise ValueError(f"Unknown adjacency pattern: {pattern}") def _create_link( - net: Network, source: str, target: str, link_params: Dict[str, Any] + net: Network, + source: str, + target: str, + link_params: Dict[str, Any], + link_count: int = 1, ) -> None: """ - Creates and adds a Link to the network, applying capacity/cost/attrs from link_params. + Creates and adds one or more Links to the network, applying capacity, cost, + and attributes from link_params. Uses deep copies of the attributes to avoid + accidental shared mutations. Args: - net (Network): The network to which the new link is added. + net (Network): The network to which the new link(s) is/are added. source (str): Source node name for the link. target (str): Target node name for the link. link_params (Dict[str, Any]): A dict possibly containing 'capacity', 'cost', and 'attrs' keys. + link_count (int): Number of parallel links to create between source and target. """ - capacity = link_params.get("capacity", 1.0) - cost = link_params.get("cost", 1.0) - attrs = copy.deepcopy(link_params.get("attrs", {})) + import copy - link = Link( - source=source, - target=target, - capacity=capacity, - cost=cost, - attrs=attrs, - ) - net.add_link(link) + for _ in range(link_count): + capacity = link_params.get("capacity", 1.0) + cost = link_params.get("cost", 1.0) + attrs = copy.deepcopy(link_params.get("attrs", {})) + + link = Link( + source=source, + target=target, + capacity=capacity, + cost=cost, + attrs=attrs, + ) + net.add_link(link) def _process_direct_nodes(net: Network, network_data: Dict[str, Any]) -> None: @@ -403,14 +419,8 @@ def _process_direct_links(net: Network, network_data: Dict[str, Any]) -> None: if source == target: raise ValueError(f"Link cannot have the same source and target: {source}") link_params = link_info.get("link_params", {}) - link = Link( - source=source, - target=target, - capacity=link_params.get("capacity", 1.0), - cost=link_params.get("cost", 1.0), - attrs=link_params.get("attrs", {}), - ) - net.add_link(link) + link_count = link_info.get("link_count", 1) + _create_link(net, source, target, link_params, link_count) def _process_link_overrides(network: Network, network_data: Dict[str, Any]) -> None: @@ -475,7 +485,7 @@ def _update_links( any_direction: bool = True, ) -> None: """ - Update all Link objects between nodes matching 'source' and 'target' paths + Updates all Link objects between nodes matching 'source' and 'target' paths with new parameters. If any_direction=True, both (source->target) and (target->source) links @@ -537,8 +547,11 @@ def _apply_parameters( Applies user-provided parameter overrides to a blueprint subgroup. Example: - If 'spine.node_count'=6 is in params_overrides, - we set 'node_count'=6 for the 'spine' subgroup. + If 'spine.node_count' = 6 is in params_overrides, + it sets 'node_count'=6 for the 'spine' subgroup. + + If 'spine.node_attrs.hw_type' = 'Dell', + it sets subgroup_def['node_attrs']['hw_type'] = 'Dell'. Args: subgroup_name (str): Name of the subgroup in the blueprint (e.g. 'spine'). @@ -547,23 +560,50 @@ def _apply_parameters( {'spine.node_count': 6, 'spine.node_attrs.hw_type': 'Dell'}. Returns: - Dict[str, Any]: A copy of subgroup_def with parameter overrides applied. + Dict[str, Any]: A copy of subgroup_def with parameter overrides applied, + including nested dictionary fields if specified by dotted paths (e.g. node_attrs.foo). """ - out = dict(subgroup_def) + import copy + + out = copy.deepcopy(subgroup_def) + for key, val in params_overrides.items(): parts = key.split(".") if parts[0] == subgroup_name and len(parts) > 1: - field_name = ".".join(parts[1:]) - out[field_name] = val + # We have a dotted path that might refer to nested dictionaries. + subpath = parts[1:] + _apply_nested_path(out, subpath, val) + return out +def _apply_nested_path( + node_def: Dict[str, Any], path_parts: List[str], value: Any +) -> None: + """ + Recursively applies a path like ["node_attrs", "role"] to set node_def["node_attrs"]["role"] = value. + Creates intermediate dicts as needed. + """ + if not path_parts: + return + key = path_parts[0] + if len(path_parts) == 1: + node_def[key] = value + return + + # Ensure that node_def[key] is a dict + if key not in node_def or not isinstance(node_def[key], dict): + node_def[key] = {} + _apply_nested_path(node_def[key], path_parts[1:], value) + + def _join_paths(parent_path: str, rel_path: str) -> str: """ Joins two path segments according to NetGraph's DSL conventions: - - If rel_path starts with '/', strip the leading slash and treat it - as appended to parent_path if parent_path is not empty. - - Otherwise, simply append rel_path to parent_path if parent_path is non-empty. + + - If rel_path starts with '/', strip the leading slash and treat it as + appended to parent_path if parent_path is not empty. + - Otherwise, simply append rel_path to parent_path if parent_path is non-empty. Args: parent_path (str): The existing path prefix. diff --git a/ngraph/lib/algorithms/calc_capacity.py b/ngraph/lib/algorithms/calc_capacity.py index 38b9ef8..e8889c6 100644 --- a/ngraph/lib/algorithms/calc_capacity.py +++ b/ngraph/lib/algorithms/calc_capacity.py @@ -21,82 +21,99 @@ def _init_graph_data( Dict[NodeID, Dict[NodeID, float]], ]: """ - Build the necessary data structures for the flow algorithm: - - `succ`: Reversed adjacency mapping, where each key is a node and its value is a - dict mapping adjacent nodes (from which flow can arrive) to the tuple of edge IDs. - - `levels`: Stores the BFS level (distance) for each node (used in Dinic's algorithm). - - `residual_cap`: Residual capacity for each edge in the reversed orientation. - - `flow_dict`: Tracks the net flow on each edge (initialized to zero). - - For PROPORTIONAL mode, the residual capacity in the reversed graph is the sum of the available - capacity on all parallel forward edges (if above a threshold MIN_CAP). For EQUAL_BALANCED mode, - the reversed edge capacity is set as the minimum available capacity among parallel edges multiplied - by the number of such edges. + Build the necessary data structures for the flow algorithm (in reversed orientation): + + - ``succ``: Reversed adjacency mapping. For each forward edge u->v in ``pred``, + store v->u in ``succ`` along with the tuple of edge IDs. + - ``levels``: A dictionary mapping each visited node to -1, indicating the BFS + level (for Dinic) is uninitialized. The actual levels are set later by + ``_set_levels_bfs``. + - ``residual_cap``: Residual capacities in the reversed graph. For PROPORTIONAL + mode, it is the sum of (capacity - flow) for parallel edges (clamped at 0). + For EQUAL_BALANCED mode, it is the minimum (capacity - flow) multiplied by + the number of parallel edges. Forward edges in the reversed graph start with + 0 capacity (they're effectively the reverse edges for flow). + - ``flow_dict``: Tracks net flow along each reversed edge (initialized to 0). + This will be updated by the Dinic or BFS-based flow routines. + + This function performs a BFS from ``init_node`` (usually the destination in the + forward graph) over the DAG ``pred`` to find all nodes that can reach ``init_node`` + in the forward direction. Only those edges/nodes are stored in the reversed data + structures. Args: flow_graph: The multigraph with capacity and flow attributes on edges. pred: Forward adjacency mapping: node -> (adjacent node -> list of EdgeIDs). - init_node: Starting node for the reverse BFS (typically the destination in forward flow). + This is a DAG typically produced by a shortest-path routine from the + source in the forward direction. + init_node: The node from which we perform the reversed BFS (generally the + destination in forward flow). flow_placement: Strategy for distributing flow (PROPORTIONAL or EQUAL_BALANCED). - capacity_attr: Name of the capacity attribute. - flow_attr: Name of the flow attribute. + capacity_attr: Name of the capacity attribute on edges. + flow_attr: Name of the flow attribute on edges. Returns: A tuple containing: - succ: The reversed adjacency dict. - - levels: A dict mapping each node to its BFS level. + - levels: A dict mapping each node encountered by the reversed BFS to -1 + (uninitialized). The BFS level values are set later. - residual_cap: The residual capacities in the reversed graph. - - flow_dict: The net flow on each edge (initially zero). + - flow_dict: The net flow on each reversed edge (initially zero). """ edges = flow_graph.get_edges() - # Reversed adjacency: For each edge u->v in forward sense, store v->u in succ. + # Reversed adjacency: For each forward edge u->v in pred, store v->u in succ. succ: Dict[NodeID, Dict[NodeID, Tuple[EdgeID, ...]]] = defaultdict(dict) - # Levels for BFS/DFS (initially empty) + + # Will store BFS levels (set to -1 here, updated later in _set_levels_bfs). levels: Dict[NodeID, int] = {} + # Residual capacities in the reversed orientation residual_cap: Dict[NodeID, Dict[NodeID, float]] = defaultdict(dict) - # Net flow (will be updated during DFS/BFS) + + # Net flow (updated during flow pushes) flow_dict: Dict[NodeID, Dict[NodeID, float]] = defaultdict(dict) + # Standard BFS to collect only the portion of pred reachable from init_node (in reverse) visited: Set[NodeID] = set() - queue: Deque[NodeID] = deque([init_node]) + queue: Deque[NodeID] = deque() + + visited.add(init_node) + levels[init_node] = -1 + queue.append(init_node) - # Perform a BFS starting from init_node (destination in forward graph) while queue: node = queue.popleft() - visited.add(node) - # Initialize level to -1 (unvisited) if not already set - if node not in levels: - levels[node] = -1 - - # Process incoming edges in the forward (pred) graph to build the reversed structure + # Check each forward adjacency from node in pred, so we can form reversed edges. for adj_node, edge_list in pred.get(node, {}).items(): - # Record the reversed edge: from adj_node -> node with all corresponding edge IDs. + # Build reversed adjacency once if node not in succ[adj_node]: succ[adj_node][node] = tuple(edge_list) - # Calculate available capacity for each parallel edge (cap - flow) + # Calculate available capacities of the forward edges capacities = [] for eid in edge_list: - cap_val = edges[eid][3][capacity_attr] - flow_val = edges[eid][3][flow_attr] - # Only consider nonnegative available capacity - c = max(0.0, cap_val - flow_val) + e_attrs = edges[eid][3] # Slightly faster repeated access + cap_val = e_attrs[capacity_attr] + flow_val = e_attrs[flow_attr] + c = cap_val - flow_val + if c < 0.0: + c = 0.0 capacities.append(c) + # Set reversed and forward capacities in the residual_cap structure if flow_placement == FlowPlacement.PROPORTIONAL: - # Sum capacities of parallel edges as the available capacity in reverse. + # Sum capacities of parallel edges for the reversed edge fwd_capacity = sum(capacities) residual_cap[node][adj_node] = ( fwd_capacity if fwd_capacity >= MIN_CAP else 0.0 ) - # In the reverse graph, the backward edge starts with zero capacity. + # Reverse edge in the BFS sense starts with 0 capacity residual_cap[adj_node][node] = 0.0 elif flow_placement == FlowPlacement.EQUAL_BALANCED: - # Use the minimum available capacity multiplied by the number of parallel edges. + # min(...) * number_of_parallel_edges if capacities: rev_cap = min(capacities) * len(capacities) residual_cap[adj_node][node] = ( @@ -104,21 +121,24 @@ def _init_graph_data( ) else: residual_cap[adj_node][node] = 0.0 - # The forward edge is unused in this BFS phase. + # The forward edge in reversed orientation starts at 0 capacity residual_cap[node][adj_node] = 0.0 else: raise ValueError(f"Unsupported flow placement: {flow_placement}") - # Initialize net flow for both orientations to zero. + # Initialize net flow for both orientations to 0 flow_dict[node][adj_node] = 0.0 flow_dict[adj_node][node] = 0.0 - # Add adjacent node to the BFS queue if not already visited. + # Enqueue adj_node if not visited if adj_node not in visited: + visited.add(adj_node) + levels[adj_node] = -1 queue.append(adj_node) - # Ensure every node in the graph appears in the reversed adjacency map. + # Ensure every node in the entire graph has at least an empty adjacency dict in succ + # (some nodes might be outside the reversed BFS component). for n in flow_graph.nodes(): succ.setdefault(n, {}) @@ -135,9 +155,9 @@ def _set_levels_bfs( An edge is considered if its residual capacity is at least MIN_CAP. Args: - start_node: The starting node for the BFS (acts as the source in the reversed graph). - levels: A dict mapping each node to its level (updated in-place). - residual_cap: Residual capacity for each edge in the reversed graph. + start_node: The starting node for the BFS (acts as the 'source' in reversed graph). + levels: The dict from node -> BFS level (modified in-place). + residual_cap: The dict of reversed residual capacities for edges. """ # Reset all node levels to -1 (unvisited) for nd in levels: @@ -149,7 +169,7 @@ def _set_levels_bfs( u = queue.popleft() # Explore all neighbors of u in the reversed graph for v, cap_uv in residual_cap[u].items(): - # Only traverse edges with sufficient capacity and unvisited nodes. + # Only traverse edges with sufficient capacity and unvisited nodes if cap_uv >= MIN_CAP and levels[v] < 0: levels[v] = levels[u] + 1 queue.append(v) @@ -171,30 +191,28 @@ def _push_flow_dfs( current: The current node in the DFS. sink: The target node in the reversed orientation. flow_in: The amount of flow available to push from the current node. - residual_cap: The residual capacities of edges. - flow_dict: Records the net flow pushed along each edge. - levels: Node levels as determined by BFS. + residual_cap: Residual capacities of edges in the reversed graph. + flow_dict: Tracks the net flow pushed along edges in the reversed graph. + levels: BFS levels in the reversed graph (from `_set_levels_bfs`). Returns: The total amount of flow successfully pushed from `current` to `sink`. """ - # Base case: reached sink, return the available flow. + # Base case: reached sink if current == sink: return flow_in total_pushed = 0.0 - # Make a static list of neighbors to avoid issues if residual_cap is updated during iteration. - neighbors = list(residual_cap[current].items()) + neighbors = list( + residual_cap[current].items() + ) # snapshot to avoid iteration changes for nxt, capacity_uv in neighbors: - # Skip edges that don't have enough residual capacity. if capacity_uv < MIN_CAP: continue - # Only consider neighbors that are exactly one level deeper. if levels.get(nxt, -1) != levels[current] + 1: continue - # Determine how much flow can be pushed along the current edge. flow_to_push = min(flow_in, capacity_uv) if flow_to_push < MIN_FLOW: continue @@ -203,18 +221,17 @@ def _push_flow_dfs( nxt, sink, flow_to_push, residual_cap, flow_dict, levels ) if pushed >= MIN_FLOW: - # Decrease residual capacity on forward edge and increase on reverse edge. + # Update residual capacities residual_cap[current][nxt] -= pushed residual_cap[nxt][current] += pushed - # Update net flow (note: in reversed orientation) + # Update net flow (remember, we're in reversed orientation) flow_dict[current][nxt] += pushed flow_dict[nxt][current] -= pushed flow_in -= pushed total_pushed += pushed - # Stop if no more flow can be pushed from the current node. if flow_in < MIN_FLOW: break @@ -228,20 +245,21 @@ def _equal_balance_bfs( ) -> None: """ Perform a BFS-like pass to distribute a nominal flow of 1.0 from `src_node` - over the reversed adjacency (succ), splitting flow equally among all outgoing parallel edges. - This method does not verify capacities; it simply assigns relative flow amounts. + over the reversed adjacency (succ), splitting flow equally among all outgoing + parallel edges from each node. This does not verify capacities. It merely + assigns relative (fractional) flow amounts, which are later scaled so that + capacities are not exceeded. Args: - src_node: The starting node from which a nominal flow of 1.0 is injected. - succ: The reversed adjacency dict where succ[u][v] is a tuple of edges from u to v. + src_node: The node from which a nominal flow of 1.0 is injected (in reversed orientation). + succ: The reversed adjacency dict, where succ[u][v] is a tuple of edges (u->v in reversed sense). flow_dict: The net flow dictionary to be updated with the BFS distribution. """ - # Calculate the total count of parallel edges leaving each node. + # Count total parallel edges leaving each node node_split: Dict[NodeID, int] = {} for node, neighbors in succ.items(): node_split[node] = sum(len(edge_tuple) for edge_tuple in neighbors.values()) - # Initialize BFS with src_node and a starting flow of 1.0. queue: Deque[Tuple[NodeID, float]] = deque([(src_node, 1.0)]) visited: Set[NodeID] = set() @@ -249,28 +267,27 @@ def _equal_balance_bfs( node, incoming_flow = queue.popleft() visited.add(node) - # Get total number of outgoing parallel edges. - split_count = node_split[ - node - ] # Previously caused KeyError if node wasn't in succ + # If no edges or negligible incoming flow, skip + split_count = node_split.get(node, 0) if split_count <= 0 or incoming_flow < MIN_FLOW: continue - # Distribute the incoming flow proportionally based on number of edges. + # Distribute the incoming_flow among outgoing edges, proportional to the count of parallel edges for nxt, edge_tuple in succ[node].items(): if not edge_tuple: - continue # Skip if there are no edges to next node. - # Compute the fraction of flow for this neighbor. + continue push_flow = (incoming_flow * len(edge_tuple)) / float(split_count) if push_flow < MIN_FLOW: continue - # Update net flow in the reversed direction. flow_dict[node][nxt] += push_flow flow_dict[nxt][node] -= push_flow - # Continue BFS for neighbor if not yet visited. if nxt not in visited: + # Note: we queue each node only once in this scheme. + # If a node can be reached from multiple parents before being popped, + # the BFS will handle the first discovered flow. + # This behavior matches the existing tests and usage expectations. queue.append((nxt, push_flow)) @@ -284,41 +301,50 @@ def calc_graph_capacity( flow_attr: str = "flow", ) -> Tuple[float, Dict[NodeID, Dict[NodeID, float]]]: """ - Calculate the maximum feasible flow from src_node to dst_node (in forward sense) + Calculate the maximum feasible flow from src_node to dst_node (forward sense) using either the PROPORTIONAL or EQUAL_BALANCED approach. - In PROPORTIONAL mode (Dinic-like): - 1. Build the reversed residual graph from dst_node. - 2. Use BFS to create a level graph and DFS to push blocking flows. - 3. Sum the reversed flows from dst_node to src_node and normalize them to obtain - the forward flow values. + In PROPORTIONAL mode (similar to Dinic in reversed orientation): + 1. Build the reversed residual graph from dst_node (via `_init_graph_data`). + 2. Use BFS (in `_set_levels_bfs`) to build a level graph and DFS (`_push_flow_dfs`) + to push blocking flows, repeating until no more flow can be pushed. + 3. The net flow found is stored in reversed orientation. Convert final flows + to forward orientation by negating and normalizing by the total. In EQUAL_BALANCED mode: - 1. Perform a BFS pass from src_node over the reversed adjacency, - distributing a nominal flow of 1.0. - 2. Determine the scaling ratio so that no edge capacity is exceeded. - 3. Scale the flow assignments and normalize the flows. + 1. Build reversed adjacency from dst_node (also via `_init_graph_data`), + ignoring capacity checks in that BFS. + 2. Perform a BFS pass from src_node (`_equal_balance_bfs`) to distribute a + nominal flow of 1.0 equally among parallel edges. + 3. Determine the scaling ratio so that no edge capacity is exceeded. + Scale the flow assignments accordingly, then normalize to the forward sense. Args: flow_graph: The multigraph with capacity and flow attributes. src_node: The source node in the forward graph. dst_node: The destination node in the forward graph. - pred: Forward adjacency mapping: node -> (adjacent node -> list of EdgeIDs). - flow_placement: Flow distribution strategy (PROPORTIONAL or EQUAL_BALANCED). - capacity_attr: Name of the capacity attribute. - flow_attr: Name of the flow attribute. + pred: Forward adjacency mapping (node -> (adjacent node -> list of EdgeIDs)), + typically produced by `spf(..., multipath=True)`. Must be a DAG. + flow_placement: The flow distribution strategy (PROPORTIONAL or EQUAL_BALANCED). + capacity_attr: Name of the capacity attribute on edges. + flow_attr: Name of the flow attribute on edges. Returns: - A tuple containing: - - total_flow: The maximum feasible flow value from src_node to dst_node. - - flow_dict: A dictionary mapping (u, v) to net flow values (positive indicates forward flow). + A tuple of: + - total_flow: The maximum feasible flow from src_node to dst_node. + - flow_dict: A nested dictionary [u][v] -> flow value in the forward sense. + Positive if flow is from u to v, negative otherwise. + + Raises: + ValueError: If src_node or dst_node is not in the graph, or the flow_placement + is unsupported. """ if src_node not in flow_graph or dst_node not in flow_graph: raise ValueError( f"Source node {src_node} or destination node {dst_node} not found in the graph." ) - # Build the reversed adjacency structures starting from dst_node. + # Build reversed data structures from dst_node succ, levels, residual_cap, flow_dict = _init_graph_data( flow_graph=flow_graph, pred=pred, @@ -331,11 +357,10 @@ def calc_graph_capacity( total_flow = 0.0 if flow_placement == FlowPlacement.PROPORTIONAL: - # Apply a reversed version of Dinic's algorithm: - # Repeatedly build the level graph and push flow until no more flow can be sent. + # Repeatedly build the level graph and push blocking flows while True: _set_levels_bfs(dst_node, levels, residual_cap) - # If src_node is unreachable (level <= 0), then no more flow can be pushed. + # If src_node is unreachable (level <= 0), no more flow if levels.get(src_node, -1) <= 0: break @@ -351,23 +376,24 @@ def calc_graph_capacity( break total_flow += pushed + # If no flow found, reset flows to zero if total_flow < MIN_FLOW: - # No flow found; reset all flow values to zero. total_flow = 0.0 for u in flow_dict: for v in flow_dict[u]: flow_dict[u][v] = 0.0 else: - # Convert the accumulated reversed flows to the forward flow convention. + # Convert reversed flows to forward sense for u in flow_dict: for v in flow_dict[u]: + # Negative and normalized flow_dict[u][v] = -(flow_dict[u][v] / total_flow) elif flow_placement == FlowPlacement.EQUAL_BALANCED: - # Step 1: Distribute a nominal flow of 1.0 from src_node over the reversed graph. + # 1. Distribute nominal flow of 1.0 from src_node _equal_balance_bfs(src_node, succ, flow_dict) - # Step 2: Determine the minimum ratio across edges to ensure capacities are not exceeded. + # 2. Determine the scaling ratio so that no edge in reversed orientation exceeds capacity min_ratio = float("inf") for u, neighbors in succ.items(): for v in neighbors: @@ -380,25 +406,26 @@ def calc_graph_capacity( min_ratio = ratio if min_ratio == float("inf") or min_ratio < MIN_FLOW: - # No feasible flow could be established. + # No feasible flow total_flow = 0.0 else: total_flow = min_ratio - # Scale the BFS distribution so that the flow fits within capacities. + # Scale flows to fit capacities for u in flow_dict: for v in flow_dict[u]: val = flow_dict[u][v] * total_flow flow_dict[u][v] = val if abs(val) >= MIN_FLOW else 0.0 - # Normalize flows to represent the forward direction. + # Normalize flows to forward direction for u in flow_dict: for v in flow_dict[u]: - flow_dict[u][v] /= total_flow + if abs(flow_dict[u][v]) > 0.0: + flow_dict[u][v] /= total_flow else: raise ValueError(f"Unsupported flow placement: {flow_placement}") - # Clamp very small flows to zero for cleanliness. + # Clamp small flows to zero for u in flow_dict: for v in flow_dict[u]: if abs(flow_dict[u][v]) < MIN_FLOW: diff --git a/ngraph/lib/algorithms/edge_select.py b/ngraph/lib/algorithms/edge_select.py index 8bb4877..842f9c3 100644 --- a/ngraph/lib/algorithms/edge_select.py +++ b/ngraph/lib/algorithms/edge_select.py @@ -1,5 +1,4 @@ -from __future__ import annotations - +from math import isclose from typing import Any, Callable, Dict, List, Optional, Set, Tuple from ngraph.lib.graph import StrictMultiDiGraph, NodeID, EdgeID, AttrDict @@ -39,7 +38,7 @@ def edge_select_fabric( Tuple[Cost, List[EdgeID]], ]: """ - Creates (fabricates) a function that selects edges between two nodes according + Creates a function that selects edges between two nodes according to a given EdgeSelect strategy (or a user-defined function). Args: @@ -47,8 +46,8 @@ def edge_select_fabric( select_value: An optional numeric threshold or scaling factor for capacity checks. edge_select_func: A user-supplied function if edge_select=USER_DEFINED. excluded_edges: A set of edges to ignore entirely. - excluded_nodes: A set of nodes to skip. - cost_attr: The edge attribute name representing cost/metric. + excluded_nodes: A set of nodes to skip (if the destination node is in this set). + cost_attr: The edge attribute name representing cost. capacity_attr: The edge attribute name representing capacity. flow_attr: The edge attribute name representing current flow. @@ -56,39 +55,38 @@ def edge_select_fabric( A function with signature: (graph, src_node, dst_node, edges_dict, excluded_edges, excluded_nodes) -> (selected_cost, [list_of_edge_ids]) - where `selected_cost` is the numeric cost used by the path-finding algorithm - (e.g. Dijkstra), and `[list_of_edge_ids]` is the set (or subset) of edges chosen. + where: + - `selected_cost` is the numeric cost used by the path-finding algorithm (e.g. Dijkstra). + - `[list_of_edge_ids]` is the list of edges chosen. """ - # -------------------------------------------------------------------------- - # Internal selection routines (closed over the above arguments). - # Each of these returns (cost, [edge_ids]) indicating which edges are chosen. - # -------------------------------------------------------------------------- - def get_all_min_cost_edges( graph: StrictMultiDiGraph, src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: - """Return all edges with the minimal cost among those available.""" - if ignored_nodes and dst_node in ignored_nodes: + """ + Return all edges whose cost is the minimum among available edges. + If the destination node is excluded, returns (inf, []). + """ + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] edge_list: List[EdgeID] = [] min_cost = float("inf") + for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - cost_val = attr[cost_attr] + cost_val = attr[cost_attr] if cost_val < min_cost: min_cost = cost_val edge_list = [edge_id] - elif abs(cost_val - min_cost) < 1e-12: - # If cost_val == min_cost + elif isclose(cost_val, min_cost, abs_tol=1e-12): edge_list.append(edge_id) return min_cost, edge_list @@ -98,20 +96,24 @@ def get_single_min_cost_edge( src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: - """Return exactly one edge: the single lowest-cost edge.""" - if ignored_nodes and dst_node in ignored_nodes: + """ + Return exactly one edge: the single lowest-cost edge. + If the destination node is excluded, returns (inf, []). + """ + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] chosen_edge: List[EdgeID] = [] min_cost = float("inf") + for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - cost_val = attr[cost_attr] + cost_val = attr[cost_attr] if cost_val < min_cost: min_cost = cost_val chosen_edge = [edge_id] @@ -123,14 +125,14 @@ def get_all_edges_with_cap_remaining( src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: """ - Return all edges that have remaining capacity >= min_cap, ignoring - their cost except for reporting the minimal one found. + Return all edges that have remaining capacity >= min_cap, + ignoring cost differences (though return the minimal cost found). """ - if ignored_nodes and dst_node in ignored_nodes: + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] edge_list: List[EdgeID] = [] @@ -138,12 +140,17 @@ def get_all_edges_with_cap_remaining( min_cap = select_value if select_value is not None else MIN_CAP for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - if (attr[capacity_attr] - attr[flow_attr]) >= min_cap: + capacity_val = attr[capacity_attr] + flow_val = attr[flow_attr] + remaining_cap = capacity_val - flow_val + + if remaining_cap >= min_cap: cost_val = attr[cost_attr] - min_cost = min(min_cost, cost_val) + if cost_val < min_cost: + min_cost = cost_val edge_list.append(edge_id) return min_cost, edge_list @@ -153,14 +160,14 @@ def get_all_min_cost_edges_with_cap_remaining( src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: """ - Return all edges that have remaining capacity >= min_cap, + Return all edges that have remaining capacity >= min_cap among those with the minimum cost. """ - if ignored_nodes and dst_node in ignored_nodes: + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] edge_list: List[EdgeID] = [] @@ -168,16 +175,19 @@ def get_all_min_cost_edges_with_cap_remaining( min_cap = select_value if select_value is not None else MIN_CAP for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - available_cap = attr[capacity_attr] - attr[flow_attr] - if available_cap >= min_cap: + capacity_val = attr[capacity_attr] + flow_val = attr[flow_attr] + remaining_cap = capacity_val - flow_val + + if remaining_cap >= min_cap: cost_val = attr[cost_attr] if cost_val < min_cost: min_cost = cost_val edge_list = [edge_id] - elif abs(cost_val - min_cost) < 1e-12: + elif isclose(cost_val, min_cost, abs_tol=1e-12): edge_list.append(edge_id) return min_cost, edge_list @@ -187,14 +197,14 @@ def get_single_min_cost_edge_with_cap_remaining( src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: """ - Return exactly one edge with the minimal cost among those with - remaining capacity >= min_cap. + Return exactly one edge with the minimal cost among those + that have remaining capacity >= min_cap. """ - if ignored_nodes and dst_node in ignored_nodes: + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] chosen_edge: List[EdgeID] = [] @@ -202,10 +212,14 @@ def get_single_min_cost_edge_with_cap_remaining( min_cap = select_value if select_value is not None else MIN_CAP for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - if (attr[capacity_attr] - attr[flow_attr]) >= min_cap: + capacity_val = attr[capacity_attr] + flow_val = attr[flow_attr] + remaining_cap = capacity_val - flow_val + + if remaining_cap >= min_cap: cost_val = attr[cost_attr] if cost_val < min_cost: min_cost = cost_val @@ -218,16 +232,15 @@ def get_single_min_cost_edge_with_cap_remaining_load_factored( src_node: NodeID, dst_node: NodeID, edges_map: Dict[EdgeID, AttrDict], - ignored_edges: Optional[Set[EdgeID]] = None, - ignored_nodes: Optional[Set[NodeID]] = None, + excluded_edges: Optional[Set[EdgeID]] = None, + excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: """ - Return exactly one edge, factoring both 'cost_attr' and load level - into a combined cost: - combined_cost = (cost * 100) + round((flow / capacity) * 10) + Return exactly one edge, factoring both cost and load level + into a combined cost: cost_factor = (cost * 100) + round((flow/capacity)*10). Only edges with remaining capacity >= min_cap are considered. """ - if ignored_nodes and dst_node in ignored_nodes: + if excluded_nodes and dst_node in excluded_nodes: return float("inf"), [] chosen_edge: List[EdgeID] = [] @@ -235,13 +248,21 @@ def get_single_min_cost_edge_with_cap_remaining_load_factored( min_cap = select_value if select_value is not None else MIN_CAP for edge_id, attr in edges_map.items(): - if ignored_edges and edge_id in ignored_edges: + if excluded_edges and edge_id in excluded_edges: continue - remaining_cap = attr[capacity_attr] - attr[flow_attr] + capacity_val = attr[capacity_attr] + flow_val = attr[flow_attr] + remaining_cap = capacity_val - flow_val + if remaining_cap >= min_cap: - load_factor = round((attr[flow_attr] / attr[capacity_attr]) * 10) - cost_val = attr[cost_attr] * 100 + load_factor + base_cost = attr[cost_attr] * 100 + # Avoid division by zero if capacity_val == 0 + load_factor = ( + round((flow_val / capacity_val) * 10) if capacity_val else 999999 + ) + cost_val = base_cost + load_factor + if cost_val < min_cost_factor: min_cost_factor = cost_val chosen_edge = [edge_id] @@ -249,7 +270,7 @@ def get_single_min_cost_edge_with_cap_remaining_load_factored( return float(min_cost_factor), chosen_edge # -------------------------------------------------------------------------- - # Fabric: map the EdgeSelect enum to the appropriate inner function. + # Map the EdgeSelect enum to the appropriate inner function. # -------------------------------------------------------------------------- if edge_select == EdgeSelect.ALL_MIN_COST: return get_all_min_cost_edges diff --git a/ngraph/lib/algorithms/max_flow.py b/ngraph/lib/algorithms/max_flow.py index eb62731a..cec39a3 100644 --- a/ngraph/lib/algorithms/max_flow.py +++ b/ngraph/lib/algorithms/max_flow.py @@ -1,11 +1,8 @@ -from __future__ import annotations - from ngraph.lib.algorithms.spf import spf from ngraph.lib.algorithms.place_flow import place_flow_on_graph from ngraph.lib.algorithms.base import EdgeSelect, FlowPlacement from ngraph.lib.graph import NodeID, StrictMultiDiGraph from ngraph.lib.algorithms.flow_init import init_flow_graph -from ngraph.lib.algorithms.edge_select import edge_select_fabric def calc_max_flow( @@ -25,14 +22,14 @@ def calc_max_flow( using an iterative shortest-path augmentation approach. By default, this function: - 1. Creates or re-initializes a flow-aware copy of the graph (using ``init_flow_graph``). - 2. Repeatedly finds a path from ``src_node`` to any reachable node via ``spf`` with - capacity constraints (through ``EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING``). - 3. Places flow along that path (using ``place_flow_on_graph``) until no augmenting path + 1. Creates or re-initializes a flow-aware copy of the graph (via ``init_flow_graph``). + 2. Repeatedly finds a path from ``src_node`` to ``dst_node`` using ``spf`` with + capacity constraints (``EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING``). + 3. Places flow along that path (via ``place_flow_on_graph``) until no augmenting path remains or the capacities are exhausted. - If ``shortest_path=True``, it will run only one iteration of path-finding and flow placement, - returning the flow placed by that single augmentation (not the true max flow). + If ``shortest_path=True``, the function performs only one iteration (single augmentation) + and returns the flow placed along that single path (not the true max flow). Args: graph (StrictMultiDiGraph): @@ -46,10 +43,10 @@ def calc_max_flow( Defaults to ``FlowPlacement.PROPORTIONAL``. shortest_path (bool): If True, place flow only once along the first shortest path found and return - immediately, rather than iterating to find the true max flow. + immediately, rather than iterating for the true max flow. reset_flow_graph (bool): - If True, reset any existing flow data (e.g., attributes in ``flow_attr`` and - ``flows_attr``). Defaults to False. + If True, reset any existing flow data (e.g., ``flow_attr``, ``flows_attr``). + Defaults to False. capacity_attr (str): The name of the capacity attribute on edges. Defaults to "capacity". flow_attr (str): @@ -61,8 +58,13 @@ def calc_max_flow( Defaults to True. Returns: - float: The total flow placed between ``src_node`` and ``dst_node``. - If ``shortest_path=True``, this is the flow placed by a single augmentation. + float: + The total flow placed between ``src_node`` and ``dst_node``. If ``shortest_path=True``, + this is just the flow from a single augmentation. + + Notes: + - For large graphs or performance-critical scenarios, consider specialized max-flow + algorithms (e.g., Dinic, Edmond-Karp) for better scaling. Examples: >>> g = StrictMultiDiGraph() @@ -83,11 +85,10 @@ def calc_max_flow( reset_flow_graph, ) - # Prepare the edge selection function (selects edges with capacity remaining). - edge_select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING) - # First path-finding iteration. - _, pred = spf(flow_graph, src_node, edge_select_func=edge_select_func) + _, pred = spf( + flow_graph, src_node, edge_select=EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING + ) flow_meta = place_flow_on_graph( flow_graph, src_node, @@ -98,7 +99,7 @@ def calc_max_flow( flow_attr=flow_attr, flows_attr=flows_attr, ) - max_flow: float = flow_meta.placed_flow + max_flow = flow_meta.placed_flow # If only one path (single augmentation) is desired, return early. if shortest_path: @@ -106,9 +107,11 @@ def calc_max_flow( # Otherwise, repeatedly find augmenting paths until no new flow can be placed. while True: - _, pred = spf(flow_graph, src_node, edge_select_func=edge_select_func) + _, pred = spf( + flow_graph, src_node, edge_select=EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING + ) if dst_node not in pred: - # No path found; we've reached the max flow. + # No path found; we've reached max flow. break flow_meta = place_flow_on_graph( @@ -122,7 +125,7 @@ def calc_max_flow( flows_attr=flows_attr, ) if flow_meta.placed_flow <= 0: - # No additional flow could be placed; we're at capacity. + # No additional flow could be placed; at capacity. break max_flow += flow_meta.placed_flow diff --git a/ngraph/lib/algorithms/spf.py b/ngraph/lib/algorithms/spf.py index 51a410b..5bd988d 100644 --- a/ngraph/lib/algorithms/spf.py +++ b/ngraph/lib/algorithms/spf.py @@ -8,6 +8,7 @@ Set, Tuple, ) + from ngraph.lib.graph import ( AttrDict, NodeID, @@ -17,108 +18,231 @@ from ngraph.lib.algorithms.base import ( Cost, EdgeSelect, + MIN_CAP, ) from ngraph.lib.algorithms.edge_select import edge_select_fabric from ngraph.lib.algorithms.path_utils import resolve_to_paths +def _spf_fast_all_min_cost_dijkstra( + graph: StrictMultiDiGraph, + src_node: NodeID, + multipath: bool, +) -> Tuple[Dict[NodeID, Cost], Dict[NodeID, Dict[NodeID, List[EdgeID]]]]: + """ + Specialized Dijkstra's SPF for: + - EdgeSelect.ALL_MIN_COST + - No excluded edges/nodes. + + Finds all edges with the same minimal cost between two nodes if multipath=True. + If multipath=False, new minimal-cost paths overwrite old ones, though edges + are still collected together for immediate neighbor expansion. + + Args: + graph: Directed graph (StrictMultiDiGraph). + src_node: Source node for SPF. + multipath: Whether to record multiple equal-cost paths. + + Returns: + A tuple of (costs, pred): + - costs: Maps each reachable node to the minimal cost from src_node. + - pred: For each reachable node, a dict of predecessor -> list of edges + from the predecessor to that node. If multipath=True, there may be + multiple predecessors for the same node. + """ + outgoing_adjacencies = graph._adj + if src_node not in outgoing_adjacencies: + raise KeyError(f"Source node '{src_node}' is not in the graph.") + + costs: Dict[NodeID, Cost] = {src_node: 0.0} + pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {src_node: {}} + min_pq: List[Tuple[Cost, NodeID]] = [(0.0, src_node)] + + while min_pq: + current_cost, node_id = heappop(min_pq) + if current_cost > costs[node_id]: + continue + + # Explore neighbors + for neighbor_id, edges_map in outgoing_adjacencies[node_id].items(): + min_edge_cost: Optional[Cost] = None + selected_edges: List[EdgeID] = [] + + # Gather the minimal cost edge(s) + for e_id, e_attr in edges_map.items(): + edge_cost = e_attr["cost"] + if min_edge_cost is None or edge_cost < min_edge_cost: + min_edge_cost = edge_cost + selected_edges = [e_id] + elif multipath and edge_cost == min_edge_cost: + selected_edges.append(e_id) + + if min_edge_cost is None: + continue + + new_cost = current_cost + min_edge_cost + if (neighbor_id not in costs) or (new_cost < costs[neighbor_id]): + costs[neighbor_id] = new_cost + pred[neighbor_id] = {node_id: selected_edges} + heappush(min_pq, (new_cost, neighbor_id)) + elif multipath and new_cost == costs[neighbor_id]: + pred[neighbor_id][node_id] = selected_edges + + return costs, pred + + +def _spf_fast_all_min_cost_with_cap_remaining_dijkstra( + graph: StrictMultiDiGraph, + src_node: NodeID, + multipath: bool, +) -> Tuple[Dict[NodeID, Cost], Dict[NodeID, Dict[NodeID, List[EdgeID]]]]: + """ + Specialized Dijkstra's SPF for: + - EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING + - No excluded edges/nodes + + Only considers edges whose (capacity - flow) >= MIN_CAP. Among those edges, + finds all edges with the same minimal cost if multipath=True. + + Args: + graph: Directed graph (StrictMultiDiGraph). + src_node: Source node for SPF. + multipath: Whether to record multiple equal-cost paths. + + Returns: + A tuple of (costs, pred): + - costs: Maps each reachable node to the minimal cost from src_node. + - pred: For each reachable node, a dict of predecessor -> list of edges + from the predecessor to that node. + """ + outgoing_adjacencies = graph._adj + if src_node not in outgoing_adjacencies: + raise KeyError(f"Source node '{src_node}' is not in the graph.") + + costs: Dict[NodeID, Cost] = {src_node: 0.0} + pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {src_node: {}} + min_pq: List[Tuple[Cost, NodeID]] = [(0.0, src_node)] + + while min_pq: + current_cost, node_id = heappop(min_pq) + if current_cost > costs[node_id]: + continue + + # Explore neighbors; skip edges without enough remaining capacity + for neighbor_id, edges_map in outgoing_adjacencies[node_id].items(): + min_edge_cost: Optional[Cost] = None + selected_edges: List[EdgeID] = [] + + for e_id, e_attr in edges_map.items(): + if (e_attr["capacity"] - e_attr["flow"]) >= MIN_CAP: + edge_cost = e_attr["cost"] + if min_edge_cost is None or edge_cost < min_edge_cost: + min_edge_cost = edge_cost + selected_edges = [e_id] + elif multipath and edge_cost == min_edge_cost: + selected_edges.append(e_id) + + if min_edge_cost is None: + continue + + new_cost = current_cost + min_edge_cost + if (neighbor_id not in costs) or (new_cost < costs[neighbor_id]): + costs[neighbor_id] = new_cost + pred[neighbor_id] = {node_id: selected_edges} + heappush(min_pq, (new_cost, neighbor_id)) + elif multipath and new_cost == costs[neighbor_id]: + pred[neighbor_id][node_id] = selected_edges + + return costs, pred + + def spf( graph: StrictMultiDiGraph, src_node: NodeID, - edge_select_func: Callable[ - [ - StrictMultiDiGraph, - NodeID, - NodeID, - Dict[EdgeID, AttrDict], - Set[EdgeID], - Set[NodeID], - ], - Tuple[Cost, List[EdgeID]], - ] = edge_select_fabric(EdgeSelect.ALL_MIN_COST), + edge_select: EdgeSelect = EdgeSelect.ALL_MIN_COST, + edge_select_func: Optional[ + Callable[ + [ + StrictMultiDiGraph, + NodeID, + NodeID, + Dict[EdgeID, AttrDict], + Set[EdgeID], + Set[NodeID], + ], + Tuple[Cost, List[EdgeID]], + ] + ] = None, multipath: bool = True, excluded_edges: Optional[Set[EdgeID]] = None, excluded_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Dict[NodeID, Cost], Dict[NodeID, Dict[NodeID, List[EdgeID]]]]: """ - Compute shortest paths (and their costs) from a source node using Dijkstra's algorithm. + Compute shortest paths (cost-based) from a source node using a Dijkstra-like method. - This function implements a single-source shortest-path (Dijkstra’s) algorithm - that can optionally allow multiple equal-cost paths to the same destination - if ``multipath=True``. It uses a min-priority queue to efficiently retrieve - the next closest node to expand. Excluded edges or excluded nodes can be - supplied to remove them from path consideration. + By default, uses EdgeSelect.ALL_MIN_COST. If multipath=True, multiple equal-cost + paths to the same node will be recorded in the predecessor structure. If no + excluded edges/nodes are given and edge_select is one of the specialized + (ALL_MIN_COST or ALL_MIN_COST_WITH_CAP_REMAINING), it uses a fast specialized + routine. Args: - graph: The directed graph (StrictMultiDiGraph) on which to run SPF. + graph: The directed graph (StrictMultiDiGraph). src_node: The source node from which to compute shortest paths. - edge_select_func: A function that, given the graph, current node, neighbor node, - a dictionary of edges, the set of excluded edges, and the set of excluded nodes, - returns a tuple of (cost, list_of_edges) representing the minimal edge cost - and the edges to use. - Defaults to an edge selection function that finds edges with the minimal cost. - multipath: If True, multiple paths with the same cost to the same node are recorded. - excluded_edges: An optional set of edges (by EdgeID) to exclude from the graph. - excluded_nodes: An optional set of nodes (by NodeID) to exclude from the graph. + edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. + edge_select_func: If provided, overrides the default edge selection function. + Must return (cost, list_of_edges) for the given node->neighbor adjacency. + multipath: Whether to record multiple same-cost paths. + excluded_edges: A set of edge IDs to ignore in the graph. + excluded_nodes: A set of node IDs to ignore in the graph. Returns: - A tuple of: - - costs: A dictionary mapping each reachable node to the cost of the shortest path - from ``src_node`` to that node. - - pred: A dictionary mapping each reachable node to another dictionary. The inner - dictionary maps a predecessor node to the list of edges taken from the predecessor - to the key node. Multiple predecessors may be stored if ``multipath=True``. + A tuple of (costs, pred): + - costs: Maps each reachable node to its minimal cost from src_node. + - pred: For each reachable node, a dict of predecessor -> list of edges + from that predecessor to the node. Multiple predecessors are possible + if multipath=True. Raises: - KeyError: If ``src_node`` is not present in ``graph``. - - Examples: - >>> costs, pred = spf(my_graph, src_node="A") - >>> print(costs) - {"A": 0, "B": 2.5, "C": 3.2} - >>> print(pred) - { - "A": {}, - "B": {"A": [("A", "B")]}, - "C": {"B": [("B", "C")]} - } + KeyError: If src_node does not exist in graph. """ if excluded_edges is None: excluded_edges = set() if excluded_nodes is None: excluded_nodes = set() - # Access adjacency once to avoid repeated lookups. - # _adj is assumed to be a dict of dicts: {node: {neighbor: {edge_id: AttrDict}}} - outgoing_adjacencies = graph._adj + # Use specialized fast code if applicable + if edge_select_func is None: + if not excluded_edges and not excluded_nodes: + if edge_select == EdgeSelect.ALL_MIN_COST: + return _spf_fast_all_min_cost_dijkstra(graph, src_node, multipath) + elif edge_select == EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING: + return _spf_fast_all_min_cost_with_cap_remaining_dijkstra( + graph, src_node, multipath + ) + else: + edge_select_func = edge_select_fabric(edge_select) - # Initialize data structures - costs: Dict[NodeID, Cost] = {src_node: 0} # cost from src_node to itself is 0 - pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = { - src_node: {} - } # no predecessor for src_node + outgoing_adjacencies = graph._adj + if src_node not in outgoing_adjacencies: + raise KeyError(f"Source node '{src_node}' is not in the graph.") - # Min-priority queue of (cost, node). The cost is used as the priority. - min_pq: List[Tuple[Cost, NodeID]] = [] - heappush(min_pq, (0, src_node)) + costs: Dict[NodeID, Cost] = {src_node: 0.0} + pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {src_node: {}} + min_pq: List[Tuple[Cost, NodeID]] = [(0.0, src_node)] while min_pq: current_cost, node_id = heappop(min_pq) - - # Skip if we've already found a better path to node_id if current_cost > costs[node_id]: continue - - # If the node is excluded, skip expanding it if node_id in excluded_nodes: continue - # Explore each neighbor of node_id + # Evaluate each neighbor using the provided edge_select_func for neighbor_id, edges_dict in outgoing_adjacencies[node_id].items(): if neighbor_id in excluded_nodes: continue - # Select best edges to neighbor edge_cost, selected_edges = edge_select_func( graph, node_id, @@ -127,22 +251,15 @@ def spf( excluded_edges, excluded_nodes, ) - if not selected_edges: - # No valid edges to this neighbor (e.g., all excluded) continue new_cost = current_cost + edge_cost - - # Check if this is a strictly better path or an equal-cost path (if multipath=True) - if neighbor_id not in costs or new_cost < costs[neighbor_id]: - # Found a new strictly better path + if (neighbor_id not in costs) or (new_cost < costs[neighbor_id]): costs[neighbor_id] = new_cost pred[neighbor_id] = {node_id: selected_edges} heappush(min_pq, (new_cost, neighbor_id)) - elif multipath and new_cost == costs[neighbor_id]: - # Found an additional path of the same minimal cost pred[neighbor_id][node_id] = selected_edges return costs, pred @@ -152,10 +269,20 @@ def ksp( graph: StrictMultiDiGraph, src_node: NodeID, dst_node: NodeID, - edge_select_func: Callable[ - [StrictMultiDiGraph, NodeID, NodeID, Dict[EdgeID, AttrDict]], - Tuple[Cost, List[EdgeID]], - ] = edge_select_fabric(EdgeSelect.ALL_MIN_COST), + edge_select: EdgeSelect = EdgeSelect.ALL_MIN_COST, + edge_select_func: Optional[ + Callable[ + [ + StrictMultiDiGraph, + NodeID, + NodeID, + Dict[EdgeID, AttrDict], + Set[EdgeID], + Set[NodeID], + ], + Tuple[Cost, List[EdgeID]], + ] + ] = None, max_k: Optional[int] = None, max_path_cost: Optional[Cost] = float("inf"), max_path_cost_factor: Optional[float] = None, @@ -164,100 +291,141 @@ def ksp( excluded_nodes: Optional[Set[NodeID]] = None, ) -> Iterator[Tuple[Dict[NodeID, Cost], Dict[NodeID, Dict[NodeID, List[EdgeID]]]]]: """ - Implementation of the Yen's algorithm for finding k shortest paths in the graph. + Generator of up to k shortest paths from src_node to dst_node using a Yen-like algorithm. + + The initial SPF (shortest path) is computed; subsequent paths are found by systematically + excluding edges/nodes used by previously generated paths. Each iteration yields a + (costs, pred) describing one path. Stops if there are no more valid paths or if max_k + is reached. + + Args: + graph: The directed graph (StrictMultiDiGraph). + src_node: The source node. + dst_node: The destination node. + edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. + edge_select_func: Optional override of the default edge selection function. + max_k: If set, yields at most k distinct paths. + max_path_cost: If set, do not yield any path whose total cost > max_path_cost. + max_path_cost_factor: If set, updates max_path_cost to: + min(max_path_cost, best_path_cost * max_path_cost_factor). + multipath: Whether to consider multiple same-cost expansions in SPF. + excluded_edges: Set of edge IDs to exclude globally. + excluded_nodes: Set of node IDs to exclude globally. + + Yields: + (costs, pred) for each discovered path from src_node to dst_node, in ascending + order of cost. """ + if edge_select_func is None: + edge_select_func = edge_select_fabric(edge_select) + excluded_edges = excluded_edges or set() excluded_nodes = excluded_nodes or set() - shortest_paths = [] # container A - candidates = [] # container B, heap-based - visited = set() - - costs, pred = spf(graph, src_node, edge_select_func, multipath) - if dst_node not in pred: - return - - shortest_path_cost = costs[dst_node] + shortest_paths = [] # Stores paths found so far: (costs, pred, excl_e, excl_n) + candidates: List[ + Tuple[ + Cost, + int, + Dict[NodeID, Cost], + Dict[NodeID, Dict[NodeID, List[EdgeID]]], + Set[EdgeID], + Set[NodeID], + ] + ] = [] + visited = set() # Tracks path signatures to avoid duplicates + + # 1) Compute the initial shortest path + costs_init, pred_init = spf( + graph, + src_node, + edge_select, + edge_select_func, + multipath, + excluded_edges, + excluded_nodes, + ) + if dst_node not in pred_init: + return # No path exists from src_node to dst_node + + best_path_cost = costs_init[dst_node] if max_path_cost_factor: - max_path_cost = min(max_path_cost, shortest_path_cost * max_path_cost_factor) + max_path_cost = min(max_path_cost, best_path_cost * max_path_cost_factor) - if shortest_path_cost > max_path_cost: + if best_path_cost > max_path_cost: return - shortest_paths.append((costs, pred, excluded_edges.copy(), excluded_nodes.copy())) - yield costs, pred + shortest_paths.append( + (costs_init, pred_init, excluded_edges.copy(), excluded_nodes.copy()) + ) + yield costs_init, pred_init candidate_id = 0 + while True: if max_k and len(shortest_paths) >= max_k: break - root_costs, root_pred, excluded_edges, excluded_nodes = shortest_paths[-1] + root_costs, root_pred, root_excl_e, root_excl_n = shortest_paths[-1] + # For each realized path from src->dst in the last SPF for path in resolve_to_paths(src_node, dst_node, root_pred): - # iterate over each concrete path in the last shortest path + # Spur node iteration + for idx, (spur_node, edges_list) in enumerate(path[:-1]): + # The path up to but not including spur_node + root_path = path[:idx] - for idx, spur_tuple in enumerate(path[:-1]): - # iterate over each node in the path, except the last one + # Copy the excluded sets + excl_e = root_excl_e.copy() + excl_n = root_excl_n.copy() - spur_node, edges_list = spur_tuple - root_path = path[:idx] - excluded_edges_tmp = excluded_edges.copy() - excluded_nodes_tmp = excluded_nodes.copy() - - # remove the edges of the spur node that were used in the previous paths - # also remove all the nodes that are on the current root path up to the spur node (loop avoidance) - for ( - path_costs, - path_pred, - path_excluded_edges, - path_excluded_nodes, - ) in shortest_paths: - for p in resolve_to_paths(src_node, dst_node, path_pred): + # Remove edges (and possibly nodes) used in previous shortest paths that + # share the same root_path + for sp_costs, sp_pred, sp_ex_e, sp_ex_n in shortest_paths: + for p in resolve_to_paths(src_node, dst_node, sp_pred): if p[:idx] == root_path: - excluded_edges_tmp.update(path_excluded_edges) - excluded_edges_tmp.update(p[idx][1]) - excluded_nodes_tmp.update(path_excluded_nodes) - excluded_nodes_tmp.update( - node_edges[0] for node_edges in p[:idx] - ) - - # calculate the shortest path from the spur node to the destination + excl_e.update(sp_ex_e) + # Exclude the next edge in that path to force a different route + excl_e.update(p[idx][1]) + excl_n.update(sp_ex_n) + excl_n.update(n_e[0] for n_e in p[:idx]) + spur_costs, spur_pred = spf( graph, spur_node, + edge_select, edge_select_func, multipath, - excluded_edges_tmp, - excluded_nodes_tmp, + excl_e, + excl_n, ) - - if dst_node in spur_pred: - spur_cost = root_costs[spur_node] - for k, v in spur_costs.items(): - spur_costs[k] = v + spur_cost - total_costs = {k: v for k, v in root_costs.items()} - total_costs.update(spur_costs) - - total_pred = {k: v for k, v in root_pred.items()} - for k, v in spur_pred.items(): - if k != spur_node: - total_pred[k] = v - - edge_ids = tuple( - sorted( - [ - edge_id - for _, v1 in total_pred.items() - for _, edge_list in v1.items() - for edge_id in edge_list - ] - ) + if dst_node not in spur_pred: + continue + + # Shift all spur_costs relative to the cost from src->spur_node + spur_base_cost = root_costs[spur_node] + for node_key, node_val in spur_costs.items(): + spur_costs[node_key] = node_val + spur_base_cost + + # Combine root + spur costs and preds + total_costs = dict(root_costs) + total_costs.update(spur_costs) + + total_pred = dict(root_pred) + for node_key, node_pred in spur_pred.items(): + # Replace spur_node's chain, but keep root_path info + if node_key != spur_node: + total_pred[node_key] = node_pred + + path_edge_ids = tuple( + sorted( + edge_id + for nbrs in total_pred.values() + for edge_list_ids in nbrs.values() + for edge_id in edge_list_ids ) - if edge_ids not in visited: - if total_costs[dst_node] > max_path_cost: - continue - - # add the path to the candidates + ) + if path_edge_ids not in visited: + if total_costs[dst_node] <= max_path_cost: heappush( candidates, ( @@ -265,16 +433,17 @@ def ksp( candidate_id, total_costs, total_pred, - excluded_edges_tmp, - excluded_nodes_tmp, + excl_e, + excl_n, ), ) - visited.add(edge_ids) + visited.add(path_edge_ids) candidate_id += 1 if not candidates: break - # select the best candidate - _, _, costs, pred, excluded_edges_tmp, excluded_nodes_tmp = heappop(candidates) - shortest_paths.append((costs, pred, excluded_edges_tmp, excluded_nodes_tmp)) - yield costs, pred + + # Pop the best candidate path from the min-heap + _, _, costs_cand, pred_cand, excl_e_cand, excl_n_cand = heappop(candidates) + shortest_paths.append((costs_cand, pred_cand, excl_e_cand, excl_n_cand)) + yield costs_cand, pred_cand diff --git a/ngraph/lib/graph.py b/ngraph/lib/graph.py index de9705f..8857c3f 100644 --- a/ngraph/lib/graph.py +++ b/ngraph/lib/graph.py @@ -10,12 +10,16 @@ def new_base64_uuid() -> str: """ - Generate a Base64-encoded UUID without padding (22-character string). + Generate a Base64-encoded UUID without padding. + + This function produces a 22-character, URL-safe, Base64-encoded UUID. Returns: str: A unique 22-character Base64-encoded UUID. """ - return base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("ascii").rstrip("=") + # For a 16-byte UUID, the standard Base64 output is always 24 characters, + # followed by '=='. We can safely slice off the trailing '==' getting 22 chars. + return base64.urlsafe_b64encode(uuid.uuid4().bytes)[:-2].decode("ascii") NodeID = Hashable @@ -29,12 +33,14 @@ class StrictMultiDiGraph(nx.MultiDiGraph): A custom multi-directed graph with strict rules and unique edge IDs. This class enforces: - - No automatic creation of missing nodes when adding an edge. - - No duplicate nodes. Attempting to add a node that already exists raises ``ValueError``. - - No duplicate edges. Attempting to add an edge with an existing key raises ``ValueError``. - - Removing non-existent nodes or edges raises ``ValueError``. - - Each edge key must be unique; by default, a Base64-UUID is generated if none is provided. - - ``copy()`` can perform a pickle-based deep copy that may be faster than NetworkX's default. + - No automatic creation of missing nodes when adding an edge. + - No duplicate nodes (raising ValueError on duplicates). + - No duplicate edges by key (raising ValueError on duplicates). + - Attempting to remove non-existent nodes or edges raises ValueError. + - Each edge key must be unique; by default, a Base64-UUID is generated + if none is provided. + - copy() can perform a pickle-based deep copy that may be faster + than NetworkX's default. Inherits from: networkx.MultiDiGraph @@ -45,8 +51,8 @@ def __init__(self, *args, **kwargs) -> None: Initialize a StrictMultiDiGraph. Args: - *args: Positional arguments forwarded to the ``MultiDiGraph`` constructor. - **kwargs: Keyword arguments forwarded to the ``MultiDiGraph`` constructor. + *args: Positional arguments forwarded to the MultiDiGraph constructor. + **kwargs: Keyword arguments forwarded to the MultiDiGraph constructor. Attributes: _edges (Dict[EdgeID, EdgeTuple]): Maps an edge key to a tuple @@ -76,20 +82,17 @@ def copy(self, as_view: bool = False, pickle: bool = True) -> StrictMultiDiGraph """ Create a copy of this graph. - By default, uses pickle-based deep copying. If ``pickle=False``, this - method calls the parent class's ``copy`` which supports views. + By default, uses pickle-based deep copying. If pickle=False, + this method calls the parent class's copy, which supports views. Args: - as_view (bool): If ``True``, returns a view instead of a full copy. - Only used if ``pickle=False``. Defaults to ``False``. - pickle (bool): If ``True``, perform a pickle-based deep copy. - Defaults to ``True``. + as_view (bool): If True, returns a view instead of a full copy; + only used if pickle=False. Defaults to False. + pickle (bool): If True, perform a pickle-based deep copy. + Defaults to True. Returns: StrictMultiDiGraph: A new instance (or view) of the graph. - - Raises: - TypeError: If the parent class copy cannot handle certain arguments. """ if not pickle: return super().copy(as_view=as_view) @@ -104,7 +107,7 @@ def add_node(self, n: NodeID, **attr: Any) -> None: Args: n (NodeID): The node to add. - **attr: Arbitrary keyword attributes to associate with this node. + **attr: Arbitrary attributes for this node. Raises: ValueError: If the node already exists in the graph. @@ -145,26 +148,24 @@ def add_edge( **attr: Any, ) -> EdgeID: """ - Add a directed edge from ``u_for_edge`` to ``v_for_edge``. + Add a directed edge from u_for_edge to v_for_edge. If no key is provided, a unique Base64-UUID is generated. This method - does not create nodes automatically; both ``u_for_edge`` and - ``v_for_edge`` must already exist in the graph. + does not create nodes automatically; both u_for_edge and v_for_edge + must already exist in the graph. Args: u_for_edge (NodeID): The source node. Must exist in the graph. v_for_edge (NodeID): The target node. Must exist in the graph. - key (Optional[EdgeID]): The unique edge key. Defaults to None, - in which case a new key is generated. If provided, - it must not already be in the graph. + key (Optional[EdgeID]): The unique edge key. If None, a new key + is generated. Must not already be in use if provided. **attr: Arbitrary edge attributes. Returns: EdgeID: The key associated with this new edge. Raises: - ValueError: If either node does not exist, or if the key - is already in use. + ValueError: If either node does not exist, or if the key is already in use. """ if u_for_edge not in self: raise ValueError(f"Source node '{u_for_edge}' does not exist.") @@ -193,21 +194,20 @@ def remove_edge( key: Optional[EdgeID] = None, ) -> None: """ - Remove an edge (or edges) between nodes ``u`` and ``v``. + Remove an edge (or edges) between nodes u and v. - If ``key`` is provided, remove only that edge. Otherwise, remove all - edges from ``u`` to ``v``. + If key is provided, remove only that edge. Otherwise, remove all edges + from u to v. Args: u (NodeID): The source node of the edge(s). Must exist in the graph. v (NodeID): The target node of the edge(s). Must exist in the graph. - key (Optional[EdgeID]): If provided, remove the specific edge - with this key. Otherwise, remove all edges from ``u`` to ``v``. + key (Optional[EdgeID]): If provided, remove the edge with this key. + Otherwise, remove all edges from u to v. Raises: - ValueError: If ``u`` or ``v`` is not in the graph, or if the - specified edge key does not exist, or if no edges are found - from ``u`` to ``v``. + ValueError: If the nodes do not exist, or if the specified edge key + does not exist, or if no edges are found from u to v. """ if u not in self: raise ValueError(f"Source node '{u}' does not exist.") @@ -253,10 +253,10 @@ def remove_edge_by_id(self, key: EdgeID) -> None: # def get_nodes(self) -> Dict[NodeID, AttrDict]: """ - Retrieve all nodes and their attributes in a dictionary. + Retrieve all nodes and their attributes as a dictionary. Returns: - Dict[NodeID, AttrDict]: A mapping of node ID to its attribute dictionary. + Dict[NodeID, AttrDict]: A mapping of node ID to its attributes. """ return dict(self.nodes(data=True)) diff --git a/notebooks/lib_examples.ipynb b/notebooks/lib_examples.ipynb index b6013f5..2511c12 100644 --- a/notebooks/lib_examples.ipynb +++ b/notebooks/lib_examples.ipynb @@ -11,7 +11,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -47,7 +47,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -108,7 +108,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -153,18 +153,18 @@ "flow_graph = init_flow_graph(g)\n", "\n", "# Demand from A→C (volume 20).\n", - "demand_ac = Demand(\"A\", \"C\", 20)\n", "flow_policy_ac = get_flow_policy(FlowPolicyConfig.TE_UCMP_UNLIM)\n", - "demand_ac.place(flow_graph, flow_policy_ac)\n", + "demand_ac = Demand(\"A\", \"C\", 20, flow_policy=flow_policy_ac)\n", + "demand_ac.place(flow_graph)\n", "assert demand_ac.placed_demand == 20, (\n", " f\"Demand from {demand_ac.src_node} to {demand_ac.dst_node} \"\n", " f\"expected to be fully placed.\"\n", ")\n", "\n", "# Demand from C→A (volume 20), using a separate FlowPolicy instance.\n", - "demand_ca = Demand(\"C\", \"A\", 20)\n", "flow_policy_ca = get_flow_policy(FlowPolicyConfig.TE_UCMP_UNLIM)\n", - "demand_ca.place(flow_graph, flow_policy_ca)\n", + "demand_ca = Demand(\"C\", \"A\", 20, flow_policy=flow_policy_ca)\n", + "demand_ca.place(flow_graph)\n", "assert demand_ca.placed_demand == 20, (\n", " f\"Demand from {demand_ca.src_node} to {demand_ca.dst_node} \"\n", " f\"expected to be fully placed.\"\n", diff --git a/notebooks/scenario.ipynb b/notebooks/scenario.ipynb deleted file mode 100644 index 6c329b8..0000000 --- a/notebooks/scenario.ipynb +++ /dev/null @@ -1,324 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from ngraph.scenario import Scenario\n", - "from ngraph.traffic_demand import TrafficDemand\n", - "from ngraph.traffic_manager import TrafficManager\n", - "from ngraph.lib.flow_policy import FlowPolicyConfig, FlowPolicy, FlowPlacement\n", - "from ngraph.lib.algorithms.base import PathAlg, EdgeSelect\n", - "from ngraph.failure_manager import FailureManager\n", - "from ngraph.failure_policy import FailurePolicy, FailureRule, FailureCondition" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "scenario_yaml = \"\"\"\n", - "blueprints:\n", - " brick_2tier:\n", - " groups:\n", - " t1:\n", - " node_count: 8\n", - " name_template: t1-{node_num}\n", - " t2:\n", - " node_count: 8\n", - " name_template: t2-{node_num}\n", - "\n", - " adjacency:\n", - " - source: /t1\n", - " target: /t2\n", - " pattern: mesh\n", - " link_params:\n", - " capacity: 2\n", - " cost: 1\n", - "\n", - " 3tier_clos:\n", - " groups:\n", - " b1:\n", - " use_blueprint: brick_2tier\n", - " b2:\n", - " use_blueprint: brick_2tier\n", - " spine:\n", - " node_count: 64\n", - " name_template: t3-{node_num}\n", - "\n", - " adjacency:\n", - " - source: b1/t2\n", - " target: spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 2\n", - " cost: 1\n", - " - source: b2/t2\n", - " target: spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 2\n", - " cost: 1\n", - "\n", - "network:\n", - " name: \"3tier_clos_network\"\n", - " version: 1.0\n", - "\n", - " groups:\n", - " my_clos1:\n", - " use_blueprint: 3tier_clos\n", - "\n", - " my_clos2:\n", - " use_blueprint: 3tier_clos\n", - "\n", - " adjacency:\n", - " - source: my_clos1/spine\n", - " target: my_clos2/spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 1\n", - " cost: 1\n", - " - source: my_clos1/spine\n", - " target: my_clos2/spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 1\n", - " cost: 1\n", - " - source: my_clos1/spine\n", - " target: my_clos2/spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 1\n", - " cost: 1\n", - " - source: my_clos1/spine\n", - " target: my_clos2/spine\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 1\n", - " cost: 1\n", - "\"\"\"\n", - "scenario = Scenario.from_yaml(scenario_yaml)\n", - "network = scenario.network" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{('b1|b2', 'b1|b2'): 256.0}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "network.max_flow(\n", - " source_path=r\"my_clos1.*(b[0-9]*)/t1\",\n", - " sink_path=r\"my_clos2.*(b[0-9]*)/t1\",\n", - " mode=\"combine\",\n", - " shortest_path=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "237.94000000003672" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "d = TrafficDemand(\n", - " source_path=r\"my_clos1.*(b[0-9]*)/t1\",\n", - " sink_path=r\"my_clos2.*(b[0-9])/t1\",\n", - " demand=256,\n", - " mode=\"full_mesh\",\n", - " flow_policy_config=FlowPolicyConfig.SHORTEST_PATHS_ECMP,\n", - ")\n", - "demands = [d]\n", - "tm = TrafficManager(\n", - " network=network,\n", - " traffic_demands=demands,\n", - ")\n", - "tm.build_graph()\n", - "tm.expand_demands()\n", - "tm.place_all_demands(placement_rounds=50)" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Overall Statistics:\n", - " mean: 203.43\n", - " stdev: 21.25\n", - " min: 179.14\n", - " max: 251.71\n" - ] - } - ], - "source": [ - "my_rules = [\n", - " FailureRule(\n", - " conditions=[FailureCondition(attr=\"type\", operator=\"==\", value=\"link\")],\n", - " logic=\"and\",\n", - " rule_type=\"choice\",\n", - " count=2,\n", - " ),\n", - "]\n", - "fpolicy = FailurePolicy(rules=my_rules)\n", - "\n", - "# Run Monte Carlo\n", - "fmgr = FailureManager(network, demands, failure_policy=fpolicy)\n", - "results = fmgr.run_monte_carlo_failures(iterations=30, parallelism=10)\n", - "overall = results[\"overall_stats\"]\n", - "print(\"Overall Statistics:\")\n", - "for k, v in overall.items():\n", - " print(f\" {k}: {v:.2f}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/var/folders/xh/83kdwyfd0fv66b04mchbfzcc0000gn/T/ipykernel_93610/4192461833.py:60: UserWarning: No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n", - " plt.legend(title=\"Priority\")\n" - ] - }, - { - "data": { - "image/png": "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iIgwOE3Tpk2xevVqHD9+HE2aNMHNmzexa9cu9O7dO8vlpKamIjU1Vfc6ISEBAKDRaKDRaAppbXInc3nFvVy5er0OGo0GGoUwUm+Mj9uHFOshxXpIsR5SrMdLcqxFbvtitFD26NEjZGRkwMnJSdLu5OSEK1euGJymR48eePToEd577z0IIZCeno4hQ4Zke/hyxowZCAkJ0Wvfu3cvLCwsCrYS+RQWFmaU5crdnj17oVYauxfGx+1DivWQYj2kWA8p1uMlOdUiOTk5V+MZLZTlx8GDBzF9+nQsXLgQXl5euH79OkaMGIGpU6fi66+/NjhNcHAwgoKCdK8TEhLg5uYGPz8/WFtbF1fXAbxIymFhYWjdujVUKlWxLluONBoNdux++aHx9/eDhekbtUkWKm4fUqyHFOshxXpIsR4vybEWmUfpcmK0X0AHBwcolUrExsZK2mNjY+Hs7Gxwmq+//hq9e/fGgAEDAAB169ZFUlISBg0ahP/+978wMdE/RU6tVkOtVuu1q1Qqo71Zxly2nL2oS8kNZZm4fUixHlKshxTrIcV6vCSnWuS2H0Y70d/U1BSenp4IDw/XtWm1WoSHh8Pb29vgNMnJyXrBS6l8cbxLiJJ7LhIRERG9+Yy6WyIoKAh9+vRBo0aN0KRJE8ydOxdJSUno168fACAwMBDlypXDjBkzAAABAQEIDQ1Fw4YNdYcvv/76awQEBOjCGREREdGbyKihrGvXrnj48CEmTpyImJgYNGjQALt379ad/H/nzh3JnrEJEyZAoVBgwoQJuHfvHhwdHREQEIBp06YZaxWIiIiICoXRT+AZPnw4hg8fbnDYwYMHJa9LlSqFSZMmYdKkScXQMyIiIqLiY/THLBERERERQxkRERGRLDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDBg9lC1YsAAeHh4wMzODl5cXjh8/nu34T58+xbBhw+Di4gK1Wo1q1aph165dxdRbIiIioqJRypgL37BhA4KCgrB48WJ4eXlh7ty58Pf3x9WrV1G2bFm98dPS0tC6dWuULVsWv//+O8qVK4fbt2/D1ta2+DtPREREVIiMGspCQ0MxcOBA9OvXDwCwePFi7Ny5E8uWLcO4ceP0xl+2bBni4uJw9OhRqFQqAICHh0dxdpmKUHJaRq7GM1cpoVAoirg3RERExctooSwtLQ2nTp1CcHCwrs3ExAS+vr6IiIgwOM327dvh7e2NYcOGYdu2bXB0dESPHj3w1VdfQalUGpwmNTUVqamputcJCQkAAI1GA41GU4hrlLPM5RX3cuXq9To0+mZfrqbzrGCLdQMav3XBjNuHFOshxXpIsR5SrMdLcqxFbvtitFD26NEjZGRkwMnJSdLu5OSEK1euGJzm5s2b2L9/P3r27Ildu3bh+vXrGDp0KDQaDSZNmmRwmhkzZiAkJESvfe/evbCwsCj4iuRDWFiYUZYrR6YmQMXSAlGJuQ9Yp+48xdYdf0JtOIe/8bh9SLEeUqyHFOshxXq8JKdaJCcn52o8ox6+zCutVouyZctiyZIlUCqV8PT0xL179/Ddd99lGcqCg4MRFBSke52QkAA3Nzf4+fnB2tq6uLoO4EVSDgsLQ+vWrXWHX0uyzHrsGPk+0nNxzUlKWgbenXUIAODv7wcL0zdq880Rtw8p1kOK9ZBiPaRYj5fkWIvMo3Q5MdqvmoODA5RKJWJjYyXtsbGxcHZ2NjiNi4sLVCqV5FBlzZo1ERMTg7S0NJiamupNo1aroVar9dpVKpXR3ixjLluOTE1NYZmLeqhU6a/8WwWV6u0KZZm4fUixHlKshxTrIcV6vCSnWuS2H0a7JYapqSk8PT0RHh6ua9NqtQgPD4e3t7fBaZo1a4br169Dq9Xq2v7991+4uLgYDGREREREbwqj3qcsKCgIP//8M1auXInLly/j888/R1JSku5qzMDAQMmFAJ9//jni4uIwYsQI/Pvvv9i5cyemT5+OYcOGGWsViIiIiAqFUY//dO3aFQ8fPsTEiRMRExODBg0aYPfu3bqT/+/cuQMTk5e50c3NDXv27MGXX36JevXqoVy5chgxYgS++uorY60CERERUaEw+kk5w4cPx/Dhww0OO3jwoF6bt7c3/vnnnyLuFREREVHxMvpjloiIiIiIoYyIiIhIFhjKiIiIiGSAoYyIiIhIBhjKiIiIiGQg31dfajQaxMTEIDk5GY6OjrC3ty/MfhERERGVKHnaU5aYmIhFixbBx8cH1tbW8PDwQM2aNeHo6Ah3d3cMHDgQJ06cKKq+EhEREb21ch3KQkND4eHhgeXLl8PX1xdbt25FZGQk/v33X0RERGDSpElIT0+Hn58f2rRpg2vXrhVlv4mIiIjeKrk+fHnixAkcPnwYtWvXNji8SZMm+Oyzz7B48WIsX74cf/31F6pWrVpoHSUiIiJ6m+U6lK1bty5X46nVagwZMiTfHSIiIiIqifJ19eXDhw+zHHb+/Pl8d4aIiIiopMpXKKtbty527typ1z579mw0adKkwJ0iIiIiKmnyFcqCgoLwySef4PPPP0dKSgru3buHDz74AN9++y3Wrl1b2H0kIiIieuvlK5SNHTsWERER+Ouvv1CvXj3Uq1cParUa586dw0cffVTYfSQiIiJ66+X7jv5VqlRBnTp1cOvWLSQkJKBr165wdnYuzL4RERERlRj5CmVHjhxBvXr1cO3aNZw7dw6LFi3CF198ga5du+LJkyeF3UciIiKit16+QlmrVq3QtWtX/PPPP6hZsyYGDBiAM2fO4M6dO6hbt25h95GIiIjorZevZ1/u3bsXPj4+krbKlSvjyJEjmDZtWqF0jIiIiKgkydeestcDmW5mJib4+uuvC9QhIiIiopIo3yf6ExEREVHhYSgjIiIikgGGMiIiIiIZYCgjIiIikgGGMiIiIiIZKFAo69SpE7744gvd6xs3bsDV1bXAnSIiIiIqafIdyuLj47Fr1y5s3LhR15aeno7Y2NhC6RgRERFRSZLvULZ37144OzsjOTkZJ06cKMw+EREREZU4+Q5lu3btQvv27dGqVSvs2rWrMPtEREREVOLkO5Tt2bMHHTp0QLt27RjKiIiIiAooX6Hs1KlTiI+PxwcffIC2bdvi9OnTePToUWH3jYiIiKjEyFco27VrF1q2bAkzMzO4ubmhRo0a2L17d2H3jYiIiKjEyHcoa9++ve51u3btsHPnzkLrFBEREVFJk+dQlpKSAqVSiQ4dOujaPv74Y8THx8Pc3BxNmjQp1A4SERERlQSl8jqBubk5/v77b0mbl5eX7mT/iIiIwukZERERUQnCxywRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyUCeQtnSpUuzHZ6YmIgBAwYUqENEREREJVGeQllQUBA6dOiAmJgYvWF79uxB7dq1ceLEiULrHBEREVFJkadQdvbsWSQlJaF27dpYt24dgBd7x/r374+AgAD06tULJ0+eLJKOEhEREb3N8nTzWA8PDxw4cABz587FwIEDsWbNGpw/fx5WVlY4cuQIGjduXFT9JCIiInqr5fmO/gAwePBgHD58GFu3boWlpSV27NiBunXrFnbfiIiIiEqMPF99eeTIEdSvXx9XrlzB7t270bZtW3h7e2PevHlF0T8iIiKiEiFPoWzUqFFo1aoVAgICcPr0afj5+eG3337D0qVL8c0336Bly5aIiooqqr4SERERvbXyFMq2bduGffv2Yc6cOTAzM9O1d+3aFRcuXICNjQ3q1atX6J0kIiIietvl6Zyyc+fOwcLCwuAwJycnbNu2DatWrSqUjhERERGVJHnaU5ZVIHtV7969890ZIiIiopIq16Fs5syZSE5OztW4x44dw86dO/PdKSIiIqKSJteh7NKlS3B3d8fQoUPx559/4uHDh7ph6enpOHfuHBYuXIimTZuia9euKF26dJF0mIiIiOhtlOtzyn799VecPXsWP/74I3r06IGEhAQolUqo1WrdHrSGDRtiwIAB6Nu3r+RCACIiIiLKXp5O9K9fvz5+/vln/PTTTzh79izu3LmDlJQUODg4oEGDBnBwcCiqfhIRERG91fJ1R38TExM0bNgQDRs2LOz+EBEREZVIebr6MiMjA7NmzUKzZs3QuHFjjBs3DikpKUXVNyIiIqISI0+hbPr06Rg/fjysrKxQrlw5zJs3D8OGDSuqvhERERGVGHkKZb/++isWLlyIPXv2YOvWrfjjjz+wZs0aaLXaouofERERUYmQp1B2584dtGvXTvfa19cXCoUC9+/fL/SOEREREZUkeQpl6enpere6UKlU0Gg0hdopIiIiopImT1dfCiHQt29fqNVqXdvz588xZMgQWFpa6to2b95ceD0kIiIiKgHyFMr69Omj19arV69C6wwRERFRSZWnULZ8+fKi6gcRERFRiZanc8qIiIiIqGgwlBERERHJAEMZERERkQwwlBERERHJgCxC2YIFC+Dh4QEzMzN4eXnh+PHjuZpu/fr1UCgU6NSpU9F2kIiIiKiIGT2UbdiwAUFBQZg0aRJOnz6N+vXrw9/fHw8ePMh2ulu3bmH06NFo3rx5MfWUiIiIqOgYPZSFhoZi4MCB6NevH2rVqoXFixfDwsICy5Yty3KajIwM9OzZEyEhIahUqVIx9paIiIioaOTpPmWFLS0tDadOnUJwcLCuzcTEBL6+voiIiMhyuilTpqBs2bLo378//vrrr2yXkZqaitTUVN3rhIQEAIBGoyn2x0NlLo+PpXohr/XQaNIl02oUokj6ZSzcPqRYDynWQ4r1kGI9XpJjLXLbF6OGskePHiEjIwNOTk6SdicnJ1y5csXgNH///TeWLl2KyMjIXC1jxowZCAkJ0Wvfu3cvLCws8tznwhAWFmaU5cpVbuuRmgFkbrJ79uyFWll0fTImbh9SrIcU6yHFekixHi/JqRbJycm5Gs+ooSyvEhMT0bt3b/z8889wcHDI1TTBwcEICgrSvU5ISICbmxv8/PxgbW1dVF01SKPRICwsDK1bt4ZKpSrWZctRXuuRnJaOscf3AwD8/f1gYfpGbb454vYhxXpIsR5SrIcU6/GSHGuReZQuJ0b9VXNwcIBSqURsbKykPTY2Fs7Oznrj37hxA7du3UJAQICuTavVAgBKlSqFq1evonLlypJp1Gq15AHqmVQqldHeLGMuW45yWw+VULw2zdsVyjJx+5BiPaRYDynWQ4r1eElOtchtP4x6or+pqSk8PT0RHh6ua9NqtQgPD4e3t7fe+DVq1MD58+cRGRmp+/vwww/x/vvvIzIyEm5ubsXZfSIiIqJCY/RdDUFBQejTpw8aNWqEJk2aYO7cuUhKSkK/fv0AAIGBgShXrhxmzJgBMzMz1KlTRzK9ra0tAOi1ExEREb1JjB7KunbtiocPH2LixImIiYlBgwYNsHv3bt3J/3fu3IGJidHv3EFERERUpIweygBg+PDhGD58uMFhBw8ezHbaFStWFH6HiIiIiIoZd0ERERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyUApY3eAKD+S0zKM3YVCp9GkIzUDSE5Lh0oojN0dg8xVSigU8uwbEdGbjqGM3kiNvtln7C4UkVIYe3y/sTuRpUbudtg4xJvBjIioCPDwJb0xzFVKNHK3M3Y3SrSTt58gRfP27aUkIpID7imjN4ZCocDGId5vbSjQaDTYs2cv/P39oFKpjN0dieS0jLd47yQRkTwwlNEbRaFQwML07dxsNQoBtRKwMC0FlertXEciIsoaD18SERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEM8A6VRJQnxfUw+DfhAe3FqbjrwYfPExU/hjIiypPifdySvB/QXvyKrx58+DxR8ePhSyLKER8GX/Lw4fNExY97yogoR8Z4GLycH9BuDMVVDz58nsh4GMqIKFeK+2HwfEC7FOtB9Pbj4UsiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGWAoIyIiIpIBhjIiIiIiGZBFKFuwYAE8PDxgZmYGLy8vHD9+PMtxf/75ZzRv3hx2dnaws7ODr69vtuMTERERvQmMHso2bNiAoKAgTJo0CadPn0b9+vXh7++PBw8eGBz/4MGD6N69Ow4cOICIiAi4ubnBz88P9+7dK+aeExERERWeUsbuQGhoKAYOHIh+/foBABYvXoyd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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import pandas as pd\n", - "import seaborn as sns\n", - "import matplotlib.pyplot as plt\n", - "from collections import defaultdict\n", - "\n", - "\n", - "def plot_priority_cdf(results, complementary: bool = True):\n", - " \"\"\"\n", - " Plots an empirical (complementary) CDF of placed volume for each priority.\n", - "\n", - " Args:\n", - " results: The dictionary returned by run_monte_carlo_failures, containing:\n", - " {\n", - " \"overall_stats\": {...},\n", - " \"by_src_dst\": {\n", - " (src, dst, priority): [\n", - " {\"iteration\": int, \"total_volume\": float, \"placed_volume\": float, ...},\n", - " ...\n", - " ],\n", - " ...\n", - " }\n", - " }\n", - " complementary: If True, plots a complementary CDF (P(X >= x)).\n", - " If False, plots a standard CDF (P(X <= x)).\n", - " \"\"\"\n", - " by_src_dst = results[\"by_src_dst\"] # {(src, dst, priority): [...]}\n", - "\n", - " # 1) Aggregate total placed volume for each iteration & priority\n", - " # (similar logic as before, but we'll directly store iteration-level sums).\n", - " volume_per_iter_priority = defaultdict(float)\n", - " for (src, dst, priority), data_list in by_src_dst.items():\n", - " for entry in data_list:\n", - " it = entry[\"iteration\"]\n", - " volume_per_iter_priority[(it, priority)] += entry[\"placed_volume\"]\n", - "\n", - " # 2) Convert to a tidy DataFrame with columns: [iteration, priority, placed_volume]\n", - " rows = []\n", - " for (it, prio), vol_sum in volume_per_iter_priority.items():\n", - " rows.append({\"iteration\": it, \"priority\": prio, \"placed_volume\": vol_sum})\n", - "\n", - " plot_df = pd.DataFrame(rows)\n", - "\n", - " # 3) Use seaborn's ECDF plot (which can do either standard or complementary CDF)\n", - " plt.figure(figsize=(7, 5))\n", - " sns.ecdfplot(\n", - " data=plot_df,\n", - " x=\"placed_volume\",\n", - " hue=\"priority\",\n", - " complementary=complementary, # True -> CCDF, False -> normal CDF\n", - " )\n", - " if complementary:\n", - " plt.ylabel(\"P(X ≥ x)\")\n", - " plt.title(\"Per-Priority Complementary CDF of Placed Volume\")\n", - " else:\n", - " plt.ylabel(\"P(X ≤ x)\")\n", - " plt.title(\"Per-Priority CDF of Placed Volume\")\n", - "\n", - " plt.xlabel(\"Total Placed Volume (per iteration)\")\n", - " plt.grid(True)\n", - " plt.legend(title=\"Priority\")\n", - " plt.show()\n", - "\n", - "\n", - "plot_priority_cdf(results, complementary=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "ngraph-venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.1" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/scenario_2.ipynb b/notebooks/scenario_2.ipynb deleted file mode 100644 index b30d181..0000000 --- a/notebooks/scenario_2.ipynb +++ /dev/null @@ -1,266 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from ngraph.scenario import Scenario\n", - "from ngraph.traffic_demand import TrafficDemand\n", - "from ngraph.traffic_manager import TrafficManager\n", - "from ngraph.lib.flow_policy import FlowPolicyConfig, FlowPolicy, FlowPlacement\n", - "from ngraph.lib.algorithms.base import PathAlg, EdgeSelect\n", - "from ngraph.failure_manager import FailureManager\n", - "from ngraph.failure_policy import FailurePolicy, FailureRule, FailureCondition" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "scenario_yaml = \"\"\"\n", - "blueprints:\n", - " server_pod:\n", - " rsw:\n", - " node_count: 48\n", - " \n", - " f16_2tier:\n", - " groups:\n", - " ssw:\n", - " node_count: 36\n", - " fsw:\n", - " node_count: 36\n", - "\n", - " adjacency:\n", - " - source: /ssw\n", - " target: /fsw\n", - " pattern: mesh\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1\n", - " \n", - " hgrid_2tier:\n", - " groups:\n", - " fauu:\n", - " node_count: 8\n", - " fadu:\n", - " node_count: 36\n", - "\n", - " adjacency:\n", - " - source: /fauu\n", - " target: /fadu\n", - " pattern: mesh\n", - " link_params:\n", - " capacity: 400\n", - " cost: 1\n", - "\n", - " fa:\n", - " groups:\n", - " fa1:\n", - " use_blueprint: hgrid_2tier\n", - " fa2:\n", - " use_blueprint: hgrid_2tier\n", - " fa3:\n", - " use_blueprint: hgrid_2tier\n", - " fa4:\n", - " use_blueprint: hgrid_2tier\n", - " fa5:\n", - " use_blueprint: hgrid_2tier\n", - " fa6:\n", - " use_blueprint: hgrid_2tier\n", - " fa7:\n", - " use_blueprint: hgrid_2tier\n", - " fa8:\n", - " use_blueprint: hgrid_2tier\n", - " \n", - " dc_fabric:\n", - " groups:\n", - " plane1:\n", - " use_blueprint: f16_2tier\n", - " plane2:\n", - " use_blueprint: f16_2tier\n", - " plane3:\n", - " use_blueprint: f16_2tier\n", - " plane4:\n", - " use_blueprint: f16_2tier\n", - " plane5:\n", - " use_blueprint: f16_2tier\n", - " plane6:\n", - " use_blueprint: f16_2tier\n", - " plane7:\n", - " use_blueprint: f16_2tier\n", - " plane8:\n", - " use_blueprint: f16_2tier\n", - "\n", - " pod1:\n", - " use_blueprint: server_pod\n", - " pod36:\n", - " use_blueprint: server_pod\n", - " \n", - " adjacency:\n", - " - source: /pod1/rsw\n", - " target: /plane[0-9]*/fsw/fsw-1\n", - " pattern: mesh\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1\n", - " - source: /pod36/rsw\n", - " target: /plane[0-9]*/fsw/fsw-36\n", - " pattern: mesh\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1\n", - " \n", - "network:\n", - " name: \"fb_region\"\n", - " version: 1.0\n", - "\n", - " groups:\n", - " dc1:\n", - " use_blueprint: dc_fabric\n", - " dc2:\n", - " use_blueprint: dc_fabric\n", - " dc3:\n", - " use_blueprint: dc_fabric\n", - " dc4:\n", - " use_blueprint: dc_fabric\n", - " dc5:\n", - " use_blueprint: dc_fabric\n", - " dc6:\n", - " use_blueprint: dc_fabric\n", - "\n", - " fa:\n", - " use_blueprint: fa\n", - "\n", - " adjacency:\n", - " - source: .*/ssw/.*\n", - " target: .*/fa1/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa2/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa3/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1\n", - " - source: .*/ssw/.*\n", - " target: .*/fa4/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa5/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa6/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa7/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - " - source: .*/ssw/.*\n", - " target: .*/fa8/fadu/.*\n", - " pattern: one_to_one\n", - " link_params:\n", - " capacity: 200\n", - " cost: 1 \n", - "\"\"\"\n", - "scenario = Scenario.from_yaml(scenario_yaml)\n", - "network = scenario.network" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "13824" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "len(network.find_links(\".*/fadu/.*\", \".*/ssw/.*\", any_direction=True))" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{('.*/fsw.*', '.*/fauu.*'): 2304.0}" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "network.max_flow(\n", - " source_path=\".*/fsw.*\",\n", - " sink_path=\".*/fauu.*\",\n", - " mode=\"combine\",\n", - " shortest_path=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "ngraph-venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.1" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/scenario_dc.ipynb b/notebooks/scenario_dc.ipynb new file mode 100644 index 0000000..cdc7789 --- /dev/null +++ b/notebooks/scenario_dc.ipynb @@ -0,0 +1,828 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from ngraph.scenario import Scenario\n", + "from ngraph.traffic_demand import TrafficDemand\n", + "from ngraph.traffic_manager import TrafficManager\n", + "from ngraph.lib.flow_policy import FlowPolicyConfig, FlowPolicy, FlowPlacement\n", + "from ngraph.lib.algorithms.base import PathAlg, EdgeSelect\n", + "from ngraph.failure_manager import FailureManager\n", + "from ngraph.failure_policy import FailurePolicy, FailureRule, FailureCondition" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "scenario_yaml = \"\"\"\n", + "blueprints:\n", + " server_pod:\n", + " rsw:\n", + " node_count: 48\n", + " \n", + " f16_2tier:\n", + " groups:\n", + " ssw:\n", + " node_count: 36\n", + " fsw:\n", + " node_count: 96\n", + "\n", + " adjacency:\n", + " - source: /ssw\n", + " target: /fsw\n", + " pattern: mesh\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " \n", + " hgrid_2tier:\n", + " groups:\n", + " fauu:\n", + " node_count: 8\n", + " fadu:\n", + " node_count: 36\n", + "\n", + " adjacency:\n", + " - source: /fauu\n", + " target: /fadu\n", + " pattern: mesh\n", + " link_params:\n", + " capacity: 400\n", + " cost: 1\n", + "\n", + " fa:\n", + " groups:\n", + " fa1:\n", + " use_blueprint: hgrid_2tier\n", + " fa2:\n", + " use_blueprint: hgrid_2tier\n", + " fa3:\n", + " use_blueprint: hgrid_2tier\n", + " fa4:\n", + " use_blueprint: hgrid_2tier\n", + " fa5:\n", + " use_blueprint: hgrid_2tier\n", + " fa6:\n", + " use_blueprint: hgrid_2tier\n", + " fa7:\n", + " use_blueprint: hgrid_2tier\n", + " fa8:\n", + " use_blueprint: hgrid_2tier\n", + " fa9:\n", + " use_blueprint: hgrid_2tier\n", + " fa10:\n", + " use_blueprint: hgrid_2tier\n", + " fa11:\n", + " use_blueprint: hgrid_2tier\n", + " fa12:\n", + " use_blueprint: hgrid_2tier\n", + " fa13:\n", + " use_blueprint: hgrid_2tier\n", + " fa14:\n", + " use_blueprint: hgrid_2tier\n", + " fa15:\n", + " use_blueprint: hgrid_2tier\n", + " fa16:\n", + " use_blueprint: hgrid_2tier\n", + " \n", + " dc_fabric:\n", + " groups:\n", + " plane1:\n", + " use_blueprint: f16_2tier\n", + " plane2:\n", + " use_blueprint: f16_2tier\n", + " plane3:\n", + " use_blueprint: f16_2tier\n", + " plane4:\n", + " use_blueprint: f16_2tier\n", + " plane5:\n", + " use_blueprint: f16_2tier\n", + " plane6:\n", + " use_blueprint: f16_2tier\n", + " plane7:\n", + " use_blueprint: f16_2tier\n", + " plane8:\n", + " use_blueprint: f16_2tier\n", + "\n", + " pod1:\n", + " use_blueprint: server_pod\n", + " pod36:\n", + " use_blueprint: server_pod\n", + " \n", + " adjacency:\n", + " - source: /pod1/rsw\n", + " target: /plane[0-9]*/fsw/fsw-1\n", + " pattern: mesh\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " - source: /pod36/rsw\n", + " target: /plane[0-9]*/fsw/fsw-36\n", + " pattern: mesh\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + "\n", + " ebb:\n", + " groups:\n", + " eb01:\n", + " node_count: 4\n", + " eb02:\n", + " node_count: 4\n", + " eb03:\n", + " node_count: 4\n", + " eb04:\n", + " node_count: 4\n", + " eb05:\n", + " node_count: 4\n", + " eb06:\n", + " node_count: 4\n", + " eb07:\n", + " node_count: 4\n", + " eb08:\n", + " node_count: 4 \n", + "\n", + " adjacency:\n", + " - source: eb01\n", + " target: eb01\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb02\n", + " target: eb02\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb03\n", + " target: eb03\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb04\n", + " target: eb04\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb05\n", + " target: eb05\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb06\n", + " target: eb06\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb07\n", + " target: eb07\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " - source: eb08\n", + " target: eb08\n", + " pattern: mesh\n", + " link_params: { capacity: 3200, cost: 10 }\n", + " \n", + "network:\n", + " name: \"fb_region\"\n", + " version: 1.0\n", + "\n", + " groups:\n", + " dc1:\n", + " use_blueprint: dc_fabric\n", + " dc2:\n", + " use_blueprint: dc_fabric\n", + " dc3:\n", + " use_blueprint: dc_fabric\n", + " dc5:\n", + " use_blueprint: dc_fabric\n", + " dc6:\n", + " use_blueprint: dc_fabric\n", + "\n", + " fa:\n", + " use_blueprint: fa\n", + "\n", + " ebb:\n", + " use_blueprint: ebb\n", + "\n", + " adjacency:\n", + " - source: .*/ssw/.*\n", + " target: .*/fa1/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa2/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa3/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " - source: .*/ssw/.*\n", + " target: .*/fa4/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa5/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa6/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa7/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa8/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " - source: .*/ssw/.*\n", + " target: .*/fa9/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa10/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa11/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " - source: .*/ssw/.*\n", + " target: .*/fa12/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa13/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa14/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa15/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1 \n", + " - source: .*/ssw/.*\n", + " target: .*/fa16/fadu/.*\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + " - source: .*/fauu-[15]\n", + " target: .*/eb0[1-8]-1\n", + " pattern: mesh\n", + " link_count: 2\n", + " link_params:\n", + " capacity: 400\n", + " cost: 1\n", + " - source: .*/fauu-[26]\n", + " target: .*/eb0[1-8]-2\n", + " pattern: mesh\n", + " link_count: 2\n", + " link_params:\n", + " capacity: 400\n", + " cost: 1 \n", + " - source: .*/fauu-[37]\n", + " target: .*/eb0[1-8]-3\n", + " pattern: mesh\n", + " link_count: 2\n", + " link_params:\n", + " capacity: 400\n", + " cost: 1 \n", + " - source: .*/fauu-[48]\n", + " target: .*/eb0[1-8]-4\n", + " pattern: mesh\n", + " link_count: 2\n", + " link_params:\n", + " capacity: 400\n", + " cost: 1 \n", + "\"\"\"\n", + "scenario = Scenario.from_yaml(scenario_yaml)\n", + "network = scenario.network" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "6016" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(network.nodes)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "167984" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(network.links)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "64" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(network.find_links(\".*/fauu/.*\", \".*/eb01/.*-1$\", any_direction=True))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Link(source='fa/fa1/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa1/fauu/fauu-1|ebb/eb01/eb01-1|3sFZdEgESQCvfbx96QfJuA'),\n", + " Link(source='fa/fa1/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa1/fauu/fauu-1|ebb/eb01/eb01-1|3JxzPvRuTeCAqu7Q4cgibA'),\n", + " Link(source='fa/fa1/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa1/fauu/fauu-5|ebb/eb01/eb01-1|NZ7fHr2pRx-1J7d5bSub6w'),\n", + " Link(source='fa/fa1/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa1/fauu/fauu-5|ebb/eb01/eb01-1|jnwHFsqpRH-V48FRmO79Jw'),\n", + " Link(source='fa/fa2/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa2/fauu/fauu-1|ebb/eb01/eb01-1|eEVxX_YfS2OCTehS5hgYuA'),\n", + " Link(source='fa/fa2/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa2/fauu/fauu-1|ebb/eb01/eb01-1|8IdHep87QRmENLXjNFXxsQ'),\n", + " Link(source='fa/fa2/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa2/fauu/fauu-5|ebb/eb01/eb01-1|tjLb3bIsQKqrP2Nrh7j4OA'),\n", + " Link(source='fa/fa2/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa2/fauu/fauu-5|ebb/eb01/eb01-1|s3DcdER4SBa3-J3PbHux5A'),\n", + " Link(source='fa/fa3/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa3/fauu/fauu-1|ebb/eb01/eb01-1|LYBH3n-DRL-A2fFSj_NHVg'),\n", + " Link(source='fa/fa3/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa3/fauu/fauu-1|ebb/eb01/eb01-1|34tRumy7Rf-49B1C5rtArQ'),\n", + " Link(source='fa/fa3/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa3/fauu/fauu-5|ebb/eb01/eb01-1|aB7ahwgFReWnuIoBfxkruA'),\n", + " Link(source='fa/fa3/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa3/fauu/fauu-5|ebb/eb01/eb01-1|_SVDn8NKQcGJZydMWB_E2g'),\n", + " Link(source='fa/fa4/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa4/fauu/fauu-1|ebb/eb01/eb01-1|5FaBSNGpSu-V4RWEs-2cwA'),\n", + " Link(source='fa/fa4/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa4/fauu/fauu-1|ebb/eb01/eb01-1|btcKCJPlQEGuMyBSa1pq7g'),\n", + " Link(source='fa/fa4/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa4/fauu/fauu-5|ebb/eb01/eb01-1|RjSP6OsWTm-mnwxqgzmBgg'),\n", + " Link(source='fa/fa4/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa4/fauu/fauu-5|ebb/eb01/eb01-1|0WpNC5L3ROKYpJbHZkc4Jg'),\n", + " Link(source='fa/fa5/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa5/fauu/fauu-1|ebb/eb01/eb01-1|nMT7xc9jTNy0YkRv8CmEyA'),\n", + " Link(source='fa/fa5/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa5/fauu/fauu-1|ebb/eb01/eb01-1|ipajqjjKRb-ZgEPTZK5Ytw'),\n", + " Link(source='fa/fa5/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa5/fauu/fauu-5|ebb/eb01/eb01-1|ZEa5wPFNSNywSpRnNuHO3g'),\n", + " Link(source='fa/fa5/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa5/fauu/fauu-5|ebb/eb01/eb01-1|MLsm1p5NT3uGwPH_7v7j9w'),\n", + " Link(source='fa/fa6/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa6/fauu/fauu-1|ebb/eb01/eb01-1|0Tdcj7YsRfmcTRzZBsquCA'),\n", + " Link(source='fa/fa6/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa6/fauu/fauu-1|ebb/eb01/eb01-1|A8QM3HsxSFC2JEl69vP1jQ'),\n", + " Link(source='fa/fa6/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa6/fauu/fauu-5|ebb/eb01/eb01-1|G5VvsYp7QmaSLEDDlqkpHg'),\n", + " Link(source='fa/fa6/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa6/fauu/fauu-5|ebb/eb01/eb01-1|tjFWHD5UQ3-BbJOBviPTWQ'),\n", + " Link(source='fa/fa7/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa7/fauu/fauu-1|ebb/eb01/eb01-1|TBYcB4j_SPGdO8U8OwzgDQ'),\n", + " Link(source='fa/fa7/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa7/fauu/fauu-1|ebb/eb01/eb01-1|5I2a_93oTvuUQGMRR7f65g'),\n", + " Link(source='fa/fa7/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa7/fauu/fauu-5|ebb/eb01/eb01-1|Mfbpq7DfSOqbmi24SOsUtg'),\n", + " Link(source='fa/fa7/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa7/fauu/fauu-5|ebb/eb01/eb01-1|0OOU1NAjQXKgeChp26vreA'),\n", + " Link(source='fa/fa8/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa8/fauu/fauu-1|ebb/eb01/eb01-1|ZnCeNYFOQ-W0mlq2FdwyPg'),\n", + " Link(source='fa/fa8/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa8/fauu/fauu-1|ebb/eb01/eb01-1|lyDKbj0yRoeEIBXhob1JgA'),\n", + " Link(source='fa/fa8/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa8/fauu/fauu-5|ebb/eb01/eb01-1|qt_ZaZBZTXqyrH9vg3Qx7g'),\n", + " Link(source='fa/fa8/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa8/fauu/fauu-5|ebb/eb01/eb01-1|V4pMQ_feQOWyww0Ytek3Dw'),\n", + " Link(source='fa/fa9/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa9/fauu/fauu-1|ebb/eb01/eb01-1|IZYCdOpbRbeip7CgXek2Iw'),\n", + " Link(source='fa/fa9/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa9/fauu/fauu-1|ebb/eb01/eb01-1|0G3_Hm9iQmmDwiR1UwVL1g'),\n", + " Link(source='fa/fa9/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa9/fauu/fauu-5|ebb/eb01/eb01-1|MrY8MqXdRDiaVo3GWsnGgQ'),\n", + " Link(source='fa/fa9/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa9/fauu/fauu-5|ebb/eb01/eb01-1|0aMsS0oeRmu-2bY5csK5kA'),\n", + " Link(source='fa/fa10/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa10/fauu/fauu-1|ebb/eb01/eb01-1|2lh071h8SFOw7hUVbKu0vQ'),\n", + " Link(source='fa/fa10/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa10/fauu/fauu-1|ebb/eb01/eb01-1|XqjYTBqOSRGKOm2rgRJJpg'),\n", + " Link(source='fa/fa10/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa10/fauu/fauu-5|ebb/eb01/eb01-1|9cDhtlQLTniFAIQe55Zwyg'),\n", + " Link(source='fa/fa10/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa10/fauu/fauu-5|ebb/eb01/eb01-1|zV_QOefgRQ-mcyYfdajR5Q'),\n", + " Link(source='fa/fa11/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa11/fauu/fauu-1|ebb/eb01/eb01-1|dg_oJ3brQBOWTSp228oypw'),\n", + " Link(source='fa/fa11/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa11/fauu/fauu-1|ebb/eb01/eb01-1|xNutN2i0RhSnuzmZvUHJ3g'),\n", + " Link(source='fa/fa11/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa11/fauu/fauu-5|ebb/eb01/eb01-1|b-c0YviiTdSqws7rrWMohg'),\n", + " Link(source='fa/fa11/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa11/fauu/fauu-5|ebb/eb01/eb01-1|08WzWfDNT3y2OwTD0ImPCQ'),\n", + " Link(source='fa/fa12/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa12/fauu/fauu-1|ebb/eb01/eb01-1|-JSm2a1DSKmJRs4pV12MiA'),\n", + " Link(source='fa/fa12/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa12/fauu/fauu-1|ebb/eb01/eb01-1|pYBAOmmPQty6qjYJe0ih_w'),\n", + " Link(source='fa/fa12/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa12/fauu/fauu-5|ebb/eb01/eb01-1|zGNViXJRSVCofdyTC9PREQ'),\n", + " Link(source='fa/fa12/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa12/fauu/fauu-5|ebb/eb01/eb01-1|8NMeBXywRFK_SG5QgTwAUQ'),\n", + " Link(source='fa/fa13/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa13/fauu/fauu-1|ebb/eb01/eb01-1|qOWmvDnSToacZEImJ3dZRw'),\n", + " Link(source='fa/fa13/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa13/fauu/fauu-1|ebb/eb01/eb01-1|x0eXL0FdTw6WzAVfJhaDtA'),\n", + " Link(source='fa/fa13/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa13/fauu/fauu-5|ebb/eb01/eb01-1|-s3s3UlKTKKtoSfNz58RXg'),\n", + " Link(source='fa/fa13/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa13/fauu/fauu-5|ebb/eb01/eb01-1|kQg0piKYTwCDh4ctq-SD2Q'),\n", + " Link(source='fa/fa14/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa14/fauu/fauu-1|ebb/eb01/eb01-1|tcQG7Tr5RLaIYXDKOyUT7A'),\n", + " Link(source='fa/fa14/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa14/fauu/fauu-1|ebb/eb01/eb01-1|LNZDkx32TO2u8hSuSWpOpA'),\n", + " Link(source='fa/fa14/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa14/fauu/fauu-5|ebb/eb01/eb01-1|S9onJLikQNeLozaV2mTzWQ'),\n", + " Link(source='fa/fa14/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa14/fauu/fauu-5|ebb/eb01/eb01-1|R_2psKt5QbSsvkZQicfVbQ'),\n", + " Link(source='fa/fa15/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa15/fauu/fauu-1|ebb/eb01/eb01-1|d63StE8ZRjOhW7ZZZ2SYhg'),\n", + " Link(source='fa/fa15/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa15/fauu/fauu-1|ebb/eb01/eb01-1|JR-XUzkDRq-nflDnl1dNeQ'),\n", + " Link(source='fa/fa15/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa15/fauu/fauu-5|ebb/eb01/eb01-1|5T92ttV_ThmH3p7AkeqDNQ'),\n", + " Link(source='fa/fa15/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa15/fauu/fauu-5|ebb/eb01/eb01-1|HmnUGlF9TB2khZJgo655gQ'),\n", + " Link(source='fa/fa16/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa16/fauu/fauu-1|ebb/eb01/eb01-1|ux5CC0D6Qq-yRjsw3FgkMQ'),\n", + " Link(source='fa/fa16/fauu/fauu-1', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa16/fauu/fauu-1|ebb/eb01/eb01-1|68HvEe8ASf2CU7VdWhnnTg'),\n", + " Link(source='fa/fa16/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa16/fauu/fauu-5|ebb/eb01/eb01-1|yIEvs6SBQIiY532ytWUSmA'),\n", + " Link(source='fa/fa16/fauu/fauu-5', target='ebb/eb01/eb01-1', capacity=400, cost=1, attrs={'type': 'link', 'disabled': False}, id='fa/fa16/fauu/fauu-5|ebb/eb01/eb01-1|m44kZWu3SkaSqu5475t5bQ'),\n", + " Link(source='ebb/eb01/eb01-1', target='ebb/eb01/eb01-2', capacity=3200, cost=10, attrs={'type': 'link', 'disabled': False}, id='ebb/eb01/eb01-1|ebb/eb01/eb01-2|MJfZW6JTRSubtfpacP_cZw'),\n", + " Link(source='ebb/eb01/eb01-1', target='ebb/eb01/eb01-3', capacity=3200, cost=10, attrs={'type': 'link', 'disabled': False}, id='ebb/eb01/eb01-1|ebb/eb01/eb01-3|EZFmY2k4R_SRa1M2E4pYoQ'),\n", + " Link(source='ebb/eb01/eb01-1', target='ebb/eb01/eb01-4', capacity=3200, cost=10, attrs={'type': 'link', 'disabled': False}, id='ebb/eb01/eb01-1|ebb/eb01/eb01-4|vyH96mBPRbCAaB4pBGDLjQ')]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.find_links(\".*\", \".*/eb01/.*-1$\", any_direction=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{('.*/fsw.*', '.*/eb.*'): 819200.0}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.max_flow(\n", + " source_path=\".*/fsw.*\",\n", + " sink_path=\".*/eb.*\",\n", + " mode=\"combine\",\n", + " shortest_path=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 6761619 function calls (6746212 primitive calls) in 2.001 seconds\n", + "\n", + " Ordered by: internal time\n", + "\n", + " ncalls tottime percall cumtime percall filename:lineno(function)\n", + " 339840 0.353 0.000 1.057 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:143(add_edge)\n", + " 339840 0.223 0.000 0.239 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/coreviews.py:81(__getitem__)\n", + " 339840 0.207 0.000 0.289 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/multidigraph.py:417(add_edge)\n", + " 1 0.195 0.195 0.215 0.215 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/spf.py:94(_spf_fast_all_min_cost_with_cap_remaining_dijkstra)\n", + " 1 0.141 0.141 1.128 1.128 /Users/networmix/ws/NetGraph/ngraph/network.py:131(to_strict_multidigraph)\n", + " 1 0.131 0.131 0.152 0.152 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/calc_capacity.py:10(_init_graph_data)\n", + " 1 0.130 0.130 0.180 0.180 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/flow_init.py:6(init_flow_graph)\n", + " 1 0.057 0.057 0.273 0.273 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/calc_capacity.py:294(calc_graph_capacity)\n", + " 339840 0.053 0.000 0.069 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/coreviews.py:103(__getitem__)\n", + " 725446 0.051 0.000 0.051 0.000 {method 'setdefault' of 'dict' objects}\n", + " 1 0.048 0.048 1.908 1.908 /Users/networmix/ws/NetGraph/ngraph/network.py:219(max_flow)\n", + " 685700 0.041 0.000 0.041 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/graph.py:462(__contains__)\n", + " 339840 0.037 0.000 0.105 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/graph.py:498(__getitem__)\n", + " 345859 0.033 0.000 0.033 0.000 {method 'update' of 'dict' objects}\n", + " 345858 0.033 0.000 0.048 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/utils/misc.py:595(_clear_cache)\n", + " 679681 0.030 0.000 0.030 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/coreviews.py:44(__init__)\n", + " 15393/1 0.028 0.000 0.034 0.034 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/calc_capacity.py:178(_push_flow_dfs)\n", + " 1 0.022 0.022 0.305 0.305 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/place_flow.py:29(place_flow_on_graph)\n", + " 279550 0.019 0.000 0.019 0.000 {method 'get' of 'dict' objects}\n", + " 373200 0.018 0.000 0.018 0.000 {method 'items' of 'dict' objects}\n", + " 341568 0.018 0.000 0.018 0.000 {built-in method builtins.abs}\n", + " 339840 0.016 0.000 0.016 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/coreviews.py:53(__getitem__)\n", + " 345867 0.015 0.000 0.015 0.000 {built-in method builtins.getattr}\n", + " 1 0.012 0.012 0.025 0.025 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/decorator.py:229(fun)\n", + " 2 0.011 0.005 0.012 0.006 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/calc_capacity.py:148(_set_levels_bfs)\n", + " 182336 0.011 0.000 0.015 0.000 {built-in method builtins.sum}\n", + " 177734 0.009 0.000 0.009 0.000 {method 'append' of 'list' objects}\n", + " 24128 0.005 0.000 0.005 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/place_flow.py:104()\n", + " 1 0.005 0.005 1.861 1.861 /Users/networmix/ws/NetGraph/ngraph/network.py:291(_compute_flow_single_group)\n", + " 2 0.004 0.002 0.025 0.013 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/socket.py:709(send_multipart)\n", + " 3872 0.004 0.000 0.004 0.000 {built-in method posix.urandom}\n", + " 3872 0.003 0.000 0.004 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/uuid.py:142(__init__)\n", + " 6018 0.003 0.000 0.004 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/digraph.py:439(add_node)\n", + " 41412 0.003 0.000 0.003 0.000 {method 'add' of 'set' objects}\n", + " 6018 0.003 0.000 0.003 0.000 {built-in method _heapq.heappop}\n", + " 6018 0.002 0.000 0.007 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:104(add_node)\n", + " 1 0.002 0.002 0.012 0.012 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:418(flush)\n", + " 1 0.002 0.002 0.702 0.702 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/max_flow.py:8(calc_max_flow)\n", + " 1746 0.002 0.000 0.004 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/ipkernel.py:775(_clean_thread_parent_frames)\n", + " 14 0.002 0.000 0.013 0.001 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/socket.py:632(send)\n", + " 2/1 0.002 0.001 0.006 0.006 {method 'control' of 'select.kqueue' objects}\n", + " 2 0.002 0.001 0.004 0.002 /Users/networmix/ws/NetGraph/ngraph/network.py:188(select_node_groups_by_path)\n", + " 3872 0.002 0.000 0.014 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:11(new_base64_uuid)\n", + " 2 0.001 0.001 0.069 0.034 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:276()\n", + " 12032 0.001 0.000 0.001 0.000 {method 'match' of 're.Pattern' objects}\n", + " 3872 0.001 0.000 0.009 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/uuid.py:710(uuid4)\n", + " 1 0.001 0.001 0.006 0.006 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:871(call_soon_threadsafe)\n", + " 3/2 0.001 0.000 1.985 0.992 {built-in method builtins.exec}\n", + " 3872 0.001 0.000 0.002 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/base64.py:112(urlsafe_b64encode)\n", + " 15393 0.001 0.000 0.001 0.000 {built-in method builtins.min}\n", + " 12078 0.001 0.000 0.001 0.000 {method 'popleft' of 'collections.deque' objects}\n", + " 873 0.001 0.000 0.001 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:1477(enumerate)\n", + " 2 0.001 0.000 0.001 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:254(get_nodes)\n", + " 12075 0.001 0.000 0.001 0.000 {method 'append' of 'collections.deque' objects}\n", + " 6111 0.001 0.000 0.001 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:1110(ident)\n", + " 3872 0.000 0.000 0.001 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/uuid.py:288(bytes)\n", + " 3872 0.000 0.000 0.014 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:64(new_edge_key)\n", + " 6017 0.000 0.000 0.000 0.000 {built-in method _heapq.heappush}\n", + " 3872 0.000 0.000 0.001 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/base64.py:51(b64encode)\n", + "5773/5769 0.000 0.000 0.000 0.000 {built-in method builtins.isinstance}\n", + " 3872 0.000 0.000 0.000 0.000 {method 'translate' of 'bytes' objects}\n", + " 3872 0.000 0.000 0.000 0.000 {method 'count' of 'list' objects}\n", + " 3872 0.000 0.000 0.000 0.000 {method 'to_bytes' of 'int' objects}\n", + " 3872 0.000 0.000 0.000 0.000 {method 'decode' of 'bytes' objects}\n", + " 3872 0.000 0.000 0.000 0.000 {built-in method from_bytes}\n", + " 3872 0.000 0.000 0.000 0.000 {built-in method binascii.b2a_base64}\n", + " 3494 0.000 0.000 0.000 0.000 {method 'keys' of 'dict' objects}\n", + " 3890 0.000 0.000 0.000 0.000 {built-in method builtins.len}\n", + " 3872 0.000 0.000 0.000 0.000 {method 'groups' of 're.Match' objects}\n", + " 1752 0.000 0.000 0.000 0.000 {method 'values' of 'dict' objects}\n", + " 5 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/attrsettr.py:66(_get_attr_opt)\n", + " 2/1 0.000 0.000 1.984 1.984 /var/folders/xh/83kdwyfd0fv66b04mchbfzcc0000gn/T/ipykernel_31684/1424787899.py:1()\n", + " 874 0.000 0.000 0.000 0.000 {method '__exit__' of '_thread.RLock' objects}\n", + " 2 0.000 0.000 0.000 0.000 {built-in method builtins.compile}\n", + " 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}\n", + " 5/2 0.000 0.000 0.069 0.035 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/events.py:87(_run)\n", + " 72 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/enum.py:1585(_get_value)\n", + " 16 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/enum.py:1592(__or__)\n", + " 5 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/attrsettr.py:43(__getattr__)\n", + " 1 0.000 0.000 0.000 0.000 {method 'execute' of 'sqlite3.Connection' objects}\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/socket.py:780(recv_multipart)\n", + " 31 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/enum.py:695(__call__)\n", + " 2 0.000 0.000 0.000 0.000 {method 'recv' of '_socket.socket' objects}\n", + " 3 0.000 0.000 0.151 0.050 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:1953(_run_once)\n", + " 3/2 0.000 0.000 1.989 0.994 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/interactiveshell.py:3543(run_code)\n", + " 5/2 0.000 0.000 0.069 0.035 {method 'run' of '_contextvars.Context' objects}\n", + " 8 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/enum.py:1603(__and__)\n", + " 31 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/enum.py:1152(__new__)\n", + " 2 0.000 0.000 0.063 0.031 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:278(_really_send)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:3120(_bind)\n", + " 2 0.000 0.000 0.069 0.035 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:574(_handle_events)\n", + " 10 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:676(__get__)\n", + " 6 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/typing.py:426(inner)\n", + " 1 0.000 0.000 0.000 0.000 {method 'send' of '_socket.socket' objects}\n", + " 2 0.000 0.000 0.000 0.000 {method '__exit__' of 'sqlite3.Connection' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:225(get)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/history.py:845(writeout_cache)\n", + " 3 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:654(_rebuild_io_state)\n", + " 2 0.000 0.000 0.069 0.034 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:547(_run_callback)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/poll.py:80(poll)\n", + " 1 0.000 0.000 0.006 0.006 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/kernelbase.py:302(poll_control_queue)\n", + " 3 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:847(_call_soon)\n", + " 2 0.000 0.000 0.069 0.034 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:157(_handle_event)\n", + " 5 0.000 0.000 0.000 0.000 :1390(_handle_fromlist)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/platform/asyncio.py:225(add_callback)\n", + " 10 0.000 0.000 0.000 0.000 {built-in method builtins.hasattr}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/digraph.py:334(__init__)\n", + " 3 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:677(_update_handler)\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/queue.py:115(empty)\n", + " 10 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:629(get)\n", + " 1 0.000 0.000 0.215 0.215 /Users/networmix/ws/NetGraph/ngraph/lib/algorithms/spf.py:159(spf)\n", + " 1 0.000 0.000 0.006 0.006 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/futures.py:391(_call_set_state)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/codeop.py:113(__call__)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/decorator.py:199(fix)\n", + " 2/1 0.000 0.000 0.006 0.006 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/selectors.py:540(select)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/typing.py:1665(__subclasscheck__)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/functools.py:1023(__get__)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:209(put_nowait)\n", + " 5 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:767(time)\n", + " 2 0.000 0.000 0.069 0.034 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:615(_handle_recv)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/selector_events.py:129(_read_from_self)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:3631(set)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:186(put)\n", + " 3 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:303(__enter__)\n", + " 10 0.000 0.000 0.000 0.000 {built-in method builtins.next}\n", + " 3 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/events.py:36(__init__)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:718(_validate)\n", + " 2 0.000 0.000 0.013 0.006 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/ioloop.py:742(_run_callback)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:689(set)\n", + " 4 0.000 0.000 0.000 0.000 {method 'extend' of 'list' objects}\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:708(__set__)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:3259(bind)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:3474(validate)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:1527(_notify_observers)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:727(_cross_validate)\n", + " 1 0.000 0.000 0.006 0.006 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/concurrent/futures/_base.py:337(_invoke_callbacks)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2934(apply_defaults)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:108(__init__)\n", + " 3 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/reportviews.py:207(__call__)\n", + " 1 0.000 0.000 0.012 0.012 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/kernelbase.py:324(_flush)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/reportviews.py:333(__iter__)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:256(get_nowait)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:322(_consume_expired)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:49(__init__)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:315(_acquire_restore)\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/selector_events.py:740(_process_events)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:3624(validate_elements)\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/typing.py:1443(__hash__)\n", + " 5 0.000 0.000 0.000 0.000 {built-in method builtins.iter}\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/typing.py:1374(__instancecheck__)\n", + " 2 0.000 0.000 0.000 0.000 {method 'set_result' of '_asyncio.Future' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/graph.py:745(nodes)\n", + " 4 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:533(sending)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:818(call_soon)\n", + " 2 0.000 0.000 0.000 0.000 {built-in method _abc._abc_subclasscheck}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/concurrent.py:182(future_set_result_unless_cancelled)\n", + " 32 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/typing.py:2367(cast)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:428(notify_all)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:1512(_notify_trait)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:1938(_add_callback)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/re/__init__.py:330(_compile)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/history.py:839(_writeout_output_cache)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2881(args)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:1523(notify_change)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:2304(validate)\n", + " 2 0.000 0.000 0.000 0.000 :121(__subclasscheck__)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/re/__init__.py:287(compile)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/history.py:833(_writeout_input_cache)\n", + " 1 0.000 0.000 0.006 0.006 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/history.py:55(only_when_enabled)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:303(helper)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:213(_is_master_process)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:145(__exit__)\n", + " 1 0.000 0.000 0.006 0.006 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/concurrent/futures/_base.py:537(set_result)\n", + " 3 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:306(__exit__)\n", + " 5 0.000 0.000 0.000 0.000 {method 'upper' of 'str' objects}\n", + " 2 0.000 0.000 0.000 0.000 {built-in method builtins.issubclass}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:317(__put_internal)\n", + " 8 0.000 0.000 0.000 0.000 {method '__exit__' of '_thread.lock' objects}\n", + " 4 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/compilerop.py:180(extra_flags)\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/queue.py:267(_qsize)\n", + " 1 0.000 0.000 0.000 0.000 :2(__init__)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/multidigraph.py:302(__init__)\n", + " 2 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:136(__enter__)\n", + " 2 0.000 0.000 0.000 0.000 {built-in method builtins.sorted}\n", + " 10 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2791(kind)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:459(update_flag)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:398(notify)\n", + " 5 0.000 0.000 0.000 0.000 {built-in method time.monotonic}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/multidigraph.py:365(adj)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:312(_release_save)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/graph.py:63(__set__)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:631(clear)\n", + " 1 0.000 0.000 0.069 0.069 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/platform/asyncio.py:200(_handle_events)\n", + " 1 0.000 0.000 0.000 0.000 {method 'values' of 'mappingproxy' objects}\n", + " 4 0.000 0.000 0.000 0.000 {built-in method builtins.max}\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:216(_check_mp_mode)\n", + " 3 0.000 0.000 0.000 0.000 {method 'acquire' of '_thread.lock' objects}\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2904(kwargs)\n", + " 5 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:529(receiving)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/selector_events.py:141(_write_to_self)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/reportviews.py:187(__iter__)\n", + " 4 0.000 0.000 0.000 0.000 {built-in method builtins.hash}\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/traitlets/traitlets.py:3486(validate_elements)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/interactiveshell.py:3495(compare)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:173(qsize)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/reportviews.py:180(__init__)\n", + " 2 0.000 0.000 0.000 0.000 {built-in method _contextvars.copy_context}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/digraph.py:41(__set__)\n", + " 3 0.000 0.000 0.000 0.000 {method 'items' of 'mappingproxy' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/sugar/poll.py:31(register)\n", + " 1 0.000 0.000 0.000 0.000 {built-in method _asyncio.get_running_loop}\n", + " 5 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:2051(get_debug)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/unix_events.py:83(_process_self_data)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:312(_put)\n", + " 2 0.000 0.000 0.000 0.000 {built-in method posix.getpid}\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2779(name)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:685()\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/digraph.py:78(__set__)\n", + " 1 0.000 0.000 0.000 0.000 {method 'done' of '_asyncio.Future' objects}\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/IPython/core/interactiveshell.py:1277(user_global_ns)\n", + " 2 0.000 0.000 0.000 0.000 {method '__enter__' of '_thread.lock' objects}\n", + " 2 0.000 0.000 0.000 0.000 {method 'cancelled' of '_asyncio.Future' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:309(_get)\n", + " 2 0.000 0.000 0.000 0.000 {method 'join' of 'str' objects}\n", + " 4 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:3074(parameters)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:177(empty)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/ipykernel/iostream.py:255(closed)\n", + " 1 0.000 0.000 0.000 0.000 {built-in method _thread.allocate_lock}\n", + " 1 0.000 0.000 0.000 0.000 {method '__enter__' of '_thread.RLock' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/queues.py:59(_set_timeout)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py:318(_is_owned)\n", + " 3 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph/lib/graph.py:263(get_edges)\n", + " 2 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/networkx/classes/reportviews.py:315(__init__)\n", + " 3 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:549(_check_closed)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/inspect.py:2873(__init__)\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/tornado/locks.py:224(clear)\n", + " 1 0.000 0.000 0.000 0.000 /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py:753(is_closed)\n", + " 1 0.000 0.000 0.000 0.000 {method 'release' of '_thread.lock' objects}\n", + " 1 0.000 0.000 0.000 0.000 {method '_is_owned' of '_thread.RLock' objects}\n", + " 1 0.000 0.000 0.000 0.000 /Users/networmix/ws/NetGraph/ngraph-venv/lib/python3.13/site-packages/zmq/eventloop/zmqstream.py:650(_check_closed)\n", + "\n", + "\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Profiling\n", + "import cProfile\n", + "import pstats\n", + "\n", + "profiler = cProfile.Profile()\n", + "profiler.enable()\n", + "\n", + "network.max_flow(\n", + " source_path=\".*/fsw.*\",\n", + " sink_path=\".*/eb.*\",\n", + " mode=\"combine\",\n", + " shortest_path=True,\n", + ")\n", + "\n", + "profiler.disable()\n", + "\n", + "stats = pstats.Stats(profiler)\n", + "stats.sort_stats(pstats.SortKey.TIME).print_stats()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ngraph-venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/small_demo.ipynb b/notebooks/small_demo.ipynb new file mode 100644 index 0000000..35d734c --- /dev/null +++ b/notebooks/small_demo.ipynb @@ -0,0 +1,324 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from ngraph.scenario import Scenario\n", + "from ngraph.traffic_demand import TrafficDemand\n", + "from ngraph.traffic_manager import TrafficManager\n", + "from ngraph.lib.flow_policy import FlowPolicyConfig, FlowPolicy, FlowPlacement\n", + "from ngraph.lib.algorithms.base import PathAlg, EdgeSelect\n", + "from ngraph.failure_manager import FailureManager\n", + "from ngraph.failure_policy import FailurePolicy, FailureRule, FailureCondition" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "scenario_yaml = \"\"\"\n", + "blueprints:\n", + " brick_2tier:\n", + " groups:\n", + " t1:\n", + " node_count: 8\n", + " name_template: t1-{node_num}\n", + " t2:\n", + " node_count: 8\n", + " name_template: t2-{node_num}\n", + "\n", + " adjacency:\n", + " - source: /t1\n", + " target: /t2\n", + " pattern: mesh\n", + " link_params:\n", + " capacity: 2\n", + " cost: 1\n", + "\n", + " 3tier_clos:\n", + " groups:\n", + " b1:\n", + " use_blueprint: brick_2tier\n", + " b2:\n", + " use_blueprint: brick_2tier\n", + " spine:\n", + " node_count: 64\n", + " name_template: t3-{node_num}\n", + "\n", + " adjacency:\n", + " - source: b1/t2\n", + " target: spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 2\n", + " cost: 1\n", + " - source: b2/t2\n", + " target: spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 2\n", + " cost: 1\n", + "\n", + "network:\n", + " name: \"3tier_clos_network\"\n", + " version: 1.0\n", + "\n", + " groups:\n", + " my_clos1:\n", + " use_blueprint: 3tier_clos\n", + "\n", + " my_clos2:\n", + " use_blueprint: 3tier_clos\n", + "\n", + " adjacency:\n", + " - source: my_clos1/spine\n", + " target: my_clos2/spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 1\n", + " cost: 1\n", + " - source: my_clos1/spine\n", + " target: my_clos2/spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 1\n", + " cost: 1\n", + " - source: my_clos1/spine\n", + " target: my_clos2/spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 1\n", + " cost: 1\n", + " - source: my_clos1/spine\n", + " target: my_clos2/spine\n", + " pattern: one_to_one\n", + " link_params:\n", + " capacity: 1\n", + " cost: 1\n", + "\"\"\"\n", + "scenario = Scenario.from_yaml(scenario_yaml)\n", + "network = scenario.network" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{('b1|b2', 'b1|b2'): 256.0}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.max_flow(\n", + " source_path=r\"my_clos1.*(b[0-9]*)/t1\",\n", + " sink_path=r\"my_clos2.*(b[0-9]*)/t1\",\n", + " mode=\"combine\",\n", + " shortest_path=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "256.0" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "d = TrafficDemand(\n", + " source_path=r\"my_clos1.*(b[0-9]*)/t1\",\n", + " sink_path=r\"my_clos2.*(b[0-9])/t1\",\n", + " demand=256,\n", + " mode=\"full_mesh\",\n", + " flow_policy_config=FlowPolicyConfig.SHORTEST_PATHS_ECMP,\n", + ")\n", + "demands = [d]\n", + "tm = TrafficManager(\n", + " network=network,\n", + " traffic_demands=demands,\n", + ")\n", + "tm.build_graph()\n", + "tm.expand_demands()\n", + "tm.place_all_demands(placement_rounds=50)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Overall Statistics:\n", + " mean: 212.69\n", + " stdev: 25.50\n", + " min: 178.94\n", + " max: 253.71\n" + ] + } + ], + "source": [ + "my_rules = [\n", + " FailureRule(\n", + " conditions=[FailureCondition(attr=\"type\", operator=\"==\", value=\"link\")],\n", + " logic=\"and\",\n", + " rule_type=\"choice\",\n", + " count=2,\n", + " ),\n", + "]\n", + "fpolicy = FailurePolicy(rules=my_rules)\n", + "\n", + "# Run Monte Carlo\n", + "fmgr = FailureManager(network, demands, failure_policy=fpolicy)\n", + "results = fmgr.run_monte_carlo_failures(iterations=30, parallelism=10)\n", + "overall = results[\"overall_stats\"]\n", + "print(\"Overall Statistics:\")\n", + "for k, v in overall.items():\n", + " print(f\" {k}: {v:.2f}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/xh/83kdwyfd0fv66b04mchbfzcc0000gn/T/ipykernel_31753/4192461833.py:60: UserWarning: No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n", + " plt.legend(title=\"Priority\")\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmUAAAHWCAYAAAA2Of5hAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAXgpJREFUeJzt3XdYU2f/P/B3iCEsERBkKIKz7lEcRavYFsFZbWvdotZZxUeLo9LHqmhdrVqtddTWVXets9Wq4K5SN26tWx9FnAwBIZD790d+nK8xYQdygPfrurja3Gfd55OT5O2ZCiGEABERERGZlYW5O0BEREREDGVEREREssBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVU5Ny5cwcKhQIrV6406XwnT54MhUJh0nkWV97e3ujXr5+5u0FFRExMDLp06YKyZctCoVBg3rx5+Z6n3LdBc/WvVatWaNWqVaEvl0yDoawEWrlyJRQKhfRnZWWF6tWrIzg4GDExMQW+fG9vb73llytXDi1atMDWrVsLfNm5NX36dGzbtq1A5h0TE4MxY8agRo0asLGxga2tLXx8fPDNN98gNja2QJZJhpKSkjB58mQcPHjQ3F3J1M2bNzFkyBBUrlwZVlZWsLe3R/PmzTF//nwkJydL473+2bKwsICDgwPq1q2LwYMH4/jx40bn/fpn8fU/Nzc3k/X/iy++wJ49exAaGorVq1ejTZs2mY77eh8sLCzg4eGBgIAAWb8/ebFlyxYoFAr88ssvmY4THh4OhUKBH374oRB7RuZUytwdIPOZMmUKKlWqhFevXuHvv//G4sWLsWvXLly8eBE2NjYFuuwGDRpg9OjRAICHDx/ip59+wscff4zFixdj6NChWU7r5eWF5ORkqFQqk/ZpwoQJGD9+vF7b9OnT0aVLF3Tu3Nmkyzp58iTatWuHly9fonfv3vDx8QEAnDp1CjNnzsThw4exd+9eky6TjEtKSkJYWBgAyHIPw86dO/Hpp59CrVYjKCgIderUQWpqKv7++2+MHTsWly5dwtKlS6XxX/9sJSQk4MqVK9i0aRN+/vlnfPHFF5g7d67BMlq3bo2goCC9Nmtra5Otw/79+9GpUyeMGTMmR+Nn9EcIgdu3b2PRokV4//33sXPnTrRt29Zk/TKn9u3bo0yZMli3bh0GDhxodJx169ZBqVSie/fuhdw7MheGshKsbdu2aNSoEQBg4MCBKFu2LObOnYvt27ejR48e+Zp3UlJSlsGufPny6N27t/Q6KCgIVatWxffff59pKEtLS4NWq4WlpSWsrKzy1b/XJSYmwtbWFqVKlUKpUgX/kYiNjcVHH30EpVKJs2fPokaNGnrDp02bhp9//rnA+0EFK2O7yo/bt2+je/fu8PLywv79++Hu7i4NGz58OG7cuIGdO3fqTfPmZwsAZs2ahZ49e+L7779HtWrV8Pnnn+sNr169usE0pvT48WM4ODjkePw3+/PRRx+hXr16mDdvXrEJZWq1Gl26dMGKFSvw8OFDeHh46A1/9eoVtm7ditatW6NcuXJm6iUVNh6+JMn7778PQPdDkGHNmjXw8fGBtbU1nJyc0L17d9y/f19vulatWqFOnTo4ffo0WrZsCRsbG3z11Ve5Wrabmxtq1qwpLTvjvLHZs2dj3rx5qFKlCtRqNS5fvpzpOWX79+9HixYtYGtrCwcHB3Tq1AlXrlzRGyfjvLHLly+jZ8+ecHR0xLvvvqs3LINCoUBiYiJWrVolHU7p168fDhw4AIVCYfRw67p166BQKBAZGZnpuv7000948OAB5s6daxDIAMDV1RUTJkzQa1u0aBFq164NtVoNDw8PDB8+3OAQZ8b7cP78efj5+cHGxgZVq1bF77//DgA4dOgQmjZtCmtra7z11luIiIgwWpurV6+ia9eusLe3R9myZTFy5Ei8evUq0/XJEBsbi1GjRsHT0xNqtRpVq1bFrFmzoNVqpXFef18XLlyIypUrw8bGBgEBAbh//z6EEJg6dSoqVKgAa2trdOrUCc+fPzdY1l9//SW916VLl0b79u1x6dIlvXH69esHOzs7PHjwAJ07d4adnR1cXFwwZswYpKenS/1xcXEBAISFhUnv8+TJkwEA58+fR79+/aTDhm5ubvjss8/w7Nkzo7V7c7tasWIFFAoFzp49a7AO06dPh1KpxIMHDzKt6bfffouXL19i2bJleoEsQ9WqVTFy5MhMp89gbW2N1atXw8nJCdOmTYMQIttpcuLWrVv49NNP4eTkBBsbG7zzzjt6ITHjVAkhBBYuXCjVN7fq1q0LZ2dnve+mNz1//hxjxoxB3bp1YWdnB3t7e7Rt2xbnzp0zGPfVq1eYPHkyqlevDisrK7i7u+Pjjz/GzZs3pXG0Wi3mzZuH2rVrw8rKCq6urhgyZAhevHihNy8hBL755htUqFABNjY2eO+99wy2xcz07t0bWq0WGzZsMBi2c+dOxMXFoVevXgB0/yidOnWq9F3o7e2Nr776CikpKVkuI+M9uHPnjl77wYMHoVAo9A4L5/c7BAAePHiAzz77DK6urlCr1ahduzaWL1+eo3oQQxm9JuMLqWzZsgB0e2yCgoJQrVo1zJ07F6NGjcK+ffvQsmVLg0Dw7NkztG3bFg0aNMC8efPw3nvv5WrZGo0G9+/fl5adYcWKFViwYAEGDx6MOXPmwMnJyej0ERERCAwMxOPHjzF58mSEhITg2LFjaN68ucGXEQB8+umnSEpKwvTp0zFo0CCj81y9ejXUajVatGiB1atXY/Xq1RgyZAhatWoFT09PrF271mCatWvXokqVKvD19c10XXfs2AFra2t06dIli4r8n8mTJ2P48OHw8PDAnDlz8Mknn+Cnn35CQEAANBqN3rgvXrxAhw4d0LRpU3z77bdQq9Xo3r07Nm7ciO7du6Ndu3aYOXMmEhMT0aVLFyQkJBgsr2vXrnj16hVmzJiBdu3a4YcffsDgwYOz7GNSUhL8/PywZs0aBAUF4YcffkDz5s0RGhqKkJAQo3VatGgRRowYgdGjR+PQoUPo2rUrJkyYgN27d+PLL7/E4MGD8ccffxgc8lq9ejXat28POzs7zJo1C19//TUuX76Md9991+C9Tk9PR2BgIMqWLYvZs2fDz88Pc+bMkQ73ubi4YPHixQB0e2My3uePP/4YgO6cnlu3bqF///5YsGABunfvjg0bNqBdu3ZGg82b21WXLl1gbW2d6bbSqlUrlC9fPtO6/vHHH6hcuTKaNWuWZf1zws7ODh999BEePHiAy5cv6w179eoVnj59qveX3Y99TEwMmjVrhj179mDYsGGYNm0aXr16hQ8//FD6B0vLli2xevVqALpDkhn1za0XL17gxYsXBt8Pr7t16xa2bduGDh06YO7cuRg7diwuXLgAPz8/PHz4UBovPT0dHTp0QFhYGHx8fDBnzhyMHDkScXFxuHjxojTekCFDMHbsWOncvf79+2Pt2rUIDAzU+9xNnDgRX3/9NerXr4/vvvsOlStXRkBAABITE7Ndr5YtW6JChQpYt26dwbB169bBxsZGOnVi4MCBmDhxIt5++218//338PPzw4wZM0x+aDM/3yExMTF45513EBERgeDgYMyfPx9Vq1bFgAEDTHJxR4kgqMRZsWKFACAiIiLEkydPxP3798WGDRtE2bJlhbW1tfjf//4n7ty5I5RKpZg2bZretBcuXBClSpXSa/fz8xMAxJIlS3K0fC8vLxEQECCePHkinjx5Is6dOye6d+8uAIgRI0YIIYS4ffu2ACDs7e3F48eP9abPGLZixQqprUGDBqJcuXLi2bNnUtu5c+eEhYWFCAoKktomTZokAIgePXoY9Ctj2OtsbW1F3759DcYNDQ0VarVaxMbGSm2PHz8WpUqVEpMmTcpy/R0dHUX9+vWzHOf1eVpaWoqAgACRnp4utf/4448CgFi+fLnUlvE+rFu3Tmq7evWqACAsLCzEP//8I7Xv2bPHoIYZ6//hhx/q9WHYsGECgDh37pzU5uXlpVeXqVOnCltbW/Hvv//qTTt+/HihVCrFvXv3hBD/9965uLjo1S40NFQAEPXr1xcajUZq79Gjh7C0tBSvXr0SQgiRkJAgHBwcxKBBg/SW8+jRI1GmTBm99r59+woAYsqUKXrjNmzYUPj4+Eivnzx5IgAYfd+SkpIM2tavXy8AiMOHD0ttWW1XPXr0EB4eHnrv35kzZwzq/6a4uDgBQHTq1CnTcd7k5eUl2rdvn+nw77//XgAQ27dvl9oAGP3Lqm9CCDFq1CgBQBw5ckRqS0hIEJUqVRLe3t566wtADB8+PEfrAEAMGDBAPHnyRDx+/FgcP35cfPDBBwKAmDNnjt66vr4Nvnr1Sm+ZQui2N7VarbcNLF++XAAQc+fONVi2VqsVQghx5MgRAUCsXbtWb/ju3bv12jM+n+3bt5emFUKIr776SgAw+t3xprFjxwoA4tq1a1JbXFycsLKykranqKgoAUAMHDhQb9oxY8YIAGL//v1Sm5+fn/Dz85NeZ3zf3759W2/aAwcOCADiwIEDetPm5ztkwIABwt3dXTx9+lRvWd27dxdlypQx+nkifdxTVoL5+/vDxcUFnp6e6N69O+zs7LB161aUL18eW7ZsgVarRdeuXfX+9ezm5oZq1arhwIEDevNSq9Xo379/jpe9d+9euLi4wMXFBfXr18emTZvQp08fzJo1S2+8Tz75RDq8lJno6GhERUWhX79+envS6tWrh9atW2PXrl0G02R3MUF2goKCkJKSIu3WB4CNGzciLS0t23Nz4uPjUbp06RwtJyIiAqmpqRg1ahQsLP7v4zpo0CDY29sbnE9kZ2en9y/nt956Cw4ODqhZsyaaNm0qtWf8/61btwyWOXz4cL3XI0aMAACjdcywadMmtGjRAo6Ojnrbi7+/P9LT03H48GG98T/99FOUKVPGoD+9e/fWO6+vadOmSE1NlQ7xhYeHIzY2Fj169NBbjlKpRNOmTQ22S8DwvW7RooXR9Tbm9ZPdM/YmvfPOOwCAM2fOZLssQLetPHz4UK9va9euhbW1NT755JNMlx0fHw8AOd5WcsLOzg4ADPaQdurUCeHh4Xp/gYGBWc5r165daNKkiXT4P2P+gwcPxp07dwz2xuXGsmXL4OLignLlyqFp06Y4evQoQkJCMGrUqEynUavV0mckPT0dz549g52dHd566y2992rz5s1wdnaWtuvXZRxa3bRpE8qUKYPWrVvrbWc+Pj6ws7OT3suMz+eIESP0Dstm1c83ZXxfvL63bPPmzXj16pV06DLjs/fmXueMCzre/B7Ij7x+hwghsHnzZnTs2BFCCL26BQYGIi4uzuhnhvTxRP8SbOHChahevTpKlSoFV1dXvPXWW9KX2vXr1yGEQLVq1YxO++aVj+XLl4elpaX0Oi4uTu9SfUtLS73A1LRpU3zzzTdQKBSwsbFBzZo1jZ4IXKlSpWzX4+7duwB0Xx5vqlmzJvbs2WNw0nVO5puVGjVqoHHjxli7di0GDBgAQPdD+84776Bq1apZTmtvb2/0sKExma2bpaUlKleuLA3PUKFCBYNzdsqUKQNPT0+DNgAG58cAMHjPq1SpAgsLC6OHgTNcv34d58+fzzRAP378WO91xYoVjfYnu35ev34dwP+d//gme3t7vddWVlYGfXJ0dDS63sY8f/4cYWFh2LBhg8E6xMXFGYxvbLtq3bo13N3dsXbtWnzwwQfQarVYv349OnXqlGXgyliXnG4rOfHy5UsAhkGvQoUK8Pf3z9W87t69q/cjnaFmzZrS8Dp16uSpn506dUJwcDAUCgVKly6N2rVrZ3vRhFarxfz587Fo0SLcvn1bOm8QgN5hz5s3b+Ktt97K8qKe69evIy4uLtMT7DO2hYzP35ufGRcXFzg6Oma9kv9fvXr1UKdOHaxfv146l3HdunVwdnaWgvHdu3dhYWFh8N3i5uYGBwcHg++B/Mjrd8iTJ08QGxuLpUuX6l0N/Lo3P0NkiKGsBGvSpIl09eWbtFotFAoF/vrrLyiVSoPhGf/izvDm5fMjR47EqlWrpNd+fn56J5Q6Ozvn6EfAlJflm3q+QUFBGDlyJP73v/8hJSUF//zzD3788cdsp6tRowaioqKQmpqqF2RNwdh7lVW7yMEJ3zk5MVur1aJ169YYN26c0eHVq1fPUX+y62fGRQOrV682eh+tN39oM5tfTnXt2hXHjh3D2LFj0aBBA9jZ2UGr1aJNmzZ6FzBkMLZdKZVK9OzZEz///DMWLVqEo0eP4uHDh9nuUbW3t4eHh4feeU75lTGv7P7hYG55CYnTp0/H119/jc8++wxTp06Fk5MTLCwsMGrUKKPvVVa0Wi3KlStn9FxAANnuvc+t3r17Y/z48Th16hQqVKiAAwcOYMiQIQbbc14ukshsmtdD6+vy+9ns3bs3+vbta3TcevXqZdlXYiijTFSpUgVCCFSqVMngBzUnxo0bp/ejk9N/NeaFl5cXAODatWsGw65evQpnZ+c835ogqy/B7t27IyQkBOvXr5fum9atW7ds59mxY0dERkZi8+bN2d565PV1q1y5stSempqK27dv5/qHKyeuX7+ut8fnxo0b0Gq18Pb2znSaKlWq4OXLlwXSnzeXAwDlypUz2bIye49fvHiBffv2ISwsDBMnTpTaM/bW5UZQUBDmzJmDP/74A3/99RdcXFyyPTwIAB06dMDSpUsRGRmZ5cUjOfHy5Uts3boVnp6e0t6s/PDy8sr0M5cxvDD9/vvveO+997Bs2TK99tjYWDg7O0uvq1SpguPHj0Oj0WR6r8MqVaogIiICzZs3z/IfcBnreP36db3P55MnT3K8NxYAevTogdDQUKxbtw5eXl5IT0+XDl1mLEer1eL69et6711MTAxiY2OzrHXGd++bF2eZcu8aoAuqpUuXRnp6eoF/DxRnPKeMjPr444+hVCoRFhZmsDdFCGFwS4A31apVC/7+/tJfxs1RC4K7uzsaNGiAVatW6X3xXLx4EXv37kW7du3yPG9bW9tM767v7OyMtm3bYs2aNVi7di3atGmj9+WfmaFDh8Ld3R2jR4/Gv//+azD88ePH+OabbwDozvuztLTEDz/8oPc+LFu2DHFxcWjfvn3eViwLCxcu1Hu9YMECAMjy/lBdu3ZFZGQk9uzZYzAsNjYWaWlpJulbYGAg7O3tMX36dIMrTwHdj2FuZdxP7833OWPPwJvbf16uIqtXrx7q1auHX375BZs3b0b37t1zdE+8cePGwdbWFgMHDjT6tI2bN29i/vz52c4nOTkZffr0wfPnz/Hf//7XJI8Ta9euHU6cOKF3+5fExEQsXboU3t7eqFWrVr6XkRtKpdLgvdq0aZPBLUc++eQTPH361Ohe7Yzpu3btivT0dEydOtVgnLS0NGlb8ff3h0qlwoIFC/SWndttpGLFimjRogU2btyINWvWoFKlSnpX3GZ8h70534wbAWf1PZDxD5nXz+tMT0/P9BBjXimVSnzyySfYvHmz0b27eflslkTcU0ZGValSBd988w1CQ0Nx584ddO7cGaVLl8bt27exdetWDB48OMd35y4M3333Hdq2bQtfX18MGDAAycnJWLBgAcqUKSOdp5EXPj4+iIiIwNy5c+Hh4YFKlSrpnUcTFBQk3drC2Be4MY6Ojti6dSvatWuHBg0a6N3R/8yZM1i/fr20V8TFxQWhoaEICwtDmzZt8OGHH+LatWtYtGgRGjduXCA3/Lx9+zY+/PBDtGnTBpGRkVizZg169uyJ+vXrZzrN2LFjsWPHDnTo0AH9+vWDj48PEhMTceHCBfz++++4c+dOjgJrduzt7bF48WL06dMHb7/9Nrp37w4XFxfcu3cPO3fuRPPmzXN0CPl11tbWqFWrFjZu3Ijq1avDyckJderUQZ06ddCyZUt8++230Gg0KF++PPbu3ZvlvbKyEhQUJH1mcvq+ValSBevWrUO3bt1Qs2ZNvTv6Hzt2DJs2bTJ4vuKDBw+wZs0aALq9Y5cvX8amTZvw6NEjjB49GkOGDMlT/980fvx4rF+/Hm3btsV//vMfODk5YdWqVbh9+zY2b96sd2FKYejQoQOmTJmC/v37o1mzZrhw4QLWrl2rtwcL0L0Pv/76K0JCQnDixAm0aNECiYmJiIiIwLBhw9CpUyf4+flhyJAhmDFjBqKiohAQEACVSoXr169j06ZNmD9/Prp06SLd927GjBno0KED2rVrh7Nnz+Kvv/7K9fbeu3dvDB48GA8fPsR///tfvWH169dH3759sXTpUsTGxsLPzw8nTpzAqlWr0Llz5yxvQVS7dm288847CA0NxfPnz+Hk5IQNGzaY7B9Kr5s5cyYOHDiApk2bYtCgQahVqxaeP3+OM2fOICIiwug9B+kN5rjkk8wr4xLpkydPZjvu5s2bxbvvvitsbW2Fra2tqFGjhhg+fLje5dt+fn6idu3aOV5+dpftC/F/t0747rvvMh325iX7ERERonnz5sLa2lrY29uLjh07isuXL+uNk3HrgidPnhjM19gtMa5evSpatmwprK2tjV7inpKSIhwdHUWZMmVEcnJyluv0pocPH4ovvvhCVK9eXVhZWQkbGxvh4+Mjpk2bJuLi4vTG/fHHH0WNGjWESqUSrq6u4vPPPxcvXrzQGyez9yGzeuON2xRkrP/ly5dFly5dROnSpYWjo6MIDg42WLc3b0cghO52CKGhoaJq1arC0tJSODs7i2bNmonZs2eL1NRUIUTm72vG5fmbNm3Sa89sWz1w4IAIDAwUZcqUEVZWVqJKlSqiX79+4tSpU9I4ffv2Fba2tgbrbex9PnbsmPDx8RGWlpZ6t8f43//+Jz766CPh4OAgypQpIz799FPx8OFDg1toZLVdZYiOjhZKpVJUr14903Ey8++//4pBgwYJb29vYWlpKUqXLi2aN28uFixYIN0uRAjd+4L/f0sLhUIh7O3tRe3atcWgQYPE8ePHjc77ze0gN27evCm6dOkiHBwchJWVlWjSpIn4888/87WMnI5r7JYYo0ePFu7u7sLa2lo0b95cREZGGtwiQgjdrU7++9//ikqVKgmVSiXc3NxEly5dxM2bN/XGW7p0qfDx8RHW1taidOnSom7dumLcuHHi4cOH0jjp6ekiLCxMWm6rVq3ExYsXjX5GsvL8+XOhVqulz+CbNBqNCAsLk/rs6ekpQkND9d5/IQxviSGE7n3y9/cXarVauLq6iq+++kqEh4cbvSVGfr5DhBAiJiZGDB8+XHh6ekq1/eCDD8TSpUtzXIuSTCGEiW7tTFQCpaWlwcPDAx07djQ4l6WomTx5MsLCwvDkyROT7NUifU+fPoW7u7t0s1EiojfxnDKifNi2bRuePHli8DBnojetXLkS6enp6NOnj7m7QkQyxXPKiPLg+PHjOH/+PKZOnYqGDRvCz8/P3F0imdq/fz8uX76MadOmoXPnzllexUpEJRtDGVEeLF68GGvWrEGDBg0MHoxO9LopU6ZIz2HNuJKViMgYs55TdvjwYXz33Xc4ffo0oqOjsXXrVunhq5k5ePAgQkJCcOnSJXh6emLChAkGVx8RERERFTVmPacsMTER9evXN7gvUmZu376N9u3b47333kNUVBRGjRqFgQMHGr03EhEREVFRIpurLxUKRbZ7yr788kvs3LlT78Z03bt3R2xsLHbv3l0IvSQiIiIqGEXqnLLIyEiDxzcEBgZi1KhRmU6TkpKClJQU6bVWq8Xz589RtmxZk9zVmoiIiCgrQggkJCTAw8MjyxsrF6lQ9ujRI7i6uuq1ubq6Ij4+HsnJyUafUTZjxgyEhYUVVheJiIiIjLp//z4qVKiQ6fAiFcryIjQ0FCEhIdLruLg4VKxYEbdv30bp0qVNvryk1DQ0/1b3jLGj41rCxlK/xBqNBgcOHMB7772X6cNwSwLWQYd10GEddFgHHdZBh3XQKQ51SEhIQKVKlbLNHUUqlLm5uRk8lDcmJgb29vZG95IBgFqthlqtNmh3cnKCvb29yftonZoGC7XuAcdly5Y1GspsbGxQtmzZIrtxmQLroMM66LAOOqyDDuugwzroFIc6ZPQ7u9OmitQd/X19fbFv3z69tvDwcOnhzURERERFlVlD2cuXLxEVFYWoqCgAulteREVF4d69ewB0hx5ff3zN0KFDcevWLYwbNw5Xr17FokWL8Ntvv+GLL74wR/eJiIiITMasoezUqVNo2LAhGjZsCAAICQlBw4YNMXHiRABAdHS0FNAAoFKlSti5cyfCw8NRv359zJkzB7/88gsCAwPN0n8iIiIiUzHrOWWtWrVCVrdJM/b4mlatWuHs2bMF2CsiIiIyBSEE0tLSkJ6enud5aDQalCpVCq9evcrXfAqSUqlEqVKl8n2rrSJ1oj8REREVDampqYiOjkZSUlK+5iOEgJubG+7fvy/r+4va2NjA3d0dlpaWeZ4HQxkRERGZlFarxe3bt6FUKuHh4QFLS8s8ByqtVouXL1/Czs4uyxuvmosQAqmpqXjy5Alu376NatWq5bmfDGVERERkUqmpqdBqtfD09ISNjU2+5qXVapGamgorKytZhjIAsLa2hkqlwt27d6W+5oU8146IiIiKPLmGqIJginUtOdUyg6TU9CwvZCAiIiLKwFBWgBp9E4FPl0QymBEREVG2GMpMzFqlRCMvR+n1qbsvkKyR5yW8RERERUXlypUxb968fM+nVatWGDVqVL7nUxAYykxMoVBg01BfnJrgb+6uEBERyVK/fv2gUCigUChgaWmJqlWrYsqUKUhLS8t0muPHj2Pw4MH5XvaWLVswdepU6bW3t7dJwp4p8OrLAqBQKGBjqTR3N4iIiGSrTZs2WLFiBVJSUrBr1y4MHz4cKpUKoaGheuOlpqYCAFxcXPJ1Mn1qaiosLS3h5OSUr34XJO4pIyIiokKnVqvh5uYGLy8vfP755/D398eOHTvQr18/dO7cGdOmTYOHhwdq1qwJwPDw5b1799CpUyfY2dnB3t4eXbt2RUxMjDR88uTJaNCgAX755RdUqlRJuk3F64cvW7Vqhbt37+KLL76Q9twlJibC3t4ev//+u15/t23bBltbWyQkJBRYTRjKiIiIyOysra2lvWL79u3DtWvXEB4ejh07dhiMq9Vq0alTJzx//hyHDh1CeHg4bt26hW7duumNd+PGDWzevBlbtmxBVFSUwXy2bNmCChUqYMqUKYiOjkZ0dDRsbW3RvXt3rFixQm/cFStWoEuXLihdurTpVvoNPHxJREREZiOEwL59+7Bnzx6MGDECT548ga2tLX755RdYWlpCq9UiPj5eb5p9+/bhwoULuH37Njw9PQEAv/76K2rXro2TJ0+icePGAHSHLH/99Ve4uLgYXbaTkxOUSiVKly4NNzc3qX3gwIFo1qwZoqOj4e7ujsePH2PXrl2IiIgooCrocE8ZERERFbo///wTdnZ2sLKyQtu2bdGtWzdMnjwZAFC3bt0snyF55coVeHp6SoEMAGrVqgUHBwdcuXJFavPy8so0kGWlSZMmqF27NlatWgUAWLNmDby8vNCyZctczys3GMqIiIio0L333nuIiorC9evXkZycjFWrVsHW1hYApP/mV37mM3DgQKxcuRKA7tBl//79C/yB6AxlREREVOhsbW1RtWpVVKxYEaVK5e5sqpo1a+L+/fu4f/++1Hb58mXExsaiVq1auZqXpaUl0tMN7yfau3dv3L17Fz/88AMuX76Mvn375mq+ecFQRkREREWKv78/6tati169euHMmTM4ceIEgoKC4Ofnh0aNGuVqXt7e3jh8+DAePHiAp0+fSu2Ojo74+OOPMXbsWAQEBKBChQqmXg0DPNG/ECSl/l8C12jSkJIOJKWmQSUKdjeonGk0aeDTp4iIKC8UCgW2b9+OESNGoGXLlrCwsECbNm2wYMGCXM9rypQpGDJkCKpUqYKUlBS9RyMOGDAA69atw2effWbK7meKoawQNPrmzas1SmHcif1m6YucVCqtRLt2TGZERCVNxrlauRl269YtvZvHVqxYEdu3b890PpMnT5YuHHjdwYMH9V6/8847OHfunNF5PHjwAGXLlkWnTp0yXY4p8fBlAXnzGZhk6HaCgs8FJSIi2UlKSsLNmzcxc+ZMDBkyJMsrQU2Je8oKSMYzMN8MHRqNBnv27EVgYABUKpWZemdeSanpRvYeEhERycO3336LadOmoWXLlgaPfSpIDGUFSPcMTP0SaxQCaiVgY1kKKhXLT0REJDeZHfosaDx8SURERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERAXi9Yd7F3emWFeGMiIiIjKpjMcIJiUlmbknhSdjXfPzCEU+54eIiIhMSqlUwsHBAY8fPwYA2NjYQKFQ5GleWq0WqampePXqFSws5LcvSQiBpKQkPH78GA4ODlAqlXmeF0MZERERmZybmxsASMEsr4QQSE5OhrW1dZ6DXWFwcHCQ1jmvGMqIiIjI5BQKBdzd3VGuXDloNJo8z0ej0eDw4cNo2bJlvg4NFiSVSpWvPWQZGMqIiIiowCiVynwFFqVSibS0NFhZWck2lJmK/A7OEhEREZVADGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRmaVnJoOIYS5u0FERGR2DGVkVu/MOoRPl0QymBERUYnHUEaFzlqlhE9FB+n1qbsvkKxJN1+HiIiIZIChjAqdQqHA+oGN8U2jNHN3hYiISDYYysgsFAoFLLn1ERERSfizSERERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMmD2ULZw4UJ4e3vDysoKTZs2xYkTJ7Icf968eXjrrbdgbW0NT09PfPHFF3j16lUh9ZaIiIioYJg1lG3cuBEhISGYNGkSzpw5g/r16yMwMBCPHz82Ov66deswfvx4TJo0CVeuXMGyZcuwceNGfPXVV4XccyIiIiLTMmsomzt3LgYNGoT+/fujVq1a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v/0WU6dOxe7du9GoUaPC6CoRERFRgTL74cuQkBD07dsXjRo1QpMmTTBv3jwkJiaif//+AICgoCCUL18eM2bMAADMmjULEydOxLp16+Dt7Y1Hjx4BAOzs7GBnZ2e29SAiIiLKD7OHsm7duuHJkyeYOHEiHj16hAYNGmD37t1wdXUFANy7dw8WFv+3Q2/x4sVITU1Fly5d9OYzadIkTJ48uTC7TkRERGQyZg9lABAcHIzg4GCjww4ePKj3+s6dOwXfISIiIqJCZvabxxIRERERQxkRERGRLDCUEREREckAQxkRERGRDDCUERERFQLzPdSQigqGMiIiokLw6ZJImPFx01QEMJQREREVEGuVErXcdc9Zvhwdj2RNupl7RHLGUEZERFRAFAoFNg3N/LGBRK9jKCMiIipACkX24xABDGVEREREssBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMsBQRkRERCQDDGVEREREMlDK3B0gIiIqKZJSc35Hf40mDSnpQFJqGlSi5N7sLKd1sFYpoSjiN4VjKCMiIiokjb6JyOUUpTDuxP4C6UvRkn0dGnk5YtNQ3yIdzHj4koiIqABZq5Ro5OVo7m4Ue6fuvijyzxblnjIiIqIClPH8y9wGBo1Ggz179iIwMAAqlaqAeid/2dUhKTU9D3sg5YmhjIiIqIApFArYWObuJ1ejEFArARvLUlCpSu7PdUmqAw9fEhEREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkREREVC0mp6RBCmLsbecZQRkRERMVCo28i8OmSyCIbzBjKiIiIqMiyVinRyMtRen3q7gska9LN2KO8YygjIiKiIkuhUGDTUF+cmuBv7q7kG0MZERERFWkKhQI2lkpzdyPfGMqIiIiIZIChjIiIiEgGGMqIiIiIZIChjIiIiEgGGMqIiIiIZIChjIiIiEgGGMqIiIiIZIChjIiIiEgGSpm7A0RERESmlJSau8csWauUUCgUBdSbnGMoIyIiomKl0TcRuRvfyxGb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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import pandas as pd\n", + "import seaborn as sns\n", + "import matplotlib.pyplot as plt\n", + "from collections import defaultdict\n", + "\n", + "\n", + "def plot_priority_cdf(results, complementary: bool = True):\n", + " \"\"\"\n", + " Plots an empirical (complementary) CDF of placed volume for each priority.\n", + "\n", + " Args:\n", + " results: The dictionary returned by run_monte_carlo_failures, containing:\n", + " {\n", + " \"overall_stats\": {...},\n", + " \"by_src_dst\": {\n", + " (src, dst, priority): [\n", + " {\"iteration\": int, \"total_volume\": float, \"placed_volume\": float, ...},\n", + " ...\n", + " ],\n", + " ...\n", + " }\n", + " }\n", + " complementary: If True, plots a complementary CDF (P(X >= x)).\n", + " If False, plots a standard CDF (P(X <= x)).\n", + " \"\"\"\n", + " by_src_dst = results[\"by_src_dst\"] # {(src, dst, priority): [...]}\n", + "\n", + " # 1) Aggregate total placed volume for each iteration & priority\n", + " # (similar logic as before, but we'll directly store iteration-level sums).\n", + " volume_per_iter_priority = defaultdict(float)\n", + " for (src, dst, priority), data_list in by_src_dst.items():\n", + " for entry in data_list:\n", + " it = entry[\"iteration\"]\n", + " volume_per_iter_priority[(it, priority)] += entry[\"placed_volume\"]\n", + "\n", + " # 2) Convert to a tidy DataFrame with columns: [iteration, priority, placed_volume]\n", + " rows = []\n", + " for (it, prio), vol_sum in volume_per_iter_priority.items():\n", + " rows.append({\"iteration\": it, \"priority\": prio, \"placed_volume\": vol_sum})\n", + "\n", + " plot_df = pd.DataFrame(rows)\n", + "\n", + " # 3) Use seaborn's ECDF plot (which can do either standard or complementary CDF)\n", + " plt.figure(figsize=(7, 5))\n", + " sns.ecdfplot(\n", + " data=plot_df,\n", + " x=\"placed_volume\",\n", + " hue=\"priority\",\n", + " complementary=complementary, # True -> CCDF, False -> normal CDF\n", + " )\n", + " if complementary:\n", + " plt.ylabel(\"P(X ≥ x)\")\n", + " plt.title(\"Per-Priority Complementary CDF of Placed Volume\")\n", + " else:\n", + " plt.ylabel(\"P(X ≤ x)\")\n", + " plt.title(\"Per-Priority CDF of Placed Volume\")\n", + "\n", + " plt.xlabel(\"Total Placed Volume (per iteration)\")\n", + " plt.grid(True)\n", + " plt.legend(title=\"Priority\")\n", + " plt.show()\n", + "\n", + "\n", + "plot_priority_cdf(results, complementary=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ngraph-venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/lib/algorithms/test_edge_select.py b/tests/lib/algorithms/test_edge_select.py index a29a81e..363aa78 100644 --- a/tests/lib/algorithms/test_edge_select.py +++ b/tests/lib/algorithms/test_edge_select.py @@ -1,16 +1,16 @@ -import math +from math import isclose import pytest from unittest.mock import MagicMock -from typing import Dict, List, Set, Tuple +from typing import Dict, Set, Tuple from ngraph.lib.graph import StrictMultiDiGraph, NodeID, EdgeID, AttrDict from ngraph.lib.algorithms.edge_select import EdgeSelect, edge_select_fabric -from ngraph.lib.algorithms.base import MIN_CAP, Cost +from ngraph.lib.algorithms.base import Cost, MIN_CAP @pytest.fixture def mock_graph() -> StrictMultiDiGraph: - """A mock StrictMultiDiGraph for passing to selection functions.""" + """A mock StrictMultiDiGraph to pass to selection functions for testing.""" return MagicMock(spec=StrictMultiDiGraph) @@ -18,6 +18,7 @@ def mock_graph() -> StrictMultiDiGraph: def edge_map() -> Dict[EdgeID, AttrDict]: """ A basic edge_map with varying costs/capacities/flows. + Edge leftover capacity = capacity - flow. """ return { "edgeA": {"cost": 10, "capacity": 100, "flow": 0}, # leftover=100 @@ -33,18 +34,20 @@ def edge_map() -> Dict[EdgeID, AttrDict]: # ------------------------------------------------------------------------------ -def test_invalid_enum_value(): +def test_invalid_enum_value() -> None: """ - Using Python's Enum with an invalid int calls the Enum constructor - and raises '999 is not a valid EdgeSelect'. - This verifies that scenario rather than your custom error message. + Ensure using an invalid int for the EdgeSelect enum raises a ValueError. + E.g., 999 is not a valid EdgeSelect. """ with pytest.raises(ValueError, match="999 is not a valid EdgeSelect"): - EdgeSelect(999) # triggers Python's built-in check + EdgeSelect(999) -def test_user_defined_no_func(): - """Provide edge_select=USER_DEFINED without 'edge_select_func', triggers ValueError.""" +def test_user_defined_no_func() -> None: + """ + Provide edge_select=USER_DEFINED without 'edge_select_func'. + This must trigger ValueError. + """ with pytest.raises(ValueError, match="requires 'edge_select_func'"): edge_select_fabric(edge_select=EdgeSelect.USER_DEFINED) @@ -54,10 +57,9 @@ def test_user_defined_no_func(): # ------------------------------------------------------------------------------ -def test_empty_edge_map(mock_graph): +def test_empty_edge_map(mock_graph: StrictMultiDiGraph) -> None: """ - An empty edges_map must always yield (inf, []). - We'll test multiple EdgeSelect variants in a loop to ensure coverage. + An empty edges_map must yield (inf, []) for any EdgeSelect variant. """ variants = [ EdgeSelect.ALL_MIN_COST, @@ -70,15 +72,17 @@ def test_empty_edge_map(mock_graph): for variant in variants: select_func = edge_select_fabric(variant) cost, edges = select_func( - mock_graph, "A", "B", {}, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", {}, excluded_edges=set(), excluded_nodes=set() ) assert cost == float("inf") assert edges == [] -def test_excluded_nodes_all_min_cost(mock_graph, edge_map): +def test_excluded_nodes_all_min_cost( + mock_graph: StrictMultiDiGraph, edge_map: Dict[EdgeID, AttrDict] +) -> None: """ - If dst_node is in ignored_nodes, we must get (inf, []) regardless of edges. + If dst_node is in excluded_nodes, we must get (inf, []) regardless of edges. """ select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( @@ -86,39 +90,34 @@ def test_excluded_nodes_all_min_cost(mock_graph, edge_map): src_node="A", dst_node="excludedB", edges_map=edge_map, - ignored_edges=None, - ignored_nodes={"excludedB"}, + excluded_edges=None, + excluded_nodes={"excludedB"}, ) assert cost == float("inf") assert edges == [] -def test_all_min_cost_tie_break(mock_graph): +def test_all_min_cost_tie_break(mock_graph: StrictMultiDiGraph) -> None: """ - Two edges with effectively equal cost within 1e-12 must be returned together. - We'll make the difference strictly < 1e-12 so they are recognized as equal. + Edges with costs within 1e-12 of each other are treated as equal. + Both edges must be returned. """ edge_map_ = { "e1": {"cost": 10.0, "capacity": 50, "flow": 0}, - "e2": { - "cost": 10.0000000000005, - "capacity": 50, - "flow": 0, - }, # diff=5e-13 < 1e-12 + "e2": {"cost": 10.0000000000005, "capacity": 50, "flow": 0}, # diff=5e-13 "e3": {"cost": 12.0, "capacity": 50, "flow": 0}, } select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( - mock_graph, "A", "B", edge_map_, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map_, excluded_edges=set(), excluded_nodes=set() ) - assert math.isclose(cost, 10.0, abs_tol=1e-12) - # e1 and e2 both returned + assert isclose(cost, 10.0, abs_tol=1e-12) assert set(edges) == {"e1", "e2"} -def test_all_min_cost_no_valid(mock_graph): +def test_all_min_cost_no_valid(mock_graph: StrictMultiDiGraph) -> None: """ - If all edges are in ignored_edges, we get (inf, []) from ALL_MIN_COST. + If all edges are in excluded_edges, we get (inf, []) from ALL_MIN_COST. """ edge_map_ = { "e1": {"cost": 10, "capacity": 50, "flow": 0}, @@ -126,7 +125,12 @@ def test_all_min_cost_no_valid(mock_graph): } select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( - mock_graph, "A", "B", edge_map_, ignored_edges={"e1", "e2"}, ignored_nodes=set() + mock_graph, + "A", + "B", + edge_map_, + excluded_edges={"e1", "e2"}, + excluded_nodes=set(), ) assert cost == float("inf") assert edges == [] @@ -137,10 +141,13 @@ def test_all_min_cost_no_valid(mock_graph): # ------------------------------------------------------------------------------ -def test_edge_select_excluded_edges(mock_graph, edge_map): +def test_edge_select_excluded_edges( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - Using ALL_MIN_COST. 'edgeC' has cost=5, but if excluded, next min is 'edgeE'=5, or else 10. - So we skip 'edgeC' and pick 'edgeE'. + Using ALL_MIN_COST. 'edgeC' has cost=5 but is excluded. + So the next minimum is also 5 => 'edgeE'. """ select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( @@ -148,134 +155,162 @@ def test_edge_select_excluded_edges(mock_graph, edge_map): "nodeA", "nodeB", edge_map, - ignored_edges={"edgeC"}, # exclude edgeC - ignored_nodes=set(), + excluded_edges={"edgeC"}, + excluded_nodes=set(), ) assert cost == 5 assert edges == ["edgeE"] -def test_edge_select_all_min_cost(mock_graph, edge_map): - """ALL_MIN_COST => all edges with minimal cost => 5 => edgeC, edgeE.""" +def test_edge_select_all_min_cost( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: + """ + ALL_MIN_COST => all edges with minimal cost => 5 => edgesC, E. + """ select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 5 assert set(chosen) == {"edgeC", "edgeE"} -def test_edge_select_single_min_cost(mock_graph, edge_map): +def test_edge_select_single_min_cost( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - SINGLE_MIN_COST => one edge with min cost => 5 => either edgeC or edgeE. + SINGLE_MIN_COST => exactly one edge with minimal cost (5) => edgeC or edgeE. """ select_func = edge_select_fabric(EdgeSelect.SINGLE_MIN_COST) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 5 assert len(chosen) == 1 assert chosen[0] in {"edgeC", "edgeE"} -def test_edge_select_all_min_cost_with_cap(mock_graph, edge_map): +def test_edge_select_all_min_cost_with_cap( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - ALL_MIN_COST_WITH_CAP_REMAINING => leftover>=10 => edgesA,B,C => among them, cost=5 => edgeC - so cost=5, chosen=[edgeC] + ALL_MIN_COST_WITH_CAP_REMAINING => leftover >= 10 => edgesA, B, C => among them cost=5 => edgeC. """ select_func = edge_select_fabric( EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING, select_value=10 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 5 assert chosen == ["edgeC"] -def test_edge_select_all_any_cost_with_cap(mock_graph, edge_map): +def test_edge_select_all_any_cost_with_cap( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - ALL_ANY_COST_WITH_CAP_REMAINING => leftover>=10 => edgesA,B,C. We return all three, ignoring - cost except for returning min cost => 5 + ALL_ANY_COST_WITH_CAP_REMAINING => leftover >= 10 => edgesA, B, C. + All returned, min cost among them is 5. """ select_func = edge_select_fabric( EdgeSelect.ALL_ANY_COST_WITH_CAP_REMAINING, select_value=10 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 5 assert set(chosen) == {"edgeA", "edgeB", "edgeC"} -def test_edge_select_single_min_cost_with_cap_remaining(mock_graph, edge_map): +def test_edge_select_single_min_cost_with_cap_remaining( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - SINGLE_MIN_COST_WITH_CAP_REMAINING => leftover>=5 => edgesA(100),B(25),C(10),D(5). - among them, min cost=5 => edgeC + SINGLE_MIN_COST_WITH_CAP_REMAINING => leftover >= 5 => edgesA(100), B(25), C(10), D(5). + Among them, minimum cost=5 => edgeC. """ select_func = edge_select_fabric( EdgeSelect.SINGLE_MIN_COST_WITH_CAP_REMAINING, select_value=5 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 5 assert chosen == ["edgeC"] -def test_edge_select_single_min_cost_with_cap_remaining_no_valid(mock_graph, edge_map): +def test_edge_select_single_min_cost_with_cap_remaining_no_valid( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - leftover>=999 => none qualify => (inf, []). + If leftover >= 999, none qualify => (inf, []). """ select_func = edge_select_fabric( EdgeSelect.SINGLE_MIN_COST_WITH_CAP_REMAINING, select_value=999 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == float("inf") assert chosen == [] -def test_edge_select_single_min_cost_load_factored(mock_graph, edge_map): +def test_edge_select_single_min_cost_load_factored( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - cost= cost*100 + round((flow/capacity)*10). Among leftover>=MIN_CAP => all edges. - edgeC => 5*100+0=500 => minimum => pick edgeC + cost_val = cost*100 + round((flow/capacity)*10). + Among leftover >= MIN_CAP => effectively all edges, the lowest combined cost is for edgeC => 5*100+0=500. """ select_func = edge_select_fabric( EdgeSelect.SINGLE_MIN_COST_WITH_CAP_REMAINING_LOAD_FACTORED ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 500.0 assert chosen == ["edgeC"] -def test_load_factored_edge_under_min_cap(mock_graph, edge_map): +def test_load_factored_edge_under_min_cap( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - If leftover < select_value => skip the edge. We'll set leftover(E)=0.5 => skip it => pick edgeC + If leftover < select_value => skip the edge. We'll set leftover(edgeE)=0.5 => skip it => pick edgeC. """ edge_map["edgeE"]["flow"] = 1.5 # leftover=0.5 select_func = edge_select_fabric( EdgeSelect.SINGLE_MIN_COST_WITH_CAP_REMAINING_LOAD_FACTORED, select_value=1.0 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == 500 assert chosen == ["edgeC"] -def test_all_any_cost_with_cap_no_valid(mock_graph, edge_map): +def test_all_any_cost_with_cap_no_valid( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ - leftover>=999 => none qualify => (inf, []). + leftover >= 999 => none qualify => (inf, []). """ select_func = edge_select_fabric( EdgeSelect.ALL_ANY_COST_WITH_CAP_REMAINING, select_value=999 ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) assert cost == float("inf") assert chosen == [] @@ -286,10 +321,13 @@ def test_all_any_cost_with_cap_no_valid(mock_graph, edge_map): # ------------------------------------------------------------------------------ -def test_user_defined_custom(mock_graph, edge_map): +def test_user_defined_custom( + mock_graph: StrictMultiDiGraph, + edge_map: Dict[EdgeID, AttrDict], +) -> None: """ Provide a user-defined function that picks edges with cost <=10 - and uses sum of costs as the cost. + and uses sum of costs as the returned cost. """ def custom_func( @@ -297,35 +335,35 @@ def custom_func( src: NodeID, dst: NodeID, edg_map: Dict[EdgeID, AttrDict], - ignored_edges: Set[EdgeID], - ignored_nodes: Set[NodeID], - ) -> Tuple[Cost, List[EdgeID]]: + excluded_edges: Set[EdgeID], + excluded_nodes: Set[NodeID], + ) -> Tuple[Cost, list]: chosen = [] total = 0.0 for eid, attrs in edg_map.items(): - if eid in ignored_edges: + if eid in excluded_edges: continue if attrs["cost"] <= 10: chosen.append(eid) total += attrs["cost"] if not chosen: return float("inf"), [] - return (total, chosen) + return total, chosen select_func = edge_select_fabric( EdgeSelect.USER_DEFINED, edge_select_func=custom_func ) cost, chosen = select_func( - mock_graph, "A", "B", edge_map, ignored_edges=set(), ignored_nodes=set() + mock_graph, "A", "B", edge_map, excluded_edges=set(), excluded_nodes=set() ) # Edges <=10 => A,B,C,E => sum=10+10+5+5=30 assert cost == 30 assert set(chosen) == {"edgeA", "edgeB", "edgeC", "edgeE"} -def test_user_defined_excludes_all(mock_graph): +def test_user_defined_excludes_all(mock_graph: StrictMultiDiGraph) -> None: """ - If user function always returns (inf, []), we confirm no edges are chosen. + If a user-defined function always returns (inf, []), confirm no edges are chosen. """ def exclude_all_func(*args, **kwargs): @@ -335,7 +373,7 @@ def exclude_all_func(*args, **kwargs): EdgeSelect.USER_DEFINED, edge_select_func=exclude_all_func ) cost, chosen = select_func( - mock_graph, "X", "Y", {}, ignored_edges=set(), ignored_nodes=set() + mock_graph, "X", "Y", {}, excluded_edges=set(), excluded_nodes=set() ) assert cost == float("inf") assert chosen == [] diff --git a/tests/test_blueprints_helpers.py b/tests/test_blueprints_helpers.py index d7a0901..7e3ed0a 100644 --- a/tests/test_blueprints_helpers.py +++ b/tests/test_blueprints_helpers.py @@ -36,7 +36,8 @@ def test_join_paths(): def test_apply_parameters(): """ - Tests _apply_parameters to ensure user-provided overrides get applied to the correct subgroup fields. + Tests _apply_parameters to ensure user-provided overrides get applied + to the correct subgroup fields. """ original_def = { "node_count": 4, @@ -79,9 +80,34 @@ def test_create_link(): assert link_obj.attrs["color"] == "red" +def test_create_link_multiple(): + """ + Tests _create_link with link_count=2 to ensure multiple parallel links are created. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + _create_link( + net, + "A", + "B", + {"capacity": 10, "cost": 1, "attrs": {"color": "green"}}, + link_count=2, + ) + + assert len(net.links) == 2 + for link_obj in net.links.values(): + assert link_obj.source == "A" + assert link_obj.target == "B" + assert link_obj.capacity == 10 + assert link_obj.cost == 1 + assert link_obj.attrs["color"] == "green" + + def test_expand_adjacency_pattern_one_to_one(): """ - Tests _expand_adjacency_pattern in 'one_to_one' mode for the simplest matching case (2:2). + Tests _expand_adjacency_pattern in 'one_to_one' mode for a simple 2:2 case. Should produce pairs: (S1->T1), (S2->T2). """ ctx_net = Network() @@ -106,8 +132,8 @@ def test_expand_adjacency_pattern_one_to_one(): def test_expand_adjacency_pattern_one_to_one_wrap(): """ - Tests 'one_to_one' with wrapping case: e.g., 4 vs 2. (both sides a multiple) - => S1->T1, S2->T2, S3->T1, S4->T2 => total 4 links + Tests 'one_to_one' with wrapping case: e.g., 4 vs 2. (both sides a multiple). + => S1->T1, S2->T2, S3->T1, S4->T2 => total 4 links. """ ctx_net = Network() # 4 source nodes @@ -140,7 +166,7 @@ def test_expand_adjacency_pattern_one_to_one_wrap(): def test_expand_adjacency_pattern_one_to_one_mismatch(): """ Tests 'one_to_one' with a mismatch (3 vs 2) => raises a ValueError - since 3 % 2 != 0 + since 3 % 2 != 0. """ ctx_net = Network() # 3 sources, 2 targets @@ -154,13 +180,14 @@ def test_expand_adjacency_pattern_one_to_one_mismatch(): with pytest.raises(ValueError) as exc: _expand_adjacency_pattern(ctx, "S", "T", "one_to_one", {}) - assert "requires either equal node counts or a valid wrap-around" in str(exc.value) + # Our error text checks + assert "requires sizes with a multiple factor" in str(exc.value) def test_expand_adjacency_pattern_mesh(): """ Tests _expand_adjacency_pattern in 'mesh' mode: all-to-all links among matched nodes, - with dedup so we don't double-link reversed pairs. + skipping self-loops and deduplicating reversed pairs. """ ctx_net = Network() ctx_net.add_node(Node("X1")) @@ -170,17 +197,36 @@ def test_expand_adjacency_pattern_mesh(): ctx = DSLExpansionContext({}, ctx_net) - # mesh => X1, X2 => Y1, Y2 => 4 links total + # mesh => X1,X2 => Y1,Y2 => 4 links total _expand_adjacency_pattern(ctx, "X", "Y", "mesh", {"capacity": 99}) assert len(ctx_net.links) == 4 for link in ctx_net.links.values(): assert link.capacity == 99 +def test_expand_adjacency_pattern_mesh_link_count(): + """ + Tests 'mesh' mode with link_count=2 to ensure multiple parallel links are created per pairing. + """ + ctx_net = Network() + ctx_net.add_node(Node("A1")) + ctx_net.add_node(Node("A2")) + ctx_net.add_node(Node("B1")) + ctx_net.add_node(Node("B2")) + + ctx = DSLExpansionContext({}, ctx_net) + + _expand_adjacency_pattern(ctx, "A", "B", "mesh", {"attrs": {"color": "purple"}}, 2) + # A1->B1, A1->B2, A2->B1, A2->B2 => each repeated 2 times => 8 total links + assert len(ctx_net.links) == 8 + for link in ctx_net.links.values(): + assert link.attrs.get("color") == "purple" + + def test_process_direct_nodes(): """ - Tests _process_direct_nodes to ensure direct node creation - works. + Tests _process_direct_nodes to ensure direct node creation works. + Existing nodes are not overwritten. """ net = Network() net.add_node(Node("Existing")) @@ -190,7 +236,7 @@ def test_process_direct_nodes(): "New1": {"foo": "bar"}, "Existing": { "override": "ignored" - }, # This won't be merged since node exists + }, # This won't be merged since node already exists }, } @@ -231,10 +277,37 @@ def test_process_direct_links(): assert link.capacity == 5 +def test_process_direct_links_link_count(): + """ + Tests _process_direct_links with link_count > 1 to ensure multiple parallel links. + """ + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2")) + + network_data = { + "links": [ + { + "source": "N1", + "target": "N2", + "link_params": {"capacity": 20}, + "link_count": 3, + } + ] + } + _process_direct_links(net, network_data) + + assert len(net.links) == 3 + for link_obj in net.links.values(): + assert link_obj.capacity == 20 + assert link_obj.source == "N1" + assert link_obj.target == "N2" + + def test_expand_blueprint_adjacency(): """ - Tests _expand_blueprint_adjacency: verifying that relative paths inside a blueprint are joined - with parent_path, then expanded as normal adjacency. + Tests _expand_blueprint_adjacency: verifying that relative paths inside a blueprint + are joined with parent_path, then expanded as normal adjacency. """ ctx_net = Network() ctx_net.add_node(Node("Parent/leaf-1")) @@ -291,7 +364,8 @@ def test_expand_adjacency(): def test_expand_group_direct(): """ - Tests _expand_group for a direct node group (no use_blueprint), ensuring node_count and name_template. + Tests _expand_group for a direct node group (no use_blueprint), + ensuring node_count and name_template usage. """ ctx_net = Network() ctx = DSLExpansionContext({}, ctx_net) @@ -362,6 +436,71 @@ def test_expand_group_blueprint(): assert sources_targets == {"Main/leaf/leaf-1", "Main/leaf/leaf-2"} +def test_expand_group_blueprint_with_params(): + """ + Tests _expand_group with blueprint usage and parameter overrides. + """ + bp = Blueprint( + name="bp1", + groups={ + "leaf": { + "node_count": 2, + "name_template": "leafy-{node_num}", + "node_attrs": {"role": "old"}, + }, + }, + adjacency=[], + ) + ctx_net = Network() + ctx = DSLExpansionContext(blueprints={"bp1": bp}, network=ctx_net) + + group_def = { + "use_blueprint": "bp1", + "parameters": { + # Overriding the name_template for the subgroup 'leaf' + "leaf.name_template": "newleaf-{node_num}", + "leaf.node_attrs.role": "updated", + }, + } + _expand_group( + ctx, + parent_path="ZoneA", + group_name="Main", + group_def=group_def, + blueprint_expansion=False, + ) + + # We expect 2 nodes => "ZoneA/Main/leaf/newleaf-1" and "...-2" + # plus the updated role attribute + assert len(ctx_net.nodes) == 2 + n1_name = "ZoneA/Main/leaf/newleaf-1" + n2_name = "ZoneA/Main/leaf/newleaf-2" + assert n1_name in ctx_net.nodes + assert n2_name in ctx_net.nodes + assert ctx_net.nodes[n1_name].attrs["role"] == "updated" + assert ctx_net.nodes[n2_name].attrs["role"] == "updated" + + +def test_expand_group_blueprint_unknown(): + """ + Tests _expand_group with a reference to an unknown blueprint -> ValueError. + """ + ctx_net = Network() + ctx = DSLExpansionContext(blueprints={}, network=ctx_net) + + group_def = { + "use_blueprint": "non_existent", + } + with pytest.raises(ValueError) as exc: + _expand_group( + ctx, + parent_path="", + group_name="Test", + group_def=group_def, + ) + assert "unknown blueprint 'non_existent'" in str(exc.value) + + def test_update_nodes(): """ Tests _update_nodes to ensure it updates matching node attributes in bulk. @@ -398,7 +537,7 @@ def test_update_links(): # Create some links net.add_link(Link("S1", "T1")) net.add_link(Link("S2", "T2")) - net.add_link(Link("T1", "S2")) # reversed + net.add_link(Link("T1", "S2")) # reversed direction # Update all links from S->T with capacity=999 _update_links(net, "S", "T", {"capacity": 999})