From cc9e526fda8aaa18cf214226705e1874cc41fb26 Mon Sep 17 00:00:00 2001 From: bolade Date: Fri, 21 Nov 2025 12:49:36 +0100 Subject: [PATCH] saving --- app/report_gen/page_8.html | 5 - .../context_generator.cpython-312.pyc | Bin 43605 -> 44951 bytes .../graph_generator.cpython-312.pyc | Bin 55468 -> 57351 bytes app/services/context_generator.py | 184 +++++++++++------- app/services/graph_generator.py | 107 +++++++--- notebooks/graphs.ipynb | 2 +- 6 files changed, 193 insertions(+), 105 deletions(-) diff --git a/app/report_gen/page_8.html b/app/report_gen/page_8.html index 63d7391..7dfd679 100644 --- a/app/report_gen/page_8.html +++ b/app/report_gen/page_8.html @@ -17,11 +17,6 @@
-

- VO2 Max - {{ vo2_max_value | default('49.5') }} ({{ - vo2_max_percentile | default('100th percentile') }}) -

-
rw4Z3V>%nxxT~Ha7HOJ4wn+lQwBg!)iKgm`pTjCyr@j*J$&He>DBRvk%-%+CO@j z{qFgl^ZTCX+eQ2hCskW=4=QZ4$1@dgKALb(uog)IOx@#cw(-Aid&?;766gziU-6MznePw| z2)na5PErv&d#YUm&$$$2saNURKpu`^)WtBC0b@B?9>b`QVKe}vlB|eftc)_6(9J5K z6p+<1l*T9p8BM@2$~>vv4M|9$!1REdcegQ&^8#5$FK)J4Nz3jc7!=4F7f;rDRkHFF zN7l)zQ`~8VS1l{(yNt5FS^s*jD$%gyJ z^TJ~r!y1aH#ylk_D*@u(|6I zpGACf1X|`w#4iALc4GUY8L*S79%;0Pbm$u7ya=4;^VHxe@j02PiiHs$OL6`@+GL8C zna@|EK1P_&3p4Y~=c|ZAKATYgr+}NmcgS|N{RYcpU;gBK|dCjF1K0Io}?N zzHAKwyP~{!IP8V2ks1j54Ga3Zx44-a$v9 zg{nsU2gIQ=vcUg{IOLTD{w`v+xjy!>$lrs3naIi_Yf^#6{seR3f}(LJ^Lx3&G&`6)}ngb%~Z(xrUgX5s=TbEyhcg@<(qTZKt)+e zMUAu&2Ba+@`c;)>C6zVOYGf>AjHeW{)?m=CEMRoZYsQPgAuQE!v#kmQH4 zdwkI%X&TXQQJe!|Q%J?AvK_>iEBKP|X=qhSnWA5Xj0qrU@jc z4hzRKS^s)g-jL>c&aCI@*9Xs61hTBRHA;g%$SHMtDFxHm2ckm>BGOdQQNZuc>mW{W zbnGdUPmmQC@HBzuRmw`j4EvA_^R71CCeY)ymodwam*J_Oa#pI}5YUOzDh(*aARNFND z>z9chQR?Y(iPjA*<<_3|1J;gqkLc``4qA7#yG3H{by@efckdJDTHBo{T*T@WT||_u zMLq4Fj-6dDr`6T#5qI?V?zVRKI=eb-(iE=iODN9qK6r;~> z+N5N(V|MsRBtu8UW+cP@xploRk9fe-Ai6}U-P0@0bNqMqJFpP$!(eoPyKc#wvef!5 zwG)bo4OcCVliI96a=}=mKY9M3`t!8bK|O4$?EJ9@{N|Faer?8}X2=wXGY-}c_nn(_ zC3k)x#d256W#$Fq(}&!{!eGlc3L#TJxB|4Sv&VM&b4&g4*@F#38?Ps&56}51sd%j6 zYEp5)lzTkswt_Rv4aNaIn93Q<^za<>_(XrES|MHG$3GcZ%*TC&1^EYxTXfbFUHrB2 zXP;0i_~CK-)R!uSAmz}3!<%_6oa||drDSn*ZzjrgqBBj96X~8q*|h$cMHk+A-Xyx` zX%qeDVKtSiQ|N$B>my6T*M?fSHrTxeIe{$2EeMx~hh+oEy3u*HpXGR!Z> zc~XxuiW*|jiWsyq2Ca%gNvjb$HBR1}A}e>9>Ds!K!W7vkC(22r@ztga_#Q%oE674* zl4iWB$lNXqFjJ}3rd4`V14B$C+YoAgP9(rvgIs4ga3Cg#0Fa?sBRu)dS9cCI#ofsoF}AV`g5?AOtgm ztt7&wGJ|v?f~PZPbSEOxHL3<%yTWT+7+GVrHxrh84n0uL zTd@b;3Ik~%dx=EcWF=WeJWx=JoE>^o;2C-&LhnEX?PY03kQ9U5F(~wYM3}3}Vzc|4g2BOhdZJl$9ZoqtZOOeRGcGQY^j^rOeN zehJ^Hu1_E^>F?Ohd7M@MgW zujH1@)H`yXPp5NE*24d>&Xc*-{hFmt5wB$7PLoYqj8QE?QH$bX5a4J>xwxaV96eUy zAARxUN~qwilM754(pHRb0}3~aAELNR7oN&j<54SpMz@_hYO;ooSysD$;qW%3eM5y& z$BHBj_ZkjHvz(3(9rBLu_8nsPbP}BXq3c;>AbOVecJJ$PxupQ8)8|Le(B{(xN-Nl= zdwZ;OPm7w4oL-`t1)mA`BHj3>ryo=J^qj>ul|9#=J$JHX@g?`w?7G3GfXO^%deCor zFkr9*45?FwJij4ttk7>Lnn6W=L-8GrG9wWVN&}aXJ!LBLn~Kh5_)Xb`l{)n zOFOTa>W?YjtqUr+giJV3^D8f|_UG9rtxG426O9u~Cf6F*P3E)?wtTJB<|Yod1k<^+ zS@g`AEmgrRF4;O|Sl~A-2xJxpvWo-xr2$J}pkPigj!S;<4yR5wqSdsaP5$_tVV^&~ zG?+eKbM~CFbkbyOo2Q+uHnvsh{h7wL>UjS{#;qVz;CvbDi0=AOdal4eNjO*!{K^1nO zOv!0Ot-}RFn~o(0HEc-BnG41$t4HPqbqwpdto*Tp5lfJ%2IDz>;$X*6q%vZBHzm>DMn z2_3PAj-o`6)>NZb+f7^BCdN)}(=?`L(>f9+qgZEjO#fM;{YTqQzwhirx5H$5V88qQ zzQ^x;=R14O-6OZf8-EiGpXl|H0OP~Tl(uhO`!pWLaD zoZ1$VCLL67w`&EhK+~w!siDbEkWNjD7zZp==L~adxt0Q20@ol9A|Mx0i$qfoYPKit zS2-5Dvs53{>HEZ#<+2E_$HZ~Q{bc85vv`ZJ-|l|5L?LwTF7U`IS~Q^UiU(Zm$0dNx zfJ^a+>h4*7U_DH)svdIVb^lt5NSmhp)U+*6!md|Zd5eKg|}4$VeHPZEldeh zRCE)2tEXaXr4mW2j;r4ml?X+<3aT2hDv<{yMM5jBe#V~aw{7-ksDc?Q)PkZ`B52Ko zxK(g$VGdWp)~frTUW1`4EpmwRNg@b-ybkd+Vwg)14pboi5#k&iJ)02!IEdM1#HRpP zIoK6fMnQnL80}|*JXiQU?;>u&@eZHo>{HA#_9Q^EZ_+iG{8M!DN!^_6LJ;?qq5onq zc_|y-lQ39y|JXQ;xaZL64m$5~viA^k{}-I>7l1iKfQb9H@zwqkd{qvy%2#PiKJxtv z^L@o}{!2l=xFYobnrH({Tgq1VCKURs8DE9o_k+=2@aPYMY(=~;ml5;6+$(3tdhMi& zUG7a81Xe-dKtA2=VyftpuHS)VB!Ugg=1K$F;N^ z%lso?&gF|4%tPZx!JvBui2sDxHyK~s$3fh~*Y@Wi&f(ki3E-!sLJo3V1DlLhg3gP@ z?E;RptIGX)t z?KkRkHv7QEPZB8PG?y3UtFX0oACeSxsK_VkJs3C`MXw?uD~z z2!jE4)bp4@H{xBU2<>+Ey57Rt$0U_WF|n)>YuYBf6>y9a*#eZ6(`3L5&nlo6X!SI4 zC9=amNJ}=+O}vsw&bJxmVR0l%Ei{V*kqgGu{2wt}7K%~9Tx|ZZoh&r-iwk88hF168 z(3sYU0SkSpl~7xs26_?HV0|x7lc)HbHD5GvMJwv|WZtr%V2AXYG>c+Zq8IFbE$@B= z-NtS+E7{Ke$(E2h)_5q@UQf+u@#P1D?wC-+e(m({RBB+Sh?Uih#ITXEMJ#?ymt&=` z;OgKd)(CGSD{Vpq&*l&-MMRwwVZhkJs>hZP8=D`qv5Q-@ni%*VEMr**?94S5OX{(X z^wwwUcy&TlY^R741D{y{qcQFd$gSNyQ`1xCFBwNXuN;N zZ8(2zUqWH>9BO;<{=rMA?GbAHPP&EKU9>eooI@)YLIpDS{~KNZL4iD+!gq!7#Jldv zV|gS-Hh`}$ytvhg*SBA(bgkLESEHN>wai8~J3BfWn!7u?}7w)FN_&NHf3Vr9 zhBaznoHh4$XE$^9;xus-YC79yptVuIQ`;5^R_y;}SYmt%-L)G4R!Mbd!-xHm~uDr8-&DXIJ zCbL&1B^idQz-3CgY0UQ+^Jn6g%p|1Gq^y`pTjtdYrtdryG#Dr7t)sObUDDA(k8Y(m z+FkvV)9RJiW6X6cBCZ#h>vE*e;>>jgy3d|7*R3{q?B;sW=vig14>!w^&@A|wRpm%x zOV3RiZ(su-pul&9T*l_krIIh*;`v%K=pq{FqQxuV^HQ_#d4)dY*xR8hC7PJiu7?6X zy<0@#+nF-Bo5&UB#_hy?~^SfTV^GL?LI|7 z(%@t`OcsI(WN;zrklHk+4h?BDg-jBXlu2WQ6U1v;pwpJgOwyR6Nv9Xx|H-l)hH0id z_|w1tvHS1-yZ^tBzLT;YXJv--dcB6BZQXL0f8B+XhP|xA2z}aay}>ce0fuMRvWQvB zA$F|{<&(ErCtE<)b8d2nRmCZ_<{8CWoti-sU#+D*{b+CUsP60BsIJyPr6!OfBl!cH ztv1nqvZQU2Wl-%R8H`^en`|#Y>3j-O@fxJyF%3{OAk2yzEA@ zVV6HGOr>A^>&~4!9Z7Cqg}2gM>5<`9a&4D6eKQqZFPGXs&wzLuU+yiZ;w{9u`vJCz zJhr=)-9mo5JDYuCU}X0KmW>QlbX{jX4d95Mg2>i%$V$q5H~^eysRz`0Op14qg)jV@ z-Ad+@bCxR5SWh*u7vLnoeB#*WS1treHQBju1-pV=*%zXYuiRhZtf6dC?rq~kqN1&~ z$=@vfc-!zpp#KQ@$^J6-S#o**bavN3%8L){*fz5Ms9Cukq)(HMqmQ#eGJMp-?jip< zTEVuH@?#~YPeHfs;{ESkI@Fj{gzVwbTxEy4&0j>kUw0I9t`+&L(lnC%UfI{H4lO25}+Rvds z5Bg7$vwcOD-$P$o%1U6yw}7xGQrTDOdh^NsC#-A@S$SfP@(O7FY2d(#*E#+LN{jl< z!Lr&WZ(HqVZ=jVIRdwDlUmwKnqPC6?1ZwMofgldmCDCCMO-%_I{k!!SI^zKbv4{&W zktF&n3$RUGMAamVs97cqdJIEGqE>Q6tY^50j4zmFn&*x#CM_{yd@&9avi8|};k?mC zch0!YF6afLG^%-Qxzbn@musE4+yip3+luUAJK6hDT2aI#7*To$9Z)cJ(0E0XIYv+k z$tc6eQOxvl$bqtu6PakZm!C2l`dq!EXDE_FSwm;cMBl2(WEpCw9<|6uIbk^DQfr(< zsxng5&JHcj!egVjd*PJR7D4%U%Zf;vplvKA*_u>)8k&lXD66lmS0103 zZlDrBEhLe5&YH>k>1vrZtR^Q6ndyQRm7@xDADV_L`=%%SgnK|XeVj?Yep$x}R>Hnx zCU-0v1Le<%56Cc$Ghr&3wQ)A_R;QEFkJap4SiEsYVreTGfoYrvJlV|feA48|dGVSjVIE8M`lTD)OD-yALoxe}Rzup1rZ%TtZf?_`phu4QAIs~;(I(G=s7 zQjDw67sRfxKOEp)-e%_fsN7c< zF9ls7+zvJ}EETN#f$}!L_V1p-b75Ytcn1s10I>wq!Z3E~-|xw(!^=4dM>d)G1TK4KCWP zql*3j-;p{s6fT~67t;)k;H3g6dmf-JZ^U_3kI<$ zkK-b*pVDv|MD^HN=#VWft?JOgS$vqD#bTHA!0F&zg?JKd0UQ7tA_xrL1t75G0Ym+uK}E&x~d~sG?z< zrmuw-{4I6`g^etK(N5;@O7hxU3k=(7ecH<0Dq++q*Hw#1 z(>Yh3l1WZIpxUd7S?sYiN6efVGugys$B33mw%=lu$(l~ZNDA@&a#HlRl1Z`d3w2i# z;j`r2&Z=0dtvkDWd3W00*_{hxI@2Xx){ri%yM0*ak$TP{owH|i|C+OSQ0E-hJ$Mfl z$zao`x@*(S#vxksKn_~h~F7abLyo}~!~Dtg$1_jbZ) z>Z*EvGr93WYScYsavz_0dd-J;(B%HqwBoACe94qEWXkE8F>ET1K~GXCI6a>#44dX& zP0b!mE$+|mf9gV6bNSJu`YZ)Khs# z7_v|9d@yFsJ1!ftPCni-ls2Pt@eL(o$&Dr3y7LB2uCdU;?b2Z(TVV#p2+w#YZOvy28#^;$?N2(82_f#ERGHx8btxvKUIv=2W zI%;hj*5!;S8J&Gln;SD)yJuc7=EWSPdo`4qQyI%F8~vAEc|)trN$E0-7?`Z0OODbZ zM`^$6blyeBye`evG*^$~cxcd7-e1<=*#E@YvY5ZmrE^S^8Wt!=%HsHGpjeJf z9kg;BM>f4@&sM@!O@b7YBi(0u|M<4>7EG^LJiUdI>#Ewd*2Bx zwWLiI4{ZD^E+71mKI#g9Dg=N^EX7bZ8pJ3EYzt-61vTK>uy;d%uf^jr&5Gyf355cN zkYXrB^R7VlgA}5Pivhkjj?w=&kT7Z^oy3#+J;5BSCDL7}l}bfuysqGK7^xx)Plzu7j@;vY?-lw$uXz8g0HaYO+fnr%|jy6X1YN0Owjim^FH)Do#-QqxAX5~*X-(z|oJLkB&3Ye)1F%fQ$j zBhq*@r)l)Jk|l0H&J-5GOj^XoXCmKzc9fk*Ub?h#Ru4F=Xl`w4X_x+YL{zrWH?B7% sT`4gs#CtacaWh^GYaRb+S3&oAQWdkY50kaA1;*8OHuok2h)q=XU*se==>Px# delta 2178 zcmb7^drZ?;6vum83Y1c)GN6dOEIK%q^Rb{WM^*hcVj{rgL+?*!lh>!x@)lKfxe0;$7YK#@U!v7RNphFk3_YIBrDeZ+;dYB$< zHzjiPcoXs$n#LuJR!4Y;tKr@V)$9!^3sn<2GG33|WpWtRG1PC06{+M7NwB7|YlOwX z&PUZztR_CLTAZwotV?n|9V`iv0kJZ8zSbENyF?faaw+gKupCgxw&R3V_Y~=86hZn`*>b(wfr5tHFCsxk9WDx_4a@u9YaHoS})r zdQ--vGPxScR<4NO{@W|J(xTn>MI$Ywld*0@tl$W#07rmX-B5qTF)9^rT(GR%K3<2NJrZyiP!0ewkcvqFnP~{$i#K5^C|K& z@z-gRv54CUY@)3PUlEmb^`J{MQAB&TsHK_h8TN1B%ta+`pn)3N=cVX{ZieS5#2}zo z+o4BO+MzM@pe{5%TH4_H9BL}8P@o_pO-gl z)@=C|q;u5w?z8rbFgGD?JMcA)>~a=WLVOOWgLs~;e0hP%H-Wz)2AKulbl^t-+sY7sxmgx0h%*h0TE&1|`ZY{c$pvh0UomNyV_ z6Zj3#zb3cXBFCBJMtK{WepCO1bq83Fw+CCt)?H`|pcMjd026_GaKy5uMVCuYeO0B` z?+GX+6<&`t;EaOd8l|^F3lDf}1M8$x{g39;XJ--$_aTkmlrm&Be^Qc@)= Dict: """Calculate VO2 Max table data based on age and gender""" - # VO2 Max Master Chart Data (from notebook) + # VO2 Max Master Chart Data (from notebook - matching exact values) 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), + "Very Poor": (29.0, 38.1), + "Poor": (38.1, 44.9), + "Fair": (44.9, 50.2), + "Good": (50.2, 61.8), + "Excellent": (57.1, 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), + "Very Poor": (27.2, 34.1), + "Poor": (34.1, 39.6), + "Fair": (39.6, 45.2), + "Good": (45.2, 51.6), + "Excellent": (51.6, 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), + "Very Poor": (24.2, 30.5), + "Poor": (30.5, 35.7), + "Fair": (35.7, 40.3), + "Good": (40.3, 46.7), + "Excellent": (46.7, 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), + "Very Poor": (20.9, 26.1), + "Poor": (26.1, 30.7), + "Fair": (30.7, 35.1), + "Good": (35.1, 41.2), + "Excellent": (41.2, 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), + "60-69 (M)": { + "Very Poor": (17.4, 22.4), + "Poor": (22.4, 26.6), + "Fair": (26.6, 30.5), + "Good": (30.5, 36.1), + "Excellent": (36.1, 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), + "Very Poor": (21.7, 28.6), + "Poor": (28.6, 34.6), + "Fair": (34.6, 40.6), + "Good": (40.6, 46.5), + "Excellent": (46.5, 56.0), "Superior": (56.0, None), }, "30-39 (F)": { - "Very Poor": (None, 24.1), + "Very Poor": (19.0, 24.1), "Poor": (24.1, 28.2), "Fair": (28.2, 32.2), "Good": (32.2, 35.7), @@ -896,42 +896,50 @@ class ContextGenerator: "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), + "Very Poor": (17.0, 21.3), + "Poor": (21.3, 24.9), + "Fair": (24.9, 28.7), + "Good": (28.7, 34.0), + "Excellent": (34.0, 41.7), + "Superior": (41.7, 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), + "Very Poor": (16.0, 19.1), + "Poor": (19.1, 24.4), + "Fair": (21.8, 27.6), + "Good": (25.2, 28.6), + "Excellent": (28.6, 35.9), + "Superior": (35.9, 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), + "60-69 (F)": { + "Very Poor": (13.4, 16.5), + "Poor": (16.5, 18.9), + "Fair": (18.9, 21.2), + "Good": (21.2, 24.6), + "Excellent": (24.6, 29.4), + "Superior": (29.4, None), }, } - # Determine age bracket - if age < 30: + # Determine age bracket (matching notebook logic) + if 20 <= age <= 29: age_key = "20-29" - elif age < 40: + elif 30 <= age <= 39: age_key = "30-39" - elif age < 50: + elif 40 <= age <= 49: age_key = "40-49" - elif age < 60: + elif 50 <= age <= 59: age_key = "50-59" + elif 60 <= age <= 69: + age_key = "60-69" else: - age_key = "60+" + # Default to closest range + if age < 20: + age_key = "20-29" + elif age >= 70: + age_key = "60-69" + else: + age_key = "30-39" # fallback gender_key = "(M)" if gender.lower() == "male" else "(F)" key = f"{age_key} {gender_key}" @@ -951,8 +959,35 @@ class ContextGenerator: return { "age_range": age_key, "ranges": result, + "raw_ranges": ranges, # Keep raw ranges for category determination } + def _determine_vo2_max_category(self, vo2_max: float, age: int, gender: str) -> str: + """Determine VO2 max category based on value, age, and gender (matching notebook logic)""" + vo2_max_table_info = self._calculate_vo2_max_table_data(age, gender) + ranges = vo2_max_table_info["raw_ranges"] + + categories = ["Very Poor", "Poor", "Fair", "Good", "Excellent", "Superior"] + + # Check Superior category first (open-ended) + min_val, max_val = ranges["Superior"] + if max_val is None and vo2_max >= min_val: + return "Superior" + + # Check other categories from Excellent down to Very Poor + # Ranges are typically [min, max) - inclusive of min, exclusive of max + for category in reversed( + categories[:-1] + ): # Exclude Superior as we already checked it + min_val, max_val = ranges[category] + # Check if value falls in this range (inclusive of min, exclusive of max) + if min_val <= vo2_max < max_val: + return category + + # If value is below all ranges, return Very Poor + # This handles the case where vo2_max < min of Very Poor + return "Very Poor" + def calculate_rmr_and_fuel_source(self) -> Dict: """Calculate RMR and fuel source from pnoe data""" metrics = {} @@ -1128,11 +1163,22 @@ class ContextGenerator: self.patient_info["age"], self.patient_info["gender"] ) + # Determine patient's VO2 max category + vo2_max_value = pnoe_metrics.get("vo2_max_per_kg", 0.0) + category = self._determine_vo2_max_category( + vo2_max_value, + self.patient_info["age"], + self.patient_info["gender"], + ) + # VO2 Max Table + gender_label = ( + "F" if self.patient_info["gender"].lower() == "female" else "M" + ) + age_range_label = f"{vo2_max_table_info['age_range']} ({gender_label})" + vo2_max_columns = [ - "Age (F)" - if self.patient_info["gender"].lower() == "female" - else "Age (M)", + "Age", "Very Poor", "Poor", "Fair", @@ -1142,7 +1188,7 @@ class ContextGenerator: ] vo2_max_data = [ [ - vo2_max_table_info["age_range"], + age_range_label, vo2_max_table_info["ranges"]["Very Poor"], vo2_max_table_info["ranges"]["Poor"], vo2_max_table_info["ranges"]["Fair"], @@ -1151,23 +1197,13 @@ class ContextGenerator: vo2_max_table_info["ranges"]["Superior"], ] ] - vo2_max_colors = [ - [ - "#b2ebf2", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - ] - ] contexts["page_8"]["vo2_max_table"] = ( graph_generator.generate_vo2_max_table( data=vo2_max_data, columns=vo2_max_columns, - cell_colors=vo2_max_colors, + vo2_max_value=vo2_max_value, + category=category, save_as_base64=True, ) ) diff --git a/app/services/graph_generator.py b/app/services/graph_generator.py index 0245525..340d210 100644 --- a/app/services/graph_generator.py +++ b/app/services/graph_generator.py @@ -1310,15 +1310,19 @@ class GraphGenerator: self, data: list[list], columns: list[str], + vo2_max_value: float = None, + category: str = None, cell_colors: list[list[str]] = None, save_as_base64: bool = True, ) -> str: """ - Generate VO2 Max table as an image with optimized sizing. + Generate VO2 Max 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 + vo2_max_value: Patient's VO2 max value (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 @@ -1327,12 +1331,11 @@ class GraphGenerator: """ import io - # Fixed optimal sizing for VO2 Max table (8 columns, 1 data row) - fig, ax = plt.subplots(figsize=(16, 2.5)) - ax.axis("off") + from matplotlib.patches import FancyArrowPatch, RegularPolygon - # Even column widths for VO2 Max table - col_widths = [1.0 / len(columns)] * len(columns) + # Fixed optimal sizing for VO2 Max table (7 columns, 1 data row) + fig, ax = plt.subplots(figsize=(14, 3)) + ax.axis("off") # Create table table = ax.table( @@ -1340,33 +1343,87 @@ 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, 2.5) - # 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) + cell.set_facecolor("#f3f4f6") # gray-100 + cell.set_text_props(color="black", fontsize=10) + # Bold the cell that corresponds to the patient's category + if category_index is not None and i == category_index: + cell.set_text_props(weight="bold", color="black", fontsize=11) + 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 - calculate approximate percentile + if vo2_max_value is not None: + if category == "Superior": + percentile = "100th percentile" + else: + percentile_map = { + "Very Poor": "1st-10th percentile", + "Poor": "10th-20th percentile", + "Fair": "20th-40th percentile", + "Good": "40th-60th percentile", + "Excellent": "60th-80th percentile", + } + 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) if save_as_base64: buf = io.BytesIO() diff --git a/notebooks/graphs.ipynb b/notebooks/graphs.ipynb index b5c88df..7d12178 100644 --- a/notebooks/graphs.ipynb +++ b/notebooks/graphs.ipynb @@ -2066,7 +2066,7 @@ ], "metadata": { "kernelspec": { - "display_name": "report-generation", + "display_name": ".venv", "language": "python", "name": "python3" },