diff --git a/pytta/__init__.py b/pytta/__init__.py index d8740aa..0d81a8a 100644 --- a/pytta/__init__.py +++ b/pytta/__init__.py @@ -108,6 +108,7 @@ from . import utils from . import rooms from . import iso3741 +from . import iso717 from .functions import read_wav, write_wav, merge, split, \ list_devices, print_devices, get_device_from_user,\ @@ -130,6 +131,7 @@ 'generate', 'utils', 'iso3741', + 'iso717', 'rooms', # Functions diff --git a/pytta/iso717.py b/pytta/iso717.py new file mode 100644 index 0000000..f970c8f --- /dev/null +++ b/pytta/iso717.py @@ -0,0 +1,70 @@ +""" +PyTTa ISO 717-1 rating: +---------------------- +Single-number quantity of airborne sound insulation, ISO 717-1:2013. + + >>> Rw, C, Ctr = pytta.iso717.airborne(R) + +""" +import numpy as np + +# ISO 717-1:2013, Table 3 (reference values) and Table 4 (spectra No. 1 and +# No. 2 for C and Ctr), from 100 Hz to 3 150 Hz in thirds and from 125 Hz +# to 2 000 Hz in octaves. +_TABLES = { + 16: dict(reference=[33, 36, 39, 42, 45, 48, 51, 52, 53, 54, 55, 56, 56, + 56, 56, 56], + spectra=([-29, -26, -23, -21, -19, -17, -15, -13, -12, -11, -10, + -9, -9, -9, -9, -9], + [-20, -20, -18, -16, -15, -14, -13, -12, -11, -9, -8, + -9, -10, -11, -13, -15]), + limit=320, at500=7), + 5: dict(reference=[36, 45, 52, 55, 56], + spectra=([-21, -14, -8, -5, -4], [-14, -10, -7, -4, -6]), + limit=100, at500=2), +} + + +def _half_up(x, step): + """Round to a multiple of step, halves up (ISO 80000-1), clearing the + binary error of the input first.""" + return np.floor(np.round(np.asarray(x, dtype=float) / step, 6) + 0.5) + + +def airborne(R): + """ + Weighted sound reduction index and spectrum adaptation terms of + ISO 717-1:2013, from R, R', Dn or DnT by band. + + Parameters: + ----------- + * R (array-like): + 16 one-third-octave bands from 100 Hz to 3 150 Hz or 5 octave + bands from 125 Hz to 2 000 Hz, in dB. + + Return: + ------- + * (Xw, C, Ctr), integers in dB: Rw (or R'w, Dn,w, DnT,w) and the + adaptation terms for pink noise and for urban traffic noise. + + R is rounded to 0.1 dB, halves up (4.4). The reference curve is shifted + in steps of 1 dB until the sum of unfavourable deviations is as large as + possible but not more than 32.0 dB in thirds or 10.0 dB in octaves, and + Xw is its value at 500 Hz. C and Ctr follow Equations (1) and (2). + Values are kept in tenths of a decibel as integers, so the limit is + compared exactly. + """ + tables = _TABLES.get(len(R)) + if tables is None: + raise ValueError('R must have 16 one-third-octave or 5 octave bands.') + tenths = _half_up(R, 0.1) + reference = np.array(tables['reference']) * 10 + shift = int(np.floor((tenths - reference).min() / 10)) # no deviation + while np.maximum(reference + (shift + 1) * 10 - tenths, 0).sum() \ + <= tables['limit']: + shift += 1 + Xw = tables['reference'][tables['at500']] + shift + C = [int(_half_up(-10 * np.log10(np.sum(10 ** ((np.array(L) - tenths / 10) + / 10))), 1)) - Xw + for L in tables['spectra']] + return Xw, C[0], C[1] diff --git a/tests/test_iso717.py b/tests/test_iso717.py new file mode 100644 index 0000000..0b9ce4c --- /dev/null +++ b/tests/test_iso717.py @@ -0,0 +1,50 @@ +""" +Tests for pytta.iso717.airborne, the single-number rating of airborne +sound insulation of ISO 717-1:2013. Each test pins one rule. +""" +import os +import unittest + +os.environ.setdefault('MPLBACKEND', 'Agg') + +import pytta + +# ISO 717-1:2013, Annex C, Table C.1: sound reduction index of a building +# element, one-third-octave bands from 100 Hz to 3 150 Hz. +R_ANNEX_C = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6, 28.0, 30.5, 31.8, + 32.5, 33.4, 33.0, 31.0, 25.5] + + +class TestISO717(unittest.TestCase): + + def test_example_of_annex_c(self): + # Rw(C;Ctr) = 30 (-2; -3) dB, with 31.8 dB of unfavourable deviation + self.assertEqual(pytta.iso717.airborne(R_ANNEX_C), (30, -2, -3)) + + def test_deviation_limit_is_inclusive(self): + # Octave bands: R 5 dB above the reference curve. Shifting it up by + # 7 dB gives 2 dB below R in each of the 5 bands, exactly 10.0 dB, + # which is allowed; 8 dB would give 15 dB. + reference = [36, 45, 52, 55, 56] + Rw, C, Ctr = pytta.iso717.airborne([r + 5 for r in reference]) + self.assertEqual(Rw, 52 + 7) + + def test_input_is_rounded_half_up_to_one_decimal(self): + # The standard rounds R to 0.1 dB before rating, halves up. These + # five bands end in 5, and round() of Python, which rounds the + # binary value, takes each of them 0.1 dB down: the deviations would + # sum 32.3 dB and Rw would drop to 29. Rounded half up, the curve is + # that of Annex C. + R = list(R_ANNEX_C) + for band, value in ((5, 22.65), (8, 27.95), (9, 30.45), (14, 30.95), + (15, 25.45)): + R[band] = value + self.assertEqual(pytta.iso717.airborne(R), (30, -2, -3)) + + def test_rejects_other_band_counts(self): + with self.assertRaises(ValueError): + pytta.iso717.airborne(R_ANNEX_C[:-1]) + + +if __name__ == '__main__': + unittest.main()