Coverage for C:\Users\t590r\Documents\GitHub\suppy\suppy\projections\_basic_projections.py: 73%

260 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2025-02-05 17:36 +0100

1"""Simple projection objects.""" 

2import math 

3from typing import List 

4import numpy as np 

5import numpy.typing as npt 

6import matplotlib.pyplot as plt 

7from matplotlib import patches 

8 

9from suppy.projections._projections import BasicProjection 

10 

11try: 

12 import cupy as cp 

13 

14 NO_GPU = False 

15except ImportError: 

16 NO_GPU = True 

17 cp = np 

18 

19# from suppy.utils.decorators import ensure_float_array 

20 

21 

22# Class for basic projections 

23 

24 

25class BoxProjection(BasicProjection): 

26 """ 

27 BoxProjection class for projecting points onto a box defined by lower 

28 and upper bounds. 

29 

30 Parameters 

31 ---------- 

32 lb : npt.NDArray 

33 Lower bounds of the box. 

34 ub : npt.NDArray 

35 Upper bounds of the box. 

36 idx : npt.NDArray or None 

37 Subset of the input vector to apply the projection on. 

38 relaxation : float, optional 

39 Relaxation parameter for the projection, by default 1. 

40 proximity_flag : bool 

41 Flag to indicate whether to take this object into account when calculating proximity, 

42 by default True. 

43 

44 Attributes 

45 ---------- 

46 lb : npt.NDArray 

47 Lower bounds of the box. 

48 ub : npt.NDArray 

49 Upper bounds of the box. 

50 relaxation : float 

51 Relaxation parameter for the projection. 

52 proximity_flag : bool 

53 Flag to indicate whether to take this object into account when calculating proximity. 

54 idx : npt.NDArray 

55 Subset of the input vector to apply the projection on. 

56 """ 

57 

58 def __init__( 

59 self, 

60 lb: npt.NDArray, 

61 ub: npt.NDArray, 

62 relaxation: float = 1, 

63 idx: npt.NDArray | None = None, 

64 proximity_flag=True, 

65 use_gpu=False, 

66 ): 

67 

68 super().__init__(relaxation, idx, proximity_flag, use_gpu) 

69 self.lb = lb 

70 self.ub = ub 

71 

72 def _project(self, x: npt.NDArray) -> npt.NDArray: 

73 """ 

74 Projects the input array `x` onto the bounds defined by `self.lb` 

75 and `self.ub`. 

76 

77 Parameters 

78 ---------- 

79 x : npt.NDArray 

80 Input array to be projected. Can be a NumPy array or a CuPy array. 

81 

82 Returns 

83 ------- 

84 npt.NDArray 

85 The projected array with values clipped to the specified bounds. 

86 

87 Notes 

88 ----- 

89 This method modifies the input array `x` in place. 

90 """ 

91 xp = cp if isinstance(x, cp.ndarray) else np 

92 x[self.idx] = xp.maximum(self.lb, xp.minimum(self.ub, x[self.idx])) 

93 return x 

94 

95 def _proximity(self, x: npt.NDArray, proximity_measures: List) -> float: 

96 res = abs(x[self.idx] - self._project(x.copy())[self.idx]) 

97 measures = [] 

98 for measure in proximity_measures: 

99 if isinstance(measure, tuple): 

100 if measure[0] == "p_norm": 

101 measures.append(1 / len(res) * (res ** measure[1]).sum()) 

102 else: 

103 raise ValueError("Invalid proximity measure") 

104 elif isinstance(measure, str) and measure == "max_norm": 

105 measures.append(res.max()) 

106 else: 

107 raise ValueError("Invalid proximity measure") 

108 return measures 

109 

110 def visualize(self, ax: plt.Axes | None = None, color=None): 

111 """ 

112 Visualize the box if it is 2D on a given matplotlib Axes. 

113 

114 Parameters 

115 ---------- 

116 ax : plt.Axes, optional 

117 The matplotlib Axes to plot on. If None, a new figure and axes are created. 

118 color : str or None, optional 

119 The color to fill the box with. If None, the box will be filled with the default color. 

120 

121 Raises 

122 ------ 

123 ValueError 

124 If the box is not 2-dimensional. 

125 """ 

126 if len(self.lb) != 2: 

127 raise ValueError("Visualization only possible for 2D boxes") 

128 

129 if ax is None: 

130 _, ax = plt.subplots() 

131 box = patches.Rectangle( 

132 (self.lb[0], self.lb[1]), 

133 self.ub[0] - self.lb[0], 

134 self.ub[1] - self.lb[1], 

135 linewidth=1, 

136 edgecolor="black", 

137 facecolor=color, 

138 alpha=0.5, 

139 ) 

140 ax.add_patch(box) 

141 

142 def get_xy(self): 

143 """ 

144 Generate the coordinates for the edges of a box if it is 2D. 

145 

146 This method creates four edges of a 2D box defined by the lower bounds (lb) and upper bounds (ub). 

147 The edges are generated using 100 points each. 

148 

149 Returns 

150 ------- 

151 npt.NDArray 

152 A 2D array of shape (2, 400) containing the concatenated coordinates of the four edges. 

153 

154 Raises 

155 ------ 

156 ValueError 

157 If the box is not 2-dimensional. 

158 """ 

159 if len(self.lb) != 2: 

160 raise ValueError("Visualization only possible for 2D boxes") 

161 edge_1 = np.array([np.linspace(self.lb[0], self.ub[0], 100), np.ones(100) * self.lb[1]]) 

162 edge_2 = np.array([np.ones(100) * self.ub[0], np.linspace(self.lb[1], self.ub[1], 100)]) 

163 edge_3 = np.array([np.linspace(self.lb[0], self.ub[0], 100), np.ones(100) * self.ub[1]]) 

164 edge_4 = np.array([np.ones(100) * self.lb[0], np.linspace(self.lb[1], self.ub[1], 100)]) 

165 return np.concatenate((edge_1, edge_2, edge_3[:, ::-1], edge_4[:, ::-1]), axis=1) 

166 

167 

168class WeightedBoxProjection(BasicProjection): 

169 """ 

170 WeightedBoxProjection applies a weighted projection on a box defined by 

171 lower and upper bounds. 

172 The idea is a "simultaneous" variant to the "sequential" BoxProjection. 

173 

174 Parameters 

175 ---------- 

176 lb : npt.NDArray 

177 Lower bounds of the box. 

178 ub : npt.NDArray 

179 Upper bounds of the box. 

180 weights : npt.NDArray 

181 Weights for the projection. 

182 relaxation : float, optional 

183 Relaxation parameter, by default 1. 

184 idx : npt.NDArray or None 

185 Subset of the input vector to apply the projection on. 

186 proximity_flag : bool, optional 

187 Flag to indicate if proximity should be calculated, by default True. 

188 use_gpu : bool, optional 

189 Flag to indicate if GPU should be used, by default False. 

190 

191 Attributes 

192 ---------- 

193 lb : npt.NDArray 

194 Lower bounds of the box. 

195 ub : npt.NDArray 

196 Upper bounds of the box. 

197 relaxation : float 

198 Relaxation parameter for the projection. 

199 proximity_flag : bool 

200 Flag to indicate whether to take this object into account when calculating proximity. 

201 idx : npt.NDArray 

202 Subset of the input vector to apply the projection on. 

203 """ 

204 

205 def __init__( 

206 self, 

207 lb: npt.NDArray, 

208 ub: npt.NDArray, 

209 weights: npt.NDArray, 

210 relaxation: float = 1, 

211 idx: npt.NDArray | None = None, 

212 proximity_flag=True, 

213 use_gpu=False, 

214 ): 

215 

216 super().__init__(relaxation, idx, proximity_flag, use_gpu) 

217 self.lb = lb 

218 self.ub = ub 

219 self.weights = weights / weights.sum() 

220 

221 def _project(self, x: npt.NDArray) -> npt.NDArray: 

222 """ 

223 Projects the input array `x`. 

224 

225 Parameters 

226 ---------- 

227 x : npt.NDArray 

228 The input array to be projected. 

229 

230 Returns 

231 ------- 

232 npt.NDArray 

233 The projected array. 

234 

235 Notes 

236 ----- 

237 This method modifies the input array `x` in place. 

238 """ 

239 xp = cp if isinstance(x, cp.ndarray) else np 

240 x[self.idx] += self.weights * ( 

241 xp.maximum(self.lb, xp.minimum(self.ub, x[self.idx])) - x[self.idx] 

242 ) 

243 return x 

244 

245 def _full_project(self, x: npt.NDArray) -> npt.NDArray: 

246 """ 

247 Projects the elements of the input array `x` within the specified 

248 bounds. 

249 

250 Parameters 

251 ---------- 

252 x : npt.NDArray 

253 Input array to be projected. 

254 

255 Returns 

256 ------- 

257 npt.NDArray 

258 The projected array with elements constrained within the bounds. 

259 """ 

260 xp = cp if isinstance(x, cp.ndarray) else np 

261 x[self.idx] = xp.maximum(self.lb, xp.minimum(self.ub, x[self.idx])) 

262 

263 return x 

264 

265 def _proximity(self, x: npt.NDArray, proximity_measures: List) -> float: 

266 res = abs(x[self.idx] - self._project(x.copy())[self.idx]) 

267 measures = [] 

268 for measure in proximity_measures: 

269 if isinstance(measure, tuple): 

270 if measure[0] == "p_norm": 

271 measures.append(self.weights @ (res ** measure[1])) 

272 else: 

273 raise ValueError("Invalid proximity measure") 

274 elif isinstance(measure, str) and measure == "max_norm": 

275 measures.append(res.max()) 

276 else: 

277 raise ValueError("Invalid proximity measure") 

278 return measures 

279 

280 def visualize(self, ax: plt.Axes | None = None, color=None): 

281 """ 

282 Visualize the box if it is 2D on a given matplotlib Axes. 

283 

284 Parameters 

285 ---------- 

286 ax : plt.Axes, optional 

287 The matplotlib Axes to plot on. If None, a new figure and axes are created. 

288 color : str or None, optional 

289 The color to fill the box with. If None, the box will be filled with the default color. 

290 

291 Raises 

292 ------ 

293 ValueError 

294 If the box is not 2-dimensional. 

295 """ 

296 if len(self.lb) != 2: 

297 raise ValueError("Visualization only possible for 2D boxes") 

298 

299 if ax is None: 

300 _, ax = plt.subplots() 

301 box = patches.Rectangle( 

302 (self.lb[0], self.lb[1]), 

303 self.ub[0] - self.lb[0], 

304 self.ub[1] - self.lb[1], 

305 linewidth=1, 

306 edgecolor="black", 

307 facecolor=color, 

308 alpha=0.5, 

309 ) 

310 ax.add_patch(box) 

311 

312 def get_xy(self): 

313 """ 

314 Generate the coordinates for the edges of a box if it is 2D. 

315 

316 This method creates four edges of a 2D box defined by the lower bounds (lb) and upper bounds (ub). 

317 The edges are generated using 100 points each. 

318 

319 Returns 

320 ------- 

321 np.ndarray 

322 A 2D array of shape (2, 400) containing the concatenated coordinates of the four edges. 

323 

324 Raises 

325 ------ 

326 ValueError 

327 If the box is not 2-dimensional. 

328 """ 

329 if len(self.lb) != 2: 

330 raise ValueError("Visualization only possible for 2D boxes") 

331 edge_1 = np.array([np.linspace(self.lb[0], self.ub[0], 100), np.ones(100) * self.lb[1]]) 

332 edge_2 = np.array([np.ones(100) * self.ub[0], np.linspace(self.lb[1], self.ub[1], 100)]) 

333 edge_3 = np.array([np.linspace(self.lb[0], self.ub[0], 100), np.ones(100) * self.ub[1]]) 

334 edge_4 = np.array([np.ones(100) * self.lb[0], np.linspace(self.lb[1], self.ub[1], 100)]) 

335 return np.concatenate((edge_1, edge_2, edge_3[:, ::-1], edge_4[:, ::-1]), axis=1) 

336 

337 

338# Projection onto a single halfspace 

339class HalfspaceProjection(BasicProjection): 

340 """ 

341 A class used to represent a projection onto a halfspace. 

342 

343 Parameters 

344 ---------- 

345 a : npt.NDArray 

346 The normal vector defining the halfspace. 

347 b : float 

348 The offset value defining the halfspace. 

349 relaxation : float, optional 

350 The relaxation parameter, by default 1. 

351 idx : npt.NDArray or None 

352 Subset of the input vector to apply the projection on. 

353 proximity_flag : bool, optional 

354 Flag to indicate whether to take this object into account when calculating proximity, by default True. 

355 use_gpu : bool, optional 

356 Flag to indicate if GPU should be used, by default False. 

357 

358 Attributes 

359 ---------- 

360 a : npt.NDArray 

361 The normal vector defining the halfspace. 

362 a_norm : npt.NDArray 

363 The normalized normal vector. 

364 b : float 

365 The offset value defining the halfspace. 

366 relaxation : float 

367 The relaxation parameter for the projection. 

368 proximity_flag : bool 

369 Flag to indicate whether to take this object into account when calculating proximity. 

370 idx : npt.NDArray 

371 Subset of the input vector to apply the projection on. 

372 """ 

373 

374 def __init__( 

375 self, 

376 a: npt.NDArray, 

377 b: float, 

378 relaxation: float = 1, 

379 idx: npt.NDArray | None = None, 

380 proximity_flag=True, 

381 use_gpu=False, 

382 ): 

383 

384 super().__init__(relaxation, idx, proximity_flag, use_gpu) 

385 self.a = a 

386 self.a_norm = self.a / (self.a @ self.a) 

387 self.b = b 

388 

389 def _linear_map(self, x): 

390 return self.a @ x 

391 

392 def _project(self, x: npt.NDArray) -> npt.NDArray: 

393 """ 

394 Projects the input array `x`. 

395 

396 Parameters 

397 ---------- 

398 x : npt.NDArray 

399 The input array to be projected. 

400 

401 Returns 

402 ------- 

403 npt.NDArray 

404 The projected array. 

405 

406 Notes 

407 ----- 

408 This method modifies the input array `x` in place. 

409 """ 

410 

411 # TODO: dtype check! 

412 y = self._linear_map(x[self.idx]) 

413 

414 if y > self.b: 

415 x[self.idx] -= (y - self.b) * self.a_norm 

416 

417 return x 

418 

419 def get_xy(self, x: npt.NDArray | None = None): 

420 """ 

421 Generate x and y coordinates for visualization of 2D halfspaces. 

422 

423 Parameters 

424 ---------- 

425 x : npt.NDArray or None, optional 

426 The x-coordinates for which to compute the corresponding y-coordinates. 

427 If None, a default range of x values from -10 to 10 is used. 

428 

429 Returns 

430 ------- 

431 np.ndarray 

432 A 2D array where the first row contains the x-coordinates and the second row contains the corresponding y-coordinates. 

433 

434 Raises 

435 ------ 

436 ValueError 

437 If the halfspace is not 2-dimensional. 

438 """ 

439 if len(self.a) != 2: 

440 raise ValueError("Visualization only possible for 2D halfspaces") 

441 

442 if x is None: 

443 x = np.linspace(-10, 10, 100) 

444 

445 if self.a[1] == 0: 

446 y = np.array([np.ones(100) * self.b, np.linspace(-10, 10, 100)]) 

447 else: 

448 y = (self.b - self.a[0] * x) / self.a[1] 

449 

450 return np.array([x, y]) 

451 

452 def visualize( 

453 self, 

454 ax: plt.Axes | None = None, 

455 x: npt.NDArray | None = None, 

456 y_fill: npt.NDArray | None = None, 

457 color=None, 

458 ): 

459 """ 

460 Visualize the halfspace if it is 2D on a given matplotlib Axes. 

461 

462 Parameters 

463 ---------- 

464 ax : plt.Axes, optional 

465 The matplotlib Axes to plot on. If None, a new figure and axes are created. 

466 color : str or None, optional 

467 The color to fill the box with. If None, the halfspace will be filled with the default color. 

468 

469 Raises 

470 ------ 

471 ValueError 

472 If the halfspace is not 2-dimensional. 

473 """ 

474 

475 if len(self.a) != 2: 

476 raise ValueError("Visualization only possible for 2D halfspaces") 

477 

478 if ax is None: 

479 _, ax = plt.subplots() 

480 

481 if x is None: 

482 x = np.linspace(-10, 10, 100) 

483 

484 if self.a[1] == 0: 

485 ax.axvline(x=self.b / self.a[0], label="Halfspace", color=color) 

486 if np.sign(self.a[0]) == 1: 

487 ax.fill_betweenx( 

488 x, 

489 ax.get_xlim()[0], 

490 self.b, 

491 color=color, 

492 label="Halfspace", 

493 alpha=0.5, 

494 ) 

495 else: 

496 ax.fill_betweenx( 

497 x, 

498 self.b, 

499 ax.get_xlim()[1], 

500 color=color, 

501 label="Halfspace", 

502 alpha=0.5, 

503 ) 

504 

505 else: 

506 y = (self.b - self.a[0] * x) / self.a[1] 

507 ax.plot(x, y, color="xkcd:black") 

508 if y_fill is None: 

509 y_fill = np.min(y) if self.a[1] > 0 else np.max(y) 

510 

511 ax.fill_between(x, y, y_fill, color=color, label="Halfspace", alpha=0.5) 

512 

513 

514class BandProjection(BasicProjection): 

515 """ 

516 A class used to represent a projection onto a band. 

517 

518 Parameters 

519 ---------- 

520 a : npt.NDArray 

521 The normal vector defining the halfspace. 

522 lb : float 

523 The lower bound of the band. 

524 ub : float 

525 The upper bound of the band. 

526 idx : npt.NDArray or None 

527 Subset of the input vector to apply the projection on. 

528 relaxation : float, optional 

529 The relaxation parameter, by default 1. 

530 idx : npt.NDArray or None 

531 Subset of the input vector to apply the projection on. 

532 

533 Attributes 

534 ---------- 

535 a : npt.NDArray 

536 The normal vector defining the halfspace. 

537 a_norm : npt.NDArray 

538 The normalized normal vector. 

539 lb : float 

540 The lower bound of the band. 

541 ub : float 

542 The upper bound of the band. 

543 relaxation : float 

544 The relaxation parameter for the projection. 

545 proximity_flag : bool 

546 Flag to indicate whether to take this object into account when calculating proximity. 

547 idx : npt.NDArray 

548 Subset of the input vector to apply the projection on. 

549 """ 

550 

551 def __init__( 

552 self, 

553 a: npt.NDArray, 

554 lb: float, 

555 ub: float, 

556 relaxation: float = 1, 

557 idx: npt.NDArray | None = None, 

558 proximity_flag=True, 

559 use_gpu=False, 

560 ): 

561 

562 super().__init__(relaxation, idx, proximity_flag, use_gpu) 

563 self.a = a 

564 self.a_norm = self.a / (self.a @ self.a) 

565 self.lb = lb 

566 self.ub = ub 

567 

568 def _project(self, x: npt.NDArray) -> npt.NDArray: 

569 """ 

570 Projects the input array `x`. 

571 

572 Parameters 

573 ---------- 

574 x : npt.NDArray 

575 The input array to be projected. 

576 

577 Returns 

578 ------- 

579 npt.NDArray 

580 The projected array. 

581 

582 Notes 

583 ----- 

584 This method modifies the input array `x` in place. 

585 """ 

586 y = self.a @ x[self.idx] 

587 

588 if y > self.ub: 

589 x[self.idx] -= (y - self.ub) * self.a_norm 

590 elif y < self.lb: 

591 x[self.idx] -= (y - self.lb) * self.a_norm 

592 

593 return x 

594 

595 def get_xy(self, x: npt.NDArray | None = None): 

596 """ 

597 Calculate the x and y coordinates for the lower and upper bounds of 

598 a 2D band. 

599 

600 Parameters 

601 ---------- 

602 x : npt.NDArray or None, optional 

603 The x-coordinates at which to evaluate the bounds. If None, a default range 

604 from -10 to 10 with 100 points is used. 

605 

606 Returns 

607 ------- 

608 tuple of np.ndarray 

609 A tuple containing two numpy arrays: 

610 - The first array represents the x and y coordinates for the lower bound. 

611 - The second array represents the x and y coordinates for the upper bound. 

612 

613 Raises 

614 ------ 

615 ValueError 

616 If the band is not 2-dimensional. 

617 """ 

618 

619 if len(self.a) != 2: 

620 raise ValueError("Visualization only possible for 2D bands") 

621 

622 if x is None: 

623 x = np.linspace(-10, 10, 100) 

624 if self.a[1] == 0: 

625 y_lb = np.array([np.ones(100) * self.lb, np.linspace(-10, 10, 100)]) 

626 y_ub = np.array([np.ones(100) * self.ub, np.linspace(-10, 10, 100)]) 

627 else: 

628 y_lb = (self.lb - self.a[0] * x) / self.a[1] 

629 y_ub = (self.ub - self.a[0] * x) / self.a[1] 

630 return np.array([x, y_lb]), np.array([x, y_ub]) 

631 

632 def visualize(self, ax: plt.Axes | None = None, x: npt.NDArray | None = None, color=None): 

633 """ 

634 Visualize the band if it is 2D on a given matplotlib Axes. 

635 

636 Parameters 

637 ---------- 

638 ax : plt.Axes, optional 

639 The matplotlib Axes to plot on. If None, a new figure and axes are created. 

640 color : str or None, optional 

641 The color to fill the box with. If None, the band will be filled with the default color. 

642 

643 Raises 

644 ------ 

645 ValueError 

646 If the band is not 2-dimensional. 

647 """ 

648 

649 if len(self.a) != 2: 

650 raise ValueError("Visualization only possible for 2D bands") 

651 

652 if ax is None: 

653 _, ax = plt.subplots() 

654 

655 if x is None: 

656 x = np.linspace(-10, 10, 100) 

657 

658 if self.a[1] == 0: 

659 ax.plot(np.ones(100) * self.lb, x, color="xkcd:black") 

660 ax.plot(np.ones(100) * self.ub, x, color="xkcd:black") 

661 # ax.axvline(x = self.b/self.a[0],label='Halfspace',color = color) 

662 if np.sign(self.a[0]) == 1: 

663 ax.fill_betweenx(x, self.lb, self.ub, color=color, label="Band", alpha=0.5) 

664 else: 

665 ax.fill_betweenx(x, self.lb, self.ub, color=color, label="Band", alpha=0.5) 

666 else: 

667 y_lb = (self.lb - self.a[0] * x) / self.a[1] 

668 y_ub = (self.ub - self.a[0] * x) / self.a[1] 

669 ax.plot(x, y_lb, color="xkcd:black") 

670 ax.plot(x, y_ub, color="xkcd:black") 

671 ax.fill_between(x, y_lb, y_ub, color=color, label="Band", alpha=0.5) 

672 

673 

674class BallProjection(BasicProjection): 

675 """ 

676 A class used to represent a projection onto a ball. 

677 

678 Parameters 

679 ---------- 

680 center : npt.NDArray 

681 The center of the ball. 

682 radius : float 

683 The radius of the ball. 

684 relaxation : float, optional 

685 The relaxation parameter (default is 1). 

686 idx : npt.NDArray or None 

687 Subset of the input vector to apply the projection on. 

688 proximity_flag : bool, optional 

689 Flag to indicate whether to take this object into account when calculating proximity, by default True. 

690 use_gpu : bool, optional 

691 Flag to indicate if GPU should be used, by default False. 

692 

693 Attributes 

694 ---------- 

695 center : npt.NDArray 

696 The center of the ball. 

697 radius : float 

698 The radius of the ball. 

699 relaxation : float 

700 The relaxation parameter for the projection. 

701 proximity_flag : bool 

702 Flag to indicate whether to take this object into account when calculating proximity. 

703 idx : npt.NDArray 

704 Subset of the input vector to apply the projection on. 

705 """ 

706 

707 def __init__( 

708 self, 

709 center: npt.NDArray, 

710 radius: float, 

711 relaxation: float = 1, 

712 idx: npt.NDArray | None = None, 

713 proximity_flag=True, 

714 use_gpu=False, 

715 ): 

716 

717 super().__init__(relaxation, idx, proximity_flag, use_gpu) 

718 self.center = center 

719 self.radius = radius 

720 

721 def _project(self, x: npt.NDArray) -> npt.NDArray: 

722 """ 

723 Projects the input array `x` onto the surface of the ball. 

724 

725 Parameters 

726 ---------- 

727 x : npt.NDArray 

728 The input array to be projected. 

729 

730 Returns 

731 ------- 

732 npt.NDArray 

733 The projected array. 

734 """ 

735 xp = cp if isinstance(x, cp.ndarray) else np 

736 if xp.linalg.norm(x[self.idx] - self.center) > self.radius: 

737 x[self.idx] -= (x[self.idx] - self.center) * ( 

738 1 - self.radius / xp.linalg.norm(x[self.idx] - self.center) 

739 ) 

740 

741 return x 

742 

743 def visualize(self, ax: plt.Axes | None = None, color=None, edgecolor=None): 

744 """ 

745 Visualize the halfspace if it is 2D on a given matplotlib Axes. 

746 

747 Parameters 

748 ---------- 

749 ax : plt.Axes, optional 

750 The matplotlib Axes to plot on. If None, a new figure and axes are created. 

751 color : str or None, optional 

752 The color to fill the box with. If None, the halfspace will be filled with the default color. 

753 

754 Raises 

755 ------ 

756 ValueError 

757 If the halfspace is not 2-dimensional. 

758 """ 

759 

760 if len(self.center) != 2: 

761 raise ValueError("Visualization only possible for 2D balls") 

762 

763 if ax is None: 

764 _, ax = plt.subplots() 

765 

766 circle = plt.Circle( 

767 (self.center[0], self.center[1]), 

768 self.radius, 

769 facecolor=color, 

770 alpha=0.5, 

771 edgecolor=edgecolor, 

772 ) 

773 ax.add_artist(circle) 

774 

775 def get_xy(self): 

776 """ 

777 Generate x and y coordinates for a 2D ball visualization. 

778 

779 Returns 

780 ------- 

781 np.ndarray 

782 A 2x50 array where the first row contains the x coordinates and the 

783 second row contains the y coordinates of the points on the circumference 

784 of the 2D ball. 

785 

786 Raises 

787 ------ 

788 ValueError 

789 If the center does not have exactly 2 dimensions. 

790 """ 

791 if len(self.center) != 2: 

792 raise ValueError("Visualization only possible for 2D balls") 

793 

794 theta = np.linspace(0, 2 * np.pi, 50) 

795 x = self.center[0] + self.radius * np.cos(theta) 

796 y = self.center[1] + self.radius * np.sin(theta) 

797 return np.array([x, y]) 

798 

799 

800class MaxDVHProjection(BasicProjection): 

801 """ 

802 Class for max dose-volume histogram projections. 

803 

804 Parameters 

805 ---------- 

806 d_max : float 

807 The maximum dose value. 

808 max_percentage : float 

809 The maximum percentage of elements allowed to exceed d_max. 

810 idx : npt.NDArray or None 

811 Subset of the input vector to apply the projection on. 

812 

813 Attributes 

814 ---------- 

815 d_max : float 

816 The maximum dose value. 

817 max_percentage : float 

818 The maximum percentage of elements allowed to exceed d_max. 

819 """ 

820 

821 def __init__( 

822 self, 

823 d_max: float, 

824 max_percentage: float, 

825 idx: npt.NDArray | None = None, 

826 proximity_flag=True, 

827 use_gpu=False, 

828 ): 

829 super().__init__(1, idx, proximity_flag, use_gpu) 

830 

831 # max percentage of elements that are allowed to exceed d_max 

832 self.max_percentage = max_percentage 

833 self.d_max = d_max 

834 

835 if isinstance(self.idx, slice): 

836 self._idx_indices = None 

837 elif self.idx.dtype == bool: 

838 raise ValueError("Boolean indexing is not supported for this projection.") 

839 else: 

840 self._idx_indices = self.idx 

841 

842 def _project(self, x: npt.NDArray) -> npt.NDArray: 

843 """ 

844 Projects the input array `x` onto the DVH constraint. 

845 

846 Parameters 

847 ---------- 

848 x : npt.NDArray 

849 The input array to be projected. 

850 

851 Returns 

852 ------- 

853 npt.NDArray 

854 The projected array. 

855 """ 

856 if isinstance(self.idx, slice): 

857 return self._project_all(x) 

858 

859 return self._project_subset(x) 

860 

861 def _project_all(self, x: npt.NDArray) -> npt.NDArray: 

862 n = len(x) 

863 am = math.floor(self.max_percentage * n) 

864 

865 l = (x > self.d_max).sum() 

866 

867 z = l - am 

868 

869 if z > 0: 

870 x[x.argsort()[n - l : n - am]] = self.d_max 

871 return x 

872 

873 def _project_subset(self, x: npt.NDArray) -> npt.NDArray: 

874 

875 n = self.idx.sum() if self.idx.dtype == bool else len(self.idx) 

876 

877 am = math.floor(self.max_percentage * n) 

878 

879 l = (x[self.idx] > self.d_max).sum() 

880 

881 z = l - am # number of elements that need to be reduced 

882 

883 if z > 0: 

884 x[self._idx_indices[x[self.idx].argsort()[n - l : n - am]]] = self.d_max 

885 

886 return x 

887 

888 # def _project(self, x: npt.NDArray) -> npt.NDArray: 

889 # """ 

890 # Projects the input array `x` onto the DVH constraint. 

891 

892 # Parameters 

893 # ---------- 

894 # x : npt.NDArray 

895 # The input array to be projected. 

896 

897 # Returns 

898 # ------- 

899 # npt.NDArray 

900 # The projected array. 

901 

902 # Notes 

903 # ----- 

904 # - The method calculates the number of elements that should receive a dose lower than `d_max` based on `max_percentage`. 

905 # - It then determines how many elements in the input array exceed `d_max`. 

906 # - If the number of elements exceeding `d_max` is greater than the allowed maximum, it reduces the highest values to `d_max`. 

907 # """ 

908 # # percentage of elements that should receive a dose lower than d_max 

909 # n = len(x) if isinstance(self.idx, slice) else self.idx.sum() 

910 # am = math.floor(self.max_percentage * n) 

911 

912 # # number of elements in structure with dose greater than d_max 

913 # l = (x[self.idx] > self.d_max).sum() 

914 

915 # z = l - am # number of elements that need to be reduced 

916 

917 # if z > 0: 

918 # x[x[self.idx].argsort()[n - l : n - am]] = self.d_max 

919 

920 # return x 

921 

922 def _proximity(self, x: npt.NDArray, proximity_measures: List) -> float: 

923 """ 

924 Calculate the proximity of the given array to a specified maximum 

925 percentage. 

926 

927 Parameters 

928 ---------- 

929 x : npt.NDArray 

930 Input array to be evaluated. 

931 

932 Returns 

933 ------- 

934 float 

935 The proximity value as a percentage. 

936 """ 

937 # TODO: Find appropriate proximity measure 

938 raise NotImplementedError 

939 

940 # n = len(x) if isinstance(self.idx, slice) else self.idx.sum() 

941 # return abs((1 / n * (x[self.idx] > self.d_max).sum()) - self.max_percentage) * 100 

942 

943 

944class MinDVHProjection(BasicProjection): 

945 """""" 

946 

947 def __init__( 

948 self, 

949 d_min: float, 

950 min_percentage: float, 

951 idx: npt.NDArray | None = None, 

952 proximity_flag=True, 

953 use_gpu=False, 

954 ): 

955 super().__init__(1, idx, proximity_flag, use_gpu) 

956 

957 # percentage of elements that need to have at least d_min 

958 self.min_percentage = min_percentage 

959 self.d_min = d_min 

960 if isinstance(self.idx, slice): 

961 self._idx_indices = None 

962 elif self.idx.dtype == bool: 

963 raise ValueError("Boolean indexing is not supported for this projection.") 

964 else: 

965 self._idx_indices = self.idx 

966 

967 def _project(self, x: npt.NDArray) -> npt.NDArray: 

968 """ 

969 Projects the input array `x` onto the DVH constraint. 

970 

971 Parameters 

972 ---------- 

973 x : npt.NDArray 

974 The input array to be projected. 

975 

976 Returns 

977 ------- 

978 npt.NDArray 

979 The projected array. 

980 """ 

981 if isinstance(self.idx, slice): 

982 return self._project_all(x) 

983 

984 return self._project_subset(x) 

985 

986 def _project_all(self, x: npt.NDArray) -> npt.NDArray: 

987 n = len(x) 

988 am = math.ceil(self.min_percentage * n) 

989 

990 l = (x > self.d_min).sum() 

991 

992 z = am - l 

993 

994 if z > 0: 

995 x[x.argsort()[n - am : n - l]] = self.d_min 

996 return x 

997 

998 def _project_subset(self, x: npt.NDArray) -> npt.NDArray: 

999 

1000 n = self.idx.sum() if self.idx.dtype == bool else len(self.idx) 

1001 

1002 am = math.ceil(self.min_percentage * n) 

1003 

1004 l = (x[self.idx] > self.d_min).sum() 

1005 

1006 z = am - l 

1007 

1008 if z > 0: 

1009 x[self._idx_indices[x[self.idx].argsort()[n - am : n - l]]] = self.d_min 

1010 

1011 return x 

1012 

1013 def _proximity(self, x: npt.NDArray, proximity_measures: List) -> float: 

1014 """ 

1015 Calculate the proximity of the given array to a specified maximum 

1016 percentage. 

1017 

1018 Parameters 

1019 ---------- 

1020 x : npt.NDArray 

1021 Input array to be evaluated. 

1022 

1023 Returns 

1024 ------- 

1025 float 

1026 The proximity value as a percentage. 

1027 """ 

1028 # TODO: Find appropriate proximity measure 

1029 raise NotImplementedError