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