Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/reference/api-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ngraph/workflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -13,6 +14,7 @@
"BuildGraph",
"CapacityEnvelopeAnalysis",
"CapacityProbe",
"NetworkStats",
"NotebookExport",
"transform",
]
113 changes: 113 additions & 0 deletions ngraph/workflow/network_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""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.

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.

Args:
scenario: Scenario containing the network and results container.
"""

network = scenario.network

# 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,
"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,
}
Comment on lines +43 to +50
Copy link

Copilot AI Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for building distribution stats is duplicated for links, node capacities, and node degrees. Consider extracting this into a helper function to reduce repetition and improve readability.

Suggested change
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,
}
link_stats = self.compute_distribution_stats(link_caps)

Copilot uses AI. Check for mistakes.

# 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():
# Skip disabled nodes unless include_disabled is True
if not self.include_disabled and node.disabled:
continue

# 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)

node_degrees.append(degree)
node_capacities.append(cap_sum)

node_stats[node_name] = {
"degree": degree,
"capacity_sum": cap_sum,
"capacities": sorted(outgoing),
}

# Create aggregate distributions for network-wide analysis
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.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.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)
186 changes: 186 additions & 0 deletions tests/workflow/test_network_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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


@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")

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"}


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]