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

Scattering and diffusion coefficient report via .report() (ISO 17497-1/-2)

Add .report() PDF fiches for the ISO 17497 surface descriptors: the random-incidence scattering coefficient (ISO 17497-1), the directional diffusion coefficient spectrum (a new DiffusionSpectrum result, ISO 17497-2 with per-band source-position averaging), and the single-source polar-response diffusion fiche. The report bodies reuse the shared two-panel and polar figure helpers.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 22, 2026, 12:52 PM +0200) ceefef81 bb630ba2

+2156 -6
+90
.github/reports/iso17497_diffusion_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 (Surface diffusion measurement) /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 13242 61 + >> 62 + stream 63 + Gb"/L>AkP(Olh#jML&Dj2U$3u0+I9#4go!HdBiPV"j@&A?m2k;]&i%M9i87bq=P\6>dEYan;*<tP"Bi-@54fX<aHsp,JM7CM"Hn+s#g=.H9P'<IQ@AtGCK*kHMMmXBhm.#YGU3kr8MX%Gg2ZBlp<73Wn#G#3`"Y\ca+-GQTi[Ajjq:*Vjao`Y.jjdMY*[01;,fJ)T(PCC'Xk%^\IZ]ea$;7]BPeDY@#%kqr[HY3o@VsqsJ3C^:UoSg%=j=e'"AY(&pc'h:@bEeh)aV^Zjn<RnX31QH&^!,PddT9s+45?iPTG2>)8.VUnn]h9"tA]<cG,9q!\A:Y4Kt]iVhpCMN==ZW5aU06aU,^H$^pN8O*,VH%nK#3WmOT,tu!UV.(B1DJ!CFP631dQnp<[rc8$nD)O1gcgZlaf*]i2#=++FRRDJ4n1/K0%I2+Dr&L2oXKiHgDbk(rr(CCpLh,&P\W<`;+P7N5)"dWfY>/3GXoDk9d>(/]R)B@\PT[u^3d%Y0AjIVIWPAZBO*H/)U6/AhmMA.hO\&eq&]H[O+GKn._WN.4s0*Zs3a\]hctPU[QF<am+''mqYKirX39<X1_jPam&mGd(Opo(^S_#peNa*#mK%bZ.fA^LpU>@YMpicFX&g+pm\W5IbCBK24U-53r<!;Y4#^GP:>%Z%Sft!14TOqW^:Um%_V\jFU\hR+rH^W02O_CJjSAB>(t\@\It)P=mX1.SGE;3FOPo6gmIbRDH$Ok"+oPL]r9@a8qVC8&e*5W1M`7!c]5(mB5JPjmK`:ShIPHZJp@@#SBsh$o&BO%LQsO'hiZV+\n)4DL[lhT>Is&G72+7YY#<scIINWtB(WqRZe`Q<AiMD^Tp.KD#(WZE50g6tmq?eEmq@hKWim4=>m!A1tpR05iU:9S1\bRC.plE%n=4_BiF#SOAmek<gDm"SWrq,+)e#-a[XnE:l;Eis6A`+W*B>6M-MdW9[h:oD$GlRLKYO48Q+5&O?DI^ih8cShV0tdA'J,Y%WpjZXU:q=85k>UYc%\Z<RSP<J_%rV@&n`tZdL?(tlmmhDl\N.]XMfQ?1a[O(&/Z/))q"rWnq>J;#jSHJI=5TNQSmK2.ceT0aqosJ45?-o9.f$6eJ:''_\>;4<k*fPB:4/St7-sF>k>WT'Z[Aq)1gT4D`!;HDm`L#BFo#h?V3OmE_pH;kDZ=[ZJl)%Y5,s4imSeLRo9"EE'Meimddd8C>WLB]pK-9W5M#A<$@Me#o(\`8FaUt!=?g\RI;np!@.3*q%</oiq$@[_eF:o9^JFASIH9>;@su)#E%+^21KB.sB[f4.U+*1<";O`n/:5.+\M8jT8tnF$^R8E@Aq4`]j(/2A"$t?jKV`HKUAd^5-*_.V&#"-Os+=@SP\>Ds=B-'qm!No3A4f@?JFb9G\B=&!WN#1]aQ_guDpQ!/g,uVO(E;pN`K%T1E4`1/P3*qlQTNq`o"$i9]*_;UAfX37kg"cM?7NJ?<H*$s7MMB]/9p5%0lSE0;Qse'GnL"G'\8dRBY's8hm3e^\s!Oq?7NJA<H)e9)#<TO7p\LnriUj2f>/m*c@SPm.elb:UniXjmV_R99*HC^KpulD.%!DkcF>sZeZ<XP`pHEu"=hEU]<g:TR7]Y4#V2<.a-^mk'g2%b\TcGP5g#I6GEk8\d`6Q/Ji(E5E-IR/o$YS!KUS0q]Zmpt9%BI(iE\8_5W#@mh9o_j=Z\MlXPC*T[_%DKA&R))QJK`Y-u)TbU-2kjZb1eh0rtjNa6Pkc@VpINTA8:=\!M6&l.3_-!\M>QOUXT9N5.i^/OaAld^B/=Y!0k#<@HL@r?K(]T*5;!I[1f1B*Mu6TD=3>bQqqU.mMp'ApMg1dP&KFlg5)a4ObTO*HEu3NlL<Zs$?+f\H.HE9e^\dgV8;U8l[gPe#Vb0f[)+n<ebNLl@(B#o2rp/ddW/l3a&-t#[3hFoEe4MpttPSG^Vi^#AXGkpKg1^q<n0N"*"f.LNoID0@'2Y`(!U9"+#@&HM8T-]O`3=DCP5UQC8?<G0g8)b4P-eT.Y"s#@U,k/J2L\@L)+?]o`FX7NQ$_:e:jh'09i"LFGCCX_MZl:=P02IYTeOP5"R1E1'pNFmiVQ+p,U*ofM/ei:gU/:,%MMde[5s\D6CVk6%tbD5HU4Me2<6mh%251=O1rWro5@bTtQems62QYhuho@m>^]Y/M7?:6k:]b@1,$mF</\iOJWV';B5)h"n0tC1Q[$hf3)]UO,/+C\5P%!Md#:R87M)DsJJ>9O1E,Y']G,Tg[(>MYKfMdY0R;Hm@_'g7#aIg=Ba^lZ!3+HW"rs4^M50#W\aS\.W;s.CZl,rfGk#=q,7"#mBRA.r/`(Q)(]5V6sdDd"l+/>k;PQl.coc4CmdNr;C:pF6(=!KXj8GOQGnREN.Z3cD.ds>BBs=KtBPFip5".>Iul,l@9IAh"t!+-rqk?O;-tR1*dn)%FI$L@PCh;X7RE\^Jt\n[kl3>hl)nnPJ?VjjEXR9#U>^2Osq^!!>/6)F\(3QV599@RU7G9E)<&&Q(MsWN=omge:[KS3!aUm[-2KUFG4iMIeHm7UeYFQaUnZWPmSc;J2O9'W=R[%_RiJtY?.X$(`Mrgm0i&.o%7#-\1ZmNer^E,!!Fos;[-s#6O'"JIX,9k-_VB)&^VXL-1mS#BI\:-/O*s%,`QDVma6D77QWQOkADqQK,b8m:.acn;e<7gN3`]/LZd#?I8@]hd#m2\J/6.```;B#TNrkk5G"J9)*_7SOXB9.,Tjbc<`BRWf*lu"+LL"j4sB(L7Nk'>c/+NU"NeRP8W-#qb/JAb.>4Nm4D85d*3DWkd/fK*4laDi^S=Pt_k>B@&Z-m"FP`C2A*R^Be.84q%rpu8<o'UsJS==-ec"`3[h$F(dc$"9#]!h4"sss1p/c^CY4;M_<,9MU6;fO-*6sD*YWX3ETG<nZ*HTkCmK>0u,bRol;Ttq%:`BsOM@#\?RkVM;,@jKSg*bs^dAu?.17(No@:b/.];/otni0qE[ZEoh)lfXs8*3A&E[W#(d%hkGC_%7k*X4t'P+oCOnMUP%YI.nM)G_V5:T)M,==>MY242l*Jg3?LONe,86:p:%RZe#1hRsJ_Ah,dj+=IRS*+D-%'bpc8a&0b^M[NrK&KDsWjfjQ*dR9!iGoa2\fJSh+6=M_bc0(:J=L-%7Q`-n%rKVI1'njMo=X)jZ:;gp*E]HDjV)fP$ZLC6HU7=QJmjSe2&mC%3j?u*7kT!@-W)SPlneFZUYl$.!5g>LQq\kBm:VuNdF61fq%g!*_H(N/84^bPZ[$J([C_2=?BsRS;,fCliUQGo773e@`KkG8dMAM;ri9m==^ed#M=WV]\1&#iCJY$SM"B`KHWr^3g)']\i2j9[7;\c0jY\00\jX6GCVipJ=lAdpoMGq6dFs_0ibs1>8@L3rhcH%KBdaU2*8m1s1I3Ue]-98.o!eM9W,U$A&:72%a4NeI>mrTl[c(@&c)F2nPWj:kpRa.@RPG]/bL`h[EK'+A`6,j1-c^N@UGU7lt8R-=U7EC6SZJQh_#@&C]<JaXVKZNA=jH9:*CVU9W7SfV/G`ls\)e+s`K<&i1`R;,$d=_iu+ge'd"B(sNOAd91&uLdu@`A1^PX/g,';SqI]K.'3QD.QfSj<$+PlQ2FhGX'``b,gA3"B$7Shf:J.@P9(jLcXr:UaFk-4`!lP4[BR![M&kQlqAir]7tkPZ]#L;K^Lp*\sVG[,<=&(7`B>WosojZLoCTREO]5@8^6Op4G5UN@D6]N3$>d7TL)22a.VW'ePKJ&".U9Z3Zsb)C&$-E]ItW(gi:5Ej=?s;(3/j["esrO:miq$%9*BEl"J/V[;q`g*oT<m6_2WJ+O0^b[_XYQHXb(j<:o"=MH\/Q=P]5e@\)ChTRAP9OaY)\S%q&N$9.(@*gL+O[Q.KW6X[?.[0'+R=uB`W>a8_8.`+ahP@/.'9".;EB@oC@VaX[<%XRg"@T-0ZZn9TnI)a2PUF5?+/(ec+bIG\SKf>%FAj.%:HF)tG$Zd"XE=S$`W2j'GjN#Ai>h$TC8StD3dLKoF]Ph>'f@OC.J0*3V6.ok2GMfcE*DU=,RI<Rp+!:FMZTqcZ"Dm]`)$gE\<QcrqZUPc8><stAlcR<.'qZlZ#2)5b;a^]*($r'<3N'd+>4N(gt$MS[@D(ck>#Q1YkRpWOVK-jJPu9%aD+r6f[_sPr7]0!CUYb3B;-)!Upi)OZX`9U5^EY_b+:H<$Q1"+@7,>[dbBl?UHj.[0%RIhomCtg2H[N+aphD.!\81M,s5m@8\2pHhoOtJPp(@UNecI"%=;MTGNto(`(4F(W=[&7eH\Pe(!`guW6Q8B@l24P'h,;Lh)\AjZi-S8\dB@<0i+`:2mP3==nn>V`PTBp/NShEO6S7!gJ&qigiO/Rl`2..jIr_]P;gI(Vc%:a7_mK;!`P8n.f*EoSI%giE9ge\IomM0AXbftbplUZAJ+$'AU3<]W%=AMdVCL_W`,muZ't*T!Pjlj5rDIpS.`t9\u65Pc#A#&\gn,Y)OtWbVPKlF\3L^%57Fjtg*DAIoH`ZW9uCH@S*VG)3Y:*n9c[('f&4*$Z#qL"Ob>@rg#0aPaUjUa>Wa=uC4M\u?*m4;4uU4+bYhHF9%7_)">;sTq0B@_bO$B2J%\G>;oK%I#>C/k4C8qh]>ekL%7M4Y;crQ&#rCEm536=t4T(2'IOa3YCffk&oHWA!J.4cQ(8e'#"2V.BJU^H'n?6gX":5.pemX0&-k)IW(pM%hg,K36Cn7Mu-;U%t9[Z=4R5M5b$j3.EX=%h'6bP?VkO>2s7h7Fbe,Ak_oZ)4_A33l%=X9J*nrGe4GMk[]UsU-/$>C-&<qM+m+^X1iT--Zf$9OI'lSN7*'64BJ&lbRc;B8(1C&NX*G2,k<d\Pu-V4A>c')(YV,Hjde[]4]GhV2l$B5ZIA$T8r&5<G`k#b?GV*o]cWHoN?3h`ds`hopu&XE_Hk`<HN*^%q$ejou#KVLE\^r;^6p0J;!V#(s_qK!q<Eo`+&d\G7"[IYO>aiFbU*gPLLK`5$P*?op*Ifef0mZe7/?2U+Nmj!S]m"HU(BT@^$Xi4oMMB>(,J/2m8G-o/aSYj\YO]I<hYA89+D9\S9saoFP4,PkBg:-nOIZIr\?QWoh1\ABMq4A%sNVSBgf<TUKZb#!rXi"Uf(iC2-P%j:8d]GOWOi(7*!BkoMQ[-A0eQE@VqQY%sU:c$="#?#)/&#-ie#L+e(,'D0HE^dY5TW?\<<%.mC-8a;4`HSZ%#ifH%G*fUXGnBYcamPus;@:`KnNETa*2'Z`Qi_kW(Y/Bcd_k$K$q0\%7OFW['VM1.k[5hm7T6p")4:t[BoG0R''-DW58jacSuM/epGmOWjp"+B*if<Q6jk\MR%tSRb.pN98]IGR:XgZb[t8+7Xdtp/`R-0$aOd<Q?49%I'-Q+gDFVQ4BGaqsUoU2]Kpr_AX^M:-"mPGH=Sb:YX!4&'8],EP(PNGdl"OF9=rNUUXj"cogk4%GFfQ8;+mUuDTtV(LiBZNq>l;QRoZf<7U:Ca[ZSg:'n.>cOAKN3;CaEekL/p%t1*;eI/%7T.o4R&iZ5O&09,_#IjUs2'qmD1HgsRZt6@2Kc%0LnOaq,AU$0O(8U#&fQ'QoBN*@U7$$UHZ$bOHn-5g*F]A@2aJd@u>tRudN&d[iHkgJ!ZfWLk205D89@E.,lm2Eas0)igF"\T,_`g#>spG^hLb_A_l`G\gK$qFo=Dc3slXRE$k6Gme5bUcm<,]]>-`EeY)*pF$=s6-fFL)DAGjHdqoM-curQ$[U5W!;A0oXRo$<%$Ze.qR+Ru+*!a0$kq,crP]5KEat<"b?WBg=8OL4h;5l;B5@ikdH^sACiM\:/agYC:sXQ5d>(LQKsq/hgO\"L^k(-g]kWub)\BXe&3RHGnjrr">3CjPl4j(g/@kkuT-=j%%f:*urO0UmgL7kCMlrZk1,uooqC*Wkgn1*U;uC95,i'4][KVL8^EBe`WS^ofg/[gl6XUNfBJO9dRjk`ObY;J_!qQ_&hV/D/[_hr@=CL(Qa:Eei3R";PT#j"m8/`]D&bjUqe6m>CS2NmSc&lsd,hsO)A)2%WNbrU*H3;^PZ&R'6Wj2!a9Fu@:ZZGfG*adgA62spI%;?:2Z"F\@4Z57r%b:UTVTsoc2Dju'5U/L9R6"!0KFN["-sAF!!eQih5DPb[mB:OW\jan`g13k5:D:3]Q::42)O@ko=f'f;%;l#k0108/LR=IY&!'3f0/YI[cigVKCJHl3i=/=ilC#$oFA!#U#i^ZlGf#QEQm%R2SAN9YZ61&#DOO[-l<8m;*%rI.fSNQ=-UD6nGc"J]b#L%`qUB@eA\<lWf'1P&@7iIqR4mjLS'2&gU`u,QaQV37=q4&2+ZN^]E=M`(IG1&gnlK%^`%NR4-+:%4/`N-JbN\B`[;FE*CaeG?#<mF<8Hd9n#0/]RAqA"6`i%aFLd`ClR6&rAnRtM7K\'bbc#/S0#/o+nA[8J3q+-SI91l:01]WB=GV(8),8QUIejr26V\#3h70a%/bb5W,V(/Xo\h6II6J=*9(2AuI("g)DU=CAZc3<33%Ya1g-`]^8T$"oPH#1q9IhR^M)AhbYlUaB&^.?/?Rag>4kRkfmaadigQ?6I:p0je!/Q>@Gjh,nIo0`2VlAaJklbhE+H&4o;4O4Z18ljN%aY1_G`JKU89K:GF0Zl')@sqAQ2j1O;1`"cq/r8%e"nOKVneqL>N'ADkFZ?,RjIe*Ak(D>1kcqa;EWEA.Gi]_n5V38#K9I3'#<HYV/1DQJc"ZtWg01>)Ss8m[B;:rLTV:f.6FOi;;B6[Mm\&UEB5i6iGA6L8oeBp-asPGe:E>tin<Nu5QXgL?gk[dSn%*AO;0;jLEAk45f0K.b/2l,a\8ag/iddU'kVu.c7!^r#K-FJ/&RS+<Q12VBd/\Hl''k43,^;[:p1;<7<YQMF(kPa+HBWJhBobdB,As=?_]7*FIZ#8,gtYm]Mhs-&OlJ+KLd<t[7tC:Vps1tGL21#pcQYX_Q(o:[XTWm,:oP^,YYO@)J[)E\_/3uu=Vq9jmlZ1LWC3[&gB*d%H5*F]iQ0O9<Ms]6A(pXfjJ3iSC-X4)D'`p"VJstF"/BJ"R+rsVZ>N'$^m%8XE%$&S*Q/'rV4dXYD>('0UB;QFK'<gcBM47mjRe:i]R7qkp)\7q8#K'(l/4eVP#t$N,7ZCQ@B*:JqnAEZYXFe#e`,gMTbU>jGcZ88_02I#oL^[RR;JZfoYE7$hK]^B#<o^%CmIsH1#*o(i&MrnFu2B^#JN\b+Yko+-'jP/(bu/rcna%$MOdra3C5$GC1&6ac6O+&Qp`hmGuH;W3Q4_R:!5`QgT)o];dIDs<Pq<eh=s<m-s3%aA_=".>D;=r\o+V[\oX5di;KUW.O0A+*T5fHM09ap!3uWeB+.&09_><m,^I(Z?#@k5]k<UO+unF3Q#m@Z(pW>fF""\tO+;@)<oM,1`:7I4H#kqpd8n#0ds3,&@R]+W#RP%oG!H(&,GNlV9/@);=D0FVIFgo9I[#6c7I=q:4s="gj.\N@R3\.u)\-$`"-nW#23EYXGI-CWk8e^,BpKK%?=mRQ*8'p0WR*j'Z?l*@\-GiN@4(U;e*mAJ(:+\&\YNBJ@+;NR9>sT)k$Zf1*D5E*N)$ULX_1Zrg>e,.3X<!-bCh@G?[T-i\h:C9XKi9T=Kla6lEt-c]LI/4]OJeZMj%#Oof.;:eZ^jJ*=(gK[:Ga#9*7Z,7&H"lZ\5S07Z35HrCs"rN>U=Y!=`MSH=n]C50(i<=mMct(K$9LDQ[*DPm<P7PuU"E'5W6q"Mu@]1hWdu/$u9.T[a:@*;>TI&928!@hM%k)XCS6O/:lbO;cY^0=qhV<0phaS?im$;ie7C.Hl';4G5Y\f4IEaCU'H8b84G_j])tRl;cAU/uB\8)+0[E*m@f4oB=E>Flm0qT433V@eq@UGsOIq_iY?M<]`?3fNjR(TXuSCO=)h(:`+1[eKs+7'L2>$8/e80TP@4\3Aj2K<Kj4CJtto8c]dM/!&X=XA_B^kLajrfmWe6j8SX!"1.e7)^aqeBQJajJ^co\I>Au$,e&k93i'<JleBF3fV\d2i;d;rR]p2,$jq4K.Vm$MI1e&:ZYm>3)e!s1G)bTbW:S3/*RV+;0G`r1tY*-7F6Edsi,3MlVZ)q&kA`[#pHYql3f@7&>\5$f\G[0U3SRiWE1==!SNHq<aBT8Dj3BB?FoEe>d'WsM6Zs_Z;Y0%J!f\'oHgY5_kH.=GrFr-H-E`PYW^l"%W-)L7g]87qdY()n:FE[1/%RsW)&!"fuV"/5Q8m%pTYE<Cee6+KJIdi>2I:6E%lRn<S,.N8mPGIn%@3+VO9p3%=0@'4/f[`5j^085IDYj+":D^KXh3@=i'm0&ND.eWP3red*5CW:V:#EZj2fDZ;IaFP6IeSUojfms0hl]2[`r!W:pY;s]YO:2M?Yu,A]I0ro[Xm%-pak02>8YpC%-p,+[d[)g2"EQl[V>ImU)uU6A:_uA8*8=L4F?UnOi8a58Kj!/e(h/.[3:H&`'#mg[ipY](<.*tisEcCph=1O*5KdiO!.)1`etpu;+=,Q$DR`e*RVp?@?_4rC`)7o@[(Na7ebB]<X>5\8<JtqN>Ff_>E+f;Pd^-GcAhS\2f5s:X%ce]6Z'5bL#9baDdhV6Yjakoi("G6j+Pq_IeMdA"$\=#B`UG';=3qZUEplKgOS/cr15h9@ire-b7kr'P1-4')cjLLHR#JEmo>lRZGgnWk":M6PInB"'L""m+Hb,*RU;S6fdqLUQWrMXHW6B$*eNR0*6^WoQ`G"RZuLhj#/+OJHsVktq^=We<56!=ZSSNr[>;2tR6FgILJ#b@>H1?&(bM[([NC0:4K-:1CN'M(7*+EUbt1.cr9i_E'M=W70\8\P^lQI[s$tkk`BK&Ch%7L*OX$$.Z?p;KPen*QWRFY[_+aA2Jt"872%&S(:r^c%&5lk(I\__^*fbX`4c\SqGqiK%8ZOCSnH_%^.h55q0ci>lc&WY"PC'1=1Yh0W(%;]bO?cd,Bg`>ZFA,SkD.OMO4hVr]h'K#O73lOS7jf'Z3Ha#tMZk<X*7X%@R/lJP1qZLM+:5R!&.1e[#N7#>*VBFHdh9`E:W*EL/e[;XU7X9\^Ytn@![p46lV'r]d^k^s\:K!'TE:onXV_ROVnM,X\)n\b30<s`@p:[m)loc%[*>R\G[/K5+#,HT,3*3lb$(9cd:3!>4t>/cDKgoFA&c8oNIa1*Up5&CDJ13]f.*Alegbr;9h:=%N/6RG$Rj//@f(1,?#!&>K8P[)*GEXD$\9&DbOHn-5g*F]A@2aJd@u>t)ec++qWqI+X-:g%il@[-[Z[3rB[1%(Nih&3%[nJP^t52@T%_DUc&DIR+*pql`;ea4%cYN<Yqj9YjNhi8+Q<5tMN/Df]3#hgJa1CHZ,M`"D)sXYdZ^(!*%T3bK_#%;d_K@=Z3Z=>+='+JE$@e<.mJ@gDOpit4NAFC='tR<Nc8Cs/n>033q;]Mdg]NieR6qsi!q2.Gq^+2Q\mQ%*0)p6CKqMeRE,3$6WJK%g!U^JJ@k]O:^^T#k8gbo$5phUXLk`&NKm:J>@$BOlon&o@[O9hoI%h6BD5%URS[HL0(3f!jj>1tOeNL01G?[YG(-m#&E"@qS)]/r\_JLj9P3okOp)pTmkcGK/mN$Hb=8&6RYcRjC7fT2HH4@]%2EOVRaaKh0nks_A$J#d#bOY0;>?JsE,G+&;<,F;/[S:e2oKp;7E;qd?T8Z/4/7j5"P).^_\#9@hp0(NUb3Z"Vf_TI5&a+)]!`.8WcrS"pGcT;&/<IscCE>56/.aUg&#$C-XQ%'b@e$Nln#B)"lOplW3R#_/3Y,XE8W\\53d,IH*X)<R=dQR*eAc/^!aPEN+i2ELj'Waa[0Hsp:/LHEU<^#T4G,T,YNo)iDNSk(P(_#5F`DrI/)+kNYL&S[KBFMihVUA+1>rMlkG@rb2mc;dT.5JkKsF(0KkJI-n/5UI1n1WcCW2A1pX/kfjDH7nX%icieu33/kGgrare=ZW[4OkT[V:=?ho)!rZoe;>KiThB$N2mRH&X]ZQ(fOGT.i1E.N()7h0nmL7P1L9K8h2WQ/.CPK#Wf6Ydb:k\]3Z7B=?i-@E-^`THf]TFi]5,[B,#92oIt-Z@u%\l'4@i3m<di>g2%c0N(n)'qDim#X3N+(!6pH7;Nh7Gi3rP-<](2[JW"/TX[X!]Q'?]oj.W:nAt=NFoHZiq&g;bQt6s[##+$j$ji.*uK+Y-`)`sKbc[%_(%Fh$0V/J+L[3jNj)7h;D'ek?\RpK1`N^5Nkb9lg.t<k9&EEZoP6hsZsfsMXG2Idj==L2F4\riB4t8BKuKZq)N/Zf:k%@qX=/'a'>h;N$QV4/1eF9Kqad<$>4NX>i7;k6CAKdW*Ob0!>P+U:)h!Mh`>j:&,U#I2)j*oY@*T$e<])FdMrV"[/`DHsV7j;`8itJ<$(PRlHs^8#`3%o.k_;>]"Ss8I'L$`50&Yt0[IM@,D5<^#'86VMS^*I1a+\_#eKTg[m&(SaEh6:"a?%bO%+%=H0.iUUD4PeP8&H\L&M3c!/r7@#'7h#mS4[?`C;"ZX8'qGL:h&1t<n2h2Ku>?]l2+ok+-DLjV,U[=`rigEP`s[T<I^>&F+u!_"?Ka!lXQK!p@foOd2h1g0WB\1iapU0+fa>T?U!7rIaH5$,ZrVbf(YaVp(++j+1R6tHQeJko.P6V^-:>l-e(Y:Z:5@=(2(s*&Xhne@j,i$T#n_)MdRNB*V=`JB6M6ei''B7j<,T29KIuh;*R,TEDZ:KD)(YYKU_dRH>bS]gVsB]F)opQ+a$j2S#N!.-8geC,^oB:7sp.HQ%B`S1/CdYPp\X"3S)J$6Sf5HSDb%Wb\PlTOl_B6;NALNo._N=WA?gmIb3&=d8G4A2dFnN^2ML(m.]GfQ]#m4:7++^?m(+mE(0mLl36!Gb%RTgH$KGb$^/f(H&LWt\Mt%R`2M2b5.::B>]MOJ7lL@,n.9;gpT`=eSN3AU`lcg7FpWCU_a"(n].\V]5aXpaHZVtES-Y1ZM)XVZj(mV)R!=!Q]IC60?OIBtki6I<JP:Q89fuB0O0=BF)Xm2RnN[+>^*XoZJ]HB\"*jD#RWsXS*a]Qk^t[mbiW5?mHqiCO[Z@V?#>"`S2"@hTLkH7X`cFn\+>R#b)hEhB=GX[9@R]'.&KNTF.VfD()^6oR[=JT"Kne^OQ%^'4T<WdU1([BR?)qD[5bue%\^`hF1#Ui]lp20LmJY7a]2f-1msXVC/6WpI*_XtaXmJK:He)G(Rtk,8bGjU2H0@'G:E0Fa8/RugUMkMA+cTJ%\V>Cnd@S'b*`C0pCA%>(@f5916Q>251[55N@7Iqc\SaW@NsR],kcfP,F*Vp@%O.JK[0J8\c,h>22+uqNrM/E`WtKZ((+3D\c,b:<PcZ7&m$0"aSrESJ^^8.G_+r-N-mnoO(FQ\XHudkkPH\C0g3g2JioHbb-b#5sjFC?*Tn+Y>HA@K^;g02BB/n$=dWc_mk;^Th$?dBNk;^[E1"/&'cZ,1f_Vf9qTAq=A=>nnf5K@1r0NPYkIYPaf\Gi]Ao5bo6K-d@2R%NCmhcqJ_7eo')o$)mGD3U[Q-m,PPDo;J]L\Yn9AfKmq36VUhDVHj0a'h0a7,<Un3=bZ[^A'V+cZ+mSN]M!or+q,`^0W`/i1E??c:cf3(i'BlEBY,[]Dq;8.O2V&Gc+&IhUAXFJfaY0ZIq*aTWs'GH^^^7Hq.-&[apqohnt3q.=CAGjq)t1h*af1W*\i)QqKU%4V[U778/E!Lg);"*_LT4LB"&1+<sJqP0I-3gSPJ%\T8$6aqVJa@V9=.qFGrinHJ%\,TNk-3gF5fQV2DWmQlKp;+2XII'#0hZM:W2>16\dn%gFBb;`ZF]JVR0T9":!B8hWrQR1o?:]60lDHVH1&^<.K!69$/HTXr*Gr86.!P<0E+rFVn)T<WYQQ(O[IE"i338258oi.GJ/I2[bI;1fhNJ&1T\jVQ5_pP[-;;F4O+t#B]=RK,JBQ/YGi:Bn\GKK7XdHE=k/;5h!GD0P4n-Cq1(!X!&>d[ri*;OA:?tAn5,ATq/8G(GA6L0mO;&\5\goY$O0(%MY:c03tZ'GS)6Qcu5-).6l/`LQQ-t;\5mrgiESiD,7WMWToVrH8b$Y#m2o/F!n-l,1oR;9<<E>guI7KX/t0D.C9VT7"N&IUO8ERHhn=`4r%,''=iMlR=?YE_0RI[@"tk'0R">0%4BfaQTHNfP/]h1H?i=!\99CXrkg`d$B_*M0;;KSiU.WKmtkOQPDShpB8%PKQu54-P#6g'tT]1O3JCEqZSU)D<r*dW)"p8"aXWlKLPpkmbmpiBqLtMMn\6R,]W2SC4dUZmPE_!U8URAB/cLnVQ_nomCH_`knmS_<#rc=cM7M/'dD(@P?*dl;5B4^K-B=(@@:<XC9kr"[M'?KFjp^pC)[+RWCfR+L-Fm&o>I`<D\]X36!#;1Er.3^PK_!1l/B0H9r$WceRs]`YfCE(/rQ`'g@GcDdo^/Puroednc3!(G,$T"Dm'o&*t7=_6LPgIA@2Tj-4Z2G!h>0"=Hsn2bp)FE-W@TT#5_U,NMSgEQ(%q@8H?G4[/Z%Et<=b2m>f-J'/;fiR(s7#lG8#EELQlOInU"hT'O?"6)9YjbDYNr3kRn%DR<>IQRJrD[4-,f@^bF!f[<q=Q7N3Qbcm\'Lr@j2dPS7gf#T!5h:'.TgGQZ,3`%k;;CJ4ki$88^&IgOFaj:0qW71]Qda.07$?fr=4;(pY+6+N4[MFP(YP(NQO&k;h#)<5f0dYK;/V%bEI$Nn\'p8m]QsJPh:jlT(XqX1me,m)Vf-_pf@/'CQ[bk#K7<j[)gu,u^A%lk/PZ99?dnZo6?jZq=^M(INATpWa?3A4C07=Nq*GHWI$hVh[muDODXXu;nuZpP(3=jSA%m@B7u"5hb<7?864;.##bAl_nDa+OK/^L=lIB5oY?kLA?N'BTTUf\@1&^(CbDXR8h=];/2!D,e=1aC>h6d(VX7Pf+G.3S'B"P_UpicUUp\j4(olP16hpS[brpO4mlQ<k;m>8$Gf9:/t;fJ&cURUhD=4b&g)A-4&-]Y5$pSbhI*MTS,(Rka:p&G$g*KSR6e+_%1#j]6mVQ\m)Hh$Cth::r:l2SEjJ+mVfmI03p&rK3$aIVgq'CVR=^\hB#/Fpc<MhSge>72Nuo.Oc4^&2b<4Se^,g%ASGHU^@Z?!a'"J9cT<5@*a7?LPUrhL<4$I<3-7R.&FJ9!'@JeiIaI'mY!jN1o'MeXM'F?!tULrkJB8)RGomR(i@qaZGZZ~>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 + [<cbc2ba12e1157a5234b626c8edcb3073><cbc2ba12e1157a5234b626c8edcb3073>] 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 + 14532 90 + %%EOF
.github/reports/iso17497_diffusion_example.webp

This is a binary file and will not be displayed.

+92
.github/reports/iso17497_diffusion_polar_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 + /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] 37 + >> /Rotate 0 /Trans << 38 + 39 + >> 40 + /Type /Page 41 + >> 42 + endobj 43 + 8 0 obj 44 + << 45 + /PageMode /UseNone /Pages 10 0 R /Type /Catalog 46 + >> 47 + endobj 48 + 9 0 obj 49 + << 50 + /Author (\(anonymous\)) /CreationDate (D:20000101000000+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) 51 + /Subject (\(unspecified\)) /Title (Surface diffusion measurement) /Trapped /False 52 + >> 53 + endobj 54 + 10 0 obj 55 + << 56 + /Count 1 /Kids [ 7 0 R ] /Type /Pages 57 + >> 58 + endobj 59 + 11 0 obj 60 + << 61 + /Filter [ /ASCII85Decode /FlateDecode ] /Length 8364 62 + >> 63 + stream 64 + Gb"/,D/U]?EW1F%@_Mu<"Ri;$qH\V9=I*JCOCQs6+9<`4T_Fa53m6W1a\/^bT;S\rAsUUdQdgjiGV)3jcV.,WF]ES7;uJJP#DIZ8r-ruP$t#M7T7*gH9uH5kT^?EJdr0uE:1*t[PqFq"(_moa6O7up6#!uea.k!KHl>JL>`:Lu2HZl4m16Bc;R]^)E-d?VB[E>T+Cb0pYMX9Ec^(Wjc,KMSqs00@c#C3S]7.M#?*6(ET+%WI+&5%30)gfZn)D:)3@@;q)/fJfbmKF_1S]`D`H19$\G=b.Ii<F5.4f-lhl^"<o8D/qc1(.9IC<Et=4coD2V4uiLVq[<E:]CKhl`8(@/k%+lcSd5FaeeE]\n\9<nh%Prc:u+`a_&C=pK7$kYf6ll6HBVO)oe>Rqh6EN\8inBoJ)dS#o#81QhWbc/&9p?r+/(;;"4@p?#%DeBOeR\>,:lrcr\_DS6sSdaGsdaSO;"9-:0DIluJuLM>U1e(_dr1H(77c2RA'SLq&%?f,*>Y7J'q0bc>nIsT_\>[9"l([p!_O&2A<[F)U5VVRrKl-K$AIBiTV]ir'QT5@WHlfHmge`mTu667DQD/K:5qicLOIQN5O5C$AI+%Q`g]R%?'!I%$^pZWlH4n!jeoP[p:2cnbWZh<<eZb1Ohg;"5*?bP@VchAf%GdI8**bh9GoDsVa]"5Tn[T?Os7i6hT:QrW`2QProX,H*/_q!o/l>h"LrI9=&b'*g`-^-AZkKe%uDdE?S386(]_@;hB)oW&E`g:<s.rjg8aq85_gO/(<-UiKX&-VVgg4_M7VS:B"VULnsgh#(*,pLYQHa2NkC$t&/ng]uTs48L&4u)WlDEb/0M>0lpgL""IZ^ZmQ=Uf<,?XCCU\G:.>mU,DH>>sIDqrSC_%KDD0_A7WFq6pbPJ30o&IK095fhi(qT4XBmh6tAWHVIuSeF@ja265,X\m'e)r&=QJpHpq107S3M2ZEI$##pa-/dIk2JYM,(p$c-+W;1if\,Ug]13oAWLJ/E"(nUkt%#!O["a]Wl9NPFfMlQn1LB]<E&X8)a/(jE939/A*O.D\o=P3u+$9W+^jBr]3$Z3n6-AM+f6&GWDouI6Nbj#A11"R@C1#HLT*f\93:SGJ#&dHaS0CCY^M+7B,"t7!DNbQ9lM1:,-%_T;ET$3mW51"iNLH</uJ<&kj5OWMULL\8tSUHrY[0]>P=He'"K!d@(.6'L_r'61\W#+=L8`Yjo::]=[(Q\8S*b.$,*4cNk7O,,[N7r&YETcsgG4$4oE+;SJ0Z8bUSn/Jbo%DWl4tgiEr1cC$[iar]Lqd!b[+K5CcW`iY5$>jM+.@`JlHF_KA4Huh8dd$(%,\bo@VUDeknLpN3T"$q$bk"90Q"2C;<qbTe2S!,ZeAS-Zj*XF[CmXKh3Apn8gtF6%hl*g4cTFaZ;JNBKlB#'E3&BnW#m4C3^/c$8!%Vr]c6!!Uhprc-LHg9h.lg@\q`hO<)dOl+Pgf0FK"au>+84<*'q*aKi7+8Z9#Or6@,$g<'0:K<N&9d.b$tMDRDe,OQ"#na>%D;H)Z/oiDpbJ&(u[YP@DS+XXu"_L?2J3*gRaV\8/6nN0HucHXtp1!r-;[*+9U]ja.E2;?g%Al8ZYB>MXYm\C(PBb1\gam'XG!%)#7L@BGKbLZqQV%ss4,fBOijLT$N-pGfWS'.>L!i>?cDle^Z_.pZN3E('K"<Gr&%UG=-&18PE20.0;QDfB18ZDe92kB5k*4JuqTeBVp=;fu.*:1@]5PK"&A1r+tK8hi_L1;C9eP!T$4omEMmR_e[pZ.u:=)<&j^nn!.nW,qr7DUc^YransfH6PH-U0Ca2SQfi>N)l]hT^<sb<rpNm$)Jb)oNJ$=$G'3`j`c3n]f3DsT"U-Sm_4V7/f<YN;-oi\B?`(Tch+545Mor8\/PU-<Q=^MpngnY#-Ks"aB.\J`J1/Ks)288mg8E70ju8e7=up=RrlB_BGr>9ndM%F[5Pj;r0X36K/#2P"c_bW7q:N.Q\^Y/(c;I'90luSOb4<f*r3&u,No]OFJp!Ke2*l+VENKK)4CU!7NhQT-*T+B`gJPAVqm:LSn=FYjf?gnASJGTY'2N0?S$]KMN:VV=6?\lRDXL_S%B@@7gGq%D][9k`-F#+%g(R\&IKu,5fQQF`>t_U6^%i)JncD@:)5\G6r"]&ja2PJ9.?U,M5,etf:4IVJf4We$">i@O!cn#.RH:K:o)bL!Ks0$:])nS;u%J4Go^F9)3NKaGrMDF?6R3V9&Z>9VB#l(?6LfYL.S.!amN+p^n<fre;+^\"2*$o;,.G"'AI6F)90lLl?Ob%$8D\oE36=`=i_GEB-K&98Lj.cc8Zq$$8_RJ#3(hpDBGr5A?Z,h-9p;bb7H3kX)qRH4+o82,dVsmqECqu$g`C50dM$bbU&oX`JY8cJ=VZ,_hVKO0sgH,[2W/XgE&5i0U3Kb-('p]f=@u"*`8jk/!_)tNu$bGL!`CT#HIk@#VIa+i!$JWfWY$5$nh?\J6O7#DN_s^(j\S6V%eskp(^hb!EZGhPile6[@CIu>[[G[H]snaPElpD_Qi:on1/19/$T3)RVO1oB7ul&/Pm']CSK;d7N0[r`V_P(cV>.7>/PCh,Y"7>I!A?&-PI$?9Jc7,e1$a2%I5FkP:%guVEdN0"A9o2n@3'0&PI$c/+W0O>_$#^f$6^&Fm^&mls(4\T*pe@J:p90#A70`,-TXG`0d`[;r"(29An[(;MKrqrWm.h`f^G(7;q+hC_<WQ3]CM1Qo5ja9Mhn5&5,^>g8-?"@hCl"^IjbM)7;QP*f>PDq'!_YE:dAVSOI"YidY2u8"?:;^'V@=Mr=.M,,[G8=BH=\dSE!Qgq60&'[d7O\SeZ>p)bI\fF&49,+G'`CU2J-FD6]HiBDS8G!I/-[./0I]8Efn.2<<RZ;"KW;VM>H%8#b#=Hgh>ZspGdnnME3C-!QdOnKq+0L%i1SEn]a+[]W$C/4Z3^.PG0_42H7q'ttcAa)L-96qrW8q1&\9cB!1Ok(eujT3Dbi*_iGGoR2@A$]-ZemG8Ar%-L0.e0g(Jjr'<8]hs3(oPOted@drJ4*!Y>I'JN1>H-4^*U37oO:K<*&PMG'N<a9'rFqChr+0L+1Ng3>eEAH:o8i+Cbod%Si&$"F_F@7fp(3MI3DNMfB>l:Vl!T:PG$S'o2Q@,6/uBo%?t*G:4U:$Z4h3$guJHOQob:Q2$[OBn_)n%poRLe3Qcbm/5MTQ=)4;G[P1_%HuWIJk$ae//(+,Yb'>;_XZ2m"VObA^S\$>RgcE5-9$`1#oXD5rjj58paK-8ITcsAUR'lVnHkrlOc.\UKNoaGCWZ?f\BH?(To.b'eA\;7/MR=qQDF+0Q<UN^:]BG,F-uZt4kHHFX`FCNu((KLJ#oZ=n7YKCY:dT/:q+4Ie]Br>P&7=WFb5*#(I.8lr^5]1@<J$I"PIAF`Zi61>ob<"`jdE>![qMgab0UU3g_&d.\Om-hX&,<VQ*LP6=N=/Z>;-'8M"Mp4?M\4FmkPD].C\:r"(6LoXcp>N_^ggWk1g$9@hb5JhuF6<cjeTJ12`#XP(VS[(rP^?84(HWP^57@U75WlBUeao3Qc_]VaRpS\j@<eU,Z'*>[iYg$NYMS,`&o:f.lgX"q_ad[QFsQUpFGkHNrfB[,iJfb!k6ObWnu>WK8c#&==F]Os-bJN7^L&g/'MA;*n4-mLXbdRb+mWO#<s?ISUe2;-#dG@hLlu-k&pAr.*G@XEn?b.qd,/]-n$tOc#LcCX$PufdKnIj!Wqr&kn,bW<<mZ+[-Z[P-:rpoN]t]T[*B\4@dL)RBGm_bDhdWcqKB(k[G#P(e];^`X:^BY>G;bc1HNaZGUL<)9)c<R=(:QbiRQ0p1EW`O=f:,NJ_*^59d='PWTM9JK)$-;=lW19o:]k7Q'n<P.S#40'PW\Z8",K&a)b+^iGlp5_LOaj!-l$JOV83jGXg9F2mK*:8q&f#$u@,+Y/7?&V2H?9'_Xu,J1"<R^4D4Pa*)C(fX'sN%8%2Zo&p`S9Z(4,EN2Cd"VoZZpd;VjUt3a=-1X%^cg"5(=qsd'qAsXZ$^gO%RT4odo`!&X.hm/.pXn4=5*:u>V(MEd9Z=^?qeZqTr`'nJmMT.'to?Z>eMV!?-t/J;p];Rq3F:u3Gc,;T&ubE4pQF+]7%L*$$Z\e/0[R5KW1=_r/gOb6cf>$ekD_!<i.6.eZ3Gr4?[.67:r;Ia9Ia(#ZV].>4rbrH1n>CB72r/lWIT@qKg`EZbE%(rJImEc$&2nI;&93Oir$9f;X&"KMgKKpGSq12sYP/T@:GM%0;;AM3@O^Xl?HW#scMO^0s`]/VuF`o&b.!B(4*4P&Xe.#KlBXN)^U.(R1FuI`tGT^'#;0.%r":!H1.f#nn77f>a%IJn5=uK;p4A,q@27TEZ3aMYW:98Dh9cBj"m*&j*#ai-fmsgGhT6#MJa"XDDi9UI)t!!-?6ID].=dTA%m#bI1$`bE95X@j]!jio"BNKjr.pA@kAfgl./fPU2[>hNZeO(+n`PK<2Q#p*+A-ZLl.`.n_%O(FeOQ0p`"IfN_aLEe)2:F/Uq'9X5b%bNOI+%'[KeS-J,_%`s'#79'1oA3`Dgk(.nkCKH?)>KKD;&jm=D_+atWq&EX,8SMIa#&]aX$NDucp%*i<gV@T>MDn1K?DKaG80j9U=,]/oOp!jNYQq=qm!C0MlU:^!F80JuX5YpB?IBgUO\D`7Y-f!^k\O]cjMfVF-`<t#4;qI!_^o_@LQ\bc8^L-C27hgODSp-Zc`WcT=Y<(KH.85>=Ufm^6nqIQE%m4*SDkK:R`6e13o#(ff9<n>m.Fnm-[S#0lcEI/p3oHPhN%@QOm3PF<b]SJI"LU.G&DIodilsh"DKil(!GupV?<7sBKDgNJEGGjBTaITP@h#o=B=LR#1#8Uh5>r'P]36-mSh!K!&T&re?c&[C-ibD[qAK"Hg99_c^oIVoP;aDN)q7aa!Vl%0OldVXInss7mBGi:Jpa_'`A_7c_>_$e[)3G7J/43B2LgMl<2UiT1."ir0S`:?6m@[TC[,;r>T66s2J1)h\c5=^G4=2Un/t,4K7L84\t?gdYGr.qM1knlL`QO,Q+Q's-Q.dYRNcA_5@!$q-4/2*I'^tnETUTh8qnBj>F`J7p8&)G'6B:M?+^p,dbQQ7/8>WnhV2CE.cc::eNQ2`%/F)b7hd*9U`Fro$X[WS4tC7ONcNoQ]/HPW$9LS&J,CjR%KLl&d)P#]J3`1-6p91?<G(K*CFdh@XE:]Z/L7`<.mBuk6qk%ST%']*>;2*lWf+$j>,'!R-8rI>+VnW)N9Ndga:k\4"k4j`^W*Eo%%,bXo?S+qE..VLHQXNE;4F.6cd[.7'gjbmVkc>D)oC"0'(nQ-U'H+F1])iM-pKfjW.*DYTQ?B#q."ecq0>5@I?:cQ4h_e=>-!Rr8<q(<K'B9dkm11W'4=ODJ5#l,D8*n-C2Fh$q\EpmHMpV_]c*Z7s59d(Ci;EgrT$4n4ug1pgnLDZLGd)DgoXF:%EU!i,)]kFHA,17C9K#;d\4!6r,p1G\2oB7V0T#jKOH^$X)&ZBk&.Q_']SmeFPe%PEpKm:j9XQ;!.ZXit@<Y;DLQ%8_N]@+#2o&eVP:GjE/O+f2*!Lc'I"TgX2@8g.+r:IG.t"r'RLG!"4Ai?c`>7'\aO-=\T6DD?^<^Mh<A!AhG\O&A]bd4D@DK$dnOm/Bro^I@_m(Z`8S$)e[i]=(_L)ZnuA/c$ZdWjW'I8o;-uQDWHJ?!PU#k1:A7liAoK1)uZRCF^@]#1(bI4OD-RYK]V<k.!(:tkRpWj<<PcNYL$it,fYumF*>JlfSC%Ojb9P:]SD#7[pLm[i#e>Mg"*H#canFqajc/U>NU(/NV6ABNh/:XlKIJB7o/Ui2OKnAgE#:=FT+R#p@^!,:P'e2:<7A88")U2G&740"A@F4&.#kd6Uiu]6moa`#rP+GCe#ZrRF+sHAFpSVB_eK*"lYLN^Jr=j<<+JQ9:6\3BVkB\-&R"B#Z)2hmEQBl'**XHD;``#fA'0*pXAI,oQia8hN;JAU)f)6,l4bh;%:),`6*a$lQ,u\FkL.rL&iU<:/k)AImG-IRHGBB_lj`>1iMVu_97]L4Fi]R4Y=]g21LP*<ZG9rg#G%]S[04W&pUj^&V87F6ZP7_nU_S8X.L4q$lE"<3go/Sis@,5Ki,r&H9WV][Ba.4:oJ2tARatQHX<!fCNbTr9O>NGbT!M'@[<8%)fQs4\#bj0F_52bagXTb3dXFkW97M.R+gtD@6KA=(trK`7ik%nj?JLMWJYP:.([eDhQa-n9JgCbaR:<g%nW4J8d7E%:rua@b?sWER8Zk%2+PD0)kIY+JuPb%=.B78&o0;ZU-Kt#k=.d%$)hFm7r(V,)C"A#Kc/[dgL4R#hKXGC6u&#M)q?a!:Hm'&K-;J")@WID9?)I^9M+r8BN%)Lcq`rNOd,3mbBf'^pSdZF*RD/)bYaO1.e>[+Z;\N?@+l#TP\C?Ehc@hCE7"-#Nl[G<+s9f:VeVaI)oBdo;#Sq:AM0n!?OeK"CVK4a#+qCcqmsco5)lI'ap/1BHkMrZosL`ekE<oQ\YOP'$-j-LP%c:AH1G>Tg16@!#NYGI,&W.E3(`&g;Qq]X+3VeD>G$;H+`I6I6.0XF]XGfRCn)NP@r,#'g%kFu43(QQ>5Wo;/*GnY3@C'^%#`5?UNARt-!+5o\K"2m&DBkrQ'6Dc(4IlJ9Zgb,#D['##HiS!.Bs.qR2hA;]Y02`lt)C=j[CY+?r:JY%.mGeP4<)e/Sg5\Q]*Ij2`'m3WoL+>X#dE[aDhsE7TMFn<lML/]E"c?(5BmPq[2nn/-Mg5XlSrrjf.ka;-P"p0-V6^#a[fN&]i]Vef"Ne8tg.5D3mmu'2-HnWus)$cT_nAam^A*B_)9nD9Njrc$A0b;->Z)=U;W_@_:HA4MYuWN)B;d4QH?-s%HSq9NhBh=oV/uLSPrRG"C=1l,O-&&UJ[OA[_!>h;Od2^q1'rIfW+b(%;MR5ob,>QJo$=+>/WfZZX.VC4qnc9:g>KC"S(_`S5G:5Z6'K\Qm:)*?m>bWZ^^BDo?L'$ubS)U>a[jHkY-'Ht6g1"@983@_:HA4=!@+M(6U_mUq0JNa/(FCZT[f)u6=HfaZ5b=.6n_+H'J%$%ao?-l$C654R'rgd`%>Nf.u3FQ^qGI1.:%3PH"HURaqt\^Sk#kA9>LchDQ5o7F6+k"W'o(i<#P=[k+YRuAEESb$V8=F`?K:@i7?+PQAf#dR*TEd0afNa<nYEQMBE<m?E_'(6GlERYV;UDleH3hn#-hFIWI.&*I&8LMq`(>lVGiuY-eX*i5JLggq5CKjQW]7CbPQ'Xp:mi44`B`9]Zd_'lrSEe6WqcP5k3//B>M")Z;Gb]PZ]W9f07d#UgA%3S0G]2jMIi]52Me7D!ft.f<R#Al_p8PPP3eQAKBNdm=B-?nfp]#'bh&\Z`&FY09#rm1eGeM9;&COth80enqkJ<BQ`F!#jh[gGU[Mu?W`#J/[@6&h)CX\5o5V>Oqj\DkhQtR:.M]J8^iM+l+";X[P_26<&'UOaV2\pU8?L2a;TNtaFo4%"'@]tEB]>Ur+'@oR97'[5a^=^L)HiC7Mq'E%d0>quj^Ha[FTpDkC(T?GD+PPBD!nFrU>BDa;6oFG0Md=R\;9aGD!+!i&;9aHO3aKu`:sHK3l+2m^U5nuF?FuY5U71gge%;&UU71fl8@k6sTUPW,]XjP=U71hB0tWl]U7-#(9Qr)'6nRkE2X"n)O_VVDJ#'.@7B1WW92J^\Mc''E%boa\L[Glpr`X'pD>6`#`c`iq>/@1=!ToL7k8)'J'>j^8I.S-*)FV>SYfQmj7tX"&@CmH'5Fo6RIrRMsp,0%?%B&BAVW%0:iN(H'HKe72%4Y,-BSTOCONt8qD8U9NgG@OF7N;-Hi:_rbjNEJKC3al?[5hG%D_B>]hd<-@/?`+;k82="e`LK=A2sNLBMkIGntt3@`(u-F%VkW%DP)C#9NLChP<R?>1&N5Pb=NJf+00]>&(LNkg:=>_HF"OeA.IqRdbVKN(!KpL4&Ru3'00!W1C]IbS.AVF_oXiMJ%OF+h-=fHr4L.P<&to2I=&B4\m4'-ItZ7r=528;\JeNK4M-!M?JQdEgO/8\FM@a[H?Zd1))bRV*IN;.T>19`\)2S@d(uT`TPonL7JWkQo&\QLl4,F;O^BNDY9.YSoAKf=LXNo?@puS>5J:9XMEW-=9a]#9IPM>807MQDh7VXJ4s0dihLCRD1A^lBNIP<qO;Of-\om$3nbh(l/m$/5S89PB:3Z;"@*IJ7QH=+BFp4YRI_WV-`nrXQ1gFnK57TVR_[R[CXKHJBDhdS%X6%=^C,6pYH,/=*96@#T>CWus]B.)#mkF(P+''?3(u##EO#*?_An$#XJ!R7lEr~>endstream 65 + endobj 66 + xref 67 + 0 12 68 + 0000000000 65535 f 69 + 0000000061 00000 n 70 + 0000000132 00000 n 71 + 0000000239 00000 n 72 + 0000000351 00000 n 73 + 0000000428 00000 n 74 + 0000000537 00000 n 75 + 0000000620 00000 n 76 + 0000000825 00000 n 77 + 0000000894 00000 n 78 + 0000001190 00000 n 79 + 0000001250 00000 n 80 + trailer 81 + << 82 + /ID 83 + [<cbc2ba12e1157a5234b626c8edcb3073><cbc2ba12e1157a5234b626c8edcb3073>] 84 + % ReportLab generated PDF document -- digest (opensource) 85 + 86 + /Info 9 0 R 87 + /Root 8 0 R 88 + /Size 12 89 + >> 90 + startxref 91 + 9706 92 + %%EOF
.github/reports/iso17497_diffusion_polar_example.webp

This is a binary file and will not be displayed.

+96
.github/reports/iso17497_scattering_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 (Surface scattering measurement) /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 13725 66 + >> 67 + stream 68 + Gb"/LlZ4%9Ep%ZG(&d5<\2mEdh@E'M_sU]Dhh?HiQ5F7)BH7Qg*/[kY5loo8jhLfcQ^Lk0K:*lJ'#4Rd?H!Q-4k3P8=]tR#at,UkqTn`Ia(N<Z]2uo%J$=QJ_lG_K%:^5]I5cQNlGYD0XO`>U1sRI-jD7>YNMZ+-kKu=0;]S>G@OZ136OqKRQTr6X->i2t%Bn'U#<1:g*h5Up07S4rq<$mc4^9!L?!jcg:X4G2cIPKemk<:*lgsH"%'TXc]_VtCr!WJ4$^?b8TQpInE:`Nm=D2qV^p-6qnI:PKi;$_Er,Jr$e@_LfXT,lGr+US)FFA1L^:Lte31IfD>eCrWhl_aXU\!(RB<.6fj-/*\o?EsK?``.t3B98holo`FJQctZHE"T*j,=<Eo=+Kf:u,]u'CIe/=KR[:)f*KMiLa]Z4M>>Fm)J+TTu>?,*#_2^@@:QnpSSkr2g\&(N>kU5DV!\baJj;Hbu*/]UMp1ukj7-iH8t&Yp3,MQ1m/=9^'=;W8*B>t<j-6:0D?"'LGC4Ap@F&`FF<WXlMBT:C2[HkaC[!t0XQ=3?QELF]E=_^@H+.Z5B:<.)a!ugroc4?eFDgEI<G,WrH`^:o&e3o.p<sCIeeJJ^E<$=IrkLO:*=?`:-.].glE<35)SUJp!N9HnDbC1N%Yjsh]&qH28"&02)f4DjhK]("?mi'jo]ZG!Slk!rHn%?^L!GE5?eZuGOO2_7uk7raTU]qNh,t(mINQMr`O@CLWf?DmtkcY$82>LHMI$&rqe>>qT36R`XLH86Sol%,Ct.oh!3ntq)-9e4%tnu*u^)tqaU2SPKW"\Vam)kPq8\gEtO3Ef15&8902("kLW3gSZ`!13$sB.M'[ZEmZbZhlf%Ngjn@BA(PKG)?X40RicgdJn7eEiqmjSZI1]7Z$Tn@g=(<5JqdAGSf5"D[BW--BpSlc*?D9'S9O9`+qqOo:Hks2;[6ApB`00U1IcOgBrn/T#75'.?QUUchU]&Q`h2?%<rVDalk-++0mJF5Rf-f1b%R9rR].dCbM(1t'GF5me);/rSIM2%7V#;$(hLGOhkYBV"M*nU](K(0MqYspr=Xr>/isTV]s1LCiG&&8PIPulE8*>brkL8AprmAr>1&UlTp?Ch`LTZC#eC.\qU\/V^+Lsp:pLF:=+*Ob9Rs$BaIJ2@`nrMu"eb.[\bL`eQj6Iqa5F(qk@h+PXFb$H:eJ)&Y.U;@='aCt(Aen71$$4;R6OGHA%uiI'Anke$]^qnr0?TZ7EPorq0p<2.]_RF'`.+n\WPf-UoeOp`e/PI:GR]pe)J^hEjn>CKD@F-AguuObkl.4&5qI4o5!Z=LPi`9<HNq4;JP#aYq;Dg"WVZn6d=KQ7KH;)M^4C2\n+3GOY]])''5W[.ZjA@/6%:AV2SI%4maA`YT/aB,hjElV][G`?CQ,q/[E6:!#qKLU#Lj7Tp[;cn$*7MI(sD7(jVq7O)s:=?rjp'>OrI>Be^"'LeLe%q@Z7YAN:oAdV8:A<EYH/)S)!oBDrR/;la=S2S<tTi^<u&NX9TQJ_uN"(F.$497=1DG^%$_uX`IT&VJt+K,:^lf`/gr6rP>G#1%8>j9SDO:/bN/nOOg_LWc`:i+o$=_]=[@XIj$g#&@cs("MHEF]Vk<'_n<:e%>kk>,Z(:@n^-UY"c@S:IiB(FO0!ie0V<PUdHfWmML";fU$r8>#:ST6;"V>CJRVr2%gbs,=C)4c]tK[\[,tX:R&[#fqR8=3q1Mu8@]Fb[(cEl,gRuVVaX>pT7B_%S.-?f<D,;LYjHKMD;"V?rOZYoaX).O;4@'R2P#&V/(VT6BTU8`fHT6_]@u(=cO=4+XGq0G0oe%BZWcfJ&m/W7)gDT.U+S<hRM/<<'<nZoAgqY$Y\hhRbT]gOe!5ttAgESd@%&BP?G'P#8^$^>qmkDuNmZU3_#1B;.Zb-a%?C,&u\%j\u]5Pr:(kQV+nhpc\k5No^qsQE@D3R<J88"0rbDV4op<E"N/pc>mgFT.W9ATk>lL_K.XZ$N_Wc8NaSQlau>nLPCSkU]D]R\V-].:h\S_)g:F,*3aa_\UJ)c^O9FuD6j!n,+t/=qeNZ4KV\_$dnA@=dr0q\YBr6SU"P5Osa+Ze0E,TB/NARB<UechsO9b\#2f=+9#fS$AbdagdcXh,J$1J;Y.0,I0-K6h=i%SppT]HM@8jKbJ?-34n)I!jjCWbqLfs\mM`./G5R.)aN6HKpi)QE'")LhHN4>KaG(fSkeqb6t,lJ[kHTS@I2j$+>'lGkJIji*MXk%+`N5Ab4O[m?[1pUa,2>4?d7GU%%'+'npEs&B01FrJo,9'"Np5;?Oc21i#!,.MoV>,?XZNq>"mS,*[4:EM$X^,QI//,C@CV'4Vg6dK#5jk_&8d&Aj'TU@gOK#]F43kP'Jp7QQRe;?Y/5UL,dWZ*C1EUhAEnXnQ8]CGj!OXF50$NMeSu@\'YHY@m6'GR(o\1N+$d@*k&7r)aj5RNKlQ^GS_A,"gcU[`u1/%(R:d^WG^l\B*sWeOL+*j2s(BuacS#9ld98nKTPAY3RZAdNbLZYDZa,p50ZU_>p7)Cp,Y\$ga-+`k*YbYd;1^TNaX#$(.:*R#bQob6Rq[6o)rBYRhP*D/UX:]g&-2!]IqL/`0X"oSFW2=4PY:D1"*&H_^AVsQrBn1"Sg.4[OHR;KKkmVm!5kLAp2epm+gsiQms6!fV/7T&eDntDYrY:%t/siLIb?[ZF.TmI&I`/PqoV,i%j9?Kfu1K%B0ssHRr`?j]fa=P8!$rL</`7Jfp;"$Eq:'E],bh+?(a4[NdZF72!@YUf_JuV%Ff,SJNGjcs-At5OG>-j@:*;]oUb_n9!*o1#bk6(Asg&CpE:kK8)J@=KEEU&FMM]4gZb&NJn7r7/4X)d1WQre3hod;P[(bauWIt]i$>[TbHX@;r:P?J24&g$f4YI'8\"lK.e2EP#bhaqV3p84R8<*'Heh,9Wm3fk@SgT]mUsqe2>T-+mPEtT0-[SRu9!o4ZjDLU[O&S6$n8>huKI3!%qal(2tR/R,67)53&OF--E%^3N7e;C25:[)/B@=d1DshIBSGd)(61)YL2)'hHEh!qBpuaYV[)YiRWX&;kJ;2?r62dU2h=Ug<bt%j#l2TVr47Yk]dV8PN=t\7(j0)!>+oeaRX2up,S'!<(9A;[>X6t372R`&[Vu!_\^]#oE&DCG1-B4:RMoChVZPf_M8\eN=gkc+TuVnaoWstc?ZL"Mm_THn.:@=M+Y%9>ju]Y,\b7eRVbs_[3"SP[&Rt.4jlldF(JKI`1GmlECieeF*h.eGaf$hbQV;"nq65Om8,rLTKu^#R30SmB`;i<<_<LAl>Hb:?Eb_42N9*DR)_SUGK^bRn/<im&&o1j(!(1p0;5+<8L=n=)+(H+O@ZDe)s1NBM#[e8pKgu+F\>O:>@D[u6mdl1H*SG;QBI+r&SeM$QP6q+P/0%^dX!f"Mo>lRiPfR]n9Cer>QuD22@G1#<sa-uHZ$mgUh%q$U12<IMK!THK@.Uai5NNqER"i:FHALf0OD!oM.JKg#@^=sAq7[\J_TOTMGM[V"m8LQO08[3>4]CkBmJ@\Qai9^%rBnS.$JMg/AW\O)9nC]6Yr^M\ZdUo9sR**&_*?c$^_8g1s<5N-)TP!/^Q:*'Pn-^X5-%]8.dZ9d5Rhh-;t)F`SMD./D'!]:mjXHY*JkW]'b_Y\gBB=.&a74VB[8kRV].J+r#3D'<P6!hpFA,@rm`aDT!ZRi)Tm/W\[!>/'ohkPc`>ujKsB7n-"=u;9Apl9gr,X69IShHA)56'R2bR)+;[[NN>\N=E`&`\8Vk3WLsc_c:@BuXtGgIQ&/D+KuiTlkE5aK.F8g8bapp'nt]N\D&SUcktE^>DjH[mZR0cb!hi;,OHST,5V<(k6&8!ZnaP4%09aDWa%=%aG]c$$-r#uV7KV8h=G#O"l^"Pp1-WBiNFU^_p7*uMih@Scd[V,5j5HL3AFa,8,KP\q63*oV"q.4lMsqHt`lkCU\u;XO!t-/Kg9TWa>o$\b[^O6drNaou$^oc2o;'f;2!p&&#TH]mYSgS$c&=2$I'8%^E_4t+=Was9H=n:FIKr?6PiZqr%A84(#KY0Tn9_'lQjsWOCdKF!04OcgHc1T>/2!./"$`Y1DPUc5Y/qc66O7Hi%<bD*0pC`C\?NA9\f!"c%Ve=/O_ST"1"7ILeA:mJW$A'.-H.jm/ZOq$>$"Ui<L]s]XP60U<Wl?=WeRMV4uUt2<0Ti_(U7U4Z=@E\gXTkPRkY&*9SI,bc1o72"MG(:IRQIfG`"`hI^)1/8?<Z*!m=A#K3U<8/cM\s-?_pIBEk'?Wa[sa0r^Nh^5GaX^*Ym^ZUe)3R&j3,2mGtJPuhdjcHY<%fuYY*7No?EJt8?0Z)ZH,2O.,JYs8Y_,!T-jBpm6[X(Y[K<..-AbhAX,l'@"6XQOaaLGW.&Wn#c3i"m&P0#!jpQa?I-DUkhs[ND-JAd+3e+;>koZW__qMf7Y/q_jaG]1"+hLeq>5]nNBqNi;D6e(Zo_p8g,7(D0g9(/8_poG10i8SEFn0%R=dFh#%cMd!ZiQ&AhV6`n!=Qgh)a8s2&d6Ur1nX)tUuF=cLM/(b4;\l7doC5dgH=UO")hN)`ZKk+A"eKDXM@V:YN\<OYI23^afemdA4:i3KF&s7;XQ]l;t7<C?50<#^#rVA:k0[b5G?<Q*%,)0Q]Q1li5S9,@T\2$M0Up`?"8qA\uR\g^RP/LAKmh3`9W'jI"X[rd1X!4_#-4f`\ALSh/#]hK$Cnl93/6WZAC+FY26cpsbMXI3)JL0`:DBNQ$,aad9;BbI#e([OB>uA[BB4O@f"BF`C$FEY%6RVc777nCb%8oC?1*Qq,ae!l142SUoB`KX>MT)_E[Ou,AP4X[0g#0aLcJV#&\9J?tW!FQO\r2k;LP8"6B<E_qMUu^lJ3Z<GoAaX;QJq:Yrd9p\L5ZcS#!+d<nn_d+g_AnWTaP&-S&20Y&MrGfj's]raO=Kek_E]</mFV<-2iY`b@(M"baE1d^+$mqbSC-r-H/4H)&"81:q$oH]jkHc"NLk]n"u(YMEb;mC8f6QA0R3mTiP3RcO&&#eIU4E2Rjpe$G&UK;+]6a""gnArqIE.dl[c=S?#jFP/SX@gRP>!MBI>ug)FK.Bb<S0p'-ipqmaoU0N+B"2NqLob?P3jaNbr$:mmIQ\MGOroa?r9I^=uL[&qWTCg-GDd!hO6A<f\]B72G\H])`fi3r%4YZZeYi/%4nAH#VaLhA=2&WCC>GtaX@QkbN^EO]W"U'rYpoAJ]lU'rYBI;hT&&'EgRC9Zm&$p*E>[=-QRs%Am@&ne_m)(oo`O/b>r-O,kM"3ZOPji"rDdk!E%@af/H1g?r=T=%DR/5fD2Y#0hn5mRh$qGnqSYoj6:2,h#8D.Tt\RfFQsVIke'U5d\R&&8i:U5u]u</L$MQKIPqFq`=.G8N_"pB&Feno*u+<dri28sb[p5Dh1*7F2eX7G7`(n!+(78Ka4;K:+olNJC\qD7%fZ<b]&d0Ntgn\4FX]=FgncP$&2])I.@E$@AaI^"4lJB`bBU'pag#en08kNYC+50"uCP&FmWVC'>ksL"E<B*mk=<&6_1j%XGOIb'd19h=b#oK_(Ul9R9/EJ>rc?#"s="2mM*u,L[f2^rV]H5qY+cIFEalA/[PIA+ir_\W.6\a(djsUcFemN)Il:B#R9U,i_^T-[HF(2hPSlf1qu<$+070&g=4_QR='\PY9HL#TlSV``<AeC<1%]V>ttDRm:E1F>l01I3@<F24OS:_/9Q<Kc*/A@GYXaJ-g9FA"s5L6)pdkX/&3PD:$k<;4pIJ.$3[*(]*Y>]e_6%ha3)bZSg:'YT=HVAKN3V[?\7a)Hs+/QW^LkT,DP0><ET!'%KP:?dd_ueh62UI^<*WNL?H&-bt)1AJiM;=&%`72A'36/(?J<J8/@1U+Kg07j2&1)eK&4EM2\o(i$_k"p*,)f-SFcdF&M093Y)NKr>NBW:.XrfXaB1=dpHUNWhpJ#i3"',qXBlmsVh\Nj-C4*#9J/>X"FYPqtc;5'%h-0Q4gCQ*Q+icUrU]N.jbg/49inJt2`"11!EjGOP4]:R#eT(\Fe15kJQknFV'4%M\VBr3`VVcT"WhL'`1(nWI%r`D\4kS%hXHjepr(eMq#*E62E>g;T6L@p&;NCb"_TNlG;t+A6r>&A&p<r;mJI3p]kq8m)kE4",\bQUF#Q753mZ_H.a_*Jm0#7IWrL"II#Tb@/0IYhsKRmr>c$6dlIPYDReSRK9PI1#TA47s'nH%Z/.Jna%8/Mbj;s%("bjIi&TX["BQ\p<cR5KjcSBF((H^f^`?2b_q8P9COVgG2u/l7F@)?fad)5^F'="q2"q.*Apq#$&9ggE![dp!XTYXo;QW:/]TabV0Z"=QX^tqVGtJ%F<DKEZD0/M;3.n6l(Gf@F[K&ZB^@u>WV^-o>9WhbDs8h,aJ+ZA$/3Iec;R4^'=5Ms&k6s';a<$Xl=9<$f`'$+]UAO'QQ'k"Rq8>fb.PgN.)YUnF_53nAR4PM5=c@5N6VYtp&42$M-WhL'=:$Yd5@\`O7WXp1\JsqE4R?M"U'"e4s&=$.olqc0pf-7)-RZ4N%dtNQ)]"9"6W=5P'/#pj@R^'+I7!^N)EX^m%<!#<K/p,gUulf9T851OP-GG]V1#Z2m*Ef)B%AB;9S2P=pbb')S63'a$-Vdb5$,M9B);r!(h75@>idYb^;HTI,-\q,;p6GdK?-.C_B2nc+uRNTN,CYf6@Q.n8S6.BBi16aT@W_"g>nHS)"j8%#D'mnmqEd=\gLJ>Y6Pk1]`GIq-"\,p2DWEpdg'D#Nr#Z8LF_SbcVM6Ke/qDl5-3u+mBeS$T1N8$>eN2d]sOQEru6)6_Wf5AC9%_Vii_rO(DG`2_ML7j+NtJ4Xhf"(MOdfP.(9,HA&O318/[9P3heeI9>^pQE%n+F&jNiq&ru4H=O@]IlNlZ:3gN1j]*L%a\&!cfL[le;5.J+'2!(R)!.*!iEoU]ou*tpNCC:`'6:LfL=sffX<p3so?RYY%?;hR3G=>Xq&ji7HL.dn5(ME[m.gefkQQf_E)K(!5\6o6V1_4;0hf>I=t<\$PHrd"CikdR1c5-maDSP></*H;46SZjkmX3IS\X(M+!VuND"?BYd_V=]S*?P$.=0U/)j+[0<OWDsWt*WHS4tTf2,H=WM[ah1DHr:)35?mjFBg(\,,=PUJIuaSOKYTrSaaIJ%Y-ZQa`YHj&o?$;g6XEM[%^^$`t2FPXqpPK$To2]Nu1F2LH,KXIZ"g2g,r>OMilA5OlJ)%7)p80,63*^rc<h#iL3i`1/#t`jlcnn.k/,&Y`o3'YX[4f.:[7m"IHh_eC#K/HXi1#8NB+tfG9:UdA+i&1`TZ02=%OW\M)^+Vj]G/&IiE-QE=\W^_.-GEhN0N$^3kk9@i_D$!=JM#S(F,I=PG8+r%DUDjrmGm-'*aQs_to9kb!!@-$n$H?LBOV2^8oine8,Lca7EEesrF0]E4&P,:ht\#gT\:.s*Z-F\@`7<!sE3u39`3S85%RU1QU30T?RN'945_`KLap:s1bTUUX3M%(7T2FL<]R2sN_<MA:pRh1u+XX'3lj;;V;SnZ@rMcOZ<</;S>alTBn30fo-"fb<AS".-j(<E"T&:)a1G4N(VbMcE%,OMSsUPs)<+[m*H:8Td_<KaK$paoLT<]f.ubnK_tYG!GZ]U'TLhXj(F%#Y1m-m/brJA=nO:g9+L0B,ft1F[6Ej]lbZ[nEP;S/t-N](t\*Ds8KT?1D&G1,S(EB(agmE0PbAIbCI]6Mt:a_luZFLp69>"Vf4;f"<Z47C^0#o<-bj<Q+&Jfln"W[V`qfeFIul'W3jA,]bFu5g*Z365R:oj^HB@flFLkNk3Xqo*Rf;'6$Sa=S3r=i',^KHa7gE"Agdi,kLV9kPtjG3=+kCb04?Ll`m!R`Ce9)^oiuH.$\Gl)q"VQ5;TdD4f6Unj^aZg>#8r,M[f'+Wie5f03d$1'-(X1?7VnuC50dO*[=^i\EGeRO/plqS*g].a%8==09Oia"@.`9G^9U_3D=KK[`jOihB4S^+X=E_5TO"3hAM6.cgc$QHp'=^_?sb=bo,AAGsM8rXR^o\f,W!b0FOf6S@MPhd"XY3Xk!g@G`:uV`0Fo8P<_RY50mAYG9(TOWEs*.U?O[5e_S+*S;kIW>+Du,:<sm"cFcHUD+1riBBu7THr?RdHVH>t=4YGQ+0&s4L'eGSQo3:RhN\]EaN&HF_S674^%,uGOGq"tKVfmSG'&[bG<N*]iN=_GSkO+M_MA9"\pRd[rOJf&8RlojXd[F%:(7e.eKW*G-fprbF&f=-$4Zs$RJ2j]U(1*XG:[EP'hZqp,&sBCA!.s`9BnV1^tRIPRIjCCVO<.$_h"\^U.2S-\3(pHTAWTUDg3I_R<t;2%q-42BT,!MiIl*oBeBYI%41phQ<->6f%DGfhP<E#Zf2HBoPA3ZEZWBB2Y/>Tk?,C1c#_)mFgqM9AZ^T@MiG#V'mbF)F>reGp[UiMR/)i=iA3t7TVZn,\hh_L)Dt-cEWu\JNnLQ%mB"C9g+4`a(R@4)(.N\eMOU4QV=iZ^6l&$d+3'Sk+[1m;;-)+4T^oaoFQt"^An@fS<1+PF(%es'I:HGfZltk5O@V^#\q?N:,ka"X3]&Zs,\*6KSEc$HXaN6Y21,(@pt)^(o:9)EW>\D=h>H$9ZeZt"^]+'+n#jn(o@4hnhU1!l1pd/KO28t^e"=?[^V\"f7$DiMYA[e(\m[hX/gL%\fK.(X;+L%O[`asnX@jJ_^JSn-0DRHqZL!lgpH5`'5ASLFRkKN?W<p62ij1M#hU8aY/A)L'_$8D`=Y),l^f]Bd=Y.*;Q<Y9Q3#BT$WMAAOB!cpo8YG<$5$LPA/NkU)`=_S.f#L3L=`fls7l`sXjWMG(^.`6p`2SIoGmCOfphXZp>C'>ub#U_BhO@lJeKV+2Bm"sSC.SfJUkN1(_CoW^^MLlM(1+ocX026*(S@o+[?dDNieK%a:#'s"A5W7?`G\WOT"!_"0p%FO'V)Onb9Tml]\G!MOjq;"/AW^=j`@d\PSC/FeeX.+po$;*M-MsQ`Pq).V5<Y=G*?0>Nt+P)6gtN*f(s1N@b0q-da+$(Uo,UJT@Mu_ek=3rp"r+63>g8'=@B^99HVO!6`n<c>3\b]1gCS/D*J<L\7:R%edr<^)BmG9D*e"qf6;)::G-3m!c-U,:AgL5q1"GE\iAj!Bc)F)+EBpa/ljJF8LqGMN!B4`*6aWa+b[BM;kp!L6HcmL(gO#Gb`;m_mbtOV]Z$"<C!Z[B<O-c0UI56oYU`IB+?:LRgq`Y9`@$^?G@u0EhQ"HI&OHn'Q-=t>#F1<HMRgtOJuGUA>mkjr)Kk"1[7-`%Z`b4GCutHqQJ;Pkj@P1.jcVI6N(5&uKZK5jL:e$`9aEh\>tk`+,1d8>k=c8GBMg,Q8O<=pD@/1Mf#,/H0EDjL:rYs64G!\J+N#\.d81dh24X9H70doAA9tW8<X]tf.V7etk#?$j\=,G/@q)HK`ec8tCG&=!9l9pYHDUJ^E')s#bkRO'3H4k^1q0?il\b;']h5)8K1__%&_*Qa,@^ML,o#H!Z$eVP:H\o_8OY^/>LKg<6I^lNb5N^I5hhh`e9Vi<!hp")Mpb9>e^54HAga)&MpFW@gff#)8oo)a0l5Z0\C1:3'>AqcqUAuo/8!,`r6VLM-2n.iE0*:"cH>'8(E_\q6,<;qi!g&H%^A\?!`bLBIIS&Og2qAndZ]pr4]^?jUug8^UnNk6aW_!KLQqF9_MK74>5?j[jQ6JXPNA=p)X?'dYrr/UXDsAf4C*2VC<I&j[^iA;4bDDl0C?Sd31`0gloFDdPQkA)/Md%odt"`a1pWt*d=?31NhPI6T["JLmKsjYoW4MLTSDW.aB%-PT#51I,$)$-P(#8K4KG$YdCA71U[m\_<:6658Z8S@a2j:naMH3i.Km[Ya'uR*Q<^MSE@/!b9q2A/#AZ.FLcV]6RUs0.ok#CV^=jr7O5BC_%7;!O'MTJeXdI&0AtN7uHDF=^]VtZCE,_r.i*PHYNl@9=aW"\>j.bPgP>.R(LP+!$.Y?HsMUN/^<k_:$7hD[/7"WiEY'DNGh5P<7elAgj-uO%A&1K"M&PNR)f)TZW6KREP!DfH<S[QjAl-ZlV:+ZJO3(K9oLskkSi<fh8F'u[&LQeX%e1#E9Vm]*g?dYgZ/i+_U5HMemQIMn1`9cu\%]BO(^XX^bX)ao!?"rP$KBj>(k^bsg'Qe`tZ6^J^/B?HS84lgB3m+ncXq&!@>(0CfYO[ioSKW"p+Rju^YpVpG0t5ps^/`,);u)qqG1J4bbND%Ynd6VUkhpML19TJ_+#S6o3Lr4&p0RR;kPO:l"A:C.F-`=V#FmTj<]<K.BR]t>KQ"s9cEupunWar7>#$d1:pqVj8+e3O56e1>4RM"6I:HijP4t@9S*A$2UXoYA>[bS0r+9*;.W[W&Q4C&I2A$JVS;%WlnZ9B+h,bntKU5h',S'F$h76ZAKrHQcMsaX:[6*p`$%9$hcD>%7%6[drPQ`..B.E)!1S<1mgi;@!dcq-VI),/p_[D51FM.+,i^M"AM\r@&'jTmQ0m+rWg%cDD0J@WL:,nMG"n/cQ)mM9p^[8M7=VN8^h(3=7CLdN`e!+(B)O`#4Y6a6N<PA4*Bqr6j>5h[EYlkjKjsR0^7u9_SOO!5enA&VS8[+`>8*\=A;J/c,[t;4k_o(PZ=L&GhfnZ/9p*!TRUU0&J;M$1C1S7dJ3B`4%f!I!JpV/:u#s\&5mC>nng//VXi$u(mLD&>aDuHD]/fNrJJUWO^MO;#296e$YD1"6E)l[FtTX8+>di!c(\F?Z!=p.$dc*F@G>\@(a[.\N3?"p"+V\0p6/X9lMB>Z(qiEGq:>S)P$-W4QPE%IQRW;XlX+%Zi"5`'?Ak#HV"<sX1OQbH7Ti?OO@XH_Mqpip1[O1EmI0cSFqI',.<)*t/VA:82(c0!lYgUe[m:F<KW90@&phFEp%qod_Ud.c8Ia!Aj>ci-,4_u:8Hr=*u@D,$h%h#'eIVm!S=nO`$kKedu_b<M")dI1]Mg$5BRo!V43,E@X+0dNnBe9]'5,]fRu9/+<(BE_J\eg>Q($o'bk8(N-"%ttm@#2N4q[7Wp,>fU60j*.18c7E&?8:ilF\pI,ilSZCd`ic^&(45;+QTMH4aCecE'bIHpMIZ.NGdD*efAb/KK/Wo<O`1J]$Ur:P5U0aQ66)QFbAV>i2,5W42\n&Y3b]EGR\2%GW2@LAG3t0)da2]A%#`mpk_W^eZC>D]7]oMXO_1bnr#rq&Cc^7@lof]FHcV"r_hTXVf4\21'&gJAGinL?MLYEigD@WZ_TXq*4ZO(_Qbq2REGK,L4V5(>`R=qZ-Js,q&8nc#V[)2s=+>65D@;l1#ND&nH[6fPM;Y9:ig*('oK_20:nAY_g\T@I"*$t^H[6fPBp2='`(f@el+sj*CHiCBT=U:Yl64GuN^90Z@lCTR&oe^<OefNV*:V`U`9F($l\:[,>r0`;\nECM-M8_I09T4pnqNE#of94tUtsrY1b1Is=!rh"6ofTVa_t?T-)@hT,f8(Ih1gn+m:^Kpf%`^t7%"_::Ft-5ps\5WNG-4CJkpG,d1HFhAqsf%eW.GO#ki*nU;N*Y-]L!p70H=MfZ',^&_g:R=]Ba(#`IsMoX4Rl4m6U=hI%;K%5\_>lF*NlXUb?F1"bE#QjUn*+/QE8[D9D]3:NqdHKE],[^NQsBN'@bp%keA("uo$6Y1!Hl[J_he]sI^gCUIAAm9e22%=$&28I`p1p"-I2)XY9ZcBQ-fkpC7dsQ\p]ZlP)Bg0ILhNG.e1`t^6DlHIS1`t^6hp@%C8Nt'ahD/u7Bg0ILhQg4LBg0=HhKD19dsQu#I+,.'bjlq&ns6%iRH?l,jt5KZ1<kS6c!0W6AXX-KR^[KSQ>&P$C#7Qc>Gh3-Vg3&"Fd_AZSEA(unR[^kEK7M'4"Sj.0[kF$pmZVd</H`rE_`:!a(18gibV.bGOsYTOF!Yor_?8@HiU>:L7\*9ZI'A8TXdA4HPjG$JO`@M(\D2DaOtP4q+6b)@>1X'QT2`cj@I'31![jseA?E(W>aE),j;aDCV\?k"@pZ5g[;"*mLgm%qpoA=rXA2b&C[p7>KqLP99W1MD=N;u:Kr\\0%\H&BgB`]B8j1J[X1Af$oh-"<";RH)TuuEBJG;AC-E;ihg$?\88=*D&AH6/_BU$VUSh4Y>Q.$kaeQsedHn0)YPOJgGJ3@6hU+Rjd8YD5C;Ar9ACSuq6Z9MJ_a-g/faVe871R#>?8)+r/[F.E$5-]Ub$jIl!p#.+)W-PWd[k[a(\CT7$)@&gF-Ck0/!ITb4EH:D4'L'1!:\4!*"$_\k%0LKAXPee]@'S0!dWu4On1/59P?Z%G#bGY.4Pa7=46nFD0Vo!*tXJSmBA'p0BO6T*!'<.[^F6p\=u9a:H<,oMqcRHOXmmH<#66XU;S,?gW1sc`Y?qnRe7h(V_FDPi%Fh5KToXt+'M.m**XgS!l$hMgl1kGZRf>G+cb+4!gaXi]3)u^&GkdUGhI/`3aT._WTI1JFeO:A6V>WUA"+eWGDV(n,ZRGj/Vom[HaMQnLo*,(0.ZS,=nA`aZRi^;+a)rF65%5mmbfN[5-TFV:,_^A."U&Vp7.;a7ZK'%A7O#U./do7)i#ABD/MZ*7T4==>X]9CK$"YE>dGD'<Jn*d':(2i-F^Xc*\/sai&;-[fQR9'+UtF%OGSN]<plGG3S2Vu,rEd51_d_c-061"=_T>Mo!1[tk"2::C.V>b[/4n6=KrVIM'1Cb#N6o.k_a(YV?7Xal8$-t26]4PE[''ni-JE2S6*aCAg+9!aE7JP\L:f>kVV+rB;d:-X%0"^)63I[`EgEejY8^$=<h/sll<.G`+e@Z;%Y-JB*3Cq9`h96APN5?23"GLg$*A5`84P#R%lT7M%!d.jLB2f?$E=$6q$)71m(/Fo#@e)aN38e=;X]+7W(gkaQ.qcDT0Sr3BrY%S[bA*iC"#X$jV%b1bZ"Ml$7+1R0.>=Q"ehpCK?k?\I!DL.JI7?<C4=WW*e5R#oMeb#':`$Vi/6PVY8E8`GsB]gsFQ,2jA8K;mWiEOWrn^1pu_*(3AK^c&#/3Qg\\P/=q3qh1nORZH0mO(+uo^OCdCq,V:sc&_UlQ`eY%OA[Gd(dVi*hXb*Ue!jSFKZo!s18N7c,.`l7@)O;pb;H5rE)+?qa&!uV@buYQ7qVA42lTa;Es7#:_@;U8^h=0g4Go4Sp)^&+"@VK7qKf]N9S%n[l\k>1>dV^>[dos9ZH$K/`dJ<*dZdm1nn]C+iq=/d@3^1-h?=0:os#_9.XP^ZN3BgT<?Osmh?N#R]FP>`t.t,gVGklQfF,#2@n)=*Eg!NXW.^-PffF=Ii>PGq.6<J5K2IXYBSGf-gbgG=;NRqdB2lj4>bl69APAfXNp'PCq],Q"8>p7"r'?n(^i5M1Z:$;$:];(uN/s!.g/q<hsj%_,<*<,U(ir%ePI=:oAOhmO[,u;MV^Q-UrSZ[10nHN7.QLg=!qL8D2ipqCT^DM11p$KNmp7MBWh7"miGACO<4+KX!m#sa2LSO2.%Nq9]Bk?kXh51.YGD`^"TA&;WlMlq6eiZ=W%.C(h&CQ%g$pU9[I"$29rc7t^n/ibMhd)n6+/Ul"PI;!]4:'[Hn_jZNocKo-OY:%4:["A+[`k7#rrORcX1)l=afG06f2qV=Pbr-(3'[?Vlhm3#\mA`LY?qfAn(,N,BC#0Z%YG:@c6K+XH/l)I^`/4AE9Gnfrc*@H)5I1\qK4]/2h;fPr-o:7-;X~>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 + 0000001228 00000 n 83 + 0000001288 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<6da76948048f500abbc723a5a1608ef3><6da76948048f500abbc723a5a1608ef3>] 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 + 15106 96 + %%EOF
.github/reports/iso17497_scattering_example.webp

This is a binary file and will not be displayed.

+20
CHANGELOG.md
··· 24 24 PASS/FAIL verdict (a higher weighted improvement passes). `verbose=True` adds 25 25 the reference-floor-with-covering column `Ln,r = Ln,r,0 - ΔL` (the ISO 717-2 26 26 derivation basis of `ΔLw`); `language="es"` renders the Spanish fiche. 27 + - `ScatteringResult.report()`, `DiffusionSpectrum.report()` and 28 + `DiffusionResult.report()`: one-page PDF surface-scattering and diffusion 29 + test-report fiches rendered through the shared accredited-report engine 30 + (ISO 17497-1:2004+A1:2014 and ISO 17497-2:2012). The scattering fiche carries 31 + the standard-basis line, an optional metadata header (client, specimen, 32 + sample area `S`, room volume `V`, test room, climate), a per-one-third-octave 33 + table of the random-incidence absorption `alpha_s` and the scattering 34 + coefficient `s` beside the `s(f)` curve on a categorical band axis, and a 35 + boxed characterisation headline; `verbose=True` adds the specular absorption 36 + `alpha_spec` column. The diffusion-spectrum fiche tabulates the diffusion 37 + coefficient `d` per band beside the `d(f)` band-axis curve (the per-band 38 + random-incidence coefficient being the average of the directional coefficients 39 + over the source positions, Clause 8.4); `verbose=True` adds the normalised 40 + `d_n` column. The polar-response fiche tabulates the corrected reflected level 41 + `L` per receiver angle beside the semicircular polar plot, boxing the 42 + directional diffusion coefficient. ISO 17497 is a characterisation, so there 43 + is no pass/fail verdict. `language="es"` renders the Spanish fiche. A new 44 + `DiffusionSpectrum` result (and its `diffusion_spectrum` constructor) carries 45 + the diffusion coefficient across the measured bands, with `.plot()` and 46 + `.report()`. 27 47 - `ToneAudibilityResult.report()`: a one-page PDF tonal audibility assessment 28 48 fiche rendered through the shared accredited-report engine, following the 29 49 ISO 1996-2:2017 Annex J engineering method (ISO/PAS 20065:2016). The sheet
+4 -2
docs/api-reference.md
··· 789 789 | `ScatteringUncertainty` | `dataclass` | **Scattering uncertainty result.**<br>• `u_scattering`: combined standard u_s<br>• `expanded`: U = 2u_s | `u.u_scattering, u.expanded` | 790 790 | `directional_diffusion_coefficient` | `function` | **Directional diffusion coefficient d_θ (ISO 17497-2 Formulas 5/6).**<br>• `levels`: n ≥ 2 reflected SPL [dB] (−inf = zero energy)<br>• `area_weights`: Ni (Formula 8) (Default: None → equal-area Formula 5) | `directional_diffusion_coefficient([-10., -12., -15., -11., -13.]) # 0.854` | 791 791 | `directional_diffusion` | `function` | **Polar response + its coefficient.**<br>• `angles` [°], `levels` [dB]<br>• `weights`: Ni (Default: None) | `res = directional_diffusion(angles, levels)`<br><br>• `DiffusionResult` | 792 - | `DiffusionResult` | `dataclass` | **Polar diffusion result.**<br>• `angles` [°], `levels` [dB]<br>• `coefficient`: d_θ (autocorrelation)<br>• `.plot()` | `res.coefficient` | 792 + | `DiffusionResult` | `dataclass` | **Polar diffusion result.**<br>• `angles` [°], `levels` [dB]<br>• `coefficient`: d_θ (autocorrelation)<br>• `.plot()`, `.report()` | `res.coefficient` | 793 + | `diffusion_spectrum` | `function` | **Diffusion spectrum d(f) (ISO 17497-2 Clause 8.5).**<br>• `frequencies`: one-third-octave centres [Hz]<br>• `diffusion`: d per band (directional, or random-incidence via Clause 8.4)<br>• `normalized`: d_n per band (Default: None) | `res = diffusion_spectrum(f, d, normalized=d_n)`<br><br>• `DiffusionSpectrum` | 794 + | `DiffusionSpectrum` | `dataclass` | **Diffusion spectrum result.**<br>• `frequencies` [Hz]<br>• `diffusion`: d per band<br>• `normalized`: d_n per band or None<br>• `.plot()`, `.report()` | `res.diffusion` | 793 795 | `normalized_diffusion_coefficient` | `function` | **Normalised coefficient d_θ,n (Formula 7).**<br>• `d_theta`: test surface<br>• `d_theta_reference`: flat reference | `normalized_diffusion_coefficient(0.6, 0.2) # 0.5`<br><br>• (d − d_r)/(1 − d_r) | 794 796 | `area_factors` | `function` | **Per-receiver area weights Ni (Clause 8.3 Formula 8).**<br>• `elevations`: θ [°], 0–90<br>• `delta_theta` [°]<br>• `delta_phi` [°] (Default: None → `delta_theta`) | `n_i = area_factors([0, 15, 30, 45, 60, 75, 90], delta_theta=15.0)`<br><br>• Dimensionless, min 1 | 795 797 | `random_incidence_diffusion` | `function` | **Random-incidence diffusion coefficient d (Clause 8.4).**<br>• `directional_coefficients`: d_θ per source<br>• `weights`: source weights (Default: None → equal; 2-D uses `TWO_DIMENSIONAL_SOURCE_WEIGHTS`) | `d = random_incidence_diffusion(d_thetas, weights=TWO_DIMENSIONAL_SOURCE_WEIGHTS)` | ··· 864 866 | `STIWarning` | `warning class` | **STI/STIPA advisory.**<br>Emitted for suspect speech-intelligibility measurements or inputs | `warnings.simplefilter('error', STIWarning)` | 865 867 | `octavefilter` / `getansifrequencies` / `normalizedfreq` / `calculate_sensitivity` / `coverage_factor` / `expanded_uncertainty` | `function` | **Deprecated aliases (warn on use; removal in 4.0).**<br>New names: `octave_filter`, `nominal_frequencies`, `normalized_frequencies`, `sensitivity`, `insulation_coverage_factor`, `insulation_expanded_uncertainty` | `octave_filter(x, fs) # not octavefilter` | 866 868 | `__version__` | `str` | **Package version string.**<br>(no parameters) | `phonometry.__version__ # '3.2.0'` | 867 - | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `PsychoacousticAnnoyanceResult`, `FluctuationStrengthResult`, `ProgramLoudnessResult`, `KWeightingResponse`, `STIResult`, `SIIResult`, `StandardSpeechSpectrum`, `NCResult`, `RCResult`, `AgeThresholdResult`, `NiptsResult`, `HtlanResult`, `ImpulseProminenceResult`, `ImpulsiveSoundResult`, `MultipleShockResult`, `ImpulseResponseResult`, `DecayCurve`, `RoomAcousticsResult`, `ReverberationResult`, `ReverberationModelResult`, `DynamicStiffnessResult`, `MobilityResult`, `TransferStiffnessResult`, `VibrationSoundPowerResult`, `StructureBornePowerResult`, `InstalledSourceResult`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult`, `PrecisionSoundPowerResult`, `PrecisionIntensityResult`, `IntensityResult`, `UncertaintyResult`, `AbsorptionRatingResult`, `ScatteringResult`, `DiffusionResult`, `InsituAbsorptionResult`, `WeightingResponse`, `WeightedSpectrum` and `DailyVibrationExposure`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 869 + | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `PsychoacousticAnnoyanceResult`, `FluctuationStrengthResult`, `ProgramLoudnessResult`, `KWeightingResponse`, `STIResult`, `SIIResult`, `StandardSpeechSpectrum`, `NCResult`, `RCResult`, `AgeThresholdResult`, `NiptsResult`, `HtlanResult`, `ImpulseProminenceResult`, `ImpulsiveSoundResult`, `MultipleShockResult`, `ImpulseResponseResult`, `DecayCurve`, `RoomAcousticsResult`, `ReverberationResult`, `ReverberationModelResult`, `DynamicStiffnessResult`, `MobilityResult`, `TransferStiffnessResult`, `VibrationSoundPowerResult`, `StructureBornePowerResult`, `InstalledSourceResult`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult`, `PrecisionSoundPowerResult`, `PrecisionIntensityResult`, `IntensityResult`, `UncertaintyResult`, `AbsorptionRatingResult`, `ScatteringResult`, `DiffusionResult`, `DiffusionSpectrum`, `InsituAbsorptionResult`, `WeightingResponse`, `WeightedSpectrum` and `DailyVibrationExposure`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 868 870 869 871 ## Notes 870 872
+24
docs/surface-scattering.md
··· 147 147 materials.check_base_plate_scattering([0.02] * len(materials.BASE_PLATE_BANDS)) 148 148 ``` 149 149 150 + **Test-report fiche.** `ScatteringResult.report(path)` renders a one-page 151 + accredited scattering test report (ISO 17497-1): a metadata header, the 152 + per-one-third-octave table of the random-incidence absorption $\alpha_s$ and the 153 + scattering coefficient $s$ beside the $s(f)$ curve on a categorical band axis, 154 + and a boxed characterisation headline (no pass/fail). `verbose=True` adds the 155 + specular absorption $\alpha_{spec}$ column and `language="es"` renders the 156 + Spanish fiche; it needs the report extra (`pip install phonometry[report]`). 157 + 158 + [![ISO 17497-1 scattering example report: a metadata header, the per-one-third-octave table of the random-incidence absorption and the scattering coefficient beside the s(f) band-axis curve, and the boxed characterisation headline over the tested frequency range](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso17497_scattering_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso17497_scattering_example.pdf) 159 + 150 160 ## 2. Diffusion coefficient (ISO 17497-2) 151 161 152 162 The diffusion coefficient $d$ measures the **spatial uniformity** of the ··· 259 269 ``` 260 270 261 271 </details> 272 + 273 + **Test-report fiche.** Collected across the one-third-octave bands, the 274 + diffusion coefficient $d(f)$ forms a `DiffusionSpectrum` 275 + (`materials.diffusion_spectrum(freqs, d, ...)`), whose `report(path)` renders a 276 + one-page diffusion test report (ISO 17497-2, Clause 8.5): the per-band table of 277 + $d$ beside the $d(f)$ band-axis curve, with a boxed characterisation headline. 278 + Per Clause 8.4 the random-incidence coefficient is itself a per-band quantity, 279 + the average of the directional coefficients over the source positions band by 280 + band (not a mean across frequency). `verbose=True` adds the normalised $d_n$ 281 + column to the table (the curve always draws $d_n$ as a companion when present). 282 + A single band's polar response is itself reportable through 283 + `DiffusionResult.report(path)`. 284 + 285 + [![ISO 17497-2 diffusion example report: a metadata header, the per-one-third-octave table of the diffusion coefficient d beside the d(f) band-axis curve (with the normalised d_n drawn as a companion curve), and the boxed characterisation headline over the tested frequency range](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso17497_diffusion_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso17497_diffusion_example.pdf) 262 286 263 287 ## Scattering or diffusion? Two coefficients, two jobs 264 288
+4 -2
llms-full.txt
··· 12906 12906 | `ScatteringUncertainty` | `dataclass` | **Scattering uncertainty result.**<br>• `u_scattering`: combined standard u_s<br>• `expanded`: U = 2u_s | `u.u_scattering, u.expanded` | 12907 12907 | `directional_diffusion_coefficient` | `function` | **Directional diffusion coefficient d_θ (ISO 17497-2 Formulas 5/6).**<br>• `levels`: n ≥ 2 reflected SPL [dB] (−inf = zero energy)<br>• `area_weights`: Ni (Formula 8) (Default: None → equal-area Formula 5) | `directional_diffusion_coefficient([-10., -12., -15., -11., -13.]) # 0.854` | 12908 12908 | `directional_diffusion` | `function` | **Polar response + its coefficient.**<br>• `angles` [°], `levels` [dB]<br>• `weights`: Ni (Default: None) | `res = directional_diffusion(angles, levels)`<br><br>• `DiffusionResult` | 12909 - | `DiffusionResult` | `dataclass` | **Polar diffusion result.**<br>• `angles` [°], `levels` [dB]<br>• `coefficient`: d_θ (autocorrelation)<br>• `.plot()` | `res.coefficient` | 12909 + | `DiffusionResult` | `dataclass` | **Polar diffusion result.**<br>• `angles` [°], `levels` [dB]<br>• `coefficient`: d_θ (autocorrelation)<br>• `.plot()`, `.report()` | `res.coefficient` | 12910 + | `diffusion_spectrum` | `function` | **Diffusion spectrum d(f) (ISO 17497-2 Clause 8.5).**<br>• `frequencies`: one-third-octave centres [Hz]<br>• `diffusion`: d per band (directional, or random-incidence via Clause 8.4)<br>• `normalized`: d_n per band (Default: None) | `res = diffusion_spectrum(f, d, normalized=d_n)`<br><br>• `DiffusionSpectrum` | 12911 + | `DiffusionSpectrum` | `dataclass` | **Diffusion spectrum result.**<br>• `frequencies` [Hz]<br>• `diffusion`: d per band<br>• `normalized`: d_n per band or None<br>• `.plot()`, `.report()` | `res.diffusion` | 12910 12912 | `normalized_diffusion_coefficient` | `function` | **Normalised coefficient d_θ,n (Formula 7).**<br>• `d_theta`: test surface<br>• `d_theta_reference`: flat reference | `normalized_diffusion_coefficient(0.6, 0.2) # 0.5`<br><br>• (d − d_r)/(1 − d_r) | 12911 12913 | `area_factors` | `function` | **Per-receiver area weights Ni (Clause 8.3 Formula 8).**<br>• `elevations`: θ [°], 0–90<br>• `delta_theta` [°]<br>• `delta_phi` [°] (Default: None → `delta_theta`) | `n_i = area_factors([0, 15, 30, 45, 60, 75, 90], delta_theta=15.0)`<br><br>• Dimensionless, min 1 | 12912 12914 | `random_incidence_diffusion` | `function` | **Random-incidence diffusion coefficient d (Clause 8.4).**<br>• `directional_coefficients`: d_θ per source<br>• `weights`: source weights (Default: None → equal; 2-D uses `TWO_DIMENSIONAL_SOURCE_WEIGHTS`) | `d = random_incidence_diffusion(d_thetas, weights=TWO_DIMENSIONAL_SOURCE_WEIGHTS)` | ··· 12981 12983 | `STIWarning` | `warning class` | **STI/STIPA advisory.**<br>Emitted for suspect speech-intelligibility measurements or inputs | `warnings.simplefilter('error', STIWarning)` | 12982 12984 | `octavefilter` / `getansifrequencies` / `normalizedfreq` / `calculate_sensitivity` / `coverage_factor` / `expanded_uncertainty` | `function` | **Deprecated aliases (warn on use; removal in 4.0).**<br>New names: `octave_filter`, `nominal_frequencies`, `normalized_frequencies`, `sensitivity`, `insulation_coverage_factor`, `insulation_expanded_uncertainty` | `octave_filter(x, fs) # not octavefilter` | 12983 12985 | `__version__` | `str` | **Package version string.**<br>(no parameters) | `phonometry.__version__ # '3.2.0'` | 12984 - | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `PsychoacousticAnnoyanceResult`, `FluctuationStrengthResult`, `ProgramLoudnessResult`, `KWeightingResponse`, `STIResult`, `SIIResult`, `StandardSpeechSpectrum`, `NCResult`, `RCResult`, `AgeThresholdResult`, `NiptsResult`, `HtlanResult`, `ImpulseProminenceResult`, `ImpulsiveSoundResult`, `MultipleShockResult`, `ImpulseResponseResult`, `DecayCurve`, `RoomAcousticsResult`, `ReverberationResult`, `ReverberationModelResult`, `DynamicStiffnessResult`, `MobilityResult`, `TransferStiffnessResult`, `VibrationSoundPowerResult`, `StructureBornePowerResult`, `InstalledSourceResult`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult`, `PrecisionSoundPowerResult`, `PrecisionIntensityResult`, `IntensityResult`, `UncertaintyResult`, `AbsorptionRatingResult`, `ScatteringResult`, `DiffusionResult`, `InsituAbsorptionResult`, `WeightingResponse`, `WeightedSpectrum` and `DailyVibrationExposure`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 12986 + | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `PsychoacousticAnnoyanceResult`, `FluctuationStrengthResult`, `ProgramLoudnessResult`, `KWeightingResponse`, `STIResult`, `SIIResult`, `StandardSpeechSpectrum`, `NCResult`, `RCResult`, `AgeThresholdResult`, `NiptsResult`, `HtlanResult`, `ImpulseProminenceResult`, `ImpulsiveSoundResult`, `MultipleShockResult`, `ImpulseResponseResult`, `DecayCurve`, `RoomAcousticsResult`, `ReverberationResult`, `ReverberationModelResult`, `DynamicStiffnessResult`, `MobilityResult`, `TransferStiffnessResult`, `VibrationSoundPowerResult`, `StructureBornePowerResult`, `InstalledSourceResult`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult`, `PrecisionSoundPowerResult`, `PrecisionIntensityResult`, `IntensityResult`, `UncertaintyResult`, `AbsorptionRatingResult`, `ScatteringResult`, `DiffusionResult`, `DiffusionSpectrum`, `InsituAbsorptionResult`, `WeightingResponse`, `WeightedSpectrum` and `DailyVibrationExposure`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 12985 12987 12986 12988 ## Notes 12987 12989
+192
scripts/generate_reports.py
··· 1087 1087 return result, metadata, "iso3744_sound_power_example.pdf" 1088 1088 1089 1089 1090 + #: One-third-octave centre frequencies of ISO 17497 Table 1 / Clause 5, in Hz 1091 + #: (100 Hz to 5000 Hz, full scale). 1092 + _SCATTER_FREQS = np.array( 1093 + [100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1094 + 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000], 1095 + dtype=float, 1096 + ) 1097 + 1098 + 1099 + def _scattering_example() -> Tuple[object, ReportMetadata, str]: 1100 + """ISO 17497-1 fiche: a random-incidence scattering-coefficient measurement. 1101 + 1102 + A documented clean-room example (ISO 17497-1 has no numeric worked example, 1103 + so an end-to-end synthetic case is built from Eqs. (1)/(4)/(5)/(6), as the 1104 + standard itself directs). A reverberation room of volume V = 200 m3 holds a 1105 + circular test sample of area S = 10 m2 on a turntable; at 20 degC the speed 1106 + of sound is c = 343.2 m/s (Eq. (2)) and the air attenuation is neglected 1107 + (m = 0, the zero-attenuation reference). The four reverberation times of 1108 + Table 2 are chosen with a perfectly symmetrical base plate (T1 = T3, so the 1109 + base-plate scattering of Eq. (6) is exactly 0 and passes the Table 1 limits) 1110 + and a rotating turntable whose apparent (specular) absorption grows with 1111 + frequency as the surface relief scatters more energy out of the specular 1112 + direction. The random-incidence absorption alpha_s (Eq. (1)) stays below the 1113 + 0.50 ceiling of Clause 6.3.4. Two worked bands, from 1114 + s = (alpha_spec - alpha_s) / (1 - alpha_s) (Eq. (5)): 1115 + 1116 + * 500 Hz: alpha_s = 0.053, alpha_spec = 0.131, s = 0.082 -> 0.08. 1117 + * 4000 Hz: alpha_s = 0.112, alpha_spec = 0.515, s = 0.454 -> 0.45. 1118 + 1119 + The scattering coefficient rises from 0.01 at 100 Hz to 0.55 at 5000 Hz, a 1120 + broadband diffusing surface. 1121 + """ 1122 + volume, area, c = 200.0, 10.0, 343.2 1123 + t1 = np.array( 1124 + [8.0, 7.9, 7.8, 7.6, 7.4, 7.2, 7.0, 6.7, 6.4, 6.0, 1125 + 5.6, 5.2, 4.8, 4.4, 4.0, 3.6, 3.2, 2.9] 1126 + ) 1127 + t3 = t1.copy() # symmetrical base plate: T1 = T3 1128 + t2 = t1 * 0.90 # sample, static turntable 1129 + t4 = t2 * (1.0 - np.linspace(0.02, 0.28, _SCATTER_FREQS.size)) 1130 + alpha_s = ph.materials.random_incidence_absorption( 1131 + volume, area, c1=c, T1=t1, c2=c, T2=t2 1132 + ) 1133 + alpha_spec = ph.materials.specular_absorption_coefficient( 1134 + volume, area, c3=c, T3=t3, c4=c, T4=t4 1135 + ) 1136 + result = ph.materials.scattering_coefficient_spectrum( 1137 + _SCATTER_FREQS, alpha_spec, alpha_s 1138 + ) 1139 + metadata = ReportMetadata( 1140 + specimen="1:1 quadratic-residue diffuser (N = 7)", 1141 + client="Example client", 1142 + manufacturer="Example acoustics", 1143 + area=area, 1144 + room_volume=volume, 1145 + mounting="Circular sample on the rotating turntable, centre displaced d/8", 1146 + test_room="Reverberation room (example)", 1147 + measurement_standard="ISO 17497-1", 1148 + temperature=20.0, 1149 + relative_humidity=54.0, 1150 + pressure=101.0, 1151 + test_date="2026-07-21", 1152 + laboratory="Phonometry reference example", 1153 + operator="phonometry", 1154 + report_id="EXAMPLE-17497-1", 1155 + ) 1156 + return result, metadata, "iso17497_scattering_example.pdf" 1157 + 1158 + 1159 + #: The 2-D single-plane source positions of ISO 17497-2 Clause 6.2.2 (0 deg and 1160 + #: +/-30 deg, +/-60 deg about the reference normal); paired with the Clause 8.4 1161 + #: source weights (0 deg -> 1, the four others -> 3). 1162 + _DIFFUSION_SOURCES = np.array([0.0, 30.0, -30.0, 60.0, -60.0]) 1163 + 1164 + 1165 + def _diffuser_polar_energy( 1166 + angles: np.ndarray, width: float, peak: float, specular: float = 0.0 1167 + ) -> np.ndarray: 1168 + """A synthetic reflected-level polar response (a specular lobe over a floor). 1169 + 1170 + The band energy is a diffuse floor of unity plus a specular lobe of linear 1171 + amplitude ``peak`` and Gaussian half-width ``width`` (degrees) centred on the 1172 + ``specular`` reflection angle; the level is ``10 lg(energy) + 60`` dB. 1173 + """ 1174 + energy = 1.0 + peak * np.exp(-(((angles - specular) / width) ** 2)) 1175 + return 10.0 * np.log10(energy) + 60.0 1176 + 1177 + 1178 + def _diffusion_example() -> Tuple[object, ReportMetadata, str]: 1179 + """ISO 17497-2 fiche: a random-incidence diffusion-coefficient spectrum d(f). 1180 + 1181 + A documented clean-room example (ISO 17497-2 has no numeric worked example 1182 + or reference polar dataset, so the polar responses are synthesised and the 1183 + coefficient computed from Formula (5), as the standard directs). A 1184 + single-plane goniometer sweeps 19 equal-area receivers from -90 to 90 deg 1185 + (10 deg spacing) about the reference normal for each of the five 2-D source 1186 + positions of Clause 6.2.2 (0 deg and +/-30 deg, +/-60 deg), whose specular 1187 + reflection falls at the mirror angle. As frequency rises the diffuser spreads 1188 + the reflected energy ever more evenly (the specular lobe broadens and 1189 + flattens), so the directional coefficient d_theta (Formula (5)) of each 1190 + source climbs with frequency. The per-band **random-incidence** coefficient 1191 + d (Clause 8.4) is the weighted average of the five directional coefficients 1192 + over the source positions (0 deg -> 1, the four others -> 3), computed band 1193 + by band, and the normalised d_n (Formula (7), against a rigid flat reference 1194 + of the same footprint) is likewise averaged over the sources. Both climb 1195 + with frequency: d from 0.23 at 100 Hz to 0.86 at 5000 Hz. Two worked bands: 1196 + at 500 Hz d = 0.51 (d_n = 0.35); at 4000 Hz d = 0.81 (d_n = 0.68). 1197 + """ 1198 + angles = np.arange(-90.0, 90.5, 10.0) 1199 + n = _SCATTER_FREQS.size 1200 + widths = np.linspace(15.0, 70.0, n) 1201 + peaks = np.linspace(30.0, 3.0, n) 1202 + weights = np.array(ph.materials.TWO_DIMENSIONAL_SOURCE_WEIGHTS, dtype=float) 1203 + d = np.empty(n) 1204 + d_n = np.empty(n) 1205 + for k in range(n): 1206 + d_theta = [] 1207 + d_theta_n = [] 1208 + for source in _DIFFUSION_SOURCES: 1209 + specular = -source # specular reflection about the reference normal 1210 + d_s = ph.materials.directional_diffusion_coefficient( 1211 + _diffuser_polar_energy(angles, widths[k], peaks[k], specular) 1212 + ) 1213 + d_ref = ph.materials.directional_diffusion_coefficient( 1214 + _diffuser_polar_energy(angles, 0.5 * widths[k], 60.0, specular) 1215 + ) 1216 + d_theta.append(d_s) 1217 + d_theta_n.append( 1218 + float(ph.materials.normalized_diffusion_coefficient(d_s, d_ref)) 1219 + ) 1220 + # Clause 8.4: average the directional coefficients over the source 1221 + # positions, band by band, to get the random-incidence coefficient. 1222 + d[k] = ph.materials.random_incidence_diffusion(d_theta, weights=weights) 1223 + d_n[k] = ph.materials.random_incidence_diffusion( 1224 + d_theta_n, weights=weights 1225 + ) 1226 + result = ph.materials.diffusion_spectrum( 1227 + _SCATTER_FREQS, d, normalized=d_n 1228 + ) 1229 + metadata = ReportMetadata( 1230 + specimen="1:1 single-plane Schroeder diffuser (N = 7)", 1231 + client="Example client", 1232 + manufacturer="Example acoustics", 1233 + mounting="Single-plane diffuser, plane of maximum diffusion", 1234 + test_room="Anechoic goniometer (example), source at 10 m, arc at 5 m", 1235 + measurement_standard="ISO 17497-2", 1236 + temperature=20.0, 1237 + relative_humidity=50.0, 1238 + pressure=101.0, 1239 + test_date="2026-07-21", 1240 + laboratory="Phonometry reference example", 1241 + operator="phonometry", 1242 + report_id="EXAMPLE-17497-2", 1243 + ) 1244 + return result, metadata, "iso17497_diffusion_example.pdf" 1245 + 1246 + 1247 + def _diffusion_polar_example() -> Tuple[object, ReportMetadata, str]: 1248 + """ISO 17497-2 fiche: the single-source polar response of one band. 1249 + 1250 + The corrected 1000 Hz polar response behind the ``_diffusion_example`` 1251 + spectrum (Clause 8.5): 19 equal-area receivers from -90 to 90 deg, whose 1252 + autocorrelation diffusion coefficient d = 0.67 (Formula (5)) for the 1253 + normal-incidence source position. 1254 + """ 1255 + angles = np.arange(-90.0, 90.5, 10.0) 1256 + widths = np.linspace(15.0, 70.0, _SCATTER_FREQS.size) 1257 + peaks = np.linspace(30.0, 3.0, _SCATTER_FREQS.size) 1258 + band = int(np.argmin(np.abs(_SCATTER_FREQS - 1000.0))) 1259 + levels = _diffuser_polar_energy(angles, widths[band], peaks[band]) 1260 + result = ph.materials.directional_diffusion(angles, levels) 1261 + metadata = ReportMetadata( 1262 + specimen="1:1 single-plane Schroeder diffuser (N = 7)", 1263 + client="Example client", 1264 + manufacturer="Example acoustics", 1265 + mounting="Single-plane diffuser, normal-incidence source (0 deg)", 1266 + test_room="Anechoic goniometer (example), source at 10 m, arc at 5 m", 1267 + measurement_standard="ISO 17497-2", 1268 + temperature=20.0, 1269 + relative_humidity=50.0, 1270 + pressure=101.0, 1271 + test_date="2026-07-21", 1272 + laboratory="Phonometry reference example", 1273 + operator="phonometry", 1274 + report_id="EXAMPLE-17497-2P", 1275 + ) 1276 + return result, metadata, "iso17497_diffusion_polar_example.pdf" 1277 + 1278 + 1090 1279 #: Every example fiche the repository keeps rendered. New report kinds append 1091 1280 #: their factory here so ``make reports`` regenerates the full set. 1092 1281 _EXAMPLES: List[Callable[[], Tuple[object, ReportMetadata, str]]] = [ ··· 1115 1304 _open_plan_example, 1116 1305 _multiple_shock_example, 1117 1306 _sound_power_example, 1307 + _scattering_example, 1308 + _diffusion_example, 1309 + _diffusion_polar_example, 1118 1310 ] 1119 1311 1120 1312
+67
site/src/content/docs/es/guides/surface-scattering.mdx
··· 49 49 50 50 import Video from '../../../../components/Video.astro'; 51 51 import ThemeImage from '../../../../components/ThemeImage.astro'; 52 + import ReportPreview from '../../../../components/ReportPreview.astro'; 52 53 53 54 Cómo devuelve una superficie el sonido incidente (cuánto dispersa fuera de la 54 55 dirección especular, con qué uniformidad reparte lo que dispersa y cuánto ··· 199 200 materials.check_base_plate_scattering([0.02] * len(materials.BASE_PLATE_BANDS)) 200 201 ``` 201 202 203 + ### Ficha de informe de ensayo de dispersión 204 + 205 + `ScatteringResult.report(path)` genera un informe de ensayo de dispersión 206 + acreditado de una página (ISO 17497-1): una cabecera de metadatos (probeta, área 207 + de muestra $S$, volumen de sala $V$, sala de ensayo, clima), la tabla por tercio 208 + de octava de la absorción de incidencia aleatoria $\alpha_s$ y el coeficiente de 209 + dispersión $s$ junto a la curva $s(f)$ sobre un eje de bandas categórico, y un 210 + titular de caracterización enmarcado. Es una caracterización, por lo que no hay 211 + veredicto de apto/no apto. `verbose=True` añade la columna de absorción especular 212 + $\alpha_{spec}$ y `language="es"` genera la ficha en español. Requiere el extra de 213 + informes (`pip install phonometry[report]`). 214 + 215 + <ReportPreview 216 + name="iso17497_scattering_example" 217 + title="Informe de ejemplo del coeficiente de dispersión ISO 17497-1 (PDF)" 218 + description="Ficha de dispersión de incidencia aleatoria de una página: una cabecera de metadatos, la tabla por tercio de octava de la absorción de incidencia aleatoria y el coeficiente de dispersión junto a la curva s(f) con eje de bandas, y el titular de caracterización enmarcado sobre el rango de frecuencias ensayado." 219 + caption="Ficha de dispersión de incidencia aleatoria (ScatteringResult.report), los espectros alpha_s y s." 220 + /> 221 + 202 222 ## 2. Coeficiente de difusión (ISO 17497-2) 203 223 204 224 El coeficiente de difusión $d$ mide la **uniformidad espacial** del sonido ··· 318 338 ``` 319 339 320 340 </details> 341 + 342 + ### Ficha de informe de ensayo de difusión 343 + 344 + Reunido a lo largo de las bandas de tercio de octava, el coeficiente de difusión 345 + $d(f)$ forma un `DiffusionSpectrum`, cuyo `report(path)` genera un informe de 346 + ensayo de difusión de una página (ISO 17497-2, Cláusula 8.5): la tabla por banda 347 + de $d$ junto a la curva $d(f)$ con eje de bandas, con un titular de 348 + caracterización enmarcado. Según la Cláusula 8.4, el coeficiente de incidencia 349 + aleatoria es a su vez una magnitud por banda, la media de los coeficientes 350 + direccionales sobre las posiciones de fuente banda a banda (**no** una media a 351 + través de las frecuencias), de modo que aquí $d$ es un coeficiente por banda. 352 + `verbose=True` añade la columna del coeficiente normalizado $d_n$ a la tabla (la 353 + curva siempre dibuja $d_n$ como acompañante cuando está presente). 354 + 355 + ```python 356 + import numpy as np 357 + from phonometry import materials 358 + 359 + # Un coeficiente de difusión por banda (aquí un ejemplo en forma cerrada). En la 360 + # práctica, la d de incidencia aleatoria de cada banda es la media, sobre las 361 + # posiciones de fuente, de los coeficientes direccionales: para la banda k, se 362 + # promedia directional_diffusion_coefficient sobre las fuentes con 363 + # random_incidence_diffusion (Cláusula 8.4). 364 + freqs = np.array([250, 500, 1000, 2000, 4000], float) 365 + d = np.array([0.30, 0.45, 0.60, 0.75, 0.88]) 366 + spectrum = materials.diffusion_spectrum(freqs, d) 367 + spectrum.report("difusion.pdf") # ficha de una página (requiere phonometry[report]) 368 + ``` 369 + 370 + <ReportPreview 371 + name="iso17497_diffusion_example" 372 + title="Informe de ejemplo del coeficiente de difusión ISO 17497-2 (PDF)" 373 + description="Ficha de difusión de una página: una cabecera de metadatos, la tabla por tercio de octava del coeficiente de difusión d junto a la curva d(f) con eje de bandas (con el normalizado d_n dibujado como curva acompañante), y el titular de caracterización enmarcado sobre el rango de frecuencias ensayado." 374 + caption="Ficha de difusión (DiffusionSpectrum.report), la d(f) de incidencia aleatoria por banda con la curva acompañante normalizada d_n." 375 + /> 376 + 377 + La respuesta polar de una sola banda también es informable: `DiffusionResult.report(path)` 378 + genera la tabla de ángulo de recepción / nivel reflejado corregido junto al 379 + diagrama polar semicircular, enmarcando el coeficiente de difusión direccional de 380 + esa banda. 381 + 382 + <ReportPreview 383 + name="iso17497_diffusion_polar_example" 384 + title="Informe de ejemplo de respuesta polar ISO 17497-2 (PDF)" 385 + description="Ficha de respuesta polar de fuente única de una página: una cabecera de metadatos, el nivel reflejado corregido por ángulo de recepción junto al diagrama polar semicircular, y el coeficiente de difusión direccional enmarcado." 386 + caption="Ficha de respuesta polar de fuente única (DiffusionResult.report), el nivel reflejado sobre el arco de receptores." 387 + /> 321 388 322 389 ## ¿Dispersión o difusión? Dos coeficientes, dos trabajos 323 390
+63
site/src/content/docs/guides/surface-scattering.mdx
··· 49 49 50 50 import Video from '../../../components/Video.astro'; 51 51 import ThemeImage from '../../../components/ThemeImage.astro'; 52 + import ReportPreview from '../../../components/ReportPreview.astro'; 52 53 53 54 How a surface returns incident sound (how much it scatters away from the 54 55 specular direction, how uniformly it spreads what it scatters, and how much it ··· 195 196 materials.check_base_plate_scattering([0.02] * len(materials.BASE_PLATE_BANDS)) 196 197 ``` 197 198 199 + ### Scattering test-report fiche 200 + 201 + `ScatteringResult.report(path)` renders a one-page accredited scattering test 202 + report (ISO 17497-1): a metadata header (specimen, sample area $S$, room volume 203 + $V$, test room, climate), the per-one-third-octave table of the random-incidence 204 + absorption $\alpha_s$ and the scattering coefficient $s$ beside the $s(f)$ curve 205 + on a categorical band axis, and a boxed characterisation headline. It is a 206 + characterisation, so there is no pass/fail verdict. `verbose=True` adds the 207 + specular absorption $\alpha_{spec}$ column, and `language="es"` renders the 208 + Spanish fiche. It needs the report extra (`pip install phonometry[report]`). 209 + 210 + <ReportPreview 211 + name="iso17497_scattering_example" 212 + title="ISO 17497-1 scattering-coefficient example report (PDF)" 213 + description="One-page random-incidence scattering fiche: a metadata header, the per-one-third-octave table of the random-incidence absorption and the scattering coefficient beside the s(f) band-axis curve, and the boxed characterisation headline over the tested frequency range." 214 + caption="Random-incidence scattering fiche (ScatteringResult.report), the alpha_s and s spectra." 215 + /> 216 + 198 217 ## 2. Diffusion coefficient (ISO 17497-2) 199 218 200 219 The diffusion coefficient $d$ measures the **spatial uniformity** of the ··· 311 330 ``` 312 331 313 332 </details> 333 + 334 + ### Diffusion test-report fiche 335 + 336 + Collected across the one-third-octave bands, the diffusion coefficient $d(f)$ 337 + forms a `DiffusionSpectrum`, whose `report(path)` renders a one-page diffusion 338 + test report (ISO 17497-2, Clause 8.5): the per-band table of $d$ beside the 339 + $d(f)$ band-axis curve, with a boxed characterisation headline. Per Clause 8.4, 340 + the random-incidence coefficient is itself a per-band quantity, the average of 341 + the directional coefficients over the source positions band by band (**not** a 342 + mean across frequency), so `d` here is one coefficient per band. `verbose=True` 343 + adds the normalised $d_n$ column to the table (the curve always draws $d_n$ as a 344 + companion when it is present). 345 + 346 + ```python 347 + import numpy as np 348 + from phonometry import materials 349 + 350 + # One diffusion coefficient per band (here a closed-form example). In practice 351 + # each band's random-incidence d is the source-position average of the 352 + # directional coefficients: for band k, average directional_diffusion_coefficient 353 + # over the source positions with random_incidence_diffusion (Clause 8.4). 354 + freqs = np.array([250, 500, 1000, 2000, 4000], float) 355 + d = np.array([0.30, 0.45, 0.60, 0.75, 0.88]) 356 + spectrum = materials.diffusion_spectrum(freqs, d) 357 + spectrum.report("diffusion.pdf") # one-page fiche (needs phonometry[report]) 358 + ``` 359 + 360 + <ReportPreview 361 + name="iso17497_diffusion_example" 362 + title="ISO 17497-2 diffusion-coefficient example report (PDF)" 363 + description="One-page diffusion fiche: a metadata header, the per-one-third-octave table of the diffusion coefficient d beside the d(f) band-axis curve (with the normalised d_n drawn as a companion curve), and the boxed characterisation headline over the tested frequency range." 364 + caption="Diffusion fiche (DiffusionSpectrum.report), the per-band random-incidence d(f) with the normalised d_n companion curve." 365 + /> 366 + 367 + A single band's polar response is itself reportable: `DiffusionResult.report(path)` 368 + renders the corrected receiver-angle / reflected-level table beside the 369 + semicircular polar plot, boxing that band's directional diffusion coefficient. 370 + 371 + <ReportPreview 372 + name="iso17497_diffusion_polar_example" 373 + title="ISO 17497-2 polar-response example report (PDF)" 374 + description="One-page single-source polar-response fiche: a metadata header, the corrected reflected level per receiver angle beside the semicircular polar plot, and the boxed directional diffusion coefficient." 375 + caption="Single-source polar-response fiche (DiffusionResult.report), the reflected level over the receiver arc." 376 + /> 314 377 315 378 ## Scattering or diffusion? Two coefficients, two jobs 316 379
+214
site/src/content/docs/reference/api/materials/scattering-diffusion.md
··· 245 245 | :--- | :--- | 246 246 | ValueError | for a mapping missing a band or a sequence of the wrong length. | 247 247 248 + ## diffusion_spectrum 249 + 250 + ```python 251 + diffusion_spectrum( 252 + frequencies: ArrayLike, 253 + diffusion: ArrayLike, 254 + *, 255 + normalized: ArrayLike | None = None, 256 + ) -> DiffusionSpectrum 257 + ``` 258 + 259 + Diffusion-coefficient spectrum `d(f)` (ISO 17497-2, Clause 8.5). 260 + 261 + Pairs the per-band diffusion coefficients `d` with their band centres and 262 + returns a plottable, reportable [`DiffusionSpectrum`](/phonometry/reference/api/materials/scattering-diffusion/#diffusionspectrum). The coefficient 263 + is the *directional* coefficient `d_theta` (Formula (5)/(6)) when it comes 264 + from a single source position, or the *random-incidence* coefficient `d` 265 + when it is the per-band average of the directional coefficients over the 266 + source positions (Clause 8.4, e.g. via [`random_incidence_diffusion`](/phonometry/reference/api/materials/scattering-diffusion/#random_incidence_diffusion) 267 + band by band). The optional normalised coefficients `d_n` (Formula (7)) 268 + are carried through when supplied. 269 + 270 + **Parameters** 271 + 272 + | Name | Description | 273 + | :--- | :--- | 274 + | `frequencies` | One-third-octave band centres, in hertz (1-D). | 275 + | `diffusion` | Diffusion coefficient `d` per band. | 276 + | `normalized` | Optional normalised diffusion coefficient `d_n` per band; `None` when the reference flat surface was not measured. | 277 + 278 + **Returns:** A [`DiffusionSpectrum`](/phonometry/reference/api/materials/scattering-diffusion/#diffusionspectrum) with `.plot()` and `.report()`. 279 + 280 + **Raises** 281 + 282 + | Exception | When | 283 + | :--- | :--- | 284 + | ValueError | if the inputs differ in length, are empty or not 1-D. | 285 + 248 286 ## DiffusionResult 249 287 250 288 ```python ··· 276 314 277 315 Requires matplotlib (`pip install phonometry[plot]`); returns the 278 316 polar `Axes` and never calls `plt.show`. 317 + 318 + ### DiffusionResult.report() 319 + 320 + ```python 321 + DiffusionResult.report( 322 + path: str, 323 + *, 324 + metadata: ReportMetadata | None = None, 325 + engine: str = 'reportlab', 326 + verbose: bool = False, 327 + language: str = 'en', 328 + ) -> str 329 + ``` 330 + 331 + Render an ISO 17497-2 polar-response test-report fiche to a PDF. 332 + 333 + Writes a one-page accredited free-field diffusion report for a single 334 + source position (ISO 17497-2:2012, Clause 8.5): the standard-basis line, 335 + an optional metadata header block, a two-panel body with the corrected 336 + polar-response table (receiver angle and reflected sound-pressure level 337 + `L`, rounded to 0,1 dB) beside the semicircular polar plot, a boxed 338 + directional diffusion coefficient `d_theta` (Formula (5)/(6)) and a 339 + footer with the fixed disclaimer. ISO 17497-2 is a characterisation, so 340 + there is no pass/fail verdict. 341 + 342 + **Parameters** 343 + 344 + | Name | Description | 345 + | :--- | :--- | 346 + | `path` | Destination path of the PDF file. | 347 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a body-and-disclaimer fiche. The applicable descriptive fields are `client`, `manufacturer`, `specimen`, `mounting`, `test_room`, `test_date`, `temperature`, `relative_humidity`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes`. The `requirement` field is ignored (ISO 17497-2 has no verdict). | 348 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 349 + | `verbose` | Accepted for signature parity; the polar-response fiche has no extended table, so it renders the same body. | 350 + | `language` | Fiche language: `"en"` (default, English, decimal point) or `"es"` (Spanish, decimal comma). | 351 + 352 + **Returns:** The written `path` as a `str`. 353 + 354 + **Raises** 355 + 356 + | Exception | When | 357 + | :--- | :--- | 358 + | ValueError | If `engine` is not `"reportlab"`. | 359 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). | 360 + 361 + ## DiffusionSpectrum 362 + 363 + ```python 364 + DiffusionSpectrum( 365 + frequencies: Real, 366 + diffusion: Real, 367 + normalized: Real | None = None, 368 + ) 369 + ``` 370 + 371 + A diffusion-coefficient spectrum `d(f)` (ISO 17497-2, Clause 8.5). 372 + 373 + Where [`DiffusionResult`](/phonometry/reference/api/materials/scattering-diffusion/#diffusionresult) holds the polar response of a single 374 + one-third-octave band, this holds the diffusion coefficient across the 375 + measured bands, so it can be tabulated and plotted against frequency as 376 + Clause 8.5 requires. The per-band coefficient is a *directional* diffusion 377 + coefficient `d_theta` (Formula (5)/(6)) when it comes from one source 378 + position, or a *random-incidence* diffusion coefficient `d` when it is the 379 + per-band average of the directional coefficients over the source positions 380 + (Clause 8.4); the standard defines both as frequency-dependent quantities, 381 + so this carries a spectrum rather than a single number. 382 + 383 + **Attributes** 384 + 385 + | Name | Description | 386 + | :--- | :--- | 387 + | `frequencies` | One-third-octave band centre frequencies, in hertz. | 388 + | `diffusion` | Diffusion coefficient `d` per band (directional per source, or random-incidence when averaged over source positions). | 389 + | `normalized` | Optional normalised diffusion coefficient `d_n` per band (Formula (7)), or `None` when the reference flat surface was not measured. | 390 + 391 + ### DiffusionSpectrum.plot() 392 + 393 + ```python 394 + DiffusionSpectrum.plot( 395 + ax: Axes | None = None, 396 + *, 397 + language: str = 'en', 398 + **kwargs: Any, 399 + ) -> Axes 400 + ``` 401 + 402 + Plot the diffusion coefficient `d` versus frequency. 403 + 404 + Requires matplotlib (`pip install phonometry[plot]`); returns the 405 + `Axes` and never calls `plt.show`. 406 + 407 + ### DiffusionSpectrum.report() 408 + 409 + ```python 410 + DiffusionSpectrum.report( 411 + path: str, 412 + *, 413 + metadata: ReportMetadata | None = None, 414 + engine: str = 'reportlab', 415 + verbose: bool = False, 416 + language: str = 'en', 417 + ) -> str 418 + ``` 419 + 420 + Render an ISO 17497-2 diffusion-coefficient test-report fiche to a PDF. 421 + 422 + Writes a one-page accredited free-field diffusion report 423 + (ISO 17497-2:2012, Clause 8.5): the standard-basis line, an optional 424 + metadata header block, a two-panel body with the per-band table 425 + (frequency, the diffusion coefficient `d` and, when present, the 426 + normalised `d_n`) beside the `d(f)` curve on a categorical band 427 + axis, a boxed characterisation headline over the tested frequency range, 428 + and a footer with the fixed disclaimer. ISO 17497-2 is a 429 + characterisation, so there is no pass/fail verdict. 430 + 431 + **Parameters** 432 + 433 + | Name | Description | 434 + | :--- | :--- | 435 + | `path` | Destination path of the PDF file. | 436 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a body-and-disclaimer fiche whose header shows only the measured frequency range. The applicable descriptive fields are `client`, `manufacturer`, `specimen`, `mounting`, `test_room`, `test_date`, `temperature`, `relative_humidity`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes`. The `requirement` field is ignored (ISO 17497-2 has no verdict). | 437 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 438 + | `verbose` | When `True` and a normalised spectrum is present, the value table adds the normalised `d_n` column. | 439 + | `language` | Fiche language: `"en"` (default, English, decimal point) or `"es"` (Spanish, decimal comma). | 440 + 441 + **Returns:** The written `path` as a `str`. 442 + 443 + **Raises** 444 + 445 + | Exception | When | 446 + | :--- | :--- | 447 + | ValueError | If `engine` is not `"reportlab"`. | 448 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). | 279 449 280 450 ## directional_diffusion 281 451 ··· 643 813 644 814 Requires matplotlib (`pip install phonometry[plot]`); returns the 645 815 `Axes` and never calls `plt.show`. 816 + 817 + ### ScatteringResult.report() 818 + 819 + ```python 820 + ScatteringResult.report( 821 + path: str, 822 + *, 823 + metadata: ReportMetadata | None = None, 824 + engine: str = 'reportlab', 825 + verbose: bool = False, 826 + language: str = 'en', 827 + ) -> str 828 + ``` 829 + 830 + Render an ISO 17497-1 scattering-coefficient test-report fiche to a PDF. 831 + 832 + Writes a one-page accredited random-incidence scattering report 833 + (ISO 17497-1:2004+A1:2014): the standard-basis line, an optional 834 + metadata header block (client, specimen, test room, sample area `S`, 835 + temperature, humidity ...), a two-panel body with the per-band table 836 + (frequency, the random-incidence absorption `alpha_s` and the 837 + scattering coefficient `s`) beside the `s(f)` curve on a categorical 838 + band axis, and a footer with the fixed disclaimer. ISO 17497-1 is a 839 + characterisation, so there is no pass/fail verdict and no single-number 840 + rating. 841 + 842 + **Parameters** 843 + 844 + | Name | Description | 845 + | :--- | :--- | 846 + | `path` | Destination path of the PDF file. | 847 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a body-and-disclaimer fiche whose header shows only the measured frequency range. The applicable descriptive fields are `client`, `manufacturer`, `specimen`, `area`, `room_volume`, `mounting`, `test_room`, `test_date`, `temperature`, `relative_humidity`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes`. The `requirement` field is ignored (ISO 17497-1 has no verdict). | 848 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 849 + | `verbose` | When `True`, the value table inserts the specular absorption `alpha_spec` column beside `alpha_s` and `s`. | 850 + | `language` | Fiche language: `"en"` (default, English, decimal point) or `"es"` (Spanish, decimal comma). | 851 + 852 + **Returns:** The written `path` as a `str`. 853 + 854 + **Raises** 855 + 856 + | Exception | When | 857 + | :--- | :--- | 858 + | ValueError | If `engine` is not `"reportlab"`. | 859 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). | 646 860 647 861 ## ScatteringUncertainty 648 862
+4
src/phonometry/__init__.py
··· 709 709 BASE_PLATE_MAX_SCATTERING, 710 710 TWO_DIMENSIONAL_SOURCE_WEIGHTS, 711 711 DiffusionResult, 712 + DiffusionSpectrum, 712 713 ScatteringDiffusionWarning, 713 714 ScatteringResult, 714 715 ScatteringUncertainty, ··· 718 719 base_plate_scattering, 719 720 check_base_plate_scattering, 720 721 directional_diffusion, 722 + diffusion_spectrum, 721 723 directional_diffusion_coefficient, 722 724 normalized_diffusion_coefficient, 723 725 random_incidence_absorption, ··· 1656 1658 "absorption_coefficient_uncertainty", 1657 1659 "scattering_coefficient_uncertainty", 1658 1660 "ScatteringUncertainty", 1661 + "diffusion_spectrum", 1659 1662 "directional_diffusion", 1660 1663 "directional_diffusion_coefficient", 1661 1664 "DiffusionResult", 1665 + "DiffusionSpectrum", 1662 1666 "normalized_diffusion_coefficient", 1663 1667 "area_factors", 1664 1668 "random_incidence_diffusion",
+142 -1
src/phonometry/_plot/materials.py
··· 35 35 DiffuseFieldAbsorptionResult, 36 36 ) 37 37 from ..materials.road_absorption import InsituAbsorptionResult 38 - from ..materials.scattering_diffusion import DiffusionResult, ScatteringResult 38 + from ..materials.scattering_diffusion import ( 39 + DiffusionResult, 40 + DiffusionSpectrum, 41 + ScatteringResult, 42 + ) 39 43 40 44 _FREQ_LABEL = "Frequency [Hz]" 41 45 ··· 57 61 "Random-incidence scattering coefficient (ISO 17497-1)": 58 62 "Coeficiente de dispersión de incidencia aleatoria (ISO 17497-1)", 59 63 "Diffusion coefficient d = ": "Coeficiente de difusión d = ", 64 + "Directional diffusion coefficient (ISO 17497-2)": 65 + "Coeficiente de difusión direccional (ISO 17497-2)", 66 + "Reflected sound-pressure level L [dB]": 67 + "Nivel de presión acústica reflejado L [dB]", 60 68 "Absorption coefficient": "Coeficiente de absorción", 61 69 "In-situ road-surface absorption (ISO 13472-1)": 62 70 "Absorción in situ de pavimentos (ISO 13472-1)", ··· 242 250 f"{format_number(float(result.coefficient), language, decimals=2)} " 243 251 "(ISO 17497-2)" 244 252 ) 253 + return cast("Axes", ax) 254 + 255 + def plot_scattering_report( 256 + result: "ScatteringResult", ax: Axes | None = None, language: str = "en", 257 + **kwargs: Any 258 + ) -> Axes: 259 + """Scattering coefficient ``s`` and ``alpha_s`` on a categorical band axis. 260 + 261 + The report-fiche variant of :func:`plot_scattering_coefficient`: the bands 262 + sit on evenly spaced positions with nominal labels (``_band_axis``) instead 263 + of a base-10 log axis, so the embedded fiche figure lines up band-for-band 264 + with the value table beside it. The area under ``s`` is a pale, fully opaque 265 + fill drawn below the curves (svglib drops alpha when it vectorises the SVG, 266 + so a translucent fill would print as a flat block). 267 + 268 + :param result: A :class:`~phonometry.scattering_diffusion.ScatteringResult`. 269 + :param ax: Existing axes, or ``None`` to create a figure. 270 + :param kwargs: Forwarded to the scattering-curve ``plot`` call. 271 + :return: The axes. 272 + """ 273 + ax = ax if ax is not None else _new_axes() 274 + freqs = np.asarray(result.frequencies, dtype=np.float64) 275 + s = np.asarray(result.scattering, dtype=np.float64) 276 + a_s = np.asarray(result.random_incidence, dtype=np.float64) 277 + positions = _band_axis( 278 + ax, freqs, xlabel=_t("Frequency [Hz]", language), language=language 279 + ) 280 + ax.fill_between( 281 + positions, 0.0, np.clip(s, 0.0, None), 282 + color=_C_PRIMARY_LIGHT, edgecolor="none", zorder=1, 283 + ) 284 + ax.plot( 285 + positions, a_s, marker="s", ms=4, color=_C_MUTED, zorder=3, 286 + label=r"$\alpha_s$", 287 + ) 288 + kwargs.setdefault("marker", "o") 289 + kwargs.setdefault("color", _C_PRIMARY) 290 + ax.plot(positions, s, ms=4, zorder=3, label=r"$s$", **kwargs) 291 + ax.set_ylabel(_t("Coefficient", language)) 292 + top = max(1.05, float(np.nanmax(s)) * 1.05) if s.size else 1.05 293 + ax.set_ylim(0.0, top) 294 + ax.set_title( 295 + _t("Random-incidence scattering coefficient (ISO 17497-1)", language) 296 + ) 297 + ax.grid(True, axis="y", alpha=0.3) 298 + _localize_band_axes(ax, language) 299 + return ax 300 + 301 + def plot_diffusion_report( 302 + result: "DiffusionSpectrum", ax: Axes | None = None, language: str = "en", 303 + **kwargs: Any 304 + ) -> Axes: 305 + """Directional diffusion coefficient ``d(f)`` on a categorical band axis. 306 + 307 + The report-fiche figure of a :class:`DiffusionSpectrum`: the per-band 308 + directional (and, when present, normalised) diffusion coefficient over the 309 + one-third-octave bands, drawn on evenly spaced band positions with nominal 310 + labels (``_band_axis``, not a base-10 log axis) so the curve lines up with 311 + the value table. The area under ``d`` is a pale, fully opaque fill below the 312 + curves (svglib drops alpha on vectorisation). 313 + 314 + :param result: A 315 + :class:`~phonometry.scattering_diffusion.DiffusionSpectrum`. 316 + :param ax: Existing axes, or ``None`` to create a figure. 317 + :param kwargs: Forwarded to the ``d(f)`` curve ``plot`` call. 318 + :return: The axes. 319 + """ 320 + ax = ax if ax is not None else _new_axes() 321 + freqs = np.asarray(result.frequencies, dtype=np.float64) 322 + d = np.asarray(result.diffusion, dtype=np.float64) 323 + positions = _band_axis( 324 + ax, freqs, xlabel=_t("Frequency [Hz]", language), language=language 325 + ) 326 + ax.fill_between( 327 + positions, 0.0, np.clip(d, 0.0, None), 328 + color=_C_PRIMARY_LIGHT, edgecolor="none", zorder=1, 329 + ) 330 + if result.normalized is not None: 331 + d_n = np.asarray(result.normalized, dtype=np.float64) 332 + ax.plot( 333 + positions, d_n, marker="s", ms=4, color=_C_MUTED, zorder=3, 334 + label=r"$d_n$", 335 + ) 336 + kwargs.setdefault("marker", "o") 337 + kwargs.setdefault("color", _C_PRIMARY) 338 + ax.plot(positions, d, ms=4, zorder=3, label=r"$d$", **kwargs) 339 + ax.set_ylabel(_t("Coefficient", language)) 340 + ax.set_ylim(0.0, 1.05) 341 + ax.set_title( 342 + _t("Directional diffusion coefficient (ISO 17497-2)", language) 343 + ) 344 + ax.grid(True, axis="y", alpha=0.3) 345 + _localize_band_axes(ax, language) 346 + return ax 347 + 348 + def plot_diffusion_polar_report( 349 + result: "DiffusionResult", ax: Axes | None = None, language: str = "en", 350 + **kwargs: Any 351 + ) -> Axes: 352 + """Polar reflected-level response for the diffusion fiche (opaque fill). 353 + 354 + The report-fiche variant of :func:`plot_diffusion_polar`: identical polar 355 + geometry, but the enclosed area is a pale, fully opaque fill below the curve 356 + (svglib drops alpha when it vectorises the SVG). The axes must be polar; the 357 + fiche renderer creates one for it. 358 + 359 + :param result: A :class:`~phonometry.scattering_diffusion.DiffusionResult`. 360 + :param ax: Existing polar axes, or ``None`` to create one. 361 + :param kwargs: Forwarded to the reflected-level curve ``plot`` call. 362 + :return: The polar axes. 363 + """ 364 + if ax is None: 365 + plt = _import_pyplot() 366 + _fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) 367 + angles_deg = np.asarray(result.angles, dtype=np.float64) 368 + angles = np.radians(angles_deg) 369 + levels = np.asarray(result.levels, dtype=np.float64) 370 + kwargs.setdefault("marker", "o") 371 + kwargs.setdefault("color", _C_PRIMARY) 372 + ax.fill(angles, levels, color=_C_PRIMARY_LIGHT, edgecolor="none", zorder=1) 373 + ax.plot(angles, levels, ms=4, zorder=3, **kwargs) 374 + # The theta-orientation setters live on the polar axes, not the base Axes. 375 + polar_ax: Any = ax 376 + polar_ax.set_theta_zero_location("N") 377 + polar_ax.set_theta_direction(-1) 378 + if angles_deg.size and float(np.nanmin(angles_deg)) >= -90.0 and \ 379 + float(np.nanmax(angles_deg)) <= 90.0: 380 + polar_ax.set_thetamin(-90) 381 + polar_ax.set_thetamax(90) 382 + ax.set_title(_t("Directional diffusion coefficient (ISO 17497-2)", language)) 383 + from .._i18n import localize_axes 384 + 385 + localize_axes(ax, language) 245 386 return cast("Axes", ax) 246 387 247 388 def plot_insitu_absorption(
+18
src/phonometry/_report/_i18n.py
··· 514 514 "Reduction of impact sound pressure level <b>&#916;L</b>, {lo} Hz to {hi} Hz": "Reducción del nivel de presión acústica de impactos <b>&#916;L</b>, {lo} Hz a {hi} Hz", 515 515 "Weighted reduction of impact sound pressure level &#916;L<sub>w</sub> rated on the 100 Hz to 3150 Hz one-third-octave bands using the ISO 717-2:2020 heavyweight reference floor (Table 4); the statement of results carries the spectrum adaptation term C<sub>I,&#916;</sub> (ISO 717-2:2020 Formula (A.4)).": "Reducción ponderada del nivel de presión acústica de impactos &#916;L<sub>w</sub> calificada sobre las bandas de tercio de octava de 100 Hz a 3150 Hz con el suelo de referencia pesado de la Norma ISO 717-2:2020 (Tabla 4); la expresión de resultados incluye el término de adaptación espectral C<sub>I,&#916;</sub> (ISO 717-2:2020 Fórmula (A.4)).", 516 516 "&#916;L<sub>w</sub> = {value} dB, required &#8805; {req} dB": "&#916;L<sub>w</sub> = {value} dB, exigido &#8805; {req} dB", 517 + # --- ISO 17497-1/-2 surface scattering and diffusion ----------------- 518 + "Surface scattering measurement": "Medición de la dispersión superficial", 519 + "Surface diffusion measurement": "Medición de la difusión superficial", 520 + "{standard} reverberation-room measurement of the random-incidence scattering coefficient per ISO 17497-1:2004+A1:2014.": "{standard} medición en cámara reverberante del coeficiente de dispersión de incidencia aleatoria según ISO 17497-1:2004+A1:2014.", 521 + "Reverberation-room measurement of the random-incidence scattering coefficient per ISO 17497-1:2004+A1:2014.": "Medición en cámara reverberante del coeficiente de dispersión de incidencia aleatoria según ISO 17497-1:2004+A1:2014.", 522 + "{standard} free-field measurement of the directional diffusion coefficient per ISO 17497-2:2012.": "{standard} medición en campo libre del coeficiente de difusión direccional según ISO 17497-2:2012.", 523 + "Free-field measurement of the directional diffusion coefficient per ISO 17497-2:2012.": "Medición en campo libre del coeficiente de difusión direccional según ISO 17497-2:2012.", 524 + "{standard} free-field polar-response measurement of the directional diffusion coefficient per ISO 17497-2:2012.": "{standard} medición de la respuesta polar en campo libre del coeficiente de difusión direccional según ISO 17497-2:2012.", 525 + "Free-field polar-response measurement of the directional diffusion coefficient per ISO 17497-2:2012.": "Medición de la respuesta polar en campo libre del coeficiente de difusión direccional según ISO 17497-2:2012.", 526 + "One-third-octave &#945;<sub>s</sub> and scattering coefficient s": "&#945;<sub>s</sub> y coeficiente de dispersión s en tercios de octava", 527 + "One-third-octave &#945;<sub>s</sub>, &#945;<sub>spec</sub> and scattering coefficient s": "&#945;<sub>s</sub>, &#945;<sub>spec</sub> y coeficiente de dispersión s en tercios de octava", 528 + "One-third-octave diffusion coefficient d": "Coeficiente de difusión d en tercios de octava", 529 + "One-third-octave diffusion coefficient d and normalised d<sub>n</sub>": "Coeficiente de difusión d y normalizado d<sub>n</sub> en tercios de octava", 530 + "Corrected polar response L per receiver angle": "Respuesta polar corregida L por ángulo de recepción", 531 + "Angle &#952; [&#176;]": "Ángulo &#952; [&#176;]", 532 + "Random-incidence scattering coefficient <b>s</b>, {lo} Hz to {hi} Hz": "Coeficiente de dispersión de incidencia aleatoria <b>s</b>, {lo} Hz a {hi} Hz", 533 + "Diffusion coefficient <b>d</b>, {lo} Hz to {hi} Hz": "Coeficiente de difusión <b>d</b>, {lo} Hz a {hi} Hz", 534 + "Directional diffusion coefficient <b>d</b> = {value}": "Coeficiente de difusión direccional <b>d</b> = {value}", 517 535 } 518 536 519 537
+7 -1
src/phonometry/_report/_layout.py
··· 137 137 y_top: float | None, 138 138 expand_step: float | None = None, 139 139 figsize: Tuple[float, float] | None = None, 140 + subplot_kw: dict[str, Any] | None = None, 140 141 language: str = "en", 141 142 ) -> Any: 142 143 """Draw a result's plot as a scaled, vector reportlab ``Drawing``. ··· 159 160 ``None`` keeps the default portrait ``(5.8, 6.4)``. A landscape size 160 161 (e.g. a wide, short time plot) is passed for a stacked full-width 161 162 figure. 163 + :param subplot_kw: Keyword arguments forwarded to ``Figure.subplots`` when 164 + the plot needs a non-rectangular axes (e.g. ``{"projection": "polar"}`` 165 + for a polar reflected-level response); ``None`` uses the default 166 + rectangular axes. A ``y_top`` of ``None`` is expected alongside a polar 167 + axes, since the radial limits are the plot's own. 162 168 :param language: Fiche language, forwarded to ``plot_fn`` so the embedded 163 169 chart's axis labels and legends are localised, and to the tick-label 164 170 decimal separator. ··· 179 185 try: 180 186 fig = Figure(figsize=figsize if figsize is not None else (5.8, 6.4)) 181 187 FigureCanvasAgg(fig) 182 - ax = fig.subplots() 188 + ax = fig.subplots(subplot_kw=subplot_kw) if subplot_kw else fig.subplots() 183 189 # Forward the fiche language so the embedded chart is localised too 184 190 # (every result ``plot`` accepts ``language``); without it a Spanish 185 191 # fiche would embed English axis labels and legends.
+555
src/phonometry/_report/iso17497.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ISO 17497-1/-2 surface-scattering and diffusion fiches (reportlab renderer). 3 + 4 + Renders the two sound-scattering surface descriptors of ISO 17497 to one-page 5 + PDFs laid out like accredited test reports: 6 + 7 + * :func:`render_scattering_report` renders a 8 + :class:`~phonometry.materials.scattering_diffusion.ScatteringResult` as a 9 + random-incidence *scattering* coefficient report (ISO 17497-1:2004+A1:2014): 10 + the standard-basis line, an optional metadata header block, a two-panel body 11 + with the per-band table (frequency, the random-incidence absorption 12 + ``alpha_s`` and the scattering coefficient ``s``) beside the ``s(f)`` curve on 13 + a categorical one-third-octave band axis, a boxed characterisation headline 14 + and the footer identity/disclaimer block. ``verbose`` adds the specular 15 + absorption ``alpha_spec`` column. 16 + 17 + * :func:`render_diffusion_spectrum_report` renders a 18 + :class:`~phonometry.materials.scattering_diffusion.DiffusionSpectrum` as a 19 + directional *diffusion* coefficient report (ISO 17497-2:2012, Clause 8.5): 20 + the per-band table (frequency, the directional diffusion coefficient ``d`` 21 + and, when present, the normalised ``d_n``) beside the ``d(f)`` band-axis 22 + curve. ``verbose`` adds the normalised ``d_n`` column when it was measured. 23 + 24 + * :func:`render_diffusion_polar_report` renders a 25 + :class:`~phonometry.materials.scattering_diffusion.DiffusionResult` (the polar 26 + response of a single source position) as the Clause 8.5 polar-response report: 27 + the corrected receiver-angle / reflected-level table beside the semicircular 28 + polar plot, with the directional diffusion coefficient ``d_theta`` boxed. 29 + 30 + Both parts of ISO 17497 are characterisations, so these fiches carry no 31 + pass/fail verdict and no single-number rating (the scattering and diffusion 32 + coefficients answer different questions and are not interchangeable). 33 + 34 + The quantity-independent skeleton lives in :mod:`._layout`; this module only 35 + holds the ISO 17497 specifics. reportlab, matplotlib and svglib are soft 36 + dependencies imported lazily (reportlab and svglib ship in the 37 + ``phonometry[report]`` extra, matplotlib in ``phonometry[plot]``); each is 38 + guarded with an actionable :class:`ImportError`. 39 + """ 40 + 41 + from __future__ import annotations 42 + 43 + import functools 44 + import html 45 + from typing import TYPE_CHECKING, Any, List, Tuple 46 + 47 + import numpy as np 48 + 49 + from ._i18n import format_number, t 50 + from ._layout import ( 51 + _ACCENT_HEX, 52 + _REPORTLAB_HINT, 53 + band_table, 54 + band_table_header_style, 55 + build_document, 56 + document_styles, 57 + fmt_meta, 58 + footer_flow, 59 + grid_table, 60 + render_figure_drawing, 61 + result_box, 62 + two_panel_body, 63 + ) 64 + from .metadata import ReportMetadata 65 + 66 + if TYPE_CHECKING: 67 + from ..materials.scattering_diffusion import ( 68 + DiffusionResult, 69 + DiffusionSpectrum, 70 + ScatteringResult, 71 + ) 72 + 73 + 74 + def _c2(value: float, language: str = "en") -> str: 75 + """Two decimals (coefficients ``s``, ``d``; ISO 17497 rounds to 0,01).""" 76 + return format_number(value, language, decimals=2) 77 + 78 + 79 + def _d1(value: float, language: str = "en") -> str: 80 + """One decimal (polar reflected levels; ISO 17497-2 rounds to 0,1 dB).""" 81 + return format_number(value, language, decimals=1) 82 + 83 + 84 + def _common_metadata_pairs( 85 + metadata: ReportMetadata | None, 86 + freq_range: str | None, 87 + language: str = "en", 88 + *, 89 + include_room_fields: bool = True, 90 + ) -> List[Tuple[str, str]]: 91 + """Build the ordered (label, value) pairs shared by the ISO 17497 fiches. 92 + 93 + The descriptive fields (client, specimen, mounting, room, climate ...) come 94 + from the :class:`ReportMetadata` when one is supplied; the measured frequency 95 + range is taken from the result. Only fields that are set are returned, so 96 + empty rows never appear. The frequency range is a label-safe formatted 97 + string; the remaining values are user free text and are XML-escaped so a 98 + stray ``&`` or ``<`` cannot break reportlab's ``Paragraph`` parser. 99 + 100 + ``include_room_fields`` controls the reverberation-room quantities (sample 101 + area ``S`` and room volume ``V``): they are shown for the ISO 17497-1 102 + scattering fiche but suppressed for the ISO 17497-2 diffusion fiches, which 103 + are free-field methods where those fields do not apply (so a metadata object 104 + reused across a Part 1 and a Part 2 campaign never leaks them into the 105 + diffusion sheet). 106 + """ 107 + def _md(name: str) -> Any: 108 + return getattr(metadata, name) if metadata is not None else None 109 + 110 + area = _md("area") if include_room_fields else None 111 + room_volume = _md("room_volume") if include_room_fields else None 112 + temperature = _md("temperature") 113 + humidity = _md("relative_humidity") 114 + pressure = _md("pressure") 115 + 116 + freq_label = t("Frequency range [Hz]", language) 117 + specs: List[Tuple[str, str | None]] = [ 118 + (t("Client", language), _md("client")), 119 + (t("Manufacturer", language), _md("manufacturer")), 120 + (t("Description", language), _md("specimen")), 121 + (t("Sample area S [m<super>2</super>]", language), 122 + fmt_meta(area, language) if area is not None else None), 123 + (t("Room volume V [m<super>3</super>]", language), 124 + fmt_meta(room_volume, language) if room_volume is not None else None), 125 + (freq_label, freq_range), 126 + (t("Mounting", language), _md("mounting")), 127 + (t("Test room", language), _md("test_room")), 128 + (t("Date of test", language), _md("test_date")), 129 + (t("Temperature [&#176;C]", language), 130 + fmt_meta(temperature, language) if temperature is not None else None), 131 + (t("Relative humidity [%]", language), 132 + fmt_meta(humidity, language) if humidity is not None else None), 133 + (t("Ambient pressure [kPa]", language), 134 + fmt_meta(pressure, language) if pressure is not None else None), 135 + ] 136 + return [ 137 + (label, value if label == freq_label else html.escape(str(value))) 138 + for label, value in specs 139 + if value is not None 140 + ] 141 + 142 + 143 + def _freq_range(freqs: np.ndarray, language: str) -> str | None: 144 + """The ``{lo} to {hi}`` frequency-range string, or ``None`` when empty.""" 145 + if not freqs.size: 146 + return None 147 + return t("{lo} to {hi}", language).format( 148 + lo=int(round(float(freqs.min()))), hi=int(round(float(freqs.max()))) 149 + ) 150 + 151 + 152 + def _basis_line( 153 + metadata: ReportMetadata | None, 154 + with_standard: str, 155 + without_standard: str, 156 + language: str, 157 + ) -> str: 158 + """The standard-basis line, naming the measurement standard when supplied.""" 159 + measurement_standard = ( 160 + metadata.measurement_standard if metadata is not None else None 161 + ) 162 + if measurement_standard: 163 + return t(with_standard, language).format( 164 + standard=html.escape(measurement_standard) 165 + ) 166 + return t(without_standard, language) 167 + 168 + 169 + def _header_flow( 170 + title: str, 171 + basis: str, 172 + header_pairs: List[Tuple[str, str]], 173 + title_style: Any, 174 + basis_style: Any, 175 + ) -> List[Any]: 176 + """The shared title/basis/metadata-grid opening of an ISO 17497 fiche.""" 177 + from reportlab.platypus import Paragraph, Spacer 178 + 179 + flow: List[Any] = [ 180 + Paragraph(title, title_style), 181 + Paragraph(basis, basis_style), 182 + ] 183 + if header_pairs: 184 + flow.append(Spacer(1, 3)) 185 + flow.append(grid_table(header_pairs)) 186 + flow.append(Spacer(1, 8)) 187 + return flow 188 + 189 + 190 + # --------------------------------------------------------------------------- 191 + # ISO 17497-1: random-incidence scattering coefficient. 192 + # --------------------------------------------------------------------------- 193 + def _scattering_table( 194 + result: "ScatteringResult", verbose: bool, language: str = "en" 195 + ) -> Any: 196 + """Build the per-band ``f | alpha_s | s`` table (``alpha_spec`` if verbose).""" 197 + from reportlab.lib.units import mm 198 + from reportlab.platypus import Paragraph 199 + 200 + head_style = band_table_header_style() 201 + freqs = np.asarray(result.frequencies, dtype=np.float64) 202 + a_s = np.asarray(result.random_incidence, dtype=np.float64) 203 + s = np.asarray(result.scattering, dtype=np.float64) 204 + if verbose: 205 + spec = np.asarray(result.specular, dtype=np.float64) 206 + header = [ 207 + Paragraph(t("f [Hz]", language), head_style), 208 + Paragraph("&#945;<sub>s</sub>", head_style), 209 + Paragraph("&#945;<sub>spec</sub>", head_style), 210 + Paragraph("s", head_style), 211 + ] 212 + rows: List[List[Any]] = [header] 213 + for fk, ak, spk, sk in zip(freqs, a_s, spec, s): 214 + rows.append([ 215 + f"{int(round(fk))}", 216 + _c2(ak, language), 217 + _c2(spk, language), 218 + _c2(sk, language), 219 + ]) 220 + col_widths = [22 * mm, 22 * mm, 24 * mm, 20 * mm] 221 + else: 222 + header = [ 223 + Paragraph(t("f [Hz]", language), head_style), 224 + Paragraph("&#945;<sub>s</sub>", head_style), 225 + Paragraph("s", head_style), 226 + ] 227 + rows = [header] 228 + for fk, ak, sk in zip(freqs, a_s, s): 229 + rows.append([ 230 + f"{int(round(fk))}", 231 + _c2(ak, language), 232 + _c2(sk, language), 233 + ]) 234 + col_widths = [20 * mm, 18 * mm, 18 * mm] 235 + return band_table(rows, col_widths, len(freqs)) 236 + 237 + 238 + def render_scattering_report( 239 + result: "ScatteringResult", 240 + path: str, 241 + *, 242 + metadata: ReportMetadata | None = None, 243 + verbose: bool = False, 244 + language: str = "en", 245 + ) -> str: 246 + """Render an ISO 17497-1 scattering-coefficient fiche to a PDF at ``path``. 247 + 248 + :param result: A 249 + :class:`~phonometry.materials.scattering_diffusion.ScatteringResult`. 250 + :param path: Destination path of the PDF file. 251 + :param metadata: Optional :class:`ReportMetadata`; ``None`` renders the body 252 + with only the measured frequency range. The ``requirement`` field is 253 + ignored (ISO 17497-1 has no verdict). 254 + :param verbose: When ``True``, the value table adds the specular absorption 255 + ``alpha_spec`` column. 256 + :param language: ``"en"`` (default) or ``"es"``. 257 + :return: The written ``path`` as a :class:`str`. 258 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 259 + installed. 260 + """ 261 + try: 262 + from reportlab.lib import colors 263 + from reportlab.lib.units import mm 264 + from reportlab.platypus import Paragraph, Spacer 265 + except ImportError as exc: 266 + raise ImportError(_REPORTLAB_HINT) from exc 267 + accent = colors.HexColor(_ACCENT_HEX) 268 + 269 + freqs = np.asarray(result.frequencies, dtype=np.float64) 270 + s = np.asarray(result.scattering, dtype=np.float64) 271 + if freqs.shape != s.shape: 272 + raise ValueError( 273 + "render_scattering_report() needs 'frequencies' and 'scattering' " 274 + "of equal length." 275 + ) 276 + 277 + styles, title_style, basis_style, caption_style = document_styles(accent) 278 + title = t("Surface scattering measurement", language) 279 + basis = _basis_line( 280 + metadata, 281 + "{standard} reverberation-room measurement of the random-incidence " 282 + "scattering coefficient per ISO 17497-1:2004+A1:2014.", 283 + "Reverberation-room measurement of the random-incidence scattering " 284 + "coefficient per ISO 17497-1:2004+A1:2014.", 285 + language, 286 + ) 287 + header_pairs = _common_metadata_pairs( 288 + metadata, _freq_range(freqs, language), language 289 + ) 290 + flow = _header_flow(title, basis, header_pairs, title_style, basis_style) 291 + 292 + from .._plot.materials import plot_scattering_report 293 + 294 + caption = ( 295 + t("One-third-octave &#945;<sub>s</sub>, &#945;<sub>spec</sub> and " 296 + "scattering coefficient s", language) 297 + if verbose 298 + else t("One-third-octave &#945;<sub>s</sub> and scattering " 299 + "coefficient s", language) 300 + ) 301 + left_cell = [ 302 + Paragraph(caption, caption_style), 303 + _scattering_table(result, verbose, language), 304 + ] 305 + plot_fn = functools.partial(plot_scattering_report, result) 306 + if verbose: 307 + plot_drawing = render_figure_drawing( 308 + plot_fn, 84 * mm, y_top=None, language=language 309 + ) 310 + flow.append( 311 + two_panel_body( 312 + left_cell, plot_drawing, left_width_mm=90.0, plot_width_mm=84.0 313 + ) 314 + ) 315 + else: 316 + plot_drawing = render_figure_drawing( 317 + plot_fn, 116 * mm, y_top=None, language=language 318 + ) 319 + flow.append(two_panel_body(left_cell, plot_drawing)) 320 + flow.append(Spacer(1, 8)) 321 + 322 + lo = int(round(float(freqs.min()))) if freqs.size else 0 323 + hi = int(round(float(freqs.max()))) if freqs.size else 0 324 + statement = t( 325 + "Random-incidence scattering coefficient <b>s</b>, {lo} Hz to {hi} Hz", 326 + language, 327 + ).format(lo=lo, hi=hi) 328 + flow.append(result_box(statement, styles, accent)) 329 + flow.extend(footer_flow(metadata, language)) 330 + return build_document(path, flow, title) 331 + 332 + 333 + # --------------------------------------------------------------------------- 334 + # ISO 17497-2: directional diffusion coefficient spectrum d(f). 335 + # --------------------------------------------------------------------------- 336 + def _diffusion_table( 337 + result: "DiffusionSpectrum", show_normalized: bool, language: str = "en" 338 + ) -> Any: 339 + """Build the per-band ``f | d`` table (adds ``d_n`` when requested).""" 340 + from reportlab.lib.units import mm 341 + from reportlab.platypus import Paragraph 342 + 343 + head_style = band_table_header_style() 344 + freqs = np.asarray(result.frequencies, dtype=np.float64) 345 + d = np.asarray(result.diffusion, dtype=np.float64) 346 + if show_normalized and result.normalized is not None: 347 + d_n = np.asarray(result.normalized, dtype=np.float64) 348 + header = [ 349 + Paragraph(t("f [Hz]", language), head_style), 350 + Paragraph("d", head_style), 351 + Paragraph("d<sub>n</sub>", head_style), 352 + ] 353 + rows: List[List[Any]] = [header] 354 + for fk, dk, dnk in zip(freqs, d, d_n): 355 + rows.append([ 356 + f"{int(round(fk))}", 357 + _c2(dk, language), 358 + _c2(dnk, language), 359 + ]) 360 + col_widths = [20 * mm, 18 * mm, 18 * mm] 361 + else: 362 + header = [ 363 + Paragraph(t("f [Hz]", language), head_style), 364 + Paragraph("d", head_style), 365 + ] 366 + rows = [header] 367 + for fk, dk in zip(freqs, d): 368 + rows.append([f"{int(round(fk))}", _c2(dk, language)]) 369 + col_widths = [28 * mm, 28 * mm] 370 + return band_table(rows, col_widths, len(freqs)) 371 + 372 + 373 + def render_diffusion_spectrum_report( 374 + result: "DiffusionSpectrum", 375 + path: str, 376 + *, 377 + metadata: ReportMetadata | None = None, 378 + verbose: bool = False, 379 + language: str = "en", 380 + ) -> str: 381 + """Render an ISO 17497-2 diffusion-coefficient fiche to a PDF at ``path``. 382 + 383 + :param result: A 384 + :class:`~phonometry.materials.scattering_diffusion.DiffusionSpectrum`. 385 + :param path: Destination path of the PDF file. 386 + :param metadata: Optional :class:`ReportMetadata`; ``None`` renders the body 387 + with only the measured frequency range. The ``requirement`` field is 388 + ignored (ISO 17497-2 has no verdict). 389 + :param verbose: When ``True`` and a normalised spectrum is present, the 390 + value table adds the normalised ``d_n`` column. 391 + :param language: ``"en"`` (default) or ``"es"``. 392 + :return: The written ``path`` as a :class:`str`. 393 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 394 + installed. 395 + """ 396 + try: 397 + from reportlab.lib import colors 398 + from reportlab.lib.units import mm 399 + from reportlab.platypus import Paragraph, Spacer 400 + except ImportError as exc: 401 + raise ImportError(_REPORTLAB_HINT) from exc 402 + accent = colors.HexColor(_ACCENT_HEX) 403 + 404 + freqs = np.asarray(result.frequencies, dtype=np.float64) 405 + d = np.asarray(result.diffusion, dtype=np.float64) 406 + if freqs.shape != d.shape: 407 + raise ValueError( 408 + "render_diffusion_spectrum_report() needs 'frequencies' and " 409 + "'diffusion' of equal length." 410 + ) 411 + 412 + styles, title_style, basis_style, caption_style = document_styles(accent) 413 + title = t("Surface diffusion measurement", language) 414 + basis = _basis_line( 415 + metadata, 416 + "{standard} free-field measurement of the directional diffusion " 417 + "coefficient per ISO 17497-2:2012.", 418 + "Free-field measurement of the directional diffusion coefficient per " 419 + "ISO 17497-2:2012.", 420 + language, 421 + ) 422 + header_pairs = _common_metadata_pairs( 423 + metadata, _freq_range(freqs, language), language, 424 + include_room_fields=False, 425 + ) 426 + flow = _header_flow(title, basis, header_pairs, title_style, basis_style) 427 + 428 + from .._plot.materials import plot_diffusion_report 429 + 430 + show_normalized = verbose and result.normalized is not None 431 + caption = ( 432 + t("One-third-octave diffusion coefficient d and normalised d<sub>n</sub>", 433 + language) 434 + if show_normalized 435 + else t("One-third-octave diffusion coefficient d", language) 436 + ) 437 + left_cell = [ 438 + Paragraph(caption, caption_style), 439 + _diffusion_table(result, show_normalized, language), 440 + ] 441 + plot_fn = functools.partial(plot_diffusion_report, result) 442 + plot_drawing = render_figure_drawing( 443 + plot_fn, 116 * mm, y_top=None, language=language 444 + ) 445 + flow.append(two_panel_body(left_cell, plot_drawing)) 446 + flow.append(Spacer(1, 8)) 447 + 448 + lo = int(round(float(freqs.min()))) if freqs.size else 0 449 + hi = int(round(float(freqs.max()))) if freqs.size else 0 450 + statement = t( 451 + "Diffusion coefficient <b>d</b>, {lo} Hz to {hi} Hz", language 452 + ).format(lo=lo, hi=hi) 453 + flow.append(result_box(statement, styles, accent)) 454 + flow.extend(footer_flow(metadata, language)) 455 + return build_document(path, flow, title) 456 + 457 + 458 + # --------------------------------------------------------------------------- 459 + # ISO 17497-2: single-source polar response. 460 + # --------------------------------------------------------------------------- 461 + def _polar_table(result: "DiffusionResult", language: str = "en") -> Any: 462 + """Build the corrected ``angle | reflected level`` polar-response table.""" 463 + from reportlab.lib.units import mm 464 + from reportlab.platypus import Paragraph 465 + 466 + head_style = band_table_header_style() 467 + angles = np.asarray(result.angles, dtype=np.float64) 468 + levels = np.asarray(result.levels, dtype=np.float64) 469 + header = [ 470 + Paragraph(t("Angle &#952; [&#176;]", language), head_style), 471 + Paragraph("L [dB]", head_style), 472 + ] 473 + rows: List[List[Any]] = [header] 474 + for ang, lev in zip(angles, levels): 475 + rows.append([_d1(ang, language), _d1(lev, language)]) 476 + return band_table(rows, [28 * mm, 28 * mm], len(angles)) 477 + 478 + 479 + def render_diffusion_polar_report( 480 + result: "DiffusionResult", 481 + path: str, 482 + *, 483 + metadata: ReportMetadata | None = None, 484 + verbose: bool = False, 485 + language: str = "en", 486 + ) -> str: 487 + """Render an ISO 17497-2 polar-response fiche to a PDF at ``path``. 488 + 489 + :param result: A 490 + :class:`~phonometry.materials.scattering_diffusion.DiffusionResult`. 491 + :param path: Destination path of the PDF file. 492 + :param metadata: Optional :class:`ReportMetadata`; ``None`` renders the body 493 + and disclaimer only. The ``requirement`` field is ignored (ISO 17497-2 494 + has no verdict). 495 + :param verbose: Accepted for signature parity; the polar-response fiche has 496 + no extended table. 497 + :param language: ``"en"`` (default) or ``"es"``. 498 + :return: The written ``path`` as a :class:`str`. 499 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 500 + installed. 501 + """ 502 + del verbose # signature parity: the polar fiche has no extended table 503 + try: 504 + from reportlab.lib import colors 505 + from reportlab.lib.units import mm 506 + from reportlab.platypus import Paragraph, Spacer 507 + except ImportError as exc: 508 + raise ImportError(_REPORTLAB_HINT) from exc 509 + accent = colors.HexColor(_ACCENT_HEX) 510 + 511 + angles = np.asarray(result.angles, dtype=np.float64) 512 + levels = np.asarray(result.levels, dtype=np.float64) 513 + if angles.shape != levels.shape: 514 + raise ValueError( 515 + "render_diffusion_polar_report() needs 'angles' and 'levels' of " 516 + "equal length." 517 + ) 518 + 519 + styles, title_style, basis_style, caption_style = document_styles(accent) 520 + title = t("Surface diffusion measurement", language) 521 + basis = _basis_line( 522 + metadata, 523 + "{standard} free-field polar-response measurement of the directional " 524 + "diffusion coefficient per ISO 17497-2:2012.", 525 + "Free-field polar-response measurement of the directional diffusion " 526 + "coefficient per ISO 17497-2:2012.", 527 + language, 528 + ) 529 + header_pairs = _common_metadata_pairs( 530 + metadata, None, language, include_room_fields=False 531 + ) 532 + flow = _header_flow(title, basis, header_pairs, title_style, basis_style) 533 + 534 + from .._plot.materials import plot_diffusion_polar_report 535 + 536 + caption = t("Corrected polar response L per receiver angle", language) 537 + left_cell = [Paragraph(caption, caption_style), _polar_table(result, language)] 538 + plot_fn = functools.partial(plot_diffusion_polar_report, result) 539 + plot_drawing = render_figure_drawing( 540 + plot_fn, 108 * mm, y_top=None, 541 + subplot_kw={"projection": "polar"}, language=language, 542 + ) 543 + flow.append( 544 + two_panel_body( 545 + left_cell, plot_drawing, left_width_mm=64.0, plot_width_mm=110.0 546 + ) 547 + ) 548 + flow.append(Spacer(1, 8)) 549 + 550 + statement = t( 551 + "Directional diffusion coefficient <b>d</b> = {value}", language 552 + ).format(value=_c2(float(result.coefficient), language)) 553 + flow.append(result_box(statement, styles, accent)) 554 + flow.extend(footer_flow(metadata, language)) 555 + return build_document(path, flow, title)
+4
src/phonometry/materials/__init__.py
··· 132 132 BASE_PLATE_BANDS, 133 133 BASE_PLATE_MAX_SCATTERING, 134 134 DiffusionResult, 135 + DiffusionSpectrum, 135 136 ScatteringDiffusionWarning, 136 137 ScatteringResult, 137 138 ScatteringUncertainty, ··· 142 143 base_plate_scattering, 143 144 check_base_plate_scattering, 144 145 directional_diffusion, 146 + diffusion_spectrum, 145 147 directional_diffusion_coefficient, 146 148 normalized_diffusion_coefficient, 147 149 random_incidence_absorption, ··· 177 179 "DELANY_BAZLEY_VALIDITY", 178 180 "DiffuseFieldAbsorptionResult", 179 181 "DiffusionResult", 182 + "DiffusionSpectrum", 180 183 "DynamicStiffnessResult", 181 184 "DynamicStiffnessWarning", 182 185 "ImpedanceTubeResult", ··· 230 233 "delany_bazley", 231 234 "diffuse_field_absorption", 232 235 "directional_diffusion", 236 + "diffusion_spectrum", 233 237 "directional_diffusion_coefficient", 234 238 "effective_kappa", 235 239 "enclosed_gas_stiffness",
+244
src/phonometry/materials/scattering_diffusion.py
··· 50 50 if TYPE_CHECKING: # pragma: no cover - typing only 51 51 from matplotlib.axes import Axes 52 52 53 + from .._report.metadata import ReportMetadata 54 + 53 55 54 56 __all__ = [ 55 57 "BASE_PLATE_BANDS", 56 58 "BASE_PLATE_MAX_SCATTERING", 57 59 "TWO_DIMENSIONAL_SOURCE_WEIGHTS", 58 60 "DiffusionResult", 61 + "DiffusionSpectrum", 59 62 "ScatteringDiffusionWarning", 60 63 "ScatteringResult", 61 64 "ScatteringUncertainty", ··· 64 67 "area_factors", 65 68 "base_plate_scattering", 66 69 "check_base_plate_scattering", 70 + "diffusion_spectrum", 67 71 "directional_diffusion", 68 72 "directional_diffusion_coefficient", 69 73 "normalized_diffusion_coefficient", ··· 361 365 check_language(language) 362 366 return plot_scattering_coefficient(self, ax=ax, language=language, **kwargs) 363 367 368 + def report( 369 + self, 370 + path: str, 371 + *, 372 + metadata: "ReportMetadata | None" = None, 373 + engine: str = "reportlab", 374 + verbose: bool = False, 375 + language: str = "en", 376 + ) -> str: 377 + """Render an ISO 17497-1 scattering-coefficient test-report fiche to a PDF. 378 + 379 + Writes a one-page accredited random-incidence scattering report 380 + (ISO 17497-1:2004+A1:2014): the standard-basis line, an optional 381 + metadata header block (client, specimen, test room, sample area ``S``, 382 + temperature, humidity ...), a two-panel body with the per-band table 383 + (frequency, the random-incidence absorption ``alpha_s`` and the 384 + scattering coefficient ``s``) beside the ``s(f)`` curve on a categorical 385 + band axis, and a footer with the fixed disclaimer. ISO 17497-1 is a 386 + characterisation, so there is no pass/fail verdict and no single-number 387 + rating. 388 + 389 + :param path: Destination path of the PDF file. 390 + :param metadata: Optional :class:`~phonometry.ReportMetadata`; ``None`` 391 + produces a body-and-disclaimer fiche whose header shows only the 392 + measured frequency range. The applicable descriptive fields are 393 + ``client``, ``manufacturer``, ``specimen``, ``area``, ``room_volume``, 394 + ``mounting``, ``test_room``, ``test_date``, ``temperature``, 395 + ``relative_humidity``, ``pressure``, ``measurement_standard``, 396 + ``laboratory``, ``operator``, ``report_id`` and ``notes``. The 397 + ``requirement`` field is ignored (ISO 17497-1 has no verdict). 398 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 399 + :param verbose: When ``True``, the value table inserts the specular 400 + absorption ``alpha_spec`` column beside ``alpha_s`` and ``s``. 401 + :param language: Fiche language: ``"en"`` (default, English, decimal 402 + point) or ``"es"`` (Spanish, decimal comma). 403 + :return: The written ``path`` as a :class:`str`. 404 + :raises ValueError: If ``engine`` is not ``"reportlab"``. 405 + :raises ImportError: If reportlab is not installed 406 + (``pip install phonometry[report]``). 407 + """ 408 + from .._i18n import check_language 409 + 410 + check_language(language) 411 + if engine != "reportlab": 412 + raise ValueError( 413 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 414 + ) 415 + from .._report.iso17497 import render_scattering_report 416 + 417 + return render_scattering_report( 418 + self, path, metadata=metadata, verbose=verbose, language=language 419 + ) 420 + 364 421 365 422 def scattering_coefficient_spectrum( 366 423 frequencies: ArrayLike, ··· 430 487 431 488 check_language(language) 432 489 return plot_diffusion_polar(self, ax=ax, language=language, **kwargs) 490 + 491 + def report( 492 + self, 493 + path: str, 494 + *, 495 + metadata: "ReportMetadata | None" = None, 496 + engine: str = "reportlab", 497 + verbose: bool = False, 498 + language: str = "en", 499 + ) -> str: 500 + """Render an ISO 17497-2 polar-response test-report fiche to a PDF. 501 + 502 + Writes a one-page accredited free-field diffusion report for a single 503 + source position (ISO 17497-2:2012, Clause 8.5): the standard-basis line, 504 + an optional metadata header block, a two-panel body with the corrected 505 + polar-response table (receiver angle and reflected sound-pressure level 506 + ``L``, rounded to 0,1 dB) beside the semicircular polar plot, a boxed 507 + directional diffusion coefficient ``d_theta`` (Formula (5)/(6)) and a 508 + footer with the fixed disclaimer. ISO 17497-2 is a characterisation, so 509 + there is no pass/fail verdict. 510 + 511 + :param path: Destination path of the PDF file. 512 + :param metadata: Optional :class:`~phonometry.ReportMetadata`; ``None`` 513 + produces a body-and-disclaimer fiche. The applicable descriptive 514 + fields are ``client``, ``manufacturer``, ``specimen``, ``mounting``, 515 + ``test_room``, ``test_date``, ``temperature``, ``relative_humidity``, 516 + ``pressure``, ``measurement_standard``, ``laboratory``, ``operator``, 517 + ``report_id`` and ``notes``. The ``requirement`` field is ignored 518 + (ISO 17497-2 has no verdict). 519 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 520 + :param verbose: Accepted for signature parity; the polar-response fiche 521 + has no extended table, so it renders the same body. 522 + :param language: Fiche language: ``"en"`` (default, English, decimal 523 + point) or ``"es"`` (Spanish, decimal comma). 524 + :return: The written ``path`` as a :class:`str`. 525 + :raises ValueError: If ``engine`` is not ``"reportlab"``. 526 + :raises ImportError: If reportlab is not installed 527 + (``pip install phonometry[report]``). 528 + """ 529 + from .._i18n import check_language 530 + 531 + check_language(language) 532 + if engine != "reportlab": 533 + raise ValueError( 534 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 535 + ) 536 + from .._report.iso17497 import render_diffusion_polar_report 537 + 538 + return render_diffusion_polar_report( 539 + self, path, metadata=metadata, verbose=verbose, language=language 540 + ) 541 + 542 + 543 + @dataclass(frozen=True) 544 + class DiffusionSpectrum: 545 + """A diffusion-coefficient spectrum ``d(f)`` (ISO 17497-2, Clause 8.5). 546 + 547 + Where :class:`DiffusionResult` holds the polar response of a single 548 + one-third-octave band, this holds the diffusion coefficient across the 549 + measured bands, so it can be tabulated and plotted against frequency as 550 + Clause 8.5 requires. The per-band coefficient is a *directional* diffusion 551 + coefficient ``d_theta`` (Formula (5)/(6)) when it comes from one source 552 + position, or a *random-incidence* diffusion coefficient ``d`` when it is the 553 + per-band average of the directional coefficients over the source positions 554 + (Clause 8.4); the standard defines both as frequency-dependent quantities, 555 + so this carries a spectrum rather than a single number. 556 + 557 + :ivar frequencies: One-third-octave band centre frequencies, in hertz. 558 + :ivar diffusion: Diffusion coefficient ``d`` per band (directional per 559 + source, or random-incidence when averaged over source positions). 560 + :ivar normalized: Optional normalised diffusion coefficient ``d_n`` per band 561 + (Formula (7)), or ``None`` when the reference flat surface was not 562 + measured. 563 + """ 564 + 565 + frequencies: Real 566 + diffusion: Real 567 + normalized: Real | None = None 568 + 569 + def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) -> Axes: 570 + """Plot the diffusion coefficient ``d`` versus frequency. 571 + 572 + Requires matplotlib (``pip install phonometry[plot]``); returns the 573 + :class:`~matplotlib.axes.Axes` and never calls ``plt.show``. 574 + """ 575 + from .._i18n import check_language 576 + from .._plot.materials import plot_diffusion_report 577 + 578 + check_language(language) 579 + return plot_diffusion_report(self, ax=ax, language=language, **kwargs) 580 + 581 + def report( 582 + self, 583 + path: str, 584 + *, 585 + metadata: "ReportMetadata | None" = None, 586 + engine: str = "reportlab", 587 + verbose: bool = False, 588 + language: str = "en", 589 + ) -> str: 590 + """Render an ISO 17497-2 diffusion-coefficient test-report fiche to a PDF. 591 + 592 + Writes a one-page accredited free-field diffusion report 593 + (ISO 17497-2:2012, Clause 8.5): the standard-basis line, an optional 594 + metadata header block, a two-panel body with the per-band table 595 + (frequency, the diffusion coefficient ``d`` and, when present, the 596 + normalised ``d_n``) beside the ``d(f)`` curve on a categorical band 597 + axis, a boxed characterisation headline over the tested frequency range, 598 + and a footer with the fixed disclaimer. ISO 17497-2 is a 599 + characterisation, so there is no pass/fail verdict. 600 + 601 + :param path: Destination path of the PDF file. 602 + :param metadata: Optional :class:`~phonometry.ReportMetadata`; ``None`` 603 + produces a body-and-disclaimer fiche whose header shows only the 604 + measured frequency range. The applicable descriptive fields are 605 + ``client``, ``manufacturer``, ``specimen``, ``mounting``, 606 + ``test_room``, ``test_date``, ``temperature``, ``relative_humidity``, 607 + ``pressure``, ``measurement_standard``, ``laboratory``, ``operator``, 608 + ``report_id`` and ``notes``. The ``requirement`` field is ignored 609 + (ISO 17497-2 has no verdict). 610 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 611 + :param verbose: When ``True`` and a normalised spectrum is present, the 612 + value table adds the normalised ``d_n`` column. 613 + :param language: Fiche language: ``"en"`` (default, English, decimal 614 + point) or ``"es"`` (Spanish, decimal comma). 615 + :return: The written ``path`` as a :class:`str`. 616 + :raises ValueError: If ``engine`` is not ``"reportlab"``. 617 + :raises ImportError: If reportlab is not installed 618 + (``pip install phonometry[report]``). 619 + """ 620 + from .._i18n import check_language 621 + 622 + check_language(language) 623 + if engine != "reportlab": 624 + raise ValueError( 625 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 626 + ) 627 + from .._report.iso17497 import render_diffusion_spectrum_report 628 + 629 + return render_diffusion_spectrum_report( 630 + self, path, metadata=metadata, verbose=verbose, language=language 631 + ) 632 + 633 + 634 + def diffusion_spectrum( 635 + frequencies: ArrayLike, 636 + diffusion: ArrayLike, 637 + *, 638 + normalized: ArrayLike | None = None, 639 + ) -> DiffusionSpectrum: 640 + """Diffusion-coefficient spectrum ``d(f)`` (ISO 17497-2, Clause 8.5). 641 + 642 + Pairs the per-band diffusion coefficients ``d`` with their band centres and 643 + returns a plottable, reportable :class:`DiffusionSpectrum`. The coefficient 644 + is the *directional* coefficient ``d_theta`` (Formula (5)/(6)) when it comes 645 + from a single source position, or the *random-incidence* coefficient ``d`` 646 + when it is the per-band average of the directional coefficients over the 647 + source positions (Clause 8.4, e.g. via :func:`random_incidence_diffusion` 648 + band by band). The optional normalised coefficients ``d_n`` (Formula (7)) 649 + are carried through when supplied. 650 + 651 + :param frequencies: One-third-octave band centres, in hertz (1-D). 652 + :param diffusion: Diffusion coefficient ``d`` per band. 653 + :param normalized: Optional normalised diffusion coefficient ``d_n`` per 654 + band; ``None`` when the reference flat surface was not measured. 655 + :return: A :class:`DiffusionSpectrum` with ``.plot()`` and ``.report()``. 656 + :raises ValueError: if the inputs differ in length, are empty or not 1-D. 657 + """ 658 + freq = np.atleast_1d(np.asarray(frequencies, dtype=np.float64)) 659 + d = np.atleast_1d(np.asarray(diffusion, dtype=np.float64)) 660 + if freq.ndim != 1 or freq.size == 0 or freq.shape != d.shape: 661 + raise ValueError( 662 + "'frequencies' and 'diffusion' must be non-empty, 1-D and " 663 + "equal-length." 664 + ) 665 + d_n: Real | None = None 666 + if normalized is not None: 667 + d_n = np.atleast_1d(np.asarray(normalized, dtype=np.float64)) 668 + if d_n.shape != freq.shape: 669 + raise ValueError( 670 + "'normalized' must match 'frequencies' in length." 671 + ) 672 + return DiffusionSpectrum( 673 + frequencies=freq, 674 + diffusion=d, 675 + normalized=d_n, 676 + ) 433 677 434 678 435 679 def directional_diffusion(
+272
tests/materials/test_iso17497_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for the ISO 17497-1/-2 scattering and diffusion reports (``.report()``). 4 + 5 + The report is a rendering feature, so these tests assert only structural facts: 6 + a valid single-page PDF is written for each fiche (scattering ``s(f)``, the 7 + diffusion spectrum ``d(f)`` and the single-source polar response), the displayed 8 + coefficients match the documented clean-room oracle, the band/angle labels and 9 + metadata appear, unknown engines/languages are rejected, XML specials in 10 + metadata do not break reportlab, and the Spanish fiche uses a decimal comma. 11 + Pixel or layout content is never inspected. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import pytest 17 + 18 + pytest.importorskip("reportlab") 19 + 20 + import numpy as np # noqa: E402 (import after importorskip) 21 + 22 + from phonometry import ReportMetadata # noqa: E402 (import after importorskip) 23 + from phonometry import materials # noqa: E402 24 + 25 + _PDF_MAGIC = b"%PDF" 26 + 27 + # The committed clean-room example: V = 200 m3, S = 10 m2, 20 degC (c = 343.2), 28 + # m = 0, symmetrical base plate (T1 = T3). See scripts/generate_reports.py. 29 + _FREQS = np.array( 30 + [100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 31 + 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000], 32 + dtype=float, 33 + ) 34 + _T1 = np.array([8.0, 7.9, 7.8, 7.6, 7.4, 7.2, 7.0, 6.7, 6.4, 6.0, 35 + 5.6, 5.2, 4.8, 4.4, 4.0, 3.6, 3.2, 2.9]) 36 + _ANGLES = np.arange(-90.0, 90.5, 10.0) 37 + 38 + 39 + def _scattering(): 40 + volume, area, c = 200.0, 10.0, 343.2 41 + t2 = _T1 * 0.90 42 + t4 = t2 * (1.0 - np.linspace(0.02, 0.28, _FREQS.size)) 43 + alpha_s = materials.random_incidence_absorption( 44 + volume, area, c1=c, T1=_T1, c2=c, T2=t2 45 + ) 46 + alpha_spec = materials.specular_absorption_coefficient( 47 + volume, area, c3=c, T3=_T1, c4=c, T4=t4 48 + ) 49 + return materials.scattering_coefficient_spectrum(_FREQS, alpha_spec, alpha_s) 50 + 51 + 52 + _SOURCES = np.array([0.0, 30.0, -30.0, 60.0, -60.0]) 53 + 54 + 55 + def _polar_energy(angles, width, peak, specular=0.0): 56 + return ( 57 + 10.0 * np.log10(1.0 + peak * np.exp(-(((angles - specular) / width) ** 2))) 58 + + 60.0 59 + ) 60 + 61 + 62 + def _diffusion_spectrum(): 63 + """The committed random-incidence spectrum: source-averaged per band (8.4).""" 64 + widths = np.linspace(15.0, 70.0, _FREQS.size) 65 + peaks = np.linspace(30.0, 3.0, _FREQS.size) 66 + weights = np.array(materials.TWO_DIMENSIONAL_SOURCE_WEIGHTS, dtype=float) 67 + d = np.empty(_FREQS.size) 68 + d_n = np.empty(_FREQS.size) 69 + for k in range(_FREQS.size): 70 + d_theta, d_theta_n = [], [] 71 + for source in _SOURCES: 72 + spec = -source 73 + d_s = materials.directional_diffusion_coefficient( 74 + _polar_energy(_ANGLES, widths[k], peaks[k], spec) 75 + ) 76 + d_ref = materials.directional_diffusion_coefficient( 77 + _polar_energy(_ANGLES, 0.5 * widths[k], 60.0, spec) 78 + ) 79 + d_theta.append(d_s) 80 + d_theta_n.append( 81 + float(materials.normalized_diffusion_coefficient(d_s, d_ref)) 82 + ) 83 + d[k] = materials.random_incidence_diffusion(d_theta, weights=weights) 84 + d_n[k] = materials.random_incidence_diffusion(d_theta_n, weights=weights) 85 + return materials.diffusion_spectrum(_FREQS, d, normalized=d_n) 86 + 87 + 88 + def _polar(): 89 + band = int(np.argmin(np.abs(_FREQS - 1000.0))) 90 + widths = np.linspace(15.0, 70.0, _FREQS.size) 91 + peaks = np.linspace(30.0, 3.0, _FREQS.size) 92 + return materials.directional_diffusion( 93 + _ANGLES, _polar_energy(_ANGLES, widths[band], peaks[band]) 94 + ) 95 + 96 + 97 + def _metadata(**overrides) -> ReportMetadata: 98 + base = dict( 99 + specimen="Quadratic-residue diffuser (N = 7)", 100 + client="Acoustic Test Client Ltd.", 101 + manufacturer="Acoustics Works Inc.", 102 + area=10.0, 103 + room_volume=200.0, 104 + mounting="Circular sample on the rotating turntable", 105 + test_room="Reverberation room R1", 106 + measurement_standard="ISO 17497-1", 107 + temperature=20.0, 108 + relative_humidity=54.0, 109 + pressure=101.0, 110 + test_date="2026-07-21", 111 + laboratory="Phonometry Reference Laboratory", 112 + operator="J. M. Requena-Plens", 113 + report_id="PHN-2026-17497", 114 + ) 115 + base.update(overrides) 116 + return ReportMetadata(**base) 117 + 118 + 119 + def _assert_one_page(path: str) -> None: 120 + from pypdf import PdfReader 121 + 122 + with open(path, "rb") as handle: 123 + assert handle.read(4) == _PDF_MAGIC 124 + assert len(PdfReader(path).pages) == 1 125 + 126 + 127 + def _text(path: str) -> str: 128 + from pypdf import PdfReader 129 + 130 + return "\n".join( 131 + page.extract_text() for page in PdfReader(path).pages 132 + ).replace("\n", " ") 133 + 134 + 135 + # --- ISO 17497-1 scattering ------------------------------------------------ 136 + def test_scattering_writes_one_page_pdf(tmp_path) -> None: 137 + out = tmp_path / "scat.pdf" 138 + returned = _scattering().report(str(out)) 139 + assert returned == str(out) 140 + _assert_one_page(str(out)) 141 + 142 + 143 + def test_scattering_displayed_values_match_oracle(tmp_path) -> None: 144 + """The fiche prints the closed-form s (Eq. (5)) and the band labels.""" 145 + out = tmp_path / "scat.pdf" 146 + _scattering().report(str(out), metadata=_metadata()) 147 + text = _text(str(out)) 148 + assert "0.08" in text # s(500 Hz) = 0.082 -> 0.08 149 + assert "0.45" in text # s(4000 Hz) = 0.454 -> 0.45 150 + assert "500" in text and "4000" in text # band labels 151 + assert "Acoustic Test Client Ltd." in text # metadata 152 + # The reverberation-room fields belong to the ISO 17497-1 scattering fiche. 153 + assert "Sample area" in text and "Room volume" in text 154 + 155 + 156 + def test_scattering_verbose_shows_alpha_spec_one_page(tmp_path) -> None: 157 + out = tmp_path / "scat_v.pdf" 158 + _scattering().report(str(out), metadata=_metadata(), verbose=True) 159 + _assert_one_page(str(out)) 160 + assert "0.13" in _text(str(out)) # alpha_spec(500 Hz) = 0.131 -> 0.13 161 + 162 + 163 + def test_scattering_unknown_engine_rejected(tmp_path) -> None: 164 + result = _scattering() 165 + out = str(tmp_path / "x.pdf") 166 + with pytest.raises(ValueError, match="engine"): 167 + result.report(out, engine="weasyprint") 168 + 169 + 170 + def test_scattering_unknown_language_rejected(tmp_path) -> None: 171 + result = _scattering() 172 + out = str(tmp_path / "x.pdf") 173 + with pytest.raises(ValueError, match="Unknown language"): 174 + result.report(out, language="xx") 175 + 176 + 177 + def test_scattering_metadata_xml_specials_do_not_break(tmp_path) -> None: 178 + out = tmp_path / "scat_xml.pdf" 179 + _scattering().report( 180 + str(out), metadata=_metadata(specimen='Panel <A> & <B> "edge"') 181 + ) 182 + _assert_one_page(str(out)) 183 + 184 + 185 + def test_scattering_no_metadata_still_renders(tmp_path) -> None: 186 + out = tmp_path / "scat_bare.pdf" 187 + _scattering().report(str(out)) 188 + _assert_one_page(str(out)) 189 + assert "100 to 5000" in _text(str(out)) # measured frequency range 190 + 191 + 192 + def test_scattering_spanish_uses_comma_decimal(tmp_path) -> None: 193 + out = tmp_path / "scat_es.pdf" 194 + _scattering().report(str(out), metadata=_metadata(), language="es") 195 + _assert_one_page(str(out)) 196 + assert "0,45" in _text(str(out)) # Spanish decimal comma 197 + 198 + 199 + # --- ISO 17497-2 diffusion spectrum --------------------------------------- 200 + def test_diffusion_writes_one_page_pdf(tmp_path) -> None: 201 + out = tmp_path / "diff.pdf" 202 + _diffusion_spectrum().report(str(out), metadata=_metadata()) 203 + _assert_one_page(str(out)) 204 + 205 + 206 + def test_diffusion_displayed_values_match_oracle(tmp_path) -> None: 207 + """The fiche prints the per-band random-incidence d (Clause 8.4).""" 208 + out = tmp_path / "diff.pdf" 209 + _diffusion_spectrum().report(str(out), metadata=_metadata()) 210 + text = _text(str(out)) 211 + assert "0.51" in text # d(500 Hz) = 0.506 -> 0.51 212 + assert "0.81" in text # d(4000 Hz) = 0.807 -> 0.81 213 + assert "500" in text and "4000" in text # band labels 214 + 215 + 216 + def test_diffusion_omits_room_fields(tmp_path) -> None: 217 + """The free-field diffusion fiche must not show sample area / room volume.""" 218 + out = tmp_path / "diff.pdf" 219 + _diffusion_spectrum().report(str(out), metadata=_metadata()) 220 + text = _text(str(out)) 221 + assert "Sample area" not in text 222 + assert "Room volume" not in text 223 + 224 + 225 + def test_diffusion_verbose_shows_normalized_one_page(tmp_path) -> None: 226 + out = tmp_path / "diff_v.pdf" 227 + _diffusion_spectrum().report(str(out), metadata=_metadata(), verbose=True) 228 + _assert_one_page(str(out)) 229 + assert "0.35" in _text(str(out)) # d_n(500 Hz) = 0.347 -> 0.35 230 + 231 + 232 + def test_diffusion_unknown_engine_rejected(tmp_path) -> None: 233 + result = _diffusion_spectrum() 234 + out = str(tmp_path / "x.pdf") 235 + with pytest.raises(ValueError, match="engine"): 236 + result.report(out, engine="weasyprint") 237 + 238 + 239 + def test_diffusion_spanish_uses_comma_decimal(tmp_path) -> None: 240 + out = tmp_path / "diff_es.pdf" 241 + _diffusion_spectrum().report(str(out), metadata=_metadata(), language="es") 242 + _assert_one_page(str(out)) 243 + assert "0,81" in _text(str(out)) 244 + 245 + 246 + # --- ISO 17497-2 polar response ------------------------------------------- 247 + def test_polar_writes_one_page_pdf(tmp_path) -> None: 248 + out = tmp_path / "polar.pdf" 249 + _polar().report(str(out), metadata=_metadata()) 250 + _assert_one_page(str(out)) 251 + 252 + 253 + def test_polar_displays_coefficient_and_angles(tmp_path) -> None: 254 + out = tmp_path / "polar.pdf" 255 + _polar().report(str(out), metadata=_metadata()) 256 + text = _text(str(out)) 257 + assert "0.67" in text # boxed directional diffusion coefficient d 258 + assert "90.0" in text # receiver angle label 259 + 260 + 261 + def test_polar_unknown_engine_rejected(tmp_path) -> None: 262 + result = _polar() 263 + out = str(tmp_path / "x.pdf") 264 + with pytest.raises(ValueError, match="engine"): 265 + result.report(out, engine="weasyprint") 266 + 267 + 268 + def test_polar_spanish_uses_comma_decimal(tmp_path) -> None: 269 + out = tmp_path / "polar_es.pdf" 270 + _polar().report(str(out), metadata=_metadata(), language="es") 271 + _assert_one_page(str(out)) 272 + assert "0,67" in _text(str(out))
+44
tests/materials/test_scattering_diffusion.py
··· 35 35 BASE_PLATE_MAX_SCATTERING, 36 36 TWO_DIMENSIONAL_SOURCE_WEIGHTS, 37 37 DiffusionResult, 38 + DiffusionSpectrum, 38 39 ScatteringDiffusionWarning, 39 40 ScatteringResult, 40 41 ScatteringUncertainty, ··· 44 45 base_plate_scattering, 45 46 check_base_plate_scattering, 46 47 directional_diffusion, 48 + diffusion_spectrum, 47 49 directional_diffusion_coefficient, 48 50 normalized_diffusion_coefficient, 49 51 random_incidence_absorption, ··· 538 540 result = directional_diffusion([-30.0, 0.0, 30.0], [70.0, 72.0, 69.0]) 539 541 ax = result.plot() 540 542 assert ax.name == "polar" 543 + plt.close("all") 544 + 545 + 546 + def test_diffusion_spectrum_builds_and_carries_fields() -> None: 547 + freqs = [250.0, 500.0, 1000.0] 548 + d = [0.3, 0.5, 0.7] 549 + d_n = [0.2, 0.4, 0.6] 550 + result = diffusion_spectrum(freqs, d, normalized=d_n) 551 + assert isinstance(result, DiffusionSpectrum) 552 + np.testing.assert_allclose(result.frequencies, freqs) 553 + np.testing.assert_allclose(result.diffusion, d) 554 + np.testing.assert_allclose(result.normalized, d_n) 555 + 556 + 557 + def test_diffusion_spectrum_optional_fields_default_none() -> None: 558 + result = diffusion_spectrum([250.0, 500.0], [0.3, 0.5]) 559 + assert result.normalized is None 560 + 561 + 562 + def test_diffusion_spectrum_length_mismatch_raises() -> None: 563 + with pytest.raises(ValueError, match="non-empty, 1-D and equal-length"): 564 + diffusion_spectrum([250.0, 500.0], [0.3]) 565 + 566 + 567 + def test_diffusion_spectrum_normalized_mismatch_raises() -> None: 568 + with pytest.raises(ValueError, match="'normalized' must match"): 569 + diffusion_spectrum( 570 + [250.0, 500.0], [0.3, 0.5], normalized=[0.2] 571 + ) 572 + 573 + 574 + def test_diffusion_spectrum_plot_returns_axes() -> None: 575 + import matplotlib 576 + 577 + matplotlib.use("Agg") 578 + import matplotlib.pyplot as plt 579 + 580 + result = diffusion_spectrum( 581 + [250.0, 500.0, 1000.0], [0.3, 0.5, 0.7], normalized=[0.2, 0.4, 0.6] 582 + ) 583 + ax = result.plot() 584 + assert isinstance(ax, plt.Axes) 541 585 plt.close("all") 542 586 543 587