diff --git a/docs/api-reference.md b/docs/api-reference.md index b71082956..328bc7231 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -703,12 +703,14 @@ cycle and warn on use; they are removed in 4.0. | `PrecisionFieldIndicators` | `dataclass` | **Annex B indicators (per band).**
• `ft`: temporal variability (= F1) or None
• `f_pi_unsigned`: F_pIn unsigned (= F2)
• `f_pi_signed`: F_pIn signed (= F3), ≥ unsigned
• `fs`: field non-uniformity FS (= F4) | `fi.f_pi_signed, fi.fs` | | `precision_qualification` | `function` | **The five ISO 9614-3 Annex C acceptance criteria.**
• `indicators`: `PrecisionFieldIndicators`
• `scan_intensity_level_1`/`_2`: LIn per scan [dB] (criterion 1)
• `pressure_residual_index`: δpI0 [dB] (criterion 2, K = 10)
• `field_nonuniformity_1`/`_2`: FS per scan density (criterion 5)
• `frequencies` [Hz] or `repeatability_limit`: Table 1 limit s | `q = precision_qualification(fi, pressure_residual_index=18.0)`

• `PrecisionCriteria` | | `PrecisionCriteria` | `dataclass` | **Annex C pass/fail per band.**
• `criterion_1`…`criterion_5`: bool arrays or None where not evaluable
• `qualified`: conjunction of criteria 1–4 or None | `q.qualified, q.criterion_2` | -| `plane_wave_frequency_range` | `function` | **Working plane-wave range (f_l, f_u) (ISO 10534-2 §4.2–4.5).**
• `spacing` s [m]
• `speed_of_sound` c0 [m/s]
• `diameter` d [m] (Default: None → spacing bound only)
• `shape`: 'circular'/'rectangular' (Default: 'circular') | `plane_wave_frequency_range(0.05, 343.2, diameter=0.1) # (343.2, 1990.6)`

• [Hz] | +| `plane_wave_frequency_range` | `function` | **Working plane-wave range (f_l, f_u) (ISO 10534-2 §4.2–4.5).**
• `spacing` s [m]
• `speed_of_sound` c0 [m/s]
• `diameter` d [m] (Default: None → spacing bound only)
• `shape`: 'circular'/'rectangular'/'square' (Default: 'circular') | `plane_wave_frequency_range(0.05, 343.2, diameter=0.1) # (343.2, 1990.6)`

• [Hz] | +| `plane_wave_frequency_range_astm` | `function` | **Working plane-wave range (f_l, f_u) (ASTM E2611-19 6.2.3–6.2.5, 6.5.4).**
• `spacing` s [m]
• `speed_of_sound` c [m/s]
• `diameter` d [m] (Default: None → spacing bound only)
• `shape`: 'circular'/'rectangular'/'square' (Default: 'circular') | `plane_wave_frequency_range_astm(0.05, 343.2, diameter=0.1) # (68.6, 2011.2)`

• [Hz]; f_u s < 0.40 c, f_u d < 0.586 c (0.500 rectangular) | | `speed_of_sound_iso` | `function` | **Speed of sound (ISO 10534-2 Eq. 5).**
• `temperature` T [K] | `speed_of_sound_iso(293.0) # 343.2`

• c0 = 343.2 √(T/293) [m/s] | | `air_density_iso` | `function` | **Air density (ISO 10534-2 Eq. 7).**
• `temperature` T [K]
• `atmospheric_pressure` [kPa] (Default: 101.325) | `air_density_iso(293.0) # 1.186`

• ρ [kg/m³] | | `speed_of_sound_astm` | `function` | **Speed of sound (ASTM E2611-19 Eq. 4).**
• `temperature` T [°C] | `speed_of_sound_astm(20.0) # 343.2`

• 20.047 √(273.15 + T) [m/s] | | `air_density_astm` | `function` | **Air density (ASTM E2611-19 Eq. 5).**
• `temperature` T [°C]
• `atmospheric_pressure` P [kPa] (Default: 101.325) | `air_density_astm(20.0) # 1.202`

• ρ [kg/m³] | | `characteristic_impedance` | `function` | **Characteristic impedance of air ρc.**
• `density` ρ [kg/m³]
• `speed_of_sound` c [m/s] | `characteristic_impedance(1.186, 343.2) # 407.0`

• [rayl] | +| `hydraulic_diameter` | `function` | **Hydraulic diameter of a rectangular tube, 4A/P (ISO 10534-2 A.2.1.5).**
• `width` w [m]
• `height` h [m] | `hydraulic_diameter(0.08, 0.04) # 0.0533`

• d_h = 2wh/(w + h) [m]; feed to `tube_attenuation_constant` | | `tube_attenuation_constant` | `function` | **Lower-bound tube attenuation k0″ (ISO 10534-2 Eq. A.18).**
• `frequency` f [Hz]
• `speed_of_sound` c0 [m/s]
• `diameter` d [m] (hydraulic for rectangular) | `tube_attenuation_constant(1000.0, 343.2, 0.1) # 0.0179`

• [Np/m] | | `tube_wavenumber` | `function` | **Complex wavenumber k0 = k0′ − j k0″ (ISO 10534-2 §2.6).**
• `frequency` f [Hz]
• `speed_of_sound` c0 [m/s]
• `attenuation`: k0″ [Np/m] (Default: None → lossless) | `tube_wavenumber(1000.0, 343.2, attenuation=0.018)`

• 18.308 − 0.018j [1/m] | | `mic_calibration_factor` | `function` | **Microphone-mismatch calibration factor Hc (ISO 10534-2 Eq. 10).**
• `h12_config1`: H12 in the standard configuration
• `h12_config2`: H12 with the microphones interchanged | `hc = mic_calibration_factor(h12_a, h12_b)`

• Hc = √(H12ᴵ/H12ᴵᴵ) (complex) | @@ -719,19 +721,19 @@ cycle and warn on use; they are removed in 4.0. | `normalized_surface_impedance` | `function` | **Normalised surface impedance Z/(ρc0) (Eq. 19).**
• `reflection`: complex r | `normalized_surface_impedance(0.5) # 3.0` | | `normalized_surface_admittance` | `function` | **Normalised surface admittance Gρc0 (Eq. 20).**
• `reflection`: complex r | `normalized_surface_admittance(0.5) # 0.333`

• (1 − r)/(1 + r) | | `two_microphone_impedance` | `function` | **Full two-microphone reduction (ISO 10534-2 Clause 7).**
• `h12`: corrected transfer function
• `frequency` f [Hz]
• `spacing` s [m], `x1` [m]
• `speed_of_sound` c0 [m/s], `characteristic_impedance` ρc0 [rayl]
• `attenuation`: k0″ [Np/m] (Default: None)
• `diameter` + `shape`: activate the plane-wave range check | `res = two_microphone_impedance(h12, frequency=f, spacing=0.05, x1=0.15, speed_of_sound=343.2, characteristic_impedance=407.0)`

• `ImpedanceTubeResult` | -| `ImpedanceTubeResult` | `dataclass` | **Two-microphone result per frequency.**
• `frequency` [Hz]
• `reflection`: complex r (Eq. 17)
• `surface_impedance` [rayl], `normalized_impedance` (Eq. 19)
• `absorption`: α = 1 − \|r\|² (Eq. 18) | `res.absorption, res.normalized_impedance` | +| `ImpedanceTubeResult` | `dataclass` | **Two-microphone result per frequency.**
• `frequency` [Hz]
• `reflection`: complex r (Eq. 17)
• `surface_impedance` [rayl], `normalized_impedance` (Eq. 19)
• `absorption`: α = 1 − \|r\|² (Eq. 18)
• Retains `spacing`, `x1`, `diameter`, `shape` when supplied | `res.absorption, res.normalized_impedance` | | `ImpedanceTubeWarning` | `warning class` | **ISO 10534-2 advisory.**
Emitted by `two_microphone_impedance` for frequencies outside the plane-wave working range (Eqs. 1–4); the results are still returned | `warnings.simplefilter('error', ImpedanceTubeWarning)` | | `standing_wave_ratio_from_level` | `function` | **Standing-wave ratio from ΔL (ISO 10534-1 Eq. 15).**
• `level_difference`: Lmax − Lmin [dB] | `standing_wave_ratio_from_level(20.0) # 10.0`

• s = 10^(ΔL/20) | | `standing_wave_reflection_magnitude` | `function` | **Reflection magnitude from the SWR (Eq. 14).**
• `swr`: s ≥ 1 | `standing_wave_reflection_magnitude(10.0) # 0.818`

• \|r\| = (s − 1)/(s + 1) | | `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**
• `swr`: s
• `first_min_distance`: x_min1 [m]
• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, 0.4) # -0.818j`

• phase φ = π(4x_min1/λ0 − 1) | | `standing_wave_absorption` | `function` | **Absorption from the SWR (Eqs. 9 + 14).**
• `swr`: s ≥ 1 | `standing_wave_absorption(10.0) # 0.331`

• α = 4s/(s + 1)² | | `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**
• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, 0.4)`

• 0.198 − 0.98j | -| `wave_decomposition` | `function` | **Decompose the field into (A, B, C, D) (ASTM E2611-19 Eqs. 17–20).**
• `h1`…`h4`: microphone transfer functions
• `l1`, `s1`, `l2`, `s2`: geometry [m]
• `wavenumber` k | `A, B, C, D = wave_decomposition(h1, h2, h3, h4, l1=0.2, s1=0.05, l2=0.2, s2=0.05, wavenumber=k)`

• Forward/backward amplitudes, both sides | +| `wave_decomposition` | `function` | **Decompose the field into (A, B, C, D) (ASTM E2611-19 Eqs. 17–20).**
• `h1`…`h4`: microphone transfer functions
• `l1`, `s1`, `l2`, `s2`: geometry [m]
• `wavenumber` k
• `diameter` + `shape`: activate the plane-wave range check | `A, B, C, D = wave_decomposition(h1, h2, h3, h4, l1=0.2, s1=0.05, l2=0.2, s2=0.05, wavenumber=k)`

• Forward/backward amplitudes, both sides | | `face_quantities` | `function` | **Face pressures and velocities (Eq. 21).**
• `a`, `b`, `c`, `d`: wave amplitudes
• `wavenumber` k, `thickness` d [m], `characteristic_impedance` ρc | `p0, pd, u0, ud = face_quantities(A, B, C, D, wavenumber=k, thickness=0.05, characteristic_impedance=407.0)` | -| `transfer_matrix_two_load` | `function` | **Two-load transfer matrix (ASTM E2611-19 Eqs. 17–22).**
• `load_a`, `load_b`: (H1, H2, H3, H4) per termination
• `l1`, `s1`, `l2`, `s2`, `thickness` [m]
• `wavenumber`, `characteristic_impedance` | `T = transfer_matrix_two_load(load_a, load_b, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`

• `TransferMatrix` | -| `transfer_matrix_one_load` | `function` | **One-load transfer matrix, symmetric specimen (Eqs. 23–24).**
• `load`: (H1, H2, H3, H4)
• Same geometry parameters as `transfer_matrix_two_load` | `T = transfer_matrix_one_load(load, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`

• Requires reciprocity + symmetry | +| `transfer_matrix_two_load` | `function` | **Two-load transfer matrix (ASTM E2611-19 Eqs. 17–22).**
• `load_a`, `load_b`: (H1, H2, H3, H4) per termination
• `l1`, `s1`, `l2`, `s2`, `thickness` [m]
• `wavenumber`, `characteristic_impedance`
• `frequency` (retained for `.plot()`), `diameter` + `shape`: range check | `T = transfer_matrix_two_load(load_a, load_b, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`

• `TransferMatrix` | +| `transfer_matrix_one_load` | `function` | **One-load transfer matrix, symmetric specimen (Eqs. 23–24).**
• `load`: (H1, H2, H3, H4)
• Same geometry/frequency/range-check parameters as `transfer_matrix_two_load` | `T = transfer_matrix_one_load(load, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`

• Requires reciprocity + symmetry | | `air_layer_transfer_matrix` | `function` | **Analytic loss-free air-layer matrix (validation reference).**
• `wavenumber` k
• `thickness` d [m]
• `characteristic_impedance` ρc [rayl] | `T = air_layer_transfer_matrix(k, 0.05, 407.0)`

• det(T) = 1, T11 = T22 | -| `TransferMatrix` | `dataclass` | **Acoustic transfer matrix [[T11, T12], [T21, T22]] (ASTM E2611-19 Eq. 16).**
• `t11`, `t12`, `t21`, `t22`: complex, scalar or per frequency | `T.t11, T.t12` | +| `TransferMatrix` | `dataclass` | **Acoustic transfer matrix [[T11, T12], [T21, T22]] (ASTM E2611-19 Eq. 16).**
• `t11`, `t12`, `t21`, `t22`: complex, scalar or per frequency
• Solver-built matrices retain `l1`/`s1`/`l2`/`s2`, `thickness`, `diameter`, `shape`, `frequency`, `air_characteristic_impedance` (so `.plot()` needs no arguments) | `T.t11, T.t12` | | `airflow_resistance` | `function` | **Airflow resistance R = Δp/qv (ISO 9053-1:2018 §3.1).**
• `pressure_drop` Δp [Pa]
• `volume_flow_rate` qv [m³/s] | `airflow_resistance(2.0, 0.0005) # 4000.0`

• [Pa·s/m³] | | `specific_airflow_resistance` | `function` | **Specific airflow resistance Rs (§3.2).**
• `resistance` R + `area` A, **or** `pressure_drop` Δp + `velocity` u | `specific_airflow_resistance(4000.0, 0.01) # 40.0`

• Rs = R·A = Δp/u [Pa·s/m] | | `airflow_resistivity` | `function` | **Airflow resistivity σ = Rs/d (§3.3).**
• `specific_resistance` Rs [Pa·s/m]
• `thickness` d [m] | `airflow_resistivity(40.0, 0.05) # 800.0`

• [Pa·s/m²] | diff --git a/docs/materials.md b/docs/materials.md index e8b369c50..0684e379f 100644 --- a/docs/materials.md +++ b/docs/materials.md @@ -501,6 +501,19 @@ f_l, f_u = materials.plane_wave_frequency_range(0.020, 343.2, diameter=0.029) print(round(f_l, 1), round(f_u, 1)) # 858.0 6864.0 ``` +The same helper covers square and rectangular tubes: pass `shape="square"` or +`shape="rectangular"` and the Eq. (3) factor replaces the circular one, with +$d$ the maximum side length. The four-microphone branch keeps its own limits +in `plane_wave_frequency_range_astm`: ASTM E2611 retains the unrounded +circular constant ($f\,d < 0.586\,c$, 6.2.4.1), the same rectangular +$f\,d < 0.500\,c$ (6.2.5), a slightly stricter spacing bound +$f\,s < 0.40\,c$ (6.5.4) and a laxer low end at 1 % of the wavelength +(6.2.3). Passing `diameter=` (and `shape=`) to `two_microphone_impedance`, +`wave_decomposition` or the `transfer_matrix_*` solvers turns the matching +check into an advisory warning, and for the Annex A attenuation estimate of a +rectangular tube `hydraulic_diameter(width, height)` supplies the $4A/P$ +diameter that `tube_attenuation_constant` expects. + **Standing-wave-ratio method (ISO 10534-1).** A probe traverses the standing wave and reads the level difference $\Delta L = L_\text{max} - L_\text{min}$ between a pressure maximum and the adjacent minimum. The standing-wave ratio, reflection @@ -654,7 +667,7 @@ random-incidence $\alpha_s$/$\alpha_w$ of ISO 354 / ISO 11654. Setting It uses the same `ReportMetadata` container and rendering engine as the other fiches. The measured frequency range is taken from the result; the applicable descriptive and geometric `ReportMetadata` fields are `client`, `manufacturer`, -`specimen`, `tube_diameter`, `mic_spacing`, `mounting`, `test_room`, +`specimen`, `tube_diameter`, `tube_shape`, `mic_spacing`, `mounting`, `test_room`, `test_date`, `temperature`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes` (`tube_diameter` and `mic_spacing` are given in metres and printed in millimetres). The `requirement` field is ignored @@ -719,8 +732,10 @@ print(np.round(tm.transmission_loss(rho_c), 6)) # [0. 0. 0.] `(H1, H2, H3, H4)` of each load; its methods (`transmission_loss`, `reflection_hard_backed`, `absorption_hard_backed`, `characteristic_impedance_material`, `material_wavenumber`) then read off the -ASTM E2611 quantities, and its `.plot(frequency, rho_c)` draws the -transmission loss with the hard-backed absorption overlaid. The matrix does +ASTM E2611 quantities, and its `.plot()` draws the transmission loss with +the hard-backed absorption overlaid (a matrix built by the solvers retains +$\rho c$ and, when supplied, the frequency vector, so only a hand-built +matrix needs them as arguments). The matrix does not have to come from a measurement: the [multilayer solver](porous-absorbers.md) exposes the chain matrix of any modelled stack in the same convention, so a predicted specimen can be read diff --git a/site/src/content/docs/es/guides/materials.mdx b/site/src/content/docs/es/guides/materials.mdx index 1973965b3..bd1510fac 100644 --- a/site/src/content/docs/es/guides/materials.mdx +++ b/site/src/content/docs/es/guides/materials.mdx @@ -596,6 +596,20 @@ f_l, f_u = materials.plane_wave_frequency_range(0.020, 343.2, diameter=0.029) print(round(f_l, 1), round(f_u, 1)) # 858.0 6864.0 ``` +El mismo ayudante cubre tubos cuadrados y rectangulares: con `shape="square"` +o `shape="rectangular"` el factor de la Ec. (3) sustituye al circular, con +$d$ el lado mayor. La rama de cuatro micrófonos mantiene sus propios límites +en `plane_wave_frequency_range_astm`: ASTM E2611 conserva la constante +circular sin redondear ($f\,d < 0{,}586\,c$, 6.2.4.1), el mismo +$f\,d < 0{,}500\,c$ rectangular (6.2.5), una cota de separación algo más +estricta $f\,s < 0{,}40\,c$ (6.5.4) y un extremo bajo más laxo en el 1 % de +la longitud de onda (6.2.3). Pasar `diameter=` (y `shape=`) a +`two_microphone_impedance`, `wave_decomposition` o los solucionadores +`transfer_matrix_*` convierte la comprobación correspondiente en un aviso, y +para la estimación de atenuación del anexo A en tubo rectangular +`hydraulic_diameter(width, height)` proporciona el diámetro $4A/P$ que espera +`tube_attenuation_constant`. + **Método de la razón de onda estacionaria (ISO 10534-1).** Una sonda recorre la onda estacionaria y lee la diferencia de nivel $\Delta L = L_\text{max} - L_\text{min}$ entre un máximo de presión y el mínimo adyacente. La razón de onda @@ -758,7 +772,7 @@ se añade la columna del módulo del factor de reflexión $|r|$. Usa el mismo contenedor `ReportMetadata` y el mismo motor que las demás fichas. El rango de frecuencias medido se toma del resultado; los campos descriptivos y geométricos de `ReportMetadata` que aplican aquí son `client`, `manufacturer`, -`specimen`, `tube_diameter`, `mic_spacing`, `mounting`, `test_room`, +`specimen`, `tube_diameter`, `tube_shape`, `mic_spacing`, `mounting`, `test_room`, `test_date`, `temperature`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` y `notes` (`tube_diameter` y `mic_spacing` se dan en metros y se imprimen en milímetros). El campo `requirement` se ignora (la @@ -824,8 +838,10 @@ micrófonos `(H1, H2, H3, H4)` medidas en cada carga; sus métodos (`transmission_loss`, `reflection_hard_backed`, `absorption_hard_backed`, `characteristic_impedance_material`, `material_wavenumber`) leen entonces las magnitudes de ASTM E2611, y su -`.plot(frequency, rho_c)` dibuja la pérdida de transmisión con la absorción -con respaldo rígido superpuesta. La matriz no tiene por qué venir de una +`.plot()` dibuja la pérdida de transmisión con la absorción con respaldo +rígido superpuesta (una matriz construida por los solucionadores retiene +$\rho c$ y, si se aporta, el vector de frecuencias, de modo que solo una +matriz construida a mano los necesita como argumentos). La matriz no tiene por qué venir de una medición: el [solucionador multicapa](/phonometry/es/guides/porous-absorbers/) expone la matriz de cadena de cualquier apilado modelado en el mismo convenio, de modo diff --git a/site/src/content/docs/guides/materials.mdx b/site/src/content/docs/guides/materials.mdx index 9567fc02a..b82aba623 100644 --- a/site/src/content/docs/guides/materials.mdx +++ b/site/src/content/docs/guides/materials.mdx @@ -582,6 +582,19 @@ f_l, f_u = materials.plane_wave_frequency_range(0.020, 343.2, diameter=0.029) print(round(f_l, 1), round(f_u, 1)) # 858.0 6864.0 ``` +The same helper covers square and rectangular tubes: pass `shape="square"` or +`shape="rectangular"` and the Eq. (3) factor replaces the circular one, with +$d$ the maximum side length. The four-microphone branch keeps its own limits +in `plane_wave_frequency_range_astm`: ASTM E2611 retains the unrounded +circular constant ($f\,d < 0.586\,c$, 6.2.4.1), the same rectangular +$f\,d < 0.500\,c$ (6.2.5), a slightly stricter spacing bound +$f\,s < 0.40\,c$ (6.5.4) and a laxer low end at 1 % of the wavelength +(6.2.3). Passing `diameter=` (and `shape=`) to `two_microphone_impedance`, +`wave_decomposition` or the `transfer_matrix_*` solvers turns the matching +check into an advisory warning, and for the Annex A attenuation estimate of a +rectangular tube `hydraulic_diameter(width, height)` supplies the $4A/P$ +diameter that `tube_attenuation_constant` expects. + **Standing-wave-ratio method (ISO 10534-1).** A probe traverses the standing wave and reads the level difference $\Delta L = L_\text{max} - L_\text{min}$ between a pressure maximum and the adjacent minimum. The standing-wave ratio, reflection @@ -739,7 +752,7 @@ random-incidence $\alpha_s$/$\alpha_w$ of ISO 354 / ISO 11654. Setting It uses the same `ReportMetadata` container and rendering engine as the other fiches. The measured frequency range is taken from the result; the applicable descriptive and geometric `ReportMetadata` fields are `client`, `manufacturer`, -`specimen`, `tube_diameter`, `mic_spacing`, `mounting`, `test_room`, +`specimen`, `tube_diameter`, `tube_shape`, `mic_spacing`, `mounting`, `test_room`, `test_date`, `temperature`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes` (`tube_diameter` and `mic_spacing` are given in metres and printed in millimetres). The `requirement` field is ignored @@ -804,8 +817,10 @@ print(np.round(tm.transmission_loss(rho_c), 6)) # [0. 0. 0.] `(H1, H2, H3, H4)` of each load; its methods (`transmission_loss`, `reflection_hard_backed`, `absorption_hard_backed`, `characteristic_impedance_material`, `material_wavenumber`) then read off the -ASTM E2611 quantities, and its `.plot(frequency, rho_c)` draws the -transmission loss with the hard-backed absorption overlaid. The matrix does +ASTM E2611 quantities, and its `.plot()` draws the transmission loss with +the hard-backed absorption overlaid (a matrix built by the solvers retains +$\rho c$ and, when supplied, the frequency vector, so only a hand-built +matrix needs them as arguments). The matrix does not have to come from a measurement: the [multilayer solver](/phonometry/guides/porous-absorbers/) exposes the chain matrix of any modelled stack in the same convention, so a predicted specimen diff --git a/site/src/content/docs/reference/api/building/insulation.md b/site/src/content/docs/reference/api/building/insulation.md index 5cfb1496b..baaa0aaad 100644 --- a/site/src/content/docs/reference/api/building/insulation.md +++ b/site/src/content/docs/reference/api/building/insulation.md @@ -820,6 +820,7 @@ ReportMetadata( requirement: float | None = None, required_class: int | None = None, notes: str | None = None, + tube_shape: str | None = None, ) ``` @@ -861,6 +862,7 @@ within 0..100 %. A violation raises `ValueError`. | `calibration` | Calibration traceability, as free text (calibrator, date and result of the most recent verification, the before/after field checks). Printed by the occupational noise-exposure fiche (ISO 9612:2009 Clause 15 c). | | `tube_diameter` | Impedance-tube inner diameter `d` (circular tube) or maximum lateral dimension (rectangular tube), in metres. Printed by the impedance-tube fiche (ISO 10534-2), where it fixes the upper plane-wave cut-on frequency. | | `mic_spacing` | Microphone spacing `s` between the two measurement positions of the impedance tube, in metres. Printed by the impedance-tube fiche (ISO 10534-2), where it bounds the working frequency range. | +| `tube_shape` | Cross-section of the impedance tube: `"circular"`, `"rectangular"` or `"square"`. Printed by the impedance-tube fiche (ISO 10534-2) next to the tube diameter, which it qualifies (inner diameter for a circular tube, maximum lateral dimension otherwise). | | `thickness` | Specimen thickness under the applied static load, in metres. Printed by the dynamic-stiffness fiche (EN 29052-1 / ISO 9052-1), where EN 29052-1:1992 Clause 9 b) requires reporting the thickness of the resilient layer under load; it is shown in millimetres. | | `mounting` | Mounting condition of the specimen (e.g. the ISO 10140-1 mounting code or a short description). | | `measurement_standard` | Measurement standard the spectrum was obtained under (e.g. `"ISO 10140-2"` or `"ISO 16283-1"`); it forms the report's standard-basis line together with the ISO 717 rating part. | @@ -876,7 +878,7 @@ within 0..100 %. A violation raises `ValueError`. | Exception | When | | :--- | :--- | -| ValueError | If a supplied dimension/mass/volume/pressure is not finite and strictly positive, a temperature or requirement is not finite, a relative humidity is outside 0..100 %, or a required class is not one of 0, 1, 2, or a position count is not a finite, positive integer. | +| ValueError | If a supplied dimension/mass/volume/pressure is not finite and strictly positive, a temperature or requirement is not finite, a relative humidity is outside 0..100 %, a required class is not one of 0, 1, 2, a position count is not a finite, positive integer, or a tube shape is not one of `"circular"`, `"rectangular"`, `"square"`. | ### ReportMetadata.is_empty() diff --git a/site/src/content/docs/reference/api/materials/impedance-tube.md b/site/src/content/docs/reference/api/materials/impedance-tube.md index 0a78b2f7b..d8080dfe1 100644 --- a/site/src/content/docs/reference/api/materials/impedance-tube.md +++ b/site/src/content/docs/reference/api/materials/impedance-tube.md @@ -208,6 +208,29 @@ Face pressures and particle velocities (ASTM E2611-19, Eq. (21)). **Returns:** Tuple `(p0, pd, u0, ud)` of face pressures and velocities. +## hydraulic_diameter + +```python +hydraulic_diameter(width: float, height: float) -> float +``` + +Hydraulic diameter of a rectangular tube, `4 A / P` (ISO 10534-2, A.2.1.5). + +For a rectangular cross-section of side lengths `w` and `h` the ratio +of four times the area to the perimeter reduces to +`d_h = 2 w h / (w + h)`; a square tube gives `d_h` equal to the side +length. This is the `d` the Eq. (A.18) attenuation estimate expects for +rectangular tubes (see [`tube_attenuation_constant`](/phonometry/reference/api/materials/impedance-tube/#tube_attenuation_constant)). + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `width` | Inner side length `w`, in metres. | +| `height` | Inner side length `h`, in metres. | + +**Returns:** Hydraulic diameter `d_h = 4 A / P`, in metres. + ## ImpedanceTubeResult ```python @@ -217,6 +240,10 @@ ImpedanceTubeResult( surface_impedance: Complex, normalized_impedance: Complex, absorption: Real, + spacing: float | None = None, + x1: float | None = None, + diameter: float | None = None, + shape: str | None = None, ) ``` @@ -229,6 +256,13 @@ reflection factor `r` at the sample surface (Eq. (17)), and `absorption` the normal-incidence coefficient `alpha = 1 - |r|^2` (Eq. (18)). +The trailing fields retain the tube geometry the reduction was run with +(microphone `spacing` `s`, distance `x1` from the sample to the +farther microphone, tube `diameter` and cross-section `shape`, stored +canonically as `"circular"`/`"rectangular"` - a `"square"` input is +kept as `"rectangular"`); they default to `None` when not supplied to +[`two_microphone_impedance`](/phonometry/reference/api/materials/impedance-tube/#two_microphone_impedance). + ### ImpedanceTubeResult.plot() ```python @@ -278,7 +312,7 @@ to the normal-incidence coefficient reported here). | Name | Description | | :--- | :--- | | `path` | Destination path of the PDF file. | -| `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/geometric fields are `client`, `manufacturer`, `specimen`, `tube_diameter`, `mic_spacing`, `mounting`, `test_room`, `test_date`, `temperature`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes`. The `requirement` field is ignored (ISO 10534-2 has no verdict). | +| `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/geometric fields are `client`, `manufacturer`, `specimen`, `tube_diameter`, `tube_shape`, `mic_spacing`, `mounting`, `test_room`, `test_date`, `temperature`, `pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id` and `notes`. The `requirement` field is ignored (ISO 10534-2 has no verdict). | | `engine` | Rendering back end; only `"reportlab"` is supported. | | `verbose` | When `True`, the value table inserts the reflection-factor magnitude `\|r\|` column. | | `language` | Fiche language: `"en"` (default, English, decimal point) or `"es"` (Spanish, decimal comma). | @@ -384,8 +418,45 @@ Clause 4.2 guideline that the spacing exceed 5 % of the wavelength, i.e. | :--- | :--- | | `spacing` | Microphone spacing `s`, in metres. | | `speed_of_sound` | Speed of sound `c0`, in metres per second. | -| `diameter` | Tube diameter (circular) or maximum lateral dimension (rectangular) `d`, in metres; `None` applies only the spacing bound. | -| `shape` | `"circular"` or `"rectangular"`. | +| `diameter` | Tube diameter (circular) or maximum lateral dimension (rectangular/square) `d`, in metres; `None` applies only the spacing bound. | +| `shape` | `"circular"`, `"rectangular"` or `"square"` (a square tube is the rectangular bound with `d` the side length). | + +**Returns:** Tuple `(f_l, f_u)` of the lower and upper frequency limits, in Hz. + +## plane_wave_frequency_range_astm + +```python +plane_wave_frequency_range_astm( + spacing: float, + speed_of_sound: float, + *, + diameter: float | None = None, + shape: str = 'circular', +) -> tuple[float, float] +``` + +Working plane-wave frequency range `(f_l, f_u)` (ASTM E2611-19). + +The upper limit is the smaller of the microphone-spacing bound +`s <= 0,8 c / (2 f_u)`, i.e. `f_u s < 0,40 c` (6.5.4), and, when the +tube `diameter` is given, the cut-on bound `f_u < K c / d` with +`K = 0,586` for a circular tube (6.2.4.1, Eq. (2)) or `K = 0,500` for +a rectangular tube with `d` the largest section dimension (6.2.5). The +lower limit follows 6.2.3: the spacing shall be greater than 1 % of the +wavelength, i.e. `f_l = c / (100 s)`. + +With two different spacings `s1`/`s2`, call with the larger one for +the upper bound and the smaller one for the lower bound (each bound is +binding for every microphone pair). + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `spacing` | Microphone spacing `s`, in metres. | +| `speed_of_sound` | Speed of sound `c`, in metres per second. | +| `diameter` | Tube diameter (circular) or largest section dimension (rectangular/square) `d`, in metres; `None` applies only the spacing bound. | +| `shape` | `"circular"`, `"rectangular"` or `"square"`. | **Returns:** Tuple `(f_l, f_u)` of the lower and upper frequency limits, in Hz. @@ -595,6 +666,9 @@ transfer_matrix_one_load( thickness: float, wavenumber: ArrayLike, characteristic_impedance: float, + frequency: ArrayLike | None = None, + diameter: float | None = None, + shape: str = 'circular', ) -> TransferMatrix ``` @@ -622,8 +696,11 @@ T21 = (u0^2 - ud^2) / DEN | `thickness` | Specimen thickness `d`, in metres. | | `wavenumber` | Air wavenumber `k`. | | `characteristic_impedance` | Characteristic impedance `rho c`. | +| `frequency` | Optional frequency vector `f`, in hertz, retained on the result so [`TransferMatrix.plot`](/phonometry/reference/api/materials/impedance-tube/#transfermatrixplot) needs no arguments. | +| `diameter` | Optional tube diameter (circular) or largest section dimension (rectangular/square), in metres, that activates the plane-wave working-range check (6.2.3-6.2.5, 6.5.4). | +| `shape` | Tube cross-section, `"circular"`, `"rectangular"` or `"square"`. | -**Returns:** The specimen [`TransferMatrix`](/phonometry/reference/api/materials/impedance-tube/#transfermatrix). +**Returns:** The specimen [`TransferMatrix`](/phonometry/reference/api/materials/impedance-tube/#transfermatrix) (measurement context retained on the result). ## transfer_matrix_two_load @@ -639,6 +716,9 @@ transfer_matrix_two_load( thickness: float, wavenumber: ArrayLike, characteristic_impedance: float, + frequency: ArrayLike | None = None, + diameter: float | None = None, + shape: str = 'circular', ) -> TransferMatrix ``` @@ -669,13 +749,30 @@ T22 = (p_da u0b - p_db u0a) / DEN | `thickness` | Specimen thickness `d`, in metres. | | `wavenumber` | Air wavenumber `k`. | | `characteristic_impedance` | Characteristic impedance `rho c`. | +| `frequency` | Optional frequency vector `f`, in hertz, retained on the result so [`TransferMatrix.plot`](/phonometry/reference/api/materials/impedance-tube/#transfermatrixplot) needs no arguments. | +| `diameter` | Optional tube diameter (circular) or largest section dimension (rectangular/square), in metres, that activates the plane-wave working-range check (6.2.3-6.2.5, 6.5.4). | +| `shape` | Tube cross-section, `"circular"`, `"rectangular"` or `"square"`. | -**Returns:** The specimen [`TransferMatrix`](/phonometry/reference/api/materials/impedance-tube/#transfermatrix). +**Returns:** The specimen [`TransferMatrix`](/phonometry/reference/api/materials/impedance-tube/#transfermatrix) (measurement context retained on the result). ## TransferMatrix ```python -TransferMatrix(t11: Complex, t12: Complex, t21: Complex, t22: Complex) +TransferMatrix( + t11: Complex, + t12: Complex, + t21: Complex, + t22: Complex, + l1: float | None = None, + s1: float | None = None, + l2: float | None = None, + s2: float | None = None, + thickness: float | None = None, + diameter: float | None = None, + shape: str | None = None, + frequency: Real | None = None, + air_characteristic_impedance: float | None = None, +) ``` Acoustic transfer matrix `[[T11, T12], [T21, T22]]` (ASTM E2611-19). @@ -684,6 +781,15 @@ Relates the pressure and normal particle velocity across a specimen, `[p; u]_{x=0} = T [p; u]_{x=d}` (Eq. (16)). Each entry is complex and may be scalar or a per-frequency array of matching shape. +The trailing fields retain the measurement context when the matrix comes +out of [`transfer_matrix_two_load`](/phonometry/reference/api/materials/impedance-tube/#transfer_matrix_two_load) / [`transfer_matrix_one_load`](/phonometry/reference/api/materials/impedance-tube/#transfer_matrix_one_load) +(tube geometry `l1`/`s1`/`l2`/`s2`, specimen `thickness`, tube +`diameter` and canonical cross-section `shape`, the `frequency` +vector when supplied to the solver, and the air +`air_characteristic_impedance` `rho c`); all default to `None` so a +hand-built matrix (for example [`air_layer_transfer_matrix`](/phonometry/reference/api/materials/impedance-tube/#air_layer_transfer_matrix)) is +unchanged. + ### TransferMatrix.absorption_hard_backed() ```python @@ -746,8 +852,8 @@ Propagation wavenumber inside the material (ASTM E2611-19, Eq. (29)). ```python TransferMatrix.plot( - frequency: ArrayLike, - characteristic_impedance: float, + frequency: ArrayLike | None = None, + characteristic_impedance: float | None = None, ax: Axes | None = None, *, language: str = 'en', @@ -761,10 +867,13 @@ Reads the four-pole entries out as the two ASTM E2611-19 spectra a laboratory quotes: the normal-incidence transmission loss `TLn(f)` (Eq. (26), the primary curve, left axis) and the hard-backed absorption coefficient `alpha(f)` (Eq. (28), a muted companion on a -0..1 right axis). The matrix itself carries no frequency axis, so the -`frequency` vector of the measurement (matching the shape of the -entries) and the air characteristic impedance `rho c` must be -supplied. +0..1 right axis). The four-pole entries carry no frequency axis of +their own, so the plot needs the measurement's `frequency` vector +(matching the shape of the entries) and the air characteristic +impedance `rho c`. A matrix built by the solvers retains both +(`self.frequency` / `self.air_characteristic_impedance`), so +`plot()` takes no arguments there; only a hand-built matrix (for +example [`air_layer_transfer_matrix`](/phonometry/reference/api/materials/impedance-tube/#air_layer_transfer_matrix)) must supply them. Requires matplotlib (`pip install phonometry[plot]`); returns the `Axes` of the transmission-loss curve. @@ -773,14 +882,20 @@ Requires matplotlib (`pip install phonometry[plot]`); returns the | Name | Description | | :--- | :--- | -| `frequency` | Frequency vector `f`, in hertz, matching the shape of the matrix entries. | -| `characteristic_impedance` | Characteristic impedance `rho c` of the air in the tube, in rayls. | +| `frequency` | Frequency vector `f`, in hertz, matching the shape of the matrix entries; `None` uses the stored `frequency`. | +| `characteristic_impedance` | Characteristic impedance `rho c` of the air in the tube, in rayls; `None` uses the stored `air_characteristic_impedance`. | | `ax` | Existing axes, or `None` to create a figure. | | `language` | Plot language: `"en"` (default) or `"es"`. | | `kwargs` | Forwarded to the transmission-loss `plot` call. | **Returns:** The axes. +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If `frequency` or `characteristic_impedance` is neither supplied nor stored on the matrix. | + ### TransferMatrix.reflection_hard_backed() ```python @@ -843,7 +958,7 @@ porous-wall and object losses and is therefore a lower limit (Clause A.2.1.5). | :--- | :--- | | `frequency` | Frequency `f`, in hertz (scalar or per band). | | `speed_of_sound` | Speed of sound `c0`, in metres per second. | -| `diameter` | Circular-tube diameter `d`, in metres, or the hydraulic diameter `4 * area / perimeter` for a rectangular tube. | +| `diameter` | Circular-tube diameter `d`, in metres, or the hydraulic diameter `4 * area / perimeter` for a rectangular tube (see [`hydraulic_diameter`](/phonometry/reference/api/materials/impedance-tube/#hydraulic_diameter)). | **Returns:** Attenuation constant `k0''`, in nepers per metre. @@ -911,9 +1026,9 @@ supplied, frequencies outside the plane-wave range (Eqs. (1)-(4)) raise an | `characteristic_impedance` | Characteristic impedance `rho c0`, in rayls. | | `attenuation` | Optional tube attenuation constant `k0''`, in nepers/m (see [`tube_attenuation_constant`](/phonometry/reference/api/materials/impedance-tube/#tube_attenuation_constant)). | | `diameter` | Optional tube diameter/lateral dimension, in metres, that activates the plane-wave range check. | -| `shape` | Tube cross-section, `"circular"` or `"rectangular"`. | +| `shape` | Tube cross-section, `"circular"`, `"rectangular"` or `"square"`. | -**Returns:** An [`ImpedanceTubeResult`](/phonometry/reference/api/materials/impedance-tube/#impedancetuberesult). +**Returns:** An [`ImpedanceTubeResult`](/phonometry/reference/api/materials/impedance-tube/#impedancetuberesult) (the tube geometry is retained on the result). ## wave_decomposition @@ -929,6 +1044,8 @@ wave_decomposition( l2: float, s2: float, wavenumber: ArrayLike, + diameter: float | None = None, + shape: str = 'circular', ) -> tuple[Complex, Complex, Complex, Complex] ``` @@ -966,5 +1083,7 @@ air-layer transfer matrix (see [`air_layer_transfer_matrix`](/phonometry/referen | `l2` | Distance `l2` from the front reference plane, in metres. | | `s2` | Downstream microphone spacing `s2`, in metres. | | `wavenumber` | Air wavenumber `k` (real or complex), scalar or per band. | +| `diameter` | Optional tube diameter (circular) or largest section dimension (rectangular/square), in metres, that activates the plane-wave working-range check (6.2.3-6.2.5, 6.5.4). | +| `shape` | Tube cross-section, `"circular"`, `"rectangular"` or `"square"`. | **Returns:** Tuple `(A, B, C, D)` of complex amplitudes. diff --git a/src/phonometry/__init__.py b/src/phonometry/__init__.py index 7a7714d6d..c34ca7c3a 100644 --- a/src/phonometry/__init__.py +++ b/src/phonometry/__init__.py @@ -523,10 +523,12 @@ apply_mic_calibration, characteristic_impedance, face_quantities, + hydraulic_diameter, mic_calibration_factor, normalized_surface_admittance, normalized_surface_impedance, plane_wave_frequency_range, + plane_wave_frequency_range_astm, reflection_factor, speed_of_sound_astm, speed_of_sound_iso, @@ -1596,6 +1598,7 @@ "helmholtz_resonator_impedance", "hemisphere_source_level", "htlan", + "hydraulic_diameter", "hydrophone_depths", "idle_channel_noise", "image_source_rir", @@ -1764,6 +1767,7 @@ "piston_resistance", "piston_volume_flow_rate", "plane_wave_frequency_range", + "plane_wave_frequency_range_astm", "plate_bending_stiffness", "plate_loss_factor", "plenum_attenuation", diff --git a/src/phonometry/_report/_i18n.py b/src/phonometry/_report/_i18n.py index 07f374c0a..bf91f61d5 100644 --- a/src/phonometry/_report/_i18n.py +++ b/src/phonometry/_report/_i18n.py @@ -192,6 +192,10 @@ "{standard} normal-incidence measurement of sound absorption and surface impedance in an impedance tube by the two-microphone transfer-function method per ISO 10534-2:2001.": "{standard} medición a incidencia normal de la absorción acústica y la impedancia superficial en tubo de impedancia por el método de la función de transferencia con dos micrófonos según ISO 10534-2:2001.", "Normal-incidence measurement of sound absorption and surface impedance in an impedance tube by the two-microphone transfer-function method per ISO 10534-2:2001.": "Medición a incidencia normal de la absorción acústica y la impedancia superficial en tubo de impedancia por el método de la función de transferencia con dos micrófonos según ISO 10534-2:2001.", "Tube diameter d [mm]": "Diámetro del tubo d [mm]", + "Tube cross-section": "Sección transversal del tubo", + "Circular": "Circular", + "Rectangular": "Rectangular", + "Square": "Cuadrada", "Microphone spacing s [mm]": "Separación de micrófonos s [mm]", "Frequency range [Hz]": "Rango de frecuencias [Hz]", "Normal-incidence α and normalised surface impedance z": "α a incidencia normal e impedancia superficial normalizada z", diff --git a/src/phonometry/_report/iso10534.py b/src/phonometry/_report/iso10534.py index a521826ad..a79f4d415 100644 --- a/src/phonometry/_report/iso10534.py +++ b/src/phonometry/_report/iso10534.py @@ -59,6 +59,13 @@ if TYPE_CHECKING: from ..materials.impedance_tube import ImpedanceTubeResult +#: Printable label for each accepted ``ReportMetadata.tube_shape`` value. +_SHAPE_LABELS = { + "circular": "Circular", + "rectangular": "Rectangular", + "square": "Square", +} + def _a2(value: float, language: str = "en") -> str: """Two decimals (``alpha``, ``|r|`` and the normalised impedance parts).""" @@ -88,6 +95,7 @@ def _md(name: str) -> Any: specimen = _md("specimen") tube_diameter = _md("tube_diameter") mic_spacing = _md("mic_spacing") + tube_shape = _md("tube_shape") mounting = _md("mounting") test_room = _md("test_room") test_date = _md("test_date") @@ -108,6 +116,9 @@ def _md(name: str) -> Any: (t("Tube diameter d [mm]", language), fmt_meta(tube_diameter * 1e3, language) if tube_diameter is not None else None), + (t("Tube cross-section", language), + t(_SHAPE_LABELS[tube_shape], language) + if tube_shape is not None else None), (t("Microphone spacing s [mm]", language), fmt_meta(mic_spacing * 1e3, language) if mic_spacing is not None else None), diff --git a/src/phonometry/_report/metadata.py b/src/phonometry/_report/metadata.py index 4d10668e8..b91d74b09 100644 --- a/src/phonometry/_report/metadata.py +++ b/src/phonometry/_report/metadata.py @@ -81,6 +81,10 @@ class ReportMetadata: positions of the impedance tube, in metres. Printed by the impedance-tube fiche (ISO 10534-2), where it bounds the working frequency range. + :ivar tube_shape: Cross-section of the impedance tube: ``"circular"``, + ``"rectangular"`` or ``"square"``. Printed by the impedance-tube fiche + (ISO 10534-2) next to the tube diameter, which it qualifies (inner + diameter for a circular tube, maximum lateral dimension otherwise). :ivar thickness: Specimen thickness under the applied static load, in metres. Printed by the dynamic-stiffness fiche (EN 29052-1 / ISO 9052-1), where EN 29052-1:1992 Clause 9 b) requires reporting the @@ -113,9 +117,10 @@ class 0 is the strictest. When supplied, the fiche's verdict passes if :ivar notes: Free-form remarks printed in the footer. :raises ValueError: If a supplied dimension/mass/volume/pressure is not finite and strictly positive, a temperature or requirement is not - finite, a relative humidity is outside 0..100 %, or a required class is - not one of 0, 1, 2, or a position count is not a finite, positive - integer. + finite, a relative humidity is outside 0..100 %, a required class is + not one of 0, 1, 2, a position count is not a finite, positive + integer, or a tube shape is not one of ``"circular"``, + ``"rectangular"``, ``"square"``. """ specimen: str | None = None @@ -151,6 +156,9 @@ class 0 is the strictest. When supplied, the fiche's verdict passes if requirement: float | None = None required_class: int | None = None notes: str | None = None + # Appended after the original fields so the positional constructor order + # of this public dataclass is preserved. + tube_shape: str | None = None #: Numeric fields that must be finite and strictly positive. _POSITIVE_FIELDS = ( @@ -232,6 +240,13 @@ def __post_init__(self) -> None: "ReportMetadata.required_class must be 0, 1 or 2 when given; " f"got {self.required_class!r}." ) + if self.tube_shape is not None and self.tube_shape not in ( + "circular", "rectangular", "square" + ): + raise ValueError( + "ReportMetadata.tube_shape must be 'circular', 'rectangular' " + f"or 'square' when given; got {self.tube_shape!r}." + ) def is_empty(self) -> bool: """Return ``True`` when no field is set (an all-``None`` instance).""" diff --git a/src/phonometry/materials/__init__.py b/src/phonometry/materials/__init__.py index e5c695d95..3fd697c2e 100644 --- a/src/phonometry/materials/__init__.py +++ b/src/phonometry/materials/__init__.py @@ -63,10 +63,12 @@ apply_mic_calibration, characteristic_impedance, face_quantities, + hydraulic_diameter, mic_calibration_factor, normalized_surface_admittance, normalized_surface_impedance, plane_wave_frequency_range, + plane_wave_frequency_range_astm, reflection_factor, speed_of_sound_astm, speed_of_sound_iso, @@ -270,6 +272,7 @@ "geometric_spreading_factor_angle", "helmholtz_resonance_frequency", "helmholtz_resonator_impedance", + "hydraulic_diameter", "insitu_absorption_coefficient", "insitu_absorption_from_reflection", "insitu_absorption_spectrum", @@ -295,6 +298,7 @@ "perforation_end_correction", "piston_volume_flow_rate", "plane_wave_frequency_range", + "plane_wave_frequency_range_astm", "power_reflection_coefficient", "practical_absorption_coefficient", "practical_coefficient_uncertainty", diff --git a/src/phonometry/materials/impedance_tube.py b/src/phonometry/materials/impedance_tube.py index f6bd9761e..1e4f47189 100644 --- a/src/phonometry/materials/impedance_tube.py +++ b/src/phonometry/materials/impedance_tube.py @@ -85,6 +85,31 @@ #: i.e. ``f_l = c0 / (20 s)``. _ISO_LOWER_WAVELENGTH_FRACTION = 20.0 +#: Upper-frequency plane-wave factor ``K``, circular tube (ASTM E2611-19, +#: 6.2.4.1, Eq. (2)). +_ASTM_KU_CIRCULAR = 0.586 +#: Upper-frequency plane-wave factor ``K``, rectangular tube with ``d`` the +#: largest section dimension (ASTM E2611-19, 6.2.5). +_ASTM_KU_RECTANGULAR = 0.500 +#: Microphone-spacing factor for the upper limit: ``s`` no larger than 80 % of +#: ``c / (2 f_u)``, i.e. ``f_u s < 0,40 c`` (ASTM E2611-19, 6.5.4). +_ASTM_KU_SPACING = 0.40 +#: Lower-limit factor: spacing shall be greater than 1 % of the wavelength +#: (ASTM E2611-19, 6.2.3), i.e. ``f_l = c / (100 s)``. +_ASTM_LOWER_WAVELENGTH_FRACTION = 100.0 + +#: Shared validation message for the tube diameter arguments. +_DIAMETER_POSITIVE = "'diameter' must be positive." + +#: Aliases accepted for the tube cross-section ``shape`` arguments. A square +#: tube is the rectangular case with equal sides (ISO 10534-2, 4.1; +#: ASTM E2611-19, 6.2.5). +_SHAPE_ALIASES = { + "circular": "circular", + "rectangular": "rectangular", + "square": "rectangular", +} + __all__ = [ "ImpedanceTubeResult", "ImpedanceTubeWarning", @@ -96,10 +121,12 @@ "apply_mic_calibration", "characteristic_impedance", "face_quantities", + "hydraulic_diameter", "mic_calibration_factor", "normalized_surface_admittance", "normalized_surface_impedance", "plane_wave_frequency_range", + "plane_wave_frequency_range_astm", "reflection_factor", "speed_of_sound_astm", "speed_of_sound_iso", @@ -202,8 +229,36 @@ def air_density_astm( # --------------------------------------------------------------------------- -# Wavenumber and tube attenuation (ISO 10534-2). +# Cross-section helpers, wavenumber and tube attenuation (ISO 10534-2). # --------------------------------------------------------------------------- +def _canonical_shape(shape: str) -> str: + """Normalise a cross-section name to ``"circular"``/``"rectangular"``.""" + try: + return _SHAPE_ALIASES[shape] + except KeyError: + raise ValueError( + "'shape' must be 'circular', 'rectangular' or 'square'." + ) from None + + +def hydraulic_diameter(width: float, height: float) -> float: + """Hydraulic diameter of a rectangular tube, ``4 A / P`` (ISO 10534-2, A.2.1.5). + + For a rectangular cross-section of side lengths ``w`` and ``h`` the ratio + of four times the area to the perimeter reduces to + ``d_h = 2 w h / (w + h)``; a square tube gives ``d_h`` equal to the side + length. This is the ``d`` the Eq. (A.18) attenuation estimate expects for + rectangular tubes (see :func:`tube_attenuation_constant`). + + :param width: Inner side length ``w``, in metres. + :param height: Inner side length ``h``, in metres. + :return: Hydraulic diameter ``d_h = 4 A / P``, in metres. + """ + if width <= 0.0 or height <= 0.0: + raise ValueError("'width' and 'height' must be positive.") + return float(2.0 * width * height / (width + height)) + + def tube_attenuation_constant( frequency: ArrayLike, speed_of_sound: float, diameter: float ) -> Real: @@ -215,13 +270,14 @@ def tube_attenuation_constant( :param frequency: Frequency ``f``, in hertz (scalar or per band). :param speed_of_sound: Speed of sound ``c0``, in metres per second. :param diameter: Circular-tube diameter ``d``, in metres, or the hydraulic - diameter ``4 * area / perimeter`` for a rectangular tube. + diameter ``4 * area / perimeter`` for a rectangular tube (see + :func:`hydraulic_diameter`). :return: Attenuation constant ``k0''``, in nepers per metre. """ if speed_of_sound <= 0.0: raise ValueError("'speed_of_sound' must be positive.") if diameter <= 0.0: - raise ValueError("'diameter' must be positive.") + raise ValueError(_DIAMETER_POSITIVE) f = np.asarray(frequency, dtype=np.float64) if np.any(f < 0.0): raise ValueError("'frequency' must be non-negative.") @@ -414,25 +470,89 @@ def plane_wave_frequency_range( :param spacing: Microphone spacing ``s``, in metres. :param speed_of_sound: Speed of sound ``c0``, in metres per second. :param diameter: Tube diameter (circular) or maximum lateral dimension - (rectangular) ``d``, in metres; ``None`` applies only the spacing bound. - :param shape: ``"circular"`` or ``"rectangular"``. + (rectangular/square) ``d``, in metres; ``None`` applies only the + spacing bound. + :param shape: ``"circular"``, ``"rectangular"`` or ``"square"`` (a square + tube is the rectangular bound with ``d`` the side length). :return: Tuple ``(f_l, f_u)`` of the lower and upper frequency limits, in Hz. """ + return _frequency_range( + spacing, + speed_of_sound, + diameter=diameter, + shape=shape, + ku_circular=_ISO_KU_CIRCULAR, + ku_rectangular=_ISO_KU_RECTANGULAR, + ku_spacing=_ISO_KU_SPACING, + lower_fraction=_ISO_LOWER_WAVELENGTH_FRACTION, + ) + + +def plane_wave_frequency_range_astm( + spacing: float, + speed_of_sound: float, + *, + diameter: float | None = None, + shape: str = "circular", +) -> tuple[float, float]: + """Working plane-wave frequency range ``(f_l, f_u)`` (ASTM E2611-19). + + The upper limit is the smaller of the microphone-spacing bound + ``s <= 0,8 c / (2 f_u)``, i.e. ``f_u s < 0,40 c`` (6.5.4), and, when the + tube ``diameter`` is given, the cut-on bound ``f_u < K c / d`` with + ``K = 0,586`` for a circular tube (6.2.4.1, Eq. (2)) or ``K = 0,500`` for + a rectangular tube with ``d`` the largest section dimension (6.2.5). The + lower limit follows 6.2.3: the spacing shall be greater than 1 % of the + wavelength, i.e. ``f_l = c / (100 s)``. + + With two different spacings ``s1``/``s2``, call with the larger one for + the upper bound and the smaller one for the lower bound (each bound is + binding for every microphone pair). + + :param spacing: Microphone spacing ``s``, in metres. + :param speed_of_sound: Speed of sound ``c``, in metres per second. + :param diameter: Tube diameter (circular) or largest section dimension + (rectangular/square) ``d``, in metres; ``None`` applies only the + spacing bound. + :param shape: ``"circular"``, ``"rectangular"`` or ``"square"``. + :return: Tuple ``(f_l, f_u)`` of the lower and upper frequency limits, in Hz. + """ + return _frequency_range( + spacing, + speed_of_sound, + diameter=diameter, + shape=shape, + ku_circular=_ASTM_KU_CIRCULAR, + ku_rectangular=_ASTM_KU_RECTANGULAR, + ku_spacing=_ASTM_KU_SPACING, + lower_fraction=_ASTM_LOWER_WAVELENGTH_FRACTION, + ) + + +def _frequency_range( + spacing: float, + speed_of_sound: float, + *, + diameter: float | None, + shape: str, + ku_circular: float, + ku_rectangular: float, + ku_spacing: float, + lower_fraction: float, +) -> tuple[float, float]: + """Shared ISO/ASTM plane-wave frequency-range arithmetic.""" if spacing <= 0.0: raise ValueError("'spacing' must be positive.") if speed_of_sound <= 0.0: raise ValueError("'speed_of_sound' must be positive.") - if shape not in ("circular", "rectangular"): - raise ValueError("'shape' must be 'circular' or 'rectangular'.") - f_upper = _ISO_KU_SPACING * speed_of_sound / spacing + canonical = _canonical_shape(shape) + f_upper = ku_spacing * speed_of_sound / spacing if diameter is not None: if diameter <= 0.0: - raise ValueError("'diameter' must be positive.") - factor = ( - _ISO_KU_CIRCULAR if shape == "circular" else _ISO_KU_RECTANGULAR - ) + raise ValueError(_DIAMETER_POSITIVE) + factor = ku_circular if canonical == "circular" else ku_rectangular f_upper = min(f_upper, factor * speed_of_sound / diameter) - f_lower = speed_of_sound / (_ISO_LOWER_WAVELENGTH_FRACTION * spacing) + f_lower = speed_of_sound / (lower_fraction * spacing) return f_lower, f_upper @@ -464,6 +584,13 @@ class ImpedanceTubeResult: (Eq. (19)), ``normalized_impedance`` the ratio ``Z / (rho c0)`` (Eq. (19)) and ``absorption`` the normal-incidence coefficient ``alpha = 1 - |r|^2`` (Eq. (18)). + + The trailing fields retain the tube geometry the reduction was run with + (microphone ``spacing`` ``s``, distance ``x1`` from the sample to the + farther microphone, tube ``diameter`` and cross-section ``shape``, stored + canonically as ``"circular"``/``"rectangular"`` - a ``"square"`` input is + kept as ``"rectangular"``); they default to ``None`` when not supplied to + :func:`two_microphone_impedance`. """ frequency: Real @@ -471,6 +598,10 @@ class ImpedanceTubeResult: surface_impedance: Complex normalized_impedance: Complex absorption: Real + spacing: float | None = None + x1: float | None = None + diameter: float | None = None + shape: str | None = None def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) -> Axes: """Plot the absorption spectrum ``alpha(f)`` with ``|r|`` overlaid. @@ -513,7 +644,8 @@ def report( produces a body-and-disclaimer fiche whose header shows only the measured frequency range. The applicable descriptive/geometric fields are ``client``, ``manufacturer``, ``specimen``, - ``tube_diameter``, ``mic_spacing``, ``mounting``, ``test_room``, + ``tube_diameter``, ``tube_shape``, ``mic_spacing``, ``mounting``, + ``test_room``, ``test_date``, ``temperature``, ``pressure``, ``measurement_standard``, ``laboratory``, ``operator``, ``report_id`` and ``notes``. The ``requirement`` field is ignored @@ -574,15 +706,18 @@ def two_microphone_impedance( nepers/m (see :func:`tube_attenuation_constant`). :param diameter: Optional tube diameter/lateral dimension, in metres, that activates the plane-wave range check. - :param shape: Tube cross-section, ``"circular"`` or ``"rectangular"``. - :return: An :class:`ImpedanceTubeResult`. + :param shape: Tube cross-section, ``"circular"``, ``"rectangular"`` or + ``"square"``. + :return: An :class:`ImpedanceTubeResult` (the tube geometry is retained on + the result). """ f = np.asarray(frequency, dtype=np.float64) k0 = tube_wavenumber(f, speed_of_sound, attenuation=attenuation) r = reflection_factor(h12, spacing=spacing, x1=x1, wavenumber=k0) + canonical = _canonical_shape(shape) if diameter is not None: f_lower, f_upper = plane_wave_frequency_range( - spacing, speed_of_sound, diameter=diameter, shape=shape + spacing, speed_of_sound, diameter=diameter, shape=canonical ) _warn_frequency_range(f, f_lower, f_upper, stacklevel=2) return ImpedanceTubeResult( @@ -591,6 +726,10 @@ def two_microphone_impedance( surface_impedance=surface_impedance(r, characteristic_impedance), normalized_impedance=normalized_surface_impedance(r), absorption=absorption_from_reflection(r), + spacing=spacing, + x1=x1, + diameter=diameter, + shape=canonical if diameter is not None else None, ) @@ -698,6 +837,46 @@ def standing_wave_normalized_impedance( # --------------------------------------------------------------------------- # ASTM E2611-19: four-microphone transfer-matrix method. # --------------------------------------------------------------------------- +def _warn_astm_plane_wave( + wavenumber: ArrayLike, + *, + s1: float, + s2: float, + diameter: float, + shape: str, + stacklevel: int, +) -> None: + """Advise when wavenumbers leave the ASTM E2611-19 plane-wave range. + + The check runs on the real part of ``k`` so no speed of sound is needed: + ``f s < 0,40 c`` maps to ``k s < 0,80 pi`` (6.5.4), ``f d < K c`` to + ``k d < 2 pi K`` (6.2.4.1/6.2.5) and the greater-than-1 %-of-wavelength + spacing bound to ``k s > 0,02 pi`` (6.2.3). The upper spacing bound binds + every microphone pair (largest spacing), the lower one the smallest. + """ + if s1 <= 0.0 or s2 <= 0.0: + raise ValueError("'s1' and 's2' must be positive.") + if diameter <= 0.0: + raise ValueError(_DIAMETER_POSITIVE) + k = np.real(np.asarray(wavenumber, dtype=np.complex128)) + ku = _ASTM_KU_CIRCULAR if shape == "circular" else _ASTM_KU_RECTANGULAR + two_pi = 2.0 * np.pi + k_upper = min( + two_pi * _ASTM_KU_SPACING / max(s1, s2), + two_pi * ku / diameter, + ) + k_lower = two_pi / (_ASTM_LOWER_WAVELENGTH_FRACTION * min(s1, s2)) + if np.any(k < k_lower) or np.any(k > k_upper): + warnings.warn( + "Wavenumbers outside the ASTM E2611-19 plane-wave working range " + "(6.2.3-6.2.5, 6.5.4) for the given microphone spacings and tube " + "cross-section; results there are advisory. See " + "plane_wave_frequency_range_astm() for the limits in hertz.", + ImpedanceTubeWarning, + stacklevel=stacklevel + 1, + ) + + def wave_decomposition( h1: ArrayLike, h2: ArrayLike, @@ -709,6 +888,8 @@ def wave_decomposition( l2: float, s2: float, wavenumber: ArrayLike, + diameter: float | None = None, + shape: str = "circular", ) -> tuple[Complex, Complex, Complex, Complex]: """Decompose the wave field into ``(A, B, C, D)`` (ASTM E2611-19, Eqs. (17)-(20)). @@ -738,10 +919,21 @@ def wave_decomposition( :param l2: Distance ``l2`` from the front reference plane, in metres. :param s2: Downstream microphone spacing ``s2``, in metres. :param wavenumber: Air wavenumber ``k`` (real or complex), scalar or per band. + :param diameter: Optional tube diameter (circular) or largest section + dimension (rectangular/square), in metres, that activates the + plane-wave working-range check (6.2.3-6.2.5, 6.5.4). + :param shape: Tube cross-section, ``"circular"``, ``"rectangular"`` or + ``"square"``. :return: Tuple ``(A, B, C, D)`` of complex amplitudes. """ if s1 <= 0.0 or s2 <= 0.0: raise ValueError("'s1' and 's2' must be positive.") + canonical = _canonical_shape(shape) + if diameter is not None: + _warn_astm_plane_wave( + wavenumber, s1=s1, s2=s2, diameter=diameter, shape=canonical, + stacklevel=2, + ) ha = np.asarray(h1, dtype=np.complex128) hb = np.asarray(h2, dtype=np.complex128) hc = np.asarray(h3, dtype=np.complex128) @@ -813,12 +1005,30 @@ class TransferMatrix: Relates the pressure and normal particle velocity across a specimen, ``[p; u]_{x=0} = T [p; u]_{x=d}`` (Eq. (16)). Each entry is complex and may be scalar or a per-frequency array of matching shape. + + The trailing fields retain the measurement context when the matrix comes + out of :func:`transfer_matrix_two_load` / :func:`transfer_matrix_one_load` + (tube geometry ``l1``/``s1``/``l2``/``s2``, specimen ``thickness``, tube + ``diameter`` and canonical cross-section ``shape``, the ``frequency`` + vector when supplied to the solver, and the air + ``air_characteristic_impedance`` ``rho c``); all default to ``None`` so a + hand-built matrix (for example :func:`air_layer_transfer_matrix`) is + unchanged. """ t11: Complex t12: Complex t21: Complex t22: Complex + l1: float | None = None + s1: float | None = None + l2: float | None = None + s2: float | None = None + thickness: float | None = None + diameter: float | None = None + shape: str | None = None + frequency: Real | None = None + air_characteristic_impedance: float | None = None def determinant(self) -> Complex: """Determinant ``T11 T22 - T12 T21`` (unity for a reciprocal specimen).""" @@ -896,8 +1106,8 @@ def characteristic_impedance_material(self) -> Complex: def plot( self, - frequency: ArrayLike, - characteristic_impedance: float, + frequency: ArrayLike | None = None, + characteristic_impedance: float | None = None, ax: Axes | None = None, *, language: str = "en", @@ -909,23 +1119,38 @@ def plot( laboratory quotes: the normal-incidence transmission loss ``TLn(f)`` (Eq. (26), the primary curve, left axis) and the hard-backed absorption coefficient ``alpha(f)`` (Eq. (28), a muted companion on a - 0..1 right axis). The matrix itself carries no frequency axis, so the - ``frequency`` vector of the measurement (matching the shape of the - entries) and the air characteristic impedance ``rho c`` must be - supplied. + 0..1 right axis). The four-pole entries carry no frequency axis of + their own, so the plot needs the measurement's ``frequency`` vector + (matching the shape of the entries) and the air characteristic + impedance ``rho c``. A matrix built by the solvers retains both + (``self.frequency`` / ``self.air_characteristic_impedance``), so + ``plot()`` takes no arguments there; only a hand-built matrix (for + example :func:`air_layer_transfer_matrix`) must supply them. Requires matplotlib (``pip install phonometry[plot]``); returns the :class:`~matplotlib.axes.Axes` of the transmission-loss curve. :param frequency: Frequency vector ``f``, in hertz, matching the shape - of the matrix entries. + of the matrix entries; ``None`` uses the stored ``frequency``. :param characteristic_impedance: Characteristic impedance ``rho c`` of - the air in the tube, in rayls. + the air in the tube, in rayls; ``None`` uses the stored + ``air_characteristic_impedance``. :param ax: Existing axes, or ``None`` to create a figure. :param language: Plot language: ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the transmission-loss ``plot`` call. :return: The axes. + :raises ValueError: If ``frequency`` or ``characteristic_impedance`` + is neither supplied nor stored on the matrix. """ + if frequency is None: + frequency = self.frequency + if characteristic_impedance is None: + characteristic_impedance = self.air_characteristic_impedance + if frequency is None or characteristic_impedance is None: + raise ValueError( + "'frequency' and 'characteristic_impedance' must be supplied " + "when the matrix does not retain them (hand-built matrices)." + ) from .._i18n import check_language from .._plot.materials import plot_transfer_matrix @@ -1017,6 +1242,35 @@ def _warn_ill_conditioned( ) +def _measurement_context( + *, + l1: float, + s1: float, + l2: float, + s2: float, + thickness: float, + diameter: float | None, + shape: str, + frequency: ArrayLike | None, + characteristic_impedance: float, +) -> dict[str, Any]: + """Context fields a solver retains on the :class:`TransferMatrix`.""" + return { + "l1": l1, + "s1": s1, + "l2": l2, + "s2": s2, + "thickness": thickness, + "diameter": diameter, + "shape": shape if diameter is not None else None, + "frequency": ( + np.asarray(frequency, dtype=np.float64) + if frequency is not None else None + ), + "air_characteristic_impedance": characteristic_impedance, + } + + def transfer_matrix_two_load( load_a: tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike], load_b: tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike], @@ -1028,6 +1282,9 @@ def transfer_matrix_two_load( thickness: float, wavenumber: ArrayLike, characteristic_impedance: float, + frequency: ArrayLike | None = None, + diameter: float | None = None, + shape: str = "circular", ) -> TransferMatrix: """Two-load transfer matrix (ASTM E2611-19, Eqs. (17)-(22)). @@ -1050,8 +1307,22 @@ def transfer_matrix_two_load( :param thickness: Specimen thickness ``d``, in metres. :param wavenumber: Air wavenumber ``k``. :param characteristic_impedance: Characteristic impedance ``rho c``. - :return: The specimen :class:`TransferMatrix`. + :param frequency: Optional frequency vector ``f``, in hertz, retained on + the result so :meth:`TransferMatrix.plot` needs no arguments. + :param diameter: Optional tube diameter (circular) or largest section + dimension (rectangular/square), in metres, that activates the + plane-wave working-range check (6.2.3-6.2.5, 6.5.4). + :param shape: Tube cross-section, ``"circular"``, ``"rectangular"`` or + ``"square"``. + :return: The specimen :class:`TransferMatrix` (measurement context + retained on the result). """ + canonical = _canonical_shape(shape) + if diameter is not None: + _warn_astm_plane_wave( + wavenumber, s1=s1, s2=s2, diameter=diameter, shape=canonical, + stacklevel=2, + ) p0a, pda, u0a, uda = _face_from_loads( load_a, l1=l1, s1=s1, l2=l2, s2=s2, thickness=thickness, wavenumber=wavenumber, characteristic_impedance=characteristic_impedance, @@ -1071,6 +1342,11 @@ def transfer_matrix_two_load( t12=np.asarray((p0b * pda - p0a * pdb) / den, dtype=np.complex128), t21=np.asarray((u0a * udb - u0b * uda) / den, dtype=np.complex128), t22=np.asarray((pda * u0b - pdb * u0a) / den, dtype=np.complex128), + **_measurement_context( + l1=l1, s1=s1, l2=l2, s2=s2, thickness=thickness, + diameter=diameter, shape=canonical, frequency=frequency, + characteristic_impedance=characteristic_impedance, + ), ) @@ -1084,6 +1360,9 @@ def transfer_matrix_one_load( thickness: float, wavenumber: ArrayLike, characteristic_impedance: float, + frequency: ArrayLike | None = None, + diameter: float | None = None, + shape: str = "circular", ) -> TransferMatrix: """One-load transfer matrix, symmetric specimen (ASTM E2611-19, Eqs. (23)-(24)). @@ -1103,8 +1382,22 @@ def transfer_matrix_one_load( :param thickness: Specimen thickness ``d``, in metres. :param wavenumber: Air wavenumber ``k``. :param characteristic_impedance: Characteristic impedance ``rho c``. - :return: The specimen :class:`TransferMatrix`. + :param frequency: Optional frequency vector ``f``, in hertz, retained on + the result so :meth:`TransferMatrix.plot` needs no arguments. + :param diameter: Optional tube diameter (circular) or largest section + dimension (rectangular/square), in metres, that activates the + plane-wave working-range check (6.2.3-6.2.5, 6.5.4). + :param shape: Tube cross-section, ``"circular"``, ``"rectangular"`` or + ``"square"``. + :return: The specimen :class:`TransferMatrix` (measurement context + retained on the result). """ + canonical = _canonical_shape(shape) + if diameter is not None: + _warn_astm_plane_wave( + wavenumber, s1=s1, s2=s2, diameter=diameter, shape=canonical, + stacklevel=2, + ) p0, pd, u0, ud = _face_from_loads( load, l1=l1, s1=s1, l2=l2, s2=s2, thickness=thickness, wavenumber=wavenumber, characteristic_impedance=characteristic_impedance, @@ -1121,6 +1414,11 @@ def transfer_matrix_one_load( t12=np.asarray((p0**2 - pd**2) / den, dtype=np.complex128), t21=np.asarray((u0**2 - ud**2) / den, dtype=np.complex128), t22=t_diag, + **_measurement_context( + l1=l1, s1=s1, l2=l2, s2=s2, thickness=thickness, + diameter=diameter, shape=canonical, frequency=frequency, + characteristic_impedance=characteristic_impedance, + ), ) diff --git a/tests/materials/test_impedance_tube.py b/tests/materials/test_impedance_tube.py index 4d62cf1f1..8261de20f 100644 --- a/tests/materials/test_impedance_tube.py +++ b/tests/materials/test_impedance_tube.py @@ -34,10 +34,12 @@ apply_mic_calibration, characteristic_impedance, face_quantities, + hydraulic_diameter, mic_calibration_factor, normalized_surface_admittance, normalized_surface_impedance, plane_wave_frequency_range, + plane_wave_frequency_range_astm, reflection_factor, speed_of_sound_astm, speed_of_sound_iso, @@ -248,6 +250,79 @@ def test_frequency_range_warning_fires() -> None: ) +def test_plane_wave_range_rectangular_bound() -> None: + # ISO 10534-2 Eq. (3): a rectangular tube cuts on at 0,50 c0 / d with d the + # maximum side length (stricter than the 0,58 circular factor of Eq. (2)). + f_lower, f_upper = plane_wave_frequency_range( + 0.03, C0, diameter=0.05, shape="rectangular" + ) + assert f_upper == pytest.approx(min(0.45 * C0 / 0.03, 0.50 * C0 / 0.05)) + assert f_lower == pytest.approx(C0 / (20.0 * 0.03)) + + +def test_plane_wave_range_square_alias() -> None: + # A square tube is the rectangular case with d the side length, in both + # the ISO 10534-2 and the ASTM E2611-19 bounds. + for fn in (plane_wave_frequency_range, plane_wave_frequency_range_astm): + square = fn(0.03, C0, diameter=0.05, shape="square") + rectangular = fn(0.03, C0, diameter=0.05, shape="rectangular") + assert square == rectangular + + +def test_plane_wave_range_invalid_shape() -> None: + with pytest.raises(ValueError, match="shape"): + plane_wave_frequency_range(0.03, C0, diameter=0.05, shape="oval") + with pytest.raises(ValueError, match="shape"): + plane_wave_frequency_range_astm(0.03, C0, diameter=0.05, shape="oval") + + +def test_plane_wave_range_astm_bounds() -> None: + # ASTM E2611-19: upper = min(0,40 c/s (6.5.4), 0,586 c/d circular + # (6.2.4.1) or 0,500 c/d rectangular (6.2.5)); lower = c / (100 s), the + # spacing greater than 1 % of the wavelength (6.2.3). + f_lower, f_upper = plane_wave_frequency_range_astm( + 0.03, C0, diameter=0.05, shape="circular" + ) + assert f_upper == pytest.approx(min(0.40 * C0 / 0.03, 0.586 * C0 / 0.05)) + assert f_lower == pytest.approx(C0 / (100.0 * 0.03)) + _, f_upper_rect = plane_wave_frequency_range_astm( + 0.03, C0, diameter=0.05, shape="rectangular" + ) + assert f_upper_rect == pytest.approx(min(0.40 * C0 / 0.03, 0.50 * C0 / 0.05)) + + +def test_hydraulic_diameter_values() -> None: + # d_h = 4 A / P = 2 w h / (w + h) (ISO 10534-2 A.2.1.5); a square tube + # gives the side length back. + assert hydraulic_diameter(0.05, 0.05) == pytest.approx(0.05) + assert hydraulic_diameter(0.08, 0.04) == pytest.approx( + 4.0 * (0.08 * 0.04) / (2.0 * (0.08 + 0.04)) + ) + with pytest.raises(ValueError, match="positive"): + hydraulic_diameter(0.0, 0.05) + + +def test_result_retains_tube_geometry() -> None: + f = np.array([800.0, 1200.0]) + h12 = _synth_h12(0.3 + 0.1j, np.asarray(tube_wavenumber(f, C0)), 0.12, 0.03) + res = two_microphone_impedance( + h12, frequency=f, spacing=0.03, x1=0.12, speed_of_sound=C0, + characteristic_impedance=RC, diameter=0.05, shape="square", + ) + assert res.spacing == pytest.approx(0.03) + assert res.x1 == pytest.approx(0.12) + assert res.diameter == pytest.approx(0.05) + # "square" is stored canonically as the rectangular case. + assert res.shape == "rectangular" + # Without a diameter no cross-section claim is retained. + res_no_d = two_microphone_impedance( + h12, frequency=f, spacing=0.03, x1=0.12, speed_of_sound=C0, + characteristic_impedance=RC, + ) + assert res_no_d.diameter is None + assert res_no_d.shape is None + + def test_frequency_range_no_warning_in_band() -> None: f = np.array([800.0, 1200.0]) h12 = _synth_h12(0.3 + 0.1j, np.asarray(tube_wavenumber(f, C0)), 0.12, 0.03) @@ -537,6 +612,99 @@ def test_one_load_recovers_symmetric_specimen() -> None: assert np.allclose(rec.t11, rec.t22) +def test_astm_solvers_warn_outside_plane_wave_range() -> None: + # With the tube diameter given, frequencies past the 6.2.4.1 cut-on + # (0,586 c/d for this 10 cm circular tube ~ 2011 Hz) must be flagged. + f = np.array([500.0, 2500.0]) + k = np.asarray(tube_wavenumber(f, C0)).real.astype(np.complex128) + tm = air_layer_transfer_matrix(k, THICKNESS, RC) + load_a = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, 0.3 + 0.0j) + load_b = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, -0.5 + 0.2j) + with pytest.warns(ImpedanceTubeWarning, match="ASTM E2611-19"): + transfer_matrix_two_load( + load_a, load_b, thickness=THICKNESS, wavenumber=k, + characteristic_impedance=RC, diameter=0.10, **GEOM, + ) + with pytest.warns(ImpedanceTubeWarning, match="ASTM E2611-19"): + transfer_matrix_one_load( + load_a, thickness=THICKNESS, wavenumber=k, + characteristic_impedance=RC, diameter=0.10, **GEOM, + ) + with pytest.warns(ImpedanceTubeWarning, match="ASTM E2611-19"): + wave_decomposition( + *load_a, wavenumber=k, diameter=0.10, **GEOM, + ) + + +def test_astm_solvers_warn_below_lower_bound() -> None: + # The 6.2.3 lower bound: spacing greater than 1 % of the wavelength. For + # s = 3 cm that is ~114 Hz; 50 Hz must be flagged. + f = np.array([50.0]) + k = np.asarray(tube_wavenumber(f, C0)).real.astype(np.complex128) + tm = air_layer_transfer_matrix(k, THICKNESS, RC) + load = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, 0.3 + 0.0j) + with pytest.warns(ImpedanceTubeWarning, match="ASTM E2611-19"): + wave_decomposition(*load, wavenumber=k, diameter=0.10, **GEOM) + + +def test_astm_solvers_silent_in_band_and_without_diameter() -> None: + import warnings as _w + + f = np.array([500.0, 1000.0, 1800.0]) + k = np.asarray(tube_wavenumber(f, C0)).real.astype(np.complex128) + tm = air_layer_transfer_matrix(k, THICKNESS, RC) + load_a = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, 0.3 + 0.0j) + load_b = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, -0.5 + 0.2j) + with _w.catch_warnings(): + _w.simplefilter("error", ImpedanceTubeWarning) + # In band with the diameter given (10 cm tube: 114 Hz .. 2011 Hz). + transfer_matrix_two_load( + load_a, load_b, thickness=THICKNESS, wavenumber=k, + characteristic_impedance=RC, diameter=0.10, **GEOM, + ) + # No diameter -> no range check, as in the ISO branch. + k_wide = np.asarray( + tube_wavenumber(np.array([50.0, 2500.0]), C0) + ).real.astype(np.complex128) + tm_wide = air_layer_transfer_matrix(k_wide, THICKNESS, RC) + wide_a = _synth_four_mics(tm_wide, np.asarray(k_wide), 1.0 + 0.0j, 0.3 + 0.0j) + wide_b = _synth_four_mics(tm_wide, np.asarray(k_wide), 0.4 + 0.5j, -0.6 + 0.1j) + transfer_matrix_two_load( + wide_a, wide_b, thickness=THICKNESS, wavenumber=k_wide, + characteristic_impedance=RC, **GEOM, + ) + + +def test_transfer_matrix_retains_measurement_context() -> None: + # 500..1500 Hz sits inside the square-tube working range (114..1716 Hz). + f = np.array([500.0, 1000.0, 1500.0]) + k = np.asarray(tube_wavenumber(f, C0)).real.astype(np.complex128) + tm = air_layer_transfer_matrix(k, THICKNESS, RC) + load_a = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, 0.3 + 0.0j) + load_b = _synth_four_mics(tm, np.asarray(k), 1.0 + 0.0j, -0.5 + 0.2j) + rec = transfer_matrix_two_load( + load_a, load_b, thickness=THICKNESS, wavenumber=k, + characteristic_impedance=RC, frequency=f, diameter=0.10, + shape="square", **GEOM, + ) + assert rec.l1 == pytest.approx(GEOM["l1"]) + assert rec.s1 == pytest.approx(GEOM["s1"]) + assert rec.l2 == pytest.approx(GEOM["l2"]) + assert rec.s2 == pytest.approx(GEOM["s2"]) + assert rec.thickness == pytest.approx(THICKNESS) + assert rec.diameter == pytest.approx(0.10) + assert rec.shape == "rectangular" + assert rec.frequency is not None + assert np.allclose(rec.frequency, f) + assert rec.air_characteristic_impedance == pytest.approx(RC) + # A hand-built matrix retains nothing and plot() demands the arguments. + bare = air_layer_transfer_matrix(k, THICKNESS, RC) + assert bare.frequency is None + assert bare.air_characteristic_impedance is None + with pytest.raises(ValueError, match="hand-built"): + bare.plot() + + def test_public_exports() -> None: import phonometry @@ -548,6 +716,7 @@ def test_public_exports() -> None: "air_density_iso", "air_density_astm", "tube_wavenumber", "tube_attenuation_constant", "mic_calibration_factor", "apply_mic_calibration", "two_microphone_impedance", "plane_wave_frequency_range", + "plane_wave_frequency_range_astm", "hydraulic_diameter", "standing_wave_absorption", "standing_wave_reflection", "standing_wave_reflection_magnitude", "standing_wave_ratio_from_level", "standing_wave_normalized_impedance", "wave_decomposition", "face_quantities",