Skip to content

Commit f7fca88

Browse files
committed
Fix TM sizing for parallel edges, version 0.3.1
Parallel edges (striped corridors) between metro pairs now sized independently during TM-based capacity sizing.
1 parent f2beccf commit f7fca88

4 files changed

Lines changed: 277 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [0.3.1] - 2025-12-07
9+
10+
### Fixed
11+
12+
- **TM Sizing**: Parallel edges (striped corridors) between metro pairs now sized independently. Previously, only one edge per metro pair was updated during TM-based capacity sizing, leaving striped links undersized.
13+
14+
## [0.3.0] - 2025-11-XX
15+
16+
### Changed
17+
18+
- Initial versioned release with TM-based capacity sizing.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
55
# ---------------------------------------------------------------------
66
[project]
77
name = "topogen"
8-
version = "0.3.0"
8+
version = "0.3.1"
99
description = "A network topology generator."
1010
readme = "README.md"
1111
authors = [{ name = "Andrey Golovanov" }]

tests/topogen/scenario/test_graph_pipeline_unit.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from types import SimpleNamespace
4+
from unittest.mock import patch
45

56
import networkx as nx
67

@@ -15,6 +16,27 @@ def _cfg() -> SimpleNamespace:
1516
)
1617

1718

19+
def _tm_sizing_cfg() -> SimpleNamespace:
20+
# Config with TM sizing enabled for testing
21+
return SimpleNamespace(
22+
traffic=SimpleNamespace(
23+
enabled=True,
24+
mw_per_dc_region=10.0,
25+
gbps_per_mw=100.0,
26+
matrix_name="default",
27+
),
28+
build=SimpleNamespace(
29+
tm_sizing=SimpleNamespace(
30+
enabled=True,
31+
quantum_gbps=3200.0,
32+
headroom=1.3,
33+
respect_min_base_capacity=True,
34+
flow_placement="EQUAL_BALANCED",
35+
)
36+
),
37+
)
38+
39+
1840
def test_metro_index_and_node_id_helpers() -> None:
1941
metros = [
2042
{"name": "A", "node_key": (0.0, 0.0)},
@@ -203,3 +225,189 @@ def test_to_network_sections_serializes_groups_and_adjacency() -> None:
203225
)
204226
for a in adjacency
205227
)
228+
229+
230+
def test_tm_sizing_preserves_parallel_edges() -> None:
231+
"""TM sizing must update all parallel corridor edges, not just one.
232+
233+
When multiple corridor edges exist between the same metro pair (striping),
234+
each edge must get its capacity sized independently based on its share of
235+
the traffic load.
236+
"""
237+
# Build a site graph with 3 parallel corridor edges between metros A and B
238+
G = nx.MultiGraph()
239+
metros = [
240+
{"name": "A", "x": 0.0, "y": 0.0, "radius_km": 10.0, "node_key": (0.0, 0.0)},
241+
{
242+
"name": "B",
243+
"x": 100.0,
244+
"y": 0.0,
245+
"radius_km": 10.0,
246+
"node_key": (100.0, 0.0),
247+
},
248+
]
249+
metro_settings = {
250+
"A": {"pop_per_metro": 1, "dc_regions_per_metro": 1},
251+
"B": {"pop_per_metro": 1, "dc_regions_per_metro": 1},
252+
}
253+
254+
# Add nodes
255+
G.add_node("metro1/pop1", site_kind="pop")
256+
G.add_node("metro1/dc1", site_kind="dc")
257+
G.add_node("metro2/pop1", site_kind="pop")
258+
G.add_node("metro2/dc1", site_kind="dc")
259+
260+
# Add 3 parallel inter-metro corridor edges between the same PoPs
261+
original_capacity = 1000.0
262+
for i in range(3):
263+
G.add_edge(
264+
"metro1/pop1",
265+
"metro2/pop1",
266+
key=f"corridor:{i}",
267+
link_type="inter_metro_corridor",
268+
base_capacity=original_capacity,
269+
target_capacity=original_capacity,
270+
cost=500,
271+
source_metro="A",
272+
target_metro="B",
273+
)
274+
275+
# Add DC-to-PoP edges (required for TM generation)
276+
G.add_edge(
277+
"metro1/dc1",
278+
"metro1/pop1",
279+
key="dc_to_pop:1",
280+
link_type="dc_to_pop",
281+
base_capacity=original_capacity,
282+
cost=1,
283+
source_metro="A",
284+
target_metro="A",
285+
)
286+
G.add_edge(
287+
"metro2/dc1",
288+
"metro2/pop1",
289+
key="dc_to_pop:2",
290+
link_type="dc_to_pop",
291+
base_capacity=original_capacity,
292+
cost=1,
293+
source_metro="B",
294+
target_metro="B",
295+
)
296+
297+
# Mock traffic matrix to generate demands between metros
298+
mock_tm = {
299+
"default": [
300+
{
301+
"source_path": "^metro1/dc1/.*",
302+
"sink_path": "^metro2/dc1/.*",
303+
"demand": 10000.0,
304+
},
305+
{
306+
"source_path": "^metro2/dc1/.*",
307+
"sink_path": "^metro1/dc1/.*",
308+
"demand": 10000.0,
309+
},
310+
]
311+
}
312+
313+
cfg = _tm_sizing_cfg()
314+
with patch("topogen.traffic_matrix.generate_traffic_matrix", return_value=mock_tm):
315+
gp.tm_based_size_capacities(G, metros, metro_settings, cfg)
316+
317+
# Verify ALL 3 parallel corridor edges got updated (not just one)
318+
corridor_edges = [
319+
(u, v, k, d)
320+
for u, v, k, d in G.edges(keys=True, data=True)
321+
if d.get("link_type") == "inter_metro_corridor"
322+
]
323+
assert len(corridor_edges) == 3, "Should still have 3 parallel corridor edges"
324+
325+
# Each edge should have been sized (base_capacity increased from original)
326+
updated_count = 0
327+
for _u, _v, _k, data in corridor_edges:
328+
if data["base_capacity"] > original_capacity:
329+
updated_count += 1
330+
331+
# All parallel edges should be sized independently
332+
assert updated_count == 3, (
333+
f"Expected all 3 parallel edges to be sized, but only {updated_count} were. "
334+
"Parallel edges between the same metro pair must be tracked individually."
335+
)
336+
337+
338+
def test_tm_sizing_single_edge_baseline() -> None:
339+
"""TM sizing works correctly with a single corridor edge (no parallelism)."""
340+
G = nx.MultiGraph()
341+
metros = [
342+
{"name": "A", "x": 0.0, "y": 0.0, "radius_km": 10.0, "node_key": (0.0, 0.0)},
343+
{
344+
"name": "B",
345+
"x": 100.0,
346+
"y": 0.0,
347+
"radius_km": 10.0,
348+
"node_key": (100.0, 0.0),
349+
},
350+
]
351+
metro_settings = {
352+
"A": {"pop_per_metro": 1, "dc_regions_per_metro": 1},
353+
"B": {"pop_per_metro": 1, "dc_regions_per_metro": 1},
354+
}
355+
356+
G.add_node("metro1/pop1", site_kind="pop")
357+
G.add_node("metro1/dc1", site_kind="dc")
358+
G.add_node("metro2/pop1", site_kind="pop")
359+
G.add_node("metro2/dc1", site_kind="dc")
360+
361+
original_capacity = 1000.0
362+
G.add_edge(
363+
"metro1/pop1",
364+
"metro2/pop1",
365+
key="corridor:0",
366+
link_type="inter_metro_corridor",
367+
base_capacity=original_capacity,
368+
target_capacity=original_capacity,
369+
cost=500,
370+
source_metro="A",
371+
target_metro="B",
372+
)
373+
G.add_edge(
374+
"metro1/dc1",
375+
"metro1/pop1",
376+
key="dc_to_pop:1",
377+
link_type="dc_to_pop",
378+
base_capacity=original_capacity,
379+
cost=1,
380+
source_metro="A",
381+
target_metro="A",
382+
)
383+
G.add_edge(
384+
"metro2/dc1",
385+
"metro2/pop1",
386+
key="dc_to_pop:2",
387+
link_type="dc_to_pop",
388+
base_capacity=original_capacity,
389+
cost=1,
390+
source_metro="B",
391+
target_metro="B",
392+
)
393+
394+
mock_tm = {
395+
"default": [
396+
{
397+
"source_path": "^metro1/dc1/.*",
398+
"sink_path": "^metro2/dc1/.*",
399+
"demand": 5000.0,
400+
},
401+
]
402+
}
403+
404+
cfg = _tm_sizing_cfg()
405+
with patch("topogen.traffic_matrix.generate_traffic_matrix", return_value=mock_tm):
406+
gp.tm_based_size_capacities(G, metros, metro_settings, cfg)
407+
408+
# The single corridor edge should be sized
409+
corridor_data = G.get_edge_data("metro1/pop1", "metro2/pop1", "corridor:0")
410+
assert corridor_data is not None
411+
assert corridor_data["base_capacity"] > original_capacity, (
412+
"Single corridor edge should have increased capacity after TM sizing"
413+
)

topogen/scenario/graph_pipeline.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -992,11 +992,15 @@ def tm_based_size_capacities(
992992
993993
Pipeline:
994994
- Generate TM using traffic_matrix.generate_traffic_matrix (in-memory).
995-
- Build a metro-level NetworkX graph with inter-metro corridor edges from G.
996-
- Convert to netgraph_core StrictMultiDiGraph via from_networkx() with
997-
bidirectional edges for symmetric flow routing.
995+
- Build a metro-level NetworkX MultiDiGraph with explicit forward and reverse
996+
edges for each inter-metro corridor in G. This allows traffic to flow in
997+
both directions while keeping per-direction loads separate.
998+
- Convert to netgraph_core StrictMultiDiGraph via from_networkx().
998999
- For each directed TM demand in the matrix, compute shortest-path ECMP
999-
fractions and accumulate load on inter-metro corridor edges only.
1000+
fractions and accumulate load on inter-metro corridor edges.
1001+
- Track forward and reverse flows separately using different edge refs.
1002+
Since G is undirected, both refs point to the same G edge, but respect_min
1003+
ensures final capacity = max(sized_forward, sized_reverse).
10001004
- Quantize inter-metro base capacities with headroom.
10011005
- Derive DC->PoP and intra-metro PoP<->PoP base capacities from metro/PoP
10021006
egress with configurable multipliers and quantization.
@@ -1043,13 +1047,18 @@ def tm_based_size_capacities(
10431047
for idx, _ in enumerate(metros, 1):
10441048
metro_idx_map.setdefault(f"metro{idx}", idx)
10451049

1046-
# Build temporary NetworkX DiGraph with metro nodes and inter-metro corridors
1047-
H = nx.DiGraph()
1050+
# Build temporary NetworkX MultiDiGraph with metro nodes and inter-metro corridors.
1051+
# MultiDiGraph is required to preserve parallel edges between the same metro pair
1052+
# (e.g., striped corridors with multiple links per corridor).
1053+
H: nx.MultiDiGraph = nx.MultiDiGraph()
10481054
for idx in set(metro_idx_map.values()):
10491055
H.add_node(idx)
10501056

1051-
# Map to track correspondence between H edges and G edges
1052-
g_edge_refs: dict[tuple[int, int], tuple[str, str, str]] = {}
1057+
# Map to track correspondence between H edges and G edges.
1058+
# Key is (src_metro_idx, dst_metro_idx, h_edge_key) to handle parallel edges.
1059+
# For each undirected G edge, we create two directed H edges (forward and reverse)
1060+
# with DIFFERENT refs so that flows in each direction are tracked separately.
1061+
g_edge_refs: dict[tuple[int, int, Any], tuple[str, str, str]] = {}
10531062

10541063
for u_g, v_g, k_g, data in G.edges(keys=True, data=True):
10551064
if str(data.get("link_type")) != "inter_metro_corridor":
@@ -1069,18 +1078,25 @@ def tm_based_size_capacities(
10691078
)
10701079

10711080
cost = int(data.get("cost", 1))
1072-
# Add edge to H with large capacity for sizing
1073-
H.add_edge(s_idx, t_idx, capacity=1e15, cost=cost)
1074-
# Track forward direction G edge reference
1075-
g_edge_refs[(s_idx, t_idx)] = (str(u_g), str(v_g), str(k_g))
1081+
# Add forward edge: source_metro -> target_metro
1082+
h_key_fwd = H.add_edge(s_idx, t_idx, key=f"{k_g}:fwd", capacity=1e15, cost=cost)
1083+
g_edge_refs[(s_idx, t_idx, h_key_fwd)] = (str(u_g), str(v_g), str(k_g))
1084+
1085+
# Add reverse edge: target_metro -> source_metro
1086+
# Use DIFFERENT ref (v_g, u_g, k_g) so reverse flows are tracked separately.
1087+
# When applied to undirected G, both refs point to the same edge but are
1088+
# processed separately, resulting in max(sized_forward, sized_reverse).
1089+
h_key_rev = H.add_edge(t_idx, s_idx, key=f"{k_g}:rev", capacity=1e15, cost=cost)
1090+
g_edge_refs[(t_idx, s_idx, h_key_rev)] = (str(v_g), str(u_g), str(k_g))
10761091

10771092
if H.number_of_edges() == 0:
10781093
raise ValueError(
10791094
"TM sizing: no inter-metro corridor edges present in site graph"
10801095
)
10811096

1082-
# Convert NetworkX graph to netgraph_core format with bidirectional edges
1083-
multidigraph, node_map, edge_map = _from_networkx(H, bidirectional=True)
1097+
# Convert NetworkX graph to netgraph_core format.
1098+
# bidirectional=False because we already added explicit reverse edges above.
1099+
multidigraph, node_map, edge_map = _from_networkx(H, bidirectional=False)
10841100
num_nodes = multidigraph.num_nodes()
10851101

10861102
# Build Core graph handle
@@ -1114,13 +1130,16 @@ def tm_based_size_capacities(
11141130
demand_val = float(d.get("demand", 0.0))
11151131
if demand_val <= 0.0:
11161132
continue
1117-
s_idx = _parse_tm_endpoint_to_metro_idx(src)
1118-
t_idx = _parse_tm_endpoint_to_metro_idx(dst)
1119-
if s_idx is None or t_idx is None or s_idx == t_idx:
1133+
s_metro = _parse_tm_endpoint_to_metro_idx(src)
1134+
t_metro = _parse_tm_endpoint_to_metro_idx(dst)
1135+
if s_metro is None or t_metro is None or s_metro == t_metro:
11201136
continue
1121-
if s_idx < 0 or s_idx >= num_nodes or t_idx < 0 or t_idx >= num_nodes:
1137+
# Convert 1-based metro indices to 0-based netgraph node indices
1138+
s_idx = node_map.to_index.get(s_metro)
1139+
t_idx = node_map.to_index.get(t_metro)
1140+
if s_idx is None or t_idx is None:
11221141
raise ValueError(
1123-
f"TM sizing: metro index out of range (src={s_idx}, dst={t_idx}, num_nodes={num_nodes})"
1142+
f"TM sizing: metro index out of range (src={s_metro}, dst={t_metro}, num_nodes={num_nodes})"
11241143
)
11251144

11261145
# Compute SPF
@@ -1134,7 +1153,7 @@ def tm_based_size_capacities(
11341153
)
11351154
except Exception as exc:
11361155
raise ValueError(
1137-
f"TM sizing: SPF failed for metro {s_idx}->{t_idx}: {exc}"
1156+
f"TM sizing: SPF failed for metro {s_metro}->{t_metro}: {exc}"
11381157
) from exc
11391158

11401159
# Place flow on DAG
@@ -1151,8 +1170,8 @@ def tm_based_size_capacities(
11511170
logger.debug(
11521171
"TM sizing: placed %s Gbps from metro%d->metro%d (cost=%s)",
11531172
f"{placed:,.1f}",
1154-
s_idx,
1155-
t_idx,
1173+
s_metro,
1174+
t_metro,
11561175
f"{cost:,}",
11571176
)
11581177
except Exception:
@@ -1170,10 +1189,17 @@ def tm_based_size_capacities(
11701189
# Map ext_id -> H edge reference (src_idx, dst_idx, key)
11711190
h_edge_ref = edge_map.to_ref.get(ext_id)
11721191
if h_edge_ref:
1173-
src_idx, dst_idx, _ = h_edge_ref
1192+
src_idx, dst_idx, h_key = h_edge_ref
11741193
# H nodes are int (metro indices), so cast is safe
11751194
if isinstance(src_idx, int) and isinstance(dst_idx, int):
1176-
g_edge_ref = g_edge_refs.get((src_idx, dst_idx))
1195+
# Look up G edge using full (src, dst, key) tuple.
1196+
# Forward and reverse H edges have different refs:
1197+
# - Forward: (u_g, v_g, k_g)
1198+
# - Reverse: (v_g, u_g, k_g)
1199+
# This keeps flows in each direction separate. Since G is undirected,
1200+
# both refs point to the same physical edge, but respect_min logic
1201+
# below ensures capacity = max(sized_forward, sized_reverse).
1202+
g_edge_ref = g_edge_refs.get((src_idx, dst_idx, h_key))
11771203
if g_edge_ref:
11781204
edge_loads[g_edge_ref] = edge_loads.get(g_edge_ref, 0.0) + flow_val
11791205

0 commit comments

Comments
 (0)