Skip to content

Commit 0846ff5

Browse files
committed
Add tests for new methods
1 parent 34d71ca commit 0846ff5

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

tests/test_grid_optimizer.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)