owlplanner 2025.12.5__py3-none-any.whl → 2025.12.20__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
@@ -25,10 +25,10 @@ Disclaimers: This code is for educational purposes only and does not constitute
25
25
  import numpy as np
26
26
 
27
27
 
28
- class Row(object):
28
+ class Row:
29
29
  """
30
- Solver-neutral API to accomodate Mosek/HiGHS.
31
- A Row represent a row in matrix A.
30
+ Solver-neutral API to accommodate Mosek/HiGHS.
31
+ A Row represents a row in matrix A.
32
32
  """
33
33
 
34
34
  def __init__(self, nvars):
@@ -58,7 +58,7 @@ class Row(object):
58
58
  return self
59
59
 
60
60
 
61
- class ConstraintMatrix(object):
61
+ class ConstraintMatrix:
62
62
  """
63
63
  Solver-neutral API for expressing constraints.
64
64
  """
@@ -143,7 +143,7 @@ class ConstraintMatrix(object):
143
143
  return Alu, lb, ub
144
144
 
145
145
 
146
- class Bounds(object):
146
+ class Bounds:
147
147
  """
148
148
  Solver-neutral API for bounds on variables.
149
149
  """
@@ -214,7 +214,7 @@ class Bounds(object):
214
214
  return self.integrality
215
215
 
216
216
 
217
- class Objective(object):
217
+ class Objective:
218
218
  """
219
219
  Solver-neutral objective function.
220
220
  """
owlplanner/config.py CHANGED
@@ -10,7 +10,7 @@ Disclaimers: This code is for educational purposes only and does not constitute
10
10
 
11
11
  """
12
12
 
13
- import toml as toml
13
+ import toml
14
14
  from io import StringIO, BytesIO
15
15
  import numpy as np
16
16
  from datetime import date
@@ -21,12 +21,13 @@ from owlplanner import mylogging as log
21
21
  from owlplanner.rates import FROM, TO
22
22
 
23
23
 
24
+ AccountTypes = ["taxable", "tax-deferred", "tax-free"]
25
+
26
+
24
27
  def saveConfig(myplan, file, mylog):
25
28
  """
26
29
  Save case parameters and return a dictionary containing all parameters.
27
30
  """
28
- # np.set_printoptions(legacy='1.21')
29
- accountTypes = ["taxable", "tax-deferred", "tax-free"]
30
31
 
31
32
  diconf = {}
32
33
  diconf["Plan Name"] = myplan._name
@@ -36,8 +37,7 @@ def saveConfig(myplan, file, mylog):
36
37
  diconf["Basic Info"] = {
37
38
  "Status": ["unknown", "single", "married"][myplan.N_i],
38
39
  "Names": myplan.inames,
39
- "Birth year": myplan.yobs.tolist(),
40
- "Birth month": myplan.mobs.tolist(),
40
+ "Date of birth": myplan.dobs,
41
41
  "Life expectancy": myplan.expectancy.tolist(),
42
42
  "Start date": myplan.startDate,
43
43
  }
@@ -46,13 +46,13 @@ def saveConfig(myplan, file, mylog):
46
46
  diconf["Assets"] = {}
47
47
  for j in range(myplan.N_j):
48
48
  amounts = myplan.beta_ij[:, j] / 1000
49
- diconf["Assets"][f"{accountTypes[j]} savings balances"] = amounts.tolist()
49
+ diconf["Assets"][f"{AccountTypes[j]} savings balances"] = amounts.tolist()
50
50
  if myplan.N_i == 2:
51
51
  diconf["Assets"]["Beneficiary fractions"] = myplan.phi_j.tolist()
52
52
  diconf["Assets"]["Spousal surplus deposit fraction"] = myplan.eta
53
53
 
54
- # Wages and Contributions.
55
- diconf["Wages and Contributions"] = {"Contributions file name": myplan.timeListsFileName}
54
+ # Household Financial Profile
55
+ diconf["Household Financial Profile"] = {"HFP file name": myplan.timeListsFileName}
56
56
 
57
57
  # Fixed Income.
58
58
  diconf["Fixed Income"] = {
@@ -90,7 +90,7 @@ def saveConfig(myplan, file, mylog):
90
90
  "Type": myplan.ARCoord,
91
91
  }
92
92
  if myplan.ARCoord == "account":
93
- for accType in accountTypes:
93
+ for accType in AccountTypes:
94
94
  diconf["Asset Allocation"][accType] = myplan.boundsAR[accType]
95
95
  else:
96
96
  diconf["Asset Allocation"]["generic"] = myplan.boundsAR["generic"]
@@ -146,8 +146,6 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
146
146
  """
147
147
  mylog = log.Logger(verbose, logstreams)
148
148
 
149
- accountTypes = ["taxable", "tax-deferred", "tax-free"]
150
-
151
149
  dirname = ""
152
150
  if isinstance(file, str):
153
151
  filename = file
@@ -180,21 +178,19 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
180
178
  # Basic Info.
181
179
  name = diconf["Plan Name"]
182
180
  inames = diconf["Basic Info"]["Names"]
183
- # status = diconf['Basic Info']['Status']
184
- yobs = diconf["Basic Info"]["Birth year"]
185
- icount = len(yobs)
186
- # Default to January if no month entry found.
187
- mobs = diconf["Basic Info"].get("Birth month", [1]*icount)
181
+ icount = len(inames)
182
+ # Default to January 15, 1965 if no entry is found.
183
+ dobs = diconf["Basic Info"].get("Date of birth", ["1965-01-15"]*icount)
188
184
  expectancy = diconf["Basic Info"]["Life expectancy"]
189
185
  s = ["", "s"][icount - 1]
190
186
  mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
191
- p = plan.Plan(inames, yobs, mobs, expectancy, name, verbose=True, logstreams=logstreams)
187
+ p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
192
188
  p._description = diconf.get("Description", "")
193
189
 
194
190
  # Assets.
195
191
  startDate = diconf["Basic Info"].get("Start date", "today")
196
192
  balances = {}
197
- for acc in accountTypes:
193
+ for acc in AccountTypes:
198
194
  balances[acc] = diconf["Assets"][f"{acc} savings balances"]
199
195
  p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
200
196
  taxFree=balances["tax-free"], startDate=startDate)
@@ -204,14 +200,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
204
200
  eta = diconf["Assets"]["Spousal surplus deposit fraction"]
205
201
  p.setSpousalDepositFraction(eta)
206
202
 
207
- # Wages and Contributions.
208
- timeListsFileName = diconf["Wages and Contributions"]["Contributions file name"]
203
+ # Household Financial Profile
204
+ timeListsFileName = diconf["Household Financial Profile"]["HFP file name"]
209
205
  if timeListsFileName != "None":
210
206
  if readContributions:
211
207
  if os.path.exists(timeListsFileName):
212
208
  myfile = timeListsFileName
213
- elif dirname != "" and os.path.exists(dirname + "/" + timeListsFileName):
214
- myfile = dirname + "/" + timeListsFileName
209
+ elif dirname != "" and os.path.exists(os.path.join(dirname, timeListsFileName)):
210
+ myfile = os.path.join(dirname, timeListsFileName)
215
211
  else:
216
212
  raise FileNotFoundError(f"File '{timeListsFileName}' not found.")
217
213
  p.readContributions(myfile)
@@ -243,7 +239,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
243
239
  frm = diconf["Rates Selection"]["From"]
244
240
  if not isinstance(frm, int):
245
241
  frm = int(frm)
246
- to = int(diconf["Rates Selection"]["To"])
242
+ to = diconf["Rates Selection"]["To"]
247
243
  if not isinstance(to, int):
248
244
  to = int(to)
249
245
  if rateMethod in ["user", "stochastic"]:
@@ -262,7 +258,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
262
258
  )
263
259
  allocType = diconf["Asset Allocation"]["Type"]
264
260
  if allocType == "account":
265
- for aType in accountTypes:
261
+ for aType in AccountTypes:
266
262
  boundsAR[aType] = np.array(diconf["Asset Allocation"][aType], dtype=np.float32)
267
263
 
268
264
  p.setAllocationRatios(
@@ -300,7 +296,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
300
296
  p.solverOptions = diconf["Solver Options"]
301
297
 
302
298
  # Address legacy case files.
303
- if diconf["Solver Options"].get("withMedicare", None) is True:
299
+ if diconf["Solver Options"].get("withMedicare"):
304
300
  p.solverOptions["withMedicare"] = "loop"
305
301
 
306
302
  # Check consistency of noRothConversions.
@@ -311,7 +307,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
311
307
  # Rebase startRothConversions on year change.
312
308
  thisyear = date.today().year
313
309
  year = p.solverOptions.get("startRothConversions", thisyear)
314
- diconf["Solver Options"]["startRothConversions"] = max(year, thisyear)
310
+ p.solverOptions["startRothConversions"] = max(year, thisyear)
315
311
 
316
312
  # Results.
317
313
  p.setDefaultPlots(diconf["Results"]["Default plots"])
@@ -0,0 +1,75 @@
1
+ year,AWI,percent
2
+ 1951, 2799.16 ,—
3
+ 1952, 2973.32 ,6.22
4
+ 1953, 3139.44 ,5.59
5
+ 1954, 3155.64 ,0.52
6
+ 1955, 3301.44 ,4.62
7
+ 1956, 3532.36 ,6.99
8
+ 1957, 3641.72 ,3.10
9
+ 1958, 3673.80 ,0.88
10
+ 1959, 3855.80 ,4.95
11
+ 1960, 4007.12 ,3.92
12
+ 1961, 4086.76 ,1.99
13
+ 1962, 4291.40 ,5.01
14
+ 1963, 4396.64 ,2.45
15
+ 1964, 4576.32 ,4.09
16
+ 1965, 4658.72 ,1.80
17
+ 1966, 4938.36 ,6.00
18
+ 1967, 5213.44 ,5.57
19
+ 1968, 5571.76 ,6.87
20
+ 1969, 5893.76 ,5.78
21
+ 1970, 6186.24 ,4.96
22
+ 1971, 6497.08 ,5.02
23
+ 1972, 7133.80 ,9.80
24
+ 1973, 7580.16 ,6.26
25
+ 1974, 8030.76 ,5.94
26
+ 1975, 8630.92 ,7.47
27
+ 1976, 9226.48 ,6.90
28
+ 1977, 9779.44 ,5.99
29
+ 1978, 10556.03 ,7.94
30
+ 1979, 11479.46 ,8.75
31
+ 1980, 12513.46 ,9.01
32
+ 1981, 13773.10 ,10.07
33
+ 1982, 14531.34 ,5.51
34
+ 1983, 15239.24 ,4.87
35
+ 1984, 16135.07 ,5.88
36
+ 1985, 16822.51 ,4.26
37
+ 1986, 17321.82 ,2.97
38
+ 1987, 18426.51 ,6.38
39
+ 1988, 19334.04 ,4.93
40
+ 1989, 20099.55 ,3.96
41
+ 1990, 21027.98 ,4.62
42
+ 1991, 21811.60 ,3.73
43
+ 1992, 22935.42 ,5.15
44
+ 1993, 23132.67 ,0.86
45
+ 1994, 23753.53 ,2.68
46
+ 1995, 24705.66 ,4.01
47
+ 1996, 25913.90 ,4.89
48
+ 1997, 27426.00 ,5.84
49
+ 1998, 28861.44 ,5.23
50
+ 1999, 30469.84 ,5.57
51
+ 2000, 32154.82 ,5.53
52
+ 2001, 32921.92 ,2.39
53
+ 2002, 33252.09 ,1.00
54
+ 2003, 34064.95 ,2.44
55
+ 2004, 35648.55 ,4.65
56
+ 2005, 36952.94 ,3.66
57
+ 2006, 38651.41 ,4.60
58
+ 2007, 40405.48 ,4.54
59
+ 2008, 41334.97 ,2.30
60
+ 2009, 40711.61 ,-1.51
61
+ 2010, 41673.83 ,2.36
62
+ 2011, 42979.61 ,3.13
63
+ 2012, 44321.67 ,3.12
64
+ 2013, 44888.16 ,1.28
65
+ 2014, 46481.52 ,3.55
66
+ 2015, 48098.63 ,3.48
67
+ 2016, 48642.15 ,1.13
68
+ 2017, 50321.89 ,3.45
69
+ 2018, 52145.80 ,3.62
70
+ 2019, 54099.99 ,3.75
71
+ 2020, 55628.60 ,2.83
72
+ 2021, 60575.07 ,8.89
73
+ 2022, 63795.13 ,5.32
74
+ 2023, 66621.80 ,4.43
75
+ 2024, 69846.57 ,4.84
@@ -0,0 +1,49 @@
1
+ year first second
2
+ 1979 180 1085
3
+ 1980 194 1171
4
+ 1981 211 1274
5
+ 1982 230 1388
6
+ 1983 254 1528
7
+ 1984 267 1612
8
+ 1985 280 1691
9
+ 1986 297 1790
10
+ 1987 310 1866
11
+ 1988 319 1922
12
+ 1989 339 2044
13
+ 1990 356 2145
14
+ 1991 370 2230
15
+ 1992 387 2333
16
+ 1993 401 2420
17
+ 1994 422 2545
18
+ 1995 426 2567
19
+ 1996 437 2635
20
+ 1997 455 2741
21
+ 1998 477 2875
22
+ 1999 505 3043
23
+ 2000 531 3202
24
+ 2001 561 3381
25
+ 2002 592 3567
26
+ 2003 606 3653
27
+ 2004 612 3689
28
+ 2005 627 3779
29
+ 2006 656 3955
30
+ 2007 680 4100
31
+ 2008 711 4288
32
+ 2009 744 4483
33
+ 2010 761 4586
34
+ 2011 749 4517
35
+ 2012 767 4624
36
+ 2013 791 4768
37
+ 2014 816 4917
38
+ 2015 826 4980
39
+ 2016 856 5157
40
+ 2017 885 5336
41
+ 2018 895 5397
42
+ 2019 926 5583
43
+ 2020 960 5785
44
+ 2021 996 6002
45
+ 2022 1024 6172
46
+ 2023 1115 6721
47
+ 2024 1174 7078
48
+ 2025 1226 7391
49
+ 2026 1286 7749
@@ -0,0 +1,75 @@
1
+ year,AWI,percent
2
+ 1951, 2799.16 ,—
3
+ 1952, 2973.32 ,6.22
4
+ 1953, 3139.44 ,5.59
5
+ 1954, 3155.64 ,0.52
6
+ 1955, 3301.44 ,4.62
7
+ 1956, 3532.36 ,6.99
8
+ 1957, 3641.72 ,3.10
9
+ 1958, 3673.80 ,0.88
10
+ 1959, 3855.80 ,4.95
11
+ 1960, 4007.12 ,3.92
12
+ 1961, 4086.76 ,1.99
13
+ 1962, 4291.40 ,5.01
14
+ 1963, 4396.64 ,2.45
15
+ 1964, 4576.32 ,4.09
16
+ 1965, 4658.72 ,1.80
17
+ 1966, 4938.36 ,6.00
18
+ 1967, 5213.44 ,5.57
19
+ 1968, 5571.76 ,6.87
20
+ 1969, 5893.76 ,5.78
21
+ 1970, 6186.24 ,4.96
22
+ 1971, 6497.08 ,5.02
23
+ 1972, 7133.80 ,9.80
24
+ 1973, 7580.16 ,6.26
25
+ 1974, 8030.76 ,5.94
26
+ 1975, 8630.92 ,7.47
27
+ 1976, 9226.48 ,6.90
28
+ 1977, 9779.44 ,5.99
29
+ 1978, 10556.03 ,7.94
30
+ 1979, 11479.46 ,8.75
31
+ 1980, 12513.46 ,9.01
32
+ 1981, 13773.10 ,10.07
33
+ 1982, 14531.34 ,5.51
34
+ 1983, 15239.24 ,4.87
35
+ 1984, 16135.07 ,5.88
36
+ 1985, 16822.51 ,4.26
37
+ 1986, 17321.82 ,2.97
38
+ 1987, 18426.51 ,6.38
39
+ 1988, 19334.04 ,4.93
40
+ 1989, 20099.55 ,3.96
41
+ 1990, 21027.98 ,4.62
42
+ 1991, 21811.60 ,3.73
43
+ 1992, 22935.42 ,5.15
44
+ 1993, 23132.67 ,0.86
45
+ 1994, 23753.53 ,2.68
46
+ 1995, 24705.66 ,4.01
47
+ 1996, 25913.90 ,4.89
48
+ 1997, 27426.00 ,5.84
49
+ 1998, 28861.44 ,5.23
50
+ 1999, 30469.84 ,5.57
51
+ 2000, 32154.82 ,5.53
52
+ 2001, 32921.92 ,2.39
53
+ 2002, 33252.09 ,1.00
54
+ 2003, 34064.95 ,2.44
55
+ 2004, 35648.55 ,4.65
56
+ 2005, 36952.94 ,3.66
57
+ 2006, 38651.41 ,4.60
58
+ 2007, 40405.48 ,4.54
59
+ 2008, 41334.97 ,2.30
60
+ 2009, 40711.61 ,-1.51
61
+ 2010, 41673.83 ,2.36
62
+ 2011, 42979.61 ,3.13
63
+ 2012, 44321.67 ,3.12
64
+ 2013, 44888.16 ,1.28
65
+ 2014, 46481.52 ,3.55
66
+ 2015, 48098.63 ,3.48
67
+ 2016, 48642.15 ,1.13
68
+ 2017, 50321.89 ,3.45
69
+ 2018, 52145.80 ,3.62
70
+ 2019, 54099.99 ,3.75
71
+ 2020, 55628.60 ,2.83
72
+ 2021, 60575.07 ,8.89
73
+ 2022, 63795.13 ,5.32
74
+ 2023, 66621.80 ,4.43
75
+ 2024, 69846.57 ,4.84
owlplanner/debts.py ADDED
@@ -0,0 +1,287 @@
1
+ """
2
+
3
+ Owl/debts:r
4
+
5
+ This file contains functions for handling debts.
6
+
7
+ Copyright © 2025 - Martin-D. Lacasse
8
+
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
+
11
+ """
12
+
13
+ ######################################################################
14
+ import numpy as np
15
+ import pandas as pd # noqa: F401
16
+ from datetime import date
17
+
18
+
19
+ def calculate_monthly_payment(principal, annual_rate, term_years):
20
+ """
21
+ Calculate monthly payment for an amortizing loan. This is a constant payment amount for a fixed-rate loan.
22
+ monthly payment = principal * (monthly_rate * (1 + monthly_rate)**num_payments) /
23
+ ((1 + monthly_rate)**num_payments - 1)
24
+ where monthly_rate = annual_rate / 100.0 / 12.0 and num_payments = term_years * 12.0.
25
+
26
+ This formula is derived from the formula for the present value of an annuity.
27
+
28
+ Parameters:
29
+ -----------
30
+ principal : float
31
+ Original loan amount
32
+ annual_rate : float
33
+ Annual interest rate as a percentage (e.g., 4.5 for 4.5%)
34
+ term_years : int
35
+ Loan term in years
36
+
37
+ Returns:
38
+ --------
39
+ float
40
+ Monthly payment amount
41
+ """
42
+ if term_years <= 0 or annual_rate < 0 or principal <= 0:
43
+ return 0.0
44
+
45
+ monthly_rate = annual_rate / 100.0 / 12.0
46
+ num_payments = term_years * 12
47
+ fac = (1 + monthly_rate)**num_payments
48
+
49
+ if monthly_rate == 0:
50
+ return principal / num_payments
51
+
52
+ payment = principal * (monthly_rate * fac) / (fac - 1)
53
+
54
+ return payment
55
+
56
+
57
+ def calculate_annual_payment(principal, annual_rate, term_years):
58
+ """
59
+ Calculate annual payment for an amortizing loan.
60
+
61
+ Parameters:
62
+ -----------
63
+ principal : float
64
+ Original loan amount
65
+ annual_rate : float
66
+ Annual interest rate as a percentage
67
+ term_years : int
68
+ Loan term in years
69
+
70
+ Returns:
71
+ --------
72
+ float
73
+ Annual payment amount
74
+ """
75
+ return 12 * calculate_monthly_payment(principal, annual_rate, term_years)
76
+
77
+
78
+ def calculate_remaining_balance(principal, annual_rate, term_years, years_elapsed):
79
+ """
80
+ Calculate remaining balance on a loan after a given number of years.
81
+
82
+ Parameters:
83
+ -----------
84
+ principal : float
85
+ Original loan amount
86
+ annual_rate : float
87
+ Annual interest rate as a percentage
88
+ term_years : int
89
+ Original loan term in years
90
+ years_elapsed : float
91
+ Number of years since loan origination
92
+
93
+ Returns:
94
+ --------
95
+ float
96
+ Remaining balance
97
+ """
98
+ if years_elapsed <= 0:
99
+ return principal
100
+
101
+ if years_elapsed >= term_years:
102
+ return 0.0
103
+
104
+ monthly_rate = annual_rate / 100.0 / 12.0
105
+ fac = 1 + monthly_rate
106
+ num_payments = term_years * 12
107
+ payments_made = int(years_elapsed * 12)
108
+
109
+ if monthly_rate == 0:
110
+ return principal * (1 - payments_made / num_payments)
111
+
112
+ remaining = principal * (fac**num_payments - fac**payments_made) / (fac**num_payments - 1)
113
+
114
+ return max(0.0, remaining)
115
+
116
+
117
+ def get_debt_payments_for_year(debts_df, year):
118
+ """
119
+ Calculate total debt payments (principal + interest) for a given year.
120
+
121
+ Parameters:
122
+ -----------
123
+ debts_df : pd.DataFrame
124
+ DataFrame with columns: name, type, year, term, amount, rate
125
+ year : int
126
+ Year for which to calculate payments
127
+
128
+ Returns:
129
+ --------
130
+ float
131
+ Total annual debt payments for the year
132
+ """
133
+ if debts_df is None or debts_df.empty:
134
+ return 0.0
135
+
136
+ total_payments = 0.0
137
+
138
+ for _, debt in debts_df.iterrows():
139
+ start_year = int(debt["year"])
140
+ term = int(debt["term"])
141
+ end_year = start_year + term
142
+
143
+ # Check if loan is active in this year
144
+ if start_year <= year < end_year:
145
+ principal = float(debt["amount"])
146
+ rate = float(debt["rate"])
147
+
148
+ # Calculate annual payment (payment amount is constant for fixed-rate loans)
149
+ annual_payment = calculate_annual_payment(principal, rate, term)
150
+ total_payments += annual_payment
151
+
152
+ return total_payments
153
+
154
+
155
+ def get_debt_balances_for_year(debts_df, year):
156
+ """
157
+ Calculate total remaining debt balances at the end of a given year.
158
+
159
+ Parameters:
160
+ -----------
161
+ debts_df : pd.DataFrame
162
+ DataFrame with columns: name, type, year, term, amount, rate
163
+ year : int
164
+ Year for which to calculate balances
165
+
166
+ Returns:
167
+ --------
168
+ float
169
+ Total remaining debt balances at end of year
170
+ """
171
+ if debts_df is None or debts_df.empty:
172
+ return 0.0
173
+
174
+ total_balance = 0.0
175
+
176
+ for _, debt in debts_df.iterrows():
177
+ start_year = int(debt["year"])
178
+ term = int(debt["term"])
179
+ end_year = start_year + term
180
+
181
+ # Check if loan is active at end of this year
182
+ if start_year <= year < end_year:
183
+ principal = float(debt["amount"])
184
+ rate = float(debt["rate"])
185
+
186
+ # Calculate remaining balance at end of year
187
+ years_elapsed = year - start_year + 1
188
+ remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
189
+ total_balance += remaining_balance
190
+
191
+ return total_balance
192
+
193
+
194
+ def get_debt_payments_array(debts_df, N_n, thisyear=None):
195
+ """
196
+ Process debts_df to provide a single array of length N_n containing
197
+ all annual payments made for each year of the plan.
198
+
199
+ Parameters:
200
+ -----------
201
+ debts_df : pd.DataFrame
202
+ DataFrame with columns: name, type, year, term, amount, rate
203
+ N_n : int
204
+ Number of years in the plan (length of output array)
205
+ thisyear : int, optional
206
+ Starting year of the plan (defaults to date.today().year).
207
+ Array index 0 corresponds to thisyear, index 1 to thisyear+1, etc.
208
+
209
+ Returns:
210
+ --------
211
+ np.ndarray
212
+ Array of length N_n with annual debt payments for each year.
213
+ payments_n[0] = payments for thisyear,
214
+ payments_n[1] = payments for thisyear+1, etc.
215
+ """
216
+ if thisyear is None:
217
+ thisyear = date.today().year
218
+
219
+ if debts_df is None or debts_df.empty:
220
+ return np.zeros(N_n)
221
+
222
+ payments_n = np.zeros(N_n)
223
+
224
+ for _, debt in debts_df.iterrows():
225
+ start_year = int(debt["year"])
226
+ term = int(debt["term"])
227
+ end_year = start_year + term
228
+ principal = float(debt["amount"])
229
+ rate = float(debt["rate"])
230
+
231
+ # Calculate annual payment (payment amount is constant for fixed-rate loans)
232
+ annual_payment = calculate_annual_payment(principal, rate, term)
233
+
234
+ # Add payments for each year the loan is active within the plan horizon
235
+ for n in range(N_n):
236
+ year = thisyear + n
237
+ if start_year <= year < end_year:
238
+ payments_n[n] += annual_payment
239
+
240
+ return payments_n
241
+
242
+
243
+ def get_remaining_debt_balance(debts_df, N_n, thisyear=None):
244
+ """
245
+ Calculate total remaining debt balance at the end of the plan horizon.
246
+ Returns the sum of all remaining balances for loans that haven't been
247
+ fully paid off by the end of the plan.
248
+
249
+ Parameters:
250
+ -----------
251
+ debts_df : pd.DataFrame
252
+ DataFrame with columns: name, type, year, term, amount, rate
253
+ N_n : int
254
+ Number of years in the plan
255
+ thisyear : int, optional
256
+ Starting year of the plan (defaults to date.today().year)
257
+
258
+ Returns:
259
+ --------
260
+ float
261
+ Total remaining debt balance at the end of the plan horizon.
262
+ Returns 0.0 if all loans are paid off or if no loans are active.
263
+ """
264
+ if thisyear is None:
265
+ thisyear = date.today().year
266
+
267
+ if debts_df is None or debts_df.empty:
268
+ return 0.0
269
+
270
+ end_year = thisyear + N_n - 1
271
+ total_balance = 0.0
272
+
273
+ for _, debt in debts_df.iterrows():
274
+ start_year = int(debt["year"])
275
+ term = int(debt["term"])
276
+ loan_end_year = start_year + term
277
+ principal = float(debt["amount"])
278
+ rate = float(debt["rate"])
279
+
280
+ # Check if loan is still active at the end of the plan
281
+ if start_year <= end_year < loan_end_year:
282
+ # Calculate remaining balance at end of plan horizon
283
+ years_elapsed = end_year - start_year + 1
284
+ remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
285
+ total_balance += remaining_balance
286
+
287
+ return total_balance