This commit is contained in:
bolade
2025-11-21 12:15:42 +01:00
parent 47f0c6f3fb
commit 29ad9e2265
6 changed files with 487 additions and 99 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
<img <img
src="data:image/png;base64, {{ vo2_max_table }}" src="data:image/png;base64, {{ vo2_max_table }}"
alt="VO2 Max Table" alt="VO2 Max Table"
class="w-full max-w-4xl h-auto object-contain" class="w-full max-w-4xl h-auto"
/> />
</div> </div>
</div> </div>
@@ -43,7 +43,7 @@
<img <img
src="data:image/png;base64, {{ hr_zones_table }}" src="data:image/png;base64, {{ hr_zones_table }}"
alt="Heart Rate Zones Table" alt="Heart Rate Zones Table"
class="w-full max-w-4xl h-auto object-contain" class="w-full max-w-4xl h-auto"
/> />
</div> </div>
</div> </div>
+448 -60
View File
@@ -552,6 +552,407 @@ class ContextGenerator:
"hr_athlete": hr_ranges["athlete"], "hr_athlete": hr_ranges["athlete"],
} }
def _calculate_rhr_table_data(self, age: int, gender: str) -> dict:
"""
Calculate Resting Heart Rate reference table data.
Args:
age: Patient age
gender: Patient gender
Returns:
Dictionary containing age_range and ranges
"""
# Determine age range
if 18 <= age <= 25:
age_range = "18-25"
elif 26 <= age <= 35:
age_range = "26-35"
elif 36 <= age <= 45:
age_range = "36-45"
elif 46 <= age <= 55:
age_range = "46-55"
elif 56 <= age <= 65:
age_range = "56-65"
elif age > 65:
age_range = "65+"
else:
age_range = "18-25" # default for under 18
# RHR Master Chart
rhr_chart = {
"male": {
"18-25": {
"Poor": (85, None),
"Below Average": (79, 85),
"Average": (74, 79),
"Above Average": (70, 74),
"Good": (66, 70),
"Excellent": (61, 66),
"Athlete": (40, 61),
},
"26-35": {
"Poor": (83, None),
"Below Average": (77, 83),
"Average": (73, 77),
"Above Average": (69, 73),
"Good": (65, 69),
"Excellent": (60, 65),
"Athlete": (42, 60),
},
"36-45": {
"Poor": (85, None),
"Below Average": (79, 85),
"Average": (74, 79),
"Above Average": (70, 74),
"Good": (65, 70),
"Excellent": (60, 65),
"Athlete": (45, 60),
},
"46-55": {
"Poor": (84, None),
"Below Average": (78, 84),
"Average": (74, 78),
"Above Average": (70, 74),
"Good": (66, 70),
"Excellent": (61, 66),
"Athlete": (48, 61),
},
"56-65": {
"Poor": (84, None),
"Below Average": (78, 84),
"Average": (74, 78),
"Above Average": (70, 74),
"Good": (65, 70),
"Excellent": (60, 65),
"Athlete": (50, 60),
},
"65+": {
"Poor": (84, None),
"Below Average": (77, 84),
"Average": (73, 77),
"Above Average": (70, 73),
"Good": (65, 70),
"Excellent": (60, 65),
"Athlete": (52, 60),
},
},
"female": {
"18-25": {
"Poor": (82, None),
"Below Average": (74, 82),
"Average": (70, 74),
"Above Average": (66, 70),
"Good": (62, 66),
"Excellent": (56, 62),
"Athlete": (40, 56),
},
"26-35": {
"Poor": (82, None),
"Below Average": (75, 82),
"Average": (71, 75),
"Above Average": (66, 71),
"Good": (62, 66),
"Excellent": (55, 62),
"Athlete": (44, 55),
},
"36-45": {
"Poor": (83, None),
"Below Average": (76, 83),
"Average": (71, 76),
"Above Average": (67, 71),
"Good": (63, 67),
"Excellent": (57, 63),
"Athlete": (47, 57),
},
"46-55": {
"Poor": (84, None),
"Below Average": (77, 84),
"Average": (72, 77),
"Above Average": (68, 72),
"Good": (64, 68),
"Excellent": (58, 64),
"Athlete": (49, 58),
},
"56-65": {
"Poor": (82, None),
"Below Average": (76, 82),
"Average": (72, 76),
"Above Average": (68, 72),
"Good": (62, 68),
"Excellent": (57, 62),
"Athlete": (51, 57),
},
"65+": {
"Poor": (80, None),
"Below Average": (74, 80),
"Average": (70, 74),
"Above Average": (66, 70),
"Good": (62, 66),
"Excellent": (56, 62),
"Athlete": (52, 56),
},
},
}
gender_key = "male" if gender.lower().startswith("m") else "female"
ranges = rhr_chart[gender_key][age_range]
# Format ranges
formatted_ranges = {}
for category, (min_val, max_val) in ranges.items():
if max_val is None:
formatted_ranges[category] = f"{min_val}bpm +"
else:
formatted_ranges[category] = f"{min_val}-{max_val}bpm"
return {
"age_range": f"{age_range} ({gender[0].upper()})",
"ranges": formatted_ranges,
}
def _calculate_zone_metrics(self, pnoe_metrics: Dict) -> Dict:
"""Calculate detailed metrics for each heart rate zone based on actual data"""
import math
# Get zone boundaries
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
optimal_row = self.pnoe_df.loc[fat_max_idx]
# Detect VT1 and VT2
vt1 = pnoe_metrics.get("vt1")
vt2 = pnoe_metrics.get("vt2")
if not vt1 or not vt2:
# Return default values if thresholds not detected
return {}
# Define zone boundaries (from notebook logic)
zone_1_start = math.floor(optimal_row["HR(bpm)_smoothed"] - 15)
zone_2_start = math.floor(optimal_row["HR(bpm)_smoothed"])
zone_3_start = math.floor(vt1["HeartRate"])
zone_4_start = math.floor(vt2["HeartRate"] - 10)
zone_5_start = math.floor(vt2["HeartRate"])
zone_1_end = zone_2_start
zone_2_end = math.floor(vt1["HeartRate"])
zone_3_end = zone_4_start
zone_4_end = zone_5_start
zone_5_end = math.floor(vt2["HeartRate"] + 10)
zones_list = [
("Zone 1", zone_1_start, zone_1_end),
("Zone 2", zone_2_start, zone_2_end),
("Zone 3", zone_3_start, zone_3_end),
("Zone 4", zone_4_start, zone_4_end),
("Zone 5", zone_5_start, zone_5_end),
]
ideal_breath_ranges = [
"Ideal Range: 15-20 breaths",
"Ideal Range: 20-25 breaths",
"Ideal Range: 25-30 breaths",
"Ideal Range: 30-35 breaths",
"Ideal Range: 40+ breaths",
]
def speed_to_pace(s_mph):
"""Convert speed in mph to pace in min/km"""
if s_mph <= 0:
return 0, 0
s_kmh = s_mph * 1.60934
p_min = 60 / s_kmh
p_m = int(p_min)
p_s = int((p_min % 1) * 60)
return p_m, p_s
zone_data = []
for i, (name, start, end) in enumerate(zones_list):
# Filter dataframe for the current zone
mask = (self.pnoe_df["HR(bpm)_smoothed"] >= start) & (
self.pnoe_df["HR(bpm)_smoothed"] <= end
)
zone_df = self.pnoe_df[mask]
if not zone_df.empty:
# Speed calculation
speed_series = zone_df[zone_df["Speed"] > 0.1]["Speed"]
if not speed_series.empty:
min_speed = speed_series.min()
max_speed = speed_series.max()
if abs(min_speed - max_speed) < 0.1:
speed_str = f"{min_speed:.1f}mph\n2% Incline"
else:
speed_str = f"{min_speed:.1f}-{max_speed:.1f}mph\n2% Incline"
# Pace calculation (max speed -> min pace, min speed -> max pace)
min_pace_m, min_pace_s = speed_to_pace(max_speed)
max_pace_m, max_pace_s = speed_to_pace(min_speed)
if min_pace_m == max_pace_m and min_pace_s == max_pace_s:
pace_str = f"{min_pace_m}:{min_pace_s:02d}min/km Pace"
else:
pace_str = (
f"{max_pace_m}:{max_pace_s:02d}-{min_pace_m}:{min_pace_s:02d}\n"
f"min/km Pace"
)
else:
speed_str = "-\n2% Incline"
pace_str = "-"
# Calories (using raw EE)
avg_cals = zone_df["EE(kcal/min)"].mean()
calories_str = f"Avg:\n{avg_cals:.1f}kcals/minute"
# Carb utilization (g/min)
avg_carbs_g = zone_df["CHO"].mean() / 4
carb_str = f"Avg: {avg_carbs_g:.1f}g/min\nCarb Utilization"
# Breathing
avg_breaths = zone_df["BF(bpm)_smoothed"].mean()
breath_str = (
f"Avg: {int(avg_breaths)} breaths\n{ideal_breath_ranges[i]}"
)
else:
speed_str = "-\n2% Incline"
pace_str = "-"
calories_str = "-"
carb_str = "-"
breath_str = f"-\n{ideal_breath_ranges[i]}"
zone_data.append(
{
"zone_name": name,
"hr_bpm": f"{int(start)}-{int(end)}bpm",
"speed": speed_str,
"pace": pace_str,
"calories": calories_str,
"carb": carb_str,
"breathing": breath_str,
}
)
return {"zones": zone_data}
def _calculate_vo2_max_table_data(self, age: int, gender: str) -> Dict:
"""Calculate VO2 Max table data based on age and gender"""
# VO2 Max Master Chart Data (from notebook)
vo2_max_data = {
"20-29 (M)": {
"Very Poor": (None, 38.1),
"Poor": (38.1, 44.1),
"Fair": (44.1, 51.0),
"Good": (51.0, 56.9),
"Excellent": (56.9, 66.3),
"Superior": (66.3, None),
},
"30-39 (M)": {
"Very Poor": (None, 34.1),
"Poor": (34.1, 39.5),
"Fair": (39.5, 45.3),
"Good": (45.3, 51.3),
"Excellent": (51.3, 59.8),
"Superior": (59.8, None),
},
"40-49 (M)": {
"Very Poor": (None, 30.5),
"Poor": (30.5, 35.4),
"Fair": (35.4, 40.9),
"Good": (40.9, 46.3),
"Excellent": (46.3, 55.6),
"Superior": (55.6, None),
},
"50-59 (M)": {
"Very Poor": (None, 26.1),
"Poor": (26.1, 30.9),
"Fair": (30.9, 35.7),
"Good": (35.7, 40.9),
"Excellent": (40.9, 50.7),
"Superior": (50.7, None),
},
"60+ (M)": {
"Very Poor": (None, 22.4),
"Poor": (22.4, 26.5),
"Fair": (26.5, 32.2),
"Good": (32.2, 36.3),
"Excellent": (36.3, 43.0),
"Superior": (43.0, None),
},
"20-29 (F)": {
"Very Poor": (None, 28.6),
"Poor": (28.6, 33.7),
"Fair": (33.7, 38.5),
"Good": (38.5, 43.8),
"Excellent": (43.8, 56.0),
"Superior": (56.0, None),
},
"30-39 (F)": {
"Very Poor": (None, 24.1),
"Poor": (24.1, 28.2),
"Fair": (28.2, 32.2),
"Good": (32.2, 35.7),
"Excellent": (35.7, 45.8),
"Superior": (45.8, None),
},
"40-49 (F)": {
"Very Poor": (None, 22.7),
"Poor": (22.7, 26.5),
"Fair": (26.5, 30.5),
"Good": (30.5, 35.0),
"Excellent": (35.0, 42.3),
"Superior": (42.3, None),
},
"50-59 (F)": {
"Very Poor": (None, 21.5),
"Poor": (21.5, 24.9),
"Fair": (24.9, 28.7),
"Good": (28.7, 32.9),
"Excellent": (32.9, 40.4),
"Superior": (40.4, None),
},
"60+ (F)": {
"Very Poor": (None, 19.0),
"Poor": (19.0, 22.7),
"Fair": (22.7, 26.1),
"Good": (26.1, 30.1),
"Excellent": (30.1, 36.7),
"Superior": (36.7, None),
},
}
# Determine age bracket
if age < 30:
age_key = "20-29"
elif age < 40:
age_key = "30-39"
elif age < 50:
age_key = "40-49"
elif age < 60:
age_key = "50-59"
else:
age_key = "60+"
gender_key = "(M)" if gender.lower() == "male" else "(F)"
key = f"{age_key} {gender_key}"
ranges = vo2_max_data.get(key, vo2_max_data["30-39 (F)"]) # Default
# Format the ranges for display
result = {}
for category, (min_val, max_val) in ranges.items():
if min_val is None:
result[category] = f"<{max_val:.1f}"
elif max_val is None:
result[category] = f"{min_val:.1f}+"
else:
result[category] = f"{min_val:.1f}-{max_val:.1f}"
return {
"age_range": age_key,
"ranges": result,
}
def calculate_rmr_and_fuel_source(self) -> Dict: def calculate_rmr_and_fuel_source(self) -> Dict:
"""Calculate RMR and fuel source from pnoe data""" """Calculate RMR and fuel source from pnoe data"""
metrics = {} metrics = {}
@@ -722,9 +1123,16 @@ class ContextGenerator:
} }
if graph_generator: if graph_generator:
# Calculate VO2 Max table data
vo2_max_table_info = self._calculate_vo2_max_table_data(
self.patient_info["age"], self.patient_info["gender"]
)
# VO2 Max Table # VO2 Max Table
vo2_max_columns = [ vo2_max_columns = [
"Age (F)", "Age (F)"
if self.patient_info["gender"].lower() == "female"
else "Age (M)",
"Very Poor", "Very Poor",
"Poor", "Poor",
"Fair", "Fair",
@@ -734,13 +1142,13 @@ class ContextGenerator:
] ]
vo2_max_data = [ vo2_max_data = [
[ [
contexts["page_8"]["age_range"], vo2_max_table_info["age_range"],
"19.0-24.1", vo2_max_table_info["ranges"]["Very Poor"],
"24.1-28.2", vo2_max_table_info["ranges"]["Poor"],
"28.2-32.2", vo2_max_table_info["ranges"]["Fair"],
"32.2-35.7", vo2_max_table_info["ranges"]["Good"],
"35.7-45.8", vo2_max_table_info["ranges"]["Excellent"],
"45.8+", vo2_max_table_info["ranges"]["Superior"],
] ]
] ]
vo2_max_colors = [ vo2_max_colors = [
@@ -763,6 +1171,10 @@ class ContextGenerator:
save_as_base64=True, save_as_base64=True,
) )
# Calculate zone metrics for the table
zone_metrics = self._calculate_zone_metrics(pnoe_metrics)
if zone_metrics.get("zones"):
# Heart Rate Zones Table # Heart Rate Zones Table
hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"] hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"]
hr_zones_data = [ hr_zones_data = [
@@ -780,48 +1192,12 @@ class ContextGenerator:
"85-88% of Max Heart Rate", "85-88% of Max Heart Rate",
"90% of Max Heart Rate", "90% of Max Heart Rate",
], ],
[ [zone_metrics["zones"][i]["hr_bpm"] for i in range(5)],
pnoe_metrics.get("zone1_bpm", "81-96bpm"), [zone_metrics["zones"][i]["speed"] for i in range(5)],
pnoe_metrics.get("zone2_bpm", "96-100bpm"), [zone_metrics["zones"][i]["pace"] for i in range(5)],
pnoe_metrics.get("zone3_bpm", "100-178bpm"), [zone_metrics["zones"][i]["calories"] for i in range(5)],
pnoe_metrics.get("zone4_bpm", "178-188bpm"), [zone_metrics["zones"][i]["carb"] for i in range(5)],
pnoe_metrics.get("zone5_bpm", "188-198bpm"), [zone_metrics["zones"][i]["breathing"] for i in range(5)],
],
[
"3.5mph\n2% Incline",
"3.5-4.0mph\n2% Incline",
"4.0-6.5mph\n2% Incline",
"6.5-7.0mph\n2% Incline",
"7.0-8.0mph\n2% Incline",
],
[
"10:39min/km Pace",
"10:39-9:19min/km Pace",
"9:19-5:44min/km Pace",
"5:44-5:20min/km Pace",
"5:20-4:40min/km Pace",
],
[
"Avg:\n4.4kcals/minute",
"Avg:\n5.9kcals/minute",
"Avg:\n9.4kcals/minute",
"Avg:\n12.5kcals/minute",
"Avg:\n12.8kcals/minute",
],
[
"Avg: 0.4g/min\nCarb Utilization",
"Avg: 0.6g/min\nCarb Utilization",
"Avg: 1.9g/min\nCarb Utilization",
"Avg: 2.9g/min\nCarb Utilization",
"Avg: 3.1g/min\nCarb Utilization",
],
[
"Avg: 27 breaths\nIdeal: 15-20",
"Avg: 28 breaths\nIdeal: 20-25",
"Avg: 31 breaths\nIdeal: 25-30",
"Avg: 42 breaths\nIdeal: 30-35",
"Avg: 51 breaths\nIdeal: 40+",
],
] ]
hr_zones_colors = [ hr_zones_colors = [
["#ffffff"] * 5, ["#ffffff"] * 5,
@@ -834,13 +1210,15 @@ class ContextGenerator:
["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"], ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"],
] ]
contexts["page_8"]["hr_zones_table"] = graph_generator.generate_table_image( contexts["page_8"]["hr_zones_table"] = (
graph_generator.generate_table_image(
data=hr_zones_data, data=hr_zones_data,
columns=hr_zones_columns, columns=hr_zones_columns,
cell_colors=hr_zones_colors, cell_colors=hr_zones_colors,
header_color="#4dd0e1", header_color="#4dd0e1",
save_as_base64=True, save_as_base64=True,
) )
)
# Page 9 # Page 9
contexts["page_9"] = { contexts["page_9"] = {
@@ -876,8 +1254,18 @@ class ContextGenerator:
if graph_generator: if graph_generator:
# Page 11 Resting Heart Rate Table # Page 11 Resting Heart Rate Table
rhr_table_info = self._calculate_rhr_table_data(
self.patient_info["age"], self.patient_info["gender"]
)
gender_label = (
"Age (F)"
if self.patient_info["gender"].lower().startswith("f")
else "Age (M)"
)
rhr_columns = [ rhr_columns = [
"Age (F)", gender_label,
"Poor", "Poor",
"Below Average", "Below Average",
"Average", "Average",
@@ -888,14 +1276,14 @@ class ContextGenerator:
] ]
rhr_data = [ rhr_data = [
[ [
contexts["page_11"]["hr_age_range"], rhr_table_info["age_range"],
contexts["page_11"]["hr_poor"], rhr_table_info["ranges"]["Poor"],
contexts["page_11"]["hr_below_avg"], rhr_table_info["ranges"]["Below Average"],
contexts["page_11"]["hr_average"], rhr_table_info["ranges"]["Average"],
contexts["page_11"]["hr_above_avg"], rhr_table_info["ranges"]["Above Average"],
contexts["page_11"]["hr_good"], rhr_table_info["ranges"]["Good"],
contexts["page_11"]["hr_excellent"], rhr_table_info["ranges"]["Excellent"],
contexts["page_11"]["hr_athlete"], rhr_table_info["ranges"]["Athlete"],
] ]
] ]
rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7] rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7]
+3 -3
View File
@@ -14,7 +14,7 @@ Expected values from PDF (Page 5):
import sys import sys
import pandas as pd import pandas as pd
sys.path.insert(0, '/Users/macbook/bio-performx') sys.path.insert(0, '/home/oluwasanmi/Documents/Work/MKD/report_generation')
from app.services.context_generator import ContextGenerator from app.services.context_generator import ContextGenerator
@@ -41,8 +41,8 @@ PATIENT_DATA = {
USE_PDF_RMR = True # Set to True to use PDF's measured RMR instead of calculating from CSV USE_PDF_RMR = True # Set to True to use PDF's measured RMR instead of calculating from CSV
# File paths # File paths
PNOE_FILE = "Pnoe_20250729_1550-Moran_Keirstyn (2).csv" PNOE_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv"
SPIROMETRY_FILE = "data/extracted_spirometry_table.csv" SPIROMETRY_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/spirometry_data.csv"
def main(): def main():
print("=" * 80) print("=" * 80)