owlplanner 2025.7.1__py3-none-any.whl → 2025.9.15__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/abcapi.py CHANGED
@@ -18,7 +18,7 @@ A for matrix, B for bounds, C for constraints. Thus the name ABCAPI.
18
18
 
19
19
  Copyright © 2024 - Martin-D. Lacasse
20
20
 
21
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
21
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
22
22
 
23
23
  """
24
24
 
owlplanner/config.py CHANGED
@@ -6,7 +6,7 @@ This file contains utility functions to save case parameters.
6
6
 
7
7
  Copyright © 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
@@ -66,7 +66,7 @@ def saveConfig(myplan, file, mylog):
66
66
  diconf["Rates Selection"] = {
67
67
  "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
68
68
  "Dividend rate": float(100 * myplan.mu),
69
- "TCJA expiration year": myplan.yTCJA,
69
+ "OBBBA expiration year": myplan.yOBBBA,
70
70
  "Method": myplan.rateMethod,
71
71
  }
72
72
  if myplan.rateMethod in ["user", "stochastic"]:
@@ -228,7 +228,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
228
228
  # Rates Selection.
229
229
  p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
230
230
  p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
231
- p.yTCJA = int(diconf["Rates Selection"]["TCJA expiration year"])
231
+ p.yOBBBA = int(diconf["Rates Selection"].get("OBBBA expiration year", 2032))
232
232
 
233
233
  frm = None
234
234
  to = None
@@ -296,6 +296,10 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
296
296
  # Solver Options.
297
297
  p.solverOptions = diconf["Solver Options"]
298
298
 
299
+ # Address legacy case files.
300
+ if diconf["Solver Options"].get("withMedicare", None) is True:
301
+ p.solverOptions["withMedicare"] = "loop"
302
+
299
303
  # Check consistency of noRothConversions.
300
304
  name = p.solverOptions.get("noRothConversions", "None")
301
305
  if name != "None" and name not in p.inames:
owlplanner/mylogging.py CHANGED
@@ -6,7 +6,7 @@ This file contains routines for handling error messages.
6
6
 
7
7
  Copyright © 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
owlplanner/plan.py CHANGED
@@ -10,7 +10,7 @@ mathematical model and a description of all variables and parameters.
10
10
 
11
11
  Copyright © 2024 - Martin-D. Lacasse
12
12
 
13
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
13
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
14
14
 
15
15
  """
16
16
 
@@ -226,12 +226,13 @@ class Plan(object):
226
226
  self._name = name
227
227
  self.setLogstreams(verbose, logstreams)
228
228
 
229
- # 7 tax brackets, 3 types of accounts, 4 classes of assets.
229
+ # 7 tax brackets, 6 Medicare brackets, 3 types of accounts, 4 classes of assets.
230
230
  self.N_t = 7
231
+ self.N_q = 6
231
232
  self.N_j = 3
232
233
  self.N_k = 4
233
234
  # 2 binary variables.
234
- self.N_z = 2
235
+ self.N_zx = 2
235
236
 
236
237
  # Default interpolation parameters for allocation ratios.
237
238
  self.interpMethod = "linear"
@@ -258,8 +259,9 @@ class Plan(object):
258
259
  raise ValueError("Name for each individual must be provided.")
259
260
 
260
261
  self.filingStatus = ("single", "married")[self.N_i - 1]
261
- # Default year TCJA is speculated to expire.
262
- self.yTCJA = 2026
262
+
263
+ # Default year OBBBA speculated to be expired and replaced by pre-TCJA rates.
264
+ self.yOBBBA = 2032
263
265
  self.inames = inames
264
266
  self.yobs = np.array(yobs, dtype=np.int32)
265
267
  self.expectancy = np.array(expectancy, dtype=np.int32)
@@ -297,20 +299,22 @@ class Plan(object):
297
299
  self.zeta_in = np.zeros((self.N_i, self.N_n))
298
300
  self.pensionAmounts = np.zeros(self.N_i)
299
301
  self.pensionAges = 65 * np.ones(self.N_i, dtype=np.int32)
300
- self.pensionIsIndexed = [False, False]
302
+ self.pensionIsIndexed = [False] * self.N_i
301
303
  self.ssecAmounts = np.zeros(self.N_i)
302
304
  self.ssecAges = 67 * np.ones(self.N_i, dtype=np.int32)
303
305
 
304
306
  # Parameters from timeLists initialized to zero.
305
307
  self.omega_in = np.zeros((self.N_i, self.N_n))
306
308
  self.Lambda_in = np.zeros((self.N_i, self.N_n))
309
+ # Go back 5 years for maturation rules on IRA and Roth.
307
310
  self.myRothX_in = np.zeros((self.N_i, self.N_n + 5))
308
311
  self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n + 5))
309
312
 
310
- # Previous 3 years for Medicare.
313
+ # Previous 2 years of MAGI needed for Medicare.
311
314
  self.prevMAGI = np.zeros((2))
315
+ self.MAGI_n = np.zeros(self.N_n)
312
316
 
313
- # Init previous balance to none.
317
+ # Init current balances to none.
314
318
  self.beta_ij = None
315
319
  self.startDate = None
316
320
 
@@ -327,8 +331,6 @@ class Plan(object):
327
331
  # Prepare RMD time series.
328
332
  self.rho_in = tx.rho_in(self.yobs, self.N_n)
329
333
 
330
- self._buildOffsetMap()
331
-
332
334
  # Initialize guardrails to ensure proper configuration.
333
335
  self._adjustedParameters = False
334
336
  self.timeListsFileName = "None"
@@ -340,7 +342,7 @@ class Plan(object):
340
342
  self.ARCoord = None
341
343
  self.objective = "unknown"
342
344
 
343
- # Placeholders to check if properly configured.
345
+ # Placeholders values used to check if properly configured.
344
346
  self.xi_n = None
345
347
  self.alpha_ijkn = None
346
348
 
@@ -368,7 +370,7 @@ class Plan(object):
368
370
  """
369
371
  Set the date when the plan starts in the current year.
370
372
  This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
371
- String format of mydate is 'month/day'.
373
+ String format of mydate is 'MM/DD', 'MM-DD', 'YYYY-MM-DD', or 'YYYY/MM/DD'. Year is ignored.
372
374
  """
373
375
  import calendar
374
376
 
@@ -397,7 +399,7 @@ class Plan(object):
397
399
 
398
400
  return None
399
401
 
400
- def _checkValue(self, value):
402
+ def _checkValueType(self, value):
401
403
  """
402
404
  Short utility function to parse and check arguments for plotting.
403
405
  """
@@ -450,7 +452,7 @@ class Plan(object):
450
452
  Set plots between nominal values or today's $.
451
453
  """
452
454
 
453
- self.defaultPlots = self._checkValue(value)
455
+ self.defaultPlots = self._checkValueType(value)
454
456
  self.mylog.vprint(f"Setting plots default value to {value}.")
455
457
 
456
458
  def setPlotBackend(self, backend: str):
@@ -463,25 +465,26 @@ class Plan(object):
463
465
 
464
466
  if backend != self._plotterName:
465
467
  self._plotter = PlotFactory.createBackend(backend)
468
+ self._plotterName = backend
466
469
  self.mylog.vprint(f"Setting plotting backend to {backend}.")
467
470
 
468
471
  def setDividendRate(self, mu):
469
472
  """
470
473
  Set dividend tax rate. Rate is in percent. Default 1.8%.
471
474
  """
472
- if not (0 <= mu <= 100):
473
- raise ValueError("Rate must be between 0 and 100.")
475
+ if not (0 <= mu <= 5):
476
+ raise ValueError("Rate must be between 0 and 5.")
474
477
  mu /= 100
475
478
  self.mylog.vprint(f"Dividend tax rate set to {u.pc(mu, f=0)}.")
476
479
  self.mu = mu
477
480
  self.caseStatus = "modified"
478
481
 
479
- def setExpirationYearTCJA(self, yTCJA):
482
+ def setExpirationYearOBBBA(self, yOBBBA):
480
483
  """
481
- Set year at which TCJA is speculated to expire.
484
+ Set year at which OBBBA is speculated to expire and rates go back to something like pre-TCJA.
482
485
  """
483
- self.mylog.vprint(f"Setting TCJA expiration year to {yTCJA}.")
484
- self.yTCJA = yTCJA
486
+ self.mylog.vprint(f"Setting OBBBA expiration year to {yOBBBA}.")
487
+ self.yOBBBA = yOBBBA
485
488
  self.caseStatus = "modified"
486
489
  self._adjustedParameters = False
487
490
 
@@ -516,7 +519,7 @@ class Plan(object):
516
519
  self.nu = nu
517
520
  self.caseStatus = "modified"
518
521
 
519
- def setPension(self, amounts, ages, indexed=(False, False), units="k"):
522
+ def setPension(self, amounts, ages, indexed=None, units="k"):
520
523
  """
521
524
  Set value of pension for each individual and commencement age.
522
525
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
@@ -525,8 +528,8 @@ class Plan(object):
525
528
  raise ValueError(f"Amounts must have {self.N_i} entries.")
526
529
  if len(ages) != self.N_i:
527
530
  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.")
531
+ if indexed is None:
532
+ indexed = [False] * self.N_i
530
533
 
531
534
  fac = u.getUnits(units)
532
535
  amounts = u.rescale(amounts, fac)
@@ -539,7 +542,7 @@ class Plan(object):
539
542
  self.pi_in = np.zeros((self.N_i, self.N_n))
540
543
  for i in range(self.N_i):
541
544
  if amounts[i] != 0:
542
- ns = max(0, self.yobs[i] + ages[i] - thisyear)
545
+ ns = max(0, ages[i] - thisyear + self.yobs[i])
543
546
  nd = self.horizons[i]
544
547
  self.pi_in[i, ns:nd] = amounts[i]
545
548
 
@@ -570,13 +573,13 @@ class Plan(object):
570
573
  thisyear = date.today().year
571
574
  self.zeta_in = np.zeros((self.N_i, self.N_n))
572
575
  for i in range(self.N_i):
573
- ns = max(0, self.yobs[i] + ages[i] - thisyear)
576
+ ns = max(0, ages[i] - thisyear + self.yobs[i])
574
577
  nd = self.horizons[i]
575
578
  self.zeta_in[i, ns:nd] = amounts[i]
576
579
 
577
580
  if self.N_i == 2:
578
- # Approximate calculation for spousal benefit (only valid at FRA).
579
- self.zeta_in[self.i_s, self.n_d :] = max(amounts[self.i_s], amounts[self.i_d])
581
+ # Approximate calculation for spousal benefit (only valid at FRA, and if of similar ages).
582
+ self.zeta_in[self.i_s, self.n_d :] = max(amounts[self.i_s], 0.5*amounts[self.i_d])
580
583
 
581
584
  self.ssecAmounts = np.array(amounts)
582
585
  self.ssecAges = np.array(ages, dtype=np.int32)
@@ -658,36 +661,6 @@ class Plan(object):
658
661
  corr=self.rateCorr,
659
662
  )
660
663
 
661
- def value(self, amount, year):
662
- """
663
- Return value of amount deflated or inflated at the beginning
664
- of the year specified.
665
- If year is in the past, value is made at the beginning of this year.
666
- If year is in the future, amount is adjusted from a reference time
667
- aligned with the beginning of the plan to the beginning of the
668
- year specified.
669
- """
670
- thisyear = date.today().year
671
- if year <= thisyear:
672
- return rates.historicalValue(amount, year)
673
- else:
674
- return self.forwardValue(amount, year)
675
-
676
- def forwardValue(self, amount, year):
677
- """
678
- Return the value of amount inflated from beginning of the plan
679
- to the beginning of the year provided.
680
- """
681
- if self.rateMethod is None:
682
- raise RuntimeError("A rate method needs to be first selected using setRates(...).")
683
-
684
- thisyear = date.today().year
685
- if year <= thisyear:
686
- raise RuntimeError("Internal error in forwardValue().")
687
- span = year - thisyear
688
-
689
- return amount * self.gamma_n[span]
690
-
691
664
  def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
692
665
  """
693
666
  Three lists containing the balance of all assets in each category for
@@ -977,7 +950,6 @@ class Plan(object):
977
950
  for i, iname in enumerate(self.inames):
978
951
  h = self.horizons[i]
979
952
  df = pd.DataFrame(0, index=np.arange(0, h+5), columns=cols)
980
- # df["year"] = self.year_n[:h]
981
953
  df["year"] = np.arange(self.year_n[0] - 5, self.year_n[h-1]+1)
982
954
  self.timeLists[iname] = df
983
955
 
@@ -1015,62 +987,70 @@ class Plan(object):
1015
987
 
1016
988
  return dat
1017
989
 
1018
- def _adjustParameters(self):
990
+ def _adjustParameters(self, gamma_n, MAGI_n):
1019
991
  """
1020
- Adjust parameters that follow inflation.
992
+ Adjust parameters that follow inflation or depend on MAGI.
993
+ Separate variables depending on MAGI (exemptions now depends on MAGI).
1021
994
  """
1022
995
  if self.rateMethod is None:
1023
996
  raise RuntimeError("A rate method needs to be first selected using setRates(...).")
1024
997
 
998
+ self.sigmaBar_n, self.theta_tn, self.Delta_tn = tx.taxParams(self.yobs, self.i_d, self.n_d,
999
+ self.N_n, gamma_n,
1000
+ MAGI_n, self.yOBBBA)
1001
+
1025
1002
  if not self._adjustedParameters:
1026
1003
  self.mylog.vprint("Adjusting parameters for inflation.")
1027
- self.sigma_n, self.theta_tn, self.Delta_tn = tx.taxParams(self.yobs, self.i_d, self.n_d,
1028
- self.N_n, self.yTCJA)
1029
- self.sigmaBar_n = self.sigma_n * self.gamma_n[:-1]
1030
- self.DeltaBar_tn = self.Delta_tn * self.gamma_n[:-1]
1031
- self.zetaBar_in = self.zeta_in * self.gamma_n[:-1]
1032
- self.xiBar_n = self.xi_n * self.gamma_n[:-1]
1004
+ self.DeltaBar_tn = self.Delta_tn * gamma_n[:-1]
1005
+ self.zetaBar_in = self.zeta_in * gamma_n[:-1]
1006
+ self.xiBar_n = self.xi_n * gamma_n[:-1]
1033
1007
  self.piBar_in = np.array(self.pi_in)
1034
1008
  for i in range(self.N_i):
1035
1009
  if self.pensionIsIndexed[i]:
1036
- self.piBar_in[i] *= self.gamma_n[:-1]
1010
+ self.piBar_in[i] *= gamma_n[:-1]
1011
+
1012
+ self.nm, self.L_nq, self.C_nq = tx.mediVals(self.yobs, self.horizons, gamma_n, self.N_n, self.N_q)
1037
1013
 
1038
1014
  self._adjustedParameters = True
1039
1015
 
1040
- return None
1016
+ # return None
1041
1017
 
1042
- def _buildOffsetMap(self):
1018
+ def _buildOffsetMap(self, options):
1043
1019
  """
1044
1020
  Utility function to map variables to a block vector.
1045
1021
  Refer to companion document for explanations.
1022
+ All binary variables must be lumped at the end of the vector.
1046
1023
  """
1024
+ medi = options.get("withMedicare", "loop") == "optimize"
1025
+
1047
1026
  # Stack all variables in a single block vector with all binary variables at the end.
1048
1027
  C = {}
1049
1028
  C["b"] = 0
1050
1029
  C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
1051
1030
  C["e"] = _qC(C["d"], self.N_i, self.N_n)
1052
- C["F"] = _qC(C["e"], self.N_n)
1053
- C["g"] = _qC(C["F"], self.N_t, self.N_n)
1054
- C["s"] = _qC(C["g"], self.N_n)
1031
+ C["f"] = _qC(C["e"], self.N_n)
1032
+ C["g"] = _qC(C["f"], self.N_t, self.N_n)
1033
+ C["m"] = _qC(C["g"], self.N_n)
1034
+ C["s"] = _qC(C["m"], self.N_n)
1055
1035
  C["w"] = _qC(C["s"], self.N_n)
1056
1036
  C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
1057
- C["z"] = _qC(C["x"], self.N_i, self.N_n)
1058
- self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
1059
- self.nbins = self.nvars - C["z"]
1060
- # # self.nvars = _qC(C["x"], self.N_i, self.N_n)
1061
- # # self.nbins = 0
1037
+ C["zx"] = _qC(C["x"], self.N_i, self.N_n)
1038
+ C["zm"] = _qC(C["zx"], self.N_i, self.N_n, self.N_zx)
1039
+ self.nvars = _qC(C["zm"], self.N_n - self.nm, self.N_q - 1) if medi else C["zm"]
1040
+ self.nbins = self.nvars - C["zx"]
1062
1041
 
1063
1042
  self.C = C
1064
1043
  self.mylog.vprint(
1065
1044
  f"Problem has {len(C)} distinct series, {self.nvars} decision variables (including {self.nbins} binary).")
1066
1045
 
1067
- return None
1068
-
1069
1046
  def _buildConstraints(self, objective, options):
1070
1047
  """
1071
1048
  Utility function that builds constraint matrix and vectors.
1072
1049
  Refactored for clarity and maintainability.
1073
1050
  """
1051
+ # Ensure parameters are adjusted for inflation and MAGI.
1052
+ self._adjustParameters(self.gamma_n, self.MAGI_n)
1053
+
1074
1054
  self.A = abc.ConstraintMatrix(self.nvars)
1075
1055
  self.B = abc.Bounds(self.nvars, self.nbins)
1076
1056
 
@@ -1089,11 +1069,11 @@ class Plan(object):
1089
1069
  self._add_net_cash_flow()
1090
1070
  self._add_income_profile()
1091
1071
  self._add_taxable_income()
1092
- self._configure_binary_variables(options)
1072
+ self._configure_Medicare_binary_variables(options)
1073
+ self._add_Medicare_costs(options)
1074
+ self._configure_exclusion_binary_variables(options)
1093
1075
  self._build_objective_vector(objective)
1094
1076
 
1095
- return None
1096
-
1097
1077
  def _add_rmd_inequalities(self):
1098
1078
  for i in range(self.N_i):
1099
1079
  if self.beta_ij[i, 1] > 0:
@@ -1107,7 +1087,7 @@ class Plan(object):
1107
1087
  def _add_tax_bracket_bounds(self):
1108
1088
  for t in range(self.N_t):
1109
1089
  for n in range(self.N_n):
1110
- self.B.setRange(_q2(self.C["F"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
1090
+ self.B.setRange(_q2(self.C["f"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
1111
1091
 
1112
1092
  def _add_standard_exemption_bounds(self):
1113
1093
  for n in range(self.N_n):
@@ -1313,6 +1293,7 @@ class Plan(object):
1313
1293
  rhs = -self.M_n[n] - self.J_n[n]
1314
1294
  row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1315
1295
  row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1296
+ row.addElem(_q1(self.C["m"], n, self.N_n), 1)
1316
1297
  for i in range(self.N_i):
1317
1298
  fac = self.psi_n[n] * self.alpha_ijkn[i, 0, 0, n]
1318
1299
  rhs += (
@@ -1330,7 +1311,7 @@ class Plan(object):
1330
1311
  row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
1331
1312
 
1332
1313
  for t in range(self.N_t):
1333
- row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
1314
+ row.addElem(_q2(self.C["f"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
1334
1315
 
1335
1316
  self.A.addRow(row, rhs, rhs)
1336
1317
 
@@ -1360,10 +1341,13 @@ class Plan(object):
1360
1341
  row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
1361
1342
  row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
1362
1343
  for t in range(self.N_t):
1363
- row.addElem(_q2(self.C["F"], t, n, self.N_t, self.N_n), 1)
1344
+ row.addElem(_q2(self.C["f"], t, n, self.N_t, self.N_n), 1)
1364
1345
  self.A.addRow(row, rhs, rhs)
1365
1346
 
1366
- def _configure_binary_variables(self, options):
1347
+ def _configure_exclusion_binary_variables(self, options):
1348
+ if not options.get("xorConstraints", True):
1349
+ return
1350
+
1367
1351
  bigM = options.get("bigM", 5e6)
1368
1352
  if not isinstance(bigM, (int, float)):
1369
1353
  raise ValueError(f"bigM {bigM} is not a number.")
@@ -1371,14 +1355,14 @@ class Plan(object):
1371
1355
  for i in range(self.N_i):
1372
1356
  for n in range(self.horizons[i]):
1373
1357
  self.A.addNewRow(
1374
- {_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1358
+ {_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
1375
1359
  _q1(self.C["s"], n, self.N_n): -1},
1376
1360
  0,
1377
1361
  bigM,
1378
1362
  )
1379
1363
  self.A.addNewRow(
1380
1364
  {
1381
- _q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
1365
+ _q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
1382
1366
  _q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
1383
1367
  _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
1384
1368
  },
@@ -1386,20 +1370,94 @@ class Plan(object):
1386
1370
  bigM,
1387
1371
  )
1388
1372
  self.A.addNewRow(
1389
- {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1373
+ {_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
1390
1374
  _q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
1391
1375
  0,
1392
1376
  bigM,
1393
1377
  )
1394
1378
  self.A.addNewRow(
1395
- {_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
1379
+ {_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
1396
1380
  _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
1397
1381
  0,
1398
1382
  bigM,
1399
1383
  )
1400
1384
  for n in range(self.horizons[i], self.N_n):
1401
- self.B.setRange(_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
1402
- self.B.setRange(_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z), 0, 0)
1385
+ self.B.setRange(_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx), 0, 0)
1386
+ self.B.setRange(_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx), 0, 0)
1387
+
1388
+ def _configure_Medicare_binary_variables(self, options):
1389
+ if options.get("withMedicare", "loop") != "optimize":
1390
+ return
1391
+
1392
+ bigM = options.get("bigM", 5e6)
1393
+ if not isinstance(bigM, (int, float)):
1394
+ raise ValueError(f"bigM {bigM} is not a number.")
1395
+
1396
+ Nmed = self.N_n - self.nm
1397
+ offset = 0
1398
+ if self.nm < 2:
1399
+ offset = 2 - self.nm
1400
+ for nn in range(offset):
1401
+ n = self.nm + nn
1402
+ for q in range(self.N_q - 1):
1403
+ self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
1404
+ -np.inf, bigM - self.L_nq[nn, q] + self.prevMAGI[n])
1405
+ self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): -bigM},
1406
+ -np.inf, self.L_nq[nn, q] - self.prevMAGI[n])
1407
+
1408
+ for nn in range(offset, Nmed):
1409
+ n2 = self.nm + nn - 2 # n - 2
1410
+ for q in range(self.N_q - 1):
1411
+ rhs1 = bigM - self.L_nq[nn, q]
1412
+ rhs2 = self.L_nq[nn, q]
1413
+ row1 = self.A.newRow()
1414
+ row2 = self.A.newRow()
1415
+
1416
+ row1.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), +bigM)
1417
+ row2.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM)
1418
+ for i in range(self.N_i):
1419
+ row1.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
1420
+ row2.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
1421
+
1422
+ row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
1423
+ row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
1424
+
1425
+ afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
1426
+ + np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
1427
+ afac = 0
1428
+ row1.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
1429
+ row2.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
1430
+
1431
+ row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
1432
+ row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1433
+
1434
+ bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, self.tau_kn[0, max(0, n2-1)])
1435
+ row1.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), +afac - bfac)
1436
+ row2.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
1437
+
1438
+ sumoni = (self.omega_in[i, n2] + self.psi_n[n2] * self.zetaBar_in[i, n2] + self.piBar_in[i, n2]
1439
+ + 0.5 * self.kappa_ijn[i, 0, n2] * afac)
1440
+ rhs1 += sumoni
1441
+ rhs2 -= sumoni
1442
+
1443
+ self.A.addRow(row1, -np.inf, rhs1)
1444
+ self.A.addRow(row2, -np.inf, rhs2)
1445
+
1446
+ def _add_Medicare_costs(self, options):
1447
+ if options.get("withMedicare", "loop") != "optimize":
1448
+ return
1449
+
1450
+ for n in range(self.nm):
1451
+ self.B.setRange(_q1(self.C["m"], n, self.N_n), 0, 0)
1452
+
1453
+ Nmed = self.N_n - self.nm
1454
+ for nn in range(Nmed):
1455
+ n = self.nm + nn
1456
+ row = self.A.newRow()
1457
+ row.addElem(_q1(self.C["m"], n, self.N_n), 1)
1458
+ for q in range(self.N_q - 1):
1459
+ row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -self.C_nq[nn, q+1])
1460
+ self.A.addRow(row, self.C_nq[nn, 0], self.C_nq[nn, 0])
1403
1461
 
1404
1462
  def _build_objective_vector(self, objective):
1405
1463
  c = abc.Objective(self.nvars)
@@ -1484,12 +1542,7 @@ class Plan(object):
1484
1542
  self.mylog.vprint(f"Running {N} Monte Carlo simulations.")
1485
1543
  self.mylog.setVerbose(verbose)
1486
1544
 
1487
- # Turn off Medicare by default, unless specified in options.
1488
- if "withMedicare" not in options:
1489
- myoptions = dict(options)
1490
- myoptions["withMedicare"] = False
1491
- else:
1492
- myoptions = options
1545
+ myoptions = options
1493
1546
 
1494
1547
  if objective == "maxSpending":
1495
1548
  columns = ["partial", objective]
@@ -1573,13 +1626,15 @@ class Plan(object):
1573
1626
  "maxRothConversion",
1574
1627
  "netSpending",
1575
1628
  "noRothConversions",
1629
+ "oppCostX",
1630
+ "withMedicare",
1576
1631
  "previousMAGIs",
1577
1632
  "solver",
1578
1633
  "spendingSlack",
1579
1634
  "startRothConversions",
1580
1635
  "units",
1581
- "withMedicare",
1582
- "oppCostX",
1636
+ "xorConstraints",
1637
+ "withSCLoop",
1583
1638
  ]
1584
1639
  # We might modify options if required.
1585
1640
  options = {} if options is None else options
@@ -1624,11 +1679,14 @@ class Plan(object):
1624
1679
  raise ValueError(f"Slack value out of range {lambdha}.")
1625
1680
  self.lambdha = lambdha / 100
1626
1681
 
1627
- # Ensure parameters are adjusted for inflation.
1628
- self._adjustParameters()
1682
+ # Reset long-term capital gain tax rate and MAGI to zero.
1683
+ self.psi_n = np.zeros(self.N_n)
1684
+ self.MAGI_n = np.zeros(self.N_n)
1685
+ self.J_n = np.zeros(self.N_n)
1686
+ self.M_n = np.zeros(self.N_n)
1629
1687
 
1630
- # Reset long-term capital gain tax rate to zero.
1631
- self.psi_n[:] = 0
1688
+ self._adjustParameters(self.gamma_n, self.MAGI_n)
1689
+ self._buildOffsetMap(options)
1632
1690
 
1633
1691
  solver = myoptions.get("solver", self.defaultSolver)
1634
1692
  if solver not in knownSolvers:
@@ -1654,7 +1712,8 @@ class Plan(object):
1654
1712
  """
1655
1713
  Self-consistent loop, regardless of solver.
1656
1714
  """
1657
- withMedicare = options.get("withMedicare", True)
1715
+ includeMedicare = options.get("withMedicare", "loop") == "loop"
1716
+ withSCLoop = options.get("withSCLoop", True)
1658
1717
 
1659
1718
  if objective == "maxSpending":
1660
1719
  objFac = -1 / self.xi_n[0]
@@ -1662,32 +1721,34 @@ class Plan(object):
1662
1721
  objFac = -1 / self.gamma_n[-1]
1663
1722
 
1664
1723
  it = 0
1665
- absdiff = np.inf
1666
1724
  old_x = np.zeros(self.nvars)
1667
- old_solutions = [np.inf]
1668
- self._estimateMedicare(None, withMedicare)
1725
+ old_objfns = [np.inf]
1726
+ self._computeNLstuff(None, includeMedicare)
1669
1727
  while True:
1670
- solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1728
+ objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1671
1729
 
1672
- if not solverSuccess or solution is None:
1730
+ if not solverSuccess or objfn is None:
1673
1731
  self.mylog.vprint("Solver failed:", solverMsg, solverSuccess)
1674
1732
  break
1675
1733
 
1676
- if not withMedicare:
1734
+ if not withSCLoop:
1677
1735
  break
1678
1736
 
1679
- self._estimateMedicare(xx)
1680
-
1681
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
1737
+ self._computeNLstuff(xx, includeMedicare)
1682
1738
 
1683
1739
  delta = xx - old_x
1684
- absdiff = np.sum(np.abs(delta), axis=0)
1685
- if absdiff < 1:
1740
+ absSolDiff = np.sum(np.abs(delta), axis=0)/100
1741
+ absObjDiff = abs(objFac*(objfn + old_objfns[-1]))/100
1742
+ self.mylog.vprint(f"Iteration: {it} objective: {u.d(objfn * objFac, f=2)},"
1743
+ f" |dX|: {absSolDiff:.2f}, |df|: {u.d(absObjDiff, f=2)}")
1744
+
1745
+ # 50 cents accuracy.
1746
+ if absSolDiff < .5 and absObjDiff < .5:
1686
1747
  self.mylog.vprint("Converged on full solution.")
1687
1748
  break
1688
1749
 
1689
1750
  # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1690
- isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1751
+ isclosenough = abs(-objfn - min(old_objfns[int(it / 2) :])) < 10 * self.xi_n[0]
1691
1752
  if isclosenough:
1692
1753
  self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1693
1754
  break
@@ -1697,13 +1758,13 @@ class Plan(object):
1697
1758
  break
1698
1759
 
1699
1760
  it += 1
1700
- old_solutions.append(-solution)
1761
+ old_objfns.append(-objfn)
1701
1762
  old_x = xx
1702
1763
 
1703
1764
  if solverSuccess:
1704
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it+1} iterations.")
1765
+ self.mylog.vprint(f"Self-consistent loop returned after {it+1} iterations.")
1705
1766
  self.mylog.vprint(solverMsg)
1706
- self.mylog.vprint(f"Objective: {u.d(solution * objFac)}")
1767
+ self.mylog.vprint(f"Objective: {u.d(objfn * objFac)}")
1707
1768
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1708
1769
  self._aggregateResults(xx)
1709
1770
  self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
@@ -1725,7 +1786,7 @@ class Plan(object):
1725
1786
  "disp": False,
1726
1787
  "mip_rel_gap": 1e-7,
1727
1788
  "presolve": True,
1728
- "node_limit": 10000 # Limit search nodes for faster solutions
1789
+ # "node_limit": 10000 # Limit search nodes for faster solutions
1729
1790
  }
1730
1791
 
1731
1792
  self._buildConstraints(objective, options)
@@ -1873,10 +1934,11 @@ class Plan(object):
1873
1934
 
1874
1935
  return solution, xx, solverSuccess, solverMsg
1875
1936
 
1876
- def _computeNIIT(self):
1937
+ def _computeNIIT(self, MAGI_n, I_n, Q_n):
1877
1938
  """
1878
- Compute Wages (W), Dividends (Q), Interests (I), and exemption(e).
1879
- For accounting for rent and trust income, one can easily add a column
1939
+ Compute ACA tax on Dividends (Q) and Interests (I).
1940
+ Pass arguments to better understand dependencies.
1941
+ For accounting for rent and/or trust income, one can easily add a column
1880
1942
  to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
1881
1943
  """
1882
1944
  J_n = np.zeros(self.N_n)
@@ -1887,17 +1949,17 @@ class Plan(object):
1887
1949
  status -= 1
1888
1950
 
1889
1951
  Gmax = tx.niitThreshold[status]
1890
- if self.MAGI_n[n] > Gmax:
1891
- J_n[n] = tx.niitRate * min(self.MAGI_n[n] - Gmax, self.I_n[n] + self.Q_n[n])
1952
+ if MAGI_n[n] > Gmax:
1953
+ J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
1892
1954
 
1893
1955
  return J_n
1894
1956
 
1895
- def _estimateMedicare(self, x=None, withMedicare=True):
1957
+ def _computeNLstuff(self, x, includeMedicare):
1896
1958
  """
1897
1959
  Compute MAGI, Medicare costs, long-term capital gain tax rate, and
1898
1960
  net investment income tax (NIIT).
1899
1961
  """
1900
- if x is None or withMedicare is False:
1962
+ if x is None:
1901
1963
  self.MAGI_n = np.zeros(self.N_n)
1902
1964
  self.J_n = np.zeros(self.N_n)
1903
1965
  self.M_n = np.zeros(self.N_n)
@@ -1906,9 +1968,11 @@ class Plan(object):
1906
1968
 
1907
1969
  self._aggregateResults(x, short=True)
1908
1970
 
1909
- self.J_n = self._computeNIIT()
1910
- self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
1971
+ self.J_n = self._computeNIIT(self.MAGI_n, self.I_n, self.Q_n)
1911
1972
  self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
1973
+ # Compute Medicare through self-consistent loop.
1974
+ if includeMedicare:
1975
+ self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
1912
1976
 
1913
1977
  return None
1914
1978
 
@@ -1923,18 +1987,19 @@ class Plan(object):
1923
1987
  Nk = self.N_k
1924
1988
  Nn = self.N_n
1925
1989
  Nt = self.N_t
1926
- # Nz = self.N_z
1990
+ # Nzx = self.N_zx
1927
1991
  n_d = self.n_d
1928
1992
 
1929
1993
  Cb = self.C["b"]
1930
1994
  Cd = self.C["d"]
1931
1995
  Ce = self.C["e"]
1932
- CF = self.C["F"]
1996
+ Cf = self.C["f"]
1933
1997
  Cg = self.C["g"]
1998
+ Cm = self.C["m"]
1934
1999
  Cs = self.C["s"]
1935
2000
  Cw = self.C["w"]
1936
2001
  Cx = self.C["x"]
1937
- Cz = self.C["z"]
2002
+ Czx = self.C["zx"]
1938
2003
 
1939
2004
  x = u.roundCents(x)
1940
2005
 
@@ -1948,26 +2013,28 @@ class Plan(object):
1948
2013
  self.d_in = np.array(x[Cd:Ce])
1949
2014
  self.d_in = self.d_in.reshape((Ni, Nn))
1950
2015
 
1951
- self.e_n = np.array(x[Ce:CF])
2016
+ self.e_n = np.array(x[Ce:Cf])
2017
+
2018
+ self.f_tn = np.array(x[Cf:Cg])
2019
+ self.f_tn = self.f_tn.reshape((Nt, Nn))
1952
2020
 
1953
- self.F_tn = np.array(x[CF:Cg])
1954
- self.F_tn = self.F_tn.reshape((Nt, Nn))
2021
+ self.g_n = np.array(x[Cg:Cm])
1955
2022
 
1956
- self.g_n = np.array(x[Cg:Cs])
2023
+ self.m_n = np.array(x[Cm:Cs])
1957
2024
 
1958
2025
  self.s_n = np.array(x[Cs:Cw])
1959
2026
 
1960
2027
  self.w_ijn = np.array(x[Cw:Cx])
1961
2028
  self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
1962
2029
 
1963
- self.x_in = np.array(x[Cx:Cz])
2030
+ self.x_in = np.array(x[Cx:Czx])
1964
2031
  self.x_in = self.x_in.reshape((Ni, Nn))
1965
2032
 
1966
- # self.z_inz = np.array(x[Cz:])
1967
- # self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
2033
+ # self.z_inz = np.array(x[Czx:])
2034
+ # self.z_inz = self.z_inz.reshape((Ni, Nn, Nzx))
1968
2035
  # print(self.z_inz)
1969
2036
 
1970
- self.G_n = np.sum(self.F_tn, axis=0)
2037
+ self.G_n = np.sum(self.f_tn, axis=0)
1971
2038
 
1972
2039
  tau_0 = np.array(self.tau_kn[0, :])
1973
2040
  tau_0[tau_0 < 0] = 0
@@ -1976,10 +2043,10 @@ class Plan(object):
1976
2043
  self.Q_n = np.sum(
1977
2044
  (
1978
2045
  self.mu
1979
- * (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
2046
+ * (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
1980
2047
  + tau_0prev * self.w_ijn[:, 0, :]
1981
2048
  )
1982
- * self.alpha_ijkn[:, 0, 0, :-1],
2049
+ * self.alpha_ijkn[:, 0, 0, :Nn],
1983
2050
  axis=0,
1984
2051
  )
1985
2052
  self.U_n = self.psi_n * self.Q_n
@@ -1987,14 +2054,14 @@ class Plan(object):
1987
2054
  self.MAGI_n = self.G_n + self.e_n + self.Q_n
1988
2055
 
1989
2056
  I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
1990
- * np.sum(self.alpha_ijkn[:, 0, 1:, :-1] * self.tau_kn[1:, :], axis=1))
2057
+ * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
1991
2058
  self.I_n = np.sum(I_in, axis=0)
1992
2059
 
1993
2060
  # Stop after building minimu required for self-consistent loop.
1994
2061
  if short:
1995
2062
  return
1996
2063
 
1997
- self.T_tn = self.F_tn * self.theta_tn
2064
+ self.T_tn = self.f_tn * self.theta_tn
1998
2065
  self.T_n = np.sum(self.T_tn, axis=0)
1999
2066
  self.P_n = np.zeros(Nn)
2000
2067
  # Add early withdrawal penalty if any.
@@ -2170,8 +2237,8 @@ class Plan(object):
2170
2237
  dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
2171
2238
  dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
2172
2239
 
2173
- taxPaid = np.sum(self.M_n, axis=0)
2174
- taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2240
+ taxPaid = np.sum(self.m_n + self.M_n, axis=0)
2241
+ taxPaidNow = np.sum((self.m_n + self.M_n) / self.gamma_n[:-1], axis=0)
2175
2242
  dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2176
2243
  dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
2177
2244
 
@@ -2313,7 +2380,7 @@ class Plan(object):
2313
2380
  The value parameter can be set to *nominal* or *today*, overriding
2314
2381
  the default behavior of setDefaultPlots().
2315
2382
  """
2316
- value = self._checkValue(value)
2383
+ value = self._checkValueType(value)
2317
2384
  title = self._name + "\nNet Available Spending"
2318
2385
  if tag:
2319
2386
  title += " - " + tag
@@ -2338,7 +2405,7 @@ class Plan(object):
2338
2405
  The value parameter can be set to *nominal* or *today*, overriding
2339
2406
  the default behavior of setDefaultPlots().
2340
2407
  """
2341
- value = self._checkValue(value)
2408
+ value = self._checkValueType(value)
2342
2409
  figures = self._plotter.plot_asset_composition(self.year_n, self.inames, self.b_ijkn,
2343
2410
  self.gamma_n, value, self._name, tag)
2344
2411
  if figure:
@@ -2358,8 +2425,8 @@ class Plan(object):
2358
2425
  The value parameter can be set to *nominal* or *today*, overriding
2359
2426
  the default behavior of setDefaultPlots().
2360
2427
  """
2361
- value = self._checkValue(value)
2362
- tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
2428
+ value = self._checkValueType(value)
2429
+ tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yOBBBA)
2363
2430
  title = self._name + "\nTaxable Ordinary Income vs. Tax Brackets"
2364
2431
  if tag:
2365
2432
  title += " - " + tag
@@ -2402,7 +2469,7 @@ class Plan(object):
2402
2469
  The value parameter can be set to *nominal* or *today*, overriding
2403
2470
  the default behavior of setDefaultPlots().
2404
2471
  """
2405
- value = self._checkValue(value)
2472
+ value = self._checkValueType(value)
2406
2473
  title = self._name + "\nSavings Balance"
2407
2474
  if tag:
2408
2475
  title += " - " + tag
@@ -2424,7 +2491,7 @@ class Plan(object):
2424
2491
  The value parameter can be set to *nominal* or *today*, overriding
2425
2492
  the default behavior of setDefaultPlots().
2426
2493
  """
2427
- value = self._checkValue(value)
2494
+ value = self._checkValueType(value)
2428
2495
  title = self._name + "\nRaw Income Sources"
2429
2496
  if tag:
2430
2497
  title += " - " + tag
@@ -2446,13 +2513,13 @@ class Plan(object):
2446
2513
  The value parameter can be set to *nominal* or *today*, overriding
2447
2514
  the default behavior of setDefaultPlots().
2448
2515
  """
2449
- value = self._checkValue(value)
2516
+ value = self._checkValueType(value)
2450
2517
  title = self._name + "\nFederal Income Tax"
2451
2518
  if tag:
2452
2519
  title += " - " + tag
2453
2520
  # All taxes: ordinary income, dividends, and NIIT.
2454
2521
  allTaxes = self.T_n + self.U_n + self.J_n
2455
- fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.M_n, self.gamma_n,
2522
+ fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.m_n + self.M_n, self.gamma_n,
2456
2523
  value, title, self.inames)
2457
2524
  if figure:
2458
2525
  return fig
@@ -2523,7 +2590,7 @@ class Plan(object):
2523
2590
  "net spending": self.g_n,
2524
2591
  "taxable ord. income": self.G_n,
2525
2592
  "taxable gains/divs": self.Q_n,
2526
- "Tax bills + Med.": self.T_n + self.U_n + self.M_n + self.J_n,
2593
+ "Tax bills + Med.": self.T_n + self.U_n + self.m_n + self.M_n + self.J_n,
2527
2594
  }
2528
2595
 
2529
2596
  fillsheet(ws, incomeDic, "currency")
@@ -2539,7 +2606,7 @@ class Plan(object):
2539
2606
  "all deposits": -np.sum(self.d_in, axis=0),
2540
2607
  "ord taxes": -self.T_n - self.J_n,
2541
2608
  "div taxes": -self.U_n,
2542
- "Medicare": -self.M_n,
2609
+ "Medicare": -self.m_n - self.M_n,
2543
2610
  }
2544
2611
  sname = "Cash Flow"
2545
2612
  ws = wb.create_sheet(sname)
@@ -2726,7 +2793,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
2726
2793
  else:
2727
2794
  fname = basename
2728
2795
 
2729
- if overwrite is False and isfile(fname):
2796
+ if not overwrite and isfile(fname):
2730
2797
  mylog.print(f'File "{fname}" already exists.')
2731
2798
  key = input("Overwrite? [Ny] ")
2732
2799
  if key != "y":
owlplanner/progress.py CHANGED
@@ -3,7 +3,7 @@ A simple object to display progress.
3
3
 
4
4
  Copyright &copy; 2024 - Martin-D. Lacasse
5
5
 
6
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
6
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
7
7
 
8
8
  """
9
9
 
owlplanner/rates.py CHANGED
@@ -23,7 +23,7 @@ to the last current data year.
23
23
 
24
24
  Copyright &copy; 2024 - Martin-D. Lacasse
25
25
 
26
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
26
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
27
27
 
28
28
  """
29
29
 
@@ -32,7 +32,6 @@ import numpy as np
32
32
  import pandas as pd
33
33
  import os
34
34
  import sys
35
- from datetime import date
36
35
 
37
36
  from owlplanner import mylogging as log
38
37
  from owlplanner import utils as u
@@ -120,27 +119,6 @@ def getRatesDistributions(frm, to, mylog=None):
120
119
  return means, stdev, corr, covar
121
120
 
122
121
 
123
- def historicalValue(amount, year):
124
- """
125
- Return the deflated value of amount given in this year's dollars as
126
- valued at the beginning of the year specified.
127
- """
128
- thisyear = date.today().year
129
- if TO != thisyear - 1:
130
- raise RuntimeError(f"Rates file needs to be updated to be current to {thisyear}.")
131
- if year < FROM:
132
- raise ValueError(f"Only data from {FROM} is available.")
133
- if year > thisyear:
134
- raise ValueError(f"Year must be < {thisyear} for historical data.")
135
-
136
- span = thisyear - year
137
- ub = len(Inflation)
138
- for n in range(ub - span, ub):
139
- amount /= 1 + Inflation[n] / 100
140
-
141
- return amount
142
-
143
-
144
122
  class Rates(object):
145
123
  """
146
124
  Rates are stored in a 4-array in the following order:
owlplanner/tax2025.py CHANGED
@@ -12,7 +12,7 @@ Module to handle all tax calculations.
12
12
 
13
13
  Copyright &copy; 2024 - Martin-D. Lacasse
14
14
 
15
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
15
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
16
16
 
17
17
  """
18
18
 
@@ -25,8 +25,8 @@ from datetime import date
25
25
 
26
26
  taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
27
27
 
28
- rates_TCJA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
29
- rates_nonTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
28
+ rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
29
+ rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
30
30
 
31
31
  ###############################################################################
32
32
  # Start of section where rates need to be actualized every year.
@@ -34,7 +34,7 @@ rates_nonTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
34
34
  # Single [0] and married filing jointly [1].
35
35
 
36
36
  # These are 2025 current.
37
- taxBrackets_TCJA = np.array(
37
+ taxBrackets_OBBBA = np.array(
38
38
  [
39
39
  [11925, 48475, 103350, 197300, 250525, 626350, 9999999],
40
40
  [23850, 96950, 206700, 394600, 501050, 751600, 9999999],
@@ -52,7 +52,7 @@ irmaaBrackets = np.array(
52
52
  # Following values are incremental IRMAA part B monthly fees.
53
53
  irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
54
54
 
55
- # Make projection for non-TCJA using 2017 to current year.
55
+ # Make projection for pre-TCJA using 2017 to current year.
56
56
  # taxBrackets_2017 = np.array(
57
57
  # [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
58
58
  # [18650, 75900, 153100, 233350, 416700, 470700, 9999999],
@@ -63,7 +63,7 @@ irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
63
63
  # For 2025, I used a 30.5% adjustment from 2017, rounded to closest 50.
64
64
  #
65
65
  # These are speculated.
66
- taxBrackets_nonTCJA = np.array(
66
+ taxBrackets_preTCJA = np.array(
67
67
  [
68
68
  [12150, 49550, 119950, 250200, 544000, 546200, 9999999], # Single
69
69
  [24350, 99100, 199850, 304600, 543950, 614450, 9999999], # MFJ
@@ -71,11 +71,11 @@ taxBrackets_nonTCJA = np.array(
71
71
  )
72
72
 
73
73
  # These are 2025 current (adjusted for inflation).
74
- stdDeduction_TCJA = np.array([15000, 30000]) # Single, MFJ
74
+ stdDeduction_OBBBA = np.array([15750, 31500]) # Single, MFJ
75
75
  # These are speculated (adjusted for inflation).
76
- stdDeduction_nonTCJA = np.array([8300, 16600]) # Single, MFJ
76
+ stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
77
77
 
78
- # These are current (adjusted for inflation).
78
+ # These are current (adjusted for inflation) per individual.
79
79
  extra65Deduction = np.array([2000, 1600]) # Single, MFJ
80
80
 
81
81
  # Thresholds for capital gains (adjusted for inflation).
@@ -90,11 +90,51 @@ capGainRates = np.array(
90
90
  niitThreshold = np.array([200000, 250000])
91
91
  niitRate = 0.038
92
92
 
93
+ # Thresholds for 65+ bonus for circumventing tax on social security.
94
+ bonusThreshold = np.array([75000, 150000])
95
+
93
96
  ###############################################################################
94
97
  # End of section where rates need to be actualized every year.
95
98
  ###############################################################################
96
99
 
97
100
 
101
+ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
102
+ """
103
+ Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
104
+ defining end points of constant piecewise linear functions representing IRMAA fees.
105
+ """
106
+ thisyear = date.today().year
107
+ assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
108
+ assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
109
+ Ni = len(yobs)
110
+ # What index year will Medicare start? 65 - age.
111
+ nm = 65 - (thisyear - yobs)
112
+ nm = np.min(nm)
113
+ # Has it already started?
114
+ nm = max(0, nm)
115
+ Nmed = Nn - nm
116
+
117
+ L = np.zeros((Nmed, Nq-1))
118
+ C = np.zeros((Nmed, Nq))
119
+
120
+ # Year starts at offset nm in the plan.
121
+ for nn in range(Nmed):
122
+ imed = 0
123
+ n = nm + nn
124
+ if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
125
+ imed += 1
126
+ if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
127
+ imed += 1
128
+ if imed:
129
+ status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
130
+ L[nn] = gamma_n[n] * irmaaBrackets[status][1:]
131
+ C[nn] = imed * gamma_n[n] * irmaaFees
132
+ else:
133
+ raise RuntimeError("mediVals: This should never happen.")
134
+
135
+ return nm, L, C
136
+
137
+
98
138
  def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
99
139
  """
100
140
  Return an array of decimal rates for capital gains.
@@ -105,7 +145,7 @@ def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
105
145
  cgRate_n = np.zeros(Nn)
106
146
 
107
147
  for n in range(Nn):
108
- if n == nd:
148
+ if status and n == nd:
109
149
  status -= 1
110
150
 
111
151
  if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
@@ -140,11 +180,11 @@ def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
140
180
  return costs
141
181
 
142
182
 
143
- def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
183
+ def taxParams(yobs, i_d, n_d, N_n, gamma_n, MAGI_n, yOBBBA=2099):
144
184
  """
145
185
  Input is year of birth, index of shortest-lived individual,
146
186
  lifespan of shortest-lived individual, total number of years
147
- in the plan, and the year that TCJA might expire.
187
+ in the plan, and the year that preTCJA rates might come back.
148
188
 
149
189
  It returns 3 time series:
150
190
  1) Standard deductions at year n (sigma_n).
@@ -154,15 +194,15 @@ def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
154
194
  Returned values are not indexed for inflation.
155
195
  """
156
196
  # Compute the deltas in-place between brackets, starting from the end.
157
- deltaBrackets_TCJA = np.array(taxBrackets_TCJA)
158
- deltaBrackets_nonTCJA = np.array(taxBrackets_nonTCJA)
197
+ deltaBrackets_OBBBA = np.array(taxBrackets_OBBBA)
198
+ deltaBrackets_preTCJA = np.array(taxBrackets_preTCJA)
159
199
  for t in range(6, 0, -1):
160
200
  for i in range(2):
161
- deltaBrackets_TCJA[i, t] -= deltaBrackets_TCJA[i, t - 1]
162
- deltaBrackets_nonTCJA[i, t] -= deltaBrackets_nonTCJA[i, t - 1]
201
+ deltaBrackets_OBBBA[i, t] -= deltaBrackets_OBBBA[i, t - 1]
202
+ deltaBrackets_preTCJA[i, t] -= deltaBrackets_preTCJA[i, t - 1]
163
203
 
164
204
  # Prepare the 3 arrays to return - use transpose for easy slicing.
165
- sigma = np.zeros((N_n))
205
+ sigmaBar = np.zeros((N_n))
166
206
  Delta = np.zeros((N_n, 7))
167
207
  theta = np.zeros((N_n, 7))
168
208
 
@@ -176,51 +216,57 @@ def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
176
216
  souls.remove(i_d)
177
217
  filingStatus -= 1
178
218
 
179
- if thisyear + n < y_TCJA:
180
- sigma[n] = stdDeduction_TCJA[filingStatus]
181
- Delta[n, :] = deltaBrackets_TCJA[filingStatus, :]
219
+ if thisyear + n < yOBBBA:
220
+ sigmaBar[n] = stdDeduction_OBBBA[filingStatus] * gamma_n[n]
221
+ Delta[n, :] = deltaBrackets_OBBBA[filingStatus, :]
182
222
  else:
183
- sigma[n] = stdDeduction_nonTCJA[filingStatus]
184
- Delta[n, :] = deltaBrackets_nonTCJA[filingStatus, :]
223
+ sigmaBar[n] = stdDeduction_preTCJA[filingStatus] * gamma_n[n]
224
+ Delta[n, :] = deltaBrackets_preTCJA[filingStatus, :]
185
225
 
186
- # Add 65+ additional exemption(s).
226
+ # Add 65+ additional exemption(s) and "bonus" phasing out.
187
227
  for i in souls:
188
228
  if thisyear + n - yobs[i] >= 65:
189
- sigma[n] += extra65Deduction[filingStatus]
229
+ sigmaBar[n] += extra65Deduction[filingStatus] * gamma_n[n]
230
+ if thisyear + n <= 2028:
231
+ sigmaBar[n] += 6000 * max(0, 1 - 0.06*max(0, MAGI_n[n] - bonusThreshold[filingStatus]))
190
232
 
191
233
  # Fill in future tax rates for year n.
192
- if thisyear + n < y_TCJA:
193
- theta[n, :] = rates_TCJA[:]
234
+ if thisyear + n < yOBBBA:
235
+ theta[n, :] = rates_OBBBA[:]
194
236
  else:
195
- theta[n, :] = rates_nonTCJA[:]
237
+ theta[n, :] = rates_preTCJA[:]
196
238
 
197
239
  Delta = Delta.transpose()
198
240
  theta = theta.transpose()
199
241
 
200
- # Return series unadjusted for inflation, in STD order.
201
- return sigma, theta, Delta
242
+ # Return series unadjusted for inflation, except for sigmaBar, in STD order.
243
+ return sigmaBar, theta, Delta
202
244
 
203
245
 
204
- def taxBrackets(N_i, n_d, N_n, y_TCJA):
246
+ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
205
247
  """
206
248
  Return dictionary containing future tax brackets
207
249
  unadjusted for inflation for plotting.
208
250
  """
209
251
  if not (0 < N_i <= 2):
210
252
  raise ValueError(f"Cannot process {N_i} individuals.")
253
+
211
254
  n_d = min(n_d, N_n)
212
255
  status = N_i - 1
213
256
 
214
- # Number of years left in TCJA from this year.
257
+ # Number of years left in OBBBA from this year.
215
258
  thisyear = date.today().year
216
- ytc = y_TCJA - thisyear
259
+ if yOBBBA < thisyear:
260
+ raise ValueError(f"Expiration year {yOBBBA} cannot be in the past.")
261
+
262
+ ytc = yOBBBA - thisyear
217
263
 
218
264
  data = {}
219
265
  for t in range(len(taxBracketNames) - 1):
220
266
  array = np.zeros(N_n)
221
267
  for n in range(N_n):
222
268
  stat = status if n < n_d else 0
223
- array[n] = taxBrackets_TCJA[stat][t] if n < ytc else taxBrackets_nonTCJA[stat][t]
269
+ array[n] = taxBrackets_OBBBA[stat][t] if n < ytc else taxBrackets_preTCJA[stat][t]
224
270
 
225
271
  data[taxBracketNames[t]] = array
226
272
 
@@ -280,12 +326,12 @@ def rho_in(yobs, N_n):
280
326
  thisyear = date.today().year
281
327
  for i in range(N_i):
282
328
  agenow = thisyear - yobs[i]
329
+ # Account for increase of RMD age between 2023 and 2032.
330
+ yrmd = 70 if yobs[i] < 1949 else 72 if 1949 <= yobs[i] <= 1950 else 73 if 1951 <= yobs[i] <= 1959 else 75
283
331
  for n in range(N_n):
284
- year = thisyear + n
285
332
  yage = agenow + n
286
333
 
287
- # Account for increase of RMD age between 2023 and 2032.
288
- if (yage < 73) or (year > 2032 and yage < 75):
334
+ if yage < yrmd:
289
335
  pass # rho[i][n] = 0
290
336
  else:
291
337
  rho[i][n] = 1.0 / rmdTable[yage - 72]
owlplanner/timelists.py CHANGED
@@ -12,7 +12,7 @@ Utility functions to read and check timelists.
12
12
 
13
13
  Copyright &copy; 2024 - Martin-D. Lacasse
14
14
 
15
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
15
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
16
16
 
17
17
  """
18
18
 
owlplanner/utils.py CHANGED
@@ -6,7 +6,7 @@ This file contains functions for handling data.
6
6
 
7
7
  Copyright &copy; 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.07.01"
1
+ __version__ = "2025.09.15"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.7.1
3
+ Version: 2025.9.15
4
4
  Summary: Owl: Retirement planner with great wisdom
5
5
  Project-URL: HomePage, https://github.com/mdlacasse/owl
6
6
  Project-URL: Repository, https://github.com/mdlacasse/owl
@@ -744,7 +744,7 @@ Look at *Basic capabilities* below for more detail.
744
744
  One can certainly have a savings plan, but due to the volatility of financial investments,
745
745
  it is impossible to have a certain asset earnings plan. This does not mean one cannot make decisions.
746
746
  These decisions need to be guided with an understanding of the sensitivity of the parameters.
747
- This is exactly where this tool fits it. Given your savings capabilities and spending desires,
747
+ This is exactly where this tool fits in. Given your savings capabilities and spending desires,
748
748
  it can generate different future realizations of
749
749
  your strategy under different market assumptions, helping to better understand your financial situation.
750
750
 
@@ -0,0 +1,22 @@
1
+ owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
+ owlplanner/abcapi.py,sha256=rtg7d0UbftinokR9VlB49VUjDjzUq3ONnJbhMXVIrgo,6879
3
+ owlplanner/config.py,sha256=UF2Dy6E9PiX6Ua8B1R0aYCNUoIYmY46up8awf_36B_Q,12615
4
+ owlplanner/mylogging.py,sha256=OVGeDFO7LIZG91R6HMpZBzjno-B8PH8Fo00Jw2Pdgqw,2558
5
+ owlplanner/plan.py,sha256=pIKULy5GFo_8xOZByZ9_CXrHx9TcXQpaob8cblK46N8,115180
6
+ owlplanner/progress.py,sha256=dUUlFmSAKUei36rUj2BINRY10f_YEUo_e23d0es6nrc,530
7
+ owlplanner/rates.py,sha256=9Nmo8AKsyi5PoCUrzhr06phkSlNTv-TXzj5iYFU76AY,14113
8
+ owlplanner/tax2025.py,sha256=2Jb_UbPT6ye-znRjA0nSaF8T8M17QW4MoRPDoW9XJ8s,10833
9
+ owlplanner/timelists.py,sha256=Q4kBt9kKAa5qxsvOe9wfyUtCQVgiwPmJXTwXUPRBBv8,4066
10
+ owlplanner/utils.py,sha256=afAjeO6Msf6Rn4jwz_7Ody9rHGWlBR7iQFqe1xzLNQc,2513
11
+ owlplanner/version.py,sha256=zy-IhWuHFT4sXG7yuvz7mQvMfgQ6BwWFA7KrAtVvwYg,28
12
+ owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
+ owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
15
+ owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2578
16
+ owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
17
+ owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
18
+ owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
19
+ owlplanner-2025.9.15.dist-info/METADATA,sha256=yacvHpuuPAtZ4fvLHH2UcbIdtkWwgVc0zwIvtOBNw64,54045
20
+ owlplanner-2025.9.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ owlplanner-2025.9.15.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
+ owlplanner-2025.9.15.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
- owlplanner/abcapi.py,sha256=8VCXS7nH_QZYxCUU3lwO0_UPR9Q5fuYQ6DHDLvHVLPg,6878
3
- owlplanner/config.py,sha256=-sSz37hwlnmI9_oXZn-R1rpmY0Vyk5L4X--NxGpgEMA,12446
4
- owlplanner/mylogging.py,sha256=RKUr-y-1XvKZzLMcfdtm4IM30LuRpJwb2qUeXmAWqME,2557
5
- owlplanner/plan.py,sha256=rivQ9lSFJx6Eahx83VyTOm6n4uxjbr6LiwiyChRhAnc,111133
6
- owlplanner/progress.py,sha256=2DOjOLo6Mo7m21wY-9iZhoUksAyi4VCbb6UL2RegNCw,529
7
- owlplanner/rates.py,sha256=7jXcuHbkJ3AVIeBYZdwme18rdYslIzCuT-c0cLzvKUU,14819
8
- owlplanner/tax2025.py,sha256=3uDJfKiSRFUp5WDcouAnTEQqEY7LnDKxqxDSKiTsOSQ,8927
9
- owlplanner/timelists.py,sha256=4pRumdoFlEmmh07wpGhDqauHl2doLG5JcRkvi41fvR4,4065
10
- owlplanner/utils.py,sha256=6Ky8ZKfNE9x--3znsZ8VZaT2PptDinszRxWsOCPanu8,2512
11
- owlplanner/version.py,sha256=J00-UKwWWUboYr10jyhzSLOwp2iWtGoC1DRPgnuINV0,28
12
- owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
- owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
15
- owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2578
16
- owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
17
- owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
18
- owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
19
- owlplanner-2025.7.1.dist-info/METADATA,sha256=_T6vNe7aESAIt668fK9IVb0VDEv5P3Z16bYPyTvr9QY,54044
20
- owlplanner-2025.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- owlplanner-2025.7.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
- owlplanner-2025.7.1.dist-info/RECORD,,