|
|
""" |
|
|
Batch processing and PDF generation utilities for Smartwatch Normative Z-Score Calculator. |
|
|
|
|
|
Author: Lars Masanneck 2026 |
|
|
""" |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
from io import BytesIO |
|
|
from reportlab.lib import colors |
|
|
from reportlab.lib.pagesizes import A4 |
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer |
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
|
|
from reportlab.lib.units import inch |
|
|
from reportlab.graphics.shapes import Drawing, Rect, Line, String |
|
|
|
|
|
|
|
|
import normalizer_model |
|
|
|
|
|
|
|
|
BIOMARKER_LABELS = { |
|
|
"nb_steps": "Number of Steps", |
|
|
"max_steps": "Maximum Steps", |
|
|
"mean_active_time": "Mean Active Time", |
|
|
"sbp": "Systolic Blood Pressure", |
|
|
"dbp": "Diastolic Blood Pressure", |
|
|
"sleep_duration": "Sleep Duration", |
|
|
"avg_night_hr": "Average Night Heart Rate", |
|
|
"nb_moderate_active_minutes": "Moderate Active Minutes", |
|
|
"nb_vigorous_active_minutes": "Vigorous Active Minutes", |
|
|
"weight": "Weight", |
|
|
"pwv": "Pulse Wave Velocity", |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
HIGHER_IS_BETTER = { |
|
|
"nb_steps", |
|
|
"max_steps", |
|
|
"mean_active_time", |
|
|
"sleep_duration", |
|
|
"nb_moderate_active_minutes", |
|
|
"nb_vigorous_active_minutes", |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
LOWER_IS_BETTER = { |
|
|
"sbp", |
|
|
"dbp", |
|
|
"pwv", |
|
|
"avg_night_hr", |
|
|
"weight", |
|
|
} |
|
|
|
|
|
|
|
|
AVAILABLE_BIOMARKERS = [ |
|
|
"nb_steps", |
|
|
"max_steps", |
|
|
"mean_active_time", |
|
|
"sleep_duration", |
|
|
"avg_night_hr", |
|
|
"nb_moderate_active_minutes", |
|
|
] |
|
|
|
|
|
|
|
|
def get_batch_template_df(): |
|
|
"""Return a template DataFrame for batch upload.""" |
|
|
return pd.DataFrame({ |
|
|
"patient_id": ["P001", "P002", "P003"], |
|
|
"age": [45, 62, 38], |
|
|
"gender": ["Man", "Woman", "Man"], |
|
|
"region": ["Western Europe", "Western Europe", "North America"], |
|
|
"bmi": [24.5, 28.1, 22.3], |
|
|
"nb_steps": [7500, 4200, 9800], |
|
|
"sleep_duration": [7.2, 6.5, 8.1], |
|
|
"avg_night_hr": [62, 68, 58], |
|
|
}) |
|
|
|
|
|
|
|
|
def process_batch_data(df: pd.DataFrame, normative_df: pd.DataFrame, |
|
|
biomarkers_to_process: list = None) -> pd.DataFrame: |
|
|
""" |
|
|
Process batch data and add z-score and percentile columns for selected biomarkers. |
|
|
|
|
|
Parameters |
|
|
---------- |
|
|
df : pd.DataFrame |
|
|
Input data with patient demographics and biomarker values |
|
|
normative_df : pd.DataFrame |
|
|
Normative reference table |
|
|
biomarkers_to_process : list, optional |
|
|
List of biomarker columns to process. If None, auto-detect from data. |
|
|
|
|
|
Returns |
|
|
------- |
|
|
pd.DataFrame |
|
|
Results with z-scores and percentiles added |
|
|
""" |
|
|
results = [] |
|
|
|
|
|
|
|
|
if biomarkers_to_process is None: |
|
|
biomarkers_to_process = [col for col in df.columns if col in AVAILABLE_BIOMARKERS] |
|
|
|
|
|
for _, row in df.iterrows(): |
|
|
result = row.to_dict() |
|
|
|
|
|
|
|
|
for biomarker in biomarkers_to_process: |
|
|
if pd.notna(row.get(biomarker)): |
|
|
try: |
|
|
res = normalizer_model.compute_normative_position( |
|
|
value=float(row[biomarker]), |
|
|
biomarker=biomarker, |
|
|
age_group=int(row['age']) if pd.notna(row.get('age')) else 45, |
|
|
region=row.get('region', 'Western Europe'), |
|
|
gender=row.get('gender', 'Man'), |
|
|
bmi=float(row.get('bmi', 24.0)) if pd.notna(row.get('bmi')) else 24.0, |
|
|
normative_df=normative_df, |
|
|
) |
|
|
result[f'{biomarker}_z'] = round(res['z_score'], 2) |
|
|
result[f'{biomarker}_percentile'] = round(res['percentile'], 1) |
|
|
|
|
|
|
|
|
z = res['z_score'] |
|
|
higher_is_better = biomarker in HIGHER_IS_BETTER |
|
|
|
|
|
if higher_is_better: |
|
|
|
|
|
if z < -2: |
|
|
result[f'{biomarker}_interpretation'] = 'Very Low ⚠️' |
|
|
elif z < -0.5: |
|
|
result[f'{biomarker}_interpretation'] = 'Below Average' |
|
|
elif z < 0.5: |
|
|
result[f'{biomarker}_interpretation'] = 'Average' |
|
|
elif z < 2: |
|
|
result[f'{biomarker}_interpretation'] = 'Above Average ✓' |
|
|
else: |
|
|
result[f'{biomarker}_interpretation'] = 'Excellent ✓✓' |
|
|
else: |
|
|
|
|
|
if z < -2: |
|
|
result[f'{biomarker}_interpretation'] = 'Very Low ✓✓' |
|
|
elif z < -0.5: |
|
|
result[f'{biomarker}_interpretation'] = 'Below Average ✓' |
|
|
elif z < 0.5: |
|
|
result[f'{biomarker}_interpretation'] = 'Average' |
|
|
elif z < 2: |
|
|
result[f'{biomarker}_interpretation'] = 'Above Average' |
|
|
else: |
|
|
result[f'{biomarker}_interpretation'] = 'Elevated ⚠️' |
|
|
|
|
|
except Exception as e: |
|
|
result[f'{biomarker}_z'] = 'N/A' |
|
|
result[f'{biomarker}_percentile'] = 'N/A' |
|
|
result[f'{biomarker}_interpretation'] = f'Error: {str(e)[:30]}' |
|
|
else: |
|
|
result[f'{biomarker}_z'] = 'N/A' |
|
|
result[f'{biomarker}_percentile'] = 'N/A' |
|
|
result[f'{biomarker}_interpretation'] = 'No data' |
|
|
|
|
|
results.append(result) |
|
|
|
|
|
return pd.DataFrame(results) |
|
|
|
|
|
|
|
|
def create_z_score_gauge(z_score: float, label: str, biomarker: str = None, |
|
|
width: float = 350, height: float = 100) -> Drawing: |
|
|
"""Create a horizontal gauge showing z-score position with context-aware coloring.""" |
|
|
d = Drawing(width, height) |
|
|
|
|
|
gauge_y = 35 |
|
|
gauge_height = 25 |
|
|
gauge_left = 50 |
|
|
gauge_width = width - 100 |
|
|
|
|
|
|
|
|
higher_is_better = biomarker in HIGHER_IS_BETTER if biomarker else False |
|
|
|
|
|
if higher_is_better: |
|
|
|
|
|
zone_colors = [ |
|
|
(colors.HexColor('#c0392b'), -3), |
|
|
(colors.HexColor('#e74c3c'), -2), |
|
|
(colors.HexColor('#f39c12'), -1), |
|
|
(colors.HexColor('#f1c40f'), 0), |
|
|
(colors.HexColor('#2ecc71'), 1), |
|
|
(colors.HexColor('#27ae60'), 2), |
|
|
] |
|
|
else: |
|
|
|
|
|
zone_colors = [ |
|
|
(colors.HexColor('#27ae60'), -3), |
|
|
(colors.HexColor('#2ecc71'), -2), |
|
|
(colors.HexColor('#f1c40f'), -1), |
|
|
(colors.HexColor('#f39c12'), 0), |
|
|
(colors.HexColor('#e74c3c'), 1), |
|
|
(colors.HexColor('#c0392b'), 2), |
|
|
] |
|
|
|
|
|
zone_width = gauge_width / 6 |
|
|
for i, (color, _) in enumerate(zone_colors): |
|
|
d.add(Rect(gauge_left + i * zone_width, gauge_y, zone_width, gauge_height, |
|
|
fillColor=color, strokeColor=None)) |
|
|
|
|
|
|
|
|
d.add(Rect(gauge_left, gauge_y, gauge_width, gauge_height, |
|
|
fillColor=None, strokeColor=colors.black, strokeWidth=1)) |
|
|
|
|
|
|
|
|
clamped_z = max(-3, min(3, z_score)) |
|
|
marker_x = gauge_left + ((clamped_z + 3) / 6) * gauge_width |
|
|
|
|
|
|
|
|
d.add(Line(marker_x, gauge_y - 8, marker_x, gauge_y + gauge_height + 8, |
|
|
strokeColor=colors.black, strokeWidth=3)) |
|
|
|
|
|
|
|
|
for i, val in enumerate([-3, -2, -1, 0, 1, 2, 3]): |
|
|
x = gauge_left + (i / 6) * gauge_width |
|
|
d.add(String(x, gauge_y - 15, str(val), fontSize=9, textAnchor='middle')) |
|
|
|
|
|
|
|
|
d.add(String(width / 2, height - 8, label, fontSize=11, textAnchor='middle', fontName='Helvetica-Bold')) |
|
|
|
|
|
|
|
|
d.add(String(width / 2, gauge_y + gauge_height + 18, f"Z = {z_score:.2f}", |
|
|
fontSize=10, textAnchor='middle', fontName='Helvetica-Bold')) |
|
|
|
|
|
return d |
|
|
|
|
|
|
|
|
def generate_pdf_report(patient_info: dict, measurements: dict, z_scores: dict = None) -> BytesIO: |
|
|
""" |
|
|
Generate a PDF report for a patient with Z-scores and graphs. |
|
|
|
|
|
Parameters |
|
|
---------- |
|
|
patient_info : dict |
|
|
Patient demographics (age, gender, region, bmi) |
|
|
measurements : dict |
|
|
Biomarker measurements (biomarker_code: value) |
|
|
z_scores : dict |
|
|
Z-score results for each biomarker |
|
|
|
|
|
Returns |
|
|
------- |
|
|
BytesIO |
|
|
PDF buffer ready for download |
|
|
""" |
|
|
buffer = BytesIO() |
|
|
doc = SimpleDocTemplate(buffer, pagesize=A4, topMargin=0.5*inch, bottomMargin=0.5*inch) |
|
|
|
|
|
styles = getSampleStyleSheet() |
|
|
|
|
|
|
|
|
title_style = ParagraphStyle( |
|
|
'Title', |
|
|
parent=styles['Heading1'], |
|
|
fontSize=18, |
|
|
spaceAfter=12, |
|
|
alignment=1, |
|
|
textColor=colors.HexColor('#d35400') |
|
|
) |
|
|
heading_style = ParagraphStyle( |
|
|
'Heading', |
|
|
parent=styles['Heading2'], |
|
|
fontSize=14, |
|
|
spaceAfter=8, |
|
|
spaceBefore=12, |
|
|
textColor=colors.HexColor('#e67e22') |
|
|
) |
|
|
normal_style = styles['Normal'] |
|
|
|
|
|
elements = [] |
|
|
|
|
|
|
|
|
elements.append(Paragraph("Smartwatch Normative Z-Score Report", title_style)) |
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
elements.append(Paragraph("Demographics", heading_style)) |
|
|
patient_data = [ |
|
|
["Age:", f"{patient_info.get('age', 'N/A')} years"], |
|
|
["Gender:", patient_info.get('gender', 'N/A')], |
|
|
["Region:", patient_info.get('region', 'N/A')], |
|
|
["BMI:", f"{patient_info.get('bmi', 'N/A')}"], |
|
|
] |
|
|
patient_table = Table(patient_data, colWidths=[2*inch, 4*inch]) |
|
|
patient_table.setStyle(TableStyle([ |
|
|
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), |
|
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'), |
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), |
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6), |
|
|
])) |
|
|
elements.append(patient_table) |
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
if measurements: |
|
|
elements.append(Paragraph("Measurements", heading_style)) |
|
|
measurements_data = [] |
|
|
for biomarker, value in measurements.items(): |
|
|
label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title()) |
|
|
measurements_data.append([f"{label}:", f"{value}"]) |
|
|
|
|
|
if measurements_data: |
|
|
meas_table = Table(measurements_data, colWidths=[2.5*inch, 3.5*inch]) |
|
|
meas_table.setStyle(TableStyle([ |
|
|
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), |
|
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'), |
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), |
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6), |
|
|
])) |
|
|
elements.append(meas_table) |
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
if z_scores: |
|
|
elements.append(Paragraph("Z-Score Analysis", heading_style)) |
|
|
elements.append(Paragraph( |
|
|
"Z-scores indicate how many standard deviations a measurement is from the population mean. " |
|
|
"Values between -2 and +2 are typically considered within normal range.", |
|
|
ParagraphStyle('ZInfo', parent=normal_style, fontSize=9, textColor=colors.grey, spaceAfter=8) |
|
|
)) |
|
|
|
|
|
|
|
|
z_data = [["Biomarker", "Value", "Z-Score", "Percentile", "Interpretation"]] |
|
|
|
|
|
for biomarker, data in z_scores.items(): |
|
|
if isinstance(data, dict) and 'z_score' in data: |
|
|
z = data['z_score'] |
|
|
pct = data['percentile'] |
|
|
value = measurements.get(biomarker, 'N/A') |
|
|
label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title()) |
|
|
|
|
|
|
|
|
higher_is_better = biomarker in HIGHER_IS_BETTER |
|
|
|
|
|
if higher_is_better: |
|
|
|
|
|
if z < -2: |
|
|
interp = "Very Low ⚠️" |
|
|
elif z < -0.5: |
|
|
interp = "Below Average" |
|
|
elif z < 0.5: |
|
|
interp = "Average" |
|
|
elif z < 2: |
|
|
interp = "Above Average ✓" |
|
|
else: |
|
|
interp = "Excellent ✓✓" |
|
|
else: |
|
|
|
|
|
if z < -2: |
|
|
interp = "Very Low ✓✓" |
|
|
elif z < -0.5: |
|
|
interp = "Below Average ✓" |
|
|
elif z < 0.5: |
|
|
interp = "Average" |
|
|
elif z < 2: |
|
|
interp = "Above Average" |
|
|
else: |
|
|
interp = "Elevated ⚠️" |
|
|
|
|
|
z_data.append([label, str(value), f"{z:.2f}", f"{pct:.1f}%", interp]) |
|
|
|
|
|
if len(z_data) > 1: |
|
|
z_table = Table(z_data, colWidths=[1.5*inch, 1*inch, 0.8*inch, 1*inch, 1.2*inch]) |
|
|
z_table.setStyle(TableStyle([ |
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e67e22')), |
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), |
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), |
|
|
('FONTSIZE', (0, 0), (-1, -1), 9), |
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'), |
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), |
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.grey), |
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6), |
|
|
('TOPPADDING', (0, 0), (-1, -1), 6), |
|
|
])) |
|
|
elements.append(z_table) |
|
|
elements.append(Spacer(1, 0.15*inch)) |
|
|
|
|
|
|
|
|
for biomarker, data in z_scores.items(): |
|
|
if isinstance(data, dict) and 'z_score' in data: |
|
|
label = BIOMARKER_LABELS.get(biomarker, biomarker.replace('_', ' ').title()) |
|
|
gauge = create_z_score_gauge(data['z_score'], label, biomarker=biomarker) |
|
|
elements.append(gauge) |
|
|
elements.append(Spacer(1, 0.1*inch)) |
|
|
|
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
elements.append(Paragraph("Reference Population", heading_style)) |
|
|
cohort_text = ( |
|
|
f"Z-scores calculated using normative data from Withings users in " |
|
|
f"{patient_info.get('region', 'Western Europe')}, filtered by gender " |
|
|
f"({patient_info.get('gender', 'N/A')}), age group, and BMI category." |
|
|
) |
|
|
elements.append(Paragraph(cohort_text, normal_style)) |
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
elements.append(Paragraph("Z-Score Classification Guide", heading_style)) |
|
|
|
|
|
classification_data = [ |
|
|
["Z-Score Range", "Classification", "Percentile"], |
|
|
["z < -2.0", "Very Low", "< 2.3%"], |
|
|
["-2.0 ≤ z < -0.5", "Below Average", "2.3% - 30.9%"], |
|
|
["-0.5 ≤ z < 0.5", "Average", "30.9% - 69.1%"], |
|
|
["0.5 ≤ z < 2.0", "Above Average", "69.1% - 97.7%"], |
|
|
["z ≥ 2.0", "Very High", "> 97.7%"], |
|
|
] |
|
|
|
|
|
class_table = Table(classification_data, colWidths=[1.8*inch, 1.5*inch, 1.5*inch]) |
|
|
class_table.setStyle(TableStyle([ |
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e67e22')), |
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), |
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), |
|
|
('FONTSIZE', (0, 0), (-1, -1), 9), |
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'), |
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), |
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.grey), |
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6), |
|
|
('TOPPADDING', (0, 0), (-1, -1), 6), |
|
|
|
|
|
('BACKGROUND', (0, 3), (-1, 3), colors.HexColor('#fef9e7')), |
|
|
])) |
|
|
elements.append(class_table) |
|
|
elements.append(Spacer(1, 0.1*inch)) |
|
|
|
|
|
context_note = Paragraph( |
|
|
"<b>Context:</b> For steps, sleep, and activity - higher is better. " |
|
|
"For heart rate - lower resting values are better. " |
|
|
"A z-score of 0 = population average for your demographic group.", |
|
|
ParagraphStyle('ContextNote', parent=normal_style, fontSize=8, textColor=colors.HexColor('#555555')) |
|
|
) |
|
|
elements.append(context_note) |
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
|
|
|
|
|
|
disclaimer = Paragraph( |
|
|
"<i>This report is for educational and research purposes only. Z-scores are based on " |
|
|
"Withings population data and may not reflect clinical reference ranges. For detailed " |
|
|
"questions regarding personal health data, contact your healthcare professionals.</i>", |
|
|
ParagraphStyle('Disclaimer', parent=normal_style, fontSize=8, textColor=colors.grey) |
|
|
) |
|
|
elements.append(disclaimer) |
|
|
|
|
|
|
|
|
elements.append(Spacer(1, 0.2*inch)) |
|
|
footer = Paragraph( |
|
|
"Built with ❤️ in Düsseldorf. © Lars Masanneck 2026.", |
|
|
ParagraphStyle('Footer', parent=normal_style, fontSize=8, textColor=colors.grey, alignment=1) |
|
|
) |
|
|
elements.append(footer) |
|
|
|
|
|
doc.build(elements) |
|
|
buffer.seek(0) |
|
|
return buffer |
|
|
|
|
|
|