From 2634e84c9b97ca18e0590813a17d579f1e0b8eba Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 25 Feb 2025 01:07:49 +0000 Subject: [PATCH 1/2] CapacityProbe step prototype --- README.md | 44 +++---- ngraph/lib/algorithms/base.py | 14 +-- ngraph/lib/algorithms/edge_select.py | 12 +- ngraph/lib/path.py | 4 +- ngraph/lib/path_bundle.py | 8 +- ngraph/network.py | 77 +++++++++++- ngraph/workflow/__init__.py | 3 + ngraph/workflow/build_graph.py | 41 +----- ngraph/workflow/capacity_probe.py | 47 +++++++ notebooks/lib_examples.ipynb | 50 ++++---- tests/lib/algorithms/sample_graphs.py | 154 +++++++++++------------ tests/lib/algorithms/test_edge_select.py | 42 +++---- tests/lib/algorithms/test_max_flow.py | 2 +- tests/lib/algorithms/test_place_flow.py | 152 +++++++++++----------- tests/lib/algorithms/test_spf_bench.py | 16 +-- tests/lib/test_demand.py | 28 ++--- tests/lib/test_flow_policy.py | 72 +++++------ tests/lib/test_path.py | 12 +- tests/lib/test_path_bundle.py | 12 +- tests/lib/test_util.py | 90 ++++++------- tests/scenarios/scenario_3.yaml | 4 + tests/scenarios/test_scenario_3.py | 3 + tests/test_readme_examples.py | 36 +++--- tests/test_result.py | 8 +- tests/workflow/test_build_graph.py | 15 +-- 25 files changed, 521 insertions(+), 425 deletions(-) create mode 100644 ngraph/workflow/capacity_probe.py diff --git a/README.md b/README.md index 1951473..8680bb3 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,10 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict g.add_node("A") g.add_node("B") g.add_node("C") - g.add_edge("A", "B", metric=1, capacity=1) - g.add_edge("A", "B", metric=1, capacity=1) - g.add_edge("B", "C", metric=1, capacity=2) - g.add_edge("A", "C", metric=2, capacity=3) + g.add_edge("A", "B", cost=1, capacity=1) + g.add_edge("A", "B", cost=1, capacity=1) + g.add_edge("B", "C", cost=1, capacity=2) + g.add_edge("A", "C", cost=2, capacity=3) # Calculate MaxFlow between the source and destination nodes max_flow = calc_max_flow(g, "A", "C") @@ -136,7 +136,7 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict """ Tests max flow calculations on a graph with parallel edges. - Graph topology (metrics/capacities): + Graph topology (costs/capacities): [1,1] & [1,2] [1,1] & [1,2] A ──────────────────► B ─────────────► C @@ -145,10 +145,10 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict └───────────────────► D ───────────────┘ Edges: - - A→B: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2) - - B→C: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2) - - A→D: (metric=2, capacity=3) - - D→C: (metric=2, capacity=3) + - A→B: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2) + - B→C: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2) + - A→D: (cost=2, capacity=3) + - D→C: (cost=2, capacity=3) The test computes: - The true maximum flow (expected flow: 6.0) @@ -164,13 +164,13 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict g.add_node(node) # Create parallel edges between A→B and B→C - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("A", "B", key=1, metric=1, capacity=2) - g.add_edge("B", "C", key=2, metric=1, capacity=1) - g.add_edge("B", "C", key=3, metric=1, capacity=2) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("A", "B", key=1, cost=1, capacity=2) + g.add_edge("B", "C", key=2, cost=1, capacity=1) + g.add_edge("B", "C", key=3, cost=1, capacity=2) # Create an alternative path A→D→C - g.add_edge("A", "D", key=4, metric=2, capacity=3) - g.add_edge("D", "C", key=5, metric=2, capacity=3) + g.add_edge("A", "D", key=4, cost=2, capacity=3) + g.add_edge("D", "C", key=5, cost=2, capacity=3) # 1. The true maximum flow max_flow_prop = calc_max_flow(g, "A", "C") @@ -193,7 +193,7 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict """ Demonstrates traffic engineering by placing two demands on a network. - Graph topology (metrics/capacities): + Graph topology (costs/capacities): [15] A ─────── B @@ -219,12 +219,12 @@ Note: Don't forget to use a virtual environment (e.g., `venv`) to avoid conflict g.add_node(node) # Create bidirectional edges with distinct labels (for clarity). - g.add_edge("A", "B", key=0, metric=1, capacity=15, label="1") - g.add_edge("B", "A", key=1, metric=1, capacity=15, label="1") - g.add_edge("B", "C", key=2, metric=1, capacity=15, label="2") - g.add_edge("C", "B", key=3, metric=1, capacity=15, label="2") - g.add_edge("A", "C", key=4, metric=1, capacity=5, label="3") - g.add_edge("C", "A", key=5, metric=1, capacity=5, label="3") + g.add_edge("A", "B", key=0, cost=1, capacity=15, label="1") + g.add_edge("B", "A", key=1, cost=1, capacity=15, label="1") + g.add_edge("B", "C", key=2, cost=1, capacity=15, label="2") + g.add_edge("C", "B", key=3, cost=1, capacity=15, label="2") + g.add_edge("A", "C", key=4, cost=1, capacity=5, label="3") + g.add_edge("C", "A", key=5, cost=1, capacity=5, label="3") # Initialize flow-related structures (e.g., to track placed flows in the graph). flow_graph = init_flow_graph(g) diff --git a/ngraph/lib/algorithms/base.py b/ngraph/lib/algorithms/base.py index 59e3bfa..3e52d2a 100644 --- a/ngraph/lib/algorithms/base.py +++ b/ngraph/lib/algorithms/base.py @@ -40,18 +40,18 @@ class EdgeSelect(IntEnum): for path-finding between a node and its neighbor(s). """ - #: Return all edges matching the minimum metric among the candidate edges. + #: Return all edges matching the minimum cost among the candidate edges. ALL_MIN_COST = 1 - #: Return all edges matching the minimum metric among edges with remaining capacity. + #: Return all edges matching the minimum cost among edges with remaining capacity. ALL_MIN_COST_WITH_CAP_REMAINING = 2 - #: Return all edges that have remaining capacity, ignoring metric except for returning min_cost. + #: Return all edges that have remaining capacity, ignoring cost except for returning min_cost. ALL_ANY_COST_WITH_CAP_REMAINING = 3 - #: Return exactly one edge (the single lowest metric). + #: Return exactly one edge (the single lowest cost). SINGLE_MIN_COST = 4 - #: Return exactly one edge, the lowest-metric edge with remaining capacity. + #: Return exactly one edge, the lowest-cost edge with remaining capacity. SINGLE_MIN_COST_WITH_CAP_REMAINING = 5 - #: Return exactly one edge factoring both metric and load: - #: cost = (metric * 100) + round(flow / capacity * 10). + #: Return exactly one edge factoring both cost and load: + #: cost = (cost * 100) + round(flow / capacity * 10). SINGLE_MIN_COST_WITH_CAP_REMAINING_LOAD_FACTORED = 6 #: Use a user-defined function for edge selection logic. USER_DEFINED = 99 diff --git a/ngraph/lib/algorithms/edge_select.py b/ngraph/lib/algorithms/edge_select.py index 54fc2e6..8bb4877 100644 --- a/ngraph/lib/algorithms/edge_select.py +++ b/ngraph/lib/algorithms/edge_select.py @@ -24,7 +24,7 @@ def edge_select_fabric( ] = None, excluded_edges: Optional[Set[EdgeID]] = None, excluded_nodes: Optional[Set[NodeID]] = None, - cost_attr: str = "metric", + cost_attr: str = "cost", capacity_attr: str = "capacity", flow_attr: str = "flow", ) -> Callable[ @@ -73,7 +73,7 @@ def get_all_min_cost_edges( ignored_edges: Optional[Set[EdgeID]] = None, ignored_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: - """Return all edges with the minimal metric among those available.""" + """Return all edges with the minimal cost among those available.""" if ignored_nodes and dst_node in ignored_nodes: return float("inf"), [] @@ -101,7 +101,7 @@ def get_single_min_cost_edge( ignored_edges: Optional[Set[EdgeID]] = None, ignored_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: - """Return exactly one edge: the single lowest-metric edge.""" + """Return exactly one edge: the single lowest-cost edge.""" if ignored_nodes and dst_node in ignored_nodes: return float("inf"), [] @@ -128,7 +128,7 @@ def get_all_edges_with_cap_remaining( ) -> Tuple[Cost, List[EdgeID]]: """ Return all edges that have remaining capacity >= min_cap, ignoring - their metric except for reporting the minimal one found. + their cost except for reporting the minimal one found. """ if ignored_nodes and dst_node in ignored_nodes: return float("inf"), [] @@ -191,7 +191,7 @@ def get_single_min_cost_edge_with_cap_remaining( ignored_nodes: Optional[Set[NodeID]] = None, ) -> Tuple[Cost, List[EdgeID]]: """ - Return exactly one edge with the minimal metric among those with + Return exactly one edge with the minimal cost among those with remaining capacity >= min_cap. """ if ignored_nodes and dst_node in ignored_nodes: @@ -224,7 +224,7 @@ def get_single_min_cost_edge_with_cap_remaining_load_factored( """ Return exactly one edge, factoring both 'cost_attr' and load level into a combined cost: - combined_cost = (metric * 100) + round((flow / capacity) * 10) + combined_cost = (cost * 100) + round((flow / capacity) * 10) Only edges with remaining capacity >= min_cap are considered. """ if ignored_nodes and dst_node in ignored_nodes: diff --git a/ngraph/lib/path.py b/ngraph/lib/path.py index 63222b3..826a5c2 100644 --- a/ngraph/lib/path.py +++ b/ngraph/lib/path.py @@ -159,7 +159,7 @@ def get_sub_path( self, dst_node: NodeID, graph: StrictMultiDiGraph, - cost_attr: str = "metric", + cost_attr: str = "cost", ) -> Path: """ Create a sub-path ending at the specified destination node, recalculating the cost. @@ -172,7 +172,7 @@ def get_sub_path( Args: dst_node: The node at which to truncate the path. graph: The graph containing edge attributes. - cost_attr: The edge attribute name to use for cost (default is "metric"). + cost_attr: The edge attribute name to use for cost (default is "cost"). Returns: A new Path instance representing the sub-path from the original source to `dst_node`. diff --git a/ngraph/lib/path_bundle.py b/ngraph/lib/path_bundle.py index 9175f83..283e123 100644 --- a/ngraph/lib/path_bundle.py +++ b/ngraph/lib/path_bundle.py @@ -48,7 +48,7 @@ def __init__( ... } Typically generated by a shortest-path or multi-path algorithm. - cost: The total path cost (e.g. distance, metric) of all paths in the bundle. + cost: The total path cost (e.g. distance, cost) of all paths in the bundle. """ self.src_node: NodeID = src_node self.dst_node: NodeID = dst_node @@ -150,7 +150,7 @@ def from_path( resolve_edges: bool = False, graph: Optional[StrictMultiDiGraph] = None, edge_select: Optional[EdgeSelect] = None, - cost_attr: str = "metric", + cost_attr: str = "cost", capacity_attr: str = "capacity", ) -> PathBundle: """ @@ -162,7 +162,7 @@ def from_path( between each node pair via the provided `edge_select`. graph: The graph used for edge resolution (required if `resolve_edges=True`). edge_select: The selection criterion for picking edges if `resolve_edges=True`. - cost_attr: The attribute name on edges representing cost (e.g., 'metric'). + cost_attr: The attribute name on edges representing cost (e.g., 'cost'). capacity_attr: The attribute name on edges representing capacity. Returns: @@ -268,7 +268,7 @@ def get_sub_path_bundle( self, new_dst_node: NodeID, graph: StrictMultiDiGraph, - cost_attr: str = "metric", + cost_attr: str = "cost", ) -> PathBundle: """ Create a sub-bundle ending at `new_dst_node` (which must appear in this bundle). diff --git a/ngraph/network.py b/ngraph/network.py index 51c8ad9..9af081c 100644 --- a/ngraph/network.py +++ b/ngraph/network.py @@ -3,7 +3,9 @@ import uuid import base64 from dataclasses import dataclass, field -from typing import Any, Dict +from typing import Any, Dict, List + +from ngraph.lib.graph import StrictMultiDiGraph def new_base64_uuid() -> str: @@ -126,3 +128,76 @@ def add_link(self, link: Link) -> None: link.attrs.setdefault("type", "link") self.links[link.id] = link + + def to_strict_multidigraph(self, add_reverse: bool = True) -> StrictMultiDiGraph: + """ + Creates a StrictMultiDiGraph representation of this Network. + + Args: + add_reverse (bool): Add a reverse edge for each link + (default is True). + + Returns: + StrictMultiDiGraph: The directed multigraph representation + of this network, including node and link attributes. + """ + graph = StrictMultiDiGraph() + + # Add nodes + for node_name, node in self.nodes.items(): + graph.add_node(node_name, **node.attrs) + + # Add edges + for link_id, link in self.links.items(): + # Forward edge + graph.add_edge( + link.source, + link.target, + key=link.id, + capacity=link.capacity, + cost=link.cost, + **link.attrs, + ) + if add_reverse: + reverse_id = f"{link.id}_rev" + graph.add_edge( + link.target, + link.source, + key=reverse_id, + capacity=link.capacity, + cost=link.cost, + **link.attrs, + ) + + return graph + + def select_nodes_by_path(self, path: str) -> List[Node]: + """ + Returns all nodes whose name is exactly 'path' or begins with 'path/'. + If none are found, tries 'path-' as a fallback prefix. + If still none are found, tries partial prefix "path" => "pathX". + + Examples: + path="SEA/clos_instance/spine" might match "SEA/clos_instance/spine/myspine-1" + path="S" might match "S1", "S2" if we resort to partial prefix logic. + """ + # 1) Exact or slash-based + result = [ + n + for n in self.nodes.values() + if n.name == path or n.name.startswith(f"{path}/") + ] + if result: + return result + + # 2) Fallback: path- + result = [n for n in self.nodes.values() if n.name.startswith(f"{path}-")] + if result: + return result + + # 3) Partial + partial = [] + for n in self.nodes.values(): + if n.name.startswith(path) and n.name != path: + partial.append(n) + return partial diff --git a/ngraph/workflow/__init__.py b/ngraph/workflow/__init__.py index e69de29..11b24d1 100644 --- a/ngraph/workflow/__init__.py +++ b/ngraph/workflow/__init__.py @@ -0,0 +1,3 @@ +from .base import WorkflowStep, register_workflow_step +from .build_graph import BuildGraph +from .capacity_probe import CapacityProbe diff --git a/ngraph/workflow/build_graph.py b/ngraph/workflow/build_graph.py index bdbc604..ea14cac 100644 --- a/ngraph/workflow/build_graph.py +++ b/ngraph/workflow/build_graph.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING from ngraph.workflow.base import WorkflowStep, register_workflow_step -from ngraph.lib.graph import StrictMultiDiGraph if TYPE_CHECKING: from ngraph.scenario import Scenario @@ -13,45 +12,9 @@ @dataclass class BuildGraph(WorkflowStep): """ - A workflow step that uses Scenario.network to build a NetworkX MultiDiGraph. - - Since links in Network are conceptually bidirectional but we need unique identifiers - for each direction, we add two directed edges per link: - - forward edge: key = link.id - - reverse edge: key = link.id + "_rev" - - The constructed graph is stored in scenario.results under (self.name, "graph"). + A workflow step that builds a StrictMultiDiGraph from scenario.network. """ def run(self, scenario: Scenario) -> None: - # Create a MultiDiGraph to hold bidirectional edges - graph = StrictMultiDiGraph() - - # 1) Add nodes - for node_name, node in scenario.network.nodes.items(): - graph.add_node(node_name, **node.attrs) - - # 2) For each physical Link, add forward and reverse edges with unique keys - for link_id, link in scenario.network.links.items(): - # Forward edge uses link.id - graph.add_edge( - link.source, - link.target, - key=link.id, - capacity=link.capacity, - cost=link.cost, - **link.attrs, - ) - # Reverse edge uses link.id + "_rev" - reverse_id = f"{link.id}_rev" - graph.add_edge( - link.target, - link.source, - key=reverse_id, - capacity=link.capacity, - cost=link.cost, - **link.attrs, - ) - - # 3) Store the resulting graph + graph = scenario.network.to_strict_multidigraph(add_reverse=True) scenario.results.put(self.name, "graph", graph) diff --git a/ngraph/workflow/capacity_probe.py b/ngraph/workflow/capacity_probe.py new file mode 100644 index 0000000..4801fc0 --- /dev/null +++ b/ngraph/workflow/capacity_probe.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ngraph.workflow.base import WorkflowStep, register_workflow_step +from ngraph.lib.algorithms.max_flow import calc_max_flow + +if TYPE_CHECKING: + from ngraph.scenario import Scenario + + +@register_workflow_step("CapacityProbe") +@dataclass +class CapacityProbe(WorkflowStep): + """ + A workflow step that probes capacity between selected nodes. + + Attributes: + source_path (str): A path/prefix to match source nodes. + sink_path (str): A path/prefix to match sink nodes. + """ + + source_path: str = "" + sink_path: str = "" + + def run(self, scenario: Scenario) -> None: + # 1) Select source and sink nodes + sources = scenario.network.select_nodes_by_path(self.source_path) + sinks = scenario.network.select_nodes_by_path(self.sink_path) + + # 2) Build the graph + graph = scenario.network.to_strict_multidigraph() + + # 3) Attach pseudo-nodes the source and sink groups, then use max flow + # to calculate max flow between them + results = {} + graph.add_node("source") + graph.add_node("sink") + for source in sources: + graph.add_edge("source", source.name, capacity=float("inf"), cost=0) + for sink in sinks: + graph.add_edge(sink.name, "sink", capacity=float("inf"), cost=0) + flow = calc_max_flow(graph, "source", "sink") + + # 4) Store results in scenario + scenario.results.put(self.name, "max_flow", flow) diff --git a/notebooks/lib_examples.ipynb b/notebooks/lib_examples.ipynb index 24cb86c..b6013f5 100644 --- a/notebooks/lib_examples.ipynb +++ b/notebooks/lib_examples.ipynb @@ -32,12 +32,12 @@ "g.add_node(\"B\")\n", "g.add_node(\"C\")\n", "g.add_node(\"D\")\n", - "g.add_edge(\"A\", \"B\", metric=1, capacity=1)\n", - "g.add_edge(\"B\", \"C\", metric=1, capacity=1)\n", - "g.add_edge(\"A\", \"B\", metric=1, capacity=2)\n", - "g.add_edge(\"B\", \"C\", metric=1, capacity=2)\n", - "g.add_edge(\"A\", \"D\", metric=2, capacity=3)\n", - "g.add_edge(\"D\", \"C\", metric=2, capacity=3)\n", + "g.add_edge(\"A\", \"B\", cost=1, capacity=1)\n", + "g.add_edge(\"B\", \"C\", cost=1, capacity=1)\n", + "g.add_edge(\"A\", \"B\", cost=1, capacity=2)\n", + "g.add_edge(\"B\", \"C\", cost=1, capacity=2)\n", + "g.add_edge(\"A\", \"D\", cost=2, capacity=3)\n", + "g.add_edge(\"D\", \"C\", cost=2, capacity=3)\n", "\n", "# Calculate MaxFlow between the source and destination nodes\n", "max_flow = calc_max_flow(g, \"A\", \"C\")\n", @@ -47,14 +47,14 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Tests max flow calculations on a graph with parallel edges.\n", "\n", - "Graph topology (metrics/capacities):\n", + "Graph topology (costs/capacities):\n", "\n", " [1,1] & [1,2] [1,1] & [1,2]\n", " A ──────────────────► B ─────────────► C\n", @@ -63,10 +63,10 @@ " └───────────────────► D ───────────────┘\n", "\n", "Edges:\n", - "- A→B: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2)\n", - "- B→C: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2)\n", - "- A→D: (metric=2, capacity=3)\n", - "- D→C: (metric=2, capacity=3)\n", + "- A→B: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2)\n", + "- B→C: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2)\n", + "- A→D: (cost=2, capacity=3)\n", + "- D→C: (cost=2, capacity=3)\n", "\n", "The test computes:\n", "- The true maximum flow (expected flow: 6.0)\n", @@ -83,13 +83,13 @@ " g.add_node(node)\n", "\n", "# Create parallel edges between A→B and B→C\n", - "g.add_edge(\"A\", \"B\", key=0, metric=1, capacity=1)\n", - "g.add_edge(\"A\", \"B\", key=1, metric=1, capacity=2)\n", - "g.add_edge(\"B\", \"C\", key=2, metric=1, capacity=1)\n", - "g.add_edge(\"B\", \"C\", key=3, metric=1, capacity=2)\n", + "g.add_edge(\"A\", \"B\", key=0, cost=1, capacity=1)\n", + "g.add_edge(\"A\", \"B\", key=1, cost=1, capacity=2)\n", + "g.add_edge(\"B\", \"C\", key=2, cost=1, capacity=1)\n", + "g.add_edge(\"B\", \"C\", key=3, cost=1, capacity=2)\n", "# Create an alternative path A→D→C\n", - "g.add_edge(\"A\", \"D\", key=4, metric=2, capacity=3)\n", - "g.add_edge(\"D\", \"C\", key=5, metric=2, capacity=3)\n", + "g.add_edge(\"A\", \"D\", key=4, cost=2, capacity=3)\n", + "g.add_edge(\"D\", \"C\", key=5, cost=2, capacity=3)\n", "\n", "# 1. The true maximum flow\n", "max_flow_prop = calc_max_flow(g, \"A\", \"C\")\n", @@ -115,7 +115,7 @@ "\"\"\"\n", "Demonstrates traffic engineering by placing two demands on a network.\n", "\n", - "Graph topology (metrics/capacities):\n", + "Graph topology (costs/capacities):\n", "\n", " [15]\n", " A ─────── B\n", @@ -142,12 +142,12 @@ " g.add_node(node)\n", "\n", "# Create bidirectional edges with distinct labels (for clarity).\n", - "g.add_edge(\"A\", \"B\", key=0, metric=1, capacity=15, label=\"1\")\n", - "g.add_edge(\"B\", \"A\", key=1, metric=1, capacity=15, label=\"1\")\n", - "g.add_edge(\"B\", \"C\", key=2, metric=1, capacity=15, label=\"2\")\n", - "g.add_edge(\"C\", \"B\", key=3, metric=1, capacity=15, label=\"2\")\n", - "g.add_edge(\"A\", \"C\", key=4, metric=1, capacity=5, label=\"3\")\n", - "g.add_edge(\"C\", \"A\", key=5, metric=1, capacity=5, label=\"3\")\n", + "g.add_edge(\"A\", \"B\", key=0, cost=1, capacity=15, label=\"1\")\n", + "g.add_edge(\"B\", \"A\", key=1, cost=1, capacity=15, label=\"1\")\n", + "g.add_edge(\"B\", \"C\", key=2, cost=1, capacity=15, label=\"2\")\n", + "g.add_edge(\"C\", \"B\", key=3, cost=1, capacity=15, label=\"2\")\n", + "g.add_edge(\"A\", \"C\", key=4, cost=1, capacity=5, label=\"3\")\n", + "g.add_edge(\"C\", \"A\", key=5, cost=1, capacity=5, label=\"3\")\n", "\n", "# Initialize flow-related structures (e.g., to track placed flows in the graph).\n", "flow_graph = init_flow_graph(g)\n", diff --git a/tests/lib/algorithms/sample_graphs.py b/tests/lib/algorithms/sample_graphs.py index 64f76ed..e66daaf 100644 --- a/tests/lib/algorithms/sample_graphs.py +++ b/tests/lib/algorithms/sample_graphs.py @@ -19,14 +19,14 @@ def line1(): g.add_node("B") g.add_node("C") - g.add_edge("A", "B", key=0, metric=1, capacity=5) - g.add_edge("B", "A", key=1, metric=1, capacity=5) - g.add_edge("B", "C", key=2, metric=1, capacity=1) - g.add_edge("C", "B", key=3, metric=1, capacity=1) - g.add_edge("B", "C", key=4, metric=1, capacity=3) - g.add_edge("C", "B", key=5, metric=1, capacity=3) - g.add_edge("B", "C", key=6, metric=2, capacity=7) - g.add_edge("C", "B", key=7, metric=2, capacity=7) + g.add_edge("A", "B", key=0, cost=1, capacity=5) + g.add_edge("B", "A", key=1, cost=1, capacity=5) + g.add_edge("B", "C", key=2, cost=1, capacity=1) + g.add_edge("C", "B", key=3, cost=1, capacity=1) + g.add_edge("B", "C", key=4, cost=1, capacity=3) + g.add_edge("C", "B", key=5, cost=1, capacity=3) + g.add_edge("B", "C", key=6, cost=2, capacity=7) + g.add_edge("C", "B", key=7, cost=2, capacity=7) return g @@ -56,12 +56,12 @@ def triangle1(): g.add_node("B") g.add_node("C") - g.add_edge("A", "B", key=0, metric=1, capacity=15, label="1") - g.add_edge("B", "A", key=1, metric=1, capacity=15, label="1") - g.add_edge("B", "C", key=2, metric=1, capacity=15, label="2") - g.add_edge("C", "B", key=3, metric=1, capacity=15, label="2") - g.add_edge("A", "C", key=4, metric=1, capacity=5, label="3") - g.add_edge("C", "A", key=5, metric=1, capacity=5, label="3") + g.add_edge("A", "B", key=0, cost=1, capacity=15, label="1") + g.add_edge("B", "A", key=1, cost=1, capacity=15, label="1") + g.add_edge("B", "C", key=2, cost=1, capacity=15, label="2") + g.add_edge("C", "B", key=3, cost=1, capacity=15, label="2") + g.add_edge("A", "C", key=4, cost=1, capacity=5, label="3") + g.add_edge("C", "A", key=5, cost=1, capacity=5, label="3") return g @@ -83,10 +83,10 @@ def square1(): for node in ("A", "B", "C", "D"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("B", "C", key=1, metric=1, capacity=1) - g.add_edge("A", "D", key=2, metric=2, capacity=2) - g.add_edge("D", "C", key=3, metric=2, capacity=2) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("B", "C", key=1, cost=1, capacity=1) + g.add_edge("A", "D", key=2, cost=2, capacity=2) + g.add_edge("D", "C", key=3, cost=2, capacity=2) return g @@ -117,10 +117,10 @@ def square2(): for node in ("A", "B", "C", "D"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("B", "C", key=1, metric=1, capacity=1) - g.add_edge("A", "D", key=2, metric=1, capacity=2) - g.add_edge("D", "C", key=3, metric=1, capacity=2) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("B", "C", key=1, cost=1, capacity=1) + g.add_edge("A", "D", key=2, cost=1, capacity=2) + g.add_edge("D", "C", key=3, cost=1, capacity=2) return g @@ -151,12 +151,12 @@ def square3(): for node in ("A", "B", "C", "D"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=100) - g.add_edge("B", "C", key=1, metric=1, capacity=125) - g.add_edge("A", "D", key=2, metric=1, capacity=75) - g.add_edge("D", "C", key=3, metric=1, capacity=50) - g.add_edge("B", "D", key=4, metric=1, capacity=50) - g.add_edge("D", "B", key=5, metric=1, capacity=50) + g.add_edge("A", "B", key=0, cost=1, capacity=100) + g.add_edge("B", "C", key=1, cost=1, capacity=125) + g.add_edge("A", "D", key=2, cost=1, capacity=75) + g.add_edge("D", "C", key=3, cost=1, capacity=50) + g.add_edge("B", "D", key=4, cost=1, capacity=50) + g.add_edge("D", "B", key=5, cost=1, capacity=50) return g @@ -187,15 +187,15 @@ def square4(): for node in ("A", "B", "C", "D"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=100) - g.add_edge("B", "C", key=1, metric=1, capacity=125) - g.add_edge("A", "D", key=2, metric=1, capacity=75) - g.add_edge("D", "C", key=3, metric=1, capacity=50) - g.add_edge("B", "D", key=4, metric=1, capacity=50) - g.add_edge("D", "B", key=5, metric=1, capacity=50) - g.add_edge("A", "B", key=6, metric=2, capacity=200) - g.add_edge("B", "D", key=7, metric=2, capacity=200) - g.add_edge("D", "C", key=8, metric=2, capacity=200) + g.add_edge("A", "B", key=0, cost=1, capacity=100) + g.add_edge("B", "C", key=1, cost=1, capacity=125) + g.add_edge("A", "D", key=2, cost=1, capacity=75) + g.add_edge("D", "C", key=3, cost=1, capacity=50) + g.add_edge("B", "D", key=4, cost=1, capacity=50) + g.add_edge("D", "B", key=5, cost=1, capacity=50) + g.add_edge("A", "B", key=6, cost=2, capacity=200) + g.add_edge("B", "D", key=7, cost=2, capacity=200) + g.add_edge("D", "C", key=8, cost=2, capacity=200) return g @@ -226,12 +226,12 @@ def square5(): for node in ("A", "B", "C", "D"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("A", "C", key=1, metric=1, capacity=1) - g.add_edge("B", "D", key=2, metric=1, capacity=1) - g.add_edge("C", "D", key=3, metric=1, capacity=1) - g.add_edge("B", "C", key=4, metric=1, capacity=1) - g.add_edge("C", "B", key=5, metric=1, capacity=1) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("A", "C", key=1, cost=1, capacity=1) + g.add_edge("B", "D", key=2, cost=1, capacity=1) + g.add_edge("C", "D", key=3, cost=1, capacity=1) + g.add_edge("B", "C", key=4, cost=1, capacity=1) + g.add_edge("C", "B", key=5, cost=1, capacity=1) return g @@ -262,13 +262,13 @@ def graph1(): for node in ("A", "B", "C", "D", "E"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("A", "C", key=1, metric=1, capacity=1) - g.add_edge("B", "D", key=2, metric=1, capacity=1) - g.add_edge("C", "D", key=3, metric=1, capacity=1) - g.add_edge("B", "C", key=4, metric=1, capacity=1) - g.add_edge("C", "B", key=5, metric=1, capacity=1) - g.add_edge("D", "E", key=6, metric=1, capacity=1) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("A", "C", key=1, cost=1, capacity=1) + g.add_edge("B", "D", key=2, cost=1, capacity=1) + g.add_edge("C", "D", key=3, cost=1, capacity=1) + g.add_edge("B", "C", key=4, cost=1, capacity=1) + g.add_edge("C", "B", key=5, cost=1, capacity=1) + g.add_edge("D", "E", key=6, cost=1, capacity=1) return g @@ -299,13 +299,13 @@ def graph2(): for node in ("A", "B", "C", "D", "E"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("B", "C", key=1, metric=1, capacity=1) - g.add_edge("B", "D", key=2, metric=1, capacity=1) - g.add_edge("C", "D", key=3, metric=1, capacity=1) - g.add_edge("D", "C", key=4, metric=1, capacity=1) - g.add_edge("C", "E", key=5, metric=1, capacity=1) - g.add_edge("D", "E", key=6, metric=1, capacity=1) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("B", "C", key=1, cost=1, capacity=1) + g.add_edge("B", "D", key=2, cost=1, capacity=1) + g.add_edge("C", "D", key=3, cost=1, capacity=1) + g.add_edge("D", "C", key=4, cost=1, capacity=1) + g.add_edge("C", "E", key=5, cost=1, capacity=1) + g.add_edge("D", "E", key=6, cost=1, capacity=1) return g @@ -344,18 +344,18 @@ def graph3(): for node in ("A", "B", "C", "D", "E", "F"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=2) - g.add_edge("A", "B", key=1, metric=1, capacity=4) - g.add_edge("A", "B", key=2, metric=1, capacity=6) - g.add_edge("B", "C", key=3, metric=1, capacity=1) - g.add_edge("B", "C", key=4, metric=1, capacity=2) - g.add_edge("B", "C", key=5, metric=1, capacity=3) - g.add_edge("C", "D", key=6, metric=2, capacity=3) - g.add_edge("A", "E", key=7, metric=1, capacity=5) - g.add_edge("E", "C", key=8, metric=1, capacity=4) - g.add_edge("A", "D", key=9, metric=4, capacity=2) - g.add_edge("C", "F", key=10, metric=1, capacity=1) - g.add_edge("F", "D", key=11, metric=1, capacity=2) + g.add_edge("A", "B", key=0, cost=1, capacity=2) + g.add_edge("A", "B", key=1, cost=1, capacity=4) + g.add_edge("A", "B", key=2, cost=1, capacity=6) + g.add_edge("B", "C", key=3, cost=1, capacity=1) + g.add_edge("B", "C", key=4, cost=1, capacity=2) + g.add_edge("B", "C", key=5, cost=1, capacity=3) + g.add_edge("C", "D", key=6, cost=2, capacity=3) + g.add_edge("A", "E", key=7, cost=1, capacity=5) + g.add_edge("E", "C", key=8, cost=1, capacity=4) + g.add_edge("A", "D", key=9, cost=4, capacity=2) + g.add_edge("C", "F", key=10, cost=1, capacity=1) + g.add_edge("F", "D", key=11, cost=1, capacity=2) return g @@ -386,18 +386,18 @@ def graph4(): for node in ("A", "B", "B1", "B2", "C"): g.add_node(node) - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("B", "C", key=1, metric=1, capacity=1) - g.add_edge("A", "B1", key=2, metric=2, capacity=2) - g.add_edge("B1", "C", key=3, metric=2, capacity=2) - g.add_edge("A", "B2", key=4, metric=3, capacity=3) - g.add_edge("B2", "C", key=5, metric=3, capacity=3) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("B", "C", key=1, cost=1, capacity=1) + g.add_edge("A", "B1", key=2, cost=2, capacity=2) + g.add_edge("B1", "C", key=3, cost=2, capacity=2) + g.add_edge("A", "B2", key=4, cost=3, capacity=3) + g.add_edge("B2", "C", key=5, cost=3, capacity=3) return g @pytest.fixture def graph5(): - """Fully connected graph with 5 nodes, each edge has metric=1, capacity=1.""" + """Fully connected graph with 5 nodes, each edge has cost=1, capacity=1.""" g = StrictMultiDiGraph() for node in ("A", "B", "C", "D", "E"): g.add_node(node) @@ -407,7 +407,7 @@ def graph5(): for src in nodes: for dst in nodes: if src != dst: - g.add_edge(src, dst, key=edge_id, metric=1, capacity=1) + g.add_edge(src, dst, key=edge_id, cost=1, capacity=1) edge_id += 1 return g diff --git a/tests/lib/algorithms/test_edge_select.py b/tests/lib/algorithms/test_edge_select.py index 7983f00..a29a81e 100644 --- a/tests/lib/algorithms/test_edge_select.py +++ b/tests/lib/algorithms/test_edge_select.py @@ -17,14 +17,14 @@ def mock_graph() -> StrictMultiDiGraph: @pytest.fixture def edge_map() -> Dict[EdgeID, AttrDict]: """ - A basic edge_map with varying metrics/capacities/flows. + A basic edge_map with varying costs/capacities/flows. """ return { - "edgeA": {"metric": 10, "capacity": 100, "flow": 0}, # leftover=100 - "edgeB": {"metric": 10, "capacity": 50, "flow": 25}, # leftover=25 - "edgeC": {"metric": 5, "capacity": 10, "flow": 0}, # leftover=10 - "edgeD": {"metric": 20, "capacity": 10, "flow": 5}, # leftover=5 - "edgeE": {"metric": 5, "capacity": 2, "flow": 1}, # leftover=1 + "edgeA": {"cost": 10, "capacity": 100, "flow": 0}, # leftover=100 + "edgeB": {"cost": 10, "capacity": 50, "flow": 25}, # leftover=25 + "edgeC": {"cost": 5, "capacity": 10, "flow": 0}, # leftover=10 + "edgeD": {"cost": 20, "capacity": 10, "flow": 5}, # leftover=5 + "edgeE": {"cost": 5, "capacity": 2, "flow": 1}, # leftover=1 } @@ -99,13 +99,13 @@ def test_all_min_cost_tie_break(mock_graph): We'll make the difference strictly < 1e-12 so they are recognized as equal. """ edge_map_ = { - "e1": {"metric": 10.0, "capacity": 50, "flow": 0}, + "e1": {"cost": 10.0, "capacity": 50, "flow": 0}, "e2": { - "metric": 10.0000000000005, + "cost": 10.0000000000005, "capacity": 50, "flow": 0, }, # diff=5e-13 < 1e-12 - "e3": {"metric": 12.0, "capacity": 50, "flow": 0}, + "e3": {"cost": 12.0, "capacity": 50, "flow": 0}, } select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( @@ -121,8 +121,8 @@ def test_all_min_cost_no_valid(mock_graph): If all edges are in ignored_edges, we get (inf, []) from ALL_MIN_COST. """ edge_map_ = { - "e1": {"metric": 10, "capacity": 50, "flow": 0}, - "e2": {"metric": 20, "capacity": 50, "flow": 0}, + "e1": {"cost": 10, "capacity": 50, "flow": 0}, + "e2": {"cost": 20, "capacity": 50, "flow": 0}, } select_func = edge_select_fabric(EdgeSelect.ALL_MIN_COST) cost, edges = select_func( @@ -156,7 +156,7 @@ def test_edge_select_excluded_edges(mock_graph, edge_map): def test_edge_select_all_min_cost(mock_graph, edge_map): - """ALL_MIN_COST => all edges with minimal metric => 5 => edgeC, edgeE.""" + """ALL_MIN_COST => all edges with minimal cost => 5 => edgeC, edgeE.""" 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() @@ -167,7 +167,7 @@ def test_edge_select_all_min_cost(mock_graph, edge_map): def test_edge_select_single_min_cost(mock_graph, edge_map): """ - SINGLE_MIN_COST => one edge with min metric => 5 => either edgeC or edgeE. + SINGLE_MIN_COST => one edge with min cost => 5 => either edgeC or edgeE. """ select_func = edge_select_fabric(EdgeSelect.SINGLE_MIN_COST) cost, chosen = select_func( @@ -180,7 +180,7 @@ def test_edge_select_single_min_cost(mock_graph, edge_map): def test_edge_select_all_min_cost_with_cap(mock_graph, edge_map): """ - ALL_MIN_COST_WITH_CAP_REMAINING => leftover>=10 => edgesA,B,C => among them, metric=5 => edgeC + ALL_MIN_COST_WITH_CAP_REMAINING => leftover>=10 => edgesA,B,C => among them, cost=5 => edgeC so cost=5, chosen=[edgeC] """ select_func = edge_select_fabric( @@ -196,7 +196,7 @@ def test_edge_select_all_min_cost_with_cap(mock_graph, edge_map): def test_edge_select_all_any_cost_with_cap(mock_graph, edge_map): """ ALL_ANY_COST_WITH_CAP_REMAINING => leftover>=10 => edgesA,B,C. We return all three, ignoring - metric except for returning min metric => 5 + cost except for returning min cost => 5 """ select_func = edge_select_fabric( EdgeSelect.ALL_ANY_COST_WITH_CAP_REMAINING, select_value=10 @@ -211,7 +211,7 @@ def test_edge_select_all_any_cost_with_cap(mock_graph, edge_map): def test_edge_select_single_min_cost_with_cap_remaining(mock_graph, edge_map): """ SINGLE_MIN_COST_WITH_CAP_REMAINING => leftover>=5 => edgesA(100),B(25),C(10),D(5). - among them, min metric=5 => edgeC + among them, min cost=5 => edgeC """ select_func = edge_select_fabric( EdgeSelect.SINGLE_MIN_COST_WITH_CAP_REMAINING, select_value=5 @@ -239,7 +239,7 @@ def test_edge_select_single_min_cost_with_cap_remaining_no_valid(mock_graph, edg def test_edge_select_single_min_cost_load_factored(mock_graph, edge_map): """ - cost= metric*100 + round((flow/capacity)*10). Among leftover>=MIN_CAP => all edges. + cost= cost*100 + round((flow/capacity)*10). Among leftover>=MIN_CAP => all edges. edgeC => 5*100+0=500 => minimum => pick edgeC """ select_func = edge_select_fabric( @@ -288,8 +288,8 @@ def test_all_any_cost_with_cap_no_valid(mock_graph, edge_map): def test_user_defined_custom(mock_graph, edge_map): """ - Provide a user-defined function that picks edges with metric <=10 - and uses sum of metrics as the cost. + Provide a user-defined function that picks edges with cost <=10 + and uses sum of costs as the cost. """ def custom_func( @@ -305,9 +305,9 @@ def custom_func( for eid, attrs in edg_map.items(): if eid in ignored_edges: continue - if attrs["metric"] <= 10: + if attrs["cost"] <= 10: chosen.append(eid) - total += attrs["metric"] + total += attrs["cost"] if not chosen: return float("inf"), [] return (total, chosen) diff --git a/tests/lib/algorithms/test_max_flow.py b/tests/lib/algorithms/test_max_flow.py index a83a8cf..e32e745 100644 --- a/tests/lib/algorithms/test_max_flow.py +++ b/tests/lib/algorithms/test_max_flow.py @@ -164,7 +164,7 @@ def test_zero_capacity_edges(self): g = StrictMultiDiGraph() g.add_node("A") g.add_node("B") - g.add_edge("A", "B", capacity=0.0, metric=1) + g.add_edge("A", "B", capacity=0.0, cost=1) max_flow = calc_max_flow(g, "A", "B") assert max_flow == 0.0 diff --git a/tests/lib/algorithms/test_place_flow.py b/tests/lib/algorithms/test_place_flow.py index 6cb0119..5156985 100644 --- a/tests/lib/algorithms/test_place_flow.py +++ b/tests/lib/algorithms/test_place_flow.py @@ -41,39 +41,39 @@ def test_place_flow_on_graph_line1_proportional(self, line1): "B", 0, { - "metric": 1, + "cost": 1, "capacity": 5, "flow": 4.0, "flows": {("A", "C", "TEST"): 4.0}, }, ), - 1: ("B", "A", 1, {"metric": 1, "capacity": 5, "flow": 0, "flows": {}}), + 1: ("B", "A", 1, {"cost": 1, "capacity": 5, "flow": 0, "flows": {}}), 2: ( "B", "C", 2, { - "metric": 1, + "cost": 1, "capacity": 1, "flow": 1.0, "flows": {("A", "C", "TEST"): 1.0}, }, ), - 3: ("C", "B", 3, {"metric": 1, "capacity": 1, "flow": 0, "flows": {}}), + 3: ("C", "B", 3, {"cost": 1, "capacity": 1, "flow": 0, "flows": {}}), 4: ( "B", "C", 4, { - "metric": 1, + "cost": 1, "capacity": 3, "flow": 3.0, "flows": {("A", "C", "TEST"): 3.0}, }, ), - 5: ("C", "B", 5, {"metric": 1, "capacity": 3, "flow": 0, "flows": {}}), - 6: ("B", "C", 6, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), - 7: ("C", "B", 7, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), + 5: ("C", "B", 5, {"cost": 1, "capacity": 3, "flow": 0, "flows": {}}), + 6: ("B", "C", 6, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), + 7: ("C", "B", 7, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), } assert flow_placement_meta.nodes == {"A", "C", "B"} assert flow_placement_meta.edges == {0, 2, 4} @@ -110,10 +110,10 @@ def test_place_flow_on_graph_line1_equal(self, line1): "capacity": 5, "flow": 2.0, "flows": {("A", "C", "TEST"): 2.0}, - "metric": 1, + "cost": 1, }, ), - 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "metric": 1}), + 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "cost": 1}), 2: ( "B", "C", @@ -122,10 +122,10 @@ def test_place_flow_on_graph_line1_equal(self, line1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "TEST"): 1.0}, - "metric": 1, + "cost": 1, }, ), - 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), + 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), 4: ( "B", "C", @@ -134,12 +134,12 @@ def test_place_flow_on_graph_line1_equal(self, line1): "capacity": 3, "flow": 1.0, "flows": {("A", "C", "TEST"): 1.0}, - "metric": 1, + "cost": 1, }, ), - 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "metric": 1}), - 6: ("B", "C", 6, {"capacity": 7, "flow": 0, "flows": {}, "metric": 2}), - 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "metric": 2}), + 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 1}), + 6: ("B", "C", 6, {"capacity": 7, "flow": 0, "flows": {}, "cost": 2}), + 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "cost": 2}), } assert flow_placement_meta.nodes == {"A", "C", "B"} assert flow_placement_meta.edges == {0, 2, 4} @@ -187,39 +187,39 @@ def test_place_flow_on_graph_line1_proportional(self, line1): "B", 0, { - "metric": 1, + "cost": 1, "capacity": 5, "flow": 4.0, "flows": {("A", "C", None): 4.0}, }, ), - 1: ("B", "A", 1, {"metric": 1, "capacity": 5, "flow": 0, "flows": {}}), + 1: ("B", "A", 1, {"cost": 1, "capacity": 5, "flow": 0, "flows": {}}), 2: ( "B", "C", 2, { - "metric": 1, + "cost": 1, "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, }, ), - 3: ("C", "B", 3, {"metric": 1, "capacity": 1, "flow": 0, "flows": {}}), + 3: ("C", "B", 3, {"cost": 1, "capacity": 1, "flow": 0, "flows": {}}), 4: ( "B", "C", 4, { - "metric": 1, + "cost": 1, "capacity": 3, "flow": 3.0, "flows": {("A", "C", None): 3.0}, }, ), - 5: ("C", "B", 5, {"metric": 1, "capacity": 3, "flow": 0, "flows": {}}), - 6: ("B", "C", 6, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), - 7: ("C", "B", 7, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), + 5: ("C", "B", 5, {"cost": 1, "capacity": 3, "flow": 0, "flows": {}}), + 6: ("B", "C", 6, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), + 7: ("C", "B", 7, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), } def test_place_flow_on_graph_graph3_proportional_1(self, graph3): @@ -254,7 +254,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 2, "flow": 1.0, "flows": {("A", "C", None): 1.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -265,7 +265,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 4, "flow": 2.0, "flows": {("A", "C", None): 2.0}, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -276,7 +276,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 6, "flow": 3.0, "flows": {("A", "C", None): 3.0}, - "metric": 1, + "cost": 1, }, ), 3: ( @@ -287,7 +287,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, - "metric": 1, + "cost": 1, }, ), 4: ( @@ -298,7 +298,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 2, "flow": 2.0, "flows": {("A", "C", None): 2.0}, - "metric": 1, + "cost": 1, }, ), 5: ( @@ -309,10 +309,10 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 3, "flow": 3.0, "flows": {("A", "C", None): 3.0}, - "metric": 1, + "cost": 1, }, ), - 6: ("C", "D", 6, {"capacity": 3, "flow": 0, "flows": {}, "metric": 2}), + 6: ("C", "D", 6, {"capacity": 3, "flow": 0, "flows": {}, "cost": 2}), 7: ( "A", "E", @@ -321,7 +321,7 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 5, "flow": 4.0, "flows": {("A", "C", None): 4.0}, - "metric": 1, + "cost": 1, }, ), 8: ( @@ -332,12 +332,12 @@ def test_place_flow_on_graph_graph3_proportional_1(self, graph3): "capacity": 4, "flow": 4.0, "flows": {("A", "C", None): 4.0}, - "metric": 1, + "cost": 1, }, ), - 9: ("A", "D", 9, {"capacity": 2, "flow": 0, "flows": {}, "metric": 4}), - 10: ("C", "F", 10, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), - 11: ("F", "D", 11, {"capacity": 2, "flow": 0, "flows": {}, "metric": 1}), + 9: ("A", "D", 9, {"capacity": 2, "flow": 0, "flows": {}, "cost": 4}), + 10: ("C", "F", 10, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), + 11: ("F", "D", 11, {"capacity": 2, "flow": 0, "flows": {}, "cost": 1}), } assert flow_placement_meta.nodes == {"A", "E", "B", "C"} assert flow_placement_meta.edges == {0, 1, 2, 3, 4, 5, 7, 8} @@ -374,7 +374,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 2, "flow": 0.6666666666666666, "flows": {("A", "D", None): 0.6666666666666666}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -385,7 +385,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 4, "flow": 1.3333333333333333, "flows": {("A", "D", None): 1.3333333333333333}, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -396,7 +396,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 6, "flow": 2.0, "flows": {("A", "D", None): 2.0}, - "metric": 1, + "cost": 1, }, ), 3: ( @@ -407,7 +407,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 1, "flow": 0.6666666666666666, "flows": {("A", "D", None): 0.6666666666666666}, - "metric": 1, + "cost": 1, }, ), 4: ( @@ -418,7 +418,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 2, "flow": 1.3333333333333333, "flows": {("A", "D", None): 1.3333333333333333}, - "metric": 1, + "cost": 1, }, ), 5: ( @@ -429,7 +429,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 3, "flow": 2.0, "flows": {("A", "D", None): 2.0}, - "metric": 1, + "cost": 1, }, ), 6: ( @@ -440,11 +440,11 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 3, "flow": 3.0, "flows": {("A", "D", None): 3.0}, - "metric": 2, + "cost": 2, }, ), - 7: ("A", "E", 7, {"capacity": 5, "flow": 0, "flows": {}, "metric": 1}), - 8: ("E", "C", 8, {"capacity": 4, "flow": 0, "flows": {}, "metric": 1}), + 7: ("A", "E", 7, {"capacity": 5, "flow": 0, "flows": {}, "cost": 1}), + 8: ("E", "C", 8, {"capacity": 4, "flow": 0, "flows": {}, "cost": 1}), 9: ( "A", "D", @@ -453,7 +453,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 2, "flow": 2.0, "flows": {("A", "D", None): 2.0}, - "metric": 4, + "cost": 4, }, ), 10: ( @@ -464,7 +464,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 1, "flow": 1.0, "flows": {("A", "D", None): 1.0}, - "metric": 1, + "cost": 1, }, ), 11: ( @@ -475,7 +475,7 @@ def test_place_flow_on_graph_graph3_proportional_2(self, graph3): "capacity": 2, "flow": 1.0, "flows": {("A", "D", None): 1.0}, - "metric": 1, + "cost": 1, }, ), } @@ -508,39 +508,39 @@ def test_place_flow_on_graph_line1_balanced_1(self, line1): "B", 0, { - "metric": 1, + "cost": 1, "capacity": 5, "flow": 2.0, "flows": {("A", "C", None): 2.0}, }, ), - 1: ("B", "A", 1, {"metric": 1, "capacity": 5, "flow": 0, "flows": {}}), + 1: ("B", "A", 1, {"cost": 1, "capacity": 5, "flow": 0, "flows": {}}), 2: ( "B", "C", 2, { - "metric": 1, + "cost": 1, "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, }, ), - 3: ("C", "B", 3, {"metric": 1, "capacity": 1, "flow": 0, "flows": {}}), + 3: ("C", "B", 3, {"cost": 1, "capacity": 1, "flow": 0, "flows": {}}), 4: ( "B", "C", 4, { - "metric": 1, + "cost": 1, "capacity": 3, "flow": 1.0, "flows": {("A", "C", None): 1.0}, }, ), - 5: ("C", "B", 5, {"metric": 1, "capacity": 3, "flow": 0, "flows": {}}), - 6: ("B", "C", 6, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), - 7: ("C", "B", 7, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), + 5: ("C", "B", 5, {"cost": 1, "capacity": 3, "flow": 0, "flows": {}}), + 6: ("B", "C", 6, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), + 7: ("C", "B", 7, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), } def test_place_flow_on_graph_line1_balanced_2(self, line1): @@ -586,39 +586,39 @@ def test_place_flow_on_graph_line1_balanced_2(self, line1): "B", 0, { - "metric": 1, + "cost": 1, "capacity": 5, "flow": 2.0, "flows": {("A", "C", None): 2.0}, }, ), - 1: ("B", "A", 1, {"metric": 1, "capacity": 5, "flow": 0, "flows": {}}), + 1: ("B", "A", 1, {"cost": 1, "capacity": 5, "flow": 0, "flows": {}}), 2: ( "B", "C", 2, { - "metric": 1, + "cost": 1, "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, }, ), - 3: ("C", "B", 3, {"metric": 1, "capacity": 1, "flow": 0, "flows": {}}), + 3: ("C", "B", 3, {"cost": 1, "capacity": 1, "flow": 0, "flows": {}}), 4: ( "B", "C", 4, { - "metric": 1, + "cost": 1, "capacity": 3, "flow": 1.0, "flows": {("A", "C", None): 1.0}, }, ), - 5: ("C", "B", 5, {"metric": 1, "capacity": 3, "flow": 0, "flows": {}}), - 6: ("B", "C", 6, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), - 7: ("C", "B", 7, {"metric": 2, "capacity": 7, "flow": 0, "flows": {}}), + 5: ("C", "B", 5, {"cost": 1, "capacity": 3, "flow": 0, "flows": {}}), + 6: ("B", "C", 6, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), + 7: ("C", "B", 7, {"cost": 2, "capacity": 7, "flow": 0, "flows": {}}), } def test_place_flow_on_graph_graph4_balanced(self, graph4): @@ -655,7 +655,7 @@ def test_place_flow_on_graph_graph4_balanced(self, graph4): "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -666,13 +666,13 @@ def test_place_flow_on_graph_graph4_balanced(self, graph4): "capacity": 1, "flow": 1.0, "flows": {("A", "C", None): 1.0}, - "metric": 1, + "cost": 1, }, ), - 2: ("A", "B1", 2, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), - 3: ("B1", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), - 4: ("A", "B2", 4, {"capacity": 3, "flow": 0, "flows": {}, "metric": 3}), - 5: ("B2", "C", 5, {"capacity": 3, "flow": 0, "flows": {}, "metric": 3}), + 2: ("A", "B1", 2, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), + 3: ("B1", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), + 4: ("A", "B2", 4, {"capacity": 3, "flow": 0, "flows": {}, "cost": 3}), + 5: ("B2", "C", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 3}), } @@ -712,12 +712,12 @@ def test_remove_flow_from_graph_4(self, graph4): # Or check exact dictionary: assert r.get_edges() == { - 0: ("A", "B", 0, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), - 1: ("B", "C", 1, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), - 2: ("A", "B1", 2, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), - 3: ("B1", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), - 4: ("A", "B2", 4, {"capacity": 3, "flow": 0, "flows": {}, "metric": 3}), - 5: ("B2", "C", 5, {"capacity": 3, "flow": 0, "flows": {}, "metric": 3}), + 0: ("A", "B", 0, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), + 1: ("B", "C", 1, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), + 2: ("A", "B1", 2, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), + 3: ("B1", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), + 4: ("A", "B2", 4, {"capacity": 3, "flow": 0, "flows": {}, "cost": 3}), + 5: ("B2", "C", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 3}), } def test_remove_specific_flow(self, graph4): diff --git a/tests/lib/algorithms/test_spf_bench.py b/tests/lib/algorithms/test_spf_bench.py index 53da5cf..5b12c6c 100644 --- a/tests/lib/algorithms/test_spf_bench.py +++ b/tests/lib/algorithms/test_spf_bench.py @@ -17,7 +17,7 @@ def create_complex_graph(num_nodes: int, num_edges: int): For each iteration, we add 4 edges, so we iterate num_edges/4 times. Returns: (node_labels, edges) where edges is a list of tuples: - (src, dst, metric, capacity). + (src, dst, cost, capacity). """ node_labels = [str(i) for i in range(num_nodes)] edges = [] @@ -28,11 +28,11 @@ def create_complex_graph(num_nodes: int, num_edges: int): if src == tgt: # skip self-loops continue - # Add four parallel edges from src->tgt with random metric/capacity + # Add four parallel edges from src->tgt with random cost/capacity for _ in range(4): - metric = random.randint(1, 10) + cost = random.randint(1, 10) cap = random.randint(1, 5) - edges.append((src, tgt, metric, cap)) + edges.append((src, tgt, cost, cap)) edges_added += 1 return node_labels, edges @@ -58,11 +58,11 @@ def graph1(): gnx.add_node(node) # Add edges to both graphs - for src, dst, metric, cap in edges: + for src, dst, cost, cap in edges: # Our custom graph - g.add_edge(src, dst, metric=metric, capacity=cap) + g.add_edge(src, dst, cost=cost, capacity=cap) # NetworkX - gnx.add_edge(src, dst, metric=metric, capacity=cap) + gnx.add_edge(src, dst, cost=cost, capacity=cap) return g, gnx @@ -84,6 +84,6 @@ def test_bench_networkx_spf_1(benchmark, graph1): """ def run_nx_dijkstra(): - nx.dijkstra_predecessor_and_distance(graph1[1], "0", weight="metric") + nx.dijkstra_predecessor_and_distance(graph1[1], "0", weight="cost") benchmark(run_nx_dijkstra) diff --git a/tests/lib/test_demand.py b/tests/lib/test_demand.py index 16d6487..af48a96 100644 --- a/tests/lib/test_demand.py +++ b/tests/lib/test_demand.py @@ -79,10 +79,10 @@ def test_demand_place_basic(self, line1) -> None: src_node="A", dst_node="C", flow_class=99, flow_id=0 ): 5.0 }, - "metric": 1, + "cost": 1, }, ), - 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "metric": 1}), + 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "cost": 1}), 2: ( "B", "C", @@ -95,10 +95,10 @@ def test_demand_place_basic(self, line1) -> None: src_node="A", dst_node="C", flow_class=99, flow_id=0 ): 0.45454545454545453 }, - "metric": 1, + "cost": 1, }, ), - 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), + 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), 4: ( "B", "C", @@ -111,10 +111,10 @@ def test_demand_place_basic(self, line1) -> None: src_node="A", dst_node="C", flow_class=99, flow_id=0 ): 1.3636363636363635 }, - "metric": 1, + "cost": 1, }, ), - 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "metric": 1}), + 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 1}), 6: ( "B", "C", @@ -127,10 +127,10 @@ def test_demand_place_basic(self, line1) -> None: src_node="A", dst_node="C", flow_class=99, flow_id=0 ): 3.1818181818181817 }, - "metric": 2, + "cost": 2, }, ), - 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "metric": 2}), + 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "cost": 2}), } assert r.get_edges() == expected_edges @@ -201,7 +201,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "B", 0, { - "metric": 1, + "cost": 1, "capacity": 15, "label": "1", "flow": 15.0, @@ -220,7 +220,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "A", 1, { - "metric": 1, + "cost": 1, "capacity": 15, "label": "1", "flow": 15.0, @@ -239,7 +239,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "C", 2, { - "metric": 1, + "cost": 1, "capacity": 15, "label": "2", "flow": 15.0, @@ -258,7 +258,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "B", 3, { - "metric": 1, + "cost": 1, "capacity": 15, "label": "2", "flow": 15.0, @@ -277,7 +277,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "C", 4, { - "metric": 1, + "cost": 1, "capacity": 5, "label": "3", "flow": 5.0, @@ -293,7 +293,7 @@ def test_multiple_demands_on_triangle(self, triangle1) -> None: "A", 5, { - "metric": 1, + "cost": 1, "capacity": 5, "label": "3", "flow": 5.0, diff --git a/tests/lib/test_flow_policy.py b/tests/lib/test_flow_policy.py index cb56fb7..09a5340 100644 --- a/tests/lib/test_flow_policy.py +++ b/tests/lib/test_flow_policy.py @@ -99,7 +99,7 @@ def test_flow_policy_place_demand_1(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -110,11 +110,11 @@ def test_flow_policy_place_demand_1(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), - 2: ("A", "D", 2, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), - 3: ("D", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "metric": 2}), + 2: ("A", "D", 2, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), + 3: ("D", "C", 3, {"capacity": 2, "flow": 0, "flows": {}, "cost": 2}), } def test_flow_policy_place_demand_2(self, square1): @@ -135,7 +135,7 @@ def test_flow_policy_place_demand_2(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -146,7 +146,7 @@ def test_flow_policy_place_demand_2(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -157,7 +157,7 @@ def test_flow_policy_place_demand_2(self, square1): "capacity": 2, "flow": 1.0, "flows": {("A", "C", "test_flow", 1): 1.0}, - "metric": 2, + "cost": 2, }, ), 3: ( @@ -168,7 +168,7 @@ def test_flow_policy_place_demand_2(self, square1): "capacity": 2, "flow": 1.0, "flows": {("A", "C", "test_flow", 1): 1.0}, - "metric": 2, + "cost": 2, }, ), } @@ -188,8 +188,8 @@ def test_flow_policy_place_demand_3(self, square1): assert placed_flow == 2 assert remaining_flow == 0 assert r.get_edges() == { - 0: ("A", "B", 0, {"capacity": 1, "flow": 0.0, "flows": {}, "metric": 1}), - 1: ("B", "C", 1, {"capacity": 1, "flow": 0.0, "flows": {}, "metric": 1}), + 0: ("A", "B", 0, {"capacity": 1, "flow": 0.0, "flows": {}, "cost": 1}), + 1: ("B", "C", 1, {"capacity": 1, "flow": 0.0, "flows": {}, "cost": 1}), 2: ( "A", "D", @@ -198,7 +198,7 @@ def test_flow_policy_place_demand_3(self, square1): "capacity": 2, "flow": 2.0, "flows": {("A", "C", "test_flow", 0): 2.0}, - "metric": 2, + "cost": 2, }, ), 3: ( @@ -209,7 +209,7 @@ def test_flow_policy_place_demand_3(self, square1): "capacity": 2, "flow": 2.0, "flows": {("A", "C", "test_flow", 0): 2.0}, - "metric": 2, + "cost": 2, }, ), } @@ -236,7 +236,7 @@ def test_flow_policy_place_demand_4(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -247,7 +247,7 @@ def test_flow_policy_place_demand_4(self, square1): "capacity": 1, "flow": 1.0, "flows": {("A", "C", "test_flow", 0): 1.0}, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -258,7 +258,7 @@ def test_flow_policy_place_demand_4(self, square1): "capacity": 2, "flow": 2.0, "flows": {("A", "C", "test_flow", 1): 2.0}, - "metric": 2, + "cost": 2, }, ), 3: ( @@ -269,7 +269,7 @@ def test_flow_policy_place_demand_4(self, square1): "capacity": 2, "flow": 2.0, "flows": {("A", "C", "test_flow", 1): 2.0}, - "metric": 2, + "cost": 2, }, ), } @@ -296,7 +296,7 @@ def test_flow_policy_place_demand_5(self, square3): "capacity": 100, "flow": 100.0, "flows": {("A", "C", "test_flow", 0): 100.0}, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -310,7 +310,7 @@ def test_flow_policy_place_demand_5(self, square3): ("A", "C", "test_flow", 0): 100.0, ("A", "C", "test_flow", 2): 25.0, }, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -324,7 +324,7 @@ def test_flow_policy_place_demand_5(self, square3): ("A", "C", "test_flow", 1): 50.0, ("A", "C", "test_flow", 2): 25.0, }, - "metric": 1, + "cost": 1, }, ), 3: ( @@ -335,10 +335,10 @@ def test_flow_policy_place_demand_5(self, square3): "capacity": 50, "flow": 50.0, "flows": {("A", "C", "test_flow", 1): 50.0}, - "metric": 1, + "cost": 1, }, ), - 4: ("B", "D", 4, {"capacity": 50, "flow": 0, "flows": {}, "metric": 1}), + 4: ("B", "D", 4, {"capacity": 50, "flow": 0, "flows": {}, "cost": 1}), 5: ( "D", "B", @@ -347,7 +347,7 @@ def test_flow_policy_place_demand_5(self, square3): "capacity": 50, "flow": 25.0, "flows": {("A", "C", "test_flow", 2): 25.0}, - "metric": 1, + "cost": 1, }, ), } @@ -389,12 +389,12 @@ def test_flow_policy_place_demand_6(self, line1): flow_id=1, ): 2.5, }, - "metric": 1, + "cost": 1, }, ), - 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "metric": 1}), - 2: ("B", "C", 2, {"capacity": 1, "flow": 0.0, "flows": {}, "metric": 1}), - 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "metric": 1}), + 1: ("B", "A", 1, {"capacity": 5, "flow": 0, "flows": {}, "cost": 1}), + 2: ("B", "C", 2, {"capacity": 1, "flow": 0.0, "flows": {}, "cost": 1}), + 3: ("C", "B", 3, {"capacity": 1, "flow": 0, "flows": {}, "cost": 1}), 4: ( "B", "C", @@ -410,10 +410,10 @@ def test_flow_policy_place_demand_6(self, line1): flow_id=1, ): 2.5 }, - "metric": 1, + "cost": 1, }, ), - 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "metric": 1}), + 5: ("C", "B", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 1}), 6: ( "B", "C", @@ -429,10 +429,10 @@ def test_flow_policy_place_demand_6(self, line1): flow_id=0, ): 2.5 }, - "metric": 2, + "cost": 2, }, ), - 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "metric": 2}), + 7: ("C", "B", 7, {"capacity": 7, "flow": 0, "flows": {}, "cost": 2}), } def test_flow_policy_place_demand_7(self, square3): @@ -472,7 +472,7 @@ def test_flow_policy_place_demand_7(self, square3): flow_id=2, ): 49.99999999999999, }, - "metric": 1, + "cost": 1, }, ), 1: ( @@ -496,7 +496,7 @@ def test_flow_policy_place_demand_7(self, square3): flow_id=2, ): 49.99999999999999, }, - "metric": 1, + "cost": 1, }, ), 2: ( @@ -514,7 +514,7 @@ def test_flow_policy_place_demand_7(self, square3): flow_id=1, ): 50.0 }, - "metric": 1, + "cost": 1, }, ), 3: ( @@ -532,11 +532,11 @@ def test_flow_policy_place_demand_7(self, square3): flow_id=1, ): 50.0 }, - "metric": 1, + "cost": 1, }, ), - 4: ("B", "D", 4, {"capacity": 50, "flow": 0, "flows": {}, "metric": 1}), - 5: ("D", "B", 5, {"capacity": 50, "flow": 0.0, "flows": {}, "metric": 1}), + 4: ("B", "D", 4, {"capacity": 50, "flow": 0, "flows": {}, "cost": 1}), + 5: ("D", "B", 5, {"capacity": 50, "flow": 0.0, "flows": {}, "cost": 1}), } def test_flow_policy_place_demand_8(self, line1): diff --git a/tests/lib/test_path.py b/tests/lib/test_path.py index dd23be2..a6f6347 100644 --- a/tests/lib/test_path.py +++ b/tests/lib/test_path.py @@ -112,10 +112,10 @@ def test_get_sub_path_success(): for node_id in ("A", "B", "C", "D"): g.add_node(node_id) - # Add edges with 'metric' attributes - eAB = g.add_edge("A", "B", cost=5, metric=5) - eBC = g.add_edge("B", "C", cost=7, metric=7) - eCD = g.add_edge("C", "D", cost=2, metric=2) + # Add edges with 'cost' attributes + eAB = g.add_edge("A", "B", cost=5) + eBC = g.add_edge("B", "C", cost=7) + eCD = g.add_edge("C", "D", cost=2) # Path is A->B->C->D path_tuple: PathTuple = ( @@ -127,7 +127,7 @@ def test_get_sub_path_success(): p = Path(path_tuple, cost=14.0) # Subpath: A->B->C - sub_p = p.get_sub_path("C", g, cost_attr="metric") + sub_p = p.get_sub_path("C", g, cost_attr="cost") assert sub_p.dst_node == "C" # Check that the cost is sum of edges (A->B=5) + (B->C=7) = 12 assert sub_p.cost == 12 @@ -157,7 +157,7 @@ def test_get_sub_path_empty_parallel_edges(): g.add_node(n) # Add an edge between N1->N2 - e12 = g.add_edge("N1", "N2", metric=10) + e12 = g.add_edge("N1", "N2", cost=10) # A path where the second to last step has an empty parallel edge set # just to confirm we skip cost addition for that step diff --git a/tests/lib/test_path_bundle.py b/tests/lib/test_path_bundle.py index 5b8f118..09000f0 100644 --- a/tests/lib/test_path_bundle.py +++ b/tests/lib/test_path_bundle.py @@ -13,12 +13,12 @@ def triangle1(): g.add_node("A") g.add_node("B") g.add_node("C") - g.add_edge("A", "B", metric=1, capacity=15, key=0) - g.add_edge("B", "A", metric=1, capacity=15, key=1) - g.add_edge("B", "C", metric=1, capacity=15, key=2) - g.add_edge("C", "B", metric=1, capacity=15, key=3) - g.add_edge("A", "C", metric=1, capacity=5, key=4) - g.add_edge("C", "A", metric=1, capacity=5, key=5) + g.add_edge("A", "B", cost=1, capacity=15, key=0) + g.add_edge("B", "A", cost=1, capacity=15, key=1) + g.add_edge("B", "C", cost=1, capacity=15, key=2) + g.add_edge("C", "B", cost=1, capacity=15, key=3) + g.add_edge("A", "C", cost=1, capacity=5, key=4) + g.add_edge("C", "A", cost=1, capacity=5, key=5) return g diff --git a/tests/lib/test_util.py b/tests/lib/test_util.py index d0f2ade..7ed6fb4 100644 --- a/tests/lib/test_util.py +++ b/tests/lib/test_util.py @@ -14,18 +14,18 @@ def create_sample_graph(with_attrs: bool = False) -> StrictMultiDiGraph: if with_attrs: # Add edges with attributes. - graph.add_edge(1, 2, 1, metric=1, capacity=1) - graph.add_edge(1, 2, 2, metric=2, capacity=2) - graph.add_edge(1, 2, 3, metric=3, capacity=3) - graph.add_edge(2, 1, 4, metric=4, capacity=4) - graph.add_edge(2, 1, 5, metric=5, capacity=5) - graph.add_edge(2, 1, 6, metric=6, capacity=6) - graph.add_edge(1, 1, 7, metric=7, capacity=7) - graph.add_edge(1, 1, 8, metric=8, capacity=8) - graph.add_edge(1, 1, 9, metric=9, capacity=9) - graph.add_edge(2, 2, 10, metric=10, capacity=10) - graph.add_edge(2, 2, 11, metric=11, capacity=11) - graph.add_edge(2, 2, 12, metric=12, capacity=12) + graph.add_edge(1, 2, 1, cost=1, capacity=1) + graph.add_edge(1, 2, 2, cost=2, capacity=2) + graph.add_edge(1, 2, 3, cost=3, capacity=3) + graph.add_edge(2, 1, 4, cost=4, capacity=4) + graph.add_edge(2, 1, 5, cost=5, capacity=5) + graph.add_edge(2, 1, 6, cost=6, capacity=6) + graph.add_edge(1, 1, 7, cost=7, capacity=7) + graph.add_edge(1, 1, 8, cost=8, capacity=8) + graph.add_edge(1, 1, 9, cost=9, capacity=9) + graph.add_edge(2, 2, 10, cost=10, capacity=10) + graph.add_edge(2, 2, 11, cost=11, capacity=11) + graph.add_edge(2, 2, 12, cost=12, capacity=12) else: # Add edges without attributes. graph.add_edge(1, 2, 1) @@ -71,68 +71,68 @@ def test_to_digraph_with_edge_func(): nx_graph = to_digraph( graph, edge_func=lambda g, u, v, edges: { - "metric": min(edge["metric"] for edge in edges.values()), + "cost": min(edge["cost"] for edge in edges.values()), "capacity": sum(edge["capacity"] for edge in edges.values()), }, ) assert dict(nx_graph.nodes) == {1: {}, 2: {}} expected_edges = { (1, 2): { - "metric": 1, + "cost": 1, "capacity": 6, "_uv_edges": [ ( 1, 2, { - 1: {"metric": 1, "capacity": 1}, - 2: {"metric": 2, "capacity": 2}, - 3: {"metric": 3, "capacity": 3}, + 1: {"cost": 1, "capacity": 1}, + 2: {"cost": 2, "capacity": 2}, + 3: {"cost": 3, "capacity": 3}, }, ), ], }, (2, 1): { - "metric": 4, + "cost": 4, "capacity": 15, "_uv_edges": [ ( 2, 1, { - 4: {"metric": 4, "capacity": 4}, - 5: {"metric": 5, "capacity": 5}, - 6: {"metric": 6, "capacity": 6}, + 4: {"cost": 4, "capacity": 4}, + 5: {"cost": 5, "capacity": 5}, + 6: {"cost": 6, "capacity": 6}, }, ), ], }, (1, 1): { - "metric": 7, + "cost": 7, "capacity": 24, "_uv_edges": [ ( 1, 1, { - 7: {"metric": 7, "capacity": 7}, - 8: {"metric": 8, "capacity": 8}, - 9: {"metric": 9, "capacity": 9}, + 7: {"cost": 7, "capacity": 7}, + 8: {"cost": 8, "capacity": 8}, + 9: {"cost": 9, "capacity": 9}, }, ), ], }, (2, 2): { - "metric": 10, + "cost": 10, "capacity": 33, "_uv_edges": [ ( 2, 2, { - 10: {"metric": 10, "capacity": 10}, - 11: {"metric": 11, "capacity": 11}, - 12: {"metric": 12, "capacity": 12}, + 10: {"cost": 10, "capacity": 10}, + 11: {"cost": 11, "capacity": 11}, + 12: {"cost": 12, "capacity": 12}, }, ), ], @@ -211,62 +211,62 @@ def test_to_graph_with_edge_func(): nx_graph = to_graph( graph, edge_func=lambda g, u, v, edges: { - "metric": min(edge["metric"] for edge in edges.values()), + "cost": min(edge["cost"] for edge in edges.values()), "capacity": sum(edge["capacity"] for edge in edges.values()), }, ) assert dict(nx_graph.nodes) == {1: {}, 2: {}} expected_edges = { (1, 2): { - "metric": 4, + "cost": 4, "capacity": 15, "_uv_edges": [ ( 1, 2, { - 1: {"metric": 1, "capacity": 1}, - 2: {"metric": 2, "capacity": 2}, - 3: {"metric": 3, "capacity": 3}, + 1: {"cost": 1, "capacity": 1}, + 2: {"cost": 2, "capacity": 2}, + 3: {"cost": 3, "capacity": 3}, }, ), ( 2, 1, { - 4: {"metric": 4, "capacity": 4}, - 5: {"metric": 5, "capacity": 5}, - 6: {"metric": 6, "capacity": 6}, + 4: {"cost": 4, "capacity": 4}, + 5: {"cost": 5, "capacity": 5}, + 6: {"cost": 6, "capacity": 6}, }, ), ], }, (1, 1): { - "metric": 7, + "cost": 7, "capacity": 24, "_uv_edges": [ ( 1, 1, { - 7: {"metric": 7, "capacity": 7}, - 8: {"metric": 8, "capacity": 8}, - 9: {"metric": 9, "capacity": 9}, + 7: {"cost": 7, "capacity": 7}, + 8: {"cost": 8, "capacity": 8}, + 9: {"cost": 9, "capacity": 9}, }, ), ], }, (2, 2): { - "metric": 10, + "cost": 10, "capacity": 33, "_uv_edges": [ ( 2, 2, { - 10: {"metric": 10, "capacity": 10}, - 11: {"metric": 11, "capacity": 11}, - 12: {"metric": 12, "capacity": 12}, + 10: {"cost": 10, "capacity": 10}, + 11: {"cost": 11, "capacity": 11}, + 12: {"cost": 12, "capacity": 12}, }, ), ], diff --git a/tests/scenarios/scenario_3.yaml b/tests/scenarios/scenario_3.yaml index 7a1cefd..2f293bd 100644 --- a/tests/scenarios/scenario_3.yaml +++ b/tests/scenarios/scenario_3.yaml @@ -62,3 +62,7 @@ network: workflow: - step_type: BuildGraph name: build_graph + - step_type: CapacityProbe + name: capacity_probe + source_path: my_clos1/b1/t1 + sink_path: my_clos2/b2/t1 diff --git a/tests/scenarios/test_scenario_3.py b/tests/scenarios/test_scenario_3.py index 4fe8736..d9ec9e5 100644 --- a/tests/scenarios/test_scenario_3.py +++ b/tests/scenarios/test_scenario_3.py @@ -69,3 +69,6 @@ def test_scenario_3_build_graph() -> None: assert ( "my_clos2/spine/t3-16" in scenario.network.nodes ), "Missing expected spine node 'my_clos2/spine/t3-16' in expanded blueprint." + + print(scenario.results.get("capacity_probe", "max_flow")) + raise diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index e26b5d6..50a2600 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -2,7 +2,7 @@ def test_max_flow_variants(): """ Tests max flow calculations on a graph with parallel edges. - Graph topology (metrics/capacities): + Graph topology (costs/capacities): [1,1] & [1,2] [1,1] & [1,2] A ──────────────────► B ─────────────► C @@ -11,10 +11,10 @@ def test_max_flow_variants(): └───────────────────► D ───────────────┘ Edges: - - A→B: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2) - - B→C: two parallel edges with (metric=1, capacity=1) and (metric=1, capacity=2) - - A→D: (metric=2, capacity=3) - - D→C: (metric=2, capacity=3) + - A→B: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2) + - B→C: two parallel edges with (cost=1, capacity=1) and (cost=1, capacity=2) + - A→D: (cost=2, capacity=3) + - D→C: (cost=2, capacity=3) The test computes: - The true maximum flow (expected flow: 6.0) @@ -30,13 +30,13 @@ def test_max_flow_variants(): g.add_node(node) # Create parallel edges between A→B and B→C - g.add_edge("A", "B", key=0, metric=1, capacity=1) - g.add_edge("A", "B", key=1, metric=1, capacity=2) - g.add_edge("B", "C", key=2, metric=1, capacity=1) - g.add_edge("B", "C", key=3, metric=1, capacity=2) + g.add_edge("A", "B", key=0, cost=1, capacity=1) + g.add_edge("A", "B", key=1, cost=1, capacity=2) + g.add_edge("B", "C", key=2, cost=1, capacity=1) + g.add_edge("B", "C", key=3, cost=1, capacity=2) # Create an alternative path A→D→C - g.add_edge("A", "D", key=4, metric=2, capacity=3) - g.add_edge("D", "C", key=5, metric=2, capacity=3) + g.add_edge("A", "D", key=4, cost=2, capacity=3) + g.add_edge("D", "C", key=5, cost=2, capacity=3) # 1. The true maximum flow max_flow_prop = calc_max_flow(g, "A", "C") @@ -57,7 +57,7 @@ def test_traffic_engineering_simulation(): """ Demonstrates traffic engineering by placing two bidirectional demands on a network. - Graph topology (metrics/capacities): + Graph topology (costs/capacities): [15] A ─────── B @@ -83,12 +83,12 @@ def test_traffic_engineering_simulation(): g.add_node(node) # Create bidirectional edges with distinct labels (for clarity). - g.add_edge("A", "B", key=0, metric=1, capacity=15, label="1") - g.add_edge("B", "A", key=1, metric=1, capacity=15, label="1") - g.add_edge("B", "C", key=2, metric=1, capacity=15, label="2") - g.add_edge("C", "B", key=3, metric=1, capacity=15, label="2") - g.add_edge("A", "C", key=4, metric=1, capacity=5, label="3") - g.add_edge("C", "A", key=5, metric=1, capacity=5, label="3") + g.add_edge("A", "B", key=0, cost=1, capacity=15, label="1") + g.add_edge("B", "A", key=1, cost=1, capacity=15, label="1") + g.add_edge("B", "C", key=2, cost=1, capacity=15, label="2") + g.add_edge("C", "B", key=3, cost=1, capacity=15, label="2") + g.add_edge("A", "C", key=4, cost=1, capacity=5, label="3") + g.add_edge("C", "A", key=5, cost=1, capacity=5, label="3") # Initialize flow-related structures (e.g., to track placed flows in the graph). flow_graph = init_flow_graph(g) diff --git a/tests/test_result.py b/tests/test_result.py index cef5f9e..d7d1508 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -52,12 +52,12 @@ def test_overwriting_value(): Test that storing a new value under an existing step/key pair overwrites the old value. """ results = Results() - results.put("Step1", "metric", 10) - assert results.get("Step1", "metric") == 10 + results.put("Step1", "cost", 10) + assert results.get("Step1", "cost") == 10 # Overwrite - results.put("Step1", "metric", 20) - assert results.get("Step1", "metric") == 20 + results.put("Step1", "cost", 20) + assert results.get("Step1", "cost") == 20 def test_empty_results(): diff --git a/tests/workflow/test_build_graph.py b/tests/workflow/test_build_graph.py index c693515..50652f6 100644 --- a/tests/workflow/test_build_graph.py +++ b/tests/workflow/test_build_graph.py @@ -1,8 +1,9 @@ import pytest -import networkx as nx from unittest.mock import MagicMock +from ngraph.lib.graph import StrictMultiDiGraph from ngraph.workflow.build_graph import BuildGraph +from ngraph.network import Network class MockNode: @@ -34,7 +35,7 @@ def mock_scenario(): Provides a mock Scenario object for testing. """ scenario = MagicMock() - scenario.network = MagicMock() + scenario.network = Network() # Sample data: scenario.network.nodes = { @@ -68,7 +69,7 @@ def mock_scenario(): def test_build_graph_stores_multidigraph_in_results(mock_scenario): """ - Ensure BuildGraph creates a MultiDiGraph, adds all nodes/edges, + Ensure BuildGraph creates a StrictMultiDiGraph, adds all nodes/edges, and stores it in scenario.results with the key (step_name, "graph"). """ step = BuildGraph(name="MyBuildStep") @@ -80,19 +81,19 @@ def test_build_graph_stores_multidigraph_in_results(mock_scenario): # Extract the arguments from the .put call call_args = mock_scenario.results.put.call_args - # Should look like ("MyBuildStep", "graph", ) + # Should look like ("MyBuildStep", "graph", ) assert call_args[0][0] == "MyBuildStep" assert call_args[0][1] == "graph" created_graph = call_args[0][2] assert isinstance( - created_graph, nx.MultiDiGraph - ), "Resulting object must be a MultiDiGraph." + created_graph, StrictMultiDiGraph + ), "Resulting object must be a StrictMultiDiGraph." # Verify the correct nodes were added assert set(created_graph.nodes()) == { "A", "B", - }, "MultiDiGraph should contain the correct node set." + }, "StrictMultiDiGraph should contain the correct node set." # Check node attributes assert created_graph.nodes["A"]["type"] == "router" assert created_graph.nodes["B"]["location"] == "rack2" From 58545152f01ae3873eff26128568a2e7c2a879b5 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 25 Feb 2025 02:29:21 +0000 Subject: [PATCH 2/2] finishing capacity_probe --- ngraph/blueprints.py | 34 +---- ngraph/network.py | 163 ++++++++++++++++------- ngraph/workflow/capacity_probe.py | 47 +++---- tests/scenarios/scenario_3.yaml | 13 +- tests/scenarios/test_scenario_3.py | 36 +++-- tests/test_blueprints_helpers.py | 38 ------ tests/test_network.py | 182 +++++++++++++++++++++++++- tests/workflow/test_capacity_probe.py | 115 ++++++++++++++++ 8 files changed, 464 insertions(+), 164 deletions(-) create mode 100644 tests/workflow/test_capacity_probe.py diff --git a/ngraph/blueprints.py b/ngraph/blueprints.py index 26c323a..31681c9 100644 --- a/ngraph/blueprints.py +++ b/ngraph/blueprints.py @@ -231,8 +231,8 @@ def _expand_adjacency_pattern( wrap-around if one side is an integer multiple of the other. Also skips self-loops. """ - source_nodes = _find_nodes_by_path(ctx.network, source_path) - target_nodes = _find_nodes_by_path(ctx.network, target_path) + source_nodes = ctx.network.select_nodes_by_path(source_path) + target_nodes = ctx.network.select_nodes_by_path(target_path) if not source_nodes or not target_nodes: return @@ -338,36 +338,6 @@ def _join_paths(parent_path: str, rel_path: str) -> str: return rel_path -def _find_nodes_by_path(net: Network, path: str) -> List[Node]: - """ - Returns all nodes whose name is exactly 'path' or begins with 'path/'. - If none are found, tries 'path-' as a fallback prefix. - If still none are found, tries partial prefix "path" => "pathX". - - Examples: - path="SEA/clos_instance/spine" might match "SEA/clos_instance/spine/myspine-1" - path="S" might match "S1", "S2" if we resort to partial prefix logic. - """ - # 1) Exact or slash-based - result = [ - n for n in net.nodes.values() if n.name == path or n.name.startswith(f"{path}/") - ] - if result: - return result - - # 2) Fallback: path- - result = [n for n in net.nodes.values() if n.name.startswith(f"{path}-")] - if result: - return result - - # 3) Partial - partial = [] - for n in net.nodes.values(): - if n.name.startswith(path) and n.name != path: - partial.append(n) - return partial - - def _process_direct_nodes(net: Network, network_data: Dict[str, Any]) -> None: """Processes direct node definitions (network_data["nodes"]).""" for node_name, node_attrs in network_data.get("nodes", {}).items(): diff --git a/ngraph/network.py b/ngraph/network.py index 9af081c..0caafc0 100644 --- a/ngraph/network.py +++ b/ngraph/network.py @@ -6,10 +6,17 @@ from typing import Any, Dict, List from ngraph.lib.graph import StrictMultiDiGraph +from ngraph.lib.algorithms.max_flow import calc_max_flow +from ngraph.lib.algorithms.base import FlowPlacement def new_base64_uuid() -> str: - """Generates a Base64-encoded UUID without padding (22 characters).""" + """ + Generates a Base64-encoded UUID without padding (22 characters). + + Returns: + str: A 22-character base64 URL-safe string. + """ return base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("ascii").rstrip("=") @@ -23,11 +30,11 @@ class Node: Attributes: name (str): The unique name of the node. - attrs (Dict[str, Any]): Optional extra metadata for the node. For example: + attrs (Dict[str, Any]): Optional extra metadata. For example: { - "type": "node", # auto-tagged upon add_node - "coords": [lat, lon], # user-provided - "region": "west_coast" # user-provided + "type": "node", # auto-tagged on add_node + "coords": [lat, lon], # user-provided + "region": "west_coast" # user-provided } """ @@ -41,22 +48,20 @@ class Link: Represents a link connecting two nodes in the network. The 'source' and 'target' fields reference node names. A unique link ID - is auto-generated from the source, target, and a random Base64-encoded UUID, - allowing multiple distinct links between the same nodes. + is auto-generated from source, target, and a random Base64-encoded UUID, + allowing multiple distinct links between the same pair of nodes. Attributes: - source (str): Unique name of the source node. - target (str): Unique name of the target node. - capacity (float): Link capacity (default is 1.0). - cost (float): Link cost (default is 1.0). - attrs (Dict[str, Any]): Optional extra metadata for the link. - For example: + source (str): Name of the source node. + target (str): Name of the target node. + capacity (float): Link capacity (default 1.0). + cost (float): Link cost (default 1.0). + attrs (Dict[str, Any]): Optional extra metadata. For example: { - "type": "link", # auto-tagged upon add_link - "distance_km": 1500, # user-provided - "fiber_provider": "Lumen", # user-provided + "type": "link", # auto-tagged on add_link + "distance_km": 1500, # user-provided } - id (str): Auto-generated unique link identifier, e.g. "SEA-DEN-abCdEf..." + id (str): Auto-generated unique link identifier, e.g. "SEA|DEN|abc123..." """ source: str @@ -67,7 +72,9 @@ class Link: id: str = field(init=False) def __post_init__(self) -> None: - """Auto-generates a unique link ID by combining the source, target, and a random Base64-encoded UUID.""" + """ + Combines source, target, and a random UUID to generate the link's ID. + """ self.id = f"{self.source}|{self.target}|{new_base64_uuid()}" @@ -76,14 +83,10 @@ class Network: """ A container for network nodes and links. - Nodes are stored in a dictionary keyed by their unique names (Node.name). - Links are stored in a dictionary keyed by their auto-generated IDs (Link.id). - The 'attrs' dict allows extra network metadata. - Attributes: - nodes (Dict[str, Node]): Mapping from node name -> Node object. - links (Dict[str, Link]): Mapping from link ID -> Link object. - attrs (Dict[str, Any]): Optional extra metadata for the network. + nodes (Dict[str, Node]): Mapping node_name -> Node object. + links (Dict[str, Link]): Mapping link_id -> Link object. + attrs (Dict[str, Any]): Optional metadata about the network. """ nodes: Dict[str, Node] = field(default_factory=dict) @@ -92,16 +95,15 @@ class Network: def add_node(self, node: Node) -> None: """ - Adds a node to the network, keyed by its name. + Adds a node to the network (keyed by node.name). - This method also auto-tags the node with ``node.attrs["type"] = "node"`` - if it's not already set. + Auto-tags the node with node.attrs["type"] = "node" if not set. Args: node (Node): The node to add. Raises: - ValueError: If a node with the same name is already in the network. + ValueError: If a node with the same name already exists. """ node.attrs.setdefault("type", "node") if node.name in self.nodes: @@ -110,16 +112,15 @@ def add_node(self, node: Node) -> None: def add_link(self, link: Link) -> None: """ - Adds a link to the network, keyed by its auto-generated ID. + Adds a link to the network (keyed by the link's auto-generated ID). - This method also auto-tags the link with ``link.attrs["type"] = "link"`` - if it's not already set. + Auto-tags the link with link.attrs["type"] = "link" if not set. Args: link (Link): The link to add. Raises: - ValueError: If the source or target node is not present in the network. + ValueError: If source or target node is missing from the network. """ if link.source not in self.nodes: raise ValueError(f"Source node '{link.source}' not found in network.") @@ -134,12 +135,10 @@ def to_strict_multidigraph(self, add_reverse: bool = True) -> StrictMultiDiGraph Creates a StrictMultiDiGraph representation of this Network. Args: - add_reverse (bool): Add a reverse edge for each link - (default is True). + add_reverse (bool): If True, adds a reverse edge for each link (default True). Returns: - StrictMultiDiGraph: The directed multigraph representation - of this network, including node and link attributes. + StrictMultiDiGraph: A directed multigraph representation of the network. """ graph = StrictMultiDiGraph() @@ -158,6 +157,7 @@ def to_strict_multidigraph(self, add_reverse: bool = True) -> StrictMultiDiGraph cost=link.cost, **link.attrs, ) + # Reverse edge (if requested) if add_reverse: reverse_id = f"{link.id}_rev" graph.add_edge( @@ -173,13 +173,22 @@ def to_strict_multidigraph(self, add_reverse: bool = True) -> StrictMultiDiGraph def select_nodes_by_path(self, path: str) -> List[Node]: """ - Returns all nodes whose name is exactly 'path' or begins with 'path/'. - If none are found, tries 'path-' as a fallback prefix. - If still none are found, tries partial prefix "path" => "pathX". + Returns nodes matching a path-based search. + + 1) Returns nodes whose name is exactly 'path' or starts with 'path/'. + 2) If none found, tries names starting with 'path-'. + 3) If still none, returns nodes whose names start with 'path' (partial match). + + Args: + path (str): The path/prefix to search. + + Returns: + List[Node]: A list of matching Node objects. Examples: - path="SEA/clos_instance/spine" might match "SEA/clos_instance/spine/myspine-1" - path="S" might match "S1", "S2" if we resort to partial prefix logic. + path="SEA/clos_instance/spine" might match + "SEA/clos_instance/spine/myspine-1". + path="S" might match "S1", "S2" (partial match fallback). """ # 1) Exact or slash-based result = [ @@ -190,14 +199,70 @@ def select_nodes_by_path(self, path: str) -> List[Node]: if result: return result - # 2) Fallback: path- + # 2) Dash-based result = [n for n in self.nodes.values() if n.name.startswith(f"{path}-")] if result: return result - # 3) Partial - partial = [] - for n in self.nodes.values(): - if n.name.startswith(path) and n.name != path: - partial.append(n) - return partial + # 3) Partial fallback + return [ + n for n in self.nodes.values() if n.name.startswith(path) and n.name != path + ] + + def max_flow( + self, + source_path: str, + sink_path: str, + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> float: + """ + Computes the maximum flow between selected source and sink nodes. + + Selects source nodes matching 'source_path' and sink nodes matching 'sink_path'. + Attaches a pseudo-node 'source' connecting to each source node with infinite + capacity edges, and similarly a pseudo-node 'sink' from each sink node. Then + calls calc_max_flow on the resulting graph. + + Args: + source_path (str): Path/prefix to select source nodes. + sink_path (str): Path/prefix to select sink nodes. + shortest_path (bool): If True, uses only the shortest paths (default False). + flow_placement (FlowPlacement): Load balancing across parallel edges. + + Returns: + float: The maximum flow found from source to sink. + + Raises: + ValueError: If no nodes match source_path or sink_path. + """ + # 1) Select source and sink nodes + sources = self.select_nodes_by_path(source_path) + sinks = self.select_nodes_by_path(sink_path) + + if not sources: + raise ValueError(f"No source nodes found matching path '{source_path}'.") + if not sinks: + raise ValueError(f"No sink nodes found matching path '{sink_path}'.") + + # 2) Build the graph + graph = self.to_strict_multidigraph() + + # 3) Add pseudo-nodes for multi-source / multi-sink flow + graph.add_node("source") + graph.add_node("sink") + + for src_node in sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + for sink_node in sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + # 4) Calculate max flow + return calc_max_flow( + graph, + "source", + "sink", + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) diff --git a/ngraph/workflow/capacity_probe.py b/ngraph/workflow/capacity_probe.py index 4801fc0..c1ab6dd 100644 --- a/ngraph/workflow/capacity_probe.py +++ b/ngraph/workflow/capacity_probe.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from ngraph.workflow.base import WorkflowStep, register_workflow_step -from ngraph.lib.algorithms.max_flow import calc_max_flow +from ngraph.lib.algorithms.base import FlowPlacement if TYPE_CHECKING: from ngraph.scenario import Scenario @@ -17,31 +17,32 @@ class CapacityProbe(WorkflowStep): A workflow step that probes capacity between selected nodes. Attributes: - source_path (str): A path/prefix to match source nodes. - sink_path (str): A path/prefix to match sink nodes. + source_path (str): Path/prefix to select source nodes. + sink_path (str): Path/prefix to select sink nodes. + shortest_path (bool): If True, uses only the shortest paths (default False). + flow_placement (FlowPlacement): Load balancing across parallel edges. """ source_path: str = "" sink_path: str = "" + shortest_path: bool = False + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL def run(self, scenario: Scenario) -> None: - # 1) Select source and sink nodes - sources = scenario.network.select_nodes_by_path(self.source_path) - sinks = scenario.network.select_nodes_by_path(self.sink_path) - - # 2) Build the graph - graph = scenario.network.to_strict_multidigraph() - - # 3) Attach pseudo-nodes the source and sink groups, then use max flow - # to calculate max flow between them - results = {} - graph.add_node("source") - graph.add_node("sink") - for source in sources: - graph.add_edge("source", source.name, capacity=float("inf"), cost=0) - for sink in sinks: - graph.add_edge(sink.name, "sink", capacity=float("inf"), cost=0) - flow = calc_max_flow(graph, "source", "sink") - - # 4) Store results in scenario - scenario.results.put(self.name, "max_flow", flow) + """ + Executes the capacity probe by computing the max flow between + nodes selected by source_path and sink_path, then storing the + result in the scenario's results container. + + Args: + scenario (Scenario): The scenario object containing the network and results. + """ + flow = scenario.network.max_flow( + self.source_path, + self.sink_path, + shortest_path=self.shortest_path, + flow_placement=self.flow_placement, + ) + + result_label = f"max_flow:[{self.source_path} -> {self.sink_path}]" + scenario.results.put(self.name, result_label, flow) diff --git a/tests/scenarios/scenario_3.yaml b/tests/scenarios/scenario_3.yaml index 2f293bd..1b45dee 100644 --- a/tests/scenarios/scenario_3.yaml +++ b/tests/scenarios/scenario_3.yaml @@ -13,7 +13,7 @@ blueprints: target: /t2 pattern: mesh link_params: - capacity: 1 + capacity: 2 cost: 1 3tier_clos: @@ -31,13 +31,13 @@ blueprints: target: spine pattern: one_to_one link_params: - capacity: 1 + capacity: 2 cost: 1 - source: b2/t2 target: spine pattern: one_to_one link_params: - capacity: 1 + capacity: 2 cost: 1 network: name: "3tier_clos_network" @@ -55,7 +55,7 @@ network: target: my_clos2/spine pattern: one_to_one link_params: - capacity: 1 + capacity: 2 cost: 1 @@ -64,5 +64,6 @@ workflow: name: build_graph - step_type: CapacityProbe name: capacity_probe - source_path: my_clos1/b1/t1 - sink_path: my_clos2/b2/t1 + source_path: my_clos1/b + sink_path: my_clos2/b + shortest_path: True diff --git a/tests/scenarios/test_scenario_3.py b/tests/scenarios/test_scenario_3.py index d9ec9e5..097f53d 100644 --- a/tests/scenarios/test_scenario_3.py +++ b/tests/scenarios/test_scenario_3.py @@ -6,16 +6,17 @@ from ngraph.failure_policy import FailurePolicy -def test_scenario_3_build_graph() -> None: +def test_scenario_3_build_graph_and_capacity_probe() -> None: """ - Integration test that verifies we can parse scenario_3.yaml, - run the BuildGraph step, and produce a valid StrictMultiDiGraph. + Integration test that verifies we can parse scenario_3.yaml, run the workflow + (BuildGraph + CapacityProbe), and check results. Checks: - The expected number of expanded nodes and links (two interconnected 3-tier CLOS fabrics). - - The presence of key expanded nodes. + - Presence of key expanded nodes. - The traffic demands are empty in this scenario. - The failure policy is empty by default. + - The max flow from my_clos1/b -> my_clos2/b matches the expected capacity. """ # 1) Load the YAML file scenario_path = Path(__file__).parent / "scenario_3.yaml" @@ -24,7 +25,7 @@ def test_scenario_3_build_graph() -> None: # 2) Parse into a Scenario object (this calls blueprint expansion) scenario = Scenario.from_yaml(yaml_text) - # 3) Run the scenario's workflow (in this YAML, there's only "BuildGraph") + # 3) Run the scenario's workflow (BuildGraph then CapacityProbe) scenario.run() # 4) Retrieve the graph built by BuildGraph @@ -34,8 +35,7 @@ def test_scenario_3_build_graph() -> None: ), "Expected a StrictMultiDiGraph in scenario.results under key ('build_graph', 'graph')." # 5) Verify total node count - # Each 3-tier CLOS instance has 32 nodes (2 sub-bricks of 8 nodes each + 16 spine), - # so with 2 instances => 64 nodes total. + # Each 3-tier CLOS instance has 32 nodes -> 2 instances => 64 total. expected_nodes = 64 actual_nodes = len(graph.nodes) assert ( @@ -43,10 +43,9 @@ def test_scenario_3_build_graph() -> None: ), f"Expected {expected_nodes} nodes, found {actual_nodes}" # 6) Verify total physical links before direction is applied to Nx - # Each 3-tier CLOS has 64 links internally. With 2 instances => 128 links, - # plus 16 links connecting my_clos1/spine to my_clos2/spine (one_to_one). - # => total physical links = 128 + 16 = 144 - # => each link becomes 2 directed edges in MultiDiGraph => 288 edges + # Each 3-tier CLOS has 64 links internally -> 2 instances => 128 + # Plus 16 links connecting my_clos1/spine -> my_clos2/spine => 144 total physical links + # Each link => 2 directed edges => 288 total edges in MultiDiGraph expected_links = 144 expected_nx_edges = expected_links * 2 actual_edges = len(graph.edges) @@ -62,7 +61,6 @@ def test_scenario_3_build_graph() -> None: assert len(policy.rules) == 0, "Expected an empty failure policy." # 9) Check presence of a few key expanded nodes - # For example: a t1 node in my_clos1/b1 and a spine node in my_clos2. assert ( "my_clos1/b1/t1/t1-1" in scenario.network.nodes ), "Missing expected node 'my_clos1/b1/t1/t1-1' in expanded blueprint." @@ -70,5 +68,15 @@ def test_scenario_3_build_graph() -> None: "my_clos2/spine/t3-16" in scenario.network.nodes ), "Missing expected spine node 'my_clos2/spine/t3-16' in expanded blueprint." - print(scenario.results.get("capacity_probe", "max_flow")) - raise + # 10) Retrieve max flow result from the CapacityProbe step + # The probe is configured with source_path="my_clos1/b" and sink_path="my_clos2/b". + flow_result_label = "max_flow:[my_clos1/b -> my_clos2/b]" + flow_value = scenario.results.get("capacity_probe", flow_result_label) + + # 11) Assert the expected max flow value + # The bottleneck is the 16 spine-to-spine links of capacity=2 => total 32. + expected_flow = 32.0 + assert flow_value == expected_flow, ( + f"Expected max flow of {expected_flow}, got {flow_value}. " + "Check blueprint or link capacities if this fails." + ) diff --git a/tests/test_blueprints_helpers.py b/tests/test_blueprints_helpers.py index 04fb150..5d33373 100644 --- a/tests/test_blueprints_helpers.py +++ b/tests/test_blueprints_helpers.py @@ -6,7 +6,6 @@ Blueprint, _apply_parameters, _join_paths, - _find_nodes_by_path, _create_link, _expand_adjacency_pattern, _process_direct_nodes, @@ -31,43 +30,6 @@ def test_join_paths(): assert _join_paths("SEA", "/leaf") == "SEA/leaf" -def test_find_nodes_by_path(): - """ - Tests _find_nodes_by_path for exact matches, slash-based prefix matches, and fallback prefix pattern. - """ - net = Network() - # Add some nodes - net.add_node(Node("SEA/spine/myspine-1")) - net.add_node(Node("SEA/leaf/leaf-1")) - net.add_node(Node("SEA-other")) - net.add_node(Node("SFO")) - - # 1) Exact match => "SFO" - nodes = _find_nodes_by_path(net, "SFO") - assert len(nodes) == 1 - assert nodes[0].name == "SFO" - - # 2) Slash prefix => "SEA/spine" matches "SEA/spine/myspine-1" - nodes = _find_nodes_by_path(net, "SEA/spine") - assert len(nodes) == 1 - assert nodes[0].name == "SEA/spine/myspine-1" - - # 3) Fallback: "SEA-other" won't be found by slash prefix "SEA/other", but if we search "SEA-other", - # we do an exact match or a fallback "SEA-other" => here it's exact, so we get 1 node - nodes = _find_nodes_by_path(net, "SEA-other") - assert len(nodes) == 1 - assert nodes[0].name == "SEA-other" - - # 4) If we search just "SEA", we match "SEA/spine/myspine-1" and "SEA/leaf/leaf-1" by slash prefix, - # but "SEA-other" won't appear because fallback never triggers (we already found slash matches). - nodes = _find_nodes_by_path(net, "SEA") - found = set(n.name for n in nodes) - assert found == { - "SEA/spine/myspine-1", - "SEA/leaf/leaf-1", - } - - def test_apply_parameters(): """ Tests _apply_parameters to ensure user-provided overrides get applied to the correct subgroup fields. diff --git a/tests/test_network.py b/tests/test_network.py index e306f1b..3a78434 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,5 +1,6 @@ import pytest from ngraph.network import Network, Node, Link, new_base64_uuid +from ngraph.lib.graph import StrictMultiDiGraph def test_new_base64_uuid_length_and_uniqueness(): @@ -47,7 +48,7 @@ def test_node_creation_custom_attrs(): def test_link_defaults_and_id_generation(): """ A Link without custom parameters should default capacity/cost to 1.0, - have an empty attrs dict, and generate a unique ID like 'A-B-'. + have an empty attrs dict, and generate a unique ID like 'A|B|'. """ link = Link("A", "B") @@ -146,7 +147,7 @@ def test_network_attrs(): def test_add_duplicate_node_raises_valueerror(): """ - With the new behavior, adding a second Node with the same name should raise ValueError + Adding a second Node with the same name should raise ValueError rather than overwriting the existing node. """ network = Network() @@ -156,3 +157,180 @@ def test_add_duplicate_node_raises_valueerror(): network.add_node(node1) with pytest.raises(ValueError, match="Node 'A' already exists in the network."): network.add_node(node2) + + +def test_select_nodes_by_path(): + """ + Tests select_nodes_by_path for exact matches, slash-based prefix matches, + and fallback prefix pattern. + """ + net = Network() + # Add some nodes + net.add_node(Node("SEA/spine/myspine-1")) + net.add_node(Node("SEA/leaf/leaf-1")) + net.add_node(Node("SEA-other")) + net.add_node(Node("SFO")) + + # 1) Exact match => "SFO" + nodes = net.select_nodes_by_path("SFO") + assert len(nodes) == 1 + assert nodes[0].name == "SFO" + + # 2) Slash prefix => "SEA/spine" matches "SEA/spine/myspine-1" + nodes = net.select_nodes_by_path("SEA/spine") + assert len(nodes) == 1 + assert nodes[0].name == "SEA/spine/myspine-1" + + # 3) Fallback: "SEA-other" won't be found by slash prefix "SEA/other", + # but if we search "SEA-other", we do an exact match, so we get 1 node + nodes = net.select_nodes_by_path("SEA-other") + assert len(nodes) == 1 + assert nodes[0].name == "SEA-other" + + # 4) If we search just "SEA", we match "SEA/spine/myspine-1" and "SEA/leaf/leaf-1" + # by slash prefix, so fallback never triggers, and "SEA-other" is not included. + nodes = net.select_nodes_by_path("SEA") + found = set(n.name for n in nodes) + assert found == { + "SEA/spine/myspine-1", + "SEA/leaf/leaf-1", + } + + +def test_select_nodes_by_path_partial_fallback(): + """ + Tests the partial prefix logic if both exact/slash-based and dash-based + lookups fail, then partial prefix 'path...' is used. + """ + net = Network() + net.add_node(Node("S1")) + net.add_node(Node("S2")) + net.add_node(Node("SEA-spine")) + net.add_node(Node("NOTMATCH")) + + # The path "S" won't match "S" exactly, won't match "S/" or "S-", so it should + # return partial matches: "S1", "S2", and "SEA-spine". + nodes = net.select_nodes_by_path("S") + found = sorted([n.name for n in nodes]) + assert found == ["S1", "S2", "SEA-spine"] + + +def test_to_strict_multidigraph_add_reverse_true(): + """ + Tests to_strict_multidigraph with add_reverse=True, ensuring + that both forward and reverse edges are added. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab) + net.add_link(link_bc) + + graph = net.to_strict_multidigraph(add_reverse=True) + + # Check nodes + assert set(graph.nodes()) == {"A", "B", "C"} + + # Each link adds two edges (forward + reverse) + edges = list(graph.edges(keys=True)) + forward_keys = {link_ab.id, link_bc.id} + reverse_keys = {f"{link_ab.id}_rev", f"{link_bc.id}_rev"} + all_keys = forward_keys.union(reverse_keys) + found_keys = {e[2] for e in edges} + + assert found_keys == all_keys + + +def test_to_strict_multidigraph_add_reverse_false(): + """ + Tests to_strict_multidigraph with add_reverse=False, ensuring + that only forward edges are added. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + link_ab = Link("A", "B") + net.add_link(link_ab) + + graph = net.to_strict_multidigraph(add_reverse=False) + + # Check nodes + assert set(graph.nodes()) == {"A", "B"} + + # Only one forward edge should exist + edges = list(graph.edges(keys=True)) + assert len(edges) == 1 + assert edges[0][0] == "A" # source + assert edges[0][1] == "B" # target + assert edges[0][2] == link_ab.id + + +def test_max_flow_simple(): + """ + Tests a simple chain A -> B -> C to verify the max_flow calculation. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + # Add links with capacities + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + + # Max flow from A to C is limited by the smallest capacity (3) + flow_value = net.max_flow("A", "C") + assert flow_value == 3.0 + + +def test_max_flow_multi_parallel(): + """ + Tests a scenario where two parallel paths can carry flow. + A -> B -> C and A -> D -> C, each with capacity 5. + The total flow A to C should be 10. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_node(Node("D")) + + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=5)) + net.add_link(Link("A", "D", capacity=5)) + net.add_link(Link("D", "C", capacity=5)) + + flow_value = net.max_flow("A", "C") + assert flow_value == 10.0 + + +def test_max_flow_no_source(): + """ + If no node in the network matches the source path, it should raise ValueError. + """ + net = Network() + # Add only "B" and "C" nodes, no "A". + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("B", "C", capacity=10)) + + with pytest.raises(ValueError, match="No source nodes found matching path 'A'"): + net.max_flow("A", "C") + + +def test_max_flow_no_sink(): + """ + If no node in the network matches the sink path, it should raise ValueError. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + with pytest.raises(ValueError, match="No sink nodes found matching path 'C'"): + net.max_flow("A", "C") diff --git a/tests/workflow/test_capacity_probe.py b/tests/workflow/test_capacity_probe.py new file mode 100644 index 0000000..44dd838 --- /dev/null +++ b/tests/workflow/test_capacity_probe.py @@ -0,0 +1,115 @@ +import pytest +from unittest.mock import MagicMock + +from ngraph.network import Network, Node, Link +from ngraph.workflow.capacity_probe import CapacityProbe +from ngraph.lib.algorithms.base import FlowPlacement + + +@pytest.fixture +def mock_scenario(): + """ + Provides a mock Scenario object with a simple Network and a mocked results. + """ + scenario = MagicMock() + scenario.network = Network() + scenario.results = MagicMock() + scenario.results.put = MagicMock() + return scenario + + +def test_capacity_probe_simple_flow(mock_scenario): + """ + Tests a simple A->B network to confirm CapacityProbe calculates the correct flow + and stores it in scenario.results with the expected label. + """ + # Create a 2-node network (A->B capacity=5) + mock_scenario.network.add_node(Node("A")) + mock_scenario.network.add_node(Node("B")) + mock_scenario.network.add_link(Link("A", "B", capacity=5)) + + # Instantiate the step + step = CapacityProbe( + name="MyCapacityProbe", + source_path="A", + sink_path="B", + shortest_path=False, + flow_placement=FlowPlacement.PROPORTIONAL, + ) + + step.run(mock_scenario) + + # The flow from A to B should be 5 + expected_flow = 5.0 + + # Validate scenario.results.put call + # The step uses the label: "max_flow:[A -> B]" + mock_scenario.results.put.assert_called_once() + call_args = mock_scenario.results.put.call_args[0] + # call_args format => (step_name, result_label, flow_value) + assert call_args[0] == "MyCapacityProbe" + assert call_args[1] == "max_flow:[A -> B]" + assert call_args[2] == expected_flow + + +def test_capacity_probe_no_source(mock_scenario): + """ + Tests that CapacityProbe raises ValueError if no source nodes match. + """ + # The network only has node "X"; no node matches "A". + mock_scenario.network.add_node(Node("X")) + mock_scenario.network.add_node(Node("B")) + mock_scenario.network.add_link(Link("X", "B", capacity=10)) + + step = CapacityProbe(name="MyCapacityProbe", source_path="A", sink_path="B") + + with pytest.raises(ValueError, match="No source nodes found matching path 'A'"): + step.run(mock_scenario) + + +def test_capacity_probe_no_sink(mock_scenario): + """ + Tests that CapacityProbe raises ValueError if no sink nodes match. + """ + # The network only has node "A"; no node matches "B". + mock_scenario.network.add_node(Node("A")) + mock_scenario.network.add_link( + Link("A", "A", capacity=10) + ) # silly link for completeness + + step = CapacityProbe(name="MyCapacityProbe", source_path="A", sink_path="B") + + with pytest.raises(ValueError, match="No sink nodes found matching path 'B'"): + step.run(mock_scenario) + + +def test_capacity_probe_parallel_paths(mock_scenario): + """ + Tests a scenario with two parallel paths from A->C: A->B->C and A->D->C, + each with capacity=5, ensuring the total flow is 10. + """ + # Create a 4-node network + mock_scenario.network.add_node(Node("A")) + mock_scenario.network.add_node(Node("B")) + mock_scenario.network.add_node(Node("C")) + mock_scenario.network.add_node(Node("D")) + + mock_scenario.network.add_link(Link("A", "B", capacity=5)) + mock_scenario.network.add_link(Link("B", "C", capacity=5)) + mock_scenario.network.add_link(Link("A", "D", capacity=5)) + mock_scenario.network.add_link(Link("D", "C", capacity=5)) + + step = CapacityProbe( + name="MyCapacityProbe", + source_path="A", + sink_path="C", + shortest_path=False, + flow_placement=FlowPlacement.PROPORTIONAL, + ) + step.run(mock_scenario) + + mock_scenario.results.put.assert_called_once() + call_args = mock_scenario.results.put.call_args[0] + assert call_args[0] == "MyCapacityProbe" + assert call_args[1] == "max_flow:[A -> C]" + assert call_args[2] == 10.0