perfectionist
This commit is contained in:
+128
-56
@@ -9,6 +9,7 @@ import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -109,7 +110,6 @@ async def upload_files(
|
||||
gender: str = Form(...),
|
||||
fat_percentage: float = Form(...),
|
||||
focus: str = Form(default="Endurance"),
|
||||
session_id: str = Form(default="default"),
|
||||
next_testing_date: str = Form(...),
|
||||
report_type: str = Form(default="full"),
|
||||
spirometry_pdf: UploadFile = File(...),
|
||||
@@ -179,6 +179,10 @@ async def upload_files(
|
||||
# Prepare patient information
|
||||
patient_name = f"{first_name} {last_name}"
|
||||
print(f"DEBUG: Received next_testing_date: '{next_testing_date}'")
|
||||
|
||||
# Generate session_id internally using timestamp for unique identification
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
patient_info = {
|
||||
"patient_name": patient_name,
|
||||
"first_name": first_name,
|
||||
@@ -290,8 +294,18 @@ async def upload_files(
|
||||
@app.get("/preview", response_class=HTMLResponse)
|
||||
async def preview(request: Request):
|
||||
"""Preview generated report"""
|
||||
# Check for required session data
|
||||
if not request.session.get("report_path"):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Ensure metrics exist in session, initialize if missing
|
||||
if "metrics" not in request.session:
|
||||
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
# Ensure patient_info exists
|
||||
if "patient_info" not in request.session:
|
||||
request.session["patient_info"] = {}
|
||||
|
||||
return render_template(
|
||||
"preview.html", {"request": request, "session": request.session}
|
||||
)
|
||||
@@ -309,8 +323,16 @@ async def serve_graph(filename: str):
|
||||
@app.get("/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request):
|
||||
"""Display edit metrics form"""
|
||||
if not request.session.get("metrics"):
|
||||
# Check for required session data
|
||||
if not request.session.get("report_path") or not request.session.get(
|
||||
"patient_info"
|
||||
):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Ensure metrics exist in session, initialize if missing
|
||||
if "metrics" not in request.session:
|
||||
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
return render_template(
|
||||
"edit.html", {"request": request, "session": request.session}
|
||||
)
|
||||
@@ -325,69 +347,117 @@ async def edit_metrics(request: Request):
|
||||
# Get form data
|
||||
form_data = await request.form()
|
||||
|
||||
# Helper function to safely convert form values to float
|
||||
def safe_float(value):
|
||||
"""Convert form value to float, return None if empty or invalid"""
|
||||
if not value or value.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# Build metric overrides
|
||||
metric_overrides = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
# Pnoe overrides
|
||||
if form_data.get("vo2_max"):
|
||||
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"])
|
||||
if form_data.get("vo2_max_per_kg"):
|
||||
metric_overrides["pnoe"]["vo2_max_per_kg"] = float(form_data["vo2_max_per_kg"])
|
||||
if form_data.get("peak_vt"):
|
||||
metric_overrides["pnoe"]["peak_vt"] = float(form_data["peak_vt"])
|
||||
if form_data.get("peak_vt_hr"):
|
||||
metric_overrides["pnoe"]["peak_vt_hr"] = float(form_data["peak_vt_hr"])
|
||||
if form_data.get("fat_max_value"):
|
||||
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
|
||||
if form_data.get("fat_max_hr"):
|
||||
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
|
||||
# Pnoe overrides - only add if value is provided and valid
|
||||
vo2_max_val = safe_float(form_data.get("vo2_max"))
|
||||
if vo2_max_val is not None:
|
||||
metric_overrides["pnoe"]["vo2_max"] = vo2_max_val
|
||||
|
||||
# VT1 and VT2 overrides
|
||||
if (
|
||||
form_data.get("vt1_hr")
|
||||
or form_data.get("vt1_speed")
|
||||
or form_data.get("vt1_time")
|
||||
):
|
||||
metric_overrides["pnoe"]["vt1"] = {
|
||||
"HeartRate": float(form_data.get("vt1_hr", 0)),
|
||||
"Speed": float(form_data.get("vt1_speed", 0)),
|
||||
"Time": float(form_data.get("vt1_time", 0)),
|
||||
vo2_max_per_kg_val = safe_float(form_data.get("vo2_max_per_kg"))
|
||||
if vo2_max_per_kg_val is not None:
|
||||
metric_overrides["pnoe"]["vo2_max_per_kg"] = vo2_max_per_kg_val
|
||||
|
||||
peak_vt_val = safe_float(form_data.get("peak_vt"))
|
||||
if peak_vt_val is not None:
|
||||
metric_overrides["pnoe"]["peak_vt"] = peak_vt_val
|
||||
|
||||
peak_vt_hr_val = safe_float(form_data.get("peak_vt_hr"))
|
||||
if peak_vt_hr_val is not None:
|
||||
metric_overrides["pnoe"]["peak_vt_hr"] = peak_vt_hr_val
|
||||
|
||||
fat_max_value_val = safe_float(form_data.get("fat_max_value"))
|
||||
if fat_max_value_val is not None:
|
||||
metric_overrides["pnoe"]["fat_max_value"] = fat_max_value_val
|
||||
|
||||
fat_max_hr_val = safe_float(form_data.get("fat_max_hr"))
|
||||
if fat_max_hr_val is not None:
|
||||
metric_overrides["pnoe"]["fat_max_hr"] = fat_max_hr_val
|
||||
|
||||
# VT1 and VT2 overrides - use existing values if not provided
|
||||
existing_metrics = request.session.get("metrics", {})
|
||||
existing_pnoe = existing_metrics.get("pnoe", {})
|
||||
existing_vt1 = existing_pnoe.get("vt1", {})
|
||||
existing_vt2 = existing_pnoe.get("vt2", {})
|
||||
|
||||
vt1_hr_val = safe_float(form_data.get("vt1_hr"))
|
||||
vt1_speed_val = safe_float(form_data.get("vt1_speed"))
|
||||
vt1_time_val = safe_float(form_data.get("vt1_time"))
|
||||
|
||||
if vt1_hr_val is not None or vt1_speed_val is not None or vt1_time_val is not None:
|
||||
vt1_dict = {
|
||||
"HeartRate": vt1_hr_val
|
||||
if vt1_hr_val is not None
|
||||
else existing_vt1.get("HeartRate", 0),
|
||||
"Speed": vt1_speed_val
|
||||
if vt1_speed_val is not None
|
||||
else existing_vt1.get("Speed", 0),
|
||||
"Time": vt1_time_val
|
||||
if vt1_time_val is not None
|
||||
else existing_vt1.get("Time", 0),
|
||||
}
|
||||
metric_overrides["pnoe"]["vt1"] = vt1_dict
|
||||
|
||||
if (
|
||||
form_data.get("vt2_hr")
|
||||
or form_data.get("vt2_speed")
|
||||
or form_data.get("vt2_time")
|
||||
):
|
||||
metric_overrides["pnoe"]["vt2"] = {
|
||||
"HeartRate": float(form_data.get("vt2_hr", 0)),
|
||||
"Speed": float(form_data.get("vt2_speed", 0)),
|
||||
"Time": float(form_data.get("vt2_time", 0)),
|
||||
vt2_hr_val = safe_float(form_data.get("vt2_hr"))
|
||||
vt2_speed_val = safe_float(form_data.get("vt2_speed"))
|
||||
vt2_time_val = safe_float(form_data.get("vt2_time"))
|
||||
|
||||
if vt2_hr_val is not None or vt2_speed_val is not None or vt2_time_val is not None:
|
||||
vt2_dict = {
|
||||
"HeartRate": vt2_hr_val
|
||||
if vt2_hr_val is not None
|
||||
else existing_vt2.get("HeartRate", 0),
|
||||
"Speed": vt2_speed_val
|
||||
if vt2_speed_val is not None
|
||||
else existing_vt2.get("Speed", 0),
|
||||
"Time": vt2_time_val
|
||||
if vt2_time_val is not None
|
||||
else existing_vt2.get("Time", 0),
|
||||
}
|
||||
metric_overrides["pnoe"]["vt2"] = vt2_dict
|
||||
|
||||
# Heart rate zones
|
||||
# Heart rate zones - only add if value is provided
|
||||
for i in range(1, 6):
|
||||
zone_key = f"zone{i}_bpm"
|
||||
if form_data.get(zone_key):
|
||||
metric_overrides["pnoe"][zone_key] = form_data[zone_key]
|
||||
zone_val = form_data.get(zone_key)
|
||||
if zone_val and zone_val.strip():
|
||||
metric_overrides["pnoe"][zone_key] = zone_val.strip()
|
||||
|
||||
# Spirometry overrides
|
||||
if form_data.get("fvc_best"):
|
||||
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"])
|
||||
if form_data.get("fvc_pred"):
|
||||
metric_overrides["spirometry"]["fvc_pred"] = float(form_data["fvc_pred"])
|
||||
if form_data.get("fev1_best"):
|
||||
metric_overrides["spirometry"]["fev1_best"] = float(form_data["fev1_best"])
|
||||
if form_data.get("fev1_pred"):
|
||||
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
|
||||
if form_data.get("fev1_fvc_pct_best"):
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = float(
|
||||
form_data["fev1_fvc_pct_best"]
|
||||
)
|
||||
if form_data.get("fev1_fvc_pct_pred"):
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float(
|
||||
form_data["fev1_fvc_pct_pred"]
|
||||
)
|
||||
# Spirometry overrides - only add if value is provided and valid
|
||||
fvc_best_val = safe_float(form_data.get("fvc_best"))
|
||||
if fvc_best_val is not None:
|
||||
metric_overrides["spirometry"]["fvc_best"] = fvc_best_val
|
||||
|
||||
fvc_pred_val = safe_float(form_data.get("fvc_pred"))
|
||||
if fvc_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fvc_pred"] = fvc_pred_val
|
||||
|
||||
fev1_best_val = safe_float(form_data.get("fev1_best"))
|
||||
if fev1_best_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_best"] = fev1_best_val
|
||||
|
||||
fev1_pred_val = safe_float(form_data.get("fev1_pred"))
|
||||
if fev1_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_pred"] = fev1_pred_val
|
||||
|
||||
fev1_fvc_pct_best_val = safe_float(form_data.get("fev1_fvc_pct_best"))
|
||||
if fev1_fvc_pct_best_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = fev1_fvc_pct_best_val
|
||||
|
||||
fev1_fvc_pct_pred_val = safe_float(form_data.get("fev1_fvc_pct_pred"))
|
||||
if fev1_fvc_pct_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = fev1_fvc_pct_pred_val
|
||||
|
||||
try:
|
||||
# Get file paths from session
|
||||
@@ -468,6 +538,7 @@ async def edit_metrics(request: Request):
|
||||
"fat_percentage": patient_info.get("fat_percentage", 0),
|
||||
"gender": patient_info.get("gender", "female"),
|
||||
}
|
||||
# Calculate fat_mass and lean_mass (extract_patient_info does this when no SECA file)
|
||||
context_gen.extract_patient_info(patient_info.get("last_name", ""))
|
||||
|
||||
spirometry_overrides = metric_overrides.get("spirometry", {})
|
||||
@@ -514,7 +585,6 @@ async def generate_report(
|
||||
height: str = Form(..., description="Patient height (e.g., 5'4\")"),
|
||||
weight: str = Form(..., description="Patient weight (e.g., 123lbs)"),
|
||||
focus: str = Form(default="Endurance", description="Training focus"),
|
||||
session_id: str = Form(default="default", description="Session ID"),
|
||||
spirometry_pdf: UploadFile = File(..., description="Spirometry PDF file"),
|
||||
pnoe_csv: UploadFile = File(..., description="Pnoe CSV file"),
|
||||
seca_excel: UploadFile = File(..., description="SECA Excel file"),
|
||||
@@ -534,7 +604,6 @@ async def generate_report(
|
||||
height: Patient height
|
||||
weight: Patient weight
|
||||
focus: Training focus (default: Endurance)
|
||||
session_id: Session identifier (default: default)
|
||||
|
||||
Returns:
|
||||
ReportResponse with report path, graphs generated, and analysis data
|
||||
@@ -571,6 +640,9 @@ async def generate_report(
|
||||
with open(seca_path, "wb") as f:
|
||||
shutil.copyfileobj(seca_excel.file, f)
|
||||
|
||||
# Generate session_id internally using timestamp for unique identification
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Prepare patient information
|
||||
patient_info = {
|
||||
"patient_name": patient_name,
|
||||
|
||||
@@ -471,3 +471,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -86,3 +86,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<!-- Macro Body (fills to the bottom, cyan or white) -->
|
||||
<div class="flex flex-col items-center py-1 px-2">
|
||||
<div class="font-bold text-sm text-black mb-1">
|
||||
{{ deficit_calories | default('1725KCals') }}
|
||||
{{ deficit_calories | default('1725KCals') }} KCals
|
||||
</div>
|
||||
<div class="text-xs text-black leading-tight text-left">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+162
-74
@@ -1,24 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Report Preview - Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% extends "base.html" %} {% block title %}Report Preview - Report Generator{%
|
||||
endblock %} {% block content %}
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
{% if not session.get('report_path') %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-yellow-800">No report found. Please <a href="/" class="underline">upload files</a> first.</p>
|
||||
<p class="text-yellow-800">
|
||||
No report found. Please
|
||||
<a href="/" class="underline">upload files</a> first.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Generated Report Preview</h2>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
Generated Report Preview
|
||||
</h2>
|
||||
<div class="flex space-x-3">
|
||||
<a href="/edit" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<a
|
||||
href="/edit"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Edit Metrics
|
||||
</a>
|
||||
<a href="/download-report/{{ session.report_path.split('/')[-1] }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700">
|
||||
<a
|
||||
href="/download-report/{{ session.report_path.split('/')[-1] }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
</div>
|
||||
@@ -26,23 +34,33 @@
|
||||
|
||||
<!-- Patient Information -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Patient Information</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Patient Information
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Name</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['patient_name'] }}</p>
|
||||
<p class="text-base font-medium text-gray-900">
|
||||
{{ session.patient_info['patient_name'] }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Age</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['age'] }}</p>
|
||||
<p class="text-base font-medium text-gray-900">
|
||||
{{ session.patient_info['age'] }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Height</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['height'] }}</p>
|
||||
<p class="text-base font-medium text-gray-900">
|
||||
{{ session.patient_info['height'] }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Weight</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['weight'] }}</p>
|
||||
<p class="text-base font-medium text-gray-900">
|
||||
{{ session.patient_info['weight'] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,56 +70,113 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Pnoe Metrics -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Pnoe Metrics</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Pnoe Metrics
|
||||
</h3>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{% if session.metrics.pnoe.get('vo2_max') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">VO2 Max</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max']) }} ml/min</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.pnoe['vo2_max'])
|
||||
}} ml/min
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('vo2_max_per_kg') %}
|
||||
{% endif %} {% if
|
||||
session.metrics.pnoe.get('vo2_max_per_kg') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">VO2 Max per kg</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max_per_kg']) }} ml/min/kg</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.pnoe['vo2_max_per_kg'])
|
||||
}} ml/min/kg
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('peak_vt') %}
|
||||
{% endif %} {% if session.metrics.pnoe.get('peak_vt') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">Peak VT</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['peak_vt']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['peak_vt_hr']) }} bpm</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.pnoe['peak_vt'])
|
||||
}} L
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
HR: {{
|
||||
"%.0f"|format(session.metrics.pnoe['peak_vt_hr'])
|
||||
}} bpm
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('fat_max_value') %}
|
||||
{% endif %} {% if
|
||||
session.metrics.pnoe.get('fat_max_value') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">Fat Max Value</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['fat_max_value']) }} kcal/min</p>
|
||||
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['fat_max_hr']) }} bpm</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.pnoe['fat_max_value'])
|
||||
}} kcal/min
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
HR: {{
|
||||
"%.0f"|format(session.metrics.pnoe['fat_max_hr'])
|
||||
}} bpm
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VT1 and VT2 -->
|
||||
{% if session.metrics.pnoe.get('vt1') or session.metrics.pnoe.get('vt2') %}
|
||||
{% if session.metrics.pnoe.get('vt1') or
|
||||
session.metrics.pnoe.get('vt2') %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Ventilatory Thresholds</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Ventilatory Thresholds
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{% if session.metrics.pnoe.get('vt1') %}
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-blue-900 mb-2">VT1</p>
|
||||
<p class="text-sm text-blue-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt1']['HeartRate']) }} bpm</p>
|
||||
<p class="text-sm text-blue-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt1']['Speed']) }} mph</p>
|
||||
<p class="text-sm text-blue-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt1']['Time']) }} sec</p>
|
||||
<p class="text-sm font-medium text-blue-900 mb-2">
|
||||
VT1
|
||||
</p>
|
||||
<p class="text-sm text-blue-700">
|
||||
Heart Rate: {{
|
||||
"%.0f"|format(session.metrics.pnoe['vt1']['HeartRate'])
|
||||
}} bpm
|
||||
</p>
|
||||
<p class="text-sm text-blue-700">
|
||||
Speed: {{
|
||||
"%.2f"|format(session.metrics.pnoe['vt1']['Speed'])
|
||||
}} mph
|
||||
</p>
|
||||
<p class="text-sm text-blue-700">
|
||||
Time: {{
|
||||
"%.0f"|format(session.metrics.pnoe['vt1']['Time'])
|
||||
}} sec
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('vt2') %}
|
||||
{% endif %} {% if session.metrics.pnoe.get('vt2') %}
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-green-900 mb-2">VT2</p>
|
||||
<p class="text-sm text-green-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt2']['HeartRate']) }} bpm</p>
|
||||
<p class="text-sm text-green-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt2']['Speed']) }} mph</p>
|
||||
<p class="text-sm text-green-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt2']['Time']) }} sec</p>
|
||||
<p class="text-sm font-medium text-green-900 mb-2">
|
||||
VT2
|
||||
</p>
|
||||
<p class="text-sm text-green-700">
|
||||
Heart Rate: {{
|
||||
"%.0f"|format(session.metrics.pnoe['vt2']['HeartRate'])
|
||||
}} bpm
|
||||
</p>
|
||||
<p class="text-sm text-green-700">
|
||||
Speed: {{
|
||||
"%.2f"|format(session.metrics.pnoe['vt2']['Speed'])
|
||||
}} mph
|
||||
</p>
|
||||
<p class="text-sm text-green-700">
|
||||
Time: {{
|
||||
"%.0f"|format(session.metrics.pnoe['vt2']['Time'])
|
||||
}} sec
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -111,17 +186,20 @@
|
||||
<!-- Heart Rate Zones -->
|
||||
{% if session.metrics.pnoe.get('zone1_bpm') %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Heart Rate Zones</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Heart Rate Zones
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-5">
|
||||
{% for i in range(1, 6) %}
|
||||
{% set zone_key = "zone" + i|string + "_bpm" %}
|
||||
{% if session.metrics.pnoe.get(zone_key) %}
|
||||
{% for i in range(1, 6) %} {% set zone_key = "zone" +
|
||||
i|string + "_bpm" %} {% if
|
||||
session.metrics.pnoe.get(zone_key) %}
|
||||
<div class="bg-gray-50 p-3 rounded-lg text-center">
|
||||
<p class="text-xs text-gray-500">Zone {{ i }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ session.metrics.pnoe[zone_key] }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ session.metrics.pnoe[zone_key] }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %} {% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -129,27 +207,53 @@
|
||||
<!-- Spirometry Metrics -->
|
||||
{% if session.metrics.spirometry %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Spirometry Metrics</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Spirometry Metrics
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{% if session.metrics.spirometry.get('fvc_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FVC Best</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fvc_best']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fvc_pred']) }}% predicted</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.spirometry['fvc_best'])
|
||||
}} L
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{
|
||||
"%.1f"|format(session.metrics.spirometry['fvc_pred'])
|
||||
}}% predicted
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.spirometry.get('fev1_best') %}
|
||||
{% endif %} {% if
|
||||
session.metrics.spirometry.get('fev1_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FEV1 Best</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_best']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_pred']) }}% predicted</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.spirometry['fev1_best'])
|
||||
}} L
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{
|
||||
"%.1f"|format(session.metrics.spirometry['fev1_pred'])
|
||||
}}% predicted
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.spirometry.get('fev1_fvc_pct_best') %}
|
||||
{% endif %} {% if
|
||||
session.metrics.spirometry.get('fev1_fvc_pct_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FEV1/FVC%</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_fvc_pct_best']) }}%</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_fvc_pct_pred']) }}% predicted</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{
|
||||
"%.2f"|format(session.metrics.spirometry['fev1_fvc_pct_best'])
|
||||
}}%
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{
|
||||
"%.1f"|format(session.metrics.spirometry['fev1_fvc_pct_pred'])
|
||||
}}% predicted
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -157,24 +261,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Graphs Section -->
|
||||
{% if session.graphs_generated %}
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Generated Graphs</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{% for graph in session.graphs_generated %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">{{ graph.name|replace('_', ' ')|title }}</p>
|
||||
<img src="/graphs/{{ graph.path.split('/')[-1] }}" alt="{{ graph.name }}" class="w-full h-auto rounded">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -132,20 +132,6 @@ Generator{% endblock %} {% block content %}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="session_id"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Session ID</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="session_id"
|
||||
id="session_id"
|
||||
value="default"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,249 @@
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def mifflin_st_jeor(weight_kg, height_cm, age_years, sex):
|
||||
"""
|
||||
Compute predicted RMR with Mifflin St Jeor.
|
||||
sex: 'male' or 'female'
|
||||
"""
|
||||
base = 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years
|
||||
if sex.lower().startswith("m"):
|
||||
return base + 5.0
|
||||
else:
|
||||
return base - 161.0
|
||||
|
||||
|
||||
def classify_metabolism(measured_kcal_day, predicted_kcal_day):
|
||||
"""
|
||||
Classify metabolic rate relative to prediction.
|
||||
Returns (label, ratio).
|
||||
"""
|
||||
ratio = measured_kcal_day / predicted_kcal_day
|
||||
|
||||
if ratio < 0.70:
|
||||
label = "very slow"
|
||||
elif ratio < 0.90:
|
||||
label = "slow"
|
||||
elif ratio <= 1.10:
|
||||
label = "average"
|
||||
elif ratio <= 1.30:
|
||||
label = "fast"
|
||||
else:
|
||||
label = "very fast"
|
||||
|
||||
return label, ratio
|
||||
|
||||
|
||||
def find_sampling_window(df):
|
||||
"""
|
||||
Derive number of samples that represent about 2 minutes.
|
||||
"""
|
||||
dt = df["T(sec)"].diff().median()
|
||||
if dt is None or dt <= 0:
|
||||
raise ValueError("Invalid time step in T(sec)")
|
||||
|
||||
samples = int(round(120.0 / dt))
|
||||
if samples < 1:
|
||||
samples = 1
|
||||
return samples
|
||||
|
||||
|
||||
def rolling_stable_window(df, window_samples):
|
||||
"""
|
||||
Find the most stable 2-minute window using rolling standard deviation.
|
||||
Returns:
|
||||
means_series, t_start, t_end
|
||||
"""
|
||||
cols_mean = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"RER",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
cols_std = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
]
|
||||
|
||||
roll_mean = df[cols_mean].rolling(window_samples, min_periods=window_samples).mean()
|
||||
roll_std = df[cols_std].rolling(window_samples, min_periods=window_samples).std()
|
||||
|
||||
# Sum std devs to get stability score; use skipna=False to preserve NaN for incomplete windows
|
||||
stability_score = roll_std.sum(axis=1, skipna=False)
|
||||
|
||||
# Find index with lowest stability score (dropna to ignore incomplete windows)
|
||||
best_idx = stability_score.dropna().idxmin()
|
||||
|
||||
means_series = roll_mean.loc[best_idx].copy()
|
||||
|
||||
start_idx = max(best_idx - window_samples + 1, 0)
|
||||
end_idx = best_idx
|
||||
|
||||
t_start = float(df["T(sec)"].iloc[start_idx])
|
||||
t_end = float(df["T(sec)"].iloc[end_idx])
|
||||
|
||||
return means_series, t_start, t_end
|
||||
|
||||
|
||||
def manual_window_means(df, t_start, t_end):
|
||||
"""
|
||||
Compute mean values inside a user-selected time window.
|
||||
"""
|
||||
mask = (df["T(sec)"] >= t_start) & (df["T(sec)"] <= t_end)
|
||||
slice_df = df.loc[mask].copy()
|
||||
|
||||
if slice_df.empty:
|
||||
raise ValueError("Manual window has no rows inside T(sec) range")
|
||||
|
||||
cols = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"RER",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
means = slice_df[cols].mean()
|
||||
return means, float(t_start), float(t_end)
|
||||
|
||||
|
||||
def load_pnoe_csv(path):
|
||||
"""
|
||||
Load and clean a PNOE CSV file.
|
||||
"""
|
||||
df = pd.read_csv(path, sep=";")
|
||||
|
||||
numeric_cols = [
|
||||
"T(sec)",
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"RER",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
for col in numeric_cols:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df = df.dropna(subset=["VO2(ml/min)", "EE(kcal/min)"]).reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def analyze_pnoe_rmr(
|
||||
path,
|
||||
weight_kg,
|
||||
height_cm,
|
||||
age_years,
|
||||
sex,
|
||||
subject_name=None,
|
||||
test_date=None,
|
||||
manual_window=None,
|
||||
):
|
||||
"""
|
||||
Analyze resting RMR from a PNOE CSV file.
|
||||
|
||||
manual_window:
|
||||
None for automatic stable window
|
||||
or (t_start_sec, t_end_sec) for user-chosen window
|
||||
"""
|
||||
df = load_pnoe_csv(path)
|
||||
window_samples = find_sampling_window(df)
|
||||
|
||||
# Automatic stable window
|
||||
auto_means, auto_t_start, auto_t_end = rolling_stable_window(df, window_samples)
|
||||
|
||||
# Manual override if provided
|
||||
manual_means = None
|
||||
manual_t_start = None
|
||||
manual_t_end = None
|
||||
|
||||
if manual_window is not None:
|
||||
t_start_manual, t_end_manual = manual_window
|
||||
manual_means, manual_t_start, manual_t_end = manual_window_means(
|
||||
df, t_start_manual, t_end_manual
|
||||
)
|
||||
chosen_source = "manual"
|
||||
chosen_means = manual_means
|
||||
chosen_t_start = manual_t_start
|
||||
chosen_t_end = manual_t_end
|
||||
else:
|
||||
chosen_source = "auto"
|
||||
chosen_means = auto_means
|
||||
chosen_t_start = auto_t_start
|
||||
chosen_t_end = auto_t_end
|
||||
|
||||
kcal_per_min = float(chosen_means["EE(kcal/min)"])
|
||||
rmr_kcal_day = kcal_per_min * 1440.0
|
||||
|
||||
predicted_kcal_day = mifflin_st_jeor(weight_kg, height_cm, age_years, sex)
|
||||
label, ratio = classify_metabolism(rmr_kcal_day, predicted_kcal_day)
|
||||
|
||||
def pack_metrics(prefix, means, t_start, t_end):
|
||||
if means is None:
|
||||
return {}
|
||||
return {
|
||||
f"{prefix}_window_start_sec": t_start,
|
||||
f"{prefix}_window_end_sec": t_end,
|
||||
f"{prefix}_VO2_L_min": float(means["VO2(ml/min)"]) / 1000.0,
|
||||
f"{prefix}_VCO2_L_min": float(means["VCO2(ml/min)"]) / 1000.0,
|
||||
f"{prefix}_VE_L_min": float(means["VE(l/min)"]),
|
||||
f"{prefix}_VT_L": float(means["VT(l)"]),
|
||||
f"{prefix}_BF_bpm": float(means["BF(bpm)"]),
|
||||
f"{prefix}_RER": float(means["RER"]),
|
||||
f"{prefix}_Fat_percent": float(means["FAT(%)"]),
|
||||
f"{prefix}_Carb_percent": float(means["CARBS(%)"]),
|
||||
f"{prefix}_kcal_per_min": float(means["EE(kcal/min)"]),
|
||||
}
|
||||
|
||||
result = {
|
||||
"subject_name": subject_name,
|
||||
"test_date": test_date,
|
||||
"sex": sex,
|
||||
"weight_kg": weight_kg,
|
||||
"height_cm": height_cm,
|
||||
"age_years": age_years,
|
||||
"chosen_window_source": chosen_source,
|
||||
"chosen_window_start_sec": chosen_t_start,
|
||||
"chosen_window_end_sec": chosen_t_end,
|
||||
"RMR_kcal_day": rmr_kcal_day,
|
||||
"Mifflin_kcal_day": predicted_kcal_day,
|
||||
"Measured_to_Mifflin_ratio": ratio,
|
||||
"Metabolic_classification": label,
|
||||
}
|
||||
|
||||
result.update(pack_metrics("auto", auto_means, auto_t_start, auto_t_end))
|
||||
result.update(pack_metrics("manual", manual_means, manual_t_start, manual_t_end))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
result = analyze_pnoe_rmr(
|
||||
path="/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv",
|
||||
weight_kg=56,
|
||||
height_cm=162,
|
||||
age_years=34,
|
||||
sex="female",
|
||||
subject_name="Cullen Pacas",
|
||||
test_date="2025-11-12",
|
||||
manual_window=None, # or (t_start_sec, t_end_sec)
|
||||
)
|
||||
|
||||
for key, value in result.items():
|
||||
print(f"{key}: {value}")
|
||||
+185
-168
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user