diff --git a/.travis.yml b/.travis.yml index e92c3ac..c60bab6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ addons: apt: packages: - pandoc + - ffmpeg install: - pip install . - pip install -r tests/requirements.txt diff --git a/doc/examples.rst b/doc/examples.rst index be11b0d..3a20de9 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -15,4 +15,5 @@ Examples examples/modal-room-acoustics examples/mirror-image-source-model + examples/animations-pulsating-sphere example-python-scripts diff --git a/doc/examples/animations-pulsating-sphere.ipynb b/doc/examples/animations-pulsating-sphere.ipynb new file mode 100644 index 0000000..a0d7da2 --- /dev/null +++ b/doc/examples/animations-pulsating-sphere.ipynb @@ -0,0 +1,366 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Animations of a Pulsating Sphere" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sfs\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from IPython.display import HTML" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this example, the sound field of a pulsating sphere is visualized.\n", + "Different acoustic variables, such as sound pressure,\n", + "particle velocity, and particle displacement, are simulated.\n", + "The first two quantities are computed with\n", + "\n", + "- [sfs.mono.source.pulsating_sphere()](../sfs.mono.source.rst#sfs.mono.source.pulsating_sphere) and \n", + "- [sfs.mono.source.pulsating_sphere_velocity()](../sfs.mono.source.rst#sfs.mono.source.pulsating_sphere_velocity)\n", + "\n", + "while the last one can be obtained by using\n", + "\n", + "- [sfs.util.displacement()](../sfs.util.rst#sfs.util.displacement)\n", + "\n", + "which converts the particle velocity into displacement.\n", + "\n", + "A couple of additional functions are implemented in\n", + "\n", + "- [animations_pulsating_sphere.py](animations_pulsating_sphere.py)\n", + "\n", + "in order to help creating animating pictures, which is fun!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import animations_pulsating_sphere as animation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Pulsating sphere\n", + "center = [0, 0, 0]\n", + "radius = 0.25\n", + "amplitude = 0.05\n", + "f = 1000 # frequency\n", + "omega = 2 * np.pi * f # angular frequency\n", + "\n", + "# Axis limits\n", + "figsize = (6, 6)\n", + "xmin, xmax = -1, 1\n", + "ymin, ymax = -1, 1\n", + "\n", + "# Animations\n", + "frames = 20 # frames per period" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Particle Displacement" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.025)\n", + "ani = animation.particle_displacement(\n", + " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can play with the animation more interactively by using `.to_jshtml`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "HTML(ani.to_jshtml())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Of course, different types of grid can be chosen.\n", + "Below is the particle animation using the same parameters\n", + "but with a [hexagonal grid](https://www.redblobgames.com/grids/hexagons/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def hex_grid(xlim, ylim, hex_edge, align='horizontal'):\n", + " if align is 'vertical':\n", + " umin, umax = ylim\n", + " vmin, vmax = xlim\n", + " else:\n", + " umin, umax = xlim\n", + " vmin, vmax = ylim\n", + " du = np.sqrt(3) * hex_edge\n", + " dv = 1.5 * hex_edge\n", + " num_u = int((umax - umin) / du)\n", + " num_v = int((vmax - vmin) / dv)\n", + " u, v = np.meshgrid(np.linspace(umin, umax, num_u),\n", + " np.linspace(vmin, vmax, num_v))\n", + " u[::2] += 0.5 * du\n", + "\n", + " if align is 'vertical':\n", + " grid = v, u, 0\n", + " elif align is 'horizontal':\n", + " grid = u, v, 0\n", + " return grid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = hex_grid([xmin, xmax], [ymin, ymax], 0.0125, 'vertical')\n", + "ani = animation.particle_displacement(\n", + " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Another one using a random grid." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = [np.random.uniform(xmin, xmax, 4000),\n", + " np.random.uniform(ymin, ymax, 4000), 0]\n", + "ani = animation.particle_displacement(\n", + " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Each grid has its strengths and weaknesses. Please refer to the\n", + "[on-line discussion](https://github.com/sfstoolbox/sfs-python/pull/69#issuecomment-468405536)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Particle Velocity" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "amplitude = 1e-3\n", + "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04)\n", + "ani = animation.particle_velocity(\n", + " omega, center, radius, amplitude, grid, frames, figsize)\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Please notice that the amplitude of the pulsating motion is adjusted\n", + "so that the arrows are neither too short nor too long.\n", + "This kind of compromise is inevitable since\n", + "\n", + "$$\n", + "\\text{(particle velocity)} = \\text{i} \\omega \\times (\\text{amplitude}),\n", + "$$\n", + "\n", + "thus the absolute value of particle velocity is usually\n", + "much larger than that of amplitude.\n", + "It should be also kept in mind that the hole in the middle\n", + "does not visualizes the exact motion of the pulsating sphere.\n", + "According to the above equation, the actual amplitude should be\n", + "much smaller than the arrow lengths.\n", + "The changing rate of its size is also two times higher than the original frequency." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sound Pressure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "amplitude = 0.05\n", + "impedance_pw = sfs.default.rho0 * sfs.default.c\n", + "max_pressure = omega * impedance_pw * amplitude\n", + "\n", + "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", + "ani = animation.sound_pressure(\n", + " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", + " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice that the sound pressure exceeds\n", + "the atmospheric pressure ($\\approx 10^5$ Pa), which of course makes no sense.\n", + "This is due to the large amplitude (50 mm) of the pulsating motion.\n", + "It was chosen to better visualize the particle movements\n", + "in the earlier animations.\n", + "\n", + "For 1 kHz, the amplitude corresponding to a moderate sound pressure,\n", + "let say 1 Pa, is in the order of micrometer.\n", + "As it is very small compared to the corresponding wavelength (0.343 m),\n", + "the movement of the particles and the spatial structure of the sound field\n", + "cannot be observed simultaneously.\n", + "Furthermore, at high frequencies, the sound pressure\n", + "for a given particle displacement scales with the frequency.\n", + "The smaller wavelength (higher frequency) we choose,\n", + "it is more likely to end up with a prohibitively high sound pressure.\n", + "\n", + "In the following examples, the amplitude is set to a realistic value 1 $\\mu$m.\n", + "Notice that the pulsating motion of the sphere is no more visible." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "amplitude = 1e-6\n", + "impedance_pw = sfs.default.rho0 * sfs.default.c\n", + "max_pressure = omega * impedance_pw * amplitude\n", + "\n", + "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", + "ani = animation.sound_pressure(\n", + " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", + " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's zoom in closer to the boundary of the sphere." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "L = 10 * amplitude\n", + "xmin_zoom, xmax_zoom = radius - L, radius + L\n", + "ymin_zoom, ymax_zoom = -L, L" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = sfs.util.xyz_grid([xmin_zoom, xmax_zoom], [ymin_zoom, ymax_zoom], 0, spacing=L / 100)\n", + "ani = animation.sound_pressure(\n", + " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", + " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", + "plt.close()\n", + "HTML(ani.to_html5_video())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This shows how the vibrating motion of the sphere (left half)\n", + "changes the sound pressure of the surrounding air (right half).\n", + "Notice that the sound pressure increases/decreases (more red/blue)\n", + "when the surface accelerates/decelerates." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [default]", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.5.6" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/doc/examples/animations_pulsating_sphere.py b/doc/examples/animations_pulsating_sphere.py new file mode 100644 index 0000000..712d4fa --- /dev/null +++ b/doc/examples/animations_pulsating_sphere.py @@ -0,0 +1,114 @@ +"""Animations of pulsating sphere.""" +import sfs +import numpy as np +from matplotlib import pyplot as plt +from matplotlib import animation + + +def particle_displacement(omega, center, radius, amplitude, grid, frames, + figsize=(8, 8), interval=80, blit=True, **kwargs): + """Generate sound particle animation.""" + velocity = sfs.mono.source.pulsating_sphere_velocity( + omega, center, radius, amplitude, grid) + displacement = sfs.util.displacement(velocity, omega) + phasor = np.exp(1j * 2 * np.pi / frames) + + fig, ax = plt.subplots(figsize=figsize) + ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) + scat = sfs.plot.particles(grid + displacement, **kwargs) + + def update_frame_displacement(i): + position = (grid + displacement * phasor**i).apply(np.real) + position = np.column_stack([position[0].flatten(), + position[1].flatten()]) + scat.set_offsets(position) + return [scat] + + return animation.FuncAnimation( + fig, update_frame_displacement, frames, + interval=interval, blit=blit) + + +def particle_velocity(omega, center, radius, amplitude, grid, frames, + figsize=(8, 8), interval=80, blit=True, **kwargs): + """Generate particle velocity animation.""" + velocity = sfs.mono.source.pulsating_sphere_velocity( + omega, center, radius, amplitude, grid) + phasor = np.exp(1j * 2 * np.pi / frames) + + fig, ax = plt.subplots(figsize=figsize) + ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) + quiv = sfs.plot.vectors( + velocity, grid, clim=[-omega * amplitude, omega * amplitude], + **kwargs) + + def update_frame_velocity(i): + quiv.set_UVC(*(velocity[:2] * phasor**i).apply(np.real)) + return [quiv] + + return animation.FuncAnimation( + fig, update_frame_velocity, frames, interval=interval, blit=True) + + +def sound_pressure(omega, center, radius, amplitude, grid, frames, + pulsate=False, figsize=(8, 8), interval=80, blit=True, + **kwargs): + """Generate sound pressure animation.""" + pressure = sfs.mono.source.pulsating_sphere( + omega, center, radius, amplitude, grid, inside=pulsate) + phasor = np.exp(1j * 2 * np.pi / frames) + + fig, ax = plt.subplots(figsize=figsize) + im = sfs.plot.soundfield(np.real(pressure), grid, **kwargs) + ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) + + def update_frame_pressure(i): + distance = np.linalg.norm(grid) + p = pressure * phasor**i + if pulsate: + p[distance <= radius + amplitude * np.real(phasor**i)] = np.nan + im.set_array(np.real(p)) + return [im] + + return animation.FuncAnimation( + fig, update_frame_pressure, frames, interval=interval, blit=True) + + +if __name__ == '__main__': + + # Pulsating sphere + center = [0, 0, 0] + radius = 0.25 + f = 750 # frequency + omega = 2 * np.pi * f # angular frequency + + # Axis limits + xmin, xmax = -1, 1 + ymin, ymax = -1, 1 + + # Animations + frames = 20 # frames per period + + # Particle displacement + amplitude = 5e-2 # amplitude of the surface displacement + grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.025) + ani = particle_displacement( + omega, center, radius, amplitude, grid, frames, c='Gray') + ani.save('pulsating_sphere_displacement.gif', dpi=80, writer='imagemagick') + + # Particle velocity + amplitude = 1e-3 # amplitude of the surface displacement + grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04) + ani = particle_velocity( + omega, center, radius, amplitude, grid, frames) + ani.save('pulsating_sphere_velocity.gif', dpi=80, writer='imagemagick') + + # Sound pressure + amplitude = 1e-6 # amplitude of the surface displacement + impedance_pw = sfs.default.rho0 * sfs.default.c + max_pressure = omega * impedance_pw * amplitude + grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005) + ani = sound_pressure( + omega, center, radius, amplitude, grid, frames, pulsate=True, + colorbar=True, vmin=-max_pressure, vmax=max_pressure) + ani.save('pulsating_sphere_pressure.gif', dpi=80, writer='imagemagick') diff --git a/sfs/mono/source.py b/sfs/mono/source.py index 05ad6cd..e3d808e 100644 --- a/sfs/mono/source.py +++ b/sfs/mono/source.py @@ -707,6 +707,115 @@ def plane_averaged_intensity(omega, x0, n0, grid, c=None, rho0=None): return util.XyzComponents([i * n for n in n0]) +def pulsating_sphere(omega, center, radius, amplitude, grid, inside=False, + c=None): + """Sound pressure of a pulsating sphere. + + Parameters + --------- + omega : float + Frequency of pulsating sphere + center : (3,) array_like + Center of sphere. + radius : float + Radius of sphere. + amplitude : float + Amplitude of displacement. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + inside : bool, optional + As default, `numpy.nan` is returned for inside the sphere. + If ``inside=True``, the sound field inside the sphere is extrapolated. + c : float, optional + Speed of sound. + + Returns + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + If ``inside=False``, `numpy.nan` is returned for inside the sphere. + + Examples + -------- + + .. plot:: + :context: close-figs + + radius = 0.25 + amplitude = 1 / (radius * omega * sfs.default.rho0 * sfs.default.c) + p = sfs.mono.source.pulsating_sphere(omega, x0, radius, amplitude, grid) + sfs.plot.soundfield(p, grid) + plt.title("Sound Pressure of a Pulsating Sphere") + + """ + if c is None: + c = default.c + k = util.wavenumber(omega, c) + center = util.asarray_1d(center) + grid = util.as_xyz_components(grid) + + distance = np.linalg.norm(grid - center) + theta = np.arctan(1, k * distance) + impedance = default.rho0 * c * np.cos(theta) * np.exp(1j * theta) + radial_velocity = 1j * omega * amplitude * radius / distance \ + * np.exp(-1j * k * (distance - radius)) + if not inside: + radial_velocity[distance <= radius] = np.nan + return impedance * radial_velocity + + +def pulsating_sphere_velocity(omega, center, radius, amplitude, grid, c=None): + """Particle velocity of a pulsating sphere. + + Parameters + --------- + omega : float + Frequency of pulsating sphere + center : (3,) array_like + Center of sphere. + radius : float + Radius of sphere. + amplitude : float + Amplitude of displacement. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + c : float, optional + Speed of sound. + + Returns + ------- + `XyzComponents` + Particle velocity at positions given by *grid*. + `numpy.nan` is returned for inside the sphere. + + Examples + -------- + + .. plot:: + :context: close-figs + + v = sfs.mono.source.pulsating_sphere_velocity(omega, x0, radius, amplitude, vgrid) + sfs.plot.soundfield(p, grid) + sfs.plot.vectors(v, vgrid) + plt.title("Sound Pressure and Particle Velocity of a Pulsating Sphere") + + """ + if c is None: + c = default.c + k = util.wavenumber(omega, c) + grid = util.as_xyz_components(grid) + + center = util.asarray_1d(center) + offset = grid - center + distance = np.linalg.norm(offset) + radial_velocity = 1j * omega * amplitude * radius / distance \ + * np.exp(-1j * k * (distance - radius)) + radial_velocity[distance <= radius] = np.nan + return util.XyzComponents([radial_velocity * o / distance for o in offset]) + + def _duplicate_zdirection(p, grid): """If necessary, duplicate field in z-direction.""" gridshape = np.broadcast(*grid).shape diff --git a/sfs/plot.py b/sfs/plot.py index 2e96d21..90d6685 100644 --- a/sfs/plot.py +++ b/sfs/plot.py @@ -328,8 +328,8 @@ def level(p, grid, xnorm=None, power=False, cmap=None, vmax=3, vmin=-50, def particles(x, trim=None, ax=None, xlabel='x (m)', ylabel='y (m)', - edgecolor='', **kwargs): - """Plot particle positions as scatter plot.""" + edgecolor='', marker='.', s=15, **kwargs): + """Plot particle positions as scatter plot""" XX, YY = [np.real(c) for c in x[:2]] if trim is not None: @@ -342,12 +342,12 @@ def particles(x, trim=None, ax=None, xlabel='x (m)', ylabel='y (m)', if ax is None: ax = plt.gca() - ax.scatter(XX, YY, edgecolor=edgecolor, **kwargs) - if xlabel: ax.set_xlabel(xlabel) if ylabel: ax.set_ylabel(ylabel) + return ax.scatter(XX, YY, edgecolor=edgecolor, marker=marker, s=s, + **kwargs) def vectors(v, grid, cmap='blacktransparent', headlength=3, headaxislength=2.5,