From 4028b7c62662ca3601d7914e46a7a19e40ea929b Mon Sep 17 00:00:00 2001 From: bolade Date: Fri, 21 Nov 2025 13:23:38 +0100 Subject: [PATCH] Good progress --- app/report_gen/page_11.html | 4 - .../context_generator.cpython-312.pyc | Bin 44951 -> 45857 bytes .../graph_generator.cpython-312.pyc | Bin 57351 -> 58444 bytes app/services/context_generator.py | 58 +++++++-- app/services/graph_generator.py | 113 +++++++++++++----- 5 files changed, 129 insertions(+), 46 deletions(-) diff --git a/app/report_gen/page_11.html b/app/report_gen/page_11.html index 0ef0c45..4cde673 100644 --- a/app/report_gen/page_11.html +++ b/app/report_gen/page_11.html @@ -123,10 +123,6 @@
-

- Resting Heart Rate - {{ resting_heart_rate | default('53bpm') }} -

-
C zec$=M-}gA|Irr3``1AL9!_AZwJx3qsc84!|?{&jogx({EP&UdZKR|AZA0UC^1N2tE zMKXydDO1drRH9x|HETudVdc(jaiOH6x~^F%+Nf^+ijS^YE!wHgB0Byj)=BMF7RQS@ zRJQ3j9VZruI?A3aQKT7o6Rk{4mei8Y#N|^lkBW9?qzS2uPIhV5i%%R@?R4}gT!oQh z#i!4p&WLk+3!fa+1l8i^vx<7owIwpTbE&fQq5?mOI4-Wn-p&@uAJ=2=evd4>6nHz$ z7uWj(UL5f6_XS$xDqleA@RLQJrN~2ec#fl8kxx8zygNHLmYE;Rc0AN5Q}qc>sng>H z5UaqgxJgN+*#~3={hK~cHV57PL6yjwdXDF~K6Rfas5zt#qM&;Ba~y|J4=*aJxm{+u zrVXlsTIh7nllnFB2l(CD)KeGK?4}HUbW{jxn|Q?4bBH@?RdPK_m#WuOEd?ap;p>zf zSn>yaoh^=5iMHgx3U5FnroQU_=jjaaY6>njUITJDz*>L`fJ%UMv*1G5M`f1|r_zdW z8`z%$X%mIGf%fbPcpKX#kFT@23wvO>1)vq6nSx(O9UZd|PV0v_!6VMr=>du+T3kyP z?&&S8>go(g2LiQHr-Z$MF1+;rc;18=bW^TF+?2^WX<8XItsGH|Y@0AuhYYbadpJ3o zwj`uZs5whcIDfD_s?7>%2838rO?qhUX@9uo%)!_K)3jD&(ucm4NaY;);nt{qSv1)a zsvY>wlri&k?p}&JuEcP1c&G6jc1pe2lUgzRlSUPuE#REDQI+=1A{K&%w(0BaHSJb6!Uo?Fi_sy+KC%%Q<}qEBOub z?{0JdjXCS!D&T&T>a0R4vqD+k;P$f_Bg z*I6~clDX*bhpS*szAGDmej^b4nq1pw?tc%g@(>yAZ$Ov#tOC4#OLaCtD$mdHKHH<; z(KRTKRm>Gv%#>3Kabv(E?^hY7sUnBZBQhabsjs8d;AHJRaGEfY52HSNFfC{iYuG(lORkvu(fDgP@f~HY+DJ*QXD~h z&=xd`8?WrYtcnzzdY(t$C85D`9`R&suy@xdG_CJL70vs1koLnQU>v?jAhGHE4WtCv zJk*a~3EzM+5$zxA0g?cKhf&DGWPypu(3v{a-^)7+A0wAbgR3DB_L5yh_!3C-mk&%p zzDmB<Ac8 z-iDwz0rp17*>56uA6O3p^aC6Mcmd!=3cZ?K_ETwgsUCyaB#I^2LD0EOt&=8pDY2E< z&t=j*U2*Or$|6(e)}jL97_zLvb{Za67B%kaz!zz{IBIrj5%C|k+-+zHu1FU^P;$cr}8kF*9Fv=4r8_q9BadPlH zG2wPUH$n$bUEz8Q!KrpAr3~l5*>^NlQu8 zQZnwY9m|}sYzb}pFw1(nW76i1+T7P`MoK1Z<&(DRsI7X!wtgb3Hnd?%Fi#5ZsNjyJ zn#kr?^Xd&;mSs{Xj0%MpvZ6xCq)-+W$|i(WlR`~Ys2Od#D?D{fF}|ffq2N+X^ur*h z=z3Drxq95OX5`zW$s-5HwvO92hc3E2F8EF_SZ9 zDTvt$V%8^Oxdn+NE^Qr}=G1BFz?m_yBbsavCqwfkctzxgS zs%bDLR*|aLx;z1;Dr+j!lu&^{hm@8vP=7jiVEZvcLc>g2&YT-wKDacYW2&CZ&I#uZ zS`$f3P3H85P}9Kjkna`yqZBZ)k7EoUfi69MmZV+Vf_9R|Yx&0343za52gqFh3wySr Aod5s; delta 2484 zcmaJ?eNa@_6~FiG7rP6~!m_xZ?BXJE6N-(ZMMcWy0|zxkB{5i z=bhj0o^$T+-23j^(W}C1R|Lb^tSlX3qLXnpu|Tr_53dSjh%UxEc#I&rneWshEfVL6 zT9#QzvlVkZb0xJ@U}YL0N>Z^E1V90u=-H*(K$uPdV(as7Uw4teY3qnk>V6?X*pd?K(Scd*whkOweA2{mcz2ufkEl5AuX z30*kSL?YqtP^2}X3Pq$X;e@Jd>sE=-Y=0@)P_gyYE;!>k}Wa$J0^8j{u%Z&)2U6d;)N4)zyGc(zD(6ei>M< zTdoB5=jo^)@Tza6s|@q1zewYHuI+7xYrR5{%L_bMmC_kl0<})1V~3Xk{w2c%WHXsu4X^hrDmDBW3 zJM8_x1(r9tr~=sE0ZtuG72x+7=KII|d)s)GAI1l44jSar5Nj}{W-;V_2z62iQx5nH z;M8XFb$$;RAsgg!`5ytn7yg9H{{#JTj~#zZkMxvSpjB3bcg$Q3{-TP-8qYHMLEnWq`He@r4N$5yZw?B1%XwA-L zb0r`Z&q_V1X*H3;UF{JPWHf076QbigTXD5)h_))6v%rjlnP8@t++dB8pD~v$d70Aw zaz|>BmZpdl>?9#6e76Xs1nIT@X8fVNYTzR^zDP|kowvOJd^Qx>00yozxeexf^xrRS z(EkmlzW{TcHVlUF75U7dQXvc(bQ-z^9&h|}@U8>rXK`LFSqe>k4QvMcA`&easKZO7 z_K7E6mwR8Y#X<56%=ww|YRDsy3nxV?z`Oyb3XBg7oF;z({(s05UR6RVHnGp`x1pRF z1O#cxf&0zvP+$v~_h9B7Fdg!<2bN=7FR(koM8Widc@9iJJw0Ms;$%g7H6He^FKsEi zqw%zqOG{dok^)Av54;VQfW;E}7OB16-_qIMNy5ZV4WpAdpPm~1F8fKlJ?bzgaG+P? z3rgM(DA6z`gLK;CXe*?qd{S?eC zY8jiWz6?S?qg7*t_yBDltHf>et+A3hZeoNOejxvVkY@n?linWl8?!<36(H~KPw-F~ zj??{z=;DNdv=V=$skvS9hdKhC2|ZxeQ`eS`a3TW$>@K^cDVnt!cGISD4;^`2O!umYRtf?qwDjL}sGnLMo%3`LnOQt2WCST0tJMHw=+MPqqQ3bS0^pV>={`e%UeKw$MUJ?*!mg^ za?BfgV8S|~pK6-Sj@iC5V=W(Cl~kh~_pH$uGy3Az!notUxcmON=f0#C7u^d1h=`Zo>-0MSE_e zV7yq7+o;JUdbXurp|;lRrr$sQYEc{P=m9YNOrC@d{|q3%Fl<{8siiejg~C@@Zl5Z| zu0}zno2W}7c%Jg4o~Mg$a-4ilNza|=^tUNhmQVAoNfkKIk$HFBkZ1SCpBR!FPGumw zXQXVnG^yoKhir34Jj2eUp2JL}GYq!uE*lKJ?7Ec&6fuDiElAgq^>p9KwfHIe#mVCA NDl@kH9f9Q`{{a$a-rCO(m+Ug$;qvMSEtXe;OedlZ_c6^+f{N_A< z=j^#>?mgKwCLKN~ImYewYyxuaRciO|pS<9BSWw19LLMYKX%{GI7pc%L(J&tsiiHY( zofzVO2w7=O&fb{Oo+A@#`ehqf_F1cPmixRo%e6Tmnuk%E%TEX;avs?ECOc2^1G`GX z`YWZ%!V2osoit0&rWv{=mP!4?pd9 zBu@@+mxY4lEqhxm!VrJ`6|H9a;fGL!CZ=KPZOGdM+K`38phy~;P(v8v&i(b*PQQN` z<=4g;Yv?>m>tda3GcvXy+>SJfeT;?9N5@70ATH{R)I;&V=Aj(zoC2e+FlAS zaMZ`nW%pt#e{{b)FkNTLTW_#Ns52v0FJ*UPtwko|=>ACd^sUNs7k`VJONZvnMA^3a{n{}XfCBhQ?f}bCM?I(Wj zq&NB1>(7cpF+cLj3jW!FX3+AFG$gNh^FeDt!Wycl^c+2+Ww4(y^M434+;T{*h8ieM z=0v`>gQ|(?Vc;CtBrv;>K*-OH34Zw@Z?h9yX$1#ciEuYU1VO=o8?>q1HLN4CwomWx zh(}lV>K)9Bp+W#H$b9^nLk+Ed^u#c&2(I6h>3I_Ev71O+EXZ$@b*1*F7yRyC=TCGOV50MAlsFp zCTxTt&t5v5nU1V?88-f9j-nc_VHhWhKn!U^5iK($TE4?V!#kz*3XmcnDMf~YdjXo% zhAngCOm>H1?Wy4r%`ds*GXHi-klP2?WA5cLw-xdvhdr&qDs6 zA%&|i*=*;fixJY(!1KK>h@B7Re7yA|S-1dO&pGEzcdLlOJKl3UXe8b=mvtc*23@@E zE*BqJEc16>@;i1ATDw!i^RD-#d-58dxsK|FVUQ?ib!79!SEQXVY0saZ)9RzGeDGyA zH;yglZ@;{|e(o+TRxgXjyY;?!xL;*@f4sY|OI@Q!SzKk&xUQP_MeXiWSKoL;@_~I} zVSs%PBi6*P^WZz|N4&inYIB>4{e-^L0L$5<7->TI1)aYlY({f#HL>5&_dCKL2n22} zyk{DTMfHVDz{`wlh>56e0LB2j2W<&$Tt>`-+T1o`8EE1RSQctBK*HTNd$3E)gSFwo zs#W$OrayvI1vR*mg#FX&cVXbn>mMLf**Ltvz5Fs}zR0sy8x~!K<(pC4fttA%{<0CC z#elgga5wyQ7d(T}C$YdrgzM&^9YW$$X#dly7&vQHAh8Ehv;v09k5wUT!^{>8eTP~K zLB+touVEyQB1J=OK0+lz6+$(_0)*2@IcoZGS~*NTzOU5)!f6UtJ4#epT}-^ph4 zuO!HCoqEkyhiAA5VFh3IX^F5Ssef844HT)9-lEBHJS5md>q(DXVE_5v4FCn`$<3c~Ul0%^@D&_R{VB4{2LFQg+kjAccXHSud4Gvh6?Q lqnDZQDhuYQx!I-IikHuv*e_ho_nusq+u{>SzbA+$@BaY@a9aQX delta 2192 zcmb7Fc}!Gi5P!#BZ!Z@1;$Cnm>{G0Ah+2(85ltJkwIpiL3Y51Xh^775Mp@q$ug2OC z1*b+WHLb0QnuUtl+QZ_7Z8YEoy|!!AHvVBvn{+*@t$);h^MFE38`B~DcD}i1mKn~S zk}HwuiT$OQv^nTB5zhHVdV!?p%9)ml(1#lTfIU9ix1tYU59lc;qzkMT?S2?<$J zqBl;`dG(UotE$mRNz~G*Shn#gtOugJ9ZvJvtVp?OoP-~fgeRy`a&w9}KJ>Dxc@KL7 ze%^bM^@k3A;1b*yS%x7cv_~?4+!_jgt$S`sNm0?$WDrG{Lcaue32bU;(~%Mt4czy+ zIDtHa9wNf)XtSAeqKeSjFhfv?9D)erj^=AfKgNa1qs|tQC*PvW47Aupfu@^YU6of= zLz${6HF-7F5nYpaRozRC4H6YmL4HjE*@m&WQ7^I{gKVYJ+B}6$pCGd_2{&;Tc>`ll zM-O#GUqwjc>Ly=vgEwMc^%~qenx?N^Yx`*4PyV-Oi;(ukecDrePllT0PzNaKdBLHg zeTbP6uC9?>!7R5C?u5E}JZz$l>V^d+)g*ge(iZ{j9}ZXQ3y!JZ4x!rfCv z;JN4yE$QvjIv;4=`I0o*nMhR5t+V~!H|u}u~^!CNSvWB0f(vsDs@6F)a#fQZ&J3C%G-xwuMiZc;-PUOzm9#d~ zbSknD4rM!F-E@JE_3_}TOt#3el0$Mz@lt}6*qId6z-3>iSsoKhj)nCTZ7}{56U^F_ z%ErRvBQ_dA2Tz_6AUDZoOdiQn#>nt+rd=ha_;TUbBgtI-RHeJAXRO;lHSno>c?ag;UNA zo;l*C--@UiyTbSfU5u&>s)`8JjSY+IB;Sgrx#T^R7E-9Czm#-C8%{P*o@7#MWwl8b zavp?jZqz((o!9?hZya6c)S*QxL(d|tr2{!-uaymdWVyRhiyKZRBK(itZh2(4Nj(iE zS|79E|D4E(o=&D>wK5u_RMvx~MJ#3)>UIj+C%1Ic$j! z$EUZg-B)(N`QfqwXHM9W7Bz*IpWnhl(bYn{*0AsDF+)1$E str: + """Determine resting heart rate category based on value, age, and gender (matching notebook logic)""" + rhr_table_info = self._calculate_rhr_table_data(age, gender) + ranges = rhr_table_info["raw_ranges"] + + # Check Poor category first (open-ended at top) + min_val, max_val = ranges["Poor"] + if max_val is None and rhr >= min_val: + return "Poor" + + # Check other categories from Below Average down to Athlete + # For RHR, lower is better, so we check from highest to lowest + for category in [ + "Below Average", + "Average", + "Above Average", + "Good", + "Excellent", + "Athlete", + ]: + min_val, max_val = ranges[category] + # Check if value falls in this range (inclusive of min, exclusive of max) + if min_val <= rhr < max_val: + return category + + # If value is below all ranges (below Athlete minimum), return Athlete + # This handles the case where rhr < min of Athlete + return "Athlete" + def _calculate_zone_metrics(self, pnoe_metrics: Dict) -> Dict: """Calculate detailed metrics for each heart rate zone based on actual data""" import math @@ -1294,14 +1324,24 @@ class ContextGenerator: self.patient_info["age"], self.patient_info["gender"] ) - gender_label = ( - "Age (F)" - if self.patient_info["gender"].lower().startswith("f") - else "Age (M)" + # Get resting heart rate value and determine category + # Extract numeric value from "53bpm" format (resting_hr_metrics already calculated above) + rhr_value_str = resting_hr_metrics.get("resting_heart_rate", "0bpm") + rhr_value = float(rhr_value_str.replace("bpm", "").strip()) + + category = self._determine_rhr_category( + rhr_value, + self.patient_info["age"], + self.patient_info["gender"], ) + gender_label = ( + "F" if self.patient_info["gender"].lower().startswith("f") else "M" + ) + age_range_label = f"{rhr_table_info['age_range']} ({gender_label})" + rhr_columns = [ - gender_label, + "Age", "Poor", "Below Average", "Average", @@ -1312,7 +1352,7 @@ class ContextGenerator: ] rhr_data = [ [ - rhr_table_info["age_range"], + age_range_label, rhr_table_info["ranges"]["Poor"], rhr_table_info["ranges"]["Below Average"], rhr_table_info["ranges"]["Average"], @@ -1322,13 +1362,13 @@ class ContextGenerator: rhr_table_info["ranges"]["Athlete"], ] ] - rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7] contexts["page_11"]["rhr_table"] = ( graph_generator.generate_resting_heart_rate_table( data=rhr_data, columns=rhr_columns, - cell_colors=rhr_colors, + rhr_value=rhr_value, + category=category, save_as_base64=True, ) ) diff --git a/app/services/graph_generator.py b/app/services/graph_generator.py index 340d210..4c9f31a 100644 --- a/app/services/graph_generator.py +++ b/app/services/graph_generator.py @@ -1334,7 +1334,7 @@ class GraphGenerator: from matplotlib.patches import FancyArrowPatch, RegularPolygon # Fixed optimal sizing for VO2 Max table (7 columns, 1 data row) - fig, ax = plt.subplots(figsize=(14, 3)) + fig, ax = plt.subplots(figsize=(14, 2.2)) ax.axis("off") # Create table @@ -1349,7 +1349,7 @@ class GraphGenerator: # Style the table table.auto_set_font_size(False) table.set_fontsize(11) - table.scale(1, 2.5) + table.scale(1, 1.8) # Header row styling (cyan background) for i in range(len(columns)): @@ -1423,7 +1423,7 @@ class GraphGenerator: percentile = percentile_map.get(category, "N/A") title = f"VO2 Max - {vo2_max_value:.1f} ({percentile})" - ax.set_title(title, fontsize=14, fontweight="bold", pad=20) + ax.set_title(title, fontsize=14, fontweight="bold", pad=10) if save_as_base64: buf = io.BytesIO() @@ -1433,7 +1433,7 @@ class GraphGenerator: bbox_inches="tight", dpi=300, facecolor="white", - pad_inches=0.1, + pad_inches=0.05, ) plt.close(fig) buf.seek(0) @@ -1447,7 +1447,7 @@ class GraphGenerator: bbox_inches="tight", dpi=300, facecolor="white", - pad_inches=0.1, + pad_inches=0.05, ) plt.close(fig) return str(output_path) @@ -1506,7 +1506,7 @@ class GraphGenerator: # Style the table table.auto_set_font_size(False) table.set_fontsize(11) - table.scale(1, 2.8) + table.scale(1, 2.0) # Apply cell colors if cell_colors: @@ -1558,15 +1558,19 @@ class GraphGenerator: self, data: list[list], columns: list[str], + rhr_value: float = None, + category: str = None, cell_colors: list[list[str]] = None, save_as_base64: bool = True, ) -> str: """ - Generate Resting Heart Rate table as an image with optimized sizing. + Generate Resting Heart Rate table as an image with optimized sizing, highlighting the patient's category. Args: data: List of rows (each row is a list of values) columns: List of column headers + rhr_value: Patient's resting heart rate value in bpm (for title and arrow) + category: Category that the patient falls into (e.g., 'Good', 'Excellent') cell_colors: Optional matrix of cell colors save_as_base64: If True, return base64 string @@ -1575,12 +1579,11 @@ class GraphGenerator: """ import io - # Optimal sizing for RHR table (8 columns, 1 data row) - fig, ax = plt.subplots(figsize=(18, 2.5)) - ax.axis("off") + from matplotlib.patches import FancyArrowPatch, RegularPolygon - # Even column widths - col_widths = [1.0 / len(columns)] * len(columns) + # Optimal sizing for RHR table (8 columns, 1 data row) + fig, ax = plt.subplots(figsize=(16, 2.2)) + ax.axis("off") # Create table table = ax.table( @@ -1588,33 +1591,77 @@ class GraphGenerator: colLabels=columns, cellLoc="center", loc="center", - colColours=["#4dd0e1"] * len(columns), - colWidths=col_widths, + bbox=[0, 0, 1, 1], ) # Style the table table.auto_set_font_size(False) table.set_fontsize(11) - table.scale(1, 3.0) + table.scale(1, 1.8) - # Apply cell colors - if cell_colors: - for i, row_colors in enumerate(cell_colors): - for j, color in enumerate(row_colors): - if color and j < len(columns): - cell = table[(i + 1, j)] - cell.set_facecolor(color) + # Header row styling (cyan background) + for i in range(len(columns)): + cell = table[(0, i)] + cell.set_facecolor("#7dd3fc") # cyan-300 equivalent + cell.set_text_props(weight="bold", color="black", fontsize=12) + cell.set_edgecolor("#9ca3af") # gray-400 + cell.set_linewidth(1) - # Style all cells - for (row, col), cell in table.get_celld().items(): - if row == 0: - cell.set_text_props(weight="bold", fontsize=12) - cell.set_edgecolor("#333333") - cell.set_linewidth(1.5) + # Find the column index for the category (if provided) + category_index = None + if category and category in columns: + category_index = columns.index(category) + + # Data row styling + for i in range(len(data[0])): + cell = table[(1, i)] + if i == 0: # Age column + cell.set_facecolor("#a5f3fc") # cyan-200 + cell.set_text_props(weight="semibold", color="black", fontsize=11) else: - cell.set_edgecolor("#666666") - cell.set_linewidth(1.0) - cell.set_text_props(fontsize=10) + # Highlight the category cell with light green background + if category_index is not None and i == category_index: + cell.set_facecolor("#d1fae5") # green-200 equivalent + cell.set_text_props(weight="bold", color="black", fontsize=11) + else: + cell.set_facecolor("#f3f4f6") # gray-100 + cell.set_text_props(color="black", fontsize=10) + cell.set_edgecolor("#9ca3af") # gray-400 + cell.set_linewidth(1) + + # Add arrow indicator below the category column + if category_index is not None: + # Calculate position + cell_width = 1.0 / len(columns) + arrow_x = (category_index + 0.5) * cell_width + + # Draw arrow pointing up + arrow = FancyArrowPatch( + (arrow_x, -0.15), + (arrow_x, -0.05), + arrowstyle="->", + mutation_scale=20, + linewidth=2, + color="black", + transform=ax.transAxes, + ) + ax.add_patch(arrow) + + # Add triangle at the top + triangle = RegularPolygon( + (arrow_x, -0.05), + 3, + radius=0.02, + orientation=np.pi / 2, + color="black", + transform=ax.transAxes, + ) + ax.add_patch(triangle) + + # Set title + if rhr_value is not None: + title = f"Resting Heart Rate - {rhr_value:.0f}bpm" + ax.set_title(title, fontsize=14, fontweight="bold", pad=10) if save_as_base64: buf = io.BytesIO() @@ -1624,7 +1671,7 @@ class GraphGenerator: bbox_inches="tight", dpi=300, facecolor="white", - pad_inches=0.1, + pad_inches=0.05, ) plt.close(fig) buf.seek(0) @@ -1638,7 +1685,7 @@ class GraphGenerator: bbox_inches="tight", dpi=300, facecolor="white", - pad_inches=0.1, + pad_inches=0.05, ) plt.close(fig) return str(output_path)