diff --git a/.github/images/anim_fdtd_metadiffuser.gif b/.github/images/anim_fdtd_metadiffuser.gif
new file mode 100644
index 000000000..846cf0edd
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser.gif differ
diff --git a/.github/images/anim_fdtd_metadiffuser.webm b/.github/images/anim_fdtd_metadiffuser.webm
new file mode 100644
index 000000000..020a0b67b
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser.webm differ
diff --git a/.github/images/anim_fdtd_metadiffuser_dark.gif b/.github/images/anim_fdtd_metadiffuser_dark.gif
new file mode 100644
index 000000000..123b8b65a
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_dark.gif differ
diff --git a/.github/images/anim_fdtd_metadiffuser_dark.webm b/.github/images/anim_fdtd_metadiffuser_dark.webm
new file mode 100644
index 000000000..a2fb4559c
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_dark.webm differ
diff --git a/.github/images/anim_fdtd_metadiffuser_dark_poster.jpg b/.github/images/anim_fdtd_metadiffuser_dark_poster.jpg
new file mode 100644
index 000000000..5561e6194
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_dark_poster.jpg differ
diff --git a/.github/images/anim_fdtd_metadiffuser_es.webm b/.github/images/anim_fdtd_metadiffuser_es.webm
new file mode 100644
index 000000000..f5b5bcf59
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_es.webm differ
diff --git a/.github/images/anim_fdtd_metadiffuser_es_dark.webm b/.github/images/anim_fdtd_metadiffuser_es_dark.webm
new file mode 100644
index 000000000..6ea66dc6e
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_es_dark.webm differ
diff --git a/.github/images/anim_fdtd_metadiffuser_es_dark_poster.jpg b/.github/images/anim_fdtd_metadiffuser_es_dark_poster.jpg
new file mode 100644
index 000000000..11f3e8518
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_es_dark_poster.jpg differ
diff --git a/.github/images/anim_fdtd_metadiffuser_es_poster.jpg b/.github/images/anim_fdtd_metadiffuser_es_poster.jpg
new file mode 100644
index 000000000..2197537b6
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_es_poster.jpg differ
diff --git a/.github/images/anim_fdtd_metadiffuser_poster.jpg b/.github/images/anim_fdtd_metadiffuser_poster.jpg
new file mode 100644
index 000000000..d74cd2bf4
Binary files /dev/null and b/.github/images/anim_fdtd_metadiffuser_poster.jpg differ
diff --git a/.github/images/metadiffuser_geometry.svg b/.github/images/metadiffuser_geometry.svg
new file mode 100644
index 000000000..a61cb76a1
--- /dev/null
+++ b/.github/images/metadiffuser_geometry.svg
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 2
+
+
+ 3
+
+
+ 4
+
+
+ 5
+
+
+ Incident sound
+
+
+ Metadiffuser cross-section (one period)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 350 mm
+
+
+ 20 mm
+
+
+ 70 mm
+
+
+ 14.7 mm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_geometry_dark.svg b/.github/images/metadiffuser_geometry_dark.svg
new file mode 100644
index 000000000..d5ed2c141
--- /dev/null
+++ b/.github/images/metadiffuser_geometry_dark.svg
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 2
+
+
+ 3
+
+
+ 4
+
+
+ 5
+
+
+ Incident sound
+
+
+ Metadiffuser cross-section (one period)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 350 mm
+
+
+ 20 mm
+
+
+ 70 mm
+
+
+ 14.7 mm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_geometry_es.svg b/.github/images/metadiffuser_geometry_es.svg
new file mode 100644
index 000000000..2b1846ae1
--- /dev/null
+++ b/.github/images/metadiffuser_geometry_es.svg
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 2
+
+
+ 3
+
+
+ 4
+
+
+ 5
+
+
+ Sonido incidente
+
+
+ Sección del metadifusor (un periodo)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 350 mm
+
+
+ 20 mm
+
+
+ 70 mm
+
+
+ 14,7 mm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_geometry_es_dark.svg b/.github/images/metadiffuser_geometry_es_dark.svg
new file mode 100644
index 000000000..358704cc2
--- /dev/null
+++ b/.github/images/metadiffuser_geometry_es_dark.svg
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 2
+
+
+ 3
+
+
+ 4
+
+
+ 5
+
+
+ Sonido incidente
+
+
+ Sección del metadifusor (un periodo)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 350 mm
+
+
+ 20 mm
+
+
+ 70 mm
+
+
+ 14,7 mm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_polar.svg b/.github/images/metadiffuser_polar.svg
new file mode 100644
index 000000000..737e0feea
--- /dev/null
+++ b/.github/images/metadiffuser_polar.svg
@@ -0,0 +1,457 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -90°
+
+
+
+
+
+
+
+ -60°
+
+
+
+
+
+
+
+ -30°
+
+
+
+
+
+
+
+ 0°
+
+
+
+
+
+
+
+ 30°
+
+
+
+
+
+
+
+ 60°
+
+
+
+
+
+
+
+ 90°
+
+
+
+
+
+
+
+
+
+ −35
+
+
+
+
+
+
+
+ −30
+
+
+
+
+
+
+
+ −25
+
+
+
+
+
+
+
+ −20
+
+
+
+
+
+
+
+ −15
+
+
+
+
+
+
+
+ −10
+
+
+
+
+
+
+
+ −5
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The 2 cm metadiffuser scatters like the 27 cm QRD (2 kHz)
+
+
+
+
+
+
+
+
+
+ Metadiffuser, panel 2 cm
+
+
+
+
+
+ QRD, wells up to 27.4 cm
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_polar_dark.svg b/.github/images/metadiffuser_polar_dark.svg
new file mode 100644
index 000000000..c85421bc1
--- /dev/null
+++ b/.github/images/metadiffuser_polar_dark.svg
@@ -0,0 +1,457 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -90°
+
+
+
+
+
+
+
+ -60°
+
+
+
+
+
+
+
+ -30°
+
+
+
+
+
+
+
+ 0°
+
+
+
+
+
+
+
+ 30°
+
+
+
+
+
+
+
+ 60°
+
+
+
+
+
+
+
+ 90°
+
+
+
+
+
+
+
+
+
+ −35
+
+
+
+
+
+
+
+ −30
+
+
+
+
+
+
+
+ −25
+
+
+
+
+
+
+
+ −20
+
+
+
+
+
+
+
+ −15
+
+
+
+
+
+
+
+ −10
+
+
+
+
+
+
+
+ −5
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The 2 cm metadiffuser scatters like the 27 cm QRD (2 kHz)
+
+
+
+
+
+
+
+
+
+ Metadiffuser, panel 2 cm
+
+
+
+
+
+ QRD, wells up to 27.4 cm
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_polar_es.svg b/.github/images/metadiffuser_polar_es.svg
new file mode 100644
index 000000000..73cb6a2f5
--- /dev/null
+++ b/.github/images/metadiffuser_polar_es.svg
@@ -0,0 +1,457 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -90°
+
+
+
+
+
+
+
+ -60°
+
+
+
+
+
+
+
+ -30°
+
+
+
+
+
+
+
+ 0°
+
+
+
+
+
+
+
+ 30°
+
+
+
+
+
+
+
+ 60°
+
+
+
+
+
+
+
+ 90°
+
+
+
+
+
+
+
+
+
+ −35
+
+
+
+
+
+
+
+ −30
+
+
+
+
+
+
+
+ −25
+
+
+
+
+
+
+
+ −20
+
+
+
+
+
+
+
+ −15
+
+
+
+
+
+
+
+ −10
+
+
+
+
+
+
+
+ −5
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ El metadifusor de 2 cm dispersa como el QRD de 27 cm (2 kHz)
+
+
+
+
+
+
+
+
+
+ Metadifusor, panel de 2 cm
+
+
+
+
+
+ QRD, pozos de hasta 27,4 cm
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/metadiffuser_polar_es_dark.svg b/.github/images/metadiffuser_polar_es_dark.svg
new file mode 100644
index 000000000..3f2ed8111
--- /dev/null
+++ b/.github/images/metadiffuser_polar_es_dark.svg
@@ -0,0 +1,457 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.1, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -90°
+
+
+
+
+
+
+
+ -60°
+
+
+
+
+
+
+
+ -30°
+
+
+
+
+
+
+
+ 0°
+
+
+
+
+
+
+
+ 30°
+
+
+
+
+
+
+
+ 60°
+
+
+
+
+
+
+
+ 90°
+
+
+
+
+
+
+
+
+
+ −35
+
+
+
+
+
+
+
+ −30
+
+
+
+
+
+
+
+ −25
+
+
+
+
+
+
+
+ −20
+
+
+
+
+
+
+
+ −15
+
+
+
+
+
+
+
+ −10
+
+
+
+
+
+
+
+ −5
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ El metadifusor de 2 cm dispersa como el QRD de 27 cm (2 kHz)
+
+
+
+
+
+
+
+
+
+ Metadifusor, panel de 2 cm
+
+
+
+
+
+ QRD, pozos de hasta 27,4 cm
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/api-reference.md b/docs/api-reference.md
index bac4df137..085c769cb 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -833,6 +833,12 @@ cycle and warn on use; they are removed in 4.0.
| `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()` |
+| `MetadiffuserWell` | `dataclass` | **One slit of a metadiffuser panel (Jiménez et al. Sci. Rep. 2017).** • `slit_height` h [m] • `resonators`: tuple of `HelmholtzResonator`, face to backing • `None` in a well sequence = flat rigid strip (R = 1) | `MetadiffuserWell(14.7e-3, (hr, hr))` • lattice a = L/M |
+| `metadiffuser_reflection` | `function` | **Per-well reflection spectra R_n(f) of a metadiffuser.** • `frequency` f [Hz], `wells`: `MetadiffuserWell`/`None` sequence • `depth` L, `period` d [m]; `angle` θ [rad] (Default: 0) • `resonator_geometry` `"slit"`/`"square"` (Default: slit) • 2-D visco-thermal TMM per slit | `panel = metadiffuser_reflection(f, wells, depth=0.02, period=0.07)` • `MetadiffuserResult` |
+| `MetadiffuserResult` | `dataclass` | **Metadiffuser spectra, one reflection row per well.** • `reflection` (N, F), `well_absorption` (N, F) • `absorption`: face average 1 − mean\|R_n\|² • retains `wells`/`depth`/`period` | `panel.plot()`; `panel.plot_geometry()` • `.plot()` α per well; `.plot_geometry()` panel section |
+| `metadiffuser_polar_response` | `function` | **Far-field polar response of a metadiffuser at one frequency.** • `frequency` f [Hz], `wells`, `depth` L, `period` d [m] • `angles` [°], `source_angle` ψ [°], `periods` (Default: 1) • Fraunhofer of R_n(f), no obliquity factor (Sci. Rep. Eq. (1)) | `pol = metadiffuser_polar_response(2000.0, wells, depth=0.02, period=0.07, periods=6)` • `DiffuserPolarResponse` |
+| `metadiffuser_diffusion_spectrum` | `function` | **Normalized diffusion spectrum d_n(f) of a metadiffuser.** • `frequencies` [Hz], `wells`, `depth` L, `period` d [m] • normalised against the same-footprint flat panel • ISO 17497-2 directional coefficient per band | `metadiffuser_diffusion_spectrum(f, wells, depth=0.02, period=0.07)` • `DiffusionSpectrum` |
+| `plot_metadiffuser_panel_geometry` | `function` | **To-scale metadiffuser panel cross-section.** • `wells`, `depth` L, `period` d [m] • numbered slits, resonators shelved into the septum • `language` | `plot_metadiffuser_panel_geometry(wells, depth=0.02, period=0.07)` • Also `MetadiffuserResult.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/surface-scattering.md b/docs/surface-scattering.md
index 1825fc45b..6d5356cdd 100644
--- a/docs/surface-scattering.md
+++ b/docs/surface-scattering.md
@@ -480,6 +480,124 @@ plt.show()
+### Metadiffusers: deep-subwavelength Schroeder diffusers
+
+A metadiffuser replaces the deep wells of a Schroeder diffuser with thin
+slits loaded by Helmholtz resonators (Jiménez, Cox, Romero-García and Groby,
+2017). Below their resonance the resonators slow the sound inside each slit,
+so a panel a few centimetres thick reaches the reflection phases that a
+classical phase grating needs tens of centimetres of depth for, and driving
+a slit to critical coupling adds a perfectly absorbing state, the `0` that
+ternary sequences require. `metadiffuser_reflection` runs the slit
+transfer-matrix chain of the [slow-sound absorber](materials.md) once per
+well (two-dimensional resonators, visco-thermal losses and end corrections
+included) and returns the per-well complex reflection $R_n(f)$;
+`metadiffuser_polar_response` and `metadiffuser_diffusion_spectrum` reduce
+that spatial profile through the same Fraunhofer far field and ISO 17497-2
+coefficient used for the classical designs above.
+
+
+
+The published quadratic-residue design packs the whole diffuser into a
+35 cm x 2 cm panel. Its first slit reaches critical coupling (the reflection
+zero the ternary `0` state is built from), and at the 2 kHz evaluation
+frequency the panel scatters like the 27.4 cm deep QRD it mimics:
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
+
+# The published quadratic-residue metadiffuser: five slits with two
+# resonators each in a 35 cm x 2 cm panel (7 cm pitch), tuned to mimic a
+# QRD designed for 500 Hz whose wells would run up to 27.4 cm deep.
+mm = 1e-3
+rows = [ # slit h, neck l_n, cavity l_c, neck w_n, cavity w_c [mm]
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+wells = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in rows
+]
+
+f = np.arange(1800.0, 2601.0, 5.0)
+panel = metadiffuser_reflection(f, wells, depth=0.02, period=0.07)
+alpha1 = panel.well_absorption[0]
+print(round(float(alpha1.max()), 2), int(f[alpha1.argmax()])) # 0.99 2305
+
+polar = metadiffuser_polar_response(2000.0, wells, depth=0.02,
+ period=0.07, periods=6)
+print(round(polar.coefficient, 2)) # 0.32
+```
+
+
+
+
+Show the code for this figure
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ materials,
+ metadiffuser_polar_response,
+)
+
+mm = 1e-3
+rows = [
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+wells = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in rows
+]
+
+# The metadiffuser panel and the QRD it was tuned to at 2 kHz, both with
+# six repetitions of the period.
+meta = metadiffuser_polar_response(2000.0, wells, depth=0.02, period=0.07,
+ periods=6)
+sequence = np.roll(materials.quadratic_residue_sequence(5), -1)
+depths = sequence * (343.0 / 500.0) / (2 * 5)
+qrd = materials.predict_diffuser_polar_response(
+ 0.07, 2000.0, depths=depths, periods=6, include_obliquity=False,
+)
+
+ax = meta.plot(marker="", linewidth=2.2, label="Metadiffuser, panel 2 cm")
+qrd.plot(ax=ax, marker="", linewidth=1.6, linestyle="--",
+ label="QRD, wells up to 27.4 cm")
+ax.legend(loc="lower center")
+```
+
+
+
+The far-field overlay above is the frequency-domain summary; the FDTD
+animation below meshes both panels for real (the metadiffuser at 0.25 mm, slits,
+necks and cavities included) and lets the same 2 kHz wavefront hit them: the
+27 cm QRD and the 2 cm panel throw out nearly the same scattered fan.
+
+
+
+[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_metadiffuser.webm)
+
## Scattering or diffusion? Two coefficients, two jobs
The two coefficients above are routinely treated as interchangeable, in
@@ -741,6 +859,14 @@ print(round(s_min, 3), round(s_max, 3)) # 0.078 0.086 (metres)
## References
+- Jiménez, N., Cox, T. J., Romero-García, V., & Groby, J.-P. (2017).
+ Metadiffusers: Deep-subwavelength sound diffusers. *Scientific Reports*,
+ 7, 5389.
+ [doi:10.1038/s41598-017-05710-5](https://doi.org/10.1038/s41598-017-05710-5).
+ The metadiffuser model implemented here: slits loaded by Helmholtz
+ resonators reproduce Schroeder phase profiles and ternary sequences from
+ panels 1/46 to 1/20 of the design wavelength thick.
+
- Cox, T. J., & D'Antonio, P. (2017). *Acoustic absorbers and diffusers:
Theory, design and application* (3rd ed.). CRC Press.
ISBN 978-1-4987-4099-9.
diff --git a/scripts/api_taxonomy.py b/scripts/api_taxonomy.py
index b18e47870..07eac13dc 100644
--- a/scripts/api_taxonomy.py
+++ b/scripts/api_taxonomy.py
@@ -156,6 +156,7 @@ class Section:
"phonometry.materials.slow_sound_absorber",
"phonometry.materials.scattering_diffusion",
"phonometry.materials.diffuser_design",
+ "phonometry.materials.metadiffuser",
"phonometry.materials.road_absorption",
),
),
@@ -353,6 +354,8 @@ class Section:
"phonometry.materials.slow_sound_absorber",
"plot_slit_absorber_geometry": "phonometry.materials.slow_sound_absorber",
"plot_qrd_geometry": "phonometry.materials.diffuser_design",
+ "plot_metadiffuser_panel_geometry": "phonometry.materials.metadiffuser",
+ "DEFAULT_POLAR_ANGLES": "phonometry.materials.diffuser_design",
"plot_impedance_tube_geometry": "phonometry.materials.impedance_tube",
"plot_transmission_tube_geometry": "phonometry.materials.impedance_tube",
"plot_silencer_geometry": "phonometry.noise_control.silencers",
diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py
index b40d315c0..7e4e808de 100644
--- a/scripts/generate_graphs.py
+++ b/scripts/generate_graphs.py
@@ -41,6 +41,19 @@
"anechoic termination": "terminación anecoica",
"rigid plug": "tapón rígido",
"|p| envelope": "envolvente |p|",
+ "The 2 cm metadiffuser scatters like the 27 cm QRD (2 kHz)":
+ "El metadifusor de 2 cm dispersa como el QRD de 27 cm (2 kHz)",
+ "Metadiffuser, panel 2 cm": "Metadifusor, panel de 2 cm",
+ "Schroeder diffuser vs metadiffuser (2D FDTD)":
+ "Difusor de Schroeder frente a metadifusor (FDTD 2D)",
+ "QRD, wells down to 27 cm": "QRD, pozos de hasta 27 cm",
+ "Metadiffuser, 2 cm panel": "Metadifusor, panel de 2 cm",
+ "real slits and resonators meshed at 0.25 mm":
+ "rendijas y resonadores mallados a 0,25 mm",
+ "a collimated specular beam": "un haz especular colimado",
+ "a wide scattered fan": "un abanico dispersado ancho",
+ "the same fan, from 2 cm": "el mismo abanico, con 2 cm",
+ "QRD, wells up to 27.4 cm": "QRD, pozos de hasta 27,4 cm",
"The virtual impedance tube: standing waves read the absorption "
"(2D FDTD)":
"El tubo de impedancia virtual: las ondas estacionarias leen la "
@@ -7941,6 +7954,101 @@ def generate_qrd_geometry(output_dir: str) -> None:
plt.close()
+#: Table-1 metadiffuser rows (slit h, neck l_n, cavity l_c, neck w_n,
+#: cavity w_c, in mm), shared by the figures and the animation mesh.
+_METADIFFUSER_T1_ROWS = (
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+)
+
+
+def _qr_metadiffuser_wells() -> tuple[Any, float, float]:
+ """The published 2 cm quadratic-residue metadiffuser (wells, L, d)."""
+ from phonometry import HelmholtzResonator, MetadiffuserWell
+
+ rows = _METADIFFUSER_T1_ROWS
+ wells = [
+ MetadiffuserWell(
+ h * 1e-3,
+ (HelmholtzResonator(ln * 1e-3, wn * 1e-3, lc * 1e-3, wc * 1e-3),)
+ * 2,
+ )
+ for h, ln, lc, wn, wc in rows
+ ]
+ return wells, 0.02, 0.07
+
+
+def generate_metadiffuser_polar(output_dir: str) -> None:
+ """A 2 cm metadiffuser scatters like the 27 cm QRD it mimics.
+
+ Far-field polar responses at 2 kHz of the five-slit metadiffuser panel
+ and of the quadratic-residue diffuser (design frequency 500 Hz, wells up
+ to 27.4 cm deep) whose reflection-phase profile it reproduces, both with
+ six repetitions. One concept: the deep-subwavelength panel replaces a
+ 13.7 times thicker classical diffuser.
+ """
+ print("Generating metadiffuser_polar...")
+ from phonometry import (
+ metadiffuser_polar_response,
+ predict_diffuser_polar_response,
+ quadratic_residue_sequence,
+ )
+
+ wells, depth, period = _qr_metadiffuser_wells()
+ sequence = np.roll(quadratic_residue_sequence(5), -1)
+ qrd_depths = sequence * (343.0 / 500.0) / (2 * 5)
+ meta = metadiffuser_polar_response(
+ 2000.0, wells, depth=depth, period=period, periods=6,
+ )
+ qrd = predict_diffuser_polar_response(
+ period, 2000.0, depths=qrd_depths, periods=6,
+ include_obliquity=False,
+ )
+ _fig, ax = plt.subplots(
+ figsize=(10, 6.2), subplot_kw={"projection": "polar"},
+ )
+ meta.plot(
+ ax=ax, color=COLOR_SECONDARY, marker="", linewidth=2.2,
+ label="Metadiffuser, panel 2 cm", language=_LANG,
+ )
+ qrd.plot(
+ ax=ax, color=COLOR_PRIMARY, marker="", linewidth=1.6,
+ linestyle="--", label="QRD, wells up to 27.4 cm", language=_LANG,
+ )
+ ax.set_title(
+ "The 2 cm metadiffuser scatters like the 27 cm QRD (2 kHz)",
+ pad=18, fontweight="bold",
+ )
+ ax.legend(loc="lower center", bbox_to_anchor=(0.5, -0.02), fontsize=9)
+ plt.tight_layout()
+ save_figure(output_dir, "metadiffuser_polar.svg")
+ plt.close()
+
+
+def generate_metadiffuser_geometry(output_dir: str) -> None:
+ """To-scale cross-section of the published 2 cm metadiffuser panel.
+
+ One period of the five-slit quadratic-residue metadiffuser: numbered
+ slits open at the face, each loaded by two Helmholtz resonators
+ shelved sideways into the septum, over a rigid backing. One concept:
+ the whole 35 cm x 2 cm panel that replaces a 27 cm deep diffuser.
+ """
+ print("Generating metadiffuser_geometry...")
+ from phonometry.materials import plot_metadiffuser_panel_geometry
+
+ wells, depth, period = _qr_metadiffuser_wells()
+ _fig, ax = plt.subplots(figsize=(10, 3.4))
+ plot_metadiffuser_panel_geometry(
+ wells, ax=ax, depth=depth, period=period, language=_LANG,
+ )
+ plt.tight_layout()
+ save_figure(output_dir, "metadiffuser_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.
@@ -11892,6 +12000,8 @@ def generate_runs_test(output_dir: str) -> None:
generate_porous_absorber_designs,
generate_absorber_stack_geometry,
# Slow-sound slit + Helmholtz-resonator perfect absorbers (Jimenez et al.)
+ generate_metadiffuser_geometry,
+ generate_metadiffuser_polar,
generate_slow_sound_absorber,
generate_slit_absorber_geometry,
generate_helmholtz_resonator_geometry,
@@ -12553,6 +12663,84 @@ def generate_posters(output_dir: str) -> None:
print(f" {os.path.basename(webm)} -> {os.path.basename(poster)}")
+def _gpu_encode_target() -> dict[str, str] | None:
+ """The remote AV1 (NVENC) encode target from .env, if enabled and alive.
+
+ Opt-in through ``PHONO_GPU_ENCODE=av1`` next to the ``PHONO_GPU_*``
+ host settings: VP9 has no NVIDIA encoder, so the hardware path writes
+ AV1 into the same WebM container (the site embeds are codec-agnostic;
+ the GitHub GIF is still derived locally). Any doubt (no .env, host
+ down, flag off) returns ``None`` and the clips keep the CPU VP9 path.
+ """
+ import subprocess
+
+ try:
+ import fdtd_gpu_remote
+
+ fdtd_gpu_remote.load_env()
+ except ImportError:
+ return None
+ if os.environ.get("PHONO_GPU_ENCODE", "").lower() != "av1":
+ return None
+ host = os.environ.get("PHONO_GPU_HOST", "")
+ user = os.environ.get("PHONO_GPU_USER", "root")
+ image = os.environ.get("PHONO_GPU_FFMPEG_IMAGE", "linuxserver/ffmpeg:latest")
+ if not host:
+ return None
+ probe = subprocess.run(
+ ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=4", "--",
+ f"{user}@{host}", "true"], capture_output=True, check=False)
+ if probe.returncode != 0:
+ return None
+ workdir = os.environ.get("PHONO_GPU_WORKDIR", "/tmp/phonometry-gpu")
+ return {"target": f"{user}@{host}", "image": image, "workdir": workdir}
+
+
+def _av1_nvenc_extra_args() -> list[str]:
+ """ffmpeg args for the remote AV1 NVENC encode (WebM container)."""
+ return [
+ "-c:v", "av1_nvenc",
+ # cq 44 calibrated against the VP9 crf-40 size on the metadiffuser
+ # clip (cq 42 = parity, 46 = -22 %): smaller files, same resolution.
+ "-b:v", "0", "-cq", "44", "-preset", "p6",
+ "-pix_fmt", "yuv420p", "-an", "-loglevel", "error",
+ ]
+
+
+def _write_gpu_ffmpeg_wrapper(target: str, image: str, workdir: str) -> str:
+ """A throwaway ffmpeg shim that runs the encode on the GPU host.
+
+ Matplotlib's writer spawns ``ffmpeg `` and streams raw
+ frames on stdin; the shim forwards stdin over ssh into the NVENC
+ container, which writes the WebM to a file in the remote work
+ directory (the matroska muxer needs a seekable target), and the shim
+ then fetches it back with scp and cleans up.
+ """
+ import shlex
+ import tempfile
+
+ q_target = shlex.quote(target)
+ q_image = shlex.quote(image)
+ q_dir = shlex.quote(workdir)
+ script = f"""#!/bin/bash
+set -euo pipefail
+out="${{@: -1}}"
+args=("${{@:1:$#-1}}")
+rid="enc-$$-$RANDOM.webm"
+quoted=$(printf ' %q' "${{args[@]}}")
+ssh -o BatchMode=yes -- {q_target} \
+ "mkdir -p {q_dir} && docker run --rm -i --gpus all \
+ -v {q_dir}:/work {q_image}$quoted /work/$rid"
+scp -q -- {q_target}:{q_dir}/"$rid" "$out"
+ssh -o BatchMode=yes -- {q_target} "rm -f {q_dir}/$rid"
+"""
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".sh", prefix="ffmpeg-gpu-", delete=False) as handle:
+ handle.write(script)
+ os.chmod(handle.name, 0o755)
+ return handle.name
+
+
def _save_animation(anim: Any, fig: Any, output_dir: str, stem: str,
make_gif: bool = True, *, fps: int | None = None,
gif_fps: int | None = None) -> None:
@@ -12573,13 +12761,30 @@ def _save_animation(anim: Any, fig: Any, output_dir: str, stem: str,
from matplotlib.animation import FFMpegWriter
webm = _anim_path(output_dir, stem, "webm")
- writer = FFMpegWriter(
- fps=_ANIM_FPS if fps is None else fps, codec="libvpx-vp9",
- extra_args=_vp9_extra_args(),
- )
- with plt.rc_context({"savefig.bbox": "standard"}):
- anim.save(webm, writer=writer, dpi=_ANIM_DPI,
- savefig_kwargs={"facecolor": fig.get_facecolor()})
+ rate = _ANIM_FPS if fps is None else fps
+ gpu = _gpu_encode_target()
+ saved = False
+ if gpu is not None:
+ wrapper = _write_gpu_ffmpeg_wrapper(gpu["target"], gpu["image"],
+ gpu["workdir"])
+ try:
+ writer = FFMpegWriter(fps=rate, codec="av1_nvenc",
+ extra_args=_av1_nvenc_extra_args())
+ with plt.rc_context({"savefig.bbox": "standard",
+ "animation.ffmpeg_path": wrapper}):
+ anim.save(webm, writer=writer, dpi=_ANIM_DPI,
+ savefig_kwargs={"facecolor": fig.get_facecolor()})
+ saved = True
+ except (RuntimeError, subprocess.CalledProcessError, OSError) as exc:
+ print(f" [gpu-encode] {stem}: {exc}; falling back to VP9")
+ finally:
+ os.remove(wrapper)
+ if not saved:
+ writer = FFMpegWriter(fps=rate, codec="libvpx-vp9",
+ extra_args=_vp9_extra_args())
+ with plt.rc_context({"savefig.bbox": "standard"}):
+ anim.save(webm, writer=writer, dpi=_ANIM_DPI,
+ savefig_kwargs={"facecolor": fig.get_facecolor()})
_extract_poster(webm)
made_gif = False
if make_gif and _LANG == "en":
@@ -14440,6 +14645,350 @@ def update(k: int) -> tuple[Any, ...]:
frames=int(tot_all.shape[1]), gif_fps=8)
+_META_DX = 0.00025
+_META_NY, _META_NX = 4800, 6400
+_META_XL, _META_FACE = 0.45, 0.36
+_META_PITCH, _META_PERIODS = 0.07, 2
+_META_F0 = 2000.0
+
+
+def _metadiffuser_panel_mask(rho: Any) -> None:
+ """Carve the Table-1 metadiffuser (two periods) into a dense slab.
+
+ The slab spans the panel depth L = 2 cm plus a 3 mm back wall under
+ the face line; each well is a vertical slit from the face with its two
+ resonators shelved sideways into the septum, the same layout the
+ to-scale drawing uses (slit at 0.12 d into the cell, lattice a = L/2).
+ """
+ dx, y1 = _META_DX, _META_FACE
+ rows = _METADIFFUSER_T1_ROWS
+ depth, back = 0.02, 0.003
+ rho[round((y1 - depth - back) / dx):round(y1 / dx),
+ round(_META_XL / dx):round((_META_XL + _META_PERIODS * 5
+ * _META_PITCH) / dx)] = 1.2e6
+ lattice = depth / 2
+ for period in range(_META_PERIODS):
+ for n, (h, ln, lc, wn, wc) in enumerate(rows):
+ x0 = _META_XL + (period * 5 + n) * _META_PITCH
+ x_slit = x0 + 0.12 * _META_PITCH
+ c0s, c1s = round(x_slit / dx), round((x_slit + h * 1e-3) / dx)
+ rho[round((y1 - depth) / dx):round(y1 / dx), c0s:c1s] = 1.2
+ for m in range(2):
+ y_m = y1 - depth + (2 - m - 0.5) * lattice
+ x_neck = x_slit + h * 1e-3
+ r0 = round((y_m - 0.5 * wn * 1e-3) / dx)
+ r1 = round((y_m + 0.5 * wn * 1e-3) / dx)
+ rho[r0:r1, c1s:round((x_neck + ln * 1e-3) / dx)] = 1.2
+ r0 = round((y_m - 0.5 * wc * 1e-3) / dx)
+ r1 = round((y_m + 0.5 * wc * 1e-3) / dx)
+ rho[r0:r1, round((x_neck + ln * 1e-3) / dx):
+ round((x_neck + (ln + lc) * 1e-3) / dx)] = 1.2
+
+
+def _meta_qrd_wells() -> list[tuple[float, float, float]]:
+ """QRD well openings (x0, x1, depth) matched to the metadiffuser.
+
+ The same N = 5 residue order as the Table-1 metadiffuser
+ (s = 1, 4, 4, 1, 0), designed at 500 Hz: depths s lambda0 / (2 N) up
+ to 27.4 cm, 65 mm wells split by 5 mm fins on the 7 cm pitch.
+ """
+ unit = (343.0 / 500.0) / 10.0
+ wells: list[tuple[float, float, float]] = []
+ for period in range(_META_PERIODS):
+ for n, s_n in enumerate((1, 4, 4, 1, 0)):
+ x0 = _META_XL + (period * 5 + n) * _META_PITCH
+ wells.append((x0 + 0.005, x0 + _META_PITCH, s_n * unit))
+ return wells
+
+
+def _meta_rho(kind: str) -> Any:
+ """Density map of one run: ``flat``, ``qrd``, ``meta`` or ``ref``."""
+ dx, ny, nx = _META_DX, _META_NY, _META_NX
+ y1 = _META_FACE
+ x_r = _META_XL + _META_PERIODS * 5 * _META_PITCH
+ rho = np.full((ny, nx), 1.2)
+ if kind == "qrd":
+ rho[round(0.05 / dx):round(y1 / dx),
+ round(_META_XL / dx):round(x_r / dx)] = 1.2e6
+ for wx0, wx1, d in _meta_qrd_wells():
+ if d > 0.0:
+ rho[round((y1 - d) / dx):round(y1 / dx),
+ round(wx0 / dx):round(wx1 / dx)] = 1.2
+ elif kind == "meta":
+ _metadiffuser_panel_mask(rho)
+ elif kind == "flat":
+ # A flat rigid slab with the metadiffuser's exact silhouette, so
+ # the fans can only come from the slits, not from the outline.
+ rho[round((y1 - 0.023) / dx):round(y1 / dx),
+ round(_META_XL / dx):round(x_r / dx)] = 1.2e6
+ return rho
+
+
+def _meta_taper() -> Any:
+ """Lateral cosine taper of the incident packet (free-field edges).
+
+ The wavefront is flat over the panels and dies smoothly well before
+ the lateral sponges, so the exterior boundaries only ever absorb
+ outgoing scattered waves and the incident front stays plane: no edge
+ arcs in the total field, and the reference run subtracts exactly.
+ """
+ x = (np.arange(_META_NX) + 0.5) * _META_DX
+ x0, x1, x2, x3 = 0.10, 0.30, 1.30, 1.50
+ w = np.zeros(_META_NX)
+ rise = (x >= x0) & (x < x1)
+ w[rise] = 0.5 - 0.5 * np.cos(np.pi * (x[rise] - x0) / (x1 - x0))
+ w[(x >= x1) & (x <= x2)] = 1.0
+ fall = (x > x2) & (x <= x3)
+ w[fall] = 0.5 + 0.5 * np.cos(np.pi * (x[fall] - x2) / (x3 - x2))
+ return w
+
+
+_META_FRAMES = 600 # 6.13 ms / 10.2 us per frame (49 frames per period)
+
+
+def _meta_pair_worker(kind: str, n_frames: int) -> tuple[Any, Any, Any]:
+ """One panel run in lockstep with its own free-field reference.
+
+ Each worker process pays for its private incident-field run, but the
+ three workers advance in parallel, so the wall time of the cached
+ field computation is about two simulations instead of four.
+ """
+ import fdtd2d
+
+ c0, dx = 343.0, _META_DX
+ y1 = _META_FACE
+ lam0 = c0 / _META_F0
+ # Duration rule: full flight source -> deepest well bottom -> top of
+ # the visible frame, (0.714 + 1.034) m / c * 1.2 = 6.12 ms, sampled at
+ # four times the 12 frames-per-period visual floor (49/period at 2 kHz).
+ every = 33
+ sims = []
+ for rho in (_meta_rho(kind), _meta_rho("ref")):
+ sim = fdtd2d.FDTD2D(c0, dx, rho=rho, shape=(_META_NY, _META_NX),
+ sponge_width=200)
+ sim.add_plane_wave("up", center=0.80, width=0.05, wavelength=lam0)
+ taper = _meta_taper()
+ sim.p *= taper[np.newaxis, :]
+ sim.vy *= taper[np.newaxis, :]
+ sims.append(sim)
+ decay = float(2.0 ** (-sims[0].dt / 0.0015))
+ face_row = round(y1 / dx)
+ trail = np.zeros_like(sims[0].p)
+ tot_frames: list[Any] = []
+ trail_frames: list[Any] = []
+ ts: list[float] = []
+ for _ in range(every * n_frames):
+ for sim in sims:
+ sim.step()
+ scat = sims[0].p - sims[1].p
+ scat[:face_row, :] = 0.0
+ np.maximum(trail * decay, np.abs(scat), out=trail)
+ if sims[0].n % every == 0 and len(ts) < n_frames:
+ tot_frames.append(sims[0].p[::20, ::20].astype(np.float32))
+ trail_frames.append(trail[::20, ::20].astype(np.float32))
+ ts.append(sims[0].time)
+ return np.stack(tot_frames), np.stack(trail_frames), np.asarray(ts)
+
+
+@lru_cache(maxsize=1)
+def _metadiffuser_fields(
+ n_frames: int = _META_FRAMES,
+) -> tuple[Any, Any, Any]:
+ """Plane-wave packet onto a deep QRD vs the 2 cm metadiffuser, cached.
+
+ A 1.6 m x 1.2 m free-field box at dx = 0.25 mm, fine enough to mesh the
+ real millimetre slits, necks and cavities of the Table-1 metadiffuser
+ (density contrast 1e6:1 builds the panels). Each panel (flat control
+ with the metadiffuser's silhouette, deep QRD, metadiffuser) advances
+ in lockstep with a free-field reference inside its own worker process,
+ so ``total - incident`` is exact; a downgoing carrier packet at the
+ 2 kHz evaluation frequency plays the goniometer source. Returns the total
+ frame stacks, the fading scattered-envelope trails [dB] and the frame
+ times. The quantitative far-field comparison lives in the companion
+ polar figure; this clip shows the near fields of the meshed panels.
+ """
+ import multiprocessing as mp
+
+ tot, trail, times = None, None, None
+ try:
+ # The GPU host of .env turns the half-hour CPU computation into a
+ # couple of minutes (the runner falls back by itself, but the CPU
+ # pool below is faster than its single-process local mode).
+ import fdtd_gpu_remote
+
+ fdtd_gpu_remote.load_env()
+ config = fdtd_gpu_remote.RemoteConfig.from_env()
+ use_gpu = bool(config.host) and fdtd_gpu_remote.remote_available(
+ config)
+ except (ImportError, OSError, ValueError):
+ use_gpu = False
+ if use_gpu:
+ import fdtd2d
+
+ # Same duration rule as the CPU worker: 6.12 ms at 49 frames/period.
+ every = 33
+ stride = 20
+ dt = fdtd2d.FDTD2D(343.0, _META_DX, shape=(4, 8)).dt
+ sample_steps = [every * k for k in range(1, n_frames + 1)]
+ lam0 = 343.0 / _META_F0
+ frames: dict[str, Any] = {}
+ for kind in ("flat", "qrd", "meta", "ref"):
+ job = fdtd_gpu_remote.build_job(
+ 343.0, _META_DX, steps=every * n_frames,
+ sample_steps=sample_steps, shape=(_META_NY, _META_NX),
+ rho=_meta_rho(kind), sponge_width=200,
+ plane_waves=[{"direction": "up", "center": 0.80,
+ "width": 0.05, "wavelength": lam0}],
+ init_scale_x=_meta_taper(),
+ sample_stride=stride, sample_dtype="float32",
+ )
+ # The four 19 800-step field jobs run ~10-12 min each on the
+ # GPU host; give each submit an hour before falling back.
+ frames[kind] = fdtd_gpu_remote.submit(
+ job, config, timeout=3600.0)["frames"]
+ face_row = round(_META_FACE / _META_DX) // stride
+ decay = float(2.0 ** (-(every * dt) / 0.0015))
+ tot_list, trail_list = [], []
+ for kind in ("flat", "qrd", "meta"):
+ scat = frames[kind] - frames["ref"]
+ scat[:, :face_row, :] = 0.0
+ running = np.zeros_like(scat[0])
+ history = np.empty_like(scat)
+ for k in range(scat.shape[0]):
+ np.maximum(running * decay, np.abs(scat[k]), out=running)
+ history[k] = running
+ tot_list.append(frames[kind].astype(np.float32))
+ trail_list.append(history)
+ tot = np.stack(tot_list)
+ trail = np.stack(trail_list)
+ times = np.asarray(sample_steps, dtype=np.float64) * dt
+ else:
+ ctx = mp.get_context("spawn")
+ with ctx.Pool(processes=3) as pool:
+ parts = pool.starmap(
+ _meta_pair_worker,
+ [(kind, n_frames) for kind in ("flat", "qrd", "meta")],
+ )
+ tot = np.stack([part[0] for part in parts])
+ trail = np.stack([part[1] for part in parts])
+ times = parts[0][2]
+ ref = float(trail[:, trail.shape[1] // 3:].max()) or 1.0
+ with np.errstate(divide="ignore"):
+ trail_db = 20.0 * np.log10(trail / ref)
+ trail_db = np.clip(trail_db, -30.0, 0.0).astype(np.float32)
+ return tot, trail_db, times
+
+
+def animate_fdtd_metadiffuser(output_dir: str) -> None:
+ """The 27 cm deep Schroeder QRD vs the 2 cm metadiffuser that mimics
+ it, next to a flat control slab (2D FDTD at 0.25 mm, real slits, necks
+ and cavities meshed): the same 2 kHz wavefront leaves the same kind of
+ scattered fan, from a panel 13.7 times thinner."""
+ from matplotlib import patheffects
+ from matplotlib.patches import Polygon, Rectangle
+
+ T = _translate_str
+ outline = [patheffects.withStroke(linewidth=2.0, foreground="white")]
+ tot_all, trail_db, times = _metadiffuser_fields()
+ y1 = _META_FACE
+ x_l = _META_XL
+ x_r = x_l + _META_PERIODS * 5 * _META_PITCH
+ vmax = float(np.quantile(np.abs(tot_all[:, 0]), 0.999))
+
+ fig = _anim_figure()
+ fig.suptitle(T("Schroeder diffuser vs metadiffuser (2D FDTD)"),
+ fontweight="bold")
+ gs = fig.add_gridspec(2, 3)
+ titles = [T("Flat rigid panel"), T("QRD, wells down to 27 cm"),
+ T("Metadiffuser, 2 cm panel")]
+ qrd_poly: list[tuple[float, float]] = [(x_l, 0.05), (x_l, y1)]
+ for wx0, wx1, d in _meta_qrd_wells():
+ qrd_poly += [(wx0, y1), (wx0, y1 - d), (wx1, y1 - d), (wx1, y1)]
+ qrd_poly += [(x_r, y1), (x_r, 0.05)]
+ xc = 0.5 * (x_l + x_r)
+
+ ims: list[Any] = []
+ d_txts: list[Any] = []
+ for col in range(3):
+ ax_t = fig.add_subplot(gs[0, col])
+ ax_s = fig.add_subplot(gs[1, col])
+ im_t = ax_t.imshow(tot_all[col][0], origin="lower",
+ extent=(0.0, 1.6, 0.0, 1.2), cmap="RdBu_r",
+ vmin=-vmax, vmax=vmax, interpolation="bilinear")
+ im_s = ax_s.imshow(trail_db[col][0], origin="lower",
+ extent=(0.0, 1.6, 0.0, 1.2), cmap="magma",
+ vmin=-30.0, vmax=0.0, interpolation="bilinear")
+ ax_t.set_title(titles[col], fontsize=10, fontweight="bold")
+ for ax in (ax_t, ax_s):
+ ax.grid(False)
+ if col == 1:
+ ax.add_patch(Polygon(qrd_poly, closed=True,
+ facecolor=COLOR_GRID,
+ edgecolor=COLOR_FG, lw=0.8))
+ else:
+ ax.add_patch(Rectangle((x_l, y1 - 0.023), x_r - x_l, 0.023,
+ facecolor=COLOR_GRID,
+ edgecolor=COLOR_FG, lw=0.8))
+ ax.set_xlim(0.06, 1.54)
+ ax.set_ylim(0.0, 1.12)
+ ax.tick_params(labelsize=7)
+ ax_t.tick_params(labelbottom=False)
+ ax_s.set_xlabel("x [m]", fontsize=8)
+ if col == 1:
+ ax_t.text(xc, 0.97, T("incident plane wavefront"), ha="center",
+ va="bottom", color="black", fontsize=7.5,
+ path_effects=outline)
+ ax_t.annotate("", xy=(xc, 0.83), xytext=(xc, 0.955),
+ arrowprops={"arrowstyle": "-|>", "color": "black",
+ "lw": 1.2})
+ d_txt = ax_s.text(xc, 1.03, "", ha="center", va="top",
+ color="white", fontsize=7.5, fontweight="bold")
+ if col == 0:
+ ax_t.set_ylabel(T("sound field p"), fontsize=9)
+ ax_s.set_ylabel(T("scattered field (total − incident)"),
+ fontsize=8)
+ else:
+ ax_t.tick_params(labelleft=False)
+ ax_s.tick_params(labelleft=False)
+ if col == 1:
+ ax_t.annotate("", xy=(x_r + 0.045, y1 - 0.274),
+ xytext=(x_r + 0.045, y1),
+ arrowprops={"arrowstyle": "-", "color": COLOR_FG,
+ "lw": 1.6})
+ ax_t.text(x_r + 0.07, y1 - 0.14, "27 cm", ha="left",
+ va="center", fontsize=7, color=COLOR_FG)
+ if col == 2:
+ ax_t.text(xc, 0.06, T("real slits and resonators meshed at "
+ "0.25 mm"), ha="center", va="bottom",
+ color="black", fontsize=6.5, path_effects=outline)
+ ax_t.annotate("", xy=(x_r + 0.045, y1 - 0.023),
+ xytext=(x_r + 0.045, y1),
+ arrowprops={"arrowstyle": "-", "color": COLOR_FG,
+ "lw": 1.6})
+ ax_t.text(x_r + 0.07, y1 - 0.012, "2 cm", ha="left",
+ va="center", fontsize=7, color=COLOR_FG)
+ ims += [im_t, im_s]
+ d_txts.append(d_txt)
+ t_txt = fig.text(0.985, 0.02, "", ha="right", va="bottom",
+ family="monospace", fontsize=10, color=COLOR_FG)
+ reveal = int(0.8 * tot_all.shape[1])
+
+ verdicts = [T("a collimated specular beam"),
+ T("a wide scattered fan"),
+ T("the same fan, from 2 cm")]
+
+ def update(k: int) -> tuple[Any, ...]:
+ for col in range(3):
+ ims[2 * col].set_data(tot_all[col][k])
+ ims[2 * col + 1].set_data(trail_db[col][k])
+ d_txts[col].set_text(verdicts[col] if k >= reveal else "")
+ t_txt.set_text(T(f"t = {times[k] * 1000.0:4.2f} ms"))
+ return (*ims, *d_txts, t_txt)
+
+ _render_clip(fig, update, output_dir, "anim_fdtd_metadiffuser",
+ frames=int(tot_all.shape[1]), gif_fps=8)
+
+
def animate_standing_wave_tube(output_dir: str) -> None:
"""ISO 10534-2 impedance tube: the incident and reflected waves travel
inside a drawn tube and their sum forms the standing-wave envelope; a
@@ -15493,6 +16042,7 @@ def update(kf: int) -> tuple[Any, ...]:
"anim_fdtd_ground_effect": animate_fdtd_ground_effect,
"anim_fdtd_ducting": animate_fdtd_ducting,
"anim_fdtd_diffusion": animate_fdtd_diffusion,
+ "anim_fdtd_metadiffuser": animate_fdtd_metadiffuser,
"anim_fdtd_impedance_tube": animate_fdtd_impedance_tube,
"anim_fdtd_transmission_tube": animate_fdtd_transmission_tube,
"anim_standing_wave_tube": animate_standing_wave_tube,
@@ -15707,6 +16257,7 @@ def _generate_figures_parallel(
"anim_fdtd_ground_effect": 300.0,
"anim_fdtd_ducting": 280.0,
"anim_fdtd_diffusion": 260.0,
+ "anim_fdtd_metadiffuser": 540.0,
"anim_standing_wave_tube": 130.0,
"anim_sweep_deconvolution": 90.0,
"anim_power_two_rooms": 80.0,
diff --git a/site/src/content/docs/es/guides/surface-scattering.mdx b/site/src/content/docs/es/guides/surface-scattering.mdx
index 056edd603..bdffc94fd 100644
--- a/site/src/content/docs/es/guides/surface-scattering.mdx
+++ b/site/src/content/docs/es/guides/surface-scattering.mdx
@@ -10,6 +10,15 @@ references:
publisher: "CRC Press"
doi: "10.1201/9781315369211"
note: "ISBN 978-1-4987-4099-9. La monografía de referencia sobre teoría y diseño de difusores, de los autores del método del coeficiente de difusión de ISO 17497-2: la distinción dispersión-difusión, los montajes de medida y la guía de diseño que esta página condensa. El Apéndice B (tabla de coeficientes de difusión normalizados, pp. 481-485) es el anclaje BEM publicado del modelo de predicción de difusores de esta página."
+ - type: article
+ authors: ["Jiménez, N.", "Cox, T. J.", "Romero-García, V.", "Groby, J.-P."]
+ year: 2017
+ title: "Metadiffusers: Deep-subwavelength sound diffusers"
+ container: "Scientific Reports"
+ volume: "7"
+ pages: "5389"
+ doi: "10.1038/s41598-017-05710-5"
+ note: "El modelo de metadifusor implementado aquí: rendijas cargadas con resonadores de Helmholtz reproducen perfiles de fase de Schroeder y secuencias ternarias con paneles de 1/46 a 1/20 de la longitud de onda de diseño."
- type: article
authors: ["Hargreaves, T. J.", "Cox, T. J.", "Lam, Y. W.", "D'Antonio, P."]
year: 2000
@@ -586,6 +595,134 @@ plt.show()
+### Metadifusores: difusores de Schroeder en sublongitud de onda profunda
+
+Un metadifusor sustituye los pozos profundos de un difusor de Schroeder por
+rendijas finas cargadas con resonadores de Helmholtz (Jiménez, Cox,
+Romero-García y Groby, 2017). Por debajo de su resonancia, los resonadores
+ralentizan el sonido dentro de cada rendija, de modo que un panel de pocos
+centímetros alcanza las fases de reflexión que a una red de fase clásica le
+exigen decenas de centímetros de profundidad; y llevar una rendija al
+acoplamiento crítico añade un estado perfectamente absorbente, el `0` que
+requieren las secuencias ternarias. `metadiffuser_reflection` ejecuta la
+cadena de matrices de transferencia del
+[absorbedor de sonido lento](/phonometry/es/guides/materials/) una vez por
+pozo (resonadores bidimensionales, pérdidas viscotérmicas y correcciones de
+extremo incluidas) y devuelve la reflexión compleja por pozo $R_n(f)$;
+`metadiffuser_polar_response` y `metadiffuser_diffusion_spectrum` reducen
+ese perfil espacial con el mismo campo lejano de Fraunhofer y el mismo
+coeficiente ISO 17497-2 que los diseños clásicos de arriba.
+
+
+
+El diseño de residuo cuadrático publicado empaqueta el difusor completo en
+un panel de 35 cm x 2 cm. Su primera rendija alcanza el acoplamiento crítico
+(el cero de reflexión con el que se construye el estado `0` ternario), y a
+la frecuencia de evaluación de 2 kHz el panel dispersa como el QRD de
+27,4 cm de profundidad al que imita:
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
+
+# El metadifusor de residuo cuadrático publicado: cinco rendijas con dos
+# resonadores cada una en un panel de 35 cm x 2 cm (paso de 7 cm),
+# ajustado para imitar un QRD diseñado a 500 Hz cuyos pozos llegarían a
+# 27,4 cm de profundidad.
+mm = 1e-3
+filas = [ # rendija h, cuello l_n, cavidad l_c, cuello w_n, cavidad w_c [mm]
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+pozos = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in filas
+]
+
+f = np.arange(1800.0, 2601.0, 5.0)
+panel = metadiffuser_reflection(f, pozos, depth=0.02, period=0.07)
+alfa1 = panel.well_absorption[0]
+print(round(float(alfa1.max()), 2), int(f[alfa1.argmax()])) # 0.99 2305
+
+polar = metadiffuser_polar_response(2000.0, pozos, depth=0.02,
+ period=0.07, periods=6)
+print(round(polar.coefficient, 2)) # 0.32
+```
+
+
+
+
+Ver el código de esta figura
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ materials,
+ metadiffuser_polar_response,
+)
+
+mm = 1e-3
+filas = [
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+pozos = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in filas
+]
+
+# El panel metadifusor y el QRD al que se ajustó, a 2 kHz y con seis
+# repeticiones del periodo.
+meta = metadiffuser_polar_response(2000.0, pozos, depth=0.02, period=0.07,
+ periods=6)
+secuencia = np.roll(materials.quadratic_residue_sequence(5), -1)
+profundidades = secuencia * (343.0 / 500.0) / (2 * 5)
+qrd = materials.predict_diffuser_polar_response(
+ 0.07, 2000.0, depths=profundidades, periods=6, include_obliquity=False,
+)
+
+ax = meta.plot(marker="", linewidth=2.2, language="es",
+ label="Metadifusor, panel de 2 cm")
+qrd.plot(ax=ax, marker="", linewidth=1.6, linestyle="--", language="es",
+ label="QRD, pozos de hasta 27,4 cm")
+ax.legend(loc="lower center")
+```
+
+
+
+La superposición de campo lejano de arriba es el resumen en frecuencia; la
+animación FDTD de abajo malla ambos paneles de verdad (el metadifusor a
+0,25 mm, con sus rendijas, cuellos y cavidades) y lanza el mismo frente de
+2 kHz contra ellos: el QRD de 27 cm y el panel de 2 cm devuelven abanicos
+dispersados casi idénticos.
+
+
+
## ¿Dispersión o difusión? Dos coeficientes, dos trabajos
Los dos coeficientes anteriores se tratan de forma rutinaria como
diff --git a/site/src/content/docs/guides/surface-scattering.mdx b/site/src/content/docs/guides/surface-scattering.mdx
index 3614affac..d47e91db6 100644
--- a/site/src/content/docs/guides/surface-scattering.mdx
+++ b/site/src/content/docs/guides/surface-scattering.mdx
@@ -10,6 +10,15 @@ references:
publisher: "CRC Press"
doi: "10.1201/9781315369211"
note: "ISBN 978-1-4987-4099-9. The reference monograph on diffuser theory and design, by the authors behind the ISO 17497-2 diffusion-coefficient method: the scattering-versus-diffusion distinction, the measurement rigs and the design guidance this page condenses. Appendix B (Normalized diffusion coefficient table, pp. 481-485) is the published BEM anchor for the diffuser-prediction model on this page."
+ - type: article
+ authors: ["Jiménez, N.", "Cox, T. J.", "Romero-García, V.", "Groby, J.-P."]
+ year: 2017
+ title: "Metadiffusers: Deep-subwavelength sound diffusers"
+ container: "Scientific Reports"
+ volume: "7"
+ pages: "5389"
+ doi: "10.1038/s41598-017-05710-5"
+ note: "The metadiffuser model implemented here: slits loaded by Helmholtz resonators reproduce Schroeder phase profiles and ternary sequences from panels 1/46 to 1/20 of the design wavelength thick."
- type: article
authors: ["Hargreaves, T. J.", "Cox, T. J.", "Lam, Y. W.", "D'Antonio, P."]
year: 2000
@@ -567,6 +576,128 @@ plt.show()
+### Metadiffusers: deep-subwavelength Schroeder diffusers
+
+A metadiffuser replaces the deep wells of a Schroeder diffuser with thin
+slits loaded by Helmholtz resonators (Jimenez, Cox, Romero-Garcia and Groby,
+2017). Below their resonance the resonators slow the sound inside each slit,
+so a panel a few centimetres thick reaches the reflection phases that a
+classical phase grating needs tens of centimetres of depth for, and driving
+a slit to critical coupling adds a perfectly absorbing state, the `0` that
+ternary sequences require. `metadiffuser_reflection` runs the slit
+transfer-matrix chain of the [slow-sound absorber](/phonometry/guides/materials/) once per
+well (two-dimensional resonators, visco-thermal losses and end corrections
+included) and returns the per-well complex reflection $R_n(f)$;
+`metadiffuser_polar_response` and `metadiffuser_diffusion_spectrum` reduce
+that spatial profile through the same Fraunhofer far field and ISO 17497-2
+coefficient used for the classical designs above.
+
+
+
+The published quadratic-residue design packs the whole diffuser into a
+35 cm x 2 cm panel. Its first slit reaches critical coupling (the reflection
+zero the ternary `0` state is built from), and at the 2 kHz evaluation
+frequency the panel scatters like the 27.4 cm deep QRD it mimics:
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
+
+# The published quadratic-residue metadiffuser: five slits with two
+# resonators each in a 35 cm x 2 cm panel (7 cm pitch), tuned to mimic a
+# QRD designed for 500 Hz whose wells would run up to 27.4 cm deep.
+mm = 1e-3
+rows = [ # slit h, neck l_n, cavity l_c, neck w_n, cavity w_c [mm]
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+wells = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in rows
+]
+
+f = np.arange(1800.0, 2601.0, 5.0)
+panel = metadiffuser_reflection(f, wells, depth=0.02, period=0.07)
+alpha1 = panel.well_absorption[0]
+print(round(float(alpha1.max()), 2), int(f[alpha1.argmax()])) # 0.99 2305
+
+polar = metadiffuser_polar_response(2000.0, wells, depth=0.02,
+ period=0.07, periods=6)
+print(round(polar.coefficient, 2)) # 0.32
+```
+
+
+
+
+Show the code for this figure
+
+```python
+import numpy as np
+from phonometry import (
+ HelmholtzResonator,
+ MetadiffuserWell,
+ materials,
+ metadiffuser_polar_response,
+)
+
+mm = 1e-3
+rows = [
+ (14.7, 13.0, 16.4, 6.2, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (30.9, 9.1, 4.3, 3.5, 9.0),
+ (15.7, 13.3, 17.0, 6.3, 9.0),
+ (20.3, 18.0, 20.7, 3.2, 9.0),
+]
+wells = [
+ MetadiffuserWell(
+ h * mm,
+ 2 * (HelmholtzResonator(ln * mm, wn * mm, lc * mm, wc * mm),),
+ )
+ for h, ln, lc, wn, wc in rows
+]
+
+# The metadiffuser panel and the QRD it was tuned to at 2 kHz, both with
+# six repetitions of the period.
+meta = metadiffuser_polar_response(2000.0, wells, depth=0.02, period=0.07,
+ periods=6)
+sequence = np.roll(materials.quadratic_residue_sequence(5), -1)
+depths = sequence * (343.0 / 500.0) / (2 * 5)
+qrd = materials.predict_diffuser_polar_response(
+ 0.07, 2000.0, depths=depths, periods=6, include_obliquity=False,
+)
+
+ax = meta.plot(marker="", linewidth=2.2, label="Metadiffuser, panel 2 cm")
+qrd.plot(ax=ax, marker="", linewidth=1.6, linestyle="--",
+ label="QRD, wells up to 27.4 cm")
+ax.legend(loc="lower center")
+```
+
+
+
+The far-field overlay above is the frequency-domain summary; the FDTD
+animation below meshes both panels for real (the metadiffuser at 0.25 mm, slits,
+necks and cavities included) and lets the same 2 kHz wavefront hit them: the
+27 cm QRD and the 2 cm panel throw out nearly the same scattered fan.
+
+
+
## Scattering or diffusion? Two coefficients, two jobs
The two coefficients above are routinely treated as interchangeable, in
diff --git a/site/src/content/docs/reference/api/index.md b/site/src/content/docs/reference/api/index.md
index e03b3583e..570b57d74 100644
--- a/site/src/content/docs/reference/api/index.md
+++ b/site/src/content/docs/reference/api/index.md
@@ -119,6 +119,7 @@ La referencia de la API se genera a partir de los docstrings del código (en ing
| [`materials.slow_sound_absorber`](/phonometry/reference/api/materials/slow-sound-absorber/) | Slow-sound slit panels loaded with Helmholtz resonators (perfect absorbers). |
| [`materials.scattering_diffusion`](/phonometry/reference/api/materials/scattering-diffusion/) | Random-incidence scattering and directional diffusion coefficients. |
| [`materials.diffuser_design`](/phonometry/reference/api/materials/diffuser-design/) | Far-field polar response and diffusion coefficient predicted from a diffuser design. |
+| [`materials.metadiffuser`](/phonometry/reference/api/materials/metadiffuser/) | Metadiffusers: deep-subwavelength Schroeder-like sound diffusers. |
| [`materials.road_absorption`](/phonometry/reference/api/materials/road-absorption/) | In-situ sound absorption of road surfaces (ISO 13472-1 / ISO 13472-2). |
## Vibration and structure-borne
diff --git a/site/src/content/docs/reference/api/materials/metadiffuser.md b/site/src/content/docs/reference/api/materials/metadiffuser.md
new file mode 100644
index 000000000..db40d8d83
--- /dev/null
+++ b/site/src/content/docs/reference/api/materials/metadiffuser.md
@@ -0,0 +1,268 @@
+---
+title: "materials.metadiffuser"
+description: "Metadiffusers: deep-subwavelength Schroeder-like sound diffusers."
+sidebar:
+ label: "metadiffuser"
+---
+
+Metadiffusers: deep-subwavelength Schroeder-like sound diffusers.
+
+A metadiffuser is a rigidly backed slotted panel whose slits are each loaded
+by an array of Helmholtz resonators (Jimenez, Cox, Romero-Garcia and Groby,
+*Metadiffusers: Deep-subwavelength sound diffusers*, Sci. Rep. 7, 5389,
+2017). The resonators slow the sound inside each slit, so the slit reaches
+its quarter-wavelength condition at a fraction of the depth a plain well
+would need; by giving every slit a different geometry the panel presents a
+spatially dependent complex reflection coefficient `R_n(f)` along its
+face. Tuning that profile to a Schroeder phase grating reproduces the
+scattering of a quadratic-residue or primitive-root diffuser from a panel
+1/46 to 1/20 of the design wavelength thick, and driving single slits to
+critical coupling adds the perfectly absorbing `0` state that ternary
+sequences require.
+
+Each slit is modelled with the transfer-matrix chain of
+[`slit_helmholtz_absorber`](/phonometry/reference/api/materials/slow-sound-absorber/#slit_helmholtz_absorber)
+(visco-thermal effective parameters, resonator end corrections and slit
+radiation correction); the panel is locally reacting, so the wells do not
+couple internally and the far field follows from the Fraunhofer integral of
+the per-well reflection sequence
+([`predict_diffuser_polar_response`](/phonometry/reference/api/materials/diffuser-design/#predict_diffuser_polar_response))
+reduced to the ISO 17497-2 directional diffusion coefficient.
+
+> Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand.
+
+## metadiffuser_diffusion_spectrum
+
+```python
+metadiffuser_diffusion_spectrum(
+ frequencies: ArrayLike,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angles: ArrayLike = (-90, -85, -80, -75, -70, -65, -60, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90),
+ source_angle: float = 0.0,
+ periods: int = 1,
+ resonator_geometry: str = 'slit',
+ speed_of_sound: float = 343.0,
+ air_density: float = 1.205,
+ viscosity: float = 1.84e-05,
+ prandtl_number: float = 0.71,
+ heat_capacity_ratio: float = 1.4,
+ atmospheric_pressure: float = 101325.0,
+) -> DiffusionSpectrum
+```
+
+Normalized diffusion-coefficient spectrum `d_n(f)` of a metadiffuser.
+
+Evaluates the far-field polar response at each frequency with
+[`metadiffuser_polar_response`](/phonometry/reference/api/materials/metadiffuser/#metadiffuser_polar_response), forms the ISO 17497-2 directional
+diffusion coefficient band by band and normalises it against a flat
+rigid reference of the same footprint (all wells `R = 1`) with
+[`normalized_diffusion_coefficient`](/phonometry/reference/api/materials/scattering-diffusion/#normalized_diffusion_coefficient),
+exactly as the paper reports `delta_n`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies of the spectrum, in hertz (1-D). |
+| `wells` | Sequence of [`MetadiffuserWell`](/phonometry/reference/api/materials/metadiffuser/#metadiffuserwell) (or `None` for a flat rigid strip) describing one period of the panel face. |
+| `depth` | Panel depth `L` common to all slits, in metres. |
+| `period` | Well pitch `d` along the panel face, in metres. |
+| `angles` | Receiver reflection angles `theta`, in degrees. |
+| `source_angle` | Angle of incidence `psi`, in degrees. |
+| `periods` | Number of repetitions `N_p` of the single period. |
+| `resonator_geometry` | `"slit"` (default) for the paper's two-dimensional resonators, `"square"` for square-duct necks and cavities. |
+| `speed_of_sound` | Speed of sound `c0` in air, in m/s. |
+| `air_density` | Air density `rho0`, in kg/m3. |
+| `viscosity` | Dynamic viscosity `eta` of air, in Pa s. |
+| `prandtl_number` | Prandtl number `Pr` of air. |
+| `heat_capacity_ratio` | Ratio of specific heats `gamma`. |
+| `atmospheric_pressure` | Static pressure `P0`, in Pa. |
+
+**Returns:** A [`DiffusionSpectrum`](/phonometry/reference/api/materials/scattering-diffusion/#diffusionspectrum) carrying the raw `d(f)` and the normalised `d_n(f)`.
+
+## metadiffuser_polar_response
+
+```python
+metadiffuser_polar_response(
+ frequency: float,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angles: ArrayLike = (-90, -85, -80, -75, -70, -65, -60, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90),
+ source_angle: float = 0.0,
+ periods: int = 1,
+ resonator_geometry: str = 'slit',
+ speed_of_sound: float = 343.0,
+ air_density: float = 1.205,
+ viscosity: float = 1.84e-05,
+ prandtl_number: float = 0.71,
+ heat_capacity_ratio: float = 1.4,
+ atmospheric_pressure: float = 101325.0,
+) -> DiffuserPolarResponse
+```
+
+Far-field polar response of a metadiffuser at one frequency.
+
+Computes the per-well complex reflection sequence at `frequency` with
+[`metadiffuser_reflection`](/phonometry/reference/api/materials/metadiffuser/#metadiffuser_reflection) (the panel is locally reacting, so the
+slit chains see the incidence angle `source_angle`) and evaluates the
+Fraunhofer far field and ISO 17497-2 directional diffusion coefficient
+with
+[`predict_diffuser_polar_response`](/phonometry/reference/api/materials/diffuser-design/#predict_diffuser_polar_response).
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequency` | Frequency of the prediction `f`, in hertz. |
+| `wells` | Sequence of [`MetadiffuserWell`](/phonometry/reference/api/materials/metadiffuser/#metadiffuserwell) (or `None` for a flat rigid strip) describing one period of the panel face. |
+| `depth` | Panel depth `L` common to all slits, in metres. |
+| `period` | Well pitch `d` along the panel face, in metres; it is the `well_width` of the far-field model. |
+| `angles` | Receiver reflection angles `theta`, in degrees. |
+| `source_angle` | Angle of incidence `psi` of the source, in degrees; also applied to the local slit reflection. |
+| `periods` | Number of repetitions `N_p` of the single period; the grating lobes of a Schroeder-like design require `periods >= 2`. |
+| `resonator_geometry` | `"slit"` (default) for the paper's two-dimensional resonators, `"square"` for square-duct necks and cavities. |
+| `speed_of_sound` | Speed of sound `c0` in air, in m/s. |
+| `air_density` | Air density `rho0`, in kg/m3. |
+| `viscosity` | Dynamic viscosity `eta` of air, in Pa s. |
+| `prandtl_number` | Prandtl number `Pr` of air. |
+| `heat_capacity_ratio` | Ratio of specific heats `gamma`. |
+| `atmospheric_pressure` | Static pressure `P0`, in Pa. |
+
+**Returns:** A [`DiffuserPolarResponse`](/phonometry/reference/api/materials/diffuser-design/#diffuserpolarresponse).
+
+## metadiffuser_reflection
+
+```python
+metadiffuser_reflection(
+ frequency: ArrayLike,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angle: float = 0.0,
+ resonator_geometry: str = 'slit',
+ speed_of_sound: float = 343.0,
+ air_density: float = 1.205,
+ viscosity: float = 1.84e-05,
+ prandtl_number: float = 0.71,
+ heat_capacity_ratio: float = 1.4,
+ atmospheric_pressure: float = 101325.0,
+) -> MetadiffuserResult
+```
+
+Per-well reflection spectra of a metadiffuser panel (Sci. Rep. Eq. (6)).
+
+Runs the rigidly backed slit transfer-matrix chain of
+[`slit_helmholtz_absorber`](/phonometry/reference/api/materials/slow-sound-absorber/#slit_helmholtz_absorber)
+once per well: each [`MetadiffuserWell`](/phonometry/reference/api/materials/metadiffuser/#metadiffuserwell) becomes a slit of height
+`h_n` and depth `L` loaded by its `M` resonators on the lattice
+`a = L / M`, and `None` wells are flat rigid strips with `R = 1`.
+The panel is locally reacting, so a well's reflection does not depend on
+its neighbours and the incidence `angle` enters only through the front
+air impedance.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequency` | Frequency vector `f`, in hertz. |
+| `wells` | Sequence of [`MetadiffuserWell`](/phonometry/reference/api/materials/metadiffuser/#metadiffuserwell) (or `None` for a flat rigid strip) describing one period of the panel face. |
+| `depth` | Panel depth `L` common to all slits, in metres. |
+| `period` | Well pitch `d` along the panel face, in metres. |
+| `angle` | Polar angle of incidence `theta`, in radians. |
+| `resonator_geometry` | `"slit"` (default) for the paper's two-dimensional resonators, `"square"` for square-duct necks and cavities. |
+| `speed_of_sound` | Speed of sound `c0` in air, in m/s. |
+| `air_density` | Air density `rho0`, in kg/m3. |
+| `viscosity` | Dynamic viscosity `eta` of air, in Pa s. |
+| `prandtl_number` | Prandtl number `Pr` of air. |
+| `heat_capacity_ratio` | Ratio of specific heats `gamma`. |
+| `atmospheric_pressure` | Static pressure `P0`, in Pa. |
+
+**Returns:** A [`MetadiffuserResult`](/phonometry/reference/api/materials/metadiffuser/#metadiffuserresult) with one reflection row per well.
+
+## MetadiffuserResult
+
+```python
+MetadiffuserResult(
+ frequency: Real,
+ reflection: Complex,
+ absorption: Real,
+ well_absorption: Real,
+ wells: tuple[MetadiffuserWell | None, ...] | None = None,
+ depth: float | None = None,
+ period: float | None = None,
+)
+```
+
+Spectra of a metadiffuser panel, one reflection row per well.
+
+`reflection` has shape `(N, len(frequency))` with the complex
+pressure reflection factor of each well (flat strips are exactly `1`),
+`absorption` is the face-averaged energy absorption
+`alpha(f) = 1 - mean_n |R_n|^2` and `well_absorption` the per-well
+`alpha_n = 1 - |R_n|^2`. The trailing fields retain the geometry the
+prediction was run with (`wells`, `depth`, `period`) so
+`plot_geometry` can draw the panel section; they default to
+`None` for hand-built results.
+
+### MetadiffuserResult.plot()
+
+```python
+MetadiffuserResult.plot(
+ ax: Axes | None = None,
+ *,
+ language: str = 'en',
+ **kwargs: Any,
+) -> Axes
+```
+
+Plot the per-well and face-averaged absorption spectra.
+
+Requires matplotlib (`pip install phonometry[plot]`); returns the
+`Axes`.
+
+### MetadiffuserResult.plot_geometry()
+
+```python
+MetadiffuserResult.plot_geometry(
+ ax: Axes | None = None,
+ *,
+ language: str = 'en',
+ **kwargs: Any,
+) -> Axes
+```
+
+Draw the panel cross-section to scale (slits and resonators).
+
+Requires matplotlib (`pip install phonometry[plot]`); returns the
+`Axes`.
+
+**Raises**
+
+| Exception | When |
+| :--- | :--- |
+| ValueError | If the result does not retain its geometry. |
+
+## MetadiffuserWell
+
+```python
+MetadiffuserWell(
+ slit_height: float,
+ resonators: tuple[HelmholtzResonator, ...],
+)
+```
+
+One slit of a metadiffuser panel.
+
+`slit_height` is the slit opening `h_n` along the panel face and
+`resonators` the Helmholtz resonators loading the slit, ordered from
+the panel face towards the rigid backing; the resonator lattice step is
+`a = L / M` for a panel of depth `L` and `M` resonators. All
+lengths are in metres. A `None` entry in a well sequence stands for a
+flat rigid strip of the panel face (`R = 1`), the `+1` state of
+ternary-sequence designs.
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 48a97bf21..0afb39082 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
@@ -153,6 +153,7 @@ helmholtz_resonator_impedance(
slit_height: float | None = None,
lattice_step: float | None = None,
end_correction: bool = True,
+ geometry: str = 'square',
air_density: float = 1.205,
viscosity: float = 1.84e-05,
prandtl_number: float = 0.71,
@@ -164,7 +165,8 @@ helmholtz_resonator_impedance(
Acoustic impedance of a Helmholtz resonator with visco-thermal losses.
-The neck and cavity use the square-duct effective parameters of
+With the default `geometry="square"` the neck and cavity are square
+ducts using the effective parameters of
[`rectangular_duct_properties`](/phonometry/reference/api/materials/slow-sound-absorber/#rectangular_duct_properties); the impedance is Appl. Phys. Lett. 2016
Eq. (A23) with the neck-to-cavity radiation correction of Eq. (A24) and,
when `slit_height` and `lattice_step` are supplied, the neck-to-slit
@@ -180,15 +182,23 @@ correction of Eqs. (A25)-(A26) added to the total neck length correction:
with `Z_n = sqrt(kappa_n rho_n) / w_n^2`, `k_n = w sqrt(rho_n / kappa_n)`
(and likewise for the cavity), reducing to Eq. (A22) when `dl = 0`.
+With `geometry="slit"` the resonator is two-dimensional (the neck and
+cavity are slit-like ducts spanning the lattice step): the effective
+parameters come from [`slit_effective_properties`](/phonometry/reference/api/materials/slow-sound-absorber/#slit_effective_properties) with the neck and
+cavity widths, the duct sections are `w_n a` and `w_c a`, and the end
+corrections are the 2-D fits of Sci. Rep. 7:5389 Eqs. (11)-(12); both
+`slit_height` and `lattice_step` are then required.
+
**Parameters**
| Name | Description |
| :--- | :--- |
| `frequency` | Frequency vector `f`, in hertz. |
| `resonator` | The [`HelmholtzResonator`](/phonometry/reference/api/materials/slow-sound-absorber/#helmholtzresonator) geometry. |
-| `slit_height` | Slit height `h` for the neck-to-slit correction; if `None` that correction is omitted. |
+| `slit_height` | Slit height `h` for the neck-to-slit correction; if `None` that correction is omitted (`"square"` only). |
| `lattice_step` | Lattice step `a` for the neck-to-slit correction. |
| `end_correction` | Include the radiation end corrections (default True). |
+| `geometry` | `"square"` (default) for square-duct necks and cavities, `"slit"` for the two-dimensional resonator model. |
| `air_density` | Air density `rho0`, in kg/m3. |
| `viscosity` | Dynamic viscosity `eta` of air, in Pa s. |
| `prandtl_number` | Prandtl number `Pr` of air. |
@@ -393,6 +403,7 @@ slit_helmholtz_absorber(
angle: float = 0.0,
end_correction: bool = True,
slit_radiation: bool = True,
+ resonator_geometry: str = 'square',
speed_of_sound: float = 343.0,
air_density: float = 1.205,
viscosity: float = 1.84e-05,
diff --git a/site/src/generated/api-sidebar.mjs b/site/src/generated/api-sidebar.mjs
index b2bbd88b4..695f40c68 100644
--- a/site/src/generated/api-sidebar.mjs
+++ b/site/src/generated/api-sidebar.mjs
@@ -118,6 +118,7 @@ export const apiSidebar = {
'reference/api/materials/slow-sound-absorber',
'reference/api/materials/scattering-diffusion',
'reference/api/materials/diffuser-design',
+ 'reference/api/materials/metadiffuser',
'reference/api/materials/road-absorption',
],
},
diff --git a/sonar-project.properties b/sonar-project.properties
index b99eb11c9..463d82a20 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -34,7 +34,7 @@ sonar.cpd.exclusions=src/phonometry/__init__.py,src/phonometry/metrology/core.py
# demands (the S-wave speed map, the recorded probe fields and the
# snapshot field). All extras are keyword-only optionals, so the parameter
# count is the physics', not incidental complexity.
-sonar.issue.ignore.multicriteria=S107A,S107B,S107C,S107D,S107E
+sonar.issue.ignore.multicriteria=S107A,S107B,S107C,S107D,S107F,S107E
sonar.issue.ignore.multicriteria.S107A.ruleKey=python:S107
sonar.issue.ignore.multicriteria.S107A.resourceKey=src/phonometry/__init__.py
sonar.issue.ignore.multicriteria.S107B.ruleKey=python:S107
@@ -43,5 +43,7 @@ sonar.issue.ignore.multicriteria.S107C.ruleKey=python:S107
sonar.issue.ignore.multicriteria.S107C.resourceKey=src/phonometry/aircraft/rotorcraft_noise.py
sonar.issue.ignore.multicriteria.S107D.ruleKey=python:S107
sonar.issue.ignore.multicriteria.S107D.resourceKey=src/phonometry/materials/slow_sound_absorber.py
+sonar.issue.ignore.multicriteria.S107F.ruleKey=python:S107
+sonar.issue.ignore.multicriteria.S107F.resourceKey=src/phonometry/materials/metadiffuser.py
sonar.issue.ignore.multicriteria.S107E.ruleKey=python:S107
sonar.issue.ignore.multicriteria.S107E.resourceKey=src/phonometry/simulation/elastic_fdtd.py
diff --git a/src/phonometry/__init__.py b/src/phonometry/__init__.py
index d28912fde..e7ddceccf 100644
--- a/src/phonometry/__init__.py
+++ b/src/phonometry/__init__.py
@@ -568,6 +568,13 @@
two_microphone_impedance,
wave_decomposition,
)
+from .materials.metadiffuser import (
+ MetadiffuserResult,
+ MetadiffuserWell,
+ metadiffuser_diffusion_spectrum,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
from .materials.porous_absorber import (
DELANY_BAZLEY_COEFFICIENTS,
DELANY_BAZLEY_VALIDITY,
@@ -1296,6 +1303,8 @@
"MISOCoherenceResult",
"MeanGroundPlaneResult",
"MembraneLayer",
+ "MetadiffuserResult",
+ "MetadiffuserWell",
"MeteorologicalCorrection",
"MicroperforatedPlateLayer",
"MicrophoneCharacteristics",
@@ -1736,6 +1745,9 @@
"measurement_positions",
"membrane_impedance",
"membrane_resonance_frequency",
+ "metadiffuser_diffusion_spectrum",
+ "metadiffuser_polar_response",
+ "metadiffuser_reflection",
"meteorological_correction",
"meteorological_corrections",
"mic_calibration_factor",
diff --git a/src/phonometry/_plot/geometry.py b/src/phonometry/_plot/geometry.py
index 92f82c5f4..ddb01754d 100644
--- a/src/phonometry/_plot/geometry.py
+++ b/src/phonometry/_plot/geometry.py
@@ -47,6 +47,7 @@
from ..environmental.ground_barriers import BarrierInsertionLoss
from ..materials.diffuser_design import DiffuserPolarResponse
from ..materials.impedance_tube import ImpedanceTubeResult, TransferMatrix
+ from ..materials.metadiffuser import MetadiffuserResult
from ..materials.porous_absorber import Layer, LayeredAbsorberResult
from ..materials.road_absorption import InsituAbsorptionResult
from ..materials.slow_sound_absorber import (
@@ -80,6 +81,8 @@
#: verbatim English text. ``_t`` returns the English key unchanged for any
#: language other than ``"es"``.
_STRINGS: dict[str, str] = {
+ "Metadiffuser cross-section (one period)":
+ "Sección del metadifusor (un periodo)",
"Air": "Aire",
"Porous": "Poroso",
"Perforated plate": "Placa perforada",
@@ -1080,6 +1083,143 @@ def plot_slit_absorber_result_geometry(
)
+def _draw_metadiffuser_well(ax: Axes, well: Any, x_slit: float,
+ depth: float, kwargs: dict[str, Any]) -> None:
+ """One slit with its sideways resonator shelves, panel coordinates."""
+ h = float(well.slit_height)
+ _material_rect(ax, x_slit, 0.0, h, depth, "cavity", **kwargs)
+ chain = list(well.resonators)
+ step = depth / len(chain)
+ for m, resonator in enumerate(chain):
+ w_n = float(resonator.neck_side)
+ l_n = float(resonator.neck_length)
+ w_c = float(resonator.cavity_side)
+ l_c = float(resonator.cavity_length)
+ y_m = depth - (m + 0.5) * step
+ x_neck = x_slit + h
+ _material_rect(
+ ax, x_neck, y_m - 0.5 * w_n, l_n, w_n, "cavity",
+ edgecolor=_C_EDGE, linewidth=0.5,
+ )
+ _material_rect(
+ ax, x_neck + l_n, y_m - 0.5 * w_c, l_c, w_c, "cavity",
+ edgecolor=_C_EDGE, linewidth=0.5,
+ )
+
+
+def plot_metadiffuser_panel_geometry(
+ wells: Sequence[Any],
+ ax: Axes | None = None,
+ *,
+ depth: float,
+ period: float,
+ language: str = "en",
+ **kwargs: Any,
+) -> Axes:
+ """Draw one period of a metadiffuser panel, to scale.
+
+ Side cut of the slotted panel (Sci. Rep. 7:5389 Fig. 1(b)): the face
+ runs along the top with the sound arriving from above, the wells
+ repeat horizontally at the pitch ``d``, and each well is a slit of
+ height ``h_n`` descending the panel depth, loaded by its resonators
+ at the lattice step ``a = L / M`` shelved sideways into the septum;
+ ``None`` wells are flat rigid strips; rigid back wall underneath.
+
+ :param wells: The well sequence of
+ :func:`~phonometry.materials.metadiffuser.metadiffuser_reflection`
+ (:class:`~phonometry.materials.metadiffuser.MetadiffuserWell` or
+ ``None`` per well).
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param depth: Panel depth ``L``, in metres.
+ :param period: Well pitch ``d``, in metres.
+ :param language: Label language, ``"en"`` (default) or ``"es"``.
+ :param kwargs: Forwarded to the slit rectangles.
+ :return: The axes.
+ """
+ _check_language(language)
+ if depth <= 0.0 or period <= 0.0:
+ raise ValueError("'depth' and 'period' must be positive.")
+ cells = list(wells)
+ if len(cells) < 2:
+ raise ValueError("'wells' must contain at least two wells.")
+ for well in cells:
+ if well is not None and well.slit_height >= period:
+ raise ValueError(
+ "every slit height must be smaller than the period."
+ )
+ if ax is None:
+ ax = _new_axes()
+ n_wells = len(cells)
+ d = period
+ total = n_wells * d
+ # Face along x with the sound arriving from above; the thin panel depth
+ # runs downward (Fig. 1(b) of the paper). Each cell carries its slit at
+ # the left, with the resonator necks and cavities branching sideways
+ # into the solid septum between slits.
+ _material_rect(
+ ax, 0.0, 0.0, total, depth, "plate", linewidth=0.7, alpha=0.45,
+ )
+ back = 0.4 * depth
+ _material_rect(ax, -0.01 * total, -back, 1.02 * total, back, "rigid")
+ kwargs.setdefault("linewidth", 0.5)
+ for index, well in enumerate(cells):
+ if well is not None:
+ _draw_metadiffuser_well(ax, well, index * d + 0.12 * d, depth,
+ kwargs)
+ for index, well in enumerate(cells):
+ if well is None:
+ continue
+ x_mark = index * d + 0.12 * d + 0.5 * float(well.slit_height)
+ ax.text(
+ x_mark, depth - 0.012 * total, str(index + 1),
+ fontsize=6, ha="center", va="top", color=_C_EDGE,
+ )
+ _incidence_arrow(
+ ax, 1.5 * d, depth + 0.5 * total * 0.16, 0.4 * total * 0.16,
+ language, downward=True,
+ )
+ off = 0.045 * total
+ _dim(ax, (0.0, -back), (total, -back), _mm(total, language),
+ offset=-off)
+ _dim(ax, (total, 0.0), (total, depth), _mm(depth, language),
+ offset=-1.3 * off, tight=True)
+ _dim(ax, ((n_wells - 1) * d, depth), (total, depth),
+ _mm(d, language), offset=0.6 * off)
+ first = next(
+ (well for well in cells if well is not None), None,
+ )
+ if first is not None:
+ index = cells.index(first)
+ x_slit = index * d + 0.12 * d
+ h = float(first.slit_height)
+ _dim(ax, (x_slit, depth), (x_slit + h, depth), _mm(h, language),
+ offset=0.6 * off, tight=True)
+ _finish_geometry_axes(
+ ax, _t("Metadiffuser cross-section (one period)", language)
+ )
+ return ax
+
+
+def plot_metadiffuser_geometry(
+ result: MetadiffuserResult,
+ ax: Axes | None = None,
+ *,
+ language: str = "en",
+ **kwargs: Any,
+) -> Axes:
+ """Panel drawing for a metadiffuser result that retained its geometry."""
+ if result.wells is None or result.depth is None or result.period is None:
+ raise ValueError(
+ "This result does not retain its geometry; call "
+ "plot_metadiffuser_panel_geometry(...) with the original "
+ "arguments."
+ )
+ return plot_metadiffuser_panel_geometry(
+ result.wells, ax=ax, depth=result.depth, period=result.period,
+ language=language, **kwargs,
+ )
+
+
def plot_diffuser_geometry(
result: DiffuserPolarResponse,
ax: Axes | None = None,
diff --git a/src/phonometry/_plot/materials.py b/src/phonometry/_plot/materials.py
index af5ac7efa..c93d45a52 100644
--- a/src/phonometry/_plot/materials.py
+++ b/src/phonometry/_plot/materials.py
@@ -30,6 +30,7 @@
from ..materials.diffuser_design import DiffuserPolarResponse
from ..materials.dynamic_stiffness import DynamicStiffnessResult
from ..materials.impedance_tube import ImpedanceTubeResult, TransferMatrix
+ from ..materials.metadiffuser import MetadiffuserResult
from ..materials.porous_absorber import (
DiffuseFieldAbsorptionResult,
LayeredAbsorberResult,
@@ -100,6 +101,9 @@
"Normalised characteristic value": "Valor característico normalizado",
r"Absorption coefficient $\alpha$": r"Coeficiente de absorción $\alpha$",
"Reflection factor $|R|$": "Factor de reflexión $|R|$",
+ "Panel average": "Media del panel",
+ "Well {n}": "Pozo {n}",
+ "Metadiffuser per-well absorption": "Absorción por pozo del metadifusor",
r"Absorption $\alpha(\theta)$": r"Absorción $\alpha(\theta)$",
r"Absorption $\alpha_{dif}$": r"Absorción $\alpha_{dif}$",
"Transmission loss $TL_n$": "Pérdida de transmisión $TL_n$",
@@ -903,3 +907,41 @@ def plot_transfer_matrix(
localize_axes(ax, language)
localize_axes(twin, language)
return ax
+
+
+def plot_metadiffuser_absorption(
+ result: MetadiffuserResult, ax: Axes | None = None,
+ language: str = "en", **kwargs: Any
+) -> Axes:
+ """Per-well and face-averaged absorption spectra of a metadiffuser.
+
+ Draws the face-averaged ``alpha(f)`` as the primary curve and each
+ well's ``alpha_n(f)`` as a muted companion, labelling only the first
+ well to keep the legend compact.
+
+ :param result: A
+ :class:`~phonometry.materials.metadiffuser.MetadiffuserResult`.
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param kwargs: Forwarded to the face-average ``plot`` call.
+ :return: The axes.
+ """
+ freqs = np.asarray(result.frequency, dtype=np.float64)
+ ax = _absorption_spectrum_axes(
+ ax,
+ freqs,
+ np.asarray(result.absorption, dtype=np.float64),
+ title=_t("Metadiffuser per-well absorption", language),
+ label=_t("Panel average", language),
+ language=language,
+ **kwargs,
+ )
+ wells = np.asarray(result.well_absorption, dtype=np.float64)
+ for n, alpha_n in enumerate(wells, start=1):
+ label = (
+ _t("Well {n}", language).format(n=f"1-{wells.shape[0]}")
+ if n == 1 else None
+ )
+ ax.semilogx(freqs, alpha_n, lw=0.9, alpha=0.6, color=_C_MUTED,
+ label=label)
+ ax.legend(loc="best", fontsize="small")
+ return ax
diff --git a/src/phonometry/materials/__init__.py b/src/phonometry/materials/__init__.py
index 442fbbbb5..65f931621 100644
--- a/src/phonometry/materials/__init__.py
+++ b/src/phonometry/materials/__init__.py
@@ -10,6 +10,7 @@
plot_helmholtz_resonator_geometry,
plot_impedance_tube_geometry,
plot_insitu_geometry,
+ plot_metadiffuser_panel_geometry,
plot_qrd_geometry,
plot_slit_absorber_geometry,
plot_transmission_tube_geometry,
@@ -96,6 +97,13 @@
two_microphone_impedance,
wave_decomposition,
)
+from .metadiffuser import (
+ MetadiffuserResult,
+ MetadiffuserWell,
+ metadiffuser_diffusion_spectrum,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
from .porous_absorber import (
DELANY_BAZLEY_COEFFICIENTS,
DELANY_BAZLEY_VALIDITY,
@@ -231,6 +239,8 @@
"InsituAbsorptionResult",
"LayeredAbsorberResult",
"MembraneLayer",
+ "MetadiffuserResult",
+ "MetadiffuserWell",
"MicroperforatedPlateLayer",
"PerforatedPlateLayer",
"PorousAbsorberWarning",
@@ -296,6 +306,9 @@
"measure_sound_absorption",
"membrane_impedance",
"membrane_resonance_frequency",
+ "metadiffuser_diffusion_spectrum",
+ "metadiffuser_polar_response",
+ "metadiffuser_reflection",
"mic_calibration_factor",
"microperforated_plate_impedance",
"miki",
@@ -316,6 +329,7 @@
"plot_helmholtz_resonator_geometry",
"plot_impedance_tube_geometry",
"plot_insitu_geometry",
+ "plot_metadiffuser_panel_geometry",
"plot_qrd_geometry",
"plot_slit_absorber_geometry",
"plot_transmission_tube_geometry",
diff --git a/src/phonometry/materials/metadiffuser.py b/src/phonometry/materials/metadiffuser.py
new file mode 100644
index 000000000..51dc238fe
--- /dev/null
+++ b/src/phonometry/materials/metadiffuser.py
@@ -0,0 +1,393 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Metadiffusers: deep-subwavelength Schroeder-like sound diffusers.
+
+A metadiffuser is a rigidly backed slotted panel whose slits are each loaded
+by an array of Helmholtz resonators (Jimenez, Cox, Romero-Garcia and Groby,
+*Metadiffusers: Deep-subwavelength sound diffusers*, Sci. Rep. 7, 5389,
+2017). The resonators slow the sound inside each slit, so the slit reaches
+its quarter-wavelength condition at a fraction of the depth a plain well
+would need; by giving every slit a different geometry the panel presents a
+spatially dependent complex reflection coefficient ``R_n(f)`` along its
+face. Tuning that profile to a Schroeder phase grating reproduces the
+scattering of a quadratic-residue or primitive-root diffuser from a panel
+1/46 to 1/20 of the design wavelength thick, and driving single slits to
+critical coupling adds the perfectly absorbing ``0`` state that ternary
+sequences require.
+
+Each slit is modelled with the transfer-matrix chain of
+:func:`~phonometry.materials.slow_sound_absorber.slit_helmholtz_absorber`
+(visco-thermal effective parameters, resonator end corrections and slit
+radiation correction); the panel is locally reacting, so the wells do not
+couple internally and the far field follows from the Fraunhofer integral of
+the per-well reflection sequence
+(:func:`~phonometry.materials.diffuser_design.predict_diffuser_polar_response`)
+reduced to the ISO 17497-2 directional diffusion coefficient.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from numpy.typing import ArrayLike
+
+from .._internal.types import Real
+from .._internal.validation import require_positive, require_positive_array
+from .diffuser_design import (
+ DEFAULT_POLAR_ANGLES,
+ DiffuserPolarResponse,
+ predict_diffuser_polar_response,
+)
+from .porous_absorber import (
+ _AIR_DENSITY,
+ _AIR_VISCOSITY,
+ _ATMOSPHERIC_PRESSURE,
+ _HEAT_CAPACITY_RATIO,
+ _PRANDTL_NUMBER,
+ _SPEED_OF_SOUND,
+ Complex,
+)
+from .scattering_diffusion import (
+ DiffusionSpectrum,
+ diffusion_spectrum,
+ normalized_diffusion_coefficient,
+)
+from .slow_sound_absorber import HelmholtzResonator, slit_helmholtz_absorber
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+__all__ = [
+ "MetadiffuserResult",
+ "MetadiffuserWell",
+ "metadiffuser_diffusion_spectrum",
+ "metadiffuser_polar_response",
+ "metadiffuser_reflection",
+]
+
+
+@dataclass(frozen=True)
+class MetadiffuserWell:
+ """One slit of a metadiffuser panel.
+
+ ``slit_height`` is the slit opening ``h_n`` along the panel face and
+ ``resonators`` the Helmholtz resonators loading the slit, ordered from
+ the panel face towards the rigid backing; the resonator lattice step is
+ ``a = L / M`` for a panel of depth ``L`` and ``M`` resonators. All
+ lengths are in metres. A ``None`` entry in a well sequence stands for a
+ flat rigid strip of the panel face (``R = 1``), the ``+1`` state of
+ ternary-sequence designs.
+ """
+
+ slit_height: float
+ resonators: tuple[HelmholtzResonator, ...]
+
+ def __post_init__(self) -> None:
+ require_positive(self.slit_height, "slit_height")
+ if not self.resonators:
+ raise ValueError("'resonators' must contain at least one resonator.")
+
+
+@dataclass(frozen=True)
+class MetadiffuserResult:
+ """Spectra of a metadiffuser panel, one reflection row per well.
+
+ ``reflection`` has shape ``(N, len(frequency))`` with the complex
+ pressure reflection factor of each well (flat strips are exactly ``1``),
+ ``absorption`` is the face-averaged energy absorption
+ ``alpha(f) = 1 - mean_n |R_n|^2`` and ``well_absorption`` the per-well
+ ``alpha_n = 1 - |R_n|^2``. The trailing fields retain the geometry the
+ prediction was run with (``wells``, ``depth``, ``period``) so
+ :meth:`plot_geometry` can draw the panel section; they default to
+ ``None`` for hand-built results.
+ """
+
+ frequency: Real
+ reflection: Complex
+ absorption: Real
+ well_absorption: Real
+ wells: tuple[MetadiffuserWell | None, ...] | None = None
+ depth: float | None = None
+ period: float | None = None
+
+ def plot(
+ self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any
+ ) -> Axes:
+ """Plot the per-well and face-averaged absorption spectra.
+
+ Requires matplotlib (``pip install phonometry[plot]``); returns the
+ :class:`~matplotlib.axes.Axes`.
+ """
+ from .._i18n import check_language
+ from .._plot.materials import plot_metadiffuser_absorption
+
+ check_language(language)
+ return plot_metadiffuser_absorption(self, ax=ax, language=language, **kwargs)
+
+ def plot_geometry(
+ self, ax: Axes | None = None, *, language: str = "en", **kwargs: Any
+ ) -> Axes:
+ """Draw the panel cross-section to scale (slits and resonators).
+
+ 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_metadiffuser_geometry
+
+ check_language(language)
+ return plot_metadiffuser_geometry(self, ax=ax, language=language, **kwargs)
+
+
+def _check_panel(
+ wells: Any, depth: float, period: float
+) -> tuple[MetadiffuserWell | None, ...]:
+ """Validate the well sequence against the panel depth and period."""
+ require_positive(depth, "depth")
+ require_positive(period, "period")
+ cells = tuple(wells)
+ if len(cells) < 2:
+ raise ValueError("'wells' must contain at least two wells.")
+ for i, well in enumerate(cells):
+ if well is None:
+ continue
+ if not isinstance(well, MetadiffuserWell):
+ raise TypeError(
+ f"wells[{i}] must be a MetadiffuserWell or None, "
+ f"got {type(well).__name__}."
+ )
+ if well.slit_height >= period:
+ raise ValueError(
+ f"wells[{i}].slit_height must be smaller than the period."
+ )
+ return cells
+
+
+def metadiffuser_reflection(
+ frequency: ArrayLike,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angle: float = 0.0,
+ resonator_geometry: str = "slit",
+ speed_of_sound: float = _SPEED_OF_SOUND,
+ air_density: float = _AIR_DENSITY,
+ viscosity: float = _AIR_VISCOSITY,
+ prandtl_number: float = _PRANDTL_NUMBER,
+ heat_capacity_ratio: float = _HEAT_CAPACITY_RATIO,
+ atmospheric_pressure: float = _ATMOSPHERIC_PRESSURE,
+) -> MetadiffuserResult:
+ """Per-well reflection spectra of a metadiffuser panel (Sci. Rep. Eq. (6)).
+
+ Runs the rigidly backed slit transfer-matrix chain of
+ :func:`~phonometry.materials.slow_sound_absorber.slit_helmholtz_absorber`
+ once per well: each :class:`MetadiffuserWell` becomes a slit of height
+ ``h_n`` and depth ``L`` loaded by its ``M`` resonators on the lattice
+ ``a = L / M``, and ``None`` wells are flat rigid strips with ``R = 1``.
+ The panel is locally reacting, so a well's reflection does not depend on
+ its neighbours and the incidence ``angle`` enters only through the front
+ air impedance.
+
+ :param frequency: Frequency vector ``f``, in hertz.
+ :param wells: Sequence of :class:`MetadiffuserWell` (or ``None`` for a
+ flat rigid strip) describing one period of the panel face.
+ :param depth: Panel depth ``L`` common to all slits, in metres.
+ :param period: Well pitch ``d`` along the panel face, in metres.
+ :param angle: Polar angle of incidence ``theta``, in radians.
+ :param resonator_geometry: ``"slit"`` (default) for the paper's
+ two-dimensional resonators, ``"square"`` for square-duct necks
+ and cavities.
+ :param speed_of_sound: Speed of sound ``c0`` in air, in m/s.
+ :param air_density: Air density ``rho0``, in kg/m3.
+ :param viscosity: Dynamic viscosity ``eta`` of air, in Pa s.
+ :param prandtl_number: Prandtl number ``Pr`` of air.
+ :param heat_capacity_ratio: Ratio of specific heats ``gamma``.
+ :param atmospheric_pressure: Static pressure ``P0``, in Pa.
+ :return: A :class:`MetadiffuserResult` with one reflection row per well.
+ """
+ f = require_positive_array(frequency, "frequency")
+ cells = _check_panel(wells, depth, period)
+ rows = np.empty((len(cells), f.size), dtype=np.complex128)
+ for i, well in enumerate(cells):
+ if well is None:
+ rows[i] = 1.0
+ continue
+ prediction = slit_helmholtz_absorber(
+ f, well.resonators,
+ slit_height=well.slit_height,
+ lattice_step=depth / len(well.resonators),
+ period=period, angle=angle,
+ resonator_geometry=resonator_geometry,
+ speed_of_sound=speed_of_sound, air_density=air_density,
+ viscosity=viscosity, prandtl_number=prandtl_number,
+ heat_capacity_ratio=heat_capacity_ratio,
+ atmospheric_pressure=atmospheric_pressure,
+ )
+ rows[i] = prediction.reflection
+ well_alpha = 1.0 - np.abs(rows) ** 2
+ return MetadiffuserResult(
+ frequency=f,
+ reflection=rows,
+ absorption=np.asarray(well_alpha.mean(axis=0), dtype=np.float64),
+ well_absorption=np.asarray(well_alpha, dtype=np.float64),
+ wells=cells,
+ depth=float(depth),
+ period=float(period),
+ )
+
+
+def metadiffuser_polar_response(
+ frequency: float,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angles: ArrayLike = DEFAULT_POLAR_ANGLES,
+ source_angle: float = 0.0,
+ periods: int = 1,
+ resonator_geometry: str = "slit",
+ speed_of_sound: float = _SPEED_OF_SOUND,
+ air_density: float = _AIR_DENSITY,
+ viscosity: float = _AIR_VISCOSITY,
+ prandtl_number: float = _PRANDTL_NUMBER,
+ heat_capacity_ratio: float = _HEAT_CAPACITY_RATIO,
+ atmospheric_pressure: float = _ATMOSPHERIC_PRESSURE,
+) -> DiffuserPolarResponse:
+ """Far-field polar response of a metadiffuser at one frequency.
+
+ Computes the per-well complex reflection sequence at ``frequency`` with
+ :func:`metadiffuser_reflection` (the panel is locally reacting, so the
+ slit chains see the incidence angle ``source_angle``) and evaluates the
+ Fraunhofer far field and ISO 17497-2 directional diffusion coefficient
+ with
+ :func:`~phonometry.materials.diffuser_design.predict_diffuser_polar_response`.
+
+ :param frequency: Frequency of the prediction ``f``, in hertz.
+ :param wells: Sequence of :class:`MetadiffuserWell` (or ``None`` for a
+ flat rigid strip) describing one period of the panel face.
+ :param depth: Panel depth ``L`` common to all slits, in metres.
+ :param period: Well pitch ``d`` along the panel face, in metres; it is
+ the ``well_width`` of the far-field model.
+ :param angles: Receiver reflection angles ``theta``, in degrees.
+ :param source_angle: Angle of incidence ``psi`` of the source, in
+ degrees; also applied to the local slit reflection.
+ :param periods: Number of repetitions ``N_p`` of the single period; the
+ grating lobes of a Schroeder-like design require ``periods >= 2``.
+ :param resonator_geometry: ``"slit"`` (default) for the paper's
+ two-dimensional resonators, ``"square"`` for square-duct necks
+ and cavities.
+ :param speed_of_sound: Speed of sound ``c0`` in air, in m/s.
+ :param air_density: Air density ``rho0``, in kg/m3.
+ :param viscosity: Dynamic viscosity ``eta`` of air, in Pa s.
+ :param prandtl_number: Prandtl number ``Pr`` of air.
+ :param heat_capacity_ratio: Ratio of specific heats ``gamma``.
+ :param atmospheric_pressure: Static pressure ``P0``, in Pa.
+ :return: A
+ :class:`~phonometry.materials.diffuser_design.DiffuserPolarResponse`.
+ """
+ f = require_positive(frequency, "frequency")
+ result = metadiffuser_reflection(
+ np.asarray([f]), wells, depth=depth, period=period,
+ angle=float(np.radians(source_angle)),
+ resonator_geometry=resonator_geometry,
+ speed_of_sound=speed_of_sound, air_density=air_density,
+ viscosity=viscosity, prandtl_number=prandtl_number,
+ heat_capacity_ratio=heat_capacity_ratio,
+ atmospheric_pressure=atmospheric_pressure,
+ )
+ # Sci. Rep. Eq. (1) is the bare Fraunhofer integral of R(x): the
+ # piecewise-constant wells carry the aperture factor, but there is no
+ # Kirchhoff obliquity term, so it is disabled here for fidelity.
+ return predict_diffuser_polar_response(
+ period, f, reflection=result.reflection[:, 0],
+ angles=angles, source_angle=source_angle, periods=periods,
+ speed_of_sound=speed_of_sound, include_obliquity=False,
+ )
+
+
+def metadiffuser_diffusion_spectrum(
+ frequencies: ArrayLike,
+ wells: Sequence[MetadiffuserWell | None],
+ *,
+ depth: float,
+ period: float,
+ angles: ArrayLike = DEFAULT_POLAR_ANGLES,
+ source_angle: float = 0.0,
+ periods: int = 1,
+ resonator_geometry: str = "slit",
+ speed_of_sound: float = _SPEED_OF_SOUND,
+ air_density: float = _AIR_DENSITY,
+ viscosity: float = _AIR_VISCOSITY,
+ prandtl_number: float = _PRANDTL_NUMBER,
+ heat_capacity_ratio: float = _HEAT_CAPACITY_RATIO,
+ atmospheric_pressure: float = _ATMOSPHERIC_PRESSURE,
+) -> DiffusionSpectrum:
+ """Normalized diffusion-coefficient spectrum ``d_n(f)`` of a metadiffuser.
+
+ Evaluates the far-field polar response at each frequency with
+ :func:`metadiffuser_polar_response`, forms the ISO 17497-2 directional
+ diffusion coefficient band by band and normalises it against a flat
+ rigid reference of the same footprint (all wells ``R = 1``) with
+ :func:`~phonometry.materials.scattering_diffusion.normalized_diffusion_coefficient`,
+ exactly as the paper reports ``delta_n``.
+
+ :param frequencies: Frequencies of the spectrum, in hertz (1-D).
+ :param wells: Sequence of :class:`MetadiffuserWell` (or ``None`` for a
+ flat rigid strip) describing one period of the panel face.
+ :param depth: Panel depth ``L`` common to all slits, in metres.
+ :param period: Well pitch ``d`` along the panel face, in metres.
+ :param angles: Receiver reflection angles ``theta``, in degrees.
+ :param source_angle: Angle of incidence ``psi``, in degrees.
+ :param periods: Number of repetitions ``N_p`` of the single period.
+ :param resonator_geometry: ``"slit"`` (default) for the paper's
+ two-dimensional resonators, ``"square"`` for square-duct necks
+ and cavities.
+ :param speed_of_sound: Speed of sound ``c0`` in air, in m/s.
+ :param air_density: Air density ``rho0``, in kg/m3.
+ :param viscosity: Dynamic viscosity ``eta`` of air, in Pa s.
+ :param prandtl_number: Prandtl number ``Pr`` of air.
+ :param heat_capacity_ratio: Ratio of specific heats ``gamma``.
+ :param atmospheric_pressure: Static pressure ``P0``, in Pa.
+ :return: A
+ :class:`~phonometry.materials.scattering_diffusion.DiffusionSpectrum`
+ carrying the raw ``d(f)`` and the normalised ``d_n(f)``.
+ """
+ freqs = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
+ if freqs.ndim != 1 or freqs.size == 0:
+ raise ValueError("'frequencies' must be a non-empty 1-D sequence.")
+ cells = _check_panel(wells, depth, period)
+ flat = np.ones(len(cells), dtype=np.complex128)
+ result = metadiffuser_reflection(
+ freqs, cells, depth=depth, period=period,
+ angle=float(np.radians(source_angle)),
+ resonator_geometry=resonator_geometry,
+ speed_of_sound=speed_of_sound, air_density=air_density,
+ viscosity=viscosity, prandtl_number=prandtl_number,
+ heat_capacity_ratio=heat_capacity_ratio,
+ atmospheric_pressure=atmospheric_pressure,
+ )
+ raw = np.empty(freqs.size, dtype=np.float64)
+ norm = np.empty(freqs.size, dtype=np.float64)
+ for i, f in enumerate(freqs):
+ surface = predict_diffuser_polar_response(
+ period, float(f), reflection=result.reflection[:, i],
+ angles=angles, source_angle=source_angle, periods=periods,
+ speed_of_sound=speed_of_sound, include_obliquity=False,
+ )
+ reference = predict_diffuser_polar_response(
+ period, float(f), reflection=flat,
+ angles=angles, source_angle=source_angle, periods=periods,
+ speed_of_sound=speed_of_sound, include_obliquity=False,
+ )
+ raw[i] = surface.coefficient
+ norm[i] = float(
+ normalized_diffusion_coefficient(
+ surface.coefficient, reference.coefficient
+ )
+ )
+ return diffusion_spectrum(freqs, raw, normalized=norm)
diff --git a/src/phonometry/materials/slow_sound_absorber.py b/src/phonometry/materials/slow_sound_absorber.py
index 269057abf..42695e7e9 100644
--- a/src/phonometry/materials/slow_sound_absorber.py
+++ b/src/phonometry/materials/slow_sound_absorber.py
@@ -275,6 +275,86 @@ def _neck_cavity_correction(neck_side: float, cavity_side: float) -> float:
return float(0.82 * (1.0 - 1.35 * x + 0.31 * x**3) * rn)
+def _neck_slit_correction_2d(neck_width: float, slit_height: float) -> float:
+ """2-D neck-to-slit end correction ``Delta l_2`` (Sci. Rep. Eq. (12)).
+
+ The 2-D ducts use their half-width as the radiation radius, so the
+ Dubos fit is evaluated with the width ratio directly and
+ ``0.82 (w_n / 2) = 0.41 w_n`` as printed in the source, exactly as
+ the paper applies it. The fit was derived for a neck narrower than
+ the main waveguide; optimised metadiffuser geometries can present
+ ``w_n > h``, where the polynomial turns negative and the resonator
+ effectively decouples from the slit (the caller warns about it).
+ """
+ x = neck_width / slit_height
+ return float(
+ 0.41
+ * (1.0 - 0.235 * x - 1.32 * x**2 + 1.54 * x**3 - 0.86 * x**4)
+ * neck_width
+ )
+
+
+def _neck_cavity_correction_2d(neck_width: float, cavity_width: float) -> float:
+ """2-D neck-to-cavity end correction ``Delta l_1`` (Sci. Rep. Eq. (11))."""
+ x = neck_width / cavity_width
+ return float(0.41 * (1.0 - 1.35 * x + 0.31 * x**3) * neck_width)
+
+
+def _slit_resonator_ducts(
+ f: Real, wn: float, wc: float, slit_height: float | None,
+ lattice_step: float | None, end_correction: bool, air: dict[str, Any],
+) -> tuple[Complex, Complex, Complex, Complex, Complex, Complex, float]:
+ """Duct parameters of the 2-D resonator (Sci. Rep. Eqs. (8)-(12))."""
+ if slit_height is None or lattice_step is None:
+ raise ValueError(
+ "The 'slit' resonator geometry requires 'slit_height' and "
+ "'lattice_step'."
+ )
+ h = require_positive(slit_height, "slit_height")
+ a = require_positive(lattice_step, "lattice_step")
+ rho_n, kap_n = slit_effective_properties(f, slit_height=wn, **air)
+ rho_c, kap_c = slit_effective_properties(f, slit_height=wc, **air)
+ z_n = np.asarray(np.sqrt(kap_n * rho_n) / (wn * a),
+ dtype=np.complex128)
+ z_c = np.asarray(np.sqrt(kap_c * rho_c) / (wc * a),
+ dtype=np.complex128)
+ dl = 0.0
+ if end_correction:
+ if wn > h:
+ warnings.warn(
+ "The resonator neck is wider than the slit "
+ f"({wn:g} m > {h:g} m); the Dubos neck-to-slit end "
+ "correction is outside its fitted domain and turns "
+ "negative, which effectively decouples the resonator.",
+ SlowSoundAbsorberWarning,
+ stacklevel=3,
+ )
+ dl = _neck_cavity_correction_2d(wn, wc)
+ dl += _neck_slit_correction_2d(wn, h)
+ return rho_n, kap_n, rho_c, kap_c, z_n, z_c, dl
+
+
+def _square_resonator_ducts(
+ f: Real, wn: float, wc: float, slit_height: float | None,
+ lattice_step: float | None, end_correction: bool,
+ props: dict[str, Any],
+) -> tuple[Complex, Complex, Complex, Complex, Complex, Complex, float]:
+ """Duct parameters of the square resonator (APL Eqs. (A23)-(A26))."""
+ rho_n, kap_n = rectangular_duct_properties(f, side=wn, **props)
+ rho_c, kap_c = rectangular_duct_properties(f, side=wc, **props)
+ z_n = np.asarray(np.sqrt(kap_n * rho_n) / wn**2, dtype=np.complex128)
+ z_c = np.asarray(np.sqrt(kap_c * rho_c) / wc**2, dtype=np.complex128)
+ dl = 0.0
+ if end_correction:
+ dl = _neck_cavity_correction(wn, wc)
+ if slit_height is not None and lattice_step is not None:
+ dl += _neck_slit_correction(
+ wn, require_positive(slit_height, "slit_height"),
+ require_positive(lattice_step, "lattice_step"),
+ )
+ return rho_n, kap_n, rho_c, kap_c, z_n, z_c, dl
+
+
def helmholtz_resonator_impedance(
frequency: ArrayLike,
resonator: HelmholtzResonator,
@@ -282,6 +362,7 @@ def helmholtz_resonator_impedance(
slit_height: float | None = None,
lattice_step: float | None = None,
end_correction: bool = True,
+ geometry: str = "square",
air_density: float = _AIR_DENSITY,
viscosity: float = _AIR_VISCOSITY,
prandtl_number: float = _PRANDTL_NUMBER,
@@ -291,7 +372,8 @@ def helmholtz_resonator_impedance(
) -> Complex:
"""Acoustic impedance of a Helmholtz resonator with visco-thermal losses.
- The neck and cavity use the square-duct effective parameters of
+ With the default ``geometry="square"`` the neck and cavity are square
+ ducts using the effective parameters of
:func:`rectangular_duct_properties`; the impedance is Appl. Phys. Lett. 2016
Eq. (A23) with the neck-to-cavity radiation correction of Eq. (A24) and,
when ``slit_height`` and ``lattice_step`` are supplied, the neck-to-slit
@@ -307,12 +389,21 @@ def helmholtz_resonator_impedance(
with ``Z_n = sqrt(kappa_n rho_n) / w_n^2``, ``k_n = w sqrt(rho_n / kappa_n)``
(and likewise for the cavity), reducing to Eq. (A22) when ``dl = 0``.
+ With ``geometry="slit"`` the resonator is two-dimensional (the neck and
+ cavity are slit-like ducts spanning the lattice step): the effective
+ parameters come from :func:`slit_effective_properties` with the neck and
+ cavity widths, the duct sections are ``w_n a`` and ``w_c a``, and the end
+ corrections are the 2-D fits of Sci. Rep. 7:5389 Eqs. (11)-(12); both
+ ``slit_height`` and ``lattice_step`` are then required.
+
:param frequency: Frequency vector ``f``, in hertz.
:param resonator: The :class:`HelmholtzResonator` geometry.
:param slit_height: Slit height ``h`` for the neck-to-slit correction; if
- ``None`` that correction is omitted.
+ ``None`` that correction is omitted (``"square"`` only).
:param lattice_step: Lattice step ``a`` for the neck-to-slit correction.
:param end_correction: Include the radiation end corrections (default True).
+ :param geometry: ``"square"`` (default) for square-duct necks and
+ cavities, ``"slit"`` for the two-dimensional resonator model.
:param air_density: Air density ``rho0``, in kg/m3.
:param viscosity: Dynamic viscosity ``eta`` of air, in Pa s.
:param prandtl_number: Prandtl number ``Pr`` of air.
@@ -327,29 +418,27 @@ def helmholtz_resonator_impedance(
wn = require_positive(resonator.neck_side, "neck_side")
lc = require_positive(resonator.cavity_length, "cavity_length")
wc = require_positive(resonator.cavity_side, "cavity_side")
- props: dict[str, Any] = {
+ if geometry not in ("square", "slit"):
+ raise ValueError("'geometry' must be 'square' or 'slit'.")
+ air: dict[str, Any] = {
"air_density": air_density,
"viscosity": viscosity,
"prandtl_number": prandtl_number,
"heat_capacity_ratio": heat_capacity_ratio,
"atmospheric_pressure": atmospheric_pressure,
- "sum_terms": sum_terms,
}
omega = 2.0 * np.pi * f
- rho_n, kap_n = rectangular_duct_properties(f, side=wn, **props)
- rho_c, kap_c = rectangular_duct_properties(f, side=wc, **props)
- z_n = np.sqrt(kap_n * rho_n) / wn**2
- z_c = np.sqrt(kap_c * rho_c) / wc**2
+ if geometry == "slit":
+ rho_n, kap_n, rho_c, kap_c, z_n, z_c, dl = _slit_resonator_ducts(
+ f, wn, wc, slit_height, lattice_step, end_correction, air,
+ )
+ else:
+ rho_n, kap_n, rho_c, kap_c, z_n, z_c, dl = _square_resonator_ducts(
+ f, wn, wc, slit_height, lattice_step, end_correction,
+ dict(air, sum_terms=sum_terms),
+ )
k_n = omega * np.sqrt(rho_n / kap_n)
k_c = omega * np.sqrt(rho_c / kap_c)
- dl = 0.0
- if end_correction:
- dl = _neck_cavity_correction(wn, wc)
- if slit_height is not None and lattice_step is not None:
- dl += _neck_slit_correction(
- wn, require_positive(slit_height, "slit_height"),
- require_positive(lattice_step, "lattice_step"),
- )
cos_n, sin_n = np.cos(k_n * ln), np.sin(k_n * ln)
cos_c, sin_c = np.cos(k_c * lc), np.sin(k_c * lc)
num = (
@@ -448,6 +537,7 @@ def _panel_transfer_matrix(
period: float,
slit_radiation: bool,
end_correction: bool,
+ resonator_geometry: str,
rho0: float,
props: dict[str, Any],
) -> Complex:
@@ -482,7 +572,8 @@ def _panel_transfer_matrix(
for res in resonators:
z_hr = helmholtz_resonator_impedance(
f, res, slit_height=slit_height, lattice_step=lattice_step,
- end_correction=end_correction, **props,
+ end_correction=end_correction, geometry=resonator_geometry,
+ **props,
)
m_hr = np.array([[ones, zeros], [ones / z_hr, ones]])
cell = _matmul(_matmul(ms, m_hr), ms)
@@ -511,6 +602,7 @@ def slit_helmholtz_absorber(
angle: float = 0.0,
end_correction: bool = True,
slit_radiation: bool = True,
+ resonator_geometry: str = "square",
speed_of_sound: float = _SPEED_OF_SOUND,
air_density: float = _AIR_DENSITY,
viscosity: float = _AIR_VISCOSITY,
@@ -580,7 +672,7 @@ def slit_helmholtz_absorber(
tm = _panel_transfer_matrix(
omega, res, slit_height=h, lattice_step=a, period=d,
slit_radiation=slit_radiation, end_correction=end_correction,
- rho0=rho0, props=props,
+ resonator_geometry=resonator_geometry, rho0=rho0, props=props,
)
t11, t12, t21, t22 = tm[0, 0], tm[0, 1], tm[1, 0], tm[1, 1]
area_cell = d * a
@@ -649,7 +741,7 @@ def _acoustic_surface_impedance(
tm = _panel_transfer_matrix(
omega, (resonator,), slit_height=slit_height, lattice_step=lattice_step,
period=period, slit_radiation=slit_radiation, end_correction=end_correction,
- rho0=rho0, props=props,
+ resonator_geometry="square", rho0=rho0, props=props,
)
return complex(tm[0, 0][0] / tm[1, 0][0])
diff --git a/tests/materials/test_metadiffuser.py b/tests/materials/test_metadiffuser.py
new file mode 100644
index 000000000..0f95007d4
--- /dev/null
+++ b/tests/materials/test_metadiffuser.py
@@ -0,0 +1,275 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Metadiffuser panels against the published designs (Sci. Rep. 7:5389, 2017).
+
+The oracles are the printed numbers of the paper and its supplementary
+material, computed here through an independent implementation of the same
+transfer-matrix model: the quadratic-residue metadiffuser of Table 1
+(critical coupling of its first slit, and the headline claim that its
+spatially dependent reflection matches the target QRD at the evaluation
+frequency), the primitive-root metadiffuser of Table 2 (the sharp
+single-slit absorption peak and the specular notch), the ternary-sequence
+states of Table 3 (perfect absorber and phase inverter; the well pitch is
+read from Fig. 6, eight wells over 80 cm) and the broadband panel of
+Table 4 (soft bounds only: several of its optimised necks are wider than
+their slits, outside the fitted domain of the Dubos end correction, so the
+low-frequency features of the paper are not recoverable from the text
+alone).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from phonometry.materials.metadiffuser import (
+ MetadiffuserWell,
+ metadiffuser_diffusion_spectrum,
+ metadiffuser_polar_response,
+ metadiffuser_reflection,
+)
+from phonometry.materials.slow_sound_absorber import (
+ HelmholtzResonator,
+ SlowSoundAbsorberWarning,
+)
+
+MM = 1e-3
+
+
+def _well(
+ h: float, ln: float, lc: float, wn: float, wc: float, m: int = 1
+) -> MetadiffuserWell:
+ resonator = HelmholtzResonator(
+ neck_length=max(ln, 0.05) * MM, neck_side=wn * MM,
+ cavity_length=lc * MM, cavity_side=wc * MM,
+ )
+ return MetadiffuserWell(h * MM, (resonator,) * m)
+
+
+# Table 1: QR-metadiffuser, N = 5 slits, M = 2, L = 2 cm, d = 7 cm.
+QR_WELLS = [
+ _well(14.7, 13.0, 16.4, 6.2, 9.0, m=2),
+ _well(30.9, 9.1, 4.3, 3.5, 9.0, m=2),
+ _well(30.9, 9.1, 4.3, 3.5, 9.0, m=2),
+ _well(15.7, 13.3, 17.0, 6.3, 9.0, m=2),
+ _well(20.3, 18.0, 20.7, 3.2, 9.0, m=2),
+]
+QR_SEQUENCE = (1.0, 4.0, 4.0, 1.0, 0.0)
+
+# Table 2: PR-metadiffuser, N = 6 slits, M = 1, L = 3.5 cm, d = 7 cm.
+PR_WELLS = [
+ _well(0.5, 26.1, 23.4, 19.4, 34.0),
+ _well(14.4, 16.7, 26.0, 14.7, 34.0),
+ _well(1.1, 5.7, 26.2, 7.3, 34.0),
+ _well(22.0, 14.3, 19.1, 13.9, 34.0),
+ _well(14.6, 18.6, 24.7, 14.7, 34.0),
+ _well(22.4, 14.9, 19.9, 18.3, 34.0),
+]
+
+# Table 3 states (ternary sequence, L = 3 cm, d = 10 cm from Fig. 6).
+INVERTER = _well(8.5, 1.8, 88.7, 8.4, 29.0)
+ABSORBER = _well(10.0, 69.4, 10.2, 2.4, 29.0)
+
+# Table 4: broadband panel, N = 11 slits, M = 1, L = 3 cm, d = 12 cm.
+BROADBAND_ROWS = [
+ (5.7, 16.3, 97.1, 6.7, 29.0), (4.9, 7.3, 106.8, 6.5, 29.0),
+ (7.7, 37.1, 74.2, 10.0, 29.0), (82.9, 0.0, 36.0, 29.0, 29.0),
+ (48.4, 35.3, 35.3, 29.0, 29.0), (74.9, 22.1, 22.1, 29.0, 29.0),
+ (20.0, 14.7, 84.3, 14.0, 29.0), (6.6, 0.1, 112.2, 9.5, 29.0),
+ (76.2, 0.0, 42.7, 29.0, 29.0), (29.5, 0.1, 89.4, 27.6, 29.0),
+ (7.6, 4.8, 106.5, 6.2, 29.0),
+]
+
+
+def test_qr_first_slit_reaches_critical_coupling() -> None:
+ # Paper: "at f = 2270 Hz the reflection coefficient vanishes at the
+ # n = 1 slit" of the QR-metadiffuser.
+ f = np.arange(2000.0, 2601.0, 5.0)
+ result = metadiffuser_reflection(f, QR_WELLS, depth=0.02, period=0.07)
+ alpha = result.well_absorption[0]
+ peak = int(np.argmax(alpha))
+ assert alpha[peak] > 0.95
+ assert f[peak] == pytest.approx(2270.0, rel=0.035)
+
+
+def test_qr_reflection_matches_target_qrd_at_evaluation_frequency() -> None:
+ # The headline claim: the metadiffuser's spatially dependent reflection
+ # reproduces the QRD designed for 500 Hz when evaluated at 2000 Hz
+ # ("perfect agreement", Fig. 3(a)). The QRD wells are s_n lambda0 / 2N.
+ c0 = 343.0
+ lam0 = c0 / 500.0
+ depths = np.array([s * lam0 / (2 * 5) for s in QR_SEQUENCE])
+ k = 2.0 * np.pi * 2000.0 / c0
+ target = np.exp(-2j * k * depths)
+ result = metadiffuser_reflection(
+ np.array([2000.0]), QR_WELLS, depth=0.02, period=0.07
+ )
+ mismatch = np.degrees(
+ np.abs(np.angle(result.reflection[:, 0] * np.conj(target)))
+ )
+ assert float(mismatch.max()) < 10.0
+
+
+def test_qr_panel_diffuses_like_the_supplementary_says() -> None:
+ # Supplementary Table 1: nominal normalized diffusion ~0.54 at the
+ # 2 kHz evaluation. The polar reduction differs in discretisation from
+ # the paper's, so the bound is soft.
+ spectrum = metadiffuser_diffusion_spectrum(
+ np.array([2000.0]), QR_WELLS, depth=0.02, period=0.07
+ )
+ assert 0.4 < float(spectrum.normalized[0]) < 0.8
+
+
+def test_pa_state_is_a_perfect_absorber_at_500_hz() -> None:
+ # Table 3 zero state: critical coupling at the 500 Hz design point.
+ f = np.arange(420.0, 601.0, 2.0)
+ result = metadiffuser_reflection(
+ f, [ABSORBER, ABSORBER], depth=0.03, period=0.10
+ )
+ alpha = result.well_absorption[0]
+ peak = int(np.argmax(alpha))
+ assert alpha[peak] > 0.99
+ assert f[peak] == pytest.approx(500.0, rel=0.02)
+
+
+def test_inverter_state_reflects_out_of_phase() -> None:
+ # Table 3 [-1] state: nearly full-magnitude reflection well beyond
+ # quadrature at the design frequency (the paper itself reports the
+ # inverting slits as imperfect due to the thermo-viscous losses).
+ result = metadiffuser_reflection(
+ np.array([500.0]), [INVERTER, INVERTER], depth=0.03, period=0.10
+ )
+ r = result.reflection[0, 0]
+ assert abs(r) > 0.9
+ assert abs(np.degrees(np.angle(r))) > 110.0
+
+
+def test_pr_metadiffuser_sharp_absorption_peak() -> None:
+ # Fig. 5(d): one slit of the PR-metadiffuser shows a sharp absorption
+ # peak at 1510 Hz (quasi-perfect, not critically coupled).
+ f = np.arange(1300.0, 1701.0, 5.0)
+ result = metadiffuser_reflection(f, PR_WELLS, depth=0.035, period=0.07)
+ per_slit_peak = result.well_absorption.max(axis=1)
+ best = int(np.argmax(per_slit_peak))
+ assert per_slit_peak[best] > 0.8
+ f_best = f[int(np.argmax(result.well_absorption[best]))]
+ assert f_best == pytest.approx(1510.0, rel=0.02)
+
+
+def test_pr_metadiffuser_specular_notch() -> None:
+ # The PRD-like scattered field presents a notch at the specular
+ # direction (Fig. 4(g), evaluated with 6 repetitions at 1 kHz).
+ polar = metadiffuser_polar_response(
+ 1000.0, PR_WELLS, depth=0.035, period=0.07, periods=6
+ )
+ angles = np.asarray(polar.angles)
+ specular = float(polar.levels[np.abs(angles) < 3.0].mean())
+ assert specular < -15.0
+
+
+def test_ternary_sequence_suppresses_the_specular_beam() -> None:
+ # Fig. 6: the [1, -1, -1, 0, -1, 1, 1, 0] sequence balances in-phase
+ # and inverted wells, so the specular direction is no longer the peak.
+ wells = [None, INVERTER, INVERTER, ABSORBER,
+ INVERTER, None, None, ABSORBER]
+ polar = metadiffuser_polar_response(
+ 500.0, wells, depth=0.03, period=0.10, periods=6,
+ angles=np.arange(-90.0, 91.0, 1.0),
+ )
+ angles = np.asarray(polar.angles)
+ specular = float(polar.levels[angles == 0.0][0])
+ off_peak = float(polar.levels[np.abs(angles) > 2.0].max())
+ assert specular < off_peak - 2.0
+
+
+def test_broadband_panel_soft_bounds() -> None:
+ # Table 4 with the supplementary's nominal diffusion at 1 kHz (0.65).
+ # Several optimised necks are wider than their slits (outside the
+ # Dubos-fit domain), so only the mid-band value is pinned, softly.
+ wells = [_well(*row) for row in BROADBAND_ROWS]
+ with pytest.warns(SlowSoundAbsorberWarning):
+ spectrum = metadiffuser_diffusion_spectrum(
+ np.array([1000.0]), wells, depth=0.03, period=0.12
+ )
+ assert 0.4 < float(spectrum.normalized[0]) < 0.8
+
+
+def test_face_average_and_flat_strips() -> None:
+ # A None well is a rigid strip with R = 1 exactly, and the
+ # face-averaged absorption is the mean of the per-well coefficients.
+ f = np.array([500.0, 1000.0])
+ result = metadiffuser_reflection(
+ f, [ABSORBER, None], depth=0.03, period=0.10
+ )
+ assert np.allclose(result.reflection[1], 1.0)
+ assert np.allclose(
+ result.absorption, result.well_absorption.mean(axis=0)
+ )
+ assert result.depth == pytest.approx(0.03)
+ assert result.period == pytest.approx(0.10)
+
+
+def test_panel_validation() -> None:
+ f = np.array([500.0])
+ with pytest.raises(ValueError, match="at least two wells"):
+ metadiffuser_reflection(f, [ABSORBER], depth=0.03, period=0.10)
+ not_a_well = [ABSORBER, 0.01]
+ with pytest.raises(TypeError, match="MetadiffuserWell"):
+ metadiffuser_reflection(f, not_a_well, depth=0.03, period=0.10)
+ too_tall = [ABSORBER, _well(110.0, 5.0, 20.0, 4.0, 20.0)]
+ with pytest.raises(ValueError, match="smaller than the period"):
+ metadiffuser_reflection(f, too_tall, depth=0.03, period=0.10)
+ empty_band = np.array([])
+ with pytest.raises(ValueError, match="non-empty"):
+ metadiffuser_diffusion_spectrum(
+ empty_band, [ABSORBER, None], depth=0.03, period=0.10
+ )
+ with pytest.raises(ValueError, match="at least one resonator"):
+ MetadiffuserWell(0.01, ())
+
+
+def test_square_resonator_geometry_and_validation() -> None:
+ # The square-duct variant runs end to end, and the geometry switch
+ # validates its inputs.
+ f = np.array([500.0, 1000.0])
+ result = metadiffuser_reflection(
+ f, [ABSORBER, None], depth=0.03, period=0.10,
+ resonator_geometry="square",
+ )
+ assert result.reflection.shape == (2, 2)
+ with pytest.raises(ValueError, match="geometry"):
+ metadiffuser_reflection(
+ f, [ABSORBER, None], depth=0.03, period=0.10,
+ resonator_geometry="round",
+ )
+
+
+def test_result_plots_render_and_validate() -> None:
+ # Smoke pass through both renderers, in both languages, plus the
+ # retained-geometry guard of the drawing.
+ matplotlib = pytest.importorskip("matplotlib")
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+
+ from phonometry.materials.metadiffuser import MetadiffuserResult
+
+ f = np.arange(400.0, 601.0, 50.0)
+ result = metadiffuser_reflection(
+ f, [ABSORBER, INVERTER], depth=0.03, period=0.10
+ )
+ for language in ("en", "es"):
+ ax = result.plot(language=language)
+ assert ax.get_lines()
+ plt.close(ax.figure)
+ ax = result.plot_geometry(language=language)
+ assert ax.patches
+ plt.close(ax.figure)
+ bare = MetadiffuserResult(
+ frequency=f,
+ reflection=result.reflection,
+ absorption=result.absorption,
+ well_absorption=result.well_absorption,
+ )
+ with pytest.raises(ValueError, match="retain"):
+ bare.plot_geometry()
+ plt.close("all")