From ef51696e694c750f2caee6e578464a8344334c12 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 4 Jul 2025 22:14:05 +0100 Subject: [PATCH 1/2] Add NetworkStats workflow step --- docs/reference/api-full.md | 29 +++++++++ docs/reference/api.md | 1 + docs/reference/cli.md | 1 + docs/reference/dsl.md | 1 + ngraph/workflow/__init__.py | 2 + ngraph/workflow/network_stats.py | 88 ++++++++++++++++++++++++++++ tests/workflow/test_network_stats.py | 52 ++++++++++++++++ 7 files changed, 174 insertions(+) create mode 100644 ngraph/workflow/network_stats.py create mode 100644 tests/workflow/test_network_stats.py diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index a29e5cb..b6f0d67 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -2120,6 +2120,35 @@ Attributes: --- +## ngraph.workflow.network_stats + +Base statistical analysis of nodes and links. + +### NetworkStats + +A workflow step that gathers capacity and degree statistics for the network. + +YAML Configuration: + ```yaml + workflow: + - step_type: NetworkStats + name: "stats" # Optional custom name for this step + ``` + +**Attributes:** + +- `name` (str) +- `seed` (Optional[int]) + +**Methods:** + +- `execute(self, scenario: "'Scenario'") -> 'None'` + - Execute the workflow step with automatic logging. +- `run(self, scenario: "'Scenario'") -> 'None'` + - Collect capacity and degree statistics. + +--- + ## ngraph.workflow.notebook_export Jupyter notebook export and generation functionality. diff --git a/docs/reference/api.md b/docs/reference/api.md index 3fa6388..06215a7 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -198,6 +198,7 @@ NetGraph provides workflow steps for automated analysis sequences. # Available workflow steps: # - BuildGraph: Builds a StrictMultiDiGraph from scenario.network # - CapacityProbe: Probes capacity (max flow) between selected groups of nodes +# - NetworkStats: Computes basic capacity and degree statistics # Example workflow configuration: workflow = [ diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 79c7676..d4fef42 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -118,6 +118,7 @@ The CLI outputs results in JSON format. The structure depends on the workflow st - **BuildGraph**: Returns graph data in node-link JSON format - **CapacityProbe**: Returns max flow values with descriptive labels +- **NetworkStats**: Reports capacity and degree statistics - **Other Steps**: Each step stores its results with step-specific keys Example output structure: diff --git a/docs/reference/dsl.md b/docs/reference/dsl.md index a2d6467..91a7320 100644 --- a/docs/reference/dsl.md +++ b/docs/reference/dsl.md @@ -493,6 +493,7 @@ workflow: - **`DistributeExternalConnectivity`**: Creates external connectivity across attachment points - **`CapacityProbe`**: Probes maximum flow capacity between node groups - **`CapacityEnvelopeAnalysis`**: Performs Monte-Carlo capacity analysis across failure scenarios +- **`NetworkStats`**: Computes basic node/link capacity and degree statistics - **`NotebookExport`**: Saves scenario results to a Jupyter notebook with configurable content and visualizations ```yaml diff --git a/ngraph/workflow/__init__.py b/ngraph/workflow/__init__.py index 8bd2faf..839f0a5 100644 --- a/ngraph/workflow/__init__.py +++ b/ngraph/workflow/__init__.py @@ -5,6 +5,7 @@ from .build_graph import BuildGraph from .capacity_envelope_analysis import CapacityEnvelopeAnalysis from .capacity_probe import CapacityProbe +from .network_stats import NetworkStats from .notebook_export import NotebookExport __all__ = [ @@ -13,6 +14,7 @@ "BuildGraph", "CapacityEnvelopeAnalysis", "CapacityProbe", + "NetworkStats", "NotebookExport", "transform", ] diff --git a/ngraph/workflow/network_stats.py b/ngraph/workflow/network_stats.py new file mode 100644 index 0000000..b6ee4ca --- /dev/null +++ b/ngraph/workflow/network_stats.py @@ -0,0 +1,88 @@ +"""Workflow step for basic node and link statistics.""" + +from __future__ import annotations + +from dataclasses import dataclass +from statistics import mean, median +from typing import TYPE_CHECKING, Dict, List + +from ngraph.workflow.base import WorkflowStep, register_workflow_step + +if TYPE_CHECKING: + from ngraph.scenario import Scenario + + +@dataclass +class NetworkStats(WorkflowStep): + """Compute basic node and link statistics for the network.""" + + def run(self, scenario: Scenario) -> None: + """Collect capacity and degree statistics. + + Args: + scenario: Scenario containing the network and results container. + """ + + network = scenario.network + + link_caps = [ + link.capacity for link in network.links.values() if not link.disabled + ] + link_caps_sorted = sorted(link_caps) + link_stats = { + "values": link_caps_sorted, + "min": min(link_caps_sorted) if link_caps_sorted else 0.0, + "max": max(link_caps_sorted) if link_caps_sorted else 0.0, + "mean": mean(link_caps_sorted) if link_caps_sorted else 0.0, + "median": median(link_caps_sorted) if link_caps_sorted else 0.0, + } + + node_stats: Dict[str, Dict[str, List[float] | float]] = {} + node_capacities = [] + node_degrees = [] + for node_name, node in network.nodes.items(): + if node.disabled: + continue + outgoing = [ + link.capacity + for link in network.links.values() + if link.source == node_name and not link.disabled + ] + degree = len(outgoing) + cap_sum = sum(outgoing) + + node_degrees.append(degree) + node_capacities.append(cap_sum) + + node_stats[node_name] = { + "degree": degree, + "capacity_sum": cap_sum, + "capacities": sorted(outgoing), + } + + node_caps_sorted = sorted(node_capacities) + node_degrees_sorted = sorted(node_degrees) + + node_capacity_dist = { + "values": node_caps_sorted, + "min": min(node_caps_sorted) if node_caps_sorted else 0.0, + "max": max(node_caps_sorted) if node_caps_sorted else 0.0, + "mean": mean(node_caps_sorted) if node_caps_sorted else 0.0, + "median": median(node_caps_sorted) if node_caps_sorted else 0.0, + } + + node_degree_dist = { + "values": node_degrees_sorted, + "min": min(node_degrees_sorted) if node_degrees_sorted else 0, + "max": max(node_degrees_sorted) if node_degrees_sorted else 0, + "mean": mean(node_degrees_sorted) if node_degrees_sorted else 0.0, + "median": median(node_degrees_sorted) if node_degrees_sorted else 0, + } + + scenario.results.put(self.name, "link_capacity", link_stats) + scenario.results.put(self.name, "node_capacity", node_capacity_dist) + scenario.results.put(self.name, "node_degree", node_degree_dist) + scenario.results.put(self.name, "per_node", node_stats) + + +register_workflow_step("NetworkStats")(NetworkStats) diff --git a/tests/workflow/test_network_stats.py b/tests/workflow/test_network_stats.py new file mode 100644 index 0000000..fa5cb09 --- /dev/null +++ b/tests/workflow/test_network_stats.py @@ -0,0 +1,52 @@ +from unittest.mock import MagicMock + +import pytest + +from ngraph.network import Link, Network, Node +from ngraph.workflow.network_stats import NetworkStats + + +@pytest.fixture +def mock_scenario(): + scenario = MagicMock() + scenario.network = Network() + scenario.results = MagicMock() + scenario.results.put = MagicMock() + + scenario.network.add_node(Node("A")) + scenario.network.add_node(Node("B")) + scenario.network.add_node(Node("C")) + + scenario.network.add_link(Link("A", "B", capacity=10)) + scenario.network.add_link(Link("A", "C", capacity=5)) + scenario.network.add_link(Link("C", "A", capacity=7)) + return scenario + + +def test_network_stats_collects_statistics(mock_scenario): + step = NetworkStats(name="stats") + + step.run(mock_scenario) + + assert mock_scenario.results.put.call_count == 4 + + keys = {call.args[1] for call in mock_scenario.results.put.call_args_list} + assert keys == {"link_capacity", "node_capacity", "node_degree", "per_node"} + + link_data = next( + call.args[2] + for call in mock_scenario.results.put.call_args_list + if call.args[1] == "link_capacity" + ) + assert link_data["values"] == [5, 7, 10] + assert link_data["min"] == 5 + assert link_data["max"] == 10 + assert link_data["median"] == 7 + assert link_data["mean"] == pytest.approx((5 + 7 + 10) / 3) + + per_node = next( + call.args[2] + for call in mock_scenario.results.put.call_args_list + if call.args[1] == "per_node" + ) + assert set(per_node.keys()) == {"A", "B", "C"} From b2d53bc52473b48e8926f583ca3da4d9fe71cf08 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sat, 5 Jul 2025 12:46:05 +0100 Subject: [PATCH 2/2] Add include_disabled parameter to NetworkStats for optional inclusion of disabled nodes and links in statistics. Update related logic and tests to ensure backward compatibility and correct behavior. --- ngraph/workflow/network_stats.py | 51 +++++++--- tests/workflow/test_network_stats.py | 134 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 13 deletions(-) diff --git a/ngraph/workflow/network_stats.py b/ngraph/workflow/network_stats.py index b6ee4ca..ce71de0 100644 --- a/ngraph/workflow/network_stats.py +++ b/ngraph/workflow/network_stats.py @@ -14,7 +14,14 @@ @dataclass class NetworkStats(WorkflowStep): - """Compute basic node and link statistics for the network.""" + """Compute basic node and link statistics for the network. + + Attributes: + include_disabled (bool): If True, include disabled nodes and links in statistics. + If False, only consider enabled entities. Defaults to False. + """ + + include_disabled: bool = False def run(self, scenario: Scenario) -> None: """Collect capacity and degree statistics. @@ -25,9 +32,14 @@ def run(self, scenario: Scenario) -> None: network = scenario.network - link_caps = [ - link.capacity for link in network.links.values() if not link.disabled - ] + # Collect link capacity statistics - filter based on include_disabled setting + if self.include_disabled: + link_caps = [link.capacity for link in network.links.values()] + else: + link_caps = [ + link.capacity for link in network.links.values() if not link.disabled + ] + link_caps_sorted = sorted(link_caps) link_stats = { "values": link_caps_sorted, @@ -37,17 +49,29 @@ def run(self, scenario: Scenario) -> None: "median": median(link_caps_sorted) if link_caps_sorted else 0.0, } + # Collect per-node statistics and aggregate data for distributions node_stats: Dict[str, Dict[str, List[float] | float]] = {} node_capacities = [] node_degrees = [] for node_name, node in network.nodes.items(): - if node.disabled: + # Skip disabled nodes unless include_disabled is True + if not self.include_disabled and node.disabled: continue - outgoing = [ - link.capacity - for link in network.links.values() - if link.source == node_name and not link.disabled - ] + + # Calculate node degree and capacity - filter links based on include_disabled setting + if self.include_disabled: + outgoing = [ + link.capacity + for link in network.links.values() + if link.source == node_name + ] + else: + outgoing = [ + link.capacity + for link in network.links.values() + if link.source == node_name and not link.disabled + ] + degree = len(outgoing) cap_sum = sum(outgoing) @@ -60,6 +84,7 @@ def run(self, scenario: Scenario) -> None: "capacities": sorted(outgoing), } + # Create aggregate distributions for network-wide analysis node_caps_sorted = sorted(node_capacities) node_degrees_sorted = sorted(node_degrees) @@ -73,10 +98,10 @@ def run(self, scenario: Scenario) -> None: node_degree_dist = { "values": node_degrees_sorted, - "min": min(node_degrees_sorted) if node_degrees_sorted else 0, - "max": max(node_degrees_sorted) if node_degrees_sorted else 0, + "min": min(node_degrees_sorted) if node_degrees_sorted else 0.0, + "max": max(node_degrees_sorted) if node_degrees_sorted else 0.0, "mean": mean(node_degrees_sorted) if node_degrees_sorted else 0.0, - "median": median(node_degrees_sorted) if node_degrees_sorted else 0, + "median": median(node_degrees_sorted) if node_degrees_sorted else 0.0, } scenario.results.put(self.name, "link_capacity", link_stats) diff --git a/tests/workflow/test_network_stats.py b/tests/workflow/test_network_stats.py index fa5cb09..eae904b 100644 --- a/tests/workflow/test_network_stats.py +++ b/tests/workflow/test_network_stats.py @@ -23,6 +23,31 @@ def mock_scenario(): return scenario +@pytest.fixture +def mock_scenario_with_disabled(): + """Scenario with disabled nodes and links for testing include_disabled parameter.""" + scenario = MagicMock() + scenario.network = Network() + scenario.results = MagicMock() + scenario.results.put = MagicMock() + + # Add nodes - some enabled, some disabled + scenario.network.add_node(Node("A")) # enabled + scenario.network.add_node(Node("B")) # enabled + scenario.network.add_node(Node("C", disabled=True)) # disabled + scenario.network.add_node(Node("D")) # enabled + + # Add links - some enabled, some disabled + scenario.network.add_link(Link("A", "B", capacity=10)) # enabled + scenario.network.add_link(Link("A", "C", capacity=5)) # enabled (to disabled node) + scenario.network.add_link( + Link("C", "A", capacity=7) + ) # enabled (from disabled node) + scenario.network.add_link(Link("B", "D", capacity=15, disabled=True)) # disabled + scenario.network.add_link(Link("D", "B", capacity=20)) # enabled + return scenario + + def test_network_stats_collects_statistics(mock_scenario): step = NetworkStats(name="stats") @@ -50,3 +75,112 @@ def test_network_stats_collects_statistics(mock_scenario): if call.args[1] == "per_node" ) assert set(per_node.keys()) == {"A", "B", "C"} + + +def test_network_stats_excludes_disabled_by_default(mock_scenario_with_disabled): + """Test that disabled nodes and links are excluded by default.""" + step = NetworkStats(name="stats") + + step.run(mock_scenario_with_disabled) + + # Get the collected data + calls = { + call.args[1]: call.args[2] + for call in mock_scenario_with_disabled.results.put.call_args_list + } + + # Link capacity should exclude disabled link (capacity=15) + link_data = calls["link_capacity"] + # Should include capacities: 10, 5, 7, 20 (excluding disabled link with capacity=15) + assert sorted(link_data["values"]) == [5, 7, 10, 20] + assert link_data["min"] == 5 + assert link_data["max"] == 20 + assert link_data["mean"] == pytest.approx((5 + 7 + 10 + 20) / 4) + + # Per-node stats should exclude disabled node C + per_node = calls["per_node"] + # Should only include enabled nodes: A, B, D (excluding disabled node C) + assert set(per_node.keys()) == {"A", "B", "D"} + + # Node A should have degree 2 (links to B and C, both enabled) + assert per_node["A"]["degree"] == 2 + assert per_node["A"]["capacity_sum"] == 15 # 10 + 5 + + # Node B should have degree 0 (link to D is disabled) + assert per_node["B"]["degree"] == 0 + assert per_node["B"]["capacity_sum"] == 0 + + # Node D should have degree 1 (link to B is enabled) + assert per_node["D"]["degree"] == 1 + assert per_node["D"]["capacity_sum"] == 20 + + +def test_network_stats_includes_disabled_when_enabled(mock_scenario_with_disabled): + """Test that disabled nodes and links are included when include_disabled=True.""" + step = NetworkStats(name="stats", include_disabled=True) + + step.run(mock_scenario_with_disabled) + + # Get the collected data + calls = { + call.args[1]: call.args[2] + for call in mock_scenario_with_disabled.results.put.call_args_list + } + + # Link capacity should include all links including disabled one + link_data = calls["link_capacity"] + # Should include all capacities: 10, 5, 7, 15, 20 + assert sorted(link_data["values"]) == [5, 7, 10, 15, 20] + assert link_data["min"] == 5 + assert link_data["max"] == 20 + assert link_data["mean"] == pytest.approx((5 + 7 + 10 + 15 + 20) / 5) + + # Per-node stats should include disabled node C + per_node = calls["per_node"] + # Should include all nodes: A, B, C, D + assert set(per_node.keys()) == {"A", "B", "C", "D"} + + # Node A should have degree 2 (links to B and C) + assert per_node["A"]["degree"] == 2 + assert per_node["A"]["capacity_sum"] == 15 # 10 + 5 + + # Node B should have degree 1 (link to D, now included) + assert per_node["B"]["degree"] == 1 + assert per_node["B"]["capacity_sum"] == 15 # disabled link now included + + # Node C should have degree 1 (link to A) + assert per_node["C"]["degree"] == 1 + assert per_node["C"]["capacity_sum"] == 7 + + # Node D should have degree 1 (link to B) + assert per_node["D"]["degree"] == 1 + assert per_node["D"]["capacity_sum"] == 20 + + +def test_network_stats_parameter_backward_compatibility(mock_scenario): + """Test that the new parameter maintains backward compatibility.""" + # Test with explicit default + step_explicit = NetworkStats(name="stats", include_disabled=False) + step_explicit.run(mock_scenario) + + # Capture results from explicit test + explicit_calls = { + call.args[1]: call.args[2] for call in mock_scenario.results.put.call_args_list + } + + # Reset mock for second test + mock_scenario.results.put.reset_mock() + + # Test with implicit default + step_implicit = NetworkStats(name="stats") + step_implicit.run(mock_scenario) + + # Capture results from implicit test + implicit_calls = { + call.args[1]: call.args[2] for call in mock_scenario.results.put.call_args_list + } + + # Results should be identical + assert explicit_calls.keys() == implicit_calls.keys() + for key in explicit_calls: + assert explicit_calls[key] == implicit_calls[key]