Skip to content

Commit fdb5460

Browse files
author
Stefan Strömer
committed
feat: migrate experimental MGA algorithm
1 parent b84d739 commit fdb5460

File tree

5 files changed

+315
-0
lines changed

5 files changed

+315
-0
lines changed

src/iesopt/alg/__init__.py

Whitespace-only changes.

src/iesopt/alg/algorithm.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class Algorithm:
2+
pass

src/iesopt/alg/mga/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .nvp import NVP
2+
3+
4+
print("EXPERIMENTAL WARNING: iesopt.alg.mga is an experimental module and its API may change in future releases.")

src/iesopt/alg/mga/nvp.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import numpy as np
2+
import matplotlib.pyplot as plt
3+
from matplotlib.widgets import Slider
4+
from scipy.spatial import ConvexHull
5+
6+
from ...model import Model
7+
from ..algorithm import Algorithm
8+
from .util import set_weighted_objective, add_obj_threshold_constraint
9+
10+
11+
# Calculate the normal vector pointing inward the convex hull
12+
def calculate_inward_normal(edge_start, edge_end, hull_vertices):
13+
# Calculate the edge vector
14+
edge_vector = edge_end - edge_start
15+
16+
# Calculate the perpendicular vector (rotate 90 degrees)
17+
# For 2D: if edge is [dx, dy], perpendiculars are [-dy, dx] and [dy, -dx]
18+
normal_candidate1 = np.array([-edge_vector[1], edge_vector[0]])
19+
normal_candidate2 = np.array([edge_vector[1], -edge_vector[0]])
20+
21+
# Find the midpoint of the edge
22+
edge_midpoint = (edge_start + edge_end) / 2
23+
24+
# Find the centroid of the convex hull
25+
centroid = np.mean(hull_vertices, axis=0)
26+
27+
# Vector from centroid to edge midpoint
28+
centroid_to_edge = edge_midpoint - centroid
29+
30+
# Choose the normal that points in the same direction as centroid_to_edge
31+
# (i.e., away from the centroid, which means inward from the hull)
32+
if np.dot(normal_candidate1, centroid_to_edge) < 0:
33+
inward_normal = normal_candidate1
34+
else:
35+
inward_normal = normal_candidate2
36+
37+
# Normalize to unit vector
38+
return inward_normal / np.linalg.norm(inward_normal)
39+
40+
41+
class Trial:
42+
def __init__(self, direction: dict):
43+
self.direction = direction
44+
self.solution = None
45+
self.gain = -np.inf
46+
47+
@property
48+
def redundant(self):
49+
return self.gain < 1e-3
50+
51+
52+
class Trials:
53+
def __init__(self):
54+
self._scheduled = []
55+
self._completed = []
56+
57+
@property
58+
def is_empty(self):
59+
return len(self._scheduled) == 0
60+
61+
def schedule(self, direction):
62+
self._scheduled.append(Trial(direction))
63+
64+
def pop(self):
65+
return self._scheduled.pop(0)
66+
67+
def complete(self, trial, solution):
68+
trial.solution = solution
69+
self._completed.append(trial)
70+
71+
def get_solutions(self):
72+
return [t.solution for t in self._completed]
73+
74+
def get_visited_directions(self):
75+
return [t.direction for t in self._completed if all(v is not None for v in t.direction.values())]
76+
77+
78+
class Domain:
79+
def __init__(self, solutions):
80+
self._solutions = solutions
81+
self._points = np.array([[s[dim] for dim in list(solutions[0].keys())[1:]] for s in solutions])
82+
83+
try:
84+
self._calculate_convex_hull()
85+
self.valid = True
86+
except:
87+
self.valid = False
88+
89+
def _calculate_convex_hull(self):
90+
hull = ConvexHull(self._points)
91+
vertices = self._points[hull.vertices]
92+
edges = []
93+
for simplex in hull.simplices:
94+
edges.append((self._points[simplex[0]], self._points[simplex[1]]))
95+
self.vertices = vertices
96+
self.edges = edges
97+
self.hull = hull
98+
99+
self._calculate_edge_normals()
100+
self._calculate_edge_lengths()
101+
102+
def get_edge(self, index):
103+
return self.edges[index], self._edge_lengths[index], self._normals[index]
104+
105+
def plot(self, ax=None):
106+
# TODO: plot current "max domain" (and "direction"; but that not in the "domain")
107+
if ax is None:
108+
fig, ax = plt.subplots(figsize=(10, 8))
109+
ax.scatter(self._points[:, 0], self._points[:, 1], c="lightblue", s=50, alpha=0.6, label="All Solutions")
110+
ax.scatter(self.vertices[:, 0], self.vertices[:, 1], c="red", s=50, label="Hull Vertices", zorder=5)
111+
for simplex in self.hull.simplices:
112+
ax.plot(self._points[simplex, 0], self._points[simplex, 1], "r-", linewidth=2)
113+
ax.fill(self.vertices[:, 0], self.vertices[:, 1], alpha=0.2, color="red")
114+
ax.legend()
115+
ax.grid(True, alpha=0.3)
116+
# for i, vertex in enumerate(self.vertices):
117+
# ax.annotate(f'V{i}', (vertex[0], vertex[1]), xytext=(5, 5), textcoords='offset points')
118+
119+
if ax is None:
120+
plt.tight_layout()
121+
return fig, ax
122+
return None
123+
124+
def _calculate_edge_lengths(self):
125+
self._edge_lengths = [np.linalg.norm(edge_end - edge_start) for edge_start, edge_end in self.edges]
126+
127+
def _calculate_edge_normals(self):
128+
self._normals = []
129+
for edge_start, edge_end in self.edges:
130+
normal = calculate_inward_normal(edge_start, edge_end, self.vertices)
131+
self._normals.append(normal)
132+
133+
134+
class Domains:
135+
def __init__(self):
136+
self._domains = []
137+
138+
def append(self, domain):
139+
self._domains.append(domain)
140+
141+
def __len__(self):
142+
return len(self._domains)
143+
144+
def __getitem__(self, index):
145+
return self._domains[index]
146+
147+
def plot(self):
148+
objectives = list(self._domains[0]._solutions[0].keys())
149+
valid = [domain for domain in self._domains if domain.valid]
150+
151+
xvals = [val for d in valid for val in d._points[:, 0]]
152+
yvals = [val for d in valid for val in d._points[:, 1]]
153+
xrange = min(xvals) * 0.95, max(xvals) * 1.05
154+
yrange = min(yvals) * 0.95, max(yvals) * 1.05
155+
156+
fig, ax = plt.subplots(figsize=(10, 8))
157+
plt.subplots_adjust(bottom=0.25)
158+
159+
# Create a slider for navigating through the plots
160+
ax_slider = plt.axes([0.1, 0.1, 0.8, 0.03])
161+
self._plot_slider = Slider(ax_slider, "Plot Index", 0, len(valid) - 1, valinit=0, valstep=1)
162+
163+
def update(val):
164+
ax.cla()
165+
valid[int(val)].plot(ax=ax)
166+
fig.canvas.draw_idle()
167+
ax.set_xlabel(objectives[1])
168+
ax.set_ylabel(objectives[2])
169+
ax.set_xlim(xrange)
170+
ax.set_ylim(yrange)
171+
172+
self._plot_slider.on_changed(update)
173+
update(0)
174+
fig.show()
175+
176+
177+
# NOTE on specific solver choice:
178+
# dual: contraints change
179+
# primal: objective change
180+
# iesopt.JuMP.set_attribute(model.core, "Method", -1)
181+
# iesopt.JuMP.set_attribute(model.core, "Method", 1) # dual (to change constraints)
182+
# iesopt.JuMP.set_attribute(model.core, "Method", 0) # primal (to change objective)
183+
184+
185+
class NVP(Algorithm):
186+
"""
187+
Normal Vector Pushing (like the most basic HSJ MGA algorithm).
188+
189+
This requires the following additional dependencies:
190+
191+
- scipy
192+
- matplotlib (for plotting)
193+
- pyqt6 (for plotting if necessary to show the interactive windows)
194+
195+
Example usage:
196+
```python
197+
import iesopt
198+
import iesopt.alg.mga as mga
199+
200+
# Consider setting `verbosity.core: error` and `verbosity.solver: off` in the config!
201+
model = iesopt.Model("config.iesopt.yaml")
202+
203+
nvp = mga.NVP(model, ["total_cost", "other_objective_A", "other_objective_B"], eps=0.05)
204+
nvp.run(maxiter=25)
205+
nvp.visualize()
206+
```
207+
where the objectives could be defined like this in the config:
208+
```yaml
209+
other_objective_A: [heatpump.exp.out_heat, hp_cool.heatpump.exp.out_heat]
210+
other_objective_B: [hwk_ne2.exp.out_heat, hwk_ne3.boiler.exp.out_heat]
211+
```
212+
"""
213+
214+
def __init__(self, model: Model, objectives: list[str], eps: float):
215+
assert len(objectives) == 3, "NVP currently only supports 3 objectives (1 primary + 2 secondary)."
216+
217+
self.model = model
218+
self.objectives = objectives
219+
self.eps = eps
220+
221+
self.domains = Domains()
222+
self.trials = Trials()
223+
224+
def run(self, maxiter: int = -1):
225+
iter = 0
226+
227+
# TODO: check model status for "ModelStatus.EMPTY"
228+
print("Initial optimization...")
229+
self.model.generate()
230+
self.model.optimize()
231+
iter += 1
232+
233+
self.trials.schedule({dim: None for dim in self.objectives[1:]})
234+
trial = self.trials.pop()
235+
self.trials.complete(trial, {dim: self.model.results.objectives[dim] for dim in self.objectives})
236+
237+
add_obj_threshold_constraint(
238+
self.model.core,
239+
self.objectives[0],
240+
(1 + self.eps) * self.trials.get_solutions()[0][self.objectives[0]],
241+
)
242+
self.model.optimize()
243+
244+
for d in self.objectives[1:]:
245+
self.trials.schedule({dim: (+1.0 if dim == d else 0.0) for dim in self.objectives[1:]})
246+
self.trials.schedule({dim: (-1.0 if dim == d else 0.0) for dim in self.objectives[1:]})
247+
248+
# TODO: this is not really efficient, it just prevents "line"-like domains from "stalling" the algorithm
249+
self.trials.schedule({dim: (1 / len(self.objectives[1:])) ** 0.5 for dim in self.objectives[1:]})
250+
self.trials.schedule({dim: -((1 / len(self.objectives[1:])) ** 0.5) for dim in self.objectives[1:]})
251+
252+
print("Starting NVP iterations ", end="", flush=True)
253+
while not self.trials.is_empty:
254+
print(".", end="", flush=True)
255+
if (maxiter > 0) and (iter >= maxiter):
256+
print("\nMaximum iterations reached.")
257+
break
258+
259+
trial = self.trials.pop()
260+
set_weighted_objective(self.model.core, trial.direction)
261+
self.model.optimize()
262+
iter += 1
263+
self.trials.complete(trial, {dim: self.model.results.objectives[dim] for dim in self.objectives})
264+
265+
domain = Domain(self.trials.get_solutions())
266+
self.domains.append(domain)
267+
268+
if domain.valid:
269+
visited = self.trials.get_visited_directions()
270+
best = (None, -np.inf, None)
271+
for i in range(len(domain.edges)):
272+
e, l, nv = domain.get_edge(i)
273+
nv = dict(zip(self.objectives[1:], nv))
274+
dist = min(sum((nv[obj] - v[obj]) ** 2 for obj in self.objectives[1:]) for v in visited)
275+
if (dist > 1e-3) and (l > best[1]):
276+
best = (e, l, nv)
277+
278+
if best[0] is None:
279+
print("\nNo new direction found, terminating.")
280+
break
281+
282+
self.trials.schedule(best[2])
283+
284+
print("\nNVP completed.")
285+
286+
def iterate(self):
287+
pass
288+
289+
def visualize(self):
290+
self.domains.plot()

src/iesopt/alg/mga/util.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import iesopt
2+
3+
4+
add_obj_threshold_constraint = iesopt.IESopt.seval("""
5+
function (model, obj, ub)
6+
JuMP.@constraint(model, internal(model).model.objectives[obj].expr <= ub)
7+
return nothing
8+
end
9+
""")
10+
11+
set_weighted_objective = iesopt.IESopt.seval("""
12+
function (model, objs)
13+
objectives = internal(model).model.objectives
14+
JuMP.@objective(model, Min, sum(
15+
objectives[name].expr * weight for (name, weight) in objs
16+
))
17+
return nothing
18+
end
19+
""")

0 commit comments

Comments
 (0)