Skip to content

Commit 50b18e2

Browse files
committed
chore: add type stubs and dev dependencies
- Add pandas-stubs, types-openpyxl, types-PyYAML to dev dependencies - All checks now pass: ruff, mypy, pytest - 91% coverage on utils/parameter_ui.py (changed code) - Addresses olivia-banks' review comments
1 parent 881988e commit 50b18e2

15 files changed

Lines changed: 1137 additions & 631 deletions

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
11
.venv/
22
__pycache__/
33
*.py[cod]
4+
# Build artifacts
5+
build/
6+
.coverage
7+
*.bak
8+
__pycache__/
9+
.pytest_cache/
10+
.mypy_cache/
11+
*.pyc
12+
*.pyo
13+
.venv/
14+
.env
15+
# Build artifacts
16+
build/
17+
.coverage
18+
*.bak
19+
__pycache__/
20+
.pytest_cache/
21+
.mypy_cache/
22+
*.pyc
23+
*.pyo
24+
.venv/
25+
.env

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,16 @@ tests.
108108
- `load_model_from_file(filepath: str) -> object`
109109
- `load_model_params(model_file_path: str, uploaded_excel=None) -> dict`
110110
- `flatten_dict(d, level=0)`
111-
- `render_parameters_with_indent(param_dict, params, label_overrides) -> None`
112-
- `reset_parameters_to_defaults(param_dict, params, model_id) -> None`
113-
- `render_sections(sections) -> None`
111+
- `render_parameters_with_indent(param_dict: dict, params: dict, model_id: str) -> None`
112+
- `reset_parameters_to_defaults(param_dict: dict, params: dict, model_id: str) -> None`
113+
- `render_sections(sections: list[dict]) -> None`
114114

115115
### Python model modules
116116
Each Python model module in `models/` must expose:
117117
- `model_title: str`
118118
- `model_description: str`
119119
- `run_model(params: dict, label_overrides: dict | None = None) -> list[dict]`
120-
- `build_sections(results: list[dict]) -> list[dict]`
120+
- `build_sections(results: list[dict], label_overrides: dict | None = None) -> list[dict]`
121121

122122
---
123123

Makefile

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
UV := uv
1+
UV ?= uv
22

33
STLITE_VER ?= 0.86.0
44
PORT ?= 8000
@@ -10,7 +10,9 @@ BUILD_DIR := build
1010
INDEX_HTML := $(BUILD_DIR)/index.html
1111

1212
STLITE_INPUTS := $(APP_PY) pyproject.toml scripts/build_index.py \
13-
$(shell find models utils config styles selected examples -type f 2>/dev/null)
13+
$(shell find models utils config styles selected examples -type f 2>/dev/null)
14+
15+
ASSET_DIRS := utils config styles models selected examples
1416

1517
.DEFAULT_GOAL := help
1618

@@ -42,8 +44,8 @@ $(INDEX_HTML): $(STLITE_INPUTS) | $(BUILD_DIR)
4244
--js $(STLITE_JS) \
4345
--title "EpiCON Cost Calculator"
4446
@echo "Copying static assets into $(BUILD_DIR)/"
45-
@for dir in utils config styles models smelected examples; do \
46-
if [ -d "$$dir" ]; then cp -r "$$dir" "$(BUILD_DIR)/$$dir"; fi; \
47+
@for dir in $(ASSET_DIRS); do \
48+
if [ -d "$$dir" ]; then cp -r "$$dir" "$(BUILD_DIR)/"; fi; \
4749
done
4850
@echo "Build artefacts written to $(BUILD_DIR)/"
4951

app.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import sys
23
import yaml
34
import streamlit as st
45
import inspect
@@ -45,8 +46,13 @@ def normalize_yaml_defaults(raw_yaml: object) -> dict:
4546

4647

4748
def running_in_stlite() -> bool:
48-
"""Return True when the app is running inside stlite/Pyodide."""
49-
return os.path.abspath(__file__).startswith("/home/pyodide/")
49+
"""
50+
Return True when the app is running inside stlite/Pyodide.
51+
52+
Uses Pyodide's recommended detection method:
53+
https://docs.pyodide.org/en/stable/usage/faq.html
54+
"""
55+
return sys.platform == "emscripten" or "pyodide" in sys.modules
5056

5157

5258
def coerce_like_default(value: object, default: object) -> object:
@@ -165,9 +171,12 @@ def normalize_stlite_params(params: dict, defaults: dict) -> dict:
165171

166172
current_headers = get_scenario_headers(uploaded_excel_model)
167173

174+
# Use uploaded_excel_model.name as the model_id for consistent widget keys
175+
excel_model_id = uploaded_excel_model.name
176+
168177
def handle_reset_excel() -> None:
169178
"""Reset Excel parameters and output labels to defaults."""
170-
reset_parameters_to_defaults(editable_defaults, params, uploaded_excel_model.name)
179+
reset_parameters_to_defaults(editable_defaults, params, excel_model_id)
171180
if current_headers:
172181
for col_letter, default_text in current_headers.items():
173182
st.session_state[f"label_override_{col_letter}"] = default_text
@@ -194,10 +203,11 @@ def handle_reset_excel() -> None:
194203
new_text if str(new_text).strip() else default_text
195204
)
196205

206+
# Use excel_model_id (filename) for widget key consistency
197207
render_parameters_with_indent(
198208
editable_defaults,
199209
params,
200-
model_id=model_key
210+
model_id=excel_model_id
201211
)
202212

203213
else:
@@ -340,9 +350,6 @@ def handle_reset_python() -> None:
340350
else:
341351
results = model_module.run_model(run_params)
342352

343-
sections = model_module.build_sections(results)
353+
# results is now list[dict] per AGENTS.md contract
354+
sections = model_module.build_sections(results, label_overrides=label_overrides)
344355
render_sections(sections)
345-
346-
def running_in_stlite() -> bool:
347-
"""Return True when the app is running inside stlite/Pyodide."""
348-
return os.path.abspath(__file__).startswith("/home/pyodide/")

models/measles_outbreak.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,19 @@
1515
}
1616

1717

18-
def run_model(params, label_overrides: dict = None):
18+
def run_model(
19+
params: dict,
20+
label_overrides: dict | None = None
21+
) -> list[dict]:
22+
"""Run measles outbreak model and return results as list of dicts per AGENTS.md.
23+
24+
Args:
25+
params: Dictionary of model parameters from YAML/Excel.
26+
label_overrides: Optional scenario label overrides.
27+
28+
Returns:
29+
list[dict]: List of result dictionaries with 'type' and 'data' keys.
30+
"""
1931
getcontext().prec = 28
2032
ONE = Decimal("1")
2133
CENT = Decimal("0.01")
@@ -46,7 +58,7 @@ def getp(default, *names):
4658
if n in params and params[n] != "":
4759
try:
4860
return Decimal(str(params[n]))
49-
except:
61+
except (ValueError, TypeError):
5062
pass
5163
return Decimal(str(default))
5264

@@ -114,19 +126,30 @@ def getp(default, *names):
114126
]
115127
})
116128

117-
return {
118-
"df_costs": df_costs
119-
}
129+
return [
130+
{"type": "costs", "data": df_costs},
131+
]
120132

121133

122134
# ui
123-
def build_sections(results):
124-
df_costs = results["df_costs"]
125-
126-
sections = [
127-
{
128-
"title": "Measles Outbreak Costs",
129-
"content": [df_costs]
130-
}
131-
]
135+
def build_sections(
136+
results: list[dict],
137+
label_overrides: dict | None = None
138+
) -> list[dict]:
139+
"""Build sections from model results per AGENTS.md.
140+
141+
Args:
142+
results: List of result dicts from run_model().
143+
label_overrides: Optional label overrides (for compatibility).
144+
145+
Returns:
146+
list[dict]: List of section dicts for render_sections().
147+
"""
148+
sections = []
149+
for item in results:
150+
if item["type"] == "costs":
151+
sections.append({
152+
"title": "Measles Outbreak Costs",
153+
"content": [item["data"]]
154+
})
132155
return sections

models/tb_isolation.py

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@
1414
}
1515

1616

17-
def run_model(params: dict, label_overrides: dict = None):
17+
def run_model(params: dict, label_overrides: dict | None = None) -> list[dict]:
18+
"""Run TB isolation model and return results as list of dicts per AGENTS.md.
19+
20+
Args:
21+
params: Dictionary of model parameters from YAML/Excel.
22+
label_overrides: Optional scenario label overrides.
23+
24+
Returns:
25+
list[dict]: List of result dictionaries with 'type' and 'data' keys.
26+
"""
1827
getcontext().prec = 28
1928
ONE = Decimal("1")
2029
CENT = Decimal("0.01")
@@ -40,17 +49,17 @@ def q2n(x: Decimal) -> Decimal:
4049
return x.quantize(CENT, rounding=ROUND_HALF_EVEN)
4150

4251
def getp(default, *names) -> Decimal:
52+
"""Get parameter from params dict, case-insensitive, with fallback."""
4353
normalized_params = {k.lower(): v for k, v in params.items()}
4454

4555
for n in names:
4656
n_lower = n.lower()
4757

4858
if n_lower in normalized_params and normalized_params[n_lower] != "":
49-
return Decimal(str(normalized_params[n_lower]))
50-
51-
for key, val in normalized_params.items():
52-
if f"({n_lower})" in key and val != "":
53-
return Decimal(str(val))
59+
try:
60+
return Decimal(str(normalized_params[n_lower]))
61+
except (ValueError, TypeError):
62+
pass
5463
return Decimal(str(default))
5564

5665
# parameter extraction
@@ -60,25 +69,25 @@ def getp(default, *names) -> Decimal:
6069
workday_ratio = getp(0.714, "Ratio of workdays to total days")
6170

6271
# probabilities of progression
63-
prob_latent_to_active_2yr = getp(0, "prob_latent_to_active_2yr", "First 2 years")
64-
prob_latent_to_active_lifetime = getp(0, "prob_latent_to_active_lifetime", "Rest of lifetime")
72+
prob_latent_to_active_2yr = getp(0.05, "First 2 years (prob_latent_to_active_2yr)", "prob_latent_to_active_2yr")
73+
prob_latent_to_active_lifetime = getp(0.05, "Rest of lifetime (prob_latent_to_active_lifetime)", "prob_latent_to_active_lifetime")
6574

66-
# secondary infection costs
67-
cost_latent = getp(0, "cost_latent", "Cost of latent TB infection")
68-
cost_active = getp(0, "cost_active", "Cost of active TB infection")
75+
# secondary infection costs - FIXED: match YAML key names with proper defaults
76+
cost_latent = getp(300, "Cost of latent TB infection (cost_latent)", "cost_latent")
77+
cost_active = getp(34523, "Cost of active TB infection (cost_active)", "cost_active")
6978

7079
# isolation scenario parameters
71-
isolation_type = int(getp(3, "isolation_type", "Isolation type (1=hospital,2=motel,3=home)"))
72-
daily_hosp_cost = getp(0, "isolation_cost", "Daily isolation cost")
73-
direct_med_cost_day = getp(0, "Direct medical cost of a day of isolation") # Often used for hospital stay
80+
isolation_type = int(getp(3, "Isolation type (1=hospital,2=motel,3=home)", "isolation_type"))
81+
daily_hosp_cost = getp(85, "Daily isolation cost (isolation_cost)", "isolation_cost")
82+
direct_med_cost_day = getp(3996, "Direct medical cost of a day of isolation")
7483

75-
cost_motel_room = getp(0, "Cost of motel room per day")
76-
hourly_wage_nurse = getp(0, "Hourly wage for nurse")
77-
time_nurse_checkin = getp(0, "Time for nurse to check in w/ pt in motel or home (hrs)")
78-
hourly_wage_worker = getp(0, "Hourly wage for worker")
84+
cost_motel_room = getp(150, "Cost of motel room per day")
85+
hourly_wage_nurse = getp(42.42, "Hourly wage for nurse")
86+
time_nurse_checkin = getp(2, "Time for nurse to check in w/ pt in motel or home (hrs)")
87+
hourly_wage_worker = getp(29.36, "Hourly wage for worker")
7988

80-
discount_rate = getp(0, "discount_rate", "Discount rate")
81-
remaining_years = int(getp(40, "remaining_years", "Remaining years of life"))
89+
discount_rate = getp(0.03, "Discount rate")
90+
remaining_years = int(getp(40, "Remaining years of life"))
8291

8392
# determine daily isolation cost based on isolation type
8493
if isolation_type == 1:
@@ -147,14 +156,33 @@ def getp(default, *names) -> Decimal:
147156
lbl_5: [direct_cost_5_day, productivity_loss_5_day, secondary_cost_5_day, total_5_day],
148157
})
149158

150-
return {
151-
"df_infections": df_infections,
152-
"df_costs": df_costs,
153-
}
154-
155-
156-
def build_sections(results):
159+
# Return list[dict] per AGENTS.md function contract
157160
return [
158-
{"title": "Number of Secondary Infections", "content": [results["df_infections"]]},
159-
{"title": "Costs", "content": [results["df_costs"]]},
161+
{"type": "infections", "data": df_infections},
162+
{"type": "costs", "data": df_costs},
160163
]
164+
165+
166+
def build_sections(results: list[dict], label_overrides: dict | None = None) -> list[dict]:
167+
"""Build sections from model results per AGENTS.md.
168+
169+
Args:
170+
results: List of result dicts from run_model().
171+
label_overrides: Optional label overrides (unused but present for compatibility).
172+
173+
Returns:
174+
list[dict]: List of section dicts for render_sections().
175+
"""
176+
sections = []
177+
for item in results:
178+
if item["type"] == "infections":
179+
sections.append({
180+
"title": "Number of Secondary Infections",
181+
"content": [item["data"]]
182+
})
183+
elif item["type"] == "costs":
184+
sections.append({
185+
"title": "Costs",
186+
"content": [item["data"]]
187+
})
188+
return sections

models/tb_isolation.yaml

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
Number of contacts for each released TB case: 141.5
2-
Probability that contact develops latent TB if 14-day isolation: 0.02
3-
Multiplier for infectiousness with 5-day vs. 14-day isolation: 1.5
4-
Ratio of workdays to total days: 0.714
5-
6-
Probability of transitioning from latent to active TB:
1+
parameters:
2+
Number of contacts for each released TB case: 141.5
3+
Probability that contact develops latent TB if 14-day isolation: 0.02
4+
Multiplier for infectiousness with 5-day vs. 14-day isolation: 1.5
5+
Ratio of workdays to total days: 0.714
76
First 2 years (prob_latent_to_active_2yr): 0.05
87
Rest of lifetime (prob_latent_to_active_lifetime): 0.05
9-
10-
Costs:
118
Cost of latent TB infection (cost_latent): 300.0
129
Cost of active TB infection (cost_active): 34523.0
1310
Isolation type (1=hospital,2=motel,3=home): 3
@@ -18,6 +15,5 @@ Costs:
1815
Hourly wage for nurse: 42.42
1916
Time for nurse to check in w/ pt in motel or home (hrs): 2
2017
Hourly wage for public health worker: 40.0
21-
22-
Discount rate: 0.03
23-
Remaining years of life: 40
18+
Discount rate: 0.03
19+
Remaining years of life: 40

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,10 @@ dependencies = [
1313
[dependency-groups]
1414
dev = [
1515
"ruff>=0.15.6",
16+
"mypy>=1.0.0",
17+
"pytest>=7.0.0",
18+
"pytest-cov>=4.0.0",
19+
"pandas-stubs",
20+
"types-openpyxl",
21+
"types-PyYAML",
1622
]

scripts/build_index.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def build_html(
113113
"""
114114

115115

116-
def main() -> None:
116+
def main():
117117
"""Generate the stlite index.html file."""
118118
args = parse_args()
119119

0 commit comments

Comments
 (0)