Skip to content

Commit fbfac88

Browse files
authored
Add network statistics workflow step (#80)
* Add NetworkStats workflow step * 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.
1 parent f3cf391 commit fbfac88

7 files changed

Lines changed: 333 additions & 0 deletions

File tree

docs/reference/api-full.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,35 @@ Attributes:
21202120

21212121
---
21222122

2123+
## ngraph.workflow.network_stats
2124+
2125+
Base statistical analysis of nodes and links.
2126+
2127+
### NetworkStats
2128+
2129+
A workflow step that gathers capacity and degree statistics for the network.
2130+
2131+
YAML Configuration:
2132+
```yaml
2133+
workflow:
2134+
- step_type: NetworkStats
2135+
name: "stats" # Optional custom name for this step
2136+
```
2137+
2138+
**Attributes:**
2139+
2140+
- `name` (str)
2141+
- `seed` (Optional[int])
2142+
2143+
**Methods:**
2144+
2145+
- `execute(self, scenario: "'Scenario'") -> 'None'`
2146+
- Execute the workflow step with automatic logging.
2147+
- `run(self, scenario: "'Scenario'") -> 'None'`
2148+
- Collect capacity and degree statistics.
2149+
2150+
---
2151+
21232152
## ngraph.workflow.notebook_export
21242153

21252154
Jupyter notebook export and generation functionality.

docs/reference/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ NetGraph provides workflow steps for automated analysis sequences.
198198
# Available workflow steps:
199199
# - BuildGraph: Builds a StrictMultiDiGraph from scenario.network
200200
# - CapacityProbe: Probes capacity (max flow) between selected groups of nodes
201+
# - NetworkStats: Computes basic capacity and degree statistics
201202

202203
# Example workflow configuration:
203204
workflow = [

docs/reference/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ The CLI outputs results in JSON format. The structure depends on the workflow st
118118

119119
- **BuildGraph**: Returns graph data in node-link JSON format
120120
- **CapacityProbe**: Returns max flow values with descriptive labels
121+
- **NetworkStats**: Reports capacity and degree statistics
121122
- **Other Steps**: Each step stores its results with step-specific keys
122123

123124
Example output structure:

docs/reference/dsl.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ workflow:
493493
- **`DistributeExternalConnectivity`**: Creates external connectivity across attachment points
494494
- **`CapacityProbe`**: Probes maximum flow capacity between node groups
495495
- **`CapacityEnvelopeAnalysis`**: Performs Monte-Carlo capacity analysis across failure scenarios
496+
- **`NetworkStats`**: Computes basic node/link capacity and degree statistics
496497
- **`NotebookExport`**: Saves scenario results to a Jupyter notebook with configurable content and visualizations
497498

498499
```yaml

ngraph/workflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .build_graph import BuildGraph
66
from .capacity_envelope_analysis import CapacityEnvelopeAnalysis
77
from .capacity_probe import CapacityProbe
8+
from .network_stats import NetworkStats
89
from .notebook_export import NotebookExport
910

1011
__all__ = [
@@ -13,6 +14,7 @@
1314
"BuildGraph",
1415
"CapacityEnvelopeAnalysis",
1516
"CapacityProbe",
17+
"NetworkStats",
1618
"NotebookExport",
1719
"transform",
1820
]

ngraph/workflow/network_stats.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Workflow step for basic node and link statistics."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from statistics import mean, median
7+
from typing import TYPE_CHECKING, Dict, List
8+
9+
from ngraph.workflow.base import WorkflowStep, register_workflow_step
10+
11+
if TYPE_CHECKING:
12+
from ngraph.scenario import Scenario
13+
14+
15+
@dataclass
16+
class NetworkStats(WorkflowStep):
17+
"""Compute basic node and link statistics for the network.
18+
19+
Attributes:
20+
include_disabled (bool): If True, include disabled nodes and links in statistics.
21+
If False, only consider enabled entities. Defaults to False.
22+
"""
23+
24+
include_disabled: bool = False
25+
26+
def run(self, scenario: Scenario) -> None:
27+
"""Collect capacity and degree statistics.
28+
29+
Args:
30+
scenario: Scenario containing the network and results container.
31+
"""
32+
33+
network = scenario.network
34+
35+
# Collect link capacity statistics - filter based on include_disabled setting
36+
if self.include_disabled:
37+
link_caps = [link.capacity for link in network.links.values()]
38+
else:
39+
link_caps = [
40+
link.capacity for link in network.links.values() if not link.disabled
41+
]
42+
43+
link_caps_sorted = sorted(link_caps)
44+
link_stats = {
45+
"values": link_caps_sorted,
46+
"min": min(link_caps_sorted) if link_caps_sorted else 0.0,
47+
"max": max(link_caps_sorted) if link_caps_sorted else 0.0,
48+
"mean": mean(link_caps_sorted) if link_caps_sorted else 0.0,
49+
"median": median(link_caps_sorted) if link_caps_sorted else 0.0,
50+
}
51+
52+
# Collect per-node statistics and aggregate data for distributions
53+
node_stats: Dict[str, Dict[str, List[float] | float]] = {}
54+
node_capacities = []
55+
node_degrees = []
56+
for node_name, node in network.nodes.items():
57+
# Skip disabled nodes unless include_disabled is True
58+
if not self.include_disabled and node.disabled:
59+
continue
60+
61+
# Calculate node degree and capacity - filter links based on include_disabled setting
62+
if self.include_disabled:
63+
outgoing = [
64+
link.capacity
65+
for link in network.links.values()
66+
if link.source == node_name
67+
]
68+
else:
69+
outgoing = [
70+
link.capacity
71+
for link in network.links.values()
72+
if link.source == node_name and not link.disabled
73+
]
74+
75+
degree = len(outgoing)
76+
cap_sum = sum(outgoing)
77+
78+
node_degrees.append(degree)
79+
node_capacities.append(cap_sum)
80+
81+
node_stats[node_name] = {
82+
"degree": degree,
83+
"capacity_sum": cap_sum,
84+
"capacities": sorted(outgoing),
85+
}
86+
87+
# Create aggregate distributions for network-wide analysis
88+
node_caps_sorted = sorted(node_capacities)
89+
node_degrees_sorted = sorted(node_degrees)
90+
91+
node_capacity_dist = {
92+
"values": node_caps_sorted,
93+
"min": min(node_caps_sorted) if node_caps_sorted else 0.0,
94+
"max": max(node_caps_sorted) if node_caps_sorted else 0.0,
95+
"mean": mean(node_caps_sorted) if node_caps_sorted else 0.0,
96+
"median": median(node_caps_sorted) if node_caps_sorted else 0.0,
97+
}
98+
99+
node_degree_dist = {
100+
"values": node_degrees_sorted,
101+
"min": min(node_degrees_sorted) if node_degrees_sorted else 0.0,
102+
"max": max(node_degrees_sorted) if node_degrees_sorted else 0.0,
103+
"mean": mean(node_degrees_sorted) if node_degrees_sorted else 0.0,
104+
"median": median(node_degrees_sorted) if node_degrees_sorted else 0.0,
105+
}
106+
107+
scenario.results.put(self.name, "link_capacity", link_stats)
108+
scenario.results.put(self.name, "node_capacity", node_capacity_dist)
109+
scenario.results.put(self.name, "node_degree", node_degree_dist)
110+
scenario.results.put(self.name, "per_node", node_stats)
111+
112+
113+
register_workflow_step("NetworkStats")(NetworkStats)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
5+
from ngraph.network import Link, Network, Node
6+
from ngraph.workflow.network_stats import NetworkStats
7+
8+
9+
@pytest.fixture
10+
def mock_scenario():
11+
scenario = MagicMock()
12+
scenario.network = Network()
13+
scenario.results = MagicMock()
14+
scenario.results.put = MagicMock()
15+
16+
scenario.network.add_node(Node("A"))
17+
scenario.network.add_node(Node("B"))
18+
scenario.network.add_node(Node("C"))
19+
20+
scenario.network.add_link(Link("A", "B", capacity=10))
21+
scenario.network.add_link(Link("A", "C", capacity=5))
22+
scenario.network.add_link(Link("C", "A", capacity=7))
23+
return scenario
24+
25+
26+
@pytest.fixture
27+
def mock_scenario_with_disabled():
28+
"""Scenario with disabled nodes and links for testing include_disabled parameter."""
29+
scenario = MagicMock()
30+
scenario.network = Network()
31+
scenario.results = MagicMock()
32+
scenario.results.put = MagicMock()
33+
34+
# Add nodes - some enabled, some disabled
35+
scenario.network.add_node(Node("A")) # enabled
36+
scenario.network.add_node(Node("B")) # enabled
37+
scenario.network.add_node(Node("C", disabled=True)) # disabled
38+
scenario.network.add_node(Node("D")) # enabled
39+
40+
# Add links - some enabled, some disabled
41+
scenario.network.add_link(Link("A", "B", capacity=10)) # enabled
42+
scenario.network.add_link(Link("A", "C", capacity=5)) # enabled (to disabled node)
43+
scenario.network.add_link(
44+
Link("C", "A", capacity=7)
45+
) # enabled (from disabled node)
46+
scenario.network.add_link(Link("B", "D", capacity=15, disabled=True)) # disabled
47+
scenario.network.add_link(Link("D", "B", capacity=20)) # enabled
48+
return scenario
49+
50+
51+
def test_network_stats_collects_statistics(mock_scenario):
52+
step = NetworkStats(name="stats")
53+
54+
step.run(mock_scenario)
55+
56+
assert mock_scenario.results.put.call_count == 4
57+
58+
keys = {call.args[1] for call in mock_scenario.results.put.call_args_list}
59+
assert keys == {"link_capacity", "node_capacity", "node_degree", "per_node"}
60+
61+
link_data = next(
62+
call.args[2]
63+
for call in mock_scenario.results.put.call_args_list
64+
if call.args[1] == "link_capacity"
65+
)
66+
assert link_data["values"] == [5, 7, 10]
67+
assert link_data["min"] == 5
68+
assert link_data["max"] == 10
69+
assert link_data["median"] == 7
70+
assert link_data["mean"] == pytest.approx((5 + 7 + 10) / 3)
71+
72+
per_node = next(
73+
call.args[2]
74+
for call in mock_scenario.results.put.call_args_list
75+
if call.args[1] == "per_node"
76+
)
77+
assert set(per_node.keys()) == {"A", "B", "C"}
78+
79+
80+
def test_network_stats_excludes_disabled_by_default(mock_scenario_with_disabled):
81+
"""Test that disabled nodes and links are excluded by default."""
82+
step = NetworkStats(name="stats")
83+
84+
step.run(mock_scenario_with_disabled)
85+
86+
# Get the collected data
87+
calls = {
88+
call.args[1]: call.args[2]
89+
for call in mock_scenario_with_disabled.results.put.call_args_list
90+
}
91+
92+
# Link capacity should exclude disabled link (capacity=15)
93+
link_data = calls["link_capacity"]
94+
# Should include capacities: 10, 5, 7, 20 (excluding disabled link with capacity=15)
95+
assert sorted(link_data["values"]) == [5, 7, 10, 20]
96+
assert link_data["min"] == 5
97+
assert link_data["max"] == 20
98+
assert link_data["mean"] == pytest.approx((5 + 7 + 10 + 20) / 4)
99+
100+
# Per-node stats should exclude disabled node C
101+
per_node = calls["per_node"]
102+
# Should only include enabled nodes: A, B, D (excluding disabled node C)
103+
assert set(per_node.keys()) == {"A", "B", "D"}
104+
105+
# Node A should have degree 2 (links to B and C, both enabled)
106+
assert per_node["A"]["degree"] == 2
107+
assert per_node["A"]["capacity_sum"] == 15 # 10 + 5
108+
109+
# Node B should have degree 0 (link to D is disabled)
110+
assert per_node["B"]["degree"] == 0
111+
assert per_node["B"]["capacity_sum"] == 0
112+
113+
# Node D should have degree 1 (link to B is enabled)
114+
assert per_node["D"]["degree"] == 1
115+
assert per_node["D"]["capacity_sum"] == 20
116+
117+
118+
def test_network_stats_includes_disabled_when_enabled(mock_scenario_with_disabled):
119+
"""Test that disabled nodes and links are included when include_disabled=True."""
120+
step = NetworkStats(name="stats", include_disabled=True)
121+
122+
step.run(mock_scenario_with_disabled)
123+
124+
# Get the collected data
125+
calls = {
126+
call.args[1]: call.args[2]
127+
for call in mock_scenario_with_disabled.results.put.call_args_list
128+
}
129+
130+
# Link capacity should include all links including disabled one
131+
link_data = calls["link_capacity"]
132+
# Should include all capacities: 10, 5, 7, 15, 20
133+
assert sorted(link_data["values"]) == [5, 7, 10, 15, 20]
134+
assert link_data["min"] == 5
135+
assert link_data["max"] == 20
136+
assert link_data["mean"] == pytest.approx((5 + 7 + 10 + 15 + 20) / 5)
137+
138+
# Per-node stats should include disabled node C
139+
per_node = calls["per_node"]
140+
# Should include all nodes: A, B, C, D
141+
assert set(per_node.keys()) == {"A", "B", "C", "D"}
142+
143+
# Node A should have degree 2 (links to B and C)
144+
assert per_node["A"]["degree"] == 2
145+
assert per_node["A"]["capacity_sum"] == 15 # 10 + 5
146+
147+
# Node B should have degree 1 (link to D, now included)
148+
assert per_node["B"]["degree"] == 1
149+
assert per_node["B"]["capacity_sum"] == 15 # disabled link now included
150+
151+
# Node C should have degree 1 (link to A)
152+
assert per_node["C"]["degree"] == 1
153+
assert per_node["C"]["capacity_sum"] == 7
154+
155+
# Node D should have degree 1 (link to B)
156+
assert per_node["D"]["degree"] == 1
157+
assert per_node["D"]["capacity_sum"] == 20
158+
159+
160+
def test_network_stats_parameter_backward_compatibility(mock_scenario):
161+
"""Test that the new parameter maintains backward compatibility."""
162+
# Test with explicit default
163+
step_explicit = NetworkStats(name="stats", include_disabled=False)
164+
step_explicit.run(mock_scenario)
165+
166+
# Capture results from explicit test
167+
explicit_calls = {
168+
call.args[1]: call.args[2] for call in mock_scenario.results.put.call_args_list
169+
}
170+
171+
# Reset mock for second test
172+
mock_scenario.results.put.reset_mock()
173+
174+
# Test with implicit default
175+
step_implicit = NetworkStats(name="stats")
176+
step_implicit.run(mock_scenario)
177+
178+
# Capture results from implicit test
179+
implicit_calls = {
180+
call.args[1]: call.args[2] for call in mock_scenario.results.put.call_args_list
181+
}
182+
183+
# Results should be identical
184+
assert explicit_calls.keys() == implicit_calls.keys()
185+
for key in explicit_calls:
186+
assert explicit_calls[key] == implicit_calls[key]

0 commit comments

Comments
 (0)