diff --git a/app/report_gen/page_8.html b/app/report_gen/page_8.html
index 8e17b38..52cb11f 100644
--- a/app/report_gen/page_8.html
+++ b/app/report_gen/page_8.html
@@ -27,7 +27,7 @@
@@ -43,7 +43,7 @@
diff --git a/app/services/__pycache__/context_generator.cpython-312.pyc b/app/services/__pycache__/context_generator.cpython-312.pyc
index a4f5275..b291d4c 100644
Binary files a/app/services/__pycache__/context_generator.cpython-312.pyc and b/app/services/__pycache__/context_generator.cpython-312.pyc differ
diff --git a/app/services/__pycache__/graph_generator.cpython-312.pyc b/app/services/__pycache__/graph_generator.cpython-312.pyc
index c3d5418..6caa01b 100644
Binary files a/app/services/__pycache__/graph_generator.cpython-312.pyc and b/app/services/__pycache__/graph_generator.cpython-312.pyc differ
diff --git a/app/services/__pycache__/report_generator.cpython-312.pyc b/app/services/__pycache__/report_generator.cpython-312.pyc
index 9a5e4a9..602f562 100644
Binary files a/app/services/__pycache__/report_generator.cpython-312.pyc and b/app/services/__pycache__/report_generator.cpython-312.pyc differ
diff --git a/app/services/context_generator.py b/app/services/context_generator.py
index 090f282..fd9a1f7 100644
--- a/app/services/context_generator.py
+++ b/app/services/context_generator.py
@@ -552,6 +552,407 @@ class ContextGenerator:
"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:
"""Calculate RMR and fuel source from pnoe data"""
metrics = {}
@@ -722,9 +1123,16 @@ class ContextGenerator:
}
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_columns = [
- "Age (F)",
+ "Age (F)"
+ if self.patient_info["gender"].lower() == "female"
+ else "Age (M)",
"Very Poor",
"Poor",
"Fair",
@@ -734,13 +1142,13 @@ class ContextGenerator:
]
vo2_max_data = [
[
- contexts["page_8"]["age_range"],
- "19.0-24.1",
- "24.1-28.2",
- "28.2-32.2",
- "32.2-35.7",
- "35.7-45.8",
- "45.8+",
+ vo2_max_table_info["age_range"],
+ vo2_max_table_info["ranges"]["Very Poor"],
+ vo2_max_table_info["ranges"]["Poor"],
+ vo2_max_table_info["ranges"]["Fair"],
+ vo2_max_table_info["ranges"]["Good"],
+ vo2_max_table_info["ranges"]["Excellent"],
+ vo2_max_table_info["ranges"]["Superior"],
]
]
vo2_max_colors = [
@@ -763,84 +1171,54 @@ class ContextGenerator:
save_as_base64=True,
)
- # Heart Rate Zones Table
- hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"]
- hr_zones_data = [
- [
- "Improves health and recovery capacity",
- "Improves endurance and fat burning",
- "Improves Aerobic fitness",
- "Improves maximum performance capacity",
- "Develops maximum performance and speed",
- ],
- [
- "55-65% of Max Heart Rate",
- "65-75% of Max Heart Rate",
- "80-85% of Max Heart Rate",
- "85-88% of Max Heart Rate",
- "90% of Max Heart Rate",
- ],
- [
- pnoe_metrics.get("zone1_bpm", "81-96bpm"),
- pnoe_metrics.get("zone2_bpm", "96-100bpm"),
- pnoe_metrics.get("zone3_bpm", "100-178bpm"),
- pnoe_metrics.get("zone4_bpm", "178-188bpm"),
- pnoe_metrics.get("zone5_bpm", "188-198bpm"),
- ],
- [
- "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 = [
- ["#ffffff"] * 5,
- ["#ffffff"] * 5,
- ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"],
- ["#ffffff"] * 5,
- ["#ffffff"] * 5,
- ["#ffffff"] * 5,
- ["#ffffff"] * 5,
- ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"],
- ]
+ # Calculate zone metrics for the table
+ zone_metrics = self._calculate_zone_metrics(pnoe_metrics)
- contexts["page_8"]["hr_zones_table"] = graph_generator.generate_table_image(
- data=hr_zones_data,
- columns=hr_zones_columns,
- cell_colors=hr_zones_colors,
- header_color="#4dd0e1",
- save_as_base64=True,
- )
+ if zone_metrics.get("zones"):
+ # Heart Rate Zones Table
+ hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"]
+ hr_zones_data = [
+ [
+ "Improves health and recovery capacity",
+ "Improves endurance and fat burning",
+ "Improves Aerobic fitness",
+ "Improves maximum performance capacity",
+ "Develops maximum performance and speed",
+ ],
+ [
+ "55-65% of Max Heart Rate",
+ "65-75% of Max Heart Rate",
+ "80-85% of Max Heart Rate",
+ "85-88% of Max Heart Rate",
+ "90% of Max Heart Rate",
+ ],
+ [zone_metrics["zones"][i]["hr_bpm"] for i in range(5)],
+ [zone_metrics["zones"][i]["speed"] for i in range(5)],
+ [zone_metrics["zones"][i]["pace"] for i in range(5)],
+ [zone_metrics["zones"][i]["calories"] for i in range(5)],
+ [zone_metrics["zones"][i]["carb"] for i in range(5)],
+ [zone_metrics["zones"][i]["breathing"] for i in range(5)],
+ ]
+ hr_zones_colors = [
+ ["#ffffff"] * 5,
+ ["#ffffff"] * 5,
+ ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"],
+ ["#ffffff"] * 5,
+ ["#ffffff"] * 5,
+ ["#ffffff"] * 5,
+ ["#ffffff"] * 5,
+ ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"],
+ ]
+
+ contexts["page_8"]["hr_zones_table"] = (
+ graph_generator.generate_table_image(
+ data=hr_zones_data,
+ columns=hr_zones_columns,
+ cell_colors=hr_zones_colors,
+ header_color="#4dd0e1",
+ save_as_base64=True,
+ )
+ )
# Page 9
contexts["page_9"] = {
@@ -876,8 +1254,18 @@ class ContextGenerator:
if graph_generator:
# 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 = [
- "Age (F)",
+ gender_label,
"Poor",
"Below Average",
"Average",
@@ -888,14 +1276,14 @@ class ContextGenerator:
]
rhr_data = [
[
- contexts["page_11"]["hr_age_range"],
- contexts["page_11"]["hr_poor"],
- contexts["page_11"]["hr_below_avg"],
- contexts["page_11"]["hr_average"],
- contexts["page_11"]["hr_above_avg"],
- contexts["page_11"]["hr_good"],
- contexts["page_11"]["hr_excellent"],
- contexts["page_11"]["hr_athlete"],
+ rhr_table_info["age_range"],
+ rhr_table_info["ranges"]["Poor"],
+ rhr_table_info["ranges"]["Below Average"],
+ rhr_table_info["ranges"]["Average"],
+ rhr_table_info["ranges"]["Above Average"],
+ rhr_table_info["ranges"]["Good"],
+ rhr_table_info["ranges"]["Excellent"],
+ rhr_table_info["ranges"]["Athlete"],
]
]
rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7]
diff --git a/notebooks/test_page_5_rmr.py b/notebooks/test_page_5_rmr.py
index 8ac7f0f..158eb27 100644
--- a/notebooks/test_page_5_rmr.py
+++ b/notebooks/test_page_5_rmr.py
@@ -14,7 +14,7 @@ Expected values from PDF (Page 5):
import sys
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
@@ -41,8 +41,8 @@ PATIENT_DATA = {
USE_PDF_RMR = True # Set to True to use PDF's measured RMR instead of calculating from CSV
# File paths
-PNOE_FILE = "Pnoe_20250729_1550-Moran_Keirstyn (2).csv"
-SPIROMETRY_FILE = "data/extracted_spirometry_table.csv"
+PNOE_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv"
+SPIROMETRY_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/spirometry_data.csv"
def main():
print("=" * 80)