| | """ |
| | 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 |
| |
|
| |
|