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