@@ -103,6 +103,11 @@ def optimize_grid(grid_opt_json):
103103
104104
105105class GridOptimizer :
106+ _ROAD_POLE_CLUSTER_OFFSET = 100_000 # road-pole cluster labels; k-means uses 0..n
107+ _ENDPOINT_SNAP_TOL_M = 1 # drop road-path poles this close to an endpoint
108+ _COST_EPS = 1e-9 # division guard in cost calculations
109+ _MAX_SHS_ITER = 100 # iteration cap for _cut_leaf_poles_on_condition
110+
106111 def __init__ (self , grid_opt_json ):
107112 print ("Initiating grid optimizer..." )
108113 # TODO go through the helper functions and figure out what they do / document
@@ -178,23 +183,44 @@ def __init__(self, grid_opt_json):
178183
179184 def optimize (self ):
180185 print ("Optimizing distribution grid..." )
186+ n_grid_consumers = self ._setup_coordinates ()
187+ print ("Determining number of poles..." )
188+ self ._place_poles (n_grid_consumers )
189+ self ._connect_nodes ()
190+ print ("Determining distribution links..." )
191+ self ._insert_long_link_poles ()
192+ print ("Determining power house location..." )
193+ self ._run_cost_and_shs_loop ()
194+ return self ._process_results ()
195+
196+ def _setup_coordinates (self ) -> int :
197+ """Convert to projected coords, build road graph, clear stale poles.
198+
199+ Returns n_grid_consumers for use by _place_poles.
200+ """
181201 self .convert_lonlat_xy ()
182202 if self .roads is not None :
183203 self ._build_road_graph ()
184204 # Drop ALL poles from previous runs -> _clear_poles() preserves road-sampled poles
185205 self .nodes = self .nodes [self .nodes ["node_type" ] != "pole" ]
186- n_total_consumers = len (self .nodes )
187206 n_shs_consumers = len (self .nodes [self .nodes ["is_connected" ] == False ]) # noqa: E712
188- n_grid_consumers = n_total_consumers - n_shs_consumers
207+ n_grid_consumers = len ( self . nodes ) - n_shs_consumers
189208 self .nodes = self .nodes .sort_index (key = lambda x : x .astype ("int64" ))
209+ return n_grid_consumers
210+
211+ def _place_poles (self , n_grid_consumers : int ):
212+ """Place k-means or road-sampled poles. Handles power-house consumer placeholder.
213+
214+ Stores _power_house_idx on self for use by _connect_nodes.
215+ """
190216 if self .power_house is not None :
191217 power_house_consumers = self ._connect_power_house_consumer_manually (
192218 self .connection_cable_max_length ,
193219 )
194220 self ._placeholder_consumers_for_power_house ()
195221 else :
196222 power_house_consumers = None
197- print ( "Determining number of poles..." )
223+
198224 if self .roads is not None :
199225 self ._place_poles_with_roads ()
200226 else :
@@ -204,52 +230,44 @@ def optimize(self):
204230
205231 if self .power_house is not None :
206232 cluster_label = self .nodes .loc ["100000" , "cluster_label" ]
207- power_house_idx = self .nodes [
233+ self . _power_house_idx = self .nodes [
208234 (self .nodes ["node_type" ] == "pole" )
209235 & (self .nodes ["cluster_label" ] == cluster_label )
210236 ].index
211237 power_house_consumers ["cluster_label" ] = cluster_label
212238 power_house_consumers ["consumer_type" ] = np .nan
213239 self .nodes = pd .concat ([self .nodes , power_house_consumers ])
214240 self ._placeholder_consumers_for_power_house (remove = True )
215- # Drop old power house index to avoid duplicates
216241 self .nodes = self .nodes .drop (index = self .power_house .index )
242+ else :
243+ self ._power_house_idx = None
217244
245+ def _connect_nodes (self ):
246+ """Build MST distribution backbone and connect consumers to their poles."""
218247 self .create_minimum_spanning_tree ()
219248 self .connect_grid_consumers ()
249+ if self .power_house is not None and self ._power_house_idx is not None :
250+ ph = self ._power_house_idx [0 ]
251+ self .nodes .loc [self .nodes .index == ph , "node_type" ] = "power-house"
252+ self .nodes .loc [self .nodes .index == ph , "how_added" ] = "manual"
220253
221- if self .power_house is not None :
222- self .nodes .loc [self .nodes .index == power_house_idx [0 ], "node_type" ] = "power-house"
223- self .nodes .loc [self .nodes .index == power_house_idx [0 ], "how_added" ] = "manual"
224-
225- # Populate self.links with distribution links so find_index_longest_distribution_link
226- # can detect links that exceed max_length. A second call follows after intermediate
227- # poles have been inserted to rebuild distribution links with long links broken.
254+ def _insert_long_link_poles (self ):
255+ """Find over-length distribution links, insert intermediate poles, and reconnect."""
228256 self .connect_grid_poles ()
229- # Find the connection links_df in the network with lengths greater than the
230- # maximum allowed length for `connection` cables, specified by the user.
231257 long_links = self .find_index_longest_distribution_link ()
232- # Add poles to the identified long `distribution` links_df, so that the
233- # distance between all poles remains below the maximum allowed distance.
234258 self ._add_fixed_poles_on_long_links (long_links = long_links )
235- # Update the (lon,lat) coordinates based on the newly inserted poles
236- # which only have (x,y) coordinates.
237259 self .convert_lonlat_xy (inverse = True )
238- # Connect all poles together using the minimum spanning tree algorithm.
239- print ("Determining distribution links..." )
240260 self .connect_grid_poles (long_links = long_links )
241- # Calculate distances of all poles from the load centroid.
242- # Find the location of the power house.
243261 self .add_number_of_distribution_and_connection_cables ()
262+
263+ def _run_cost_and_shs_loop (self ):
264+ """Iteratively place power house, compute costs, and determine SHS consumers."""
244265 n_iter = 2 if self .power_house is None else 1
245- print ("Determining power house location..." )
246266 for i in range (n_iter ):
247267 if self .power_house is None and i == 0 :
248268 self ._select_location_of_power_house ()
249269 self ._set_direction_of_links ()
250- self .allocate_poles_to_branches ()
251- self .allocate_subbranches_to_branches ()
252- self .label_branch_of_consumers ()
270+ self ._build_branch_hierarchy ()
253271 self .determine_cost_per_pole ()
254272 self ._connection_cost_per_consumer ()
255273 self .determine_costs_per_branch ()
@@ -271,8 +289,6 @@ def optimize(self):
271289 else :
272290 break
273291
274- return self ._process_results ()
275-
276292 def _process_results (self ):
277293 """
278294 Returns a json object with processed nodes and links
@@ -547,8 +563,8 @@ def _place_pole(label, x, y):
547563 # Drop positions that coincide with an endpoint (1m tolerance)
548564 road_positions = [
549565 (px , py ) for px , py in raw_positions
550- if math .sqrt ((px - x_from ) ** 2 + (py - y_from ) ** 2 ) > 1
551- and math .sqrt ((px - x_to ) ** 2 + (py - y_to ) ** 2 ) > 1
566+ if math .sqrt ((px - x_from ) ** 2 + (py - y_from ) ** 2 ) > self . _ENDPOINT_SNAP_TOL_M
567+ and math .sqrt ((px - x_to ) ** 2 + (py - y_to ) ** 2 ) > self . _ENDPOINT_SNAP_TOL_M
552568 ]
553569
554570 if road_positions :
@@ -906,7 +922,14 @@ def add_number_of_distribution_and_connection_cables(self):
906922 self .nodes ["n_connection_links" ].fillna (0 ).astype (int )
907923 )
908924
909- def allocate_poles_to_branches (self ):
925+ def _build_branch_hierarchy (self ):
926+ """Assign branch and parent_branch labels to all poles and consumers.
927+
928+ Runs the three allocation steps in the required order:
929+ 1. assign branch labels to poles (allocate_poles_to_branches)
930+ 2. assign parent_branch labels to poles (allocate_subbranches_to_branches)
931+ 3. propagate branch labels to consumers (label_branch_of_consumers)
932+ """
910933 poles = self ._poles ().copy ()
911934 leaf_poles = pd .Series (
912935 poles [poles ["n_distribution_links" ] == 1 ].index
@@ -952,7 +975,27 @@ def allocate_poles_to_branches(self):
952975 "branch" ,
953976 ] = power_house
954977
955- def label_branch_of_consumers (self ):
978+ # --- parent_branch assignment ---
979+ poles = self ._poles ().copy ()
980+ self .nodes ["parent_branch" ] = None
981+
982+ if len (poles ["branch" ].unique ()) > 1 :
983+ leaf_poles_idx = poles [poles ["n_distribution_links" ] == 1 ].index
984+ self ._determine_parent_branches (leaf_poles_idx )
985+ poles_excl_ph = poles [poles ["node_type" ] != "power-house" ]
986+ split_poles_idx = poles_excl_ph [
987+ poles_excl_ph ["n_distribution_links" ] > 2 # noqa: PLR2004
988+ ].index
989+ if len (split_poles_idx ) > 0 :
990+ self ._determine_parent_branches (split_poles_idx )
991+
992+ self .nodes .loc [
993+ (self .nodes ["parent_branch" ].isna ())
994+ & (self .nodes ["node_type" ].isin (["pole" , "power-house" ])),
995+ "parent_branch" ,
996+ ] = power_house
997+
998+ # --- propagate branch to consumers ---
956999 branch_map = self .nodes .loc [
9571000 self .nodes .node_type .isin (["pole" , "power-house" ]),
9581001 "branch" ,
@@ -962,7 +1005,7 @@ def label_branch_of_consumers(self):
9621005 "parent" ,
9631006 ].map (branch_map )
9641007
965- def determine_parent_branches (self , start_poles ):
1008+ def _determine_parent_branches (self , start_poles ):
9661009 poles = self ._poles ().copy ()
9671010 for pole in start_poles :
9681011 branch_start_pole = poles [poles .index == pole ]["branch" ].iloc [0 ]
@@ -975,27 +1018,6 @@ def determine_parent_branches(self, start_poles):
9751018 "parent_branch" ,
9761019 ] = parent_branch
9771020
978- def allocate_subbranches_to_branches (self ):
979- poles = self ._poles ().copy ()
980- self .nodes ["parent_branch" ] = None
981- power_house = poles [poles ["node_type" ] == "power-house" ].index [0 ]
982-
983- if len (poles ["branch" ].unique ()) > 1 :
984- leaf_poles = poles [poles ["n_distribution_links" ] == 1 ].index
985- self .determine_parent_branches (leaf_poles )
986- poles_expect_power_house = poles [poles ["node_type" ] != "power-house" ]
987- split_poles = poles_expect_power_house [
988- poles_expect_power_house ["n_distribution_links" ] > 2 # noqa: PLR2004
989- ].index
990- if len (split_poles ) > 0 :
991- self .determine_parent_branches (split_poles )
992-
993- self .nodes .loc [
994- (self .nodes ["parent_branch" ].isna ())
995- & (self .nodes ["node_type" ].isin (["pole" , "power-house" ])),
996- "parent_branch" ,
997- ] = power_house
998-
9991021 def determine_cost_per_pole (self ):
10001022 poles = self ._poles ().copy ()
10011023 links = self .get_links ().copy ()
@@ -1188,7 +1210,7 @@ def _cut_leaf_poles_on_condition(self):
11881210 exclude_lst .extend (
11891211 self .nodes [self .nodes ["shs_options" ] == 1 ]["parent" ].unique ()
11901212 )
1191- for _ in range (100 ):
1213+ for _ in range (self . _MAX_SHS_ITER ):
11921214 counter = 0
11931215 leaf_poles = self .nodes [self .nodes ["n_distribution_links" ] == 1 ].index
11941216 for pole in leaf_poles :
@@ -1212,7 +1234,7 @@ def _cut_leaf_poles_on_condition(self):
12121234 "cost_per_branch" ,
12131235 ].iloc [0 ] / (
12141236 self .nodes .loc [consumer_of_branch , "yearly_consumption" ].sum ()
1215- + 1e-9
1237+ + self . _COST_EPS
12161238 )
12171239 if average_marginal_cost_of_pole > self .max_levelized_grid_cost or (
12181240 average_total_cost_of_pole > self .max_levelized_grid_cost
@@ -1735,7 +1757,7 @@ def _add_pole(x, y):
17351757 poles ["custom_specification" ] = ""
17361758 poles ["shs_options" ] = 0
17371759 # High-offset cluster labels avoid collision with k-means labels (0..n)
1738- poles ["cluster_label" ] = range (100000 , 100000 + len (poles ))
1760+ poles ["cluster_label" ] = range (self . _ROAD_POLE_CLUSTER_OFFSET , self . _ROAD_POLE_CLUSTER_OFFSET + len (poles ))
17391761
17401762 self .nodes = pd .concat (
17411763 [self .nodes , poles ],
0 commit comments