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