Skip to content

Commit 6788714

Browse files
authored
Merge pull request #26 from rl-institut/fix/refactor
Refactor main algorithm
2 parents 1f85f6c + c7eb81f commit 6788714

2 files changed

Lines changed: 80 additions & 60 deletions

File tree

task_queue/grid_optimizer.py

Lines changed: 79 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def optimize_grid(grid_opt_json):
103103

104104

105105
class 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],

tests/test_grid_optimizer.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,9 +1260,7 @@ def test_allocate_branches_assigns_branch_and_propagates_to_consumers(
12601260
optimizer._set_direction_of_links()
12611261
optimizer.distribution_links = optimizer.links[optimizer.links["link_type"] == "distribution"]
12621262

1263-
optimizer.allocate_poles_to_branches()
1264-
optimizer.allocate_subbranches_to_branches()
1265-
optimizer.label_branch_of_consumers()
1263+
optimizer._build_branch_hierarchy()
12661264

12671265
assert optimizer.nodes.at["p-0", "branch"] == "p-0"
12681266
assert optimizer.nodes.at["p-1", "branch"] == "p-0"

0 commit comments

Comments
 (0)