@@ -1102,3 +1102,222 @@ def test_road_following_places_intermediate_poles_along_road(
11021102 f"Gap between ({ ax :.0f} ,{ ay :.0f} ) and ({ bx :.0f} ,{ by :.0f} ) = { dist :.1f} m "
11031103 f"> max { max_len } m"
11041104 )
1105+
1106+
1107+ # ---------------------------------------------------------------------------
1108+ # _binary_search_n
1109+ # ---------------------------------------------------------------------------
1110+
1111+ def test_binary_search_n_finds_minimum_satisfying_n (optimizer : GridOptimizer ) -> None :
1112+ result = optimizer ._binary_search_n (list (range (1 , 11 )), lambda n : n >= 4 )
1113+ assert result == 4
1114+
1115+
1116+ def test_binary_search_n_returns_last_element_when_nothing_satisfies (
1117+ optimizer : GridOptimizer ,
1118+ ) -> None :
1119+ # probe always False — last element is the guaranteed fallback
1120+ result = optimizer ._binary_search_n ([1 , 2 , 3 ], lambda n : False )
1121+ assert result == 3
1122+
1123+
1124+ def test_binary_search_n_single_candidate (optimizer : GridOptimizer ) -> None :
1125+ result = optimizer ._binary_search_n ([7 ], lambda n : n >= 7 )
1126+ assert result == 7
1127+
1128+
1129+ def test_binary_search_n_all_satisfy_returns_first (optimizer : GridOptimizer ) -> None :
1130+ result = optimizer ._binary_search_n (list (range (1 , 20 )), lambda n : True )
1131+ assert result == 1
1132+
1133+
1134+ # ---------------------------------------------------------------------------
1135+ # _sample_road_poles
1136+ # ---------------------------------------------------------------------------
1137+
1138+ def test_sample_road_poles_places_poles_at_interval (optimizer : GridOptimizer ) -> None :
1139+ """250 m road split into two segments, max_length=100 m → 4 poles at 0, 100, 200, 250."""
1140+ optimizer .roads = pd .DataFrame ({
1141+ "x0" : [0.0 , 125.0 ],
1142+ "y0" : [0.0 , 0.0 ],
1143+ "x1" : [125.0 , 250.0 ],
1144+ "y1" : [0.0 , 0.0 ],
1145+ "road_id" : ["r-0" , "r-1" ],
1146+ "parent_road_id" : ["road-1" , "road-1" ],
1147+ })
1148+
1149+ count = optimizer ._sample_road_poles ()
1150+
1151+ road_poles = optimizer .nodes [optimizer .nodes .index .str .startswith ("rp-" )]
1152+ assert count == 4
1153+ assert len (road_poles ) == 4
1154+ xs = sorted (road_poles ["x" ].tolist ())
1155+ assert xs == pytest .approx ([0.0 , 100.0 , 200.0 , 250.0 ], abs = 1.0 )
1156+
1157+
1158+ def test_sample_road_poles_deduplicates_shared_endpoint (
1159+ optimizer : GridOptimizer ,
1160+ ) -> None :
1161+ """Two separate polylines share endpoint (100, 0) — must appear exactly once."""
1162+ optimizer .roads = pd .DataFrame ({
1163+ "x0" : [0.0 , 100.0 ],
1164+ "y0" : [0.0 , 0.0 ],
1165+ "x1" : [100.0 , 200.0 ],
1166+ "y1" : [0.0 , 0.0 ],
1167+ "road_id" : ["r-0" , "r-1" ],
1168+ "parent_road_id" : ["road-1" , "road-2" ],
1169+ })
1170+
1171+ optimizer ._sample_road_poles ()
1172+
1173+ road_poles = optimizer .nodes [optimizer .nodes .index .str .startswith ("rp-" )]
1174+ xs_at_100 = [x for x in road_poles ["x" ].tolist () if abs (x - 100.0 ) < 0.1 ]
1175+ assert len (xs_at_100 ) == 1 , "Shared endpoint (100, 0) must appear exactly once"
1176+
1177+
1178+ def test_sample_road_poles_adds_required_columns (optimizer : GridOptimizer ) -> None :
1179+ """Road poles must carry all columns expected by downstream methods."""
1180+ optimizer .roads = pd .DataFrame ({
1181+ "x0" : [0.0 ], "y0" : [0.0 ], "x1" : [50.0 ], "y1" : [0.0 ],
1182+ "road_id" : ["r-0" ], "parent_road_id" : ["road-1" ],
1183+ })
1184+
1185+ optimizer ._sample_road_poles ()
1186+
1187+ road_poles = optimizer .nodes [optimizer .nodes .index .str .startswith ("rp-" )]
1188+ for col in ("n_connection_links" , "n_distribution_links" , "parent" ,
1189+ "custom_specification" , "shs_options" ):
1190+ assert col in road_poles .columns , f"Missing column: { col } "
1191+ assert (road_poles ["n_connection_links" ] == 0 ).all ()
1192+ assert (road_poles ["shs_options" ] == 0 ).all ()
1193+ assert (road_poles ["node_type" ] == "pole" ).all ()
1194+
1195+
1196+ # ---------------------------------------------------------------------------
1197+ # _associate_consumers_to_road_poles
1198+ # ---------------------------------------------------------------------------
1199+
1200+ def test_associate_consumers_assigns_nearby_returns_far_unassigned (
1201+ optimizer : GridOptimizer ,
1202+ ) -> None :
1203+ """Consumer within connection_cable_max_length is assigned; one beyond is not."""
1204+ optimizer .nodes = optimizer .nodes [optimizer .nodes ["node_type" ] != "power-house" ].copy ()
1205+ optimizer .nodes .loc ["0" , ["x" , "y" , "is_connected" , "node_type" ]] = [0.0 , 0.0 , True , "consumer" ]
1206+ optimizer .nodes .loc ["1" , ["x" , "y" , "is_connected" , "node_type" ]] = [500.0 , 0.0 , True , "consumer" ]
1207+ optimizer ._add_node (
1208+ "rp-0" , node_type = "pole" , x = 10.0 , y = 0.0 ,
1209+ how_added = "road-sampled" , cluster_label = 100000 , is_connected = True ,
1210+ )
1211+
1212+ unassigned = optimizer ._associate_consumers_to_road_poles ()
1213+
1214+ assert "0" not in unassigned , "Consumer within range must be assigned"
1215+ assert "1" in unassigned , "Consumer beyond max_length must be unassigned"
1216+ assert optimizer .nodes .at ["0" , "cluster_label" ] == 100000
1217+
1218+
1219+ def test_associate_consumers_drops_empty_road_pole (optimizer : GridOptimizer ) -> None :
1220+ """Road pole that attracts no consumers must be removed from self.nodes."""
1221+ optimizer .nodes = optimizer .nodes [optimizer .nodes ["node_type" ] != "power-house" ].copy ()
1222+ optimizer .nodes .loc ["0" , ["x" , "y" , "is_connected" , "node_type" ]] = [0.0 , 0.0 , True , "consumer" ]
1223+ optimizer .nodes .loc ["1" , ["x" , "y" , "is_connected" , "node_type" ]] = [0.0 , 1.0 , True , "consumer" ]
1224+ # rp-0 close to consumers; rp-1 far beyond connection_cable_max_length (40 m)
1225+ optimizer ._add_node ("rp-0" , node_type = "pole" , x = 0.0 , y = 0.0 ,
1226+ how_added = "road-sampled" , cluster_label = 100000 , is_connected = True )
1227+ optimizer ._add_node ("rp-1" , node_type = "pole" , x = 999.0 , y = 0.0 ,
1228+ how_added = "road-sampled" , cluster_label = 100001 , is_connected = True )
1229+
1230+ optimizer ._associate_consumers_to_road_poles ()
1231+
1232+ assert "rp-0" in optimizer .nodes .index , "Used road pole must be kept"
1233+ assert "rp-1" not in optimizer .nodes .index , "Empty road pole must be dropped"
1234+
1235+
1236+ # ---------------------------------------------------------------------------
1237+ # _build_branch_hierarchy
1238+ # ---------------------------------------------------------------------------
1239+
1240+ def test_allocate_branches_assigns_branch_and_propagates_to_consumers (
1241+ optimizer : GridOptimizer ,
1242+ ) -> None :
1243+ """Linear chain: c-0/c-1 → p-0/p-1 → power-house.
1244+
1245+ Both poles must end up in branch p-0; both consumers must inherit that branch.
1246+ """
1247+ optimizer .nodes = optimizer .nodes [optimizer .nodes ["node_type" ] != "consumer" ].copy ()
1248+ optimizer .nodes .loc ["2" , ["x" , "y" , "node_type" , "parent" , "n_distribution_links" ]] = [
1249+ 0.0 , 0.0 , "power-house" , "unknown" , 1 ,
1250+ ]
1251+ optimizer ._add_node ("p-0" , node_type = "pole" , consumer_type = "n.a." , consumer_detail = "n.a." ,
1252+ x = 10.0 , y = 0.0 , parent = "unknown" , n_distribution_links = 2 )
1253+ optimizer ._add_node ("p-1" , node_type = "pole" , consumer_type = "n.a." , consumer_detail = "n.a." ,
1254+ x = 20.0 , y = 0.0 , parent = "unknown" , n_distribution_links = 1 )
1255+ optimizer ._add_node ("c-0" , node_type = "consumer" , x = 15.0 , y = 0.0 , parent = "p-0" )
1256+ optimizer ._add_node ("c-1" , node_type = "consumer" , x = 25.0 , y = 0.0 , parent = "p-1" )
1257+
1258+ optimizer ._add_links ("p-0" , "2" )
1259+ optimizer ._add_links ("p-1" , "p-0" )
1260+ optimizer ._set_direction_of_links ()
1261+ optimizer .distribution_links = optimizer .links [optimizer .links ["link_type" ] == "distribution" ]
1262+
1263+ optimizer .allocate_poles_to_branches ()
1264+ optimizer .allocate_subbranches_to_branches ()
1265+ optimizer .label_branch_of_consumers ()
1266+
1267+ assert optimizer .nodes .at ["p-0" , "branch" ] == "p-0"
1268+ assert optimizer .nodes .at ["p-1" , "branch" ] == "p-0"
1269+ assert optimizer .nodes .at ["c-0" , "branch" ] == "p-0"
1270+ assert optimizer .nodes .at ["c-1" , "branch" ] == "p-0"
1271+
1272+
1273+ # ---------------------------------------------------------------------------
1274+ # _find_opt_kmeans_for_unassigned
1275+ # ---------------------------------------------------------------------------
1276+
1277+ @pytest .mark .integration
1278+ def test_find_opt_kmeans_for_unassigned_satisfies_cable_length (
1279+ grid_design : dict ,
1280+ ) -> None :
1281+ """Binary search must find n such that all connection cables ≤ max_length.
1282+
1283+ 4 consumers spaced ~50 m apart; connection_cable_max_length = 40 m.
1284+ One cluster centred between two adjacent consumers is ~25 m from each,
1285+ so n=2 should be sufficient. The method must return n ≥ 1 and the
1286+ final clustering must produce no violated cable.
1287+ """
1288+ pytest .importorskip ("k_means_constrained" )
1289+ pytest .importorskip ("utm" )
1290+ pytest .importorskip ("pyproj" )
1291+
1292+ design = copy .deepcopy (grid_design )
1293+ design ["pole" ]["max_n_connections" ] = 2 # size_max must not exceed n_samples (4)
1294+ payload = {
1295+ "nodes" : [
1296+ {
1297+ "latitude" : 0.0 ,
1298+ "longitude" : float (i ) * 0.00045 ,
1299+ "node_type" : "consumer" ,
1300+ "consumer_type" : "household" ,
1301+ "consumer_detail" : str (i ),
1302+ "how_added" : "manual" ,
1303+ "shs_options" : 0 ,
1304+ "custom_specification" : "" ,
1305+ }
1306+ for i in range (4 )
1307+ ],
1308+ "grid_design" : design ,
1309+ "yearly_demand" : 1_200.0 ,
1310+ }
1311+ opt = GridOptimizer (payload )
1312+ opt .convert_lonlat_xy ()
1313+
1314+ n = opt ._find_opt_kmeans_for_unassigned (opt .get_grid_consumers ().index )
1315+
1316+ assert n >= 1
1317+ opt .kmeans_clustering (n_clusters = n , consumer_indices = opt .get_grid_consumers ().index )
1318+ opt .connect_grid_consumers ()
1319+ conn = opt .links [opt .links ["link_type" ] == "connection" ]
1320+ assert (conn ["length" ] <= opt .connection_cable_max_length ).all (), (
1321+ f"Cable length violated with n={ n } : max={ conn ['length' ].max ():.1f} m "
1322+ f"> limit={ opt .connection_cable_max_length } m"
1323+ )
0 commit comments