madman.helpers.ase.workflows.converge
Convergence.
1r"""Convergence.""" 2 3from collections.abc import Callable, Generator, Mapping, Sequence 4from numbers import Integral, Real 5from typing import Literal, Optional, TypedDict 6 7import numpy as np 8import seaborn as sns 9from ase.parallel import parprint 10from matplotlib import pyplot as plt 11from sympy import Expr, Function, Symbol, latex, sympify 12 13from madman.utilities import gen_regular_grid 14 15 16sns.set_theme( 17 context="talk", 18 style="white", 19 rc={"figure.titlesize": "medium", "axes.formatter.useoffset": False}, 20) 21 22 23class ConvergenceParameter: 24 r"""Convergence parameter.""" 25 26 def __init__( 27 self, 28 keyword: str, 29 *, 30 value_min: Real, 31 value_max: Optional[Real] = None, 32 value_spc: Optional[Real] = None, 33 objective: Optional[Callable[[Real], np.ndarray[Real] | Real]] = None, 34 direction: Optional[Real] = None, 35 criterion: Optional[ 36 Literal[ 37 "slope-neg", 38 "slope-abs", 39 "1st-min", 40 "slope-abs-avg", 41 "slope-abs-max", 42 ] 43 ] = None, 44 threshold: Optional[Real] = None, 45 stability: Optional[Integral] = None, 46 symb: Optional[Symbol | str] = None, 47 unit: Optional[Expr | str] = None, 48 obj_symb: Optional[Sequence[Symbol | str, ...] | Symbol | str] = None, 49 obj_unit: Optional[Sequence[Expr | str, ...] | Expr | str] = None, 50 ) -> None: 51 r"""Initialize ConvergenceParameter object. 52 53 Args: 54 keyword: Parameter keyword. 55 value_min: Parameter minimum value. 56 value_max: Parameter maximum value. 57 value_spc: Parameter value spacing. 58 objective: Univariate objective function. 59 direction: Convergence direction. 60 criterion: Convergence criterion. 61 threshold: Convergence threshold. 62 stability: Convergence stability. 63 symb: Parameter symbol for plots. 64 unit: Parameter unit for plots. 65 obj_symb: Objective function symbol(s) for plots. 66 obj_unit: Objective function unit(s) for plots. 67 68 Raises: 69 TypeError: If keyword is invalid. 70 TypeError: If objective is non-callable. 71 TypeError: If direction is non-real. 72 ValueError: If direction is zero. 73 ValueError: If criterion is unknown. 74 TypeError: If threshold is non-real. 75 ValueError: If threshold is non-positive. 76 TypeError: If stability is non-integer. 77 ValueError: If stability is invalid. 78 TypeError: If symbol is invalid. 79 TypeError: If unit is invalid. 80 TypeError: If objective symbol is invalid. 81 TypeError: If objective unit is invalid. 82 """ 83 if not isinstance(keyword, str): 84 raise TypeError("Invalid keyword!") 85 self._keyword = keyword 86 87 self._value_min = float(value_min) 88 self._value_max = float(value_max) if value_max is not None else None 89 self._value_spc = float(value_spc) if value_spc is not None else None 90 91 value_seq = ( 92 gen_regular_grid(self._value_min, self._value_max, self._value_spc) 93 if self._value_max is not None and self._value_spc is not None 94 else [self._value_min] 95 ) 96 self._value_seq = list(value_seq) 97 98 if objective is not None and not callable(objective): 99 raise TypeError("Non-callable objective!") 100 self._objective = objective 101 102 if len(self._value_seq) > 1: 103 if not isinstance(direction, Real): 104 raise TypeError("Non-real direction!") 105 if direction == 0.0: 106 raise ValueError("Zero direction!") 107 self._direction = int(direction / abs(direction)) 108 109 if self._direction < 0.0: 110 self._value_seq = sorted(self._value_seq, reverse=True) 111 112 if criterion not in { 113 "slope-neg", 114 "slope-abs", 115 "1st-min", 116 "slope-abs-avg", 117 "slope-abs-max", 118 }: 119 raise ValueError("Unknown criterion!") 120 self._criterion = criterion 121 122 if not isinstance(threshold, Real): 123 raise TypeError("Non-real threshold!") 124 if threshold <= 0.0: 125 raise ValueError("Non-positive threshold!") 126 self._threshold = float(threshold) 127 128 if not isinstance(stability, Integral): 129 raise TypeError("Non-integer stability!") 130 if stability < 1 or stability > len(self._value_seq) - 1: 131 raise ValueError("Invalid stability!") 132 self._stability = int(stability) 133 134 else: 135 self._direction = None 136 self._criterion = None 137 self._threshold = None 138 self._stability = None 139 140 if symb is None or symb == "": 141 symb = f"\\text{{{self._keyword}}}" 142 if isinstance(symb, str): 143 self._symb = Symbol(symb) 144 elif isinstance(symb, Symbol): 145 self._symb = symb 146 else: 147 raise TypeError("Invalid symbol!") 148 149 if unit is None or unit == "": 150 unit = "1" 151 if isinstance(unit, str): 152 unit = sympify(unit, rational=True) 153 for s in unit.free_symbols: 154 s_new = Symbol(f"\\mathrm{{{s.name}}}") 155 unit = unit.replace(s, s_new) 156 self._unit = unit 157 elif isinstance(unit, Expr): 158 self._unit = unit 159 else: 160 raise TypeError("Invalid unit!") 161 162 if obj_symb is None or obj_symb == "": 163 obj_symb = r"\text{objective}" 164 if isinstance(obj_symb, (Symbol, str)): 165 obj_symb = [obj_symb] 166 if not isinstance(obj_symb, Sequence) or not all( 167 isinstance(s, (Symbol, str)) for s in obj_symb 168 ): 169 raise TypeError("Invalid objective symbol!") 170 171 self._obj_symb = [] 172 for s in obj_symb: 173 if s == "": 174 s = r"\text{objective}" 175 if isinstance(s, str): 176 s = Symbol(s) 177 self._obj_symb.append(s) 178 179 if obj_unit is None or obj_unit == "": 180 obj_unit = "1" 181 if isinstance(obj_unit, (Expr, str)): 182 obj_unit = [obj_unit] 183 if not isinstance(obj_unit, Sequence) or not all( 184 isinstance(s, (Expr, str)) for s in obj_unit 185 ): 186 raise TypeError("Invalid objective unit!") 187 188 self._obj_unit = [] 189 for u in obj_unit: 190 if u == "": 191 u = "1" 192 if isinstance(u, str): 193 u = sympify(u, rational=True) 194 for s in u.free_symbols: 195 s_new = Symbol(f"\\mathrm{{{s.name}}}") 196 u = u.replace(s, s_new) 197 self._obj_unit.append(u) 198 199 self._obj_value_seq = [None] * len(self._value_seq) 200 self._obj_slope_seq = [None] * len(self._value_seq) 201 self._value_opt = np.nan 202 self._converged = False 203 204 @property 205 def keyword(self) -> str: 206 r"""Parameter keyword.""" 207 return self._keyword 208 209 @property 210 def value_min(self) -> float: 211 r"""Parameter minimum value.""" 212 return self._value_min 213 214 @property 215 def value_max(self) -> float: 216 r"""Parameter maximum value.""" 217 return self._value_max 218 219 @property 220 def value_spc(self) -> float: 221 r"""Parameter target spacing.""" 222 return self._value_spc 223 224 @property 225 def objective(self) -> Optional[Callable[[Real], np.ndarray[Real] | Real]]: 226 r"""Objective function.""" 227 return self._objective 228 229 @property 230 def direction(self) -> Optional[int]: 231 r"""Convergence direction.""" 232 return self._direction 233 234 @property 235 def criterion(self) -> Optional[str]: 236 r"""Convergence criterion.""" 237 return self._criterion 238 239 @property 240 def threshold(self) -> Optional[float]: 241 r"""Convergence threshold.""" 242 return self._threshold 243 244 @property 245 def stability(self) -> Optional[int]: 246 r"""Convergence stability.""" 247 return self._stability 248 249 @property 250 def symb(self) -> Symbol: 251 r"""Parameter plot symbol.""" 252 return self._symb 253 254 @property 255 def unit(self) -> Expr: 256 r"""Parameter plot unit.""" 257 return self._unit 258 259 @property 260 def obj_symb(self) -> list[Symbol, ...]: 261 r"""Objective plot symbol(s).""" 262 return self._obj_symb 263 264 @property 265 def obj_unit(self) -> list[Expr, ...]: 266 r"""Objective plot unit(s).""" 267 return self._obj_unit 268 269 @property 270 def value_seq(self) -> list[float, ...]: 271 r"""Parameter values considered.""" 272 return self._value_seq 273 274 @property 275 def obj_value_seq(self) -> list[np.ndarray[Real] | Real, ...]: 276 r"""Objective function values.""" 277 return self._obj_value_seq 278 279 @property 280 def obj_slope_seq(self) -> list[np.ndarray[Real] | Real, ...]: 281 r"""Objective slope sequence.""" 282 return self._obj_slope_seq 283 284 @property 285 def value_opt(self) -> float: 286 r"""Parameter optimal value.""" 287 return self._value_opt 288 289 @property 290 def converged(self) -> float: 291 r"""True if optimal parameter value is converged.""" 292 return self._converged 293 294 def converge(self) -> None: 295 r"""Converge parameter. 296 297 Returns: 298 True if convergence succeeded. 299 300 Raises: 301 ValueError: If objective function is undefined. 302 ValueError: If criterion is incompatible. 303 """ 304 self.clear() 305 306 if self._objective is None: 307 raise ValueError("Undefined objective function!") 308 309 if len(self._value_seq) == 1: 310 self._value_opt = self._value_seq[0] 311 parprint(f"{self._keyword} fixed to {self._value_opt}...") 312 self._converged = True 313 return None 314 315 for i, value in enumerate(self._value_seq): 316 obj_value = self._objective(value) 317 if ( 318 np.ndim(obj_value) == 0 319 and self._criterion not in {"slope-neg", "slope-abs", "1st-min"} 320 ) or ( 321 np.ndim(obj_value) >= 1 322 and self._criterion not in {"slope-abs-avg", "slope-abs-max"} 323 ): 324 raise ValueError( 325 f"`{self.keyword}` convergence criterion incompatible" 326 + " with dimensionality of objective function values!" 327 ) 328 self._obj_value_seq[i] = obj_value 329 330 if i > 0: 331 obj_diff = obj_value - self._obj_value_seq[i - 1] 332 diff = value - self._value_seq[i - 1] 333 obj_slope = obj_diff / diff 334 self._obj_slope_seq[i - 1] = obj_slope 335 336 if i >= self._stability: 337 i_stab = i - self._stability 338 339 if self._criterion == "slope-neg": 340 is_converged = [ 341 obj_slope < 0.0 and abs(obj_slope) < self._threshold 342 for obj_slope in self._obj_slope_seq[i_stab:i] 343 ] 344 elif self._criterion == "slope-abs": 345 is_converged = [ 346 abs(obj_slope) < self._threshold 347 for obj_slope in self._obj_slope_seq[i_stab:i] 348 ] 349 elif self._criterion == "1st-min": 350 is_converged = [ 351 self._obj_value_seq[i_stab] < obj_value 352 or abs(self._obj_value_seq[i_stab] - obj_value) 353 < self._threshold 354 for obj_value in self._obj_value_seq[i_stab + 1 : i + 1] 355 ] 356 elif self._criterion == "slope-abs-avg": 357 is_converged = [ 358 np.mean(np.abs(obj_slope)) < self._threshold 359 for obj_slope in self._obj_slope_seq[i_stab:i] 360 ] 361 elif self._criterion == "slope-abs-max": 362 is_converged = [ 363 np.amax(np.abs(obj_slope)) < self._threshold 364 for obj_slope in self._obj_slope_seq[i_stab:i] 365 ] 366 else: 367 is_converged = [False] 368 369 if all(is_converged): 370 self._value_opt = self._value_seq[i_stab] 371 parprint( 372 f"{self._keyword} converged to {self._value_opt}..." 373 ) 374 self._converged = True 375 return None 376 377 self._value_opt = self._value_seq[-1] 378 parprint(f"{self._keyword} did not converge!") 379 return None 380 381 def plot( 382 self, qnty: Literal["obj-value", "obj-slope"] 383 ) -> Generator[plt.Axes, None, None]: 384 r"""Plot quantity vs convergence parameter. 385 386 Args: 387 qnty: Quantity to plot. 388 389 Yields: 390 Plot axes. 391 392 Raises: 393 ValueError: If quantity is unknown. 394 """ 395 if qnty not in {"obj-value", "obj-slope"}: 396 raise ValueError("Unknown quantity!") 397 398 obj_dim = np.ndim(self._obj_value_seq[0]) 399 if obj_dim > 2: 400 parprint("Cannot plot objective with values dimension > 2!") 401 return None 402 403 if obj_dim == 2: 404 n_plots = self._obj_value_seq[0].shape[0] 405 else: 406 n_plots = 1 407 408 if obj_dim == 0: 409 obj_value_seq = [ 410 obj_value if obj_value is not None else np.nan 411 for obj_value in self._obj_value_seq 412 ] 413 obj_slope_seq = [ 414 obj_slope if obj_slope is not None else np.nan 415 for obj_slope in self._obj_slope_seq 416 ] 417 else: 418 obj_value_seq = [ 419 ( 420 np.reshape(obj_value, [n_plots, -1]) 421 if obj_value is not None 422 else None 423 ) 424 for obj_value in self._obj_value_seq 425 ] 426 obj_slope_seq = [ 427 ( 428 np.reshape(obj_slope, [n_plots, -1]) 429 if obj_slope is not None 430 else None 431 ) 432 for obj_slope in self._obj_slope_seq 433 ] 434 435 label = f"${latex(self._symb)}$ / ${latex(self._unit)}$" 436 437 if n_plots > 1 and len(self._obj_symb) == 1: 438 obj_symbs = [] 439 for i in range(n_plots): 440 s = self._obj_symb[0] 441 s = s.replace(s, Symbol(s.name + f"[{i + 1}]")) 442 obj_symbs.append(s) 443 else: 444 obj_symbs = self._obj_symb 445 446 if n_plots > 1 and len(self._obj_unit) == 1: 447 obj_units = n_plots * self._obj_unit 448 else: 449 obj_units = self._obj_unit 450 451 delta = Function(r"\Delta") 452 obj_value_labels = [ 453 f"${latex(obj_symb)}$ / ${latex(obj_unit)}$" 454 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 455 ] 456 obj_slope_labels = [ 457 f"${latex(delta(obj_symb) / delta(self._symb))}$" 458 + " / " 459 + f"${latex(obj_unit / self._unit)}$" 460 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 461 ] 462 463 for i in range(n_plots): 464 _, ax = plt.subplots(tight_layout=True) 465 466 if qnty == "obj-value": 467 ax.set_ylabel(obj_value_labels[i]) 468 else: 469 ax.set_ylabel(obj_slope_labels[i]) 470 471 if obj_dim == 0: 472 ax.set_xlabel(label) 473 ax.axvline(self._value_opt, linestyle="--", color="k") 474 ax.plot( 475 self._value_seq, 476 obj_value_seq if qnty == "obj-value" else obj_slope_seq, 477 "o-", 478 color="k", 479 ) 480 else: 481 if qnty == "obj-value": 482 for value, obj_value in zip(self._value_seq, obj_value_seq): 483 if obj_value is not None: 484 ax.plot( 485 obj_value[i], 486 "-" if value == self._value_opt else "--", 487 color="k" if value == self._value_opt else None, 488 label=f"{value:.2g}", 489 ) 490 else: 491 ax.set_xlabel(label) 492 obj_slope_abs_avg_seq = [ 493 ( 494 np.abs(obj_slope[i]).mean() 495 if obj_slope is not None 496 else np.nan 497 ) 498 for obj_slope in obj_slope_seq 499 ] 500 obj_slope_abs_max_seq = [ 501 ( 502 np.abs(obj_slope[i]).max() 503 if obj_slope is not None 504 else np.nan 505 ) 506 for obj_slope in obj_slope_seq 507 ] 508 ax.plot( 509 self._value_seq, 510 obj_slope_abs_avg_seq, 511 "o-", 512 color="k", 513 label=r"$\text{avg}$", 514 ) 515 ax.plot( 516 self._value_seq, 517 obj_slope_abs_max_seq, 518 "v-", 519 color="k", 520 label=r"$\text{max}$", 521 ) 522 523 ax.legend( 524 loc="upper right", 525 bbox_to_anchor=(1.0, 1.0), 526 edgecolor="k", 527 title_fontsize="x-small", 528 fontsize="x-small", 529 title=label if qnty == "obj-value" else None, 530 ) 531 532 if qnty == "obj-value" and self._criterion == "1st-min": 533 if not np.isnan(self._value_opt): 534 i_opt = self._value_seq.index(self._value_opt) 535 ax.axhspan( 536 obj_value_seq[i_opt] - self._threshold, 537 obj_value_seq[i_opt] + self._threshold, 538 color="k", 539 alpha=0.5, 540 ) 541 542 if qnty == "obj-slope" and self._criterion == "slope-neg": 543 ax.axhspan(-self._threshold, 0.0, color="k", alpha=0.5) 544 545 if qnty == "obj-slope" and self._criterion == "slope-abs": 546 threshold = self._threshold 547 ax.axhspan(-threshold, threshold, color="k", alpha=0.5) 548 549 if qnty == "obj-slope" and self._criterion in { 550 "slope-abs-avg", 551 "slope-abs-max", 552 }: 553 ax.axhspan(0.0, self._threshold, color="k", alpha=0.5) 554 555 yield ax 556 557 def clear(self) -> None: 558 r"""Clear convergence results.""" 559 self._obj_value_seq = [None] * len(self._value_seq) 560 self._obj_slope_seq = [None] * len(self._value_seq) 561 self._value_opt = np.nan 562 self._converged = False 563 564 565class MultivariateConvergenceParameterSettings(TypedDict, total=False): 566 r"""Multivariate convergence parameter settings.""" 567 568 value_min: Real 569 r"""Parameter minimum value.""" 570 571 value_max: Optional[Real] 572 r"""Parameter maximum value.""" 573 574 value_spc: Optional[Real] 575 r"""Paramater value spacing.""" 576 577 direction: Optional[Real] 578 r"""Convergence direction.""" 579 580 criterion: Optional[ 581 Literal[ 582 "slope-neg", 583 "slope-abs", 584 "1st-min", 585 "slope-abs-avg", 586 "slope-abs-max", 587 ] 588 ] 589 r"""Convergence criterion.""" 590 591 threshold: Optional[Real] 592 r"""Convergence threshold.""" 593 594 stability: Optional[Integral] 595 r"""Convergence stability.""" 596 597 symb: Optional[Symbol | str] 598 r"""Parameter symbol for plots.""" 599 600 unit: Optional[Expr | str] 601 r"""Parameter unit for plots.""" 602 603 604class MultivariateConvergence: 605 r"""Multivariate convergence.""" 606 607 def __init__( 608 self, 609 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 610 params: Mapping[str, MultivariateConvergenceParameterSettings], 611 *, 612 crop: bool = False, 613 req_sc: bool = False, 614 niter_max: Integral = 1, 615 obj_symb: Optional[Sequence[str, ...] | str] = None, 616 obj_unit: Optional[Sequence[str, ...] | str] = None, 617 ) -> None: 618 r"""Initialize MultivariateConvergence object. 619 620 Args: 621 objective: Multivariate objective function; caching is recommended. 622 params: Convergence parameters, specified as a mapping of the 623 multivariate objective function argument keywords into the 624 corresponding convergence settings. 625 The settings are a dictionary of the ConvergenceParameter 626 argument keywords 'value_min', 'value_max', 'value_spc', 627 'direction', 'criterion', 'threshold', 'stability', 'symb' and 628 'unit', into the desired values. 629 The remaining ConvergenceParameter arguments are 'objective', 630 'obj_symb' and 'obj_unit'. 631 'objective' is set to the restriction of the multivariate 632 objective function to the univariate objective function of the 633 current parameter for the current value of the other parameters. 634 'obj_symb' and 'obj_unit' are inherited. 635 crop: If True, parameter values preceding the current optimal value 636 are removed at each iteration step. 637 req_sc: If True, self-consistency is required to achieve 638 convergence; self-consistency means that the final optimal 639 parameter values are equal to the final working parameter 640 values. 641 niter_max: Maximum number of iterations. 642 obj_symb: Objective function symbol(s) for plots. 643 obj_unit: Objective function unit(s) for plots. 644 645 Raises: 646 TypeError: If objective is non-callable. 647 TypeError: If `crop` is not boolean. 648 TypeError: If `req_sc` is not boolean. 649 TypeError: If maximum number of iterations is non-integer. 650 ValueError: If maximum number of iterations is below 1. 651 """ 652 if not callable(objective): 653 raise TypeError("Non-callable objective!") 654 self._objective = objective 655 656 if not isinstance(crop, bool): 657 raise TypeError("Non-boolean `crop` parameter!") 658 self._crop = crop 659 660 if not isinstance(req_sc, bool): 661 raise TypeError("Non-boolean `req_sc` parameter!") 662 self._req_sc = req_sc 663 664 if not isinstance(niter_max, Integral): 665 raise TypeError("Non-integer maximum number of iterations!") 666 if niter_max < 1: 667 raise ValueError("Maximum number of iterations below 1!") 668 self._niter_max = int(niter_max) 669 670 self._params_seq = [{}] + [None] * (self._niter_max - 1) 671 self._wrk_pt_seq = [{}] + [None] * (self._niter_max - 1) 672 self._opt_pt_seq = [None] * self._niter_max 673 self._values_opt = {} 674 675 for keyword, settings in params.items(): 676 settings.update({"obj_symb": obj_symb, "obj_unit": obj_unit}) 677 param = ConvergenceParameter(keyword, objective=None, **settings) 678 self._params_seq[0].update({keyword: param}) 679 self._wrk_pt_seq[0].update({keyword: param.value_seq[0]}) 680 self._values_opt.update({keyword: np.nan}) 681 682 for param in self._params_seq[0].values(): 683 param._objective = type(self).ret_obj_wrt_param( 684 self._objective, 685 keyword=param.keyword, 686 wrk_pt=self._wrk_pt_seq[0], 687 ) 688 self._converged = False 689 690 @property 691 def objective(self) -> Callable[[Real, ...], np.ndarray[Real] | Real]: 692 r"""Multivariate objective function.""" 693 return self._objective 694 695 @property 696 def crop(self) -> bool: 697 r"""Crop parameter values at each iteration.""" 698 return self._crop 699 700 @property 701 def req_sc(self) -> bool: 702 r"""Require self-consistency.""" 703 return self._req_sc 704 705 @property 706 def niter_max(self) -> int: 707 r"""Maximum number of iterations.""" 708 return self._niter_max 709 710 @property 711 def params_seq(self) -> list[dict[str, ConvergenceParameter], ...]: 712 r"""Convergence parameters sequence.""" 713 return self._params_seq 714 715 @property 716 def wrk_pt_seq(self) -> list[dict[str, Real], ...]: 717 r"""Working point sequence.""" 718 return self._wrk_pt_seq 719 720 @property 721 def opt_pt_seq(self) -> list[dict[str, Real], ...]: 722 r"""Optimal point sequence.""" 723 return self._opt_pt_seq 724 725 @property 726 def values_opt(self) -> dict[str, Real]: 727 r"""Optimal parameter values.""" 728 return self._values_opt 729 730 @property 731 def converged(self) -> bool: 732 r"""True if converged.""" 733 return self._converged 734 735 def run(self) -> bool: 736 r"""Run multivariate convergence. 737 738 Returns: 739 True if convergence succeeded. 740 """ 741 self.clear() 742 743 iter_i = 0 744 while iter_i < self._niter_max: 745 if iter_i > 0: 746 params = {} 747 wrk_pt = self._opt_pt_seq[iter_i - 1].copy() 748 for param in self._params_seq[iter_i - 1].values(): 749 if param.direction is None: 750 params.update({param.keyword: param}) 751 else: 752 value_min = ( 753 wrk_pt[param.keyword] 754 if self._crop is True and param.direction > 0 755 else param.value_min 756 ) 757 value_max = ( 758 wrk_pt[param.keyword] 759 if self._crop is True and param.direction < 0 760 else param.value_max 761 ) 762 param_obj = type(self).ret_obj_wrt_param( 763 self._objective, 764 keyword=param.keyword, 765 wrk_pt=wrk_pt, 766 ) 767 768 params.update( 769 { 770 param.keyword: ConvergenceParameter( 771 param.keyword, 772 value_min=value_min, 773 value_max=value_max, 774 value_spc=param.value_spc, 775 objective=param_obj, 776 direction=param.direction, 777 criterion=param.criterion, 778 threshold=param.threshold, 779 stability=param.stability, 780 symb=param.symb, 781 unit=param.unit, 782 obj_symb=param.obj_symb, 783 obj_unit=param.obj_unit, 784 ) 785 } 786 ) 787 self._wrk_pt_seq[iter_i] = wrk_pt 788 self._params_seq[iter_i] = params 789 790 opt_pt = {} 791 for param in self._params_seq[iter_i].values(): 792 param.converge() 793 if param.converged is False: 794 parprint( 795 f"{param.keyword} convergence failed!" 796 + " Fixing to best value..." 797 ) 798 opt_pt.update({param.keyword: param.value_opt}) 799 self._opt_pt_seq[iter_i] = opt_pt 800 801 if self._opt_pt_seq[iter_i] == self._wrk_pt_seq[iter_i]: 802 parprint("Self-consistency achieved...") 803 if all( 804 param.converged is True 805 for param in self._params_seq[iter_i].values() 806 ): 807 parprint( 808 "Multivariate convergence achieved..." 809 ) 810 self._converged = True 811 self._values_opt = self._opt_pt_seq[iter_i] 812 return None 813 iter_i += 1 814 815 if self._req_sc is True: 816 parprint("Self-consistent multivariate convergence failed!") 817 else: 818 parprint("Non-self-consistent multivariate convergence performed...") 819 self._values_opt = self._opt_pt_seq[-1] 820 return None 821 822 def plot( 823 self, 824 qnty: Literal["obj-value", "obj-slope"], 825 ) -> dict[str, list[Generator[plt.Axes, None, None], ...]]: 826 r"""Plot quantity vs convergence parameters. 827 828 Args: 829 qnty: Quantity to plot. 830 831 Returns: 832 Plot axes. 833 """ 834 plots = {keyword: [] for keyword in self._values_opt} 835 836 for params, wrk_pt in zip(self._params_seq, self._wrk_pt_seq): 837 if params is None: 838 break 839 for keyword, param in params.items(): 840 wrk_pt_txt = [ 841 f"${latex(p.symb)}={latex(wrk_pt[k]*p.unit)}$" 842 for k, p in params.items() 843 if k != keyword 844 ] 845 wrk_pt_txt = ", ".join(wrk_pt_txt) 846 847 def param_plots(param, wrk_pt_txt): 848 for ax in param.plot(qnty): 849 ax.set_title(wrk_pt_txt, fontsize="x-small") 850 yield ax 851 852 plots[keyword].append(param_plots(param, wrk_pt_txt)) 853 854 return plots 855 856 def clear(self) -> None: 857 r"""Clear convergence results.""" 858 i = 1 859 while i < len(self._params_seq): 860 self._params_seq[i] = None 861 self._wrk_pt_seq[i] = None 862 self._opt_pt_seq[i] = None 863 i += 1 864 for param in self._params_seq[0].values(): 865 param.clear() 866 self._opt_pt_seq[0] = None 867 self._values_opt = {keyword: np.nan for keyword in self._values_opt} 868 self._converged = False 869 870 @staticmethod 871 def ret_obj_wrt_param( 872 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 873 *, 874 keyword: str, 875 wrk_pt: Mapping[str, Real], 876 ) -> Callable[[Real], np.ndarray[Real] | Real]: 877 r"""Return univariate objective from multivariate objective. 878 879 Note: 880 The univariate objective w.r.t. to a parameter of the multivariate 881 objective is obtained by fixing all the other parameters to the 882 values specified by the working point. 883 884 Args: 885 objective: Multivariate objective function. 886 keyword: Univariate objective parameter keyword. 887 wrk_pt: Working point specified as a mapping of keywords of the 888 multivariate objective parameters into the corresponding 889 working point values. 890 891 Returns: 892 Univariate objective. 893 """ 894 895 def obj_wrt_param(value): 896 kwargs = wrk_pt.copy() 897 kwargs.update({keyword: value}) 898 return objective(**kwargs) 899 900 return obj_wrt_param
24class ConvergenceParameter: 25 r"""Convergence parameter.""" 26 27 def __init__( 28 self, 29 keyword: str, 30 *, 31 value_min: Real, 32 value_max: Optional[Real] = None, 33 value_spc: Optional[Real] = None, 34 objective: Optional[Callable[[Real], np.ndarray[Real] | Real]] = None, 35 direction: Optional[Real] = None, 36 criterion: Optional[ 37 Literal[ 38 "slope-neg", 39 "slope-abs", 40 "1st-min", 41 "slope-abs-avg", 42 "slope-abs-max", 43 ] 44 ] = None, 45 threshold: Optional[Real] = None, 46 stability: Optional[Integral] = None, 47 symb: Optional[Symbol | str] = None, 48 unit: Optional[Expr | str] = None, 49 obj_symb: Optional[Sequence[Symbol | str, ...] | Symbol | str] = None, 50 obj_unit: Optional[Sequence[Expr | str, ...] | Expr | str] = None, 51 ) -> None: 52 r"""Initialize ConvergenceParameter object. 53 54 Args: 55 keyword: Parameter keyword. 56 value_min: Parameter minimum value. 57 value_max: Parameter maximum value. 58 value_spc: Parameter value spacing. 59 objective: Univariate objective function. 60 direction: Convergence direction. 61 criterion: Convergence criterion. 62 threshold: Convergence threshold. 63 stability: Convergence stability. 64 symb: Parameter symbol for plots. 65 unit: Parameter unit for plots. 66 obj_symb: Objective function symbol(s) for plots. 67 obj_unit: Objective function unit(s) for plots. 68 69 Raises: 70 TypeError: If keyword is invalid. 71 TypeError: If objective is non-callable. 72 TypeError: If direction is non-real. 73 ValueError: If direction is zero. 74 ValueError: If criterion is unknown. 75 TypeError: If threshold is non-real. 76 ValueError: If threshold is non-positive. 77 TypeError: If stability is non-integer. 78 ValueError: If stability is invalid. 79 TypeError: If symbol is invalid. 80 TypeError: If unit is invalid. 81 TypeError: If objective symbol is invalid. 82 TypeError: If objective unit is invalid. 83 """ 84 if not isinstance(keyword, str): 85 raise TypeError("Invalid keyword!") 86 self._keyword = keyword 87 88 self._value_min = float(value_min) 89 self._value_max = float(value_max) if value_max is not None else None 90 self._value_spc = float(value_spc) if value_spc is not None else None 91 92 value_seq = ( 93 gen_regular_grid(self._value_min, self._value_max, self._value_spc) 94 if self._value_max is not None and self._value_spc is not None 95 else [self._value_min] 96 ) 97 self._value_seq = list(value_seq) 98 99 if objective is not None and not callable(objective): 100 raise TypeError("Non-callable objective!") 101 self._objective = objective 102 103 if len(self._value_seq) > 1: 104 if not isinstance(direction, Real): 105 raise TypeError("Non-real direction!") 106 if direction == 0.0: 107 raise ValueError("Zero direction!") 108 self._direction = int(direction / abs(direction)) 109 110 if self._direction < 0.0: 111 self._value_seq = sorted(self._value_seq, reverse=True) 112 113 if criterion not in { 114 "slope-neg", 115 "slope-abs", 116 "1st-min", 117 "slope-abs-avg", 118 "slope-abs-max", 119 }: 120 raise ValueError("Unknown criterion!") 121 self._criterion = criterion 122 123 if not isinstance(threshold, Real): 124 raise TypeError("Non-real threshold!") 125 if threshold <= 0.0: 126 raise ValueError("Non-positive threshold!") 127 self._threshold = float(threshold) 128 129 if not isinstance(stability, Integral): 130 raise TypeError("Non-integer stability!") 131 if stability < 1 or stability > len(self._value_seq) - 1: 132 raise ValueError("Invalid stability!") 133 self._stability = int(stability) 134 135 else: 136 self._direction = None 137 self._criterion = None 138 self._threshold = None 139 self._stability = None 140 141 if symb is None or symb == "": 142 symb = f"\\text{{{self._keyword}}}" 143 if isinstance(symb, str): 144 self._symb = Symbol(symb) 145 elif isinstance(symb, Symbol): 146 self._symb = symb 147 else: 148 raise TypeError("Invalid symbol!") 149 150 if unit is None or unit == "": 151 unit = "1" 152 if isinstance(unit, str): 153 unit = sympify(unit, rational=True) 154 for s in unit.free_symbols: 155 s_new = Symbol(f"\\mathrm{{{s.name}}}") 156 unit = unit.replace(s, s_new) 157 self._unit = unit 158 elif isinstance(unit, Expr): 159 self._unit = unit 160 else: 161 raise TypeError("Invalid unit!") 162 163 if obj_symb is None or obj_symb == "": 164 obj_symb = r"\text{objective}" 165 if isinstance(obj_symb, (Symbol, str)): 166 obj_symb = [obj_symb] 167 if not isinstance(obj_symb, Sequence) or not all( 168 isinstance(s, (Symbol, str)) for s in obj_symb 169 ): 170 raise TypeError("Invalid objective symbol!") 171 172 self._obj_symb = [] 173 for s in obj_symb: 174 if s == "": 175 s = r"\text{objective}" 176 if isinstance(s, str): 177 s = Symbol(s) 178 self._obj_symb.append(s) 179 180 if obj_unit is None or obj_unit == "": 181 obj_unit = "1" 182 if isinstance(obj_unit, (Expr, str)): 183 obj_unit = [obj_unit] 184 if not isinstance(obj_unit, Sequence) or not all( 185 isinstance(s, (Expr, str)) for s in obj_unit 186 ): 187 raise TypeError("Invalid objective unit!") 188 189 self._obj_unit = [] 190 for u in obj_unit: 191 if u == "": 192 u = "1" 193 if isinstance(u, str): 194 u = sympify(u, rational=True) 195 for s in u.free_symbols: 196 s_new = Symbol(f"\\mathrm{{{s.name}}}") 197 u = u.replace(s, s_new) 198 self._obj_unit.append(u) 199 200 self._obj_value_seq = [None] * len(self._value_seq) 201 self._obj_slope_seq = [None] * len(self._value_seq) 202 self._value_opt = np.nan 203 self._converged = False 204 205 @property 206 def keyword(self) -> str: 207 r"""Parameter keyword.""" 208 return self._keyword 209 210 @property 211 def value_min(self) -> float: 212 r"""Parameter minimum value.""" 213 return self._value_min 214 215 @property 216 def value_max(self) -> float: 217 r"""Parameter maximum value.""" 218 return self._value_max 219 220 @property 221 def value_spc(self) -> float: 222 r"""Parameter target spacing.""" 223 return self._value_spc 224 225 @property 226 def objective(self) -> Optional[Callable[[Real], np.ndarray[Real] | Real]]: 227 r"""Objective function.""" 228 return self._objective 229 230 @property 231 def direction(self) -> Optional[int]: 232 r"""Convergence direction.""" 233 return self._direction 234 235 @property 236 def criterion(self) -> Optional[str]: 237 r"""Convergence criterion.""" 238 return self._criterion 239 240 @property 241 def threshold(self) -> Optional[float]: 242 r"""Convergence threshold.""" 243 return self._threshold 244 245 @property 246 def stability(self) -> Optional[int]: 247 r"""Convergence stability.""" 248 return self._stability 249 250 @property 251 def symb(self) -> Symbol: 252 r"""Parameter plot symbol.""" 253 return self._symb 254 255 @property 256 def unit(self) -> Expr: 257 r"""Parameter plot unit.""" 258 return self._unit 259 260 @property 261 def obj_symb(self) -> list[Symbol, ...]: 262 r"""Objective plot symbol(s).""" 263 return self._obj_symb 264 265 @property 266 def obj_unit(self) -> list[Expr, ...]: 267 r"""Objective plot unit(s).""" 268 return self._obj_unit 269 270 @property 271 def value_seq(self) -> list[float, ...]: 272 r"""Parameter values considered.""" 273 return self._value_seq 274 275 @property 276 def obj_value_seq(self) -> list[np.ndarray[Real] | Real, ...]: 277 r"""Objective function values.""" 278 return self._obj_value_seq 279 280 @property 281 def obj_slope_seq(self) -> list[np.ndarray[Real] | Real, ...]: 282 r"""Objective slope sequence.""" 283 return self._obj_slope_seq 284 285 @property 286 def value_opt(self) -> float: 287 r"""Parameter optimal value.""" 288 return self._value_opt 289 290 @property 291 def converged(self) -> float: 292 r"""True if optimal parameter value is converged.""" 293 return self._converged 294 295 def converge(self) -> None: 296 r"""Converge parameter. 297 298 Returns: 299 True if convergence succeeded. 300 301 Raises: 302 ValueError: If objective function is undefined. 303 ValueError: If criterion is incompatible. 304 """ 305 self.clear() 306 307 if self._objective is None: 308 raise ValueError("Undefined objective function!") 309 310 if len(self._value_seq) == 1: 311 self._value_opt = self._value_seq[0] 312 parprint(f"{self._keyword} fixed to {self._value_opt}...") 313 self._converged = True 314 return None 315 316 for i, value in enumerate(self._value_seq): 317 obj_value = self._objective(value) 318 if ( 319 np.ndim(obj_value) == 0 320 and self._criterion not in {"slope-neg", "slope-abs", "1st-min"} 321 ) or ( 322 np.ndim(obj_value) >= 1 323 and self._criterion not in {"slope-abs-avg", "slope-abs-max"} 324 ): 325 raise ValueError( 326 f"`{self.keyword}` convergence criterion incompatible" 327 + " with dimensionality of objective function values!" 328 ) 329 self._obj_value_seq[i] = obj_value 330 331 if i > 0: 332 obj_diff = obj_value - self._obj_value_seq[i - 1] 333 diff = value - self._value_seq[i - 1] 334 obj_slope = obj_diff / diff 335 self._obj_slope_seq[i - 1] = obj_slope 336 337 if i >= self._stability: 338 i_stab = i - self._stability 339 340 if self._criterion == "slope-neg": 341 is_converged = [ 342 obj_slope < 0.0 and abs(obj_slope) < self._threshold 343 for obj_slope in self._obj_slope_seq[i_stab:i] 344 ] 345 elif self._criterion == "slope-abs": 346 is_converged = [ 347 abs(obj_slope) < self._threshold 348 for obj_slope in self._obj_slope_seq[i_stab:i] 349 ] 350 elif self._criterion == "1st-min": 351 is_converged = [ 352 self._obj_value_seq[i_stab] < obj_value 353 or abs(self._obj_value_seq[i_stab] - obj_value) 354 < self._threshold 355 for obj_value in self._obj_value_seq[i_stab + 1 : i + 1] 356 ] 357 elif self._criterion == "slope-abs-avg": 358 is_converged = [ 359 np.mean(np.abs(obj_slope)) < self._threshold 360 for obj_slope in self._obj_slope_seq[i_stab:i] 361 ] 362 elif self._criterion == "slope-abs-max": 363 is_converged = [ 364 np.amax(np.abs(obj_slope)) < self._threshold 365 for obj_slope in self._obj_slope_seq[i_stab:i] 366 ] 367 else: 368 is_converged = [False] 369 370 if all(is_converged): 371 self._value_opt = self._value_seq[i_stab] 372 parprint( 373 f"{self._keyword} converged to {self._value_opt}..." 374 ) 375 self._converged = True 376 return None 377 378 self._value_opt = self._value_seq[-1] 379 parprint(f"{self._keyword} did not converge!") 380 return None 381 382 def plot( 383 self, qnty: Literal["obj-value", "obj-slope"] 384 ) -> Generator[plt.Axes, None, None]: 385 r"""Plot quantity vs convergence parameter. 386 387 Args: 388 qnty: Quantity to plot. 389 390 Yields: 391 Plot axes. 392 393 Raises: 394 ValueError: If quantity is unknown. 395 """ 396 if qnty not in {"obj-value", "obj-slope"}: 397 raise ValueError("Unknown quantity!") 398 399 obj_dim = np.ndim(self._obj_value_seq[0]) 400 if obj_dim > 2: 401 parprint("Cannot plot objective with values dimension > 2!") 402 return None 403 404 if obj_dim == 2: 405 n_plots = self._obj_value_seq[0].shape[0] 406 else: 407 n_plots = 1 408 409 if obj_dim == 0: 410 obj_value_seq = [ 411 obj_value if obj_value is not None else np.nan 412 for obj_value in self._obj_value_seq 413 ] 414 obj_slope_seq = [ 415 obj_slope if obj_slope is not None else np.nan 416 for obj_slope in self._obj_slope_seq 417 ] 418 else: 419 obj_value_seq = [ 420 ( 421 np.reshape(obj_value, [n_plots, -1]) 422 if obj_value is not None 423 else None 424 ) 425 for obj_value in self._obj_value_seq 426 ] 427 obj_slope_seq = [ 428 ( 429 np.reshape(obj_slope, [n_plots, -1]) 430 if obj_slope is not None 431 else None 432 ) 433 for obj_slope in self._obj_slope_seq 434 ] 435 436 label = f"${latex(self._symb)}$ / ${latex(self._unit)}$" 437 438 if n_plots > 1 and len(self._obj_symb) == 1: 439 obj_symbs = [] 440 for i in range(n_plots): 441 s = self._obj_symb[0] 442 s = s.replace(s, Symbol(s.name + f"[{i + 1}]")) 443 obj_symbs.append(s) 444 else: 445 obj_symbs = self._obj_symb 446 447 if n_plots > 1 and len(self._obj_unit) == 1: 448 obj_units = n_plots * self._obj_unit 449 else: 450 obj_units = self._obj_unit 451 452 delta = Function(r"\Delta") 453 obj_value_labels = [ 454 f"${latex(obj_symb)}$ / ${latex(obj_unit)}$" 455 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 456 ] 457 obj_slope_labels = [ 458 f"${latex(delta(obj_symb) / delta(self._symb))}$" 459 + " / " 460 + f"${latex(obj_unit / self._unit)}$" 461 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 462 ] 463 464 for i in range(n_plots): 465 _, ax = plt.subplots(tight_layout=True) 466 467 if qnty == "obj-value": 468 ax.set_ylabel(obj_value_labels[i]) 469 else: 470 ax.set_ylabel(obj_slope_labels[i]) 471 472 if obj_dim == 0: 473 ax.set_xlabel(label) 474 ax.axvline(self._value_opt, linestyle="--", color="k") 475 ax.plot( 476 self._value_seq, 477 obj_value_seq if qnty == "obj-value" else obj_slope_seq, 478 "o-", 479 color="k", 480 ) 481 else: 482 if qnty == "obj-value": 483 for value, obj_value in zip(self._value_seq, obj_value_seq): 484 if obj_value is not None: 485 ax.plot( 486 obj_value[i], 487 "-" if value == self._value_opt else "--", 488 color="k" if value == self._value_opt else None, 489 label=f"{value:.2g}", 490 ) 491 else: 492 ax.set_xlabel(label) 493 obj_slope_abs_avg_seq = [ 494 ( 495 np.abs(obj_slope[i]).mean() 496 if obj_slope is not None 497 else np.nan 498 ) 499 for obj_slope in obj_slope_seq 500 ] 501 obj_slope_abs_max_seq = [ 502 ( 503 np.abs(obj_slope[i]).max() 504 if obj_slope is not None 505 else np.nan 506 ) 507 for obj_slope in obj_slope_seq 508 ] 509 ax.plot( 510 self._value_seq, 511 obj_slope_abs_avg_seq, 512 "o-", 513 color="k", 514 label=r"$\text{avg}$", 515 ) 516 ax.plot( 517 self._value_seq, 518 obj_slope_abs_max_seq, 519 "v-", 520 color="k", 521 label=r"$\text{max}$", 522 ) 523 524 ax.legend( 525 loc="upper right", 526 bbox_to_anchor=(1.0, 1.0), 527 edgecolor="k", 528 title_fontsize="x-small", 529 fontsize="x-small", 530 title=label if qnty == "obj-value" else None, 531 ) 532 533 if qnty == "obj-value" and self._criterion == "1st-min": 534 if not np.isnan(self._value_opt): 535 i_opt = self._value_seq.index(self._value_opt) 536 ax.axhspan( 537 obj_value_seq[i_opt] - self._threshold, 538 obj_value_seq[i_opt] + self._threshold, 539 color="k", 540 alpha=0.5, 541 ) 542 543 if qnty == "obj-slope" and self._criterion == "slope-neg": 544 ax.axhspan(-self._threshold, 0.0, color="k", alpha=0.5) 545 546 if qnty == "obj-slope" and self._criterion == "slope-abs": 547 threshold = self._threshold 548 ax.axhspan(-threshold, threshold, color="k", alpha=0.5) 549 550 if qnty == "obj-slope" and self._criterion in { 551 "slope-abs-avg", 552 "slope-abs-max", 553 }: 554 ax.axhspan(0.0, self._threshold, color="k", alpha=0.5) 555 556 yield ax 557 558 def clear(self) -> None: 559 r"""Clear convergence results.""" 560 self._obj_value_seq = [None] * len(self._value_seq) 561 self._obj_slope_seq = [None] * len(self._value_seq) 562 self._value_opt = np.nan 563 self._converged = False
Convergence parameter.
27 def __init__( 28 self, 29 keyword: str, 30 *, 31 value_min: Real, 32 value_max: Optional[Real] = None, 33 value_spc: Optional[Real] = None, 34 objective: Optional[Callable[[Real], np.ndarray[Real] | Real]] = None, 35 direction: Optional[Real] = None, 36 criterion: Optional[ 37 Literal[ 38 "slope-neg", 39 "slope-abs", 40 "1st-min", 41 "slope-abs-avg", 42 "slope-abs-max", 43 ] 44 ] = None, 45 threshold: Optional[Real] = None, 46 stability: Optional[Integral] = None, 47 symb: Optional[Symbol | str] = None, 48 unit: Optional[Expr | str] = None, 49 obj_symb: Optional[Sequence[Symbol | str, ...] | Symbol | str] = None, 50 obj_unit: Optional[Sequence[Expr | str, ...] | Expr | str] = None, 51 ) -> None: 52 r"""Initialize ConvergenceParameter object. 53 54 Args: 55 keyword: Parameter keyword. 56 value_min: Parameter minimum value. 57 value_max: Parameter maximum value. 58 value_spc: Parameter value spacing. 59 objective: Univariate objective function. 60 direction: Convergence direction. 61 criterion: Convergence criterion. 62 threshold: Convergence threshold. 63 stability: Convergence stability. 64 symb: Parameter symbol for plots. 65 unit: Parameter unit for plots. 66 obj_symb: Objective function symbol(s) for plots. 67 obj_unit: Objective function unit(s) for plots. 68 69 Raises: 70 TypeError: If keyword is invalid. 71 TypeError: If objective is non-callable. 72 TypeError: If direction is non-real. 73 ValueError: If direction is zero. 74 ValueError: If criterion is unknown. 75 TypeError: If threshold is non-real. 76 ValueError: If threshold is non-positive. 77 TypeError: If stability is non-integer. 78 ValueError: If stability is invalid. 79 TypeError: If symbol is invalid. 80 TypeError: If unit is invalid. 81 TypeError: If objective symbol is invalid. 82 TypeError: If objective unit is invalid. 83 """ 84 if not isinstance(keyword, str): 85 raise TypeError("Invalid keyword!") 86 self._keyword = keyword 87 88 self._value_min = float(value_min) 89 self._value_max = float(value_max) if value_max is not None else None 90 self._value_spc = float(value_spc) if value_spc is not None else None 91 92 value_seq = ( 93 gen_regular_grid(self._value_min, self._value_max, self._value_spc) 94 if self._value_max is not None and self._value_spc is not None 95 else [self._value_min] 96 ) 97 self._value_seq = list(value_seq) 98 99 if objective is not None and not callable(objective): 100 raise TypeError("Non-callable objective!") 101 self._objective = objective 102 103 if len(self._value_seq) > 1: 104 if not isinstance(direction, Real): 105 raise TypeError("Non-real direction!") 106 if direction == 0.0: 107 raise ValueError("Zero direction!") 108 self._direction = int(direction / abs(direction)) 109 110 if self._direction < 0.0: 111 self._value_seq = sorted(self._value_seq, reverse=True) 112 113 if criterion not in { 114 "slope-neg", 115 "slope-abs", 116 "1st-min", 117 "slope-abs-avg", 118 "slope-abs-max", 119 }: 120 raise ValueError("Unknown criterion!") 121 self._criterion = criterion 122 123 if not isinstance(threshold, Real): 124 raise TypeError("Non-real threshold!") 125 if threshold <= 0.0: 126 raise ValueError("Non-positive threshold!") 127 self._threshold = float(threshold) 128 129 if not isinstance(stability, Integral): 130 raise TypeError("Non-integer stability!") 131 if stability < 1 or stability > len(self._value_seq) - 1: 132 raise ValueError("Invalid stability!") 133 self._stability = int(stability) 134 135 else: 136 self._direction = None 137 self._criterion = None 138 self._threshold = None 139 self._stability = None 140 141 if symb is None or symb == "": 142 symb = f"\\text{{{self._keyword}}}" 143 if isinstance(symb, str): 144 self._symb = Symbol(symb) 145 elif isinstance(symb, Symbol): 146 self._symb = symb 147 else: 148 raise TypeError("Invalid symbol!") 149 150 if unit is None or unit == "": 151 unit = "1" 152 if isinstance(unit, str): 153 unit = sympify(unit, rational=True) 154 for s in unit.free_symbols: 155 s_new = Symbol(f"\\mathrm{{{s.name}}}") 156 unit = unit.replace(s, s_new) 157 self._unit = unit 158 elif isinstance(unit, Expr): 159 self._unit = unit 160 else: 161 raise TypeError("Invalid unit!") 162 163 if obj_symb is None or obj_symb == "": 164 obj_symb = r"\text{objective}" 165 if isinstance(obj_symb, (Symbol, str)): 166 obj_symb = [obj_symb] 167 if not isinstance(obj_symb, Sequence) or not all( 168 isinstance(s, (Symbol, str)) for s in obj_symb 169 ): 170 raise TypeError("Invalid objective symbol!") 171 172 self._obj_symb = [] 173 for s in obj_symb: 174 if s == "": 175 s = r"\text{objective}" 176 if isinstance(s, str): 177 s = Symbol(s) 178 self._obj_symb.append(s) 179 180 if obj_unit is None or obj_unit == "": 181 obj_unit = "1" 182 if isinstance(obj_unit, (Expr, str)): 183 obj_unit = [obj_unit] 184 if not isinstance(obj_unit, Sequence) or not all( 185 isinstance(s, (Expr, str)) for s in obj_unit 186 ): 187 raise TypeError("Invalid objective unit!") 188 189 self._obj_unit = [] 190 for u in obj_unit: 191 if u == "": 192 u = "1" 193 if isinstance(u, str): 194 u = sympify(u, rational=True) 195 for s in u.free_symbols: 196 s_new = Symbol(f"\\mathrm{{{s.name}}}") 197 u = u.replace(s, s_new) 198 self._obj_unit.append(u) 199 200 self._obj_value_seq = [None] * len(self._value_seq) 201 self._obj_slope_seq = [None] * len(self._value_seq) 202 self._value_opt = np.nan 203 self._converged = False
Initialize ConvergenceParameter object.
Arguments:
- keyword: Parameter keyword.
- value_min: Parameter minimum value.
- value_max: Parameter maximum value.
- value_spc: Parameter value spacing.
- objective: Univariate objective function.
- direction: Convergence direction.
- criterion: Convergence criterion.
- threshold: Convergence threshold.
- stability: Convergence stability.
- symb: Parameter symbol for plots.
- unit: Parameter unit for plots.
- obj_symb: Objective function symbol(s) for plots.
- obj_unit: Objective function unit(s) for plots.
Raises:
- TypeError: If keyword is invalid.
- TypeError: If objective is non-callable.
- TypeError: If direction is non-real.
- ValueError: If direction is zero.
- ValueError: If criterion is unknown.
- TypeError: If threshold is non-real.
- ValueError: If threshold is non-positive.
- TypeError: If stability is non-integer.
- ValueError: If stability is invalid.
- TypeError: If symbol is invalid.
- TypeError: If unit is invalid.
- TypeError: If objective symbol is invalid.
- TypeError: If objective unit is invalid.
210 @property 211 def value_min(self) -> float: 212 r"""Parameter minimum value.""" 213 return self._value_min
Parameter minimum value.
215 @property 216 def value_max(self) -> float: 217 r"""Parameter maximum value.""" 218 return self._value_max
Parameter maximum value.
220 @property 221 def value_spc(self) -> float: 222 r"""Parameter target spacing.""" 223 return self._value_spc
Parameter target spacing.
225 @property 226 def objective(self) -> Optional[Callable[[Real], np.ndarray[Real] | Real]]: 227 r"""Objective function.""" 228 return self._objective
Objective function.
230 @property 231 def direction(self) -> Optional[int]: 232 r"""Convergence direction.""" 233 return self._direction
Convergence direction.
235 @property 236 def criterion(self) -> Optional[str]: 237 r"""Convergence criterion.""" 238 return self._criterion
Convergence criterion.
240 @property 241 def threshold(self) -> Optional[float]: 242 r"""Convergence threshold.""" 243 return self._threshold
Convergence threshold.
245 @property 246 def stability(self) -> Optional[int]: 247 r"""Convergence stability.""" 248 return self._stability
Convergence stability.
260 @property 261 def obj_symb(self) -> list[Symbol, ...]: 262 r"""Objective plot symbol(s).""" 263 return self._obj_symb
Objective plot symbol(s).
265 @property 266 def obj_unit(self) -> list[Expr, ...]: 267 r"""Objective plot unit(s).""" 268 return self._obj_unit
Objective plot unit(s).
270 @property 271 def value_seq(self) -> list[float, ...]: 272 r"""Parameter values considered.""" 273 return self._value_seq
Parameter values considered.
275 @property 276 def obj_value_seq(self) -> list[np.ndarray[Real] | Real, ...]: 277 r"""Objective function values.""" 278 return self._obj_value_seq
Objective function values.
280 @property 281 def obj_slope_seq(self) -> list[np.ndarray[Real] | Real, ...]: 282 r"""Objective slope sequence.""" 283 return self._obj_slope_seq
Objective slope sequence.
285 @property 286 def value_opt(self) -> float: 287 r"""Parameter optimal value.""" 288 return self._value_opt
Parameter optimal value.
290 @property 291 def converged(self) -> float: 292 r"""True if optimal parameter value is converged.""" 293 return self._converged
True if optimal parameter value is converged.
295 def converge(self) -> None: 296 r"""Converge parameter. 297 298 Returns: 299 True if convergence succeeded. 300 301 Raises: 302 ValueError: If objective function is undefined. 303 ValueError: If criterion is incompatible. 304 """ 305 self.clear() 306 307 if self._objective is None: 308 raise ValueError("Undefined objective function!") 309 310 if len(self._value_seq) == 1: 311 self._value_opt = self._value_seq[0] 312 parprint(f"{self._keyword} fixed to {self._value_opt}...") 313 self._converged = True 314 return None 315 316 for i, value in enumerate(self._value_seq): 317 obj_value = self._objective(value) 318 if ( 319 np.ndim(obj_value) == 0 320 and self._criterion not in {"slope-neg", "slope-abs", "1st-min"} 321 ) or ( 322 np.ndim(obj_value) >= 1 323 and self._criterion not in {"slope-abs-avg", "slope-abs-max"} 324 ): 325 raise ValueError( 326 f"`{self.keyword}` convergence criterion incompatible" 327 + " with dimensionality of objective function values!" 328 ) 329 self._obj_value_seq[i] = obj_value 330 331 if i > 0: 332 obj_diff = obj_value - self._obj_value_seq[i - 1] 333 diff = value - self._value_seq[i - 1] 334 obj_slope = obj_diff / diff 335 self._obj_slope_seq[i - 1] = obj_slope 336 337 if i >= self._stability: 338 i_stab = i - self._stability 339 340 if self._criterion == "slope-neg": 341 is_converged = [ 342 obj_slope < 0.0 and abs(obj_slope) < self._threshold 343 for obj_slope in self._obj_slope_seq[i_stab:i] 344 ] 345 elif self._criterion == "slope-abs": 346 is_converged = [ 347 abs(obj_slope) < self._threshold 348 for obj_slope in self._obj_slope_seq[i_stab:i] 349 ] 350 elif self._criterion == "1st-min": 351 is_converged = [ 352 self._obj_value_seq[i_stab] < obj_value 353 or abs(self._obj_value_seq[i_stab] - obj_value) 354 < self._threshold 355 for obj_value in self._obj_value_seq[i_stab + 1 : i + 1] 356 ] 357 elif self._criterion == "slope-abs-avg": 358 is_converged = [ 359 np.mean(np.abs(obj_slope)) < self._threshold 360 for obj_slope in self._obj_slope_seq[i_stab:i] 361 ] 362 elif self._criterion == "slope-abs-max": 363 is_converged = [ 364 np.amax(np.abs(obj_slope)) < self._threshold 365 for obj_slope in self._obj_slope_seq[i_stab:i] 366 ] 367 else: 368 is_converged = [False] 369 370 if all(is_converged): 371 self._value_opt = self._value_seq[i_stab] 372 parprint( 373 f"{self._keyword} converged to {self._value_opt}..." 374 ) 375 self._converged = True 376 return None 377 378 self._value_opt = self._value_seq[-1] 379 parprint(f"{self._keyword} did not converge!") 380 return None
Converge parameter.
Returns:
True if convergence succeeded.
Raises:
- ValueError: If objective function is undefined.
- ValueError: If criterion is incompatible.
382 def plot( 383 self, qnty: Literal["obj-value", "obj-slope"] 384 ) -> Generator[plt.Axes, None, None]: 385 r"""Plot quantity vs convergence parameter. 386 387 Args: 388 qnty: Quantity to plot. 389 390 Yields: 391 Plot axes. 392 393 Raises: 394 ValueError: If quantity is unknown. 395 """ 396 if qnty not in {"obj-value", "obj-slope"}: 397 raise ValueError("Unknown quantity!") 398 399 obj_dim = np.ndim(self._obj_value_seq[0]) 400 if obj_dim > 2: 401 parprint("Cannot plot objective with values dimension > 2!") 402 return None 403 404 if obj_dim == 2: 405 n_plots = self._obj_value_seq[0].shape[0] 406 else: 407 n_plots = 1 408 409 if obj_dim == 0: 410 obj_value_seq = [ 411 obj_value if obj_value is not None else np.nan 412 for obj_value in self._obj_value_seq 413 ] 414 obj_slope_seq = [ 415 obj_slope if obj_slope is not None else np.nan 416 for obj_slope in self._obj_slope_seq 417 ] 418 else: 419 obj_value_seq = [ 420 ( 421 np.reshape(obj_value, [n_plots, -1]) 422 if obj_value is not None 423 else None 424 ) 425 for obj_value in self._obj_value_seq 426 ] 427 obj_slope_seq = [ 428 ( 429 np.reshape(obj_slope, [n_plots, -1]) 430 if obj_slope is not None 431 else None 432 ) 433 for obj_slope in self._obj_slope_seq 434 ] 435 436 label = f"${latex(self._symb)}$ / ${latex(self._unit)}$" 437 438 if n_plots > 1 and len(self._obj_symb) == 1: 439 obj_symbs = [] 440 for i in range(n_plots): 441 s = self._obj_symb[0] 442 s = s.replace(s, Symbol(s.name + f"[{i + 1}]")) 443 obj_symbs.append(s) 444 else: 445 obj_symbs = self._obj_symb 446 447 if n_plots > 1 and len(self._obj_unit) == 1: 448 obj_units = n_plots * self._obj_unit 449 else: 450 obj_units = self._obj_unit 451 452 delta = Function(r"\Delta") 453 obj_value_labels = [ 454 f"${latex(obj_symb)}$ / ${latex(obj_unit)}$" 455 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 456 ] 457 obj_slope_labels = [ 458 f"${latex(delta(obj_symb) / delta(self._symb))}$" 459 + " / " 460 + f"${latex(obj_unit / self._unit)}$" 461 for obj_symb, obj_unit in zip(obj_symbs, obj_units) 462 ] 463 464 for i in range(n_plots): 465 _, ax = plt.subplots(tight_layout=True) 466 467 if qnty == "obj-value": 468 ax.set_ylabel(obj_value_labels[i]) 469 else: 470 ax.set_ylabel(obj_slope_labels[i]) 471 472 if obj_dim == 0: 473 ax.set_xlabel(label) 474 ax.axvline(self._value_opt, linestyle="--", color="k") 475 ax.plot( 476 self._value_seq, 477 obj_value_seq if qnty == "obj-value" else obj_slope_seq, 478 "o-", 479 color="k", 480 ) 481 else: 482 if qnty == "obj-value": 483 for value, obj_value in zip(self._value_seq, obj_value_seq): 484 if obj_value is not None: 485 ax.plot( 486 obj_value[i], 487 "-" if value == self._value_opt else "--", 488 color="k" if value == self._value_opt else None, 489 label=f"{value:.2g}", 490 ) 491 else: 492 ax.set_xlabel(label) 493 obj_slope_abs_avg_seq = [ 494 ( 495 np.abs(obj_slope[i]).mean() 496 if obj_slope is not None 497 else np.nan 498 ) 499 for obj_slope in obj_slope_seq 500 ] 501 obj_slope_abs_max_seq = [ 502 ( 503 np.abs(obj_slope[i]).max() 504 if obj_slope is not None 505 else np.nan 506 ) 507 for obj_slope in obj_slope_seq 508 ] 509 ax.plot( 510 self._value_seq, 511 obj_slope_abs_avg_seq, 512 "o-", 513 color="k", 514 label=r"$\text{avg}$", 515 ) 516 ax.plot( 517 self._value_seq, 518 obj_slope_abs_max_seq, 519 "v-", 520 color="k", 521 label=r"$\text{max}$", 522 ) 523 524 ax.legend( 525 loc="upper right", 526 bbox_to_anchor=(1.0, 1.0), 527 edgecolor="k", 528 title_fontsize="x-small", 529 fontsize="x-small", 530 title=label if qnty == "obj-value" else None, 531 ) 532 533 if qnty == "obj-value" and self._criterion == "1st-min": 534 if not np.isnan(self._value_opt): 535 i_opt = self._value_seq.index(self._value_opt) 536 ax.axhspan( 537 obj_value_seq[i_opt] - self._threshold, 538 obj_value_seq[i_opt] + self._threshold, 539 color="k", 540 alpha=0.5, 541 ) 542 543 if qnty == "obj-slope" and self._criterion == "slope-neg": 544 ax.axhspan(-self._threshold, 0.0, color="k", alpha=0.5) 545 546 if qnty == "obj-slope" and self._criterion == "slope-abs": 547 threshold = self._threshold 548 ax.axhspan(-threshold, threshold, color="k", alpha=0.5) 549 550 if qnty == "obj-slope" and self._criterion in { 551 "slope-abs-avg", 552 "slope-abs-max", 553 }: 554 ax.axhspan(0.0, self._threshold, color="k", alpha=0.5) 555 556 yield ax
Plot quantity vs convergence parameter.
Arguments:
- qnty: Quantity to plot.
Yields:
Plot axes.
Raises:
- ValueError: If quantity is unknown.
566class MultivariateConvergenceParameterSettings(TypedDict, total=False): 567 r"""Multivariate convergence parameter settings.""" 568 569 value_min: Real 570 r"""Parameter minimum value.""" 571 572 value_max: Optional[Real] 573 r"""Parameter maximum value.""" 574 575 value_spc: Optional[Real] 576 r"""Paramater value spacing.""" 577 578 direction: Optional[Real] 579 r"""Convergence direction.""" 580 581 criterion: Optional[ 582 Literal[ 583 "slope-neg", 584 "slope-abs", 585 "1st-min", 586 "slope-abs-avg", 587 "slope-abs-max", 588 ] 589 ] 590 r"""Convergence criterion.""" 591 592 threshold: Optional[Real] 593 r"""Convergence threshold.""" 594 595 stability: Optional[Integral] 596 r"""Convergence stability.""" 597 598 symb: Optional[Symbol | str] 599 r"""Parameter symbol for plots.""" 600 601 unit: Optional[Expr | str] 602 r"""Parameter unit for plots."""
Multivariate convergence parameter settings.
Convergence criterion.
Inherited Members
- builtins.dict
- get
- setdefault
- pop
- popitem
- keys
- items
- values
- update
- fromkeys
- clear
- copy
605class MultivariateConvergence: 606 r"""Multivariate convergence.""" 607 608 def __init__( 609 self, 610 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 611 params: Mapping[str, MultivariateConvergenceParameterSettings], 612 *, 613 crop: bool = False, 614 req_sc: bool = False, 615 niter_max: Integral = 1, 616 obj_symb: Optional[Sequence[str, ...] | str] = None, 617 obj_unit: Optional[Sequence[str, ...] | str] = None, 618 ) -> None: 619 r"""Initialize MultivariateConvergence object. 620 621 Args: 622 objective: Multivariate objective function; caching is recommended. 623 params: Convergence parameters, specified as a mapping of the 624 multivariate objective function argument keywords into the 625 corresponding convergence settings. 626 The settings are a dictionary of the ConvergenceParameter 627 argument keywords 'value_min', 'value_max', 'value_spc', 628 'direction', 'criterion', 'threshold', 'stability', 'symb' and 629 'unit', into the desired values. 630 The remaining ConvergenceParameter arguments are 'objective', 631 'obj_symb' and 'obj_unit'. 632 'objective' is set to the restriction of the multivariate 633 objective function to the univariate objective function of the 634 current parameter for the current value of the other parameters. 635 'obj_symb' and 'obj_unit' are inherited. 636 crop: If True, parameter values preceding the current optimal value 637 are removed at each iteration step. 638 req_sc: If True, self-consistency is required to achieve 639 convergence; self-consistency means that the final optimal 640 parameter values are equal to the final working parameter 641 values. 642 niter_max: Maximum number of iterations. 643 obj_symb: Objective function symbol(s) for plots. 644 obj_unit: Objective function unit(s) for plots. 645 646 Raises: 647 TypeError: If objective is non-callable. 648 TypeError: If `crop` is not boolean. 649 TypeError: If `req_sc` is not boolean. 650 TypeError: If maximum number of iterations is non-integer. 651 ValueError: If maximum number of iterations is below 1. 652 """ 653 if not callable(objective): 654 raise TypeError("Non-callable objective!") 655 self._objective = objective 656 657 if not isinstance(crop, bool): 658 raise TypeError("Non-boolean `crop` parameter!") 659 self._crop = crop 660 661 if not isinstance(req_sc, bool): 662 raise TypeError("Non-boolean `req_sc` parameter!") 663 self._req_sc = req_sc 664 665 if not isinstance(niter_max, Integral): 666 raise TypeError("Non-integer maximum number of iterations!") 667 if niter_max < 1: 668 raise ValueError("Maximum number of iterations below 1!") 669 self._niter_max = int(niter_max) 670 671 self._params_seq = [{}] + [None] * (self._niter_max - 1) 672 self._wrk_pt_seq = [{}] + [None] * (self._niter_max - 1) 673 self._opt_pt_seq = [None] * self._niter_max 674 self._values_opt = {} 675 676 for keyword, settings in params.items(): 677 settings.update({"obj_symb": obj_symb, "obj_unit": obj_unit}) 678 param = ConvergenceParameter(keyword, objective=None, **settings) 679 self._params_seq[0].update({keyword: param}) 680 self._wrk_pt_seq[0].update({keyword: param.value_seq[0]}) 681 self._values_opt.update({keyword: np.nan}) 682 683 for param in self._params_seq[0].values(): 684 param._objective = type(self).ret_obj_wrt_param( 685 self._objective, 686 keyword=param.keyword, 687 wrk_pt=self._wrk_pt_seq[0], 688 ) 689 self._converged = False 690 691 @property 692 def objective(self) -> Callable[[Real, ...], np.ndarray[Real] | Real]: 693 r"""Multivariate objective function.""" 694 return self._objective 695 696 @property 697 def crop(self) -> bool: 698 r"""Crop parameter values at each iteration.""" 699 return self._crop 700 701 @property 702 def req_sc(self) -> bool: 703 r"""Require self-consistency.""" 704 return self._req_sc 705 706 @property 707 def niter_max(self) -> int: 708 r"""Maximum number of iterations.""" 709 return self._niter_max 710 711 @property 712 def params_seq(self) -> list[dict[str, ConvergenceParameter], ...]: 713 r"""Convergence parameters sequence.""" 714 return self._params_seq 715 716 @property 717 def wrk_pt_seq(self) -> list[dict[str, Real], ...]: 718 r"""Working point sequence.""" 719 return self._wrk_pt_seq 720 721 @property 722 def opt_pt_seq(self) -> list[dict[str, Real], ...]: 723 r"""Optimal point sequence.""" 724 return self._opt_pt_seq 725 726 @property 727 def values_opt(self) -> dict[str, Real]: 728 r"""Optimal parameter values.""" 729 return self._values_opt 730 731 @property 732 def converged(self) -> bool: 733 r"""True if converged.""" 734 return self._converged 735 736 def run(self) -> bool: 737 r"""Run multivariate convergence. 738 739 Returns: 740 True if convergence succeeded. 741 """ 742 self.clear() 743 744 iter_i = 0 745 while iter_i < self._niter_max: 746 if iter_i > 0: 747 params = {} 748 wrk_pt = self._opt_pt_seq[iter_i - 1].copy() 749 for param in self._params_seq[iter_i - 1].values(): 750 if param.direction is None: 751 params.update({param.keyword: param}) 752 else: 753 value_min = ( 754 wrk_pt[param.keyword] 755 if self._crop is True and param.direction > 0 756 else param.value_min 757 ) 758 value_max = ( 759 wrk_pt[param.keyword] 760 if self._crop is True and param.direction < 0 761 else param.value_max 762 ) 763 param_obj = type(self).ret_obj_wrt_param( 764 self._objective, 765 keyword=param.keyword, 766 wrk_pt=wrk_pt, 767 ) 768 769 params.update( 770 { 771 param.keyword: ConvergenceParameter( 772 param.keyword, 773 value_min=value_min, 774 value_max=value_max, 775 value_spc=param.value_spc, 776 objective=param_obj, 777 direction=param.direction, 778 criterion=param.criterion, 779 threshold=param.threshold, 780 stability=param.stability, 781 symb=param.symb, 782 unit=param.unit, 783 obj_symb=param.obj_symb, 784 obj_unit=param.obj_unit, 785 ) 786 } 787 ) 788 self._wrk_pt_seq[iter_i] = wrk_pt 789 self._params_seq[iter_i] = params 790 791 opt_pt = {} 792 for param in self._params_seq[iter_i].values(): 793 param.converge() 794 if param.converged is False: 795 parprint( 796 f"{param.keyword} convergence failed!" 797 + " Fixing to best value..." 798 ) 799 opt_pt.update({param.keyword: param.value_opt}) 800 self._opt_pt_seq[iter_i] = opt_pt 801 802 if self._opt_pt_seq[iter_i] == self._wrk_pt_seq[iter_i]: 803 parprint("Self-consistency achieved...") 804 if all( 805 param.converged is True 806 for param in self._params_seq[iter_i].values() 807 ): 808 parprint( 809 "Multivariate convergence achieved..." 810 ) 811 self._converged = True 812 self._values_opt = self._opt_pt_seq[iter_i] 813 return None 814 iter_i += 1 815 816 if self._req_sc is True: 817 parprint("Self-consistent multivariate convergence failed!") 818 else: 819 parprint("Non-self-consistent multivariate convergence performed...") 820 self._values_opt = self._opt_pt_seq[-1] 821 return None 822 823 def plot( 824 self, 825 qnty: Literal["obj-value", "obj-slope"], 826 ) -> dict[str, list[Generator[plt.Axes, None, None], ...]]: 827 r"""Plot quantity vs convergence parameters. 828 829 Args: 830 qnty: Quantity to plot. 831 832 Returns: 833 Plot axes. 834 """ 835 plots = {keyword: [] for keyword in self._values_opt} 836 837 for params, wrk_pt in zip(self._params_seq, self._wrk_pt_seq): 838 if params is None: 839 break 840 for keyword, param in params.items(): 841 wrk_pt_txt = [ 842 f"${latex(p.symb)}={latex(wrk_pt[k]*p.unit)}$" 843 for k, p in params.items() 844 if k != keyword 845 ] 846 wrk_pt_txt = ", ".join(wrk_pt_txt) 847 848 def param_plots(param, wrk_pt_txt): 849 for ax in param.plot(qnty): 850 ax.set_title(wrk_pt_txt, fontsize="x-small") 851 yield ax 852 853 plots[keyword].append(param_plots(param, wrk_pt_txt)) 854 855 return plots 856 857 def clear(self) -> None: 858 r"""Clear convergence results.""" 859 i = 1 860 while i < len(self._params_seq): 861 self._params_seq[i] = None 862 self._wrk_pt_seq[i] = None 863 self._opt_pt_seq[i] = None 864 i += 1 865 for param in self._params_seq[0].values(): 866 param.clear() 867 self._opt_pt_seq[0] = None 868 self._values_opt = {keyword: np.nan for keyword in self._values_opt} 869 self._converged = False 870 871 @staticmethod 872 def ret_obj_wrt_param( 873 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 874 *, 875 keyword: str, 876 wrk_pt: Mapping[str, Real], 877 ) -> Callable[[Real], np.ndarray[Real] | Real]: 878 r"""Return univariate objective from multivariate objective. 879 880 Note: 881 The univariate objective w.r.t. to a parameter of the multivariate 882 objective is obtained by fixing all the other parameters to the 883 values specified by the working point. 884 885 Args: 886 objective: Multivariate objective function. 887 keyword: Univariate objective parameter keyword. 888 wrk_pt: Working point specified as a mapping of keywords of the 889 multivariate objective parameters into the corresponding 890 working point values. 891 892 Returns: 893 Univariate objective. 894 """ 895 896 def obj_wrt_param(value): 897 kwargs = wrk_pt.copy() 898 kwargs.update({keyword: value}) 899 return objective(**kwargs) 900 901 return obj_wrt_param
Multivariate convergence.
608 def __init__( 609 self, 610 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 611 params: Mapping[str, MultivariateConvergenceParameterSettings], 612 *, 613 crop: bool = False, 614 req_sc: bool = False, 615 niter_max: Integral = 1, 616 obj_symb: Optional[Sequence[str, ...] | str] = None, 617 obj_unit: Optional[Sequence[str, ...] | str] = None, 618 ) -> None: 619 r"""Initialize MultivariateConvergence object. 620 621 Args: 622 objective: Multivariate objective function; caching is recommended. 623 params: Convergence parameters, specified as a mapping of the 624 multivariate objective function argument keywords into the 625 corresponding convergence settings. 626 The settings are a dictionary of the ConvergenceParameter 627 argument keywords 'value_min', 'value_max', 'value_spc', 628 'direction', 'criterion', 'threshold', 'stability', 'symb' and 629 'unit', into the desired values. 630 The remaining ConvergenceParameter arguments are 'objective', 631 'obj_symb' and 'obj_unit'. 632 'objective' is set to the restriction of the multivariate 633 objective function to the univariate objective function of the 634 current parameter for the current value of the other parameters. 635 'obj_symb' and 'obj_unit' are inherited. 636 crop: If True, parameter values preceding the current optimal value 637 are removed at each iteration step. 638 req_sc: If True, self-consistency is required to achieve 639 convergence; self-consistency means that the final optimal 640 parameter values are equal to the final working parameter 641 values. 642 niter_max: Maximum number of iterations. 643 obj_symb: Objective function symbol(s) for plots. 644 obj_unit: Objective function unit(s) for plots. 645 646 Raises: 647 TypeError: If objective is non-callable. 648 TypeError: If `crop` is not boolean. 649 TypeError: If `req_sc` is not boolean. 650 TypeError: If maximum number of iterations is non-integer. 651 ValueError: If maximum number of iterations is below 1. 652 """ 653 if not callable(objective): 654 raise TypeError("Non-callable objective!") 655 self._objective = objective 656 657 if not isinstance(crop, bool): 658 raise TypeError("Non-boolean `crop` parameter!") 659 self._crop = crop 660 661 if not isinstance(req_sc, bool): 662 raise TypeError("Non-boolean `req_sc` parameter!") 663 self._req_sc = req_sc 664 665 if not isinstance(niter_max, Integral): 666 raise TypeError("Non-integer maximum number of iterations!") 667 if niter_max < 1: 668 raise ValueError("Maximum number of iterations below 1!") 669 self._niter_max = int(niter_max) 670 671 self._params_seq = [{}] + [None] * (self._niter_max - 1) 672 self._wrk_pt_seq = [{}] + [None] * (self._niter_max - 1) 673 self._opt_pt_seq = [None] * self._niter_max 674 self._values_opt = {} 675 676 for keyword, settings in params.items(): 677 settings.update({"obj_symb": obj_symb, "obj_unit": obj_unit}) 678 param = ConvergenceParameter(keyword, objective=None, **settings) 679 self._params_seq[0].update({keyword: param}) 680 self._wrk_pt_seq[0].update({keyword: param.value_seq[0]}) 681 self._values_opt.update({keyword: np.nan}) 682 683 for param in self._params_seq[0].values(): 684 param._objective = type(self).ret_obj_wrt_param( 685 self._objective, 686 keyword=param.keyword, 687 wrk_pt=self._wrk_pt_seq[0], 688 ) 689 self._converged = False
Initialize MultivariateConvergence object.
Arguments:
- objective: Multivariate objective function; caching is recommended.
- params: Convergence parameters, specified as a mapping of the multivariate objective function argument keywords into the corresponding convergence settings. The settings are a dictionary of the ConvergenceParameter argument keywords 'value_min', 'value_max', 'value_spc', 'direction', 'criterion', 'threshold', 'stability', 'symb' and 'unit', into the desired values. The remaining ConvergenceParameter arguments are 'objective', 'obj_symb' and 'obj_unit'. 'objective' is set to the restriction of the multivariate objective function to the univariate objective function of the current parameter for the current value of the other parameters. 'obj_symb' and 'obj_unit' are inherited.
- crop: If True, parameter values preceding the current optimal value are removed at each iteration step.
- req_sc: If True, self-consistency is required to achieve convergence; self-consistency means that the final optimal parameter values are equal to the final working parameter values.
- niter_max: Maximum number of iterations.
- obj_symb: Objective function symbol(s) for plots.
- obj_unit: Objective function unit(s) for plots.
Raises:
691 @property 692 def objective(self) -> Callable[[Real, ...], np.ndarray[Real] | Real]: 693 r"""Multivariate objective function.""" 694 return self._objective
Multivariate objective function.
696 @property 697 def crop(self) -> bool: 698 r"""Crop parameter values at each iteration.""" 699 return self._crop
Crop parameter values at each iteration.
701 @property 702 def req_sc(self) -> bool: 703 r"""Require self-consistency.""" 704 return self._req_sc
Require self-consistency.
706 @property 707 def niter_max(self) -> int: 708 r"""Maximum number of iterations.""" 709 return self._niter_max
Maximum number of iterations.
711 @property 712 def params_seq(self) -> list[dict[str, ConvergenceParameter], ...]: 713 r"""Convergence parameters sequence.""" 714 return self._params_seq
Convergence parameters sequence.
716 @property 717 def wrk_pt_seq(self) -> list[dict[str, Real], ...]: 718 r"""Working point sequence.""" 719 return self._wrk_pt_seq
Working point sequence.
721 @property 722 def opt_pt_seq(self) -> list[dict[str, Real], ...]: 723 r"""Optimal point sequence.""" 724 return self._opt_pt_seq
Optimal point sequence.
726 @property 727 def values_opt(self) -> dict[str, Real]: 728 r"""Optimal parameter values.""" 729 return self._values_opt
Optimal parameter values.
731 @property 732 def converged(self) -> bool: 733 r"""True if converged.""" 734 return self._converged
True if converged.
736 def run(self) -> bool: 737 r"""Run multivariate convergence. 738 739 Returns: 740 True if convergence succeeded. 741 """ 742 self.clear() 743 744 iter_i = 0 745 while iter_i < self._niter_max: 746 if iter_i > 0: 747 params = {} 748 wrk_pt = self._opt_pt_seq[iter_i - 1].copy() 749 for param in self._params_seq[iter_i - 1].values(): 750 if param.direction is None: 751 params.update({param.keyword: param}) 752 else: 753 value_min = ( 754 wrk_pt[param.keyword] 755 if self._crop is True and param.direction > 0 756 else param.value_min 757 ) 758 value_max = ( 759 wrk_pt[param.keyword] 760 if self._crop is True and param.direction < 0 761 else param.value_max 762 ) 763 param_obj = type(self).ret_obj_wrt_param( 764 self._objective, 765 keyword=param.keyword, 766 wrk_pt=wrk_pt, 767 ) 768 769 params.update( 770 { 771 param.keyword: ConvergenceParameter( 772 param.keyword, 773 value_min=value_min, 774 value_max=value_max, 775 value_spc=param.value_spc, 776 objective=param_obj, 777 direction=param.direction, 778 criterion=param.criterion, 779 threshold=param.threshold, 780 stability=param.stability, 781 symb=param.symb, 782 unit=param.unit, 783 obj_symb=param.obj_symb, 784 obj_unit=param.obj_unit, 785 ) 786 } 787 ) 788 self._wrk_pt_seq[iter_i] = wrk_pt 789 self._params_seq[iter_i] = params 790 791 opt_pt = {} 792 for param in self._params_seq[iter_i].values(): 793 param.converge() 794 if param.converged is False: 795 parprint( 796 f"{param.keyword} convergence failed!" 797 + " Fixing to best value..." 798 ) 799 opt_pt.update({param.keyword: param.value_opt}) 800 self._opt_pt_seq[iter_i] = opt_pt 801 802 if self._opt_pt_seq[iter_i] == self._wrk_pt_seq[iter_i]: 803 parprint("Self-consistency achieved...") 804 if all( 805 param.converged is True 806 for param in self._params_seq[iter_i].values() 807 ): 808 parprint( 809 "Multivariate convergence achieved..." 810 ) 811 self._converged = True 812 self._values_opt = self._opt_pt_seq[iter_i] 813 return None 814 iter_i += 1 815 816 if self._req_sc is True: 817 parprint("Self-consistent multivariate convergence failed!") 818 else: 819 parprint("Non-self-consistent multivariate convergence performed...") 820 self._values_opt = self._opt_pt_seq[-1] 821 return None
Run multivariate convergence.
Returns:
True if convergence succeeded.
823 def plot( 824 self, 825 qnty: Literal["obj-value", "obj-slope"], 826 ) -> dict[str, list[Generator[plt.Axes, None, None], ...]]: 827 r"""Plot quantity vs convergence parameters. 828 829 Args: 830 qnty: Quantity to plot. 831 832 Returns: 833 Plot axes. 834 """ 835 plots = {keyword: [] for keyword in self._values_opt} 836 837 for params, wrk_pt in zip(self._params_seq, self._wrk_pt_seq): 838 if params is None: 839 break 840 for keyword, param in params.items(): 841 wrk_pt_txt = [ 842 f"${latex(p.symb)}={latex(wrk_pt[k]*p.unit)}$" 843 for k, p in params.items() 844 if k != keyword 845 ] 846 wrk_pt_txt = ", ".join(wrk_pt_txt) 847 848 def param_plots(param, wrk_pt_txt): 849 for ax in param.plot(qnty): 850 ax.set_title(wrk_pt_txt, fontsize="x-small") 851 yield ax 852 853 plots[keyword].append(param_plots(param, wrk_pt_txt)) 854 855 return plots
Plot quantity vs convergence parameters.
Arguments:
- qnty: Quantity to plot.
Returns:
Plot axes.
857 def clear(self) -> None: 858 r"""Clear convergence results.""" 859 i = 1 860 while i < len(self._params_seq): 861 self._params_seq[i] = None 862 self._wrk_pt_seq[i] = None 863 self._opt_pt_seq[i] = None 864 i += 1 865 for param in self._params_seq[0].values(): 866 param.clear() 867 self._opt_pt_seq[0] = None 868 self._values_opt = {keyword: np.nan for keyword in self._values_opt} 869 self._converged = False
Clear convergence results.
871 @staticmethod 872 def ret_obj_wrt_param( 873 objective: Callable[[Real, ...], np.ndarray[Real] | Real], 874 *, 875 keyword: str, 876 wrk_pt: Mapping[str, Real], 877 ) -> Callable[[Real], np.ndarray[Real] | Real]: 878 r"""Return univariate objective from multivariate objective. 879 880 Note: 881 The univariate objective w.r.t. to a parameter of the multivariate 882 objective is obtained by fixing all the other parameters to the 883 values specified by the working point. 884 885 Args: 886 objective: Multivariate objective function. 887 keyword: Univariate objective parameter keyword. 888 wrk_pt: Working point specified as a mapping of keywords of the 889 multivariate objective parameters into the corresponding 890 working point values. 891 892 Returns: 893 Univariate objective. 894 """ 895 896 def obj_wrt_param(value): 897 kwargs = wrk_pt.copy() 898 kwargs.update({keyword: value}) 899 return objective(**kwargs) 900 901 return obj_wrt_param
Return univariate objective from multivariate objective.
Note:
The univariate objective w.r.t. to a parameter of the multivariate objective is obtained by fixing all the other parameters to the values specified by the working point.
Arguments:
- objective: Multivariate objective function.
- keyword: Univariate objective parameter keyword.
- wrk_pt: Working point specified as a mapping of keywords of the multivariate objective parameters into the corresponding working point values.
Returns:
Univariate objective.