diff --git a/.github/images/absorber_stack_geometry.svg b/.github/images/absorber_stack_geometry.svg new file mode 100644 index 000000000..f82cd44e9 --- /dev/null +++ b/.github/images/absorber_stack_geometry.svg @@ -0,0 +1,3872 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Microperforated plate, 1 mm + + + Air + + + Porous + + + Rigid backing + + + Incident sound + + + Layered absorber cross-section + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/absorber_stack_geometry_dark.svg b/.github/images/absorber_stack_geometry_dark.svg new file mode 100644 index 000000000..46d97e043 --- /dev/null +++ b/.github/images/absorber_stack_geometry_dark.svg @@ -0,0 +1,3872 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Microperforated plate, 1 mm + + + Air + + + Porous + + + Rigid backing + + + Incident sound + + + Layered absorber cross-section + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/absorber_stack_geometry_es.svg b/.github/images/absorber_stack_geometry_es.svg new file mode 100644 index 000000000..992d57525 --- /dev/null +++ b/.github/images/absorber_stack_geometry_es.svg @@ -0,0 +1,3872 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Placa microperforada, 1 mm + + + Aire + + + Poroso + + + Respaldo rígido + + + Sonido incidente + + + Sección del absorbente multicapa + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/absorber_stack_geometry_es_dark.svg b/.github/images/absorber_stack_geometry_es_dark.svg new file mode 100644 index 000000000..e271276ed --- /dev/null +++ b/.github/images/absorber_stack_geometry_es_dark.svg @@ -0,0 +1,3872 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Placa microperforada, 1 mm + + + Aire + + + Poroso + + + Respaldo rígido + + + Sonido incidente + + + Sección del absorbente multicapa + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/helmholtz_resonator_geometry.svg b/.github/images/helmholtz_resonator_geometry.svg new file mode 100644 index 000000000..c9aaf33b9 --- /dev/null +++ b/.github/images/helmholtz_resonator_geometry.svg @@ -0,0 +1,201 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + Helmholtz resonator cross-section + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 mm + + + 1 mm + + + 30 mm + + + 27 mm + + + + + + + + + + + + + + + diff --git a/.github/images/helmholtz_resonator_geometry_dark.svg b/.github/images/helmholtz_resonator_geometry_dark.svg new file mode 100644 index 000000000..6a9025635 --- /dev/null +++ b/.github/images/helmholtz_resonator_geometry_dark.svg @@ -0,0 +1,201 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + Helmholtz resonator cross-section + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 mm + + + 1 mm + + + 30 mm + + + 27 mm + + + + + + + + + + + + + + + diff --git a/.github/images/helmholtz_resonator_geometry_es.svg b/.github/images/helmholtz_resonator_geometry_es.svg new file mode 100644 index 000000000..6b915344e --- /dev/null +++ b/.github/images/helmholtz_resonator_geometry_es.svg @@ -0,0 +1,201 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + Sección del resonador de Helmholtz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 mm + + + 1 mm + + + 30 mm + + + 27 mm + + + + + + + + + + + + + + + diff --git a/.github/images/helmholtz_resonator_geometry_es_dark.svg b/.github/images/helmholtz_resonator_geometry_es_dark.svg new file mode 100644 index 000000000..90ce5a556 --- /dev/null +++ b/.github/images/helmholtz_resonator_geometry_es_dark.svg @@ -0,0 +1,201 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + Sección del resonador de Helmholtz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 mm + + + 1 mm + + + 30 mm + + + 27 mm + + + + + + + + + + + + + + + diff --git a/.github/images/impedance_tube_geometry.svg b/.github/images/impedance_tube_geometry.svg new file mode 100644 index 000000000..122681134 --- /dev/null +++ b/.github/images/impedance_tube_geometry.svg @@ -0,0 +1,3772 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cross-section + + + Sample + + + Loudspeaker + + + 1 + + + 2 + + + Plane-wave range 343 to 1991 Hz + + + Impedance tube (ISO 10534-2), to scale + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s = 50 mm + + + x1 = 150 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/impedance_tube_geometry_dark.svg b/.github/images/impedance_tube_geometry_dark.svg new file mode 100644 index 000000000..1a4d04fea --- /dev/null +++ b/.github/images/impedance_tube_geometry_dark.svg @@ -0,0 +1,3772 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cross-section + + + Sample + + + Loudspeaker + + + 1 + + + 2 + + + Plane-wave range 343 to 1991 Hz + + + Impedance tube (ISO 10534-2), to scale + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s = 50 mm + + + x1 = 150 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/impedance_tube_geometry_es.svg b/.github/images/impedance_tube_geometry_es.svg new file mode 100644 index 000000000..32fc39905 --- /dev/null +++ b/.github/images/impedance_tube_geometry_es.svg @@ -0,0 +1,3772 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sección transversal + + + Muestra + + + Altavoz + + + 1 + + + 2 + + + Rango de onda plana 343 a 1991 Hz + + + Tubo de impedancia (ISO 10534-2), a escala + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s = 50 mm + + + x1 = 150 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/impedance_tube_geometry_es_dark.svg b/.github/images/impedance_tube_geometry_es_dark.svg new file mode 100644 index 000000000..442d2ef64 --- /dev/null +++ b/.github/images/impedance_tube_geometry_es_dark.svg @@ -0,0 +1,3772 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sección transversal + + + Muestra + + + Altavoz + + + 1 + + + 2 + + + Rango de onda plana 343 a 1991 Hz + + + Tubo de impedancia (ISO 10534-2), a escala + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s = 50 mm + + + x1 = 150 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/qrd_geometry.svg b/.github/images/qrd_geometry.svg new file mode 100644 index 000000000..268b9153b --- /dev/null +++ b/.github/images/qrd_geometry.svg @@ -0,0 +1,349 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Incident sound + + + depth sequence 0, 50, 200, 100, 100, 200, 50 mm + + + QRD well profile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 80.7 mm + + + 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/qrd_geometry_dark.svg b/.github/images/qrd_geometry_dark.svg new file mode 100644 index 000000000..f80a32603 --- /dev/null +++ b/.github/images/qrd_geometry_dark.svg @@ -0,0 +1,349 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Incident sound + + + depth sequence 0, 50, 200, 100, 100, 200, 50 mm + + + QRD well profile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 80.7 mm + + + 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/qrd_geometry_es.svg b/.github/images/qrd_geometry_es.svg new file mode 100644 index 000000000..5bde6f99b --- /dev/null +++ b/.github/images/qrd_geometry_es.svg @@ -0,0 +1,349 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonido incidente + + + secuencia de profundidades 0, 50, 200, 100, 100, 200, 50 mm + + + Perfil de pozos del difusor QRD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 80,7 mm + + + 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/qrd_geometry_es_dark.svg b/.github/images/qrd_geometry_es_dark.svg new file mode 100644 index 000000000..1a76a9643 --- /dev/null +++ b/.github/images/qrd_geometry_es_dark.svg @@ -0,0 +1,349 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonido incidente + + + secuencia de profundidades 0, 50, 200, 100, 100, 200, 50 mm + + + Perfil de pozos del difusor QRD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 80,7 mm + + + 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/slit_absorber_geometry.svg b/.github/images/slit_absorber_geometry.svg new file mode 100644 index 000000000..ef00b9280 --- /dev/null +++ b/.github/images/slit_absorber_geometry.svg @@ -0,0 +1,242 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Slit + + + Incident sound + + + Slit metamaterial absorber cross-section (one period) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 1 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/slit_absorber_geometry_dark.svg b/.github/images/slit_absorber_geometry_dark.svg new file mode 100644 index 000000000..f25e8912a --- /dev/null +++ b/.github/images/slit_absorber_geometry_dark.svg @@ -0,0 +1,242 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Slit + + + Incident sound + + + Slit metamaterial absorber cross-section (one period) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 1 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/slit_absorber_geometry_es.svg b/.github/images/slit_absorber_geometry_es.svg new file mode 100644 index 000000000..cd151a295 --- /dev/null +++ b/.github/images/slit_absorber_geometry_es.svg @@ -0,0 +1,242 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rendija + + + Sonido incidente + + + Sección del absorbente metamaterial de rendija (un periodo) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 1 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/slit_absorber_geometry_es_dark.svg b/.github/images/slit_absorber_geometry_es_dark.svg new file mode 100644 index 000000000..64dc8042f --- /dev/null +++ b/.github/images/slit_absorber_geometry_es_dark.svg @@ -0,0 +1,242 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rendija + + + Sonido incidente + + + Sección del absorbente metamaterial de rendija (un periodo) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30 mm + + + 1 mm + + + 50 mm + + + + + + + + + + + + + + + + + + + diff --git a/.github/images/transmission_tube_geometry.svg b/.github/images/transmission_tube_geometry.svg new file mode 100644 index 000000000..d2ef541e3 --- /dev/null +++ b/.github/images/transmission_tube_geometry.svg @@ -0,0 +1,3821 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cross-section + + + Sample + + + Loudspeaker + + + Termination + + + 1 + + + 2 + + + 3 + + + 4 + + + Plane-wave range 69 to 2011 Hz + + + Transmission tube (ASTM E2611), to scale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s1 = 50 mm + + + s2 = 50 mm + + + l1 = 100 mm + + + l2 = 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/transmission_tube_geometry_dark.svg b/.github/images/transmission_tube_geometry_dark.svg new file mode 100644 index 000000000..b116c6d66 --- /dev/null +++ b/.github/images/transmission_tube_geometry_dark.svg @@ -0,0 +1,3821 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cross-section + + + Sample + + + Loudspeaker + + + Termination + + + 1 + + + 2 + + + 3 + + + 4 + + + Plane-wave range 69 to 2011 Hz + + + Transmission tube (ASTM E2611), to scale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s1 = 50 mm + + + s2 = 50 mm + + + l1 = 100 mm + + + l2 = 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/transmission_tube_geometry_es.svg b/.github/images/transmission_tube_geometry_es.svg new file mode 100644 index 000000000..2d4694f25 --- /dev/null +++ b/.github/images/transmission_tube_geometry_es.svg @@ -0,0 +1,3821 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sección transversal + + + Muestra + + + Altavoz + + + Terminación + + + 1 + + + 2 + + + 3 + + + 4 + + + Rango de onda plana 69 a 2011 Hz + + + Tubo de transmisión (ASTM E2611), a escala + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s1 = 50 mm + + + s2 = 50 mm + + + l1 = 100 mm + + + l2 = 200 mm + + + + + + + + + + + + + + + diff --git a/.github/images/transmission_tube_geometry_es_dark.svg b/.github/images/transmission_tube_geometry_es_dark.svg new file mode 100644 index 000000000..5c00332ef --- /dev/null +++ b/.github/images/transmission_tube_geometry_es_dark.svg @@ -0,0 +1,3821 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sección transversal + + + Muestra + + + Altavoz + + + Terminación + + + 1 + + + 2 + + + 3 + + + 4 + + + Rango de onda plana 69 a 2011 Hz + + + Tubo de transmisión (ASTM E2611), a escala + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 mm + + + s1 = 50 mm + + + s2 = 50 mm + + + l1 = 100 mm + + + l2 = 200 mm + + + + + + + + + + + + + + + diff --git a/docs/api-reference.md b/docs/api-reference.md index 328bc7231..8194c27d0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -705,6 +705,8 @@ cycle and warn on use; they are removed in 4.0. | `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'/'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) | +| `plot_impedance_tube_geometry` | `function` | **To-scale side view of the ISO 10534-2 tube.**
• `spacing` s, `x1` [m]
• `diameter` d [m], `shape` (Default: 'circular')
• `sample_thickness` [m] (Default: 50 mm nominal)
• `language` | `plot_impedance_tube_geometry(spacing=0.05, x1=0.15, diameter=0.10)`

• Dimensioned drawing + plane-wave range; also `ImpedanceTubeResult.plot_geometry()` | +| `plot_transmission_tube_geometry` | `function` | **To-scale side view of the ASTM E2611 tube.**
• `l1`, `s1`, `l2`, `s2`, `thickness` [m]
• `diameter` d [m], `shape` (Default: 'circular')
• `language` | `plot_transmission_tube_geometry(l1=0.10, s1=0.05, l2=0.20, s2=0.05, thickness=0.05, diameter=0.10)`

• Four microphones + termination; also `TransferMatrix.plot_geometry()` | | `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] | @@ -752,6 +754,7 @@ cycle and warn on use; they are removed in 4.0. | `DELANY_BAZLEY_VALIDITY` | `tuple` | **Stated fit range in X = ρf/σ.**
(0.01, 1.0) | `DELANY_BAZLEY_VALIDITY # (0.01, 1.0)` | | `MIKI_VALIDITY` | `tuple` | **Miki fit range in f/σ.**
(0.01, 1.0) | `MIKI_VALIDITY # (0.01, 1.0)` | | `layered_absorber` | `function` | **Transfer-matrix multilayer prediction at one angle (Cox & D'Antonio Eq. 2.29; Bies Eq. D.95; Mechel D.4).**
• `frequency` f [Hz]
• `layers`: stack from the incidence side (`AirLayer`, `PorousLayer`, `PerforatedPlateLayer`, `MicroperforatedPlateLayer`, `MembraneLayer`)
• `angle` θ [rad] (Default: 0)
• `termination`: 'rigid'/'free'/complex Z [Pa·s/m] (Default: 'rigid')
• `speed_of_sound`, `air_density`, `viscosity` | `res = layered_absorber(f, [PorousLayer(0.05, med)])`

• `LayeredAbsorberResult` | +| `plot_absorber_stack` | `function` | **To-scale cross-section of a layer stack.**
• `layers`: front layer first (or a single layer)
• `language` | `plot_absorber_stack(layers)`

• Dimensioned drawing, rigid backing at right; also `LayeredAbsorberResult.plot_geometry()` and `layer.plot()` | | `LayeredAbsorberResult` | `dataclass` | **Oblique-incidence prediction.**
• `surface_impedance` Zs, `normalized_impedance`
• `reflection` R(θ), `absorption` α(θ)
• `transfer_matrix`: (2, 2, n) chain matrix (det = 1)
• `.plot()`: α(f) with \|R\| overlaid | `res.absorption, res.surface_impedance` | | `diffuse_field_absorption` | `function` | **Random-incidence (Paris) absorption (Mechel D.5 Eq. 9).**
• `frequency`, `layers`, `termination` as `layered_absorber`
• `angle_limit` [rad] (Default: π/2; 75°–87° truncations in use)
• `quadrature_points` (Default: 64, Gauss–Legendre) | `dif = diffuse_field_absorption(f, layers)`

• `DiffuseFieldAbsorptionResult` | | `DiffuseFieldAbsorptionResult` | `dataclass` | **Paris-integral result.**
• `absorption`: α_dif(f)
• `angle_limit` [rad]
• `.plot()` | `dif.absorption` | @@ -769,6 +772,8 @@ cycle and warn on use; they are removed in 4.0. | `membrane_resonance_frequency` | `function` | **Membrane mass-spring resonance (Cox Eqs. 7.9/7.10).**
• `surface_density` m [kg/m²], `cavity_depth` d [m]
• `isothermal` (Default: False)
• `speed_of_sound`, `air_density` | `membrane_resonance_frequency(surface_density=5.0, cavity_depth=0.05) # ≈120`

• ≈ 60/√(md) (50/√(md) isothermal) [Hz] | | `PorousAbsorberWarning` | `warning class` | **Porous-model advisory.**
Emitted when the Delany–Bazley / Miki regressions are evaluated outside their published fit range; values are still returned | `warnings.simplefilter('error', PorousAbsorberWarning)` | | `slit_helmholtz_absorber` | `function` | **Slit panel loaded with Helmholtz resonators (Jiménez et al. Appl. Sci. 2017).**
• `frequency` f [Hz], `resonators`: one/many `HelmholtzResonator`
• `slit_height` h, `lattice_step` a, `period` d [m]
• `angle` θ [rad] (Default: 0)
• `end_correction`, `slit_radiation` (Default: True)
• visco-thermal slit + square-duct losses | `res = slit_helmholtz_absorber(f, hr, slit_height=1e-3, lattice_step=3e-2, period=5e-2)`

• `SlitResonatorAbsorberResult` | +| `plot_slit_absorber_geometry` | `function` | **To-scale cross-section of one period of the slit panel.**
• `resonators` (one per lattice step)
• `slit_height` h, `lattice_step` a, `period` d [m]
• `language` | `plot_slit_absorber_geometry([hr], slit_height=1e-3, lattice_step=0.03, period=0.05)`

• Also `SlitResonatorAbsorberResult.plot_geometry()` | +| `plot_helmholtz_resonator_geometry` | `function` | **To-scale cross-section of a square-section Helmholtz resonator.**
• `resonator`: `HelmholtzResonator`
• `language` | `plot_helmholtz_resonator_geometry(hr)`

• Also `HelmholtzResonator.plot()` | | `SlitResonatorAbsorberResult` | `dataclass` | **Slow-sound panel prediction.**
• `surface_impedance` Z = T11/T21, `normalized_impedance`
• `reflection` R(θ), `absorption` α = 1 − \|R\|²
• `effective_wavenumber`, `effective_impedance`
• `transfer_matrix`: (2, 2, n)
• `.plot()`: α(f) with \|R\| overlaid | `res.absorption, res.plot()` | | `critical_coupling_design` | `function` | **Solve geometry for perfect absorption at a frequency (critical coupling, Eq. 9).**
• `target_frequency` f0 [Hz], base `resonator`
• `lattice_step` a, `period` d [m], `angle` θ
• tunes cavity length + slit height so the reflection zero is real
• warns if α does not reach ≈1 | `d = critical_coupling_design(300.0, hr, lattice_step=3e-2, period=5e-2)`

• `CriticalCouplingResult` | | `CriticalCouplingResult` | `dataclass` | **Perfect-absorption design outcome.**
• `resonator`, `slit_height`: solved geometry
• `absorption` (≈1), `normalized_impedance` (≈1)
• `converged`, `target_frequency`, `angle` | `d.resonator, d.slit_height, d.absorption` | @@ -813,6 +818,7 @@ cycle and warn on use; they are removed in 4.0. | `ScatteringDiffusionWarning` | `warning class` | **ISO 17497 advisory.**
Emitted for out-of-range scattering/diffusion measurement conditions (e.g. a base plate over the Table 1 limits) | `warnings.simplefilter('error', ScatteringDiffusionWarning)` | | `quadratic_residue_sequence` | `function` | **Quadratic residue sequence s_n (Cox & D'Antonio Eq. 10.2).**
• `prime`: odd prime generator N | `quadratic_residue_sequence(7) # [0 1 4 2 2 4 1]`

• s_n = n² mod N | | `qrd_well_depths` | `function` | **QRD well depths d_n (Cox & D'Antonio Eq. 10.3).**
• `prime`: generator N
• `design_frequency` f0 [Hz]
• `speed_of_sound` c [m/s] (Default: 343) | `qrd_well_depths(7, 500.0) # d_max 0.196 m`

• d_n = s_n λ0/(2N) | +| `plot_qrd_geometry` | `function` | **To-scale QRD well profile.**
• `depths` d_n [m], `well_width` w [m]
• `periods` (Default: 1), `fin_width` [m] (Default: w/12)
• `language` | `plot_qrd_geometry(qrd_well_depths(7, 500.0), 0.12, periods=2)`

• Also `DiffuserPolarResponse.plot_geometry()` | | `predict_diffuser_polar_response` | `function` | **Predicted far-field polar response (Cox & D'Antonio Eq. 5.8).**
• `well_width` w [m], `frequency` f [Hz]
• `depths` d_n [m] or `reflection` R_n (exactly one)
• `angles` [°] (Default: semicircle), `source_angle` ψ [°]
• `periods` Np (Default: 1) | `s = predict_diffuser_polar_response(0.10, 2000.0, depths=d, periods=5)`

• `DiffuserPolarResponse` | | `predicted_diffusion_spectrum` | `function` | **Predicted diffusion spectrum d(f) of a design.**
• `well_width` [m], `frequencies` [Hz]
• `depths` d_n [m]
• `periods` (Default: 1)
• `normalize`: also d_n vs flat reference (Default: True) | `res = predicted_diffusion_spectrum(0.10, f, depths=d, periods=5)`

• `DiffusionSpectrum` | | `DiffuserPolarResponse` | `dataclass` | **Predicted diffuser polar response.**
• `frequency` [Hz]
• `angles` [°], `levels` [dB] (peak at 0)
• `coefficient`: d_θ
• `.plot()` | `s.coefficient` | diff --git a/docs/materials.md b/docs/materials.md index 0684e379f..da7a47a83 100644 --- a/docs/materials.md +++ b/docs/materials.md @@ -501,6 +501,37 @@ 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 ``` +Those limits are easier to see on the hardware itself. +`plot_impedance_tube_geometry` draws a tube to scale with its plane-wave +band worked out, and a measured `two_microphone_impedance` result that was +given its geometry (`spacing`, `x1`, `diameter`) redraws its own setup with +`result.plot_geometry()`. + +To-scale side view of a 100 mm ISO 10534-2 impedance tube: the loudspeaker at the left end, microphones 1 and 2 flush in the wall at s = 50 mm spacing with microphone 1 at x1 = 150 mm from the specimen face, the specimen against the rigid backing at the right, the circular cross-section beside the tube and the plane-wave working range of 343 to 1991 Hz that this geometry sets + +*Everything the working-range inequalities talk about, in one to-scale side +view: the 100 mm bore sets the 1991 Hz top end, the 50 mm spacing sets the +343 Hz bottom end, and the microphones sit at $s$ = 50 mm with the farther +one $x_1$ = 150 mm from the specimen face.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_impedance_tube_geometry(spacing=0.05, x1=0.15, diameter=0.10) +plt.show() + +# A measured result that retains its geometry draws its own tube: +# res = materials.two_microphone_impedance( +# h12, frequency=f, spacing=0.05, x1=0.15, diameter=0.10, ...) +# res.plot_geometry() +``` + +
+ 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 @@ -714,6 +745,36 @@ $$ ASTM E2611 four-microphone transmission-loss tube: a sound source, two microphones upstream and two downstream of the test specimen at spacings s1 and s2 and offsets l1 and l2, an adjustable termination for the two-load method, the upstream A and B and downstream C and D travelling waves, and the transfer matrix and transmission-loss relations +To scale it looks like this. A `TransferMatrix` recovered by the two-load or +one-load solvers retains `l1`, `s1`, `l2`, `s2` and the specimen thickness, +so `tm.plot_geometry()` redraws the tube a measurement was taken in. + +To-scale side view of a 100 mm ASTM E2611 transmission tube: the loudspeaker at the left, microphones 1 and 2 upstream at s1 = 50 mm spacing and l1 = 100 mm from the specimen, the 50 mm specimen in the middle of the tube, microphones 3 and 4 downstream at s2 = 50 mm spacing and l2 = 200 mm, the changeable termination of the two-load method at the right, the circular cross-section beside the tube and the plane-wave working range of 69 to 2011 Hz + +*Where the four microphones of the transfer-matrix method actually sit +around a 50 mm specimen in a 100 mm tube: $s_1 = s_2$ = 50 mm, +$l_1$ = 100 mm, $l_2$ = 200 mm, and the changeable termination that +provides the second load. The ASTM working range for this geometry is +69 to 2011 Hz.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_transmission_tube_geometry( + l1=0.10, s1=0.05, l2=0.20, s2=0.05, thickness=0.05, diameter=0.10, +) +plt.show() + +# A TransferMatrix from transfer_matrix_two_load / _one_load retains its +# geometry, so tm.plot_geometry() redraws the tube it was measured in. +``` + +
+ ```python import numpy as np from phonometry import materials diff --git a/docs/porous-absorbers.md b/docs/porous-absorbers.md index 1569e4c0e..a22a380c1 100644 --- a/docs/porous-absorbers.md +++ b/docs/porous-absorbers.md @@ -194,6 +194,43 @@ plt.show() +Behind every one of those curves there is a plain layer list, read front to +back in the order the incident wave meets it. `plot_absorber_stack` draws +that list to scale before any physics runs, and a solved result retains its +layers, so `materials.layered_absorber(f, layers).plot_geometry()` draws the +same cross-section from the result itself. + +To-scale cross-section of a three-layer absorber: a 1 mm microperforated plate at the front, a 30 mm air cavity and a 50 mm porous layer against the rigid backing at the right, each thickness dimensioned, with the incident sound arriving from the left + +*The layer list as the wave meets it: microperforated plate, air cavity, +porous layer, rigid backing. Drawing the stack to scale before computing +anything catches the classic metres-versus-millimetres slip in a thickness +at a glance.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +import numpy as np +from phonometry import materials + +f = np.linspace(200.0, 4000.0, 100) +layers = [ + materials.MicroperforatedPlateLayer(0.001, 0.0002, 0.01), + materials.AirLayer(0.03), + materials.PorousLayer(0.05, materials.miki(f, 20000.0)), +] + +# The free function draws any layer list; a solved result retains its +# layers, so this draws the same cross-section: +# materials.layered_absorber(f, layers).plot_geometry() +materials.plot_absorber_stack(layers) +plt.show() +``` + +
+ ## 3. Resonant sheets: perforated, microperforated, membrane **Perforated panel.** The air plugs in the holes are the mass of a @@ -395,6 +432,67 @@ res = slit_helmholtz_absorber( res.plot() # alpha(f) with |R| overlaid; peak = 1 at 300 Hz ``` +The solved geometry is worth a look before the absorption curve. The +resonator draws itself with `.plot()` and the panel result with +`.plot_geometry()`, both dimensioned and to scale. + +To-scale cross-section of the square-section Helmholtz resonator the 300 Hz critical-coupling design starts from: a neck 3 mm wide and 1 mm long opening into a rigid-walled cavity 27 mm wide and 30 mm deep, with the four defining dimensions dimensioned + +*The four numbers that define the resonator: the neck side and length set +the moving mass and most of the loss, the cavity side and length set the +stiffness. `critical_coupling_design` keeps this cross-section and retunes +the cavity length to place the resonance.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator + +resonator = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +resonator.plot() # dimensioned cross-section, to scale +plt.show() +``` + +
+ +To-scale cross-section of one period of the critically-coupled slit panel: a slit about 1 mm high running the 30 mm depth of the panel, loaded by the tuned Helmholtz resonator cavity that fills the rest of the 50 mm period, with the rigid backing behind and the incident sound arriving from the left + +*One period of the solved design, to scale: the whole panel is 30 mm deep, +$\lambda/38$ at 300 Hz, and the sub-millimetre slit that does all the +absorbing is barely visible. That is exactly the point of the slow-sound +mechanism.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator, critical_coupling_design, materials + +base = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +design = critical_coupling_design( + 300.0, base, lattice_step=3.0e-2, period=5.0e-2, +) + +# The free function draws any resonator list; a slit_helmholtz_absorber +# result retains its geometry, so res.plot_geometry() draws the same period. +materials.plot_slit_absorber_geometry( + [design.resonator], slit_height=design.slit_height, + lattice_step=3.0e-2, period=5.0e-2, +) +plt.show() +``` + +
+ Absorption of a slit panel loaded by one Helmholtz resonator: the critically-coupled design reaches alpha = 1 at 300 Hz, while narrowing or widening the slit breaks the balance and lowers the peak; the panel depth is lambda/38 *One resonator, one slit, one loss-versus-leakage balance: the critically diff --git a/docs/surface-scattering.md b/docs/surface-scattering.md index bfcbea8de..93f9e9246 100644 --- a/docs/surface-scattering.md +++ b/docs/surface-scattering.md @@ -204,6 +204,37 @@ in the 200-400 Hz bands (agreement within 0.01; over the full published 100-5000 Hz range the model-vs-BEM mean absolute deviation is about 0.09, because edge diffraction is outside the Fraunhofer model). +The surface itself is worth drawing before predicting anything from it. +`plot_qrd_geometry` turns the depth sequence into the to-scale well profile +below, and a predicted `DiffuserPolarResponse` retains its geometry, so +`qrd.plot_geometry()` draws the surface it was computed for. + +To-scale well profile of two periods of the commercial N = 7 quadratic-residue diffuser: wells 80.7 mm wide at an 85.7 mm pitch, separated by thin fins on a rigid base, following the depth sequence 0, 50, 200, 100, 100, 200, 50 mm with the deepest wells at 200 mm, and the incident sound arriving from above + +*Two of the six periods, to scale: the quadratic residues $n^2 \bmod 7$ +turned into a buildable surface. The 490 Hz design frequency is what puts +the deepest well at exactly 200 mm.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +# The published N = 7 QRD: well width 3.6 m / 42, deepest well 0.2 m. +depths = materials.qrd_well_depths(7, 490.0, speed_of_sound=343.0) +pitch = 3.6 / 42 # 42 wells across the 3.6 m panel +fin = 0.005 # thin fins, included in the pitch +materials.plot_qrd_geometry(depths, pitch - fin, fin_width=fin, periods=2) +plt.show() + +# A predicted DiffuserPolarResponse retains its geometry, so +# qrd.plot_geometry() draws the profile it was computed for (all 6 periods). +``` + +
+ ```python from phonometry import materials diff --git a/scripts/api_taxonomy.py b/scripts/api_taxonomy.py index 80e790a74..53d7844f5 100644 --- a/scripts/api_taxonomy.py +++ b/scripts/api_taxonomy.py @@ -343,6 +343,15 @@ class Section: # Defined in phonometry._plot.room; documented helper for the ISO 18233 # excitation signals that live in room_ir. "plot_excitation": "phonometry.room.room_ir", + # Defined in phonometry._plot.geometry; documented with the materials + # module whose devices each drawing depicts. + "plot_absorber_stack": "phonometry.materials.porous_absorber", + "plot_helmholtz_resonator_geometry": + "phonometry.materials.slow_sound_absorber", + "plot_slit_absorber_geometry": "phonometry.materials.slow_sound_absorber", + "plot_qrd_geometry": "phonometry.materials.diffuser_design", + "plot_impedance_tube_geometry": "phonometry.materials.impedance_tube", + "plot_transmission_tube_geometry": "phonometry.materials.impedance_tube", # Defined in underwater.acoustics, also re-exported by # underwater.ship_radiated_noise (identity scan is ambiguous). "UNDERWATER_REFERENCE_PRESSURE": "phonometry.underwater.acoustics", diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py index 3c3ed66ad..ff41072d1 100644 --- a/scripts/generate_graphs.py +++ b/scripts/generate_graphs.py @@ -7869,6 +7869,150 @@ def generate_slow_sound_absorber(output_dir: str) -> None: plt.close() +def generate_absorber_stack_geometry(output_dir: str) -> None: + """To-scale cross-section of a three-layer absorber design. + + The microperforated plate + air gap + porous layer stack drawn the way a + lab manual would: each layer with its material fill and dimensioned + thickness, the rigid backing at the right and the sound arriving from the + left. One concept: the geometry behind the multilayer absorption curve. + """ + print("Generating absorber_stack_geometry...") + from phonometry import materials + + frequency = np.linspace(200.0, 4000.0, 100) + medium = materials.miki(frequency, 20000.0) + layers: list[Any] = [ + materials.MicroperforatedPlateLayer(0.001, 0.0002, 0.01), + materials.AirLayer(0.03), + materials.PorousLayer(0.05, medium), + ] + _fig, ax = plt.subplots(figsize=(10, 6.2)) + materials.plot_absorber_stack(layers, ax=ax, language=_LANG) + plt.tight_layout() + save_figure(output_dir, "absorber_stack_geometry.svg") + plt.close() + + +def generate_slit_absorber_geometry(output_dir: str) -> None: + """To-scale cross-section of the critically-coupled slit panel. + + One period of the slow-sound metamaterial absorber of the companion + absorption figure: the 300 Hz critical-coupling design (single Helmholtz + resonator loading a thin slit, panel depth lambda/38). One concept: what + the deep-subwavelength panel actually looks like. + """ + print("Generating slit_absorber_geometry...") + from phonometry import ( + HelmholtzResonator, + critical_coupling_design, + materials, + ) + + base = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, + ) + design = critical_coupling_design( + 300.0, base, lattice_step=3.0e-2, period=5.0e-2, + ) + _fig, ax = plt.subplots(figsize=(10, 6.2)) + materials.plot_slit_absorber_geometry( + [design.resonator], ax=ax, slit_height=design.slit_height, + lattice_step=3.0e-2, period=5.0e-2, language=_LANG, + ) + plt.tight_layout() + save_figure(output_dir, "slit_absorber_geometry.svg") + plt.close() + + +def generate_qrd_geometry(output_dir: str) -> None: + """To-scale well profile of the N = 7 QRD of the polar-response figure. + + Two periods of the commercial N = 7 quadratic-residue diffuser (42 wells + across 3,6 m, so an 85,7 mm pitch split into an 80,7 mm well plus a 5 mm + fin; deepest well 0,2 m, design frequency 490 Hz) drawn as the physical + profile the Fraunhofer prediction models. One concept: the + quadratic-residue depth sequence as a real, buildable surface. + """ + print("Generating qrd_geometry...") + from phonometry import materials + + depths = materials.qrd_well_depths(7, 490.0, speed_of_sound=343.0) + pitch = 3.6 / 42 + fin = 0.005 + _fig, ax = plt.subplots(figsize=(10, 6.2)) + materials.plot_qrd_geometry( + depths, pitch - fin, ax=ax, periods=2, fin_width=fin, language=_LANG, + ) + plt.tight_layout() + save_figure(output_dir, "qrd_geometry.svg") + plt.close() + + +def generate_impedance_tube_geometry(output_dir: str) -> None: + """To-scale side view of a 100 mm ISO 10534-2 impedance tube. + + Loudspeaker, the two flush microphones (s = 50 mm, x1 = 150 mm), the + sample against its rigid backing, the circular cross-section and the + plane-wave working range those dimensions buy. One concept: the tube + geometry fixes the usable frequency band. + """ + print("Generating impedance_tube_geometry...") + from phonometry import materials + + _fig, ax = plt.subplots(figsize=(10, 6.2)) + materials.plot_impedance_tube_geometry( + ax=ax, spacing=0.05, x1=0.15, diameter=0.10, shape="circular", + language=_LANG, + ) + plt.tight_layout() + save_figure(output_dir, "impedance_tube_geometry.svg") + plt.close() + + +def generate_transmission_tube_geometry(output_dir: str) -> None: + """To-scale side view of a 100 mm ASTM E2611 transmission tube. + + Four flush microphones around the specimen (l1 = 100 mm, s1 = 50 mm, + l2 = 200 mm, s2 = 50 mm), the changeable termination of the two-load + method and the ASTM working range. One concept: where the four + microphones of the transfer-matrix method actually sit. + """ + print("Generating transmission_tube_geometry...") + from phonometry import materials + + _fig, ax = plt.subplots(figsize=(10, 6.2)) + materials.plot_transmission_tube_geometry( + ax=ax, l1=0.10, s1=0.05, l2=0.20, s2=0.05, thickness=0.05, + diameter=0.10, shape="circular", language=_LANG, + ) + plt.tight_layout() + save_figure(output_dir, "transmission_tube_geometry.svg") + plt.close() + + +def generate_helmholtz_resonator_geometry(output_dir: str) -> None: + """To-scale cross-section of the 300 Hz slow-sound Helmholtz resonator. + + The square-section resonator of the critical-coupling design (neck 1 x 3 + mm, cavity 30 x 27 mm) with its four defining dimensions. One concept: + the resonator geometry the slit-panel figures build on. + """ + print("Generating helmholtz_resonator_geometry...") + from phonometry import HelmholtzResonator + + resonator = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, + ) + _fig, ax = plt.subplots(figsize=(10, 6.2)) + resonator.plot(ax=ax, language=_LANG) + plt.tight_layout() + save_figure(output_dir, "helmholtz_resonator_geometry.svg") + plt.close() + + def generate_scattering_coefficient(output_dir: str) -> None: """ISO 17497-1: scattering coefficient s(f) from a per-band measurement.""" print("Generating scattering_coefficient.png...") @@ -11325,13 +11469,19 @@ def generate_runs_test(output_dir: str) -> None: generate_impedance_tube, # Porous materials & multilayer absorbers (Mechel / Bies / Cox & D'Antonio) generate_porous_absorber_designs, + generate_absorber_stack_geometry, # Slow-sound slit + Helmholtz-resonator perfect absorbers (Jimenez et al.) generate_slow_sound_absorber, + generate_slit_absorber_geometry, + generate_helmholtz_resonator_geometry, # Scattering/diffusion, in-situ road absorption, precision sound power # (ISO 17497-1/-2, ISO 13472-1, ISO 3745 / ISO 9614-3) generate_scattering_coefficient, generate_diffusion_polar, generate_diffuser_prediction, + generate_qrd_geometry, + generate_impedance_tube_geometry, + generate_transmission_tube_geometry, generate_insitu_absorption, generate_precision_anechoic_power, generate_intensity_scan_power, diff --git a/site/src/content/docs/es/guides/materials.mdx b/site/src/content/docs/es/guides/materials.mdx index bd1510fac..6a21e44f5 100644 --- a/site/src/content/docs/es/guides/materials.mdx +++ b/site/src/content/docs/es/guides/materials.mdx @@ -596,6 +596,39 @@ 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 ``` +Esos límites se ven mejor sobre el propio equipo. +`plot_impedance_tube_geometry` dibuja un tubo a escala con su banda de onda +plana ya calculada, y un resultado medido de `two_microphone_impedance` al +que se le dio su geometría (`spacing`, `x1`, `diameter`) redibuja su propio +montaje con `result.plot_geometry()`. + + + +*Todo lo que cuentan las desigualdades del rango de trabajo, en una vista +lateral a escala: el diámetro de 100 mm fija el extremo superior de +1991 Hz, la separación de 50 mm fija el extremo inferior de 343 Hz, y los +micrófonos quedan a $s$ = 50 mm con el más lejano a $x_1$ = 150 mm de la +cara de la probeta.* + +
+Mostrar el código de esta figura + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_impedance_tube_geometry(spacing=0.05, x1=0.15, + diameter=0.10, language="es") +plt.show() + +# Un resultado medido que retiene su geometría dibuja su propio tubo: +# res = materials.two_microphone_impedance( +# h12, frequency=f, spacing=0.05, x1=0.15, diameter=0.10, ...) +# res.plot_geometry() +``` + +
+ 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 @@ -819,6 +852,37 @@ $$ +A escala tiene este aspecto. Una `TransferMatrix` recuperada por los +solucionadores de dos cargas o de una carga retiene `l1`, `s1`, `l2`, `s2` y +el espesor de la probeta, así que `tm.plot_geometry()` redibuja el tubo en +el que se midió. + + + +*Dónde se sitúan de verdad los cuatro micrófonos del método de la matriz de +transferencia alrededor de una probeta de 50 mm en un tubo de 100 mm: +$s_1 = s_2$ = 50 mm, $l_1$ = 100 mm, $l_2$ = 200 mm, y la terminación +intercambiable que aporta la segunda carga. El rango de trabajo ASTM para +esta geometría es de 69 a 2011 Hz.* + +
+Mostrar el código de esta figura + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_transmission_tube_geometry( + l1=0.10, s1=0.05, l2=0.20, s2=0.05, thickness=0.05, diameter=0.10, + language="es") +plt.show() + +# Una TransferMatrix de transfer_matrix_two_load / _one_load retiene su +# geometría, así que tm.plot_geometry() redibuja el tubo en el que se midió. +``` + +
+ ```python import numpy as np from phonometry import materials diff --git a/site/src/content/docs/es/guides/porous-absorbers.mdx b/site/src/content/docs/es/guides/porous-absorbers.mdx index 4e93b780e..90a087716 100644 --- a/site/src/content/docs/es/guides/porous-absorbers.mdx +++ b/site/src/content/docs/es/guides/porous-absorbers.mdx @@ -317,6 +317,44 @@ plt.show() +Detrás de cada una de esas curvas hay una simple lista de capas, leída de +delante hacia atrás en el orden en que la onda incidente la encuentra. +`plot_absorber_stack` dibuja esa lista a escala antes de ejecutar ninguna +física, y un resultado resuelto retiene sus capas, así que +`materials.layered_absorber(f, layers).plot_geometry()` dibuja la misma +sección desde el propio resultado. + + + +*La lista de capas tal y como la encuentra la onda: placa microperforada, +cámara de aire, capa porosa, respaldo rígido. Dibujar el apilado a escala +antes de calcular nada permite cazar de un vistazo el clásico despiste de +metros frente a milímetros en un espesor.* + +
+Mostrar el código de esta figura + +```python +import matplotlib.pyplot as plt +import numpy as np +from phonometry import materials + +f = np.linspace(200.0, 4000.0, 100) +layers = [ + materials.MicroperforatedPlateLayer(0.001, 0.0002, 0.01), + materials.AirLayer(0.03), + materials.PorousLayer(0.05, materials.miki(f, 20000.0)), +] + +# La función libre dibuja cualquier lista de capas; un resultado resuelto +# retiene sus capas, así que esto dibuja la misma sección: +# materials.layered_absorber(f, layers).plot_geometry() +materials.plot_absorber_stack(layers, language="es") +plt.show() +``` + +
+ ## 3. Láminas resonantes: perforado, microperforado, membrana **Panel perforado.** Los tapones de aire de los orificios son la masa de un @@ -541,6 +579,69 @@ res = slit_helmholtz_absorber( res.plot() # alpha(f) con |R| superpuesto; pico = 1 a 300 Hz ``` +La geometría resuelta merece un vistazo antes que la curva de absorción. El +resonador se dibuja con `.plot()` y el resultado del panel con +`.plot_geometry()`, ambos acotados y a escala. + + + +*Los cuatro números que definen el resonador: el lado y la longitud del +cuello fijan la masa móvil y la mayor parte de la pérdida, y el lado y la +longitud de la cavidad fijan la rigidez. `critical_coupling_design` conserva +esta sección y reajusta la longitud de la cavidad para colocar la +resonancia.* + +
+Mostrar el código de esta figura + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator + +resonator = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +resonator.plot(language="es") # sección acotada, a escala +plt.show() +``` + +
+ + + +*Un periodo del diseño resuelto, a escala: el panel entero tiene 30 mm de +profundidad, $\lambda/38$ a 300 Hz, y la ranura submilimétrica que hace toda +la absorción apenas se ve. Esa es justamente la gracia del mecanismo de +sonido lento.* + +
+Mostrar el código de esta figura + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator, critical_coupling_design, materials + +base = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +design = critical_coupling_design( + 300.0, base, lattice_step=3.0e-2, period=5.0e-2, +) + +# La función libre dibuja cualquier lista de resonadores; un resultado de +# slit_helmholtz_absorber retiene su geometría, así que res.plot_geometry() +# dibuja el mismo periodo. +materials.plot_slit_absorber_geometry( + [design.resonator], slit_height=design.slit_height, + lattice_step=3.0e-2, period=5.0e-2, + language="es") +plt.show() +``` + +
+ *Un resonador, una ranura, un equilibrio entre pérdida y fuga. El diseño con diff --git a/site/src/content/docs/es/guides/surface-scattering.mdx b/site/src/content/docs/es/guides/surface-scattering.mdx index d36149379..7bf3a453b 100644 --- a/site/src/content/docs/es/guides/surface-scattering.mdx +++ b/site/src/content/docs/es/guides/surface-scattering.mdx @@ -282,6 +282,39 @@ de 200-400 Hz (acuerdo dentro de 0,01; en todo el rango publicado de 100-5000 Hz la desviación absoluta media modelo-BEM ronda 0,09, porque la difracción de borde queda fuera del modelo de Fraunhofer). +La propia superficie merece dibujarse antes de predecir nada con ella. +`plot_qrd_geometry` convierte la secuencia de profundidades en el perfil de +pozos a escala de abajo, y una `DiffuserPolarResponse` predicha retiene su +geometría, así que `qrd.plot_geometry()` dibuja la superficie para la que se +calculó. + + + +*Dos de los seis periodos, a escala: los residuos cuadráticos $n^2 \bmod 7$ +convertidos en una superficie construible. La frecuencia de diseño de +490 Hz es la que deja el pozo más profundo exactamente en 200 mm.* + +
+Ver el código de esta figura + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +# El QRD N = 7 publicado: ancho de pozo 3,6 m / 42, pozo más profundo 0,2 m. +depths = materials.qrd_well_depths(7, 490.0, speed_of_sound=343.0) +pitch = 3.6 / 42 # 42 pozos a lo ancho del panel de 3,6 m +fin = 0.005 # aletas finas, incluidas en el paso +materials.plot_qrd_geometry(depths, pitch - fin, fin_width=fin, periods=2, + language="es") +plt.show() + +# Una DiffuserPolarResponse predicha retiene su geometría, así que +# qrd.plot_geometry() dibuja el perfil para el que se calculó (los 6 periodos). +``` + +
+ ```python from phonometry import materials diff --git a/site/src/content/docs/guides/materials.mdx b/site/src/content/docs/guides/materials.mdx index b82aba623..35914a29c 100644 --- a/site/src/content/docs/guides/materials.mdx +++ b/site/src/content/docs/guides/materials.mdx @@ -582,6 +582,37 @@ 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 ``` +Those limits are easier to see on the hardware itself. +`plot_impedance_tube_geometry` draws a tube to scale with its plane-wave +band worked out, and a measured `two_microphone_impedance` result that was +given its geometry (`spacing`, `x1`, `diameter`) redraws its own setup with +`result.plot_geometry()`. + + + +*Everything the working-range inequalities talk about, in one to-scale side +view: the 100 mm bore sets the 1991 Hz top end, the 50 mm spacing sets the +343 Hz bottom end, and the microphones sit at $s$ = 50 mm with the farther +one $x_1$ = 150 mm from the specimen face.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_impedance_tube_geometry(spacing=0.05, x1=0.15, diameter=0.10) +plt.show() + +# A measured result that retains its geometry draws its own tube: +# res = materials.two_microphone_impedance( +# h12, frequency=f, spacing=0.05, x1=0.15, diameter=0.10, ...) +# res.plot_geometry() +``` + +
+ 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 @@ -799,6 +830,36 @@ $$ +To scale it looks like this. A `TransferMatrix` recovered by the two-load or +one-load solvers retains `l1`, `s1`, `l2`, `s2` and the specimen thickness, +so `tm.plot_geometry()` redraws the tube a measurement was taken in. + + + +*Where the four microphones of the transfer-matrix method actually sit +around a 50 mm specimen in a 100 mm tube: $s_1 = s_2$ = 50 mm, +$l_1$ = 100 mm, $l_2$ = 200 mm, and the changeable termination that +provides the second load. The ASTM working range for this geometry is +69 to 2011 Hz.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +materials.plot_transmission_tube_geometry( + l1=0.10, s1=0.05, l2=0.20, s2=0.05, thickness=0.05, diameter=0.10, +) +plt.show() + +# A TransferMatrix from transfer_matrix_two_load / _one_load retains its +# geometry, so tm.plot_geometry() redraws the tube it was measured in. +``` + +
+ ```python import numpy as np from phonometry import materials diff --git a/site/src/content/docs/guides/porous-absorbers.mdx b/site/src/content/docs/guides/porous-absorbers.mdx index b7164e2a1..75153a999 100644 --- a/site/src/content/docs/guides/porous-absorbers.mdx +++ b/site/src/content/docs/guides/porous-absorbers.mdx @@ -306,6 +306,43 @@ plt.show() +Behind every one of those curves there is a plain layer list, read front to +back in the order the incident wave meets it. `plot_absorber_stack` draws +that list to scale before any physics runs, and a solved result retains its +layers, so `materials.layered_absorber(f, layers).plot_geometry()` draws the +same cross-section from the result itself. + + + +*The layer list as the wave meets it: microperforated plate, air cavity, +porous layer, rigid backing. Drawing the stack to scale before computing +anything catches the classic metres-versus-millimetres slip in a thickness +at a glance.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +import numpy as np +from phonometry import materials + +f = np.linspace(200.0, 4000.0, 100) +layers = [ + materials.MicroperforatedPlateLayer(0.001, 0.0002, 0.01), + materials.AirLayer(0.03), + materials.PorousLayer(0.05, materials.miki(f, 20000.0)), +] + +# The free function draws any layer list; a solved result retains its +# layers, so this draws the same cross-section: +# materials.layered_absorber(f, layers).plot_geometry() +materials.plot_absorber_stack(layers) +plt.show() +``` + +
+ ## 3. Resonant sheets: perforated, microperforated, membrane **Perforated panel.** The air plugs in the holes are the mass of a @@ -524,6 +561,67 @@ res = slit_helmholtz_absorber( res.plot() # alpha(f) with |R| overlaid; peak = 1 at 300 Hz ``` +The solved geometry is worth a look before the absorption curve. The +resonator draws itself with `.plot()` and the panel result with +`.plot_geometry()`, both dimensioned and to scale. + + + +*The four numbers that define the resonator: the neck side and length set +the moving mass and most of the loss, the cavity side and length set the +stiffness. `critical_coupling_design` keeps this cross-section and retunes +the cavity length to place the resonance.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator + +resonator = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +resonator.plot() # dimensioned cross-section, to scale +plt.show() +``` + +
+ + + +*One period of the solved design, to scale: the whole panel is 30 mm deep, +$\lambda/38$ at 300 Hz, and the sub-millimetre slit that does all the +absorbing is barely visible. That is exactly the point of the slow-sound +mechanism.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import HelmholtzResonator, critical_coupling_design, materials + +base = HelmholtzResonator( + neck_length=1.0e-3, neck_side=3.0e-3, + cavity_length=30.0e-3, cavity_side=27.0e-3, +) +design = critical_coupling_design( + 300.0, base, lattice_step=3.0e-2, period=5.0e-2, +) + +# The free function draws any resonator list; a slit_helmholtz_absorber +# result retains its geometry, so res.plot_geometry() draws the same period. +materials.plot_slit_absorber_geometry( + [design.resonator], slit_height=design.slit_height, + lattice_step=3.0e-2, period=5.0e-2, +) +plt.show() +``` + +
+ *One resonator, one slit, one loss-versus-leakage balance. The critically diff --git a/site/src/content/docs/guides/surface-scattering.mdx b/site/src/content/docs/guides/surface-scattering.mdx index 00fc2bd55..ee7f6f245 100644 --- a/site/src/content/docs/guides/surface-scattering.mdx +++ b/site/src/content/docs/guides/surface-scattering.mdx @@ -275,6 +275,37 @@ in the 200-400 Hz bands (agreement within 0.01; over the full published 100-5000 Hz range the model-vs-BEM mean absolute deviation is about 0.09, because edge diffraction is outside the Fraunhofer model). +The surface itself is worth drawing before predicting anything from it. +`plot_qrd_geometry` turns the depth sequence into the to-scale well profile +below, and a predicted `DiffuserPolarResponse` retains its geometry, so +`qrd.plot_geometry()` draws the surface it was computed for. + + + +*Two of the six periods, to scale: the quadratic residues $n^2 \bmod 7$ +turned into a buildable surface. The 490 Hz design frequency is what puts +the deepest well at exactly 200 mm.* + +
+Show the code for this figure + +```python +import matplotlib.pyplot as plt +from phonometry import materials + +# The published N = 7 QRD: well width 3.6 m / 42, deepest well 0.2 m. +depths = materials.qrd_well_depths(7, 490.0, speed_of_sound=343.0) +pitch = 3.6 / 42 # 42 wells across the 3.6 m panel +fin = 0.005 # thin fins, included in the pitch +materials.plot_qrd_geometry(depths, pitch - fin, fin_width=fin, periods=2) +plt.show() + +# A predicted DiffuserPolarResponse retains its geometry, so +# qrd.plot_geometry() draws the profile it was computed for (all 6 periods). +``` + +
+ ```python from phonometry import materials diff --git a/site/src/content/docs/reference/api/materials/diffuser-design.md b/site/src/content/docs/reference/api/materials/diffuser-design.md index 77d9d0427..250270b5d 100644 --- a/site/src/content/docs/reference/api/materials/diffuser-design.md +++ b/site/src/content/docs/reference/api/materials/diffuser-design.md @@ -80,6 +80,9 @@ DiffuserPolarResponse( levels: Real, coefficient: float, source_angle: float = 0.0, + well_width: float | None = None, + depths: Real | None = None, + periods: int | None = None, ) ``` @@ -94,6 +97,9 @@ A predicted far-field polar response of a diffuser at one frequency. | `levels` | Predicted reflected sound-pressure level at each angle, in decibels, referenced to the peak of the response (peak at 0 dB). | | `coefficient` | Directional diffusion coefficient `d_theta` of the predicted response (ISO 17497-2, Formula (5)). | | `source_angle` | Angle of incidence `psi` of the source, in degrees. | +| `well_width` | Well width `w` of the predicted surface, in metres, always retained by the predictor (with `periods`) so `plot_geometry` can draw the well profile; appended after the original fields and `None` only for hand-built responses. | +| `depths` | Well depths `d_n` of one period, in metres, when the response was predicted from depths; `None` otherwise (explicit `reflection` surfaces have no drawable well profile). | +| `periods` | Number of repeated periods of the prediction. | ### DiffuserPolarResponse.plot() @@ -111,6 +117,64 @@ Plot the predicted polar response with the diffusion coefficient annotated. Requires matplotlib (`pip install phonometry[plot]`); returns the polar `Axes` and never calls `plt.show`. +### DiffuserPolarResponse.plot_geometry() + +```python +DiffuserPolarResponse.plot_geometry( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the well profile of the predicted surface to scale. + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If the response does not retain its well geometry (hand-built, or predicted from an explicit `reflection` sequence). | + +## plot_qrd_geometry + +```python +plot_qrd_geometry( + depths: ArrayLike, + well_width: float, + ax: Axes | None = None, + *, + periods: int = 1, + fin_width: float | None = None, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the well profile of a quadratic-residue diffuser, to scale. + +Wells open upward; the profile repeats `periods` times with thin fins +between wells. Pairs with +[`qrd_well_depths`](/phonometry/reference/api/materials/diffuser-design/#qrd_well_depths), which supplies the depth +sequence. + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `depths` | Well depths `d_n`, in metres (one period). | +| `well_width` | Well width `w`, in metres. | +| `ax` | Existing axes, or `None` to create a figure. | +| `periods` | Number of repeated periods (>= 1). | +| `fin_width` | Fin thickness between wells, in metres; `None` draws `w / 12`. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the base-slab rectangle. | + +**Returns:** The axes. + ## predict_diffuser_polar_response ```python 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 d8080dfe1..6a788c985 100644 --- a/site/src/content/docs/reference/api/materials/impedance-tube.md +++ b/site/src/content/docs/reference/api/materials/impedance-tube.md @@ -279,6 +279,28 @@ Plot the absorption spectrum `alpha(f)` with `|r|` overlaid. Requires matplotlib (`pip install phonometry[plot]`); returns the `Axes`. +### ImpedanceTubeResult.plot_geometry() + +```python +ImpedanceTubeResult.plot_geometry( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the two-microphone tube to scale (dimensioned side view). + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If the result does not retain its tube geometry (`spacing`/`x1`). | + ### ImpedanceTubeResult.report() ```python @@ -460,6 +482,94 @@ binding for every microphone pair). **Returns:** Tuple `(f_l, f_u)` of the lower and upper frequency limits, in Hz. +## plot_impedance_tube_geometry + +```python +plot_impedance_tube_geometry( + ax: Axes | None = None, + *, + spacing: float, + x1: float, + diameter: float | None = None, + shape: str | None = 'circular', + sample_thickness: float | None = None, + speed_of_sound: float = 343.2, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the ISO 10534-2 two-microphone tube to scale. + +Side view: loudspeaker at the left (three tube diameters before the +farther microphone, the Clause 4.3 margin), the two flush microphones at +`x1` and `x1 - s` from the sample face, the sample against its rigid +backing at the right, the cross-section emblem, and the plane-wave +working range of [`plane_wave_frequency_range`](/phonometry/reference/api/materials/impedance-tube/#plane_wave_frequency_range). + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `ax` | Existing axes, or `None` to create a figure. | +| `spacing` | Microphone spacing `s`, in metres. | +| `x1` | Distance from the sample face to the farther microphone, in metres. | +| `diameter` | Inner diameter (circular) or lateral dimension (rectangular/square), in metres; `None` draws a nominal bore and omits the bore dimension and the cut-on bound. | +| `shape` | `"circular"`, `"rectangular"`, `"square"` or `None`. | +| `sample_thickness` | Drawn sample thickness, in metres; `None` draws a 50 mm nominal sample. | +| `speed_of_sound` | Speed of sound for the working range, in m/s. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the tube-bore rectangle. | + +**Returns:** The axes. + +## plot_transmission_tube_geometry + +```python +plot_transmission_tube_geometry( + ax: Axes | None = None, + *, + l1: float, + s1: float, + l2: float, + s2: float, + thickness: float, + diameter: float | None = None, + shape: str | None = 'circular', + speed_of_sound: float = 343.2, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the ASTM E2611 four-microphone transmission tube to scale. + +Side view: loudspeaker, the two upstream microphones at `l1` and +`l1 + s1` from the front face of the specimen, the specimen spanning +its thickness, the two downstream microphones at `l2` and `l2 + s2` +(measured from the front face, the module's locked convention), and the +changeable termination of the two-load method, with the ASTM working +range of +[`plane_wave_frequency_range_astm`](/phonometry/reference/api/materials/impedance-tube/#plane_wave_frequency_range_astm). + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `ax` | Existing axes, or `None` to create a figure. | +| `l1` | Front face to the nearer upstream microphone, in metres. | +| `s1` | Upstream microphone spacing, in metres. | +| `l2` | Front face to the nearer downstream microphone, in metres. | +| `s2` | Downstream microphone spacing, in metres. | +| `thickness` | Specimen thickness, in metres; must be smaller than `l2` (the downstream microphones sit past the back face). | +| `diameter` | Inner diameter (circular) or largest section dimension (rectangular/square), in metres; `None` draws a nominal bore and omits the bore dimension and the cut-on bound. | +| `shape` | `"circular"`, `"rectangular"`, `"square"` or `None`. | +| `speed_of_sound` | Speed of sound for the working range, in m/s. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the tube-bore rectangle. | + +**Returns:** The axes. + ## reflection_factor ```python @@ -896,6 +1006,28 @@ Requires matplotlib (`pip install phonometry[plot]`); returns the | :--- | :--- | | ValueError | If `frequency` or `characteristic_impedance` is neither supplied nor stored on the matrix. | +### TransferMatrix.plot_geometry() + +```python +TransferMatrix.plot_geometry( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the four-microphone tube to scale (dimensioned side view). + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If the matrix does not retain its tube geometry (`l1`/`s1`/`l2`/`s2`/`thickness`). | + ### TransferMatrix.reflection_hard_backed() ```python diff --git a/site/src/content/docs/reference/api/materials/porous-absorber.md b/site/src/content/docs/reference/api/materials/porous-absorber.md index f964c6970..d3a8437a9 100644 --- a/site/src/content/docs/reference/api/materials/porous-absorber.md +++ b/site/src/content/docs/reference/api/materials/porous-absorber.md @@ -353,6 +353,7 @@ LayeredAbsorberResult( reflection: Complex, absorption: Real, transfer_matrix: Complex, + layers: tuple[Layer, ...] | None = None, ) ``` @@ -366,6 +367,11 @@ plane-wave reflection factor `R(theta)`, `absorption` the coefficient matrix with shape `(2, 2, len(frequency))` (unimodular: every layer is reciprocal). +`layers` retains the layer sequence the stack was solved with (front +layer first) so `plot_geometry` can draw the cross-section; it is +appended after the original fields and defaults to `None` for +hand-built results. + ### LayeredAbsorberResult.plot() ```python @@ -382,6 +388,28 @@ Plot the absorption spectrum `alpha(f)` with `|R|` overlaid. Requires matplotlib (`pip install phonometry[plot]`); returns the `Axes`. +### LayeredAbsorberResult.plot_geometry() + +```python +LayeredAbsorberResult.plot_geometry( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the solved stack cross-section to scale (dimensioned). + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If the result does not retain its `layers`. | + ## membrane_impedance ```python @@ -628,6 +656,35 @@ air-plug length, and `delta -> 0.85` for an isolated hole. **Returns:** End-correction factor `delta` (dimensionless, per end). +## plot_absorber_stack + +```python +plot_absorber_stack( + layers: Sequence[Layer] | Layer, + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw a layered-absorber cross-section to scale, rigid backing at right. + +Sound arrives from the left; each layer is drawn with its material fill +and its thickness dimensioned below the stack. A membrane (no physical +depth) is drawn as a thin sheet. + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `layers` | The layer sequence of [`layered_absorber`](/phonometry/reference/api/materials/porous-absorber/#layered_absorber), front layer first, or a single layer. | +| `ax` | Existing axes, or `None` to create a figure. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the front-layer rectangle. | + +**Returns:** The axes. + ## PorousAbsorberWarning Advisory for porous-model use outside the published fit range. diff --git a/site/src/content/docs/reference/api/materials/slow-sound-absorber.md b/site/src/content/docs/reference/api/materials/slow-sound-absorber.md index 0dcbb9aad..48a97bf21 100644 --- a/site/src/content/docs/reference/api/materials/slow-sound-absorber.md +++ b/site/src/content/docs/reference/api/materials/slow-sound-absorber.md @@ -215,6 +215,86 @@ A square-cross-section Helmholtz resonator loading a slit. `cavity_length` `l_c` and `cavity_side` `w_c` the closed cavity; all lengths are in metres. +### HelmholtzResonator.plot() + +```python +HelmholtzResonator.plot( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw the resonator cross-section to scale (dimensioned). + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +## plot_helmholtz_resonator_geometry + +```python +plot_helmholtz_resonator_geometry( + resonator: HelmholtzResonator, + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw a square-section Helmholtz resonator cross-section, to scale. + +Neck opening upward into free air, cavity below, with the four defining +dimensions (neck side and length, cavity side and length) dimensioned. + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `resonator` | A [`HelmholtzResonator`](/phonometry/reference/api/materials/slow-sound-absorber/#helmholtzresonator). | +| `ax` | Existing axes, or `None` to create a figure. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the cavity rectangle. | + +**Returns:** The axes. + +## plot_slit_absorber_geometry + +```python +plot_slit_absorber_geometry( + resonators: Sequence[HelmholtzResonator] | HelmholtzResonator, + ax: Axes | None = None, + *, + slit_height: float, + lattice_step: float, + period: float, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw one period of the slit metamaterial absorber, to scale. + +Side cut of the panel: the slit (height `h`) runs from the mouth at the +left into the panel; `N` Helmholtz resonators load it from below at the +lattice step `a` (total depth `L = N a`); the panel repeats vertically +with `period` `d`; rigid back wall at the right. + +**Parameters** + +| Name | Description | +| :--- | :--- | +| `resonators` | The resonator chain of [`slit_helmholtz_absorber`](/phonometry/reference/api/materials/slow-sound-absorber/#slit_helmholtz_absorber) (one per lattice step, or a single resonator reused for all steps). | +| `ax` | Existing axes, or `None` to create a figure. | +| `slit_height` | Slit height `h`, in metres. | +| `lattice_step` | Lattice step `a`, in metres. | +| `period` | Panel period `d`, in metres. | +| `language` | Label language, `"en"` (default) or `"es"`. | +| `kwargs` | Forwarded to the slit rectangle. | + +**Returns:** The axes. + ## rectangular_duct_properties ```python @@ -370,6 +450,10 @@ SlitResonatorAbsorberResult( effective_wavenumber: Complex, effective_impedance: Complex, transfer_matrix: Complex, + resonators: tuple[HelmholtzResonator, ...] | None = None, + slit_height: float | None = None, + lattice_step: float | None = None, + period: float | None = None, ) ``` @@ -384,6 +468,11 @@ backed panel, `normalized_impedance` its ratio to `Z0 = rho0 c0 / S0`, `k_eff` and `Z_eff` (Appl. Sci. 2017 Eq. (5)), and `transfer_matrix` the total 2x2 chain matrix with shape `(2, 2, len(frequency))`. +The trailing fields retain the panel geometry the prediction was run +with (`resonators`, `slit_height`, `lattice_step`, `period`) so +`plot_geometry` can draw the cross-section; they are appended after +the original fields and default to `None` for hand-built results. + ### SlitResonatorAbsorberResult.plot() ```python @@ -400,6 +489,28 @@ Plot the absorption spectrum `alpha(f)` with `|R|` overlaid. Requires matplotlib (`pip install phonometry[plot]`); returns the `Axes`. +### SlitResonatorAbsorberResult.plot_geometry() + +```python +SlitResonatorAbsorberResult.plot_geometry( + ax: Axes | None = None, + *, + language: str = 'en', + **kwargs: Any, +) -> Axes +``` + +Draw one period of the panel cross-section to scale (dimensioned). + +Requires matplotlib (`pip install phonometry[plot]`); returns the +`Axes`. + +**Raises** + +| Exception | When | +| :--- | :--- | +| ValueError | If the result does not retain its geometry. | + ## SlowSoundAbsorberWarning Advisory for slow-sound absorber use outside the modelled regime. diff --git a/src/phonometry/__init__.py b/src/phonometry/__init__.py index c34ca7c3a..03fcf5891 100644 --- a/src/phonometry/__init__.py +++ b/src/phonometry/__init__.py @@ -9,6 +9,14 @@ from typing import Any from ._internal.warnings import PhonometryWarning, _warn_renamed +from ._plot.geometry import ( + plot_absorber_stack, + plot_helmholtz_resonator_geometry, + plot_impedance_tube_geometry, + plot_qrd_geometry, + plot_slit_absorber_geometry, + plot_transmission_tube_geometry, +) from ._plot.room import plot_excitation from ._report import ReportMetadata from ._version import __version__ @@ -1771,7 +1779,13 @@ "plate_bending_stiffness", "plate_loss_factor", "plenum_attenuation", + "plot_absorber_stack", "plot_excitation", + "plot_helmholtz_resonator_geometry", + "plot_impedance_tube_geometry", + "plot_qrd_geometry", + "plot_slit_absorber_geometry", + "plot_transmission_tube_geometry", "power_reflection_coefficient", "power_spectral_density", "practical_absorption_coefficient", diff --git a/src/phonometry/_plot/geometry.py b/src/phonometry/_plot/geometry.py new file mode 100644 index 000000000..8c8e69c3c --- /dev/null +++ b/src/phonometry/_plot/geometry.py @@ -0,0 +1,1049 @@ +# Copyright (c) 2026. Jose M. Requena-Plens +"""To-scale geometry drawings of measurement set-ups and treatment designs. + +Every renderer draws the *physical* device the way a lab manual would: a +dimensioned cross-section in metres with ``ax.set_aspect("equal")``, so a +100 mm tube really is twice as tall as a 50 mm one. They complement the +spectral ``plot()`` renderers: the geometry is what you build, the spectrum is +what you measure. + +Lazy-imported from the ``plot()``/``plot_geometry()`` methods of the domain +objects and from the public ``plot_*_geometry`` functions; domain classes are +referenced only under ``TYPE_CHECKING`` so this rendering leaf never imports +domain code at module level (see ``tests/test_package_architecture.py``). +Layer dataclasses are dispatched by class name at runtime for the same reason. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import numpy as np +from numpy.typing import ArrayLike + +from .common import ( + _C_EDGE, + _C_MUTED, + _C_PRIMARY, + _C_PRIMARY_LIGHT, + _C_SECONDARY, + _C_SECONDARY_LIGHT, + _new_axes, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + from ..materials.diffuser_design import DiffuserPolarResponse + from ..materials.impedance_tube import ImpedanceTubeResult, TransferMatrix + from ..materials.porous_absorber import Layer, LayeredAbsorberResult + from ..materials.slow_sound_absorber import ( + HelmholtzResonator, + SlitResonatorAbsorberResult, + ) + +#: Spanish translations of the fixed strings rendered here, keyed by their +#: verbatim English text. ``_t`` returns the English key unchanged for any +#: language other than ``"es"``. +_STRINGS: dict[str, str] = { + "Air": "Aire", + "Porous": "Poroso", + "Perforated plate": "Placa perforada", + "Microperforated plate": "Placa microperforada", + "Membrane": "Membrana", + "Rigid backing": "Respaldo rígido", + "Incident sound": "Sonido incidente", + "Layered absorber cross-section": "Sección del absorbente multicapa", + "Helmholtz resonator cross-section": "Sección del resonador de Helmholtz", + "Slit metamaterial absorber cross-section (one period)": + "Sección del absorbente metamaterial de rendija (un periodo)", + "QRD well profile": "Perfil de pozos del difusor QRD", + "Impedance tube (ISO 10534-2), to scale": + "Tubo de impedancia (ISO 10534-2), a escala", + "Transmission tube (ASTM E2611), to scale": + "Tubo de transmisión (ASTM E2611), a escala", + "Loudspeaker": "Altavoz", + "Sample": "Muestra", + "Termination": "Terminación", + "Cross-section": "Sección transversal", + "Plane-wave range {fl} to {fu} Hz": "Rango de onda plana {fl} a {fu} Hz", + "depth sequence {seq}": "secuencia de profundidades {seq}", + "Slit": "Rendija", + "mm": "mm", +} + + +def _t(text: str, language: str = "en") -> str: + """Translate a fixed UI string to Spanish, else return it unchanged.""" + return _STRINGS.get(text, text) if language == "es" else text + + +def _check_language(language: str) -> None: + """Reject unknown languages with the shared package error.""" + from .._i18n import check_language + + check_language(language) + + +def _mm(value: float, language: str) -> str: + """A length in millimetres, localised, trimmed (0.05 -> ``"50 mm"``).""" + from .._i18n import format_number + + return ( + format_number(value * 1e3, language, decimals=1, trim=True) + + " " + _t("mm", language) + ) + + +# --------------------------------------------------------------------------- +# Drafting primitives (matplotlib patches, imported lazily). +# --------------------------------------------------------------------------- +def _dim( + ax: Axes, + p1: tuple[float, float], + p2: tuple[float, float], + label: str, + *, + offset: float = 0.0, + fontsize: float = 8.0, + tight: bool = False, +) -> None: + """A drafting dimension: double-headed arrow between two points + label. + + ``offset`` displaces the dimension line perpendicular to ``p1 -> p2`` + (positive = to the left of the direction of travel), with dashed + extension lines back to the measured points. ``tight`` switches to bar + ends for spans too short for two arrowheads. + """ + a = np.asarray(p1, dtype=np.float64) + b = np.asarray(p2, dtype=np.float64) + direction = b - a + length = float(np.hypot(*direction)) + if length < 1e-12: + return + normal = np.array([-direction[1], direction[0]]) / length + ao = a + normal * offset + bo = b + normal * offset + if abs(offset) > 1e-12: + for point, moved in ((a, ao), (b, bo)): + ax.plot( + [point[0], moved[0]], [point[1], moved[1]], + linestyle=":", linewidth=0.7, color=_C_EDGE, zorder=4, + ) + style = "|-|, widthA=0.4, widthB=0.4" if tight else "<->" + ax.annotate( + "", xy=tuple(bo), xytext=tuple(ao), + arrowprops={"arrowstyle": style, "color": _C_EDGE, "linewidth": 0.9}, + zorder=5, + ) + if not label: + return + mid = (ao + bo) / 2.0 + angle = float(np.degrees(np.arctan2(direction[1], direction[0]))) + if angle > 90.0 or angle <= -90.0: + angle += 180.0 + ax.annotate( + label, xy=(mid[0], mid[1]), xytext=tuple(normal * 9.0), + textcoords="offset points", fontsize=fontsize, ha="center", + va="center", rotation=angle, rotation_mode="anchor", zorder=6, + ) + + +#: Fill styles per material kind: (facecolor, hatch, alpha). +_MATERIAL_STYLE: dict[str, tuple[str, str | None, float]] = { + "air": ("none", None, 1.0), + "porous": (_C_SECONDARY_LIGHT, "...", 0.9), + "plate": (_C_MUTED, None, 0.85), + "membrane": (_C_SECONDARY, None, 0.9), + "rigid": (_C_MUTED, "//", 0.5), + "cavity": (_C_PRIMARY_LIGHT, None, 0.5), +} + + +def _material_rect( + ax: Axes, + x: float, + y: float, + width: float, + height: float, + kind: str, + **kwargs: Any, +) -> Any: + """A material cross-section rectangle in the house style; returns it.""" + from matplotlib.patches import Rectangle + + face, hatch, alpha = _MATERIAL_STYLE[kind] + kwargs.setdefault("facecolor", face) + kwargs.setdefault("edgecolor", _C_EDGE) + kwargs.setdefault("linewidth", 0.9) + kwargs.setdefault("alpha", alpha) + patch = Rectangle((x, y), width, height, hatch=hatch, **kwargs) + ax.add_patch(patch) + return patch + + +def _incidence_arrow( + ax: Axes, x: float, y: float, length: float, language: str, + *, downward: bool = False, +) -> None: + """Incident-sound arrow pointing in +x (or -y), with its label beside.""" + tip = (x, y - length) if downward else (x + length, y) + ax.annotate( + "", xy=tip, xytext=(x, y), + arrowprops={"arrowstyle": "-|>", "color": _C_PRIMARY, + "linewidth": 1.6}, + zorder=5, + ) + label = _t("Incident sound", language) + if downward: + ax.text( + x, y + 0.08 * length, label, + fontsize=8, ha="center", va="bottom", color=_C_PRIMARY, + ) + else: + ax.text( + x + 0.5 * length, y + 0.06 * length, label, + fontsize=8, ha="center", va="bottom", color=_C_PRIMARY, + ) + + +def _microphone(ax: Axes, x: float, y: float, size: float, label: str) -> None: + """A flush wall microphone: stem + head circle + label above.""" + from matplotlib.patches import Circle + + ax.plot([x, x], [y, y + 0.55 * size], color=_C_EDGE, linewidth=1.4) + ax.add_patch( + Circle((x, y + 0.75 * size), 0.22 * size, facecolor=_C_PRIMARY, + edgecolor=_C_EDGE, linewidth=0.8, zorder=5) + ) + ax.text(x, y + 1.15 * size, label, fontsize=8, ha="center", va="bottom") + + +def _loudspeaker( + ax: Axes, x: float, y_centre: float, size: float, language: str +) -> None: + """A loudspeaker driver: magnet box + cone opening toward +x.""" + from matplotlib.patches import Polygon, Rectangle + + ax.add_patch( + Rectangle((x - 0.6 * size, y_centre - 0.25 * size), 0.35 * size, + 0.5 * size, facecolor=_C_MUTED, edgecolor=_C_EDGE, + linewidth=0.9) + ) + ax.add_patch( + Polygon( + [(x - 0.25 * size, y_centre - 0.18 * size), + (x - 0.25 * size, y_centre + 0.18 * size), + (x, y_centre + 0.48 * size), + (x, y_centre - 0.48 * size)], + closed=True, facecolor=_C_SECONDARY_LIGHT, edgecolor=_C_EDGE, + linewidth=0.9, + ) + ) + ax.text( + x - 0.42 * size, y_centre - 0.62 * size, _t("Loudspeaker", language), + fontsize=8, ha="center", va="top", + ) + + +def _finish_geometry_axes(ax: Axes, title: str) -> None: + """Equal aspect, no spines/ticks, padded autoscale, bold title.""" + ax.set_aspect("equal", adjustable="datalim") + ax.autoscale() + ax.margins(0.12) + ax.set_axis_off() + ax.set_title(title, fontweight="bold") + + +def _tube_frequency_note( + ax: Axes, + x: float, + y: float, + f_range: tuple[float, float], + language: str, +) -> None: + """The plane-wave working range printed under a tube drawing.""" + from .._i18n import format_number + + fl, fu = f_range + ax.text( + x, y, + _t("Plane-wave range {fl} to {fu} Hz", language).format( + fl=format_number(fl, language, decimals=0, trim=True), + fu=format_number(fu, language, decimals=0, trim=True), + ), + fontsize=8, ha="center", va="top", + ) + + +def _tube_bore( + ax: Axes, + x_left: float, + x_right: float, + bore: float, + language: str, + *, + shape: str | None, + diameter_known: bool, + **kwargs: Any, +) -> Any: + """Tube walls + bore + cross-section emblem; returns the primary patch. + + The bore spans ``y in [0, bore]``; walls are drawn just outside it. The + cross-section emblem (circle or square, to the same scale) sits left of + the tube with the inner dimension dimensioned when it is known. + """ + from matplotlib.patches import Circle, Rectangle + + wall = max(0.06 * bore, 0.004) + kwargs.setdefault("facecolor", "none") + kwargs.setdefault("edgecolor", _C_EDGE) + kwargs.setdefault("linewidth", 1.6) + primary = Rectangle( + (x_left, 0.0), x_right - x_left, bore, **kwargs + ) + ax.add_patch(primary) + for y_wall in (-wall, bore): + ax.add_patch( + Rectangle((x_left, y_wall), x_right - x_left, wall, + facecolor=_C_MUTED, edgecolor=_C_EDGE, linewidth=0.6, + alpha=0.5) + ) + # Cross-section emblem, same scale, centred on the bore axis. + cx = x_left - 2.4 * bore + cy = 0.5 * bore + if shape == "circular": + ax.add_patch( + Circle((cx, cy), 0.5 * bore, facecolor="none", + edgecolor=_C_EDGE, linewidth=1.2) + ) + else: + ax.add_patch( + Rectangle((cx - 0.5 * bore, cy - 0.5 * bore), bore, bore, + facecolor="none", edgecolor=_C_EDGE, linewidth=1.2) + ) + ax.text( + cx, cy - 0.72 * bore, _t("Cross-section", language), fontsize=8, + ha="center", va="top", + ) + if diameter_known: + _dim(ax, (cx - 0.5 * bore, cy), (cx + 0.5 * bore, cy), + _mm(bore, language)) + return primary + + +# --------------------------------------------------------------------------- +# Layered absorber stack. +# --------------------------------------------------------------------------- +#: Nominal drawn thickness of a zero-thickness membrane, as a fraction of the +#: total stack depth (a membrane has surface density but no depth of its own). +_MEMBRANE_DRAW_FRACTION = 0.012 + + +def _layer_kind_and_thickness(layer: Any, total: float) -> tuple[str, float, str]: + """Map a layer dataclass to (style kind, drawn thickness, label key). + + Dispatch is by class name so this rendering leaf never imports the domain + dataclasses at runtime. + """ + name = type(layer).__name__ + if name == "AirLayer": + return "air", float(layer.thickness), "Air" + if name == "PorousLayer": + return "porous", float(layer.thickness), "Porous" + if name == "PerforatedPlateLayer": + return "plate", float(layer.thickness), "Perforated plate" + if name == "MicroperforatedPlateLayer": + return "plate", float(layer.thickness), "Microperforated plate" + if name == "MembraneLayer": + return "membrane", _MEMBRANE_DRAW_FRACTION * total, "Membrane" + raise TypeError(f"Unsupported layer type: {name!r}.") + + +def _stack_total_depth(layers: Sequence[Any]) -> float: + """Physical depth of the stack (membranes contribute zero).""" + return float(sum(getattr(layer, "thickness", 0.0) for layer in layers)) + + +def _draw_plate_holes( + ax: Axes, layer: Any, x: float, thickness: float, height: float +) -> None: + """Carve the hole pattern of a (micro)perforated plate, to scale. + + Holes of diameter ``2 r`` at the square-lattice pitch that reproduces the + plate's ``open_area`` (``b = 2 r sqrt(pi / (4 sigma))``). + """ + radius = float(layer.hole_radius) + sigma = float(layer.open_area) + if radius <= 0.0 or sigma <= 0.0: + return + pitch = 2.0 * radius * float(np.sqrt(np.pi / (4.0 * sigma))) + n = int(height // pitch) + if n < 1: + return + y0 = 0.5 * (height - (n - 1) * pitch) + for i in range(n): + _material_rect( + ax, x, y0 + i * pitch - radius, thickness, 2.0 * radius, "cavity", + edgecolor="none", + ) + + +def plot_absorber_stack( + layers: Sequence[Layer] | Layer, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw a layered-absorber cross-section to scale, rigid backing at right. + + Sound arrives from the left; each layer is drawn with its material fill + and its thickness dimensioned below the stack. A membrane (no physical + depth) is drawn as a thin sheet. + + :param layers: The layer sequence of + :func:`~phonometry.materials.layered_absorber`, front layer first, or + a single layer. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the front-layer rectangle. + :return: The axes. + """ + _check_language(language) + stack = list(layers) if isinstance(layers, Sequence) else [layers] + if not stack: + raise ValueError("'layers' must contain at least one layer.") + total = _stack_total_depth(stack) + if total <= 0.0: + total = 0.05 + # Dispatching every layer up front also validates the types before any + # figure is created. + kinds = [_layer_kind_and_thickness(layer, total) for layer in stack] + if ax is None: + ax = _new_axes() + height = 0.9 * total + thin = 0.14 * total + x = 0.0 + for index, (layer, dispatched) in enumerate(zip(stack, kinds)): + kind, drawn, label_key = dispatched + extra = dict(kwargs) if index == 0 else {} + _material_rect(ax, x, 0.0, drawn, height, kind, **extra) + if kind == "plate": + _draw_plate_holes(ax, layer, x, drawn, height) + label = _t(label_key, language) + physical = float(getattr(layer, "thickness", 0.0)) + if physical > 0.0 and drawn >= thin: + _dim( + ax, (x, 0.0), (x + drawn, 0.0), _mm(physical, language), + offset=-(0.10 * height + 0.14 * height * (index % 2)), + ) + elif physical > 0.0: + # Too thin for a dimension line: fold it into the label. + label = f"{label}, {_mm(physical, language)}" + centre = x + 0.5 * drawn + y_label = height * (1.03 + 0.08 * (index % 2)) + ax.text(centre, y_label, label, fontsize=8, ha="center", va="bottom") + if drawn < thin: + ax.plot( + [centre, centre], [height, y_label], + linestyle=":", linewidth=0.7, color=_C_EDGE, zorder=4, + ) + x += drawn + backing = 0.10 * total + _material_rect(ax, x, -0.05 * height, backing, 1.1 * height, "rigid") + ax.text( + x + 0.5 * backing, height * 1.11, _t("Rigid backing", language), + fontsize=8, ha="center", va="bottom", + ) + _incidence_arrow(ax, -0.55 * total, 0.5 * height, 0.35 * total, language) + _finish_geometry_axes(ax, _t("Layered absorber cross-section", language)) + return ax + + +# --------------------------------------------------------------------------- +# Helmholtz resonator and the slit metamaterial absorber. +# --------------------------------------------------------------------------- +def _draw_resonator( + ax: Axes, + x_mouth: float, + y_mouth: float, + resonator: Any, + *, + wall: float, +) -> tuple[float, float]: + """Draw one square-section resonator hanging below its neck mouth. + + The neck opens upward at ``(x_mouth, y_mouth)`` (its centre); the cavity + sits below the neck. Returns the total (width, depth) drawn, wall + included, for autoscaling by the caller. + """ + w_n = float(resonator.neck_side) + l_n = float(resonator.neck_length) + w_c = float(resonator.cavity_side) + l_c = float(resonator.cavity_length) + # Solid body around the air volume. + body_w = w_c + 2.0 * wall + body_d = l_n + l_c + wall + _material_rect( + ax, x_mouth - 0.5 * body_w, y_mouth - body_d, body_w, body_d, "rigid", + linewidth=0.7, + ) + # Air volume: neck then cavity. + _material_rect( + ax, x_mouth - 0.5 * w_n, y_mouth - l_n, w_n, l_n, "cavity", + edgecolor=_C_EDGE, + ) + _material_rect( + ax, x_mouth - 0.5 * w_c, y_mouth - l_n - l_c, w_c, l_c, "cavity", + edgecolor=_C_EDGE, + ) + return body_w, body_d + + +def plot_helmholtz_resonator_geometry( + resonator: HelmholtzResonator, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw a square-section Helmholtz resonator cross-section, to scale. + + Neck opening upward into free air, cavity below, with the four defining + dimensions (neck side and length, cavity side and length) dimensioned. + + :param resonator: A + :class:`~phonometry.materials.slow_sound_absorber.HelmholtzResonator`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the cavity rectangle. + :return: The axes. + """ + _check_language(language) + if ax is None: + ax = _new_axes() + w_n = float(resonator.neck_side) + l_n = float(resonator.neck_length) + w_c = float(resonator.cavity_side) + l_c = float(resonator.cavity_length) + wall = 0.06 * max(w_c, l_n + l_c) + _draw_resonator(ax, 0.0, 0.0, resonator, wall=wall) + if kwargs: + _material_rect( + ax, -0.5 * w_c, -l_n - l_c, w_c, l_c, "cavity", **kwargs + ) + off = 0.12 * max(w_c, l_n + l_c) + _dim(ax, (-0.5 * w_n, 0.0), (0.5 * w_n, 0.0), _mm(w_n, language), + offset=2.0 * off) + _dim(ax, (0.5 * w_c + wall, 0.0), (0.5 * w_c + wall, -l_n), + _mm(l_n, language), offset=-2.0 * off) + _dim(ax, (0.5 * w_c + wall, -l_n), (0.5 * w_c + wall, -l_n - l_c), + _mm(l_c, language), offset=-off) + _dim(ax, (-0.5 * w_c, -l_n - l_c - wall), (0.5 * w_c, -l_n - l_c - wall), + _mm(w_c, language), offset=-off) + _finish_geometry_axes( + ax, _t("Helmholtz resonator cross-section", language) + ) + return ax + + +def plot_slit_absorber_geometry( + resonators: Sequence[HelmholtzResonator] | HelmholtzResonator, + ax: Axes | None = None, + *, + slit_height: float, + lattice_step: float, + period: float, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw one period of the slit metamaterial absorber, to scale. + + Side cut of the panel: the slit (height ``h``) runs from the mouth at the + left into the panel; ``N`` Helmholtz resonators load it from below at the + lattice step ``a`` (total depth ``L = N a``); the panel repeats vertically + with ``period`` ``d``; rigid back wall at the right. + + :param resonators: The resonator chain of + :func:`~phonometry.materials.slit_helmholtz_absorber` (one per + lattice step, or a single resonator reused for all steps). + :param ax: Existing axes, or ``None`` to create a figure. + :param slit_height: Slit height ``h``, in metres. + :param lattice_step: Lattice step ``a``, in metres. + :param period: Panel period ``d``, in metres. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the slit rectangle. + :return: The axes. + """ + _check_language(language) + if slit_height <= 0.0 or lattice_step <= 0.0 or period <= 0.0: + raise ValueError( + "'slit_height', 'lattice_step' and 'period' must be positive." + ) + chain = ( + list(resonators) + if isinstance(resonators, Sequence) + else [resonators] + ) + if not chain: + raise ValueError("'resonators' must contain at least one resonator.") + if ax is None: + ax = _new_axes() + n = len(chain) + depth = n * lattice_step + h = slit_height + d = period + wall = 0.05 * h + # Panel slab (one period tall): slit at the top of the cell. + y_slit = d - h + _material_rect(ax, 0.0, 0.0, depth, d, "rigid", linewidth=0.7, alpha=0.35) + _material_rect(ax, 0.0, y_slit, depth, h, "cavity", **kwargs) + # The slit can be a fraction of a millimetre: label it from above with a + # dotted leader instead of squeezing text inside it. + x_label = 0.3 * depth + ax.text( + x_label, d + 0.10 * d, _t("Slit", language), fontsize=8, + ha="center", va="bottom", + ) + ax.plot( + [x_label, x_label], [y_slit + h, d + 0.10 * d], + linestyle=":", linewidth=0.7, color=_C_EDGE, zorder=4, + ) + for index, resonator in enumerate(chain): + x_mouth = (index + 0.5) * lattice_step + _draw_resonator(ax, x_mouth, y_slit, resonator, wall=wall) + # Rigid back wall. + back = 0.08 * depth + _material_rect(ax, depth, -0.05 * d, back, 1.1 * d, "rigid") + _incidence_arrow( + ax, -0.75 * depth, y_slit + 0.5 * h, 0.45 * depth, language + ) + off = 0.08 * d + _dim(ax, (0.0, d), (depth, d), _mm(depth, language), offset=2.0 * off) + _dim(ax, (0.72 * depth, y_slit), (0.72 * depth, y_slit + h), + _mm(h, language), tight=True) + _dim(ax, (depth + back, 0.0), (depth + back, d), _mm(d, language), + offset=-off) + if n > 1: + _dim(ax, (0.0, 0.0), (lattice_step, 0.0), _mm(lattice_step, language), + offset=-off) + _finish_geometry_axes( + ax, + _t("Slit metamaterial absorber cross-section (one period)", language), + ) + return ax + + +# --------------------------------------------------------------------------- +# QRD well profile. +# --------------------------------------------------------------------------- +def plot_qrd_geometry( + depths: ArrayLike, + well_width: float, + ax: Axes | None = None, + *, + periods: int = 1, + fin_width: float | None = None, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw the well profile of a quadratic-residue diffuser, to scale. + + Wells open upward; the profile repeats ``periods`` times with thin fins + between wells. Pairs with + :func:`~phonometry.materials.qrd_well_depths`, which supplies the depth + sequence. + + :param depths: Well depths ``d_n``, in metres (one period). + :param well_width: Well width ``w``, in metres. + :param ax: Existing axes, or ``None`` to create a figure. + :param periods: Number of repeated periods (>= 1). + :param fin_width: Fin thickness between wells, in metres; ``None`` draws + ``w / 12``. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the base-slab rectangle. + :return: The axes. + """ + _check_language(language) + d = np.asarray(depths, dtype=np.float64) + if d.ndim != 1 or d.size == 0: + raise ValueError("'depths' must be a non-empty 1-D sequence.") + if np.any(d < 0.0): + raise ValueError("'depths' must be non-negative.") + if well_width <= 0.0: + raise ValueError("'well_width' must be positive.") + if periods < 1: + raise ValueError("'periods' must be >= 1.") + fin = well_width / 12.0 if fin_width is None else float(fin_width) + if fin < 0.0: + raise ValueError("'fin_width' must be non-negative.") + if ax is None: + ax = _new_axes() + n = int(d.size) + d_max = float(d.max()) if float(d.max()) > 0.0 else well_width + base = 0.15 * d_max + pitch = well_width + fin + total_width = periods * n * pitch + fin + # Base slab behind the deepest well. + _material_rect( + ax, 0.0, -d_max - base, total_width, base, "rigid", **kwargs + ) + # Fins and well bottoms: the solid between the carved wells. + x = 0.0 + for _period in range(periods): + for depth in d: + _material_rect(ax, x, -d_max, fin, d_max, "plate", + linewidth=0.6) + x += fin + if d_max - depth > 0.0: + _material_rect( + ax, x, -d_max, well_width, d_max - float(depth), "plate", + linewidth=0.6, + ) + x += well_width + _material_rect(ax, x, -d_max, fin, d_max, "plate", linewidth=0.6) + _incidence_arrow( + ax, 0.5 * total_width, 1.1 * d_max, 0.55 * d_max, language, + downward=True, + ) + off = 0.12 * d_max + _dim(ax, (fin, 0.0), (fin + well_width, 0.0), _mm(well_width, language), + offset=1.5 * off) + _dim(ax, (0.0, 0.0), (0.0, -d_max), _mm(d_max, language), + offset=-3.0 * off) + from .._i18n import format_number + + seq = ", ".join( + format_number(float(v * 1e3), language, decimals=1, trim=True) + for v in d + ) + ax.text( + 0.5 * total_width, -d_max - 1.6 * base, + _t("depth sequence {seq}", language).format(seq=seq) + " " + + _t("mm", language), + fontsize=8, ha="center", va="top", color=_C_EDGE, + ) + _finish_geometry_axes(ax, _t("QRD well profile", language)) + return ax + + +# --------------------------------------------------------------------------- +# Impedance tube (ISO 10534-2) and transmission tube (ASTM E2611). +# --------------------------------------------------------------------------- +#: ISO 10534-2 4.3: non-plane source modes die out within about three tube +#: diameters, so the drawn source section keeps that margin before the first +#: microphone. +_SOURCE_MARGIN_DIAMETERS = 3.0 + +#: Drawn sample thickness when the caller does not supply one, in metres. +_NOMINAL_SAMPLE_THICKNESS = 0.05 + + +def _nominal_bore(diameter: float | None, fallback: float) -> float: + """The drawn bore: the real diameter, or a nominal stand-in.""" + return float(diameter) if diameter is not None else fallback + + +def plot_impedance_tube_geometry( + ax: Axes | None = None, + *, + spacing: float, + x1: float, + diameter: float | None = None, + shape: str | None = "circular", + sample_thickness: float | None = None, + speed_of_sound: float = 343.2, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw the ISO 10534-2 two-microphone tube to scale. + + Side view: loudspeaker at the left (three tube diameters before the + farther microphone, the Clause 4.3 margin), the two flush microphones at + ``x1`` and ``x1 - s`` from the sample face, the sample against its rigid + backing at the right, the cross-section emblem, and the plane-wave + working range of :func:`~phonometry.materials.plane_wave_frequency_range`. + + :param ax: Existing axes, or ``None`` to create a figure. + :param spacing: Microphone spacing ``s``, in metres. + :param x1: Distance from the sample face to the farther microphone, in + metres. + :param diameter: Inner diameter (circular) or lateral dimension + (rectangular/square), in metres; ``None`` draws a nominal bore and + omits the bore dimension and the cut-on bound. + :param shape: ``"circular"``, ``"rectangular"``, ``"square"`` or ``None``. + :param sample_thickness: Drawn sample thickness, in metres; ``None`` + draws a 50 mm nominal sample. + :param speed_of_sound: Speed of sound for the working range, in m/s. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the tube-bore rectangle. + :return: The axes. + """ + from ..materials.impedance_tube import plane_wave_frequency_range + + _check_language(language) + if spacing <= 0.0 or x1 <= spacing: + raise ValueError("'spacing' must be positive and 'x1' > 'spacing'.") + if diameter is not None and diameter <= 0.0: + raise ValueError("'diameter' must be positive when given.") + if sample_thickness is not None and sample_thickness <= 0.0: + raise ValueError("'sample_thickness' must be positive when given.") + if ax is None: + ax = _new_axes() + bore = _nominal_bore(diameter, 1.5 * spacing) + thickness = ( + _NOMINAL_SAMPLE_THICKNESS + if sample_thickness is None else float(sample_thickness) + ) + lead_in = _SOURCE_MARGIN_DIAMETERS * bore + x_left = -(x1 + lead_in) + x_right = thickness + 0.35 * bore + emblem = "circular" if shape in (None, "circular") else "rectangular" + _tube_bore( + ax, x_left, x_right, bore, language, shape=emblem, + diameter_known=diameter is not None, **kwargs, + ) + # Sample against the rigid backing plug: front face at x = 0. + _material_rect(ax, 0.0, 0.0, thickness, bore, "porous") + _material_rect(ax, thickness, 0.0, x_right - thickness, bore, "rigid") + ax.text(0.5 * thickness, -0.16 * bore, _t("Sample", language), + fontsize=8, ha="center", va="top") + _loudspeaker(ax, x_left + 0.1 * bore, 0.5 * bore, bore, language) + wall = max(0.06 * bore, 0.004) + _microphone(ax, -x1, bore + wall, 0.5 * bore, "1") + _microphone(ax, -(x1 - spacing), bore + wall, 0.5 * bore, "2") + off = 0.3 * bore + y_dim = -0.5 * bore + _dim(ax, (-x1, y_dim), (-(x1 - spacing), y_dim), + "s = " + _mm(spacing, language), + tight=spacing < 0.12 * (x_right - x_left)) + _dim(ax, (-x1, y_dim - 2.0 * off), (0.0, y_dim - 2.0 * off), + "x1 = " + _mm(x1, language)) + f_range = plane_wave_frequency_range( + spacing, speed_of_sound, diameter=diameter, + shape=shape if shape is not None else "circular", + ) + _tube_frequency_note( + ax, 0.5 * (x_left + x_right), y_dim - 3.6 * off, f_range, language + ) + _finish_geometry_axes( + ax, _t("Impedance tube (ISO 10534-2), to scale", language) + ) + return ax + + +def plot_transmission_tube_geometry( + ax: Axes | None = None, + *, + l1: float, + s1: float, + l2: float, + s2: float, + thickness: float, + diameter: float | None = None, + shape: str | None = "circular", + speed_of_sound: float = 343.2, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Draw the ASTM E2611 four-microphone transmission tube to scale. + + Side view: loudspeaker, the two upstream microphones at ``l1`` and + ``l1 + s1`` from the front face of the specimen, the specimen spanning + its thickness, the two downstream microphones at ``l2`` and ``l2 + s2`` + (measured from the front face, the module's locked convention), and the + changeable termination of the two-load method, with the ASTM working + range of + :func:`~phonometry.materials.plane_wave_frequency_range_astm`. + + :param ax: Existing axes, or ``None`` to create a figure. + :param l1: Front face to the nearer upstream microphone, in metres. + :param s1: Upstream microphone spacing, in metres. + :param l2: Front face to the nearer downstream microphone, in metres. + :param s2: Downstream microphone spacing, in metres. + :param thickness: Specimen thickness, in metres; must be smaller than + ``l2`` (the downstream microphones sit past the back face). + :param diameter: Inner diameter (circular) or largest section dimension + (rectangular/square), in metres; ``None`` draws a nominal bore and + omits the bore dimension and the cut-on bound. + :param shape: ``"circular"``, ``"rectangular"``, ``"square"`` or ``None``. + :param speed_of_sound: Speed of sound for the working range, in m/s. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the tube-bore rectangle. + :return: The axes. + """ + from ..materials.impedance_tube import plane_wave_frequency_range_astm + + _check_language(language) + if min(l1, s1, l2, s2, thickness) <= 0.0: + raise ValueError( + "'l1', 's1', 'l2', 's2' and 'thickness' must be positive." + ) + if diameter is not None and diameter <= 0.0: + raise ValueError("'diameter' must be positive when given.") + if l2 <= thickness: + raise ValueError( + "'l2' is measured from the front face and must exceed " + "'thickness'." + ) + if ax is None: + ax = _new_axes() + bore = _nominal_bore(diameter, 1.5 * max(s1, s2)) + lead_in = _SOURCE_MARGIN_DIAMETERS * bore + x_left = -(l1 + s1 + lead_in) + x_term = l2 + s2 + 0.8 * bore + x_right = x_term + 0.6 * bore + emblem = "circular" if shape in (None, "circular") else "rectangular" + _tube_bore( + ax, x_left, x_right, bore, language, shape=emblem, + diameter_known=diameter is not None, **kwargs, + ) + _material_rect(ax, 0.0, 0.0, thickness, bore, "porous") + ax.text(0.5 * thickness, bore + 0.22 * bore, _t("Sample", language), + fontsize=8, ha="center", va="bottom") + _loudspeaker(ax, x_left + 0.1 * bore, 0.5 * bore, bore, language) + # Changeable termination of the two-load method. + _material_rect(ax, x_term, -0.1 * bore, x_right - x_term, 1.2 * bore, + "plate") + ax.text(0.5 * (x_term + x_right), -0.28 * bore, + _t("Termination", language), fontsize=8, ha="center", va="top") + wall = max(0.06 * bore, 0.004) + positions = ( + (-(l1 + s1), "1"), (-l1, "2"), (l2, "3"), (l2 + s2, "4"), + ) + for x_mic, label in positions: + _microphone(ax, x_mic, bore + wall, 0.5 * bore, label) + y_dim = -0.62 * bore + off = 0.3 * bore + width = x_right - x_left + _dim(ax, (-(l1 + s1), y_dim), (-l1, y_dim), + "s1 = " + _mm(s1, language), tight=s1 < 0.12 * width) + _dim(ax, (l2, y_dim), (l2 + s2, y_dim), "s2 = " + _mm(s2, language), + tight=s2 < 0.12 * width) + _dim(ax, (-l1, y_dim - 2.0 * off), (0.0, y_dim - 2.0 * off), + "l1 = " + _mm(l1, language)) + _dim(ax, (0.0, y_dim - 4.0 * off), (l2, y_dim - 4.0 * off), + "l2 = " + _mm(l2, language)) + f_range = plane_wave_frequency_range_astm( + max(s1, s2), speed_of_sound, diameter=diameter, + shape=shape if shape is not None else "circular", + ) + _tube_frequency_note( + ax, 0.5 * (x_left + x_right), y_dim - 5.6 * off, f_range, language + ) + _finish_geometry_axes( + ax, _t("Transmission tube (ASTM E2611), to scale", language) + ) + return ax + + +# --------------------------------------------------------------------------- +# Renderers bound to result objects (geometry retained on the result). +# --------------------------------------------------------------------------- +def plot_layered_absorber_geometry( + result: LayeredAbsorberResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Stack drawing for a result that retained its ``layers``.""" + if result.layers is None: + raise ValueError( + "This result does not retain its layers; call " + "plot_absorber_stack(layers) with the original layer sequence." + ) + return plot_absorber_stack( + result.layers, ax=ax, language=language, **kwargs + ) + + +def plot_slit_absorber_result_geometry( + result: SlitResonatorAbsorberResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Slit-panel drawing for a result that retained its geometry.""" + if ( + result.resonators is None + or result.slit_height is None + or result.lattice_step is None + or result.period is None + ): + raise ValueError( + "This result does not retain its geometry; call " + "plot_slit_absorber_geometry(...) with the original arguments." + ) + return plot_slit_absorber_geometry( + result.resonators, ax=ax, slit_height=result.slit_height, + lattice_step=result.lattice_step, period=result.period, + language=language, **kwargs, + ) + + +def plot_diffuser_geometry( + result: DiffuserPolarResponse, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """QRD well profile for a polar response that retained its geometry.""" + if result.depths is None or result.well_width is None: + raise ValueError( + "This response does not retain its well geometry; call " + "plot_qrd_geometry(depths, well_width) instead." + ) + return plot_qrd_geometry( + result.depths, result.well_width, ax=ax, + periods=result.periods if result.periods is not None else 1, + language=language, **kwargs, + ) + + +def plot_impedance_tube_result_geometry( + result: ImpedanceTubeResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Tube drawing for an ISO 10534-2 result that retained its geometry.""" + if result.spacing is None or result.x1 is None: + raise ValueError( + "This result does not retain its tube geometry; call " + "plot_impedance_tube_geometry(...) with the original arguments." + ) + return plot_impedance_tube_geometry( + ax=ax, spacing=result.spacing, x1=result.x1, + diameter=result.diameter, shape=result.shape, language=language, + **kwargs, + ) + + +def plot_transfer_matrix_geometry( + result: TransferMatrix, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Tube drawing for an ASTM E2611 matrix that retained its context.""" + if ( + result.l1 is None or result.s1 is None or result.l2 is None + or result.s2 is None or result.thickness is None + ): + raise ValueError( + "This matrix does not retain its tube geometry; call " + "plot_transmission_tube_geometry(...) with the original " + "arguments." + ) + return plot_transmission_tube_geometry( + ax=ax, l1=result.l1, s1=result.s1, l2=result.l2, s2=result.s2, + thickness=result.thickness, diameter=result.diameter, + shape=result.shape, language=language, **kwargs, + ) diff --git a/src/phonometry/materials/__init__.py b/src/phonometry/materials/__init__.py index 3fd697c2e..5e536e23d 100644 --- a/src/phonometry/materials/__init__.py +++ b/src/phonometry/materials/__init__.py @@ -3,6 +3,14 @@ from __future__ import annotations +from .._plot.geometry import ( + plot_absorber_stack, + plot_helmholtz_resonator_geometry, + plot_impedance_tube_geometry, + plot_qrd_geometry, + plot_slit_absorber_geometry, + plot_transmission_tube_geometry, +) from .absorption_rating import ( OCTAVE_BANDS, REFERENCE_CURVE, @@ -299,6 +307,12 @@ "piston_volume_flow_rate", "plane_wave_frequency_range", "plane_wave_frequency_range_astm", + "plot_absorber_stack", + "plot_helmholtz_resonator_geometry", + "plot_impedance_tube_geometry", + "plot_qrd_geometry", + "plot_slit_absorber_geometry", + "plot_transmission_tube_geometry", "power_reflection_coefficient", "practical_absorption_coefficient", "practical_coefficient_uncertainty", diff --git a/src/phonometry/materials/diffuser_design.py b/src/phonometry/materials/diffuser_design.py index 616a942b2..fafda6afe 100644 --- a/src/phonometry/materials/diffuser_design.py +++ b/src/phonometry/materials/diffuser_design.py @@ -245,6 +245,14 @@ class DiffuserPolarResponse: :ivar coefficient: Directional diffusion coefficient ``d_theta`` of the predicted response (ISO 17497-2, Formula (5)). :ivar source_angle: Angle of incidence ``psi`` of the source, in degrees. + :ivar well_width: Well width ``w`` of the predicted surface, in metres, + always retained by the predictor (with ``periods``) so + :meth:`plot_geometry` can draw the well profile; appended after the + original fields and ``None`` only for hand-built responses. + :ivar depths: Well depths ``d_n`` of one period, in metres, when the + response was predicted from depths; ``None`` otherwise (explicit + ``reflection`` surfaces have no drawable well profile). + :ivar periods: Number of repeated periods of the prediction. """ frequency: float @@ -252,6 +260,9 @@ class DiffuserPolarResponse: levels: Real coefficient: float source_angle: float = 0.0 + well_width: float | None = None + depths: Real | None = None + periods: int | None = None def plot( self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any @@ -267,6 +278,24 @@ def plot( check_language(language) return plot_diffuser_polar_response(self, ax=ax, language=language, **kwargs) + def plot_geometry( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw the well profile of the predicted surface to scale. + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + + :raises ValueError: If the response does not retain its well + geometry (hand-built, or predicted from an explicit + ``reflection`` sequence). + """ + from .._i18n import check_language + from .._plot.geometry import plot_diffuser_geometry + + check_language(language) + return plot_diffuser_geometry(self, ax=ax, language=language, **kwargs) + def _resolve_reflection( depths: ArrayLike | None, @@ -390,6 +419,12 @@ def predict_diffuser_polar_response( levels=levels, coefficient=coefficient, source_angle=psi, + well_width=w, + depths=( + np.asarray(depths, dtype=np.float64) if depths is not None + else None + ), + periods=n_periods, ) diff --git a/src/phonometry/materials/impedance_tube.py b/src/phonometry/materials/impedance_tube.py index 1e4f47189..deffd60a8 100644 --- a/src/phonometry/materials/impedance_tube.py +++ b/src/phonometry/materials/impedance_tube.py @@ -615,6 +615,25 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) - check_language(language) return plot_impedance_tube(self, ax=ax, language=language, **kwargs) + def plot_geometry( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw the two-microphone tube to scale (dimensioned side view). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + + :raises ValueError: If the result does not retain its tube geometry + (``spacing``/``x1``). + """ + from .._i18n import check_language + from .._plot.geometry import plot_impedance_tube_result_geometry + + check_language(language) + return plot_impedance_tube_result_geometry( + self, ax=ax, language=language, **kwargs + ) + def report( self, path: str, @@ -1160,6 +1179,25 @@ def plot( language=language, **kwargs, ) + def plot_geometry( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw the four-microphone tube to scale (dimensioned side view). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + + :raises ValueError: If the matrix does not retain its tube geometry + (``l1``/``s1``/``l2``/``s2``/``thickness``). + """ + from .._i18n import check_language + from .._plot.geometry import plot_transfer_matrix_geometry + + check_language(language) + return plot_transfer_matrix_geometry( + self, ax=ax, language=language, **kwargs + ) + def air_layer_transfer_matrix( wavenumber: ArrayLike, thickness: float, characteristic_impedance: float diff --git a/src/phonometry/materials/porous_absorber.py b/src/phonometry/materials/porous_absorber.py index 5a3bad400..d0f139e4d 100644 --- a/src/phonometry/materials/porous_absorber.py +++ b/src/phonometry/materials/porous_absorber.py @@ -67,7 +67,7 @@ import warnings from collections.abc import Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np from numpy.typing import ArrayLike, NDArray @@ -679,15 +679,41 @@ def membrane_resonance_frequency( # --------------------------------------------------------------------------- # Declarative layers and the transfer-matrix solver # --------------------------------------------------------------------------- +class _DrawableLayer: + """Shared geometry drawing for the layer dataclasses. + + ``plot()`` draws the layer as a one-layer stack cross-section, to scale, + against the rigid backing; a full stack is drawn by + :func:`~phonometry.materials.plot_absorber_stack` or by + :meth:`LayeredAbsorberResult.plot_geometry`. + """ + + def plot( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw this layer's cross-section to scale (dimensioned). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + """ + from .._i18n import check_language + from .._plot.geometry import plot_absorber_stack + + check_language(language) + return plot_absorber_stack( + [cast("Layer", self)], ax=ax, language=language, **kwargs + ) + + @dataclass(frozen=True) -class AirLayer: +class AirLayer(_DrawableLayer): """A plain air gap of ``thickness`` metres inside the stack.""" thickness: float @dataclass(frozen=True) -class PorousLayer: +class PorousLayer(_DrawableLayer): """A porous layer of ``thickness`` metres described by *medium*. ``medium`` is a :class:`PorousMediumResult` (from :func:`delany_bazley`, @@ -701,7 +727,7 @@ class PorousLayer: @dataclass(frozen=True) -class PerforatedPlateLayer: +class PerforatedPlateLayer(_DrawableLayer): """A rigid perforated plate (see :func:`perforated_plate_impedance`).""" thickness: float @@ -711,7 +737,7 @@ class PerforatedPlateLayer: @dataclass(frozen=True) -class MicroperforatedPlateLayer: +class MicroperforatedPlateLayer(_DrawableLayer): """A microperforated plate (see :func:`microperforated_plate_impedance`).""" thickness: float @@ -721,7 +747,7 @@ class MicroperforatedPlateLayer: @dataclass(frozen=True) -class MembraneLayer: +class MembraneLayer(_DrawableLayer): """A limp impervious membrane (see :func:`membrane_impedance`).""" surface_density: float @@ -748,6 +774,11 @@ class LayeredAbsorberResult: ``alpha(theta) = 1 - |R|^2`` and ``transfer_matrix`` the total chain matrix with shape ``(2, 2, len(frequency))`` (unimodular: every layer is reciprocal). + + ``layers`` retains the layer sequence the stack was solved with (front + layer first) so :meth:`plot_geometry` can draw the cross-section; it is + appended after the original fields and defaults to ``None`` for + hand-built results. """ frequency: Real @@ -757,6 +788,7 @@ class LayeredAbsorberResult: reflection: Complex absorption: Real transfer_matrix: Complex + layers: tuple[Layer, ...] | None = None def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) -> Axes: """Plot the absorption spectrum ``alpha(f)`` with ``|R|`` overlaid. @@ -770,6 +802,24 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) - check_language(language) return plot_layered_absorber(self, ax=ax, language=language, **kwargs) + def plot_geometry( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw the solved stack cross-section to scale (dimensioned). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + + :raises ValueError: If the result does not retain its ``layers``. + """ + from .._i18n import check_language + from .._plot.geometry import plot_layered_absorber_geometry + + check_language(language) + return plot_layered_absorber_geometry( + self, ax=ax, language=language, **kwargs + ) + @dataclass(frozen=True) class DiffuseFieldAbsorptionResult: @@ -1068,6 +1118,7 @@ def layered_absorber( reflection=np.asarray(r, dtype=np.complex128), absorption=np.asarray(alpha, dtype=np.float64), transfer_matrix=tm, + layers=tuple(layers), ) diff --git a/src/phonometry/materials/slow_sound_absorber.py b/src/phonometry/materials/slow_sound_absorber.py index 67c60df6b..269057abf 100644 --- a/src/phonometry/materials/slow_sound_absorber.py +++ b/src/phonometry/materials/slow_sound_absorber.py @@ -118,6 +118,22 @@ class HelmholtzResonator: cavity_length: float cavity_side: float + def plot( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw the resonator cross-section to scale (dimensioned). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + """ + from .._i18n import check_language + from .._plot.geometry import plot_helmholtz_resonator_geometry + + check_language(language) + return plot_helmholtz_resonator_geometry( + self, ax=ax, language=language, **kwargs + ) + # --------------------------------------------------------------------------- # Visco-thermal effective parameters @@ -364,6 +380,11 @@ class SlitResonatorAbsorberResult: ``effective_wavenumber`` and ``effective_impedance`` the retrieved ``k_eff`` and ``Z_eff`` (Appl. Sci. 2017 Eq. (5)), and ``transfer_matrix`` the total 2x2 chain matrix with shape ``(2, 2, len(frequency))``. + + The trailing fields retain the panel geometry the prediction was run + with (``resonators``, ``slit_height``, ``lattice_step``, ``period``) so + :meth:`plot_geometry` can draw the cross-section; they are appended after + the original fields and default to ``None`` for hand-built results. """ frequency: Real @@ -375,6 +396,10 @@ class SlitResonatorAbsorberResult: effective_wavenumber: Complex effective_impedance: Complex transfer_matrix: Complex + resonators: tuple[HelmholtzResonator, ...] | None = None + slit_height: float | None = None + lattice_step: float | None = None + period: float | None = None def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) -> Axes: """Plot the absorption spectrum ``alpha(f)`` with ``|R|`` overlaid. @@ -388,6 +413,24 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any) - check_language(language) return plot_slit_resonator_absorber(self, ax=ax, language=language, **kwargs) + def plot_geometry( + self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any + ) -> Axes: + """Draw one period of the panel cross-section to scale (dimensioned). + + Requires matplotlib (``pip install phonometry[plot]``); returns the + :class:`~matplotlib.axes.Axes`. + + :raises ValueError: If the result does not retain its geometry. + """ + from .._i18n import check_language + from .._plot.geometry import plot_slit_absorber_result_geometry + + check_language(language) + return plot_slit_absorber_result_geometry( + self, ax=ax, language=language, **kwargs + ) + def _slit_radiation_length(slit_height: float, period: float, terms: int = 400) -> float: """Slit-to-free-air radiation end correction ``Delta l_slit`` (APL Eq. (A27)).""" @@ -559,6 +602,10 @@ def slit_helmholtz_absorber( effective_wavenumber=np.asarray(k_eff, dtype=np.complex128), effective_impedance=np.asarray(z_eff, dtype=np.complex128), transfer_matrix=np.asarray(tm, dtype=np.complex128), + resonators=res, + slit_height=slit_height, + lattice_step=lattice_step, + period=period, ) diff --git a/tests/materials/test_geometry_plots.py b/tests/materials/test_geometry_plots.py new file mode 100644 index 000000000..1ad332f8b --- /dev/null +++ b/tests/materials/test_geometry_plots.py @@ -0,0 +1,262 @@ +# Copyright (c) 2026. Jose M. Requena-Plens +""" +Geometry drawings of materials devices (``_plot/geometry.py``). + +Smoke coverage plus the contracts that matter: every renderer returns the +axes it drew on with an equal aspect, results retain the geometry their +``plot_geometry()`` needs, hand-built results refuse to draw with a clear +message, and the drawings are really to scale (the sample patch of the +impedance tube spans exactly the drawn thickness). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +matplotlib = pytest.importorskip("matplotlib") +matplotlib.use("Agg") + +import matplotlib.pyplot as plt + +from phonometry import materials as m + +FREQ = np.linspace(200.0, 2000.0, 32) + + +@pytest.fixture(autouse=True) +def _close_figures(): + yield + plt.close("all") + + +def _resonator() -> m.HelmholtzResonator: + return m.HelmholtzResonator( + neck_length=0.002, neck_side=0.002, + cavity_length=0.015, cavity_side=0.01, + ) + + +def _layers() -> list: + medium = m.miki(FREQ, 20000.0) + return [ + m.MicroperforatedPlateLayer(0.001, 0.0002, 0.01), + m.AirLayer(0.03), + m.PorousLayer(0.05, medium), + ] + + +# --------------------------------------------------------------------------- +# Smoke: every renderer returns an equal-aspect Axes. +# --------------------------------------------------------------------------- +def _geometry_axes(): + yield m.plot_absorber_stack(_layers()) + yield _resonator().plot() + yield m.plot_slit_absorber_geometry( + [_resonator()] * 3, slit_height=0.002, lattice_step=0.015, + period=0.02, + ) + yield m.plot_qrd_geometry(m.qrd_well_depths(7, 500.0), 0.12, periods=2) + yield m.plot_impedance_tube_geometry( + spacing=0.05, x1=0.15, diameter=0.1, shape="circular" + ) + yield m.plot_transmission_tube_geometry( + l1=0.1, s1=0.03, l2=0.15, s2=0.03, thickness=0.05, diameter=0.1, + shape="square", + ) + + +def test_all_geometry_renderers_return_equal_aspect_axes() -> None: + for ax in _geometry_axes(): + assert ax.get_aspect() == 1.0 + assert not ax.axison + + +def test_renderers_accept_external_ax_and_language() -> None: + for language in ("en", "es"): + _, ax = plt.subplots() + out = m.plot_qrd_geometry( + m.qrd_well_depths(7, 500.0), 0.12, ax=ax, language=language + ) + assert out is ax + + +def test_unknown_language_rejected() -> None: + resonator = _resonator() + with pytest.raises(ValueError, match="Unknown language"): + resonator.plot(language="fr") + + +# --------------------------------------------------------------------------- +# Geometry retention and the hand-built refusal paths. +# --------------------------------------------------------------------------- +def test_layered_result_retains_layers_and_draws() -> None: + layers = _layers() + res = m.layered_absorber(FREQ, layers) + assert res.layers == tuple(layers) + assert res.plot_geometry() is not None + bare = m.LayeredAbsorberResult( + frequency=res.frequency, angle=res.angle, + surface_impedance=res.surface_impedance, + normalized_impedance=res.normalized_impedance, + reflection=res.reflection, absorption=res.absorption, + transfer_matrix=res.transfer_matrix, + ) + with pytest.raises(ValueError, match="does not retain"): + bare.plot_geometry() + + +def test_single_layer_dataclasses_plot() -> None: + for layer in _layers(): + assert layer.plot() is not None + assert m.MembraneLayer(0.5).plot() is not None + assert m.PerforatedPlateLayer(0.01, 0.005, 0.2).plot() is not None + + +def test_slit_result_retains_geometry_and_draws() -> None: + res = m.slit_helmholtz_absorber( + FREQ, [_resonator()] * 3, slit_height=0.002, lattice_step=0.015, + period=0.02, + ) + assert res.resonators == (_resonator(),) * 3 + assert res.slit_height == pytest.approx(0.002) + assert res.lattice_step == pytest.approx(0.015) + assert res.period == pytest.approx(0.02) + assert res.plot_geometry() is not None + + +def test_diffuser_response_retains_wells_and_draws() -> None: + depths = m.qrd_well_depths(7, 500.0) + res = m.predict_diffuser_polar_response( + 0.12, 1000.0, depths=depths, periods=2 + ) + assert res.well_width == pytest.approx(0.12) + assert res.periods == 2 + assert res.depths is not None + assert np.allclose(res.depths, depths) + assert res.plot_geometry() is not None + explicit = m.predict_diffuser_polar_response( + 0.12, 1000.0, reflection=np.exp(-2j * np.pi * np.arange(7) / 7), + ) + assert explicit.depths is None + with pytest.raises(ValueError, match="does not retain"): + explicit.plot_geometry() + + +def test_impedance_tube_result_draws_from_retained_geometry() -> None: + f = np.array([500.0, 1000.0]) + res = m.two_microphone_impedance( + np.array([0.5 + 0.1j, 0.4 - 0.2j]), frequency=f, spacing=0.05, + x1=0.15, speed_of_sound=343.2, characteristic_impedance=413.0, + diameter=0.1, + ) + assert res.plot_geometry() is not None + bare = m.two_microphone_impedance( + np.array([0.5 + 0.1j, 0.4 - 0.2j]), frequency=f, spacing=0.05, + x1=0.15, speed_of_sound=343.2, characteristic_impedance=413.0, + ) + # Without a diameter the drawing still works (nominal bore, no cut-on). + assert bare.plot_geometry() is not None + + +def test_transfer_matrix_geometry_paths() -> None: + f = np.array([500.0, 1000.0]) + k = 2.0 * np.pi * f / 343.2 + hand_built = m.air_layer_transfer_matrix(k, 0.05, 413.0) + with pytest.raises(ValueError, match="does not retain"): + hand_built.plot_geometry() + + +# --------------------------------------------------------------------------- +# To-scale checks and validation. +# --------------------------------------------------------------------------- +def test_impedance_tube_sample_patch_is_to_scale() -> None: + thickness = 0.08 + ax = m.plot_impedance_tube_geometry( + spacing=0.05, x1=0.15, diameter=0.1, sample_thickness=thickness + ) + from matplotlib.patches import Rectangle + + widths = { + round(p.get_width(), 6) + for p in ax.patches + if isinstance(p, Rectangle) and p.get_x() == 0.0 + } + assert round(thickness, 6) in widths + + +def test_qrd_geometry_validation() -> None: + with pytest.raises(ValueError, match="non-empty"): + m.plot_qrd_geometry([], 0.12) + with pytest.raises(ValueError, match="non-negative"): + m.plot_qrd_geometry([-0.01], 0.12) + with pytest.raises(ValueError, match="well_width"): + m.plot_qrd_geometry([0.05], 0.0) + with pytest.raises(ValueError, match="periods"): + m.plot_qrd_geometry([0.05], 0.12, periods=0) + + +def test_transmission_tube_validation() -> None: + with pytest.raises(ValueError, match="must exceed"): + m.plot_transmission_tube_geometry( + l1=0.1, s1=0.03, l2=0.04, s2=0.03, thickness=0.05 + ) + with pytest.raises(ValueError, match="positive"): + m.plot_transmission_tube_geometry( + l1=0.1, s1=0.0, l2=0.15, s2=0.03, thickness=0.05 + ) + with pytest.raises(ValueError, match="diameter"): + m.plot_transmission_tube_geometry( + l1=0.1, s1=0.03, l2=0.15, s2=0.03, thickness=0.05, diameter=-1.0 + ) + + +def test_impedance_tube_validation() -> None: + with pytest.raises(ValueError, match="spacing"): + m.plot_impedance_tube_geometry(spacing=0.05, x1=0.04) + with pytest.raises(ValueError, match="diameter"): + m.plot_impedance_tube_geometry(spacing=0.05, x1=0.15, diameter=-0.1) + with pytest.raises(ValueError, match="sample_thickness"): + m.plot_impedance_tube_geometry( + spacing=0.05, x1=0.15, sample_thickness=0.0 + ) + with pytest.raises(ValueError, match="Unknown language"): + m.plot_impedance_tube_geometry(spacing=0.05, x1=0.15, language="de") + + +def test_slit_geometry_validation() -> None: + resonator = _resonator() + with pytest.raises(ValueError, match="positive"): + m.plot_slit_absorber_geometry( + resonator, slit_height=0.0, lattice_step=0.015, period=0.02 + ) + with pytest.raises(ValueError, match="at least one"): + m.plot_slit_absorber_geometry( + [], slit_height=0.002, lattice_step=0.015, period=0.02 + ) + + +def test_absorber_stack_validation() -> None: + with pytest.raises(ValueError, match="at least one"): + m.plot_absorber_stack([]) + with pytest.raises(TypeError, match="Unsupported layer"): + m.plot_absorber_stack([object()]) + + +# --------------------------------------------------------------------------- +# Spanish labels reach the artists. +# --------------------------------------------------------------------------- +def _texts(ax) -> str: + return " ".join(t.get_text() for t in ax.texts) + " " + ax.get_title() + + +def test_spanish_strings_rendered() -> None: + ax = m.plot_impedance_tube_geometry( + spacing=0.05, x1=0.15, diameter=0.1, language="es" + ) + joined = _texts(ax) + assert "Altavoz" in joined + assert "Muestra" in joined + assert "a escala" in ax.get_title() + ax = m.plot_absorber_stack(_layers(), language="es") + assert "Respaldo rígido" in _texts(ax)