owlplanner 2025.5.3__py3-none-any.whl → 2025.5.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
owlplanner/plan.py CHANGED
@@ -22,7 +22,6 @@ from functools import wraps
22
22
  from openpyxl import Workbook
23
23
  from openpyxl.utils.dataframe import dataframe_to_rows
24
24
  import time
25
- import io
26
25
 
27
26
  from owlplanner import utils as u
28
27
  from owlplanner import tax2025 as tx
@@ -32,6 +31,7 @@ from owlplanner import config
32
31
  from owlplanner import timelists
33
32
  from owlplanner import logging
34
33
  from owlplanner import progress
34
+ from owlplanner import plots
35
35
 
36
36
 
37
37
  # This makes all graphs to have the same height.
@@ -248,10 +248,14 @@ class Plan(object):
248
248
  self.defaultSolver = "HiGHS"
249
249
 
250
250
  self.N_i = len(yobs)
251
- assert 0 < self.N_i and self.N_i <= 2, f"Cannot support {self.N_i} individuals."
252
- assert self.N_i == len(expectancy), f"Expectancy must have {self.N_i} entries."
253
- assert self.N_i == len(inames), f"Names for individuals must have {self.N_i} entries."
254
- assert inames[0] != "" or (self.N_i == 2 and inames[1] == ""), "Name for each individual must be provided."
251
+ if not (0 <= self.N_i <= 2):
252
+ raise ValueError(f"Cannot support {self.N_i} individuals.")
253
+ if self.N_i != len(expectancy):
254
+ raise ValueError(f"Expectancy must have {self.N_i} entries.")
255
+ if self.N_i != len(inames):
256
+ raise ValueError(f"Names for individuals must have {self.N_i} entries.")
257
+ if inames[0] == "" or (self.N_i == 2 and inames[1] == ""):
258
+ raise ValueError("Name for each individual must be provided.")
255
259
 
256
260
  self.filingStatus = ["single", "married"][self.N_i - 1]
257
261
  # Default year TCJA is speculated to expire.
@@ -263,10 +267,9 @@ class Plan(object):
263
267
  # Reference time is starting date in the current year and all passings are assumed at the end.
264
268
  thisyear = date.today().year
265
269
  self.horizons = self.yobs + self.expectancy - thisyear + 1
266
- # self.horizons = [yobs[i] + expectancy[i] - thisyear + 1 for i in range(self.N_i)]
267
270
  self.N_n = np.max(self.horizons)
268
271
  self.year_n = np.linspace(thisyear, thisyear + self.N_n - 1, self.N_n, dtype=np.int32)
269
- # Year in the plan (if any) where individuals turn 59. For 10% withdrawal penalty.
272
+ # Year index in the plan (if any) where individuals turn 59. For 10% withdrawal penalty.
270
273
  self.n59 = 59 - thisyear + self.yobs
271
274
  self.n59[self.n59 < 0] = 0
272
275
  # Handle passing of one spouse before the other.
@@ -433,7 +436,8 @@ class Plan(object):
433
436
  where s_n is the surplus amount. Here d_0n is the taxable account
434
437
  deposit for the first spouse while d_1n is for the second spouse.
435
438
  """
436
- assert 0 <= eta and eta <= 1, "Fraction must be between 0 and 1."
439
+ if not (0 <= eta <= 1):
440
+ raise ValueError("Fraction must be between 0 and 1.")
437
441
  if self.N_i != 2:
438
442
  self.mylog.vprint("Deposit fraction can only be 0 for single individuals.")
439
443
  eta = 0
@@ -452,11 +456,12 @@ class Plan(object):
452
456
 
453
457
  def setDividendRate(self, mu):
454
458
  """
455
- Set dividend rate on equities. Rate is in percent. Default 2%.
459
+ Set dividend tax rate. Rate is in percent. Default 2%.
456
460
  """
457
- assert 0 <= mu and mu <= 100, "Rate must be between 0 and 100."
461
+ if not (0 <= mu <= 100):
462
+ raise ValueError("Rate must be between 0 and 100.")
458
463
  mu /= 100
459
- self.mylog.vprint(f"Dividend return rate on equities set to {u.pc(mu, f=1)}.")
464
+ self.mylog.vprint(f"Dividend tax rate set to {u.pc(mu, f=0)}.")
460
465
  self.mu = mu
461
466
  self.caseStatus = "modified"
462
467
 
@@ -473,7 +478,8 @@ class Plan(object):
473
478
  """
474
479
  Set long-term income tax rate. Rate is in percent. Default 15%.
475
480
  """
476
- assert 0 <= psi and psi <= 100, "Rate must be between 0 and 100."
481
+ if not (0 <= psi <= 100):
482
+ raise ValueError("Rate must be between 0 and 100.")
477
483
  psi /= 100
478
484
  self.mylog.vprint(f"Long-term capital gain income tax set to {u.pc(psi, f=0)}.")
479
485
  self.psi = psi
@@ -484,10 +490,11 @@ class Plan(object):
484
490
  Set fractions of savings accounts that is left to surviving spouse.
485
491
  Default is [1, 1, 1] for taxable, tax-deferred, adn tax-exempt accounts.
486
492
  """
487
- assert len(phi) == self.N_j, f"Fractions must have {self.N_j} entries."
493
+ if len(phi) != self.N_j:
494
+ raise ValueError(f"Fractions must have {self.N_j} entries.")
488
495
  for j in range(self.N_j):
489
- assert 0 <= phi[j] <= 1, "Fractions must be between 0 and 1."
490
-
496
+ if not (0 <= phi[j] <= 1):
497
+ raise ValueError("Fractions must be between 0 and 1.")
491
498
  self.phi_j = np.array(phi, dtype=np.float32)
492
499
  self.mylog.vprint("Spousal beneficiary fractions set to",
493
500
  ["{:.2f}".format(self.phi_j[j]) for j in range(self.N_j)])
@@ -502,20 +509,24 @@ class Plan(object):
502
509
  Set the heirs tax rate on the tax-deferred portion of the estate.
503
510
  Rate is in percent. Default is 30%.
504
511
  """
505
- assert 0 <= nu and nu <= 100, "Rate must be between 0 and 100."
512
+ if not (0 <= nu <= 100):
513
+ raise ValueError("Rate must be between 0 and 100.")
506
514
  nu /= 100
507
515
  self.mylog.vprint(f"Heirs tax rate on tax-deferred portion of estate set to {u.pc(nu, f=0)}.")
508
516
  self.nu = nu
509
517
  self.caseStatus = "modified"
510
518
 
511
- def setPension(self, amounts, ages, indexed=[False, False], units="k"):
519
+ def setPension(self, amounts, ages, indexed=(False, False), units="k"):
512
520
  """
513
521
  Set value of pension for each individual and commencement age.
514
522
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
515
523
  """
516
- assert len(amounts) == self.N_i, f"Amounts must have {self.N_i} entries."
517
- assert len(ages) == self.N_i, f"Ages must have {self.N_i} entries."
518
- assert len(indexed) >= self.N_i, f"Indexed list must have at least {self.N_i} entries."
524
+ if len(amounts) != self.N_i:
525
+ raise ValueError(f"Amounts must have {self.N_i} entries.")
526
+ if len(ages) != self.N_i:
527
+ raise ValueError(f"Ages must have {self.N_i} entries.")
528
+ if len(indexed) < self.N_i:
529
+ raise ValueError(f"Indexed list must have at least {self.N_i} entries.")
519
530
 
520
531
  fac = u.getUnits(units)
521
532
  amounts = u.rescale(amounts, fac)
@@ -546,8 +557,10 @@ class Plan(object):
546
557
  Set value of social security for each individual and commencement age.
547
558
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
548
559
  """
549
- assert len(amounts) == self.N_i, f"Amounts must have {self.N_i} entries."
550
- assert len(ages) == self.N_i, f"Ages must have {self.N_i} entries."
560
+ if len(amounts) != self.N_i:
561
+ raise ValueError(f"Amounts must have {self.N_i} entries.")
562
+ if len(ages) != self.N_i:
563
+ raise ValueError(f"Ages must have {self.N_i} entries.")
551
564
 
552
565
  fac = u.getUnits(units)
553
566
  amounts = u.rescale(amounts, fac)
@@ -582,10 +595,14 @@ class Plan(object):
582
595
  as a second argument. Default value is 60%.
583
596
  Dip and increase are percent changes in the smile profile.
584
597
  """
585
- assert 0 <= percent and percent <= 100, f"Survivor value {percent} outside range."
586
- assert 0 <= dip and dip <= 100, f"Dip value {dip} outside range."
587
- assert -100 <= increase and increase <= 100, f"Increase value {increase} outside range."
588
- assert 0 <= delay and delay <= self.N_n - 2, f"Delay value {delay} outside year range."
598
+ if not (0 <= percent <= 100):
599
+ raise ValueError(f"Survivor value {percent} outside range.")
600
+ if not (0 <= dip <= 100):
601
+ raise ValueError(f"Dip value {dip} outside range.")
602
+ if not (-100 <= increase <= 100):
603
+ raise ValueError(f"Increase value {increase} outside range.")
604
+ if not (0 <= delay <= self.N_n - 2):
605
+ raise ValueError(f"Delay value {delay} outside year range.")
589
606
 
590
607
  self.chi = percent / 100
591
608
 
@@ -676,7 +693,8 @@ class Plan(object):
676
693
  raise RuntimeError("A rate method needs to be first selected using setRates(...).")
677
694
 
678
695
  thisyear = date.today().year
679
- assert year > thisyear, "Internal error in forwardValue()."
696
+ if year <= thisyear:
697
+ raise RuntimeError("Internal error in forwardValue().")
680
698
  span = year - thisyear
681
699
 
682
700
  return amount * self.gamma_n[span]
@@ -688,9 +706,12 @@ class Plan(object):
688
706
  one entry. Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
689
707
  """
690
708
  plurals = ["", "y", "ies"][self.N_i]
691
- assert len(taxable) == self.N_i, f"taxable must have {self.N_i} entr{plurals}."
692
- assert len(taxDeferred) == self.N_i, f"taxDeferred must have {self.N_i} entr{plurals}."
693
- assert len(taxFree) == self.N_i, f"taxFree must have {self.N_i} entr{plurals}."
709
+ if len(taxable) != self.N_i:
710
+ raise ValueError(f"taxable must have {self.N_i} entr{plurals}.")
711
+ if len(taxDeferred) != self.N_i:
712
+ raise ValueError(f"taxDeferred must have {self.N_i} entr{plurals}.")
713
+ if len(taxFree) != self.N_i:
714
+ raise ValueError(f"taxFree must have {self.N_i} entr{plurals}.")
694
715
 
695
716
  fac = u.getUnits(units)
696
717
  taxable = u.rescale(taxable, fac)
@@ -766,13 +787,17 @@ class Plan(object):
766
787
  if allocType == "account":
767
788
  # Make sure we have proper input.
768
789
  for item in [taxable, taxDeferred, taxFree]:
769
- assert len(item) == self.N_i, f"{item} must have one entry per individual."
790
+ if len(item) != self.N_i:
791
+ raise ValueError(f"{item} must have one entry per individual.")
770
792
  for i in range(self.N_i):
771
793
  # Initial and final.
772
- assert len(item[i]) == 2, f"{item}[{i}] must have 2 lists (initial and final)."
794
+ if len(item[i]) != 2:
795
+ raise ValueError(f"{item}[{i}] must have 2 lists (initial and final).")
773
796
  for z in range(2):
774
- assert len(item[i][z]) == self.N_k, f"{item}[{i}][{z}] must have {self.N_k} entries."
775
- assert abs(sum(item[i][z]) - 100) < 0.01, "Sum of percentages must add to 100."
797
+ if len(item[i][z]) != self.N_k:
798
+ raise ValueError(f"{item}[{i}][{z}] must have {self.N_k} entries.")
799
+ if abs(sum(item[i][z]) - 100) > 0.01:
800
+ raise ValueError("Sum of percentages must add to 100.")
776
801
 
777
802
  for i in range(self.N_i):
778
803
  self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
@@ -799,13 +824,17 @@ class Plan(object):
799
824
  self.boundsAR["tax-free"] = taxFree
800
825
 
801
826
  elif allocType == "individual":
802
- assert len(generic) == self.N_i, "generic must have one list per individual."
827
+ if len(generic) != self.N_i:
828
+ raise ValueError("generic must have one list per individual.")
803
829
  for i in range(self.N_i):
804
830
  # Initial and final.
805
- assert len(generic[i]) == 2, f"generic[{i}] must have 2 lists (initial and final)."
831
+ if len(generic[i]) != 2:
832
+ raise ValueError(f"generic[{i}] must have 2 lists (initial and final).")
806
833
  for z in range(2):
807
- assert len(generic[i][z]) == self.N_k, f"generic[{i}][{z}] must have {self.N_k} entries."
808
- assert abs(sum(generic[i][z]) - 100) < 0.01, "Sum of percentages must add to 100."
834
+ if len(generic[i][z]) != self.N_k:
835
+ raise ValueError(f"generic[{i}][{z}] must have {self.N_k} entries.")
836
+ if abs(sum(generic[i][z]) - 100) > 0.01:
837
+ raise ValueError("Sum of percentages must add to 100.")
809
838
 
810
839
  for i in range(self.N_i):
811
840
  self.mylog.vprint(f"{self.inames[i]}: Setting gliding allocation ratios (%) to {allocType}.")
@@ -817,30 +846,26 @@ class Plan(object):
817
846
  start = generic[i][0][k] / 100
818
847
  end = generic[i][1][k] / 100
819
848
  dat = self._interpolator(start, end, Nin)
820
- for j in range(self.N_j):
821
- self.alpha_ijkn[i, j, k, :Nin] = dat[:]
849
+ self.alpha_ijkn[i, :, k, :Nin] = dat[:]
822
850
 
823
851
  self.boundsAR["generic"] = generic
824
852
 
825
853
  elif allocType == "spouses":
826
- assert len(generic) == 2, "generic must have 2 entries (initial and final)."
854
+ if len(generic) != 2:
855
+ raise ValueError("generic must have 2 entries (initial and final).")
827
856
  for z in range(2):
828
- assert len(generic[z]) == self.N_k, f"generic[{z}] must have {self.N_k} entries."
829
- assert abs(sum(generic[z]) - 100) < 0.01, "Sum of percentages must add to 100."
830
-
831
- self.mylog.vprint(f"Setting gliding allocation ratios (%) to {allocType}.")
832
- self.mylog.vprint(f"\t{generic[0]} -> {generic[1]}")
833
-
834
- # Use longest-lived spouse for both time scales.
835
- Nxn = max(self.horizons) + 1
857
+ if len(generic[z]) != self.N_k:
858
+ raise ValueError(f"generic[{z}] must have {self.N_k} entries.")
859
+ if abs(sum(generic[z]) - 100) > 0.01:
860
+ raise ValueError("Sum of percentages must add to 100.")
836
861
 
837
- for k in range(self.N_k):
838
- start = generic[0][k] / 100
839
- end = generic[1][k] / 100
840
- dat = self._interpolator(start, end, Nxn)
841
- for i in range(self.N_i):
842
- for j in range(self.N_j):
843
- self.alpha_ijkn[i, j, k, :Nxn] = dat[:]
862
+ for i in range(self.N_i):
863
+ Nin = self.horizons[i] + 1
864
+ for k in range(self.N_k):
865
+ start = generic[0][k] / 100
866
+ end = generic[1][k] / 100
867
+ dat = self._interpolator(start, end, Nin)
868
+ self.alpha_ijkn[i, :, k, :Nin] = dat[:]
844
869
 
845
870
  self.boundsAR["generic"] = generic
846
871
 
@@ -873,8 +898,7 @@ class Plan(object):
873
898
  try:
874
899
  filename, self.timeLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
875
900
  except Exception as e:
876
- raise Exception(f"Unsuccessful read of contributions: {e}")
877
- return False
901
+ raise Exception(f"Unsuccessful read of contributions: {e}") from e
878
902
 
879
903
  self.timeListsFileName = filename
880
904
  self.setContributions()
@@ -971,9 +995,7 @@ class Plan(object):
971
995
  a linear interpolation.
972
996
  """
973
997
  # num goes one more year as endpoint=True.
974
- dat = np.linspace(a, b, numPoints)
975
-
976
- return dat
998
+ return np.linspace(a, b, numPoints)
977
999
 
978
1000
  def _tanhInterp(self, a, b, numPoints):
979
1001
  """
@@ -1051,259 +1073,217 @@ class Plan(object):
1051
1073
  def _buildConstraints(self, objective, options):
1052
1074
  """
1053
1075
  Utility function that builds constraint matrix and vectors.
1054
- Refer to companion document for notation and detailed explanations.
1055
- """
1056
- # Bounds values.
1057
- zero = 0
1058
- inf = np.inf
1076
+ Refactored for clarity and maintainability.
1077
+ """
1078
+ self._setup_constraint_shortcuts(options)
1079
+
1080
+ self.A = abc.ConstraintMatrix(self.nvars)
1081
+ self.B = abc.Bounds(self.nvars, self.nbins)
1082
+
1083
+ self._add_rmd_inequalities()
1084
+ self._add_tax_bracket_bounds()
1085
+ self._add_standard_exemption_bounds()
1086
+ self._add_defunct_constraints()
1087
+ self._add_roth_conversion_constraints(options)
1088
+ self._add_withdrawal_limits()
1089
+ self._add_conversion_limits()
1090
+ self._add_objective_constraints(objective, options)
1091
+ self._add_initial_balances()
1092
+ self._add_surplus_deposit_linking()
1093
+ self._add_account_balance_carryover()
1094
+ self._add_net_cash_flow()
1095
+ self._add_income_profile()
1096
+ self._add_taxable_income()
1097
+ self._configure_binary_variables(options)
1098
+ self._build_objective_vector(objective)
1059
1099
 
1060
- # Simplified notation.
1061
- Ni = self.N_i
1062
- Nj = self.N_j
1063
- Nk = self.N_k
1064
- Nn = self.N_n
1065
- Nt = self.N_t
1066
- Nz = self.N_z
1067
- i_d = self.i_d
1068
- i_s = self.i_s
1069
- n_d = self.n_d
1070
-
1071
- Cb = self.C["b"]
1072
- Cd = self.C["d"]
1073
- Ce = self.C["e"]
1074
- CF = self.C["F"]
1075
- Cg = self.C["g"]
1076
- Cs = self.C["s"]
1077
- Cw = self.C["w"]
1078
- Cx = self.C["x"]
1079
- Cz = self.C["z"]
1080
-
1081
- spLo = 1 - self.lambdha
1082
- spHi = 1 + self.lambdha
1100
+ return None
1083
1101
 
1102
+ def _setup_constraint_shortcuts(self, options):
1103
+ # Set up all the local variables as attributes for use in helpers
1084
1104
  oppCostX = options.get("oppCostX", 0.)
1085
- xnet = 1 - oppCostX/100.
1086
-
1087
- tau_ijn = np.zeros((Ni, Nj, Nn))
1088
- for i in range(Ni):
1089
- for j in range(Nj):
1090
- for n in range(Nn):
1091
- tau_ijn[i, j, n] = np.sum(self.alpha_ijkn[i, j, :, n] * self.tau_kn[:, n], axis=0)
1092
-
1093
- # Weights are normalized on k: sum_k[alpha*(1 + tau)] = 1 + sum_k(alpha*tau).
1094
- Tau1_ijn = 1 + tau_ijn
1095
- Tauh_ijn = 1 + tau_ijn / 2
1105
+ self.xnet = 1 - oppCostX / 100.
1106
+ self.optionsUnits = u.getUnits(options.get("units", "k"))
1096
1107
 
1097
- units = u.getUnits(options.get("units", "k"))
1098
- # No units for bigM.
1099
- bigM = options.get("bigM", 5e6)
1100
- assert isinstance(bigM, (int, float)), f"bigM {bigM} is not a number."
1101
-
1102
- ###################################################################
1103
- # Inequality constraint matrix with upper and lower bound vectors.
1104
- A = abc.ConstraintMatrix(self.nvars)
1105
- B = abc.Bounds(self.nvars, self.nbins)
1106
-
1107
- # RMDs inequalities, only if there is an initial balance in tax-deferred account.
1108
- for i in range(Ni):
1108
+ def _add_rmd_inequalities(self):
1109
+ for i in range(self.N_i):
1109
1110
  if self.beta_ij[i, 1] > 0:
1110
1111
  for n in range(self.horizons[i]):
1111
1112
  rowDic = {
1112
- _q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
1113
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
1113
+ _q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): 1,
1114
+ _q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): -self.rho_in[i, n],
1114
1115
  }
1115
- A.addNewRow(rowDic, zero, inf)
1116
+ self.A.addNewRow(rowDic, 0, np.inf)
1116
1117
 
1117
- # Income tax bracket range inequalities.
1118
- for t in range(Nt):
1119
- for n in range(Nn):
1120
- B.setRange(_q2(CF, t, n, Nt, Nn), zero, self.DeltaBar_tn[t, n])
1118
+ def _add_tax_bracket_bounds(self):
1119
+ for t in range(self.N_t):
1120
+ for n in range(self.N_n):
1121
+ self.B.setRange(_q2(self.C["F"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
1121
1122
 
1122
- # Standard exemption range inequalities.
1123
- for n in range(Nn):
1124
- B.setRange(_q1(Ce, n, Nn), zero, self.sigmaBar_n[n])
1123
+ def _add_standard_exemption_bounds(self):
1124
+ for n in range(self.N_n):
1125
+ self.B.setRange(_q1(self.C["e"], n, self.N_n), 0, self.sigmaBar_n[n])
1125
1126
 
1126
- # Start with no activities after passing.
1127
- for i in range(Ni):
1128
- for n in range(self.horizons[i], Nn):
1129
- B.setRange(_q2(Cd, i, n, Ni, Nn), zero, zero)
1130
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1131
- for j in range(Nj):
1132
- B.setRange(_q3(Cw, i, j, n, Ni, Nj, Nn), zero, zero)
1133
-
1134
- # Roth conversions equalities/inequalities.
1135
- # This condition supercedes everything else.
1127
+ def _add_defunct_constraints(self):
1128
+ if self.N_i == 2:
1129
+ for n in range(self.n_d, self.N_n):
1130
+ self.B.setRange(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n), 0, 0)
1131
+ self.B.setRange(_q2(self.C["x"], self.i_d, n, self.N_i, self.N_n), 0, 0)
1132
+ for j in range(self.N_j):
1133
+ self.B.setRange(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n), 0, 0)
1134
+
1135
+ def _add_roth_conversion_constraints(self, options):
1136
1136
  if "maxRothConversion" in options and options["maxRothConversion"] == "file":
1137
- # self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
1138
- for i in range(Ni):
1137
+ for i in range(self.N_i):
1139
1138
  for n in range(self.horizons[i]):
1140
1139
  rhs = self.myRothX_in[i][n]
1141
- B.setRange(_q2(Cx, i, n, Ni, Nn), rhs, rhs)
1140
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), rhs, rhs)
1142
1141
  else:
1143
1142
  if "maxRothConversion" in options:
1144
1143
  rhsopt = options["maxRothConversion"]
1145
- assert isinstance(rhsopt, (int, float)), "Specified maxRothConversion is not a number."
1146
- rhsopt *= units
1147
- if rhsopt < 0:
1148
- # self.mylog.vprint('Unlimited Roth conversions (<0)')
1149
- pass
1150
- else:
1151
- # self.mylog.vprint('Limiting Roth conversions to:', u.d(rhsopt))
1152
- for i in range(Ni):
1144
+ if not isinstance(rhsopt, (int, float)):
1145
+ raise ValueError(f"Specified maxRothConversion {rhsopt} is not a number.")
1146
+
1147
+ if rhsopt >= 0:
1148
+ rhsopt *= self.optionsUnits
1149
+ for i in range(self.N_i):
1153
1150
  for n in range(self.horizons[i]):
1154
- # MOSEK chokes if completely zero. Add a 1 cent slack.
1155
- # Should we adjust Roth conversion cap with inflation?
1156
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, rhsopt + 0.01)
1151
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, rhsopt + 0.01)
1157
1152
 
1158
- # Process startRothConversions option.
1159
1153
  if "startRothConversions" in options:
1160
1154
  rhsopt = options["startRothConversions"]
1161
- assert isinstance(rhsopt, (int, float)), "Specified startRothConversions is not a number."
1155
+ if not isinstance(rhsopt, (int, float)):
1156
+ raise ValueError(f"Specified startRothConversions {rhsopt} is not a number.")
1162
1157
  thisyear = date.today().year
1163
1158
  yearn = max(rhsopt - thisyear, 0)
1164
-
1165
- for i in range(Ni):
1159
+ for i in range(self.N_i):
1166
1160
  nstart = min(yearn, self.horizons[i])
1167
1161
  for n in range(0, nstart):
1168
- B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1162
+ self.B.setRange(_q2(self.C["x"], i, n, self.N_i, self.N_n), 0, 0)
1169
1163
 
1170
- # Process noRothConversions option. Also valid when N_i == 1, why not?
1171
1164
  if "noRothConversions" in options and options["noRothConversions"] != "None":
1172
1165
  rhsopt = options["noRothConversions"]
1173
1166
  try:
1174
1167
  i_x = self.inames.index(rhsopt)
1175
- except ValueError:
1176
- raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:")
1177
-
1178
- for n in range(Nn):
1179
- B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
1168
+ except ValueError as e:
1169
+ raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:") from e
1170
+ for n in range(self.N_n):
1171
+ self.B.setRange(_q2(self.C["x"], i_x, n, self.N_i, self.N_n), 0, 0)
1180
1172
 
1181
- # Impose withdrawal limits on taxable and tax-exempt accounts.
1182
- for i in range(Ni):
1173
+ def _add_withdrawal_limits(self):
1174
+ for i in range(self.N_i):
1183
1175
  for j in [0, 2]:
1184
- for n in range(Nn):
1185
- rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
1186
- A.addNewRow(rowDic, zero, inf)
1176
+ for n in range(self.N_n):
1177
+ rowDic = {_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n): -1,
1178
+ _q3(self.C["b"], i, j, n, self.N_i, self.N_j, self.N_n + 1): 1}
1179
+ self.A.addNewRow(rowDic, 0, np.inf)
1187
1180
 
1188
- # Impose withdrawals and conversion limits on tax-deferred account.
1189
- for i in range(Ni):
1190
- for n in range(Nn):
1181
+ def _add_conversion_limits(self):
1182
+ for i in range(self.N_i):
1183
+ for n in range(self.N_n):
1191
1184
  rowDic = {
1192
- _q2(Cx, i, n, Ni, Nn): -1,
1193
- _q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
1194
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
1185
+ _q2(self.C["x"], i, n, self.N_i, self.N_n): -1,
1186
+ _q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n): -1,
1187
+ _q3(self.C["b"], i, 1, n, self.N_i, self.N_j, self.N_n + 1): 1,
1195
1188
  }
1196
- A.addNewRow(rowDic, zero, inf)
1189
+ self.A.addNewRow(rowDic, 0, np.inf)
1197
1190
 
1198
- # Constraints depending on objective function.
1191
+ def _add_objective_constraints(self, objective, options):
1199
1192
  if objective == "maxSpending":
1200
- # Impose optional constraint on final bequest requested in today's $.
1201
1193
  if "bequest" in options:
1202
1194
  bequest = options["bequest"]
1203
- assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
1204
- bequest *= units * self.gamma_n[-1]
1195
+ if not isinstance(bequest, (int, float)):
1196
+ raise ValueError(f"Desired bequest {bequest} is not a number.")
1197
+ bequest *= self.optionsUnits * self.gamma_n[-1]
1205
1198
  else:
1206
- # If not specified, defaults to $1 (nominal $).
1207
1199
  bequest = 1
1208
1200
 
1209
- row = A.newRow()
1210
- for i in range(Ni):
1211
- row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
1212
- row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
1213
- # Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
1214
- # as heirs's benefits of 10y tax-free is not weighted in?
1215
- row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
1216
- A.addRow(row, bequest, bequest)
1217
- # self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
1201
+ row = self.A.newRow()
1202
+ for i in range(self.N_i):
1203
+ row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1204
+ row.addElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1 - self.nu)
1205
+ row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1206
+ self.A.addRow(row, bequest, bequest)
1218
1207
  elif objective == "maxBequest":
1219
1208
  spending = options["netSpending"]
1220
- assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
1221
- # Account for time elapsed in the current year.
1222
- spending *= units * self.yearFracLeft
1223
- # self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
1224
- # To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
1225
- # A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
1226
- B.setRange(_q1(Cg, 0, Nn), spending, spending)
1227
-
1228
- # Set initial balances through bounds or constraints.
1229
- for i in range(Ni):
1230
- for j in range(Nj):
1209
+ if not isinstance(spending, (int, float)):
1210
+ raise ValueError(f"Desired spending provided {spending} is not a number.")
1211
+ spending *= self.optionsUnits * self.yearFracLeft
1212
+ self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
1213
+
1214
+ def _add_initial_balances(self):
1215
+ for i in range(self.N_i):
1216
+ for j in range(self.N_j):
1231
1217
  rhs = self.beta_ij[i, j]
1232
- # A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
1233
- B.setRange(_q3(Cb, i, j, 0, Ni, Nj, Nn + 1), rhs, rhs)
1218
+ self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
1234
1219
 
1235
- # Link surplus and taxable account deposits regardless of Ni.
1236
- for i in range(Ni):
1220
+ def _add_surplus_deposit_linking(self):
1221
+ for i in range(self.N_i):
1237
1222
  fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
1238
- for n in range(n_d):
1239
- rowDic = {_q2(Cd, i, n, Ni, Nn): 1, _q1(Cs, n, Nn): -fac1}
1240
- A.addNewRow(rowDic, zero, zero)
1223
+ for n in range(self.n_d):
1224
+ rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac1}
1225
+ self.A.addNewRow(rowDic, 0, 0)
1241
1226
  fac2 = u.krond(self.i_s, i)
1242
- for n in range(n_d, Nn):
1243
- rowDic = {_q2(Cd, i, n, Ni, Nn): 1, _q1(Cs, n, Nn): -fac2}
1244
- A.addNewRow(rowDic, zero, zero)
1245
-
1246
- # No surplus allowed during the last year to be used as a tax loophole.
1247
- B.setRange(_q1(Cs, Nn - 1, Nn), zero, zero)
1248
-
1249
- if Ni == 2:
1250
- # No conversion during last year.
1251
- # B.setRange(_q2(Cx, i_d, nd-1, Ni, Nn), zero, zero)
1252
- # B.setRange(_q2(Cx, i_s, Nn-1, Ni, Nn), zero, zero)
1253
-
1254
- # No withdrawals or deposits for any i_d-owned accounts after year of passing.
1255
- # Implicit n_d < Nn imposed by for loop.
1256
- for n in range(n_d, Nn):
1257
- B.setRange(_q2(Cd, i_d, n, Ni, Nn), zero, zero)
1258
- B.setRange(_q2(Cx, i_d, n, Ni, Nn), zero, zero)
1259
- for j in range(Nj):
1260
- B.setRange(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero, zero)
1261
-
1262
- # Account balances carried from year to year.
1263
- # Considering spousal asset transfer at passing of a spouse.
1264
- # Using hybrid approach with 'if' statement and Kronecker deltas.
1265
- for i in range(Ni):
1266
- for j in range(Nj):
1267
- for n in range(Nn):
1268
- if Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1:
1269
- # fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
1227
+ for n in range(self.n_d, self.N_n):
1228
+ rowDic = {_q2(self.C["d"], i, n, self.N_i, self.N_n): 1, _q1(self.C["s"], n, self.N_n): -fac2}
1229
+ self.A.addNewRow(rowDic, 0, 0)
1230
+ # Prevent surplus on last year.
1231
+ self.B.setRange(_q1(self.C["s"], self.N_n - 1, self.N_n), 0, 0)
1232
+
1233
+ def _add_account_balance_carryover(self):
1234
+ tau_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
1235
+ for i in range(self.N_i):
1236
+ for j in range(self.N_j):
1237
+ for n in range(self.N_n):
1238
+ tau_ijn[i, j, n] = np.sum(self.alpha_ijkn[i, j, :, n] * self.tau_kn[:, n], axis=0)
1239
+
1240
+ # Weights are normalized on k: sum_k[alpha*(1 + tau)] = 1 + sum_k[alpha*tau]
1241
+ Tau1_ijn = 1 + tau_ijn
1242
+ Tauh_ijn = 1 + tau_ijn / 2
1243
+
1244
+ for i in range(self.N_i):
1245
+ for j in range(self.N_j):
1246
+ for n in range(self.N_n):
1247
+ if self.N_i == 2 and self.n_d < self.N_n and i == self.i_d and n == self.n_d - 1:
1270
1248
  fac1 = 0
1271
1249
  else:
1272
1250
  fac1 = 1
1273
1251
 
1274
1252
  rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
1275
1253
 
1276
- row = A.newRow()
1277
- row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
1278
- row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -fac1 * Tau1_ijn[i, j, n])
1279
- row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1 * Tau1_ijn[i, j, n])
1280
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
1254
+ row = self.A.newRow()
1255
+ row.addElem(_q3(self.C["b"], i, j, n + 1, self.N_i, self.N_j, self.N_n + 1), 1)
1256
+ row.addElem(_q3(self.C["b"], i, j, n, self.N_i, self.N_j, self.N_n + 1), -fac1 * Tau1_ijn[i, j, n])
1257
+ row.addElem(_q3(self.C["w"], i, j, n, self.N_i, self.N_j, self.N_n), fac1 * Tau1_ijn[i, j, n])
1258
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
1281
1259
  row.addElem(
1282
- _q2(Cx, i, n, Ni, Nn),
1283
- -fac1 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
1260
+ _q2(self.C["x"], i, n, self.N_i, self.N_n),
1261
+ -fac1 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
1284
1262
  )
1285
1263
 
1286
- if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
1264
+ if self.N_i == 2 and self.n_d < self.N_n and i == self.i_s and n == self.n_d - 1:
1287
1265
  fac2 = self.phi_j[j]
1288
- rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
1289
- row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2 * Tau1_ijn[i_d, j, n])
1290
- row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2 * Tau1_ijn[i_d, j, n])
1291
- row.addElem(_q2(Cd, i_d, n, Ni, Nn), -fac2 * u.krond(j, 0) * Tau1_ijn[i_d, 0, n])
1266
+ rhs += fac2 * self.kappa_ijn[self.i_d, j, n] * Tauh_ijn[self.i_d, j, n]
1267
+ row.addElem(_q3(self.C["b"], self.i_d, j, n, self.N_i, self.N_j, self.N_n + 1),
1268
+ -fac2 * Tau1_ijn[self.i_d, j, n])
1269
+ row.addElem(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n),
1270
+ fac2 * Tau1_ijn[self.i_d, j, n])
1271
+ row.addElem(_q2(self.C["d"], self.i_d, n, self.N_i, self.N_n),
1272
+ -fac2 * u.krond(j, 0) * Tau1_ijn[self.i_d, 0, n])
1292
1273
  row.addElem(
1293
- _q2(Cx, i_d, n, Ni, Nn),
1294
- -fac2 * (xnet*u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i_d, j, n],
1274
+ _q2(self.C["x"], self.i_d, n, self.N_i, self.N_n),
1275
+ -fac2 * (self.xnet * u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[self.i_d, j, n],
1295
1276
  )
1296
- A.addRow(row, rhs, rhs)
1277
+ self.A.addRow(row, rhs, rhs)
1297
1278
 
1279
+ def _add_net_cash_flow(self):
1298
1280
  tau_0prev = np.roll(self.tau_kn[0, :], 1)
1299
1281
  tau_0prev[tau_0prev < 0] = 0
1300
-
1301
- # Net cash flow.
1302
- for n in range(Nn):
1282
+ for n in range(self.N_n):
1303
1283
  rhs = -self.M_n[n]
1304
- row = A.newRow({_q1(Cg, n, Nn): 1})
1305
- row.addElem(_q1(Cs, n, Nn), 1)
1306
- for i in range(Ni):
1284
+ row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1285
+ row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1286
+ for i in range(self.N_i):
1307
1287
  fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
1308
1288
  rhs += (
1309
1289
  self.omega_in[i, n]
@@ -1312,112 +1292,99 @@ class Plan(object):
1312
1292
  + self.Lambda_in[i, n]
1313
1293
  - 0.5 * fac * self.mu * self.kappa_ijn[i, 0, n]
1314
1294
  )
1315
-
1316
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), fac * self.mu)
1317
- # Minus capital gains on taxable withdrawals using last year's rate if >=0.
1318
- # Plus taxable account withdrawals, and all other withdrawals.
1319
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fac * (tau_0prev[n] - self.mu) - 1)
1295
+ row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), fac * self.mu)
1296
+ row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fac * (tau_0prev[n] - self.mu) - 1)
1320
1297
  penalty = 0.1 if n < self.n59[i] else 0
1321
- row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
1322
- row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
1323
- row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
1324
-
1325
- # Minus tax on ordinary income, T_n.
1326
- for t in range(Nt):
1327
- row.addElem(_q2(CF, t, n, Nt, Nn), self.theta_tn[t, n])
1298
+ row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
1299
+ row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1 + penalty)
1300
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
1328
1301
 
1329
- A.addRow(row, rhs, rhs)
1302
+ for t in range(self.N_t):
1303
+ row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
1330
1304
 
1331
- # Impose income profile.
1332
- for n in range(1, Nn):
1333
- rowDic = {_q1(Cg, 0, Nn): spLo * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1334
- A.addNewRow(rowDic, -inf, zero)
1335
- rowDic = {_q1(Cg, 0, Nn): spHi * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1336
- A.addNewRow(rowDic, zero, inf)
1305
+ self.A.addRow(row, rhs, rhs)
1337
1306
 
1338
- # Taxable ordinary income.
1339
- for n in range(Nn):
1307
+ def _add_income_profile(self):
1308
+ spLo = 1 - self.lambdha
1309
+ spHi = 1 + self.lambdha
1310
+ for n in range(1, self.N_n):
1311
+ rowDic = {_q1(self.C["g"], 0, self.N_n): spLo * self.xiBar_n[n],
1312
+ _q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
1313
+ self.A.addNewRow(rowDic, -np.inf, 0)
1314
+ rowDic = {_q1(self.C["g"], 0, self.N_n): spHi * self.xiBar_n[n],
1315
+ _q1(self.C["g"], n, self.N_n): -self.xiBar_n[0]}
1316
+ self.A.addNewRow(rowDic, 0, np.inf)
1317
+
1318
+ def _add_taxable_income(self):
1319
+ for n in range(self.N_n):
1340
1320
  rhs = 0
1341
- row = A.newRow()
1342
- row.addElem(_q1(Ce, n, Nn), 1)
1343
- for i in range(Ni):
1321
+ row = self.A.newRow()
1322
+ row.addElem(_q1(self.C["e"], n, self.N_n), 1)
1323
+ for i in range(self.N_i):
1344
1324
  rhs += self.omega_in[i, n] + 0.85 * self.zetaBar_in[i, n] + self.piBar_in[i, n]
1345
- # Taxable income from tax-deferred withdrawals.
1346
- row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1)
1347
- row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
1348
-
1349
- # Taxable returns on securities in taxable account.
1350
- fak = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
1325
+ row.addElem(_q3(self.C["w"], i, 1, n, self.N_i, self.N_j, self.N_n), -1)
1326
+ row.addElem(_q2(self.C["x"], i, n, self.N_i, self.N_n), -1)
1327
+ fak = np.sum(self.tau_kn[1:self.N_k, n] * self.alpha_ijkn[i, 0, 1:self.N_k, n], axis=0)
1351
1328
  rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
1352
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak)
1353
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak)
1354
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fak)
1355
-
1356
- for t in range(Nt):
1357
- row.addElem(_q2(CF, t, n, Nt, Nn), 1)
1358
-
1359
- A.addRow(row, rhs, rhs)
1329
+ row.addElem(_q3(self.C["b"], i, 0, n, self.N_i, self.N_j, self.N_n + 1), -fak)
1330
+ row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
1331
+ row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
1332
+ for t in range(self.N_t):
1333
+ row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), 1)
1334
+ self.A.addRow(row, rhs, rhs)
1335
+
1336
+ def _configure_binary_variables(self, options):
1337
+ bigM = options.get("bigM", 5e6)
1338
+ if not isinstance(bigM, (int, float)):
1339
+ raise ValueError(f"bigM {bigM} is not a number.")
1360
1340
 
1361
- # Configure binary variables.
1362
- for i in range(Ni):
1341
+ for i in range(self.N_i):
1363
1342
  for n in range(self.horizons[i]):
1364
- # for z in range(Nz):
1365
- # B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
1366
-
1367
- # Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
1368
- A.addNewRow(
1369
- {_q3(Cz, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
1370
- zero,
1343
+ self.A.addNewRow(
1344
+ {_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1345
+ _q1(self.C["s"], n, self.N_n): -1},
1346
+ 0,
1371
1347
  bigM,
1372
1348
  )
1373
-
1374
- A.addNewRow(
1349
+ self.A.addNewRow(
1375
1350
  {
1376
- _q3(Cz, i, n, 0, Ni, Nn, Nz): bigM,
1377
- _q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
1378
- _q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
1351
+ _q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1352
+ _q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
1353
+ _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
1379
1354
  },
1380
- zero,
1355
+ 0,
1381
1356
  bigM,
1382
1357
  )
1383
-
1384
- # Exclude simultaneous Roth conversions and tax-exempt withdrawals.
1385
- A.addNewRow(
1386
- {_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
1387
- zero,
1358
+ self.A.addNewRow(
1359
+ {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1360
+ _q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
1361
+ 0,
1388
1362
  bigM,
1389
1363
  )
1390
-
1391
- A.addNewRow(
1392
- {_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
1393
- zero,
1364
+ self.A.addNewRow(
1365
+ {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1366
+ _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
1367
+ 0,
1394
1368
  bigM,
1395
1369
  )
1370
+ for n in range(self.horizons[i], self.N_n):
1371
+ self.B.setRange(_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
1372
+ self.B.setRange(_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z), 0, 0)
1396
1373
 
1397
- for n in range(self.horizons[i], Nn):
1398
- B.setRange(_q3(Cz, i, n, 0, Ni, Nn, Nz), zero, zero)
1399
- B.setRange(_q3(Cz, i, n, 1, Ni, Nn, Nz), zero, zero)
1400
-
1401
- # Now build a solver-neutral objective vector.
1374
+ def _build_objective_vector(self, objective):
1402
1375
  c = abc.Objective(self.nvars)
1403
1376
  if objective == "maxSpending":
1404
- # c.setElem(_q1(Cg, 0, Nn), -1) # Only OK in implemention without slack.
1405
- for n in range(Nn):
1406
- c.setElem(_q1(Cg, n, Nn), -1/self.gamma_n[n])
1377
+ for n in range(self.N_n):
1378
+ c.setElem(_q1(self.C["g"], n, self.N_n), -1/self.gamma_n[n])
1407
1379
  elif objective == "maxBequest":
1408
- for i in range(Ni):
1409
- c.setElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), -1)
1410
- c.setElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), -(1 - self.nu))
1411
- c.setElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), -1)
1380
+ for i in range(self.N_i):
1381
+ c.setElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1382
+ c.setElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), -(1 - self.nu))
1383
+ c.setElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), -1)
1412
1384
  else:
1413
1385
  raise RuntimeError("Internal error in objective function.")
1414
-
1415
- self.A = A
1416
- self.B = B
1417
1386
  self.c = c
1418
1387
 
1419
- return None
1420
-
1421
1388
  @_timer
1422
1389
  def runHistoricalRange(self, objective, options, ystart, yend, *, verbose=False, figure=False, progcall=None):
1423
1390
  """
@@ -1461,10 +1428,11 @@ class Plan(object):
1461
1428
 
1462
1429
  progcall.finish()
1463
1430
  self.mylog.resetVerbose()
1464
- fig, description = self._showResults(objective, df, N, figure)
1465
- self.mylog.print(description.getvalue())
1466
1431
 
1467
1432
  if figure:
1433
+ fig, description = plots.show_histogram_results(objective, df, N, self.year_n,
1434
+ self.n_d, self.N_i, self.phi_j)
1435
+ self.mylog.print(description.getvalue())
1468
1436
  return fig, description.getvalue()
1469
1437
 
1470
1438
  return N, df
@@ -1517,98 +1485,15 @@ class Plan(object):
1517
1485
 
1518
1486
  progcall.finish()
1519
1487
  self.mylog.resetVerbose()
1520
- fig, description = self._showResults(objective, df, N, figure)
1521
- self.mylog.print(description.getvalue())
1522
1488
 
1523
1489
  if figure:
1490
+ fig, description = plots.show_histogram_results(objective, df, N, self.year_n,
1491
+ self.n_d, self.N_i, self.phi_j)
1492
+ self.mylog.print(description.getvalue())
1524
1493
  return fig, description.getvalue()
1525
1494
 
1526
1495
  return N, df
1527
1496
 
1528
- def _showResults(self, objective, df, N, figure):
1529
- """
1530
- Show a histogram of values from runMC() and runHistoricalRange().
1531
- """
1532
- import seaborn as sbn
1533
-
1534
- description = io.StringIO()
1535
-
1536
- pSuccess = u.pc(len(df) / N)
1537
- print(f"Success rate: {pSuccess} on {N} samples.", file=description)
1538
- title = f"$N$ = {N}, $P$ = {pSuccess}"
1539
- means = df.mean(axis=0, numeric_only=True)
1540
- medians = df.median(axis=0, numeric_only=True)
1541
-
1542
- my = 2 * [self.year_n[-1]]
1543
- if self.N_i == 2 and self.n_d < self.N_n:
1544
- my[0] = self.year_n[self.n_d - 1]
1545
-
1546
- # Don't show partial bequest of zero if spouse is full beneficiary,
1547
- # or if solution led to empty accounts at the end of first spouse's life.
1548
- if np.all(self.phi_j == 1) or medians.iloc[0] < 1:
1549
- if medians.iloc[0] < 1:
1550
- print(f"Optimized solutions all have null partial bequest in year {my[0]}.", file=description)
1551
- df.drop("partial", axis=1, inplace=True)
1552
- means = df.mean(axis=0, numeric_only=True)
1553
- medians = df.median(axis=0, numeric_only=True)
1554
-
1555
- df /= 1000
1556
- if len(df) > 0:
1557
- thisyear = self.year_n[0]
1558
- if objective == "maxBequest":
1559
- fig, axes = plt.subplots()
1560
- # Show both partial and final bequests in the same histogram.
1561
- sbn.histplot(df, multiple="dodge", kde=True, ax=axes)
1562
- legend = []
1563
- # Don't know why but legend is reversed from df.
1564
- for q in range(len(means) - 1, -1, -1):
1565
- dmedian = u.d(medians.iloc[q], latex=True)
1566
- dmean = u.d(means.iloc[q], latex=True)
1567
- legend.append(f"{my[q]}: $M$: {dmedian}, $\\bar{{x}}$: {dmean}")
1568
- plt.legend(legend, shadow=True)
1569
- plt.xlabel(f"{thisyear} $k")
1570
- plt.title(objective)
1571
- leads = [f"partial {my[0]}", f" final {my[1]}"]
1572
- elif len(means) == 2:
1573
- # Show partial bequest and net spending as two separate histograms.
1574
- fig, axes = plt.subplots(1, 2, figsize=(10, 5))
1575
- cols = ["partial", objective]
1576
- leads = [f"partial {my[0]}", objective]
1577
- for q in range(2):
1578
- sbn.histplot(df[cols[q]], kde=True, ax=axes[q])
1579
- dmedian = u.d(medians.iloc[q], latex=True)
1580
- dmean = u.d(means.iloc[q], latex=True)
1581
- legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
1582
- axes[q].set_label(legend)
1583
- axes[q].legend(labels=legend)
1584
- axes[q].set_title(leads[q])
1585
- axes[q].set_xlabel(f"{thisyear} $k")
1586
- else:
1587
- # Show net spending as single histogram.
1588
- fig, axes = plt.subplots()
1589
- sbn.histplot(df[objective], kde=True, ax=axes)
1590
- dmedian = u.d(medians.iloc[0], latex=True)
1591
- dmean = u.d(means.iloc[0], latex=True)
1592
- legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
1593
- plt.legend(legend, shadow=True)
1594
- plt.xlabel(f"{thisyear} $k")
1595
- plt.title(objective)
1596
- leads = [objective]
1597
-
1598
- plt.suptitle(title)
1599
- # plt.show()
1600
-
1601
- for q in range(len(means)):
1602
- print(f"{leads[q]:>12}: Median ({thisyear} $): {u.d(medians.iloc[q])}", file=description)
1603
- print(f"{leads[q]:>12}: Mean ({thisyear} $): {u.d(means.iloc[q])}", file=description)
1604
- mmin = 1000 * df.iloc[:, q].min()
1605
- mmax = 1000 * df.iloc[:, q].max()
1606
- print(f"{leads[q]:>12}: Range: {u.d(mmin)} - {u.d(mmax)}", file=description)
1607
- nzeros = len(df.iloc[:, q][df.iloc[:, q] < 0.001])
1608
- print(f"{leads[q]:>12}: N zero solns: {nzeros}", file=description)
1609
-
1610
- return fig, description
1611
-
1612
1497
  def resolve(self):
1613
1498
  """
1614
1499
  Solve a plan using saved options.
@@ -1619,7 +1504,7 @@ class Plan(object):
1619
1504
 
1620
1505
  @_checkConfiguration
1621
1506
  @_timer
1622
- def solve(self, objective, options={}):
1507
+ def solve(self, objective, options=None):
1623
1508
  """
1624
1509
  This function builds the necessary constaints and
1625
1510
  runs the optimizer.
@@ -1661,6 +1546,7 @@ class Plan(object):
1661
1546
  "oppCostX",
1662
1547
  ]
1663
1548
  # We might modify options if required.
1549
+ options = {} if options is None else options
1664
1550
  myoptions = dict(options)
1665
1551
 
1666
1552
  for opt in myoptions:
@@ -2260,56 +2146,15 @@ class Plan(object):
2260
2146
 
2261
2147
  A tag string can be set to add information to the title of the plot.
2262
2148
  """
2263
- import seaborn as sbn
2264
-
2265
2149
  if self.rateMethod in [None, "user", "historical average", "conservative"]:
2266
2150
  self.mylog.vprint(f"Warning: Cannot plot correlations for {self.rateMethod} rate method.")
2267
2151
  return None
2268
2152
 
2269
- rateNames = [
2270
- "S&P500 (incl. div.)",
2271
- "Baa Corp. Bonds",
2272
- "10-y T-Notes",
2273
- "Inflation",
2274
- ]
2275
-
2276
- df = pd.DataFrame()
2277
- for k, name in enumerate(rateNames):
2278
- data = 100 * self.tau_kn[k]
2279
- df[name] = data
2280
-
2281
- g = sbn.PairGrid(df, diag_sharey=False, height=1.8, aspect=1)
2282
- if shareRange:
2283
- minval = df.min().min() - 5
2284
- maxval = df.max().max() + 5
2285
- g.set(xlim=(minval, maxval), ylim=(minval, maxval))
2286
- g.map_upper(sbn.scatterplot)
2287
- g.map_lower(sbn.kdeplot)
2288
- # g.map_diag(sbn.kdeplot)
2289
- g.map_diag(sbn.histplot, color="orange")
2290
-
2291
- # Put zero axes on off-diagonal plots.
2292
- imod = len(rateNames) + 1
2293
- for i, ax in enumerate(g.axes.flat):
2294
- ax.axvline(x=0, color="grey", linewidth=1, linestyle=":")
2295
- if i % imod != 0:
2296
- ax.axhline(y=0, color="grey", linewidth=1, linestyle=":")
2297
- # ax.tick_params(axis='both', labelleft=True, labelbottom=True)
2298
-
2299
- # plt.subplots_adjust(wspace=0.3, hspace=0.3)
2300
-
2301
- title = self._name + "\n"
2302
- title += f"Rates Correlations (N={self.N_n}) {self.rateMethod}"
2303
- if self.rateMethod in ["historical", "histochastic"]:
2304
- title += " (" + str(self.rateFrm) + "-" + str(self.rateTo) + ")"
2305
-
2306
- if tag != "":
2307
- title += " - " + tag
2308
-
2309
- g.fig.suptitle(title, y=1.08)
2153
+ fig = plots.show_rates_correlations(self._name, self.tau_kn, self.N_n, self.rateMethod,
2154
+ self.rateFrm, self.rateTo, tag, shareRange)
2310
2155
 
2311
2156
  if figure:
2312
- return g.fig
2157
+ return fig
2313
2158
 
2314
2159
  plt.show()
2315
2160
  return None
@@ -2320,49 +2165,12 @@ class Plan(object):
2320
2165
 
2321
2166
  A tag string can be set to add information to the title of the plot.
2322
2167
  """
2323
- import matplotlib.ticker as tk
2324
-
2325
2168
  if self.rateMethod is None:
2326
2169
  self.mylog.vprint("Warning: Rate method must be selected before plotting.")
2327
2170
  return None
2328
2171
 
2329
- fig, ax = plt.subplots(figsize=(6, 4))
2330
- plt.grid(visible="both")
2331
- title = self._name + "\nReturn & Inflation Rates (" + str(self.rateMethod)
2332
- if self.rateMethod in ["historical", "histochastic", "historical average"]:
2333
- title += " " + str(self.rateFrm) + "-" + str(self.rateTo)
2334
- title += ")"
2335
-
2336
- if tag != "":
2337
- title += " - " + tag
2338
-
2339
- rateName = [
2340
- "S&P500 (incl. div.)",
2341
- "Baa Corp. Bonds",
2342
- "10-y T-Notes",
2343
- "Inflation",
2344
- ]
2345
- ltype = ["-", "-.", ":", "--"]
2346
- for k in range(self.N_k):
2347
- if self.yearFracLeft == 1:
2348
- data = 100 * self.tau_kn[k]
2349
- years = self.year_n
2350
- else:
2351
- data = 100 * self.tau_kn[k, 1:]
2352
- years = self.year_n[1:]
2353
-
2354
- # Use ddof=1 to match pandas.
2355
- label = (
2356
- rateName[k] + " <" + "{:.1f}".format(np.mean(data)) + " +/- {:.1f}".format(np.std(data, ddof=1)) + "%>"
2357
- )
2358
- ax.plot(years, data, label=label, ls=ltype[k % self.N_k])
2359
-
2360
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
2361
- ax.legend(loc="best", reverse=False, fontsize=8, framealpha=0.7)
2362
- # ax.legend(loc='upper left')
2363
- ax.set_title(title)
2364
- ax.set_xlabel("year")
2365
- ax.set_ylabel("%")
2172
+ fig = plots.show_rates(self._name, self.tau_kn, self.year_n, self.yearFracLeft,
2173
+ self.N_k, self.rateMethod, self.rateFrm, self.rateTo, tag)
2366
2174
 
2367
2175
  if figure:
2368
2176
  return fig
@@ -2384,10 +2192,9 @@ class Plan(object):
2384
2192
  if tag != "":
2385
2193
  title += " - " + tag
2386
2194
 
2387
- # style = {'net': '-', 'target': ':'}
2388
2195
  style = {"profile": "-"}
2389
2196
  series = {"profile": self.xi_n}
2390
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat="$\\xi$")
2197
+ fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="$\\xi$")
2391
2198
 
2392
2199
  if figure:
2393
2200
  return fig
@@ -2422,7 +2229,7 @@ class Plan(object):
2422
2229
  }
2423
2230
  yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2424
2231
 
2425
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2232
+ fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
2426
2233
 
2427
2234
  if figure:
2428
2235
  return fig
@@ -2471,9 +2278,8 @@ class Plan(object):
2471
2278
  if tag != "":
2472
2279
  title += " - " + tag
2473
2280
 
2474
- fig, ax = _stackPlot(
2475
- years_n, self.inames, title, range(self.N_i), y2stack, stackNames, "upper left", yformat
2476
- )
2281
+ fig, ax = plots.stack_plot(years_n, self.inames, title, range(self.N_i),
2282
+ y2stack, stackNames, "upper left", yformat)
2477
2283
  figures.append(fig)
2478
2284
 
2479
2285
  if figure:
@@ -2522,7 +2328,8 @@ class Plan(object):
2522
2328
  if tag != "":
2523
2329
  title += " - " + tag
2524
2330
 
2525
- fig, ax = _stackPlot(self.year_n, self.inames, title, [i], y2stack, stackNames, "upper left", "percent")
2331
+ fig, ax = plots.stack_plot(self.year_n, self.inames, title, [i],
2332
+ y2stack, stackNames, "upper left", "percent")
2526
2333
  figures.append(fig)
2527
2334
 
2528
2335
  if figure:
@@ -2560,7 +2367,8 @@ class Plan(object):
2560
2367
  for key in self.savings_in:
2561
2368
  savings_in[key] = self.savings_in[key] / self.gamma_n
2562
2369
 
2563
- fig, ax = _stackPlot(year_n, self.inames, title, range(self.N_i), savings_in, stypes, "upper left", yformat)
2370
+ fig, ax = plots.stack_plot(year_n, self.inames, title, range(self.N_i),
2371
+ savings_in, stypes, "upper left", yformat)
2564
2372
 
2565
2373
  if figure:
2566
2374
  return fig
@@ -2582,7 +2390,6 @@ class Plan(object):
2582
2390
 
2583
2391
  title = self._name + "\nRaw Income Sources"
2584
2392
  stypes = self.sources_in.keys()
2585
- # stypes = [item for item in stypes if "RothX" not in item]
2586
2393
 
2587
2394
  if tag != "":
2588
2395
  title += " - " + tag
@@ -2596,9 +2403,8 @@ class Plan(object):
2596
2403
  for key in stypes:
2597
2404
  sources_in[key] = self.sources_in[key] / self.gamma_n[:-1]
2598
2405
 
2599
- fig, ax = _stackPlot(
2600
- self.year_n, self.inames, title, range(self.N_i), sources_in, stypes, "upper left", yformat
2601
- )
2406
+ fig, ax = plots.stack_plot(self.year_n, self.inames, title, range(self.N_i),
2407
+ sources_in, stypes, "upper left", yformat)
2602
2408
 
2603
2409
  if figure:
2604
2410
  return fig
@@ -2624,11 +2430,10 @@ class Plan(object):
2624
2430
  for t in range(self.N_t):
2625
2431
  key = "f " + str(t)
2626
2432
  series[key] = self.F_tn[t] / self.DeltaBar_tn[t]
2627
- # print(key, series[key])
2628
2433
  style[key] = various[q % len(various)]
2629
2434
  q += 1
2630
2435
 
2631
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat="")
2436
+ fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat="")
2632
2437
 
2633
2438
  plt.show()
2634
2439
  return None
@@ -2661,7 +2466,7 @@ class Plan(object):
2661
2466
  if tag != "":
2662
2467
  title += " - " + tag
2663
2468
 
2664
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2469
+ fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
2665
2470
 
2666
2471
  if figure:
2667
2472
  return fig
@@ -2696,7 +2501,7 @@ class Plan(object):
2696
2501
  if tag != "":
2697
2502
  title += " - " + tag
2698
2503
 
2699
- fig, ax = _lineIncomePlot(self.year_n, series, style, title, yformat)
2504
+ fig, ax = plots.line_income_plot(self.year_n, series, style, title, yformat)
2700
2505
 
2701
2506
  data = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
2702
2507
  for key in data:
@@ -2826,7 +2631,7 @@ class Plan(object):
2826
2631
  for i in range(self.N_i):
2827
2632
  sname = self.inames[i] + "'s Sources"
2828
2633
  ws = wb.create_sheet(sname)
2829
- fillsheet(ws, srcDic, "currency", op=lambda x: x[i])
2634
+ fillsheet(ws, srcDic, "currency", op=lambda x: x[i]) # noqa: B023
2830
2635
 
2831
2636
  # Account balances except final year.
2832
2637
  accDic = {
@@ -2846,7 +2651,7 @@ class Plan(object):
2846
2651
  for i in range(self.N_i):
2847
2652
  sname = self.inames[i] + "'s Accounts"
2848
2653
  ws = wb.create_sheet(sname)
2849
- fillsheet(ws, accDic, "currency", op=lambda x: x[i])
2654
+ fillsheet(ws, accDic, "currency", op=lambda x: x[i]) # noqa: B023
2850
2655
  # Add final balances.
2851
2656
  lastRow = [
2852
2657
  self.year_n[-1] + 1,
@@ -2963,75 +2768,11 @@ class Plan(object):
2963
2768
  if key == "n":
2964
2769
  break
2965
2770
  except Exception as e:
2966
- raise Exception(f"Unanticipated exception: {e}.")
2771
+ raise Exception(f"Unanticipated exception: {e}.") from e
2967
2772
 
2968
2773
  return None
2969
2774
 
2970
2775
 
2971
- def _lineIncomePlot(x, series, style, title, yformat="\\$k"):
2972
- """
2973
- Core line plotter function.
2974
- """
2975
- import matplotlib.ticker as tk
2976
-
2977
- fig, ax = plt.subplots(figsize=(6, 4))
2978
- plt.grid(visible="both")
2979
-
2980
- for sname in series:
2981
- ax.plot(x, series[sname], label=sname, ls=style[sname])
2982
-
2983
- ax.legend(loc="upper left", reverse=True, fontsize=8, framealpha=0.3)
2984
- ax.set_title(title)
2985
- ax.set_xlabel("year")
2986
- ax.set_ylabel(yformat)
2987
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
2988
- if "k" in yformat:
2989
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
2990
- # Give range to y values in unindexed flat profiles.
2991
- ymin, ymax = ax.get_ylim()
2992
- if ymax - ymin < 5000:
2993
- ax.set_ylim((ymin * 0.95, ymax * 1.05))
2994
-
2995
- return fig, ax
2996
-
2997
-
2998
- def _stackPlot(x, inames, title, irange, series, snames, location, yformat="\\$k"):
2999
- """
3000
- Core function for stacked plots.
3001
- """
3002
- import matplotlib.ticker as tk
3003
-
3004
- nonzeroSeries = {}
3005
- for sname in snames:
3006
- for i in irange:
3007
- tmp = series[sname][i]
3008
- if sum(tmp) > 1.0:
3009
- nonzeroSeries[sname + " " + inames[i]] = tmp
3010
-
3011
- if len(nonzeroSeries) == 0:
3012
- # print('Nothing to plot for', title)
3013
- return None, None
3014
-
3015
- fig, ax = plt.subplots(figsize=(6, 4))
3016
- plt.grid(visible="both")
3017
-
3018
- ax.stackplot(x, nonzeroSeries.values(), labels=nonzeroSeries.keys(), alpha=0.6)
3019
- ax.legend(loc=location, reverse=True, fontsize=8, ncol=2, framealpha=0.5)
3020
- ax.set_title(title)
3021
- ax.set_xlabel("year")
3022
- ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
3023
- if "k" in yformat:
3024
- ax.set_ylabel(yformat)
3025
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(x / 1000), ",")))
3026
- elif yformat == "percent":
3027
- ax.set_ylabel("%")
3028
- ax.get_yaxis().set_major_formatter(tk.FuncFormatter(lambda x, p: format(int(100 * x), ",")))
3029
- else:
3030
- raise RuntimeError(f"Unknown yformat: {yformat}.")
3031
-
3032
- return fig, ax
3033
-
3034
-
3035
2776
  def _saveWorkbook(wb, basename, overwrite, mylog):
3036
2777
  """
3037
2778
  Utility function to save XL workbook.
@@ -3051,7 +2792,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
3051
2792
  mylog.vprint("Skipping save and returning.")
3052
2793
  return None
3053
2794
 
3054
- while True:
2795
+ for _ in range(3):
3055
2796
  try:
3056
2797
  mylog.vprint(f'Saving plan as "{fname}".')
3057
2798
  wb.save(fname)
@@ -3062,7 +2803,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
3062
2803
  if key == "n":
3063
2804
  break
3064
2805
  except Exception as e:
3065
- raise Exception(f"Unanticipated exception {e}.")
2806
+ raise Exception(f"Unanticipated exception {e}.") from e
3066
2807
 
3067
2808
  return None
3068
2809