[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.

Add reverberation-time report fiches via .report() (#336)

Add a one-page PDF .report() to the two reverberation result types.

ReverberationModelResult.report() renders a design-stage prediction of the
reverberation time by the five classical statistical-acoustics models (Sabine,
Eyring, Millington-Sette, Fitzroy and Arau-Puchades): a per-band table with one
reverberation-time column per model beside the model comparison plot, and a
boxed mid-frequency reverberation time from Arau-Puchades with the per-model
spread alongside. It is labelled a prediction, not a measurement: the five
models bracket the reverberation time likely to occur, so no PASS/FAIL verdict
is emitted; a target reverberation time is printed as a reference line only.

ReverberationResult.report() renders an enclosed-space characterisation
(EN 12354-6:2003): a per-band table of the equivalent sound absorption area A
and the reverberation time T beside the reverberation-time plot, the room
volume and object fraction in the header, and a boxed mid-frequency
reverberation time with the mid-frequency absorption area alongside. A target
reverberation time is likewise printed as a reference line without a verdict,
since a room reverberation time is a target range rather than a strictly
higher/lower-is-better quantity.

Both renderers live in a shared _report/reverberation.py module (shared
mid-frequency descriptor, time formatting, octave-band table and header grid
helpers). Add the English and Spanish fixed strings, one committed example
fiche per result under .github/reports/, structural and value-presence tests
in English and Spanish, and the guide sections in both languages. Regenerate
the API reference.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 23, 2026, 3:57 AM +0200) 1ad7ae1f e4781d14

+1656
+25
CHANGELOG.md
··· 25 25 metadata `requirement` adds a PASS/FAIL verdict (a lower value is better); 26 26 without it neither fiche prints a verdict. `language="es"` renders the 27 27 Spanish fiches. 28 + - `ReverberationModelResult.report()`: a one-page PDF reverberation-time 29 + **prediction** fiche. The sheet is clearly labelled a design-stage 30 + prediction, not a measurement: its basis line names the five classical 31 + statistical-acoustics models (Sabine, Eyring, Millington-Sette, Fitzroy and 32 + Arau-Puchades), it carries an optional metadata header (client, room, 33 + description, room volume, total surface area, climate, date), a per-band 34 + table with one reverberation-time column per model beside the model 35 + comparison plot, and a boxed mid-frequency reverberation time from 36 + Arau-Puchades (the recommended model for a non-uniform absorption 37 + distribution) with the per-model spread alongside. The five models bracket 38 + the reverberation time likely to occur. A target reverberation time supplied 39 + via the metadata `requirement` is printed as a reference line without a 40 + PASS/FAIL verdict, since a room reverberation time is a target range rather 41 + than a strictly higher/lower-is-better quantity. `language="es"` renders the 42 + Spanish fiche. 43 + - `ReverberationResult.report()`: a one-page PDF enclosed-space characterisation 44 + fiche (EN 12354-6:2003). It carries a basis line naming EN 12354-6:2003, an 45 + optional metadata header (client, room, description, room volume, object 46 + fraction, climate, date), a per-band table of the equivalent sound absorption 47 + area `A` and the reverberation time `T` beside the reverberation-time plot, 48 + and a boxed mid-frequency reverberation time with the mid-frequency absorption 49 + area alongside. EN 12354-6 gives a diffuse-field estimate; a target 50 + reverberation time supplied via the metadata `requirement` is printed as a 51 + reference line without a PASS/FAIL verdict. `language="es"` renders the 52 + Spanish fiche. 28 53 - `OutdoorAttenuation.report()`: a one-page PDF outdoor sound propagation 29 54 **prediction** fiche (ISO 9613-2:1996, general method of calculation). The 30 55 sheet is clearly labelled a prediction, not a measurement. It carries a
+80
scripts/generate_reports.py
··· 1530 1530 return result, metadata, "iso3382_room_acoustics_example.pdf" 1531 1531 1532 1532 1533 + def _reverberation_prediction_example() -> Tuple[object, ReportMetadata, str]: 1534 + """Reverberation-time prediction fiche: five models over the octave bands. 1535 + 1536 + A shoebox classroom 8 x 5 x 3 m (V = 120 m3, S = 158 m2, the geometry of 1537 + the reverberation_prediction module tests) with an anisotropic absorption 1538 + distribution: one wall pair treated with a broadband absorber (alpha rising 1539 + with frequency), the other two pairs lightly absorptive. The anisotropy is 1540 + what separates the five models, so the fiche compares them meaningfully. 1541 + The values printed are computed by the classical closed-form models 1542 + themselves (each anchored by the module tests), so the fiche needs no 1543 + separate numeric oracle. It is a design-stage prediction, not a 1544 + measurement: the five models bracket the reverberation time likely to 1545 + occur. 1546 + """ 1547 + freqs = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) 1548 + treated = [0.10, 0.15, 0.30, 0.45, 0.55, 0.60] # broadband absorber wall 1549 + side = [0.08, 0.10, 0.12, 0.15, 0.18, 0.20] # lightly absorptive walls 1550 + floor_ceiling = [0.05, 0.08, 0.10, 0.12, 0.15, 0.18] 1551 + result = ph.reverberation_time_models( 1552 + (8.0, 5.0, 3.0), (treated, side, floor_ceiling), frequencies=freqs 1553 + ) 1554 + metadata = ReportMetadata( 1555 + specimen="Classroom, one wall lined with a broadband absorber", 1556 + client="Example client", 1557 + test_room="Classroom C1 (example)", 1558 + temperature=20.0, 1559 + relative_humidity=50.0, 1560 + pressure=101.3, 1561 + test_date="2026-07-21", 1562 + laboratory="Phonometry reference example", 1563 + operator="phonometry", 1564 + report_id="EXAMPLE-REVERB", 1565 + requirement=0.8, 1566 + ) 1567 + return result, metadata, "reverberation_prediction_example.pdf" 1568 + 1569 + 1570 + def _enclosed_space_absorption_example() -> Tuple[object, ReportMetadata, str]: 1571 + """Enclosed-space fiche: absorption area A and reverberation time T (EN 12354-6). 1572 + 1573 + A small 5 x 4 x 2.5 m meeting room (V = 50 m3) characterised over the 1574 + standard octave bands by the EN 12354-6:2003 Clause 4 model: a carpeted 1575 + floor, an acoustic-tile ceiling and painted-plaster walls, a few hard 1576 + objects (their equivalent area from Formula 4) giving a small object 1577 + fraction, and the recommended 20 degC / 50-70 % air-attenuation profile. 1578 + A and T are computed by the tested Formula 1 / Formula 5 primitives, so the 1579 + fiche needs no separate numeric oracle. EN 12354-6 gives a diffuse-field 1580 + estimate, not a measurement. 1581 + """ 1582 + surfaces = [ 1583 + (20.0, [0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.55]), # carpeted floor 1584 + (20.0, [0.20, 0.40, 0.65, 0.75, 0.80, 0.80, 0.75]), # acoustic ceiling 1585 + (45.0, [0.02, 0.02, 0.03, 0.04, 0.05, 0.05, 0.05]), # painted-plaster walls 1586 + ] 1587 + object_volumes = [0.5, 0.8, 0.3] # furniture and fittings, m3 1588 + objects = ph.hard_object_absorption(object_volumes) 1589 + psi = ph.object_fraction(object_volumes, 50.0) 1590 + result = ph.enclosed_space_reverberation( 1591 + surfaces, 50.0, objects=objects, object_fraction=psi, 1592 + air_condition="20C_50-70", 1593 + ) 1594 + metadata = ReportMetadata( 1595 + specimen="Meeting room, furnished", 1596 + client="Example client", 1597 + test_room="Meeting room M2 (example)", 1598 + measurement_standard="EN 12354-6", 1599 + temperature=20.0, 1600 + relative_humidity=55.0, 1601 + pressure=101.3, 1602 + test_date="2026-07-21", 1603 + laboratory="Phonometry reference example", 1604 + operator="phonometry", 1605 + report_id="EXAMPLE-EN12354-6", 1606 + requirement=0.6, 1607 + ) 1608 + return result, metadata, "enclosed_space_absorption_example.pdf" 1609 + 1610 + 1533 1611 def _noise_criteria_example() -> Tuple[object, ReportMetadata, str]: 1534 1612 """Noise Criteria fiche: an office spectrum rated NC-40 (ANSI/ASA S12.2). 1535 1613 ··· 2687 2765 _nipts_example, 2688 2766 _htlan_example, 2689 2767 _room_acoustics_example, 2768 + _reverberation_prediction_example, 2769 + _enclosed_space_absorption_example, 2690 2770 _noise_criteria_example, 2691 2771 _room_criteria_example, 2692 2772 _open_plan_example,
+96
.github/reports/enclosed_space_absorption_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 /Symbol /Name /F3 /Subtype /Type1 /Type /Font 21 + >> 22 + endobj 23 + 5 0 obj 24 + << 25 + /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /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 (Sound absorption in an enclosed space) /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 11752 66 + >> 67 + stream 68 + Gb"/LD0$uCNTPH`MY_?*PX8ZupD69R#MD2q]a`Z\[2H]:O;o&NV^YK`QSHUE=8V:o%tMm(B!Y(SO?GL78d^ERm&Wl+J4(TD8<b,'rG26/a61A0S&=3chq>iWiUCB^@1:'4\&58aci3X+eXA4R`qe\MSF,OHZ1K+94BcB-Wl+$p/B&'.a<B9rlGq5L8_\S`_r3WKi!,[tK6(`+i6Ra.04#/Z5?qQ%]t$/:]GOBsL80Z8I;sXXH+eYi>e`9[?@P-Yr]D0L!Gqb!rk/]7OQIC*rb&b>pGPW#as^BFT`R@8i=fCur]lR+@?lT?p!)asdER!I4;i$c=qBr6]fl>Uk\PZe1L/g,roe*F7-MB,Y1C@hYC9S\*H<ek^oqYcCj<;#)IEB9NUK.0r7W%Ka2a-bikn4hWteO41U7ks=bskp2UdI@(i>"0`q"#S%#p?[7`O>WSJ%L-hDl3agh9#tZi9)X:9FDM;.Yqgs)iG_k%m1#F&+kZE[2aK#5;@)qpXZgq<h\e:rYoZ^&P<c^L#ff?Mlcf;*Vteh=1BZ=pFj%k<"#:<&&0\5.H)F?QWXGZ:$p/F]526(upt#akNkSe*EXWY92oceDp7)V!U@?h/j2L%_l8?]qrjHQf5PtmMKjhke7_;G<Hr@?H`is0)Y#[GlQWBM<5ipHh.Xjs,S7N?6A&5-W`gYXIjTjIR=Hkh&Nqj5!gA1MXNN@1E,^'B)]+Ji,F,D#0Qc/eU%"Nq`OZ1-)(4PD]cmQpDg!@O1.$-krEk7qM^`2?e3+jDpM3dTAKK,I.okn),fYPeGZudCj_UecSW(Kc@:A9+5U8)YHO@@s6l"=O5\];&'c(sA+*p]kak0'We!IrH@:,;8Boo$IV0gGBk?kXfirmK?D$@!F[E3DX]^Wt.W!T7T)VRcrOg<+r6rE07bIY;ZO:N%#&uh[8hIR4nT6\eR=?p^El5lr\]Y=(,jr-n>]jO%_s3ep+<UPaHq<^:kMF28Po6^'2:HJn-R$380Rg@OA3=]+0mN-siH$o:q%3maUt1<`7S>("%`Sr.lc2\i%OYU[KD;q+F(PjPHC=OYou=#+>skH\`gTM?\\Df#$$X+nd6U@mds?Tpol/s0o_3+WLL#Ph,Ym2R`(,_c_TkIbeskOOQ&NG6&=TLtGkukN=:6PbW,q`[3&heiqmZV)CD2i\k-)BU%DDMJ3jiS$/qtd6BYQ>s)TF&@F,MK-R5]8[L_'C(oj@>S24Gq'2u;3.EV+T,[_=u*4LLf`=&L%Khd+PhhtV3>ICCQjVRj)ZJ%c[P[ZVn6YI%N'07:+;RAP<\7$=Ne*_X)VZ?:^XP@o#p2K_OGL'23IhG56'%;Tra2.[Em5WJD9er=fBjb?.+CW<`^C-s/bO]<FTWt1oNX$X5HqbcR"c03?2W`Y]6Tg84rajA7a3:sf3:\FJBd-D;S]?kCSd"IUF4buD)8L!HfmpBk!dH(UZG@"i-4PYFilUem8O]F4#FU<gD6Jc]Q.Pp0p'O"N*`q"X.VCm]Xb\B>a]LZdDaAR1F.;UG`2:5,0=Vg1C\6s31oUj>sZ.Z':X)qua\'5_YJlLtADXNP6jb*hJ#u<oU2DjL`r^pb*D_3j*p/5sU.@CFO."Z&u_mAc4Cqq=F]G@u<H<c"Nl@ug>&ARG2PBBB2B@<\6I0+g+s8T"C::;SECe+[Aa5f@"d1+U2JVXGm#a&G4V?L#JCN&G.PL8,5\:_a:\%4]:L!LOC>=0S[L]@=lE,etZA#dQ"1u/(g\PibO[n3j.If.[(f4OgJRa<7XFkPM/Vd1qhloo4%rbg6=DY`_s?2Dh%![^hZS"I]u^J2%kLNibk1\C,U^hC$qf#"<!#7?`h*;pF2g"EOj*MXJ].5;WM;U42*`dXgjk8K*==d^">#2ko"_,S5'pq`;]'FZ-U%5P:WiaN^ufmon2g?[EPLae5<g0aYiRMBn\,r,gQ:sV=39=mP3YRc1LcjPGV(5MTbI`ePWY'e=$@_37d"&"@br?FQ5fOD?O*"u1W.b?pY+a#S#m'KU0Thji7.7bYEV_)"EdYs*Pf&IaX?D=jK<SAKm4VDN_oEGOJeA'2rP<"sDnsS6B32&0o8=W"JObASX/T-@/OerOVH@V-:`e&i:>\Jd!&alWb#"L/Z(*0>0Gd=\^*%I:P@5+K=QmTag?\9HAStj"k5)pL[.ESrH%Zu4CeHumUE`cdbKaESIVg]fTr(Eo)$&cm\#.,pJ"pj6C:@$:`NAXAB7$OU;6K78<bnhR#dX!fB;k=[Fki*<a4C3RZ)%AtV\4C=@iE[bhQ0c%%VbG400nQd'$ag=#._SW#5SmL8SBdNN^SFa10OC_R;i,[K!TS=R$O6r#)7[ptHEr8>JC%P4oQ\FKY`<lZH^Pq3jucFNRN=B[.5!:l-CFYt(@3M$$L<n7N&]qQ&0'ku(DamG9sPs_&^m57$=JQ2R:WNW`6qkt"a[&&!ln!=g.d7I7rZ8=D^I=r;dP9e=0mujY00V7Z@i4\Q2:28mqCMg\L'E@,cJsPQ6R"K@Q%:md6J"8a1sF,I?H@3LF]QK&/QB:#SOXI5dfm9#lb5eb8k/0F='#M+"C<.81'uL5i#C]eg(m!68BSq[YFj-5*?aNE/6taUjl'"S#Mgk4Zej\I!B`s6GH%TQrHm%1._hR&E++5*pLuclre0@m`CY(:Z17)QhI:4\W"4)RMMuncljtHA\u@(kQO8,5f+X1,JSKO#qgO4)QOo,rK?0jknp-O<Djc-0Z#9[Y+p88e[lZ:/hla2I_@"BbL19t"D5R*l=Ebs[^KChajq54NreSE#ILoTlXR1`?G,s95N@-r4r3JEj0_'7K`+ZPSs5h)lqjL?Wk*mF$]4WMn3@micoFIu=W4[4WG#6A5d5#^$@,^?!X)`k(0Q.Z/L")Xp.AaVN_\M?Sb`Pu)(?=SkEDIZP7b+[%0qQ^'j"gCTkS7eeh'*86376`Y2.LgAor"*I3/+3297KaMU\.[AL8A<6D!H<)RHLhhQ^DM[FRhjQ`]QJ7#S4W<\uKp2(SclPW@8$oN#g'af<VQ>MJ=pp5O@B8@?!GD(hg+hbC9gT<tSb%(@:JS,1'U+p5&ZM9:5HBL>;0NUqCAT0e<.U#SqqUs>PYVC``6FXqg-8\N[FM`.P+HAa(*oE1Q3Mp8KY%%i-rYWo18Yo+=HiIfhmM]o_=og^CZT7eBSBYO<E_G#]'07/j*]\W-O2M@XGUVRo[Y)EN2kAlacguIK]\ro/tDo=-pRD;i3Hj:>7_^/!D-YOu\/V5OTeG#b<GpK1L"csL-ngTO7W8m<ZIY&q?Ni[S*HlFG+^$KI>J#hZI4o8q46CS8T+_UgqZF$,Q/LO37@e!7#=Eri>-.:'TMdH+Q`F?I@Qb6FF(MSjoW;hWW/0&a8/&1ZV#,4OrB2UjI\I.E83CK^DQ^*eGpTakO2`tuuCqs*6'%`PfVD[7^p?V&kleAM0qHe!^:I&br1PSddG*ou0Bkh.YjVP$WMA_8H+*<:[Xq+WaEr;c<%BqId)E9]YTW+%u]254?>K*UpgqY3OX&m1=:RLFf$6(i4qjXE&4)hfg9d]:i3%:.0,I6E9CLo6$?!+*fQ84kFYXdYlZ>p.H/C%i9d.:R\,!60D!]QY6V:g*l[FdrAI;+s9$g67V!9c7:%urQR>j:oX,Wa8`G,-7H<HW%X\s0#t3:\'UnDbZp*BIn5ZD[Ht85=0c+nA23K@L<$dL(?;`9\QqkJZ<-'JbB!Sgc)P,P#p2p2lFF==+-9&5hJr^#^i6G1rP^[CP)"1EYk:Nf6(,Q3'#;I0au^,?(q6a6]p3k+*Wj8om(f;0+S1HQ$FA:c$1."\nHET&u[n`kc:Y%%mA-.TQ1%5!^8;h+PhQ$3B(JoN9<B;J%nQ,AZ"@]>m&I_&Z/d,8Eg8JD9R#dL5'HFLkIeY8!M]EDtcq=qC3g_h:^N@i:amn-B[)]/$INRLHSij#[!(PXtpZ*\I(inEuesm!Q.nQ1Wp$fs+Zj``+tGMomfAMu(\>C)]8#<U)C#9m`Ejq^'t/q'PX_[!!I,Cc6X:SV`c=Osf17XDi?3p/]^l/@UIa*R@RLO`l::i>P0iY4+U^3AY?Dlu+Wg[,$R*X]M2=0Uh4:CjJ9olNN,082<)5Y?T]/!_'P$IZ!gXm^-9J"0ZNC/g!;2Bi2%LDt:3!4cPe4/MFcl1G<5XUFDZ5>cm@,]\I7IiT6YV=_P'OF*#@E<q\@VDRI.HEmMD"FX/2HA%1@=T#SsUQ5<lqq/d1YBQDdb%;9<K4!!*kH/[kVGtFJtb7V*;?FI/;nT(\lKpR/[W7$,Z,&PodUY<k^k#'rLH/[kVGo>QlE1;VtXmBc7ghM,njdpaS$s1_mo!4NDNL*j$0>-'r08kCfDY)AXPs(+n>:hM@h"M+c*OEqYo<OWEnHAE+bQ!O6*!Jh.=kXJ>0kCK:#G_Dq]2H:)l\-s-^bsWWS*<6U_sJ(<gk8snQ(nQ-/Jo'iXj.?0T,R[8^\<I!6o1"]Z0V;_O&V<5BH6p5>tdhN4H*T$_E!f=:(Opfdss%jV;QUE,b4;p95M+q"Oie_K^FJ:R`Uu//'b'"$QJm!.a%Qo83!jEmH>:l$:_`,,m66Nh=gEKZNC%_LL#mr.:!WDS+O9[BU::(oeVNrqk.APHnO's6;;4hic("i;2,KflrfBRpm9oh8^TmL"??7FP90GuemBW<iF:4k>&`<@NNS*C]-Mo=JL-$2p)sH#<ZEMh1$&B_$VW#[?kgg'WbJ$c;nT2uhkCm5Mu9hKc56.!fX-;&G4j*]S:;^e>jfQ7kGRmHN'WDu.kp4o>V:lM%H7^=Q/jn`aU'0:5nc"mo)UF.Rk_gZShQN"+LYL3.`+0>/&R8%EN*7+8;JLmFuh@3)!d#m46Vo-;lV8T`OS-0NE<>&[gR7=1\[Q4e`/`?aF'chOE?#/@Pi?G&5Xq('"Rb1U?R<$Y(4eCdBJ:\,fY#\PCKPI7sul%hNih)E='c]3&GO_K?GlL4c/V)a"],_U7r%AJQB4`YiFXNZ(Xl&A6_q^<e:h.$R-:T=720AjQhe&+<4t40`?T=OY+R=k9+a^fL<LHU?Cl;*uM\mke+ob<p1D7H!hZKSZ**<31i4`le/0G,*Hf?s.'P]?&LJ3Q(`40JjfHubXC$<^>NO-jhrKZ%S0Tbn-%)O>9JHl8;a"BpJ?h,[2gI*P+m*9@C$sTH-10kbQ5,[>pjK-]%G)]At9.R[SC*[Y(]tZ/7i.MZlFrSQua]Ge8+YP[4N^lmmVi;'o>[E^@;C)&;>+aK6Y)?AQ^<mUCqe[h&=FtKM![%/%Go.W`njna_g(dK,rZ71G1U%;(S^ZE^U[R86dQhIg[XL13IB`BcD)b[T(gndKjDu5V"[.j^'"*E6q6`]k<q(N)H.NVVa\WPf5nd"$cJ[@KO`IT_5s9i=$BBPi3)Q=N]WHL`WFIOl;Su*,j/g2BJTN4/\&_m=<<`O(j6f+Hd[3H:Y]1Y2$I'^`4HE@$=f&6P3KFm[ViK2l+MU-KlKE^fm8I`lEPEOnG3`AoZ`lCpO[&WSmot<b_AmW"0?eWOPKYYRX<(TTED%QXGkDi*#[R@#7c8Ace^IP2pX>-Ch6,A-Ju-P&_4S)hu9di^hn.I-op1p!Ptj,W*oo)TQ@[aoc*qE(uRn/0Q^*8jM932>"!!S`=PuJ&*mb5(e':"&59kC+fe4@Xp!6aL`93aN%`_'O\n:3LmKNXD%R:&LGDH1Gs863/rD<fsbWa?Mkt35)Vg`7NC0+I9)PgO@jA(7VpIt+UL]&aj"^Zf\=4L-lSQ*dTi"tgY[;.p30N&chH/N@A1,$DtsOcnpK*sGE.8[QS7a;(FH@R]UUUe:$#g-[I*'jjU:i5e#Y'U]Q1-R`Vo$a.%u'jC%c'tbj]L1#f_k2rS3GRi*)ZFRY1kX=kM=kKPIur0U&dd$SEn*R=efiCf<tHUZ%ZK$8ktb**$7@$'f,;f[*s'Ra]3l;q(,!I*F(fnlijjX.cte,P]p,@aNl`@^=fd95_0oe/_%PpKf&,[W_j8Atma9b,f#T/sd?Jb2Wr:`\q:f"jfqV,o1[m9Z=rZckmI(%l%Ej60rB.7TpdW5l4)\j15,Sa_(cgiDc;Q<bdc,+\tS.F[A)0UBI1+qoGcXgBXA6WkITZfecU?c)TKEYdq>Lpm.MNCqGnqJD$X*j^M<J&Df;i>a_Yccp^-4?o`R.1iG^`=uTF,D@Bf5Hte_r.[rM47a*+7OPJ$E!>;Q#kNrGT0jmLb#%CUA=(JpY82>q3Z=J7'%8)"Q_MN\qbC`+JkF.hc%IQnj28^_,:VghC+KZ_\i#ZsQ.b8`pL47@JHYF@l%TD-M=NKt]P\/rDdRGb-,!l'kb]hWh[B%D)fql?MAmU^8Y*X%#7c)fP4:u2W_PIt'n3iEppXhOn@on2]ru*GIT\pS:b])Ke?pqb(GQe^oSIN)J2CLCB"7HL&SdgZYcG!P"1QShdNlBc-4NAH%$s2@,eiL;o]E+L&T`_YjG;C=^LYp4o%&lmSF"6Om9(=*Jk@-#"Bk3\7G?;l$GK=o)>NlQ,=`$5NjRL[QJ\2iiAS>t1#"n3*%hj4h9\ka[SM-p##mRK*Q]V/^#-+2>/."+7jVh.]T[&/Gp+ll[^ZZ?:a*\9X4cd[mRstA^Z5'Os9sXQ8>C,\=#I!C:NlfAD-Zoo=_>TEsn,>GuP2L(HE:ner>g4%br?VHA'dm(nBb:J1'9H;C1B!K1m`)G"2C4[%Em(h:q:"bqLq2ajH#H1+kkU6B06jckd9Coo(5;MX[&(6i7Brf"O%m#6(<)HID9:T,Jhsla>.09GDN';0-3TfEn7::L\+HD2'0^"]+"m65@[epWa^U`e:iFCpO=9VI>,?2R?R*suUXfdD]Lqj_?IUH9F$X^.%rl`'=f%EaC0@Asr$l8<'MMtMMeaDPP/'&)d&HNb,95$&#``\g)GP'7EStYIT5ij<rJZr*W))5R[l94506q8u2ImF8&eTbj5;YcQXoZEu#;u!Bk+FLK18M+a>`uNc_C6\U47a#9Nf6*b-c-^<"6a[s!HPM-r8&k9VW6]_'U"BC'Tg4e6BkkS^`LlS#dDj(6a0O0#q`QVD)WIB`&^MLOp9bGUi#P6#2\,sCjXp&^Qt`QftbZ7gQU9"r\PS=).AEg;2$Sies^c+Xu9!H>B&:t3FQBT>W'+3os&op/8.AbkW-qRn]2]ReP_i:f+K2]L#?CdO1cjoF<eh(PU[c%j!+=3rY(8$6[2^fH@gIGqXH@e;@YHu1ka(Cm?8at*Q%FNo<)gN40InMdOMuOYqc-Ed`O^`=nhYrBlP*t/$5*QPr7/u>D>_/!q[grk!Dmt:[0>n+>/MU>clrr'&R^pC+Qc+0*JsIY=XXS<j,)3:JGq(6<th(=7rnI+m##%<4Ns@0.-5ePhcre8ZNCYfhgu<:H%uo9/"KH6`NP[Kbf;Z1&)N:n"V`7Q:0e,,4[#lq3$P]8OWR=7-'u+Z&HqA`eC=N=t=j/GI4dMU5WXrdu\R1FoTJPVtP\a(LfldgQuLKndc7C<OKdF6^Z,c@Og`J)gsGFg8PDXo?W4*i'f.u]1?8X1MUpUh]c'S@3_*ETGQb4Yi^!]aHRU4E$Qp.6!D;eH#T`g)/GS=i(E*9-hn%(D\jOcI@/NbB/=S57&5Je<rL(MKd#1jl#bmWbSoT^d';@jA,-&8B[0:!VRX^NVKB[[d6"KS[_)DHVn$j4dPj"8D2'q$hj?r0+YPhn(cIn!/%ar"AmZl?N`Cl?):rW4_%A7G]YX?8e[%AXKWRV`r5LaHjMkJen!IK.%AqDRjtXtOl)#T.P,(^Upf+ZuMMaWRD0aS=8Uchb#TTn6b3_od9f'3C`I$Be-*'LZ#/H&"(5c>(f%O5LY"b_hMkcU4-LXUuO!XVB8"BL0[-q[)H#F@p?N<=J\c<f?^B!\D+lJ:?$qtleGDq:KMCgP8l<mHoM`Q(nFTD.A@`A%R4tc))?U-YR9Y9;ShkM5]4J\jOA@Uf/,kfhmnjdBbQM`HP4%oJgAC'3l]oFFgC`<OLr[9PKP!=g48CATB!UW=cfJ([5pPB4!E6k#1\#-g+BM\53U8'/UAk.H<,:EM"mjFlh15obdRu&jl4$<;pJInf'5*8OO^as`0ZCTkV0=0%$.4HKUf%a.]g1#B2.8?uLNM-*Eng&0+#_\i/iE)X@gQHn&>$=Lt@]"O:*3IAo-m`b2F8nc>.kg;/801D\+o*^:q`X[bn"1C4+=<!SA'56Q)p"7QPK0(:%hh2$r&#.)KP44_kO,N/DLDnUl+Ag%)D/O:_^n9t@WX.ja1E9239Jr`C9q"_0>ZoZ,_OY9F8+g5Kenlf[qp6I`StML1gncVcS",;ZcTFE^F`5<.mcl4-jBi>^lpE#!XjM#6qNF!?"fTKb/"'t9=/>J8iLG=-7@H#dI#[42jIk`XL]<4TR!+T(Lh-IWc=nt'2V_h.SkoWO93Vg*i?37EStTi:B7^!"^'2SV)/3n-:DZK-,cLUg!8#O_fI?mY+ZYECN5Kd^nQm"9MJ=;.SG&`2+nG.Z0WD>bCgD9Kmn]@IEn&?OfbGA&E8t7>Qk;0[>*=]pIn#io3;$4KNJKF&8*K/.s:W?KbHU13C1JX)*Vi?G^C<GiL2AB#U+!#B!NAL%?"J3p$BNF`QP2m;tMKMHW@u'#$\$A;APcMOmQGQLK`GA;_s#>g'L6FY2CQpiof9F&Do2aqj#6D=qA/<6qYi."+YHO3OXZ&,e2)gS*#VCh^%W2]kInHlW=OCLs>%>Y\'r=BKd`ZU]q<&*\dqi2!kXiU=FI02XJg^ioBnIj>`9_o$r^fj55Ma'Z5qK1;Tbc4'Y\0mn&\1A.l1&=be7A%1`i_[8`.5V#^h%(1O&D%d4=op+'JZf,;TU^c3!D7n.B4YpCB5%TYNP%V`DWXQ1]BG[P?K)/OqmNFs.n=-u6"d+O@[44=YY30O*H7tuSTl56ldQYC<[H46QT^KqFs$P32omC>nk3ag'\b#N'3rCIb&8BP_?hG:6,L@qWBS;j-MXIbGSA#+@g+U+(l.h3FDU2VA4Xl_Ot8kc;O"^WE2;OZO,7)B:a(,.<pXX7a.9i0NA(\GYuGM55"qpDd34#F?ue&Er*'hU9#$rf0O+R<#@aA_u?6f`ZD]18N4dAXFT*+4*$%U+WI8!<]h+4Cf:<VhnCaaFp@)GL$Ah8TSqQ9D./2/N:k+X^f62Lj_mn$X<5*G^4CO)oBp^4`p<7sgMb34-t=%KZf?lY>d5Oq;]XJ+$T1XA/7HEH]nammcl.S@OM%hWX0E6Sbh$INLFk/NUVpBkNMYmCLAPhr>,I;LlNP']@-^NJ+*<b/G4kW?R[_q`,J\N8)"emu_&Y$k%86BoL\umS@aEb(i,W83=a@C:8PR5jfEH^p,K(<t$)I&m^D1b&uE(#@r)1fc4c$lkK5S/)/gImbNNcN\<B3=RkpUTYDb/fFW0Y:GSCWAkB8-?l;eS.WEc%0'urU?7"#Eg5@kWN.#F>?"7sZabNH?#4+l\[g(o.QXn2acLe,4]Rc0W4tYcnlW'"MF4RB#WEs/'B6e2O%<sYmWZVsjqSnW.Bi:A>Or$,%5_-D*5R6OP]6q\lmqF9SHm$'O`7e[d>P=l.?Qpmi<ee'C.sZ;9i,*b"Q(^::2,l8_m^K1?B>aWAIZ$M[8K-KWp9X3G`KnhVp2JXpC;`,5<c$2p%qh#=au/(SCVb*>LRHBT=s5BC[I,hU@.A@m^@%'7f6rF."j[^IGP_ePaZ[=e^]tTX\pald59(;K+,nIEc1hqV&pQ45R60'UI>?3R>X'SV]>)bmK>5l#mF@?]a?Dt13E[m0'H!^\-jNWn4Whq.QE?f3JQ4@VESN>6*d1cAfIlP*-]tWR@a<B!HC!E*P0#A[ZsB#IL;kG&f1A5EXmHKS7B9GF@'t@NSDoWQ."af#@"hh8itTEG.j#_KUaoaQ>&Ba=c8OdJGW*au4LBbtr"2-R)FSt?q@6mlfj%hSL^-M;aKb6E#FX>/%0n$A[3^:%njsjP.Hm3dRg16?iD9+(g8*Meda&6qk0%pek).c;qMpRhoC(lk;QJS2%S2J]@`M8^hf!>U`N7gY(feV'`%Xh8I)rtO5U=lsSm"NJ7Oh*0X!D=]bjo<cS8m;lHM[?G7`MpE-jA<_l_ldV?FR)2IGi-h'4d>+#h$!!GG%oeXeH6unl01+")&9!^]Y=uC&')\B$oUdQdq.DdMArRE4:`;9jfM'/#u5l4MuP'D[e)O;C.n2/hlTn'"e*&9db>J=LD]+r:)#F.E2m=>/amsnk_sQs2a9DS9UEfS>s4fI!^baBc@6O[O/ZOZ9VT:SP'k5f0&KgMl(158+COdffGsAW2F@M5L'152B(41\p\=dJ*&D@I/$oiA;3DV`upRhb_@I._kf1O`pIPc["/]3ElB7"b")_7AEPYLFRZ.ULG<GM*%e-=@q#$U1Idu^LE"LU@Nh:Q2K5r>RQ\aL"Tuf)+XAs,8Ph!Za`n6B421MZ.rQgNRN@5=EHWMn+%F4d;XCo*[tDaIU%ct7mQ5t2gG./8%#:tf0o:'t1aU1ALd\$YcltSlqY,72WR^[JpN]\kM`Fth/$Gh9V4lGh@f'liCU_nsBQ41r1c7jcX6hiHGI5!9mX=1U>6;O>B`P39_6(-PYhC;Le>-`765faa/ld!dWYbAX9^LS5:^_V7,5,oV2%+Um-LPc>%1@MI,5,nYJ.$]g-MH:SjqmI"Gj[aRZ\iSEk?2*YhPSQ45.Rq@]4^0u*o1[Nr&2$<ce?dYHr]buqn$@M]9,]tin[X_=iRqMn9sg>a?YdE[Q?V*<[#IhJYm9!GMcLi23.[3P$H#.`B85WkI..,"Wq!P_ps&mq-nBtr8qtnl?Da-%>Q;5=`9)Q%^-B*BBOnZI\_i79u:k*TP2j;[lrU93l6em(dj&Tm-;XMi:-?sn7S_8;0A/D3#l68#Nr"gVk:I6_r\f1#LSp(29$()p>=bW55`_mlT)(k?q)5nCG%dJ!_D4sH"<ifj(SX+]OM)cnq/[,2?m.@mY*@4_)HSr!;C%Gc9'2,0te8j-c9qJTHfnF#B<^m.\k9:<]TqnrnOgmol@B9Co2Nb)V`O'(,]H2P&r(*I%sLQ$LMHs=20ZqP.YpVd4)J#0)fS"obV-\%ZdcA>i1X5rj'97@=<9?F*`*dgmjt>LI:'O)3_UdldNY*rNdrYZbt$B'7Y;S^c3jaL.Ch"RSYuT=V$iH_p)!C@K![EVnX4BXY4RHlcZaRYD8kpXtR!80X=2Ml_EF.iJ:*'p!/[r1Ii8!qVq;:np:7\\:/L@9G@$_npEF1\K7p2?Cap/Qc%nq5Hg,d\>c7PX'$g0!jk@_=FI-ciSTEC++E;m?i:!sSRYcuYPH9"[qA3LYLbUHcS&Y/cUb4R)5u]d0!ERXSfJ:69;X7FdpF,biJZYJh]L5!1VC7^rVlDl2QFbMLVKJ<r5>-SC+!(Qa#l&U:9@d94WjYi57Lss1S9`Gs10(C41bnGM/bIhSm9^:L=`=N-U@1^FFElZbMhHD7Qd3EO,+51F!C/L0Y_J\Teh%LiZ=8)eHKhp(OUT%_^4jAo'4i1]0pG2KAhkU?!^d-YA^Rm-!$Y>YDB/'+9-J*roS%q#fO&pJ%YcfIWKdW6Ne@s_!BPNq<%F$r:\\+eK7a-*e@"4:`\mlk$Nq<GG%5X0s+,U3W"X3_bMOim6Bu;f`1JRbrkiKGVa>-QApN*q"*a2(IpD0:gXb5pYLB@J`TR>hHdtZSRgeIfKP+H2(D[i2eMo.<dMM=AHd2Sp5HB$ebY4^fYn`-M+aGY2`)1NrN)&O03p\PU<s`cVHBVeH0ap9`>Z<gSW2c=f,*?^ohR&Lin8/oL<oi`\-.S%~>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 + 0000000428 00000 n 78 + 0000000537 00000 n 79 + 0000000620 00000 n 80 + 0000000862 00000 n 81 + 0000000931 00000 n 82 + 0000001235 00000 n 83 + 0000001295 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<ecad1c09407faaafff33e84228a6d237><ecad1c09407faaafff33e84228a6d237>] 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 + 13140 96 + %%EOF
.github/reports/enclosed_space_absorption_example.webp

This is a binary file and will not be displayed.

+90
.github/reports/reverberation_prediction_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 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 /ZapfDingbats /Name /F4 /Subtype /Type1 /Type /Font 26 + >> 27 + endobj 28 + 6 0 obj 29 + << 30 + /Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 9 0 R /Resources << 31 + /ExtGState << 32 + /gRLs0 << 33 + /CA .3 34 + >> 35 + >> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] 36 + >> /Rotate 0 /Trans << 37 + 38 + >> 39 + /Type /Page 40 + >> 41 + endobj 42 + 7 0 obj 43 + << 44 + /PageMode /UseNone /Pages 9 0 R /Type /Catalog 45 + >> 46 + endobj 47 + 8 0 obj 48 + << 49 + /Author (\(anonymous\)) /CreationDate (D:20000101000000+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) 50 + /Subject (\(unspecified\)) /Title (Reverberation-time prediction) /Trapped /False 51 + >> 52 + endobj 53 + 9 0 obj 54 + << 55 + /Count 1 /Kids [ 6 0 R ] /Type /Pages 56 + >> 57 + endobj 58 + 10 0 obj 59 + << 60 + /Filter [ /ASCII85Decode /FlateDecode ] /Length 15861 61 + >> 62 + stream 63 + Gb"/L>B:e+NUH]AML%8pZB)VfpPU'h@!fr+Z/L]&SPf0P#g^Kp7C=E/nfj9^mI@AEp]+i*24MjKaOZGPH,:c*3\(l#6m;XW&nqS<s&o?qn73Abqg/&!n*ON=nG:sK_\e-[Ii,nJrQS]`Z!&pFd5[cROL#sU2O;(PT1A3`9Q_2cM5@^j$op^;SA5)YWjKI)1qZ#G(>N:2>&XuE^\VjHfCes/p%cGcdFmdLo&sUIHotcN4Z^OQc\C'O4hUbSYkSb*mlF-/RUb@QJ&BgO=&G[J7DZtR(#V1A2PK`gl\hP4VlPa!KD0FTL9l*AjbNMJ50%hSCu>.M]K>uUHD"%dO"N&G%M+S$#DZra!+$']B5cDB(W-ce^-?j`FLqIjnEl;"8#Pg*d<bNOhiEeYl,=rkrqlBM-'e9(dH*U<c[HiD:>ZPr3C!;GqW"Kk%gR%)(I.h6]F.$&U"Li%[6UI\'?(C[/G.oK2\Ro(`nh-WN9,M!%ZSJ>7cE5F+0A<lJ$lU1f!s+\lt5Y0Odp1\dhnC11&Ub6#:CUSf>Ac@['LoGXHRI>O4CN(cH]1.GKYas->Mu?]CierK8YY/C1R+1-&,Sr07WcMc6gdI'@#NlpP^H<iVCkknGF<H/VM^=oc:F0efOg"3'1CVIlfVR*b]Sqh[Y,t])46%oL\Z:r9WA/)bc?G2]$+oOLL;A7#UFB9DcEW]87i/4T+m=p_-C#57N*+iJ-kKWi'e"T8VZnn#s8g__;&]%brdIGu*k4cJ62@hE4-V?_:UF*"NjXcJKW^Y(qZ$otg.r:("LX;P:?l6hS1lO6(O+GT8K?:Hi4kpiO4krlf5nqS=JOri=r4AWT-_M`6YMc)UCQ6>V'Db8;gD+$Q58]fit)EVT/*]$%$Hc/5j+EHe>WYWYnAIWP&a$NcXeM[LT>s7t]k?,l3:4(f!S`]"@\kT3)R+'btBTM4Xn>m@AfI;^HSBk?kXfdgp`5+gsV2+"]aWEG'l.Vt=LT)V:[s6j5Sj`gaq%:Sk;Dbd9t&r)Sn<3!bLO/M$sHiCk*h]%%C+1p%!'^Es.;K0"5flDRbadG3@B^<?Rj;i=43i/#tbMgb`s*4]r'g"lO+l%eSW+K<\q<^cpXnQs?3CcG(PN:QGWC$O7ar-/k<6jW4DQM!o2,ls9f2RpVmDG*is(\>&<(K+IN65kImS^_/)h#A?oj%"?n*\C3Bs?0I-H]G4X>X(Rd5M7p$WI^6^-<#_1r3"b<;G;-e[seds8%d?m]2<h\%!QBn1)h=LGYXDm#*<pIBd@0qjY?<+](bfbFq%orA%uRLV8,@TmHU*^M\rlU-M8#k\jp/]ru@hda-OR5E[^i\j&.,)A:V\\J?as40E'`^XQXbfnCH/a2d*[7NNL8l1h4Y3MXpukA-C:34][f"6a6/p699=]%,M2W1u.ZoujNmALHbF6LH=GF=NQ8R5Dh"qU&mgh,)qK?3Sc+j%Km,MNU//6EKdQ@kQB"[3cJ5a1P:#m3kUB-Ps[+auZO]g'r/EP'<.E!C1HT05>['d&<$L\5Chr*CW%)H;RlFr@M!9Q*i]Xm8TI>9t*r-N^L=E$M1"$;'?]P,tOfDN=*?IRGp='VGgo(2V2030JC%RHlEf4Aa:pIR%q9/+Ab_*C2"SB^!*THd#r!74.>U`kU#8+CbV.0eYUjq?8CsdJCaN&boM2^]C*=f-W3f[m<gQ[W:$,5Xo#H1@2BE5!;!M*8V:1!Zif2TKKGm.A4S10_X1<97;qBl,JpPGY)1eE0GX8iM1V_j0$2Wl0Pc0`0R#8hOr=b'\XlK@S@jB#d#AC[K'-_?`S@ia5FIM_Au"Rkd']-E2+S^d!F3RP"aKI*="TUs@gPpu>rL3o5Q<+EH[A0`oiF#tledj_&2K52ifE9lXSEU#,fr_`_$3e]H_H<b6ekFmTD%!\?6b^rl-ln$J\Hm=rUA(D/W)\0&s7!^i&jRaPK/gpS!CpcAU8@O??d+LRB4QkfiQY7#G\j"igQQ)]r'JHD;;S(hsM[348e!.&#\%<0rA]%9uGMrQqN-pEgmd@jnnNGE++Lq(PH,7r$4XJ7`t_1j7c8Xs812XX7#;3pZZ(gk;\m1n8GZ7qo^h0\:6Ge1rK;rr'urF[WYc@S"N;5GB1)(=bn]5jrM,Q%F%N^AX:X">P`e#UZY6!MRV(M>GrHj`rZHD!GCEt?W'cU^aMGS95#Z$9t#Z,J>El#MOfBeZY6K>Q(Or<0dg5sUNGeKqeM`Qg0l*$KV0+a6`U*gr00oaa4l\30+NPtN_G0F)(>ss=L$9(iK/kY*8,4\e,sGtE6]d_j3X[pSg5Wpa;Z[<;N6U&`-dSK/k$%o)c//6Btt\SX&AgI_\)<A;TNLRO5Yc$AC8jsTrCT,jD3_#W#UZ>@&*rCPc-,m)F%H)LC=d_WP9akRD(*';.oAdL>(T_q&R-W-=j8+dqJVNL+;H>e2Hj;+bRo`)<r=1fNPBg0dMN3Od;N!#]XSq]["9&C6l)[gh]7#_IS2D&*(#:4]>^oPb\%@,g4-/^<h6N![TE'';#S%Oq2[)$3fk$^s6L&&pkdQM!$8c9\FJM:'W)72@q/%>c:XlUXJm9#'=hg>+Qs3R=@f)#[k;CSYjhCF$P5GoeHT%Soib"0.g<?WmY-toPhkCY`;EE]hFJTF.T9b9b3`i'GoDJX:a#hJOs=U8FT2s7O."u(E;U\/1d1)Rq.65,.K]`'eg>VGa:jj+m.X]2:+-R'Ui#Z=D]C:kp&[5UMV>8%AnLa/QE.<jg+du.WYmR>,cABo8_B2iG6:dJ:lSR@m2*&TY]cB/*\N+l52M!Zqp7Q^>Zr0*5kKG!eM8,'i:tm,L!n(cZ2D+)P'Gpb6O2depoOWjT+SCU9c0YlP/@KR,G1M_"3]@+C^#&B1:NeO%lKd!k1ia[?(o[c:>tM-k2@G,#apiKggfV%S7^UcP;Vpen,d_GnT*C>b(fA^2t]gYuaTIJjg=kU]mGcGdnU(+NP*b(kcVa(Jcg&75';5U;oj;,^=GmP**UH8ZaAVa@Ll+*R.e<hV!-pJba+FloD(O1dfA=oG`,.o^8E1f66Q-=nAqbUq/'`;Xmp)O[Tk-p56rjo^kQa=VR!bX,)-0<M!ec#sB*0=JD]CDB[:l@e606Db*.V%)HPTdjh8i;)nWrY1Y4V(%P1/$eZ`iO+'McW`!1Q/B?-DS?GlGTJe%6lp]'LS_b_=AAo>p#Y?VN;L,G!A$`Bub>4"W^d+)VGP&Eam9Hie]0/"\E_M-10rIg8a[Wq)B<o6\hh))fA%Bm#XcG_">F"`8(0tj\876m]=99g%F".pgX4jS:<L`<`[a,=u)tTM\C3/4brgjmT2gnRp*L!L410f0FDJBgqM@@:\,msAVTFU?,=3gd3D$h:EKatk,mCJT<QBF26li-=gg@\M6cW[q5UgR1%-+3&-I9>!SY<dThT)ua*%u^;R7Fd/^-XN2:`A.$eV%^c,Q(m0_M5PP.eVUbNJ'L*[Mki)dYX<A@/5SQt^&H(?D^8"IUVn^WH!LS;(>+MMn">\eG_uh!"K<]Obu!Gr]I*a'4X!ZPJCFSl:LUnpg@*n`=1n814!NERlZdaM:e@lsqfA&+A<W%iV(X;)QHc==o4cm*=r*-r[r>;&&e:B=7t:9kKk%1WB>&6u1PTZ)QWuKij9?"/P6$Q97:jO1H4G!.n;)/5G>BarbjL;kXC%'_jYpTuo8:`8@+2.E5l5]tOnERG9l2PpkLUqFXS$)&fP[)Dbk-)ihO3&sr?3B'>q81g/Y?j8O0<2$,E`mt\hUAdh1NajX7g+0^X0LQ4Ot3)kA,SAi,RSQI2UWij:/K;6*T;Ue2Ip3"CA'Q)::)oU5gc\-nY$FmI))YW`ukaj8+JL27V&D\iEjh]*FD_<pe)?I\Cb@DQGh74!3)Rm_Rqp<VnPS?cYg%_J&8tg,cooY`iUup89mQUVJB9,$L!e&`H#HiK\SW9RkOl1Ro4X[tZh?J/Q6VW%;i'0#u*U$?HB*VmDYV_6E.N:A]C5.jLLFG`SBuO72a5*\Ma3B"44k`iU*fGm_KuhRKY)?Difo\>p3sB';>>H;GVKUC`1#&@Mt&A(APZNHn\UW$MnM6X<*Za?a8Y;<(iI4WhrQU]->f!q4EAit#j1(&TtF?&a[i>nf)a1jgOTTT9iuB@22NFF1pACZCt3[o8466XV!Z2]+m=F4Q^Ar"9u'.Ct@eN>3Nk]?R+if55jZ_@7TNf<#U>qDc?.)M;<8J:nO\Ph\G^^rK'#.@!=2_oVtD$<NLE=gs3gZZ+sVXMK-Y?[I_&qF(Gq,rCX:6hXE(.%[Wp.De9U`9ECLS_b_7)!oMW67`nX"M7&>%2tMJ]-nU/KU"C\!td,+4<9&4@to8>&])d5'A9*,Q%c2\UATn`EN',.,j\l<gI!BWNAUb`l'@K#TMS2t:lclTKN,oe,MFk6h#m5@SCNuGI`T18"H[/EY[TFl,@<3oYf6/aOZ4%d9cr^9OuhVk_]1"n(gHJYs&,u::7kI^0/Rl1C*t9ZNbcTNAJ]10:YYXh*XIn5BYq)Sf+qtA\1"U0e`8tb^gs]$#EY[2jFp)TAPkHa3SZ@sF9o'_R!q>gVFh#eF3l/p:#SW=7,d>^;j.GUK.!5AIA[E4@j6,uUV$+3#/[,j4-gt:l<l!hB):[Y5d.h]?.>PlVjHg*'$it3Lq^AbUPmbXNf=EQjWXe^=<oAK,Y4`?]f7F7oapYY'Whu(Y3QA7'/2MtNBGt4>MGJs2R9&\Vjhn4;-/Zk>ItVCR9(XcDh/8Xr"_H\V7EX1(DELO,+HT07&LtoTE2euFjgDVMaik4(("0DCj8:TL,($J;@:<6ic81$CWr,55F3ERecOb(]qJ&><f)p5V$.hY(Q-4r_)F#G[q;2>+no\Dms7RjTe$YOKr1uj$"=5H%5R6$o>G1(g6=HX-4Wri*ME]8fK4lq/;o+Go=jUQ:XmbmVa[Ah+`?$D@9OKc?<PVOr*3pq+hG1)g,P6*QlQ!j2N_$^iZ!P!!:25FXW&srGeV'b,J>\$3.U@<]^B`jHerAdR/=ZsSCtp&*cT6l9`m+q_]QHm#aN>IJRGOcI*#-m;&2T.G;W2`Ee>X5S`*?%fffpSg!;Bb!i!_ujDn0GS?A9U"fb=\Eas#,L;Z468DL@qmHe[IL``l5NO;?I&@U.d$^to9@B7U`.:@ck0RZTr6BP#e9KV+1@NIY"_)p>$-;O2V;*Ym#A5PV[Vh!]QOP/)`1`mEPehXK1DsSi-i(OS#%SfB99%F_)AR>;NPs,RGL.30;X?N\?Rn0?`@o'Ps]T<4malK<X7+c\0!]XGACqFlWVUV#^k?YF_W[sd7A`$pi[Rm6P!_R_JZ9kWkORW4s_TX]b*C:3WiGBbLIBLh\X-F1u-r71N+S&,aPq#U1j0+.0Z)61f7UfFm;_W)7o5d-DJ8dX>ohZVfZEUN`#h>Z[0U&ddMc%p]k7R>UFME!\/ZYuDVXX^7P_B:r-<-H4@RL$cl5;)[:JKFC'2D6Knldbi<mI?-K.9t3HE6C`ijIjE4%%dl(5&`S8Fq9sTOT<(]q`daVO]<F[AhLmU*oJd8ds,JRL618K([Dg9H=#NT_NhHn3n&0^,BUDdut$dO!Ae=lYe-Y<tH-BV)_2FG-W:P8SPS-.*$bsP6i!G8/CSH++0Z<p3pUm;b?X?X(\\2[&^fmUB=6`HLM*E6_j9_(Z&QV2BuIpMtPG"53ZK:p6t2*Na/u.W1Mf^\p/H_]@(tGkKJa-jN,PnppVBC=.[YE>%96+Tdbe#0jKk/Y+^a:m`L^T.?U+@M<RDm6dT:=rOJf&8RlihXd\2]+fF8+;NTH?-SUOcE9F6'f+f5#0sg5CAV_6uc.oS^iDh:#a,)($<fU=7j=RA"/-BZ(P@`08Gr<jZ.+)9Q>t'`hfe>N#B%Onr']/=IOtcVa*VNnVi:qkX<UjChL/frJ39H1`[gB6'+t^,%(I"CI.VIZsiD[(cOiZ4BMf%TR(=071H^j2cUVYjqSASM7EQT6)Ba$O_-G#]BQof[K+#CDZ)*ZZW#;*n&f:@`+d(#3\$A:=$K(![PWj]AmOEn6:<]F0^ObgXeE=b$056IbUlar!p?`*83Q-OsNNIq6^lQr(@Sq5%^*_X?Y<k_%c3N09WfDS8!IkP9?ln58b5BElA]B[`".U+&OGiHh,"r+PMDN-'9D3pB2:RVkJA]*bl=Kg%']c=[j%c0D0K=>23`]MSjPH3hG'_#3i?;8W8=+7_%^D^7O=O+N3e*\MV4%n/.fon4:@I&WLQS]2!=.$fr[RAltlRs<\0__$-Q,8L,\6AeH3G>mX\4+=f$M0TbR$Y_s3jY-j#gYFa_ELug-*cTq=S(W$f)//5Hl?&6BZd.6[hfS,o34r89@$-'X!qc1o+sQ>dIU_>T"h3YM]22-eX4;`^FWYDE9[3[[<%!:(9F&9^&I$-g3Kmd=_[%/o%N.%e"WBd,49$!\4&JldHk>oc9$ETE/WXSQTV6cDNMBSPa]>3"_ZahV'7L'`aWPm>VkCje]2@187kOm&cK\dos%QHo?tZa(hqZ/DQrmSQg.",?8U^h_K95PKrl0Kf)9_?SF[63.F?H-0-72fopVJ;/nIWE7g=o@Qhh&3W?q=Y#sMUG%r[oRM`YcqV_.>DQ_aaEC<2WRHaCJ3\-gl'H<r'D(0Ae@]Vp?fSI2m.E6AU_E`]ciGIRA8(>mpU;;=]%>#4HS8$B[PYi=5LH<,nBX``1KAK18WeD$:FV*tDj'[S52JF4-8.a.W03-T)1%3@[1Ke7-HjKr(`ppt7W5hUaqR&&DL->&/EmM;-##_/#;U8cLrms1rAg0`C"0OdsnOW'Bc"F<jYaZs(A9Vd't>:a_,[;W$]>6=+i1p;2E/K<)=R?e]>WhWK5:do,3UcR\P"bmL5ZRE^MB,Pi(b$g/AmD?_bG&?5nfWqeE,e'"[]"XjUX=6m&A:<;`,[;IriCfE@?<+kOC]og2UoIj9HLmU&.NCpB`bgr35Nj?/(F$qk?@86#jAHG-QT'6=?trACmZYqR[S[YK^i&4^QE'c-M^ka>=lA7R(H4OLUaH5aW`q:H94-Bn>a^fRHQJm3.]J#g$6L*>ej=?qEZ6<ZGHU/:@MVHkmu?4kCE8^V*FA6C%e)_&'].RIlD_Hm[ShImRo=Yq>mNE$3!3*Fn/]1c\RGrKl&.N'`5*W-mOo&@Kc-T]mSA790'[fg(Nj3@'h@EFro>JZ@eA)7LAS*!2#MK>XWO?a]@N%Ec.SA26#7%7(T6J^Ia(]NC]/nkg9lCH"*0rjn:7qkUY%735*-.D"R\2miiD<57+A+n,,1dq7`:;i$V.q^KoKA-)=Pq9kse)5;<m,cpBH:AXmq6YXQKuOdHI4H))!epq<>IRP,u3"`b.r&`p$Rt5pGMe6@!%MD#D4j1]u:7hl[-Y](&tTS64!WPG$6&D]*)ah<Mk]gdbJ*ge`eVjQ=<cJ%sO<\'Ks?&<k(eZ/p\9F_&'GOo$S6S%t*`As/!/6F<\co9p!BXsq/)F-u4)o4BOWFBOB%P`A2s=HnjC.ij1cA\Y:jU3/R'XWp7#:[QVWEu$rU>\oI\G?<=u+8M4']cRV@W,`<G9nkUag[\n6p=;mn7%rND_sVr'KpH[nPiN-tp`s:&-sQI'=s?+>'=o?RXsC6hfsaCfH4LrsAMqJXk'<7smS^ZL(cr/YV6Nahb#;b.UqiD%+e&7fI95mrLW;Q/-Na&0Sdrh:)@`/C+U_Xd[^1mNiOP53N$0!MCN5J0?UZ$VR$sPR<L335rJeP?!,!Y9:)Jjpp_MSs@\-[4HOR2J@-bd&!&?ekKq%_!UnAo%RTJ@DTV7!]M!0WPPr]lm_,pClM02'b)*VrBG^C<g/I4@J"@)*M]VIJQ)AWX]p?]WG`QP2m;rf@=HW@r&#$Zn!&f,is*p^EL&%/jbVIj)&f<u/=2$G?^Y4T]A!W.n&ZP:O'Bhmt`QAN+&Z9SHKgS5a(_Z=?&Cp[oj8F^S)TBf3\n6F";kX0/=hh4Ej?-sY<JOp:jQoq)S2K%4`FA8h)k\SnHP/NiU+iku]i9!M'rboY#qR4L+LInFf\>4Rp,B7<FTdu=L87H7WBF(bH_M<OtCe%^d1$-T8\M8Wg/dq)*_^rI_Lu)j^*DZ:#!ZuZ;@[3BSLC9BPXQ7A8G[P=u)/Pk2ok3PR(RRH7d.*&s4"lbTE?t3qNroSIWajE0>c+n\lSa]JrH^KF/NgD`a\]3Wl%!?cQ]')'S$1$,,71Q%H9\ZL)j[fgnNH3>@.fFiZQ<K3;_Hb$q2.OsM$P*3E`?tZgq%N*f*csq@\bj#-'!Wo#Us08Q(r[(-:rg39"glFb.c@$=kSkID&$n$9<$jDGSZ^DWAb$U)bU`o@C/D,)lX6-B\B[;UgI!&Zn/5Q5-oFZ)T4h&PjeC7Ni7c^&,IQ\I_3s7ZD5s/#u3@)^*M44r_G3C(f8]W'1]#ZHT6/L"`F(HnlOMPh06$*o%%er%-es3i3f`-DNfa@gWelp'u\fOT@:m,Wb#tA[<V@&<5fGVFS**fkuY('pR7Qe);j\c1PlWF?)4jrE<[t\4$T@\STRI^Y*pV90#k+k&\0IJO1B4BC-<rZ`rUMo$[4=>N3GYC-asYJbFU+%G3'l^\p`QTQY%9TNbr3/0lZbXF=jLOf?&US\@YgTF/[p$;rBU;XDdDnhAudf%,]2I.u0>Q01d?G7,5tLb[uTt;lg=.##q`4*7WaigSN(n,!FqkaiJ#Z9CqTjUHM.39G01t,$!;7E!s-[K3R>/Y56'@7+!]sjqMM'E0G5W\]U#V<JGdb@h*XSXU6hgHj``tZ`Bla1t)j6IRSDcLrQP%g.@o?BV(cHp;@3QX$Wnr@BX<e"5bh?,^_1#Mm)3p:4]n90ij%sR+@r0&A$br2P1H5n[@)Q*:VA.m^2/(Lmt0Eb\]>lK@.+m*B)?aL9iksm$dCl[BOk=_i3c&XUpc]>5&meYkYdG?hXs%F^LnP_=L%Zh*-HlEkf,TL82e,#<.?YPN86#VR&lN^78L2De:;:#CF/YkUfM11Qm^Q?Z33p2GF8sa\C5l=KWq4Nm6O+K/q(j^iJBoWgnW!C*lne*GLQ#1B\M,WBC%l(a`IO"0"93?#?1YfTnem$0Z@VP!/0?[&O;V.d/,j<d)\IqGd4;K755`\C.I]W/bperAk7`XV.DOJ_<r&Z@SjSn3^_(b$\$H=Q*<RfEF!`6X"%;Uj*2Z[;CpagOrA^fdqLAO:i%erKoBG+?iJI#LJrVY-*&gK;@_h;elY.cY^j["-@"#f;GOh8LYKM6.ZN#*gBbl/>7g`AW&q_e9opZc<TmckL_&g=-Z3"[N`-%c#BUlR]P03PDe-$Xh'nM9SlVX]LNC:S6?;gf,Clh]$403.*+rpicLIHr#VAoK*[Ck[^LLFju.'A`6t@oaDBF6naSLp?*U<'?@NUR;0F*R`X^+)ZiY?V5mQ2g;jDJ7TLH?j'22LuV64m-=c;Z"Cg[Q]WC:d8,E.Uj/2Sk\eX[6e0Q53?&AC69F124Wp*HuuT9I1.-dL]MgilL(=krWl]2]7TI32)GrZ@P5+M@H]jA[-=`4*3(O_-#G%u.d^Z9VT:Sb(iS?1YRAV]De42*$RRN(p5"'_O:cr6t#iRG`aQH$&qJ5TEDRA1V+'NelkjK.9GU[$MZ3Or$RLTZZM*pZEipprSOIdZR'$ds*:]0iX<+hu1_WFq!-''i.C_*<T9+*,p3Kl+sVZo"bVf/8HY#M_[bHk?Os],EPM..8TOeo$It'oV(P:`Cse(bp8h5ah4$1gA[Ju0S9J4&f8h'%K>EVgZngAF-fs"arG[ISZh@pMC&oL`rV!B4<EOTQ*AhX[jTJP&3LE/[^b6eTXE%#:BIZc\aVH&g7"$d+^.ZHP)_HLnjT)q'+_-dHbNdWd_ZqC2Flp#[+$gBDK;1;ac'48Wm8Up!0/e"\fjkCg_fK=jJhl`L"c)#j_=2`N[*CB#YQ^l'@!`HMuc_F#eqB1^YLG>gFt`1e@)mGae6)ePYVLkmCDb/:tqG55;?nK?-Yp@.?5/[r1Ukc=I[cFjD5l)mKjDGJlhqk;p:!$,1kk0R&+^oFW4f@S'`C,@4l/E*N_W<-R`S2pNQ,Y%_@8,DBScJr$BP^4`jaGL*qZ.(-=W!gpYo,l/9!54e<4&>LW5B`pKbKg14tSA[qU2G$,d+(A0,P9clY>A',ar(-s3(D7Tuc0stTggR6161qHpM>m-JiksKiUZF4t,#D;E:.i@UWP$cOC@<B=/`:d^CZ2[+jI"+>O_<,o2JrJ0"_L7?9Se:PJSH"P0hk$Rg$2H/Uo&Pp_l77U3]X]a$faJe*Tm=b_+*WWER3k5FiJJGiO!^5goc,uq1^V(\bTkRCEP5Gdap.+o2n[96.>XSTMUB&NbPJiBY&+((,l+kF+JP$X\OPp=q8'pK-__.RKd]C\#W3g);u[n65IIeGp-$IZ+NK\[l%N2;bu2r(Wj2N0lD<7u/(D)GG1KV='W:=O<nGj5g2;?#2erK/X/Q3%Xj4&W$*`,-dT!&UMB:X:85`2hLA)u'n!6dRLA(i\n+QU`%.=QBhJH8$);^]:]N;MTATXRCkhaS\\XJi<mFLHiAq"c[Ndl\HE;q8g=9mUS_dbTJ?2q'r-i7Ur[s`A%p9&[\pGaIhh3=CDb%QmPQ*<g_DSA2%*)F*8:6E0iU[<r1?:`"J$9um2,,gm6a?A/Ygjnpi#&iOD9Sd_<'!O#/8`ss<N"STudrSMV"9XFlis]X`_e)&?EH(<$+HDiYq/-fd<Zm(8\!u@GD#6LFX2du;`uP:Pb$*$k99MbS1Q`"Hf+f]ej&p[W7DDsM>0FIf1t3SG><QbSL^Zt5Zu$[R+QH,,-]kM8e5O>67O9?Pf[)sSEP&]haKWNO6#eEGZ^'!4nY&#iE:TOQ,ABED4?60+bAcu'OL&Nj%CN%+E/5Bs_p:2R]Oo[g>o-5&:1R<cS^aTLH68<I[,P=2Q3=_Q$CH/3k`FO:$m\a5I+[pq10"%i&7V9<eSna0K@>F!NMd53l(&:"&APRp?cel_=%k;g6"4kkFE6b:7-<<kE=;m&6h3N(MI[Vb:(d&=k(]LAh"\Z6_kCYemUt3O4&9'+QEJ:0eu1=:m2U"WpGa';+H])'PT7gh"Fe09DNs9YU6)AGmC3KYJRm)>GCamYRR*A1d4jrK6TiH?KabBl[kZuf;H"72QO#l],n+(k5sqR:O%98aBHp9=.ln,SQ=!fj\U[@*/orXZ5'Z0G;OF'K)AJXW3o+94-eELHml[iAa%ui0SppTi"\X03,sBP:*p4nAO(2U",sH,Dp^4s&C3)tSq(bib>1*NHFFhaOaD$/DYP30TcB<nCq.*o"g'HI9[3%S7Nn5r)lX/<7qRa>UmXMC8qnG[c1[kdrrEoNZbl9Q6]8-EeJ+PS8eT^f(M"Ab`=?ks5m)jEqh3<"Hi-*H$/:diZH^+>&4C8`2!B\nOI@A'"V]_KO1N:CJ:ciN8c5Nntdi:/OL#`]aC-WGJPs\2QPr8*TMQFR993-Uramr;Y;UT7FX@m<E4,;^$QbdbULh)rgn?KbTU<be6UEE9*-a:!nc`N)l\fXM=<G'L+B"3DaX8N0`^C$eh+#r#f'%[n5"(Xso$9_\^EqP`cI.JpESM7[Z\04961b).)l"?)Zg5=3-]MuLok0E2ajZHZ3L?E&*OQ)PHE7g)E_m(5OX6"+=%Jp9.Xqf$Pk3=-ZNmk_HmkEs[!+^<FhePtgFr;I(:idtgmtBSU$E@H8#Am?'Z\AX7[_uB:%10c&0s_)1TPo%_)KGb1WA<!dd7ULb1Q'aD$R@)\P&)BQ=&p5C=8F<";5oI&cl3Qh2$>8;Z+c4/JnO*Oi(EA@FqO!`hY2,ZmoQ@Z->5]s/O'JaNi^g-pXd-8>>YLtI8C@9&^DqHnd:dV7.&Qa1iSB53caK6b-p]RN9iE&DII&ml!]Tb,u^s`kG]=m3@(FJ*8df%<V`Sn.eq[J?BqHN6O[ruU,`,2g.J]oZ0*2uAt:@LFB(Da2Q,@9$TbK0,Fet3#(t$T]3=h#"k3#VV=(IGEkeu!Q+GJ.-"A]g44?`,,jpaq6o!ap/Xt6VjXjR%GA*C79?XliVP.LTZYpg,$%@<G1=Q)_FdYaBhJfKghA/['UNVkGU02)?R/fsrb+@]kC8N)VYfK-[=Tpkn38\*kOXCN3kBK_/dBTZW@?R(MRm5@?]D(PgY\gi$)47-1TShH_G=XK!`jkj4G&(p+U*k8]D:9Il7\U6s^L0ULbT,Z9Y'*+6lkK"*ZQ9Kfb;0BMAIa?<K%":u!KL8A(.Yie2]tPVK"Tj>,oF@_;-)*NH1>@W`[%D$I9db@'<5.2G;GCmXR9lUqXKZcVUPucPa_@1<aO"'B4E86"S;%oRqXSj(,KAR',EC_%VETUn4;3$s(])Li__@qn8P5$Z55ZA\LM=(.d8BhPn\a9>kI3_?%5=%ke2[anZ=Bt,7Q..<cI+O_[**SEf'k3$.KSLY.'$Z$[f:N93KQ:/E]osF")QVk>H7b;QTccn1?qa9@2[V$X0'SNR<?cV[\O&r-_Q92dprBj(]I3V`e"I,XL5?@>\VZ>1TGb[N=6bCuaB/CjXgS/;V#1>Kh]_He?>dK'9Zo\f]X%-h$,V\;m10baoZTLY4Jr43X9p$0)lm"q30P'P?7]Hc9la4lZA\_ZkST/U:MF\/1&en`9?L#Vj&WE%h&UB19@/=Z=XY=,ZDANqk$8+L@!0@\,hV8.b[Ua@@BnAdtm$*/bR1#,obY1Bo0F^D@?'3A'b@:.mQ6q!Z=;]PR*i>C94lMpF]c_'Qjdi;;b7nD4%BmsXf'o4AY<GnroqaVQkl*CFc,A%hVo,N#ZfIV:CV17q@JbY=:\(+h]gr?Xi$=XBmZ9lArW5bHg</R)Ta:M!qNE+1'"njj@],4iKSp?kilouFZU,aSp]SR<BFf*n]K7TB+giGQ+R#9GNgE5,SQdZVP.G!@.!9@g[#CYb/]DYPZden)0HFZEpun\oN0kqG\!>;orYikE;WCKM(L#iXSDa#bkj823aCMT.p2VKoZ'jo>mCXr;tES.UjK^e424OZat!1[D[j24:A0^XX/Z<tpP)Do$CRIth<[#"qLZ0bY+H&aiVLm8VPX;<*IK$OFQi`/o4g*K1&fP:Z+'bXgklSXKgC;QEIRHf0u+`sdQp#ncDspnXJ+dS>"k/6d)LaUI%j3?Y#)ZeZ!N4E=i",L5,oNk79l3SU8f^V.R&)E(q+p2FaJVDXX*!2pN$/"L47N>PO%NO!j1?*0tm1D)Q+ekZF/Z/ooRYC,J.OeSbZ@-(F&/[Wq]A&n?I3P$s(bWbH/(I/>ll-?4T7JN:><^StRPqpi4;BdF.1)`RX2&i.K)^::F''-_35heC4#5X_lUN'SPCN)8@A0kAgShT,64,bCOg(B'(B=@oEUIC\)iiKBeZ.o8r)8c?bCpa<+/bWZ\Pnh=]2Ni,ua2<ca.>dF+;&;Sqj-itQGS;Dm7C&VqrT#g4Kk>XE_F+XI@67if[Gm.DK*OsfXZDBL*3E8Z^eqb1^-@;,r[9tFg`3G9L-WdQr2^oMhI+qV"Zma>K#!LBWCdTo7TGFG@_l5V.UmQC$7IuTV!#f5M4Lom`ZHHAK'RV?*ldjXF^_F\"Zd*)i(\K.^F&1jWkiBre^-4S/<"D*CnUThrVSdE/N^V5HGD/%g*.Kc]YmcfB=ca?g<XiZY0eC`^$0jGbbH(C%rGCO0jA=e5DjJdgDR9]fkaUEXu[^RhV8tL:P@BP[cIl]$*cSZm5$j2&Zs_jN7DCf038$\ec-=a4oBa*8shB)(Y8Ka.Kedkn-i?'1/3;=2.Y6jOCOdt?IR/C4qBW$,3SMgCRFn0+*JEj&t<[:;lYElHDR1/7Y_a(C"#Gs+m*n3YpWZ2>g+A&Hc_kV4nC;8/-q"%_eWlYhXVZsAe.fUH#ul(bkLQ&Q@XK[S\/r%qh+#p@(TBY+1+H5L=DhEdCt8(,)Q#AEO/(1$#=/'g]o>/W6Z[b6>@J#/^Za:Ygu0"Sd$A3dl`fj^s+YYZqYQs]:YaG3SdP_C>0a4XjQC)!lek7B*9oTS.X8Gl&Gge[HkK0^0<+8WiI;jl*gIYQ8lH=R>jk8nIfR6iZa@p;hn>sZD[:.%[f_tB=3S)=/+Jl%Ld,O18<WTiG(H[M0#3*PH6LkkaP.o@u=Em5nRh5i[*)d2]7X3CrV2JVj;-(BF9bWLM#-R/QUQ-crTobo%[\.kesFHBKS0e%4AATZCtC5D!fqGHW&+Q.2.cJ3;:3OcELZ4XDL.<WCC<t%OjkP;at2.Gi$fJ3$9)Zg\ldgmF9D%:K7g?J17EJ+VRh3<S28g)VBKb-<1+)2:=dO&qtD4Y'l^Bf8A'-[lD`<Ygne8F'E-YFYTrJ.H_OnG!`jJN!ail?18\2@ngg"apC"kW:Y`nPr^IL`PUN.Wc(N[j'14@1Lr(PYLlL@(0bHa^I'hP'E`mJhN1uHqiFcG#MdL$'mU&26;q9D$1$ek,k[^2M\f["bF[7c2B['AZUYnQ_UQLYBd'RC1joW0,/SQ[1A@HuXF3[AEEfYKi5@NFO&E;WUq#YT2LZMpV'W!9^PoW_\M?5Z=;:!;;[cB;=dGNU/c#tl=V70i[8q9e\>JW$[3!lo.#*/7d-T9Kr\PM(e*K<S$7ad-mk<9@^nYm53F[Rs#&j[$Rn^Lr$[ACi.mVO`$MTOFn"Uk<.O:9&TFdiM!+VH?6q1eSiI!f$qtVMTIf"HdjmU"c_s;M7S)O#^L2UGFg8&FhV&:aCM*b*W?Cu37$1UP-?N6!5M/TpWO1KWkOd@2b1%7m<kO41-Ilq)Q4Z^5\^E$Y\Cj>p9nr^m]X#.iTou6FcjQQ"''A$mlrU@_*hjbd9>5e!?iJ3&pl\f+8Vc_>e@"s10`t(BRre\C9rGr\Q)h%,GlW7[D1b[RdeAWq5=6%;Ppu?HjB8Q$gh%fJ\V1KNPL.9/@eb9;eN_\3@)Q9'sc(LV/hu7kLFS['I8Oo/]s7`;KA,>0Tlu_q!qXTfa9Q=CupICSM3'[9P5Eq-L2Yt`O?JdJ?QZ`diL3+ksS4kJs+g\oG+3aYrfm%A2EjHD5ea-gd:D7R(i!jFJ-+7a1Y!VL2%/%g$LK6s[H?Ir0BB+D]^L$ks&KG+!SA/E<dern+qY\V7r8@/0lu]a"3`uusLO[/hQlGguGDu0rq:+3Qi0EQfUsm+=/&m<jo$?W[U\C[eMp$bIlf"U4*$FtCr9Rt(r3.6fcNnd15!)nST)UbiSA$!r5+TGM3*Zt!D_B>\5B?!I5B>Gpj+E8JUVh`EJa0j0]!qmU3]K8!"Rok[3IAA%^A.A?F?(=H%&[a)_ng;t5+i87-gT7)Z$$)sCT?aWd9kN%`UN$fi:)5PG`:!-!KW<@B73H%S.0+dLS)kA4`'UAg,`R_St/[sDb2@i4$0?`:MFQk3^'3mPBfs\'%Z59Da*[nosUXfp71W_?jtn^rLKs^RLd`*5$jhOO*;GL'L-*[m*9#QH,,q;k/Hki28;71_p=*&rK7<9n@s`tnu!RYp?P#_"82ZR0oai)n/I>9l?iBcLKAY$%0soq590UQRtGbr^+(pPTMI2=oR@le.=Xa;nDjAX0uC+!PpICAg)P\H=r'@aqKoICg\U.4#DhPqo:sJ>`)oNbbZN#!oL)AmH81r'j)6P7S?M2#^E4%pBnnD-(O%$Lbh'%?]g.C_aU&^)DHeOhiS_ruZ/OC3O2'uN5RY7DrqPI=<%#rdqVD!qI7dFMrV(r.O2pQJ+R]3kj7V7)j4X5SEVObf?Jkashu*?ns7#F@]5R`Am<?=Y"")<B4sp;W5YKE]%\tMNQ,tOhn'Jf%3C6m':HOofs7:6T"8&YQj"q_X2u78+^*h$HE(Rq0=2TODpqOFKBl@T?>KonuE[P\l`9iHp(Qg@_9@<+nbmP*kd>Lf@Q]R-G1do77WJF?ADX.\Sc&7YK6^hSaGF2qW(V<DY/S^bVra$=WThDgsDuO<rM@lWW8Pi,kFqokmS,X"b@pi@~>endstream 64 + endobj 65 + xref 66 + 0 11 67 + 0000000000 65535 f 68 + 0000000061 00000 n 69 + 0000000122 00000 n 70 + 0000000229 00000 n 71 + 0000000341 00000 n 72 + 0000000450 00000 n 73 + 0000000533 00000 n 74 + 0000000774 00000 n 75 + 0000000842 00000 n 76 + 0000001138 00000 n 77 + 0000001197 00000 n 78 + trailer 79 + << 80 + /ID 81 + [<f9981440603122b0191f3283ffb2fcf6><f9981440603122b0191f3283ffb2fcf6>] 82 + % ReportLab generated PDF document -- digest (opensource) 83 + 84 + /Info 8 0 R 85 + /Root 7 0 R 86 + /Size 11 87 + >> 88 + startxref 89 + 17151 90 + %%EOF
.github/reports/reverberation_prediction_example.webp

This is a binary file and will not be displayed.

+176
tests/room/test_enclosed_space_absorption_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for the enclosed-space absorption/reverberation report (``.report()``). 3 + 4 + The report is a rendering feature, so these tests assert only structural facts: 5 + a valid single-page PDF is written for an EN 12354-6 enclosed-space result, the 6 + per-band absorption area A and reverberation time T appear in the rendered 7 + text, the fiche is a characterisation (no PASS/FAIL verdict, only a target 8 + reference line when a target is supplied), unknown engines/languages are 9 + rejected, XML specials in metadata do not break reportlab, and the Spanish 10 + fiche is translated with comma decimals. Pixel or layout content is never 11 + inspected. 12 + 13 + The displayed A and T are produced by the tested EN 12354-6 Formula 1 / 14 + Formula 5 primitives, so the oracle is re-derived here through the public API 15 + rather than hardcoded. 16 + """ 17 + 18 + from __future__ import annotations 19 + 20 + import pytest 21 + 22 + pytest.importorskip("reportlab") 23 + pytest.importorskip("svglib") 24 + pytest.importorskip("pypdf") 25 + 26 + from phonometry import ( # noqa: E402 (import after importorskip) 27 + ReportMetadata, 28 + ReverberationResult, 29 + enclosed_space_reverberation, 30 + hard_object_absorption, 31 + object_fraction, 32 + ) 33 + 34 + _PDF_MAGIC = b"%PDF" 35 + _VOLUME = 50.0 36 + _SURFACES = [ 37 + (20.0, [0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.55]), 38 + (20.0, [0.20, 0.40, 0.65, 0.75, 0.80, 0.80, 0.75]), 39 + (45.0, [0.02, 0.02, 0.03, 0.04, 0.05, 0.05, 0.05]), 40 + ] 41 + _OBJECT_VOLUMES = [0.5, 0.8, 0.3] 42 + 43 + 44 + def _result() -> ReverberationResult: 45 + objects = hard_object_absorption(_OBJECT_VOLUMES) 46 + psi = object_fraction(_OBJECT_VOLUMES, _VOLUME) 47 + return enclosed_space_reverberation( 48 + _SURFACES, _VOLUME, objects=objects, object_fraction=psi, 49 + air_condition="20C_50-70", 50 + ) 51 + 52 + 53 + def _metadata(**overrides) -> ReportMetadata: 54 + base = dict( 55 + specimen="Meeting room, furnished", 56 + client="Acoustic Test Client Ltd.", 57 + test_room="Meeting room M2", 58 + measurement_standard="EN 12354-6", 59 + temperature=20.0, 60 + relative_humidity=55.0, 61 + pressure=101.3, 62 + test_date="2026-07-21", 63 + laboratory="Phonometry Reference Laboratory", 64 + operator="J. M. Requena-Plens", 65 + report_id="PHN-2026-EN12354-6", 66 + ) 67 + base.update(overrides) 68 + return ReportMetadata(**base) 69 + 70 + 71 + def _assert_one_page(path: str) -> None: 72 + from pypdf import PdfReader 73 + 74 + with open(path, "rb") as handle: 75 + assert handle.read(4) == _PDF_MAGIC 76 + assert len(PdfReader(path).pages) == 1 77 + 78 + 79 + def _text(path: str) -> str: 80 + from pypdf import PdfReader 81 + 82 + return "\n".join( 83 + page.extract_text() for page in PdfReader(path).pages 84 + ).replace("\n", " ") 85 + 86 + 87 + def test_report_writes_one_page_pdf(tmp_path) -> None: 88 + out = tmp_path / "enclosed.pdf" 89 + returned = _result().report(str(out)) 90 + assert returned == str(out) 91 + _assert_one_page(str(out)) 92 + 93 + 94 + def test_report_with_metadata_one_page(tmp_path) -> None: 95 + out = tmp_path / "enclosed_meta.pdf" 96 + _result().report(str(out), metadata=_metadata()) 97 + _assert_one_page(str(out)) 98 + 99 + 100 + def test_no_metadata_still_renders(tmp_path) -> None: 101 + out = tmp_path / "enclosed_bare.pdf" 102 + _result().report(str(out), metadata=None) 103 + _assert_one_page(str(out)) 104 + 105 + 106 + def test_unknown_engine_rejected(tmp_path) -> None: 107 + res = _result() 108 + out = str(tmp_path / "x.pdf") 109 + with pytest.raises(ValueError, match="engine"): 110 + res.report(out, engine="weasyprint") 111 + 112 + 113 + def test_unknown_language_rejected(tmp_path) -> None: 114 + res = _result() 115 + out = str(tmp_path / "bad.pdf") 116 + with pytest.raises(ValueError, match="Unknown language"): 117 + res.report(out, language="xx") 118 + 119 + 120 + def test_band_labels_and_area_and_time_render(tmp_path) -> None: 121 + """Nominal band labels and the closed-form A and T (2 decimals) render.""" 122 + res = _result() 123 + out = tmp_path / "values.pdf" 124 + res.report(str(out), metadata=_metadata()) 125 + text = _text(str(out)) 126 + for label in ("125", "1000", "8000"): 127 + assert label in text 128 + # The 1000 Hz absorption area and reverberation time, two decimals. 129 + assert f"{res.absorption_area[3]:.2f}" in text 130 + assert f"{res.reverberation_time[3]:.2f}" in text 131 + 132 + 133 + def test_mid_frequency_descriptor_renders(tmp_path) -> None: 134 + """The boxed mid-frequency reverberation time appears in the text.""" 135 + res = _result() 136 + out = tmp_path / "mid.pdf" 137 + res.report(str(out), metadata=_metadata()) 138 + text = _text(str(out)) 139 + t_mid = 0.5 * (res.reverberation_time[2] + res.reverberation_time[3]) 140 + assert f"{t_mid:.2f}" in text 141 + 142 + 143 + def test_characterisation_has_no_pass_fail_verdict(tmp_path) -> None: 144 + """The fiche never invents a PASS/FAIL verdict, even with a target.""" 145 + out = tmp_path / "noverdict.pdf" 146 + _result().report(str(out), metadata=_metadata(requirement=0.6)) 147 + text = _text(str(out)) 148 + assert "PASS" not in text and "FAIL" not in text 149 + assert "Target reverberation time" in text 150 + 151 + 152 + def test_metadata_xml_specials_do_not_break(tmp_path) -> None: 153 + md = ReportMetadata( 154 + client="Ac & Co <Ltd>", 155 + specimen="room <A> & <B>", 156 + test_room="Room & Stage", 157 + laboratory="Lab & Sons", 158 + report_id="R&D-EN12354", 159 + measurement_standard="EN 12354-6 & Annex", 160 + ) 161 + out = tmp_path / "xml.pdf" 162 + _result().report(str(out), metadata=md) 163 + _assert_one_page(str(out)) 164 + 165 + 166 + def test_spanish_fiche_uses_comma_decimal(tmp_path) -> None: 167 + import re 168 + 169 + out = tmp_path / "enclosed_es.pdf" 170 + _result().report(str(out), metadata=_metadata(requirement=0.6), language="es") 171 + _assert_one_page(str(out)) 172 + text = _text(str(out)) 173 + assert "Absorción acústica en un recinto" in text 174 + assert "Tiempo de reverberación objetivo" in text 175 + assert re.search(r"\d,\d", text) is not None # comma decimal separator 176 + assert "PASS" not in text and "FAIL" not in text
+185
tests/room/test_reverberation_prediction_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for the reverberation-time prediction report (``.report()`` -> PDF). 3 + 4 + The report is a rendering feature, so these tests assert only structural facts: 5 + a valid single-page PDF is written for a five-model prediction, the five model 6 + names and the closed-form per-model values appear in the rendered text, the 7 + fiche reads as a prediction (no PASS/FAIL verdict, only a target reference line 8 + when a target is supplied), unknown engines/languages are rejected, XML 9 + specials in metadata do not break reportlab, and the Spanish fiche is 10 + translated with comma decimals. Pixel or layout content is never inspected. 11 + 12 + The displayed values are produced by the classical closed-form models 13 + themselves (each anchored by the reverberation_prediction module tests), so the 14 + oracle is re-derived here through the public API rather than hardcoded. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import pytest 20 + 21 + pytest.importorskip("reportlab") 22 + pytest.importorskip("svglib") 23 + pytest.importorskip("pypdf") 24 + 25 + import numpy as np # noqa: E402 (import after importorskip) 26 + 27 + from phonometry import ( # noqa: E402 28 + ReportMetadata, 29 + ReverberationModelResult, 30 + reverberation_time_models, 31 + ) 32 + 33 + _PDF_MAGIC = b"%PDF" 34 + _MODELS = ("Sabine", "Eyring", "Millington-Sette", "Fitzroy", "Arau-Puchades") 35 + # A shoebox 8 x 5 x 3 m (V = 120 m3, S = 158 m2) with an anisotropic 36 + # absorption distribution over the octave bands (one treated wall pair). 37 + _FREQS = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) 38 + _TREATED = [0.10, 0.15, 0.30, 0.45, 0.55, 0.60] 39 + _SIDE = [0.08, 0.10, 0.12, 0.15, 0.18, 0.20] 40 + _FLOOR = [0.05, 0.08, 0.10, 0.12, 0.15, 0.18] 41 + 42 + 43 + def _result() -> ReverberationModelResult: 44 + return reverberation_time_models( 45 + (8.0, 5.0, 3.0), (_TREATED, _SIDE, _FLOOR), frequencies=_FREQS 46 + ) 47 + 48 + 49 + def _metadata(**overrides) -> ReportMetadata: 50 + base = dict( 51 + specimen="Classroom, one wall lined with a broadband absorber", 52 + client="Acoustic Test Client Ltd.", 53 + test_room="Classroom C1", 54 + temperature=20.0, 55 + relative_humidity=50.0, 56 + pressure=101.3, 57 + test_date="2026-07-21", 58 + laboratory="Phonometry Reference Laboratory", 59 + operator="J. M. Requena-Plens", 60 + report_id="PHN-2026-REVERB", 61 + ) 62 + base.update(overrides) 63 + return ReportMetadata(**base) 64 + 65 + 66 + def _assert_one_page(path: str) -> None: 67 + from pypdf import PdfReader 68 + 69 + with open(path, "rb") as handle: 70 + assert handle.read(4) == _PDF_MAGIC 71 + assert len(PdfReader(path).pages) == 1 72 + 73 + 74 + def _text(path: str) -> str: 75 + from pypdf import PdfReader 76 + 77 + return "\n".join( 78 + page.extract_text() for page in PdfReader(path).pages 79 + ).replace("\n", " ") 80 + 81 + 82 + def test_report_writes_one_page_pdf(tmp_path) -> None: 83 + out = tmp_path / "reverb.pdf" 84 + returned = _result().report(str(out)) 85 + assert returned == str(out) 86 + _assert_one_page(str(out)) 87 + 88 + 89 + def test_report_with_metadata_one_page(tmp_path) -> None: 90 + out = tmp_path / "reverb_meta.pdf" 91 + _result().report(str(out), metadata=_metadata()) 92 + _assert_one_page(str(out)) 93 + 94 + 95 + def test_no_metadata_still_renders(tmp_path) -> None: 96 + out = tmp_path / "reverb_bare.pdf" 97 + _result().report(str(out), metadata=None) 98 + _assert_one_page(str(out)) 99 + 100 + 101 + def test_unknown_engine_rejected(tmp_path) -> None: 102 + res = _result() 103 + out = str(tmp_path / "x.pdf") 104 + with pytest.raises(ValueError, match="engine"): 105 + res.report(out, engine="weasyprint") 106 + 107 + 108 + def test_unknown_language_rejected(tmp_path) -> None: 109 + res = _result() 110 + out = str(tmp_path / "bad.pdf") 111 + with pytest.raises(ValueError, match="Unknown language"): 112 + res.report(out, language="xx") 113 + 114 + 115 + def test_all_model_names_render(tmp_path) -> None: 116 + """The five model column headers appear in the rendered fiche text.""" 117 + out = tmp_path / "models.pdf" 118 + _result().report(str(out), metadata=_metadata()) 119 + text = _text(str(out)) 120 + for name in _MODELS: 121 + assert name in text 122 + 123 + 124 + def test_band_labels_and_per_model_values_render(tmp_path) -> None: 125 + """Nominal band labels and closed-form per-model T (2 decimals) render.""" 126 + res = _result() 127 + out = tmp_path / "values.pdf" 128 + res.report(str(out), metadata=_metadata()) 129 + text = _text(str(out)) 130 + for label in ("125", "500", "1000", "4000"): 131 + assert label in text 132 + # The 500 Hz Arau-Puchades and Sabine times, formatted to two decimals. 133 + arau_500 = f"{res.arau_puchades[2]:.2f}" 134 + sabine_500 = f"{res.sabine[2]:.2f}" 135 + assert arau_500 in text 136 + assert sabine_500 in text 137 + 138 + 139 + def test_mid_frequency_prediction_descriptor_renders(tmp_path) -> None: 140 + """The boxed Arau-Puchades mid-frequency prediction appears in the text.""" 141 + res = _result() 142 + out = tmp_path / "mid.pdf" 143 + res.report(str(out), metadata=_metadata()) 144 + text = _text(str(out)) 145 + t_mid = 0.5 * (res.arau_puchades[2] + res.arau_puchades[3]) 146 + assert f"{t_mid:.2f}" in text 147 + # It is a prediction, so it is labelled as such (Arau-Puchades descriptor). 148 + assert "Predicted" in text 149 + assert "Arau-Puchades" in text 150 + 151 + 152 + def test_prediction_has_no_pass_fail_verdict(tmp_path) -> None: 153 + """A prediction never invents a PASS/FAIL verdict, even with a target.""" 154 + out = tmp_path / "noverdict.pdf" 155 + _result().report(str(out), metadata=_metadata(requirement=0.8)) 156 + text = _text(str(out)) 157 + assert "PASS" not in text and "FAIL" not in text 158 + # The target is instead shown as a reference line. 159 + assert "Target reverberation time" in text 160 + 161 + 162 + def test_metadata_xml_specials_do_not_break(tmp_path) -> None: 163 + md = ReportMetadata( 164 + client="Ac & Co <Ltd>", 165 + specimen="hall <A> & foyer", 166 + test_room="Room & Stage", 167 + laboratory="Lab & Sons", 168 + report_id="R&D-REVERB", 169 + ) 170 + out = tmp_path / "xml.pdf" 171 + _result().report(str(out), metadata=md) 172 + _assert_one_page(str(out)) 173 + 174 + 175 + def test_spanish_fiche_uses_comma_decimal(tmp_path) -> None: 176 + import re 177 + 178 + out = tmp_path / "reverb_es.pdf" 179 + _result().report(str(out), metadata=_metadata(requirement=0.8), language="es") 180 + _assert_one_page(str(out)) 181 + text = _text(str(out)) 182 + assert "Predicción del tiempo de reverberación" in text 183 + assert "Tiempo de reverberación objetivo" in text 184 + assert re.search(r"\d,\d", text) is not None # comma decimal separator 185 + assert "PASS" not in text and "FAIL" not in text
+19
src/phonometry/_report/_i18n.py
··· 786 786 "representative HTLAN = {value} dB HL, maximum {req} dB HL": "HTLAN representativo = {value} dB HL, máximo {req} dB HL", 787 787 "These values are a statistical prediction for a noise-exposed population (ISO 1999:2013), not a clinical diagnosis or a measured audiogram of any individual.": "Estos valores son una predicción estadística para una población expuesta al ruido (ISO 1999:2013), no un diagnóstico clínico ni un audiograma medido de ninguna persona.", 788 788 "The population fractile Q is the fraction of the noise-exposed population predicted to show a smaller threshold shift, so a higher fractile is a more-susceptible individual (ISO 1999:2013, 6.3.2).": "El fractil poblacional Q es la fracción de la población expuesta al ruido que se predice con un desplazamiento del umbral menor, por lo que un fractil mayor corresponde a un individuo más susceptible (ISO 1999:2013, 6.3.2).", 789 + # --- reverberation-time prediction / enclosed-space characterisation -- 790 + "Reverberation-time prediction": "Predicción del tiempo de reverberación", 791 + "Sound absorption in an enclosed space": "Absorción acústica en un recinto", 792 + "Design-stage prediction of the reverberation time by five statistical-acoustics models (Sabine, Eyring, Millington-Sette, Fitzroy and Arau-Puchades).": "Predicción en fase de diseño del tiempo de reverberación mediante cinco modelos de acústica estadística (Sabine, Eyring, Millington-Sette, Fitzroy y Arau-Puchades).", 793 + "{standard} estimate of the equivalent sound absorption area and reverberation time of an enclosed space per EN 12354-6:2003.": "{standard} estimación del área de absorción acústica equivalente y del tiempo de reverberación de un recinto según EN 12354-6:2003.", 794 + "Equivalent sound absorption area and reverberation time of an enclosed space per EN 12354-6:2003.": "Área de absorción acústica equivalente y tiempo de reverberación de un recinto según EN 12354-6:2003.", 795 + "Total surface area S [m<super>2</super>]": "Superficie total S [m<super>2</super>]", 796 + "Object fraction &#968;": "Fracción de objetos &#968;", 797 + "Reverberation time by model [s]": "Tiempo de reverberación por modelo [s]", 798 + "Absorption area A and reverberation time T": "Área de absorción A y tiempo de reverberación T", 799 + "T [s]": "T [s]", 800 + "Predicted T<sub>mid</sub> (Arau-Puchades, 500-1000 Hz) = <b>{value} s</b>": "T<sub>mid</sub> previsto (Arau-Puchades, 500-1000 Hz) = <b>{value} s</b>", 801 + "Predicted T (Arau-Puchades) = <b>{value} s</b>": "T previsto (Arau-Puchades) = <b>{value} s</b>", 802 + "T = <b>{value} s</b>": "T = <b>{value} s</b>", 803 + "A (500-1000 Hz) = {value} m<super>2</super>": "A (500-1000 Hz) = {value} m<super>2</super>", 804 + "Target reverberation time T = {req} s": "Tiempo de reverberación objetivo T = {req} s", 805 + "Design-stage prediction from the room geometry and surface absorption by the classical statistical-acoustics models; it is not a measurement. The five models bracket the reverberation time likely to occur; Arau-Puchades is recommended for a non-uniform absorption distribution and drives the boxed descriptor.": "Predicción en fase de diseño a partir de la geometría del recinto y la absorción de las superficies mediante los modelos clásicos de acústica estadística; no es una medición. Los cinco modelos acotan el tiempo de reverberación que probablemente se produzca; Arau-Puchades se recomienda para una distribución de absorción no uniforme y determina el valor destacado.", 806 + "Sabine T = 24 ln10/c0 * V/(A + 4mV); Eyring replaces A by -S ln(1 - alpha_bar); Millington-Sette sums -S_i ln(1 - alpha_i) per surface; Fitzroy and Arau-Puchades are the area-weighted arithmetic and geometric means of the three axial Eyring times.": "Sabine T = 24 ln10/c0 * V/(A + 4mV); Eyring sustituye A por -S ln(1 - alpha_bar); Millington-Sette suma -S_i ln(1 - alpha_i) por superficie; Fitzroy y Arau-Puchades son las medias aritmética y geométrica, ponderadas por área, de los tres tiempos de Eyring axiales.", 807 + "Estimate of the equivalent sound absorption area A and the reverberation time T of an enclosed space from its surface, object and air absorption by the EN 12354-6:2003 Clause 4 model (A by Formula 1, T = 55.3/c0 * V(1 - psi)/A by Formula 5, with the object fraction psi = sum Vobj / V). It is an estimate for a diffuse field, not a measurement.": "Estimación del área de absorción acústica equivalente A y del tiempo de reverberación T de un recinto a partir de la absorción de sus superficies, objetos y aire mediante el modelo del apartado 4 de la EN 12354-6:2003 (A por la fórmula 1, T = 55,3/c0 * V(1 - psi)/A por la fórmula 5, con la fracción de objetos psi = suma Vobj / V). Es una estimación para un campo difuso, no una medición.", 789 808 } 790 809 791 810
+537
src/phonometry/_report/reverberation.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Reverberation-time fiches (reportlab renderer). 3 + 4 + Two related one-page PDF fiches for the octave-band reverberation time of a 5 + room, sharing the accredited-laboratory skeleton of :mod:`._layout`: 6 + 7 + * :func:`render_reverberation_models_report` renders a 8 + :class:`~phonometry.room.reverberation_prediction.ReverberationModelResult` 9 + as a design-stage **prediction**: the reverberation time estimated by five 10 + classical statistical-acoustics models (Sabine, Eyring/Norris-Eyring, 11 + Millington-Sette, Fitzroy and Arau-Puchades) per octave band, laid out as a 12 + per-band table with one column per model beside the result's own model 13 + comparison plot. It is explicitly labelled a prediction, not a measurement: 14 + the five models bracket the reverberation time likely to occur, and 15 + Arau-Puchades (the recommended model for a non-uniform absorption 16 + distribution) drives the boxed mid-frequency descriptor. 17 + 18 + * :func:`render_enclosed_space_report` renders a 19 + :class:`~phonometry.room.enclosed_space_absorption.ReverberationResult` as a 20 + room characterisation: the total equivalent sound absorption area ``A`` and 21 + the reverberation time ``T`` of an enclosed space per octave band by the 22 + EN 12354-6:2003 Clause 4 model, laid out as a per-band ``A``/``T`` table 23 + beside the result's own reverberation-time plot, with the room volume and the 24 + object fraction in the header. 25 + 26 + Neither fiche carries a PASS/FAIL verdict: a room reverberation time is a 27 + target range rather than a simply-higher-or-lower-is-better quantity, so a 28 + supplied target reverberation time is printed as a reference line without an 29 + invented pass direction. 30 + 31 + reportlab, matplotlib and svglib are soft dependencies imported lazily by the 32 + shared helpers that need them (reportlab and svglib ship in the 33 + ``phonometry[report]`` extra, matplotlib in ``phonometry[plot]``); each raises 34 + an actionable :class:`ImportError`. 35 + """ 36 + 37 + from __future__ import annotations 38 + 39 + import html 40 + import math 41 + from collections.abc import Sequence 42 + from typing import TYPE_CHECKING, Any, List, Tuple 43 + 44 + import numpy as np 45 + 46 + from ._i18n import format_number, t 47 + from ._layout import ( 48 + _ACCENT_HEX, 49 + _LIGHT_HEX, 50 + _REPORTLAB_HINT, 51 + build_document, 52 + document_styles, 53 + escaped_pairs, 54 + fmt_meta, 55 + footer_flow, 56 + grid_table, 57 + measurement_basis_style, 58 + render_figure_drawing, 59 + result_box, 60 + two_panel_body, 61 + ) 62 + from .metadata import ReportMetadata 63 + 64 + if TYPE_CHECKING: 65 + from ..room.enclosed_space_absorption import ReverberationResult 66 + from ..room.reverberation_prediction import ReverberationModelResult 67 + 68 + #: Octave centres whose mean is the mid-frequency reverberation-time descriptor 69 + #: quoted for rooms (the 500 Hz and 1000 Hz octave bands). 70 + _MID_BANDS = (500.0, 1000.0) 71 + 72 + #: Tolerance (relative) for matching a band centre to a nominal frequency. 73 + _BAND_MATCH_REL = 0.06 74 + 75 + #: The five prediction models, in the order the table columns print them. 76 + _MODEL_NAMES = ("Sabine", "Eyring", "Millington-Sette", "Fitzroy", "Arau-Puchades") 77 + 78 + #: The recommended model for a non-uniform absorption distribution; its 79 + #: mid-frequency reverberation time drives the boxed prediction descriptor. 80 + _PRIMARY_MODEL = "Arau-Puchades" 81 + 82 + 83 + # --------------------------------------------------------------------------- 84 + # Shared helpers. 85 + # --------------------------------------------------------------------------- 86 + 87 + 88 + def _fmt_t(value: float, language: str = "en") -> str: 89 + """Format a reverberation time to two decimals, or an em dash if not finite.""" 90 + v = float(value) 91 + if not math.isfinite(v): 92 + return "—" 93 + return format_number(v, language, decimals=2) 94 + 95 + 96 + def _band_value(freqs: np.ndarray, values: np.ndarray, target: float) -> float: 97 + """Return the value at the band closest to ``target`` Hz, or NaN if none near.""" 98 + if freqs.size == 0: 99 + return float("nan") 100 + idx = int(np.argmin(np.abs(freqs - target))) 101 + if abs(float(freqs[idx]) - target) > _BAND_MATCH_REL * target: 102 + return float("nan") 103 + return float(values[idx]) 104 + 105 + 106 + def _mid_or_first(freqs: np.ndarray, values: np.ndarray) -> Tuple[float, bool]: 107 + """Return ``(value, is_mid)`` for the mid-frequency descriptor. 108 + 109 + When both the 500 Hz and 1000 Hz octaves are present and finite, the 110 + descriptor is their mean (``is_mid`` True); otherwise it is the first 111 + finite value with ``is_mid`` False, so no false "500-1000 Hz" claim is made 112 + for a band set that does not span both mid octaves. 113 + """ 114 + low = _band_value(freqs, values, _MID_BANDS[0]) 115 + high = _band_value(freqs, values, _MID_BANDS[1]) 116 + if math.isfinite(low) and math.isfinite(high): 117 + return 0.5 * (low + high), True 118 + finite = values[np.isfinite(values)] 119 + return (float(finite[0]) if finite.size else float("nan")), False 120 + 121 + 122 + def _octave_table( 123 + header_cells: List[Any], rows: List[List[Any]], col_widths: List[Any] 124 + ) -> Any: 125 + """Assemble an octave-band table with the accredited accent/zebra styling. 126 + 127 + Octave-band tables have one row per octave, so (unlike the one-third-octave 128 + band tables) there is no triplet grouping rule. Called only after the 129 + renderer has imported reportlab. 130 + """ 131 + from reportlab.lib import colors 132 + from reportlab.platypus import Table, TableStyle 133 + 134 + accent = colors.HexColor(_ACCENT_HEX) 135 + light = colors.HexColor(_LIGHT_HEX) 136 + data = [header_cells, *rows] 137 + table = Table(data, colWidths=col_widths, repeatRows=1) 138 + table.setStyle( 139 + TableStyle( 140 + [ 141 + ("BACKGROUND", (0, 0), (-1, 0), accent), 142 + ("FONTSIZE", (0, 1), (-1, -1), 8), 143 + ("ALIGN", (0, 0), (-1, -1), "CENTER"), 144 + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), 145 + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, light]), 146 + ("LINEBELOW", (0, 0), (-1, 0), 0.6, accent), 147 + ("TOPPADDING", (0, 0), (-1, -1), 2.6), 148 + ("BOTTOMPADDING", (0, 0), (-1, -1), 2.6), 149 + ("BOX", (0, 0), (-1, -1), 0.5, accent), 150 + ] 151 + ) 152 + ) 153 + return table 154 + 155 + 156 + def _band_header_style() -> Any: 157 + """White-on-accent header-cell paragraph style for the octave tables.""" 158 + from reportlab.lib import colors 159 + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet 160 + 161 + return ParagraphStyle( 162 + "reverb_thead", parent=getSampleStyleSheet()["Normal"], 163 + fontSize=7.2, textColor=colors.white, alignment=1, leading=8.5, 164 + ) 165 + 166 + 167 + def _target_line(requirement: float, styles: Any, language: str) -> List[Any]: 168 + """A muted reference line for a supplied target reverberation time. 169 + 170 + A room reverberation time is a target range, not a strictly 171 + higher/lower-is-better quantity, so the target is reported without an 172 + invented PASS/FAIL verdict. Called only after the renderer imported 173 + reportlab. 174 + """ 175 + from reportlab.lib import colors 176 + from reportlab.lib.styles import ParagraphStyle 177 + from reportlab.platypus import Paragraph 178 + 179 + style = ParagraphStyle( 180 + "reverb_target", parent=styles["Normal"], fontSize=9.5, leading=13, 181 + spaceBefore=4, textColor=colors.HexColor("#555555"), 182 + ) 183 + text = t("Target reverberation time T = {req} s", language).format( 184 + req=format_number(requirement, language, decimals=2) 185 + ) 186 + return [Paragraph(f"<font color='{_ACCENT_HEX}'>&#9632;</font> {text}", style)] 187 + 188 + 189 + def _header_grid( 190 + volume: float, 191 + extra_pairs: Sequence[Tuple[str, str | None]], 192 + metadata: ReportMetadata | None, 193 + language: str, 194 + ) -> List[Any]: 195 + """Build the metadata header grid (volume plus room-identity fields). 196 + 197 + Returns the flowables (a spacer and the grid table) or an empty list when no 198 + field is supplied. The room volume is authoritative from the result itself; 199 + the descriptive fields come from the :class:`ReportMetadata` when given. 200 + """ 201 + from reportlab.platypus import Spacer 202 + 203 + client = metadata.client if metadata is not None else None 204 + test_room = metadata.test_room if metadata is not None else None 205 + specimen = metadata.specimen if metadata is not None else None 206 + test_date = metadata.test_date if metadata is not None else None 207 + temperature = metadata.temperature if metadata is not None else None 208 + humidity = metadata.relative_humidity if metadata is not None else None 209 + pressure = metadata.pressure if metadata is not None else None 210 + 211 + specs: List[Tuple[str, str | None]] = [ 212 + (t("Client", language), client), 213 + (t("Room", language), test_room), 214 + (t("Description", language), specimen), 215 + (t("Room volume V [m<super>3</super>]", language), 216 + fmt_meta(volume, language)), 217 + *extra_pairs, 218 + (t("Temperature [&#176;C]", language), 219 + fmt_meta(temperature, language) if temperature is not None else None), 220 + (t("Relative humidity [%]", language), 221 + fmt_meta(humidity, language) if humidity is not None else None), 222 + (t("Ambient pressure [kPa]", language), 223 + fmt_meta(pressure, language) if pressure is not None else None), 224 + (t("Date of prediction", language), test_date), 225 + ] 226 + pairs = escaped_pairs(specs) 227 + if not pairs: 228 + return [] 229 + return [Spacer(1, 3), grid_table(pairs)] 230 + 231 + 232 + # --------------------------------------------------------------------------- 233 + # Prediction fiche (five statistical-acoustics models). 234 + # --------------------------------------------------------------------------- 235 + 236 + 237 + def _models_table(result: "ReverberationModelResult", language: str) -> Any: 238 + """Build the per-band table with one reverberation-time column per model.""" 239 + from reportlab.lib.units import mm 240 + from reportlab.platypus import Paragraph 241 + 242 + head = _band_header_style() 243 + header = [Paragraph(t("f [Hz]", language), head)] 244 + header += [Paragraph(name, head) for name in _MODEL_NAMES] 245 + 246 + freqs = np.asarray(result.frequencies, dtype=np.float64) 247 + curves = [np.asarray(result.models[name], dtype=np.float64) for name in _MODEL_NAMES] 248 + rows: List[List[Any]] = [] 249 + for i, fk in enumerate(freqs): 250 + rows.append( 251 + [f"{int(round(float(fk)))}", *[_fmt_t(c[i], language) for c in curves]] 252 + ) 253 + col_widths = [14 * mm, 17.6 * mm, 17.6 * mm, 17.6 * mm, 17.6 * mm, 17.6 * mm] 254 + return _octave_table(header, rows, col_widths) 255 + 256 + 257 + def _prediction_statement( 258 + result: "ReverberationModelResult", language: str 259 + ) -> Tuple[str, List[str]]: 260 + """The boxed prediction descriptor and the per-model spread beside it.""" 261 + freqs = np.asarray(result.frequencies, dtype=np.float64) 262 + primary = np.asarray(result.models[_PRIMARY_MODEL], dtype=np.float64) 263 + value, is_mid = _mid_or_first(freqs, primary) 264 + if is_mid: 265 + statement = t( 266 + "Predicted T<sub>mid</sub> (Arau-Puchades, 500-1000 Hz) = " 267 + "<b>{value} s</b>", 268 + language, 269 + ).format(value=_fmt_t(value, language)) 270 + else: 271 + statement = t( 272 + "Predicted T (Arau-Puchades) = <b>{value} s</b>", language 273 + ).format(value=_fmt_t(value, language)) 274 + extended: List[str] = [] 275 + for name in _MODEL_NAMES: 276 + model_value, _ = _mid_or_first( 277 + freqs, np.asarray(result.models[name], dtype=np.float64) 278 + ) 279 + extended.append(f"{name}: {_fmt_t(model_value, language)} s") 280 + return statement, extended 281 + 282 + 283 + def render_reverberation_models_report( 284 + result: "ReverberationModelResult", 285 + path: str, 286 + *, 287 + metadata: ReportMetadata | None = None, 288 + verbose: bool = False, 289 + language: str = "en", 290 + ) -> str: 291 + """Render a reverberation-time prediction fiche to a PDF at ``path``. 292 + 293 + :param result: A 294 + :class:`~phonometry.room.reverberation_prediction.ReverberationModelResult`. 295 + :param path: Destination path of the PDF file. 296 + :param metadata: Optional :class:`ReportMetadata`; ``None`` renders a bare 297 + prediction fiche. A supplied ``requirement`` is printed as a target 298 + reverberation-time reference line (no PASS/FAIL, since a room target is 299 + a range). 300 + :param verbose: Accepted for signature parity with the other fiches; the 301 + model table already shows every computed value, so it has no effect. 302 + :param language: ``"en"`` (default) or ``"es"``. 303 + :return: The written ``path`` as a :class:`str`. 304 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 305 + installed. 306 + """ 307 + del verbose # every model column is always shown; kept for signature parity 308 + try: 309 + from reportlab.lib import colors 310 + from reportlab.platypus import Paragraph, Spacer 311 + except ImportError as exc: 312 + raise ImportError(_REPORTLAB_HINT) from exc 313 + accent = colors.HexColor(_ACCENT_HEX) 314 + 315 + styles, title_style, basis_style, caption_style = document_styles(accent) 316 + flow: List[Any] = [ 317 + Paragraph(t("Reverberation-time prediction", language), title_style), 318 + Paragraph( 319 + t( 320 + "Design-stage prediction of the reverberation time by five " 321 + "statistical-acoustics models (Sabine, Eyring, " 322 + "Millington-Sette, Fitzroy and Arau-Puchades).", 323 + language, 324 + ), 325 + basis_style, 326 + ), 327 + ] 328 + 329 + extra = [(t("Total surface area S [m<super>2</super>]", language), 330 + fmt_meta(result.surface_area, language))] 331 + flow.extend(_header_grid(result.volume, extra, metadata, language)) 332 + flow.append(Spacer(1, 8)) 333 + 334 + from reportlab.lib.units import mm 335 + 336 + left_cell = [ 337 + Paragraph(t("Reverberation time by model [s]", language), caption_style), 338 + _models_table(result, language), 339 + ] 340 + plot_drawing = render_figure_drawing( 341 + result.plot, 70 * mm, y_top=None, language=language 342 + ) 343 + flow.append( 344 + two_panel_body(left_cell, plot_drawing, left_width_mm=102.0, plot_width_mm=72.0) 345 + ) 346 + flow.append(Spacer(1, 8)) 347 + 348 + statement, extended = _prediction_statement(result, language) 349 + flow.append(result_box(statement, styles, accent, extended)) 350 + if metadata is not None and metadata.requirement is not None: 351 + flow.extend(_target_line(metadata.requirement, styles, language)) 352 + 353 + basis_strip = measurement_basis_style() 354 + flow.append( 355 + Paragraph( 356 + t( 357 + "Design-stage prediction from the room geometry and surface " 358 + "absorption by the classical statistical-acoustics models; it " 359 + "is not a measurement. The five models bracket the " 360 + "reverberation time likely to occur; Arau-Puchades is " 361 + "recommended for a non-uniform absorption distribution and " 362 + "drives the boxed descriptor.", 363 + language, 364 + ), 365 + basis_strip, 366 + ) 367 + ) 368 + flow.append( 369 + Paragraph( 370 + t( 371 + "Sabine T = 24 ln10/c0 * V/(A + 4mV); Eyring replaces A by " 372 + "-S ln(1 - alpha_bar); Millington-Sette sums -S_i ln(1 - " 373 + "alpha_i) per surface; Fitzroy and Arau-Puchades are the " 374 + "area-weighted arithmetic and geometric means of the three " 375 + "axial Eyring times.", 376 + language, 377 + ), 378 + basis_strip, 379 + ) 380 + ) 381 + flow.extend(footer_flow(metadata, language)) 382 + return build_document(path, flow, t("Reverberation-time prediction", language)) 383 + 384 + 385 + # --------------------------------------------------------------------------- 386 + # Enclosed-space characterisation fiche (EN 12354-6). 387 + # --------------------------------------------------------------------------- 388 + 389 + 390 + def _enclosed_table(result: "ReverberationResult", language: str) -> Any: 391 + """Build the per-band ``f | A | T`` table of the enclosed-space fiche.""" 392 + from reportlab.lib.units import mm 393 + from reportlab.platypus import Paragraph 394 + 395 + head = _band_header_style() 396 + header = [ 397 + Paragraph(t("f [Hz]", language), head), 398 + Paragraph("A [m<super>2</super>]", head), 399 + Paragraph("T [s]", head), 400 + ] 401 + freqs = np.asarray(result.frequencies, dtype=np.float64) 402 + area = np.asarray(result.absorption_area, dtype=np.float64) 403 + rt = np.asarray(result.reverberation_time, dtype=np.float64) 404 + rows: List[List[Any]] = [] 405 + for i, fk in enumerate(freqs): 406 + rows.append( 407 + [ 408 + f"{int(round(float(fk)))}", 409 + format_number(float(area[i]), language, decimals=2), 410 + _fmt_t(rt[i], language), 411 + ] 412 + ) 413 + return _octave_table(header, rows, [18 * mm, 19 * mm, 19 * mm]) 414 + 415 + 416 + def _enclosed_statement( 417 + result: "ReverberationResult", language: str 418 + ) -> Tuple[str, List[str]]: 419 + """The boxed reverberation-time descriptor and the mid-frequency area.""" 420 + freqs = np.asarray(result.frequencies, dtype=np.float64) 421 + rt = np.asarray(result.reverberation_time, dtype=np.float64) 422 + area = np.asarray(result.absorption_area, dtype=np.float64) 423 + t_value, is_mid = _mid_or_first(freqs, rt) 424 + if is_mid: 425 + statement = t( 426 + "T<sub>mid</sub> (500-1000 Hz) = <b>{value} s</b>", language 427 + ).format(value=_fmt_t(t_value, language)) 428 + else: 429 + statement = t("T = <b>{value} s</b>", language).format( 430 + value=_fmt_t(t_value, language) 431 + ) 432 + a_value, _ = _mid_or_first(freqs, area) 433 + extended: List[str] = [] 434 + if math.isfinite(a_value): 435 + extended.append( 436 + t("A (500-1000 Hz) = {value} m<super>2</super>", language).format( 437 + value=format_number(a_value, language, decimals=2) 438 + ) 439 + ) 440 + return statement, extended 441 + 442 + 443 + def render_enclosed_space_report( 444 + result: "ReverberationResult", 445 + path: str, 446 + *, 447 + metadata: ReportMetadata | None = None, 448 + verbose: bool = False, 449 + language: str = "en", 450 + ) -> str: 451 + """Render an enclosed-space absorption/reverberation fiche to a PDF at ``path``. 452 + 453 + :param result: A 454 + :class:`~phonometry.room.enclosed_space_absorption.ReverberationResult`. 455 + :param path: Destination path of the PDF file. 456 + :param metadata: Optional :class:`ReportMetadata`; ``None`` renders a bare 457 + characterisation fiche. A supplied ``requirement`` is printed as a 458 + target reverberation-time reference line (no PASS/FAIL). 459 + :param verbose: Accepted for signature parity with the other fiches; the 460 + band table already shows both A and T, so it has no effect. 461 + :param language: ``"en"`` (default) or ``"es"``. 462 + :return: The written ``path`` as a :class:`str`. 463 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 464 + installed. 465 + """ 466 + del verbose # A and T are always shown; kept for signature parity 467 + try: 468 + from reportlab.lib import colors 469 + from reportlab.platypus import Paragraph, Spacer 470 + except ImportError as exc: 471 + raise ImportError(_REPORTLAB_HINT) from exc 472 + accent = colors.HexColor(_ACCENT_HEX) 473 + 474 + styles, title_style, basis_style, caption_style = document_styles(accent) 475 + measurement_standard = ( 476 + metadata.measurement_standard if metadata is not None else None 477 + ) 478 + if measurement_standard: 479 + basis = t( 480 + "{standard} estimate of the equivalent sound absorption area and " 481 + "reverberation time of an enclosed space per EN 12354-6:2003.", 482 + language, 483 + ).format(standard=html.escape(measurement_standard)) 484 + else: 485 + basis = t( 486 + "Equivalent sound absorption area and reverberation time of an " 487 + "enclosed space per EN 12354-6:2003.", 488 + language, 489 + ) 490 + flow: List[Any] = [ 491 + Paragraph(t("Sound absorption in an enclosed space", language), title_style), 492 + Paragraph(basis, basis_style), 493 + ] 494 + 495 + extra = [(t("Object fraction &#968;", language), 496 + fmt_meta(result.object_fraction, language))] 497 + flow.extend(_header_grid(result.volume, extra, metadata, language)) 498 + flow.append(Spacer(1, 8)) 499 + 500 + from reportlab.lib.units import mm 501 + 502 + left_cell = [ 503 + Paragraph( 504 + t("Absorption area A and reverberation time T", language), caption_style 505 + ), 506 + _enclosed_table(result, language), 507 + ] 508 + plot_drawing = render_figure_drawing( 509 + result.plot, 116 * mm, y_top=None, language=language 510 + ) 511 + flow.append(two_panel_body(left_cell, plot_drawing)) 512 + flow.append(Spacer(1, 8)) 513 + 514 + statement, extended = _enclosed_statement(result, language) 515 + flow.append(result_box(statement, styles, accent, extended)) 516 + if metadata is not None and metadata.requirement is not None: 517 + flow.extend(_target_line(metadata.requirement, styles, language)) 518 + 519 + basis_strip = measurement_basis_style() 520 + flow.append( 521 + Paragraph( 522 + t( 523 + "Estimate of the equivalent sound absorption area A and the " 524 + "reverberation time T of an enclosed space from its surface, " 525 + "object and air absorption by the EN 12354-6:2003 Clause 4 " 526 + "model (A by Formula 1, T = 55.3/c0 * V(1 - psi)/A by " 527 + "Formula 5, with the object fraction psi = sum Vobj / V). It " 528 + "is an estimate for a diffuse field, not a measurement.", 529 + language, 530 + ), 531 + basis_strip, 532 + ) 533 + ) 534 + flow.extend(footer_flow(metadata, language)) 535 + return build_document( 536 + path, flow, t("Sound absorption in an enclosed space", language) 537 + )
+54
src/phonometry/room/enclosed_space_absorption.py
··· 33 33 if TYPE_CHECKING: 34 34 from matplotlib.axes import Axes 35 35 36 + from .._report.metadata import ReportMetadata 37 + 36 38 from numpy.typing import ArrayLike 37 39 38 40 from .._internal.types import as_float_or_array ··· 226 228 227 229 check_language(language) 228 230 return plot_enclosed_space_absorption(self, ax=ax, language=language, **kwargs) 231 + 232 + def report( 233 + self, 234 + path: str, 235 + *, 236 + metadata: "ReportMetadata | None" = None, 237 + engine: str = "reportlab", 238 + verbose: bool = False, 239 + language: str = "en", 240 + ) -> str: 241 + """Render an enclosed-space absorption/reverberation fiche to a PDF. 242 + 243 + Writes a one-page report characterising an enclosed space by the 244 + EN 12354-6:2003 Clause 4 model: a standard-basis line, an optional 245 + metadata header block (client, room, description, room volume, object 246 + fraction, climate ...), a per-band table of the equivalent sound 247 + absorption area ``A`` and the reverberation time ``T`` beside the 248 + result's own reverberation-time plot (:meth:`plot`), the boxed 249 + mid-frequency reverberation time with the mid-frequency absorption area 250 + alongside, and a footer with the fixed disclaimer. EN 12354-6 gives a 251 + diffuse-field estimate rather than a measurement; a supplied 252 + ``metadata.requirement`` is printed as a target reverberation-time 253 + reference line without a PASS/FAIL verdict, since a room reverberation 254 + time is a target range rather than a strictly higher/lower-is-better 255 + quantity. 256 + 257 + :param path: Destination path of the PDF file. 258 + :param metadata: Optional 259 + :class:`~phonometry.ReportMetadata`; ``None`` produces a bare 260 + characterisation fiche (body, result and disclaimer only). 261 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 262 + :param verbose: Accepted for parity with the other fiches; the band 263 + table already shows both A and T, so it has no effect. 264 + :param language: Fiche language: ``"en"`` (default, English) or 265 + ``"es"`` (Spanish, with a comma decimal separator). 266 + :return: The written ``path`` as a :class:`str`. 267 + :raises ValueError: If ``engine`` is not ``"reportlab"``. 268 + :raises ImportError: If reportlab is not installed 269 + (``pip install phonometry[report]``). 270 + """ 271 + from .._i18n import check_language 272 + 273 + check_language(language) 274 + if engine != "reportlab": 275 + raise ValueError( 276 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 277 + ) 278 + from .._report.reverberation import render_enclosed_space_report 279 + 280 + return render_enclosed_space_report( 281 + self, path, metadata=metadata, verbose=verbose, language=language 282 + ) 229 283 230 284 231 285 def enclosed_space_reverberation(
+57
src/phonometry/room/reverberation_prediction.py
··· 62 62 if TYPE_CHECKING: 63 63 from matplotlib.axes import Axes 64 64 65 + from .._report.metadata import ReportMetadata 66 + 65 67 from numpy.typing import ArrayLike, NDArray 66 68 67 69 from .._internal.types import as_float_or_array ··· 525 527 526 528 check_language(language) 527 529 return plot_reverberation_models(self, ax=ax, language=language, **kwargs) 530 + 531 + def report( 532 + self, 533 + path: str, 534 + *, 535 + metadata: "ReportMetadata | None" = None, 536 + engine: str = "reportlab", 537 + verbose: bool = False, 538 + language: str = "en", 539 + ) -> str: 540 + """Render a reverberation-time prediction fiche to a PDF. 541 + 542 + Writes a one-page report of the reverberation time predicted by the 543 + five statistical-acoustics models (Sabine, Eyring, Millington-Sette, 544 + Fitzroy and Arau-Puchades): a standard-basis line marking it a 545 + design-stage prediction, an optional metadata header block (client, 546 + room, description, room volume, total surface area, climate ...), a 547 + per-band table with one reverberation-time column per model beside the 548 + result's own model-comparison plot (:meth:`plot`), the boxed 549 + mid-frequency reverberation time from Arau-Puchades (the recommended 550 + model for a non-uniform absorption distribution) with the per-model 551 + spread alongside, and a footer with the fixed disclaimer. This is a 552 + prediction, not a measurement: the five models bracket the 553 + reverberation time likely to occur. A supplied 554 + ``metadata.requirement`` is printed as a target reverberation-time 555 + reference line without a PASS/FAIL verdict, since a room reverberation 556 + time is a target range rather than a strictly higher/lower-is-better 557 + quantity. 558 + 559 + :param path: Destination path of the PDF file. 560 + :param metadata: Optional 561 + :class:`~phonometry.ReportMetadata`; ``None`` produces a bare 562 + prediction fiche (body, result and disclaimer only). 563 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 564 + :param verbose: Accepted for parity with the other fiches; the model 565 + table already shows every computed value, so it has no effect. 566 + :param language: Fiche language: ``"en"`` (default, English) or 567 + ``"es"`` (Spanish, with a comma decimal separator). 568 + :return: The written ``path`` as a :class:`str`. 569 + :raises ValueError: If ``engine`` is not ``"reportlab"``. 570 + :raises ImportError: If reportlab is not installed 571 + (``pip install phonometry[report]``). 572 + """ 573 + from .._i18n import check_language 574 + 575 + check_language(language) 576 + if engine != "reportlab": 577 + raise ValueError( 578 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 579 + ) 580 + from .._report.reverberation import render_reverberation_models_report 581 + 582 + return render_reverberation_models_report( 583 + self, path, metadata=metadata, verbose=verbose, language=language 584 + ) 528 585 529 586 530 587 def reverberation_time_models(
+61
site/src/content/docs/guides/enclosed-space-absorption.mdx
··· 27 27 --- 28 28 29 29 import ThemeImage from '../../../components/ThemeImage.astro'; 30 + import ReportPreview from '../../../components/ReportPreview.astro'; 30 31 31 32 **EN 12354-6:2003** predicts the **total equivalent sound absorption area** of a 32 33 room and its **reverberation time** from the absorption of its surfaces and ··· 187 188 times up to twice the prediction in low-diffusivity rooms. The classical 188 189 alternatives for those cases live in 189 190 [Reverberation-time prediction](/phonometry/guides/reverberation-prediction/). 191 + 192 + ## 4. Enclosed-space report (`.report()`) 193 + 194 + `ReverberationResult.report(path)` renders a one-page PDF fiche characterising 195 + the enclosed space: a basis line naming EN 12354-6:2003, an optional metadata 196 + header block (client, room, description, room volume, object fraction, climate), 197 + a per-band table of the equivalent sound absorption area $A$ and the 198 + reverberation time $T$ beside the reverberation-time plot (`.plot()`), and the 199 + boxed mid-frequency reverberation time with the mid-frequency absorption area 200 + alongside. EN 12354-6 gives a diffuse-field **estimate**, not a measurement, so 201 + no PASS/FAIL verdict is emitted; a target reverberation time supplied through 202 + the metadata's `requirement` field is printed as a reference line only, since a 203 + room reverberation time is a target range rather than a strictly 204 + higher/lower-is-better quantity. It uses the same `ReportMetadata` container and 205 + rendering engine as the other fiches; passing `metadata=None` produces a bare 206 + characterisation fiche. Rendering needs reportlab 207 + (`pip install phonometry[report]`); only `engine="reportlab"` is supported. The 208 + fiche renders in English by default; pass `language="es"` for a Spanish fiche 209 + (translated fixed strings and a comma decimal separator). 210 + 211 + ```python 212 + from phonometry import ( 213 + enclosed_space_reverberation, hard_object_absorption, object_fraction, 214 + ReportMetadata, 215 + ) 216 + 217 + surfaces = [ # per octave band, 125 Hz - 8 kHz 218 + (20.0, [0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.55]), # carpeted floor 219 + (20.0, [0.20, 0.40, 0.65, 0.75, 0.80, 0.80, 0.75]), # acoustic ceiling 220 + (45.0, [0.02, 0.02, 0.03, 0.04, 0.05, 0.05, 0.05]), # painted-plaster walls 221 + ] 222 + volumes = [0.5, 0.8, 0.3] # furniture, m^3 223 + result = enclosed_space_reverberation( 224 + surfaces, 50.0, 225 + objects=hard_object_absorption(volumes), 226 + object_fraction=object_fraction(volumes, 50.0), 227 + air_condition="20C_50-70", 228 + ) 229 + result.report( 230 + "enclosed_space_fiche.pdf", 231 + metadata=ReportMetadata( 232 + specimen="Meeting room, furnished", 233 + test_room="Meeting room M2", 234 + measurement_standard="EN 12354-6", 235 + temperature=20.0, relative_humidity=55.0, 236 + laboratory="Phonometry Reference Laboratory", 237 + requirement=0.6, # printed as a target reference line, no verdict 238 + ), 239 + ) # the per-band A/T table + the boxed T_mid 240 + ``` 241 + 242 + The example fiche is regenerated with `make reports` and kept rendered in the 243 + repository; click the preview to open the PDF. 244 + 245 + <ReportPreview 246 + name="enclosed_space_absorption_example" 247 + title="Enclosed-space absorption and reverberation example report (PDF)" 248 + description="One-page EN 12354-6 enclosed-space fiche: a metadata header (client, room, description, room volume, object fraction, temperature, humidity and pressure), the octave-band table of the equivalent sound absorption area A and the reverberation time T from 125 Hz to 8 kHz beside the reverberation-time plot, and the boxed mid-frequency reverberation time with the mid-frequency absorption area alongside (no PASS/FAIL verdict)." 249 + caption="Enclosed-space fiche (ReverberationResult.report), the per-band A/T table and the boxed T_mid." 250 + /> 190 251 191 252 ## See also 192 253
+54
site/src/content/docs/guides/reverberation-prediction.mdx
··· 97 97 98 98 import Video from '../../../components/Video.astro'; 99 99 import ThemeImage from '../../../components/ThemeImage.astro'; 100 + import ReportPreview from '../../../components/ReportPreview.astro'; 100 101 101 102 The **reverberation time** $T$, the time for the sound-energy level to fall by 102 103 60 dB after the source stops, is predicted here from a room's **volume**, ··· 347 348 practice, quote a *band* of predictions (Sabine and Eyring, or Fitzroy and 348 349 Arau-Puchades for axial cases) rather than a single value; where the models 349 350 spread, the room is telling you its field is not diffuse. 351 + 352 + ## 5. Prediction report (`.report()`) 353 + 354 + `ReverberationModelResult.report(path)` renders a one-page PDF fiche of the 355 + prediction: a basis line marking it a **design-stage prediction** by the five 356 + statistical-acoustics models, an optional metadata header block (client, room, 357 + description, room volume, total surface area, climate), a per-band table with 358 + one reverberation-time column per model beside the model comparison plot 359 + (`.plot()`), and the boxed mid-frequency reverberation time from Arau-Puchades 360 + (the recommended model for a non-uniform absorption distribution) with the 361 + per-model spread alongside. It is a prediction, not a measurement: the five 362 + models bracket the reverberation time likely to occur, so no PASS/FAIL verdict 363 + is emitted. A target reverberation time supplied through the metadata's 364 + `requirement` field is printed as a reference line only, since a room 365 + reverberation time is a target range rather than a strictly 366 + higher/lower-is-better quantity. It uses the same `ReportMetadata` container and 367 + rendering engine as the other fiches; passing `metadata=None` produces a bare 368 + prediction fiche. Rendering needs reportlab (`pip install phonometry[report]`); 369 + only `engine="reportlab"` is supported. The fiche renders in English by default; 370 + pass `language="es"` for a Spanish fiche (translated fixed strings and a comma 371 + decimal separator). 372 + 373 + ```python 374 + from phonometry import reverberation_time_models, ReportMetadata 375 + 376 + result = reverberation_time_models( 377 + (8.0, 5.0, 3.0), # a shoebox room, one treated wall pair 378 + ([0.10, 0.15, 0.30, 0.45, 0.55, 0.60], # treated wall pair, per octave band 379 + [0.08, 0.10, 0.12, 0.15, 0.18, 0.20], # side walls 380 + [0.05, 0.08, 0.10, 0.12, 0.15, 0.18]),# floor/ceiling 381 + frequencies=[125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0], 382 + ) 383 + result.report( 384 + "reverberation_fiche.pdf", 385 + metadata=ReportMetadata( 386 + specimen="Classroom, one wall lined with a broadband absorber", 387 + test_room="Classroom C1", 388 + temperature=20.0, relative_humidity=50.0, 389 + laboratory="Phonometry Reference Laboratory", 390 + requirement=0.8, # printed as a target reference line, no verdict 391 + ), 392 + ) # the five-model table + the boxed T_mid 393 + ``` 394 + 395 + The example fiche is regenerated with `make reports` and kept rendered in the 396 + repository; click the preview to open the PDF. 397 + 398 + <ReportPreview 399 + name="reverberation_prediction_example" 400 + title="Reverberation-time prediction example report (PDF)" 401 + description="One-page reverberation-time prediction fiche: a metadata header (client, room, description, room volume, total surface area, temperature, humidity and pressure), the octave-band table with one reverberation-time column per model (Sabine, Eyring, Millington-Sette, Fitzroy and Arau-Puchades from 125 Hz to 4 kHz) beside the five-model comparison plot, the boxed mid-frequency reverberation time from Arau-Puchades with the per-model spread alongside, and a target reverberation-time reference line (no PASS/FAIL verdict)." 402 + caption="Reverberation-time prediction fiche (ReverberationModelResult.report), the five-model table and the boxed T_mid." 403 + /> 350 404 351 405 ## See also 352 406
+65
site/src/content/docs/es/guides/enclosed-space-absorption.mdx
··· 27 27 --- 28 28 29 29 import ThemeImage from '../../../../components/ThemeImage.astro'; 30 + import ReportPreview from '../../../../components/ReportPreview.astro'; 30 31 31 32 **EN 12354-6:2003** predice el **área de absorción sonora equivalente total** de 32 33 una sala y su **tiempo de reverberación** a partir de la absorción de sus ··· 191 192 salas de baja difusividad. Las alternativas clásicas para esos casos viven 192 193 en 193 194 [Predicción del tiempo de reverberación](/phonometry/es/guides/reverberation-prediction/). 195 + 196 + ## 4. Informe del recinto (`.report()`) 197 + 198 + `ReverberationResult.report(path)` genera una ficha PDF de una página que 199 + caracteriza el recinto: una línea de base que nombra la EN 12354-6:2003, un 200 + bloque de cabecera de metadatos opcional (cliente, sala, descripción, volumen de 201 + la sala, fracción de objetos, condiciones climáticas), una tabla por bandas del 202 + área de absorción acústica equivalente $A$ y el tiempo de reverberación $T$ 203 + junto a la gráfica del tiempo de reverberación (`.plot()`), y el tiempo de 204 + reverberación de frecuencias medias destacado con el área de absorción de 205 + frecuencias medias al lado. La EN 12354-6 ofrece una **estimación** para campo 206 + difuso, no una medición, por lo que no se emite ningún veredicto CUMPLE/NO 207 + CUMPLE; un tiempo de reverberación objetivo indicado mediante el campo 208 + `requirement` de los metadatos se imprime solo como línea de referencia, ya que 209 + el tiempo de reverberación de una sala es un intervalo objetivo y no una 210 + magnitud en la que simplemente cuanto más alto o más bajo, mejor. Usa el mismo 211 + contenedor `ReportMetadata` y el mismo motor de renderizado que las demás 212 + fichas; pasar `metadata=None` produce una ficha de caracterización básica. El 213 + renderizado necesita reportlab (`pip install phonometry[report]`); solo se 214 + admite `engine="reportlab"`. La ficha se renderiza en inglés por defecto; pasa 215 + `language="es"` para una ficha en español (cadenas fijas traducidas y separador 216 + decimal de coma). 217 + 218 + ```python 219 + from phonometry import ( 220 + enclosed_space_reverberation, hard_object_absorption, object_fraction, 221 + ReportMetadata, 222 + ) 223 + 224 + surfaces = [ # por banda de octava, 125 Hz - 8 kHz 225 + (20.0, [0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.55]), # suelo enmoquetado 226 + (20.0, [0.20, 0.40, 0.65, 0.75, 0.80, 0.80, 0.75]), # techo acústico 227 + (45.0, [0.02, 0.02, 0.03, 0.04, 0.05, 0.05, 0.05]), # paredes de yeso pintado 228 + ] 229 + volumes = [0.5, 0.8, 0.3] # mobiliario, m^3 230 + result = enclosed_space_reverberation( 231 + surfaces, 50.0, 232 + objects=hard_object_absorption(volumes), 233 + object_fraction=object_fraction(volumes, 50.0), 234 + air_condition="20C_50-70", 235 + ) 236 + result.report( 237 + "enclosed_space_fiche.pdf", 238 + metadata=ReportMetadata( 239 + specimen="Sala de reuniones, amueblada", 240 + test_room="Sala de reuniones M2", 241 + measurement_standard="EN 12354-6", 242 + temperature=20.0, relative_humidity=55.0, 243 + laboratory="Laboratorio de referencia Phonometry", 244 + requirement=0.6, # impreso como línea de referencia, sin veredicto 245 + ), 246 + language="es", 247 + ) # la tabla A/T por bandas + el T_mid destacado 248 + ``` 249 + 250 + La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en 251 + el repositorio; haz clic en la vista previa para abrir el PDF. 252 + 253 + <ReportPreview 254 + name="enclosed_space_absorption_example" 255 + title="Informe de ejemplo de absorción y reverberación de un recinto (PDF)" 256 + description="Ficha de recinto EN 12354-6 de una página: una cabecera de metadatos (cliente, sala, descripción, volumen de la sala, fracción de objetos, temperatura, humedad y presión), la tabla por bandas de octava del área de absorción acústica equivalente A y el tiempo de reverberación T de 125 Hz a 8 kHz junto a la gráfica del tiempo de reverberación, y el tiempo de reverberación de frecuencias medias destacado con el área de absorción de frecuencias medias al lado (sin veredicto CUMPLE/NO CUMPLE)." 257 + caption="Ficha de recinto (ReverberationResult.report), la tabla A/T por bandas y el T_mid destacado." 258 + /> 194 259 195 260 ## Véase también 196 261
+58
site/src/content/docs/es/guides/reverberation-prediction.mdx
··· 97 97 98 98 import Video from '../../../../components/Video.astro'; 99 99 import ThemeImage from '../../../../components/ThemeImage.astro'; 100 + import ReportPreview from '../../../../components/ReportPreview.astro'; 100 101 101 102 El **tiempo de reverberación** $T$, el tiempo que tarda el nivel de energía 102 103 sonora en caer 60 dB tras detenerse la fuente, se predice aquí a partir del ··· 358 359 Eyring, o Fitzroy y Arau-Puchades en los casos axiales) en lugar de un 359 360 único valor; donde los modelos se separan, la sala está diciendo que su 360 361 campo no es difuso. 362 + 363 + ## 5. Informe de predicción (`.report()`) 364 + 365 + `ReverberationModelResult.report(path)` genera una ficha PDF de una página de la 366 + predicción: una línea de base que la marca como **predicción en fase de diseño** 367 + por los cinco modelos de acústica estadística, un bloque de cabecera de 368 + metadatos opcional (cliente, sala, descripción, volumen de la sala, superficie 369 + total, condiciones climáticas), una tabla por bandas con una columna de tiempo 370 + de reverberación por modelo junto a la gráfica de comparación de modelos 371 + (`.plot()`), y el tiempo de reverberación de frecuencias medias destacado a 372 + partir de Arau-Puchades (el modelo recomendado para una distribución de 373 + absorción no uniforme) con la dispersión entre modelos al lado. Es una 374 + predicción, no una medición: los cinco modelos acotan el tiempo de reverberación 375 + que probablemente se produzca, por lo que no se emite ningún veredicto 376 + CUMPLE/NO CUMPLE. Un tiempo de reverberación objetivo indicado mediante el campo 377 + `requirement` de los metadatos se imprime solo como línea de referencia, ya que 378 + el tiempo de reverberación de una sala es un intervalo objetivo y no una 379 + magnitud en la que simplemente cuanto más alto o más bajo, mejor. Usa el mismo 380 + contenedor `ReportMetadata` y el mismo motor de renderizado que las demás 381 + fichas; pasar `metadata=None` produce una ficha de predicción básica. El 382 + renderizado necesita reportlab (`pip install phonometry[report]`); solo se 383 + admite `engine="reportlab"`. La ficha se renderiza en inglés por defecto; pasa 384 + `language="es"` para una ficha en español (cadenas fijas traducidas y separador 385 + decimal de coma). 386 + 387 + ```python 388 + from phonometry import reverberation_time_models, ReportMetadata 389 + 390 + result = reverberation_time_models( 391 + (8.0, 5.0, 3.0), # sala tipo caja, un par de paredes tratado 392 + ([0.10, 0.15, 0.30, 0.45, 0.55, 0.60], # par de paredes tratado, por banda de octava 393 + [0.08, 0.10, 0.12, 0.15, 0.18, 0.20], # paredes laterales 394 + [0.05, 0.08, 0.10, 0.12, 0.15, 0.18]),# suelo/techo 395 + frequencies=[125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0], 396 + ) 397 + result.report( 398 + "reverberation_fiche.pdf", 399 + metadata=ReportMetadata( 400 + specimen="Aula, una pared revestida con un absorbente de banda ancha", 401 + test_room="Aula C1", 402 + temperature=20.0, relative_humidity=50.0, 403 + laboratory="Laboratorio de referencia Phonometry", 404 + requirement=0.8, # impreso como línea de referencia, sin veredicto 405 + ), 406 + language="es", 407 + ) # la tabla de los cinco modelos + el T_mid destacado 408 + ``` 409 + 410 + La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en 411 + el repositorio; haz clic en la vista previa para abrir el PDF. 412 + 413 + <ReportPreview 414 + name="reverberation_prediction_example" 415 + title="Informe de ejemplo de predicción del tiempo de reverberación (PDF)" 416 + description="Ficha de predicción del tiempo de reverberación de una página: una cabecera de metadatos (cliente, sala, descripción, volumen de la sala, superficie total, temperatura, humedad y presión), la tabla por bandas de octava con una columna de tiempo de reverberación por modelo (Sabine, Eyring, Millington-Sette, Fitzroy y Arau-Puchades de 125 Hz a 4 kHz) junto a la gráfica de comparación de los cinco modelos, el tiempo de reverberación de frecuencias medias destacado a partir de Arau-Puchades con la dispersión entre modelos al lado, y una línea de referencia del tiempo de reverberación objetivo (sin veredicto CUMPLE/NO CUMPLE)." 417 + caption="Ficha de predicción del tiempo de reverberación (ReverberationModelResult.report), la tabla de los cinco modelos y el T_mid destacado." 418 + /> 361 419 362 420 ## Véase también 363 421
+48
site/src/content/docs/reference/api/rooms/enclosed-space-absorption.md
··· 210 210 211 211 Requires matplotlib (`pip install phonometry[plot]`); returns the 212 212 `Axes`. 213 + 214 + ### ReverberationResult.report() 215 + 216 + ```python 217 + ReverberationResult.report( 218 + path: str, 219 + *, 220 + metadata: ReportMetadata | None = None, 221 + engine: str = 'reportlab', 222 + verbose: bool = False, 223 + language: str = 'en', 224 + ) -> str 225 + ``` 226 + 227 + Render an enclosed-space absorption/reverberation fiche to a PDF. 228 + 229 + Writes a one-page report characterising an enclosed space by the 230 + EN 12354-6:2003 Clause 4 model: a standard-basis line, an optional 231 + metadata header block (client, room, description, room volume, object 232 + fraction, climate ...), a per-band table of the equivalent sound 233 + absorption area `A` and the reverberation time `T` beside the 234 + result's own reverberation-time plot (`plot`), the boxed 235 + mid-frequency reverberation time with the mid-frequency absorption area 236 + alongside, and a footer with the fixed disclaimer. EN 12354-6 gives a 237 + diffuse-field estimate rather than a measurement; a supplied 238 + `metadata.requirement` is printed as a target reverberation-time 239 + reference line without a PASS/FAIL verdict, since a room reverberation 240 + time is a target range rather than a strictly higher/lower-is-better 241 + quantity. 242 + 243 + **Parameters** 244 + 245 + | Name | Description | 246 + | :--- | :--- | 247 + | `path` | Destination path of the PDF file. | 248 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a bare characterisation fiche (body, result and disclaimer only). | 249 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 250 + | `verbose` | Accepted for parity with the other fiches; the band table already shows both A and T, so it has no effect. | 251 + | `language` | Fiche language: `"en"` (default, English) or `"es"` (Spanish, with a comma decimal separator). | 252 + 253 + **Returns:** The written `path` as a `str`. 254 + 255 + **Raises** 256 + 257 + | Exception | When | 258 + | :--- | :--- | 259 + | ValueError | If `engine` is not `"reportlab"`. | 260 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). |
+51
site/src/content/docs/reference/api/rooms/reverberation-prediction.md
··· 287 287 Requires matplotlib (`pip install phonometry[plot]`); returns the 288 288 `Axes`. 289 289 290 + ### ReverberationModelResult.report() 291 + 292 + ```python 293 + ReverberationModelResult.report( 294 + path: str, 295 + *, 296 + metadata: ReportMetadata | None = None, 297 + engine: str = 'reportlab', 298 + verbose: bool = False, 299 + language: str = 'en', 300 + ) -> str 301 + ``` 302 + 303 + Render a reverberation-time prediction fiche to a PDF. 304 + 305 + Writes a one-page report of the reverberation time predicted by the 306 + five statistical-acoustics models (Sabine, Eyring, Millington-Sette, 307 + Fitzroy and Arau-Puchades): a standard-basis line marking it a 308 + design-stage prediction, an optional metadata header block (client, 309 + room, description, room volume, total surface area, climate ...), a 310 + per-band table with one reverberation-time column per model beside the 311 + result's own model-comparison plot (`plot`), the boxed 312 + mid-frequency reverberation time from Arau-Puchades (the recommended 313 + model for a non-uniform absorption distribution) with the per-model 314 + spread alongside, and a footer with the fixed disclaimer. This is a 315 + prediction, not a measurement: the five models bracket the 316 + reverberation time likely to occur. A supplied 317 + `metadata.requirement` is printed as a target reverberation-time 318 + reference line without a PASS/FAIL verdict, since a room reverberation 319 + time is a target range rather than a strictly higher/lower-is-better 320 + quantity. 321 + 322 + **Parameters** 323 + 324 + | Name | Description | 325 + | :--- | :--- | 326 + | `path` | Destination path of the PDF file. | 327 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a bare prediction fiche (body, result and disclaimer only). | 328 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 329 + | `verbose` | Accepted for parity with the other fiches; the model table already shows every computed value, so it has no effect. | 330 + | `language` | Fiche language: `"en"` (default, English) or `"es"` (Spanish, with a comma decimal separator). | 331 + 332 + **Returns:** The written `path` as a `str`. 333 + 334 + **Raises** 335 + 336 + | Exception | When | 337 + | :--- | :--- | 338 + | ValueError | If `engine` is not `"reportlab"`. | 339 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). | 340 + 290 341 ## sabine_reverberation_time 291 342 292 343 ```python