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

feat(aircraft): IEC 61265 measurement-system verifier (Table 1 + scalar tolerances)

José M. Requena Plens (Jul 12, 2026, 5:14 PM +0200) d4b813e5 4b6799e9

+181
+60
tests/test_aircraft_noise_system.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for the IEC 61265:1995 aircraft-noise measurement-system verifier. 3 + 4 + The oracle is IEC 61265 Table 1 (microphone directional-response tolerances), 5 + transcribed verbatim, plus the scalar tolerance limits of clauses 4.5-4.7. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import pytest 11 + 12 + from phonometry import verify_aircraft_noise_system 13 + from phonometry.compliance import _iec61265_directional_limit 14 + 15 + 16 + def test_directional_limits_table1() -> None: 17 + # Table 1 verbatim spot checks. 18 + assert _iec61265_directional_limit(1000.0, 90.0) == 1.0 # 50-1600 Hz row 19 + assert _iec61265_directional_limit(4000.0, 90.0) == 2.0 20 + assert _iec61265_directional_limit(10000.0, 150.0) == 7.5 21 + assert _iec61265_directional_limit(6300.0, 120.0) == 4.0 22 + 23 + 24 + def test_directional_intermediate_angle_uses_greater() -> None: 25 + # 75 deg is between 60 and 90 -> use the 90 deg limit (subclause 4.4.2). 26 + assert _iec61265_directional_limit(4000.0, 75.0) == _iec61265_directional_limit(4000.0, 90.0) 27 + 28 + 29 + def test_directional_pass() -> None: 30 + meas = {4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}} 31 + result = verify_aircraft_noise_system(directional=meas) 32 + assert result["passed"] is True 33 + assert all(c["ok"] for c in result["checks"]) 34 + 35 + 36 + def test_directional_fail() -> None: 37 + meas = {4000.0: {90: 2.5}} # limit is 2.0 dB at 4 kHz / 90 deg 38 + result = verify_aircraft_noise_system(directional=meas) 39 + assert result["passed"] is False 40 + 41 + 42 + def test_scalar_checks() -> None: 43 + result = verify_aircraft_noise_system( 44 + frequency_response={1000.0: 1.2, 8000.0: 1.6}, # 1.6 > 1.5 -> fail 45 + linearity={"reference": 0.3, "other": 0.6}, # 0.6 > 0.5 -> fail 46 + resolution=0.1, 47 + ) 48 + by_q = {(c["quantity"], c.get("frequency")): c["ok"] for c in result["checks"]} 49 + assert by_q[("frequency_response", 1000.0)] is True 50 + assert by_q[("frequency_response", 8000.0)] is False 51 + assert result["passed"] is False 52 + 53 + 54 + def test_out_of_range_frequency_raises() -> None: 55 + with pytest.raises(ValueError): 56 + verify_aircraft_noise_system(directional={20.0: {90: 0.5}}) 57 + 58 + 59 + def test_empty_call_not_passed() -> None: 60 + assert verify_aircraft_noise_system()["passed"] is False
+2
src/phonometry/__init__.py
··· 34 34 ) 35 35 from .compliance import ( 36 36 class_limits, 37 + verify_aircraft_noise_system, 37 38 verify_filter_class, 38 39 verify_weighting_class, 39 40 weighting_class_limits, ··· 1132 1133 "RepeatedMeasurementResult", 1133 1134 "CalibrationWarning", 1134 1135 "verify_filter_class", 1136 + "verify_aircraft_noise_system", 1135 1137 "class_limits", 1136 1138 "verify_weighting_class", 1137 1139 "weighting_class_limits",
+119
src/phonometry/compliance.py
··· 431 431 overall = None 432 432 433 433 return {"overall_class": overall, "bands": bands} 434 + 435 + 436 + # --------------------------------------------------------------------------- 437 + # IEC 61265:1995 aircraft-noise measurement-system tolerances 438 + # --------------------------------------------------------------------------- 439 + 440 + #: Tabulated depression/incidence angles (degrees) of IEC 61265 Table 1. 441 + _IEC61265_ANGLES: Tuple[float, ...] = (30.0, 60.0, 90.0, 120.0, 150.0) 442 + 443 + #: IEC 61265:1995 Table 1: maximum permitted |sensitivity(0°) − sensitivity(θ)| 444 + #: (dB) per one-third-octave band. Rows: (f_low, f_high, limits at the angles). 445 + _IEC61265_DIRECTIONAL: Tuple[Tuple[float, float, Tuple[float, ...]], ...] = ( 446 + (50.0, 1600.0, (0.5, 0.5, 1.0, 1.0, 1.0)), 447 + (2000.0, 2000.0, (0.5, 0.5, 1.0, 1.0, 1.0)), 448 + (2500.0, 2500.0, (0.5, 0.5, 1.0, 1.5, 1.5)), 449 + (3150.0, 3150.0, (0.5, 1.0, 1.5, 2.0, 2.0)), 450 + (4000.0, 4000.0, (0.5, 1.0, 2.0, 2.5, 2.5)), 451 + (5000.0, 5000.0, (0.5, 1.5, 2.5, 3.0, 3.0)), 452 + (6300.0, 6300.0, (1.0, 2.0, 3.0, 4.0, 4.0)), 453 + (8000.0, 8000.0, (1.5, 2.5, 4.0, 5.5, 5.5)), 454 + (10000.0, 10000.0, (2.0, 3.5, 5.5, 6.5, 7.5)), 455 + ) 456 + 457 + 458 + def _iec61265_directional_limit(frequency: float, angle: float) -> float: 459 + """The IEC 61265 Table 1 directional tolerance for a frequency and angle. 460 + 461 + Per subclause 4.4.2, an incidence angle between two tabulated angles takes 462 + the limit of the greater tabulated angle. 463 + """ 464 + row = next( 465 + (lims for lo, hi, lims in _IEC61265_DIRECTIONAL if lo <= frequency <= hi), 466 + None, 467 + ) 468 + if row is None: 469 + raise ValueError("'frequency' is outside the IEC 61265 range 50 Hz-10 kHz.") 470 + if angle <= 0.0 or angle > _IEC61265_ANGLES[-1]: 471 + raise ValueError("'angle' must lie in (0, 150] degrees.") 472 + col = next(i for i, a in enumerate(_IEC61265_ANGLES) if a >= angle) 473 + return row[col] 474 + 475 + 476 + def verify_aircraft_noise_system( 477 + *, 478 + directional: "Dict[float, Dict[float, float]] | None" = None, 479 + frequency_response: "Dict[float, float] | None" = None, 480 + linearity: "Dict[str, float] | None" = None, 481 + resolution: "float | None" = None, 482 + ) -> Dict[str, Any]: 483 + """Verify measured performance against IEC 61265:1995 tolerances. 484 + 485 + Each supplied measurement is checked against the standard's limit; the 486 + one-third-octave filtering itself is covered by the IEC 61260 class-2 487 + verification (subclause 4.6) and is not repeated here. 488 + 489 + :param directional: Microphone directional response as 490 + ``{frequency_hz: {angle_deg: |Δsensitivity| dB}}`` (Table 1, §4.4.2). 491 + :param frequency_response: System response deviations 492 + ``{frequency_hz: deviation_db}`` against the ±1.5 dB limit (§4.5.1). 493 + :param linearity: Level non-linearity ``{"reference": dB, "other": dB}`` 494 + against the ±0.4/±0.5 dB limits (§4.5.2). 495 + :param resolution: Readout resolution, in dB, against the 0.1 dB limit (§4.7). 496 + :return: ``{"passed": bool, "checks": [{"quantity", "limit", "value", "ok", 497 + ...}]}``; ``passed`` is the conjunction of every check. 498 + :raises ValueError: If a frequency or angle is out of the tabulated range. 499 + """ 500 + checks: List[Dict[str, Any]] = [] 501 + 502 + if directional is not None: 503 + for freq, per_angle in directional.items(): 504 + for angle, value in per_angle.items(): 505 + limit = _iec61265_directional_limit(float(freq), float(angle)) 506 + checks.append( 507 + { 508 + "quantity": "directional_response", 509 + "frequency": float(freq), 510 + "angle": float(angle), 511 + "limit": limit, 512 + "value": float(value), 513 + "ok": abs(float(value)) <= limit, 514 + } 515 + ) 516 + 517 + if frequency_response is not None: 518 + for freq, dev in frequency_response.items(): 519 + checks.append( 520 + { 521 + "quantity": "frequency_response", 522 + "frequency": float(freq), 523 + "limit": 1.5, 524 + "value": float(dev), 525 + "ok": abs(float(dev)) <= 1.5, 526 + } 527 + ) 528 + 529 + if linearity is not None: 530 + for kind, dev in linearity.items(): 531 + limit = 0.4 if kind == "reference" else 0.5 532 + checks.append( 533 + { 534 + "quantity": f"linearity_{kind}", 535 + "limit": limit, 536 + "value": float(dev), 537 + "ok": abs(float(dev)) <= limit, 538 + } 539 + ) 540 + 541 + if resolution is not None: 542 + checks.append( 543 + { 544 + "quantity": "resolution", 545 + "limit": 0.1, 546 + "value": float(resolution), 547 + "ok": float(resolution) <= 0.1, 548 + } 549 + ) 550 + 551 + passed = bool(checks) and all(c["ok"] for c in checks) 552 + return {"passed": passed, "checks": checks}