owlplanner 2025.4.26__tar.gz → 2025.5.1__tar.gz

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.
Files changed (120) hide show
  1. owlplanner-2025.5.1/.thediffs.swp +0 -0
  2. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/PKG-INFO +1 -1
  3. owlplanner-2025.5.1/git-cheats +11 -0
  4. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/pyproject.toml +1 -1
  5. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/abcapi.py +17 -21
  6. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/plan.py +198 -179
  7. owlplanner-2025.5.1/src/owlplanner/version.py +1 -0
  8. owlplanner-2025.5.1/thediffs +0 -0
  9. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Documentation.py +8 -0
  10. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Optimization_Parameters.py +6 -5
  11. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/requirements.txt +2 -1
  12. owlplanner-2025.4.26/src/owlplanner/version.py +0 -1
  13. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/.devcontainer/devcontainer.json +0 -0
  14. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/.flake8 +0 -0
  15. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/.gitattributes +0 -0
  16. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/.github/workflows/github-actions-runtests.yml +0 -0
  17. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/.gitignore +0 -0
  18. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/INSTALL.md +0 -0
  19. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/LICENSE +0 -0
  20. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/BF01580653.pdf +0 -0
  21. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/FE00006821-Class-VI-Injection-Permit--Salient-Features-and-Regulatory-Challenges_Final.pdf +0 -0
  22. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/Kou-OptionPricingDouble-2004.pdf +0 -0
  23. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/Multi-Period Mean Expected-Shortfall Strategies Cut Your Losses and Ride Your Gains .pdf +0 -0
  24. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/Optimal Asset Allocation for Retirement Saving Deterministic Vs. Time Consistent Adaptive Strategies.pdf +0 -0
  25. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/Rule-based_strategies_for_dynamic_life_cycle_inves.pdf +0 -0
  26. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/Snyder-LinearProgrammingSpecial-1984.pdf +0 -0
  27. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/WileyModelBuildinginMathematicalProgramming5th2013.sharethefiles.com.pdf +0 -0
  28. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/bv_cvxbook.pdf +0 -0
  29. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/Papers/s10436-006-0062-y.pdf +0 -0
  30. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/README.md +0 -0
  31. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/USER_GUIDE.md +0 -0
  32. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docker/Dockerfile +0 -0
  33. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docker/README.md +0 -0
  34. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docker/docker-compose.yml +0 -0
  35. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docker/fastentrypoint.sh +0 -0
  36. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/AD-taxDef.png +0 -0
  37. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/AD-taxFree.png +0 -0
  38. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/AD-taxable.png +0 -0
  39. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/Hist_Bequest.png +0 -0
  40. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/Hist_Spending.png +0 -0
  41. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/MC-tutorial2a.png +0 -0
  42. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/MC-tutorial2b.png +0 -0
  43. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/OwlUI.png +0 -0
  44. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/allocations.png +0 -0
  45. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/owl.png +0 -0
  46. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/profile.png +0 -0
  47. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/ratesCorrelations.png +0 -0
  48. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/ratesPlot.png +0 -0
  49. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/savingsPlot.png +0 -0
  50. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/sourcesPlot.png +0 -0
  51. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/spendingPlot.png +0 -0
  52. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/taxIncomePlot.png +0 -0
  53. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/images/taxesPlot.png +0 -0
  54. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/newowl.tex +0 -0
  55. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/owl.pdf +0 -0
  56. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/docs/owl.tex +0 -0
  57. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_jack+jill.toml +0 -0
  58. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_joe.toml +0 -0
  59. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_john+sally.toml +0 -0
  60. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_jon+jane.toml +0 -0
  61. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_kim+sam-bequest.toml +0 -0
  62. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/case_kim+sam-spending.toml +0 -0
  63. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/jack+jill.xlsx +0 -0
  64. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/joe.xlsx +0 -0
  65. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/john+sally.xlsx +0 -0
  66. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/jon+jane.xlsx +0 -0
  67. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/examples/template.xlsx +0 -0
  68. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/john+sally.ipynb +0 -0
  69. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/kim+sam.ipynb +0 -0
  70. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/template.ipynb +0 -0
  71. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/tutorial_1.ipynb +0 -0
  72. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/tutorial_2.ipynb +0 -0
  73. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/notebooks/tutorial_3.ipynb +0 -0
  74. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/owlplanner.cmd +0 -0
  75. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/owlplanner.sh +0 -0
  76. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/reponse.txt +0 -0
  77. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/requirements.txt +0 -0
  78. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/__init__.py +0 -0
  79. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/config.py +0 -0
  80. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/data/__init__.py +0 -0
  81. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/data/rates.csv +0 -0
  82. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/logging.py +0 -0
  83. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/progress.py +0 -0
  84. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/rates.py +0 -0
  85. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/tax2025.py +0 -0
  86. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/timelists.py +0 -0
  87. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/src/owlplanner/utils.py +0 -0
  88. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/tests/test_logger.py +0 -0
  89. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/tests/test_regressions.py +0 -0
  90. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/tests/test_repro.py +0 -0
  91. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/tests/test_toml_cases.py +0 -0
  92. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/tests/test_units.py +0 -0
  93. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ttt.py +0 -0
  94. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ttt2.py +0 -0
  95. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ttt3.py +0 -0
  96. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/About_Owl.py +0 -0
  97. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Asset_Allocation.py +0 -0
  98. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Create_Case.py +0 -0
  99. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Current_Assets.py +0 -0
  100. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Fixed_Income.py +0 -0
  101. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Graphs.py +0 -0
  102. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Historical_Range.py +0 -0
  103. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Logs.py +0 -0
  104. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Monte_Carlo.py +0 -0
  105. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Output_Files.py +0 -0
  106. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Quick_Start.py +0 -0
  107. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/README.md +0 -0
  108. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Rates_Selection.py +0 -0
  109. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Settings.py +0 -0
  110. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Wages_And_Contributions.py +0 -0
  111. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/Worksheets.py +0 -0
  112. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/main+fonts.py +0 -0
  113. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/main.py +0 -0
  114. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/owlbridge.py +0 -0
  115. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/plots.py +0 -0
  116. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/progress.py +0 -0
  117. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/sskeys.py +0 -0
  118. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/sskeys.py.color +0 -0
  119. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/style.css +0 -0
  120. {owlplanner-2025.4.26 → owlplanner-2025.5.1}/ui/tomlexamples.py +0 -0
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.4.26
3
+ Version: 2025.5.1
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
@@ -0,0 +1,11 @@
1
+ # Restore older version as HEAD
2
+ git checkout <thursday good commit hash>
3
+ git checkout -b fix-branch
4
+ git cherry-pick <another good commit hash>
5
+
6
+ (repeat cherry pick for each commit you want)
7
+ Now you have a branch with just the commits you want. You can merge it back into master like this:
8
+
9
+ git merge --strategy=ours master
10
+ git checkout master
11
+ git merge fix-branch
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "owlplanner"
7
- version = "2025.04.26"
7
+ version = "2025.05.01"
8
8
  authors = [
9
9
  { name="Martin-D. Lacasse", email="martin.d.lacasse@gmail.com" },
10
10
  ]
@@ -47,12 +47,10 @@ class Row(object):
47
47
  self.ind.append(ind)
48
48
  self.val.append(val)
49
49
 
50
- def addElemDic(self, rowDic=None):
50
+ def addElemDic(self, rowDic={}):
51
51
  """
52
52
  Add elements at indices provided by a dictionary.
53
53
  """
54
- if rowDic is None:
55
- rowDic = {}
56
54
  for key in rowDic:
57
55
  self.addElem(key, rowDic[key])
58
56
  return self
@@ -75,13 +73,11 @@ class ConstraintMatrix(object):
75
73
  self.ub = []
76
74
  self.key = []
77
75
 
78
- def newRow(self, rowDic=None):
76
+ def newRow(self, rowDic={}):
79
77
  """
80
78
  Create a new row and populate its elements using the dictionary provided.
81
79
  Return the row created.
82
80
  """
83
- if rowDic is None:
84
- rowDic = {}
85
81
  row = Row(self.nvars)
86
82
  row.addElemDic(rowDic)
87
83
  return row
@@ -97,8 +93,12 @@ class ConstraintMatrix(object):
97
93
  self.ub.append(ub)
98
94
  if lb == ub:
99
95
  self.key.append("fx")
96
+ elif ub == np.inf and lb == -np.inf:
97
+ self.key.append("fr")
100
98
  elif ub == np.inf:
101
99
  self.key.append("lo")
100
+ elif lb == -np.inf:
101
+ self.key.append("up")
102
102
  else:
103
103
  self.key.append("ra")
104
104
  self.ncons += 1
@@ -145,13 +145,16 @@ class Bounds(object):
145
145
  Solver-neutral API for bounds on variables.
146
146
  """
147
147
 
148
- def __init__(self, nvars):
148
+ def __init__(self, nvars, nbins):
149
149
  self.nvars = nvars
150
+ self.nbins = nbins
150
151
  self.ind = []
151
152
  self.lb = []
152
153
  self.ub = []
153
154
  self.key = []
154
155
  self.integrality = []
156
+ for ii in range(nvars-nbins, nvars):
157
+ self.setBinary(ii)
155
158
 
156
159
  def setBinary(self, ii):
157
160
  assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
@@ -161,27 +164,20 @@ class Bounds(object):
161
164
  self.key.append("ra")
162
165
  self.integrality.append(ii)
163
166
 
164
- def set0_Ub(self, ii, ub):
165
- assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
166
- self.ind.append(ii)
167
- self.lb.append(0)
168
- self.ub.append(ub)
169
- self.key.append("ra")
170
-
171
- def setLb_Inf(self, ii, lb):
172
- assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
173
- self.ind.append(ii)
174
- self.lb.append(lb)
175
- self.ub.append(np.inf)
176
- self.key.append("lo")
177
-
178
167
  def setRange(self, ii, lb, ub):
179
168
  assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
169
+ assert lb <= ub, f"Lower bound {lb} > upper bound {ub}."
180
170
  self.ind.append(ii)
181
171
  self.lb.append(lb)
182
172
  self.ub.append(ub)
183
173
  if lb == ub:
184
174
  self.key.append("fx")
175
+ elif ub == np.inf and lb == -np.inf:
176
+ self.key.append("fr")
177
+ elif ub == np.inf:
178
+ self.key.append("lo")
179
+ elif lb == -np.inf:
180
+ self.key.append("up")
185
181
  else:
186
182
  self.key.append("ra")
187
183
 
@@ -1026,7 +1026,7 @@ class Plan(object):
1026
1026
  Utility function to map variables to a block vector.
1027
1027
  Refer to companion document for explanations.
1028
1028
  """
1029
- # Stack all variables in a single block vector.
1029
+ # Stack all variables in a single block vector with all binary variables at the end.
1030
1030
  C = {}
1031
1031
  C["b"] = 0
1032
1032
  C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
@@ -1038,9 +1038,13 @@ class Plan(object):
1038
1038
  C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
1039
1039
  C["z"] = _qC(C["x"], self.N_i, self.N_n)
1040
1040
  self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
1041
+ self.nbins = self.nvars - C["z"]
1042
+ # # self.nvars = _qC(C["x"], self.N_i, self.N_n)
1043
+ # # self.nbins = 0
1041
1044
 
1042
1045
  self.C = C
1043
- self.mylog.vprint(f"Problem has {len(C)} distinct time series forming {self.nvars} decision variables.")
1046
+ self.mylog.vprint(
1047
+ f"Problem has {len(C)} distinct series, {self.nvars} decision variables (including {self.nbins} binary).")
1044
1048
 
1045
1049
  return None
1046
1050
 
@@ -1095,7 +1099,7 @@ class Plan(object):
1095
1099
  ###################################################################
1096
1100
  # Inequality constraint matrix with upper and lower bound vectors.
1097
1101
  A = abc.ConstraintMatrix(self.nvars)
1098
- B = abc.Bounds(self.nvars)
1102
+ B = abc.Bounds(self.nvars, self.nbins)
1099
1103
 
1100
1104
  # RMDs inequalities, only if there is an initial balance in tax-deferred account.
1101
1105
  for i in range(Ni):
@@ -1110,11 +1114,19 @@ class Plan(object):
1110
1114
  # Income tax bracket range inequalities.
1111
1115
  for t in range(Nt):
1112
1116
  for n in range(Nn):
1113
- B.set0_Ub(_q2(CF, t, n, Nt, Nn), self.DeltaBar_tn[t, n])
1117
+ B.setRange(_q2(CF, t, n, Nt, Nn), zero, self.DeltaBar_tn[t, n])
1114
1118
 
1115
1119
  # Standard exemption range inequalities.
1116
1120
  for n in range(Nn):
1117
- B.set0_Ub(_q1(Ce, n, Nn), self.sigmaBar_n[n])
1121
+ B.setRange(_q1(Ce, n, Nn), zero, self.sigmaBar_n[n])
1122
+
1123
+ # Start with no activities after passing.
1124
+ for i in range(Ni):
1125
+ for n in range(self.horizons[i], Nn):
1126
+ B.setRange(_q2(Cd, i, n, Ni, Nn), zero, zero)
1127
+ B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1128
+ for j in range(Nj):
1129
+ B.setRange(_q3(Cw, i, j, n, Ni, Nj, Nn), zero, zero)
1118
1130
 
1119
1131
  # Roth conversions equalities/inequalities.
1120
1132
  # This condition supercedes everything else.
@@ -1136,8 +1148,9 @@ class Plan(object):
1136
1148
  # self.mylog.vprint('Limiting Roth conversions to:', u.d(rhsopt))
1137
1149
  for i in range(Ni):
1138
1150
  for n in range(self.horizons[i]):
1139
- # Should we adjust Roth conversion cap with inflation?
1140
- B.set0_Ub(_q2(Cx, i, n, Ni, Nn), rhsopt)
1151
+ # MOSEK chokes if completely zero. Add a 1 cent slack.
1152
+ # Should we adjust Roth conversion cap with inflation?
1153
+ B.setRange(_q2(Cx, i, n, Ni, Nn), zero, rhsopt + 0.01)
1141
1154
 
1142
1155
  # Process startRothConversions option.
1143
1156
  if "startRothConversions" in options:
@@ -1149,7 +1162,7 @@ class Plan(object):
1149
1162
  for i in range(Ni):
1150
1163
  nstart = min(yearn, self.horizons[i])
1151
1164
  for n in range(0, nstart):
1152
- B.set0_Ub(_q2(Cx, i, n, Ni, Nn), zero)
1165
+ B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
1153
1166
 
1154
1167
  # Process noRothConversions option. Also valid when N_i == 1, why not?
1155
1168
  if "noRothConversions" in options and options["noRothConversions"] != "None":
@@ -1160,7 +1173,7 @@ class Plan(object):
1160
1173
  raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:")
1161
1174
 
1162
1175
  for n in range(Nn):
1163
- B.set0_Ub(_q2(Cx, i_x, n, Ni, Nn), zero)
1176
+ B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
1164
1177
 
1165
1178
  # Impose withdrawal limits on taxable and tax-exempt accounts.
1166
1179
  for i in range(Ni):
@@ -1206,13 +1219,15 @@ class Plan(object):
1206
1219
  spending *= units * self.yearFracLeft
1207
1220
  # self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
1208
1221
  # To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
1209
- A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
1222
+ # A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
1223
+ B.setRange(_q1(Cg, 0, Nn), spending, spending)
1210
1224
 
1211
- # Set initial balances through constraints.
1225
+ # Set initial balances through bounds or constraints.
1212
1226
  for i in range(Ni):
1213
1227
  for j in range(Nj):
1214
1228
  rhs = self.beta_ij[i, j]
1215
- A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
1229
+ # A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
1230
+ B.setRange(_q3(Cb, i, j, 0, Ni, Nj, Nn + 1), rhs, rhs)
1216
1231
 
1217
1232
  # Link surplus and taxable account deposits regardless of Ni.
1218
1233
  for i in range(Ni):
@@ -1226,20 +1241,20 @@ class Plan(object):
1226
1241
  A.addNewRow(rowDic, zero, zero)
1227
1242
 
1228
1243
  # No surplus allowed during the last year to be used as a tax loophole.
1229
- B.set0_Ub(_q1(Cs, Nn - 1, Nn), zero)
1244
+ B.setRange(_q1(Cs, Nn - 1, Nn), zero, zero)
1230
1245
 
1231
1246
  if Ni == 2:
1232
1247
  # No conversion during last year.
1233
- # B.set0_Ub(_q2(Cx, i_d, nd-1, Ni, Nn), zero)
1234
- # B.set0_Ub(_q2(Cx, i_s, Nn-1, Ni, Nn), zero)
1248
+ # B.setRange(_q2(Cx, i_d, nd-1, Ni, Nn), zero, zero)
1249
+ # B.setRange(_q2(Cx, i_s, Nn-1, Ni, Nn), zero, zero)
1235
1250
 
1236
1251
  # No withdrawals or deposits for any i_d-owned accounts after year of passing.
1237
1252
  # Implicit n_d < Nn imposed by for loop.
1238
1253
  for n in range(n_d, Nn):
1239
- B.set0_Ub(_q2(Cd, i_d, n, Ni, Nn), zero)
1240
- B.set0_Ub(_q2(Cx, i_d, n, Ni, Nn), zero)
1254
+ B.setRange(_q2(Cd, i_d, n, Ni, Nn), zero, zero)
1255
+ B.setRange(_q2(Cx, i_d, n, Ni, Nn), zero, zero)
1241
1256
  for j in range(Nj):
1242
- B.set0_Ub(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero)
1257
+ B.setRange(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero, zero)
1243
1258
 
1244
1259
  # Account balances carried from year to year.
1245
1260
  # Considering spousal asset transfer at passing of a spouse.
@@ -1312,8 +1327,8 @@ class Plan(object):
1312
1327
 
1313
1328
  # Impose income profile.
1314
1329
  for n in range(1, Nn):
1315
- rowDic = {_q1(Cg, 0, Nn): -spLo * self.xiBar_n[n], _q1(Cg, n, Nn): self.xiBar_n[0]}
1316
- A.addNewRow(rowDic, zero, inf)
1330
+ rowDic = {_q1(Cg, 0, Nn): spLo * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1331
+ A.addNewRow(rowDic, -inf, zero)
1317
1332
  rowDic = {_q1(Cg, 0, Nn): spHi * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
1318
1333
  A.addNewRow(rowDic, zero, inf)
1319
1334
 
@@ -1343,8 +1358,8 @@ class Plan(object):
1343
1358
  # Configure binary variables.
1344
1359
  for i in range(Ni):
1345
1360
  for n in range(self.horizons[i]):
1346
- for z in range(Nz):
1347
- B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
1361
+ # for z in range(Nz):
1362
+ # B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
1348
1363
 
1349
1364
  # Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
1350
1365
  A.addNewRow(
@@ -1376,6 +1391,10 @@ class Plan(object):
1376
1391
  bigM,
1377
1392
  )
1378
1393
 
1394
+ for n in range(self.horizons[i], Nn):
1395
+ B.setRange(_q3(Cz, i, n, 0, Ni, Nn, Nz), zero, zero)
1396
+ B.setRange(_q3(Cz, i, n, 1, Ni, Nn, Nz), zero, zero)
1397
+
1379
1398
  # Now build a solver-neutral objective vector.
1380
1399
  c = abc.Objective(self.nvars)
1381
1400
  if objective == "maxSpending":
@@ -1597,7 +1616,7 @@ class Plan(object):
1597
1616
 
1598
1617
  @_checkConfiguration
1599
1618
  @_timer
1600
- def solve(self, objective, options=None):
1619
+ def solve(self, objective, options={}):
1601
1620
  """
1602
1621
  This function builds the necessary constaints and
1603
1622
  runs the optimizer.
@@ -1622,7 +1641,8 @@ class Plan(object):
1622
1641
 
1623
1642
  # Check objective and required options.
1624
1643
  knownObjectives = ["maxBequest", "maxSpending"]
1625
- knownSolvers = ["HiGHS", "MOSEK"]
1644
+ knownSolvers = ["HiGHS", "PuLP/CBC", "MOSEK"]
1645
+
1626
1646
  knownOptions = [
1627
1647
  "bequest",
1628
1648
  "bigM",
@@ -1636,11 +1656,8 @@ class Plan(object):
1636
1656
  "units",
1637
1657
  "withMedicare",
1638
1658
  ]
1639
- # We will modify options if required.
1640
- if options is None:
1641
- myoptions = {}
1642
- else:
1643
- myoptions = dict(options)
1659
+ # We might modify options if required.
1660
+ myoptions = dict(options)
1644
1661
 
1645
1662
  for opt in myoptions:
1646
1663
  if opt not in knownOptions:
@@ -1672,91 +1689,70 @@ class Plan(object):
1672
1689
  units = u.getUnits(options.get("units", "k"))
1673
1690
  self.prevMAGI = units * np.array(magi)
1674
1691
 
1675
- self.lambdha = 0
1676
- if "spendingSlack" in myoptions:
1677
- lambdha = myoptions["spendingSlack"]
1678
- if lambdha < 0 or lambdha > 50:
1679
- raise ValueError(f"Slack value out of range {lambdha}.")
1680
- self.lambdha = lambdha / 100
1692
+ lambdha = myoptions.get("spendingSlack", 0)
1693
+ if lambdha < 0 or lambdha > 50:
1694
+ raise ValueError(f"Slack value out of range {lambdha}.")
1695
+ self.lambdha = lambdha / 100
1681
1696
 
1682
1697
  self._adjustParameters()
1683
1698
 
1684
- if "solver" in options:
1685
- solver = myoptions["solver"]
1686
- if solver not in knownSolvers:
1687
- raise ValueError(f"Unknown solver {solver}.")
1688
- else:
1689
- solver = self.defaultSolver
1699
+ solver = myoptions.get("solver", self.defaultSolver)
1700
+ if solver not in knownSolvers:
1701
+ raise ValueError(f"Unknown solver {solver}.")
1690
1702
 
1691
1703
  if solver == "HiGHS":
1692
- self._milpSolve(objective, myoptions)
1704
+ solverMethod = self._milpSolve
1705
+ elif solver == "PuLP/CBC":
1706
+ solverMethod = self._pulpSolve
1693
1707
  elif solver == "MOSEK":
1694
- self._mosekSolve(objective, myoptions)
1708
+ solverMethod = self._mosekSolve
1709
+ else:
1710
+ raise RuntimeError("Internal error in defining solverMethod.")
1711
+
1712
+ self._scSolve(objective, options, solverMethod)
1695
1713
 
1696
1714
  self.objective = objective
1697
1715
  self.solverOptions = myoptions
1698
1716
 
1699
1717
  return None
1700
1718
 
1701
- def _milpSolve(self, objective, options):
1719
+ def _scSolve(self, objective, options, solverMethod):
1702
1720
  """
1703
- Solve problem using scipy HiGHS solver.
1721
+ Self-consistent loop, regardless of solver.
1704
1722
  """
1705
- from scipy import optimize
1706
-
1707
- withMedicare = True
1708
- if "withMedicare" in options and options["withMedicare"] is False:
1709
- withMedicare = False
1723
+ withMedicare = options.get("withMedicare", True)
1710
1724
 
1711
1725
  if objective == "maxSpending":
1712
1726
  objFac = -1 / self.xi_n[0]
1713
1727
  else:
1714
1728
  objFac = -1 / self.gamma_n[-1]
1715
1729
 
1716
- # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1717
- milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
1718
-
1719
1730
  it = 0
1720
1731
  absdiff = np.inf
1721
1732
  old_x = np.zeros(self.nvars)
1722
1733
  old_solutions = [np.inf]
1723
1734
  self._estimateMedicare(None, withMedicare)
1724
1735
  while True:
1725
- self._buildConstraints(objective, options)
1726
- Alu, lbvec, ubvec = self.A.arrays()
1727
- Lb, Ub = self.B.arrays()
1728
- integrality = self.B.integralityArray()
1729
- c = self.c.arrays()
1730
-
1731
- bounds = optimize.Bounds(Lb, Ub)
1732
- constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
1733
- solution = optimize.milp(
1734
- c,
1735
- integrality=integrality,
1736
- constraints=constraint,
1737
- bounds=bounds,
1738
- options=milpOptions,
1739
- )
1740
- it += 1
1736
+ solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1741
1737
 
1742
- if not solution.success:
1738
+ if not solverSuccess:
1743
1739
  break
1744
1740
 
1745
1741
  if not withMedicare:
1746
1742
  break
1747
1743
 
1748
- self._estimateMedicare(solution.x)
1744
+ self._estimateMedicare(xx)
1749
1745
 
1750
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution.fun * objFac, f=2)}")
1746
+ self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
1751
1747
 
1752
- delta = solution.x - old_x
1748
+ delta = xx - old_x
1753
1749
  absdiff = np.sum(np.abs(delta), axis=0)
1754
1750
  if absdiff < 1:
1755
1751
  self.mylog.vprint("Converged on full solution.")
1756
1752
  break
1757
1753
 
1758
1754
  # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1759
- isclosenough = abs(-solution.fun - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1755
+ isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1760
1756
  if isclosenough:
1761
1757
  self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1762
1758
  break
@@ -1765,39 +1761,114 @@ class Plan(object):
1765
1761
  self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
1766
1762
  break
1767
1763
 
1768
- old_solutions.append(-solution.fun)
1769
- old_x = solution.x
1764
+ old_solutions.append(-solution)
1765
+ old_x = xx
1770
1766
 
1771
- if solution.success:
1767
+ if solverSuccess:
1772
1768
  self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
1773
- self.mylog.vprint(solution.message)
1774
- self.mylog.vprint(f"Objective: {u.d(solution.fun * objFac)}")
1769
+ self.mylog.vprint(solverMsg)
1770
+ self.mylog.vprint(f"Objective: {u.d(solution * objFac)}")
1775
1771
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1776
- self._aggregateResults(solution.x)
1772
+ self._aggregateResults(xx)
1777
1773
  self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
1778
1774
  self.caseStatus = "solved"
1779
1775
  else:
1780
- self.mylog.vprint("WARNING: Optimization failed:", solution.message, solution.success)
1776
+ self.mylog.vprint("WARNING: Optimization failed:", solverMsg, solverSuccess)
1781
1777
  self.caseStatus = "unsuccessful"
1782
1778
 
1783
1779
  return None
1784
1780
 
1785
- def _mosekSolve(self, objective, options):
1781
+ def _milpSolve(self, objective, options):
1786
1782
  """
1787
- Solve problem using MOSEK solver.
1783
+ Solve problem using scipy HiGHS solver.
1788
1784
  """
1789
- import mosek
1785
+ from scipy import optimize
1790
1786
 
1791
- withMedicare = True
1792
- if "withMedicare" in options and options["withMedicare"] is False:
1793
- withMedicare = False
1787
+ # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1788
+ milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
1794
1789
 
1795
- if objective == "maxSpending":
1796
- objFac = -1 / self.xi_n[0]
1797
- else:
1798
- objFac = -1 / self.gamma_n[-1]
1790
+ self._buildConstraints(objective, options)
1791
+ Alu, lbvec, ubvec = self.A.arrays()
1792
+ Lb, Ub = self.B.arrays()
1793
+ integrality = self.B.integralityArray()
1794
+ c = self.c.arrays()
1795
+
1796
+ bounds = optimize.Bounds(Lb, Ub)
1797
+ constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
1798
+ solution = optimize.milp(
1799
+ c,
1800
+ integrality=integrality,
1801
+ constraints=constraint,
1802
+ bounds=bounds,
1803
+ options=milpOptions,
1804
+ )
1799
1805
 
1800
- # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1806
+ return solution.fun, solution.x, solution.success, solution.message
1807
+
1808
+ def _pulpSolve(self, objective, options):
1809
+ """
1810
+ Solve problem using scipy PuLP solver.
1811
+ """
1812
+ import pulp
1813
+
1814
+ self._buildConstraints(objective, options)
1815
+ Alu, lbvec, ubvec = self.A.arrays()
1816
+ ckeys = self.A.keys()
1817
+ Lb, Ub = self.B.arrays()
1818
+ vkeys = self.B.keys()
1819
+ c = self.c.arrays()
1820
+ c_list = c.tolist()
1821
+
1822
+ prob = pulp.LpProblem(self._name.replace(" ", "_"), pulp.LpMinimize)
1823
+
1824
+ x = []
1825
+ for i in range(self.nvars - self.nbins):
1826
+ if vkeys[i] == "ra":
1827
+ x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
1828
+ elif vkeys[i] == "lo":
1829
+ x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=None)]
1830
+ elif vkeys[i] == "up":
1831
+ x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=None, upBound=Ub[i])]
1832
+ elif vkeys[i] == "fr":
1833
+ x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=None, upBound=None)]
1834
+ elif vkeys[i] == "fx":
1835
+ x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
1836
+ else:
1837
+ raise RuntimeError(f"Internal error: Variable with wierd bound f{vkeys[i]}.")
1838
+
1839
+ x.extend([pulp.LpVariable(f"z_{i}", cat="Binary") for i in range(self.nbins)])
1840
+
1841
+ prob += pulp.lpDot(c_list, x)
1842
+
1843
+ for r in range(self.A.ncons):
1844
+ row = Alu[r].tolist()
1845
+ if ckeys[r] in ["lo", "ra"] and lbvec[r] != -np.inf:
1846
+ prob += pulp.lpDot(row, x) >= lbvec[r]
1847
+ if ckeys[r] in ["up", "ra"] and ubvec[r] != np.inf:
1848
+ prob += pulp.lpDot(row, x) <= ubvec[r]
1849
+ if ckeys[r] == "fx":
1850
+ prob += pulp.lpDot(row, x) == ubvec[r]
1851
+
1852
+ # prob.writeLP("C:\\Users\\marti\\Downloads\\pulp.lp")
1853
+ # prob.writeMPS("C:\\Users\\marti\\Downloads\\pulp.mps", rename=True)
1854
+ # solver_list = pulp.listSolvers(onlyAvailable=True)
1855
+ # print("Available solvers:", solver_list)
1856
+ # solver = pulp.getSolver("MOSEK")
1857
+ # prob.solve(solver)
1858
+
1859
+ prob.solve(pulp.PULP_CBC_CMD(msg=False))
1860
+ # Filter out None values and convert to array.
1861
+ xx = np.array([0 if x[i].varValue is None else x[i].varValue for i in range(self.nvars)])
1862
+ solution = np.dot(c, xx)
1863
+ success = (pulp.LpStatus[prob.status] == "Optimal")
1864
+
1865
+ return solution, xx, success, pulp.LpStatus[prob.status]
1866
+
1867
+ def _mosekSolve(self, objective, options):
1868
+ """
1869
+ Solve problem using MOSEK solver.
1870
+ """
1871
+ import mosek
1801
1872
 
1802
1873
  bdic = {
1803
1874
  "fx": mosek.boundkey.fx,
@@ -1807,95 +1878,53 @@ class Plan(object):
1807
1878
  "up": mosek.boundkey.up,
1808
1879
  }
1809
1880
 
1810
- it = 0
1811
- absdiff = np.inf
1812
- old_x = np.zeros(self.nvars)
1813
- old_solutions = [np.inf]
1814
- self._estimateMedicare(None, withMedicare)
1815
- while True:
1816
- self._buildConstraints(objective, options)
1817
- Aind, Aval, clb, cub = self.A.lists()
1818
- ckeys = self.A.keys()
1819
- vlb, vub = self.B.arrays()
1820
- integrality = self.B.integralityList()
1821
- vkeys = self.B.keys()
1822
- cind, cval = self.c.lists()
1823
-
1824
- task = mosek.Task()
1825
- # task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-5)
1826
- # task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
1827
- # task.set_Stream(mosek.streamtype.msg, _streamPrinter)
1828
- task.appendcons(self.A.ncons)
1829
- task.appendvars(self.A.nvars)
1830
-
1831
- for ii in range(len(cind)):
1832
- task.putcj(cind[ii], cval[ii])
1833
-
1834
- for ii in range(self.nvars):
1835
- task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
1836
-
1837
- for ii in range(len(integrality)):
1838
- task.putvartype(integrality[ii], mosek.variabletype.type_int)
1839
-
1840
- for ii in range(self.A.ncons):
1841
- task.putarow(ii, Aind[ii], Aval[ii])
1842
- task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
1843
-
1844
- task.putobjsense(mosek.objsense.minimize)
1845
- task.optimize()
1846
-
1847
- solsta = task.getsolsta(mosek.soltype.itg)
1848
- # prosta = task.getprosta(mosek.soltype.itg)
1849
- it += 1
1850
-
1851
- if solsta != mosek.solsta.integer_optimal:
1852
- break
1881
+ solverMsg = str()
1853
1882
 
1854
- xx = np.array(task.getxx(mosek.soltype.itg))
1855
- solution = task.getprimalobj(mosek.soltype.itg)
1883
+ def _streamPrinter(text, msg=solverMsg):
1884
+ msg += text
1856
1885
 
1857
- if withMedicare is False:
1858
- break
1886
+ self._buildConstraints(objective, options)
1887
+ Aind, Aval, clb, cub = self.A.lists()
1888
+ ckeys = self.A.keys()
1889
+ vlb, vub = self.B.arrays()
1890
+ integrality = self.B.integralityList()
1891
+ vkeys = self.B.keys()
1892
+ cind, cval = self.c.lists()
1859
1893
 
1860
- self._estimateMedicare(xx)
1894
+ task = mosek.Task()
1895
+ # task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6)
1896
+ # task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
1897
+ # task.set_Stream(mosek.streamtype.msg, _streamPrinter)
1898
+ task.appendcons(self.A.ncons)
1899
+ task.appendvars(self.A.nvars)
1861
1900
 
1862
- self.mylog.vprint("Iteration:", it, "objective:", u.d(solution * objFac, f=2))
1901
+ for ii in range(len(cind)):
1902
+ task.putcj(cind[ii], cval[ii])
1863
1903
 
1864
- delta = xx - old_x
1865
- absdiff = np.sum(np.abs(delta), axis=0)
1866
- if absdiff < 1:
1867
- self.mylog.vprint("Converged on full solution.")
1868
- break
1904
+ for ii in range(self.nvars):
1905
+ task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
1869
1906
 
1870
- # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1871
- isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1872
- if isclosenough:
1873
- self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1874
- break
1907
+ for ii in range(len(integrality)):
1908
+ task.putvartype(integrality[ii], mosek.variabletype.type_int)
1875
1909
 
1876
- if it > 59:
1877
- self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
1878
- break
1910
+ for ii in range(self.A.ncons):
1911
+ task.putarow(ii, Aind[ii], Aval[ii])
1912
+ task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
1879
1913
 
1880
- old_solutions.append(-solution)
1881
- old_x = xx
1914
+ task.putobjsense(mosek.objsense.minimize)
1915
+ task.optimize()
1916
+
1917
+ # Problem MUST contain binary variables to make these calls.
1918
+ solsta = task.getsolsta(mosek.soltype.itg)
1919
+ solverSuccess = (solsta == mosek.solsta.integer_optimal)
1882
1920
 
1921
+ xx = np.array(task.getxx(mosek.soltype.itg))
1922
+ solution = task.getprimalobj(mosek.soltype.itg)
1883
1923
  task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
1924
+ task.solutionsummary(mosek.streamtype.msg)
1884
1925
  # task.writedata(self._name+'.ptf')
1885
- if solsta == mosek.solsta.integer_optimal:
1886
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
1887
- task.solutionsummary(mosek.streamtype.msg)
1888
- self.mylog.vprint("Objective:", u.d(solution * objFac))
1889
- self.caseStatus = "solved"
1890
- # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1891
- self._aggregateResults(xx)
1892
- self._timestamp = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
1893
- else:
1894
- self.mylog.vprint("WARNING: Optimization failed:", "Infeasible or unbounded.")
1895
- task.solutionsummary(mosek.streamtype.msg)
1896
- self.caseStatus = "unsuccessful"
1897
1926
 
1898
- return None
1927
+ return solution, xx, solverSuccess, solverMsg
1899
1928
 
1900
1929
  def _estimateMedicare(self, x=None, withMedicare=True):
1901
1930
  """
@@ -3066,13 +3095,3 @@ def _formatSpreadsheet(ws, ftype):
3066
3095
  cell.number_format = fstring
3067
3096
 
3068
3097
  return None
3069
-
3070
-
3071
- def _streamPrinter(text):
3072
- """
3073
- Define a stream printer to grab output from MOSEK.
3074
- """
3075
- import sys
3076
-
3077
- sys.stdout.write(text)
3078
- sys.stdout.flush()
@@ -0,0 +1 @@
1
+ __version__ = "2025.05.01"
Binary file
@@ -285,6 +285,14 @@ and past 2 years, when applicable. Values default to zero.
285
285
  These numbers are needed to calculate the Income-Related Monthly Adjusted Amounts (IRMAA).
286
286
  MAGI for current year is required as it allows plan to start in mid-year for the first year.
287
287
 
288
+ Different solvers can be selected. This option is mostly for verification purposes. All solvers
289
+ tested (HiGHS, COIN-OR Branch-and-Cut solver, and MOSEK) provided very similar results.
290
+ Due to the mixed integer formulation, solver performance is sometimes unpredictable.
291
+ In general, CBC will tend to be slower, partly because of the algorithm,
292
+ and partly because it solves the problem through a model description saved in
293
+ a temporary file requiring I/O.
294
+ Using HiGHS for most cases provides very good results.
295
+
288
296
  The time profile modulating the net spending amount
289
297
  can be selected to either be *flat* or follow a *smile* shape.
290
298
  The smile shape has three configurable parameters: a *dip* percentage
@@ -82,12 +82,13 @@ else:
82
82
  if years[ii] > 0:
83
83
  ret = kz.getNum(f"MAGI for year {years[ii]} ($k)", "MAGI" + str(ii), help=helpmsg)
84
84
 
85
+ st.divider()
86
+ st.write("##### Solver")
87
+ choices = ["HiGHS", "PuLP/CBC"]
85
88
  if owb.hasMOSEK():
86
- st.divider()
87
- st.write("##### Solver")
88
- choices = ["HiGHS", "MOSEK"]
89
- kz.initKey("solver", choices[0])
90
- ret = kz.getRadio("Linear programming solver", choices, "solver")
89
+ choices += ["MOSEK"]
90
+ kz.initKey("solver", choices[0])
91
+ ret = kz.getRadio("Linear programming solver", choices, "solver")
91
92
 
92
93
  st.divider()
93
94
  st.write("##### Spending Profile")
@@ -4,7 +4,8 @@ seaborn
4
4
  pandas
5
5
  openpyxl
6
6
  odfpy
7
+ pulp
7
8
  scipy
8
9
  streamlit
9
10
  toml
10
- owlplanner >= 2025.04.26
11
+ owlplanner >= 2025.05.01
@@ -1 +0,0 @@
1
- __version__ = "2025.04.26"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes