[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

Noise-control performance reports via .report() (#331)

* Add noise-control performance reports via .report()

Add a one-page PDF .report() fiche to the three noise_control result
types, each laid out with a per-band table beside the result's own plot,
a boxed single-number performance figure and an optional PASS/FAIL
verdict:

- EnclosureResult: machine-enclosure insertion loss (Bies, Hansen &
Howard, section 7.4.2). The table lists the supplied panel transmission
loss R, the interior build-up correction C and the net insertion loss
IL = R - C; the boxed figure is the mean insertion loss with the
external and internal surface areas. verbose=True adds the interior
room constant column. A declared minimum passes when the mean meets it.
- ReactiveSilencerResult: reactive-silencer transmission loss (Munjal
Eq. (3.27); Bies sections 8.8-8.9). The table lists the transmission
loss TL and, when end impedances were given, the insertion loss IL; the
boxed figure is the mean transmission loss with the peak and the device
kind. A declared minimum passes when the mean meets it.
- HvacSpectrumResult: HVAC duct-noise spectrum (Bies Chapter 8; VDI
2081-1). A regenerated-noise spectrum boxes the A-weighted sound power
level with the overall total (lower is better); an attenuation spectrum
boxes the mean attenuation (more is better). verbose=True adds the
A-weighting correction and A-weighted band-level columns.

The three renderers share a two-panel skeleton in
_report/_noise_control_fiche.py and reuse the sound-power table builder,
band labels and header grid. Each accepts an optional metadata header,
states its method basis and renders in English or Spanish.

Register one committed example per fiche under .github/reports/, add
structural and clean-room number-presence tests (EN and ES), and update
the CHANGELOG and the regenerated API reference.

* Address SonarCloud findings on the noise-control renderers

Reduce render_noise_control_fiche below the parameter-count threshold by
fixing the two-panel split widths internally (the three renderers never
overrode them), and lift the HVAC verdict symbol/unit selection out of a
nested conditional into an explicit if/elif/else. No change to rendered
output; the committed example fiches are unaffected.

* Round the requirement to display precision and fit the verbose table

Compare the declared requirement at the same one-decimal precision as the
measured value in the noise-control verdict, so the printed comparison can
never contradict the verdict at the boundary. Also trim the verbose
enclosure table columns to sum to the 64 mm left panel width.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 23, 2026, 1:11 AM +0200) 85053ea0 55e12a3f

+2298
+96
.github/reports/enclosure_insertion_loss_example.pdf
··· 1 + %PDF-1.4 2 + %���� ReportLab Generated PDF document (opensource) 3 + 1 0 obj 4 + << 5 + /F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 6 0 R 6 + >> 7 + endobj 8 + 2 0 obj 9 + << 10 + /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font 11 + >> 12 + endobj 13 + 3 0 obj 14 + << 15 + /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font 16 + >> 17 + endobj 18 + 4 0 obj 19 + << 20 + /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font 21 + >> 22 + endobj 23 + 5 0 obj 24 + << 25 + /BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font 26 + >> 27 + endobj 28 + 6 0 obj 29 + << 30 + /BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font 31 + >> 32 + endobj 33 + 7 0 obj 34 + << 35 + /Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << 36 + /ExtGState << 37 + /gRLs0 << 38 + /CA .3 39 + >> 40 + >> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] 41 + >> /Rotate 0 /Trans << 42 + 43 + >> 44 + /Type /Page 45 + >> 46 + endobj 47 + 8 0 obj 48 + << 49 + /PageMode /UseNone /Pages 10 0 R /Type /Catalog 50 + >> 51 + endobj 52 + 9 0 obj 53 + << 54 + /Author (\(anonymous\)) /CreationDate (D:20000101000000+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) 55 + /Subject (\(unspecified\)) /Title (Machine enclosure insertion loss) /Trapped /False 56 + >> 57 + endobj 58 + 10 0 obj 59 + << 60 + /Count 1 /Kids [ 7 0 R ] /Type /Pages 61 + >> 62 + endobj 63 + 11 0 obj 64 + << 65 + /Filter [ /ASCII85Decode /FlateDecode ] /Length 13279 66 + >> 67 + stream 68 + Gb"/LlZ4)eXljp.(%Z!Ta]Y6U^E;._&$_lThK!LSA_l(BZ;Om`F_!4n&"06afY;n*"402PG^)bOe6sb>ieiP[e'j:oTE0-nJ@:*(s"+1sGVT]b4<M<:SK4dNT"c1[(rPk@qr-f207N`6C<;i5j*Yl7c?Aj5=Se&,*j)b'eRA(sQK!WRj:L]trppq*V<tC\iV<efi!,[tJodmjaWD$'?QYiAL\9ojn_(n9oC['EHEfZK4?>PEkf(N-_t1VQc[Q*dre(3^%N-@#aD<]tdMUB3jgNOJMANH%<W:n,ht*Iq-biqoY0Tn(:(J1sZWG'+l`3S8mD3bIL&.kAZP+tjk2:?04C"I"S6h^*?!R$j%SgAGFk"FYfm2o>PFp\Q*PYjmI5;k7SY*(r\YU*<^,]A(Ho:hQC0@/=s#0f6m-b2B0>AEsT)MFN;?`s_&-%*%FKln$4Zg>>5<BqGjm-@REr9[^coU_77Rph`E\UI?H*kT+p_%94QMIYj0>;i(j`lQsG-qkS7KKOn;TNA9e1gg,Duqh^@HMT7X@;JQLuX/\FqSGO\j71U%V;[4ag];\ldO$an*GlgDDWNK=aP38VqShg%bE@-UFeV,a2,lej7RB4/a[AqKet+qgkY0^&GkDQmJ5^eGP?J5A"mRjQu;>`NP#&]_s@!lEclB4-4U5iY?.of/hb%Vb4(sE3ofZsL\g!o5i83;0.pt-rU&hHm.B@VjDF:u,B924H@9a[r7,`pd*op9$dE%+GrrQ$8;FrA]sp1SMls?"\c]XZGs?%nH[J)!aUUO_s$uA;G>GsoP"jW380E:;S/koWr,&2&:C!ftFT\VBO`ZQo%\"*@ga7NCY>=#VcOVWO:k.QgT"K$a#k[LkmQgXUOa!V8Za\D-Wr-0;I^VEedf,4cmHr$CbG371%R9s%)uA.Mr8)i0nKu\D++e9XF7Ku4ipc)XIDPdLa8c#-_4:ii^>Eb7L;(;&aZ`8aIX?%EAoiKUd9R%NSpbY!V3T.QG?r8g(]FB;]*tiT^]/n#qYM),<.2$i-7;0ZIWV`;Gs39ui#g5A(W3HVA!b&.^34pRXho9>N]!-tc3iI@1)=ZS3uO14;W^b+VH,pt]"4[08H5l(ingAS%%-^R[&Klf$o[ukAc5^8fR(B-$LqIRB/:"gXBb&P`i:Rk6T%hm9dR@*5;\*e?Mi\N"!tTUm3r]%hHkIaj*OeprN8n6%Ska.`B,jb2<.Z^or9tDgt3JIPb;20.`l0T\Y_nDT"c8:]Gc7s_uKU,j,MUOYM6K=X`\*GhS3FPM6-#"[Jaq:n`;].gRGrq[9_7K9hFg.mji7)qbD!lj;^m?henXHWn-pA^\`JdmEM92D'a\bT`2#I>=`:)Ru'GHdF@HFjC_D=<]$5lKcF_Ol!nd_=d1?fQ<@W\L)ji>P13$3DcUipduM:Z1aWjLe]8jrYqFmlD_LA1Xm#Ja[pqKC:i8][@lcCQE<c[Wfr='"A)#2`NpKa4m3<Pn78Pn2>EJ4u8R1)+lr<[ZA:%0W)"0fBO-p\f*eFtBR+lT1K)YIPD'a,>ZPD7YU^]u[Q*K/IGkRljG&H\YDoZuM\slnC[Rh>ugkoo$)/mJOh0EK&9'lh[GI#b^g1k(SS4pLk?GJ,Xp7,5-o.p5QDUJ-Lbf4G);05k&g<-SPJ[T)<igUFFD]5Y<VTr;\dnlA?Fns(md(>WG[(=#PeZXHqQ<<k;eA<<RiE>b&[KI7;VX'>TgC)PC%<4HQIbJQb1RUfX:WN<bnMU75lRuR]NS"J'!dn1A&(G>;$RC%k@j#[cMdq1oYoOc6Gc#sJHZsBb2dT`VlI0O;4h1d+/aTsX8B;;@qcC]"Fa0"i?n><"oe5H*+f*oU#_)aDZgTQknFHgWQg>Pp0QB:BrS<.c(Ms5dgIQ25IXV#U,L#gS-dre\_a^t>VA[BZ]_S<?qU]mueELHKLiKfD7`*h?&nS[O*Xt[o(M,MMi*/6e/OMU8rBT@Bj;@N#b49?^%A!r]/A'j+5XlgR6UCg>!6UZYKrqHg8_ENf4jHisAs?koH6hV+Q\>i]?Fb$g]Sr,qU1lhK7"VCi+b2r.61V"f:X3pQrgCqUBmm>g[Tp*sjpeS;7n0'Q9n?LRqNnmJ>ci1a2p)6On_jM<L@UEQN;'\G#Vgn2)>:I'>^8gH:&9sAiY(km%@C+$W5W-hpGp.N_H>G_SW6:6S>@U56Qd;`>,p)(B:H3'(31"]5eHhC6ucOg+Ca)_#q,mHLfEUYl!,E\FA@XZFVp((g?;0uP=uC6'0SKYcZj!@K<98tQaL]PTkFSG8Aa`Tmc,,#T)K%R)Z<21,#]juHp+iB,*bmY<+Per.EJ`u=Z`Kt-5:NPjBl<KeYETU5#a]^Q<Y#s1ALoH+(?"Ipr+XcUH`-m=KN&RNMXdu9!sF>I>pMuM?i;b.7+6P8Q"uN!N"nCN+0bT\>_U/SpgsmlepqUUHnO8TmDS2LcQ)qSc+^dHYTWd'$;-=9^RKkJN,rM`@?!4jMf'/LGMpeQV`NnL'>bUQT;<[ln.jl/)]]cF3[uClqL0B"DaGH+:Z?4PF4lK?q>dd#Tf$u+]$IY^bk#LTP9Wk)@;UmbS)HT9>X`<!].)q*'48tdM]rKYD%3HA]f61fcGcISWbCf5lbFoj@sB:(22jbJ;OAYGfN0DqHXG>-nu,b_q;rn7HQDN2^K7cE%/q5^lq5?E-3uk12qJ[_$^49!XPeL&[PB/U@Y('MckpL@/D`*,:@I$.f"s"_3Ubb/Ho;Y(_NknLnK;.X[(8ESOh0Q&3<[@^rHdp[O5l_^;1;F/Aos<UBjuC*sa#%.\o,*$4D5`0TfUR;@$'lFd:m$FZs=K9>6Li8n&IV`$J8)=TrP>AnupQ:>k#_C7:&b)fd7G&?@n:8t1)!RKCm=;S'qSBCRSoS4F3qlp##76mdCT"=@]#96*:3XWJ.)3D=#%bh._FQFBC0@5/K"[a;fJ&OD"Hb<eVXfRs7p#74s.3]Ilc?AFu\,EgacaQ@7)lmX]R#u!]:Zc;/216>IXe/_Ri&2sd;)8&M(&YVIZm[Ks5]rg1QdFa-S.E:P]Z.n0lgXt>brUCi=YW?Ga5+JUABpqGY!c7'i#*/p54U`0rdpllh^]:'c&iB890d3SO($;`"iWG$i)2JTZf%OL,l(QY/`Vbr1P6!K`kQSdf2H0K'5=)<Rm)g4.<)Wj!AV(AeWN6q$,etEqCEP`2j>s3%bcsk2^9F=S-.r`.#Q7*n8^!q9?"AQnpL]Y&eK[(A^_:ntj>5E.E5L@/aFYjSMI[s-/(`g,!X(C-D3o2ceEnO2qqL%Rk%dJX_%Y"kb3rGHW,\D34HcZ[8LXJQ7M\8NP"ZcV7/FGpWsShFf[B7SK<q9X^<'uBjcu:U.`9)59njTGPMrM%B"#ND`_N=B6\_[Q/P#NJ8o7`Bd("=S!r#c(k?ptpf\_5acTs(Ibdr</\lnff&1!0=W2f$]_>5Oanq<k!TJOM;-q2`[^+G7VAS*ar/VjJb[,jg,>J;Q!TSpk1UV!U(15!UF9TMS_Su4><&"ED0(YGb0EWpWO"blW\/Eg+m<`j2nV^iiC"6iB[h3+m3gmrO$S/S1sosPq4h8CaY-TP<?8M^rP01N+r<=GjQdhZsFl_:%Q/Y7&i"LqK7q`=2/$-aU4\k!7(XEIs$h56)50o0OY2hRKTMD=I\)T+j9l:F5P_l_QjW6P\?YB%+,XjNQt[!X!QBR)`O-jP]K.QV?QB_!&LkA+1SMVUO6&4%IA_PpbPA-^Xs#ob``fFfMG86hua2A/;qa=`>U&aE=M&Re<JW6a78ajPAd">3H!BjWnPWdD-1/Qaj'5uIu"_S\YI1P-$D#_Q<ir/2qj5RTX/21at2\nbbsP#-S7gQY9BQV\%%lTDAs3u]8k`l8b<">]i&L2kc#h;>`rWr4.rY*'f:G!).;[?qnk\ae(:)uHt](NX(@2a1MgiunhSLNApBU(QeL:-0TD6K1ErNn"S63\p/n;5a;'QHB4^G0NK"fqW34-J`+t?dTurWNR#%j(JR%"&mqkD+7,nVimUA:)OpMI,TU;eD_sd85V7p4DU7T'Hr$Y-C=/aF#D[^?9`X\C)XuE%29;Hj=0])&MN45XKO@:(@dFH3pTrkJR$dD+eN_*Jc-lTFIA.I)")#Xr)IT6M:UhcqoMEJRh&`ICp.-R2s%i/'\IN[i_qpT8Oh!.S=?(U^_G.:(@:oV/5`p$!-Mu$\8q$+CeM0sQY=:_eqS?#6M53Jd/De6D<UB@+286o,L[?2*mfXJN.f/@p`,6G4e=5Y7`&U[4J\+D=M>/AIr\6]H!.l#N"KJOF3D&+]>*I><Vo9oq2uaeMSAO#mrIZ!:?UNL^C_MT-)htW(&1m=.jhr^dHRq%9:.*J-@mb=V6#pU,Y#<l,VTC`)IVVTN0dE=h-9i::Q&oF0a>-#cK9J'GOP@A?2NZSmp6W_P_YdtbAkpM>dPIe/aD<bWJ=)n&_X(+;@i0NrRJedm2pn*+E?EK@V$WV4ZGt;(3_,.N'N<DNofpRjs&^dkG%ElV^*&ThC2k2JsJlT'=KZC\BG@>XniO7?Y:H`hr`>9p0pO@5(\A%Fs5AGR*[ed((s\a^5n(hf*[=Ub9n7giNo4k/YdH&dn.<h]/Na!CL?<%5lQ=Fb^/-lR/$uqGPChBgo>drlN>P"/9c`ncoGCd]DL'"X7h7M%n,#IOiYa`d'^R!5@(a5?g.>O47+AU23\Q]p368gC#>q=U$Dg.2>FHQYY!I?mf%=aWdUT,"49!:Ys^Qmbimd;h4LiS[4cWM+M-!!`*$G*kh=nLT<;D+06DW8X\4R7mi*44o&fr+Q<.g-./H1CV)='O@?[SUh>O=a<Vo,7#GQ"BA!#0EM6?7%rTUmnqdZGf7_E1RS`/Ol("-2oL_"IC^tDi^^qcG]fp^m^?l_177s*_'Irbe58RX,*K/qDA/,eprLBQ#O(^/]Es7#2#M3Iu/3J!*f-2]O`rYpqVaZ.Q;'q?>`ij]dgfpm3*FU)ut$,\'[+WRrrhi>e:UkUur<L^4kMaf2%_fSH!\/+3_"m:KbPD0]0A'f?G%ILe0^Wg1gA)`V'MO@3(ar$QLA9/2MEhe1$&`Oj^"Ba&54WR;BB,sb8n.+qbO6gr2'STnG""#VM&KDI?@JSOn8T+pd?QtN8A(!2ZSGl$kXX>%RidaosMnF$+huJfp4Idqc7,_[-#pL<,R+90m3_nn?M3=mhS4;(6$U5&>^)&`uB[iB_];9kMqf2,V&tejTr:E0%@Gonu$F*^SiqTL@MB[4g>a0kf_Fba?JHd\f8HOY=f3)#9B`]cE%qlOXb/24'<JD6<j&0s*9^r)/r+_DeNQA'TQq:R=2"=cfKV7t7?l<07C3]_jV`ke9,A5R,PTR2r4Cjg2lJ<FR<\Mf>ORf#VM>1U2C\hG:n;J54)Wia4`0U3nI]4)#YEXOM/I9]8&(ZgN3j'3=ih^%8IbO#3km7e=@uR321q#*Ej7!RC*DI#S":lUk(GJ*)`ZQ(i;MX<YLE8j7OSUVbknf"(W)V.AK%;0AADsHlGbh@+7["'H!stK!^C@![F,%"D:?_,)4+sYRjs/Uk5'>dTA2F1*,+r#oLDSB8X!Vknc60ZeAFqCnj/.35'-mgmW^?2/[uliP%$D`qhY,qG&Zpfm?\b@5_X9#s!I4`YL[#.T>Gt'hd`",GW7Is>95BdCC9G*:i@)Wh7FJILO^QIQdA($:];g#D+S8^Qkt7*%j2Ts.^b8UA-ci6c?Ib?1JlX?26]eFD$qWo;Kjc8^.P.UMs&gOs8p@(K:>l',+CBFqM3&tU(Vk$aW,FOg]k-K[XOB)g5&G),8l?0g24t_R[c0*(.3-jr]!olsP>Q'neo^q29NcH?W@Z*H5tkZ`$<k`aoR!pl;1b"[W/\a_+?rPWOl;SulmKi-2BJTN4/\&_l%$m\O(f8!UKcf_4CPI0DHf&-dWiLD7\cQV/M^Ar(F-WR#"]&7K>sMeQK$+H55*n:ab;*.$1'>NgC.`Z&I]$h[;<c[bUAAnaJgA[+)uiu'&LTH+5iOl"^pkb0P1CE(3>GqP%mZMeX9qOW_F[O-TTF[(_ZFrj"uq,Za\J82Y[nCeg>0!fNMPqBsnb0E%mV%H"<^0(uJ4Kfju7L9KVqKnXACF_`QP&-;MKm0*]L,J1LoG],2ZHPr3o/o&?<F:/H<^n:mhV"4#ECiS`2h[V.]uQT3*%!*B"J*#[JgfXC(O/PU<W/Hj]ASJMnYS2>LK=TT1N4'2MPdRo91Jm)>aE3.F!C[8S^lLV^?G>QetV+(nBjk33C^;<0&fh,%Um6R\t'lOb2k]:TV\QV!Q:3i:>"V%ZF,HCiFeUL`A51VQ;![W$c":*sV>(5mp&)baYWLHZdAf1]Na6!hjR?UoZnlhJ41fMa?Z2Ij&GAbNg:#cCN-p@cI'.V7UZ(ZSB2;Zt0e@=7a\0WD`HEA<\PuqjO:]lVAY;sn=GV-JZc4G2^`F*I-d_)1`ka73#mjPMqBlV_42A;Nbgr4bM#:$[ab^H_"HKS.!loiXem.Cq[723\CRkmC^jT5$!]UVn?],"tmK!92DbulO(.)1ikUX_CC3US+^komkCgE\IMZHmU>hW*cXNE!*LcD.@5[-MlCHe04Ye8O<Q(7]]]a"G$@]PU-J1)K8MZ[C3:Eo&\$R)Z6'`dOeW[kUj-BWCEU2g<Q+Qq*0QfZ3t,`:V4Dnq(rla:XklQs2=aZJF']+5L4*Z90H(!S<Yt9aa_P#\lTUe1><1B<nNT/s=8C7&kj75FfNtAV4;(lafAL5A$4pUkYhae<W034M2#tff5+idE41:OLun2SRl3r@^8[N.;RDV9$&6qF>Hc=YA>&I#%j4]dLk?gM'QNtZ#nX=WW5>2lEC];jQ<\jE+!U%)S]!kKd"l")fXb0%B+:O?K7D8lP_D%g!^_kCe%WONb,]/m2WI)27<SLYa.SFcN\2hJI^$>h9GY\48_j#ba`k5E:i03X;!<mG9YU)j$pT;+bKkIhfM]LgDS)Fg/_7%ab'<_ZSLM_-E_e%9't5S'/@k2mUn&@"3VSf"'4!joaZ%>q:%bXb[BOJ'UaS7$[UVNaKb`.3%^WH_s.T\)g(`+Mt]@agTLDOO31LZXI4>(NWD0-X*4otL<":GXNMGoL%bSfAY31Wp6'N-8\2/Ph:T>1a/Li!\EiAO(\\^0]r$i_UskcJjR)r[9CsM5IgNqu6LYcn?nhWs"5mO!!*CMO/1#K3qMT\>!557jaoJZVNe&MFhCX9>-H9K)M/g(_nT2I%ENuAVCML,O4pc"^(73;l=q3L`qMT]);n]&hb*o>`5)BRB&D#?oZYIkkM_=dlFFd$lrWi/O=E/$V;9:"Xg4RD<D0ZQRG&3[lAu"e<hE@SA>KLGP^1`Edn)DXil+6IQ"RYAUmcMW6ZK91CDt`74l6aCi@V4(<Ce#l2B"6(bAc=@Ss'=eP).KAIAM5*8$19N"B.L5nce4aeYNu,SjkdgrP>&A=Qi0>Ar/4i>]anVUBg;U74j<=^@)[d>P3/tQI&.-G:W4WWn\&BM?SBg7+!A/gqY>_29R@BU;l0q.\Zc_o]udF.9hFn!84e+O;D^eZis[&O7#JI\n1@f9^./ph'OA-;PNO_*Jb^n3+Q;[_)J:%nUg#(h7L_h%3/H=Git)[Q.[Xf4Ghqq<0Ft1m2EPb8b9nS;8)ja?QP6s3Z(8$OrRb>n)k@Q\Do:6o][J1@49a#P+`PT3("TQ\Cp@4.?=hHEkrM42>*p%b,qSWi%NTB,X]Gq+=0olB,IQ>j0=,G,&Lg;Y8ANc1*l9*l!/H?/,Y'Zc"bC0Z7F`I>VoU#X!jh2`at5HO`JHth2j9F5X#hE&`L&X>%OGT]&3Dmm3oO@Z(Co^JQfeW\)Dn6>a%F+PSQBJO'-l`1_W.+tQ('p^%]!LBFPc=<6)fXo=S@U1VHjR;;erWW5hMZ:eU2W9,rki_Q9Bq"4D%C]J))`'nWdYG0Z"K_\/sVrkf0sdImIqqLG=a`cQ1Qlm4pcO1'C&mnZpPZ):FD]prmG#EVbNs+^((I8<DsYWPTkVWH,m.&lElpqgS2S8[He_fu%OU"!KeP)D3u)#uS^2,0e/BX8?6fY1mHl@omKeYS6K^7$6e]Z_AcOr;U;@cnYR4&o]^\?[qUj[7g6gP!+8Y[lE[#UV+S8O!!Daq=s/NhA-og+6>(gp./&0m!?lf(IK3VP3!60#kGX1a7/:^)rd/)KD/A@dEhJfIlGhdZZjkmmB_$8%a4+mFO/e0>pk&MpBVL:7rAQ=mML0;jUEKGXuAh;mUTeRiu*L+W4F$-P$;bo#a*p)j2Zu:f#2lf88ccGa,&fBk]o!U5'f?opJL4!OPRl1Ru"0Oeh_I'm:`A']HPSiTkl9Di0kT13k`WAk7$d<ZQ%)`>j+\eTjDD43X5FiY$S3'eN@lrbg[$h1iOCp:qcKefu8Kn%lZ<&DG"1&*@:VBd[Ile@D">X8lVJ?"C$_>/Zm]>e^jaA:-\EGEME_]\:M>(VRQC*]kZFqD6'X>rFIidY@"K/;Z:_sX*(7u>lAP_:$&Em'NU\_Xr?T+H%/jr41j2fO'/BX2E&QB0Y_n0fslJSqPR-]/[;_qrKAW*[bb2Drok)Eb:m(TrlGh%g[\U*rok)ECMC$br=^LR?/o;Qk]IqnF8]!WG(\!3o(%M=l?b@u\M2iG\pi#GY3Xu1Q1827]4#FQ:@3JNKAl#`V\/$#[)fLu1;Lk>oA/quVTAB&:3!uK_<m+DXof@&1-a.(1[O0@,l8(r_jbuDTq.<(gipCV6T1Gd<FF,R0C\.]aRgMbPU$!I`2C)ocsh&Q?(n<!SQ>6uhFE>.a4LI#odc[1;t+_eJ7WUE)!&'oI)?_g#<oW.lBj"SM^h;^Wt%!=*]TjiPkEP"+aD+6N&5LiB\Hb<EQ/k.cO:-N%(fu<J"P]\\a"Em]$rgA[q)PZ6[)5VH'<5Y83=]XHeir]Bs-hPapa;-(7-j$b03ipfT(@>5]5Q88Q*db,SAl[Zl=[5BF0T."SffPO?PpQ4>Q1=Pi.R6'6Qh)e_l*2KPJg!+fge_H[2(6*#4I&hS[0:IGgMZZhh_Qe3*`g+I/Z#R]!_Z-2+SV=>$G=q@;p;M[&U32=jgL_%)W=1SkE)h:\V"#!@;,!qIIU#?E,3'F.\6RnjNq[`AUVii!c*C(@>R('^i4""/S+Y>YNNGtGfE)'M>iOm>XW;UMSZGd'[.'*6T\a9o%:MG`G&<0`+0Lic1<J5F#=F`]V4O4-rf$1(#\)*VrB]#4W0/Or[\n,UsR-!Xt6o>6_Wra0S#_gjPfmu74o3W#%eY/^`N\A<NU3L_fIp/\B)3Ut^['[Y)`[o--[72]C&\XpJF9B>E>S9i1E,X*jeXC0(Uc6Qe;B[JK)Z>&b)cfqS.[8nlJP.c)PA)K`(Bo\Fuls0of3"B8J#QYDt4A;Wt9tMB@/SVM=ARA_@iI<thn80ne)VRsq`=dX,KHGWbiC@<@)9V[jGsp[V`dMD,.*t3ba$P#GDV:'FH?o:>)g4UCWH!S=9,GDVLq-Rpf#FT\@.7C@`bV4b4@l,Vb<_0P9+I`5_/r?A':UCD7EM#2k!OMIR2J#QObU!b8dtD0N9@9&0WhkTMl@-[AlEtr#uEOG!,<^4fU.lkn!,sAHp]\l;s)7'cATfaK^6e]i^&6cAl\aKk!Ok:jL]+*QJIZk713FPNS7hm3^jO^%A3us'3\FE$KHS:L:L*S+]F;p5[#\`eVZ5LC=X#d<Pl)JA\6qLl%uR_g=!*OktAY@1pa9Ie5`VNZMmiJn;Xb5Y0=%n^>EXsiVaekOHPi%;*3ZJJ2(UohLTr79sot)\*l+h=7k-NA2\WI810?2pcm]^_G0hDhJ&NR\1]d.IHKZG.AAuEimZCH$kF;p%(DH%5,M',7+M$L"/\BOE(U"+-L,ggK``R2<43/o2O,gZfC%7I%cO^T.nP7YBagZCbCjXM%MlA)@!/Z0?(BW\"d1iaI_KF*j/tdT.%T*2)%V\n+bQm1Zi3YSk5l=<"*?XEXIX&=a;-Y$fl1*'/F/U5h=;%i;l\a0rBe[Jp)h0mp't4Dp6[g,F6E%U[1D=UbL`$skJfAtOSf$ea:,^qjbj;hSe;-V2%8s%gVhb_GlDm(.]\uTZ0^/Aq0<-`^DM-7q4ca'[rqM@Si5N5U"7Za4@8Q_l]Y`1jbk#p9r:m$SK[3R/ob&Zl]PpbMM3Bd;aG^9??;AiLQJg/H/3f&8!^'=?6O?8KpG83^S5%K=]@t@Aeo1l@dti-4$KMd.^JQO50<cD/i;4Gf[B(H<?-BlU1*`SOdan`gf-o*KRV1'9H@o"gJ+%4<H`[AK[e%.lFepn*2(*IXi@O76HnhWBAX'UA4&O.b0I"uAX@Vr$`Q,FMiKu&,lR$$pKhpiE^J-nBkKI5(_D$L?l.5i0o5Ph2Z7=^'DH&f0=Tai\(uMO\)$Ij4+&at(/s0HRgP9YQ&D@*q&0Sp]$W.B!1u%P<6KsOMd<i/Te9QJTC*J0+pNp55A<lZ-.T;sqKS?+R3YW$HV.t_*2.i&,Ld^]Qsju_U8J!Zk$Qo-IC$$CQdu%E?JQD>a)([Fg`mSaVN8]gl'gW4ZF\'hpTs?"YR^g+3M-T$DQK?D8lJ+oO,VehV13C13J#Dt+@N(-cpZTR1Nr)tkVQ^fY`!9*SA\<.^!i4<D7f5"ln$p6Md&0kAo4pG8>'c2h*[ImSZVh>DC38LE7q6QEMp#K?L"Rlb[kW>nqsU1:`=?eSCb"uZOUR6Bp;leD&=rIq?YKc<ea?oX(3&-8rUpVU4q'Zlcm)Z$R4&54iMeYFf)9M]2]Zj\7m+THti/QC>*^=]N<?uK_naf`u*4caB!]#*#R-P(;iDLSW&7^9du;P`%$6VXH?^"[*jJO#e9TZVHaB2AD3$E<A/XZB5][!HH;WQ)[9DR5@LkAPhKZe@U)=<KFl%[#Ic?']:3a.]-^a'=RKS:c<Prmc&H>q*Ge,&L<XqO]**%R[%ok[][@j;\l_$tMA>WuiaTTg,TidQZ9K.@>fBH$k=$9+AB],caol4g[YWsWPifAZ%0A-D%::\QP-S@&;Odjkan7;,I&!%FK\Ek%%.>#i:=23PMkDFFnO0dnmAco".Plde_@MbXcX9SeUa!GhQX*]lXBacfhC)^iH#26CFLH&L-Jfi0kSWZu3c(ETk88;.9+dTU.9G=7c%s/-Z;@TXbR1nOX`43'Al.E`.f3iY;p\fp=),>ZL%jVh)rJhAL(;S/1$&nbPsh6?RqBCY2pE&9<Gq[ZA9&)/?9.1l1K(8+B&/`7MceX,J^qWW8BAGjO5$\aAkDB%ThJWD3o+mD>S;0]#1cDl'\PKa(EkNI\EQfY/UCt;-N[s?eq^]'kF<jA::=MD&e/m%#n%6K:R0jESuS:p!48u"GeeuTF<B8JbcE+VY$P.>ada->[X3'7_cXtd)kC<L"?>p7(i7a2D=?7=]s@>XEVM]SMM#RcS-)=>H)IuS@ugjT<ar;sqbTml5g/62]KaK,pf2$\]gTin203,BX[6"!805qtm':]s>3(%BTaB83eB5(f_#$R"$10Q@WG2(pN-2#&W_9GbEo=>FGeNEo(N.8sS4,VG<rn*:XZ\\&/>kNNoMc&VG$jL&*lFt;-\362Pdk<ceQGXi*qQ@kchk=6GhGFZGd^GO3RnGhN7IB0?fcoOUqsde^>]V=ftGJpa"4j-)-,W+[qpFE)kY<nkHaXRJ/b+p5:5D7Fr%?hLn.!$]YcKNOe'1h"C%/&'1udgL"neBg/]Y5#TSPG,:r<Ro2rfG7[IU^R6n2(Rb<me"P160N,9S#9V^`116!NNA`Oi\<fV%$lX#+"PbP^RPT,/<8iN&'K6;BVj^T-@M;<>-Q/+5jGtEN=#7+SHAql8li-7W8Gi2LHkVCe$%"/>p1c9'R,S$+dVUrpb19UGeAO@]?;&P<8O6bb@N-@<gq_/ekF8)GJQ40N]L=(KfL=$s:>9TsS(>m=7*&`L)X\U4I42])?j"M&2C],7&&pJO.2IUOoTSm=LiY_Sq*J7u\J3GSo5A*XAe8<cnBeZ/,`gNuhc/e9'(fqWUVR@d.iMUF\oh*j*nt_QYCReuMoImhu_"HuH.]q*9CG8Y&LG5`CWPeu^PPLk>T59srlm=WO`B4]9aJBX\b!m#'mUSS.5#i/I)=BsqqeB`-0dj<Lf%]Ho>t:h4I!rZ:]tcT,qdk,T/s"#g6?KSBWj0LCP*4C1>/8`rJCp^@HlFSY_[Z)mK$d",!MAXBbY62=1@f,rl/@"Q]_^0s1uCD;^A].=-c>OWeG\A%pVYJCF591#2JPTYG2g4i4GUb2&%D\%$G+Ei(K^&[E&-YKT8+eD>T]IG8#I0ho?3o8e)C)giaV]Nqr*#7?OkE^8l[jQiX$3r%PPTUf?\pMCp'9t)2F]Haf)rY.q5?rLq/7B_2DA^l`_T(k2:?04C"I"S6h^*?!MK1Se4;.KP1VkK-sIpEOe7t4ZFK8hO_JC3*Zt!kNdcZ#=Y-o)%2m8Kou0)q#u2nJ*O/r2:_(bf9tUYlDOSRGO:`U`HS6KLrIb:/A/M$(7Tq0Eb,K,eYQXKm:>4d!10i1p]iU)D`1pkWSFSnFu?mEN3fhsE=JOpcDP9<)kO/bhB$#6J%MA1n]T[^oC^6%o^^VNREC@n[@^b`?4CbcE4&JJONQcs\a^'?j[fr3Zh))%G'l9o$6p4bNAlkGBWf7pLruG"J,[\'5O=mlI>D]r]8"BeSM[4';AP!!7IIYaU/`8.B>\KZG51s^jm1mropVa:Z09Tgq=)kfUZ7]A[r7#=Zh*e]+j]q%WB@S-r:>go,]&895%e/5>`ZfgS'ra'0^dQ1ZX'1C?2r$)08G2/Q^bLUT:MMa?DeU+#H*pW>U+B#-f2I!l,;3*1<rZ*XqmYLs,IhD4hQ#KM$3]jJ;3O3PGc0#N:\J2$[NghI^8[dQS6&m)s9A0:4p2`X<(Z/gj8SSpkWP0:]Fs_*$-(AYKs2RPP"su2#;d%o7m5c*F>mU^A.8K:au6WrO7F>[g1,DLM%u!*M'>dL82r(0jR<;m.C(FiuKI<hP#4AJ[BJ6q+],o[PcD*#="cM+'bD.s7=iD7Iu`%c`bYicVE&XkPlfIo4i]f4s^fUrl;jsS)a/^&1>Bl^%$EWIWK[]3Ll\#fAQ>os8;#?J,]H3lKQ'5qu*.9ps3SKmsn*a.?/79k-kS^lYFjd`hesB.sIP%]%XmQ8Yfr>Un-YaTAHZ;7_^#$9GEU,p1,"h3;WICi0'K#f>IX%nUGc!db_iVf'FAGmY'tu\CpTPqKGeoa;7WIVr=3'hB_ClbjBK&PgGW77%s]<C:HCd.<rD;TG]gr]NH<tOoE#27&se4>.%b5[X$u<fV_em3D6>SDgoGb"iWm49JjA=`:1bG#P!l"6N~>endstream 69 + endobj 70 + xref 71 + 0 12 72 + 0000000000 65535 f 73 + 0000000061 00000 n 74 + 0000000132 00000 n 75 + 0000000239 00000 n 76 + 0000000351 00000 n 77 + 0000000460 00000 n 78 + 0000000537 00000 n 79 + 0000000620 00000 n 80 + 0000000862 00000 n 81 + 0000000931 00000 n 82 + 0000001230 00000 n 83 + 0000001290 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<3f5b1654bfb93453197adbbb60f3c497><3f5b1654bfb93453197adbbb60f3c497>] 88 + % ReportLab generated PDF document -- digest (opensource) 89 + 90 + /Info 9 0 R 91 + /Root 8 0 R 92 + /Size 12 93 + >> 94 + startxref 95 + 14662 96 + %%EOF
.github/reports/enclosure_insertion_loss_example.webp

This is a binary file and will not be displayed.

+96
.github/reports/hvac_duct_noise_example.pdf
··· 1 + %PDF-1.4 2 + %���� ReportLab Generated PDF document (opensource) 3 + 1 0 obj 4 + << 5 + /F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 6 0 R 6 + >> 7 + endobj 8 + 2 0 obj 9 + << 10 + /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font 11 + >> 12 + endobj 13 + 3 0 obj 14 + << 15 + /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font 16 + >> 17 + endobj 18 + 4 0 obj 19 + << 20 + /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font 21 + >> 22 + endobj 23 + 5 0 obj 24 + << 25 + /BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font 26 + >> 27 + endobj 28 + 6 0 obj 29 + << 30 + /BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font 31 + >> 32 + endobj 33 + 7 0 obj 34 + << 35 + /Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << 36 + /ExtGState << 37 + /gRLs0 << 38 + /CA .3 39 + >> 40 + >> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] 41 + >> /Rotate 0 /Trans << 42 + 43 + >> 44 + /Type /Page 45 + >> 46 + endobj 47 + 8 0 obj 48 + << 49 + /PageMode /UseNone /Pages 10 0 R /Type /Catalog 50 + >> 51 + endobj 52 + 9 0 obj 53 + << 54 + /Author (\(anonymous\)) /CreationDate (D:20000101000000+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) 55 + /Subject (\(unspecified\)) /Title (HVAC duct noise spectrum) /Trapped /False 56 + >> 57 + endobj 58 + 10 0 obj 59 + << 60 + /Count 1 /Kids [ 7 0 R ] /Type /Pages 61 + >> 62 + endobj 63 + 11 0 obj 64 + << 65 + /Filter [ /ASCII85Decode /FlateDecode ] /Length 15145 66 + >> 67 + stream 68 + Gb"/L=d'iGOle`T76[h2AZSebLW9%=Uc9TYEE=%j/$53f>6>g5!4.)=h+^f$?bcU9&Xh2*KtB#u;Db(1ZQeH!nF.[>h8<$Krq$'Vl1m/aojIXh^\Z'-l+rFE:YR]hpicMr_.S8tb\P=*[k>T3VHWpV<p/^/qK_CZc_XFlk]1HIg8:=ET<95b7TK<48sp`j;[[%)"YGCXqG2S7k:aTA*rU2,1L46L^"uK8cZZ3F=$N7*5!A\el+sRMJcDDCmd!7jMb`Ur7D7#\c#Xg@<QC">WJg\h4+FFT:FEp5h<_;iT3aXmH$X`j?2ch>4P<FnSmD?$H+^cA>s5jR4ds)L\_<%@mm!Es:N%<R]\EARnD_i;IJFQTqs<m?h:6VUqg!C?3ZcGrG%\GYh8KhOs4,TSobTNrPM\O9\DCLbs)%VgjY%.J*dobkF7K%i`GTnmo]#E558bVO?XDMm?(Oddk/juW[/FJ1qT6WfeC1CF0jPHqd+Pl#q-U!`s7Z1cE6W>!pRcA2#(Q4imM54pT]Ehh"2U"IQ:YXKED.b=%[GC72^0:dNJ%#_T<KTJiMSS8HLga^;o74+%Z\K>Y]Zq"7=33QcF$09#-M=s5[4$G*tQ\iSaE:QDWtOrJLIYq'].EVA7CC%Y"j1rLDNnD(2A$8jnZgcq9\oN]=E7_o^S-7l2%;jcd)O3[.qW$#]%n7(drUpFa8DK;-H$5*?%Rq$Y+[j];F\/nak51bDYtYRpKn)'drb!VcT?8ZnTh1QCaX1UucfCH$N/#E$s-6rT>i*n[C:9hd9W@V9rtE\%KYD8,%<CrqhT_o:%$ZqYoRMhu/+,FY%%BcZ_<D/g!5hr7jcPLU3k+[b>'+<$1JPT7L5GFL@V;b0?K)+,U`qlbC-Om*?\t`VC&D`R?nMr^IS!jnTEp+OdZth=WW9p&G$]iH#2^k4uCikFLLbk=sk-D%Q,R8^+FbUdb/9;mh%ePo<mU)cnuWp4sKVO"$":#h>ZG(+&+^ZW@5,r-/#;Iid$:rq(-$;/^pZm"/tdT'#'\8tG5Q/^!\<oH!tqQG5D.r?$fJCEY-1T-mm=X4s4MIV%IV6eaNfb:Dg`pWi1K(<XfeXgtLR>X+:r3baI)d2a@V7o,;l'2k$tESC2tD:J.Kj0,CJr+G,=-+#>JPu2GRQ&L(TNp?)0Pu,MVc;D]15MU%IV:V][<$JC[=.\7A[Ao;Xd%L,q;[s9,V2:"s#qB=Peb]D[3sZ$H6+(f6&ntm9"::?<n7XlYEVf"irnlUl2E(HIhBA.j[ahdkB`#Lj6eUUlI?dF9aVjK"ol`ClQCGihAa>_^2^"t&f@.iK`_ZGrj4Dae++6"jG/'T9h:u0Yq]$rnD0L[\d(:bik+or7g/o'^Zq<^QMioN*oDN5FnZ<S*g?o(M-U&LuC=S?-Ur/N8e65.>-l]I=I]]@j%J2p5;/6BlTknm*W-+>`FaF*mOYkkKW7L$?r9d"=C-c#Z0<-e+3$9[AU.XUCA5Kt4g0t`b_HO'PnCtB"q,274W*BsqGWr<XGl\L6A=%8eAJos%P[<At\+eVD'jVNb\$FaDcaPAmU9TsE^4LAO)N:SC3Z><!+oh4Ro^:*YNYQJRXS^n<Pc6CKhY?+W]P)c3;Qi>*gt/NlH_IOg3pu0k?]<]1-/bbaV_``&INAIcjKfrs@-k"<,%Y54+XUs3D.Cj'h/QP8j2IG#LbSOD#L/42(@U+iRsQgVRHf]WRQ\E`Dg-MWB8G'jkqBf?0(/&G[WL]3U>(1&Q5XlqlsoP:O8nk>[@84E;tl1,k)`hSic;]$Hha!\J60Y<h:h<N5?<HLTBKgr6Bei<qtn%CG_j]GB[Qd*#I)b?is<_Ol\j*=k86`k4k/Sq,OQ;PXMtGAJcX_$d(tqF&98FJ(-3o:<M"U^jFu+KjAR=)*BNkKQi^e6b.''X^$f?#0u;5aVpm6>1/&pUEqX&h`ItWSY*W.3Be2@C_)d`aQQ?2f-#k*#b52ek(E/p"g5=Gt3pHTf2]@U78:#AIStqhWgAo6[@t7gd%Y$=XL`-/"K[$GXV$.MWBjN5):c6.q2mB>"83r@mPb<FrqM#P.PrEG4q93?O+UKcKVa9L?:VkKneKGK4YdZ6mi7XG(mAW"3'M,]8APtio'P,k54Fr)Ll:@8XOUafYT0n]3XGR2uV)J0J]1FIf^WDV0HK9P)^rY*<H`1+,@jjEiJ4\X?q"TPh:]D^-)Z<211=&^-PlfC:ON=;-C1m?6B96QbWt%hXK]3h9,6!pRmWq`^!?EY\$V>5l:jMm;ckpd<OQaY4=^sQ:?G]AG(hdem??N82,oUPo18/puA2R7.JC0W5r?6j6Op-aaqeWDkMBi(]E`D,O/m(Z0YSCX=!mq!gm#,4C[jNEV<`%@9-L)^pPDBhS/RE'KJR?X^H+FPU9+Fr-29]1Z50q=k\G)(TTn05-Y.<bkO>dbcO^?N%^si?[Ojl33!4"eET7b.);(*'!.PNH!.d1]GFcM-r>3,ofPU&&R&XXr8n2L[r[Qt`o7P2QGPU.!"AOdlHLEK;FJH-gf9AUAXZU?>$-WCK[PBSpMU>M6cVO#\q.h'K3VC_&$esfDN3,iY*>3I>B/oc#e(dT,_6h3QnVuVd6m,s<o:"Maj*ig""jJJ#H:>cbMo/3'BhidQ>Ood@+@BCsbUO`oW5gp6rE7-'E#18XMB*q_k-[[GlhuHK;@2a[F%X@Ib0HjjS,W`Ll$[gU2O?t]TKVr?J-+J8,&!bdFCo"*EgOq6e7>+33L74C[$kV!HrYncf96TV7ll\Us/.Gcs^@:E1%BNdEFHa,Xls2$L&0kT>J')0A4T2An=HplinH#1rrJ9Yf?n"/a7[rHWPlhDN.3n>A&8NhK(o<N%Ml><r!7;$+Aa4@EbT<LQRYE&W3[X@Ymeu,o1Q_u;W0L?rCV_h=<t-u+(0+a$%F$oG.'jP[J1^MD<qG&!mt[k.6@^m/lVcCVn(q1"]j(C2YO"UATEY/Z<8C0i,h!B675m#I-6UE=^lY&u)eK-u0NF@1V2p<:?A55[KVaj!L`J$3/9%t.J*!SP/S7/sV[Sq,TXf\P3g`HNZG5rD\-hi1b55j!"MF:.T1Dk*;1==P#OFj2GnS:05ri(USe,CA5s[hX.'*I?N>RhE.iGY6-XlG?PZO+p[=Z4.FU/[3f>hjHgX8WFBkWBK,If8U'6sU(<RC^&E5L-'bga\f'R8a*5_Hrfo;U%(=D4XR?C<s>WgAPka2U^e5c'W9&j0b%\Tb4Y!#!otH6C%,:o%d;3P`:*'j5fALs1+R$O\"^=uE\s.WU5caGLC?a]a_sHf`sl%hN'A6A``5E!T:;.eN0ta:")eXg0K#7%8pL'1'8ZUonaP44lcOKdS^B!1>!F6#13c75KkC1@l69\24<8RJC93/"47HC-3Do*n!AH%GDk9GnaHR]Td,UpbJ/F^g:$,>B&fM."#"#?te5tYfrJ5b6PsNE`/uCdPZlmMddj.6HZWPp.u_Y:1HF9Lcm$.A.ZF&\P]IG'(sNY\HoeqWE+p)[>P%Z%W,S)?#dt#@b=,D&679X'(e(>&k!n?V/6Po'bt:>'NKmOS88gj+G/@PWmHA3inr7Z%=>!'.;8NF*7;)U&#^g_[kqAJ:c-kF_RTio:sg5<$o2E#RW',_0BkdY1h!:s#A_1%!f<r,A0VkSV])kF&=K(A.O&uV#4YhjQY^1+6JrXO(],u'W#jL;Z=)7:#"]rE\K:iMQoiA#:mb4oj^05%">]1_f*&0&kcTck77-\?Ti2_4>r#?Oh%87'+(Tcm#qK)a&kM7?HchVeHZS<6:@0-RE`N$<<l[:#Y?#dU<B!*dFlR`#po855lGglFDS-@24lDhWL$V4=gM$oF=Xn7+j&k-rUndLp)%Cn.Agnf%@JB%$P!+,GIF0(dnJ*4H97<os+%IRF)OT7`[^f=_3`Z+;FNOnnnO5]uUmBB47>e+Ek!cWSU`C'bh;m"Zqco*jDUd"MSsopFAjkT#8tIN-NG,"<G^[n<Udtp<f3-\EN6YEd*/XnI(t`2Y6^RU^KAgh4_-_>c8u+As5>FsCgO.Z5(45G[]dX1=,f&Q[Q<<LXV)UJJ$"7i!!`_M3=g7@1]gUg6!7[\ej2uY[M_3J109M!]X:?7;L?RC@TuXc(etMQ`rr261:bl>Jm2bK=X-0\:$(lg4E5]*E<Cp;XKQjC@XIATMe/o].Ip/:ZF5%\XSfd6BpZ@q5S_?:.c'S&N'(f].plJ/CH92*&j3@'9C.8(d]"hq[HVs#0Pg5(j?g;n;D\C^?=isD588OM\64?cmW<#V__K_JkgnWCqpp>hcO)9J!iF9K]GAk.Nn(.L;YP/kp&ehWr\_bruU@l$04YBpW_ttf&3mgB.TAE^5fXj'Y%sMdg!5nuES'ZP2Qf90=4#"9.T:c=5<-BUlQS93V-%qB?fK1Cf3;-)JlPF(M`I6C^lJXK+fNIkLY[81gq.T)-DL9nY5933ZL0[E"aX6Z))`*?*289HU#kp*1nq'[2fY]'mUV1Mm./Qd9j4t[#lJ:1GH$r3^JY^LZhTo=!#MQdKpCFo8q`:qLBO48OG6eX`cE"X*o6J#Jq`:qLBO4n5d\a6-YP.D/kOhU;lb's,ST^ge8o8KQa1F:fBtmUD3j8!c?;oYQR.f^nhS&`thPkpof69'T-#?e?1WNs2mG48Xl^cb]H$r3S8u>/KZ0ff:]mK?r]i4ehYO?/jdar?lZX3=LRr;NfWd1/_G;Q(*KD:Q%kLAA%q5iGPI@\u61p>GJPFRZXo4maNHsZ9kr]\Wn?G'_lR1\DBQ--PuPi':h"(u,k",#fLB5<7H_)h&&J`_[\rnT.C-\YT5!HY-1d)Ou+*YN!R3(U^\rq=Z:$7Gl4F+TL3-2_fKrgT!,aT]r]Q%C'qij]dgfpm14$jUj`$,\'[+WRthBLh-&;F@%JY"a/j7@;6a0V&P.Y(c[F+b\qKUnK*[(j+Y,K:Q*%[j<#?/r]%l4nD*J)3['GRVOmG)bjj2LjK+7@-&6tFL[^a3h5!a*>#OufiQ?2KjR_C:Ft$.*#"-9q:HR!D$ed#16/V\d56-=r^XW'k;bLi!Ng9'o"lE"![?(^E3>jTKo8Gs&D:H!<>Q#b4D,t?lBn_7NI:@>IMh'l05fe&WA"3h992X<'B#u=53jZ9q0gYIJT!nh6t3-Qj/6K=b1PYZ=Usl!=42%]KX;XC$!>$SS#"K]"B6?_?kEN6_T2o2V@A"gME+iUpjZt+2+dX6e#;,j79qZ*nQVJn6Kq3s);?mrnGKIUI4-9nkLKTs+0(YiA6:GKhO7_M0@6/LhOIZ(pgbjg`oQ=PN!1LgVD+]3bPW^%FH;A^9#&b4"R(XHCFTerf8;TY;Y!CD[JB"XKFX"s:=9e:hjE&9p]mE$ir^%4"3AH8*hX82OGcnIL\N-M*b9h`qCecB(PF=f=4$ks4sHb4j/<3-UoKc9nH5\?"<jf&##aLca&ru:mN.<KKWB(R,MT]AFPoaO/,aNA?rYj8(XggF-?A^o+h=(QoP;P.VKCf*3!ZC^KCLY'Oc\c8&gNOdNoIj1ejIM[<*1C3mP6gG:[$K23l6+Lorp):2RK44T[#4A.+r@O>bc6PihCVEZO;ofSDRdDUGNK]lXD,u<lF5p4#-6EhUq%!VRk*9;,b0.4gP9n<#gY.>>q#K@&]r9,;C[lWn%,P,(\HeUc'T>Ida5`>*U>^N;WoV>\`P.NDjau]"RC79m&#A?K[J.64j[*KpJk9b[Ur,FgrJEo>9Q@fogKq59*<3"$nE[m2-r]YQtCa(G5UB!]rI%,W*),YoVo'=YMVDnWEe2\Bs)"%f,%g$,djg30(7[1.cSKr0?FR%a",8*_[2"%bPLFPP7dj"b/7i41O4"GtcZ]Zc$+=aX6-^Hm#uh,Ue3ak+J4&/3SVniM0GEG`"L-;$8n%V">l]:e8I8(`irhGYeAU:C62r!,aN(81pma6r9:C>+k.+P'3#;*2]\hN<E!$f`ueL%;:AIToh6I'11:8W^s1m+bYKI%*9_M]`ERdf4:3MmoXc"!tmK-mskq3""g#OCp+2eWdq/XaK$^3Lm<*QEq84_=\_e@0(>I1('DDG1uK(t"h&,+cql^DC'%DT.MS9Z3Spi$DeYY5Zk-X&FBqFVE7*asU^:SaG0g;ZUm4[jK%_b/3%VERbeEBe5$uLIK:qq()E"3fA1(s`$7?-kCEos(`'gFFG`Cfnbh[*GhDmPjBps;NTi^$3<IsH#^FZn@]e#a/'8LseP7(SM5EA/:E+e@`c0#-aO27o1o7k7WWg8s`2fj]8)2q=457?bZQ>G4l/RUm7+b2/CZM;Bdqk.`;Yt(b_ciXZMLIo.%9V%MPCjo1aMnu>PcFJ1<eT>H.]?G*<>04/>k73HB#u;XKAe&<jf"RU\bqAGD^TY`"QBle=$e@CLZ*<s^K<j)rP:s![&(t2"Z7ZZd+5u=Oi](&)r<CIi-oYeAA5RfTc:TVT-t/)jWPm]t"0@\O7B*OE=o4#IhcE^NAie8@)f+'m(A=nTP,[?VBe9M]EmKA`K6V6%KXPFWBom_i^iJ9hd?qqRq8Qq0Ogd&EG\F,DR5PSYN*Nh3fl2oK/[4J6AFkC`%&P8;JljUm2sKbqlAsnA4DCB"q1.d(Ju/^L@3jBqHSJ55ZSQCI$oc;g9Is10OrlttDqUdBc%i">d#XH;08UieYaDu]6,T2A9ZP8nNAc>ZK(ppsn&tn2@$>HY93I]a(.nTVVG0M(CtUL),Nb*G;@N$]6kV;:0HH0^'d-%C*L>ZOC_"Vs-hc[Lr9_B@TLd8m_2U6.TZRF_O(7`iBXg1f86]ng*G*naQJGm%3G(KkC]uG"/V?$T4kNng!K`-Z(447-I\f2>:-7o(qXFhp8ee9e(N%!e;(d*lTYSi3*HZY?!1?m4ZJnLm`5eL(&I1%1oa=T2^OMW,JTguO>Nm,0AeYYU8IsE7/4$,]#a)?AQ**tp$0iuWFh]6CqN'XCD!kUZ>9o%C^!l/r4Xdp@n`ug2D%K`('BDeUj4IG:'V,\Wj3!q\gW&OIdEftdB[&D<F,eaUo6.6]]0='D?([?oc=eUKKm"Q/e&ODu1pihb9$mniG<9GW3kD&jh@2BFGD!#AK73$/K=qYhD9])iB%:+ll5a/knnb<?5Ird[]Wj(HCAOU1.Gs>DVH'*h[Ad/q>.(>j7%7Ah^t(*AFURQr;VeqA-CV2%\i3s.Z0F5^qOgfsT""2/?5)817g\Zc1O_4Ah[_ML\HW!8AT?H\oG!H[r`sK%R,Bie`CUncD!Z:2Xm>5U;k9+8+I,RO0:Z$a%T,%p9%p9PhADoC_p\%%m`jqb^=l.6Sk5JMe9a?$-VKKhl_LU`1F@9fg>o)aWDG!_FGmqX&5BJ4EG1ru!?U`>(4#ZlOu7$+SIS6bc*)ZR#W*Y21_<8f#$&:7)&7-d#Xi6RB:NFp`K?S*Th-EU-6c"!RtVt\JaEf!>Kl6*WW)%ZX"Rfgn3tDbk/egOe2X;$QY)EKIWkYXD18,,Z#Ts>/9pq6Z58([4%,=`>l$U(G!eW,Da?XJ]`h&aVPn*F7l6K0"jMD=GgE'?Np+G+/tb#So&7k?$!dg:5V,kUW-XsH]bL!emAWmHD&'qb#iQ>_'#_"_f\CS*LT0Z@b\8'?d8t>4-s^>`\iH6-oD<Ka5XLCK-a;'LQPUVJgrqsJDgSeRLIcRgbg[c<Y;ou%n3I/;E9@/8(+rge%%:7C[c!_;<V:8Jl-d2^/F,!Qg%E:A\hT=04(AE0NW*B1F(B+(U1;N_mY!l@6rQqu:J4fV>OH)]g1#PlWL)>$U<,Q'\_/A.*1K=Pr52$HJ5%^:Xu3N0iG>f`:9aMMX/kb25Ofl+_ImD$ksg_h,7gM85ebPA`2f"SP-:T)gc[ARK%A8.in7J9V,9ZqM!j<rg+6bTCkb:fLl86l_+thZR5rR@9H="g7\sZn-&l'[WO"QWZZelggut^3g-<t4$.TC)i6_%^o!*D:IPob5#jgS^0gLHC"Ug#4AbI&JCejg(<=NQQEdaJpT/?go$=+3DPm=?^\QUsr["<8eP0[iS(Y,f!HTg<(Y'n5)U\e;YN,-p-7a4Y#R,u-4iN&?od=Qr$W5eV+75Z:$L_ClL2)M@]\tN9&S/j6_A]/;T$`m[i*1OWpF`!B]:fmBK./pW+ih*=:);[]+L6ml.Jtm^-a,+\E`Zc9E(`_L8MsLYok=KLU/^aZ":'D>E1*X-.)NjjX#%(7@7$+U8At@BN<>T:gNi'f+Ei6L4/HdQ_jqYa;CqP()ZV?dNCqT:!UXHgG:kc!l;?'b#UOCuB6-!MeF)Q>DgH]AJ^uLZsOsD<U[(a?#N,O`s`]g-",)->j6jIAp6)P0=o1Gok1G3>tEOJq:BM0C<U!]aY'fqW@:e%@G*(oEoP!`q618!,"]o%EH-h(6tphF-pW)&a>)Od#fkQH[rEu:mJYQfB.Oj$H<A!D:an=p"MOu5FulsRc,G?]PB4`n*J<ZCCR[_WS+bf>Ps%&I1(@9_Al)+cl"^JFX67)8LYB?c"&L(m4!%]N0YoJP940n:In'Y/es2if<&"e(rP*",q$6OFii3(9.XFIZam25eTc;ro7LWPXtjOlY\o&bAB2Lj>$fJfr'\/2&@*/8Vep&O.2CWE"q]GB*P/DOLTr1EJ'5lcSj$VZ.5arCVgfZ$$sXjZC5@73t/_Mb6f_O0c/H0Pc^SEr!^q`-brC#?X"%8':oO[?cl<^#c@fpj4i]T'Pp0WW(MH:$TD3D's*^3a%M=5.74"mr3lHO0\"+h_LmN`XmmCHX=NR'9ZLAXu'=]BmR^RpmfL[+J\CCC/sAE6l"RX^S!\XNqX,fFt2HIgMgXEWX1:;fst+)qlF#Ged'=W'0&.L*!e38a`q9=CbQ<5VDQSM'0Jc>$(P94GXPWW*ds%2O44+sBD_VJFicR``1S0Vns%e((:_k3L%VL4m;O,alYObjnJn^b3BBK]4OXBP1V=]$N,`6)Mc=Wr5&fd,9j"VZ+m_04;e*\IE!3L<OutHc<FWYaitlqEO;(]CN1\&U946&a;lX^2>E6]SrWI7ZmaY1EfXZ:s(,2!?6Yr]ZMb"8q+stNRPPn/\Bt,K05&Is[9\9[p"@Y!)26Em_G`(jQ_JgI$2tCsSOm:A9Os+^(fSND2UDR!]i6BK?q=Nd4h;'<M8]PmVnShiZPWm20,ne\rb`8`0[b>;n?1pm6fmWcTOT'u^DF[f'<F+/$KptVB5mEP=P%5+ZRfrdW9qNg/mo8-fU36iIikjiE+Lk_EC&F6g?60Cbmb.7]G2MR!1TaD,+-fb5#C$(*Qprn@;nLesni:2mjc%<#OWKXIH"I`s91FeLLeE/%r1Za6^dsKNOpFS]Zo_#=5gimWW7a[DhRjsXdcX<8.&Tm)_G9k&S##ho/10(HgXM^NS^<j)-S+U\s7Vkq0-'h?Aq!D<Qbu;I[[5c\__?#nF(>p:,*NA/MeqR6GV[]lC9<KiUCcu2fQjH6M)eQZ]PW[bEHc,$Yu9LgP.)mFLOPs=2,IY5$WBNh$W?h9_2(RZnp`YHmipji\G'i.1S*BXrVIhIW?(9sQ70%9CNq&aG/?A$+ShUCnSH=EJ3T=u4e7'@7Isgp*;iHZ2f=)F?dL>#7V^]%e`nfW\U+4#,F.hldm^AXD<1D7RsQXBh!Msj^Q0?"qIVaZc0`>bmc%_2W4@mNHq;SUI.l)R7ac-G?RP^1SEN'$CRs[MEh`$FUiiCrcAB91BWMmG;,6ahm;\]\O)q&aQiu_CImatKZ8o;1i*YX8]&2in#2K#;VVlNSak(3Hhh\p\B"lg8mMk%8/+tCY4i`M1V=rbZ4!KGsd%%T"luC5/q1OCorHDmQR<.EB472*MH??WjQ,.kG%#,%A+6[dd#B<<fW1_k96N_,DW\n;:R^$VP=S8A!XR[)r)=3d-YW;Et3B0uGQdm0;m(qQ)E%Bu&@ZPaZUJtFVlo3-=Su*W41*<=h`At9.7=\<N%%t&P7n9s8J7Q#X7qpW`K.b4LAs#'j[8AOHmXPgI997.f6'rl^)oQ2JoC0<+$o3F[(<AO]FQLBedQjmO<$-c4(#c%`j5a0K[n>eG8Eh$\(A%,gDr=(&"o''0mWNX`n=X"!C0Y:35<)F4%?TB]SK]$N$iWRnQK58,:Wg,ijXM\eA2e.s]8brd0Y^,&OEtXBqmEGJ/S)D8U$sAan.O8V"oM>%XpJA/1>I0A3.HhuH+?!aH!jaWa&P%6JG?.kKW,=l4FRr]3#M)P8G4Y'n*l3<pd6l0dNqdRk@2lqGB'PEaEP?=QS$!G5d"/7P[)=J[t^[kBoF+KF5=r\OT;d'mW2Ho!fMFZBQp1?_1*CV\O9DS*R:t6aXMi&4i:)64u6:]C&qtZ19smS%r:GeP#q.,C-31!PTdJD@bJCpfo(_@"',B$Cp8]$(9n0Z+H7C*^_hAVl.ddAD`?#A:/)o)5Pl7e1<B'07c_Ri=`m,fPd%,Q=E)mmh[ojLLj:$;Z?/rRhhA>XNE>U!LF6>#=WL3rKn8^P7a%3OZ(02H7ZTOj(c<>XOQFQ4XR.Dg$':)aI)jVmK0A')H"2tZcOJLRWs#rR!dn;k$Ye8JG-%JR'!fltKs;3"Wg%7[/2dk:l0X'L4@uPT,4NtO^hj#8[mLqD]bNaurPAf$\^X-a`jmBR/Z]TuZG6iQ69X]7"=J;Zk/u2%&?Y#2M\`G$%YHC5%V'q+iu#r6KEiuL0a:)l)X=":R.80K<ugiGfn22fO'=>?d&7q51Md0=I(*g<1pQR_3X0hhM1_<gl4*hj-&+_C-g^pt_J'<?BcE5^]^+LhTPXdkN?lt.VN!MjZQbLRMsimJ\R>SP/ude5C`'2(CIRqJ;&o*pjjcss:MS[l\B*^>Bj!"rL2[<U.bX_>9Q/S54;-m$jsph?Dp!2]Z.Rk@_"4F:UlAc7_;^042GF8@[,7Pc7[h;C;GHRU/F1V%QK0;pM$Fa&UR6)@Q7qN8bbdI_4.4s\r.kJ`^?F+pQRP6VeYiO+4osEq>'1fSEB:Qtgf?'9[Z8!g1t?PkP`bOeeu(;2+uU/?MPB8QF66"C6)g^/SFsZ`bm,+!2?f,,-fCap9fYTBm*u:;6)LN<<,;/D[=-q^gG^=*j@EEAEMl8K4sju\X2&?Lo0k]dmD]QLM$#cJic-OA2.1V;f_rkmFq_"CKT2O4`9sfX_`tMmiM8mabtt3-%(LOWm`NF57H7K`53.m'J7()>P_I\ab9gt71G9[T7&b?lV_d-;JqN)D/7tt7INaUqfqF-q#]<oe9;pe,KP3_.K50\m4./CJWbX$>gJA1fd?isnVOEL+(EF>UPH`$RAIbH@/*HhHJ+//L]_u>;_fk/)5Sc79)2gqXVZFqX!&73-JffYGEI4<?TRP\'-RV6(iS]I.e2:Wr"7PS;U/YdI6e[QVKk.b$9j$BYL%SI+d\--t5l+3_0XL'lD:GN#;E(A*o7@I)-4bA'A^VhuBZnK91fR:GF39u_;(l:V(*T8qj;hPUS+IWMR5PSIdeL+R:MDuu9i\HI4*b-?a4]D2X\.ENA,CPMJl,OaVL_YSUr`I8!]h&cVUZs(`gWuqN+^s-NO3kmS5^1%aZ-l`QsRMOdY5Y=P\jNR/VR73qlo(Jb:I&!a3jV4IS9R2e5Z9.W:t3sM=dh7<F_(8Yt<4b\gho\ccpbnCoq^W0ShGBj7CW$XAhLWa2p'Ek*KEG9>AgKV2=W$@bZ?&WMT&ln749p/N`5f^7k=!h0"A!TcT8[7WT:RX(Za-7=usO]02<\oKJdSHWTOA+6Q+EJ4%BpH#5@]\iO@6BVLuG7b72NTpqN$HrnF]GB-SqC"O>/1TRCgOR5p$cV*(F:<,hlaT49b4gB.2)f/+hX-m)V9oF.+/D:FqAEN1d-;@=di=(=V@c$'k3Y_,<C>bTkl,i;5h-4om_HZGk;?IkO_=<OjLlOVlFUdLTE&ss^=g^KtaEm;o$;K&Ubchb343%I=o[IH1!anUb?ps.UAY8!7&#t14rt'="L<l'o,[=0a=(gBVLi'[UTq0n:1IZXX@L'8DD,]2hb>f!Y3@&1M!#Eaa_l6ftN3'W)`AWFDj5ckVD27@UAZ#U!s5'8eU[;K2J"%`#+[N9+ir'T,VTC_i66NB_b75=?nO`;nZ.)#GO).tXnC5G3(SJh"4<mNj8$aD%WlCjRQFtgtB!2I!5dOe]D$8mm"i*!#&.;Ss1E_.Vjj^Q=:j"=CSWu/:@H'*5U@.'SRFK+O+Pq5L[Us1+D=Csse)i5!5-n)#>"hXL#7Y`[Ooksa!!t=Nm-l6h6c&\RCi^WI&a-8?Q'aED;p!1`K?UCr+E-r#*J<g^WRKGJBqJSo1nqX-*%TTa`!P@H"<OpFc\9S7(pXs_/^YCZcPQ0aP*0F$kQ*&85c3A[PEX6K[\pD[lGRL=ItiG)1"e"^kG=Lk.%q`sRWh[]jpKYlLJ(mPPJOiD\VW=@A4tXf=#!QD</TG6&-LNj29RTK.I!^<k(![(/qYL?$XtQrStNpWOf4d-NMo1,-<[T@>t/F,Pn;(,HFgo^5s:2pK>LKob2KM(RBA[?,-Y$JSo^MqW]Z$hdk.LOGcr*j'C:qfe1H'm2Sm"[Tl9u@4R+f&VNMrHJL>ah5!FhU9U5HFfnuOFLb*7H+Nm64*Gj!!jl[&T%/VNbL`#h>X1OO21$p9m]TSf'oLF@(G.uGclj(>>=6<(I?PmETMemc-laghZ<>`QH0d)n]_*F:AH:.T\,mYce+nVO?SLmZd?jrQ(\![+T`[AZdO"Rr`!gF<npjl_toi$V<;t"9r)atNMH,\!`M6rct`JM7"kokoVPjNI6?*KeB"6L73l_#<%aSj]\=+7/Eq'Q!m3t2`DdH40jjZl@n1FYUW&c"\\'G&`e@8KCQMBkn8?Ha2<7cje@Ya30ZO=&rKK\7PP/^B"im'1aZ:mA1EL'ukdJtn`C1X<=ZQ$L"%ge;#Nc&%`>E0d8]^d/0@I*#TkD98i<kh5F@9.XQ$?&:u#SM5IOH1o/p,*%S+BbRFE!euh.i??rSeLBLH%[q#Q87IF=*q5uJ]7B#ONLGRB1l.1!""a`i<%K^5:)JQLVoX4O,ES/0XIDU;;N?,Cb`IMR$-[\0i=j&T:GML]gT4Y@ama&U4L(kP1>KMMS;'i/L(?0iSN.OS7I2RuJpg5?;oe3I(j/Vc@u\(Ik>Iprr$5D9_"*!oH`X=e`(S8+0j&pj;pq%DiKcD%Xj7Al/#3q#CNBXh8r3s1;9QN`1^GECk$&<>MO$MmE$T?&+"<ZlL$coeSO-2^SH8V^3rSirEtf)KCQ2C&)M(IA[gfehmZb3gR5Z)"0:FmC?NbIAC*=aiV!84MS^AuQBnlg\2nASE5$+M3)tig,kGA@ICka??"7fD/D'NW*XJV!h?`_]nGiaaPOjU_>FpmPPS>9-Y,Of6W"O+HO=<R69\#Rq`!qlF&j^W.cF%@;97J7,`6S]\E:Cnj(<FrIa:ch/t>FV?uEG&CaZ<j9p(]`jJO`BJFhU/2)m2C^ci@]&pIO`Am5U(uh2nl%2-MD2YI>bto(0J3n+0m3DjbYqV^s+(pK3ce'UG>,`=*$1j@5Bh&ZgGDVU&I=`E/oLemP?cd8B\L>"u'c%7AFR8#:L#P+ct;3/I(hq9AlUCLtUm0PXW0!k7,ibi=&1acKPU,1kQGdi'Tdjc:W'D@5t^.36I,[[)l0GZj@dD=WB`P7p-8b;&7(/<2',%)6e`QnO8j/9@\2<D;5GK"%Zki3Et.;mI!GMfA^_ukJK9hAb&,lmbW8."qnPN8IQ'-<&T?>0H['.DrWNhjD9":cAY$BDF(WBo$dLrKj`Rlnl[WYFgF(n8SB-]oWgE,M/T/Bep/Yl_0/h1nIAXLK'!S9^IAoWXS]g'Z!BBLB-RT.Ga"O4S,m]aK2Sq=[QW'8`Tn,RHGm$/0s!kR#J"@UmPlOM]I%Gre/?JjS9_B[Q]6I6*alru#-pUP?rCQ/\pYu=ZRnaodN-bRqB_K70?7*>f;n-DXO4;nadqGi:[Y7NOCeU:7("`HGqZRLh8/*a<Y'UIp0`MY)BbNUOo`[\Q/-VZ_[MUNhD!C8N6Pb-'Ab2[D2jP9d@mhm4Rg$KeT_&jqcsI)\oe$8e&;6p4\K8D]`R$td#td<6nVa^c*h[GZ>D6(S%V3%83iIJFLEd'(d]H1>j\C1Il.na<tD08s!u[so\`4Xrq,.'l'hoNT<kLf.o^I7^#5dnYieiQ5EGT0XB])d>EUqT:2HCL<pnMul/^%40>"-CTA8cS:"P^r>s+RI?2h.ARa-(@n'7eK5G'"pVpsa(cN`,q&$Dgo^O/'A_C[`r4oP+HhZnRZ6E3`>G7T-[SNQc6qi`\Js8:HW-Q'FeT)HXNd5Z&u\WIZ^E'"(?P4"9s"BFS]#9.g'M]X=aj`L'KT6F?Icd&+D7&8;L(t1D,+NI9Q4rkn_GI31@g?U[.n@P0jLVW'7d8-Z!duS..^J#H1:TnukpF$!!^@Qoi49+u]U0rJL_B07]Q0,>M\iHB3-\5uZa$7)qHaib'c/f]emT+Crnmbu;p)a&Dj$!p0F8Mp9e*d(1Z^^4BCVnZ[IsQFK45X?!r+:M(_>7HKEdK2>*DK-j?W^<hN80Su'CNl^pEV)(<M[BK.RgE$i7GLZpj/fT-gSo%$'%Zcd#le4b$s.Ur:7Jg%MJDm=4[uAQ&%lMg)4Y+OJ/U+n]kLK\fbWTCY[Lq`*E%mq<@Qoo45n;\p:s#TOe1*E-Zd$nE8WiLPH]?Z%]66O.LBs+9'A(n'6/UM9*G\o'k<AQ"s`#f=TM$VjA4,G's:Ul-I-C4YiT'NL/o,EHeh&32J*B/lBUG&EB+jB!KFc01I:f3/<Uk3k0Q&6X-e848e(RK"jEuiR?!A+.cg*"OE$(Y3C9/jlt#'Ieim0rkRCe^Boo]puTK6mN9n:UZnZmO/\/?/&CEAo_"n!*9t?6-P%9tE(1lCB'XsFbr0tLq9.^%jnhL&-r:Eu47;_#E0hYrqs]A6U9r+EJ,\RgP<G?5puM#*npGY&p!9%&VVOHXSj,<k]gTo_*e*L*rFu#mo'1sSp@b),IIk4gr#^Fdp7'=qrqtm00@>i&E+Qeo+2=Z6F\`Oh,Usa72^,h%qsiVB<,KAiQC#p@4?^HIT$<HBh!\dB^3f_Oio/-X=1d?`s6$rmH\l'`!uf]<i7gOSnf[iI`:GjW?iOo-,4`^p`P)>8\'JOh'DTBY9/FYp1Rb'&o&B#.b/1$Fh)/tFcZZ3^M&j=S)KU&#/S8kkemHf.Dol3ijbqOagL/jUPNOs<h21C&rt"Q(UKd~>endstream 69 + endobj 70 + xref 71 + 0 12 72 + 0000000000 65535 f 73 + 0000000061 00000 n 74 + 0000000132 00000 n 75 + 0000000239 00000 n 76 + 0000000351 00000 n 77 + 0000000460 00000 n 78 + 0000000537 00000 n 79 + 0000000620 00000 n 80 + 0000000862 00000 n 81 + 0000000931 00000 n 82 + 0000001222 00000 n 83 + 0000001282 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<bedc9465afae0464a80dd18f845273b8><bedc9465afae0464a80dd18f845273b8>] 88 + % ReportLab generated PDF document -- digest (opensource) 89 + 90 + /Info 9 0 R 91 + /Root 8 0 R 92 + /Size 12 93 + >> 94 + startxref 95 + 16520 96 + %%EOF
.github/reports/hvac_duct_noise_example.webp

This is a binary file and will not be displayed.

+96
.github/reports/reactive_silencer_example.pdf
··· 1 + %PDF-1.4 2 + %���� ReportLab Generated PDF document (opensource) 3 + 1 0 obj 4 + << 5 + /F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 6 0 R 6 + >> 7 + endobj 8 + 2 0 obj 9 + << 10 + /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font 11 + >> 12 + endobj 13 + 3 0 obj 14 + << 15 + /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font 16 + >> 17 + endobj 18 + 4 0 obj 19 + << 20 + /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font 21 + >> 22 + endobj 23 + 5 0 obj 24 + << 25 + /BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font 26 + >> 27 + endobj 28 + 6 0 obj 29 + << 30 + /BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font 31 + >> 32 + endobj 33 + 7 0 obj 34 + << 35 + /Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << 36 + /ExtGState << 37 + /gRLs0 << 38 + /CA .3 39 + >> 40 + >> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] 41 + >> /Rotate 0 /Trans << 42 + 43 + >> 44 + /Type /Page 45 + >> 46 + endobj 47 + 8 0 obj 48 + << 49 + /PageMode /UseNone /Pages 10 0 R /Type /Catalog 50 + >> 51 + endobj 52 + 9 0 obj 53 + << 54 + /Author (\(anonymous\)) /CreationDate (D:20000101000000+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) 55 + /Subject (\(unspecified\)) /Title (Reactive silencer transmission loss) /Trapped /False 56 + >> 57 + endobj 58 + 10 0 obj 59 + << 60 + /Count 1 /Kids [ 7 0 R ] /Type /Pages 61 + >> 62 + endobj 63 + 11 0 obj 64 + << 65 + /Filter [ /ASCII85Decode /FlateDecode ] /Length 11766 66 + >> 67 + stream 68 + Gb".aCNCFbEo6O?p/?ihbk8Uj:AMg'XF$c0jC5GFWE<4N)?N>g"<[fnfhUYLVk::'JtOMh@'%hKcXuWRG$a"Rh:(3/CHDXu=ShK&d</ffFeAWKG1LeK(S)"`kg$p1St/%?l%t[o1&&]`jLUjun$B:g*rOetB4IrImnG@PFa_=Sg2?l[#rH@#0<%$;5S($($%$V$gHYLWIQlLmrGCD.Y--s2fmMUmfB:?%ibGg1=tRAPeRnE[48Z9EqZOt1Dt)(ij7-P7St01nl6CIgWB6VX`I\I7/cBVVn+pU>o$r+L?SARJk?AqDlD!$>cM,A55Fcr`]a=W+/(K,lID25Drd52??*%A9:LC6,kM>_CC?B!>5$a[sf2o^LJ(cL:gWS+TS`RubPAab\IltLF[/8Zqom3sXRScjf]q0("h>-V1pW`#^MsL1bdlDZ?^Z[oLT)I/+bJ&2rq9siY^:]nQ54/=Ph9;5A5BMBYrHu>.!L8s(YEneUqJ,i3qj5j`B&<K8[G7odf<+5G^#\lFo,5i$e!7XhgD0Xcc_'q*^k8Zs%m(N-<(J2,\KT,BcV,]SHA4u:m1CBN:B>cPC-.6\?,Qsp2qsUkd?EHp?d4qlcO\=>)V:I?h5J*Y+G\N2;\c^doHZR^qrN4q$4o@qSdor)6L'aDmX7fg&\8!(S';I>c\KprkdHE6mciDLBR\iEdIcS9]E7p2)EF?oShGNmWa@G[NlZImIF4>C:CiHiVW-IMeBd9HD)Yi$m:s=<0(+L#mIr2PMd*DIDjtcqob)H(S.U4j4<(H\bB3csghbTGEMCCBmX]1mh@8"J5Hdfu=0qSbe`CNpr,5tJ%FIB?f=SZtaj<>bKe\?gY$Bi%PL&BL=7c+\p@WagI8A[i4Enn$o_ZD["7*iih@f;&]G\1Git'#pmhS,1hrnCKkF2oTotUNII`LLfP2CP*epo<d$[r!NT\?Tp[rW1XYZ^FIWrCgtW&$&sRXFDUNBTh82jAm+`)`\bW5c&_G&#qpru(h7nW:c^m2qt9BRYE9KN<s6Hn;j*Tab'c5@8Wld>qo;MkT-[4![agY>&Qa.T9W4I>oPe[7p6'`4NGAXgGTBZ2:`2/Z:\-5G31?J+X9MXe9:_@g';Rem"5=(=k4mm(63^5rX?(Eq'GmM#V-ORpi-`ohqjB=/"CfWe'kZ.`pfGU+oL9ee`Nk.,eH8:Dmj1c&G6B^Jja\+.DiiihSq?]JnY32bjKJCO0'ToSl<O'@9@j7Qenr7#U\dp7aXZke@)Gs85l2j)k%I4]88Yc_dL^mG$aZ)W1CGP4)b*3HW"4o>!(uOSLMm#c=dP!CEm0bU-*5R#d9->*rSPj-mN>[o`7<H&@5TX5U%J=EgF<L$e2H*S#hr^S02:A:er01j>"geZ=1I6_b6m#cgF9W)4FIX(bjX&$u\4Be7VG0O3'HCfEkR'nMjgTg^?q*2LNBg2'c?$brCc,c`!s2_).0".mMr^R&%o,$()C/VZF4"Xl[*k[@4HY!WA&iR<6242kg0lDW.5NSP:O[UcQ#Fc27h%)QF\1)@lD@*/\Nr;Burm)9G%j)8t1gIjhAAFE5Eo^Wh?Fam"M.j8]CD,BsW&6>P.\&6"JX`]`hKF31>Q7#pE6iXiIEj<)_mqiUZp'15"J6kn;A+V`#K=e6#>YBW<dnQo71[V;Afn5'j;fE,EQa_(K\7KK'UYhOb%(?'-:V,Y)j60E!()jd[(\^gY6+Pedr9WOS?Z/X4EWa3P[Z<&.3*sd/2N7-^DLf3#WVtCdYMXMV\Xhr/]:pLN3;[FB@5Ps8?Q'CRed:uSI1-*1p,:S0A+gC3bsOkd%<*s:GrLcA2b`^AJp%pJZ&=>3R&cm@B7@8q(=.\t3u`S7S?"=b?j\<dY`>\fg"M<7#q^cXV!0>XN-,G])0%ffZ[e3))A]_R)E2jUNW,RQ$)[8!&X\-Vi)dHO3CB(cCG(r!m=DEXWFkJ?)^+B'(k1IQ19;.&DmfsMZSML1.4n44%kZG<PV^rGhLcRY@R@(Af[k`Aau`EpGRhj;=FudQJoTfSH42^[S?iU9(:M)UA!/3Pi<e4G#Ze0qb,sU.9c5gd>Snn<h0`h13%mo9nL.$9j9:3f<1Y)n:Or<**$[2]1)S?>_4*4(i4(%YGcI@TOW?$^Zs?t'[V:Gi@0fr_(7@Ac!i(PN";r-U#SeOl-^$rMdOp*lL2"e<kt*R"Yd%=X-@MW:kZn2f(C54B$,I`)$CrtEScYSuq@J0;`69@NM\387q&o4Eb!_!uNMUYmL"8VA\]2Dj`<QX'5,a.-2_"<B?pT3u]S[HKA#@ZeHh\aP08lGQY<p=7>0[r-&545C3r1.&I8\KF$0rL*7-tgM!@WMc`<pbjnr&&]B-U81k"lAML)A,2k;fg,g09e3Xrp^LflM*j@.FLG9g!1C*JbV=-P'r1&:afW'dM.O9EKKiAdlS3LPePU/N@2`/"&:p&sA4+%E!j8=N<l2^,3^2hFf.u8_u3D=LQ8hX3P]$6m)cN!K'O5dTM*kDZ_nV&0gSS91du5Tl_T8/nR3S-J(]Cc`l:0?-%Ri&^nO9_TE$s%Mjd8%Neu\9Rk[p1+G6k8$#[#588$UQXVnY#fJ76E:UmAMI=8d+T*ep#7Q_tCC&-t:kogZaGZ'=Bjh8p;$4_f!b]q/Ce7rALRKd10EEC#YfM<U#r,?Zd8>U#i/7atOpfEdn1!R#!R=a>6O(![$g=V+2GojO`'P^W6]FEK$8CeanLIO4@s2Vi*%h:D".h>WVqFJMf+_cO_PIY/F^jf5_$LqZnbsDW+VOWE!\aqC>MoU8CO$6MbMcJ[7#["6d(Gbc0o62]YQYeC=TgENWJ`;O7qD`e+2F+Ld(p<@n?UOt*Tkj8+E+F6Nh.F,)8i\c]PDmiL^)e;%]2Uf$JA.imL0l,6&?.<\,.eYH"ZO=6WA5+J/%&8=P7P&%p!lJ>Q=(@];TkfL3u*`2o@P1YQ[SFN<EQD#]^bjPaieS?OR-LW`TA35&O'T<O`d`0K7tnY_*OD)]X4R^\lPjMcku8P:$bGcnI`2!t:hYl!ODr\I.l0S5GhT+^g!OfDq)WZLS)+gn@^cR4#4DkCf$ZiX+HX&elH6S&4Ge^&^TaH7>R>Z@SFU.cjgo8C#<7JN=Y=RG&lF@;.3a_%c5CGU%61C<+d-#bn!H`o3;agOZ?5dbng%b=%o;-fS'ib"-3%Eh8+NJuTW<.JlG((4nk!)'jH_n8k^4"9;&%$d4/8S-2:Mb*sCmY=(ep*f`Xc4P[FY%o4LPg4h(t`mZ([18#pd4j`YOCVtDtf#8"2BYFm:LWWRcrB%gS3L#CIP:V6<<XiCJ!PHL-.WW6A4T?Z>SuAVJPSpP<PTj`dYK%hHRG!N/=MTGGHMgUMj]14B3BSdN*n!A@%I0"\4VI+,g<-W_Chd1E@4NhN%G4Zi'\:I4TP<)(_U?>lS:$D85Ygn7(3E9DmNejk6m3kXlZpQ1'rR.bjS1jUh*`N7<cYmD@R/E^4ihn2(Br3$nO_"p#hOsf^?iIBJh"]`/n0M_Mr#7HDj*Ni/&\["<?[*sX:2]db&Cb/,VTKH\5Q9?W\[?HVn$NZf#UU,;Ek.OZuhm0-jO#5<+OURU.JuKoKhCiPk>5'K8:@29fP"i(\FB#R]mC3K/@!JJO!4UA-3S]]Bg`J&B7IV'bPT1K&SSe/m4Y9K&P_M(OackTt3)hgO`N@FpJ/C%-):TKkZ&?i=Z@BDWqrJ495q6@PpI&j2>\AA<T9*[HWoIFr+WqC"IVkT/SO>reQgifEn5CV]Fkkb9!fkmBuoo-s+Oa]K70/]pdH18_s1"eW&U'j-.&/W>g?%lGd<^ljitN_*u8i?+h9"c9e6m4M'&PH9*h7c!\uEQmI+[$+jdo&DQCT[Ri$Om4DG9b3bH\HlP5aW.CX\>\tCDGc-]FH)D'(/&'t]5hq6tBHkEmU#f.\oHXNJ(l(\jg[M0[R9XO"VEK$\b:tO^3hlBue$9!+j:ST!0s_SaNqbZW=s%a'=e%iCTa(\e9C-a<>T3f)T*N&0[4.Eqe:."Z[(aZ8@RdeL0lOA8"U*K9D"1)f/]k5h`OV,'"]YTnZ_6,U7u<Mc"L&?'))Y1\*LTm]HEf=)BQ\"O,)X)T*P.M!=GPaOrO8stUQT>K/WZht:J/L",^^"m_@_4>PEIUK$'2&29UbE<e:2[1rGcPc`UqQUSN,,"p2J9#YOrDko*%8a"F[Tt*t%8(]^tSUhhVk4,tT(IAb_^&b::1mV:23i(Ik!X2_f,'=NYZ4%r@JB!Xt7nD?6"bJ[$oO]N_Q]Z*suK*dth#_U3gNAImYdqlTnlpUfU1!p)02qrBAugdRF=BG(ij4n2@"/gA:,%cTdt*\JN->5nT@"0`_GCjo(Ji/g#3]3BBU"BoX.RZ#3?<C'5r0)B0A"h*'"V[1HbE&@-tYiX\.F[iU_jr9u.NaB"tpX.@^UdokrMujK4+k/69OHKi)M46(]KLNCT)+8eg.m*+VUVUUq]le$J8ACEQ(p!P9:O%;`pcqYf4kVV\nUXm;nG'f4egJ*o&mdkF2g@3%N)qTX3*u>_TCD\JGV'YO0"E"8C>$Mp]%?e3f13-RS(uUlG4=5.*c>]LCOO(c,N1NT^A>'l="SbY?M@WqnG5\#9s-,]K/lDGeZstjFg(HEYE+?<3/T]]b77jdm,<3FCOO(c,FL/9AprMj>]Rs(c'C:lShU"3(kjc%F^Z)a>4up:m^57;oo\a4D'.Cl2W.TY*fejcepiU&h79^k5\>6LapP,O0uQS$0i0bDE>WpV"BuP@!S26,G(:C7rL4`[H4%(*#c:9[Y-brf3XMO32$d`AJ,fM47*0r":Z,^1KO73cIfj:W1/9TQ"Xf=4FSB6<2J(]a/\i@DKJ=aC+EY'tY:ai(;StU]<P#Ee(9CK2JE`/gF/_hs"m:IL:I>GQ1$CMkKDI+f[k[5a9XN5:7V7j;1'S2<eBi/.DUcKGK#7[E!EShf]Yd<NehP5dUB>lXQ[RFY;><e'3"er.KRjFpYiKtf(>m^4J;A+0Qm>BIV>oLflN%6G.%o)H@U?QS:k6SC4Hq@0+`O_H'"lnj@$V?3lRZTnaqC'o3K:,L(?7).pn#CAUQJ[T(7+ACh2fHe/WNsEc4B#.&;.4sXoO<4b^lCLW@4Kpa$k1,_l1(T+(3L%j!8"=rGJ,t:D-LOr;aMs+=)oVTLsE#n$o0EQ3lS9,ek6FI02;u1hMo4$q^@t3^tN^'KjB96o@I5B.#DJ*`pWZr$ad>bB_0J[n(aZ1g-P<F27/'ZqEA*;b5@B4'^)K;5,3@@nP7pDu<89R.3f4f@tlnF?"k0,_HbsL+2XjYX)Sqr)7o.K7Pb3dh<X(LtL,NLG9gl#;TU,^g(,(!pmm&b$Mmon4C3;&/i@7Ik_AHi0=K)$BecCHH[d?kgfi*Ue6l+ncL*Q$(R?l%8;oI$tV^EL3rFH+bg>:EnS2$Xb2a(2T("[%!nUqN'cfEPsfN@),0J<)>sp)E>(C<#rr5g<COnpe-876&dXGLBA9HHMF]h5pp(Uf$&-!u!Upb_CaO]e<j-i5RkF!3@^Ag]?aN`kX#UWK6<=F>c_a!3$6eDk;^p"jeY]<<>I4[<(Cn$>6*td0BP\YqKXM\a;,a3h/[5FmPQQZ%[t`<W?DS@I;nf"nXBOVUq'JpPJ^bS@:?;Zubum"mNbDcbM@2j^X3p7X>IS4t[NW+s>OtZ)B#p\I>CgY71%Vo/K*5GgSnV]_OEA[7(*AQ@(+4^5/Gh=kgqYHTUn631H_]+'KS.XMMIL_hLI!U,Htkg`a\en*LZma6D<$..i9!#=[YZ-S/`s6A&D/8;XZJ4-4PsNk\"9ZQ]N,k&#'.b,S.=$Q\UPBNM\Fe=fIe7b/j-k45QPr)S"VJu,/I`I=uI?LAqWSBP#K>aIR"&<nb"MdgB*E[-kPN3$"F/>_d]c'\YLVe:1P\bJYJ?Ig.=nX(qN*li[BdsD<,Qq66!oW.VXMn_.5-kMQkhB?K!"V_rQpl8oT2OQ"64uh/V1H%#C5Q^C<@t%^9psY4eZA9FAk:J@pjCJp1eNOb170dOZ[m?80#*WC;=n2)I9P$%X[k%b)dsZ5SfSI:e?F\i<_dI9+fR-a-U%D397_Q\1JIY6I]Din4gLJAQ#sK3ghqRMQr>B\r"LY-RhMVt1o9C9+=2"4m#WUl;RqJZlAEQIab7U-]O+b49Ms<R5]ndcEH^C#Tl3]Guq_$<3;cAR>79e*%)mE@<.n(tZ[qo/LiBiTuf?H,23)(rUaMBP?0m=jrPcU7oqe6#Tm"dnAM"P:[`M"l)r8"OIUC7_dTK03#K#D"4NFdf]+Z8J/mV7FYJSMbO_V?kMTKa#R/'-*-Hmln/nie6X?cqdBJAAB`NWOGUm=4_n8%Kr,E*No-2BLmjX?/Jar7Bb_:MNt]UC*^DoqK%OUT`[gBQJF2CiE_:?<oToG)1(,JbS@Fa=ndB_>>4EOXUeq>Y(mJEYF:qPMZHm%3hX8_Y.]H`0SOZVD[>VSLU?`8$lF.V;(7[e'a2YcSG]cNd11ojGOaN=Mj^^624,mD6NVcO8R5%*S8?2$-2@o57R!ZEtE")UZZKY:``s[t`LlS,8@b,)HTY!jq5>Lt&K'(06+7SkJ:==a31kOr;25qp^fGV&2Jd^a]#*p_]a:H=qeSNU1`O_qn^lOn.08Le80^3St+q:0!U,id5(['9[8H?L/opp>8D/?KdH7;G79L?j0MJ6'f,FrWV*Z!n"i0"i3Ys\XLpNUd>`i/qkkt/ZG"t"-$1;SO9\RGG[iDWu*WNu[Cb1(r9Nkej#P=JQhO67M)`&=nLZfRRV]kTR)3,:>SNa\o>pO%-lUD4[I7s$\q0fSm0Cs8,ehU(Z1,A8NtWG]Q+Jl3hCi#s<K<r161e^>%#[&PK(qT&`YpldD+92*k5#isQTT-iEG2YB&,FLkSO'TB8X*e)sS$jrPO'F?!dkSgL\6.lFDmIq;ss**ER)hnS[3atGU2EWR9='+((e*l]S42m[H"&7YD6B8eQ<]>&8T/!qfH29Y"i'4fbm-iq'c:?u,DB5E"TLL$Xn63Y\[nUZ&KA&5d\irVCI:-je^t/./Xm#plO84@/O&Q1SBcAIEO?d"=E!`<lI97*frGM=+oJZ9d3mudH'5%Hc`^V4%(W!22[UqFV8,`:N<Aul)r`I]ET#clRg#bf837MRgD\gU$8F<:q<YjmQ'J[t^5k_L>Bj&ZC*2>@f(9i<'=n#NJF]m&i!9de-O2t18H$^a:@tp=>o7>Jec5-8>._6BL%*]Q>aiN3$OY[GPPouJcQs1"BMNXg;]&piTg]V4?F+ldkQIIf!d\sB7e;9$HSYME)P/(gX8DFQo+sRR(]EJo"It94sr2uB2DNNW/9^1Y**IT:D"sOpKoIY"/>eo`PYMUJOc@"\d"a(,KckLsAN*m[?f+5aGbTqB;c.Q;5]PTr"L5E.`7<9k\BLrfFRG=QH=`eD9G@D0X+C`_)6Op^F.OTQ4$.TE+T(/!U!Q$-#p#,r#6M!?2",of]C-n7XD;m#RTDdM9eE2^YDq+olk$(MU8kgH5Yk9^<1CSmBj&&:B#+@ip7!:\pP*C]Cdalb]'%M$U.$3X)q&Xh](BQ\%WCqj."eom;O,0d*\hr\2r,PZ.Qo+ZTHD11/j*"c#WR*:q^6mR0"Pao4f)\Cum?PBK3\#Rc`A[`W.PntHqY<-d'9h0mZ_9l#<jL\+7!grog+53DU<^'X8S]I9%P+?gnd=B4L39&?2\C#$$8?'4?7U9)n89h+#f7OOW\Mu$@in7r5J$Ldf[AbRWD9?jVc0VEUU,&9D%JB[\.5\W4IDtSQ9H^_*aoXG6aIG!J,fMR.F[<b2OlNcIRokJ&+U9?+7pX[[D()0(33dPbM\G-GAT"]5k\8![T\otG5n%F^9=LU-\flT(`:5U_:TD)el-BfM_#Q@!MKYi,b8"r7Mgdt>?DbZG560ubnr5aApH4(e64]K%(7]VPi<H4)Ik,a<]4CG2B2u3O+YZHZqiu"dHE_&NMhIFl8&5JMI(K_`cB_+og3g!Uj"X>h9f'j^:XWU1.oVYrgqEkg*,lA@;7hEdg!s7HC+3@DC-/6L2E[BF*A6C9Mo5Q@Ym6s$QcgG`XaFp:=XWPdL^JSY\iMDLS<)kbnb&t(>b8Q/dB2O&4kERZ,JYWQo1,aCIMe(r&S5/e1?<gjTFI[1.HYd%A+QuH+!A#XJp!LU=%QVi+F#pV``Oi#Zjh[oL/QZ_jXHl(34"H#H4t%*'p`brJhS"@$nluD,_HI6.q%1Li2G'jV,26UP;#4`>+BlPfQ?&&dRY.fjh-&1g-VXV2_Bj'U!>Zp]q9@i*OeFkOJ8C.@0G3bimU^%'ZC]5ZtBm03Fr^D77,cgmU$@mfUN;L:7JKa6M#=,F4pk!m=.r<2%u+X0!0K#u7QqoMdi.'>YC$!:<CD`@;Q:MH4UQC.SOYH`F<IE?)5tit/fdb;aTB#'HB*)#qs\G"N/ml]iW1/nTr(N):ci4eAJE:_bbA-,=1O0lSMRr0:+eEh`SqONech=X6cMS$DU?&<.J.s*21&b,jh!,luI\@nIak!Ki"b@1/a?9NDIB0gW>4Von`L&@?]PM&)%A8)9HGVf4t5bHl5]beepn!B'LQ,oDQ&hH##qT&<#I71BEa8.^5F)A/KN/srlmZA+*?<r/0qc(3j2`N[Bi/tuRY";WEsV)1BTRk%F%.U=L%JfD'*lSjks')"iI\SP/M@B1ms5O6f.WgDqR3?L8a*ht[-Gg+:t4iEa!]Dj#B8OrZ2bf"cRc80I3M=]Le#TQFk*[tgrs)/<*IS^UI/0'Ak[emWDL1MW@O;YrVF#4>VKKN%sUYk<Vo]:0hs#jW9^\qnW4JHgl1ZjK>,?g,b-#YhqH?U,H0sH0=m\PV9i2N;9$O1!]-&VcsfEA,YEb)$p:GEq"1N,a,;Sh^pfF1;[_l%c_D(CdBNIgJSNW]@ZPsuX@/i?uIOeCM++KM$=W-&@c60fZ'IH8mhe[>gYO$o!e?F;AP*G$,Z:AdEU'I[H$_^T@0L;$Z^h>tU[Z,SYK<n.ntY%\l:R)V\DCbuZme]?D(>\Kc'KN6A)3/kSi>A!sg"WhqXi/:u,8b`QFEqfekTRqJ:-3edR*2u4Xf`kAU6]g.`MgTB,Y@lQ)2Bq_D=Z,P8I1_GiI#6amU`V5>+;N$%!/lT(4@$@e)'<7o*"miS0A[\RXJG'DV[HW1QUZ>hDII;3p+.(Efpo&3];j`O]kI'")>l;&^I^&nBIFbI7s:Zhs3Jr%*U/E/?t%`$'6,ONqo:)X2F#q/UiIP_^n>R`i##N?J!6W\`hSdb.qj(4BY^@J%>j'M5[fRu@6m)=?2C"!V=R:UFJ+1$'&OSUc\oWZKeh*<gG`[tI?Y37m=Qm4rG1iZEf[NVmA"(QoR1hlMnFK1^=UYJS)4\?3D+dFORHEHpE#,L!dimfSD8am[4%,6qX'Tbeo%0CXmE3a6KLtTXbIHl":j/)QsG+8po]cYFdqB'S-CUhY[$olJNehQDJdf4i?[;q;I^s@:6(nA,GbF.P50M;ao!n*qm"R0U9\CYq1N0S:2F_@g`1#D1EDSkbpHr?:,Imd\<g_7DK)7WKG6Mp8/X1gfGL-9f6Y$L!EXe!f'&h4Gm?(Nd+J`ohoXQn\=q]gU?QuCp#58W3]i)raq,2^0gOPNW<k13Wb2BS_TcNWf5qHMi/*F%Rfr<difjXt]qBm0Ysm*[Snpo0C:nN0B51,"1RtF]iT*[ASr61de/#D\"=pEt<@r-61Q&Mp[(u@-p]!Af>q0<O_Zr.NP>`GeWRXFsM5""Gk_Dc-B7N%MB;.O=W2Z(,f>nEd3&1r@9(97*U8B0:QX<ualY2ZL=eJ/j6CboI3S[S'".YA9_W5L.O9`l6&f%jBYm8naAZ?lj.+u&/qF>>@WrQleW6eNOoKd"-/qSb[CEPRibn?ad+sqF]BG)OoKO_>Df$SouCj'Vs."4_Wig!Tl/giGYLiSqO1G,0m:cYH;45A+@#$N'7;kp4B-@4<4TSji0Fu6&kq/Fd[37tQ-ZQW)hR`S\g3GTsSbotpGX*6+!O@L?YBuMc2P=3)Z2Y!V$;54NTq^'KB(p`3_NS%99A>Bnm(pM)^'5C/D#gX*8Q=8?R6LtprVp"06GIOV=NGD(^HBX_gQnoCLd`%*J,X>lc_a0Mscub_8K]?;`Y"G3>'FfFK7V7apjq5NLi&20-):6OJa_aWia[i$ph;ENKNB;&6dc2'(P*#m(N$ObnHdMs4#cND(3"0[8fhSN*n')U@'B!F_R=S&[-b]:LkuLg,j&q3aI&qWY9;>5aAWA9tV7c)K71Kt^je9hDORd_3b=8si45X%$gNdC1[RG^DE2QWK5B8&=bs02d+)6jW]+/BQWcY?4=cC^MlB-,PJ20-DB`=Y0"@MmC,jge0Bp1+1'lAkp`ADCYql<;Lkb3Fu(C=80qjX(WJuXdF]0)f/..q8Qfd:QJdUjhCB$t9$fh$h`UHQnZHK%($n!SFH`-W!b*_[,cUi`!="hcPJ"c+BLEh+iR9G_u'b85FJYU#=*G-]VIgm37Pl-<l'SGR[Z"57$#r$L15PBUXQO/<^h]R/Yo^Lc8]h_W?n+,+o7B>Wn,31UAR1Z9M6K3'gK9p7/Vi]RtDR<[%&n=KT.IbI$2k2&(YT@a4.XKf^5B,]D/;?7oB!8OV1p%9'l>M!:iQfKnW.*S07H.-/R^bU3[jRp0tpgi;k?_2D4r;d4t41g$Nl-@$2St1jDAQO6C#HA9H0Q^qEQ\tJ#I?3j+QupZZk^CO.ch5KLjI>u@ZJ2P.Q^btEAok@`G1We>#%.D)HeP%h#d$(#jeVOVHgrDISpc4.\`(Xe%PM)&)=Nl2!KW'o1oE\k=E)[PN4Y:gbg6=dQD&]R@D%qMN'3e\2?2F]D#S]=s5b!F`gX6Wo>M73odsZ`0lUM(0IS#rL#?Gq@)V)6qn&0PXHf9G@PcLQe_"g&0L^7O,+)RG)&a^DmUCT+hn6\[G(86HiV`?EC->qJQeTh(DiCSiop6JPaIqT^Xe%W[VC]L7h-[bCK@'Pei-R1!G]05l_fVfWNTVS>#oMY3FU?g;qsM(KUsp.c3So;j!Y3?eATWne;\V$9^\8OkV=fBuY$ZYXr8^)s//#[@l?cq8B:J="fm_"7UfdQJ_&)J*00Fa<4[63D"o.u0B*[<Wn8e8ah;NS5gQ(d=Pb=5LS^cUq\\g56A$HQWo[Zrl76Wi>"7,Wp].XTcDm0/lmUEk5FPFua2d7k?X^3YfF;M"0ldJq9lZos!hla/3l-;K[I_ONBEimOVp%`Dc?XIZY=0q=0nS8'_Ho[[7hL="id*71>;1<!&5?Zlk7ET88I<VWVX$cMu%T@[h,5Pd,'Fu8<lLjj90@*G.H+j.Gr477K;\'DS@e92hT6ACXGB(=mq>&J\S%dV'_I"kq(EaW.h>)4U?bS_]U6N/iX^QhNs7u#%"ht(I(0=0*m@WIIc$[3AU?DHn$Z9ha8=n'@""1TLlgkooghO`A#BC3.@!:5\K<^\f].VJ\jF`uKmtt#P01ruCs'`<!hlbPFIbki=.\1[2\Qg^fMtqPp.nGJSq0J&gTDrrGG]c0aa1tW$pMkUCJ$iHu!G23]j*LD@^\d.H?U$XNma#6)qj62)qt;(@M$M#_S&b*Mq0%51.,ZeB:/*neQ>q-KrZI6,oUcVn49,.l:+5tOHSlq!2pt:LI<Y>s#kR^-[%.0-o7-KPV1/THJLE[gN&1jq-?6LQq:Qi6=5c)-r*iWOQ[%rD@'ct,[6_do)GnqcQIilpgD0+nm<l5DI:i(c5.^lMk<Adem\o2DqRl?b(%*$`(NH+qD#QD$,0$;U++>6hm,fG%-iXEbYMiC~>endstream 69 + endobj 70 + xref 71 + 0 12 72 + 0000000000 65535 f 73 + 0000000061 00000 n 74 + 0000000132 00000 n 75 + 0000000239 00000 n 76 + 0000000351 00000 n 77 + 0000000460 00000 n 78 + 0000000537 00000 n 79 + 0000000620 00000 n 80 + 0000000862 00000 n 81 + 0000000931 00000 n 82 + 0000001233 00000 n 83 + 0000001293 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<a0f1cc3d4133031ec946f4261402e161><a0f1cc3d4133031ec946f4261402e161>] 88 + % ReportLab generated PDF document -- digest (opensource) 89 + 90 + /Info 9 0 R 91 + /Root 8 0 R 92 + /Size 12 93 + >> 94 + startxref 95 + 13152 96 + %%EOF
.github/reports/reactive_silencer_example.webp

This is a binary file and will not be displayed.

+31
CHANGELOG.md
··· 43 43 the metadata `requirement` adds a PASS/FAIL verdict (a higher SII passes). 44 44 `verbose=True` adds the equivalent disturbance spectrum level `Di` column; 45 45 `language="es"` renders the Spanish fiche. 46 + - Noise-control performance reports via `.report()` on the three 47 + `noise_control` result types, each a one-page PDF laid out with a per-band 48 + table beside the result's own plot, a boxed single-number performance figure 49 + and an optional PASS/FAIL verdict: 50 + - `EnclosureResult.report()`: a machine-enclosure insertion-loss fiche (Bies, 51 + Hansen & Howard, Engineering Noise Control 5th ed., section 7.4.2). The 52 + per-band table lists the supplied panel transmission loss `R`, the interior 53 + build-up correction `C` and the net insertion loss `IL = R - C`; the boxed 54 + figure is the mean insertion loss over the analysis bands with the external 55 + and internal surface areas. A declared minimum via the metadata 56 + `requirement` passes when the mean insertion loss meets it (more is 57 + better); `verbose=True` adds the interior room constant `R_i` column. 58 + - `ReactiveSilencerResult.report()`: a reactive-silencer transmission-loss 59 + fiche (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, 60 + Hansen & Howard, sections 8.8-8.9). The per-band table lists the 61 + transmission loss `TL` and, when end impedances were supplied, the 62 + insertion loss `IL`; the boxed figure is the mean transmission loss with 63 + the peak transmission loss and the device kind. A declared minimum passes 64 + when the mean transmission loss meets it (more is better). 65 + - `HvacSpectrumResult.report()`: an HVAC duct-noise-spectrum fiche (Bies, 66 + Hansen & Howard, Chapter 8; VDI 2081-1). A regenerated-noise spectrum boxes 67 + the A-weighted sound power level `L_WA` (dB(A) re 1 pW) with the overall 68 + unweighted total, and a declared limit passes at or below it (lower is 69 + better); an attenuation spectrum boxes the mean attenuation, and a declared 70 + minimum passes at or above it. `verbose=True` adds the A-weighting 71 + correction and A-weighted band-level columns for a regenerated-noise 72 + spectrum. 73 + 74 + All three accept an optional metadata header (client, equipment, test 75 + environment, instrumentation, climate, date), state their method basis, and 76 + render in English or Spanish (`language="es"`). 46 77 - `StructureBornePowerResult.report()`: a one-page PDF structure-borne sound 47 78 power characterization fiche for the power a piece of building service 48 79 equipment injects into a reception plate (EN 15657:2018 reception-plate
+107
scripts/generate_reports.py
··· 2414 2414 requirement=0.75, # SII = 0.851 >= 0.75 -> PASS (good audibility) 2415 2415 ) 2416 2416 return result, metadata, "ansi_s3_5_sii_example.pdf" 2417 + def _enclosure_example() -> Tuple[object, ReportMetadata, str]: 2418 + """Enclosure fiche: the insertion loss of a machine enclosure (Bies 7.4.2). 2419 + 2420 + A documented clean-room case combining a supplied octave-band panel 2421 + transmission loss R = [18, 22, 28, 33, 38, 42, 45] dB (a sheet-steel 2422 + enclosure) with an interior of mean absorption alpha_i = 0.30, external 2423 + surface area S_E = 24 m2 and internal surface area S_i = 30 m2. The interior 2424 + room constant R_i = S_i alpha_i / (1 - alpha_i) = 30 x 0.3 / 0.7 = 12.86 m2, 2425 + the build-up correction C = 10 lg(0.3 + S_E / R_i) = 10 lg(0.3 + 24/12.86) 2426 + = 3.4 dB and the net insertion loss IL = R - C (Bies, Hansen & Howard, 2427 + Engineering Noise Control 5th ed., Eqs. (7.103), (7.111)), giving a mean 2428 + insertion loss of 28.9 dB over the seven octave bands. The requirement is a 2429 + plausible minimum mean insertion loss the example clears (more is better). 2430 + """ 2431 + freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 2432 + panel_r = np.array([18, 22, 28, 33, 38, 42, 45], dtype=float) 2433 + result = ph.enclosure_insertion_loss( 2434 + panel_r, 24.0, 30.0, 0.30, frequencies=freqs 2435 + ) 2436 + metadata = ReportMetadata( 2437 + specimen="Sheet-steel close-fitting machine enclosure", 2438 + client="Example client", 2439 + manufacturer="Example enclosures", 2440 + test_room="Machine hall, line 3 (example)", 2441 + instrumentation="Class 1 SLM (IEC 61672-1), octave bank", 2442 + measurement_standard="Bies & Hansen 7.4.2", 2443 + temperature=21.0, 2444 + relative_humidity=45.0, 2445 + pressure=101.2, 2446 + test_date="2026-07-22", 2447 + laboratory="Phonometry reference example", 2448 + operator="phonometry", 2449 + report_id="EXAMPLE-ENCLOSURE", 2450 + requirement=20.0, 2451 + ) 2452 + return result, metadata, "enclosure_insertion_loss_example.pdf" 2453 + 2454 + 2455 + def _silencer_example() -> Tuple[object, ReportMetadata, str]: 2456 + """Silencer fiche: the transmission loss of an expansion chamber (four-pole). 2457 + 2458 + A documented clean-room case: a simple expansion chamber of length L = 0.5 m 2459 + and area S_exp = 0.08 m2 between pipes of area S_duct = 0.01 m2 (area ratio 2460 + m = 8), sampled at the octave-band centres 63 Hz to 4 kHz by the plane-wave 2461 + four-pole method (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq. 2462 + (3.27); Bies, Hansen & Howard, Engineering Noise Control 5th ed., Eq. 2463 + (8.111)). The transmission loss matches the closed form 2464 + TL = 10 lg[1 + (1/4)(m - 1/m)^2 sin^2(kL)], peaking near 2465 + 10 lg[1 + (1/4)(8 - 1/8)^2] = 12.2 dB, with a mean of 8.9 dB over the seven 2466 + bands. The requirement is a plausible minimum mean transmission loss the 2467 + example clears (more is better). 2468 + """ 2469 + freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 2470 + result = ph.noise_control.silencers.expansion_chamber( 2471 + freqs, 0.5, 0.08, 0.01 2472 + ) 2473 + metadata = ReportMetadata( 2474 + specimen="Simple expansion-chamber muffler (m = 8)", 2475 + client="Example client", 2476 + manufacturer="Example silencers", 2477 + test_room="Duct acoustics rig (example)", 2478 + instrumentation="Two-microphone transfer-matrix bench", 2479 + measurement_standard="Munjal Eq. (3.27)", 2480 + temperature=20.0, 2481 + pressure=101.3, 2482 + test_date="2026-07-22", 2483 + laboratory="Phonometry reference example", 2484 + operator="phonometry", 2485 + report_id="EXAMPLE-SILENCER", 2486 + requirement=6.0, 2487 + ) 2488 + return result, metadata, "reactive_silencer_example.pdf" 2489 + 2490 + 2491 + def _hvac_example() -> Tuple[object, ReportMetadata, str]: 2492 + """HVAC fiche: the flow-generated noise of a straight duct (VDI 2081-1). 2493 + 2494 + A documented clean-room case: the flow-generated octave-band sound power 2495 + level of a straight duct carrying air at U = 12 m/s in a cross-section of 2496 + S = 0.04 m2, L_WB = 7 + 50 lg U + 10 lg S - 2 - 26 lg(1.14 + 0.02 f / U) 2497 + dB re 1 pW (VDI 2081-1; Bies, Hansen & Howard, Engineering Noise Control 2498 + 5th ed., Eq. (8.251)). Combining the seven octave bands with the ISO 3744 2499 + Annex E A-weighting corrections gives the A-weighted sound power level 2500 + L_WA = 38.8 dB(A) re 1 pW (overall unweighted L_W = 47.0 dB). The 2501 + requirement is a plausible maximum A-weighted level the example clears 2502 + (lower is better). 2503 + """ 2504 + freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 2505 + result = ph.noise_control.hvac.flow_noise_straight_duct(freqs, 12.0, 0.04) 2506 + metadata = ReportMetadata( 2507 + specimen="Straight supply duct, 0.04 m2 cross-section", 2508 + client="Example client", 2509 + test_room="Air-handling plant room (example)", 2510 + instrumentation="In-duct sound power (ISO 5136 method)", 2511 + measurement_standard="VDI 2081-1", 2512 + temperature=21.0, 2513 + pressure=101.2, 2514 + test_date="2026-07-22", 2515 + laboratory="Phonometry reference example", 2516 + operator="phonometry", 2517 + report_id="EXAMPLE-HVAC", 2518 + requirement=45.0, 2519 + ) 2520 + return result, metadata, "hvac_duct_noise_example.pdf" 2417 2521 2418 2522 2419 2523 #: Every example fiche the repository keeps rendered. New report kinds append ··· 2465 2569 _flanking_impact_level_example, 2466 2570 _sti_example, 2467 2571 _sii_example, 2572 + _enclosure_example, 2573 + _silencer_example, 2574 + _hvac_example, 2468 2575 ] 2469 2576 2470 2577
+46
site/src/content/docs/reference/api/noise_control/enclosures.md
··· 111 111 112 112 Requires matplotlib (`pip install phonometry[plot]`); returns the 113 113 `Axes`. 114 + 115 + ### EnclosureResult.report() 116 + 117 + ```python 118 + EnclosureResult.report( 119 + path: str, 120 + *, 121 + metadata: ReportMetadata | None = None, 122 + engine: str = 'reportlab', 123 + verbose: bool = False, 124 + language: str = 'en', 125 + ) -> str 126 + ``` 127 + 128 + Render a machine-enclosure insertion-loss fiche to `path`. 129 + 130 + Writes a one-page enclosure-performance sheet: the method-basis line 131 + naming the Bies, Hansen & Howard insertion-loss model 132 + (Engineering Noise Control 5th ed., section 7.4.2), an optional metadata 133 + header (client, enclosed machine, test environment, instrumentation, 134 + climate, date), a per-band table (nominal frequency, the supplied panel 135 + transmission loss `R`, the interior build-up correction `C` and the 136 + net insertion loss `IL = R - C`) beside the `R`, `C` and `IL` 137 + curves, the boxed mean insertion loss over the analysis bands with the 138 + external and internal surface areas, an optional verdict row against a 139 + declared minimum, and a method-basis strip stating 140 + `IL = R - C` with `C = 10 lg(0.3 + S_E / R_i)`. 141 + 142 + **Parameters** 143 + 144 + | Name | Description | 145 + | :--- | :--- | 146 + | `path` | Destination path of the PDF file. | 147 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the enclosed machine, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared minimum mean insertion loss (more insertion loss is better). The surface areas come from the result itself. | 148 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 149 + | `verbose` | When `True` the per-band table adds the interior room constant `R_i` column. | 150 + | `language` | Fiche language: `"en"` (default) or `"es"`. | 151 + 152 + **Returns:** The written `path` as a `str`. 153 + 154 + **Raises** 155 + 156 + | Exception | When | 157 + | :--- | :--- | 158 + | ValueError | If `engine` is not `"reportlab"` or `language` is unknown. | 159 + | ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). |
+46
site/src/content/docs/reference/api/noise_control/hvac.md
··· 191 191 192 192 Requires matplotlib (`pip install phonometry[plot]`). 193 193 194 + ### HvacSpectrumResult.report() 195 + 196 + ```python 197 + HvacSpectrumResult.report( 198 + path: str, 199 + *, 200 + metadata: ReportMetadata | None = None, 201 + engine: str = 'reportlab', 202 + verbose: bool = False, 203 + language: str = 'en', 204 + ) -> str 205 + ``` 206 + 207 + Render an HVAC duct-noise-spectrum fiche to `path`. 208 + 209 + Writes a one-page HVAC-noise sheet: the method-basis line naming the 210 + reported quantity and the Bies, Hansen & Howard chapter (Engineering 211 + Noise Control 5th ed., Chapter 8), an optional metadata header (client, 212 + duct element, test environment, instrumentation, climate, date), a 213 + per-band table (nominal frequency and the reported quantity) beside the 214 + spectrum, the boxed single-number result (for a regenerated-noise 215 + spectrum the A-weighted sound power level `L_WA` re 1 pW with the 216 + overall unweighted total; for an attenuation spectrum the mean 217 + attenuation with its band range), an optional verdict row against a 218 + declared limit, and a method-basis strip stating the reported quantity's 219 + relation. 220 + 221 + **Parameters** 222 + 223 + | Name | Description | 224 + | :--- | :--- | 225 + | `path` | Destination path of the PDF file. | 226 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the duct element, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared maximum A-weighted sound power level for a regenerated-noise spectrum (lower is better) or a declared minimum mean attenuation for an attenuation spectrum (more is better). | 227 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 228 + | `verbose` | When `True` a regenerated-noise table adds the A-weighting correction and the A-weighted band level columns. | 229 + | `language` | Fiche language: `"en"` (default) or `"es"`. | 230 + 231 + **Returns:** The written `path` as a `str`. 232 + 233 + **Raises** 234 + 235 + | Exception | When | 236 + | :--- | :--- | 237 + | ValueError | If `engine` is not `"reportlab"` or `language` is unknown. | 238 + | ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). | 239 + 194 240 ## plenum_attenuation 195 241 196 242 ```python
+47
site/src/content/docs/reference/api/noise_control/silencers.md
··· 256 256 257 257 Requires matplotlib (`pip install phonometry[plot]`); returns the 258 258 `Axes`. 259 + 260 + ### ReactiveSilencerResult.report() 261 + 262 + ```python 263 + ReactiveSilencerResult.report( 264 + path: str, 265 + *, 266 + metadata: ReportMetadata | None = None, 267 + engine: str = 'reportlab', 268 + verbose: bool = False, 269 + language: str = 'en', 270 + ) -> str 271 + ``` 272 + 273 + Render a reactive-silencer transmission-loss fiche to `path`. 274 + 275 + Writes a one-page silencer-performance sheet: the method-basis line 276 + naming the plane-wave four-pole (transfer-matrix) method (Munjal, 277 + Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen & 278 + Howard, Engineering Noise Control 5th ed., sections 8.8-8.9), an 279 + optional metadata header (client, device, test environment, 280 + instrumentation, climate, date), a per-band table (nominal frequency, 281 + the transmission loss `TL` and, when computed, the insertion loss 282 + `IL`) beside the `TL` (and `IL`) curves, the boxed mean 283 + transmission loss over the analysis bands with the peak transmission 284 + loss and the device kind, an optional verdict row against a declared 285 + minimum, and a method-basis strip stating the four-pole 286 + transmission-loss relation. 287 + 288 + **Parameters** 289 + 290 + | Name | Description | 291 + | :--- | :--- | 292 + | `path` | Destination path of the PDF file. | 293 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the device, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared minimum mean transmission loss (more transmission loss is better). | 294 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 295 + | `verbose` | Accepted for signature symmetry with the other fiches; the silencer table already shows the insertion loss when it was computed. | 296 + | `language` | Fiche language: `"en"` (default) or `"es"`. | 297 + 298 + **Returns:** The written `path` as a `str`. 299 + 300 + **Raises** 301 + 302 + | Exception | When | 303 + | :--- | :--- | 304 + | ValueError | If `engine` is not `"reportlab"` or `language` is unknown. | 305 + | ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). |
+41
src/phonometry/_report/_i18n.py
··· 697 697 "No single-number K<sub>ij</sub> (no bands in the Annex A range)": "Sin número único K<sub>ij</sub> (no hay bandas en el intervalo del anexo A)", 698 698 "Single-number K<sub>ij</sub> = <b>{value} dB</b>": "Número único K<sub>ij</sub> = <b>{value} dB</b>", 699 699 "Values in brackets are bracketed bands, excluded from the single number.": "Los valores entre corchetes son bandas excluidas del número único.", 700 + # --- noise-control performance fiches (enclosure / silencer / HVAC) --- 701 + "Machine enclosure insertion loss": "Pérdida por inserción de encapsulado de máquina", 702 + "Reactive silencer transmission loss": "Pérdida por transmisión de silenciador reactivo", 703 + "HVAC duct noise spectrum": "Espectro de ruido de conducto de climatización", 704 + "Determination of the insertion loss of a machine enclosure from the panel transmission loss and the interior build-up correction (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., section 7.4.2, Eqs. (7.103) and (7.111)).": "Determinación de la pérdida por inserción de un encapsulado de máquina a partir de la pérdida por transmisión del panel y la corrección por reverberación interior (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., apartado 7.4.2, Ecs. (7.103) y (7.111)).", 705 + "Determination of the transmission loss of a reactive silencer by the plane-wave four-pole (transfer-matrix) method (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., sections 8.8 to 8.9).": "Determinación de la pérdida por transmisión de un silenciador reactivo por el método de cuatro polos (matriz de transferencia) de onda plana (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Ec. (3.27); Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., apartados 8.8 a 8.9).", 706 + "Determination of the flow-generated (regenerated) octave-band sound power level of a duct element (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., Chapter 8, section 8.15; VDI 2081-1).": "Determinación del nivel de potencia acústica en bandas de octava generado por el flujo (ruido regenerado) de un elemento de conducto (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., Capítulo 8, apartado 8.15; VDI 2081-1).", 707 + "Determination of the octave-band attenuation of a duct element (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., Chapter 8).": "Determinación de la atenuación en bandas de octava de un elemento de conducto (Bies, Hansen &amp; Howard, Engineering Noise Control 5th ed., Capítulo 8).", 708 + "Insertion loss per band": "Pérdida por inserción por banda", 709 + "Octave-band insertion loss": "Pérdida por inserción en bandas de octava", 710 + "One-third-octave-band insertion loss": "Pérdida por inserción en bandas de tercio de octava", 711 + "Transmission loss per band": "Pérdida por transmisión por banda", 712 + "Octave-band transmission loss": "Pérdida por transmisión en bandas de octava", 713 + "One-third-octave-band transmission loss": "Pérdida por transmisión en bandas de tercio de octava", 714 + "Octave-band regenerated sound power levels": "Niveles de potencia acústica regenerada en bandas de octava", 715 + "One-third-octave-band regenerated sound power levels": "Niveles de potencia acústica regenerada en bandas de tercio de octava", 716 + "Regenerated sound power levels per band": "Niveles de potencia acústica regenerada por banda", 717 + "Octave-band attenuation": "Atenuación en bandas de octava", 718 + "One-third-octave-band attenuation": "Atenuación en bandas de tercio de octava", 719 + "Attenuation per band": "Atenuación por banda", 720 + "Mean insertion loss IL = <b>{value} dB</b>": "Pérdida por inserción media IL = <b>{value} dB</b>", 721 + "Mean panel transmission loss R = {value} dB": "Pérdida por transmisión media del panel R = {value} dB", 722 + "External surface area S<sub>E</sub> = {value} m<super>2</super>": "Superficie externa S<sub>E</sub> = {value} m<super>2</super>", 723 + "Internal surface area S<sub>i</sub> = {value} m<super>2</super>": "Superficie interna S<sub>i</sub> = {value} m<super>2</super>", 724 + "Mean transmission loss TL = <b>{value} dB</b>": "Pérdida por transmisión media TL = <b>{value} dB</b>", 725 + "Peak transmission loss = {value} dB": "Pérdida por transmisión máxima = {value} dB", 726 + "Device: {kind}": "Dispositivo: {kind}", 727 + "Mean insertion loss IL = {value} dB": "Pérdida por inserción media IL = {value} dB", 728 + "Resonance frequencies: {values} Hz": "Frecuencias de resonancia: {values} Hz", 729 + "A-weighted sound power level L<sub>WA</sub> = <b>{value} dB(A)</b> re {ref}": "Nivel de potencia acústica ponderado A L<sub>WA</sub> = <b>{value} dB(A)</b> re {ref}", 730 + "Overall sound power level L<sub>W</sub> = {value} dB re {ref}": "Nivel de potencia acústica total L<sub>W</sub> = {value} dB re {ref}", 731 + "Overall sound power level L<sub>W</sub> = <b>{value} dB</b> re {ref}": "Nivel de potencia acústica total L<sub>W</sub> = <b>{value} dB</b> re {ref}", 732 + "Mean attenuation D = <b>{value} dB</b>": "Atenuación media D = <b>{value} dB</b>", 733 + "Band attenuation range {lo} to {hi} dB": "Intervalo de atenuación por banda de {lo} a {hi} dB", 734 + "{sym} = {value} {unit}, required &#8805; {req} {unit}": "{sym} = {value} {unit}, requerido &#8805; {req} {unit}", 735 + "{sym} = {value} {unit}, required &#8804; {req} {unit}": "{sym} = {value} {unit}, requerido &#8804; {req} {unit}", 736 + "IL = R - C with the interior build-up correction C = 10 lg(0.3 + S<sub>E</sub> / R<sub>i</sub>) and the interior room constant R<sub>i</sub> = S<sub>i</sub> &#945;<sub>i</sub> / (1 - &#945;<sub>i</sub>) (Eqs. (7.103) and (7.111)). The panel transmission loss R is supplied by the caller (measured or predicted); this evaluation combines it with the interior build-up correction only.": "IL = R - C con la corrección por reverberación interior C = 10 lg(0,3 + S<sub>E</sub> / R<sub>i</sub>) y la constante de sala interior R<sub>i</sub> = S<sub>i</sub> &#945;<sub>i</sub> / (1 - &#945;<sub>i</sub>) (Ecs. (7.103) y (7.111)). La pérdida por transmisión del panel R la aporta el usuario (medida o predicha); esta evaluación solo la combina con la corrección por reverberación interior.", 737 + "TL = 10 lg[(Z<sub>n</sub>/Z<sub>1</sub>) (1/4) |T<sub>11</sub> + T<sub>12</sub>/Z<sub>n</sub> + Z<sub>1</sub> T<sub>21</sub> + (Z<sub>1</sub>/Z<sub>n</sub>) T<sub>22</sub>|<super>2</super>] from the compound four-pole matrix T, with the characteristic impedances Z<sub>1</sub> = &#961;c/S<sub>in</sub> and Z<sub>n</sub> = &#961;c/S<sub>out</sub> (Munjal Eq. (3.27), no flow). TL is the intrinsic attenuation for an anechoic termination; the insertion loss adds the source and radiation impedance mismatch.": "TL = 10 lg[(Z<sub>n</sub>/Z<sub>1</sub>) (1/4) |T<sub>11</sub> + T<sub>12</sub>/Z<sub>n</sub> + Z<sub>1</sub> T<sub>21</sub> + (Z<sub>1</sub>/Z<sub>n</sub>) T<sub>22</sub>|<super>2</super>] a partir de la matriz de cuatro polos compuesta T, con las impedancias características Z<sub>1</sub> = &#961;c/S<sub>in</sub> y Z<sub>n</sub> = &#961;c/S<sub>out</sub> (Munjal Ec. (3.27), sin flujo). TL es la atenuación intrínseca para una terminación anecoica; la pérdida por inserción añade el desajuste de impedancia de fuente y radiación.", 738 + "L<sub>W</sub> is the flow-generated octave-band sound power level radiated by the element (Chapter 8, section 8.15; VDI 2081-1), referenced to the reference sound power 1 pW.": "L<sub>W</sub> es el nivel de potencia acústica en bandas de octava generado por el flujo que radia el elemento (Capítulo 8, apartado 8.15; VDI 2081-1), referido a la potencia acústica de referencia 1 pW.", 739 + "The A-weighted level L<sub>WA</sub> combines the band levels with the ISO 3744:2010 Annex E A-weighting corrections C<sub>A</sub> (Tables E.1/E.2).": "El nivel ponderado A L<sub>WA</sub> combina los niveles de banda con las correcciones de ponderación A C<sub>A</sub> de la Norma ISO 3744:2010 anexo E (Tablas E.1/E.2).", 740 + "The octave-band attenuation is the passive insertion loss the element adds along the duct path (Chapter 8). Path attenuations add band by band; regenerated noise is added separately.": "La atenuación en bandas de octava es la pérdida por inserción pasiva que el elemento añade a lo largo del recorrido del conducto (Capítulo 8). Las atenuaciones del recorrido se suman banda a banda; el ruido regenerado se añade por separado.", 700 741 } 701 742 702 743
+197
src/phonometry/_report/_noise_control_fiche.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Shared renderer for the noise-control performance fiches. 3 + 4 + The machine-enclosure insertion loss (:mod:`.enclosure`), the reactive-silencer 5 + transmission loss (:mod:`.silencer`) and the HVAC duct-noise spectrum 6 + (:mod:`.hvac`) print the same one-page performance sheet: a title and 7 + method-basis line, an optional metadata header, a two-panel body with the 8 + per-band table on the left and the result's own ``plot`` on the right, a boxed 9 + single-number performance figure, an optional requirement verdict and a 10 + method-basis strip. Only the fixed text and the per-band table content differ 11 + between them, so this module holds the common skeleton and each renderer 12 + supplies only its specifics (the basis line, the table columns, the boxed 13 + figure and the verdict direction). 14 + 15 + The quantity-independent flowable helpers live in :mod:`._layout`; the per-band 16 + table builder, the nominal band labels and the one-decimal cell formatter are 17 + shared with the sound-power fiches (:mod:`._sound_power_fiche`), and the header 18 + grid reuses the sound-power source/environment layout. 19 + 20 + reportlab, matplotlib and svglib are soft dependencies imported lazily 21 + (reportlab and svglib ship in the ``phonometry[report]`` extra, matplotlib in 22 + ``phonometry[plot]``); each is guarded with an actionable :class:`ImportError`. 23 + """ 24 + 25 + from __future__ import annotations 26 + 27 + import math 28 + from typing import Any, List, Tuple 29 + 30 + import numpy as np 31 + 32 + from ._i18n import format_number, t 33 + from ._layout import ( 34 + _ACCENT_HEX, 35 + _REPORTLAB_HINT, 36 + build_document, 37 + display_round, 38 + document_styles, 39 + footer_flow, 40 + grid_table, 41 + measurement_basis_style, 42 + render_figure_drawing, 43 + result_box, 44 + two_panel_body, 45 + verdict_flow, 46 + ) 47 + from ._sound_power_fiche import band_labels, d1, metadata_pairs, power_value_table 48 + from .metadata import ReportMetadata 49 + 50 + __all__ = [ 51 + "band_labels", 52 + "d1", 53 + "mean_finite", 54 + "performance_verdict", 55 + "power_value_table", 56 + "render_noise_control_fiche", 57 + ] 58 + 59 + 60 + def mean_finite(values: np.ndarray) -> float: 61 + """Arithmetic mean of the finite entries of a per-band array (``nan`` if none).""" 62 + arr = np.asarray(values, dtype=np.float64) 63 + arr = arr[np.isfinite(arr)] 64 + if arr.size == 0: 65 + return float("nan") 66 + return float(np.mean(arr)) 67 + 68 + 69 + def performance_verdict( 70 + value: float, 71 + requirement: float, 72 + symbol: str, 73 + *, 74 + higher_is_better: bool, 75 + unit: str = "dB", 76 + language: str = "en", 77 + ) -> Tuple[str, bool]: 78 + """Verdict text and PASS flag for a performance figure against a requirement. 79 + 80 + ``higher_is_better`` selects the direction: an insertion or transmission 81 + loss passes at or above the declared minimum, while a radiated noise level 82 + passes at or below the declared maximum. Both the measured value and the 83 + requirement are compared at the displayed (one-decimal) precision so the 84 + printed numbers cannot contradict the verdict at the boundary. ``symbol`` is 85 + markup (e.g. ``IL``, ``L<sub>WA</sub>``) and ``unit`` its unit symbol 86 + (``dB`` or ``dB(A)``), neither of which is translated. 87 + """ 88 + rounded = display_round(value) 89 + rounded_requirement = display_round(requirement) 90 + if higher_is_better: 91 + passed = math.isfinite(value) and rounded >= rounded_requirement 92 + template = t( 93 + "{sym} = {value} {unit}, required &#8805; {req} {unit}", language 94 + ) 95 + else: 96 + passed = math.isfinite(value) and rounded <= rounded_requirement 97 + template = t( 98 + "{sym} = {value} {unit}, required &#8804; {req} {unit}", language 99 + ) 100 + text = template.format( 101 + sym=symbol, 102 + value=d1(value, language), 103 + req=format_number(requirement, language, decimals=1), 104 + unit=unit, 105 + ) 106 + return text, passed 107 + 108 + 109 + def render_noise_control_fiche( 110 + result: Any, 111 + path: str, 112 + *, 113 + title: str, 114 + basis: str, 115 + caption: str, 116 + value_table: Any, 117 + statement: str, 118 + extended: List[str], 119 + basis_strips: List[str], 120 + metadata: ReportMetadata | None, 121 + language: str, 122 + verdict: Tuple[str, bool] | None = None, 123 + ) -> str: 124 + """Assemble the shared noise-control fiche flow and build the PDF at ``path``. 125 + 126 + The metadata header grid reuses the sound-power source/environment layout, 127 + and the requirement verdict is supplied pre-computed by each renderer (its 128 + quantity symbol and pass direction). The two-panel body puts the per-band 129 + ``value_table`` on the left (a compact ~64 mm cell that fits the widest 130 + four-column enclosure/HVAC table) and the result's own ``plot`` on the right. 131 + 132 + :param result: The noise-control result; its ``plot`` draws the right panel. 133 + :param title: The already-translated fiche title. 134 + :param basis: The already-translated method-basis line. 135 + :param caption: The already-translated band-set caption above the table. 136 + :param value_table: The pre-built per-band reportlab table flowable. 137 + :param statement: The boxed single-number statement (markup). 138 + :param extended: The extended terms shown alongside the boxed statement. 139 + :param basis_strips: The method-basis-strip paragraphs (markup). 140 + :param metadata: Optional :class:`ReportMetadata` for the header, footer 141 + identity and (via each renderer) the requirement verdict. 142 + :param language: ``"en"`` (default) or ``"es"``. 143 + :param verdict: Optional pre-computed ``(text, passed)`` verdict row. 144 + :return: The written ``path`` as a :class:`str`. 145 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 146 + installed. 147 + """ 148 + # Fixed two-panel split, summing to the 174 mm content width: a compact 149 + # table cell beside the plot, with the embedded figure just inside its cell. 150 + left_width_mm, plot_width_mm, figure_width_mm = 64.0, 110.0, 108.0 151 + try: 152 + from reportlab.lib import colors 153 + from reportlab.lib.units import mm 154 + from reportlab.platypus import Paragraph, Spacer 155 + except ImportError as exc: 156 + raise ImportError(_REPORTLAB_HINT) from exc 157 + accent = colors.HexColor(_ACCENT_HEX) 158 + 159 + styles, title_style, basis_style, caption_style = document_styles(accent) 160 + 161 + flow: List[Any] = [ 162 + Paragraph(title, title_style), 163 + Paragraph(basis, basis_style), 164 + ] 165 + 166 + if metadata is not None and not metadata.is_empty(): 167 + header_pairs = metadata_pairs(metadata, language) 168 + if header_pairs: 169 + flow.append(Spacer(1, 3)) 170 + flow.append(grid_table(header_pairs)) 171 + flow.append(Spacer(1, 8)) 172 + 173 + left_cell = [Paragraph(caption, caption_style), value_table] 174 + plot_drawing = render_figure_drawing( 175 + result.plot, figure_width_mm * mm, y_top=None, language=language 176 + ) 177 + flow.append( 178 + two_panel_body( 179 + left_cell, 180 + plot_drawing, 181 + left_width_mm=left_width_mm, 182 + plot_width_mm=plot_width_mm, 183 + ) 184 + ) 185 + flow.append(Spacer(1, 8)) 186 + 187 + flow.append(result_box(statement, styles, accent, extended)) 188 + if verdict is not None: 189 + text, passed = verdict 190 + flow.extend(verdict_flow(text, passed, styles, language)) 191 + 192 + basis_style_strip = measurement_basis_style() 193 + for strip in basis_strips: 194 + flow.append(Paragraph(strip, basis_style_strip)) 195 + flow.extend(footer_flow(metadata, language)) 196 + 197 + return build_document(path, flow, title)
+206
src/phonometry/_report/enclosure.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Machine-enclosure insertion-loss performance fiche (reportlab renderer). 3 + 4 + Renders a :class:`~phonometry.noise_control.enclosures.EnclosureResult` (the 5 + net insertion loss of a close or free-standing machine enclosure, Bies, Hansen 6 + & Howard, *Engineering Noise Control* 5th ed., section 7.4.2) to a one-page 7 + enclosure-performance sheet: 8 + 9 + * a title and the method-basis line naming the Bies insertion-loss model; 10 + * an optional metadata header (client, enclosed machine, test environment, 11 + instrumentation, climate, date); 12 + * a two-panel body with the per-band table on the left (nominal frequency, the 13 + supplied panel transmission loss ``R``, the interior build-up correction 14 + ``C`` and the net insertion loss ``IL = R - C``; with ``verbose=True`` the 15 + interior room constant ``R_i`` is added) and the panel ``R``, correction 16 + ``C`` and insertion-loss ``IL`` curves drawn by the result's own 17 + ``plot(ax=...)`` on the right; 18 + * a boxed single-number result, the mean insertion loss over the analysis 19 + bands, with the external and internal surface areas alongside; 20 + * an optional verdict row when a declared minimum is supplied via the metadata 21 + ``requirement`` (more insertion loss is better); 22 + * a method-basis strip stating ``IL = R - C`` with 23 + ``C = 10 lg(0.3 + S_E / R_i)`` and the interior room constant. 24 + 25 + The quantity-independent skeleton and the two-panel assembly live in 26 + :mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the 27 + enclosure specifics. reportlab, matplotlib and svglib are soft dependencies 28 + imported lazily (reportlab and svglib ship in the ``phonometry[report]`` extra, 29 + matplotlib in ``phonometry[plot]``); each is guarded with an actionable 30 + :class:`ImportError`. 31 + """ 32 + 33 + from __future__ import annotations 34 + 35 + from typing import TYPE_CHECKING, Any, List, Tuple 36 + 37 + import numpy as np 38 + 39 + from ._i18n import format_number, t 40 + from ._noise_control_fiche import ( 41 + band_labels, 42 + d1, 43 + mean_finite, 44 + performance_verdict, 45 + power_value_table, 46 + render_noise_control_fiche, 47 + ) 48 + from .metadata import ReportMetadata 49 + 50 + if TYPE_CHECKING: 51 + from ..noise_control.enclosures import EnclosureResult 52 + 53 + 54 + def _basis(language: str = "en") -> str: 55 + """The method-basis line naming the Bies enclosure insertion-loss model.""" 56 + return t( 57 + "Determination of the insertion loss of a machine enclosure from the " 58 + "panel transmission loss and the interior build-up correction (Bies, " 59 + "Hansen &amp; Howard, Engineering Noise Control 5th ed., section 7.4.2, " 60 + "Eqs. (7.103) and (7.111)).", 61 + language, 62 + ) 63 + 64 + 65 + def _caption(result: Any, language: str = "en") -> str: 66 + """The caption declaring the analysis band set above the table.""" 67 + freqs = getattr(result, "frequencies", None) 68 + n = np.asarray(result.insertion_loss, dtype=np.float64).size 69 + if freqs is None: 70 + return t("Insertion loss per band", language) 71 + _, fraction = band_labels(freqs, n) 72 + if fraction == 1: 73 + return t("Octave-band insertion loss", language) 74 + return t("One-third-octave-band insertion loss", language) 75 + 76 + 77 + def _value_table(result: Any, verbose: bool, language: str = "en") -> Any: 78 + """Build the per-band table (nominal frequency, R, C, IL; verbose adds R_i). 79 + 80 + Called only after the renderer has imported reportlab. 81 + """ 82 + r = np.asarray(result.panel_transmission_loss, dtype=np.float64) 83 + c = np.asarray(result.correction, dtype=np.float64) 84 + il = np.asarray(result.insertion_loss, dtype=np.float64) 85 + n = il.size 86 + labels, fraction = band_labels(getattr(result, "frequencies", None), n) 87 + 88 + if not verbose: 89 + header = [ 90 + t("f [Hz]", language), 91 + "R [dB]", 92 + "C [dB]", 93 + "IL [dB]", 94 + ] 95 + widths = [16.0, 16.0, 16.0, 16.0] 96 + rows_data = [ 97 + [labels[i], d1(r[i], language), d1(c[i], language), d1(il[i], language)] 98 + for i in range(n) 99 + ] 100 + return power_value_table(header, rows_data, widths, fraction) 101 + 102 + r_i = np.asarray(result.room_constant, dtype=np.float64) 103 + header = [ 104 + t("f [Hz]", language), 105 + "R [dB]", 106 + "C [dB]", 107 + "R<sub>i</sub> [m<super>2</super>]", 108 + "IL [dB]", 109 + ] 110 + widths = [12.0, 13.0, 13.0, 13.0, 13.0] 111 + rows_data = [ 112 + [ 113 + labels[i], 114 + d1(r[i], language), 115 + d1(c[i], language), 116 + d1(r_i[i], language), 117 + d1(il[i], language), 118 + ] 119 + for i in range(n) 120 + ] 121 + return power_value_table(header, rows_data, widths, fraction) 122 + 123 + 124 + def _statement(result: Any, language: str = "en") -> Tuple[float, str, List[str]]: 125 + """The boxed mean insertion loss and its extended enclosure terms.""" 126 + mean_il = mean_finite(result.insertion_loss) 127 + mean_r = mean_finite(result.panel_transmission_loss) 128 + statement = t( 129 + "Mean insertion loss IL = <b>{value} dB</b>", language 130 + ).format(value=d1(mean_il, language)) 131 + extended = [ 132 + t("Mean panel transmission loss R = {value} dB", language).format( 133 + value=d1(mean_r, language) 134 + ), 135 + t( 136 + "External surface area S<sub>E</sub> = {value} m<super>2</super>", 137 + language, 138 + ).format(value=format_number(float(result.external_area), language, decimals=2)), 139 + t( 140 + "Internal surface area S<sub>i</sub> = {value} m<super>2</super>", 141 + language, 142 + ).format(value=format_number(float(result.internal_area), language, decimals=2)), 143 + ] 144 + return mean_il, statement, extended 145 + 146 + 147 + def _basis_strip(language: str = "en") -> str: 148 + """The IL = R - C relation line for the method-basis strip.""" 149 + return t( 150 + "IL = R - C with the interior build-up correction " 151 + "C = 10 lg(0.3 + S<sub>E</sub> / R<sub>i</sub>) and the interior room " 152 + "constant R<sub>i</sub> = S<sub>i</sub> &#945;<sub>i</sub> / " 153 + "(1 - &#945;<sub>i</sub>) (Eqs. (7.103) and (7.111)). The panel " 154 + "transmission loss R is supplied by the caller (measured or predicted); " 155 + "this evaluation combines it with the interior build-up correction only.", 156 + language, 157 + ) 158 + 159 + 160 + def render_enclosure_report( 161 + result: "EnclosureResult", 162 + path: str, 163 + *, 164 + metadata: ReportMetadata | None = None, 165 + verbose: bool = False, 166 + language: str = "en", 167 + ) -> str: 168 + """Render a machine-enclosure insertion-loss fiche to a PDF at ``path``. 169 + 170 + :param result: An 171 + :class:`~phonometry.noise_control.enclosures.EnclosureResult` carrying 172 + the per-band panel transmission loss ``R``, the interior build-up 173 + correction ``C``, the net insertion loss ``IL = R - C`` and the external 174 + and internal surface areas. 175 + :param path: Destination path of the PDF file. 176 + :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare 177 + fiche (body + result + basis, no header). A supplied ``requirement`` is 178 + read as a declared minimum mean insertion loss (more is better). 179 + :param verbose: When ``True`` the per-band table adds the interior room 180 + constant ``R_i`` column. 181 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 182 + :return: The written ``path`` as a :class:`str`. 183 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 184 + installed. 185 + """ 186 + mean_il, statement, extended = _statement(result, language) 187 + verdict = None 188 + if metadata is not None and metadata.requirement is not None: 189 + verdict = performance_verdict( 190 + mean_il, metadata.requirement, "IL", 191 + higher_is_better=True, language=language, 192 + ) 193 + return render_noise_control_fiche( 194 + result, 195 + path, 196 + title=t("Machine enclosure insertion loss", language), 197 + basis=_basis(language), 198 + caption=_caption(result, language), 199 + value_table=_value_table(result, verbose, language), 200 + statement=statement, 201 + extended=extended, 202 + basis_strips=[_basis_strip(language)], 203 + metadata=metadata, 204 + language=language, 205 + verdict=verdict, 206 + )
+327
src/phonometry/_report/hvac.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """HVAC duct-noise-spectrum performance fiche (reportlab renderer). 3 + 4 + Renders a :class:`~phonometry.noise_control.hvac.HvacSpectrumResult` (a 5 + per-band HVAC duct quantity: the flow-generated (regenerated) sound power level 6 + of a straight duct or bend, or the passive attenuation of an end reflection, 7 + bend or plenum, Bies, Hansen & Howard, *Engineering Noise Control* 5th ed., 8 + Chapter 8) to a one-page HVAC-noise sheet: 9 + 10 + * a title and the method-basis line naming the reported quantity and the Bies 11 + chapter; 12 + * an optional metadata header (client, duct element, test environment, 13 + instrumentation, climate, date); 14 + * a two-panel body with the per-band table on the left (nominal frequency and 15 + the reported quantity; with ``verbose=True`` a regenerated-noise spectrum 16 + adds the A-weighting correction and the A-weighted band level) and the 17 + spectrum drawn by the result's own ``plot(ax=...)`` on the right; 18 + * a boxed single-number result: for a regenerated-noise spectrum the A-weighted 19 + sound power level ``L_WA`` (dB(A) re 1 pW) with the overall unweighted total; 20 + for an attenuation spectrum the mean attenuation with its band range; 21 + * an optional verdict row when a declared limit is supplied via the metadata 22 + ``requirement`` (lower regenerated noise is better; more attenuation is 23 + better); 24 + * a method-basis strip stating the reported quantity's relation. 25 + 26 + The quantity-independent skeleton and the two-panel assembly live in 27 + :mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the 28 + HVAC specifics. reportlab, matplotlib and svglib are soft dependencies imported 29 + lazily (reportlab and svglib ship in the ``phonometry[report]`` extra, 30 + matplotlib in ``phonometry[plot]``); each is guarded with an actionable 31 + :class:`ImportError`. 32 + """ 33 + 34 + from __future__ import annotations 35 + 36 + from typing import TYPE_CHECKING, Any, List, Tuple 37 + 38 + import numpy as np 39 + 40 + from ._i18n import t 41 + from ._noise_control_fiche import ( 42 + band_labels, 43 + d1, 44 + mean_finite, 45 + performance_verdict, 46 + power_value_table, 47 + render_noise_control_fiche, 48 + ) 49 + from .metadata import ReportMetadata 50 + 51 + if TYPE_CHECKING: 52 + from ..noise_control.hvac import HvacSpectrumResult 53 + 54 + #: Reference sound power for a regenerated-noise sound power level, 1 pW. 55 + _POWER_REFERENCE = "1 pW" 56 + 57 + 58 + def _is_power(result: Any) -> bool: 59 + """Return ``True`` for a regenerated sound power spectrum, else attenuation.""" 60 + return getattr(result, "quantity", "") == "sound_power_level" 61 + 62 + 63 + def _a_weighting(frequencies: np.ndarray) -> np.ndarray | None: 64 + """A-weighting band corrections at nominal band centres, or ``None``. 65 + 66 + Reuses the library's ISO 3744:2010 Annex E A-weighting table (the same 67 + corrections the sound-power determination applies), so the fiche's 68 + A-weighted level is consistent with the rest of the library. Returns 69 + ``None`` when the spectrum is not on nominal octave/one-third-octave centres 70 + (the table is undefined there), and the fiche then falls back to the 71 + unweighted total. Imported lazily to keep this render leaf free of a 72 + module-level domain import. 73 + """ 74 + try: 75 + from ..emission.sound_power import _a_weighting_corrections 76 + 77 + return np.asarray( 78 + _a_weighting_corrections(np.asarray(frequencies, dtype=np.float64)), 79 + dtype=np.float64, 80 + ) 81 + except (ValueError, ImportError): 82 + return None 83 + 84 + 85 + def _overall_level(levels: np.ndarray) -> float: 86 + """Energy-summed overall level, ``10 lg(sum 10^(L/10))``.""" 87 + finite = levels[np.isfinite(levels)] 88 + if finite.size == 0: 89 + return float("nan") 90 + return float(10.0 * np.log10(np.sum(10.0 ** (finite / 10.0)))) 91 + 92 + 93 + def _a_weighted_total( 94 + levels: np.ndarray, corrections: np.ndarray | None 95 + ) -> float | None: 96 + """A-weighted energy-summed total, or ``None`` when no corrections apply.""" 97 + if corrections is None: 98 + return None 99 + weighted = levels + corrections 100 + return _overall_level(weighted) 101 + 102 + 103 + def _basis(result: Any, language: str = "en") -> str: 104 + """The method-basis line naming the reported quantity and the Bies chapter.""" 105 + if _is_power(result): 106 + return t( 107 + "Determination of the flow-generated (regenerated) octave-band sound " 108 + "power level of a duct element (Bies, Hansen &amp; Howard, " 109 + "Engineering Noise Control 5th ed., Chapter 8, section 8.15; " 110 + "VDI 2081-1).", 111 + language, 112 + ) 113 + return t( 114 + "Determination of the octave-band attenuation of a duct element (Bies, " 115 + "Hansen &amp; Howard, Engineering Noise Control 5th ed., Chapter 8).", 116 + language, 117 + ) 118 + 119 + 120 + def _caption(result: Any, language: str = "en") -> str: 121 + """The caption declaring the analysis band set above the table.""" 122 + n = np.asarray(result.values, dtype=np.float64).size 123 + _, fraction = band_labels(getattr(result, "frequencies", None), n) 124 + power = _is_power(result) 125 + if fraction == 1: 126 + return ( 127 + t("Octave-band regenerated sound power levels", language) 128 + if power 129 + else t("Octave-band attenuation", language) 130 + ) 131 + if fraction == 3: 132 + return ( 133 + t("One-third-octave-band regenerated sound power levels", language) 134 + if power 135 + else t("One-third-octave-band attenuation", language) 136 + ) 137 + return ( 138 + t("Regenerated sound power levels per band", language) 139 + if power 140 + else t("Attenuation per band", language) 141 + ) 142 + 143 + 144 + def _value_table( 145 + result: Any, verbose: bool, corrections: np.ndarray | None, language: str = "en" 146 + ) -> Any: 147 + """Build the per-band table (nominal frequency and the reported quantity). 148 + 149 + A verbose regenerated-noise table adds the A-weighting correction and the 150 + A-weighted band level. Called only after the renderer has imported reportlab. 151 + """ 152 + values = np.asarray(result.values, dtype=np.float64) 153 + n = values.size 154 + labels, fraction = band_labels(getattr(result, "frequencies", None), n) 155 + power = _is_power(result) 156 + quantity_header = "L<sub>W</sub> [dB]" if power else "D [dB]" 157 + 158 + if not (verbose and power and corrections is not None): 159 + header = [t("f [Hz]", language), quantity_header] 160 + widths = [32.0, 32.0] 161 + rows_data = [[labels[i], d1(values[i], language)] for i in range(n)] 162 + return power_value_table(header, rows_data, widths, fraction) 163 + 164 + weighted = values + corrections 165 + header = [ 166 + t("f [Hz]", language), 167 + "L<sub>W</sub> [dB]", 168 + "C<sub>A</sub> [dB]", 169 + "L<sub>WA</sub> [dB]", 170 + ] 171 + widths = [16.0, 16.0, 16.0, 16.0] 172 + rows_data = [ 173 + [ 174 + labels[i], 175 + d1(values[i], language), 176 + d1(corrections[i], language), 177 + d1(weighted[i], language), 178 + ] 179 + for i in range(n) 180 + ] 181 + return power_value_table(header, rows_data, widths, fraction) 182 + 183 + 184 + def _statement( 185 + result: Any, corrections: np.ndarray | None, language: str = "en" 186 + ) -> Tuple[float, bool, str, List[str]]: 187 + """The boxed headline figure, its pass direction and the extended terms. 188 + 189 + Returns ``(value, higher_is_better, statement, extended)``. A 190 + regenerated-noise spectrum boxes the A-weighted sound power level (lower is 191 + better) with the overall unweighted total alongside; an attenuation spectrum 192 + boxes the mean attenuation (more is better) with its band range. 193 + """ 194 + values = np.asarray(result.values, dtype=np.float64) 195 + label = str(getattr(result, "label", "")) 196 + if _is_power(result): 197 + overall = _overall_level(values) 198 + lwa = _a_weighted_total(values, corrections) 199 + if lwa is not None: 200 + statement = t( 201 + "A-weighted sound power level L<sub>WA</sub> = " 202 + "<b>{value} dB(A)</b> re {ref}", 203 + language, 204 + ).format(value=d1(lwa, language), ref=_POWER_REFERENCE) 205 + extended = [ 206 + t( 207 + "Overall sound power level L<sub>W</sub> = {value} dB re {ref}", 208 + language, 209 + ).format(value=d1(overall, language), ref=_POWER_REFERENCE), 210 + label, 211 + ] 212 + return lwa, False, statement, extended 213 + statement = t( 214 + "Overall sound power level L<sub>W</sub> = <b>{value} dB</b> re {ref}", 215 + language, 216 + ).format(value=d1(overall, language), ref=_POWER_REFERENCE) 217 + return overall, False, statement, [label] 218 + 219 + mean_att = mean_finite(values) 220 + finite = values[np.isfinite(values)] 221 + lo = float(np.min(finite)) if finite.size else float("nan") 222 + hi = float(np.max(finite)) if finite.size else float("nan") 223 + statement = t( 224 + "Mean attenuation D = <b>{value} dB</b>", language 225 + ).format(value=d1(mean_att, language)) 226 + extended = [ 227 + t("Band attenuation range {lo} to {hi} dB", language).format( 228 + lo=d1(lo, language), hi=d1(hi, language) 229 + ), 230 + label, 231 + ] 232 + return mean_att, True, statement, extended 233 + 234 + 235 + def _basis_strip( 236 + result: Any, corrections: np.ndarray | None, language: str = "en" 237 + ) -> List[str]: 238 + """The method-basis strip(s) for the reported quantity.""" 239 + if _is_power(result): 240 + strips = [ 241 + t( 242 + "L<sub>W</sub> is the flow-generated octave-band sound power " 243 + "level radiated by the element (Chapter 8, section 8.15; " 244 + "VDI 2081-1), referenced to the reference sound power 1 pW.", 245 + language, 246 + ) 247 + ] 248 + if corrections is not None: 249 + strips.append( 250 + t( 251 + "The A-weighted level L<sub>WA</sub> combines the band levels " 252 + "with the ISO 3744:2010 Annex E A-weighting corrections " 253 + "C<sub>A</sub> (Tables E.1/E.2).", 254 + language, 255 + ) 256 + ) 257 + return strips 258 + return [ 259 + t( 260 + "The octave-band attenuation is the passive insertion loss the " 261 + "element adds along the duct path (Chapter 8). Path attenuations " 262 + "add band by band; regenerated noise is added separately.", 263 + language, 264 + ) 265 + ] 266 + 267 + 268 + def render_hvac_report( 269 + result: "HvacSpectrumResult", 270 + path: str, 271 + *, 272 + metadata: ReportMetadata | None = None, 273 + verbose: bool = False, 274 + language: str = "en", 275 + ) -> str: 276 + """Render an HVAC duct-noise-spectrum fiche to a PDF at ``path``. 277 + 278 + :param result: An 279 + :class:`~phonometry.noise_control.hvac.HvacSpectrumResult` carrying the 280 + per-band quantity (a regenerated sound power level or a passive 281 + attenuation), its band frequencies and a short element label. 282 + :param path: Destination path of the PDF file. 283 + :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare 284 + fiche (body + result + basis, no header). A supplied ``requirement`` is 285 + read as a declared maximum A-weighted level for a regenerated-noise 286 + spectrum (lower is better) or a declared minimum mean attenuation for an 287 + attenuation spectrum (more is better). 288 + :param verbose: When ``True`` a regenerated-noise table adds the A-weighting 289 + correction and the A-weighted band level columns. 290 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 291 + :return: The written ``path`` as a :class:`str`. 292 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 293 + installed. 294 + """ 295 + freqs = np.asarray(result.frequencies, dtype=np.float64) 296 + corrections = _a_weighting(freqs) if _is_power(result) else None 297 + value, higher_is_better, statement, extended = _statement( 298 + result, corrections, language 299 + ) 300 + verdict = None 301 + if metadata is not None and metadata.requirement is not None: 302 + a_weighted = _is_power(result) and corrections is not None 303 + if a_weighted: 304 + symbol = "L<sub>WA</sub>" 305 + elif _is_power(result): 306 + symbol = "L<sub>W</sub>" 307 + else: 308 + symbol = "D" 309 + unit = "dB(A)" if a_weighted else "dB" 310 + verdict = performance_verdict( 311 + value, metadata.requirement, symbol, 312 + higher_is_better=higher_is_better, unit=unit, language=language, 313 + ) 314 + return render_noise_control_fiche( 315 + result, 316 + path, 317 + title=t("HVAC duct noise spectrum", language), 318 + basis=_basis(result, language), 319 + caption=_caption(result, language), 320 + value_table=_value_table(result, verbose, corrections, language), 321 + statement=statement, 322 + extended=extended, 323 + basis_strips=_basis_strip(result, corrections, language), 324 + metadata=metadata, 325 + language=language, 326 + verdict=verdict, 327 + )
+204
src/phonometry/_report/silencer.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Reactive-silencer transmission-loss performance fiche (reportlab renderer). 3 + 4 + Renders a 5 + :class:`~phonometry.noise_control.silencers.ReactiveSilencerResult` (the 6 + transmission and, when end impedances are given, insertion loss of a reactive 7 + silencer computed by the plane-wave four-pole method, Munjal, *Acoustics of 8 + Ducts and Mufflers* 2nd ed.; Bies, Hansen & Howard, *Engineering Noise Control* 9 + 5th ed., sections 8.8-8.9) to a one-page silencer-performance sheet: 10 + 11 + * a title and the method-basis line naming the four-pole (transfer-matrix) 12 + method and the device kind; 13 + * an optional metadata header (client, device, test environment, 14 + instrumentation, climate, date); 15 + * a two-panel body with the per-band table on the left (nominal frequency, the 16 + transmission loss ``TL`` and, when computed, the insertion loss ``IL``) and 17 + the ``TL`` (and ``IL``) curves drawn by the result's own ``plot(ax=...)`` on 18 + the right; 19 + * a boxed single-number result, the mean transmission loss over the analysis 20 + bands, with the peak transmission loss and the device kind alongside; 21 + * an optional verdict row when a declared minimum is supplied via the metadata 22 + ``requirement`` (more transmission loss is better); 23 + * a method-basis strip stating the four-pole transmission-loss relation. 24 + 25 + The quantity-independent skeleton and the two-panel assembly live in 26 + :mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the 27 + silencer specifics. reportlab, matplotlib and svglib are soft dependencies 28 + imported lazily (reportlab and svglib ship in the ``phonometry[report]`` extra, 29 + matplotlib in ``phonometry[plot]``); each is guarded with an actionable 30 + :class:`ImportError`. 31 + """ 32 + 33 + from __future__ import annotations 34 + 35 + from typing import TYPE_CHECKING, Any, List, Tuple 36 + 37 + import numpy as np 38 + 39 + from ._i18n import format_number, t 40 + from ._noise_control_fiche import ( 41 + band_labels, 42 + d1, 43 + mean_finite, 44 + performance_verdict, 45 + power_value_table, 46 + render_noise_control_fiche, 47 + ) 48 + from .metadata import ReportMetadata 49 + 50 + if TYPE_CHECKING: 51 + from ..noise_control.silencers import ReactiveSilencerResult 52 + 53 + 54 + def _basis(language: str = "en") -> str: 55 + """The method-basis line naming the four-pole transmission-loss method.""" 56 + return t( 57 + "Determination of the transmission loss of a reactive silencer by the " 58 + "plane-wave four-pole (transfer-matrix) method (Munjal, Acoustics of " 59 + "Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen &amp; Howard, " 60 + "Engineering Noise Control 5th ed., sections 8.8 to 8.9).", 61 + language, 62 + ) 63 + 64 + 65 + def _caption(result: Any, language: str = "en") -> str: 66 + """The caption declaring the analysis band set above the table.""" 67 + freqs = getattr(result, "frequencies", None) 68 + n = np.asarray(result.transmission_loss, dtype=np.float64).size 69 + if freqs is None: 70 + return t("Transmission loss per band", language) 71 + _, fraction = band_labels(freqs, n) 72 + if fraction == 1: 73 + return t("Octave-band transmission loss", language) 74 + return t("One-third-octave-band transmission loss", language) 75 + 76 + 77 + def _value_table(result: Any, language: str = "en") -> Any: 78 + """Build the per-band table (nominal frequency, TL, and IL when computed). 79 + 80 + Called only after the renderer has imported reportlab. 81 + """ 82 + tl = np.asarray(result.transmission_loss, dtype=np.float64) 83 + n = tl.size 84 + labels, fraction = band_labels(getattr(result, "frequencies", None), n) 85 + 86 + if result.insertion_loss is None: 87 + header = [t("f [Hz]", language), "TL [dB]"] 88 + widths = [32.0, 32.0] 89 + rows_data = [[labels[i], d1(tl[i], language)] for i in range(n)] 90 + return power_value_table(header, rows_data, widths, fraction) 91 + 92 + il = np.asarray(result.insertion_loss, dtype=np.float64) 93 + header = [t("f [Hz]", language), "TL [dB]", "IL [dB]"] 94 + widths = [22.0, 21.0, 21.0] 95 + rows_data = [ 96 + [labels[i], d1(tl[i], language), d1(il[i], language)] for i in range(n) 97 + ] 98 + return power_value_table(header, rows_data, widths, fraction) 99 + 100 + 101 + def _resonance_term(result: Any, language: str = "en") -> str | None: 102 + """The extended term naming the resonance frequencies, or ``None``.""" 103 + resonances = getattr(result, "resonances", None) 104 + if resonances is None: 105 + return None 106 + res = np.atleast_1d(np.asarray(resonances, dtype=np.float64)) 107 + res = res[np.isfinite(res)] 108 + if res.size == 0: 109 + return None 110 + listed = ", ".join(format_number(float(fr), language, decimals=0) for fr in res) 111 + return t("Resonance frequencies: {values} Hz", language).format(values=listed) 112 + 113 + 114 + def _statement(result: Any, language: str = "en") -> Tuple[float, str, List[str]]: 115 + """The boxed mean transmission loss and its extended silencer terms.""" 116 + tl = np.asarray(result.transmission_loss, dtype=np.float64) 117 + mean_tl = mean_finite(tl) 118 + finite = tl[np.isfinite(tl)] 119 + peak_tl = float(np.max(finite)) if finite.size else float("nan") 120 + statement = t( 121 + "Mean transmission loss TL = <b>{value} dB</b>", language 122 + ).format(value=d1(mean_tl, language)) 123 + extended = [ 124 + t("Peak transmission loss = {value} dB", language).format( 125 + value=d1(peak_tl, language) 126 + ), 127 + t("Device: {kind}", language).format(kind=str(result.kind)), 128 + ] 129 + if result.insertion_loss is not None: 130 + mean_il = mean_finite(result.insertion_loss) 131 + extended.append( 132 + t("Mean insertion loss IL = {value} dB", language).format( 133 + value=d1(mean_il, language) 134 + ) 135 + ) 136 + resonance = _resonance_term(result, language) 137 + if resonance is not None: 138 + extended.append(resonance) 139 + return mean_tl, statement, extended 140 + 141 + 142 + def _basis_strip(language: str = "en") -> str: 143 + """The four-pole transmission-loss relation line for the method-basis strip.""" 144 + return t( 145 + "TL = 10 lg[(Z<sub>n</sub>/Z<sub>1</sub>) (1/4) |T<sub>11</sub> + " 146 + "T<sub>12</sub>/Z<sub>n</sub> + Z<sub>1</sub> T<sub>21</sub> + " 147 + "(Z<sub>1</sub>/Z<sub>n</sub>) T<sub>22</sub>|<super>2</super>] from the " 148 + "compound four-pole matrix T, with the characteristic impedances " 149 + "Z<sub>1</sub> = &#961;c/S<sub>in</sub> and Z<sub>n</sub> = " 150 + "&#961;c/S<sub>out</sub> (Munjal Eq. (3.27), no flow). TL is the " 151 + "intrinsic attenuation for an anechoic termination; the insertion loss " 152 + "adds the source and radiation impedance mismatch.", 153 + language, 154 + ) 155 + 156 + 157 + def render_reactive_silencer_report( 158 + result: "ReactiveSilencerResult", 159 + path: str, 160 + *, 161 + metadata: ReportMetadata | None = None, 162 + verbose: bool = False, 163 + language: str = "en", 164 + ) -> str: 165 + """Render a reactive-silencer transmission-loss fiche to a PDF at ``path``. 166 + 167 + :param result: A 168 + :class:`~phonometry.noise_control.silencers.ReactiveSilencerResult` 169 + carrying the per-band transmission loss ``TL`` and, when end impedances 170 + were supplied, the insertion loss ``IL``, the device ``kind`` and any 171 + resonance frequencies. 172 + :param path: Destination path of the PDF file. 173 + :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare 174 + fiche (body + result + basis, no header). A supplied ``requirement`` is 175 + read as a declared minimum mean transmission loss (more is better). 176 + :param verbose: Accepted for signature symmetry with the other fiches; the 177 + silencer table already shows the insertion loss when it was computed. 178 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 179 + :return: The written ``path`` as a :class:`str`. 180 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 181 + installed. 182 + """ 183 + del verbose # the silencer table has no extra verbose column 184 + mean_tl, statement, extended = _statement(result, language) 185 + verdict = None 186 + if metadata is not None and metadata.requirement is not None: 187 + verdict = performance_verdict( 188 + mean_tl, metadata.requirement, "TL", 189 + higher_is_better=True, language=language, 190 + ) 191 + return render_noise_control_fiche( 192 + result, 193 + path, 194 + title=t("Reactive silencer transmission loss", language), 195 + basis=_basis(language), 196 + caption=_caption(result, language), 197 + value_table=_value_table(result, language), 198 + statement=statement, 199 + extended=extended, 200 + basis_strips=[_basis_strip(language)], 201 + metadata=metadata, 202 + language=language, 203 + verdict=verdict, 204 + )
+57
src/phonometry/noise_control/enclosures.py
··· 47 47 if TYPE_CHECKING: 48 48 from matplotlib.axes import Axes 49 49 50 + from .._report.metadata import ReportMetadata 51 + 50 52 51 53 @dataclass(frozen=True) 52 54 class EnclosureResult: ··· 82 84 83 85 check_language(language) 84 86 return plot_enclosure(self, ax=ax, language=language, **kwargs) 87 + 88 + def report( 89 + self, 90 + path: str, 91 + *, 92 + metadata: "ReportMetadata | None" = None, 93 + engine: str = "reportlab", 94 + verbose: bool = False, 95 + language: str = "en", 96 + ) -> str: 97 + """Render a machine-enclosure insertion-loss fiche to ``path``. 98 + 99 + Writes a one-page enclosure-performance sheet: the method-basis line 100 + naming the Bies, Hansen & Howard insertion-loss model 101 + (Engineering Noise Control 5th ed., section 7.4.2), an optional metadata 102 + header (client, enclosed machine, test environment, instrumentation, 103 + climate, date), a per-band table (nominal frequency, the supplied panel 104 + transmission loss ``R``, the interior build-up correction ``C`` and the 105 + net insertion loss ``IL = R - C``) beside the ``R``, ``C`` and ``IL`` 106 + curves, the boxed mean insertion loss over the analysis bands with the 107 + external and internal surface areas, an optional verdict row against a 108 + declared minimum, and a method-basis strip stating 109 + ``IL = R - C`` with ``C = 10 lg(0.3 + S_E / R_i)``. 110 + 111 + :param path: Destination path of the PDF file. 112 + :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying 113 + the header (``client``, ``specimen`` the enclosed machine, 114 + ``test_room`` the test environment, ``instrumentation``, 115 + ``temperature``, ``relative_humidity``, ``pressure``, ``test_date``), 116 + the footer identity (``laboratory``, ``operator``, ``report_id``, 117 + ``notes``) and, via ``requirement``, a declared minimum mean 118 + insertion loss (more insertion loss is better). The surface areas 119 + come from the result itself. 120 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 121 + :param verbose: When ``True`` the per-band table adds the interior room 122 + constant ``R_i`` column. 123 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 124 + :return: The written ``path`` as a :class:`str`. 125 + :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language`` 126 + is unknown. 127 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is 128 + not installed (``pip install phonometry[report]``). 129 + """ 130 + from .._i18n import check_language 131 + 132 + check_language(language) 133 + if engine != "reportlab": 134 + raise ValueError( 135 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 136 + ) 137 + from .._report.enclosure import render_enclosure_report 138 + 139 + return render_enclosure_report( 140 + self, path, metadata=metadata, verbose=verbose, language=language 141 + ) 85 142 86 143 87 144 class PanelTransmissionResult(Protocol):
+58
src/phonometry/noise_control/hvac.py
··· 36 36 if TYPE_CHECKING: 37 37 from matplotlib.axes import Axes 38 38 39 + from .._report.metadata import ReportMetadata 40 + 39 41 _C_AIR = 343.0 40 42 41 43 # --------------------------------------------------------------------------- ··· 143 145 144 146 check_language(language) 145 147 return plot_hvac_spectrum(self, ax=ax, language=language, **kwargs) 148 + 149 + def report( 150 + self, 151 + path: str, 152 + *, 153 + metadata: "ReportMetadata | None" = None, 154 + engine: str = "reportlab", 155 + verbose: bool = False, 156 + language: str = "en", 157 + ) -> str: 158 + """Render an HVAC duct-noise-spectrum fiche to ``path``. 159 + 160 + Writes a one-page HVAC-noise sheet: the method-basis line naming the 161 + reported quantity and the Bies, Hansen & Howard chapter (Engineering 162 + Noise Control 5th ed., Chapter 8), an optional metadata header (client, 163 + duct element, test environment, instrumentation, climate, date), a 164 + per-band table (nominal frequency and the reported quantity) beside the 165 + spectrum, the boxed single-number result (for a regenerated-noise 166 + spectrum the A-weighted sound power level ``L_WA`` re 1 pW with the 167 + overall unweighted total; for an attenuation spectrum the mean 168 + attenuation with its band range), an optional verdict row against a 169 + declared limit, and a method-basis strip stating the reported quantity's 170 + relation. 171 + 172 + :param path: Destination path of the PDF file. 173 + :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying 174 + the header (``client``, ``specimen`` the duct element, ``test_room`` 175 + the test environment, ``instrumentation``, ``temperature``, 176 + ``relative_humidity``, ``pressure``, ``test_date``), the footer 177 + identity (``laboratory``, ``operator``, ``report_id``, ``notes``) 178 + and, via ``requirement``, a declared maximum A-weighted sound power 179 + level for a regenerated-noise spectrum (lower is better) or a 180 + declared minimum mean attenuation for an attenuation spectrum (more 181 + is better). 182 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 183 + :param verbose: When ``True`` a regenerated-noise table adds the 184 + A-weighting correction and the A-weighted band level columns. 185 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 186 + :return: The written ``path`` as a :class:`str`. 187 + :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language`` 188 + is unknown. 189 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is 190 + not installed (``pip install phonometry[report]``). 191 + """ 192 + from .._i18n import check_language 193 + 194 + check_language(language) 195 + if engine != "reportlab": 196 + raise ValueError( 197 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 198 + ) 199 + from .._report.hvac import render_hvac_report 200 + 201 + return render_hvac_report( 202 + self, path, metadata=metadata, verbose=verbose, language=language 203 + ) 146 204 147 205 148 206 def end_reflection_loss(
+58
src/phonometry/noise_control/silencers.py
··· 75 75 if TYPE_CHECKING: 76 76 from matplotlib.axes import Axes 77 77 78 + from .._report.metadata import ReportMetadata 79 + 78 80 #: Reference air properties at 20 degC, 101.325 kPa. 79 81 _C_AIR = 343.0 80 82 _RHO_AIR = 1.206 ··· 353 355 354 356 check_language(language) 355 357 return plot_reactive_silencer(self, ax=ax, language=language, **kwargs) 358 + 359 + def report( 360 + self, 361 + path: str, 362 + *, 363 + metadata: "ReportMetadata | None" = None, 364 + engine: str = "reportlab", 365 + verbose: bool = False, 366 + language: str = "en", 367 + ) -> str: 368 + """Render a reactive-silencer transmission-loss fiche to ``path``. 369 + 370 + Writes a one-page silencer-performance sheet: the method-basis line 371 + naming the plane-wave four-pole (transfer-matrix) method (Munjal, 372 + Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen & 373 + Howard, Engineering Noise Control 5th ed., sections 8.8-8.9), an 374 + optional metadata header (client, device, test environment, 375 + instrumentation, climate, date), a per-band table (nominal frequency, 376 + the transmission loss ``TL`` and, when computed, the insertion loss 377 + ``IL``) beside the ``TL`` (and ``IL``) curves, the boxed mean 378 + transmission loss over the analysis bands with the peak transmission 379 + loss and the device kind, an optional verdict row against a declared 380 + minimum, and a method-basis strip stating the four-pole 381 + transmission-loss relation. 382 + 383 + :param path: Destination path of the PDF file. 384 + :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying 385 + the header (``client``, ``specimen`` the device, ``test_room`` the 386 + test environment, ``instrumentation``, ``temperature``, 387 + ``relative_humidity``, ``pressure``, ``test_date``), the footer 388 + identity (``laboratory``, ``operator``, ``report_id``, ``notes``) 389 + and, via ``requirement``, a declared minimum mean transmission loss 390 + (more transmission loss is better). 391 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 392 + :param verbose: Accepted for signature symmetry with the other fiches; 393 + the silencer table already shows the insertion loss when it was 394 + computed. 395 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 396 + :return: The written ``path`` as a :class:`str`. 397 + :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language`` 398 + is unknown. 399 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is 400 + not installed (``pip install phonometry[report]``). 401 + """ 402 + from .._i18n import check_language 403 + 404 + check_language(language) 405 + if engine != "reportlab": 406 + raise ValueError( 407 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 408 + ) 409 + from .._report.silencer import render_reactive_silencer_report 410 + 411 + return render_reactive_silencer_report( 412 + self, path, metadata=metadata, verbose=verbose, language=language 413 + ) 356 414 357 415 358 416 def _result(
+215
tests/noise_control/test_enclosure_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for the machine-enclosure insertion-loss ``.report()`` fiche. 4 + 5 + The rendered values are checked against a clean-room oracle derived from the 6 + Bies, Hansen & Howard model (Engineering Noise Control 5th ed., section 7.4.2), 7 + independent of the library's own combination path: the interior room constant 8 + ``R_i = S_i alpha_i / (1 - alpha_i)`` (Eq. (7.103)), the build-up correction 9 + ``C = 10 lg(0.3 + S_E / R_i)`` (Eq. (7.111)) and the net insertion loss 10 + ``IL = R - C``. The tests recompute the mean insertion loss and a couple of band 11 + values from these closed forms and assert they appear in the PDF, along with the 12 + nominal band labels, the method-basis prose and the surface areas. Values are 13 + read back via pypdf text extraction; structural facts (one page, rejected 14 + engines/languages) complete the rendering contract. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import math 20 + import os 21 + 22 + import numpy as np 23 + import pytest 24 + 25 + from phonometry import ReportMetadata, enclosure_insertion_loss 26 + 27 + _PDF_MAGIC = b"%PDF" 28 + 29 + _FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 30 + _PANEL_R = np.array([18, 22, 28, 33, 38, 42, 45], dtype=float) 31 + _S_E = 24.0 # external surface area, m^2 32 + _S_I = 30.0 # internal surface area, m^2 33 + _ALPHA = 0.30 # mean interior absorption 34 + 35 + 36 + def _assert_one_page(path: str) -> None: 37 + from pypdf import PdfReader 38 + 39 + with open(path, "rb") as handle: 40 + assert handle.read(4) == _PDF_MAGIC 41 + assert os.path.getsize(path) > 0 42 + assert len(PdfReader(path).pages) == 1 43 + 44 + 45 + def _extract_text(path: str) -> str: 46 + from pypdf import PdfReader 47 + 48 + raw = "\n".join(page.extract_text() for page in PdfReader(path).pages) 49 + return " ".join(raw.split()) 50 + 51 + 52 + def _result(): 53 + return enclosure_insertion_loss(_PANEL_R, _S_E, _S_I, _ALPHA, frequencies=_FREQS) 54 + 55 + 56 + def _oracle_correction() -> np.ndarray: 57 + """Closed-form build-up correction C = 10 lg(0.3 + S_E / R_i) (Eq. (7.111)).""" 58 + r_i = _S_I * _ALPHA / (1.0 - _ALPHA) 59 + return 10.0 * np.log10(0.3 + _S_E / r_i) * np.ones_like(_PANEL_R) 60 + 61 + 62 + def _oracle_il() -> np.ndarray: 63 + """Closed-form net insertion loss IL = R - C.""" 64 + return _PANEL_R - _oracle_correction() 65 + 66 + 67 + def _oracle_mean_il() -> float: 68 + return float(np.mean(_oracle_il())) 69 + 70 + 71 + def test_hand_oracle_matches_library() -> None: 72 + """The library IL and C equal the closed-form Bies values.""" 73 + res = _result() 74 + np.testing.assert_allclose(res.correction, _oracle_correction(), atol=1e-9) 75 + np.testing.assert_allclose(res.insertion_loss, _oracle_il(), atol=1e-9) 76 + 77 + 78 + def test_report_renders_oracle_values(tmp_path) -> None: 79 + """The fiche prints the mean IL and a couple of band R/C/IL values.""" 80 + pytest.importorskip("reportlab") 81 + pytest.importorskip("svglib") 82 + pytest.importorskip("matplotlib") 83 + res = _result() 84 + out = tmp_path / "enclosure.pdf" 85 + returned = res.report(str(out), metadata=ReportMetadata(requirement=20.0)) 86 + assert returned == str(out) 87 + _assert_one_page(str(out)) 88 + text = _extract_text(str(out)) 89 + 90 + # Mean insertion loss (boxed) to one decimal. 91 + assert f"{_oracle_mean_il():.1f}" in text 92 + # Two band IL values (250 Hz and 1 kHz) to one decimal. 93 + il = _oracle_il() 94 + assert f"{il[2]:.1f}" in text 95 + assert f"{il[4]:.1f}" in text 96 + # The supplied panel R heads its column. 97 + assert f"{_PANEL_R[2]:.1f}" in text 98 + # The build-up correction C to one decimal. 99 + assert f"{_oracle_correction()[0]:.1f}" in text 100 + # Nominal band labels and the caption. 101 + assert "63" in text and "4000" in text 102 + assert "Octave-band insertion loss" in text 103 + # Method basis prose and the surface areas in the boxed result. 104 + assert "Bies" in text 105 + assert "IL = R - C" in text 106 + assert "24.00" in text # external surface area S_E 107 + assert "30.00" in text # internal surface area S_i 108 + 109 + 110 + def test_verbose_adds_room_constant_column(tmp_path) -> None: 111 + """verbose=True adds the interior room constant R_i column.""" 112 + pytest.importorskip("reportlab") 113 + pytest.importorskip("svglib") 114 + pytest.importorskip("matplotlib") 115 + res = _result() 116 + out = tmp_path / "verbose.pdf" 117 + res.report(str(out), verbose=True) 118 + _assert_one_page(str(out)) 119 + text = _extract_text(str(out)) 120 + r_i = _S_I * _ALPHA / (1.0 - _ALPHA) # = 12.857... -> 12.9 121 + assert f"{r_i:.1f}" in text 122 + 123 + 124 + def test_third_octave_labels_and_caption(tmp_path) -> None: 125 + """A one-third-octave set is labelled by nominal centres and captioned.""" 126 + pytest.importorskip("reportlab") 127 + pytest.importorskip("svglib") 128 + pytest.importorskip("matplotlib") 129 + freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630], dtype=float) 130 + r = np.linspace(20.0, 40.0, freqs.size) 131 + res = enclosure_insertion_loss(r, _S_E, _S_I, _ALPHA, frequencies=freqs) 132 + out = tmp_path / "third.pdf" 133 + res.report(str(out)) 134 + _assert_one_page(str(out)) 135 + text = _extract_text(str(out)) 136 + assert "One-third-octave-band insertion loss" in text 137 + for label in ("100", "125", "160", "630"): 138 + assert label in text 139 + 140 + 141 + @pytest.mark.parametrize( 142 + ("limit", "verdict"), 143 + [(20.0, "PASS"), (40.0, "FAIL")], 144 + ) 145 + def test_verdict_against_declared_minimum(tmp_path, limit: float, verdict: str) -> None: 146 + """A declared minimum yields a PASS/FAIL verdict (more insertion loss is better).""" 147 + pytest.importorskip("reportlab") 148 + pytest.importorskip("svglib") 149 + pytest.importorskip("matplotlib") 150 + res = _result() 151 + out = tmp_path / f"verdict_{limit}.pdf" 152 + res.report(str(out), metadata=ReportMetadata(requirement=limit)) 153 + text = _extract_text(str(out)) 154 + assert verdict in text 155 + assert "required" in text 156 + # Mean IL (28.9) passes a 20 dB minimum, fails a 40 dB one. 157 + assert (_oracle_mean_il() >= limit) == (verdict == "PASS") 158 + assert math.isfinite(_oracle_mean_il()) 159 + 160 + 161 + def test_metadata_header_renders(tmp_path) -> None: 162 + """Supplied metadata renders the machine, environment and identity fields.""" 163 + pytest.importorskip("reportlab") 164 + pytest.importorskip("svglib") 165 + pytest.importorskip("matplotlib") 166 + res = _result() 167 + metadata = ReportMetadata( 168 + client="Example works", 169 + specimen="Reciprocating compressor", 170 + test_room="Machine hall, line 3", 171 + instrumentation="Class 1 SLM, octave bank", 172 + laboratory="Acoustics lab", 173 + report_id="ENC-01", 174 + ) 175 + out = tmp_path / "meta.pdf" 176 + res.report(str(out), metadata=metadata) 177 + _assert_one_page(str(out)) 178 + text = _extract_text(str(out)) 179 + for token in ( 180 + "Example works", "Reciprocating compressor", "Machine hall, line 3", 181 + "Class 1 SLM, octave bank", "Acoustics lab", 182 + ): 183 + assert token in text 184 + 185 + 186 + def test_spanish_report_renders_translated_fiche(tmp_path) -> None: 187 + """language="es" renders the enclosure vocabulary and comma decimals.""" 188 + pytest.importorskip("reportlab") 189 + pytest.importorskip("svglib") 190 + pytest.importorskip("matplotlib") 191 + res = _result() 192 + out = tmp_path / "es.pdf" 193 + res.report(str(out), language="es") 194 + _assert_one_page(str(out)) 195 + text = _extract_text(str(out)) 196 + assert "Pérdida por inserción de encapsulado de máquina" in text 197 + assert "Pérdida por inserción media" in text 198 + # Comma decimal separator on the mean insertion loss. 199 + assert f"{_oracle_mean_il():.1f}".replace(".", ",") in text 200 + 201 + 202 + def test_unknown_engine_rejected(tmp_path) -> None: 203 + """An unknown rendering engine raises ValueError.""" 204 + res = _result() 205 + out = str(tmp_path / "x.pdf") 206 + with pytest.raises(ValueError, match="engine"): 207 + res.report(out, engine="weasyprint") 208 + 209 + 210 + def test_unknown_language_rejected(tmp_path) -> None: 211 + """An unknown fiche language raises ValueError.""" 212 + res = _result() 213 + out = str(tmp_path / "bad.pdf") 214 + with pytest.raises(ValueError, match="language"): 215 + res.report(out, language="xx")
+193
tests/noise_control/test_hvac_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for the HVAC duct-noise-spectrum ``.report()`` fiche. 4 + 5 + The rendered values are checked against a clean-room oracle independent of the 6 + library's own path. For a regenerated-noise spectrum the band sound power level 7 + follows the VDI 2081-1 straight-duct formula 8 + ``L_W = 7 + 50 lg U + 10 lg S - 2 - 26 lg(1.14 + 0.02 f / U)`` (Bies, Hansen & 9 + Howard, Engineering Noise Control 5th ed., Eq. (8.251)); the A-weighted total 10 + combines the bands with the published octave A-weighting corrections (IEC 11 + 61672-1 / ISO 3744 Annex E Table E.2). The tests recompute the A-weighted and 12 + overall levels and a couple of band values and assert they appear in the PDF, 13 + along with the nominal band labels, the method-basis prose and the reported 14 + quantity. An attenuation spectrum exercises the more-is-better direction. 15 + Values are read back via pypdf text extraction; structural facts complete the 16 + rendering contract. 17 + """ 18 + 19 + from __future__ import annotations 20 + 21 + import math 22 + import os 23 + 24 + import numpy as np 25 + import pytest 26 + 27 + from phonometry import ReportMetadata 28 + from phonometry.noise_control import hvac 29 + 30 + _PDF_MAGIC = b"%PDF" 31 + 32 + _FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 33 + _U = 12.0 # flow velocity, m/s 34 + _S = 0.04 # duct area, m^2 35 + 36 + #: Published octave-band A-weighting corrections (IEC 61672-1 / ISO 3744 37 + #: Annex E Table E.2), the clean-room oracle for the A-weighted total. 38 + _A_WEIGHT = np.array([-26.2, -16.1, -8.6, -3.2, 0.0, 1.2, 1.0]) 39 + 40 + 41 + def _assert_one_page(path: str) -> None: 42 + from pypdf import PdfReader 43 + 44 + with open(path, "rb") as handle: 45 + assert handle.read(4) == _PDF_MAGIC 46 + assert os.path.getsize(path) > 0 47 + assert len(PdfReader(path).pages) == 1 48 + 49 + 50 + def _extract_text(path: str) -> str: 51 + from pypdf import PdfReader 52 + 53 + raw = "\n".join(page.extract_text() for page in PdfReader(path).pages) 54 + return " ".join(raw.split()) 55 + 56 + 57 + def _result(): 58 + return hvac.flow_noise_straight_duct(_FREQS, _U, _S) 59 + 60 + 61 + def _oracle_lw() -> np.ndarray: 62 + """Closed-form VDI 2081-1 straight-duct band sound power level.""" 63 + return ( 64 + 7.0 + 50.0 * np.log10(_U) + 10.0 * np.log10(_S) - 2.0 65 + - 26.0 * np.log10(1.14 + 0.02 * _FREQS / _U) 66 + ) 67 + 68 + 69 + def _oracle_overall() -> float: 70 + lw = _oracle_lw() 71 + return float(10.0 * np.log10(np.sum(10.0 ** (lw / 10.0)))) 72 + 73 + 74 + def _oracle_lwa() -> float: 75 + lw = _oracle_lw() + _A_WEIGHT 76 + return float(10.0 * np.log10(np.sum(10.0 ** (lw / 10.0)))) 77 + 78 + 79 + def test_hand_oracle_matches_library() -> None: 80 + """The library band L_W equals the closed-form VDI values.""" 81 + res = _result() 82 + np.testing.assert_allclose(res.values, _oracle_lw(), atol=1e-9) 83 + 84 + 85 + def test_report_renders_oracle_values(tmp_path) -> None: 86 + """The fiche prints the A-weighted total, the overall L_W and band values.""" 87 + pytest.importorskip("reportlab") 88 + pytest.importorskip("svglib") 89 + pytest.importorskip("matplotlib") 90 + res = _result() 91 + out = tmp_path / "hvac.pdf" 92 + returned = res.report(str(out), metadata=ReportMetadata(requirement=45.0)) 93 + assert returned == str(out) 94 + _assert_one_page(str(out)) 95 + text = _extract_text(str(out)) 96 + 97 + assert f"{_oracle_lwa():.1f}" in text # A-weighted total (boxed) 98 + assert "dB(A)" in text 99 + assert f"{_oracle_overall():.1f}" in text # overall L_W (extended) 100 + assert "re 1 pW" in text 101 + # Two band L_W values (125 Hz and 1 kHz). 102 + lw = _oracle_lw() 103 + assert f"{lw[1]:.1f}" in text 104 + assert f"{lw[4]:.1f}" in text 105 + # Nominal band labels, caption and method basis. 106 + assert "63" in text and "4000" in text 107 + assert "Octave-band regenerated sound power levels" in text 108 + assert "VDI 2081-1" in text 109 + 110 + 111 + def test_verbose_adds_a_weighting_columns(tmp_path) -> None: 112 + """verbose=True adds the A-weighting correction and A-weighted band columns.""" 113 + pytest.importorskip("reportlab") 114 + pytest.importorskip("svglib") 115 + pytest.importorskip("matplotlib") 116 + res = _result() 117 + out = tmp_path / "verbose.pdf" 118 + res.report(str(out), verbose=True) 119 + _assert_one_page(str(out)) 120 + text = _extract_text(str(out)) 121 + # The 63 Hz and 2 kHz A-weighting corrections to one decimal. 122 + assert "-26.2" in text 123 + assert "1.2" in text 124 + 125 + 126 + @pytest.mark.parametrize( 127 + ("limit", "verdict"), 128 + [(45.0, "PASS"), (30.0, "FAIL")], 129 + ) 130 + def test_power_verdict_lower_is_better(tmp_path, limit: float, verdict: str) -> None: 131 + """A regenerated-noise limit passes at or below the A-weighted level.""" 132 + pytest.importorskip("reportlab") 133 + pytest.importorskip("svglib") 134 + pytest.importorskip("matplotlib") 135 + res = _result() 136 + out = tmp_path / f"verdict_{limit}.pdf" 137 + res.report(str(out), metadata=ReportMetadata(requirement=limit)) 138 + text = _extract_text(str(out)) 139 + assert verdict in text 140 + assert "required" in text 141 + assert (_oracle_lwa() <= limit) == (verdict == "PASS") 142 + assert math.isfinite(_oracle_lwa()) 143 + 144 + 145 + def test_attenuation_report_mean_and_direction(tmp_path) -> None: 146 + """An attenuation spectrum boxes the mean and passes when more is better.""" 147 + pytest.importorskip("reportlab") 148 + pytest.importorskip("svglib") 149 + pytest.importorskip("matplotlib") 150 + bands = np.array([63, 125, 250, 500, 1000, 2000], dtype=float) 151 + res = hvac.end_reflection_loss(bands, 0.3, termination="flush") 152 + mean_att = float(np.mean(res.values)) 153 + out = tmp_path / "atten.pdf" 154 + res.report(str(out), metadata=ReportMetadata(requirement=1.0)) 155 + _assert_one_page(str(out)) 156 + text = _extract_text(str(out)) 157 + assert "Octave-band attenuation" in text 158 + assert "Mean attenuation D" in text 159 + assert f"{mean_att:.1f}" in text 160 + # More attenuation is better: mean (>1 dB) clears the 1 dB minimum. 161 + assert "PASS" in text 162 + assert mean_att >= 1.0 163 + 164 + 165 + def test_spanish_report_renders_translated_fiche(tmp_path) -> None: 166 + """language="es" renders the HVAC vocabulary and comma decimals.""" 167 + pytest.importorskip("reportlab") 168 + pytest.importorskip("svglib") 169 + pytest.importorskip("matplotlib") 170 + res = _result() 171 + out = tmp_path / "es.pdf" 172 + res.report(str(out), language="es") 173 + _assert_one_page(str(out)) 174 + text = _extract_text(str(out)) 175 + assert "Espectro de ruido de conducto de climatización" in text 176 + assert "Nivel de potencia acústica ponderado A" in text 177 + assert f"{_oracle_lwa():.1f}".replace(".", ",") in text 178 + 179 + 180 + def test_unknown_engine_rejected(tmp_path) -> None: 181 + """An unknown rendering engine raises ValueError.""" 182 + res = _result() 183 + out = str(tmp_path / "x.pdf") 184 + with pytest.raises(ValueError, match="engine"): 185 + res.report(out, engine="weasyprint") 186 + 187 + 188 + def test_unknown_language_rejected(tmp_path) -> None: 189 + """An unknown fiche language raises ValueError.""" 190 + res = _result() 191 + out = str(tmp_path / "bad.pdf") 192 + with pytest.raises(ValueError, match="language"): 193 + res.report(out, language="xx")
+177
tests/noise_control/test_silencer_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for the reactive-silencer transmission-loss ``.report()`` fiche. 4 + 5 + The rendered values are checked against a clean-room oracle derived from the 6 + simple-expansion-chamber closed form (Bies, Hansen & Howard, Engineering Noise 7 + Control 5th ed., Eq. (8.111)), independent of the library's four-pole path: 8 + ``TL = 10 lg[1 + (1/4)(m - 1/m)^2 sin^2(kL)]`` with the area ratio 9 + ``m = S_exp / S_duct`` and ``k = 2 pi f / c``. The tests recompute the mean and 10 + peak transmission loss and a couple of band values from this closed form and 11 + assert they appear in the PDF, along with the nominal band labels, the 12 + four-pole method basis prose and the device kind. Values are read back via pypdf 13 + text extraction; structural facts (one page, rejected engines/languages) 14 + complete the rendering contract. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import os 20 + 21 + import numpy as np 22 + import pytest 23 + 24 + from phonometry.noise_control import silencers as sl 25 + 26 + _PDF_MAGIC = b"%PDF" 27 + 28 + _FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float) 29 + _LENGTH = 0.5 30 + _S_EXP = 0.08 31 + _S_DUCT = 0.01 32 + _C = 343.0 33 + 34 + 35 + def _assert_one_page(path: str) -> None: 36 + from pypdf import PdfReader 37 + 38 + with open(path, "rb") as handle: 39 + assert handle.read(4) == _PDF_MAGIC 40 + assert os.path.getsize(path) > 0 41 + assert len(PdfReader(path).pages) == 1 42 + 43 + 44 + def _extract_text(path: str) -> str: 45 + from pypdf import PdfReader 46 + 47 + raw = "\n".join(page.extract_text() for page in PdfReader(path).pages) 48 + return " ".join(raw.split()) 49 + 50 + 51 + def _result(): 52 + return sl.expansion_chamber(_FREQS, _LENGTH, _S_EXP, _S_DUCT) 53 + 54 + 55 + def _oracle_tl() -> np.ndarray: 56 + """Closed-form expansion-chamber TL (Bies Eq. (8.111)).""" 57 + m = _S_EXP / _S_DUCT 58 + k = 2.0 * np.pi * _FREQS / _C 59 + return 10.0 * np.log10(1.0 + 0.25 * (m - 1.0 / m) ** 2 * np.sin(k * _LENGTH) ** 2) 60 + 61 + 62 + def test_hand_oracle_matches_library() -> None: 63 + """The library TL equals the closed-form expansion-chamber values.""" 64 + res = _result() 65 + np.testing.assert_allclose(res.transmission_loss, _oracle_tl(), atol=1e-9) 66 + 67 + 68 + def test_report_renders_oracle_values(tmp_path) -> None: 69 + """The fiche prints the mean and peak TL and a couple of band TL values.""" 70 + pytest.importorskip("reportlab") 71 + pytest.importorskip("svglib") 72 + pytest.importorskip("matplotlib") 73 + res = _result() 74 + out = tmp_path / "silencer.pdf" 75 + returned = res.report(str(out), metadata=None) 76 + assert returned == str(out) 77 + _assert_one_page(str(out)) 78 + text = _extract_text(str(out)) 79 + 80 + tl = _oracle_tl() 81 + assert f"{float(np.mean(tl)):.1f}" in text # mean TL (boxed) 82 + assert f"{float(np.max(tl)):.1f}" in text # peak TL (extended) 83 + # Two band TL values (125 Hz and 500 Hz). 84 + assert f"{tl[1]:.1f}" in text 85 + assert f"{tl[3]:.1f}" in text 86 + # Nominal band labels and caption. 87 + assert "63" in text and "4000" in text 88 + assert "Octave-band transmission loss" in text 89 + # Method basis prose and the device kind. 90 + assert "Munjal" in text 91 + assert "four-pole" in text 92 + assert "expansion chamber" in text 93 + 94 + 95 + def test_insertion_loss_column_when_impedances_given(tmp_path) -> None: 96 + """A result with end impedances renders the insertion-loss column.""" 97 + pytest.importorskip("reportlab") 98 + pytest.importorskip("svglib") 99 + pytest.importorskip("matplotlib") 100 + z = 1.206 * _C / _S_DUCT # anechoic reference -> IL equals TL 101 + res = sl.expansion_chamber( 102 + _FREQS, _LENGTH, _S_EXP, _S_DUCT, 103 + source_impedance=z, radiation_impedance=z, 104 + ) 105 + out = tmp_path / "with_il.pdf" 106 + res.report(str(out)) 107 + _assert_one_page(str(out)) 108 + text = _extract_text(str(out)) 109 + assert "IL [dB]" in text 110 + # For the anechoic reference the insertion loss equals the transmission loss. 111 + assert "Mean insertion loss IL" in text 112 + 113 + 114 + def test_resonator_reports_resonances(tmp_path) -> None: 115 + """A tuned resonator lists its resonance frequencies in the boxed terms.""" 116 + pytest.importorskip("reportlab") 117 + pytest.importorskip("svglib") 118 + pytest.importorskip("matplotlib") 119 + f = np.array([50, 63, 80, 100, 125, 160, 200], dtype=float) 120 + res = sl.helmholtz_resonator(f, 0.01, 1e-4, 0.02, 1e-3) 121 + out = tmp_path / "helmholtz.pdf" 122 + res.report(str(out)) 123 + _assert_one_page(str(out)) 124 + text = _extract_text(str(out)) 125 + assert "Helmholtz resonator" in text 126 + assert "Resonance frequencies" in text 127 + 128 + 129 + @pytest.mark.parametrize( 130 + ("limit", "verdict"), 131 + [(6.0, "PASS"), (12.0, "FAIL")], 132 + ) 133 + def test_verdict_against_declared_minimum(tmp_path, limit: float, verdict: str) -> None: 134 + """A declared minimum yields a PASS/FAIL verdict (more TL is better).""" 135 + pytest.importorskip("reportlab") 136 + pytest.importorskip("svglib") 137 + pytest.importorskip("matplotlib") 138 + from phonometry import ReportMetadata 139 + 140 + res = _result() 141 + out = tmp_path / f"verdict_{limit}.pdf" 142 + res.report(str(out), metadata=ReportMetadata(requirement=limit)) 143 + text = _extract_text(str(out)) 144 + assert verdict in text 145 + assert "required" in text 146 + assert (float(np.mean(_oracle_tl())) >= limit) == (verdict == "PASS") 147 + 148 + 149 + def test_spanish_report_renders_translated_fiche(tmp_path) -> None: 150 + """language="es" renders the silencer vocabulary and comma decimals.""" 151 + pytest.importorskip("reportlab") 152 + pytest.importorskip("svglib") 153 + pytest.importorskip("matplotlib") 154 + res = _result() 155 + out = tmp_path / "es.pdf" 156 + res.report(str(out), language="es") 157 + _assert_one_page(str(out)) 158 + text = _extract_text(str(out)) 159 + assert "Pérdida por transmisión de silenciador reactivo" in text 160 + assert "Pérdida por transmisión media" in text 161 + assert f"{float(np.mean(_oracle_tl())):.1f}".replace(".", ",") in text 162 + 163 + 164 + def test_unknown_engine_rejected(tmp_path) -> None: 165 + """An unknown rendering engine raises ValueError.""" 166 + res = _result() 167 + out = str(tmp_path / "x.pdf") 168 + with pytest.raises(ValueError, match="engine"): 169 + res.report(out, engine="weasyprint") 170 + 171 + 172 + def test_unknown_language_rejected(tmp_path) -> None: 173 + """An unknown fiche language raises ValueError.""" 174 + res = _result() 175 + out = str(tmp_path / "bad.pdf") 176 + with pytest.raises(ValueError, match="language"): 177 + res.report(out, language="xx")