owlplanner 2025.12.20__py3-none-any.whl → 2026.1.26__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.
@@ -1,16 +1,23 @@
1
1
  """
2
+ Social Security benefit calculation rules and utilities.
2
3
 
3
- Owl/socialsecurity
4
- --------
4
+ This module implements Social Security rules including full retirement age
5
+ calculations, benefit computations, and related retirement planning functions.
5
6
 
6
- A retirement planner using linear programming optimization.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
7
8
 
8
- This file contains the rules related to social security.
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.
9
13
 
10
- Copyright © 2025 - Martin-D. Lacasse
11
-
12
- 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.
13
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/>.
14
21
  """
15
22
 
16
23
  import numpy as np
@@ -91,7 +98,7 @@ def getSpousalBenefits(pias):
91
98
  return benefits
92
99
 
93
100
 
94
- def getSelfFactor(fra, convage, bornOnFirst):
101
+ def getSelfFactor(fra, convage, bornOnFirstDays):
95
102
  """
96
103
  Return the reduction/increase factor to multiply PIA based on claiming age.
97
104
 
@@ -102,7 +109,8 @@ def getSelfFactor(fra, convage, bornOnFirst):
102
109
  - After FRA: Benefits are increased by 8% per year (up to 132% at age 70)
103
110
 
104
111
  The function automatically adjusts for Social Security age if the birthday is on
105
- the first day of the month (adds 1/12 year to conventional age).
112
+ the 1st or 2nd day of the month (adds 1/12 year to conventional age), consistent
113
+ with SSA rules that treat both days the same for age calculation purposes.
106
114
 
107
115
  Parameters
108
116
  ----------
@@ -111,8 +119,8 @@ def getSelfFactor(fra, convage, bornOnFirst):
111
119
  convage : float
112
120
  Conventional age when benefits start, in years (can be fractional with 1/12 increments).
113
121
  Must be between 62 and 70 inclusive.
114
- bornOnFirst : bool
115
- True if birthday is on the first day of the month, False otherwise.
122
+ bornOnFirstDays : bool
123
+ True if birthday is on the 1st or 2nd day of the month, False otherwise.
116
124
  If True, the function adds 1/12 year to convert to Social Security age.
117
125
 
118
126
  Returns
@@ -131,8 +139,8 @@ def getSelfFactor(fra, convage, bornOnFirst):
131
139
  if convage < 62 or convage > 70:
132
140
  raise ValueError(f"Age {convage} out of range.")
133
141
 
134
- # Add a month to conventional age if born on the first.
135
- offset = 0 if not bornOnFirst else 1/12
142
+ # Add a month to conventional age if born on the 1st or 2nd (SSA treats both the same).
143
+ offset = 0 if not bornOnFirstDays else 1/12
136
144
  ssage = convage + offset
137
145
 
138
146
  diff = fra - ssage
@@ -146,7 +154,7 @@ def getSelfFactor(fra, convage, bornOnFirst):
146
154
  return .8 - 0.05 * (diff - 3)
147
155
 
148
156
 
149
- def getSpousalFactor(fra, convage, bornOnFirst):
157
+ def getSpousalFactor(fra, convage, bornOnFirstDays):
150
158
  """
151
159
  Return the reduction factor to multiply spousal benefits based on claiming age.
152
160
 
@@ -156,7 +164,8 @@ def getSpousalFactor(fra, convage, bornOnFirst):
156
164
  - At or after FRA: Full spousal benefit (50% of spouse's PIA, no increase for delay)
157
165
 
158
166
  The function automatically adjusts for Social Security age if the birthday is on
159
- the first day of the month (adds 1/12 year to conventional age).
167
+ the 1st or 2nd day of the month (adds 1/12 year to conventional age), consistent
168
+ with SSA rules that treat both days the same for age calculation purposes.
160
169
 
161
170
  Parameters
162
171
  ----------
@@ -165,8 +174,8 @@ def getSpousalFactor(fra, convage, bornOnFirst):
165
174
  convage : float
166
175
  Conventional age when benefits start, in years (can be fractional with 1/12 increments).
167
176
  Must be at least 62 (no maximum, but no increase beyond FRA).
168
- bornOnFirst : bool
169
- True if birthday is on the first day of the month, False otherwise.
177
+ bornOnFirstDays : bool
178
+ True if birthday is on the 1st or 2nd day of the month, False otherwise.
170
179
  If True, the function adds 1/12 year to convert to Social Security age.
171
180
 
172
181
  Returns
@@ -185,8 +194,8 @@ def getSpousalFactor(fra, convage, bornOnFirst):
185
194
  if convage < 62:
186
195
  raise ValueError(f"Age {convage} out of range.")
187
196
 
188
- # Add a month to conventional age if born on the first.
189
- offset = 0 if not bornOnFirst else 1/12
197
+ # Add a month to conventional age if born on the 1st or 2nd (SSA treats both the same).
198
+ offset = 0 if not bornOnFirstDays else 1/12
190
199
  ssage = convage + offset
191
200
 
192
201
  diff = fra - ssage
owlplanner/tax2026.py CHANGED
@@ -1,19 +1,23 @@
1
1
  """
2
+ Tax calculation module for 2026 tax year rules.
2
3
 
3
- Owl/tax2026
4
- ---
4
+ This module handles all tax calculations including income tax brackets,
5
+ capital gains tax, and other tax-related computations based on 2026 tax rules.
5
6
 
6
- A retirement planner using linear programming optimization.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
7
8
 
8
- See companion document for a complete explanation and description
9
- of all variables and parameters.
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.
10
13
 
11
- Module to handle all tax calculations.
12
-
13
- Copyright &copy; 2026 - Martin-D. Lacasse
14
-
15
- 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.
16
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/>.
17
21
  """
18
22
 
19
23
  import numpy as np
@@ -116,25 +120,26 @@ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
116
120
  """
117
121
  Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
118
122
  defining end points of constant piecewise linear functions representing IRMAA fees.
123
+ Costs C include the fact that one or two indivuals have to pay. Eligibility is built-in.
119
124
  """
120
125
  thisyear = date.today().year
121
126
  assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
122
127
  assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
123
128
  Ni = len(yobs)
124
- # What index year will Medicare start? 65 - age.
125
- nm = 65 - (thisyear - yobs)
126
- nm = np.min(nm)
129
+ # What index year will Medicare start? 65 - age for each individual.
130
+ nm = yobs + 65 - thisyear
131
+ nm = np.maximum(0, nm)
132
+ nmstart = np.min(nm)
127
133
  # Has it already started?
128
- nm = max(0, nm)
129
- Nmed = Nn - nm
134
+ Nmed = Nn - nmstart
130
135
 
131
136
  L = np.zeros((Nmed, Nq-1))
132
137
  C = np.zeros((Nmed, Nq))
133
138
 
134
- # Year starts at offset nm in the plan.
139
+ # Year starts at offset nmstart in the plan. L and C arrays are shorter.
135
140
  for nn in range(Nmed):
136
141
  imed = 0
137
- n = nm + nn
142
+ n = nmstart + nn
138
143
  if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
139
144
  imed += 1
140
145
  if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
@@ -146,28 +151,83 @@ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
146
151
  else:
147
152
  raise RuntimeError("mediVals: This should never happen.")
148
153
 
149
- return nm, L, C
154
+ return nmstart, L, C
150
155
 
151
156
 
152
- def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
157
+ def capitalGainTax(Ni, txIncome_n, ltcg_n, gamma_n, nd, Nn):
153
158
  """
154
- Return an array of decimal rates for capital gains.
155
- Parameter nd is the index year of first passing of a spouse, if applicable,
156
- nd == Nn for single individuals.
159
+ Return an array of tax on capital gains.
160
+
161
+ Parameters:
162
+ -----------
163
+ Ni : int
164
+ Number of individuals (1 or 2)
165
+ txIncome_n : array
166
+ Array of taxable income for each year (ordinary income + capital gains)
167
+ ltcg_n : array
168
+ Array of long-term capital gains for each year
169
+ gamma_n : array
170
+ Array of inflation adjustment factors for each year
171
+ nd : int
172
+ Index year of first passing of a spouse, if applicable (nd == Nn for single individuals)
173
+ Nn : int
174
+ Total number of years in the plan
175
+
176
+ Returns:
177
+ --------
178
+ cgTax_n : array
179
+ Array of tax on capital gains for each year
180
+
181
+ Notes:
182
+ ------
183
+ Thresholds are determined by the taxable income which is roughly AGI - (standard/itemized) deductions.
184
+ Taxable income can also be thought of as taxable ordinary income + capital gains.
185
+
186
+ Long-term capital gains are taxed at 0%, 15%, or 20% based on total taxable income.
187
+ Capital gains "stack on top" of ordinary income, so the portion of gains that
188
+ pushes total income above each threshold is taxed at the corresponding rate.
157
189
  """
158
190
  status = Ni - 1
159
- cgRate_n = np.zeros(Nn)
191
+ cgTax_n = np.zeros(Nn)
160
192
 
161
193
  for n in range(Nn):
162
194
  if status and n == nd:
163
195
  status -= 1
164
196
 
165
- if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
166
- cgRate_n[n] = 0.20
167
- elif magi_n[n] > gamma_n[n] * capGainRates[status][0]:
168
- cgRate_n[n] = 0.15
197
+ # Calculate ordinary income (taxable income minus capital gains).
198
+ ordIncome = txIncome_n[n] - ltcg_n[n]
199
+
200
+ # Get inflation-adjusted thresholds for this year.
201
+ threshold15 = gamma_n[n] * capGainRates[status][0] # 0% to 15% threshold
202
+ threshold20 = gamma_n[n] * capGainRates[status][1] # 15% to 20% threshold
203
+
204
+ # Calculate how much LTCG falls in the 20% bracket.
205
+ # This is the portion of LTCG that pushes total income above threshold20.
206
+ if txIncome_n[n] > threshold20:
207
+ ltcg20 = min(ltcg_n[n], txIncome_n[n] - threshold20)
208
+ else:
209
+ ltcg20 = 0
210
+
211
+ # Calculate how much LTCG falls in the 15% bracket.
212
+ # This is the portion of LTCG in the range [threshold15, threshold20].
213
+ if ordIncome >= threshold20:
214
+ # All LTCG is already in the 20% bracket.
215
+ ltcg15 = 0
216
+ elif txIncome_n[n] > threshold15:
217
+ # Some LTCG falls in the 15% bracket.
218
+ # The 15% bracket spans from threshold15 to threshold20.
219
+ bracket_top = min(threshold20, txIncome_n[n])
220
+ bracket_bottom = max(threshold15, ordIncome)
221
+ ltcg15 = min(ltcg_n[n] - ltcg20, bracket_top - bracket_bottom)
222
+ else:
223
+ # Total income is below the 15% threshold.
224
+ ltcg15 = 0
225
+
226
+ # Remaining LTCG is in the 0% bracket (ltcg0 = ltcg_n[n] - ltcg20 - ltcg15).
227
+ # Calculate tax: 20% on ltcg20, 15% on ltcg15, 0% on remainder.
228
+ cgTax_n[n] = 0.20 * ltcg20 + 0.15 * ltcg15
169
229
 
170
- return cgRate_n
230
+ return cgTax_n
171
231
 
172
232
 
173
233
  def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
@@ -271,7 +331,7 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
271
331
  # Number of years left in OBBBA from this year.
272
332
  thisyear = date.today().year
273
333
  if yOBBBA < thisyear:
274
- raise ValueError(f"Expiration year {yOBBBA} cannot be in the past.")
334
+ raise ValueError(f"OBBBA expiration year {yOBBBA} cannot be in the past.")
275
335
 
276
336
  ytc = yOBBBA - thisyear
277
337
 
@@ -307,7 +367,7 @@ def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
307
367
  return J_n
308
368
 
309
369
 
310
- def rho_in(yobs, N_n):
370
+ def rho_in(yobs, longevity, N_n):
311
371
  """
312
372
  Return Required Minimum Distribution fractions for each individual.
313
373
  This implementation does not support spouses with more than
@@ -350,11 +410,30 @@ def rho_in(yobs, N_n):
350
410
  5.2,
351
411
  4.9,
352
412
  4.6,
413
+ 4.3,
414
+ 4.1,
415
+ 3.9,
416
+ 3.7,
417
+ 3.5,
418
+ 3.4,
419
+ 3.3,
420
+ 3.1,
421
+ 3.0,
422
+ 2.9,
423
+ 2.8,
424
+ 2.7,
425
+ 2.5,
426
+ 2.3,
427
+ 2.0
353
428
  ]
354
429
 
355
430
  N_i = len(yobs)
356
431
  if N_i == 2 and abs(yobs[0] - yobs[1]) > 10:
357
432
  raise RuntimeError("RMD: Unsupported age difference of more than 10 years.")
433
+ if np.any(np.array(longevity) > 120):
434
+ raise RuntimeError(
435
+ "RMD: Unsupported life expectancy over 120 years."
436
+ )
358
437
 
359
438
  rho = np.zeros((N_i, N_n))
360
439
  thisyear = date.today().year
owlplanner/timelists.py CHANGED
@@ -1,24 +1,30 @@
1
1
  """
2
+ Time horizon data validation and processing utilities.
2
3
 
3
- Owl/timelists
4
- ---
4
+ This module provides utility functions to read and validate timelist data
5
+ from Excel files, including wage, contribution, and other time-based parameters.
5
6
 
6
- A retirement planner using linear programming optimization.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
7
8
 
8
- See companion document for a complete explanation and description
9
- of all variables and parameters.
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.
10
13
 
11
- Utility functions to read and check timelists.
12
-
13
- Copyright &copy; 2024 - Martin-D. Lacasse
14
-
15
- 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.
16
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/>.
17
21
  """
18
22
 
19
23
  from datetime import date
20
24
  import pandas as pd
21
25
 
26
+ from . import utils as u
27
+
22
28
 
23
29
  # Expected headers in each excel sheet, one per individual.
24
30
  _timeHorizonItems = [
@@ -35,6 +41,7 @@ _timeHorizonItems = [
35
41
 
36
42
 
37
43
  _debtItems = [
44
+ "active",
38
45
  "name",
39
46
  "type",
40
47
  "year",
@@ -51,8 +58,10 @@ _debtTypes = [
51
58
 
52
59
 
53
60
  _fixedAssetItems = [
61
+ "active",
54
62
  "name",
55
63
  "type",
64
+ "year",
56
65
  "basis",
57
66
  "value",
58
67
  "rate",
@@ -71,7 +80,7 @@ _fixedAssetTypes = [
71
80
  ]
72
81
 
73
82
 
74
- def read(finput, inames, horizons, mylog):
83
+ def read(finput, inames, horizons, mylog, filename=None):
75
84
  """
76
85
  Read listed parameters from an excel spreadsheet or through
77
86
  a dictionary of dataframes through Pandas.
@@ -80,6 +89,20 @@ def read(finput, inames, horizons, mylog):
80
89
  IRA ctrb, Roth IRA ctrb, Roth conv, and big-ticket items.
81
90
  Supports xls, xlsx, xlsm, xlsb, odf, ods, and odt file extensions.
82
91
  Return a dictionary of dataframes by individual's names.
92
+
93
+ Parameters
94
+ ----------
95
+ finput : file-like object, str, or dict
96
+ Input file or dictionary of DataFrames
97
+ inames : list
98
+ List of individual names
99
+ horizons : list
100
+ List of time horizons
101
+ mylog : logger
102
+ Logger instance
103
+ filename : str, optional
104
+ Explicit filename for logging purposes. If provided, this will be used
105
+ instead of trying to extract it from finput.
83
106
  """
84
107
 
85
108
  mylog.vprint("Reading wages, contributions, conversions, and big-ticket items over time...")
@@ -89,13 +112,19 @@ def read(finput, inames, horizons, mylog):
89
112
  finput = "dictionary of DataFrames"
90
113
  streamName = "dictionary of DataFrames"
91
114
  else:
115
+ if filename is not None:
116
+ streamName = f"file '{filename}'"
117
+ elif hasattr(finput, "name"):
118
+ streamName = f"file '{finput.name}'"
119
+ else:
120
+ streamName = finput
121
+
92
122
  # Read all worksheets in memory but only process those with proper names.
93
123
  try:
94
124
  # dfDict = pd.read_excel(finput, sheet_name=None, usecols=_timeHorizonItems)
95
125
  dfDict = pd.read_excel(finput, sheet_name=None)
96
126
  except Exception as e:
97
- raise Exception(f"Could not read file {finput}: {e}.") from e
98
- streamName = f"file '{finput}'"
127
+ raise Exception(f"Could not read file {streamName}: {e}.") from e
99
128
 
100
129
  timeLists = _conditionTimetables(dfDict, inames, horizons, mylog)
101
130
  mylog.vprint(f"Successfully read time horizons from {streamName}.")
@@ -111,10 +140,13 @@ def _checkColumns(df, iname, colList):
111
140
  Ensure all columns in colList are present. Remove others.
112
141
  """
113
142
  # Drop all columns not in the list.
114
- df = df.loc[:, ~df.columns.str.contains("^Unnamed")]
115
- for col in df.columns:
116
- if col == "" or col not in colList:
117
- df.drop(col, axis=1, inplace=True)
143
+ # Make an explicit copy to avoid SettingWithCopyWarning
144
+ df = df.loc[:, ~df.columns.str.contains("^Unnamed")].copy()
145
+
146
+ # Collect columns to drop
147
+ cols_to_drop = [col for col in df.columns if col == "" or col not in colList]
148
+ if cols_to_drop:
149
+ df = df.drop(cols_to_drop, axis=1)
118
150
 
119
151
  # Check that all columns in the list are present.
120
152
  for item in colList:
@@ -141,6 +173,9 @@ def _conditionTimetables(dfDict, inames, horizons, mylog):
141
173
 
142
174
  df = _checkColumns(df, iname, _timeHorizonItems)
143
175
 
176
+ # Ensure columns are in the correct order
177
+ df = df[_timeHorizonItems].copy()
178
+
144
179
  # Only consider lines in proper year range. Go back 5 years for Roth maturation.
145
180
  df = df[df["year"] >= (thisyear - 5)]
146
181
  df = df[df["year"] < endyear]
@@ -150,7 +185,10 @@ def _conditionTimetables(dfDict, inames, horizons, mylog):
150
185
  year = thisyear + n
151
186
  year_rows = df[df["year"] == year]
152
187
  if year_rows.empty:
153
- df.loc[len(df)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
188
+ # Create a new row as a dictionary to ensure correct column mapping.
189
+ new_row = {col: 0 for col in _timeHorizonItems}
190
+ new_row["year"] = year
191
+ df = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)
154
192
  missing.append(year)
155
193
  else:
156
194
  for item in _timeHorizonItems:
@@ -220,6 +258,21 @@ def _conditionHouseTables(dfDict, mylog):
220
258
  f"Converted {mask.sum()} commission value(s) from decimal "
221
259
  f"to percentage in Fixed Assets table."
222
260
  )
261
+ # Validate and reset "year" column (reference year) if in the past
262
+ if "year" in df.columns:
263
+ thisyear = date.today().year
264
+ mask = df["year"] < thisyear
265
+ if mask.any():
266
+ df.loc[mask, "year"] = thisyear
267
+ mylog.vprint(
268
+ f"Reset {mask.sum()} reference year value(s) to {thisyear} "
269
+ f"in Fixed Assets table (years cannot be in the past)."
270
+ )
271
+
272
+ # Convert "active" column to boolean if it exists.
273
+ # Excel may read booleans as strings ("True"/"False") or numbers (1/0).
274
+ if "active" in df.columns:
275
+ df["active"] = df["active"].apply(u.convert_to_bool).astype(bool)
223
276
 
224
277
  houseDic[page] = df
225
278
  mylog.vprint(f"Found {len(df)} valid row(s) in {page} table.")
@@ -228,3 +281,126 @@ def _conditionHouseTables(dfDict, mylog):
228
281
  mylog.vprint(f"Table for {page} not found. Assuming empty table.")
229
282
 
230
283
  return houseDic
284
+
285
+
286
+ def conditionDebtsAndFixedAssetsDF(df, tableType, mylog=None):
287
+ """
288
+ Condition a DataFrame for Debts or Fixed Assets by:
289
+ - Creating an empty DataFrame with proper columns if df is None or empty
290
+ - Resetting the index
291
+ - Filling NaN values with 0 while preserving boolean columns (like "active")
292
+
293
+ Parameters
294
+ ----------
295
+ df : pandas.DataFrame or None
296
+ The DataFrame to condition, or None/empty to create a new empty DataFrame
297
+ tableType : str
298
+ Type of table: "Debts" or "Fixed Assets"
299
+ mylog : logger, optional
300
+ Logger instance for optional UI/log output
301
+
302
+ Returns
303
+ -------
304
+ pandas.DataFrame
305
+ Conditioned DataFrame with proper columns and no NaN values (except boolean columns default to True)
306
+ """
307
+ # Map table type to column items
308
+ items = {"Debts": _debtItems, "Fixed Assets": _fixedAssetItems}
309
+ if tableType not in items:
310
+ raise ValueError(f"tableType must be 'Debts' or 'Fixed Assets', got '{tableType}'")
311
+
312
+ columnItems = items[tableType]
313
+
314
+ df = u.ensure_dataframe(df, pd.DataFrame(columns=columnItems))
315
+
316
+ df = df.copy()
317
+ df.reset_index(drop=True, inplace=True)
318
+
319
+ # Ensure all required columns exist
320
+ for col in columnItems:
321
+ if col not in df.columns:
322
+ df[col] = None
323
+
324
+ # Only keep the columns we need, in the correct order
325
+ df = df[columnItems].copy()
326
+
327
+ # Define which columns are integers vs floats
328
+ if tableType == "Debts":
329
+ int_cols = ["year", "term"]
330
+ float_cols = ["amount", "rate"]
331
+ else: # Fixed Assets
332
+ int_cols = ["year", "yod"]
333
+ float_cols = ["basis", "value", "rate", "commission"]
334
+
335
+ # Handle empty DataFrame by setting dtypes directly
336
+ if len(df) == 0:
337
+ dtype_dict = {}
338
+ dtype_dict["active"] = bool
339
+ for col in ["name", "type"]:
340
+ dtype_dict[col] = "object" # string columns
341
+ for col in int_cols:
342
+ dtype_dict[col] = "int64"
343
+ for col in float_cols:
344
+ dtype_dict[col] = "float64"
345
+ df = df.astype(dtype_dict)
346
+ else:
347
+ # Fill NaN values and ensure proper types for non-empty DataFrame
348
+ for col in df.columns:
349
+ if col == "active":
350
+ # Ensure "active" column is boolean, handling strings/numbers from Excel
351
+ df[col] = df[col].apply(u.convert_to_bool).astype(bool)
352
+ elif col in ["name", "type"]:
353
+ # String columns: ensure they are strings, not lists
354
+ # Streamlit data_editor can return lists for string columns in some cases
355
+ def convert_to_string(val):
356
+ if pd.isna(val) or val is None:
357
+ return ""
358
+ if isinstance(val, list):
359
+ # If it's a list, join the elements (handles Streamlit data_editor edge cases)
360
+ # Filter out None/NaN values before joining
361
+ cleaned = [str(x) for x in val if x is not None and not pd.isna(x)]
362
+ return " ".join(cleaned) if cleaned else ""
363
+ return str(val)
364
+
365
+ df[col] = df[col].apply(convert_to_string).astype(str)
366
+ # Replace "nan" string with empty string
367
+ df[col] = df[col].replace("nan", "").replace("None", "")
368
+ elif col in int_cols:
369
+ # Integer columns: convert to int64, fill NaN with 0
370
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
371
+ elif col in float_cols:
372
+ # Float columns: convert to float64, fill NaN with 0.0
373
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0).astype("float64")
374
+
375
+ # For Fixed Assets, validate and reset "year" column if in the past
376
+ if tableType == "Fixed Assets" and "year" in df.columns and len(df) > 0:
377
+ thisyear = date.today().year
378
+ mask = df["year"] < thisyear
379
+ if mask.any():
380
+ df.loc[mask, "year"] = thisyear
381
+
382
+ if mylog is not None:
383
+ mylog.vprint(f"Found {len(df)} valid row(s) in {tableType} table.")
384
+
385
+ return df
386
+
387
+
388
+ def getTableTypes(tableType):
389
+ """
390
+ Get the list of valid types for a given table type.
391
+
392
+ Parameters
393
+ ----------
394
+ tableType : str
395
+ Type of table: "Debts" or "Fixed Assets"
396
+
397
+ Returns
398
+ -------
399
+ list
400
+ List of valid types for the specified table
401
+ """
402
+ types = {"Debts": _debtTypes, "Fixed Assets": _fixedAssetTypes}
403
+ if tableType not in types:
404
+ raise ValueError(f"tableType must be 'Debts' or 'Fixed Assets', got '{tableType}'")
405
+
406
+ return types[tableType]