From 384af2b1cfea05adcdc76ca6a9bd3178d9f1cce9 Mon Sep 17 00:00:00 2001 From: DylanAdlard Date: Thu, 18 Dec 2025 14:34:19 +0200 Subject: [PATCH 1/3] fixed --- README.md | 6 +- demo_files/output.pdf | Bin 29095 -> 44306 bytes demo_files/~$demo_input.xlsx | Bin 165 -> 0 bytes gui.py | 47 +++---- src/ecoff_fitter/cli.py | 96 ++++++++++---- src/ecoff_fitter/report.py | 116 +++++++---------- tests/test_cli.py | 85 ++++++++++++ tests/test_report.py | 245 ++++++++++------------------------- 8 files changed, 296 insertions(+), 299 deletions(-) delete mode 100644 demo_files/~$demo_input.xlsx create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index 718eca9..4e6fa41 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Once installed, you can call the CLI. Example using demo files: ```bash -ecoffitter --input demo_files/input.txt --params demo_files/params.txt --outfile demo_files/output.txt +ecoff-fitter --input demo_files/input.txt --params demo_files/params.txt --outfile demo_files/output.txt ``` Instead of using a parameter file, you can also specify parameters directly. @@ -155,9 +155,9 @@ Instead of using a parameter file, you can also specify parameters directly. Usage: ```bash -ecoff_fitter [-h] --input INPUT [--params PARAMS] +ecoff-fitter [-h] --input INPUT [--params PARAMS] [--dilution_factor DILUTION_FACTOR] -[--distributions ] [--boundary_support boundary_support] +[--distributions DISTRIBUTIONS] [--boundary_support BOUNDARY_SUPPORT] [--percentile PERCENTILE] [--outfile OUTFILE] [--verbose] ``` diff --git a/demo_files/output.pdf b/demo_files/output.pdf index 37e63625ed4c17a6defa2c83592b1e60972df8fd..4ba591a18c56d01c8ff9a5a3e73fb630a54e2778 100644 GIT binary patch delta 21725 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 YPOGSNmse^f7*8dhvpb#fa}w delta 7961 zcmZu!c_38n_irp=B-_wtxw5s$%-q=#*+V2LWXrzqDqBXD>`R_3C1fjPt0}1@uQo!6 zP+3X{l@_He@ta}Z_Zxow(L6r)+~;$ibI$WQ=cc}9x%wVabrwOrsPBi;7C8H5YWD;u zzuw_N@xBe!u_wwNp3(m#mcT35xS543qJALvi!AL}XMg8j$qW7MRtf6qG6h$@7r=ud zw7XxZfzxk3ZgLt_33V3xQ*}?}bdb*-zfm5Q$^?Sf@0ot`RA#P;V)NB0%WvQ3$`__@ zeeZes#rovE+*7|E93Q7j?-u)#y3HN#;Mp^EKXd-AosM*J{%yZ=ZJ{Z9XHQiqk0*U; z4JmN$f4g;1=TGp!M#4p2r%~(ChQ7syYv1!`1%Ss(t$FX!U)iF*Vh#Z`i>f!bh^Q~- za=~tvv8=gvQ^9Ud+rPZ$t8FGol+08xd7xd%qu5{l!80g0Fk$O7D(`(_tuH;UdI!s)R5qWHz2I_|NZ zZTW-UlYKGXt8F%oT4H)otxMoH$xKBsDz7WW(u{Iz(_sG3=qt8+bOHq0)fB%or z-A6A6y=tDjy6q64pNW8 z-+qitYWs1_R|f?2%-iRL(&i?{Cz}jTe7SVCYJ}+44904vbqT#{UHN7A^4Rk{HizX( zgtz*@#|1=5_3Yb4eLYk+4YaUi^sEo)ODumy{Fng7A`!PXRE$1?%>r<&HwH1s3n&Nj z->?7e9Ho=8s47eyXk3@uJ$rkduRwPD;6zHS+HO_hfqf6&7}lNP17jM~PZiEv*u^uj zK#SeU-Q4~EM3r>Qc5sdC;gRmLkC3m8{je?ec6b{53y~f%yREL}DL0aWdfeK$2R~@< zuvgG>@)#>~X=J;3>Gj7q4^-f`$x9FJE+#vj>QNR=kTmS4#zO9ou3v78`yBAm*ZV5% zSLWTJ=#;S*pGx0U_fmE%W0MNG=NR=j4P1}MiygJA&o|l|{VH|J|C}!xX)S15di9)Y z?0oB_@kH26oW7N`=`y#Y&?v+$fQ7HCO&I`1$^o*&8UP;ai@uc7{ zhehJ_>Tc;QO5N3>-ca|m*J*6Ze)gy8$KoG@={vHDcNN-0q|?uM^pC$j{bRE8@zdrR zgWQKBk?dGl@O(S4s8oqxXeeJB+Hef{SI? zzjh>(HdXK?4TVsNvHbaw=l$J=RP%%Rh(bQ?y9Ox9ufFnsJW$Q@&v5#F!NQuT*Bi#x zjTP1j^SZ_qWwyjP``DZif~jdLYea@WQ9`1MK4E zvyVhtj~{B(&zFB_p+fw&jm1$~n@j#Vd;b;Njeb0diJ!Lfl*;i3hAEM&3HDMY8M=pX zyPt~3gKfkH4FhWAq`Vx-*LY)+=H;+t@-s~||JeYfxWvoy96~)ZsyLD(Aflzd;Kbr+ z37>gXvDP&2aPcJTaa?-!TOnX{i+tI4qwrMGw7p1Jq5==DE1q_vqNMgrGNJU+p&oS^ zwFmj%Z@F}h-RO4I{&B>@=u@ojD}Llg{ShK5_jN$05PXj++po;(rtq~{V_mBZUQ|KH z>3&~|<3WwcGebg0+&vy(B%cj$%uC&YdQvZJYyc1I+6I&lUdpyIG)s`(u9~jBrL6RG zPi)tT2)`g6pE6*Pc7hw4vvg_Ekx@Uxx42zWM zb()_Lg^@c;hi_3Yr0m}zi0v>w)@`2Le3ZMlxrH_Eabe=K?wqkpG132oOGwG@kslN5 zt>-8i-dq^tI+@`j!ZTI+^4dObvlm27aNH-!_dUzobJ-Uo&j_opcN{-qC7`u)Pc`q& zNh#7lMz?S7>)-mcUaMzTPE+6Y(soPM-X|61Sy_Vli#hz8Ib^9Z<+^D#QuulqUoq~I zYJh(`u`#S-|BZHzu8!AsWIfJDkBWa9UHi@3CD7h0(PSmMl~1csddSM?iuFqtn?&I& zA38bfPO7D_$|ZH>hVhkK{R)$2%@esHrWC6+Xo3|NQOGXjYVA+Hrn75SB~b3GS4mrF z<`|;&%+6A!!@J*?Q{99zv{Cqy1YemAqV=}1(BW&u;jtrBi^HG(sO&FKcbw~& zYwXp&vadW9Ln+;?r5%1Pv+9QS#HLz*&TCnQ*KQ(BubOUGo*211a8M(a3!7NnD4G_> z#oJsSq!VBH`o_q!g78N*7LSa-=NFqM9XXRama@l;7Xd^4X~mo#1)K1#dL{U{+;rFD zxG-IP%NCgsP^o*ah`PQd#`LaP{@rYrvM% zZ}=6gZ;yrCkZiT?m=UNR51D@BU-y)%|KX{Rjpm2BIfR?Ccr%fjTN#?w_Ume@oAO}n z%Zu$#^_8s)-V9IO@tB&qtMkFmBg?XTVVaDU9JjmV@M(EUBU&Y@Ue*I@D}h+f9_Fu%X`zKQcBR2{l4VM z@Rr~_F%QjAyPwl3)Mo8bwy!_Jo^`6g4!I`A%E#g!iW&wg*nj(((nRwUiO$?|F8cYn zW=CA3iA#!!#sK$_l+B?K^iBcfOmkPUlDfo0TUSZDQ!M18R%Scx6$|sHg3GQP0a1s- zURr&d+kX3K)n5LoezCoIaUDl>_CLCm#m&K{Qc_faYTNm#%={tsP^y@Tcf)W*LVZlE zQCEpcfQo%$lli9z(KiNtjrol}(x2lV)jCJysT`Vl%*9pweO_{`Ps52d=B5ZBURS1Y zz~@^MG<4tYOS&T14Dsp}E4swvp=%jP)zYM{Z$Y*fOKqgmdA@7sAihw}uxHB+Z7kc! z+9aIe=4EB5^t#fXnpAk9yyz+KB-?@1wuU>K8;k}c2xd8N*=(FQD0QD~XcB9;ng9=W zU4~)3=WQ}FxK{V@{yXkz>Cd=IYpfiuVVc|Bhoqv+v|dmWTHr=MULn?>713b}7|nJxlK)XE_V0D198QCu8WokqqN_4Xsb)Jr?TXIKTCXu{B;FKu zz%UEa`_cHT&}@se_KmM^!Wstm>f%pAL)eL)O`SaG7`Rh-Tz}W2FzLv^n!Ud0y^Cq5~hf{|{T+DQWPgWF_ z9UB)3#;1 ztz~CkytxyyVKS|a^p$XI%eX<%+*toiWU&%Y7e|?%%CDiK zEZn!;U$80UJ0mh%4B~G?{{CoBI#iNPQ2G+5-uS+0WD;?iRUcz`H2Eqt||sgxf{Utb-JrHP6@SUSA9k zJo(|nDKBd)<$H5sp};67aKVb`XK=6WM^C)p!z;h)l!B+`b8|xjLVadF--|!>OWCrv z`rSLpsS(8~`}gr2&u;&!3HdWIA2|5V-Vn)w>TlgXB)-|1dul{qawvnpDL$ipp^`uF z#vJPozX3I*gQvZp{}x{d8xO|P%hnZ%g&_>YSm20c3?RdzG#@+;!Ga|NK+IslQo!3_ zBCsV{fZZ?`;Gp;-V9=o=2ofniEHIp^a2Y6D^8h&g#;8$N~0bD&Jj_x-mqDASF5a6-`3bgC>0)irnR(s$S0vu67 z(XAW_3`&=^yvKDsEtVeuZ+>d~z&sG#@&3e=}bfm@a?0B0Z!^c1ClsnsY8mH_ryTd`pA;IefX z3zi6InMXmuaUCfl2|}wwL<)q~gwR?L2?wEdAQBNmAAr#M5Q&Z-L;{4(9s~r?HX>3; zbO2rZ5SbXZm-Qc24J_{B{Q?}j4M#U1Dsa8^AaA8SH zZ_xR$xfHV}+o#%v*&u)MN|U#Tyu{B z2aHibnIsKVPOS&N9!J3YBPd#@w*UgTo1kc8C&Un7!UP4jo(cjQri|a+dvD@R+6nk- zV+@gIh{0bs+>afGVJth&@6PdX%JKAAN{WrWCtF9#L-w95)^3(_XD3?UJ$tLmx~}Yq zARZh#EC>Y61Zl;m#1WwKm=LWmKn?+>%!NR7NG+JKK+#N2V-Vo9C5jbGB!avYNvffq zfVu?do8UMRghWivQ-iDVJ#${%)3@90BO@F6qK*ez4e(&uZV$v=+@r)RXpuPkv^1pm z(s9}1$ntetdRuMJNMKC7cax7@4U_*NgH?iKC2p4 z`+P1JOE5XCGw5%0qz2hFWlnrP9OjfG_5FvjnarNPMBC_dktP-U%L9%ay|Qge?rdP4 z`K!+zljvX33L{{Hl?+IMr93nf@A21TIMss>{=?Zj3eNaaHZ?7HY|u?wf1g!q3$Lw0 z2yZs%%X~gCYiQ&X^xC*Rd$?9&F_csPlx{_|%b9=x$x6;Z?&N!XdsLmV*V`;3Kf3mC z*NkshUsm70#g_zwcS?f5=}b8b3AeQLjOYVR89X%8$S4GYv?QS4qj(VDj59;fFhC>P z1jx7u(XL0!Aizmi6!;X=2uj_Afbt<}@FlhgWVoY1Nt`pld7v2baydonzvv^A!uZ(Q zI+vrrEhl?iO^_?M(MT}uLX)C!fN_o=K1y9#a^KlKHWHUNzs~%=dB+X^y3BvphYkGR zY$ZFC8QwbtoG;rF9=pX~j|lE}b}28b_D-}LISw@)o1x{X9eu(jZ9vM?u=cMBp3c6{ z9;D2Al=$Kc^jfxIhCjjeeWAe7G8@^d_zz~0Z9(Kk871N5wg!KVyW76Lse)*?VJ(~*ThX2>r$ z9zxrBd3e~+uP_j}AW5ZPJoJ)6p&7Rv?dNdP!4o3T^?6BZ=(g@&p3rK&W(YJyq5p4( zMB6i@5A6tHG4!pSA+!s9MTXGs5EhG}?*h?f3hhn*gZ|W)zCT1)iNCa5$PXecE6e}H zoIp&y_?(46ptWBVU;~^+JXCdvz*G`Vh{)6tEr`g}k%JJCsUvm}kttqGzn6pr$viRf zKg}lor`hzAEvdI7MEqN>>8)uAc7{lFI=Yx5>E|Lb`P?BAlh2dUx|R%=qD5lrr58kE zQZa;?-q)5)yde@(C;z9*q<=-5q14N|Oz(cn=F7TFUgl>6kIdxvgUCz#NM^Rk{|Yue zf#iQ>dP$hcOlp6K%si+6k}i|~m1(*zlb86A=q2T)EK~e17mTgFHqj?0MjO?_LRjYh zS}?sx04A?qlFU4`7Q9Ep!PVyIiNiLYe)?XXUVBb>x?pf>>JSA8<&!p9(%Z1t2~U3r z`&Zwlz`!>@oEt-i>D7Xz?8achV*Vx;3Jz>3kavNJ6h^OO{DV9p!tjiSkHJv?IxYrJV5Dx9A&D^$tis5I)knkNDU5usv?E~GIE^9T z8Nf6!(u^Tf);dVR5hx&`P?CzFkTI(xO0P%8(71vP zi@`A_sZ|(Z&Cc{fT9Y*_hO*|ESS*e)G}06P*D>j||BoS=F>6*OE{$jE{wtBouf}Sg{oR8dbzn*6s&m zu!Pl-hA~tec6F#>3~`M>!I-s02-8b_bxP=3PFWp(MqRGHyD&ZaOV0c)crcc-CdV+n zw$_l)>v9bVOlEwmt%w$k!>lEy7x3TCU>R?~I1*!cSoty@yOxAVp%6iR*){_GkZ2<> rFGfdO>exD-j$ROjUfs)|rhfi5zWxEe4vt8ege4G=($bm-wUGY@t_J_T diff --git a/demo_files/~$demo_input.xlsx b/demo_files/~$demo_input.xlsx deleted file mode 100644 index 5a932052db2a5d1e1d32a453f59be330b8becc3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165 zcmWgj%}g%JFV0UZQSeVo%S=vH2rW)6QXm9G8GIQs8Il=_81fm4fjEt!gh7G9A4sQx R#Z!U2P@qgIP=x};5CA3W7%cz* diff --git a/gui.py b/gui.py index e84079a..a2fe156 100644 --- a/gui.py +++ b/gui.py @@ -160,48 +160,31 @@ def run_ecoff(self): individual_results[col] = (fitter, result) - text = "ECOFF RESULTS\n" - text += "=====================================\n\n" + text = "ECOFF RESULTS\n=====================================\n\n" - if len(individual_results.keys())>1: - text += "GLOBAL FIT\n" - text += "-------------------------------------\n" - text += f" ECOFF: {global_result[0]:.4f}\n" - text += f" z-value: {global_result[1]:.4f}\n" - for i in range(global_fitter.distributions): - if global_fitter.distributions > 1: - text += f" Component {i+1}:\n" - text += f" mu = {dilution_factor**global_fitter.mus_[i]:.4f}\n" - text += f" sigma (folds) = {dilution_factor**global_fitter.sigmas_[i]:.4f}\n" - text += "\n" - - text += "INDIVIDUAL FITS:\n" - text += "-------------------------------------\n" + if len(individual_results) > 1: + global_report = GenerateReport.from_fitter(global_fitter, global_result) + text += global_report.to_text("GLOBAL FIT") + text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" + + else: + global_report = GenerateReport.from_fitter(global_fitter, global_result) + + # Individual fits + for name, (fitter, result) in individual_results.items(): + rep = GenerateReport.from_fitter(fitter, result) + text += rep.to_text(label=name) - for col, (fitter, result) in individual_results.items(): - text += f"{col}\n" - text += f" ECOFF: {result[0]:.4f}\n" - text += f" z-value: {result[1]:.4f}\n" - for i in range(fitter.distributions): - if fitter.distributions>1: - text += f" Component {i+1}:\n" - text += f" mu = {dilution_factor**fitter.mus_[i]:.4f}\n" - text += f" sigma (folds) = {dilution_factor**fitter.sigmas_[i]:.4f}\n" - text += "\n" if outfile: if len(individual_results.keys())==1: validate_output_path(outfile) - report = GenerateReport.from_fitter(global_fitter, global_result) if outfile.endswith(".pdf"): - report.save_pdf(outfile) + global_report.save_pdf(outfile) else: - report.write_out(outfile) - text += f"\nSaved global report to: {outfile}" + global_report.write_out(outfile) elif (len(individual_results.keys()))>1: # Build section reports - global_report = GenerateReport.from_fitter(global_fitter, global_result) - indiv_reports = { name: GenerateReport.from_fitter(fitter, result) for name, (fitter, result) in individual_results.items() diff --git a/src/ecoff_fitter/cli.py b/src/ecoff_fitter/cli.py index e49c5f6..cb92d13 100644 --- a/src/ecoff_fitter/cli.py +++ b/src/ecoff_fitter/cli.py @@ -9,11 +9,13 @@ import argparse from typing import Any, List, Optional from ecoff_fitter import ECOFFitter -from ecoff_fitter.report import GenerateReport +from ecoff_fitter.report import GenerateReport, CombinedReport from ecoff_fitter.defence import validate_output_path +from ecoff_fitter.utils import read_multi_obs_input from unittest.mock import MagicMock, patch + def build_parser() -> argparse.ArgumentParser: """Create and configure the command-line argument parser.""" parser = argparse.ArgumentParser( @@ -27,7 +29,7 @@ def build_parser() -> argparse.ArgumentParser: required=True, help=( "Path to the input MIC dataset (CSV, TSV, XLSX, or XLS) " - "with columns 'MIC' and 'observations'." + "with columns 'MIC' and assay name." ), ) parser.add_argument( @@ -80,28 +82,74 @@ def main(argv: Optional[List[str]] = None) -> None: parser = build_parser() args = parser.parse_args(argv) - fitter = ECOFFitter( - input=args.input, - params=args.params, - dilution_factor=args.dilution_factor, - distributions=args.distributions, - boundary_support=args.boundary_support, - ) - - result = fitter.generate(percentile=args.percentile) - - report = GenerateReport.from_fitter(fitter, result) - - report.print_stats(args.verbose) - - if args.outfile: - - validate_output_path(args.outfile) - - if args.outfile.endswith(".pdf"): - report.save_pdf(args.outfile) - else: - report.write_out(args.outfile) + try: + + data_dict = read_multi_obs_input(args.input) + df_global = data_dict['global'] + df_individual = data_dict['individual'] + + global_fitter = ECOFFitter( + input=df_global, + params=args.params, + distributions=args.distributions, + boundary_support=args.boundary_support, + dilution_factor=args.dilution_factor + ) + + global_result = global_fitter.generate(percentile=args.percentile) + + individual_results = {} + for col, subdf in df_individual.items(): + + fitter = ECOFFitter( + input=subdf, + params=args.params, + dilution_factor=args.dilution_factor, + distributions=args.distributions, + boundary_support=args.boundary_support, + ) + + result = fitter.generate(percentile=args.percentile) + individual_results[col] = (fitter, result) + + text = "\n\nECOFF RESULTS\n=====================================\n\n" + + global_report = GenerateReport.from_fitter(global_fitter, global_result) + + if len(individual_results) > 1: + text += global_report.to_text("GLOBAL FIT") + text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" + + # Individual fits + for name, (fitter, result) in individual_results.items(): + rep = GenerateReport.from_fitter(fitter, result) + text += rep.to_text(label=name) + + if args.outfile: + validate_output_path(args.outfile) + if len(individual_results.keys())==1: + + if args.outfile.endswith(".pdf"): + global_report.save_pdf(args.outfile) + else: + global_report.write_out(args.outfile) + elif (len(individual_results.keys()))>1: + # Build section reports + indiv_reports = { + name: GenerateReport.from_fitter(fitter, result) + for name, (fitter, result) in individual_results.items() + } + # Build combined PDF + combined = CombinedReport(args.outfile, global_report, indiv_reports) + if args.outfile.endswith(".pdf"): + combined.save_pdf(args.outfile) + else: + combined.write_out(args.outfile) + + print (text) + + except Exception as e: + print ('Error', str(e)) if __name__ == "__main__": diff --git a/src/ecoff_fitter/report.py b/src/ecoff_fitter/report.py index 6fe2df6..f817e5c 100644 --- a/src/ecoff_fitter/report.py +++ b/src/ecoff_fitter/report.py @@ -81,48 +81,44 @@ def intervals( self.fitter.define_intervals(), ) - def print_stats(self, verbose: bool = False) -> None: - print(f"\nECOFF (original scale): {self.ecoff:.2}") - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - print(f"μ: {self.dilution_factor**mu:.2f}") - print(f"σ (folds): {self.dilution_factor**sigma:.2f}") - else: - print("\nComponent means and sigmas (original scale):") - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - print( - f" μ{i}: {self.dilution_factor**mu:.4f}, " - f"σ{i} (folds): {self.dilution_factor**sigma:.4f}" - ) + def to_text(self, label: str | None = None, verbose: bool = False) -> str: + """ + Produce the exact text representation currently created inside the GUI. + `label` is optional (e.g., column name or 'GLOBAL FIT'). + """ + lines = [] + + if label: + lines.append(f"{label}") + lines.append("-------------------------------------") + + lines.append(f" ECOFF: {self.ecoff:.4f}") + # z-values stored in self.z are ECOFF values for 99, 97.5, 95 percentiles + lines.append(f" log scale: {np.log2(self.ecoff):.4f}\n") + + # Mixture components + for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): + prefix = f" Component {i}:" if self.distributions > 1 else "" + mu_line = f" μ = {self.dilution_factor ** mu:.4f}" + sigma_line = f" σ (folds) = {self.dilution_factor ** sigma:.4f}" + if prefix: + lines.append(prefix) + lines.append(mu_line) + lines.append(sigma_line) + + # Verbose model details if verbose and self.model is not None: - print("\n--- Model details ---") - print(self.model) + lines.append("") + lines.append("--- Model details ---") + lines.append(str(self.model)) - def write_out(self, path: str) -> None: - z0, z1, z2 = self.z + return "\n".join(lines) + "\n" + + def write_out(self, path: str) -> None: with open(path, "w") as f: - f.write(f"ECOFF: {self.ecoff:.2f}\n") - f.write(f"99th percentile: {z0:.2f}\n") - f.write(f"97.5th percentile: {z1:.2f}\n") - f.write(f"95th percentile: {z2:.2f}\n") - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - f.write( - f"μ: {self.dilution_factor**mu}, " - f"σ (folds): {self.dilution_factor**sigma}\n" - ) - else: - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - f.write( - f"μ{i}: {self.dilution_factor**mu}, " - f"σ{i} (folds): {self.dilution_factor**sigma}\n" - ) + f.write(self.to_text()) print(f"\nResults saved to: {path}") @@ -136,9 +132,13 @@ def save_pdf(self, outfile: str) -> None: def _make_pdf(self, title: Optional[str] = None) -> Figure: fig, (ax_plot, ax_text) = plt.subplots( - nrows=1, ncols=2, figsize=(10, 4), gridspec_kw={"width_ratios": [2, 1]} + nrows=1, + ncols=2, + figsize=(10, 4), + gridspec_kw={"width_ratios": [2, 1]} ) + # Plot area low_log, high_log, weights = self.intervals plot_mic_distribution( @@ -149,53 +149,35 @@ def _make_pdf(self, title: Optional[str] = None) -> Figure: dilution_factor=self.dilution_factor, mus=self.mus, sigmas=self.sigmas, - log2_ecoff=np.log2(self.ecoff) if self.ecoff else None, + log2_ecoff=np.log2(self.ecoff), ax=ax_plot, ) - ax_plot.legend(fontsize=7, frameon=False) if title: ax_plot.set_title(title) - # Right-hand text ------------- - z0, z1, z2 = self.z + ax_plot.legend(fontsize=7, frameon=False) + + # Text area ax_text.axis("off") - lines = [ - f"ECOFF: {self.ecoff:.2f}", - f"99th percentile: {z0:.2f}", - f"97.5th percentile: {z1:.2f}", - f"95th percentile: {z2:.2f}", - ] - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - lines += [ - f"μ: {self.dilution_factor**mu:.2f}", - f"σ: {self.dilution_factor**sigma:.2f}", - ] - else: - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - lines.append( - f"μ{i}: {self.dilution_factor**mu:.4f}, " - f"σ{i} (folds): {self.dilution_factor**sigma:.4f}" - ) + # re-use the unified report formatter + text = self.to_text(label=None if title is None else title) ax_text.text( 0.05, - 0.9, - "\n".join(lines), - fontsize=11, + 0.95, + text, + fontsize=10, va="top", - family="monospace", + family="monospace" ) fig.tight_layout(rect=(0, 0, 1, 0.95)) - return fig + class CombinedReport: def __init__( self, diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..9197a55 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,85 @@ +import pytest +from unittest.mock import MagicMock +import ecoff_fitter.cli as cli + + +# ------------------------------------------------------------ +# Fixtures that safely isolate CLI from real dependencies +# ------------------------------------------------------------ + +@pytest.fixture +def mock_loader(monkeypatch): + """ + Pretend that the input contains only a single global dataset + and no individual datasets. + """ + monkeypatch.setattr( + cli, + "read_multi_obs_input", + lambda path: {"global": MagicMock(), "individual": {}}, + ) + + +@pytest.fixture +def mock_fitter(monkeypatch): + """Mock ECOFFitter so it always returns a simple fixed result.""" + mock_instance = MagicMock() + mock_instance.generate.return_value = (4.0, 2.0, 1.0, 0.5) + + monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock_instance)) + return mock_instance + + +@pytest.fixture +def mock_report(monkeypatch): + """Mock GenerateReport.from_fitter.""" + report_instance = MagicMock() + report_cls = MagicMock() + report_cls.from_fitter.return_value = report_instance + + monkeypatch.setattr(cli, "GenerateReport", report_cls) + return report_instance + + +@pytest.fixture +def mock_validate(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(cli, "validate_output_path", mock) + return mock + + +# ------------------------------------------------------------ +# Parser tests +# ------------------------------------------------------------ + +def test_parser_accepts_basic_args(): + parser = cli.build_parser() + args = parser.parse_args(["--input", "data.csv", "--percentile", "95"]) + assert args.input == "data.csv" + assert args.percentile == 95 + + +def test_parser_requires_input(): + parser = cli.build_parser() + with pytest.raises(SystemExit): + parser.parse_args([]) + +def test_main_runs_minimal(mock_loader, mock_fitter, mock_report, capsys): + cli.main(["--input", "fake.csv"]) + # Output contains header + out = capsys.readouterr().out + assert "ECOFF RESULTS" in out + # ECOFFitter used + assert cli.ECOFFitter.called + # global fitter generate called + mock_fitter.generate.assert_called_once() + + +def test_main_outfile_txt(mock_loader, mock_fitter, mock_report, mock_validate): + cli.main(["--input", "fake.csv", "--outfile", "results.txt"]) + mock_validate.assert_called_once_with("results.txt") + + +def test_main_outfile_pdf(mock_loader, mock_fitter, mock_report, mock_validate): + cli.main(["--input", "fake.csv", "--outfile", "report.pdf"]) + mock_validate.assert_called_once_with("report.pdf") diff --git a/tests/test_report.py b/tests/test_report.py index 678d07b..da47e88 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,29 +1,35 @@ import io import sys import pytest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch, call, ANY import ecoff_fitter.cli as cli import ecoff_fitter.report as report -# have called a combination of cli.GenerateREport and report.GenerateReport for depth +@pytest.fixture +def mock_loader(monkeypatch): + """ + Ensures CLI never touches the filesystem and always receives + one global dataset and zero individual datasets (simplest case). + """ + monkeypatch.setattr( + cli, + "read_multi_obs_input", + lambda _: {"global": MagicMock(), "individual": {}}, + ) + @pytest.fixture def mock_fitter(monkeypatch): - """Mock ECOFFitter to avoid real fitting.""" mock = MagicMock(name="ECOFFitterMock") - - # New result format: (ecoff, z, mu, sigma) mock.generate.return_value = (4.0, 2.0, 1.0, 0.5) - monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock)) return mock @pytest.fixture def mock_report(monkeypatch): - """Mock GenerateReport to avoid plotting and file writing.""" report_instance = MagicMock(name="ReportInstance") report_cls = MagicMock() report_cls.from_fitter.return_value = report_instance @@ -33,29 +39,23 @@ def mock_report(monkeypatch): @pytest.fixture def mock_validate(monkeypatch): - """Mock validate_output_path to skip filesystem checks.""" mock = MagicMock() monkeypatch.setattr(cli, "validate_output_path", mock) return mock -# ----------------------------- -# Basic parser and help tests -# ----------------------------- +# Basic parser tests def test_build_parser_help(capsys): - """Parser should show usage and required arguments.""" parser = cli.build_parser() with pytest.raises(SystemExit): parser.parse_args([]) - captured = capsys.readouterr() - output = captured.err or captured.out - assert "usage:" in output.lower() + output = (capsys.readouterr().err or "").lower() + assert "usage" in output assert "--input" in output def test_parser_accepts_basic_args(): - """Parser should correctly interpret key CLI arguments.""" parser = cli.build_parser() args = parser.parse_args( ["--input", "data.csv", "--distributions", "2", "--percentile", "97.5"] @@ -65,253 +65,152 @@ def test_parser_accepts_basic_args(): assert args.percentile == 97.5 -# ----------------------------- -# Integration-like CLI tests -# ----------------------------- +# CLI integration tests -def test_main_runs_with_minimal_args(mock_fitter, mock_report, monkeypatch): - """Main should call ECOFFitter and GenerateReport correctly.""" - argv = ["--input", "fake.csv"] +def test_main_runs_with_minimal_args(mock_loader, mock_fitter, mock_report): + cli.main(["--input", "fake.csv"]) - cli.main(argv) - - cli.ECOFFitter.assert_called_once_with( - input="fake.csv", - params=None, - dilution_factor=2, - distributions=1, - boundary_support=1, - ) + # ECOFFitter MUST be called at least once + assert cli.ECOFFitter.called + # global fitter generate() must run once mock_fitter.generate.assert_called_once_with(percentile=99.0) - cli.GenerateReport.from_fitter.assert_called_once() - - -def test_main_verbose_flag_triggers_print(mock_fitter, mock_report, capsys): - """Verbose flag should pass through to print_stats(verbose=True).""" - argv = ["--input", "data.csv", "--verbose"] - cli.main(argv) - mock_report.print_stats.assert_called_once_with(True) - - -def test_main_outfile_txt(mock_fitter, mock_report, mock_validate): - """If outfile ends with .txt, report.write_out() should be used.""" - argv = ["--input", "file.csv", "--outfile", "result.txt"] - cli.main(argv) - mock_validate.assert_called_once_with("result.txt") - mock_report.write_out.assert_called_once_with("result.txt") - mock_report.save_pdf.assert_not_called() + # one GenerateReport created + cli.GenerateReport.from_fitter.assert_called() -def test_main_outfile_pdf(mock_fitter, mock_report, mock_validate): - """If outfile ends with .pdf, report.save_pdf() should be used.""" - argv = ["--input", "file.csv", "--outfile", "report.pdf"] - cli.main(argv) - mock_validate.assert_called_once_with("report.pdf") - mock_report.save_pdf.assert_called_once_with("report.pdf") - mock_report.write_out.assert_not_called() -def test_main_invalid_percentile_raises(monkeypatch): - """Percentile outside 0–100 should raise AssertionError.""" +def test_main_invalid_percentile_prints_error(mock_loader, monkeypatch, capsys): mock_fitter = MagicMock() - mock_fitter.generate.side_effect = AssertionError( - "percentile must be between 0 and 100" - ) + mock_fitter.generate.side_effect = AssertionError("percentile must be between 0 and 100") + monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock_fitter)) - argv = ["--input", "data.csv", "--percentile", "200"] - with pytest.raises(AssertionError): - cli.main(argv) + cli.main(["--input", "data.csv", "--percentile", "200"]) + + out = capsys.readouterr().out.lower() + assert "error" in out + assert "percentile" in out -# ----------------------------- -# GenerateReport.from_fitter -# ----------------------------- +# GenerateReport tests def test_generate_report_from_fitter_single_distribution(): - """from_fitter should correctly build a report for 1-distribution models.""" fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 - fitter.mus_ = [1.0] fitter.sigmas_ = [0.5] - fitter.define_intervals.return_value = ("low", "high", "weights") + fitter.compute_ecoff.side_effect = [(10,), (8,), (6,)] - fitter.compute_ecoff.side_effect = [ - (10.0,), # 99 - (8.0,), # 97.5 - (6.0,), # 95 - ] - - # result tuple is ignored for mus/sigmas in the new API - result = (4.0, "ignored", 1.0, 0.5) - - r = report.GenerateReport.from_fitter(fitter, result) + r = report.GenerateReport.from_fitter(fitter, (4.0, None)) assert r.ecoff == 4.0 assert r.mus == [1.0] assert r.sigmas == [0.5] - - assert r.z == (10.0, 8.0, 6.0) - assert r.intervals == ("low", "high", "weights") - + assert r.z == (10, 8, 6) def test_generate_report_from_fitter_two_distributions(): - """from_fitter should correctly build a report for 2-distribution models.""" fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] fitter.sigmas_ = [0.2, 0.5] - fitter.define_intervals.return_value = ("low", "high", "weights") + fitter.compute_ecoff.side_effect = [(10,), (8,), (6,)] - fitter.compute_ecoff.side_effect = [ - (10.0,), # 99 - (8.0,), # 97.5 - (6.0,), # 95 - ] + r = report.GenerateReport.from_fitter(fitter, (4.0, None)) - # result tuple is ignored for mus/sigmas in new API - result = (4.0, "ignored", 1.0, 0.2, 2.0, 0.5) - - r = report.GenerateReport.from_fitter(fitter, result) - - assert r.ecoff == 4.0 assert r.mus == [1.0, 2.0] assert r.sigmas == [0.2, 0.5] + assert r.z == (10, 8, 6) - assert r.z == (10.0, 8.0, 6.0) - assert r.intervals == ("low", "high", "weights") - - -# ----------------------------- -# Write-out tests -# ----------------------------- +# write_out def test_generate_report_write_out(tmp_path): - """write_out should write correct text output for a 2-distribution report.""" path = tmp_path / "out.txt" - # ---- Mock a minimal fitter ---- fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] + fitter.mus_ = [1, 2] fitter.sigmas_ = [0.2, 0.4] fitter.define_intervals.return_value = ("a", "b", "c") - # ---- Build report using new API ---- - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10.0, 8.0, 6.0), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) r.write_out(str(path)) text = path.read_text() - # Check essential content - assert "ECOFF: 4.00" in text - assert "99th percentile: 10.00" in text - assert "97.5th percentile: 8.00" in text - assert "95th percentile: 6.00" in text + assert "ECOFF: 4.0000" in text + assert "log scale:" in text + + # Rounded 4-decimal sigma outputs + assert "1.1487" in text # 2**0.2 + assert "1.3195" in text # 2**0.4 + - # Component values must still appear exactly - assert f"{2**1.0}" in text - assert f"{2**0.2}" in text - assert f"{2**2.0}" in text - assert f"{2**0.4}" in text +# save_pdf tests @patch("ecoff_fitter.report.plot_mic_distribution") @patch("ecoff_fitter.report.PdfPages") @patch("ecoff_fitter.report.plt") def test_generate_report_save_pdf(mock_plt, mock_pdf, mock_plot): - """save_pdf should produce a PDF using PdfPages.""" - fake_fig = MagicMock(name="Figure") - fake_ax1 = MagicMock(name="PlotAxis") - fake_ax2 = MagicMock(name="TextAxis") + fake_fig = MagicMock() + fake_ax1 = MagicMock() + fake_ax2 = MagicMock() mock_plt.subplots.return_value = (fake_fig, (fake_ax1, fake_ax2)) mock_pdf.return_value.__enter__.return_value = MagicMock() - # ---- mock fitter ---- fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 - fitter.mus_ = [1.0] + fitter.mus_ = [1] fitter.sigmas_ = [0.2] fitter.define_intervals.return_value = ("low", "high", "weights") - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10.0, 8.0, 6.0), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) r.save_pdf("out.pdf") mock_pdf.assert_called_once_with("out.pdf") - mock_plot.assert_called_once() - fake_ax1.legend.assert_called_once() fake_ax2.axis.assert_called_once_with("off") mock_plt.close.assert_called_once_with(fake_fig) -def test_generate_report_print_stats_single_dist(capsys): - # ---- Mock fitter ---- + +# to_text tests +def test_generate_report_to_text_single_dist(): fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 fitter.mus_ = [1.0] fitter.sigmas_ = [0.5] - fitter.define_intervals.return_value = ("a", "b", "c") - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10, 8, 6), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) + text = r.to_text() - r.print_stats(verbose=False) - out = capsys.readouterr().out + assert "ECOFF: 4.0000" in text + assert "log scale: 2.0000" in text + assert "μ = 2.0000" in text + assert "σ (folds) = 1.4142" in text - assert "ECOFF (original scale): 4" in out - assert "μ: 2.00" in out # 2**1.0 - assert "σ (folds): 1.41" in out # 2**0.5 - assert "Model details" not in out -def test_generate_report_print_stats_two_dist_verbose(capsys): - # ---- Mock fitter ---- +def test_generate_report_to_text_two_dist_verbose(): fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] + fitter.mus_ = [1, 2] fitter.sigmas_ = [0.2, 0.5] - fitter.define_intervals.return_value = ("a", "b", "c") fitter.model_ = "FAKE_MODEL_DETAILS" - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10, 8, 6), - ) - - r.print_stats(verbose=True) - out = capsys.readouterr().out - - # WT component (lowest mean) - assert "μ1: 2.0000" in out - assert "σ1 (folds): 1.1487" in out # 2**0.2 - - # Resistant component - assert "μ2: 4.0000" in out - assert "σ2 (folds): 1.4142" in out # 2**0.5 - + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) + text = r.to_text(verbose=True) - assert "Model details" in out - assert "FAKE_MODEL_DETAILS" in out + assert "Component 1" in text + assert "Component 2" in text + assert "--- Model details ---" in text + assert "FAKE_MODEL_DETAILS" in text From 4c982486ff819c3c662d4991199493a7dfe504b7 Mon Sep 17 00:00:00 2001 From: DylanAdlard Date: Thu, 18 Dec 2025 14:44:36 +0200 Subject: [PATCH 2/3] multi obs --- src/ecoff_fitter/report.py | 32 ++++++++++++++++++++++++++-- tests/test_cli.py | 43 +++++++++++++++++++++++++++++++------- tests/test_ecoff_fitter.py | 10 ++------- tests/test_report.py | 42 +++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/ecoff_fitter/report.py b/src/ecoff_fitter/report.py index f817e5c..7c945c3 100644 --- a/src/ecoff_fitter/report.py +++ b/src/ecoff_fitter/report.py @@ -177,7 +177,6 @@ def _make_pdf(self, title: Optional[str] = None) -> Figure: return fig - class CombinedReport: def __init__( self, @@ -186,7 +185,7 @@ def __init__( individual_reports: Dict[str, GenerateReport], ) -> None: """ - outfile: PDF filename + outfile: PDF filename (for save_pdf) global_report: GenerateReport instance individual_reports: dict {column_name: GenerateReport} """ @@ -194,6 +193,33 @@ def __init__( self.global_report = global_report self.individual_reports = individual_reports + def write_out(self, path: str) -> None: + """ + Write a consolidated text report containing: + - Global fit summary + - Individual fit summaries + + Uses GenerateReport.to_text() for formatting consistency. + """ + lines: list[str] = [] + + # ----- GLOBAL REPORT ----- + lines.append("===== GLOBAL FIT =====") + lines.append(self.global_report.to_text(label="GLOBAL FIT")) + + # ----- INDIVIDUAL REPORTS ----- + for name, report in self.individual_reports.items(): + lines.append(f"\n===== INDIVIDUAL FIT: {name} =====") + lines.append(report.to_text(label=name)) + + # Join and write file + text = "\n".join(lines) + + with open(path, "w") as f: + f.write(text) + + print(f"\nCombined text report saved to: {path}") + def save_pdf(self) -> None: from matplotlib.backends.backend_pdf import PdfPages @@ -211,3 +237,5 @@ def save_pdf(self) -> None: plt.close(fig) print(f"Combined PDF saved to {self.outfile}") + + print(f"Combined PDF saved to {self.outfile}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9197a55..16c9145 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,10 +3,6 @@ import ecoff_fitter.cli as cli -# ------------------------------------------------------------ -# Fixtures that safely isolate CLI from real dependencies -# ------------------------------------------------------------ - @pytest.fixture def mock_loader(monkeypatch): """ @@ -48,10 +44,6 @@ def mock_validate(monkeypatch): return mock -# ------------------------------------------------------------ -# Parser tests -# ------------------------------------------------------------ - def test_parser_accepts_basic_args(): parser = cli.build_parser() args = parser.parse_args(["--input", "data.csv", "--percentile", "95"]) @@ -83,3 +75,38 @@ def test_main_outfile_txt(mock_loader, mock_fitter, mock_report, mock_validate): def test_main_outfile_pdf(mock_loader, mock_fitter, mock_report, mock_validate): cli.main(["--input", "fake.csv", "--outfile", "report.pdf"]) mock_validate.assert_called_once_with("report.pdf") + + +def test_main_runs_with_multiple_individuals(monkeypatch, mock_fitter, mock_report, mock_validate): + """ + Ensure the CLI runs when multiple individual datasets are present + and that CombinedReport is invoked. + """ + + # Make two fake individual datasets + fake_global = MagicMock() + fake_indiv1 = MagicMock() + fake_indiv2 = MagicMock() + + def fake_loader(path): + return { + "global": fake_global, + "individual": { + "A": fake_indiv1, + "B": fake_indiv2, + }, + } + + monkeypatch.setattr(cli, "read_multi_obs_input", fake_loader) + + # Mock CombinedReport so we can detect when it's used + combined_instance = MagicMock() + combined_cls = MagicMock(return_value=combined_instance) + monkeypatch.setattr(cli, "CombinedReport", combined_cls) + + cli.main(["--input", "fake.csv", "--outfile", "combined.pdf"]) + + mock_validate.assert_called_once_with("combined.pdf") + + combined_cls.assert_called_once() + combined_instance.save_pdf.assert_called_once() diff --git a/tests/test_ecoff_fitter.py b/tests/test_ecoff_fitter.py index 99f8ec2..192dc44 100644 --- a/tests/test_ecoff_fitter.py +++ b/tests/test_ecoff_fitter.py @@ -5,9 +5,7 @@ from ecoff_fitter import ECOFFitter -# ============================================================ # __init__.py AND PACKAGE IMPORT TESTS -# ============================================================ def test_ecoffitter_importable_from_root(): """Package root must expose ECOFFitter.""" @@ -30,9 +28,7 @@ def fake_main(): assert called["hit"] is True -# ============================================================ # DATA FIXTURES -# ============================================================ @pytest.fixture def simple_data(): @@ -50,9 +46,9 @@ def censored_data(): }) -# ============================================================ + # INITIALIZATION -# ============================================================ + def test_init_with_dataframe(simple_data): fitter = ECOFFitter(simple_data, dilution_factor=2, distributions=1) @@ -62,9 +58,7 @@ def test_init_with_dataframe(simple_data): assert "observations" in fitter.obj_df.columns -# ============================================================ # INTERVAL CONSTRUCTION -# ============================================================ def test_define_intervals_uncensored(simple_data): fitter = ECOFFitter(simple_data) diff --git a/tests/test_report.py b/tests/test_report.py index da47e88..bd757ac 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -214,3 +214,45 @@ def test_generate_report_to_text_two_dist_verbose(): assert "Component 2" in text assert "--- Model details ---" in text assert "FAKE_MODEL_DETAILS" in text + +def test_combined_report_write_out(tmp_path): + # Create output file path + path = tmp_path / "combined.txt" + + # Mock global and individual GenerateReport instances + global_report = MagicMock() + global_report.to_text.return_value = "GLOBAL TEXT\n" + + report_A = MagicMock() + report_A.to_text.return_value = "REPORT A TEXT\n" + + report_B = MagicMock() + report_B.to_text.return_value = "REPORT B TEXT\n" + + # Build CombinedReport + combined = report.CombinedReport( + outfile="dummy.pdf", + global_report=global_report, + individual_reports={ + "A": report_A, + "B": report_B, + }, + ) + + # Act + combined.write_out(str(path)) + + text = path.read_text() + + assert "===== GLOBAL FIT =====" in text + assert "GLOBAL TEXT" in text + + assert "===== INDIVIDUAL FIT: A =====" in text + assert "REPORT A TEXT" in text + + assert "===== INDIVIDUAL FIT: B =====" in text + assert "REPORT B TEXT" in text + + global_report.to_text.assert_called_with(label="GLOBAL FIT") + report_A.to_text.assert_called_with(label="A") + report_B.to_text.assert_called_with(label="B") From 1a68a7ec6c29e70e7e0bf1de47a928dc583c1368 Mon Sep 17 00:00:00 2001 From: DylanAdlard Date: Thu, 18 Dec 2025 14:53:31 +0200 Subject: [PATCH 3/3] outfile fix --- gui.py | 12 +++++++----- src/ecoff_fitter/cli.py | 4 ++-- src/ecoff_fitter/report.py | 6 +++--- tests/test_report.py | 19 ++++++++----------- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/gui.py b/gui.py index a2fe156..07b2547 100644 --- a/gui.py +++ b/gui.py @@ -162,13 +162,12 @@ def run_ecoff(self): text = "ECOFF RESULTS\n=====================================\n\n" + global_report = GenerateReport.from_fitter(global_fitter, global_result) if len(individual_results) > 1: - global_report = GenerateReport.from_fitter(global_fitter, global_result) + text += global_report.to_text("GLOBAL FIT") text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" - else: - global_report = GenerateReport.from_fitter(global_fitter, global_result) # Individual fits for name, (fitter, result) in individual_results.items(): @@ -177,8 +176,8 @@ def run_ecoff(self): if outfile: + validate_output_path(outfile) if len(individual_results.keys())==1: - validate_output_path(outfile) if outfile.endswith(".pdf"): global_report.save_pdf(outfile) else: @@ -192,7 +191,10 @@ def run_ecoff(self): # Build combined PDF combined = CombinedReport(outfile, global_report, indiv_reports) - combined.save_pdf() + if outfile.endswith(".pdf"): + combined.save_pdf() + else: + combined.write_out() for widget in self.plot_frame.winfo_children(): diff --git a/src/ecoff_fitter/cli.py b/src/ecoff_fitter/cli.py index cb92d13..11dd165 100644 --- a/src/ecoff_fitter/cli.py +++ b/src/ecoff_fitter/cli.py @@ -142,9 +142,9 @@ def main(argv: Optional[List[str]] = None) -> None: # Build combined PDF combined = CombinedReport(args.outfile, global_report, indiv_reports) if args.outfile.endswith(".pdf"): - combined.save_pdf(args.outfile) + combined.save_pdf() else: - combined.write_out(args.outfile) + combined.write_out() print (text) diff --git a/src/ecoff_fitter/report.py b/src/ecoff_fitter/report.py index 7c945c3..08515b4 100644 --- a/src/ecoff_fitter/report.py +++ b/src/ecoff_fitter/report.py @@ -193,7 +193,7 @@ def __init__( self.global_report = global_report self.individual_reports = individual_reports - def write_out(self, path: str) -> None: + def write_out(self) -> None: """ Write a consolidated text report containing: - Global fit summary @@ -215,10 +215,10 @@ def write_out(self, path: str) -> None: # Join and write file text = "\n".join(lines) - with open(path, "w") as f: + with open(self.outfile, "w") as f: f.write(text) - print(f"\nCombined text report saved to: {path}") + print(f"\nCombined text report saved to: {self.outfile}") def save_pdf(self) -> None: from matplotlib.backends.backend_pdf import PdfPages diff --git a/tests/test_report.py b/tests/test_report.py index bd757ac..0d8714c 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -216,10 +216,10 @@ def test_generate_report_to_text_two_dist_verbose(): assert "FAKE_MODEL_DETAILS" in text def test_combined_report_write_out(tmp_path): - # Create output file path - path = tmp_path / "combined.txt" + # CombinedReport now writes to its outfile + outfile = tmp_path / "combined.txt" - # Mock global and individual GenerateReport instances + # Mock reports global_report = MagicMock() global_report.to_text.return_value = "GLOBAL TEXT\n" @@ -229,20 +229,16 @@ def test_combined_report_write_out(tmp_path): report_B = MagicMock() report_B.to_text.return_value = "REPORT B TEXT\n" - # Build CombinedReport combined = report.CombinedReport( - outfile="dummy.pdf", + outfile=str(outfile), global_report=global_report, - individual_reports={ - "A": report_A, - "B": report_B, - }, + individual_reports={"A": report_A, "B": report_B}, ) # Act - combined.write_out(str(path)) + combined.write_out() # NO ARGUMENT NOW - text = path.read_text() + text = outfile.read_text() assert "===== GLOBAL FIT =====" in text assert "GLOBAL TEXT" in text @@ -256,3 +252,4 @@ def test_combined_report_write_out(tmp_path): global_report.to_text.assert_called_with(label="GLOBAL FIT") report_A.to_text.assert_called_with(label="A") report_B.to_text.assert_called_with(label="B") +