Lars Masanneck commited on
Commit
ae420f7
·
1 Parent(s): e10935c

New features / batch processing and PDF reporting

Browse files
batch_utils.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch processing and PDF generation utilities for Smartwatch Normative Z-Score Calculator.
3
+
4
+ Author: Lars Masanneck 2026
5
+ """
6
+ import pandas as pd
7
+ import numpy as np
8
+ from io import BytesIO
9
+ from reportlab.lib import colors
10
+ from reportlab.lib.pagesizes import A4
11
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
12
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
13
+ from reportlab.lib.units import inch
14
+ from reportlab.graphics.shapes import Drawing, Rect, Line, String
15
+
16
+ # Import from the main normalizer model
17
+ import normalizer_model
18
+
19
+ # Friendly biomarker labels (matching the main app)
20
+ BIOMARKER_LABELS = {
21
+ "nb_steps": "Number of Steps",
22
+ "max_steps": "Maximum Steps",
23
+ "mean_active_time": "Mean Active Time",
24
+ "sbp": "Systolic Blood Pressure",
25
+ "dbp": "Diastolic Blood Pressure",
26
+ "sleep_duration": "Sleep Duration",
27
+ "avg_night_hr": "Average Night Heart Rate",
28
+ "nb_moderate_active_minutes": "Moderate Active Minutes",
29
+ "nb_vigorous_active_minutes": "Vigorous Active Minutes",
30
+ "weight": "Weight",
31
+ "pwv": "Pulse Wave Velocity",
32
+ }
33
+
34
+ # Biomarkers available for batch processing (excluding disabled ones)
35
+ AVAILABLE_BIOMARKERS = [
36
+ "nb_steps",
37
+ "max_steps",
38
+ "mean_active_time",
39
+ "sleep_duration",
40
+ "avg_night_hr",
41
+ "nb_moderate_active_minutes",
42
+ ]
43
+
44
+
45
+ def get_batch_template_df():
46
+ """Return a template DataFrame for batch upload."""
47
+ return pd.DataFrame({
48
+ "patient_id": ["P001", "P002", "P003"],
49
+ "age": [45, 62, 38],
50
+ "gender": ["Man", "Woman", "Man"],
51
+ "region": ["Western Europe", "Western Europe", "North America"],
52
+ "bmi": [24.5, 28.1, 22.3],
53
+ "nb_steps": [7500, 4200, 9800],
54
+ "sleep_duration": [7.2, 6.5, 8.1],
55
+ "avg_night_hr": [62, 68, 58],
56
+ })
57
+
58
+
59
+ def process_batch_data(df: pd.DataFrame, normative_df: pd.DataFrame,
60
+ biomarkers_to_process: list = None) -> pd.DataFrame:
61
+ """
62
+ Process batch data and add z-score and percentile columns for selected biomarkers.
63
+
64
+ Parameters
65
+ ----------
66
+ df : pd.DataFrame
67
+ Input data with patient demographics and biomarker values
68
+ normative_df : pd.DataFrame
69
+ Normative reference table
70
+ biomarkers_to_process : list, optional
71
+ List of biomarker columns to process. If None, auto-detect from data.
72
+
73
+ Returns
74
+ -------
75
+ pd.DataFrame
76
+ Results with z-scores and percentiles added
77
+ """
78
+ results = []
79
+
80
+ # Auto-detect biomarkers if not specified
81
+ if biomarkers_to_process is None:
82
+ biomarkers_to_process = [col for col in df.columns if col in AVAILABLE_BIOMARKERS]
83
+
84
+ for _, row in df.iterrows():
85
+ result = row.to_dict()
86
+
87
+ # Process each biomarker
88
+ for biomarker in biomarkers_to_process:
89
+ if pd.notna(row.get(biomarker)):
90
+ try:
91
+ res = normalizer_model.compute_normative_position(
92
+ value=float(row[biomarker]),
93
+ biomarker=biomarker,
94
+ age_group=int(row['age']) if pd.notna(row.get('age')) else 45,
95
+ region=row.get('region', 'Western Europe'),
96
+ gender=row.get('gender', 'Man'),
97
+ bmi=float(row.get('bmi', 24.0)) if pd.notna(row.get('bmi')) else 24.0,
98
+ normative_df=normative_df,
99
+ )
100
+ result[f'{biomarker}_z'] = round(res['z_score'], 2)
101
+ result[f'{biomarker}_percentile'] = round(res['percentile'], 1)
102
+
103
+ # Add interpretation
104
+ z = res['z_score']
105
+ if z < -2:
106
+ result[f'{biomarker}_interpretation'] = 'Very Low'
107
+ elif z < -1:
108
+ result[f'{biomarker}_interpretation'] = 'Below Average'
109
+ elif z < 1:
110
+ result[f'{biomarker}_interpretation'] = 'Average'
111
+ elif z < 2:
112
+ result[f'{biomarker}_interpretation'] = 'Above Average'
113
+ else:
114
+ result[f'{biomarker}_interpretation'] = 'Very High'
115
+
116
+ except Exception as e:
117
+ result[f'{biomarker}_z'] = 'N/A'
118
+ result[f'{biomarker}_percentile'] = 'N/A'
119
+ result[f'{biomarker}_interpretation'] = f'Error: {str(e)[:30]}'
120
+ else:
121
+ result[f'{biomarker}_z'] = 'N/A'
122
+ result[f'{biomarker}_percentile'] = 'N/A'
123
+ result[f'{biomarker}_interpretation'] = 'No data'
124
+
125
+ results.append(result)
126
+
127
+ return pd.DataFrame(results)
128
+
129
+
130
+ def create_z_score_gauge(z_score: float, label: str, width: float = 350, height: float = 100) -> Drawing:
131
+ """Create a horizontal gauge showing z-score position with orange theme."""
132
+ d = Drawing(width, height)
133
+
134
+ gauge_y = 35
135
+ gauge_height = 25
136
+ gauge_left = 50
137
+ gauge_width = width - 100
138
+
139
+ # Color zones - orange themed
140
+ zone_colors = [
141
+ (colors.HexColor('#2ecc71'), -2), # Green - very low (good for some metrics)
142
+ (colors.HexColor('#27ae60'), -1), # Darker green
143
+ (colors.HexColor('#f39c12'), 0), # Orange - average
144
+ (colors.HexColor('#e67e22'), 1), # Darker orange
145
+ (colors.HexColor('#d35400'), 2), # Deep orange
146
+ (colors.HexColor('#c0392b'), 3), # Red - extreme
147
+ ]
148
+
149
+ zone_width = gauge_width / 6
150
+ for i, (color, _) in enumerate(zone_colors):
151
+ d.add(Rect(gauge_left + i * zone_width, gauge_y, zone_width, gauge_height,
152
+ fillColor=color, strokeColor=None))
153
+
154
+ # Border
155
+ d.add(Rect(gauge_left, gauge_y, gauge_width, gauge_height,
156
+ fillColor=None, strokeColor=colors.black, strokeWidth=1))
157
+
158
+ # Marker position (clamp z to -3, 3)
159
+ clamped_z = max(-3, min(3, z_score))
160
+ marker_x = gauge_left + ((clamped_z + 3) / 6) * gauge_width
161
+
162
+ # Marker line
163
+ d.add(Line(marker_x, gauge_y - 8, marker_x, gauge_y + gauge_height + 8,
164
+ strokeColor=colors.black, strokeWidth=3))
165
+
166
+ # Scale labels
167
+ for i, val in enumerate([-3, -2, -1, 0, 1, 2, 3]):
168
+ x = gauge_left + (i / 6) * gauge_width
169
+ d.add(String(x, gauge_y - 15, str(val), fontSize=9, textAnchor='middle'))
170
+
171
+ # Title
172
+ d.add(String(width / 2, height - 8, label, fontSize=11, textAnchor='middle', fontName='Helvetica-Bold'))
173
+
174
+ # Z-score value
175
+ d.add(String(width / 2, gauge_y + gauge_height + 18, f"Z = {z_score:.2f}",
176
+ fontSize=10, textAnchor='middle', fontName='Helvetica-Bold'))
177
+
178
+ return d
179
+
180
+
181
+ def generate_pdf_report(patient_info: dict, measurements: dict, z_scores: dict = None) -> BytesIO:
182
+ """
183
+ Generate a PDF report for a patient with Z-scores and graphs.
184
+
185
+ Parameters
186
+ ----------
187
+ patient_info : dict
188
+ Patient demographics (age, gender, region, bmi)
189
+ measurements : dict
190
+ Biomarker measurements (biomarker_code: value)
191
+ z_scores : dict
192
+ Z-score results for each biomarker
193
+
194
+ Returns
195
+ -------
196
+ BytesIO
197
+ PDF buffer ready for download
198
+ """
199
+ buffer = BytesIO()
200
+ doc = SimpleDocTemplate(buffer, pagesize=A4, topMargin=0.5*inch, bottomMargin=0.5*inch)
201
+
202
+ styles = getSampleStyleSheet()
203
+
204
+ # Orange-themed styles
205
+ title_style = ParagraphStyle(
206
+ 'Title',
207
+ parent=styles['Heading1'],
208
+ fontSize=18,
209
+ spaceAfter=12,
210
+ alignment=1,
211
+ textColor=colors.HexColor('#d35400')
212
+ )
213
+ heading_style = ParagraphStyle(
214
+ 'Heading',
215
+ parent=styles['Heading2'],
216
+ fontSize=14,
217
+ spaceAfter=8,
218
+ spaceBefore=12,
219
+ textColor=colors.HexColor('#e67e22')
220
+ )
221
+ normal_style = styles['Normal']
222
+
223
+ elements = []
224
+
225
+ # Title
226
+ elements.append(Paragraph("Smartwatch Normative Z-Score Report", title_style))
227
+ elements.append(Spacer(1, 0.2*inch))
228
+
229
+ # Patient Information
230
+ elements.append(Paragraph("Demographics", heading_style))
231
+ patient_data = [
232
+ ["Age:", f"{patient_info.get('age', 'N/A')} years"],
233
+ ["Gender:", patient_info.get('gender', 'N/A')],
234
+ ["Region:", patient_info.get('region', 'N/A')],
235
+ ["BMI:", f"{patient_info.get('bmi', 'N/A')}"],
236
+ ]
237
+ patient_table = Table(patient_data, colWidths=[2*inch, 4*inch])
238
+ patient_table.setStyle(TableStyle([
239
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
240
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
241
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
242
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
243
+ ]))
244
+ elements.append(patient_table)
245
+ elements.append(Spacer(1, 0.2*inch))
246
+
247
+ # Measurements
248
+ if measurements:
249
+ elements.append(Paragraph("Measurements", heading_style))
250
+ measurements_data = []
251
+ for biomarker, value in measurements.items():
252
+ label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title())
253
+ measurements_data.append([f"{label}:", f"{value}"])
254
+
255
+ if measurements_data:
256
+ meas_table = Table(measurements_data, colWidths=[2.5*inch, 3.5*inch])
257
+ meas_table.setStyle(TableStyle([
258
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
259
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
260
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
261
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
262
+ ]))
263
+ elements.append(meas_table)
264
+ elements.append(Spacer(1, 0.2*inch))
265
+
266
+ # Z-Score Analysis
267
+ if z_scores:
268
+ elements.append(Paragraph("Z-Score Analysis", heading_style))
269
+ elements.append(Paragraph(
270
+ "Z-scores indicate how many standard deviations a measurement is from the population mean. "
271
+ "Values between -2 and +2 are typically considered within normal range.",
272
+ ParagraphStyle('ZInfo', parent=normal_style, fontSize=9, textColor=colors.grey, spaceAfter=8)
273
+ ))
274
+
275
+ # Z-score table
276
+ z_data = [["Biomarker", "Value", "Z-Score", "Percentile", "Interpretation"]]
277
+
278
+ for biomarker, data in z_scores.items():
279
+ if isinstance(data, dict) and 'z_score' in data:
280
+ z = data['z_score']
281
+ pct = data['percentile']
282
+ value = measurements.get(biomarker, 'N/A')
283
+ label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title())
284
+
285
+ # Interpretation
286
+ if z < -2:
287
+ interp = "Very Low"
288
+ elif z < -1:
289
+ interp = "Below Average"
290
+ elif z < 1:
291
+ interp = "Average"
292
+ elif z < 2:
293
+ interp = "Above Average"
294
+ else:
295
+ interp = "Very High"
296
+
297
+ z_data.append([label, str(value), f"{z:.2f}", f"{pct:.1f}%", interp])
298
+
299
+ if len(z_data) > 1:
300
+ z_table = Table(z_data, colWidths=[1.5*inch, 1*inch, 0.8*inch, 1*inch, 1.2*inch])
301
+ z_table.setStyle(TableStyle([
302
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e67e22')),
303
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
304
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
305
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
306
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
307
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
308
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
309
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
310
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
311
+ ]))
312
+ elements.append(z_table)
313
+ elements.append(Spacer(1, 0.15*inch))
314
+
315
+ # Add Z-score gauges
316
+ for biomarker, data in z_scores.items():
317
+ if isinstance(data, dict) and 'z_score' in data:
318
+ label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title())
319
+ gauge = create_z_score_gauge(data['z_score'], label)
320
+ elements.append(gauge)
321
+ elements.append(Spacer(1, 0.1*inch))
322
+
323
+ elements.append(Spacer(1, 0.2*inch))
324
+
325
+ # Cohort Information
326
+ elements.append(Paragraph("Reference Population", heading_style))
327
+ cohort_text = (
328
+ f"Z-scores calculated using normative data from Withings users in "
329
+ f"{patient_info.get('region', 'Western Europe')}, filtered by gender "
330
+ f"({patient_info.get('gender', 'N/A')}), age group, and BMI category."
331
+ )
332
+ elements.append(Paragraph(cohort_text, normal_style))
333
+ elements.append(Spacer(1, 0.3*inch))
334
+
335
+ # Disclaimer
336
+ disclaimer = Paragraph(
337
+ "<i>This report is for educational and research purposes only. Z-scores are based on "
338
+ "Withings population data and may not reflect clinical reference ranges. For detailed "
339
+ "questions regarding personal health data, contact your healthcare professionals.</i>",
340
+ ParagraphStyle('Disclaimer', parent=normal_style, fontSize=8, textColor=colors.grey)
341
+ )
342
+ elements.append(disclaimer)
343
+
344
+ # Footer
345
+ elements.append(Spacer(1, 0.2*inch))
346
+ footer = Paragraph(
347
+ "Built with ❤️ in Düsseldorf. © Lars Masanneck 2026.",
348
+ ParagraphStyle('Footer', parent=normal_style, fontSize=8, textColor=colors.grey, alignment=1)
349
+ )
350
+ elements.append(footer)
351
+
352
+ doc.build(elements)
353
+ buffer.seek(0)
354
+ return buffer
355
+
pages/1_Batch_Analysis.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch Analysis page for Smartwatch Normative Z-Score Calculator.
3
+
4
+ Upload multiple patient records for bulk z-score analysis.
5
+ """
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import sys
9
+ import os
10
+ from io import BytesIO
11
+
12
+ # Add parent directory to path for imports
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
14
+ from batch_utils import get_batch_template_df, process_batch_data, BIOMARKER_LABELS, AVAILABLE_BIOMARKERS
15
+ import normalizer_model
16
+
17
+ st.set_page_config(
18
+ page_title="Batch Analysis - Smartwatch Z-Score Calculator",
19
+ page_icon="📊",
20
+ layout="wide",
21
+ )
22
+
23
+ # Load normative data
24
+ DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Table_1_summary_measure.csv")
25
+
26
+ @st.cache_data
27
+ def get_normative_data():
28
+ try:
29
+ return normalizer_model.load_normative_table(DATA_PATH)
30
+ except Exception as e:
31
+ st.error(f"Could not load normative data: {e}")
32
+ return None
33
+
34
+ normative_df = get_normative_data()
35
+
36
+ st.title("📊 Batch Analysis")
37
+ st.markdown("**Upload multiple patient records for bulk smartwatch biomarker analysis**")
38
+
39
+ st.info(
40
+ "Upload an Excel or CSV file with patient data. Each row will be analyzed and "
41
+ "z-scores will be calculated for all available biomarkers."
42
+ )
43
+
44
+ col1, col2 = st.columns(2)
45
+
46
+ with col1:
47
+ st.subheader("📥 Download Template")
48
+ st.markdown("Use this template to prepare your data in the correct format.")
49
+
50
+ template_df = get_batch_template_df()
51
+
52
+ # Create downloadable Excel template
53
+ output = BytesIO()
54
+ with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
55
+ template_df.to_excel(writer, index=False, sheet_name='Patient Data')
56
+ workbook = writer.book
57
+ worksheet = writer.sheets['Patient Data']
58
+
59
+ # Orange-themed header format
60
+ header_format = workbook.add_format({
61
+ 'bold': True,
62
+ 'bg_color': '#e67e22',
63
+ 'font_color': 'white',
64
+ 'border': 1
65
+ })
66
+ for col_num, value in enumerate(template_df.columns.values):
67
+ worksheet.write(0, col_num, value, header_format)
68
+ worksheet.set_column(col_num, col_num, 18)
69
+
70
+ st.download_button(
71
+ label="⬇️ Download Excel Template",
72
+ data=output.getvalue(),
73
+ file_name="smartwatch_zscore_template.xlsx",
74
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
75
+ )
76
+
77
+ st.markdown("#### Required Columns:")
78
+ st.markdown("""
79
+ | Column | Description | Example |
80
+ |--------|-------------|---------|
81
+ | patient_id | Unique identifier | P001 |
82
+ | age | Age in years | 45 |
83
+ | gender | Man/Woman | Man |
84
+ | region | Geographic region | Western Europe |
85
+ | bmi | Body Mass Index | 24.5 |
86
+ """)
87
+
88
+ st.markdown("#### Biomarker Columns (optional):")
89
+ biomarker_table = "| Column | Description |\n|--------|-------------|\n"
90
+ for code in AVAILABLE_BIOMARKERS:
91
+ label = BIOMARKER_LABELS.get(code, code)
92
+ biomarker_table += f"| {code} | {label} |\n"
93
+ st.markdown(biomarker_table)
94
+
95
+ st.markdown("*Note: Include only the biomarkers you have data for. Leave cells blank if not measured.*")
96
+
97
+ with col2:
98
+ st.subheader("📤 Upload Data")
99
+
100
+ uploaded_file = st.file_uploader(
101
+ "Choose an Excel or CSV file",
102
+ type=['xlsx', 'xls', 'csv'],
103
+ help="Upload a file with patient data following the template format"
104
+ )
105
+
106
+ if uploaded_file is not None:
107
+ try:
108
+ if uploaded_file.name.endswith('.csv'):
109
+ df = pd.read_csv(uploaded_file)
110
+ else:
111
+ df = pd.read_excel(uploaded_file)
112
+
113
+ st.success(f"✅ Loaded {len(df)} patient records")
114
+
115
+ # Detect available biomarkers in the uploaded data
116
+ detected_biomarkers = [col for col in df.columns if col in AVAILABLE_BIOMARKERS]
117
+
118
+ if detected_biomarkers:
119
+ st.markdown(f"**Detected biomarkers:** {', '.join([BIOMARKER_LABELS.get(b, b) for b in detected_biomarkers])}")
120
+ else:
121
+ st.warning("No recognized biomarker columns found. Please check your column names.")
122
+
123
+ with st.expander("Preview uploaded data"):
124
+ st.dataframe(df, use_container_width=True)
125
+
126
+ except Exception as e:
127
+ st.error(f"Error reading file: {str(e)}")
128
+ df = None
129
+
130
+ st.markdown("---")
131
+
132
+ # Processing section
133
+ if uploaded_file is not None and 'df' in dir() and df is not None and normative_df is not None:
134
+
135
+ # Biomarker selection
136
+ st.subheader("Select Biomarkers to Analyze")
137
+ detected_biomarkers = [col for col in df.columns if col in AVAILABLE_BIOMARKERS]
138
+
139
+ if detected_biomarkers:
140
+ selected_biomarkers = st.multiselect(
141
+ "Choose biomarkers to include in analysis",
142
+ options=detected_biomarkers,
143
+ default=detected_biomarkers,
144
+ format_func=lambda x: BIOMARKER_LABELS.get(x, x)
145
+ )
146
+
147
+ if st.button("🔬 Process Batch Data", type="primary"):
148
+ if not selected_biomarkers:
149
+ st.error("Please select at least one biomarker to analyze.")
150
+ else:
151
+ with st.spinner("Processing patient data..."):
152
+ results_df = process_batch_data(df, normative_df, selected_biomarkers)
153
+
154
+ st.success("✅ Processing complete!")
155
+
156
+ # Results section
157
+ st.subheader("Results")
158
+
159
+ # Build display columns dynamically
160
+ base_cols = ['patient_id', 'age', 'gender', 'region', 'bmi']
161
+ display_cols = [c for c in base_cols if c in results_df.columns]
162
+
163
+ for bm in selected_biomarkers:
164
+ if bm in results_df.columns:
165
+ display_cols.append(bm)
166
+ if f'{bm}_z' in results_df.columns:
167
+ display_cols.append(f'{bm}_z')
168
+ if f'{bm}_percentile' in results_df.columns:
169
+ display_cols.append(f'{bm}_percentile')
170
+ if f'{bm}_interpretation' in results_df.columns:
171
+ display_cols.append(f'{bm}_interpretation')
172
+
173
+ available_cols = [c for c in display_cols if c in results_df.columns]
174
+
175
+ # Style function for interpretation columns
176
+ def highlight_interpretation(val):
177
+ if pd.isna(val) or val == 'N/A' or val == 'No data':
178
+ return ''
179
+ val_str = str(val).lower()
180
+ if 'average' in val_str and 'below' not in val_str and 'above' not in val_str:
181
+ return 'background-color: #90EE90' # Green
182
+ elif 'below' in val_str:
183
+ return 'background-color: #87CEEB' # Light blue
184
+ elif 'above' in val_str:
185
+ return 'background-color: #FFD700' # Gold
186
+ elif 'very low' in val_str:
187
+ return 'background-color: #ADD8E6' # Light blue
188
+ elif 'very high' in val_str:
189
+ return 'background-color: #FF6B6B' # Red
190
+ return ''
191
+
192
+ # Apply styling to interpretation columns
193
+ interp_cols = [c for c in available_cols if 'interpretation' in c]
194
+ if interp_cols:
195
+ styled_df = results_df[available_cols].style.applymap(
196
+ highlight_interpretation,
197
+ subset=interp_cols
198
+ )
199
+ st.dataframe(styled_df, use_container_width=True)
200
+ else:
201
+ st.dataframe(results_df[available_cols], use_container_width=True)
202
+
203
+ # Summary Statistics
204
+ st.subheader("Summary Statistics")
205
+
206
+ # Create columns for each biomarker
207
+ if len(selected_biomarkers) > 0:
208
+ cols = st.columns(min(len(selected_biomarkers), 3))
209
+
210
+ for idx, bm in enumerate(selected_biomarkers[:3]):
211
+ with cols[idx]:
212
+ st.markdown(f"**{BIOMARKER_LABELS.get(bm, bm)}**")
213
+ z_col = f'{bm}_z'
214
+ if z_col in results_df.columns:
215
+ # Filter out non-numeric values
216
+ z_values = pd.to_numeric(results_df[z_col], errors='coerce').dropna()
217
+ if len(z_values) > 0:
218
+ st.metric("Mean Z-Score", f"{z_values.mean():.2f}")
219
+ st.metric("Patients Analyzed", len(z_values))
220
+
221
+ # Distribution of interpretations
222
+ interp_col = f'{bm}_interpretation'
223
+ if interp_col in results_df.columns:
224
+ interp_counts = results_df[interp_col].value_counts()
225
+ st.bar_chart(interp_counts)
226
+
227
+ # Export Results
228
+ st.subheader("📥 Export Results")
229
+
230
+ output = BytesIO()
231
+ with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
232
+ results_df.to_excel(writer, index=False, sheet_name='Results')
233
+ workbook = writer.book
234
+ worksheet = writer.sheets['Results']
235
+
236
+ # Orange-themed header
237
+ header_format = workbook.add_format({
238
+ 'bold': True,
239
+ 'bg_color': '#e67e22',
240
+ 'font_color': 'white',
241
+ 'border': 1
242
+ })
243
+ for col_num, value in enumerate(results_df.columns.values):
244
+ worksheet.write(0, col_num, value, header_format)
245
+ worksheet.set_column(col_num, col_num, 18)
246
+
247
+ st.download_button(
248
+ label="⬇️ Download Results as Excel",
249
+ data=output.getvalue(),
250
+ file_name="smartwatch_zscore_results.xlsx",
251
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
252
+ )
253
+ else:
254
+ st.warning(
255
+ "No recognized biomarker columns found in your data. "
256
+ "Please ensure your columns match the template format."
257
+ )
258
+
259
+ # Footer
260
+ st.markdown("---")
261
+ st.markdown(
262
+ "*Batch analysis calculates z-scores relative to the Withings normative population, "
263
+ "stratified by region, gender, age group, and BMI category.*"
264
+ )
265
+ st.markdown(
266
+ "Built with ❤️ in Düsseldorf. © Lars Masanneck 2026."
267
+ )
268
+
pages/2_PDF_Report.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Report Generator page for Smartwatch Normative Z-Score Calculator.
3
+
4
+ Generate downloadable PDF reports for individual patients.
5
+ """
6
+ import streamlit as st
7
+ import sys
8
+ import os
9
+
10
+ # Add parent directory to path for imports
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+ from batch_utils import generate_pdf_report, BIOMARKER_LABELS, AVAILABLE_BIOMARKERS
13
+ import normalizer_model
14
+
15
+ st.set_page_config(
16
+ page_title="PDF Report - Smartwatch Z-Score Calculator",
17
+ page_icon="📄",
18
+ layout="wide",
19
+ )
20
+
21
+ # Load normative data
22
+ DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Table_1_summary_measure.csv")
23
+
24
+ @st.cache_data
25
+ def get_normative_data():
26
+ try:
27
+ return normalizer_model.load_normative_table(DATA_PATH)
28
+ except Exception as e:
29
+ st.error(f"Could not load normative data: {e}")
30
+ return None
31
+
32
+ normative_df = get_normative_data()
33
+
34
+ st.title("📄 PDF Report Generator")
35
+ st.markdown("**Generate a professional smartwatch biomarker report for download**")
36
+
37
+ st.info(
38
+ "Enter patient information and biomarker measurements below to generate a downloadable PDF report "
39
+ "with z-scores, percentiles, and visual gauges."
40
+ )
41
+
42
+ col1, col2 = st.columns(2)
43
+
44
+ with col1:
45
+ st.subheader("👤 Patient Information")
46
+
47
+ patient_name = st.text_input(
48
+ "Patient Name/ID (optional)",
49
+ placeholder="e.g., John Doe or P001"
50
+ )
51
+
52
+ # Region with default Western Europe
53
+ if normative_df is not None:
54
+ regions = sorted(normative_df["area"].unique())
55
+ if "Western Europe" in regions:
56
+ default_region_idx = regions.index("Western Europe")
57
+ else:
58
+ default_region_idx = 0
59
+ else:
60
+ regions = ["Western Europe", "Southern Europe", "North America", "Japan"]
61
+ default_region_idx = 0
62
+
63
+ region = st.selectbox(
64
+ "Region",
65
+ regions,
66
+ index=default_region_idx
67
+ )
68
+
69
+ # Gender
70
+ if normative_df is not None:
71
+ genders = sorted(normative_df["gender"].unique())
72
+ else:
73
+ genders = ["Man", "Woman"]
74
+
75
+ gender = st.selectbox("Gender", genders)
76
+
77
+ age = st.number_input(
78
+ "Age (years)",
79
+ min_value=0,
80
+ max_value=120,
81
+ value=45
82
+ )
83
+
84
+ bmi = st.number_input(
85
+ "BMI",
86
+ min_value=10.0,
87
+ max_value=60.0,
88
+ value=24.0,
89
+ step=0.1,
90
+ format="%.1f"
91
+ )
92
+
93
+ with col2:
94
+ st.subheader("📊 Biomarker Measurements")
95
+ st.caption("Select which biomarkers to include in the report")
96
+
97
+ # Biomarker inputs with checkboxes
98
+ measurements = {}
99
+
100
+ include_steps = st.checkbox("Include Number of Steps", value=True)
101
+ if include_steps:
102
+ measurements['nb_steps'] = st.number_input(
103
+ "Number of Steps",
104
+ min_value=0.0,
105
+ max_value=50000.0,
106
+ value=6500.0,
107
+ step=100.0
108
+ )
109
+
110
+ include_sleep = st.checkbox("Include Sleep Duration", value=True)
111
+ if include_sleep:
112
+ measurements['sleep_duration'] = st.number_input(
113
+ "Sleep Duration (hours)",
114
+ min_value=0.0,
115
+ max_value=24.0,
116
+ value=7.5,
117
+ step=0.1,
118
+ format="%.1f"
119
+ )
120
+
121
+ include_hr = st.checkbox("Include Average Night Heart Rate", value=True)
122
+ if include_hr:
123
+ measurements['avg_night_hr'] = st.number_input(
124
+ "Average Night Heart Rate (bpm)",
125
+ min_value=30.0,
126
+ max_value=150.0,
127
+ value=62.0,
128
+ step=1.0
129
+ )
130
+
131
+ include_active = st.checkbox("Include Mean Active Time", value=False)
132
+ if include_active:
133
+ measurements['mean_active_time'] = st.number_input(
134
+ "Mean Active Time (minutes)",
135
+ min_value=0.0,
136
+ max_value=1440.0,
137
+ value=45.0,
138
+ step=1.0
139
+ )
140
+
141
+ include_moderate = st.checkbox("Include Moderate Active Minutes", value=False)
142
+ if include_moderate:
143
+ measurements['nb_moderate_active_minutes'] = st.number_input(
144
+ "Moderate Active Minutes",
145
+ min_value=0.0,
146
+ max_value=1440.0,
147
+ value=30.0,
148
+ step=1.0
149
+ )
150
+
151
+ st.markdown("---")
152
+
153
+ # Generate Report Button
154
+ if st.button("📄 Generate PDF Report", type="primary"):
155
+ if not measurements:
156
+ st.error("Please include at least one biomarker measurement.")
157
+ elif normative_df is None:
158
+ st.error("Normative data not loaded. Cannot generate report.")
159
+ else:
160
+ patient_info = {
161
+ 'name': patient_name if patient_name else 'Not specified',
162
+ 'age': age,
163
+ 'gender': gender,
164
+ 'region': region,
165
+ 'bmi': bmi
166
+ }
167
+
168
+ # Calculate z-scores for each included biomarker
169
+ z_scores = {}
170
+ errors = []
171
+
172
+ for biomarker, value in measurements.items():
173
+ try:
174
+ result = normalizer_model.compute_normative_position(
175
+ value=value,
176
+ biomarker=biomarker,
177
+ age_group=age,
178
+ region=region,
179
+ gender=gender,
180
+ bmi=bmi,
181
+ normative_df=normative_df
182
+ )
183
+ z_scores[biomarker] = result
184
+ except Exception as e:
185
+ errors.append(f"{BIOMARKER_LABELS.get(biomarker, biomarker)}: {str(e)}")
186
+
187
+ if errors:
188
+ for err in errors:
189
+ st.warning(f"Z-score calculation note: {err}")
190
+
191
+ if z_scores:
192
+ with st.spinner("Generating PDF report..."):
193
+ pdf_buffer = generate_pdf_report(patient_info, measurements, z_scores)
194
+
195
+ st.success("✅ PDF report generated successfully!")
196
+
197
+ # Report Preview
198
+ st.subheader("Report Preview")
199
+
200
+ with st.expander("View Report Contents", expanded=True):
201
+ st.markdown("### Demographics")
202
+ st.markdown(f"- **Age:** {age} years")
203
+ st.markdown(f"- **Gender:** {gender}")
204
+ st.markdown(f"- **Region:** {region}")
205
+ st.markdown(f"- **BMI:** {bmi}")
206
+
207
+ st.markdown("### Measurements & Z-Scores")
208
+
209
+ # Create columns for z-score display
210
+ num_scores = len(z_scores)
211
+ if num_scores > 0:
212
+ cols = st.columns(min(num_scores, 3))
213
+
214
+ for idx, (biomarker, data) in enumerate(z_scores.items()):
215
+ with cols[idx % 3]:
216
+ label = BIOMARKER_LABELS.get(biomarker, biomarker)
217
+ z = data['z_score']
218
+ pct = data['percentile']
219
+ value = measurements[biomarker]
220
+
221
+ # Determine interpretation
222
+ if z < -2:
223
+ interp = "Very Low"
224
+ elif z < -1:
225
+ interp = "Below Average"
226
+ elif z < 1:
227
+ interp = "Average"
228
+ elif z < 2:
229
+ interp = "Above Average"
230
+ else:
231
+ interp = "Very High"
232
+
233
+ st.metric(
234
+ label,
235
+ f"Z = {z:.2f}",
236
+ f"{pct:.1f}th percentile"
237
+ )
238
+ st.caption(f"Value: {value} | {interp}")
239
+
240
+ # Cohort info
241
+ age_group_str = normalizer_model._categorize_age(age, normative_df)
242
+ bmi_cat = normalizer_model.categorize_bmi(bmi)
243
+ st.markdown("### Reference Population")
244
+ st.markdown(
245
+ f"Z-scores calculated from normative data: **{region}**, "
246
+ f"**{gender}**, age group **{age_group_str}**, BMI category **{bmi_cat}**."
247
+ )
248
+
249
+ # Download button
250
+ filename = f"smartwatch_report_{patient_name.replace(' ', '_') if patient_name else 'patient'}.pdf"
251
+
252
+ st.download_button(
253
+ label="⬇️ Download PDF Report",
254
+ data=pdf_buffer,
255
+ file_name=filename,
256
+ mime="application/pdf"
257
+ )
258
+ else:
259
+ st.error("Could not calculate z-scores for any biomarkers. Please check your inputs.")
260
+
261
+ # Information section
262
+ st.markdown("---")
263
+
264
+ st.markdown("### Report Contents")
265
+ st.markdown("""
266
+ The generated PDF report includes:
267
+
268
+ 1. **Patient Demographics** - Age, gender, region, BMI
269
+ 2. **Biomarker Measurements** - All selected smartwatch metrics
270
+ 3. **Z-Score Analysis** - Comparison to normative population data
271
+ - Z-scores and percentiles for each biomarker
272
+ - Visual gauge charts showing position in distribution
273
+ - Interpretation (Very Low → Average → Very High)
274
+ 4. **Reference Population Info** - Details about the comparison cohort
275
+
276
+ *All reports include a disclaimer noting educational/research purpose.*
277
+ """)
278
+
279
+ # Footer
280
+ st.markdown("---")
281
+ st.markdown(
282
+ "*PDF reports are for educational and research purposes. "
283
+ "For detailed questions regarding personal health data, contact your healthcare professionals.*"
284
+ )
285
+ st.markdown(
286
+ "Built with ❤️ in Düsseldorf. © Lars Masanneck 2026."
287
+ )
288
+
requirements.txt CHANGED
@@ -7,4 +7,6 @@ matplotlib==3.8.0
7
  seaborn==0.13.0
8
  openpyxl==3.1.2
9
  altair==5.5.0
10
- plotly==5.21.0
 
 
 
7
  seaborn==0.13.0
8
  openpyxl==3.1.2
9
  altair==5.5.0
10
+ plotly==5.21.0
11
+ reportlab==4.0.0
12
+ xlsxwriter==3.1.9