|
| 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() |
0 commit comments