owlplanner 2025.4.26__py3-none-any.whl → 2025.5.1__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 +17 -21
- owlplanner/plan.py +198 -179
- owlplanner/version.py +1 -1
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.5.1.dist-info}/METADATA +1 -1
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.5.1.dist-info}/RECORD +7 -7
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.5.1.dist-info}/WHEEL +0 -0
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.5.1.dist-info}/licenses/LICENSE +0 -0
owlplanner/abcapi.py
CHANGED
|
@@ -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=
|
|
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=
|
|
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
|
|
owlplanner/plan.py
CHANGED
|
@@ -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(
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
1140
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1234
|
-
# B.
|
|
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.
|
|
1240
|
-
B.
|
|
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.
|
|
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):
|
|
1316
|
-
A.addNewRow(rowDic,
|
|
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
|
-
|
|
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=
|
|
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
|
|
1640
|
-
|
|
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
|
-
|
|
1676
|
-
if
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
|
1704
|
+
solverMethod = self._milpSolve
|
|
1705
|
+
elif solver == "PuLP/CBC":
|
|
1706
|
+
solverMethod = self._pulpSolve
|
|
1693
1707
|
elif solver == "MOSEK":
|
|
1694
|
-
self._mosekSolve
|
|
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
|
|
1719
|
+
def _scSolve(self, objective, options, solverMethod):
|
|
1702
1720
|
"""
|
|
1703
|
-
|
|
1721
|
+
Self-consistent loop, regardless of solver.
|
|
1704
1722
|
"""
|
|
1705
|
-
|
|
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
|
-
|
|
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
|
|
1738
|
+
if not solverSuccess:
|
|
1743
1739
|
break
|
|
1744
1740
|
|
|
1745
1741
|
if not withMedicare:
|
|
1746
1742
|
break
|
|
1747
1743
|
|
|
1748
|
-
self._estimateMedicare(
|
|
1744
|
+
self._estimateMedicare(xx)
|
|
1749
1745
|
|
|
1750
|
-
self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution
|
|
1746
|
+
self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
|
|
1751
1747
|
|
|
1752
|
-
delta =
|
|
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
|
|
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
|
|
1769
|
-
old_x =
|
|
1764
|
+
old_solutions.append(-solution)
|
|
1765
|
+
old_x = xx
|
|
1770
1766
|
|
|
1771
|
-
if
|
|
1767
|
+
if solverSuccess:
|
|
1772
1768
|
self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
|
|
1773
|
-
self.mylog.vprint(
|
|
1774
|
-
self.mylog.vprint(f"Objective: {u.d(solution
|
|
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(
|
|
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:",
|
|
1776
|
+
self.mylog.vprint("WARNING: Optimization failed:", solverMsg, solverSuccess)
|
|
1781
1777
|
self.caseStatus = "unsuccessful"
|
|
1782
1778
|
|
|
1783
1779
|
return None
|
|
1784
1780
|
|
|
1785
|
-
def
|
|
1781
|
+
def _milpSolve(self, objective, options):
|
|
1786
1782
|
"""
|
|
1787
|
-
Solve problem using
|
|
1783
|
+
Solve problem using scipy HiGHS solver.
|
|
1788
1784
|
"""
|
|
1789
|
-
import
|
|
1785
|
+
from scipy import optimize
|
|
1790
1786
|
|
|
1791
|
-
|
|
1792
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1855
|
-
|
|
1883
|
+
def _streamPrinter(text, msg=solverMsg):
|
|
1884
|
+
msg += text
|
|
1856
1885
|
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1901
|
+
for ii in range(len(cind)):
|
|
1902
|
+
task.putcj(cind[ii], cval[ii])
|
|
1863
1903
|
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
-
|
|
1871
|
-
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
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
|
|
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()
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.
|
|
1
|
+
__version__ = "2025.05.01"
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
owlplanner/__init__.py,sha256=QqrdT0Qks20osBTg7h0vJHAxpP9lL7DA99xb0nYbtw4,254
|
|
2
|
-
owlplanner/abcapi.py,sha256=
|
|
2
|
+
owlplanner/abcapi.py,sha256=Lt8OUgbrfOPzAw0HyxyT2wT-IXI3d9Zo26MwyqdX56Y,6617
|
|
3
3
|
owlplanner/config.py,sha256=F6GS3n02VeFX0GCVeM4J7Ra0in4N632W6TZIXk7Yj2w,12519
|
|
4
4
|
owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
|
|
5
|
-
owlplanner/plan.py,sha256=
|
|
5
|
+
owlplanner/plan.py,sha256=yfJRP41ExY5cqKDfBXFox9M0a3O7GeYRy4fk9EPTduk,118954
|
|
6
6
|
owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
|
|
7
7
|
owlplanner/rates.py,sha256=gJaoe-gJqWCQV5qVLlHp-Yn9TSJs-PJzeTbOwMCbqWs,15682
|
|
8
8
|
owlplanner/tax2025.py,sha256=JDBtFFAf2bWtKUMuE3W5F0nBhYaKBjmdJj0iayM2iGA,7829
|
|
9
9
|
owlplanner/timelists.py,sha256=tYieZU67FT6TCcQQis36JaXGI7dT6NqD7RvdEjgJL4M,4026
|
|
10
10
|
owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
|
|
11
|
-
owlplanner/version.py,sha256=
|
|
11
|
+
owlplanner/version.py,sha256=i0t7xBSM4RiKs64A-MRBD5qug-N0BpH70YpBYsc7Lbw,28
|
|
12
12
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
14
|
-
owlplanner-2025.
|
|
15
|
-
owlplanner-2025.
|
|
16
|
-
owlplanner-2025.
|
|
17
|
-
owlplanner-2025.
|
|
14
|
+
owlplanner-2025.5.1.dist-info/METADATA,sha256=wfo48m-vul82qOlWxk3X6lrbabChuLTxNBJktKCgse0,53926
|
|
15
|
+
owlplanner-2025.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
owlplanner-2025.5.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
17
|
+
owlplanner-2025.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|