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

Configure Feed

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

Add wind-turbine tonal audibility report via .report() (#337)

* Add wind-turbine tonal audibility report via .report()

Add WindTurbineTonalityResult.report() rendering a one-page PDF
wind-turbine tonality-assessment fiche (IEC 61400-11:2012+A1:2018,
subclauses 9.5.2-9.5.5).

The sheet carries a standard-basis line, an optional metadata header
(source/situation, client, measurement position, instrumentation, date),
a two-panel body with the critical-band and masking analysis in a metrics
table (tone frequency, critical bandwidth, tone level L_pt, masking-noise
level L_pn, tonality dL_tn, audibility criterion L_a and tonal audibility
dL_a) beside the narrowband-spectrum plot with the critical band, masking
level and tone marked, and a boxed decisive tonal audibility dL_a with the
tone frequency and the audibility decision (a tone is audible when dL_a
exceeds 0 dB). A maximum acceptable tonal audibility supplied via the
metadata requirement adds a PASS/FAIL verdict (a lower audibility is
better). English and Spanish both render.

Register the example fiche, add structural and i18n tests, and regenerate
the committed example PDF, WebP preview and API reference.

* Base the audibility decision on the displayed rounded tonal audibility

The boxed result, verdict and decision text all commit to the tonal
audibility rounded as displayed, but the decision phrase and note branched
on the raw is_audible flag. A raw dL_a just above 0 dB that rounds to a
displayed 0.0 dB could therefore print a self-contradicting "0.0 dB > 0".
Derive the audibility decision from the same rounded value through a shared
helper, and add a boundary regression test.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 23, 2026, 5:58 AM +0200) f3c5cc8b 648230cf

+837
+96
.github/reports/iec61400_wind_turbine_tonality_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 (Wind-turbine tonal audibility assessment) /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 17524 66 + >> 67 + stream 68 + Gb"/LBl><<Q0!5gMYpRsqk[e0eE?oa>I*']A%M%/H@T%+5d;_o4X<ZI=dJ/V5PNqZ1*=V>@P(BU.]!9)OngbBb8^2I\6JaMdD):QIsO+LT0Afpo=:K@R%Aaiq)##6knhf]:I"^A7p_h3F`npo0\,IO=IlAaki^V(T1bg=k]1HIRSur6:Y4KO6rj*28su8W;@?q("YGCXqOd]Kq:2h-m>L;6cEGNjgZMWc(XpKaBBNWm?_$$Q0V."SSDDt*SmC0$4?`Y8l05CuU&Ud8+6lfTX%r%>9r#gDIHP4/JkIr-ms1R5n;o_\rL3T/s4mKurQXuq:S7T'.R#l%oDX1tT79"p9]+;i-6('NB@kW/SPOk/iLbSBJ(sVWPFE@qSR.i!T[BJ+3UH88r8l!!9GLFtB@H[9NS'r2:UmfjZg%@*c`X/"D]b>sIf;9u7iRJIn&b%k^;"ahMfHMWr'>L?Y4TL=[<;3Ya1%tQ^Rq*g4hIE2B:mJ1q&g'1loj;"JPst``trgpZVV)EU0DrU7ttp(8La,d(Yeuh8P%0'D)D?DH2ch"oPFBKFE;p.ioKh2q:-hta5gMKb,JfAiI5E@FI%X+\>d0pro<V9o7&m;\imUVSc&HaArg&C4ahBpErFP[^K^;*5QM,e&r=hiXMW_[L#61+e*8tVnsJ-9W4C=0_]t4t#6*<5:G0C,\io.u%Q<@E!&Arnnbh.paj#b01U)i&]BAg#+89(*bE^]VZ@BB5rdqlG=3j[FTDa(^pAFe<rjLEk?_:Vr?(I#Epr%JGX8bj?ji`^7r$/tQC&-6.c0b03bfYuCqY8pN59mqT+LY"jS.L>LiTBM'f@/oT5:)3%rH8Os+9+'$&BtaRp>jCEIlt9QiW\lMqj5M4rATM@0GD2c\NL&4^-0)R$/$tb0u%hL+/Jel6J8o\$iWm?X4PB^Nmn?bU/.n_(ggeQ$2!grl+d6Y,^]RhLQAAmhg]_O\Qe=Sb:.S6,IAlN(Xlad8%8K:na6?9`6V(*K3^^_c]`Qo%6hK]57bl\.CJkAGiE.TG"lc=Pdp']-aX/5!Z+`1&5i83C>.^sUSTL?W=tQ+\L&,iSZfDtVltUCdG1#PHKt.?ge0/kNgF!qkbYtM>CQ6k9Dd+OBoZN1g6d>r>.XUH,EMi8e:>^q>=.<J'(YrPT)o7GI=:h@(W8gnR(f0kl];)@n&$[,qmlQd?amR2YNJ5O9ETPu4ir_gU,BC6s7'FMBefa9`??/1:*A0Wc^tf-I,#iOYJ!#-oY/W@nRJX26#1=0l<cD^*ErWj1,fB*6%J*A\)Xs+LG]%sfr'muK_b[hI)I:p3OLOfU$R3j^#'lu"%,@mE=T/.BF\>Yb8rYK.F7t_EtI['bQ2l6qo&O3]*$[^0:Aor2k)Aaq&<$DD'o5A-S)^99e[r51^2I&=.Fk341XKj$?fk&2th]ILgGNgEd>!jH2u!u3jI&nNt]]t@_&48%Zoal&]_Cd,J!?^_ar(>,X4dd4)5:e%[<FRe=*.-3U5+DrAWPZG":>c]AihK]*.51e,Hjk?!]D'@i4V'm2kQBj)-8MZ&\^Xj&`"!!kKGB.Cm%:/'O7<s*spnfCrnDX`]4TXD<C6<iB^b[8uQflic`l$-U#?j^&!8O"$fup?q5&X*"&dc[#1KV3_]Nr_P!2%u*,%rTCA]X+u!gH\RUqOYssMd'I$,<#ti"@2ceILjiQMI;]a6RsUgfH4oW%C)ls4hAh!EF84I]r10;>d[fi<])*<AUJ]X[5#)%=Rm2CBj/-a40X#s!3Rtg"*VYjpIe8uYR]lUr5Q(H;1%+"3$k=XHDPR6H/j,&Iq!sbPh1'ADZSEdbo:EC8mqCujT0pj6F%go#&^)M8_[LE_rfIdVa<O"qG+Q?"eE'0mGZ8i0\(>2?"I'H!odeFu*&QbTQ1BWnoo3'f5JC4mcAcI;AS9PSK)N9,$,u0PFI'Aeb\0/C--e7bgI/`&U3rp2OXA5%nV#-=J@YhOR9]VW!%5<PV<C#N\5J%1qa`KIe1%9]\3QPJ150&*Q==NP%V9!NP@s56+)&St?Y^[CTb/E9V/7o)mZbTk+VJ]1pPUP,%GhBSOOOn(PSC'bC0id@=5"fHURV2RkWW(CK,aLTYa;4A*QpjAJmA)No!F+@X#u1'cniA-M&F<`HTn*X0r&e))H6:/&<T@V=*T_.XHt=K.h(tghWe-A%PlUoq*[[8]sYQ>?93cAc"m$%gaBTMQ<.6@>#d28CJQ*)D;mlDKAu)@*ZbDK*BGsYSB!_FP^+*%FLjulY:WH*?eO[X5?J"N]98`FhAaY`qmF39ZBMf9ls&nIm!KU\-ZNp2/1lEH>NipNEUA\-eG2R$99DCZEDgb+:np/5H<58#Y1$ihFs'kc=6::BO,1&u1se0c05R>EHJ*fkNFc`e=@ZI'TSFj9jAhHU6(%Ln/_!h.5%:&!Sjm3%/8B8rY,%Ob[K2=G`rNYR*WB`:-YE"+-Z#2C#`T\cH_t&88YJg_$/<Z@S-fg/PjoF/a;p^'WOt*mgrs92+j"!a:4ph\Y6%R$TK8l:1YA2=F/JKmW<M#Na@'[^FSR=.l4ddjmmN6m$JH#YPcs`D1J%`KLfL@h7B%kNYMCHY_KU%%OL3f()L/QqDVB;Kh)n%M+h32noHq';$h^+jQaCQ#,<_.alX\4XX:UqQ%_ZRhq+ACEQ^q3^P-rPda$PCUkASt1&e*u#l!-cB"Y)^.$.N_,P[4s-2M"IVUB7iG\]%Hh/]lHci_bZ;Lf>r;YpIREG'FP-c;P\bUh\E$Y?ba+,\q5Q,V--V^A=[J]/%fO"jad$6lk@C,bbF0(9_((JR9thH+Li=%>JaSoL"8)Kil)_Gn5Y`]f1_?pM,0g?mq8u`RPEP^5FZZ.lRU&-ss)Mik:IZ2Cq.uJE/p$?uUWI364%^^sdk0k8mVgi!P)ueZr-+1Yus)$$5*P$Sr600F<hudZJm^_=$D7cla29TX@$?4q%;\Jl8$BoPs)$N%,og,s@HtopK,iN<^A@)_JIoO4[]62ahEM4>!lE3T1Y%9)+Vo4Uk-uWlgnS]V8us9md1dLnFj38FbN_Eb\MWFM\At?Asf40KLh003s`ER#*mjRlq?&,cSaje'WOYKlmO!_L4lo;K"'bdn3Tp@URQH@*naZlm=Ho0`l]Il&8k=0;WL@O"'aIN%>/EQAdhML#6B,86@D'[7L`+Qoq5?[M@9WV4.VN'sf\H6m!UPgTIboL%W<XSQGk.MK^P!EMZ'REYFt"IM..VTUHGM"=_ibJ<1"L9l0p5C[k%NM!3A0_g&$*OJOFS!S!^lD5O-cC#-sKeA*:WJ1!+R<fDcLd,&2P@[")o+O_QP$/iAVR3#38MIWW\[QDi1Q]LnqSLKsBC=f</$lUWW$=J3Iop,CpW.rhUg"60]l.\8"2in$rD`)XrKqh^iL&RZY)-`QkXmZ-cB*+hk?!9q/GEk4k8,&,#(kk4])s)=.k<S?=B#n/@)+9OOH/0V@#2qKHs.M`'''OD;4g#t+\G*uSrdO8'5(jFf*&?]L'#GGg#mSI6nIG`_>kUPi(W8Z.PXD@8W#Sfa1mB^k`0nnH5qOa8=%!?5$*guid#kiM.`Vf.p7XC&.]#OM<c\h[REDi;[96q6UrohOX##02K_eC4&7;F5%-??>'[";6MG(8?ULO_R@^AO0V[_?V2_YM[1b9K>a?=i>(l@0(<hYZ"g+bGM6'93j1-u!I2)Hp875WKP5QkDE;GEP7dM-p?_O.E^UQ<7Fat/X*-3R\37]'q;Pe?@$'":O77[7S-)*geS%\<qB;MLUXgu[jD!TT:@%1KM1ij0(?\!1+d-5I;g6h`XA%=rL42],G190\4GlNW;@N-_!;G$k?6n['su4\gM4Jh#BCm/U%MW`lZi,UZq+Zl:3iE<dB]9RFA\.r8>HVnrc[!8`P.K-TYg+oirmR\7l-"(8b8joG0_mI(&U-5#-b@Q247!,hj^"2Ji\Jp\]GO<aCi`3;??0;Jje9IbJgQ(P')\)UF<>:rmql:hGL&u%TZN'K8d4!^MT":F#P!n8R!'qY(4h;UF-Eb4C2cbX!$kVF0/$di&)Za./#'J7;XKK>dQ`nf'Vd53[K64D:/QRLWhP-.$Ql==m.Bf&.t=XiBK$4.'dA7-dA8W6\BdtO159*V+F6MtGoAfH(YTU8!l1j:s:=="`YlKo5J@g.\C;VBqO7C=l"OI58`p'51eAsjdM[1^][#o9<4^u;NE@Uo*DME^3gWC8f/d(Dau,p'7S6XUja@hraDLru"`RV5tpc9;A9rDN*]VM6<n&LDUTaiCdF5]0hGdJT1.(b>/U^hVqhql57^lImCa@]F[Ld1&]d:jRcB*u^@j2D_tL+"=%qhAAf@X8C_ndF%\K2*f1k7rLA5_pRoXNrHi,WA[/P+fYuK]siftdF>b[p)oXVHZMl'YP.\j<7Ns<K=X@K%8q&)l90\t>PR(krjRU]D;g!!mUY(Cr8MlIHf8BBai=^-'D!FG>5V0I?P1tcpm%%?.=UBa@Fi(u-5=uJj17<`(i\i0<>fNXZt"Sm$8dQ(Z#+2F=nX/Yd`<&`jI"%/Vhb\"0>t7tfVG260e>i!E*!Tf1,(eO(Jm;^?j07??,apt4%+rai=cIEA;NfK"T^Fu,j'BTP*6MYP*ih&eoLBBDp5<?iSW3t=rN*-\nTJ%)9I"K@p4Vq4iSS->&?8pa^VS]Z#'JO+cNial].^>r8_3U[\KVu"nprC/*$Zrn[;S;1\aTqMU_.GUB&s.)]YRPP%l&bj;8RBF%:ab#M>8]n`WOLR+>_HbI8"RUPc'\rN*Jb3P[a0q\bAeEu)i083_87^g6"&m,&s=pPXJ;R%+K"OMIj4XL!9`qC9G]Z9Y<3&P]Lid\Klt6sT@acs2ltbHfYVAP0fF)/@W\h]YC_bd"r7Y9I:l#CT)'TGDdKfV;136%NO.'tiR*@l%+:L+d#u]ZLYsW4^_KOa',iJ6_7`5d>T@-,N0;1h6+"Nd!pKN[[XMdD1P8dQKpXP^]BG8>@S2-PZdS=iL0Y+aK'+)rI8"Z3/j*JrjsZ#)F:_'%k5!5fJ2RGmT'j.u9RgX]dO!b.G28-(rSSQ<sV&Pq+9$,oD0j!JsPC?C,"RIc,<d7]<?sPSP5@nYNi&oskY&Pnh5UIHeP`_q,#;',P/_W)o,jSV&*il4D3N!L4+VIS)!%J9-7@\qE^c5&!g17:IMtg@7.77-gPgf;YuDfF),05d%*i_6JKA\)"C(8H?bkM$=Q4^"+StImF+`+-24[m\FJ<.m<(-(t!SsLT5Le,`GdB(IgJ@k)BreqM2"G\.LVf6Cq>3<[rP<i2rtQ7:rM1^l`IuZYIH1U0MC7r"sD5qm7*+C/p9jC%IUWJl>T1"RVR-h-P6WVE1"ph&B/<#XpSC(0\.uF0UC^Ju$dL021es/Qk"d*L85\K&+p=#)EF66:%6%dG02:g!L=*6DN9_<WU:8L[4NV7s88!L\<?@a9S&+5Q^%p*/T;=AKNr'<8s6EEr3RPk'0lV]4un_:$)e#4=#-f,Wenr<2GqKM_j&)4>mVlhfL*ZIS@Oh.<T+;%?(`G%rpOT^u5d:&].4);1Y$$@j]$i2Sn<lX0Rr"F\<lJLa7ST$7:AI9L=;f8P1l2p1Z!M,D"M70(qTf??Lp?nM8Oq)HZ9)q1.++CqH(W$3J1Viq[FspV^Ru3W9g5f;La-;*IM\ZuC'X)Z\Oka9Fn9%=l7li/XT]Aig.]a=a;?CaZZf&6r#+S:Z&6[]0t<<#5J+b#j?AP>.(H?(X&M@>Uj1oM*qkfNX6u:C.><m-T<)RYJtS<kk&;)EY;CCk(&*I(n22%YECNFdc!K.^JLk>W@qGTA%Yn7XV-k"L#'mN4I\LY'3o[.8;V%T]p"h[Yti)h+D8HF"aREmglJeOpa'_5EA/:n1nVCiMDd,a5F"2F&KtLWhp[&brero#qm/\MV6*.I0<+bUP6ur:>3"Hb3Be8m"MSh*"VA[^k&]nB+hBgT?_6;%pUNuiaPkE4$VS+2=(`pa4XZd/"+uedXfe%iT[,\b[J_;ALTE[]LcY89a2a@K?uY^fY-l-(3]!)U?HpeisRBY6T/"J!TaCU_dXFmIPY6s^at(XF66)lR7hZ'XZjhMnJGE8k)jP5=Za&&Z*%7,0M1\be>UO`lpgOb<O]3]ZP-g\5mOJ$eY20B]Ks3=,!oM?oe]4RW3))f-E;j&Wq)/VEU-md`B[k..W2$+K.A1uR11E3;/6hbm36I%8B0S/,Qij&Hak<eY#Z5FGNT9X1;O!F&HrMWUL>Fecb10Y`n+?i24%MOT7YWdM^1N=PXc4N?1VJ))Z$]omj0\V%2R^dGgPq428_DA\Buo_8J&W_ps)"+%lW)V>ftL%Z;:n4E#D'g]X-Sf1,4OeQd5)l-BaaA`7Ela])nFoe6)-c(j*98U#j5,@l/]4jW13hT@k_R2L_FD<DdNoTO]@7F"A'\8u!r+A't"=k*WhoTF+qjobiEim$4,)0FG0U%kA[l.)1q*),sZ;'omlDU/9[BTEV)[Yq]9U?tWtR\%A[be#Mf:fZkicKe/0B[Y1\g'rCVZLC(SHlc2l!F1dQ20Zm"6YV<5<8uE7F@9$@>`ZsfDY(BOF`S5D&qLEt-QA,`g(AilqkcNJ0ppsQ$lT9L]i4?<mjs*%'m&a?H7jX"S3F*_`Y+G6Y1OIuYSE2>.AhRNn%pe[cC]gHK8WA=[d4sF<?#q=-hAmP&i$+;AQD/V8U"j%09$SC['\iuoi*?QG*4*]l$6%[\]!@!X\7]`\W"6.>8PRZ1\c<od2c>XWCsH&c&rX:Li&"'P*`=CdQLrBhq5MjjKRtAGNra)5GE>YGg(2]7)f]l-U7"r8.rX!;3*[aO'_I9(bu>t_?rM9,`hoNPq:qI*AEK[m9R58A?Ep>9qcdlm`&#I)nDS16/XSgSLa,ZY&dA@u?;TBh:F<J3\<E3bj_Hk%<F50<4_4>lLegXA__834+ue^eOu]f*.bRX\>&>Nm"F=njcc^-s(JhZA>P_lU,_Hg_^9K9[NGEGQc\LZdW:p@`3dd&Q94Nmnk+jO8_$D-][i^QQ!*6!A=gMMM![6k]L<l#,(>GKOLI@'GXj-F97LT3:X+ZD*YI&i#C4VM8D5QG_P-?T0i+<>_>nTXuVmCnDNloiO<OpO7N*Hj]gQ!$OF=9sSF+>`&SY4\MB[1Tm`l/0W$b"h$Jbgs>c/u"rTmNg;&hk';#]\/K)H>D'2b-kKe,'<L@[L@]KEX;6[@sT0<Ge]"4[W4ReBSi;oAN1+r/r4nmtmEa\5udf`k=<cE20<)$Gms8VYPk[F,Q)DCads0p"R30gXj4766WhKB:&@GK&(cpTjTak?UB<%RPc*"AXcNk)2,l8/_RQYgU?0qPX$D;%;s4%$"Wee>QS&&n!=0/K6'+&2`Jaf1"M>h^,&nq[f,L"?etFV57Ld=Z>>o9<?LVZ)e0&Pke2QTY_.3<"?39`6%oat]\?7k>=%!NqJ]N_0,^,;3"o<&[dN4dpVt9beF['5'>i9[*Q1l\U<G2dk*@E:99a_W7%'OuB2e\Tk\;3)]DOlQ[j'Ca1M-^5r=jR/Q\76^BmP%b=>0!VHB1jK)<Y>[OV28B3JY&56Q^3k+g@X"*1CU+-8+b`S&d6Y2\@m4T@6n]@EDbo?kCu.D<l[Us4dC"eb)^'C_"0"--\kuEFcT[kui`JPZb0TqB!tr6@Kf(C3+748Mg,tkEh%4c)JAr-cscW"aVV/m0VtU>aI;uAQ]A/:1[)2qHA_W^YMcf^P*B;&$M>Vk&;SHYb^=2SI=FBJPg0dU'XDO&8eH5b,q-o/s,;W[!>JA9Wb>MO/Tq-VRomCMB$RaAQ*.O)kfj`C4Zi+PI<p^S>Wo*I?7CF"6Yd[<Pdr[JT'p0!iA7X.E0DjK'+gc.;'Hm4=i1[C'*,9q"sSij3d+Ki1tmNKL$$X`L!XDl1h)[C:AukXd1QT,_jqH(kBTfj-lAl&@:Vh*Y=Iu;mfJB)5CAP/d$rIgFCW&Qt?V-8Yi^dYhqi&M^2Hf,+t)I*UsN=4sWQ3O7?644H38$PJ9bur0<Sl/k\r5$1%5`q'_O706iE-bk<5;3FY72TpShK"+VVo)5^!#Q4X&[3o`>mF\@C\du1S4[&V"..WVsV0"4,n,B7-03<>8QO)rmk&598Jc01r'#jN9Cc)6W[K%#d%4&Wf%G<&s[cY]'?L`#U0S4$u7eR^;15Q?:](@FID59lY.?Y.$I&"!+aplPRb#elR1bPG81_,o(M(9qkBO#=(Ye[_Rhr9@=\iHE;XeL_\^Y(#3$GDH^H11/i]19PtHH!3k+"R>haE&:pEn#;C]i]VWa75m07?P``MTD.YDIa3DG%uI6rdWTDKMfr>KL:o[6-DKrl7'")kNs;o<mFT:",2`-4FU=18h1>gDm`cj@7coS,<E?RiZIS?m'kiAjdU$4895gu7-U*XQn1<'j[tl6FhWLS.9(UEib$YN16PH_3)<%:EpFD.m/@IL:JQ>$h7::Pe193%:BN*tYg3X1;<82k-<2srQ/s<FKGrO?aLa!CT^UhsO+qQN9^-raP2XKN@m<Nd*YD[X?CO:2dE1R/nlM;:D6skERg1o&(PeDj&Y5J+u7I9j9ISFuHqf2pROB2)3UO@#klfE/F(^U6"]r*V_6uI?IOVD\^^Fo#3\mH7u"N#Oe\%j6PT=nGs&5Y0aDN/.&?RrbFcco^/E>G;A]76*V%QQsn@Ns$8YCg3t[%$geO`Pj;I@,lgbPm$kT[=CJ[7$W([S?ZO&?-r%&,gTo%Mu#EN.Uc<\H"S:]]S!gY/4GB-:`K9a92Im^Sf7Np2"!IcVOk$&%sb?hCUTX7D#Bcqe8!\[b#`%E<f%E<(WFgqetgBW-<I#oj-@V$2r[3"5)EKh!Y.Em-[Tr;J"o,`Ra;&=7j;]p\kF'M/RNcnpQ*pW;J5(H(U*=V"_@Oj?o\i?o]7(ad93r=).;cE?d`0n?b+N,jF?oq:r_TaEm?iEURHZV2+kU#7q-mR:NTDTdS]jrM5`Nkg[l7P!H=M`TH1$X1T9&PFY(Va8N96!kV63J'lEBAAfa&CAq>5"N.b^LCM*4+imEBYC0;%*KO-9GT5S!A4Ua_:;Wcm2=\EI3;LFrh^=;o2g)]U/C7Moihn=[%FESp#!8<f,H8!Zla#H*W4FMrQM[leWL3l1LpY)gKgJU0PsX+j=*S&?0gi)NDo8D&LFXue^L1t+3,%=<L.`sgZKP2PJf(1Kd<o5^rK!%1D!3k;odMpsQm<FfVf-SeO6A??/uI\$[#%;X_sqjj.F$K]LXhmZ@?W7oHYeP0:IS1boqZ*\-SF[_;`:f]T#Fml1rOh`o]_H&%,N,6MGu<`.m?uWM!kS'b2j!]X4@Dbo?djJ>K*V_,DF?rLrNd'H0bk4WcH"FC4].s3./f+)qi@TQ4h'RHn#.hCs_D%lo_u@-j*E*G%q?j[W'G'oFT-fpS'f_k$o7Y,KjX<i6bqZA)/hOiY"I04$_2[[=&>$HjiG^1l!#NoYM#*YZ[6,fKueD%_<;HDaDf+oRWoq5LZ:&K;\4er#[GIpi[eCqf![UkIi6iZtpHGMkg>dJ7CdgJI0QKRCuoNGGdA=ni10ZC@4GinC:XlP+;kH(qUcsUF3M*4)RRM10u2Kh98Ut]1-BO6ZRA*:,Q6UlKku&h%P9mT_5WU"-1eA).r6M1t_*)fJiHo+uq!6K9+/pjiVC>R;GsdUoSF]W:X4l8RpndA.@N4$+3c9J)'7dLobOf8t<lR6V_<k=Yrh[,IK#9c`217JIJrT9sLpHUq%V7ZC!+=[^1tg/b+4#XF^4Mj$+I"Z:#KG;tN3-/lJiqWaE+<JH=hSqQd/:fj\\JhE;09qhR1#pUMIcf;O1XR[Y'q3p,[gr.F,`V^'m#9-53EX2?+>>m,R)CFFsn&JL(J)^1!;4He^`>Ei7LCY<9si4]E3L.1HUIF;)q_Q`C^OYW<V!@&nl5G9)d$q0;6e[3Q^@69IM3?6&nL3Oguh@dJ+)/^*mF^Ur/'=-,,gWWd?``j#UB"Sij+N@Ut!SWDbEU/13!MMG$&%adW>3t+E:[R"PBX)oF:9`UfTAQIoF+`8c8/Afr`[LNLi(!l)8_3YlA\?kgm]]d29(#ZE3H6eB'<eBIr:eHqj5SQG!7!Lf1/Jd4A)RQ<*KBe'2hVGGYW=2Z,B!$Vi5A=UeK?<0FO<^l-MDq&:-9^sKm64KqM,m+BV573d'9//[j6oM$tQc._)N`10T#+Hq6qqs,EnGD/*R*15.l<JAR:XISe%8%KA1RcgFda*"b0@\i']ol=M*MY@+4l5@+1WZ03jOtCq=mMDOuh0RV/CYra)rUI=1be35uSG#i:IJ$b/l<RNjZs2PJr[BN@A9]"%tgUlCH`915Ds/[-CDGh%4r:n^Wgl?;*d@o-n+<_q9=*c<(Z&dU-P"nqdpB?k8>V7=UZ#.U$WOJ,$N_pQ^jluPK*2,rUg2;g*b^@K*E&4'a.I+bF5gHR[+irg>?iO_9B]Ipuk-Q(J;V\K@;9b1'kl-'_4([M8?>]k$G7'S"+JY@5XL'.OO#)U'SR`uuo9FAQA!iVDpJR_n+$`ooQeY$.OTuK1Fpf-8o<aDkcWl,4-'bMQmhLr]8`FjhcXGEt1g;BdJ#E<oCH(8jRie[h+ZRD2SXFg?(Cr]$9;Fe^rPUa#&SQ?eM^24X99C-@BFpLc4KN7Ys8%ImtT9B!)Q.fccKQJc&44GZKML[$r3>5n%cI=FXed08_fQmu1O'J!Q^>k`qMOe_1NIjkF94:61*jL6"S7mPqc9Aa]^'A<$aPoc0f]ACeb6^#"plWNqq#k4D%:-^X:.r#9Zf+R9lMt\:f0,la$-j%ck)>(h\sNZT+J^K%+p"e2ZAA?gocK-W-5()adUA*jjJ<R26-7C?Pn2muq-;\BrKVTqheids2cLn_CsU.fr(*iH/LYTH9)O3l&ntt@QB%aon18$+_]!jL3')dPe9V7%`sc/8"EsAt[]$G`]K\+D=RBofB-7s'a]uh4ZE/KuKW$PGKm4ZL]#J%W?=bCReKUn<RsPo29qT=apL]Ki'k'm_]!RXbHS6BV4d399]$-CMM9<KiD2^Cg&V:cg@]/^Tab$XZB<_`T78YhhO[.L-W,<R9MO\U*JbkM$NDHnhQDSQ?TLi3>;kmpZ88G"?<(J@t'XX2oQ6@enCb]F*0BmYqm%m9qZKnrpeUH.1YE'r[@XD>eRN!W8711Ds/a(L3b;g$^Lmf.54rcGp8@=hYe0>olQ"`Y@%iK4*&h:")'7qB-:L^$7AM]mr`H3G<QSjd^m$1.)O)c11)9S`&d_Hurj*b&p6IR#u,JpGXTUt[M]dCS_BFI=HQbf8-WB$@Xp32f\`O7o]ERlV<92,O%gRNd#W[Utg_7`"nU((QhSn7]p^f'5"\l_\X+XT[;I<P-?@>UK6a8=fi6"k*.kr:D;#Wh$pl6R7e4SlcnVWQne43`ij4ih#RY\-[nJrDV*)Lg@V<>IH<+@DB`M(*T]A;90H;7$InldF!"`uRr`;a#,UfrF(G:LC4LEK=?_CHRUp?'ePEl]skt9oLk(BnKmIOB%G(/\hr(BbI*Spc-qMI#t'p1phD$%up3ueV?I[@@A*NC.p<@6oH8]+jFrk.ON0JMk>f>cnAJ^K:ZH\JQ;[tj:EQ.)p30C1l:.#VCO&7\n\UuSe?lsRu*?2k$Ya!/4::ai"K+5iM8Y6B>_H@%g,k>:08:>F"Y<<j]L`g@SS)a/b,?9M/@ddbRD.QCUg$<)C[$kcXrN!Q%js>B;Hk()H3so\8ui?CGa+i&[5o'=DAJe3L=7!.k==FD")2.Q.B!K$1nq;=D>:@<Z3SHAMuUlM'\29Qpgd%8os+1V2`"CcM7U2d:?b'VGW>sFp2<5*5'/HfZ'/f\)+1>.PY%BP)VAfKsK7Z:7='UDo9jWF9g)JB7hXWA&qO>I"#HUI+C:L.P!l>*<rW(8(]K$2ZV-\'sTb3kMe=T;IM9NKgV6RT_\IfclNu*;+Z0.Br4$+FE_nfW20S\XeM-:KAdCSEM&)Si<Y,SpK$5baP<`g`k/9u9=3")K-j&D`t+V0^o7V`kEf),KoOOQc+</G&bOs36JZku<%^b@)GELdMeCLq-E'Z?;AB[F>_;NIP@a<m\)Wnj_H8snU9&\h1P/*7GdH+i.b.PYA0m/5$u<D"c1*efV]eL.YC"s2Bg\:+fB>T.cEWSidd+=[:t:\?8`sL1!'Be>Tbb:7Sbb1a1pVT8,.J?1?kW-CFj9Bt/q\hnfHBM1KY:SFI<N@B,71'2^<lVa1.Fah*^Vs=Bc]'gs3/)H45:kS!\2>@VJ8tIG0tHl8^9b^Q]lVC9Jp<11oafm[Adk&E<Ncuq!<;t2d](YOsa8T:4S<hi>eo:%eXT^E0fWT_+;^_7>$BGe\"o9!;J2_J:$bI*flL$Y)"CC=`UPtOUi_H1=+=6B#N,Vrqsj;pp*Rj"@VF!UuYgN2W1]P")-FDpY_+l#lQB;,#V'5GFU@R9kCr9Cu8Tb/`ci7P*9c9c'qAtk0j0K\25FHWZT?JopC#',8"9HP#Yt4,MD(<5*S#PA7>$(.(]7LBQ$4a'A>[+d"^Rr3)XAU/Iq#.MF.I&WlIuVCiN!M*7BTSL\,7AlKb:gKHZa)o]q`^^6UeJh*\K%0_bNmO&%3[<4NSos%Fm'noBD%&;8ZS:^UL:k)jGmmZfg46r4B0L#fflQ#0IcOt`5n;C((<)Ge"lei?&(@10U&JF3V`59umpe3<&*SSG](M/H1b>En=e-$kCA^gI?WTjr=1gE,YN^-7SuE8cAYE[TuXfXb;+cGo2L9,.-$rOJq`=Y@B]Mg3%UQ]K1)h[/=,QKka7Yr9;;H_WMC`,dbb,MaSgDgITYS20=5'9@^:!4Vm!R3Iil\&;FD$97]Dp':<4hX4!XUqT-AjD=M:YAn95pW1nlfd!hV'&>m^*0gR1do)S<7A?(&%4e2rG%a4/e=k"\MT[`=nHoe+lJ`ZuNMp`gQIV8mls&!q7?.aN*'.FX^bmSVmtt'</#N1a:9MtkFsNds_E][uEVCM7&#FM#a-,]cUckAD(pQ?J3sZp7$L4[s_aXq;@EY$]3(R<A6E"7OoAVM:^r>S14Y[A^g1#>C.]1)!)jN]Em5:]Z*@0*9`Oj-H6=FJ@]O`A0R?q94Gr4r(*Z7KLT!I^eRVm:lM?S*nGGa)Vg#-?0Z@PlFqrAN2*8A5=prafT-*#Zo^9h8>?r!@J%k)0_n]>p^Hr4,N/PYLCs.CBJIdK62GRG.D,/eIJ)N_2.R,\UT1WLAKCN5rNN]]XV2_:%G;N1er;nt*h@u;JPi]Yh_9W<^G/R3?no.(Zf'2_SEa%;BaedV/WZ(GelNd5Wf%C"pupjdDf^eA\u)r=A9ApL8EGWHJ0g/u/1!^:!-:iJ8LOdpKQl$J2YZWrQU]V-S4(!m`)((6A4L`>PUq.([JE%t5[-CE/LD$ng.bM,rgU+]SC'`>Vsc#t-.%Y0cq&1fO=Pc)_dH7sXIT=LXKeW@/3pWhM'M,-`Eef]iUb\<f2^+h"S[^?^*?iuNf/D"5$XC[.;8XToPc(qsV1gMWFL2]:MD\%Yl4DTse2KMBpOML.#5-P4Cg:X#[k4mjP:)O:E*pL2$jDjCg`trWpDA79qgg6?C&L`239G;/sN,CYoMTbjNGk%oZ?A'7^ZRS&;IMD/#eP#6L.(5\op>HuUAI_5Y,p:"Y#4<98XHZ0:jhmX0&G73oZ%_;-XiEaK&AgAn2hLfmPXI2odg"<p!SkC@!Seh:96V$8*#;G20PH3XoG+-Uc4P];(ck?)*rutFQa2@H_qcHq3:!;"AioF3j8&jrHH1Tj3#90=`=Pk`aF\qE.gZS`@Bu(>(1%B)pdhLZOBj#Y*Mu6.GX0h$RXrkM(EZ7LNb3/Y6OS`O-ti>U%*0('"i6](KR:X5V6``fZmgjBdfNf_"J\[?+U?JY+>1$YNhksV*o5R'[mAp2X.9cQ/"&LK)i,AMP(5>W+3nRhE6bF>8=S>AoRiG$gd+rl'1"V4lEbpGMYoG56Ehboe9')uqc<Z#.lNQd,*<Nb7)h!7N"<(bg5hOI$`O`<7/nEB[7ZsYA=gKm?gVc[BbDDSj5VQ-Wrm12rh0>His!]))6CEk)M!YaLFNT:/B#jW-FGB>"1;b1@.Gfo%e?Z9nl)=dh/7a@YGoAeJmTiXb#PFec?AY1'Z+AXFO=:hY,7mhU"_po3(O5];r`N_ZO&H9hLH9oT5`(3Qe:B#>`+#N/]uLZ*m,c6mZVXTYM"Sj%Yb_\-q#K$:Sh/M_EB:.,09urV'Ha#`[4#mb"dhHNa0C>7rbl#hr;7Hf.u;dU1*2NoDgu_"M<r``i=8Ci^ie,bnhC%(3GSBcWq8\a>tK;_i<]'&<P2\\u'?l0P(:I.*h`"lcFCC,&.RB[obPpF$`SmI#ejHUu0IYGZ*9T7H9M&LZ@/4L<5bf`O:;1BY0E>_r+#gb5>@pGBFb$nM3';j(9q.(QpQ?iM+!fR&e3M+4s&*5)F!\P.\===>*Phh^ItQntfOX!rS4eARigD4,.$keE_+s\pQ/Je`0Khd3H2b^2fW30](fOEIBn)dFstId2S[ZAKG2%1H&)uSW=J9\iFnN:7WJ];s/J?&BHt33[u6(jbr`gE&i8.O=OG\oJB^2e^CR);b9^I];Y<A4%$EU;ECNZP&neDq'o".'e])`]]!k#0io#n@%h+j"E1$dqS58Uc>i)jeUMD2paUQd62@@_9,RR$=ZO):jajtYV[[t3qm$#gF;@?#\F^'2*!o`iB_X'^fTC3ZS6Y*VQHf>eiJI<XK(<2'!)(#a*ciEef\)R1Y`HTZVAe*@eD+#Gho"&^*(iK_&^3R(BW5?&WgpmY_"k?cem/nqAM7pP]G&,QW)U++><I$DMnN5irCE!GR]`<`/<V%I+uW'!AY>$?iTOfDbHNC.dbrj\%03#P(YM<cR6M*<k.ZMQ\rutG>],2f7dPL?Q8ME2,<gChHiVC@aE2A2DI@L:qkMtcWKo^iao*!G2N<g)18i:9/,_pqGH7G;W!EM?m0IOC3r?Kqoupr?X!1WF*Fb%=HC46b:t'BslJu`?%fHl]MKM/=<RhRj.pbp"S5g@g;V..(U1a&l@%r,X(@ru4.=C<P[#43G+s#h::itAp.n(;m4B@Z*[Y>E3FD*)U`dGOm)uX`a_#f2.h!fMPi3M3KLP+eaK;5W&^BI.2l,sS7FOqbp."t\2&]n9lM1OtdI\mX/P`9`ua@8T%:E,ZTrgk`,fs>\k4tdUpi9Qs(n8>4..bZI3g/#?N[(X1@NB8hYV93(K.U1c^4](V8Id=VU?PE:J>6_=D2n7]B$/"('Unu5T1O?W)j7]EZ"ZX(@I/0Bg=CLCi:mL^->=huHJj;#mbA?&cjuLJ-^#W7tCfPj#kG*I&4'$6rm8Vm.<8;]n<*U&0o<C("D>odgO$=rRf/o.ro]?7[q#GfX4oc*A!0JT&==JMRoD6(,?74/^RVsQ0lCb]C>;/^B82;U_)e\,\>"JR&K+=HJWC\hi@UjRG^/DS8(EVURdjo@FZ%5?W<RU**0ssEgV'DLG+$J-Oqb^o5h_2!B+0TZNE2eoZU3r3Q=G4t=F@NF_VT`Tj^j'o/OK6pjO%+D(7Xr:r[20MW8TMJ.L%V)YHP:Sjb[\4d<c9k`,k"Hk8hDAC!4ZdnBC[(/=/DQ(3)UMQq;=[Pd57D5@*aXdk\(A%iX!dY+!h=4?jt^M9B=iEM8=A1nQmGRXH&"+%:u$[!s)@X6/7OD=UEX!hfiAO$Cfs!Xkj)tWs4<.5s-3l,_6m??'j"X`4J&SM)8*`ql\Lj\^E.PA`kYT9AAMiR"7'n9,+,s)(]Ks/PlDlde'$"X:>7k(tPdC0VVL36J41=ZtH4PSLT3cgSARr]YpE(aog>L="H<E]1^aEFcW'<('P*_G,^3JM$URHqkD2&bq.]<b"*=<fi2+655[B<;-O^`_t#qb`Bp.$UL$3"JBZ'l3`pE=JRc6"KZb.AOteU"EYLnlJaM;\LF'7>8jke8?Df*mEfM/K;,B%)@_3u=f,E*d)F4YhdP_pLG=i`InLI)(MJBd9cloA7i)g2d3oZB`d4AXm)4/P1-9jZ)cdh7;7f>I9V<Pc^a!M-U7BS+9gX$M4_)YRS3!\Rk!gGH$[htT#N/OrdTi*KRo&[?l<MfLV;?'`$UM\,%)M`U`%[3!^j25Wf`+&%e^`+pE2K7E6=Y/HED%an,@fWGp8&ghcGXn5Y%qC>(*m.5C,95qniO@i0!nWC+-"5ODhR;p&SqZpSeg9KAV^#aXhg?4uhh;TcG6*f9]Ak<b2W'4uq(?Kt$U='k:g0F]Ol4pT]C=,@0'rW8fDC,XF`md8cfDrJ!uB3'E9$TlR\K<PO\6LX0R"F)R%V$E(6L!H%LItfqjWG%+TS31f4WRa^KQHUe#1Z54pQD$SpjDOT:UKdIJ.dM)*_,8KcXJiO+bq[^jS>jY=H58P<ONi**ib]RM2=hG5]\XjQZ9:PDe\d`O#0>c7dI]J!`EkT)K86)YI+0lH/Mf[bnju`H'_np9)VbH?qA13P"aoj1EdT=S4uK5Oi;[e$Dj=4T8l[VVC981a<OD!q!5PRnnMk4o+,&X@Uf]=.'uh#(P=scqIG]ohq\'9fLl&HEo2sF0deQqM;ejR[!dJbH-r=n[,)PaLp;7h0iq[%'`c-1GO=RZM2,G4n/H%0('3kEI(b,#Q"5Wa*aLlrO30NS6hiFSm@sQ^Ns%R-O5F?,E?:;J?%c6CH*maAEGCkr1sqpS'U\s@9acjhXAcccYoaXT*Q;hI:&L-5UjR8%kJ:b-IUFj\ZFBckj]Jg_6*1<^"[FMDY:sHC(Bg((s@f-m#c3qg.c(J]7&-+o@_BHMqdK72oJbrqVqGLm.0N.p64%d(:KCo0(/E"j`ASb]XP3&55iVrrjB0/lp,>nL(k_ION>FM"WVhAQNQ\.*.i+DKS5qErnBYcEKFT['-H#'M%@`eS5"DHb*+Dc]_@a1agYnLLZ^TcMR:U"0oc2"C`0EW-$?CTbPqIj1?7g9kBcmOe@W@^^<Q;b-MQWBZmMS5_VD<?DpG:c?Tk<Yi.mMOQM-Y@rq+n4BN((agNifjE'F3KDu">Kp%8(OlcU(\-YGiN]9p>1GlI4>-H'q<K"]G`4N::$,iG;Ds'!m"5cu,EP2B:J.iD2+k4/<`m1(s,^%*"r4IH7'T>.nIS\Fr/qYo7@dr1:9/k.,s+8C]@c9Ld,G4[fnQc$/:P-mgV%c8;le`l\KmnU%3DJV)RVMhdqSR"qHTARfS\3.=tJ*0njm&lr/k?.\-I`4P39:VX&!r2'Al5;e:Mqb).0:sd'f'Qpkgk'D)@H]coW!?CGgdbQ,519:Z\p/XDqu$;G:[UjI-e#h[qu#aTjk9#Y&`;9nB@gctVe=K3hbPl>HVgHom8jUthlf8:IJ<[9G.Mu`5L?U[rm,N_mGG\RChI;XLq_r&@cbZ-o)"X*ZNkdg>UV[8GMt:Da+N9jcu;"$LB%!lBDdTMRD)lhLo^c3l[Lqpk3D%$`V%n3e,Oj\5EG'*q[l`trSTO'+T*(pbBH/Z.!_Z[)c'MMGIdT+8M`Z^>Vjq4d^l)I1d/Ubpjj3LXLGO*>67,MhiXib"SS4*k1Y+dRC1:$Iq=+S#7WF-=cMPSB34k;e37mCjdY)n`:VIW#EYi*i;~>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 + 0000001238 00000 n 83 + 0000001298 00000 n 84 + trailer 85 + << 86 + /ID 87 + [<4ab9585aea4adc5fa5189444ef1488c3><4ab9585aea4adc5fa5189444ef1488c3>] 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 + 18915 96 + %%EOF
.github/reports/iec61400_wind_turbine_tonality_example.webp

This is a binary file and will not be displayed.

+13
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - `WindTurbineTonalityResult.report()`: a one-page PDF wind-turbine 12 + tonal audibility assessment fiche (IEC 61400-11:2012+A1:2018, subclauses 13 + 9.5.2-9.5.5). The sheet carries a standard-basis line, an optional metadata 14 + header (source/situation, client, measurement position, instrumentation, 15 + date), a two-panel body with the critical-band and masking analysis in a 16 + metrics table (tone frequency, critical bandwidth, tone level `L_pt`, 17 + masking-noise level `L_pn`, tonality `dL_tn`, audibility criterion `L_a` and 18 + tonal audibility `dL_a`) beside the narrowband-spectrum plot with the 19 + critical band, masking level and tone marked, and a boxed decisive tonal 20 + audibility `dL_a` with the tone frequency and the audibility decision (a 21 + tone is audible when `dL_a` exceeds 0 dB). A maximum acceptable tonal 22 + audibility supplied via the metadata `requirement` adds a PASS/FAIL verdict 23 + (a lower audibility is better). `language="es"` renders the Spanish fiche. 11 24 - `NiptsResult.report()` and `HtlanResult.report()`: one-page PDF 12 25 noise-induced hearing-loss **prediction** fiches (ISO 1999:2013). Both sheets 13 26 are clearly labelled statistical predictions for a noise-exposed population,
+35
scripts/generate_reports.py
··· 1084 1084 return result, metadata, "ntacou112_impulse_prominence_example.pdf" 1085 1085 1086 1086 1087 + def _wind_turbine_tonality_example() -> Tuple[object, ReportMetadata, str]: 1088 + """Wind-turbine tonality fiche: an IEC 61400-11:2012+A1:2018 assessment. 1089 + 1090 + A clean 500 Hz tone (a gearbox line) 30 dB above a flat 30 dB narrow-band 1091 + floor, sampled at Delta f = 2 Hz over 440-560 Hz. The critical band about 1092 + 500 Hz is CBW = 117.256 Hz (Formula 30) and the ENBW is 1.5 * 2 = 3 Hz, so 1093 + the masking-noise level is L_pn = 30 + 10 lg(117.256 / 3) = 45.92 dB 1094 + (Formula 31); the single tone line gives L_pt = 60 dB, hence the tonality 1095 + is Delta L_tn = 14.08 dB (Formula 32). The audibility criterion at 500 Hz 1096 + is L_a = -2 - lg(1 + (500/502)^2.5) = -2.30 dB (Formula 34), so the tonal 1097 + audibility is Delta L_a = 14.08 - (-2.30) = 16.38 dB (Formula 33): the tone 1098 + is audible. The requirement is a plausible maximum acceptable tonal 1099 + audibility the example exceeds, so the optional verdict FAILs. 1100 + """ 1101 + df = 2.0 1102 + frequencies = np.arange(440.0, 560.0 + df, df) 1103 + levels = np.full(frequencies.size, 30.0) 1104 + levels[int(np.argmin(np.abs(frequencies - 500.0)))] = 60.0 1105 + result = ph.wind_turbine_tonality(levels, frequencies) 1106 + metadata = ReportMetadata( 1107 + specimen="Horizontal-axis wind turbine, gearbox tone", 1108 + client="Example client", 1109 + test_room="Ground board, downwind reference position (example)", 1110 + instrumentation="Class 1 analyser, FFT with 2 Hz lines (Hann window)", 1111 + measurement_standard="IEC 61400-11", 1112 + test_date="2026-07-21", 1113 + laboratory="Phonometry reference example", 1114 + operator="phonometry", 1115 + report_id="EXAMPLE-61400-11", 1116 + requirement=6.0, 1117 + ) 1118 + return result, metadata, "iec61400_wind_turbine_tonality_example.pdf" 1119 + 1120 + 1087 1121 def _epnl_example() -> Tuple[object, ReportMetadata, str]: 1088 1122 """EPNL fiche: an ICAO Annex 16 aircraft-noise-certification result. 1089 1123 ··· 2979 3013 _program_loudness_example, 2980 3014 _tone_audibility_example, 2981 3015 _impulse_prominence_example, 3016 + _wind_turbine_tonality_example, 2982 3017 _epnl_example, 2983 3018 _filter_class_example, 2984 3019 _filter_class_1995_example,
+46
site/src/content/docs/reference/api/aeroacoustics/wind-turbine-noise.md
··· 197 197 ``` 198 198 199 199 Plot the narrowband spectrum with the critical band and masking level. 200 + 201 + ### WindTurbineTonalityResult.report() 202 + 203 + ```python 204 + WindTurbineTonalityResult.report( 205 + path: str, 206 + *, 207 + metadata: ReportMetadata | None = None, 208 + engine: str = 'reportlab', 209 + verbose: bool = False, 210 + language: str = 'en', 211 + ) -> str 212 + ``` 213 + 214 + Render a wind-turbine tonal audibility assessment fiche to a PDF. 215 + 216 + Writes a one-page tonality-assessment report following 217 + IEC 61400-11:2012+A1:2018 (subclauses 9.5.2-9.5.5): the standard-basis 218 + line, an optional metadata header (source/situation, client, measurement 219 + position, instrumentation and date), a two-panel body with the 220 + critical-band / masking analysis in a metrics table (tone frequency, 221 + critical bandwidth, tone level `L_pt`, masking-noise level `L_pn`, 222 + tonality `ΔL_tn`, audibility criterion `L_a` and tonal audibility 223 + `ΔL_a`) beside the narrowband-spectrum plot with the critical band, 224 + masking level and tone marked, the boxed decisive tonal audibility 225 + `ΔL_a` and the tone frequency with the audibility decision, an optional 226 + verdict row and a footer with the fixed disclaimer. 227 + 228 + **Parameters** 229 + 230 + | Name | Description | 231 + | :--- | :--- | 232 + | `path` | Destination path of the PDF file. | 233 + | `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata); `None` produces a bare assessment fiche (body, result and disclaimer only). A supplied `requirement` is read as the maximum acceptable tonal audibility `ΔL_a` in dB (a lower audibility passes). | 234 + | `engine` | Rendering back end; only `"reportlab"` is supported. | 235 + | `verbose` | Accepted for signature parity with the other fiches; the metrics table already shows the full Formula 30-34 chain, so it has no effect. | 236 + | `language` | Fiche language: `"en"` (default, English) or `"es"` (Spanish, with a comma decimal separator). | 237 + 238 + **Returns:** The written `path` as a `str`. 239 + 240 + **Raises** 241 + 242 + | Exception | When | 243 + | :--- | :--- | 244 + | ValueError | If `language` is not one of the supported languages, or if `engine` is not `"reportlab"`. | 245 + | ImportError | If reportlab is not installed (`pip install phonometry[report]`). |
+18
src/phonometry/_report/_i18n.py
··· 843 843 "Design-stage prediction from the room geometry and surface absorption by the classical statistical-acoustics models; it is not a measurement. The five models bracket the reverberation time likely to occur; Arau-Puchades is recommended for a non-uniform absorption distribution and drives the boxed descriptor.": "Predicción en fase de diseño a partir de la geometría del recinto y la absorción de las superficies mediante los modelos clásicos de acústica estadística; no es una medición. Los cinco modelos acotan el tiempo de reverberación que probablemente se produzca; Arau-Puchades se recomienda para una distribución de absorción no uniforme y determina el valor destacado.", 844 844 "Sabine T = 24 ln10/c0 * V/(A + 4mV); Eyring replaces A by -S ln(1 - alpha_bar); Millington-Sette sums -S_i ln(1 - alpha_i) per surface; Fitzroy and Arau-Puchades are the area-weighted arithmetic and geometric means of the three axial Eyring times.": "Sabine T = 24 ln10/c0 * V/(A + 4mV); Eyring sustituye A por -S ln(1 - alpha_bar); Millington-Sette suma -S_i ln(1 - alpha_i) por superficie; Fitzroy y Arau-Puchades son las medias aritmética y geométrica, ponderadas por área, de los tres tiempos de Eyring axiales.", 845 845 "Estimate of the equivalent sound absorption area A and the reverberation time T of an enclosed space from its surface, object and air absorption by the EN 12354-6:2003 Clause 4 model (A by Formula 1, T = 55.3/c0 * V(1 - psi)/A by Formula 5, with the object fraction psi = sum Vobj / V). It is an estimate for a diffuse field, not a measurement.": "Estimación del área de absorción acústica equivalente A y del tiempo de reverberación T de un recinto a partir de la absorción de sus superficies, objetos y aire mediante el modelo del apartado 4 de la EN 12354-6:2003 (A por la fórmula 1, T = 55,3/c0 * V(1 - psi)/A por la fórmula 5, con la fracción de objetos psi = suma Vobj / V). Es una estimación para un campo difuso, no una medición.", 846 + # --- wind-turbine tonal audibility (IEC 61400-11) -------------------- 847 + "Wind-turbine tonal audibility assessment": "Evaluación de la audibilidad tonal de aerogenerador", 848 + "{standard} tonal audibility of a wind turbine per IEC 61400-11:2012+A1:2018 (subclauses 9.5.2-9.5.5); a tone is audible when the tonal audibility &#916;L<sub>a</sub> exceeds 0 dB.": "{standard} audibilidad tonal de un aerogenerador según IEC 61400-11:2012+A1:2018 (apartados 9.5.2-9.5.5); un tono es audible cuando la audibilidad tonal &#916;L<sub>a</sub> supera 0 dB.", 849 + "Tonal audibility of a wind turbine per IEC 61400-11:2012+A1:2018 (subclauses 9.5.2-9.5.5); a tone is audible when the tonal audibility &#916;L<sub>a</sub> exceeds 0 dB.": "Audibilidad tonal de un aerogenerador según IEC 61400-11:2012+A1:2018 (apartados 9.5.2-9.5.5); un tono es audible cuando la audibilidad tonal &#916;L<sub>a</sub> supera 0 dB.", 850 + "Critical-band analysis": "Análisis de banda crítica", 851 + "Tone frequency f [Hz]": "Frecuencia del tono f [Hz]", 852 + "Critical bandwidth [Hz]": "Ancho de banda crítico [Hz]", 853 + "Tonal audibility &#916;L<sub>a</sub> = <b>{dl} dB</b> &nbsp; tone f = <b>{f} Hz</b>": "Audibilidad tonal &#916;L<sub>a</sub> = <b>{dl} dB</b> &nbsp; tono f = <b>{f} Hz</b>", 854 + "Tonality &#916;L<sub>tn</sub> = {tn} dB (L<sub>pt</sub> &#8722; L<sub>pn</sub>)": "Tonalidad &#916;L<sub>tn</sub> = {tn} dB (L<sub>pt</sub> &#8722; L<sub>pn</sub>)", 855 + "Audibility criterion L<sub>a</sub> = {la} dB": "Criterio de audibilidad L<sub>a</sub> = {la} dB", 856 + "Decision: no tone identified": "Decisión: no se identifica ningún tono", 857 + "Decision: tone audible": "Decisión: tono audible", 858 + "Decision: tone not audible": "Decisión: tono no audible", 859 + "&#916;L<sub>a</sub> = {dl} dB, required &#8804; {req} dB": "&#916;L<sub>a</sub> = {dl} dB, exigido &#8804; {req} dB", 860 + "No tone was identified in the critical band, so the spectrum carries no tonal audibility and is excluded from the standardised averaging over the spectra of a wind-speed bin (IEC 61400-11:2012 subclause 9.5.1).": "No se identificó ningún tono en la banda crítica, por lo que el espectro no tiene audibilidad tonal y se excluye del promediado normalizado sobre los espectros de un intervalo de velocidad de viento (IEC 61400-11:2012 apartado 9.5.1).", 861 + "The tone is audible (decisive &#916;L<sub>a</sub> = {dl} dB &gt; 0): the tonality rises above the audibility criterion (IEC 61400-11:2012+A1:2018, Formulae 33-34).": "El tono es audible (&#916;L<sub>a</sub> decisiva = {dl} dB &gt; 0): la tonalidad supera el criterio de audibilidad (IEC 61400-11:2012+A1:2018, fórmulas 33-34).", 862 + "The tone is not audible (decisive &#916;L<sub>a</sub> = {dl} dB &#8804; 0): the tonality stays at or below the audibility criterion (IEC 61400-11:2012+A1:2018, Formulae 33-34).": "El tono no es audible (&#916;L<sub>a</sub> decisiva = {dl} dB &#8804; 0): la tonalidad se mantiene igual o por debajo del criterio de audibilidad (IEC 61400-11:2012+A1:2018, fórmulas 33-34).", 863 + "The tonal audibility &#916;L<sub>a</sub> = &#916;L<sub>tn</sub> &#8722; L<sub>a</sub> is the amount by which the tonality rises above the audibility criterion (IEC 61400-11:2012+A1:2018, Formulae 33-34); the tonality &#916;L<sub>tn</sub> = L<sub>pt</sub> &#8722; L<sub>pn</sub> compares the tone level with the masking-noise level within the critical band about the tone (Formulae 30-32).": "La audibilidad tonal &#916;L<sub>a</sub> = &#916;L<sub>tn</sub> &#8722; L<sub>a</sub> es la magnitud en que la tonalidad supera el criterio de audibilidad (IEC 61400-11:2012+A1:2018, fórmulas 33-34); la tonalidad &#916;L<sub>tn</sub> = L<sub>pt</sub> &#8722; L<sub>pn</sub> compara el nivel del tono con el nivel de ruido de enmascaramiento dentro de la banda crítica en torno al tono (fórmulas 30-32).", 846 864 } 847 865 848 866
+360
src/phonometry/_report/iec61400.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Wind-turbine tonal audibility assessment fiche (reportlab renderer). 3 + 4 + Renders a 5 + :class:`~phonometry.environmental.wind_turbine_noise.WindTurbineTonalityResult` 6 + to a one-page PDF laid out like a wind-turbine tonality-assessment report of an 7 + environmental-noise laboratory. IEC 61400-11:2012+A1:2018 decides whether a 8 + wind turbine emits an audible tone: from the narrow-band spectrum it forms the 9 + critical band about the candidate tone (Formula 30), the masking-noise level 10 + ``L_pn`` (Formula 31), the tonality ``ΔL_tn = L_pt - L_pn`` (Formula 32), the 11 + audibility criterion ``L_a`` (Formula 34) and the tonal audibility 12 + ``ΔL_a = ΔL_tn - L_a`` (Formula 33); a tone is audible when ``ΔL_a`` exceeds 13 + 0 dB. 14 + 15 + * a title and the standard-basis line (measurement standard + the IEC 61400-11 16 + tonality method); 17 + * an optional metadata header grid, rendered only for the fields supplied on 18 + the :class:`ReportMetadata`: the source/situation (``specimen``), the client, 19 + the measurement position (``test_room``), the instrumentation and the date; 20 + * a two-panel body with the critical-band / masking analysis in a compact 21 + metrics table on the left (tone frequency, critical bandwidth, tone level 22 + ``L_pt``, masking-noise level ``L_pn``, tonality ``ΔL_tn``, audibility 23 + criterion ``L_a`` and tonal audibility ``ΔL_a``) beside the result's own 24 + narrowband-spectrum plot with the critical band, masking level and tone 25 + marked (the result's :meth:`plot`); 26 + * a boxed single-number result: the decisive tonal audibility ``ΔL_a`` and the 27 + tone frequency, with the audibility decision the result carries; 28 + * an optional PASS/FAIL verdict row when a maximum acceptable tonal audibility 29 + is supplied via the metadata ``requirement`` (read as the maximum acceptable 30 + ``ΔL_a``, a lower audibility being better); and 31 + * a footer identity/disclaimer block. 32 + 33 + This is a non-band fiche: the plot self-scales (``y_top=None``) rather than 34 + using the fixed dB axis of the insulation fiches. The quantity-independent 35 + skeleton lives in :mod:`._layout`; this module only holds the IEC 61400-11 36 + tonality specifics. reportlab, matplotlib and svglib are soft dependencies 37 + imported lazily (reportlab and svglib ship in the ``phonometry[report]`` extra, 38 + matplotlib in ``phonometry[plot]``); each is guarded with an actionable 39 + :class:`ImportError`. 40 + """ 41 + 42 + from __future__ import annotations 43 + 44 + import html 45 + from typing import TYPE_CHECKING, Any, List, Tuple 46 + 47 + from ._i18n import format_number, t 48 + from ._layout import ( 49 + _ACCENT_HEX, 50 + _MUTED_HEX, 51 + _REPORTLAB_HINT, 52 + _VERDICT_BAD_HEX, 53 + _VERDICT_OK_HEX, 54 + build_document, 55 + display_round, 56 + document_styles, 57 + footer_flow, 58 + grid_table, 59 + measurement_basis_style, 60 + metrics_table, 61 + render_figure_drawing, 62 + result_box, 63 + two_panel_body, 64 + verdict_flow, 65 + ) 66 + from .metadata import ReportMetadata 67 + 68 + if TYPE_CHECKING: 69 + from ..environmental.wind_turbine_noise import WindTurbineTonalityResult 70 + 71 + 72 + def _fmt(value: float, language: str, decimals: int = 1) -> str: 73 + """A quantity rounded to ``decimals`` decimals, localised separator.""" 74 + return format_number(float(value), language, decimals=decimals) 75 + 76 + 77 + def _esc(value: str | None) -> str | None: 78 + """HTML-escape an optional free-text metadata value.""" 79 + return html.escape(value) if value else None 80 + 81 + 82 + def _audible_as_displayed(result: "WindTurbineTonalityResult") -> bool: 83 + """Whether the fiche's decision reads the tone as audible. 84 + 85 + The box, verdict and decision text all commit to the tonal audibility 86 + rounded exactly as displayed, so the audibility decision is taken on that 87 + same rounded value rather than on the result's raw ``is_audible`` flag; 88 + otherwise a raw ``ΔL_a`` of, say, 0.03 dB (audible) would print "0.0 dB 89 + > 0", contradicting its own number at the 0 dB boundary. A tone must still 90 + have been identified (subclause 9.5.4) for any audibility decision to apply. 91 + """ 92 + return ( 93 + result.has_identified_tone and display_round(result.tonal_audibility) > 0.0 94 + ) 95 + 96 + 97 + def _metadata_pairs( 98 + metadata: ReportMetadata | None, language: str = "en" 99 + ) -> List[Tuple[str, str]]: 100 + """Build the ordered (label, value) pairs of the tonality header grid. 101 + 102 + A wind-turbine tonality assessment identifies the source/situation whose 103 + noise was analysed (``specimen``), the client, the measurement position 104 + (``test_room``) and the instrumentation; only supplied fields are returned. 105 + """ 106 + if metadata is None: 107 + return [] 108 + specs: List[Tuple[str, str | None]] = [ 109 + (t("Source / situation", language), _esc(metadata.specimen)), 110 + (t("Client", language), _esc(metadata.client)), 111 + (t("Measurement position", language), _esc(metadata.test_room)), 112 + (t("Instrumentation", language), _esc(metadata.instrumentation)), 113 + (t("Date of test", language), _esc(metadata.test_date)), 114 + ] 115 + return [(label, value) for label, value in specs if value] 116 + 117 + 118 + def _metric_rows( 119 + result: "WindTurbineTonalityResult", language: str = "en" 120 + ) -> List[Tuple[str, str]]: 121 + """The critical-band / masking analysis shown in the left-hand table. 122 + 123 + Lists the tone frequency and the chain of Formulae 30-34: the critical 124 + bandwidth, the tone level ``L_pt``, the masking-noise level ``L_pn``, the 125 + tonality ``ΔL_tn``, the audibility criterion ``L_a`` and the tonal 126 + audibility ``ΔL_a``. 127 + """ 128 + return [ 129 + ( 130 + t("Tone frequency f [Hz]", language), 131 + _fmt(result.tone_frequency, language), 132 + ), 133 + ( 134 + t("Critical bandwidth [Hz]", language), 135 + _fmt(result.critical_bandwidth, language), 136 + ), 137 + ("L<sub>pt</sub> [dB]", _fmt(result.tone_level, language)), 138 + ("L<sub>pn</sub> [dB]", _fmt(result.masking_level, language)), 139 + ("&#916;L<sub>tn</sub> [dB]", _fmt(result.tonality, language)), 140 + ("L<sub>a</sub> [dB]", _fmt(result.audibility_criterion, language)), 141 + ( 142 + "&#916;L<sub>a</sub> [dB]", 143 + _fmt(display_round(result.tonal_audibility), language), 144 + ), 145 + ] 146 + 147 + 148 + def _basis_line(measurement_standard: str | None, language: str = "en") -> str: 149 + """The standard-basis line, with the measurement standard when supplied.""" 150 + if measurement_standard: 151 + return t( 152 + "{standard} tonal audibility of a wind turbine per " 153 + "IEC 61400-11:2012+A1:2018 (subclauses 9.5.2-9.5.5); a tone is " 154 + "audible when the tonal audibility &#916;L<sub>a</sub> exceeds 0 dB.", 155 + language, 156 + ).format(standard=html.escape(measurement_standard)) 157 + return t( 158 + "Tonal audibility of a wind turbine per IEC 61400-11:2012+A1:2018 " 159 + "(subclauses 9.5.2-9.5.5); a tone is audible when the tonal audibility " 160 + "&#916;L<sub>a</sub> exceeds 0 dB.", 161 + language, 162 + ) 163 + 164 + 165 + def _statement( 166 + result: "WindTurbineTonalityResult", language: str = "en" 167 + ) -> Tuple[str, List[str]]: 168 + """The boxed decisive tonal audibility ``ΔL_a`` and the tone frequency. 169 + 170 + The tonal audibility is rounded exactly as displayed so the box can never 171 + contradict its own decision at the 0 dB boundary. The extended terms name 172 + the tonality and audibility criterion, and the audibility decision the 173 + result carries. 174 + """ 175 + delta = display_round(result.tonal_audibility) 176 + statement = t( 177 + "Tonal audibility &#916;L<sub>a</sub> = <b>{dl} dB</b> &nbsp; " 178 + "tone f = <b>{f} Hz</b>", 179 + language, 180 + ).format(dl=_fmt(delta, language), f=_fmt(result.tone_frequency, language)) 181 + extended = [ 182 + t( 183 + "Tonality &#916;L<sub>tn</sub> = {tn} dB " 184 + "(L<sub>pt</sub> &#8722; L<sub>pn</sub>)", 185 + language, 186 + ).format(tn=_fmt(result.tonality, language)), 187 + t("Audibility criterion L<sub>a</sub> = {la} dB", language).format( 188 + la=_fmt(result.audibility_criterion, language) 189 + ), 190 + _decision_phrase(result, language), 191 + ] 192 + return statement, extended 193 + 194 + 195 + def _decision_phrase( 196 + result: "WindTurbineTonalityResult", language: str = "en" 197 + ) -> str: 198 + """A short coloured phrase carrying the audibility decision. 199 + 200 + When no tone was identified in the critical band the result carries no 201 + standard tonal audibility, so the phrase says so; otherwise it states 202 + whether the tone is audible. 203 + """ 204 + if not result.has_identified_tone: 205 + return ( 206 + f"<font color='{_MUTED_HEX}'>" 207 + f"{t('Decision: no tone identified', language)}</font>" 208 + ) 209 + if _audible_as_displayed(result): 210 + return ( 211 + f"<font color='{_VERDICT_BAD_HEX}'>&#9679; " 212 + f"{t('Decision: tone audible', language)}</font>" 213 + ) 214 + return ( 215 + f"<font color='{_VERDICT_OK_HEX}'>&#9679; " 216 + f"{t('Decision: tone not audible', language)}</font>" 217 + ) 218 + 219 + 220 + def _verdict( 221 + result: "WindTurbineTonalityResult", requirement: float, language: str = "en" 222 + ) -> Tuple[str, bool]: 223 + """Verdict text and PASS flag against a supplied maximum tonal audibility. 224 + 225 + The ``requirement`` is read as the maximum acceptable tonal audibility 226 + ``ΔL_a`` in dB (a lower audibility is better, as a more audible tone is more 227 + intrusive); the assessment passes when the tonal audibility, rounded as 228 + displayed, is at or below it. 229 + """ 230 + delta = display_round(result.tonal_audibility) 231 + passed = delta <= requirement + 1e-9 232 + text = t( 233 + "&#916;L<sub>a</sub> = {dl} dB, required &#8804; {req} dB", language 234 + ).format(dl=_fmt(delta, language), req=_fmt(requirement, language)) 235 + return text, passed 236 + 237 + 238 + def _decision_note( 239 + result: "WindTurbineTonalityResult", language: str = "en" 240 + ) -> str: 241 + """The measurement-basis note stating the audibility decision.""" 242 + delta = _fmt(display_round(result.tonal_audibility), language) 243 + if not result.has_identified_tone: 244 + return t( 245 + "No tone was identified in the critical band, so the spectrum " 246 + "carries no tonal audibility and is excluded from the standardised " 247 + "averaging over the spectra of a wind-speed bin (IEC 61400-11:2012 " 248 + "subclause 9.5.1).", 249 + language, 250 + ) 251 + if _audible_as_displayed(result): 252 + return t( 253 + "The tone is audible (decisive &#916;L<sub>a</sub> = {dl} dB " 254 + "&gt; 0): the tonality rises above the audibility criterion " 255 + "(IEC 61400-11:2012+A1:2018, Formulae 33-34).", 256 + language, 257 + ).format(dl=delta) 258 + return t( 259 + "The tone is not audible (decisive &#916;L<sub>a</sub> = {dl} dB " 260 + "&#8804; 0): the tonality stays at or below the audibility criterion " 261 + "(IEC 61400-11:2012+A1:2018, Formulae 33-34).", 262 + language, 263 + ).format(dl=delta) 264 + 265 + 266 + def render_wind_turbine_tonality_report( 267 + result: "WindTurbineTonalityResult", 268 + path: str, 269 + *, 270 + metadata: ReportMetadata | None = None, 271 + verbose: bool = False, 272 + language: str = "en", 273 + ) -> str: 274 + """Render a wind-turbine tonal audibility assessment fiche to a PDF at ``path``. 275 + 276 + :param result: A 277 + :class:`~phonometry.environmental.wind_turbine_noise.WindTurbineTonalityResult` 278 + carrying the tone/masking levels, the critical band and the tonal 279 + audibility. 280 + :param path: Destination path of the PDF file. 281 + :param metadata: Optional :class:`ReportMetadata` supplying the header 282 + identity (``specimen`` the source/situation, ``client``, ``test_room`` 283 + the measurement position, ``instrumentation`` and ``test_date``) and the 284 + footer identity. A supplied ``requirement`` is read as the maximum 285 + acceptable tonal audibility ``ΔL_a`` in dB. 286 + :param verbose: Accepted for signature parity with the other fiches; the 287 + metrics table already shows the full Formula 30-34 chain, so it has no 288 + effect. 289 + :param language: Fiche language: ``"en"`` (default) or ``"es"``. 290 + :return: The written ``path`` as a :class:`str`. 291 + :raises ImportError: If reportlab (or, for the figure, matplotlib) is not 292 + installed. 293 + """ 294 + del verbose # the metrics table already shows the full chain; kept for parity 295 + try: 296 + from reportlab.lib import colors 297 + from reportlab.lib.units import mm 298 + from reportlab.platypus import Paragraph, Spacer 299 + except ImportError as exc: 300 + raise ImportError(_REPORTLAB_HINT) from exc 301 + accent = colors.HexColor(_ACCENT_HEX) 302 + 303 + styles, title_style, basis_style, caption_style = document_styles(accent) 304 + title = t("Wind-turbine tonal audibility assessment", language) 305 + measurement_standard = ( 306 + metadata.measurement_standard if metadata is not None else None 307 + ) 308 + 309 + flow: List[Any] = [ 310 + Paragraph(title, title_style), 311 + Paragraph(_basis_line(measurement_standard, language), basis_style), 312 + ] 313 + 314 + header_pairs = _metadata_pairs(metadata, language) 315 + if header_pairs: 316 + flow.append(Spacer(1, 3)) 317 + flow.append(grid_table(header_pairs)) 318 + flow.append(Spacer(1, 8)) 319 + 320 + left_cell = [ 321 + Paragraph(t("Critical-band analysis", language), caption_style), 322 + metrics_table( 323 + _metric_rows(result, language), col_widths=[38 * mm, 18 * mm] 324 + ), 325 + ] 326 + # Non-band plot (the narrowband spectrum with the critical band, masking 327 + # level and tone): self-scaling axis, drawn by the result's own ``plot``. 328 + plot_drawing = render_figure_drawing( 329 + result.plot, 116 * mm, y_top=None, figsize=(5.8, 4.4), language=language 330 + ) 331 + flow.append(two_panel_body(left_cell, plot_drawing)) 332 + flow.append(Spacer(1, 8)) 333 + 334 + statement, extended = _statement(result, language) 335 + flow.append(result_box(statement, styles, accent, extended)) 336 + 337 + if metadata is not None and metadata.requirement is not None: 338 + text, passed = _verdict(result, metadata.requirement, language) 339 + flow.extend(verdict_flow(text, passed, styles, language)) 340 + 341 + basis_strip_style = measurement_basis_style() 342 + flow.append(Paragraph(_decision_note(result, language), basis_strip_style)) 343 + flow.append( 344 + Paragraph( 345 + t( 346 + "The tonal audibility &#916;L<sub>a</sub> = &#916;L<sub>tn</sub> " 347 + "&#8722; L<sub>a</sub> is the amount by which the tonality rises " 348 + "above the audibility criterion (IEC 61400-11:2012+A1:2018, " 349 + "Formulae 33-34); the tonality &#916;L<sub>tn</sub> = " 350 + "L<sub>pt</sub> &#8722; L<sub>pn</sub> compares the tone level " 351 + "with the masking-noise level within the critical band about the " 352 + "tone (Formulae 30-32).", 353 + language, 354 + ), 355 + basis_strip_style, 356 + ) 357 + ) 358 + flow.extend(footer_flow(metadata, language)) 359 + 360 + return build_document(path, flow, title)
+56
src/phonometry/environmental/wind_turbine_noise.py
··· 36 36 from matplotlib.axes import Axes 37 37 from numpy.typing import NDArray 38 38 39 + from .._report.metadata import ReportMetadata 40 + 39 41 40 42 class WindTurbineNoiseWarning(PhonometryWarning): 41 43 """The tonality inputs leave the standard's stated domain of validity.""" ··· 210 212 from .._plot.environmental import plot_wind_turbine_tonality 211 213 212 214 return plot_wind_turbine_tonality(self, ax=ax, language=check_language(language), **kwargs) 215 + 216 + def report( 217 + self, 218 + path: str, 219 + *, 220 + metadata: "ReportMetadata | None" = None, 221 + engine: str = "reportlab", 222 + verbose: bool = False, 223 + language: str = "en", 224 + ) -> str: 225 + """Render a wind-turbine tonal audibility assessment fiche to a PDF. 226 + 227 + Writes a one-page tonality-assessment report following 228 + IEC 61400-11:2012+A1:2018 (subclauses 9.5.2-9.5.5): the standard-basis 229 + line, an optional metadata header (source/situation, client, measurement 230 + position, instrumentation and date), a two-panel body with the 231 + critical-band / masking analysis in a metrics table (tone frequency, 232 + critical bandwidth, tone level ``L_pt``, masking-noise level ``L_pn``, 233 + tonality ``ΔL_tn``, audibility criterion ``L_a`` and tonal audibility 234 + ``ΔL_a``) beside the narrowband-spectrum plot with the critical band, 235 + masking level and tone marked, the boxed decisive tonal audibility 236 + ``ΔL_a`` and the tone frequency with the audibility decision, an optional 237 + verdict row and a footer with the fixed disclaimer. 238 + 239 + :param path: Destination path of the PDF file. 240 + :param metadata: Optional 241 + :class:`~phonometry.ReportMetadata`; ``None`` produces a bare 242 + assessment fiche (body, result and disclaimer only). A supplied 243 + ``requirement`` is read as the maximum acceptable tonal audibility 244 + ``ΔL_a`` in dB (a lower audibility passes). 245 + :param engine: Rendering back end; only ``"reportlab"`` is supported. 246 + :param verbose: Accepted for signature parity with the other fiches; the 247 + metrics table already shows the full Formula 30-34 chain, so it has 248 + no effect. 249 + :param language: Fiche language: ``"en"`` (default, English) or 250 + ``"es"`` (Spanish, with a comma decimal separator). 251 + :return: The written ``path`` as a :class:`str`. 252 + :raises ValueError: If ``language`` is not one of the supported 253 + languages, or if ``engine`` is not ``"reportlab"``. 254 + :raises ImportError: If reportlab is not installed 255 + (``pip install phonometry[report]``). 256 + """ 257 + from .._i18n import check_language 258 + 259 + check_language(language) 260 + if engine != "reportlab": 261 + raise ValueError( 262 + f"Unknown report engine {engine!r}; only 'reportlab' is supported." 263 + ) 264 + from .._report.iec61400 import render_wind_turbine_tonality_report 265 + 266 + return render_wind_turbine_tonality_report( 267 + self, path, metadata=metadata, verbose=verbose, language=language 268 + ) 213 269 214 270 215 271 def _validate_narrowband(
+213
tests/environmental/test_wind_turbine_tonality_report.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for the wind-turbine tonal audibility report (``.report()`` -> PDF). 4 + 5 + The report is a rendering feature, so these tests assert only structural facts: 6 + a valid single-page PDF is written, unknown engines and languages are rejected, 7 + XML specials in metadata do not break reportlab, the verdict renders both ways, 8 + and the boxed tonal audibility ``ΔL_a``, the tone frequency and the audibility 9 + decision appear in the extracted text. The tonality algorithm itself is 10 + validated against the IEC 61400-11 oracle elsewhere 11 + (tests/environmental/test_wind_turbine_noise.py); this fiche test reuses the 12 + same hand-derived synthetic tone so its numbers are documented. 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + import numpy as np 18 + import pytest 19 + 20 + pytest.importorskip("reportlab") 21 + 22 + from phonometry import ReportMetadata # noqa: E402 23 + from phonometry.environmental.wind_turbine_noise import ( # noqa: E402 24 + wind_turbine_tonality, 25 + ) 26 + 27 + _PDF_MAGIC = b"%PDF" 28 + 29 + 30 + def _synthetic_tone() -> tuple[np.ndarray, np.ndarray]: 31 + """A clean 500 Hz tone 30 dB above a flat 30 dB floor (df = 2 Hz). 32 + 33 + Hand-derived in the module tests: ΔL_a = 16.38 dB, audible, at 500 Hz. 34 + """ 35 + df = 2.0 36 + freqs = np.arange(440.0, 560.0 + df, df) 37 + levels = np.full(freqs.size, 30.0) 38 + levels[int(np.argmin(np.abs(freqs - 500.0)))] = 60.0 39 + return levels, freqs 40 + 41 + 42 + def _result(): 43 + levels, freqs = _synthetic_tone() 44 + return wind_turbine_tonality(levels, freqs) 45 + 46 + 47 + def _assert_one_page(path: str) -> None: 48 + import os 49 + 50 + with open(path, "rb") as handle: 51 + assert handle.read(4) == _PDF_MAGIC 52 + assert os.path.getsize(path) > 0 53 + from pypdf import PdfReader 54 + 55 + assert len(PdfReader(path).pages) == 1 56 + 57 + 58 + def _extract_text(path: str) -> str: 59 + from pypdf import PdfReader 60 + 61 + return "\n".join(page.extract_text() for page in PdfReader(path).pages) 62 + 63 + 64 + def test_report_writes_one_page_pdf(tmp_path) -> None: 65 + """An audible-tone result renders a one-page PDF fiche.""" 66 + result = _result() 67 + out = tmp_path / "wt.pdf" 68 + returned = result.report(str(out)) 69 + assert returned == str(out) 70 + _assert_one_page(str(out)) 71 + 72 + 73 + def test_unknown_engine_rejected(tmp_path) -> None: 74 + """An unknown rendering engine raises ``ValueError``.""" 75 + result = _result() 76 + out = str(tmp_path / "x.pdf") 77 + with pytest.raises(ValueError, match="engine"): 78 + result.report(out, engine="weasyprint") 79 + 80 + 81 + def test_unknown_language_rejected(tmp_path) -> None: 82 + """An unknown fiche language raises ``ValueError``.""" 83 + result = _result() 84 + out = str(tmp_path / "bad.pdf") 85 + with pytest.raises(ValueError, match="language"): 86 + result.report(out, language="xx") 87 + 88 + 89 + def test_report_states_audibility_frequency_and_decision(tmp_path) -> None: 90 + """The fiche states ΔL_a, the tone frequency and the audibility decision.""" 91 + result = _result() 92 + out = tmp_path / "wt.pdf" 93 + result.report(str(out)) 94 + text = _extract_text(str(out)).replace("\n", " ") 95 + 96 + assert result.is_audible is True 97 + assert "Tonal audibility" in text 98 + assert f"= {result.tonal_audibility:.1f} dB" in text # boxed ΔL_a 99 + assert f"{result.tone_frequency:.1f} Hz" in text 100 + assert "audible" in text 101 + assert "IEC 61400-11" in text 102 + 103 + 104 + def test_metadata_appears_and_one_page(tmp_path) -> None: 105 + """A populated ReportMetadata renders one page and prints its fields.""" 106 + md = ReportMetadata( 107 + specimen="Horizontal-axis wind turbine, gearbox tone", 108 + client="Acoustic Test Client Ltd.", 109 + test_room="Ground board, downwind reference position", 110 + instrumentation="Class 1 analyser, 2 Hz FFT lines", 111 + measurement_standard="IEC 61400-11", 112 + test_date="2026-07-21", 113 + laboratory="Phonometry Reference Laboratory", 114 + operator="J. M. Requena-Plens", 115 + report_id="PHN-2026-61400", 116 + ) 117 + out = tmp_path / "meta.pdf" 118 + _result().report(str(out), metadata=md, verbose=True) 119 + _assert_one_page(str(out)) 120 + text = _extract_text(str(out)).replace("\n", " ") 121 + assert "Horizontal-axis wind turbine, gearbox tone" in text 122 + assert "Ground board, downwind reference position" in text 123 + assert "PHN-2026-61400" in text 124 + assert "IEC 61400-11" in text 125 + 126 + 127 + def test_requirement_pass_and_fail_both_render(tmp_path) -> None: 128 + """A PASS and a FAIL tonal-audibility limit both render one page.""" 129 + result = _result() 130 + delta = result.tonal_audibility 131 + passing = tmp_path / "pass.pdf" 132 + failing = tmp_path / "fail.pdf" 133 + result.report(str(passing), metadata=ReportMetadata(requirement=delta + 2.0)) 134 + result.report(str(failing), metadata=ReportMetadata(requirement=delta - 2.0)) 135 + _assert_one_page(str(passing)) 136 + _assert_one_page(str(failing)) 137 + assert "PASS" in _extract_text(str(passing)).replace("\n", " ") 138 + assert "FAIL" in _extract_text(str(failing)).replace("\n", " ") 139 + 140 + 141 + def test_report_escapes_xml_specials_in_metadata(tmp_path) -> None: 142 + """Metadata with XML specials (& < >) renders without crashing reportlab.""" 143 + md = ReportMetadata( 144 + client="Ac & Co <Ltd>", 145 + specimen="turbine <A> & gearbox", 146 + test_room="pos <1> & <2>", 147 + laboratory="Lab & Sons", 148 + operator="A <B>", 149 + report_id="R&D-61400", 150 + measurement_standard="IEC 61400-11 & A1", 151 + ) 152 + out = tmp_path / "xml.pdf" 153 + _result().report(str(out), metadata=md) 154 + _assert_one_page(str(out)) 155 + 156 + 157 + def test_spanish_report_renders_translated_fiche(tmp_path) -> None: 158 + """``language="es"`` renders a one-page Spanish fiche with comma decimals.""" 159 + import re 160 + 161 + out = tmp_path / "wt_es.pdf" 162 + _result().report( 163 + str(out), 164 + metadata=ReportMetadata(specimen="aerogenerador de eje horizontal"), 165 + language="es", 166 + ) 167 + _assert_one_page(str(out)) 168 + text = _extract_text(str(out)) 169 + assert "Evaluación de la audibilidad tonal de aerogenerador" in text 170 + assert "Decisión" in text 171 + assert re.search(r"\d,\d", text) is not None # comma decimal separator 172 + 173 + 174 + def test_decision_matches_displayed_audibility_at_boundary(tmp_path) -> None: 175 + """The audibility decision reads the displayed rounded ΔL_a, not the raw flag. 176 + 177 + A raw tonal audibility of 0.03 dB is audible (> 0), but it rounds to the 178 + displayed 0.0 dB; the fiche must state the tone is *not* audible so the 179 + decision text cannot contradict the number printed in the box. 180 + """ 181 + import dataclasses 182 + 183 + result = dataclasses.replace( 184 + _result(), tonal_audibility=0.03, is_audible=True 185 + ) 186 + assert result.is_audible is True # raw flag is audible 187 + out = tmp_path / "boundary.pdf" 188 + result.report(str(out)) 189 + _assert_one_page(str(out)) 190 + text = _extract_text(str(out)).replace("\n", " ") 191 + assert "The tone is not audible" in text 192 + assert "0.0 dB" in text 193 + 194 + 195 + def test_no_identified_tone_reports_exclusion(tmp_path) -> None: 196 + """A spectrum with no classified tone renders and states the exclusion. 197 + 198 + A broad 33 dB bump on a 30 dB floor produces no tone line (subclause 9.5.4), 199 + so the fiche states that no tone was identified and the spectrum is excluded 200 + from the bin averaging, rather than fabricating an audibility decision. 201 + """ 202 + df = 2.0 203 + freqs = np.arange(380.0, 620.0 + df, df) 204 + levels = np.full(freqs.size, 30.0) 205 + levels[np.abs(freqs - 500.0) <= 20.0] = 33.0 206 + result = wind_turbine_tonality(levels, freqs, tone_frequency=500.0) 207 + out = tmp_path / "notone.pdf" 208 + result.report(str(out)) 209 + _assert_one_page(str(out)) 210 + text = _extract_text(str(out)).replace("\n", " ") 211 + assert result.has_identified_tone is False 212 + assert result.is_audible is False 213 + assert "No tone was identified" in text