owlplanner 2025.12.20__py3-none-any.whl → 2026.2.2__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/config.py CHANGED
@@ -1,13 +1,23 @@
1
1
  """
2
+ Configuration management for saving and loading case parameters.
2
3
 
3
- Owl/conftoml
4
+ This module provides utility functions to save and load retirement planning
5
+ case parameters in TOML format.
4
6
 
5
- This file contains utility functions to save case parameters.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
6
8
 
7
- Copyright © 2024 - Martin-D. Lacasse
9
+ This program is free software: you can redistribute it and/or modify
10
+ it under the terms of the GNU General Public License as published by
11
+ the Free Software Foundation, either version 3 of the License, or
12
+ (at your option) any later version.
8
13
 
9
- Disclaimers: This code is for educational purposes only and does not constitute financial advice.
14
+ This program is distributed in the hope that it will be useful,
15
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ GNU General Public License for more details.
10
18
 
19
+ You should have received a copy of the GNU General Public License
20
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
11
21
  """
12
22
 
13
23
  import toml
@@ -24,92 +34,271 @@ from owlplanner.rates import FROM, TO
24
34
  AccountTypes = ["taxable", "tax-deferred", "tax-free"]
25
35
 
26
36
 
37
+ # Translation dictionary for backward compatibility: old keys -> new snake_case keys
38
+ _KEY_TRANSLATION = {
39
+ # Root level keys
40
+ "Plan Name": "case_name",
41
+ "Description": "description",
42
+ # Section names
43
+ "Basic Info": "basic_info",
44
+ "Assets": "savings_assets",
45
+ "Household Financial Profile": "household_financial_profile",
46
+ "Fixed Income": "fixed_income",
47
+ "Rates Selection": "rates_selection",
48
+ "Asset Allocation": "asset_allocation",
49
+ "Optimization Parameters": "optimization_parameters",
50
+ "Solver Options": "solver_options",
51
+ "Results": "results",
52
+ # Basic Info keys
53
+ "Status": "status",
54
+ "Names": "names",
55
+ "Date of birth": "date_of_birth",
56
+ "Life expectancy": "life_expectancy",
57
+ "Start date": "start_date",
58
+ # Assets keys
59
+ "taxable savings balances": "taxable_savings_balances",
60
+ "tax-deferred savings balances": "tax_deferred_savings_balances",
61
+ "tax-free savings balances": "tax_free_savings_balances",
62
+ "Beneficiary fractions": "beneficiary_fractions",
63
+ "Spousal surplus deposit fraction": "spousal_surplus_deposit_fraction",
64
+ # Household Financial Profile keys
65
+ "HFP file name": "HFP_file_name",
66
+ # Fixed Income keys
67
+ "Pension monthly amounts": "pension_monthly_amounts",
68
+ "Pension ages": "pension_ages",
69
+ "Pension indexed": "pension_indexed",
70
+ "Social security PIA amounts": "social_security_pia_amounts",
71
+ "Social security ages": "social_security_ages",
72
+ # Rates Selection keys
73
+ "Heirs rate on tax-deferred estate": "heirs_rate_on_tax_deferred_estate",
74
+ "Dividend rate": "dividend_rate",
75
+ "OBBBA expiration year": "obbba_expiration_year",
76
+ "Method": "method",
77
+ "Rate seed": "rate_seed",
78
+ "Reproducible rates": "reproducible_rates",
79
+ "Values": "values",
80
+ "Standard deviations": "standard_deviations",
81
+ "Correlations": "correlations",
82
+ "From": "from",
83
+ "To": "to",
84
+ "Reverse sequence": "reverse_sequence",
85
+ "Roll sequence": "roll_sequence",
86
+ # Asset Allocation keys
87
+ "Interpolation method": "interpolation_method",
88
+ "Interpolation center": "interpolation_center",
89
+ "Interpolation width": "interpolation_width",
90
+ "Type": "type",
91
+ # Optimization Parameters keys
92
+ "Spending profile": "spending_profile",
93
+ "Surviving spouse spending percent": "surviving_spouse_spending_percent",
94
+ "Smile dip": "smile_dip",
95
+ "Smile increase": "smile_increase",
96
+ "Smile delay": "smile_delay",
97
+ "Objective": "objective",
98
+ # Results keys
99
+ "Default plots": "default_plots",
100
+ }
101
+
102
+
103
+ def translate_old_keys(diconf):
104
+ """
105
+ Translate old TOML keys to new snake_case keys for backward compatibility.
106
+ This function recursively processes the configuration dictionary and replaces
107
+ old keys with new snake_case keys.
108
+
109
+ Args:
110
+ diconf: Configuration dictionary (may be modified in place)
111
+
112
+ Returns:
113
+ Dictionary with translated keys
114
+ """
115
+ if not isinstance(diconf, dict):
116
+ return diconf
117
+
118
+ translated = {}
119
+
120
+ # First, translate section names at the top level
121
+ for key, value in diconf.items():
122
+ new_key = _KEY_TRANSLATION.get(key, key)
123
+
124
+ if isinstance(value, dict):
125
+ # Recursively translate keys within sections
126
+ translated[new_key] = {}
127
+ for sub_key, sub_value in value.items():
128
+ new_sub_key = _KEY_TRANSLATION.get(sub_key, sub_key)
129
+ if isinstance(sub_value, dict):
130
+ translated[new_key][new_sub_key] = translate_old_keys(sub_value)
131
+ else:
132
+ translated[new_key][new_sub_key] = sub_value
133
+ else:
134
+ translated[new_key] = value
135
+
136
+ return translated
137
+
138
+
139
+ def _read_toml_file(file):
140
+ """
141
+ Unified TOML file reading for different input types.
142
+
143
+ This function handles reading TOML content from three different input types:
144
+ - str: File path (reads from filesystem)
145
+ - BytesIO: Binary stream (decodes to UTF-8 string)
146
+ - StringIO: Text stream (reads string directly)
147
+
148
+ Parameters
149
+ ----------
150
+ file : str, BytesIO, or StringIO
151
+ The file source to read from. Can be a file path string, BytesIO object,
152
+ or StringIO object.
153
+
154
+ Returns
155
+ -------
156
+ tuple
157
+ A tuple containing:
158
+ - diconf (dict): The loaded TOML configuration dictionary
159
+ - dirname (str): The directory name if file is a string path, empty string otherwise
160
+ - filename (str): The filename if file is a string path, None otherwise
161
+
162
+ Raises
163
+ ------
164
+ FileNotFoundError
165
+ If file is a string path and the file cannot be found.
166
+ RuntimeError
167
+ If file is BytesIO or StringIO and reading fails.
168
+ ValueError
169
+ If file is not one of the supported types.
170
+ """
171
+ dirname = ""
172
+ filename = None
173
+
174
+ if isinstance(file, str):
175
+ filename = file
176
+ dirname = os.path.dirname(filename)
177
+ if not filename.endswith(".toml"):
178
+ filename = filename + ".toml"
179
+
180
+ try:
181
+ with open(filename, "r") as f:
182
+ diconf = toml.load(f)
183
+ except Exception as e:
184
+ raise FileNotFoundError(f"File {filename} not found: {e}") from e
185
+ elif isinstance(file, BytesIO):
186
+ try:
187
+ string = file.getvalue().decode("utf-8")
188
+ diconf = toml.loads(string)
189
+ except Exception as e:
190
+ raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
191
+ elif isinstance(file, StringIO):
192
+ try:
193
+ string = file.getvalue()
194
+ diconf = toml.loads(string)
195
+ except Exception as e:
196
+ raise RuntimeError(f"Cannot read from StringIO: {e}") from e
197
+ else:
198
+ raise ValueError(f"Type {type(file)} not a valid type")
199
+
200
+ return diconf, dirname, filename
201
+
202
+
27
203
  def saveConfig(myplan, file, mylog):
28
204
  """
29
205
  Save case parameters and return a dictionary containing all parameters.
30
206
  """
31
207
 
32
208
  diconf = {}
33
- diconf["Plan Name"] = myplan._name
34
- diconf["Description"] = myplan._description
209
+ diconf["case_name"] = myplan._name
210
+ diconf["description"] = myplan._description
35
211
 
36
212
  # Basic Info.
37
- diconf["Basic Info"] = {
38
- "Status": ["unknown", "single", "married"][myplan.N_i],
39
- "Names": myplan.inames,
40
- "Date of birth": myplan.dobs,
41
- "Life expectancy": myplan.expectancy.tolist(),
42
- "Start date": myplan.startDate,
213
+ diconf["basic_info"] = {
214
+ "status": ["unknown", "single", "married"][myplan.N_i],
215
+ "names": myplan.inames,
216
+ "date_of_birth": myplan.dobs,
217
+ "life_expectancy": myplan.expectancy.tolist(),
218
+ "start_date": myplan.startDate,
43
219
  }
44
220
 
45
221
  # Assets.
46
- diconf["Assets"] = {}
222
+ diconf["savings_assets"] = {}
47
223
  for j in range(myplan.N_j):
48
224
  amounts = myplan.beta_ij[:, j] / 1000
49
- diconf["Assets"][f"{AccountTypes[j]} savings balances"] = amounts.tolist()
225
+ # Map account type names to snake_case keys
226
+ account_key_map = {
227
+ "taxable": "taxable_savings_balances",
228
+ "tax-deferred": "tax_deferred_savings_balances",
229
+ "tax-free": "tax_free_savings_balances"
230
+ }
231
+ diconf["savings_assets"][account_key_map[AccountTypes[j]]] = amounts.tolist()
50
232
  if myplan.N_i == 2:
51
- diconf["Assets"]["Beneficiary fractions"] = myplan.phi_j.tolist()
52
- diconf["Assets"]["Spousal surplus deposit fraction"] = myplan.eta
233
+ diconf["savings_assets"]["beneficiary_fractions"] = myplan.phi_j.tolist()
234
+ diconf["savings_assets"]["spousal_surplus_deposit_fraction"] = myplan.eta
53
235
 
54
236
  # Household Financial Profile
55
- diconf["Household Financial Profile"] = {"HFP file name": myplan.timeListsFileName}
237
+ diconf["household_financial_profile"] = {"HFP_file_name": myplan.timeListsFileName}
56
238
 
57
239
  # Fixed Income.
58
- diconf["Fixed Income"] = {
59
- "Pension monthly amounts": (myplan.pensionAmounts).tolist(),
60
- "Pension ages": myplan.pensionAges.tolist(),
61
- "Pension indexed": myplan.pensionIsIndexed,
62
- "Social security PIA amounts": (myplan.ssecAmounts).tolist(),
63
- "Social security ages": myplan.ssecAges.tolist(),
240
+ diconf["fixed_income"] = {
241
+ "pension_monthly_amounts": (myplan.pensionAmounts).tolist(),
242
+ "pension_ages": myplan.pensionAges.tolist(),
243
+ "pension_indexed": myplan.pensionIsIndexed,
244
+ "social_security_pia_amounts": (myplan.ssecAmounts).tolist(),
245
+ "social_security_ages": myplan.ssecAges.tolist(),
64
246
  }
65
247
 
66
248
  # Rates Selection.
67
- diconf["Rates Selection"] = {
68
- "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
69
- "Dividend rate": float(100 * myplan.mu),
70
- "OBBBA expiration year": myplan.yOBBBA,
71
- "Method": myplan.rateMethod,
249
+ diconf["rates_selection"] = {
250
+ "heirs_rate_on_tax_deferred_estate": float(100 * myplan.nu),
251
+ "dividend_rate": float(100 * myplan.mu),
252
+ "obbba_expiration_year": myplan.yOBBBA,
253
+ "method": myplan.rateMethod,
72
254
  }
255
+ # Store seed and reproducibility flag for stochastic methods
256
+ if myplan.rateMethod in ["stochastic", "histochastic"]:
257
+ if myplan.rateSeed is not None:
258
+ diconf["rates_selection"]["rate_seed"] = int(myplan.rateSeed)
259
+ diconf["rates_selection"]["reproducible_rates"] = bool(myplan.reproducibleRates)
73
260
  if myplan.rateMethod in ["user", "stochastic"]:
74
- diconf["Rates Selection"]["Values"] = (100 * myplan.rateValues).tolist()
261
+ diconf["rates_selection"]["values"] = (100 * myplan.rateValues).tolist()
75
262
  if myplan.rateMethod in ["stochastic"]:
76
- diconf["Rates Selection"]["Standard deviations"] = (100 * myplan.rateStdev).tolist()
77
- diconf["Rates Selection"]["Correlations"] = myplan.rateCorr.tolist()
263
+ diconf["rates_selection"]["standard_deviations"] = (100 * myplan.rateStdev).tolist()
264
+ diconf["rates_selection"]["correlations"] = myplan.rateCorr.tolist()
78
265
  if myplan.rateMethod in ["historical average", "historical", "histochastic"]:
79
- diconf["Rates Selection"]["From"] = int(myplan.rateFrm)
80
- diconf["Rates Selection"]["To"] = int(myplan.rateTo)
266
+ diconf["rates_selection"]["from"] = int(myplan.rateFrm)
267
+ diconf["rates_selection"]["to"] = int(myplan.rateTo)
81
268
  else:
82
- diconf["Rates Selection"]["From"] = int(FROM)
83
- diconf["Rates Selection"]["To"] = int(TO)
269
+ diconf["rates_selection"]["from"] = int(FROM)
270
+ diconf["rates_selection"]["to"] = int(TO)
271
+ diconf["rates_selection"]["reverse_sequence"] = bool(myplan.rateReverse)
272
+ diconf["rates_selection"]["roll_sequence"] = int(myplan.rateRoll)
84
273
 
85
274
  # Asset Allocation.
86
- diconf["Asset Allocation"] = {
87
- "Interpolation method": myplan.interpMethod,
88
- "Interpolation center": float(myplan.interpCenter),
89
- "Interpolation width": float(myplan.interpWidth),
90
- "Type": myplan.ARCoord,
275
+ diconf["asset_allocation"] = {
276
+ "interpolation_method": myplan.interpMethod,
277
+ "interpolation_center": float(myplan.interpCenter),
278
+ "interpolation_width": float(myplan.interpWidth),
279
+ "type": myplan.ARCoord,
91
280
  }
92
281
  if myplan.ARCoord == "account":
93
282
  for accType in AccountTypes:
94
- diconf["Asset Allocation"][accType] = myplan.boundsAR[accType]
283
+ diconf["asset_allocation"][accType] = myplan.boundsAR[accType]
95
284
  else:
96
- diconf["Asset Allocation"]["generic"] = myplan.boundsAR["generic"]
285
+ diconf["asset_allocation"]["generic"] = myplan.boundsAR["generic"]
97
286
 
98
287
  # Optimization Parameters.
99
- diconf["Optimization Parameters"] = {
100
- "Spending profile": myplan.spendingProfile,
101
- "Surviving spouse spending percent": int(100 * myplan.chi),
288
+ diconf["optimization_parameters"] = {
289
+ "spending_profile": myplan.spendingProfile,
290
+ "surviving_spouse_spending_percent": int(100 * myplan.chi),
102
291
  }
103
292
  if myplan.spendingProfile == "smile":
104
- diconf["Optimization Parameters"]["Smile dip"] = int(myplan.smileDip)
105
- diconf["Optimization Parameters"]["Smile increase"] = int(myplan.smileIncrease)
106
- diconf["Optimization Parameters"]["Smile delay"] = int(myplan.smileDelay)
293
+ diconf["optimization_parameters"]["smile_dip"] = int(myplan.smileDip)
294
+ diconf["optimization_parameters"]["smile_increase"] = int(myplan.smileIncrease)
295
+ diconf["optimization_parameters"]["smile_delay"] = int(myplan.smileDelay)
107
296
 
108
- diconf["Optimization Parameters"]["Objective"] = myplan.objective
109
- diconf["Solver Options"] = myplan.solverOptions
297
+ diconf["optimization_parameters"]["objective"] = myplan.objective
298
+ diconf["solver_options"] = myplan.solverOptions
110
299
 
111
300
  # Results.
112
- diconf["Results"] = {"Default plots": myplan.defaultPlots}
301
+ diconf["results"] = {"default_plots": myplan.defaultPlots}
113
302
 
114
303
  if isinstance(file, str):
115
304
  filename = file
@@ -146,62 +335,48 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
146
335
  """
147
336
  mylog = log.Logger(verbose, logstreams)
148
337
 
149
- dirname = ""
150
- if isinstance(file, str):
151
- filename = file
152
- dirname = os.path.dirname(filename)
153
- if not filename.endswith(".toml"):
154
- filename = filename + ".toml"
338
+ diconf, dirname, filename = _read_toml_file(file)
155
339
 
340
+ if filename is not None:
156
341
  mylog.vprint(f"Reading plan from case file '{filename}'.")
157
342
 
158
- try:
159
- with open(filename, "r") as f:
160
- diconf = toml.load(f)
161
- except Exception as e:
162
- raise FileNotFoundError(f"File {filename} not found: {e}") from e
163
- elif isinstance(file, BytesIO):
164
- try:
165
- string = file.getvalue().decode("utf-8")
166
- diconf = toml.loads(string)
167
- except Exception as e:
168
- raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
169
- elif isinstance(file, StringIO):
170
- try:
171
- string = file.getvalue()
172
- diconf = toml.loads(string)
173
- except Exception as e:
174
- raise RuntimeError(f"Cannot read from StringIO: {e}") from e
175
- else:
176
- raise ValueError(f"Type {type(file)} not a valid type")
343
+ # Translate old keys to new snake_case keys for backward compatibility
344
+ diconf = translate_old_keys(diconf)
177
345
 
178
346
  # Basic Info.
179
- name = diconf["Plan Name"]
180
- inames = diconf["Basic Info"]["Names"]
347
+ name = diconf["case_name"]
348
+ inames = diconf["basic_info"]["names"]
181
349
  icount = len(inames)
182
350
  # Default to January 15, 1965 if no entry is found.
183
- dobs = diconf["Basic Info"].get("Date of birth", ["1965-01-15"]*icount)
184
- expectancy = diconf["Basic Info"]["Life expectancy"]
351
+ dobs = diconf["basic_info"].get("date_of_birth", ["1965-01-15"]*icount)
352
+ expectancy = diconf["basic_info"]["life_expectancy"]
185
353
  s = ["", "s"][icount - 1]
186
354
  mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
187
355
  p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
188
- p._description = diconf.get("Description", "")
356
+ p._description = diconf.get("description", "")
189
357
 
190
358
  # Assets.
191
- startDate = diconf["Basic Info"].get("Start date", "today")
359
+ startDate = diconf["basic_info"].get("start_date", "today")
192
360
  balances = {}
361
+ # Map account type names to snake_case keys
362
+ account_key_map = {
363
+ "taxable": "taxable_savings_balances",
364
+ "tax-deferred": "tax_deferred_savings_balances",
365
+ "tax-free": "tax_free_savings_balances"
366
+ }
193
367
  for acc in AccountTypes:
194
- balances[acc] = diconf["Assets"][f"{acc} savings balances"]
368
+ balances[acc] = diconf["savings_assets"][account_key_map[acc]]
195
369
  p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
196
370
  taxFree=balances["tax-free"], startDate=startDate)
197
371
  if icount == 2:
198
- phi_j = diconf["Assets"]["Beneficiary fractions"]
372
+ phi_j = diconf["savings_assets"]["beneficiary_fractions"]
199
373
  p.setBeneficiaryFractions(phi_j)
200
- eta = diconf["Assets"]["Spousal surplus deposit fraction"]
374
+ eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
201
375
  p.setSpousalDepositFraction(eta)
202
376
 
203
377
  # Household Financial Profile
204
- timeListsFileName = diconf["Household Financial Profile"]["HFP file name"]
378
+ hfp_section = diconf.get("household_financial_profile", {})
379
+ timeListsFileName = hfp_section.get("HFP_file_name", "None")
205
380
  if timeListsFileName != "None":
206
381
  if readContributions:
207
382
  if os.path.exists(timeListsFileName):
@@ -216,50 +391,62 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
216
391
  mylog.vprint(f"Ignoring to read contributions file {timeListsFileName}.")
217
392
 
218
393
  # Fixed Income.
219
- ssecAmounts = np.array(diconf["Fixed Income"].get("Social security PIA amounts", [0]*icount), dtype=np.int32)
220
- ssecAges = np.array(diconf["Fixed Income"]["Social security ages"])
394
+ ssecAmounts = np.array(diconf["fixed_income"].get("social_security_pia_amounts", [0]*icount), dtype=np.int32)
395
+ ssecAges = np.array(diconf["fixed_income"]["social_security_ages"])
221
396
  p.setSocialSecurity(ssecAmounts, ssecAges)
222
- pensionAmounts = np.array(diconf["Fixed Income"].get("Pension monthly amounts", [0]*icount), dtype=np.float32)
223
- pensionAges = np.array(diconf["Fixed Income"]["Pension ages"])
224
- pensionIsIndexed = diconf["Fixed Income"]["Pension indexed"]
397
+ pensionAmounts = np.array(diconf["fixed_income"].get("pension_monthly_amounts", [0]*icount), dtype=np.float32)
398
+ pensionAges = np.array(diconf["fixed_income"]["pension_ages"])
399
+ pensionIsIndexed = diconf["fixed_income"]["pension_indexed"]
225
400
  p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
226
401
 
227
402
  # Rates Selection.
228
- p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
229
- p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
230
- p.yOBBBA = int(diconf["Rates Selection"].get("OBBBA expiration year", 2032))
403
+ p.setDividendRate(float(diconf["rates_selection"].get("dividend_rate", 1.8))) # Fix for mod.
404
+ p.setHeirsTaxRate(float(diconf["rates_selection"]["heirs_rate_on_tax_deferred_estate"]))
405
+ p.yOBBBA = int(diconf["rates_selection"].get("obbba_expiration_year", 2032))
231
406
 
232
407
  frm = None
233
408
  to = None
234
409
  rateValues = None
235
410
  stdev = None
236
411
  rateCorr = None
237
- rateMethod = diconf["Rates Selection"]["Method"]
412
+ rateSeed = None
413
+ reproducibleRates = False
414
+ rateMethod = diconf["rates_selection"]["method"]
238
415
  if rateMethod in ["historical average", "historical", "histochastic"]:
239
- frm = diconf["Rates Selection"]["From"]
416
+ frm = diconf["rates_selection"]["from"]
240
417
  if not isinstance(frm, int):
241
418
  frm = int(frm)
242
- to = diconf["Rates Selection"]["To"]
419
+ to = diconf["rates_selection"]["to"]
243
420
  if not isinstance(to, int):
244
421
  to = int(to)
245
422
  if rateMethod in ["user", "stochastic"]:
246
- rateValues = np.array(diconf["Rates Selection"]["Values"], dtype=np.float32)
423
+ rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
247
424
  if rateMethod in ["stochastic"]:
248
- stdev = np.array(diconf["Rates Selection"]["Standard deviations"], dtype=np.float32)
249
- rateCorr = np.array(diconf["Rates Selection"]["Correlations"], dtype=np.float32)
250
- p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr)
425
+ stdev = np.array(diconf["rates_selection"]["standard_deviations"], dtype=np.float64)
426
+ rateCorr = np.array(diconf["rates_selection"]["correlations"], dtype=np.float64)
427
+ # Load seed and reproducibility flag for stochastic methods
428
+ if rateMethod in ["stochastic", "histochastic"]:
429
+ rateSeed = diconf["rates_selection"].get("rate_seed")
430
+ if rateSeed is not None:
431
+ rateSeed = int(rateSeed)
432
+ reproducibleRates = diconf["rates_selection"].get("reproducible_rates", False)
433
+ p.setReproducible(reproducibleRates, seed=rateSeed)
434
+ reverseSequence = diconf["rates_selection"].get("reverse_sequence", False)
435
+ rollSequence = diconf["rates_selection"].get("roll_sequence", 0)
436
+ p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr,
437
+ reverse=reverseSequence, roll=rollSequence)
251
438
 
252
439
  # Asset Allocation.
253
440
  boundsAR = {}
254
441
  p.setInterpolationMethod(
255
- diconf["Asset Allocation"]["Interpolation method"],
256
- float(diconf["Asset Allocation"]["Interpolation center"]),
257
- float(diconf["Asset Allocation"]["Interpolation width"]),
442
+ diconf["asset_allocation"]["interpolation_method"],
443
+ float(diconf["asset_allocation"]["interpolation_center"]),
444
+ float(diconf["asset_allocation"]["interpolation_width"]),
258
445
  )
259
- allocType = diconf["Asset Allocation"]["Type"]
446
+ allocType = diconf["asset_allocation"]["type"]
260
447
  if allocType == "account":
261
448
  for aType in AccountTypes:
262
- boundsAR[aType] = np.array(diconf["Asset Allocation"][aType], dtype=np.float32)
449
+ boundsAR[aType] = np.array(diconf["asset_allocation"][aType], dtype=np.float64)
263
450
 
264
451
  p.setAllocationRatios(
265
452
  allocType,
@@ -268,7 +455,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
268
455
  taxFree=boundsAR["tax-free"],
269
456
  )
270
457
  elif allocType == "individual" or allocType == "spouses":
271
- boundsAR["generic"] = np.array(diconf["Asset Allocation"]["generic"], dtype=np.float32)
458
+ boundsAR["generic"] = np.array(diconf["asset_allocation"]["generic"], dtype=np.float64)
272
459
  p.setAllocationRatios(
273
460
  allocType,
274
461
  generic=boundsAR["generic"],
@@ -277,14 +464,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
277
464
  raise ValueError(f"Unknown asset allocation type {allocType}.")
278
465
 
279
466
  # Optimization Parameters.
280
- p.objective = diconf["Optimization Parameters"]["Objective"]
467
+ p.objective = diconf["optimization_parameters"]["objective"]
281
468
 
282
- profile = diconf["Optimization Parameters"]["Spending profile"]
283
- survivor = int(diconf["Optimization Parameters"]["Surviving spouse spending percent"])
469
+ profile = diconf["optimization_parameters"]["spending_profile"]
470
+ survivor = int(diconf["optimization_parameters"]["surviving_spouse_spending_percent"])
284
471
  if profile == "smile":
285
- dip = int(diconf["Optimization Parameters"]["Smile dip"])
286
- increase = int(diconf["Optimization Parameters"]["Smile increase"])
287
- delay = int(diconf["Optimization Parameters"]["Smile delay"])
472
+ dip = int(diconf["optimization_parameters"]["smile_dip"])
473
+ increase = int(diconf["optimization_parameters"]["smile_increase"])
474
+ delay = int(diconf["optimization_parameters"]["smile_delay"])
288
475
  else:
289
476
  dip = 15
290
477
  increase = 12
@@ -293,23 +480,33 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
293
480
  p.setSpendingProfile(profile, survivor, dip, increase, delay)
294
481
 
295
482
  # Solver Options.
296
- p.solverOptions = diconf["Solver Options"]
483
+ p.solverOptions = dict(diconf["solver_options"])
297
484
 
298
- # Address legacy case files.
299
- if diconf["Solver Options"].get("withMedicare"):
485
+ # Defaults for options not present in case file (e.g. Case_joe.toml).
486
+ # Ensures Medicare is computed in loop mode and self-consistent loop runs.
487
+ if "withMedicare" not in p.solverOptions:
300
488
  p.solverOptions["withMedicare"] = "loop"
489
+ if "withSCLoop" not in p.solverOptions:
490
+ p.solverOptions["withSCLoop"] = True
491
+
492
+ # Address legacy case files.
493
+ # Convert boolean values (True/False) to string format, but preserve string values
494
+ withMedicare = p.solverOptions.get("withMedicare")
495
+ if isinstance(withMedicare, bool):
496
+ p.solverOptions["withMedicare"] = "loop" if withMedicare else "None"
301
497
 
302
498
  # Check consistency of noRothConversions.
303
499
  name = p.solverOptions.get("noRothConversions", "None")
304
500
  if name != "None" and name not in p.inames:
305
501
  raise ValueError(f"Unknown name {name} for noRothConversions.")
306
502
 
307
- # Rebase startRothConversions on year change.
503
+ # Rebase startRothConversions and yOBBBA on year change.
308
504
  thisyear = date.today().year
309
505
  year = p.solverOptions.get("startRothConversions", thisyear)
310
506
  p.solverOptions["startRothConversions"] = max(year, thisyear)
507
+ p.yOBBBA = max(p.yOBBBA, thisyear)
311
508
 
312
509
  # Results.
313
- p.setDefaultPlots(diconf["Results"]["Default plots"])
510
+ p.setDefaultPlots(diconf["results"]["default_plots"])
314
511
 
315
512
  return p
@@ -0,0 +1,21 @@
1
+ """
2
+ Data package for Owl retirement planner.
3
+
4
+ This package contains data files and utilities for the retirement planner,
5
+ including historical rate of return data.
6
+
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
8
+
9
+ This program is free software: you can redistribute it and/or modify
10
+ it under the terms of the GNU General Public License as published by
11
+ the Free Software Foundation, either version 3 of the License, or
12
+ (at your option) any later version.
13
+
14
+ This program is distributed in the hope that it will be useful,
15
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ GNU General Public License for more details.
18
+
19
+ You should have received a copy of the GNU General Public License
20
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
21
+ """