Skip to content

Commit ad65617

Browse files
authored
Merge pull request #23 from rl-institut/feature/pipeline-tests
Add pipeline tests
2 parents 41218ff + ec6dfaa commit ad65617

1 file changed

Lines changed: 320 additions & 0 deletions

File tree

tests/test_grid_optimizer.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,3 +623,323 @@ def test_connect_grid_consumers_links_each_consumer_to_cluster_pole(
623623
assert set(optimizer.links["link_type"]) == {"connection"}
624624
assert optimizer.nodes.loc["0", "parent"] == "p-0"
625625
assert optimizer.nodes.loc["1", "parent"] == "p-0"
626+
627+
628+
def _has_link(opt: GridOptimizer, a: str, b: str) -> bool:
629+
"""Check a link between poles a and b exists in either direction."""
630+
return f"({a}, {b})" in opt.links.index or f"({b}, {a})" in opt.links.index
631+
632+
633+
@pytest.mark.parametrize("to_from", [False, True])
634+
@pytest.mark.parametrize("n_intermediate", [1, 2, 3])
635+
def test_break_long_link_creates_complete_chain(
636+
optimizer: GridOptimizer, n_intermediate: int, to_from: bool
637+
) -> None:
638+
"""Regression: _break_long_link must form an unbroken chain for any N.
639+
"""
640+
optimizer._add_node("p-from", node_type="pole", x=0.0, y=0.0)
641+
optimizer._add_node("p-to", node_type="pole", x=100.0, y=0.0)
642+
643+
inter_ids = [f"p-mid-{i}" for i in range(n_intermediate)]
644+
for i, idx in enumerate(inter_ids):
645+
x = (i + 1) * 100.0 / (n_intermediate + 1)
646+
optimizer._add_node(idx, node_type="pole", type_fixed=True, x=x, y=0.0)
647+
648+
added_poles_df = optimizer._poles().loc[inter_ids]
649+
added_poles = (added_poles_df, to_from)
650+
651+
optimizer._break_long_link("p-from", "p-to", added_poles)
652+
653+
assert len(optimizer.links) == n_intermediate + 1, (
654+
f"Expected {n_intermediate + 1} links, got {len(optimizer.links)}: "
655+
f"{list(optimizer.links.index)}"
656+
)
657+
658+
# to_from=True: poles added from mst_to direction, so chain runs in reverse.
659+
ordered_inter = list(reversed(inter_ids)) if to_from else inter_ids
660+
chain = ["p-from"] + ordered_inter + ["p-to"]
661+
for a, b in zip(chain, chain[1:]):
662+
assert _has_link(optimizer, a, b), (
663+
f"Missing link between {a} and {b}. Links present: {list(optimizer.links.index)}"
664+
)
665+
666+
for idx in inter_ids:
667+
assert optimizer.nodes.loc[idx, "how_added"] == "long-distance"
668+
669+
670+
# ---------------------------------------------------------------------------
671+
# Full-pipeline integration test
672+
# ---------------------------------------------------------------------------
673+
674+
@pytest.fixture
675+
def simple_grid_payload() -> dict:
676+
"""4-consumer grid, hand-calculable layout.
677+
678+
At lat=1.0, lon=10.0 (UTM zone 32):
679+
- 0.00009 deg ≈ 10 m
680+
- 0.00270 deg ≈ 300 m
681+
682+
Layout (approx, power house at origin):
683+
684+
PH(0,0) C0(10m E) C1(10m N) ...295m gap... C2(300m E) C3(300m E, 10m N)
685+
686+
Near cluster (C0, C1): centroid ≈ 7m NE of PH
687+
Far cluster (C2, C3): centroid ≈ 300m E of PH
688+
689+
distribution_cable.max_length=100m → the ~295m near-to-far pole span forces
690+
intermediate poles (ceil(295/100)-1 = 2 poles inserted).
691+
SHS threshold set astronomically high so all consumers stay grid-connected.
692+
max_n_connections=3, so 2 consumers/pole is within limit.
693+
"""
694+
return {
695+
"nodes": {
696+
"latitude": [1.0, 1.000090, 1.0, 1.000090, 1.0],
697+
"longitude": [10.000090, 10.0, 10.002700, 10.002700, 10.0],
698+
"node_type": ["consumer", "consumer", "consumer", "consumer", "power-house"],
699+
"consumer_type": ["household", "household", "household", "household", "n.a."],
700+
"consumer_detail": ["default", "default", "default", "default", "n.a."],
701+
"how_added": ["manual", "manual", "manual", "manual", "manual"],
702+
"is_connected": [True, True, True, True, True],
703+
"shs_options": [0, 0, 0, 0, 0],
704+
"custom_specification": ["", "", "", "", ""],
705+
},
706+
"grid_design": {
707+
"distribution_cable": {"max_length": 100.0, "epc": 5.0},
708+
"connection_cable": {"max_length": 30.0, "epc": 2.0},
709+
"pole": {"max_n_connections": 3, "epc": 100.0},
710+
"mg": {"epc": 50.0},
711+
"shs": {"include": True, "max_grid_cost": 1_000_000.0},
712+
},
713+
"yearly_demand": 1_200.0,
714+
}
715+
716+
717+
718+
@pytest.mark.integration
719+
def test_optimize_full_pipeline_simple_grid(simple_grid_payload: dict) -> None:
720+
"""End-to-end smoke test: verifies the full optimize() chain on a minimal,
721+
hand-calculable grid.
722+
723+
What this catches:
724+
- Long link breaking: intermediate poles inserted, all distribution links ≤ max_length
725+
- Clustering: all 4 consumers assigned to a pole (parent set)
726+
- Connectivity: every pole reachable from power house via distribution links
727+
- Pole connection limit: n_connection_links ≤ max_n_connections for every pole
728+
- No consumer left behind: all 4 are grid-connected (SHS threshold is very high)
729+
"""
730+
pytest.importorskip("scipy")
731+
pytest.importorskip("utm")
732+
pytest.importorskip("k_means_constrained")
733+
pytest.importorskip("pyproj")
734+
735+
dist_max = 100.0
736+
conn_max = 30.0
737+
max_n_conn = 3
738+
739+
grid_opt = GridOptimizer(simple_grid_payload)
740+
result = grid_opt.optimize()
741+
742+
nodes_out = result["nodes"]
743+
links_out = result["links"]
744+
745+
# --- All 4 input consumers present, connected, and parented ---
746+
consumer_positions = [
747+
i for i, t in enumerate(nodes_out["node_type"]) if t == "consumer"
748+
]
749+
assert len(consumer_positions) == 4
750+
751+
for i in consumer_positions:
752+
assert nodes_out["is_connected"][i] is True, (
753+
f"Consumer at index {i} should be grid-connected"
754+
)
755+
assert nodes_out["parent"][i] is not None, (
756+
f"Consumer at index {i} should have a parent pole"
757+
)
758+
759+
# --- No link exceeds its cable max length ---
760+
for label, ltype, length in zip(
761+
links_out["label"], links_out["link_type"], links_out["length"]
762+
):
763+
if ltype == "distribution":
764+
assert length <= dist_max, (
765+
f"Distribution link {label} length {length:.1f}m > max {dist_max}m"
766+
)
767+
elif ltype == "connection":
768+
assert length <= conn_max, (
769+
f"Connection link {label} length {length:.1f}m > max {conn_max}m"
770+
)
771+
772+
# --- At least one intermediate (long-distance) pole was inserted ---
773+
long_distance_poles = grid_opt.nodes[
774+
grid_opt.nodes["how_added"] == "long-distance"
775+
]
776+
assert len(long_distance_poles) >= 1, (
777+
"No intermediate pole found; long link breaking did not fire"
778+
)
779+
780+
# --- Pole connection-link count within limit ---
781+
for pole_idx, row in grid_opt.nodes[
782+
grid_opt.nodes["node_type"] == "pole"
783+
].iterrows():
784+
assert row["n_connection_links"] <= max_n_conn, (
785+
f"Pole {pole_idx} has {row['n_connection_links']} connection links "
786+
f"(max {max_n_conn})"
787+
)
788+
789+
# --- Full network connectivity: all poles reachable from power house ---
790+
dist_links = grid_opt.links[grid_opt.links["link_type"] == "distribution"]
791+
power_house_idx = grid_opt.nodes[
792+
grid_opt.nodes["node_type"] == "power-house"
793+
].index[0]
794+
all_poles = grid_opt.nodes[
795+
grid_opt.nodes["node_type"].isin(["pole", "power-house"])
796+
].index
797+
798+
reachable: set = {power_house_idx}
799+
queue = [power_house_idx]
800+
while queue:
801+
current = queue.pop()
802+
neighbors = set(
803+
dist_links[dist_links["from_node"] == current]["to_node"].tolist()
804+
+ dist_links[dist_links["to_node"] == current]["from_node"].tolist()
805+
)
806+
for neighbor in neighbors - reachable:
807+
reachable.add(neighbor)
808+
queue.append(neighbor)
809+
810+
unreachable = [p for p in all_poles if p not in reachable]
811+
assert not unreachable, (
812+
f"Poles not reachable from power house: {unreachable}"
813+
)
814+
815+
816+
817+
818+
@pytest.fixture
819+
def shs_grid_payload() -> dict:
820+
"""3-consumer grid designed so that the isolated far consumer becomes SHS.
821+
822+
Layout (approx, power house at origin):
823+
824+
PH(0,0) C0(40m E) C1(40m N) ...460m gap... C2(500m E)
825+
826+
Near consumers (C0, C1): placed 40 m from PH — beyond the 30 m
827+
connection-cable auto-attach threshold in _connect_power_house_consumer_manually
828+
— so they go through k-means and get a proper cluster pole.
829+
The near-cluster pole's marginal cost is ~0.4 (well below 1.0) so it is
830+
never cut.
831+
832+
max_n_connections=3 gives 3 placeholder nodes at PH. The binary search in
833+
_find_opt_number_of_poles converges to 3 clusters (PH, near, far), which
834+
cleanly separates C0/C1 from C2. With only 2 clusters the k_means_constrained
835+
capacity check (size_max x n_clusters >= n_samples) would fail.
836+
837+
Far consumer (C2): single consumer, ~500 m from PH.
838+
839+
Cost estimate for C2 (hand-calculated):
840+
yearly_consumption = 1200 / 3 = 400 Wh/year
841+
distribution chain = 5 poles x (epc_pole + ~96m x epc_dist)
842+
= 5 x (100 + 96x5) = 5 x 580 = 2900 currency/year
843+
connection cost C2 = mg.epc = 50
844+
marginal_cost = (2900 + 50) / 400 = 7.4 currency/Wh
845+
max_levelized_cost = max_grid_cost / 1000 = 1000/1000 = 1.0
846+
847+
7.4 >> 1.0 -> C2 pole is cut, C2 becomes SHS.
848+
Intermediate long-distance poles then cascade-removed by
849+
_cut_leaf_poles_without_connection (no consumers left on that branch).
850+
"""
851+
return {
852+
"nodes": {
853+
# 40 m E / N of PH so _connect_power_house_consumer_manually
854+
# (threshold = connection_cable.max_length = 30 m) does NOT
855+
# grab C0/C1 before k-means runs.
856+
"latitude": [1.0, 1.000360, 1.0, 1.0],
857+
"longitude": [10.000359, 10.0, 10.004493, 10.0],
858+
"node_type": ["consumer", "consumer", "consumer", "power-house"],
859+
"consumer_type": ["household", "household", "household", "n.a."],
860+
"consumer_detail": ["default", "default", "default", "n.a."],
861+
"how_added": ["manual", "manual", "manual", "manual"],
862+
"is_connected": [True, True, True, True],
863+
"shs_options": [0, 0, 0, 0],
864+
"custom_specification": ["", "", "", ""],
865+
},
866+
"grid_design": {
867+
"distribution_cable": {"max_length": 100.0, "epc": 5.0},
868+
"connection_cable": {"max_length": 30.0, "epc": 2.0},
869+
# 3 connections/pole -> binary search finds 3 clusters (PH + near + far)
870+
"pole": {"max_n_connections": 3, "epc": 100.0},
871+
"mg": {"epc": 50.0},
872+
"shs": {"include": True, "max_grid_cost": 1_000.0},
873+
},
874+
"yearly_demand": 1_200.0,
875+
}
876+
877+
878+
@pytest.mark.integration
879+
def test_optimize_full_pipeline_shs_consumer(shs_grid_payload: dict) -> None:
880+
"""End-to-end: optimizer assigns isolated far consumer to SHS.
881+
882+
Consumer "2" (C2, 500m east) is too expensive to connect relative to the
883+
SHS threshold — the optimizer should cut its pole and mark it SHS.
884+
Consumers "0" and "1" (near cluster, ~40m from PH) must stay grid-connected.
885+
886+
Also verifies the intermediate long-distance poles are cascade-removed by
887+
_cut_leaf_poles_without_connection after the far cluster pole is cut —
888+
the remaining grid contains only the near cluster.
889+
"""
890+
pytest.importorskip("scipy")
891+
pytest.importorskip("utm")
892+
pytest.importorskip("k_means_constrained")
893+
pytest.importorskip("pyproj")
894+
895+
grid_opt = GridOptimizer(shs_grid_payload)
896+
result = grid_opt.optimize()
897+
898+
nodes_out = result["nodes"]
899+
900+
label_to_idx = {lbl: i for i, lbl in enumerate(nodes_out["label"])}
901+
902+
# --- Near consumers remain grid-connected with a parent ---
903+
for consumer_label in ("0", "1"):
904+
i = label_to_idx[consumer_label]
905+
assert nodes_out["is_connected"][i] is True, (
906+
f"Near consumer {consumer_label} should be grid-connected"
907+
)
908+
assert nodes_out["parent"][i] is not None, (
909+
f"Near consumer {consumer_label} should have a parent pole"
910+
)
911+
912+
# --- Far isolated consumer assigned to SHS ---
913+
i = label_to_idx["2"]
914+
assert nodes_out["is_connected"][i] is False, (
915+
"Consumer '2' (500m isolated) should be SHS (is_connected=False)"
916+
)
917+
assert nodes_out["parent"][i] is None, (
918+
"Consumer '2' (SHS) should have no parent"
919+
)
920+
921+
# --- No orphaned poles: every remaining pole reachable from power house ---
922+
dist_links = grid_opt.links[grid_opt.links["link_type"] == "distribution"]
923+
power_house_idx = grid_opt.nodes[
924+
grid_opt.nodes["node_type"] == "power-house"
925+
].index[0]
926+
all_poles = grid_opt.nodes[
927+
grid_opt.nodes["node_type"].isin(["pole", "power-house"])
928+
].index
929+
930+
reachable: set = {power_house_idx}
931+
queue = [power_house_idx]
932+
while queue:
933+
current = queue.pop()
934+
neighbors = set(
935+
dist_links[dist_links["from_node"] == current]["to_node"].tolist()
936+
+ dist_links[dist_links["to_node"] == current]["from_node"].tolist()
937+
)
938+
for neighbor in neighbors - reachable:
939+
reachable.add(neighbor)
940+
queue.append(neighbor)
941+
942+
unreachable = [p for p in all_poles if p not in reachable]
943+
assert not unreachable, (
944+
f"Poles not reachable from power house after SHS pruning: {unreachable}"
945+
)

0 commit comments

Comments
 (0)