Skip to content

Commit ec6dfaa

Browse files
committed
test(grid_optimizer): add SHS economic determination integration test
Runs optimize() on a 3-consumer grid where one consumer (C2, 500 m from PH) is too expensive to connect and must be assigned SHS by the optimizer. Fixture design notes: - C0 and C1 are placed 40 m from PH, beyond the 30 m auto-attach threshold of _connect_power_house_consumer_manually, so they go through k-means and get a proper cluster pole that survives the SHS cut. - max_n_connections=3 so the binary-search pole finder converges to 3 clusters (PH, near, far), avoiding the k_means_constrained capacity error that would occur with 2 clusters and 3 placeholder nodes. Verifies: - C0, C1 remain grid-connected with a parent pole after the far cut - C2 is marked SHS (is_connected=False, parent=None) - no orphaned poles remain in the pruned network
1 parent 24d158a commit ec6dfaa

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

tests/test_grid_optimizer.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,3 +813,133 @@ def test_optimize_full_pipeline_simple_grid(simple_grid_payload: dict) -> None:
813813
)
814814

815815

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)