owlplanner 2025.2.25__tar.gz → 2025.2.27__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/PKG-INFO +1 -1
  2. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_jack+jill.toml +1 -1
  3. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_john+sally.toml +1 -1
  4. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_jon+jane.toml +1 -1
  5. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/pyproject.toml +1 -1
  6. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/plan.py +40 -24
  7. owlplanner-2025.2.27/src/owlplanner/version.py +1 -0
  8. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Asset_Allocation.py +1 -2
  9. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Assets.py +1 -2
  10. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Create_Case.py +18 -7
  11. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Documentation.py +9 -7
  12. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Fixed_Income.py +1 -2
  13. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Graphs.py +3 -3
  14. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Historical_Range.py +1 -2
  15. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Logs.py +1 -2
  16. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Monte_Carlo.py +1 -2
  17. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Optimization_Parameters.py +1 -2
  18. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Output_Files.py +10 -7
  19. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Quick_Start.py +4 -3
  20. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Rates_Selection.py +1 -2
  21. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Wages_And_Contributions.py +9 -7
  22. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Worksheets.py +1 -2
  23. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/owlbridge.py +10 -11
  24. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/plots.py +2 -0
  25. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/requirements.txt +1 -1
  26. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/sskeys.py +63 -27
  27. owlplanner-2025.2.27/ui/tomlexamples.py +16 -0
  28. owlplanner-2025.2.25/src/owlplanner/version.py +0 -1
  29. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/.devcontainer/devcontainer.json +0 -0
  30. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/.flake8 +0 -0
  31. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/.gitattributes +0 -0
  32. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/.github/workflows/github-actions-runtests.yml +0 -0
  33. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/.gitignore +0 -0
  34. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/INSTALL.md +0 -0
  35. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/LICENSE +0 -0
  36. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/README.md +0 -0
  37. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/USER_GUIDE.md +0 -0
  38. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docker/Dockerfile +0 -0
  39. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docker/README.md +0 -0
  40. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docker/docker-compose.yml +0 -0
  41. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docker/fastentrypoint.sh +0 -0
  42. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/AD-taxDef.png +0 -0
  43. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/AD-taxFree.png +0 -0
  44. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/AD-taxable.png +0 -0
  45. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/Hist_Bequest.png +0 -0
  46. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/Hist_Spending.png +0 -0
  47. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/MC-tutorial2a.png +0 -0
  48. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/MC-tutorial2b.png +0 -0
  49. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/OwlUI.png +0 -0
  50. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/allocations.png +0 -0
  51. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/owl.png +0 -0
  52. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/profile.png +0 -0
  53. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/ratesCorrelations.png +0 -0
  54. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/ratesPlot.png +0 -0
  55. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/savingsPlot.png +0 -0
  56. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/sourcesPlot.png +0 -0
  57. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/spendingPlot.png +0 -0
  58. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/taxIncomePlot.png +0 -0
  59. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/images/taxesPlot.png +0 -0
  60. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/owl.pdf +0 -0
  61. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/docs/owl.tex +0 -0
  62. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_joe.toml +0 -0
  63. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_kim+sam-bequest.toml +0 -0
  64. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/case_kim+sam-spending.toml +0 -0
  65. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/jack+jill.xlsx +0 -0
  66. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/joe.xlsx +0 -0
  67. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/john+sally.xlsx +0 -0
  68. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/jon+jane.xlsx +0 -0
  69. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/examples/template.xlsx +0 -0
  70. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/john+sally.ipynb +0 -0
  71. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/kim+sam.ipynb +0 -0
  72. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/template.ipynb +0 -0
  73. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/tutorial_1.ipynb +0 -0
  74. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/tutorial_2.ipynb +0 -0
  75. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/notebooks/tutorial_3.ipynb +0 -0
  76. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/owlplanner.cmd +0 -0
  77. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/owlplanner.sh +0 -0
  78. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/requirements.txt +0 -0
  79. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/__init__.py +0 -0
  80. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/abcapi.py +0 -0
  81. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/config.py +0 -0
  82. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/data/__init__.py +0 -0
  83. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/data/rates.csv +0 -0
  84. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/logging.py +0 -0
  85. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/progress.py +0 -0
  86. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/rates.py +0 -0
  87. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/tax2025.py +0 -0
  88. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/timelists.py +0 -0
  89. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/src/owlplanner/utils.py +0 -0
  90. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/tests/test_logger.py +0 -0
  91. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/tests/test_regressions.py +0 -0
  92. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/tests/test_repro.py +0 -0
  93. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/tests/test_toml_cases.py +0 -0
  94. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/tests/test_units.py +0 -0
  95. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ttt.py +0 -0
  96. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/About_Owl.py +0 -0
  97. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/README.md +0 -0
  98. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/Settings.py +0 -0
  99. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/main+fonts.py +0 -0
  100. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/main.py +0 -0
  101. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/progress.py +0 -0
  102. {owlplanner-2025.2.25 → owlplanner-2025.2.27}/ui/style.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.2.25
3
+ Version: 2025.2.27
4
4
  Summary: Owl: Retirement planner with great wisdom
5
5
  Project-URL: HomePage, https://github.com/mdlacasse/owl
6
6
  Project-URL: Repository, https://github.com/mdlacasse/owl
@@ -1,5 +1,5 @@
1
1
  "Plan Name" = "jack+jill"
2
- Description = "This example case aims to demonstrate some of Owl's capabilities. Jack and Jill are a married couple a few years from retirement. A wages and contributions file called 'jack+jill.xlsx' is associated with this case."
2
+ Description = "This example aims to demonstrate some of Owl's capabilities. Jack and Jill are a married couple a few years from retirement. A wages and contributions file called 'jack+jill.xlsx' is associated with this case. This case uses the historical rate sequence of 1969 as a test case for guiding spending amounts from a near worst-case historical scenario. This case also demonstrates that the optimal strategy for Roth conversions does not necessarily involve surfing a tax bracket."
3
3
 
4
4
  ["Basic Info"]
5
5
  Status = "married"
@@ -1,5 +1,5 @@
1
1
  "Plan Name" = "john+sally"
2
- Description = "This example reproduces the case of John and Sally, discussed by Eric Sajdak. This case can be used to compare the heuristic strategy of surfing a tax bracket for performing Roth conversions to a solution optimized by linear programming. The former is a good approach when one assumes fixed rates. When rates are varying, and the market drops, an optimized solution shows that it is sometime advantageous to convert above the target tax bracket. File 'john+sally.xlsx' contains wages and contributions associated with this case. Run this case with different rates to see the effect on Roth conversions."
2
+ Description = "This example reproduces the case of John and Sally, discussed by Eric Sajdak. This case can be used to compare the heuristic strategy of surfing a tax bracket for performing Roth conversions to a solution optimized by linear programming. The former is a good approach when one assumes fixed rates. When rates are varying, and the market drops, an optimized solution shows that it is sometime advantageous to convert above the target tax bracket. File 'john+sally.xlsx' contains wages and contributions associated with this case. Run this case with different rates to see the effects on Roth conversions."
3
3
 
4
4
  ["Basic Info"]
5
5
  Status = "married"
@@ -1,5 +1,5 @@
1
1
  "Plan Name" = "Jon+Jane"
2
- Description = "This case is similar to a case discussed by i-orp. It involve Jon and Jane close to retirement, and assumes a base case of optimistic returns of 10% with an inflation of 3.5%. A wages and contributions file called 'jon+jane.xlsx' is associated with this case."
2
+ Description = "This case reproduces a similar case discussed a while back on i-orp. It involves Jon and Jane close to retirement, and assumes a base case of optimistic returns of 10% with an inflation of 3.5%. A wages and contributions file called 'jon+jane.xlsx' is associated with this case."
3
3
 
4
4
  ["Basic Info"]
5
5
  Status = "married"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "owlplanner"
7
- version = "2025.02.25"
7
+ version = "2025.02.27"
8
8
  authors = [
9
9
  { name="Martin-D. Lacasse", email="martin.d.lacasse@gmail.com" },
10
10
  ]
@@ -2062,6 +2062,12 @@ class Plan(object):
2062
2062
 
2063
2063
  return mylist
2064
2064
 
2065
+ def summaryDf(self):
2066
+ """
2067
+ Return summary as a dataframe.
2068
+ """
2069
+ return pd.DataFrame(self.summaryDic(), index=[self._name])
2070
+
2065
2071
  def summaryString(self):
2066
2072
  """
2067
2073
  Return summary as a string.
@@ -2081,29 +2087,34 @@ class Plan(object):
2081
2087
  dic = {}
2082
2088
  # Results
2083
2089
  dic["Plan name"] = self._name
2084
- dic[f"Net yearly spending basis in {now}$"] = u.d(self.g_n[0] / self.xi_n[0])
2085
- dic[f"Net yearly spending for year {now}"] = u.d(self.g_n[0] / self.yearFracLeft)
2090
+ dic["Net yearly spending basis"] = u.d(self.g_n[0] / self.xi_n[0])
2091
+ dic[f"Net spending for year {now}"] = u.d(self.g_n[0] / self.yearFracLeft)
2086
2092
  dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0])
2087
2093
 
2088
2094
  totIncome = np.sum(self.g_n, axis=0)
2089
2095
  totIncomeNow = np.sum(self.g_n / self.gamma_n[:-1], axis=0)
2090
- dic[f"Total net spending in {now}$"] = f"{u.d(totIncomeNow)} ({u.d(totIncome)} nominal)"
2096
+ dic["Total net spending"] = f"{u.d(totIncomeNow)}"
2097
+ dic["- Total net spending (nominal)"] = f"{u.d(totIncome)}"
2091
2098
 
2092
2099
  totRoth = np.sum(self.x_in, axis=(0, 1))
2093
2100
  totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[:-1], axis=0)
2094
- dic[f"Total Roth conversions in {now}$"] = f"{u.d(totRothNow)} ({u.d(totRoth)} nominal)"
2101
+ dic["Total Roth conversions"] = f"{u.d(totRothNow)}"
2102
+ dic["- Total Roth conversions (nominal)"] = f"{u.d(totRoth)}"
2095
2103
 
2096
2104
  taxPaid = np.sum(self.T_n, axis=0)
2097
2105
  taxPaidNow = np.sum(self.T_n / self.gamma_n[:-1], axis=0)
2098
- dic[f"Total income tax paid on ordinary income in {now}$"] = f"{u.d(taxPaidNow)} ({u.d(taxPaid)} nominal)"
2106
+ dic["Total income tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
2107
+ dic["- Total income tax paid on ordinary income (nominal)"] = f"{u.d(taxPaid)}"
2099
2108
 
2100
2109
  taxPaid = np.sum(self.U_n, axis=0)
2101
2110
  taxPaidNow = np.sum(self.U_n / self.gamma_n[:-1], axis=0)
2102
- dic[f"Total tax paid on gains and dividends in {now}$"] = f"{u.d(taxPaidNow)} ({u.d(taxPaid)} nominal)"
2111
+ dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
2112
+ dic["- Total tax paid on gains and dividends (nominal)"] = f"{u.d(taxPaid)}"
2103
2113
 
2104
2114
  taxPaid = np.sum(self.M_n, axis=0)
2105
2115
  taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2106
- dic[f"Total Medicare premiums paid in {now}$"] = f"{u.d(taxPaidNow)} ({u.d(taxPaid)} nominal)"
2116
+ dic["Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2117
+ dic["- Total Medicare premiums paid (nominal)"] = f"{u.d(taxPaid)}"
2107
2118
 
2108
2119
  if self.N_i == 2 and self.n_d < self.N_n:
2109
2120
  p_j = self.partialEstate_j * (1 - self.phi_j)
@@ -2117,30 +2128,35 @@ class Plan(object):
2117
2128
  totSpousalNow = totSpousal / self.gamma_n[nx + 1]
2118
2129
  iname_s = self.inames[self.i_s]
2119
2130
  iname_d = self.inames[self.i_d]
2120
- dic[f"Spousal wealth transfer from {iname_d} to {iname_s} in year {ynx} (nominal)"] = (
2121
- f"taxable: {u.d(q_j[0])} tax-def: {u.d(q_j[1])} tax-free: {u.d(q_j[2])}")
2122
-
2123
- dic[f"Sum of spousal bequests to {iname_s} in year {ynx} in {now}$"] = (
2124
- f"{u.d(totSpousalNow)} ({u.d(totSpousal)} nominal)")
2125
- dic[
2126
- f"Post-tax non-spousal bequests from {iname_d} in year {ynx} (nominal)"] = (
2127
- f"taxable: {u.d(p_j[0])} tax-def: {u.d(p_j[1])} tax-free: {u.d(p_j[2])}")
2128
- dic[
2129
- f"Sum of post-tax non-spousal bequests from {iname_d} in year {ynx} in {now}$"] = (
2130
- f"{u.d(totOthersNow)} ({u.d(totOthers)} nominal)")
2131
+ dic[f"Sum of spousal transfer to {iname_s} in year {ynx}"] = (f"{u.d(totSpousalNow)}")
2132
+ dic[f"- Sum of spousal transfer to {iname_s} in year {ynx} (nominal)"] = (f"{u.d(totSpousal)}")
2133
+ dic[f"-- Spousal transfer to {iname_s} in year {ynx} - taxable (nominal)"] = (f"{u.d(q_j[0])}")
2134
+ dic[f"-- Spousal transfer to {iname_s} in year {ynx} - tax-def (nominal)"] = (f"{u.d(q_j[1])}")
2135
+ dic[f"-- Spousal transfer to {iname_s} in year {ynx} - tax-free (nominal)"] = (f"{u.d(q_j[2])}")
2136
+
2137
+ dic[f"Sum of post-tax non-spousal bequests from {iname_d} in year {ynx}"] = (f"{u.d(totOthersNow)}")
2138
+ dic[f"- Sum of post-tax non-spousal bequests from {iname_d} in year {ynx} (nominal)"] = (
2139
+ f"{u.d(totOthers)}")
2140
+ dic[f"-- Post-tax non-spousal bequests from {iname_d} in year {ynx} - taxable (nominal)"] = (
2141
+ f"{u.d(p_j[0])}")
2142
+ dic[f"-- Post-tax non-spousal bequests from {iname_d} in year {ynx} - tax-def (nominal)"] = (
2143
+ f"{u.d(p_j[1])}")
2144
+ dic[f"-- Post-tax non-spousal bequests from {iname_d} in year {ynx} - tax-free (nominal)"] = (
2145
+ f"{u.d(p_j[2])}")
2131
2146
 
2132
2147
  estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
2133
2148
  estate[1] *= 1 - self.nu
2134
2149
  lastyear = self.year_n[-1]
2135
- dic[f"Post-tax account values at the end of final plan year {lastyear} (nominal)"] = (
2136
- f"taxable: {u.d(estate[0])} tax-def: {u.d(estate[1])} tax-free: {u.d(estate[2])}")
2137
-
2138
2150
  totEstate = np.sum(estate)
2139
2151
  totEstateNow = totEstate / self.gamma_n[-1]
2140
- dic[f"Total estate value at the end of final plan year {lastyear} in {now}$"] = (
2141
- f"{u.d(totEstateNow)} ({u.d(totEstate)} nominal)")
2152
+ dic[f"Total estate value at the end of {lastyear}"] = (f"{u.d(totEstateNow)}")
2153
+ dic[f"- Total estate value at the end of {lastyear} (nominal)"] = (f"{u.d(totEstate)}")
2154
+ dic[f"-- Post-tax account value at the end of {lastyear} - taxable (nominal)"] = (f"{u.d(estate[0])}")
2155
+ dic[f"-- Post-tax account value at the end of {lastyear} - tax-def (nominal)"] = (f"{u.d(estate[1])}")
2156
+ dic[f"-- Post-tax account value at the end of {lastyear} - tax-free (nominal)"] = (f"{u.d(estate[2])}")
2157
+
2142
2158
  dic["Plan starting date"] = str(self.startDate)
2143
- dic["Cumulative inflation factor from start date to end of plan"] = f"{self.gamma_n[-1]:.2f}"
2159
+ dic[f"Cumulative inflation factor from start date to end of {lastyear}"] = f"{self.gamma_n[-1]:.2f}"
2144
2160
  for i in range(self.N_i):
2145
2161
  dic[f"{self.inames[i]:>12}'s {self.horizons[i]:02}-year life horizon"] = (
2146
2162
  f"{now} -> {now + self.horizons[i] - 1}")
@@ -0,0 +1 @@
1
+ __version__ = "2025.02.27"
@@ -81,8 +81,7 @@ def checkAllAllocs():
81
81
  return result
82
82
 
83
83
 
84
- ret = kz.titleBar("allocs")
85
- kz.caseHeader("Asset Allocation")
84
+ ret = kz.titleBar("Asset Allocation")
86
85
 
87
86
  if ret is None or kz.caseHasNoPlan():
88
87
  st.info("Case(s) must be first created before running this page.")
@@ -2,8 +2,7 @@ import streamlit as st
2
2
 
3
3
  import sskeys as kz
4
4
 
5
- ret = kz.titleBar("assets")
6
- kz.caseHeader("Assets")
5
+ ret = kz.titleBar("Assets")
7
6
 
8
7
  if ret is None or kz.caseHasNoPlan():
9
8
  st.info("Case(s) must be first created before running this page.")
@@ -1,13 +1,14 @@
1
1
  from datetime import date
2
+ from io import StringIO
2
3
  import streamlit as st
3
4
 
4
5
  import sskeys as kz
5
6
  import owlbridge as owb
7
+ import tomlexamples as tomlex
6
8
 
7
9
 
8
10
  caseChoices = kz.allCaseNames()
9
- ret = kz.titleBar("setup", caseChoices)
10
- kz.caseHeader("Create Case")
11
+ ret = kz.titleBar("Create Case", caseChoices)
11
12
 
12
13
  if ret == kz.newCase:
13
14
  st.info("#### Starting a new case from scratch.\n\n" "A name for the scenario must first be provided.")
@@ -23,12 +24,22 @@ elif ret == kz.loadCaseFile:
23
24
  # "<a href="Documentation" target="_self">Documentation</a>", unsafe_allow_html=True)
24
25
  st.info(
25
26
  "#### Starting a case from a *case* parameter file.\n\n"
26
- "Look at the :material/help: [Documentation](Documentation) for where to find examples.\n\n"
27
- "Alternatively, select `New Case...` to start a case from scratch."
27
+ "Upload your own case or select one from multiple examples."
28
+ " Alternatively, you can select `New Case...` in the margin selector box to start a case from scratch.\n\n"
29
+ "Look at the :material/help: [Documentation](Documentation) for more details."
28
30
  )
29
- confile = st.file_uploader("Upload *case* parameter file...", key="_confile", type=["toml"])
30
- if confile is not None:
31
- if kz.createCaseFromFile(confile):
31
+ st.write("#### Upload your own case file")
32
+ file = st.file_uploader("Upload *case* parameter file...", key="_confile", type=["toml"])
33
+ if file is not None:
34
+ mystringio = StringIO(file.read().decode("utf-8"))
35
+ if kz.createCaseFromFile(mystringio):
36
+ st.rerun()
37
+
38
+ st.write("#### Load a case example")
39
+ case = st.selectbox("Examples available from GitHub", tomlex.cases, index=None, placeholder="Select a case")
40
+ if case is not None:
41
+ mystringio = tomlex.loadExample(case)
42
+ if kz.createCaseFromFile(mystringio):
32
43
  st.rerun()
33
44
  else:
34
45
  helpmsg = "Case name can be changed by editing it directly."
@@ -36,7 +36,7 @@ formulation of the optimization problem can be found
36
36
  ### Getting started with the user interface
37
37
  Functions of each page are described below in the same order as they appear in the sidebar.
38
38
  Typically, pages would be accessed in order, starting from the top.
39
- The `Select case` selection box at the bottom of the margin allows to select an existing case
39
+ The `Case selector` box at the top of the page allows to select an existing case
40
40
  or create a new one from scratch, or from a *case* parameter file, which
41
41
  would then populate all parameter values.
42
42
  This box is present in all pages except those in the **Resources** section
@@ -51,7 +51,7 @@ This section contains the steps for creating and configuring case scenarios.
51
51
 
52
52
  #### Create Case
53
53
  This page is where every new scenario begins.
54
- It controls the creation of scenarios as the `Select case` drop-down menu contains
54
+ It controls the creation of scenarios as the `Case selector` drop-down menu contains
55
55
  two additional items when this page is open:
56
56
  one to create new cases, and the other to create cases from a *case* parameter file.
57
57
  This page also allows you to duplicate and/or rename scenarios, as well as deleting them.
@@ -65,7 +65,7 @@ When duplicating a scenario, make sure to visit all pages in the **Case Setup**
65
65
  and verify that all parameters are as intended.
66
66
 
67
67
  ##### Initializing the life parameters for the realization
68
- Start with the `Select case` box and choose one of `New case...` or `Upload case file...`.
68
+ Start with the `Case selector` box and choose one of `New case...` or `Upload case file...`.
69
69
 
70
70
  If `Upload case file...` is selected, a *case* file must be uploaded.
71
71
  These files end with the *.toml* extension, are human readable (and therefore editable),
@@ -307,13 +307,15 @@ The first line of the *Sources* worksheets are the most important
307
307
  as these lines are the only ones that are actionable.
308
308
 
309
309
  #### Output Files
310
- This page allow to save many files for future use.
310
+ This page allow to compare cases and save files for future use.
311
311
  First it shows a synopsis of the computed scenario by
312
312
  displaying sums of income, bequest, and spending values over the duration of the plan.
313
- The contents of this page can be downloaded as a plain text file by
314
- clicking the button at the bottom of the section.
313
+ If more than one case was run, they will be compared provided they were made
314
+ for the same individuals.
315
+ The contents of the synopsis can be downloaded as a plain text file by
316
+ clicking the button below it.
315
317
 
316
- Finally, parameters used to generate the case are collected in *toml* format and displayed.
318
+ Similarly, parameters used to generate the case are collected in *toml* format and displayed.
317
319
  The `Download case file...` button allows to save the parameters used to generate the
318
320
  outcome of this case to a *case* file.
319
321
 
@@ -36,8 +36,7 @@ def getToggleInput(i, key, thing):
36
36
  st.toggle(thing, on_change=kz.setpull, value=defval, args=[nkey], key="_" + nkey)
37
37
 
38
38
 
39
- ret = kz.titleBar("fixed")
40
- kz.caseHeader("Fixed Income")
39
+ ret = kz.titleBar("Fixed Income")
41
40
 
42
41
  if ret is None or kz.caseHasNoPlan():
43
42
  st.info("Case(s) must be first created before running this page.")
@@ -4,8 +4,7 @@ import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
6
 
7
- ret = kz.titleBar("results")
8
- kz.caseHeader("Graphs")
7
+ ret = kz.titleBar("Graphs")
9
8
 
10
9
  if ret is None or kz.caseHasNoPlan():
11
10
  st.info("Case(s) must be first created before running this page.")
@@ -21,9 +20,10 @@ else:
21
20
  ret = kz.getRadio("Dollar amounts in plots", choices, "plots", callback=owb.setDefaultPlots)
22
21
 
23
22
  with col2:
23
+ helpmsg = "Click on button if graphs are not all showing."
24
24
  st.button(
25
25
  "Re-run single case",
26
- help="Optimize single scenario.",
26
+ help=helpmsg,
27
27
  on_click=owb.runPlan,
28
28
  disabled=kz.caseIsNotRunReady(),
29
29
  )
@@ -4,8 +4,7 @@ import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
6
 
7
- ret = kz.titleBar("historicalRange")
8
- kz.caseHeader("Historical Range")
7
+ ret = kz.titleBar("Historical Range")
9
8
 
10
9
  if ret is None or kz.caseHasNoPlan():
11
10
  st.info("Case(s) must be first created before running this page.")
@@ -3,8 +3,7 @@ import streamlit as st
3
3
  import sskeys as kz
4
4
 
5
5
 
6
- ret = kz.titleBar("logs")
7
- kz.caseHeader("Logs")
6
+ ret = kz.titleBar("Logs")
8
7
 
9
8
  if ret is None or kz.caseHasNoPlan():
10
9
  st.info("Case(s) must be first created before running this page.")
@@ -4,8 +4,7 @@ import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
6
 
7
- ret = kz.titleBar("MC")
8
- kz.caseHeader("Monte Carlo")
7
+ ret = kz.titleBar("Monte Carlo")
9
8
 
10
9
  if ret is None or kz.caseHasNoPlan():
11
10
  st.info("Case(s) must be first created before running this page.")
@@ -16,8 +16,7 @@ def initProfile():
16
16
  owb.setProfile(profileChoices[0], False)
17
17
 
18
18
 
19
- ret = kz.titleBar("opto")
20
- kz.caseHeader("Optimization Parameters")
19
+ ret = kz.titleBar("Optimization Parameters")
21
20
 
22
21
  if ret is None or kz.caseHasNoPlan():
23
22
  st.info("Case(s) must be first created before running this page.")
@@ -3,8 +3,7 @@ import streamlit as st
3
3
  import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
- ret = kz.titleBar("summary")
7
- kz.caseHeader("Output Files")
6
+ ret = kz.titleBar("Output Files")
8
7
 
9
8
  if ret is None or kz.caseHasNoPlan():
10
9
  st.info("Case(s) must be first created before running this page.")
@@ -16,12 +15,15 @@ else:
16
15
  st.info("Case status is currently '%s'." % kz.getKey("caseStatus"))
17
16
  else:
18
17
  caseName = kz.getKey("name")
19
- lines = kz.getKey("summary")
20
- if lines != "":
18
+ df = kz.compareSummaries()
19
+ if df is not None:
21
20
  st.write("#### Synopsis")
22
- st.code(lines, language=None)
21
+ # st.code(lines, language=None)
22
+ # st.markdown(df.to_markdown())
23
+ st.dataframe(df[1:], use_container_width=True)
23
24
  st.download_button(
24
- "Download synopsis", data=lines, file_name=f"Synopsis_{caseName}.txt", mime="text/plain;charset=UTF-8"
25
+ "Download synopsis", data=df[1:].to_string(), file_name=f"Synopsis_{caseName}.txt",
26
+ mime="text/plain;charset=UTF-8"
25
27
  )
26
28
 
27
29
  st.divider()
@@ -54,5 +56,6 @@ else:
54
56
  st.code(lines, language="toml")
55
57
 
56
58
  st.download_button(
57
- "Download case parameter file", data=lines, file_name=f"case_{caseName}.toml", mime="application/toml"
59
+ "Download case parameter file", data=lines,
60
+ file_name=f"case_{caseName}.toml", mime="application/toml"
58
61
  )
@@ -36,7 +36,8 @@ of Jack and Jill provided here as an example:
36
36
  - Wages and contributions file named
37
37
  [jack+jill.xlsx](https://raw.github.com/mdlacasse/Owl/main/examples/jack+jill.xlsx)
38
38
  in Excel format.
39
- 1) Navigate to the **Create Case** page and drag and drop the case parameter file
39
+ 1) Navigate to the **Create Case** page and select the case of *jack+jill* among the GitHub examples.
40
+ Alternatively, you can drag and drop the case parameter file
40
41
  you just downloaded (*case_jack+jill.toml*).
41
42
  1) Navigate to the **Wages and Contributions** page and
42
43
  drag and drop the wages and contributions file you downloaded (*jack+jill.xlsx*).
@@ -54,8 +55,8 @@ its parameters can be saved by using the `Download case file...` button on the `
54
55
  Alternatively, you can duplicate any existing case by using
55
56
  the `Duplicate case` button, and then edit its values to fit your situation.
56
57
 
57
- Multiple cases can coexist and can be called and compared using the `Select case` box
58
- at the bottom of the margin.
58
+ Multiple cases can coexist and can be called and compared using the `Case selector` box
59
+ at the top of the page.
59
60
 
60
61
  More information can be found on the :material/help: **[Documentation](Documentation)**
61
62
  page located in the **Resources** section.
@@ -47,8 +47,7 @@ kz.initKey("rateType", rateChoices[0])
47
47
  kz.initKey("fixedType", fixedChoices[0])
48
48
  kz.initKey("varyingType", varyingChoices[0])
49
49
 
50
- ret = kz.titleBar("rates")
51
- kz.caseHeader("Rates Selection")
50
+ ret = kz.titleBar("Rates Selection")
52
51
 
53
52
  if ret is None or kz.caseHasNoPlan():
54
53
  st.info("Case(s) must be first created before running this page.")
@@ -4,15 +4,15 @@ import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
6
 
7
+ # Refresh Wages and Contributions tables as time window likely to change.
7
8
  def resetTimeLists():
8
- if not kz.getKey("duplicate"):
9
- tlists = owb.resetContributions()
10
- for i, iname in enumerate(tlists):
11
- kz.setKey("timeList" + str(i), tlists[iname])
9
+ # if not kz.getKey("duplicate"):
10
+ tlists = owb.resetContributions()
11
+ for i, iname in enumerate(tlists):
12
+ kz.setKey("timeList" + str(i), tlists[iname])
12
13
 
13
14
 
14
- ret = kz.titleBar("wages")
15
- kz.caseHeader("Wages and Contributions")
15
+ ret = kz.titleBar("Wages and Contributions")
16
16
 
17
17
  if ret is None or kz.caseHasNoPlan():
18
18
  st.info("Case(s) must be first created before running this page.")
@@ -38,11 +38,12 @@ else:
38
38
  " that has not yet been uploaded."
39
39
  )
40
40
 
41
+ st.write("#### Upload a *Wages and Contributions* file")
41
42
  kz.initKey("_xlsx", 0)
42
43
  stTimeLists = st.file_uploader(
43
44
  "Upload values from a wages and contributions file...",
44
45
  key="_stTimeLists" + str(kz.getKey("_xlsx")),
45
- type=["xlsx"],
46
+ type=["xlsx", "ods"],
46
47
  )
47
48
  if stTimeLists is not None:
48
49
  if owb.readContributions(stTimeLists):
@@ -51,6 +52,7 @@ else:
51
52
  kz.storeKey("_xlsx", kz.getKey("_xlsx") + 1)
52
53
  st.rerun()
53
54
 
55
+ st.divider()
54
56
  for i in range(n):
55
57
  st.write("##### " + kz.getKey("iname" + str(i)) + "'s timetable")
56
58
  colfor = {"year": st.column_config.NumberColumn(None, format="%d")}
@@ -3,8 +3,7 @@ import streamlit as st
3
3
  import sskeys as kz
4
4
  import owlbridge as owb
5
5
 
6
- ret = kz.titleBar("worksheets")
7
- kz.caseHeader("Worksheets")
6
+ ret = kz.titleBar("Worksheets")
8
7
 
9
8
  if ret is None or kz.caseHasNoPlan():
10
9
  st.info("Case(s) must be first created before running this page.")
@@ -122,15 +122,15 @@ def runPlan(plan):
122
122
  except Exception as e:
123
123
  st.error(f"Solution failed: {e}")
124
124
  kz.storeKey("caseStatus", "exception")
125
- kz.storeKey("summary", "")
125
+ kz.storeKey("summaryDf", None)
126
126
  return
127
127
 
128
128
  kz.storeKey("caseStatus", plan.caseStatus)
129
129
  if plan.caseStatus == "solved":
130
- kz.storeKey("summary", plan.summaryString())
130
+ kz.storeKey("summaryDf", plan.summaryDf())
131
131
  kz.storeKey("casetoml", getCaseString().getvalue())
132
132
  else:
133
- kz.storeKey("summary", "")
133
+ kz.storeKey("summaryDf", None)
134
134
  kz.storeKey("casetoml", "")
135
135
 
136
136
 
@@ -381,7 +381,7 @@ def _setAllocationRatios(plan):
381
381
  def plotSingleResults(plan):
382
382
  c = 0
383
383
  n = 3
384
- cols = st.columns(n, gap="medium")
384
+ cols = st.columns(n, gap="small")
385
385
  fig = plan.showRates(figure=True)
386
386
  if fig:
387
387
  cols[c].write("##### Annual Rates")
@@ -400,7 +400,7 @@ def plotSingleResults(plan):
400
400
  cols[c].pyplot(fig)
401
401
  c = (c + 1) % n
402
402
 
403
- cols = st.columns(n, gap="medium")
403
+ cols = st.columns(n, gap="small")
404
404
  fig = plan.showSources(figure=True)
405
405
  if fig:
406
406
  cols[c].write("##### Raw Income Sources")
@@ -544,17 +544,16 @@ def saveCaseFile(plan):
544
544
  return BytesIO(encoded_data)
545
545
 
546
546
 
547
- def createCaseFromFile(file):
548
- strio = StringIO()
547
+ def createCaseFromFile(strio):
548
+ logstrio = StringIO()
549
549
  try:
550
- mystringio = StringIO(file.read().decode("utf-8"))
551
- plan = owl.readConfig(mystringio, logstreams=[strio], readContributions=False)
550
+ plan = owl.readConfig(strio, logstreams=[logstrio], readContributions=False)
552
551
  except Exception as e:
553
552
  st.error(f"Failed to parse case file: {e}")
554
553
  return "", {}
555
554
 
556
555
  name, mydic = genDic(plan)
557
- mydic["logs"] = strio
556
+ mydic["logs"] = logstrio
558
557
 
559
558
  return name, mydic
560
559
 
@@ -565,7 +564,7 @@ def genDic(plan):
565
564
  dic["plan"] = plan
566
565
  dic["name"] = plan._name
567
566
  dic["description"] = plan._description
568
- dic["summary"] = ""
567
+ dic["summaryDf"] = None
569
568
  dic["casetoml"] = ""
570
569
  dic["caseStatus"] = "new"
571
570
  dic["status"] = ["unknown", "single", "married"][plan.N_i]
@@ -8,6 +8,8 @@ def changeStyle(key):
8
8
  val = kz.getGlobalKey("_" + key)
9
9
  kz.storeGlobalKey(key, val)
10
10
  plt.style.use(val)
11
+ # This makes all graphs appear have the same height.
12
+ plt.rcParams.update({'figure.autolayout': True})
11
13
 
12
14
 
13
15
  styles = ["default"]
@@ -7,4 +7,4 @@ scipy
7
7
  streamlit
8
8
  toml
9
9
  # --extra-index-url https://test.pypi.org/simple
10
- owlplanner >= 2025.02.25
10
+ owlplanner >= 2025.02.27
@@ -3,6 +3,7 @@ Module for storing keys in Streamlit session state.
3
3
  """
4
4
 
5
5
  import streamlit as st
6
+ import pandas as pd
6
7
  import copy
7
8
  import re
8
9
 
@@ -22,8 +23,8 @@ def init():
22
23
  ss = st.session_state
23
24
  if "cases" not in ss:
24
25
  ss.cases = {
25
- newCase: {"iname0": "", "status": "unkown", "caseStatus": "new", "summary": ""},
26
- loadCaseFile: {"iname0": "", "status": "unkown", "caseStatus": "new", "summary": ""},
26
+ newCase: {"iname0": "", "status": "unkown", "caseStatus": "new"},
27
+ loadCaseFile: {"iname0": "", "status": "unkown", "caseStatus": "new"},
27
28
  }
28
29
 
29
30
  # Variable for storing name of current case.
@@ -151,21 +152,6 @@ def caseIsNotMCReady():
151
152
  return caseIsNotRunReady() or getKey("rateType") != "varying" or "tochastic" not in getKey("varyingType")
152
153
 
153
154
 
154
- def titleBar(nkey, choices=None):
155
- if choices is None:
156
- choices = onlyCaseNames()
157
- helpmsg = "Select an existing case, or create a new one from scratch or from a *case* parameter file."
158
- return st.sidebar.selectbox(
159
- "Select case",
160
- choices,
161
- help=helpmsg,
162
- index=getIndex(currentCaseName(), choices),
163
- key="_" + nkey,
164
- on_change=switchToCase,
165
- args=[nkey],
166
- )
167
-
168
-
169
155
  def currentCaseDic() -> dict:
170
156
  return ss.cases[ss.currentCase]
171
157
 
@@ -192,16 +178,17 @@ def duplicateCase():
192
178
  ss.cases[dupname] = copy.deepcopy(ss.cases[ss.currentCase])
193
179
  ss.cases[ss.currentCase]["plan"] = currentPlan
194
180
  ss.cases[dupname]["name"] = dupname
195
- ss.cases[dupname]["summary"] = ""
181
+ ss.cases[dupname]["summaryDf"] = None
196
182
  ss.cases[dupname]["duplicate"] = True
197
183
  refreshCase(ss.cases[dupname])
198
184
  ss.currentCase = dupname
185
+ st.toast("Case duplicated except for Wages and Contributions tables.")
199
186
 
200
187
 
201
- def createCaseFromFile(confile):
188
+ def createCaseFromFile(strio):
202
189
  import owlbridge as owb
203
190
 
204
- name, dic = owb.createCaseFromFile(confile)
191
+ name, dic = owb.createCaseFromFile(strio)
205
192
  if name == "":
206
193
  return False
207
194
  elif name in ss.cases:
@@ -228,7 +215,7 @@ def createNewCase(case):
228
215
  st.error(f"Case name '{casename}' already exists.")
229
216
  return
230
217
 
231
- ss.cases[casename] = {"name": casename, "caseStatus": "unknown", "summary": "", "logs": None}
218
+ ss.cases[casename] = {"name": casename, "caseStatus": "unknown", "logs": None}
232
219
  setCurrentCase(ss._newcase)
233
220
 
234
221
 
@@ -237,10 +224,11 @@ def renameCase(key):
237
224
  return
238
225
  newname = ss["_" + key]
239
226
  plan = getKey("plan")
240
- if plan:
241
- plan.rename(newname)
242
227
  ss.cases[newname] = ss.cases.pop(ss.currentCase)
243
228
  ss.cases[newname]["name"] = newname
229
+ if plan:
230
+ plan.rename(newname)
231
+ ss.cases[newname]["caseStatus"] = "modified"
244
232
  setCurrentCase(newname)
245
233
 
246
234
 
@@ -325,6 +313,34 @@ def getAccountBalances(ni):
325
313
  return bal
326
314
 
327
315
 
316
+ def compareSummaries():
317
+ df = getKey("summaryDf")
318
+ if df is None:
319
+ return None
320
+ for case in onlyCaseNames():
321
+ if case == currentCaseName():
322
+ continue
323
+ odf = ss.cases[case]["summaryDf"]
324
+ if odf is None or set(odf.columns) != set(df.columns):
325
+ continue
326
+ df = pd.concat([df, odf])
327
+
328
+ if df.shape[0] > 1:
329
+ # Unroll to subtract strings representations of numbers.
330
+ for col in range(1, df.shape[1] - 5):
331
+ strval = df.iloc[0, col]
332
+ if isinstance(strval, str) and strval[0] == "$":
333
+ f0val = float(strval[1:].replace(",", ""))
334
+ for row in range(1, df.shape[0]):
335
+ fnval = float(df.iloc[row, col][1:].replace(",", ""))
336
+ diff = fnval - f0val
337
+ sign = "+" if diff >= 0 else "-"
338
+ sign = "" if diff == 0 else sign
339
+ df.iloc[row, col] = f"{sign}${abs(diff):,.0f}"
340
+
341
+ return df.transpose()
342
+
343
+
328
344
  def getSolveParameters():
329
345
  maximize = getKey("objective")
330
346
  if maximize is None:
@@ -488,8 +504,28 @@ def orangeDivider():
488
504
  st.html("<style> hr {border-color: orange;}</style><hr>")
489
505
 
490
506
 
491
- def caseHeader(txt):
492
- # st.html("<div style="text-align: right;color: orange;font-style: italic;">%s</div>" % currentCaseName())
493
- st.html("<div style='text-align: left;color: orange;font-style: italic;'>%s</div>" % currentCaseName())
494
- st.write("## " + txt)
507
+ def titleBar(txt, choices=None):
508
+ # st.html(f"<div style='text-align: left;color: orange;font-style: italic;'>{currentCaseName()}</div>")
509
+ if choices is None:
510
+ choices = onlyCaseNames()
511
+ helpmsg = "Select an existing case."
512
+ else:
513
+ helpmsg = "Select an existing case, or create a new one from scratch or from a *case* parameter file."
514
+
515
+ col1, col2 = st.columns(2, gap="large")
516
+ with col1:
517
+ st.write("## " + txt)
518
+ with col2:
519
+ nkey = txt
520
+ ret = st.selectbox(
521
+ "Case selector",
522
+ choices,
523
+ help=helpmsg,
524
+ index=getIndex(currentCaseName(), choices),
525
+ key="_" + nkey,
526
+ on_change=switchToCase,
527
+ args=[nkey],
528
+ )
529
+
495
530
  orangeDivider()
531
+ return ret
@@ -0,0 +1,16 @@
1
+ import requests
2
+ import streamlit as st
3
+ from io import StringIO
4
+
5
+
6
+ cases = ["jack+jill", "joe", "john+sally", "jon+jane", "kim+sam-bequest", "kim+sam-spending"]
7
+
8
+
9
+ def loadExample(case):
10
+ url = f"https://raw.github.com/mdlacasse/owl/main/examples/case_{case}.toml"
11
+ response = requests.get(url)
12
+ if response.status_code == 200:
13
+ return StringIO(response.text)
14
+ else:
15
+ st.error(f"Failed to load case parameter file from GitHub: {response.status_code}.")
16
+ return None
@@ -1 +0,0 @@
1
- __version__ = "2025.02.25"
File without changes
File without changes
File without changes
File without changes