diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..22d7f47 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Test + +on: [push, pull_request] + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + python-version: [3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Prepare Ubuntu + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y pandoc ffmpeg + if: matrix.os == 'ubuntu-latest' + - name: Prepare OSX + run: brew install pandoc ffmpeg + if: matrix.os == 'macOS-latest' + - name: prepare Windows + run: choco install pandoc ffmpeg + if: matrix.os == 'windows-latest' + - name: Install dependencies + run: | + python -V + python -m pip install --upgrade pip + python -m pip install . + python -m pip install -r tests/requirements.txt + python -m pip install -r doc/requirements.txt + # This is needed in example scripts: + python -m pip install pillow + - name: Test + run: python -m pytest + - name: Test examples + run: python doc/examples/run_all.py + - name: Test documentation + run: python -m sphinx doc/ _build/ -b doctest diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 61bb858..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python -python: - - "2.7" - - "3.6" -addons: - apt: - packages: - - pandoc -install: - - pip install . - - pip install -r tests/requirements.txt - - pip install -r doc/requirements.txt -script: - - python -m pytest - # This executes the example notebooks and checks for valid URLs: - - python -m sphinx doc/ _build/ -b linkcheck diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 68887d1..006d7e4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -6,37 +6,79 @@ improvement, please create an issue or a pull request at https://github.com/sfstoolbox/sfs-python/. Contributions are always welcome! -Instead of pip-installing the latest release from PyPI, you should get the +Development Installation +^^^^^^^^^^^^^^^^^^^^^^^^ + +Instead of pip-installing the latest release from PyPI_, you should get the newest development version from Github_:: git clone https://github.com/sfstoolbox/sfs-python.git cd sfs-python - python setup.py develop --user + python3 -m pip install --user -e . -.. _Github: https://github.com/sfstoolbox/sfs-python/ +... where ``-e`` stands for ``--editable``. This way, your installation always stays up-to-date, even if you pull new changes from the Github repository. -If you prefer, you can also replace the last command with:: +.. _PyPI: https://pypi.org/project/sfs/ +.. _Github: https://github.com/sfstoolbox/sfs-python/ - pip install --user -e . -... where ``-e`` stands for ``--editable``. +Building the Documentation +^^^^^^^^^^^^^^^^^^^^^^^^^^ If you make changes to the documentation, you can re-create the HTML pages using Sphinx_. You can install it and a few other necessary packages with:: - pip install -r doc/requirements.txt --user + python3 -m pip install -r doc/requirements.txt --user To create the HTML pages, use:: - python setup.py build_sphinx + python3 setup.py build_sphinx The generated files will be available in the directory ``build/sphinx/html/``. +To create the EPUB file, use:: + + python3 setup.py build_sphinx -b epub + +The generated EPUB file will be available in the directory +``build/sphinx/epub/``. + +To create the PDF file, use:: + + python3 setup.py build_sphinx -b latex + +Afterwards go to the folder ``build/sphinx/latex/`` and run LaTeX to create the +PDF file. If you don’t know how to create a PDF file from the LaTeX output, you +should have a look at Latexmk_ (see also this `Latexmk tutorial`_). + +It is also possible to automatically check if all links are still valid:: + + python3 setup.py build_sphinx -b linkcheck + .. _Sphinx: http://sphinx-doc.org/ +.. _Latexmk: http://personal.psu.edu/jcc8/software/latexmk-jcc/ +.. _Latexmk tutorial: https://mg.readthedocs.io/latexmk.html + +Running the Tests +^^^^^^^^^^^^^^^^^ + +You'll need pytest_ for that. +It can be installed with:: + + python3 -m pip install -r tests/requirements.txt --user + +To execute the tests, simply run:: + + python3 -m pytest + +.. _pytest: https://pytest.org/ + +Creating a New Release +^^^^^^^^^^^^^^^^^^^^^^ New releases are made using the following steps: @@ -46,14 +88,14 @@ New releases are made using the following steps: #. Create an (annotated) tag with ``git tag -a x.y.z`` #. Clear the ``dist/`` directory #. Create a source distribution with ``python3 setup.py sdist`` -#. Create a wheel distribution with ``python3 setup.py bdist_wheel --universal`` +#. Create a wheel distribution with ``python3 setup.py bdist_wheel`` #. Check that both files have the correct content -#. Upload them to PyPI with twine_: ``twine upload dist/*`` +#. Upload them to PyPI_ with twine_: ``python3 -m twine upload dist/*`` #. Push the commit and the tag to Github and `add release notes`_ containing a link to PyPI and the bullet points from ``NEWS.rst`` -#. Check that the new release was built correctly on RTD_, delete the "stable" - version and select the new release as default version +#. Check that the new release was built correctly on RTD_ + and select the new release as default version -.. _twine: https://pypi.python.org/pypi/twine +.. _twine: https://twine.readthedocs.io/ .. _add release notes: https://github.com/sfstoolbox/sfs-python/tags -.. _RTD: http://readthedocs.org/projects/sfs/builds/ +.. _RTD: https://readthedocs.org/projects/sfs-python/builds/ diff --git a/LICENSE b/LICENSE index 385a6a6..800c691 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014-2016 SFS Toolbox Developers +Copyright (c) 2014-2019 SFS Toolbox Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NEWS.rst b/NEWS.rst index 1a3eed8..0a1d06d 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,49 @@ Version History =============== + +Version 0.6.2 (2021-06-05): + * build doc fix, use sphinx4, mathjax2, html_css_files + +Version 0.6.1 (2021-06-05): + * New default driving function for `sfs.td.wfs.point_25d()` for reference curve + +Version 0.6.0 (2020-12-01): + * New function `sfs.fd.source.line_bandlimited()` computing the sound field of a spatially bandlimited line source + * Drop support for Python 3.5 + +Version 0.5.0 (2019-03-18): + * Switching to separate `sfs.plot2d` and `sfs.plot3d` for plotting functions + * Move `sfs.util.displacement()` to `sfs.fd.displacement()` + * Switch to keyword only arguments + * New default driving function for `sfs.fd.wfs.point_25d()` + * New driving function syntax, e.g. `sfs.fd.wfs.point_25d()` + * Example for the sound field of a pulsating sphere + * Add time domain NFC-HOA driving functions `sfs.td.nfchoa` + * `sfs.fd.synthesize()`, `sfs.td.synthesize()` for soundfield superposition + * Change `sfs.mono` to `sfs.fd` and `sfs.time` to `sfs.td` + * Move source selection helpers to `sfs.util` + * Use `sfs.default` object instead of `sfs.defs` submodule + * Drop support for legacy Python 2.7 + +Version 0.4.0 (2018-03-14): + * Driving functions in time domain for a plane wave, point source, and + focused source + * Image source model for a point source in a rectangular room + * `sfs.util.DelayedSignal` class and `sfs.util.as_delayed_signal()` + * Improvements to the documentation + * Start using Jupyter notebooks for examples in documentation + * Spherical Hankel function as `sfs.util.spherical_hn2()` + * Use `scipy.special.spherical_jn`, `scipy.special.spherical_yn` instead of + `scipy.special.sph_jnyn` + * Generalization of the modal order argument in `sfs.mono.source.point_modal()` + * Rename `sfs.util.normal_vector()` to `sfs.util.normalize_vector()` + * Add parameter ``max_order`` to NFCHOA driving functions + * Add ``beta`` parameter to Kaiser tapering window + * Fix clipping problem of sound field plots with matplotlib 2.1 + * Fix elevation in `sfs.util.cart2sph()` + * Fix `sfs.tapering.tukey()` for ``alpha=1`` + Version 0.3.1 (2016-04-08): * Fixed metadata of release @@ -9,12 +52,10 @@ Version 0.3.0 (2016-04-08): * Driving functions for the synthesis of various virtual source types with edge-shaped arrays by the equivalent scattering appoach * Driving functions for the synthesis of focused sources by WFS - * Several refactorings, bugfixes and other improvements Version 0.2.0 (2015-12-11): * Ability to calculate and plot particle velocity and displacement fields * Several function name and parameter name changes - * Several refactorings, bugfixes and other improvements Version 0.1.1 (2015-10-08): * Fix missing `sfs.mono` subpackage in PyPI packages diff --git a/README.rst b/README.rst index 6244ef9..0c3962e 100644 --- a/README.rst +++ b/README.rst @@ -1,29 +1,25 @@ -Sound Field Synthesis Toolbox for Python -======================================== +Sound Field Synthesis (SFS) Toolbox for Python +============================================== -The Sound Field Synthesis Toolbox for Python gives you the possibility to create -numercial simulations of sound field synthesis methods like wave field synthesis -(WFS) or near-field compensated higher order Ambisonics (NFC-HOA). - -Theory: - http://sfstoolbox.org/ +A Python library for creating numercial simulations of sound field synthesis +methods like Wave Field Synthesis (WFS) or Near-Field Compensated Higher Order +Ambisonics (NFC-HOA). Documentation: - http://python.sfstoolbox.org/ + https://sfs-python.readthedocs.io/ Source code and issue tracker: https://github.com/sfstoolbox/sfs-python/ -Python Package Index: - https://pypi.python.org/pypi/sfs/ - -SFS Toolbox for Matlab: - http://matlab.sfstoolbox.org/ - License: MIT -- see the file ``LICENSE`` for details. Quick start: - * Install NumPy, SciPy and Matplotlib + * Install Python 3, NumPy, SciPy and Matplotlib * ``python3 -m pip install sfs --user`` - * ``python3 doc/examples/horizontal_plane_arrays.py`` + * Check out the examples in the documentation + +More information about the underlying theory can be found at +https://sfs.readthedocs.io/. +There is also a Sound Field Synthesis Toolbox for Octave/Matlab, see +https://sfs-matlab.readthedocs.io/. diff --git a/data/arrays/example_array_4LS_2D.csv b/data/arrays/example_array_4LS_2D.csv new file mode 100644 index 0000000..4569574 --- /dev/null +++ b/data/arrays/example_array_4LS_2D.csv @@ -0,0 +1,4 @@ +1,0,0,-1,0,0,1 +0,1,0,0,-1,0,1 +-1,0,0,1,0,0,1 +0,-1,0,0,1,0,1 \ No newline at end of file diff --git a/data/arrays/example_array_6LS_3D.txt b/data/arrays/example_array_6LS_3D.txt new file mode 100644 index 0000000..c50f3f6 --- /dev/null +++ b/data/arrays/example_array_6LS_3D.txt @@ -0,0 +1,6 @@ +1 0 0 1 +-1 0 0 1 +0 1 0 1 +0 -1 0 1 +0 0 1 1 +0 0 -1 1 \ No newline at end of file diff --git a/data/arrays/university_rostock.csv b/data/arrays/wfs_university_rostock_2015.csv similarity index 100% rename from data/arrays/university_rostock.csv rename to data/arrays/wfs_university_rostock_2015.csv diff --git a/data/arrays/wfs_university_rostock_2018.csv b/data/arrays/wfs_university_rostock_2018.csv new file mode 100644 index 0000000..f3cbd1c --- /dev/null +++ b/data/arrays/wfs_university_rostock_2018.csv @@ -0,0 +1,64 @@ +1.8555,0.12942,1.6137,-1,0,0,0.1877 +1.8604,0.31567,1.6137,-1,0,0,0.2045 +1.8638,0.53832,1.6133,-1,0,0,0.22837 +1.8665,0.77237,1.6118,-1,0,0,0.24117 +1.8673,1.0206,1.6157,-1,0,0,0.24838 +1.8688,1.2691,1.6154,-1,0,0,0.23781 +1.8702,1.4962,1.6167,-1,0,0,0.20929 +1.8755,1.6876,1.6163,-1,0,0,0.22679 +1.6875,1.8702,1.6203,0,-1,0,0.22545 +1.4993,1.8843,1.6154,0,-1,0,0.21679 +1.2547,1.8749,1.6174,0,-1,0,0.23875 +1.022,1.8768,1.6184,0,-1,0,0.23992 +0.77488,1.8763,1.6175,0,-1,0,0.2349 +0.55221,1.8775,1.6177,0,-1,0,0.2327 +0.3095,1.8797,1.6157,0,-1,0,0.24573 +0.060789,1.882,1.6134,0,-1,0,0.21554 +-0.12151,1.8841,1.6101,0,-1,0,0.18685 +-0.31278,1.8791,1.613,0,-1,0,0.20506 +-0.53142,1.8855,1.6099,0,-1,0,0.22562 +-0.76382,1.8905,1.6061,0,-1,0,0.23945 +-1.0102,1.8888,1.6101,0,-1,0,0.25042 +-1.2646,1.8911,1.6086,0,-1,0,0.23947 +-1.4891,1.8936,1.607,0,-1,0,0.20807 +-1.6807,1.8964,1.6062,0,-1,0,0.22572 +-1.8625,1.7108,1.6075,1,0,0,0.22016 +-1.863,1.5303,1.6066,1,0,0,0.21877 +-1.8611,1.2733,1.6107,1,0,0,0.2448 +-1.8653,1.0408,1.6075,1,0,0,0.23885 +-1.8729,0.79578,1.6054,1,0,0,0.23437 +-1.8704,0.5722,1.6071,1,0,0,0.23219 +-1.881,0.33166,1.6053,1,0,0,0.24605 +-1.8783,0.080365,1.6075,1,0,0,0.21801 +-1.8781,-0.10434,1.6061,1,0,0,0.1852 +-1.8798,-0.28999,1.609,1,0,0,0.20278 +-1.8842,-0.50982,1.6095,1,0,0,0.22814 +-1.8911,-0.74608,1.6054,1,0,0,0.23945 +-1.8901,-0.98854,1.6102,1,0,0,0.24439 +-1.8928,-1.2348,1.6095,1,0,0,0.24209 +-1.8925,-1.4727,1.6117,1,0,0,0.21306 +-1.8939,-1.6609,1.6115,1,0,0,0.22209 +-1.7127,-1.8417,1.611,0,1,0,0.21959 +-1.5295,-1.8417,1.6129,0,1,0,0.21598 +-1.2809,-1.8485,1.6079,0,1,0,0.24212 +-1.0454,-1.8478,1.6094,0,1,0,0.2401 +-0.80072,-1.8512,1.609,0,1,0,0.23619 +-0.57305,-1.8524,1.6082,0,1,0,0.23437 +-0.33198,-1.8525,1.6074,0,1,0,0.24395 +-0.085164,-1.854,1.6085,0,1,0,0.21792 +0.10383,-1.8571,1.6082,0,1,0,0.18649 +0.28774,-1.8609,1.6061,0,1,0,0.20288 +0.50951,-1.8574,1.6049,0,1,0,0.22772 +0.74305,-1.8643,1.6034,0,1,0,0.23983 +0.989,-1.8695,1.6036,0,1,0,0.24802 +1.239,-1.8649,1.6041,0,1,0,0.24388 +1.4767,-1.8678,1.6054,0,1,0,0.20977 +1.6585,-1.8653,1.6059,0,1,0,0.22148 +1.8436,-1.6811,1.6054,-1,0,0,0.22264 +1.8563,-1.4974,1.6033,-1,0,0,0.21688 +1.8468,-1.248,1.6072,-1,0,0,0.24047 +1.85,-1.0167,1.6076,-1,0,0,0.23909 +1.8513,-0.76986,1.6101,-1,0,0,0.23739 +1.8585,-0.54207,1.6076,-1,0,0,0.23585 +1.8562,-0.29831,1.6107,-1,0,0,0.24122 +1.857,-0.059658,1.6121,-1,0,0,0.21387 diff --git a/doc/README b/doc/README index deb2a66..2752b55 100644 --- a/doc/README +++ b/doc/README @@ -1,7 +1,8 @@ This directory holds the documentation in reStructuredText/Sphinx format. +It also contains some examples (Jupyter notebooks and Python scripts). Have a look at the online documentation for the auto-generated HTML version: -http://python.sfstoolbox.org/ +https://sfs-python.readthedocs.io/ If you want to generate the HTML (or LaTeX/PDF) files on your computer, have a -look at http://python.sfstoolbox.org/en/latest/contributing.html. +look at https://sfs-python.readthedocs.io/en/latest/contributing.html. diff --git a/doc/_static/css/title.css b/doc/_static/css/title.css new file mode 100644 index 0000000..e539e2b --- /dev/null +++ b/doc/_static/css/title.css @@ -0,0 +1,33 @@ +.wy-side-nav-search>a, .wy-side-nav-search .wy-dropdown>a { + font-family: "Roboto Slab","ff-tisa-web-pro","Georgia",Arial,sans-serif; + font-size: 200%; + margin-top: .222em; + margin-bottom: .202em; +} +.wy-side-nav-search { + padding: 0; +} +form#rtd-search-form { + margin-left: .809em; + margin-right: .809em; +} +.rtd-nav a { + float: left; + display: block; + width: 33.3%; + height: 100%; + padding-top: 7px; + color: white; +} +.rtd-nav { + overflow: hidden; + width: 100%; + height: 35px; + margin-top: 15px; +} +.rtd-nav a:hover { + background-color: #388bbd; +} +.rtd-nav a.active { + background-color: #388bbd; +} diff --git a/doc/_template/layout.html b/doc/_template/layout.html new file mode 100644 index 0000000..36a3c25 --- /dev/null +++ b/doc/_template/layout.html @@ -0,0 +1,14 @@ +{% extends "!layout.html" %} +{% block sidebartitle %} + + {{ project }} + + {% include "searchbox.html" %} + +
+ Theory + Matlab + Python +
+ +{% endblock %} diff --git a/doc/api.rst b/doc/api.rst new file mode 100644 index 0000000..1e66983 --- /dev/null +++ b/doc/api.rst @@ -0,0 +1,4 @@ +API Documentation +================= + +.. automodule:: sfs diff --git a/doc/arrays.rst b/doc/arrays.rst deleted file mode 100644 index 611fb79..0000000 --- a/doc/arrays.rst +++ /dev/null @@ -1,13 +0,0 @@ -Secondary Sources -================= - -Loudspeaker Arrays ------------------- - -.. automodule:: sfs.array - :exclude-members: ArrayData - -Tapering --------- - -.. automodule:: sfs.tapering diff --git a/doc/conf.py b/doc/conf.py index 0a67eae..35b2874 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -17,6 +17,8 @@ import os from subprocess import check_output +import sphinx + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -32,24 +34,35 @@ # ones. extensions = [ 'sphinx.ext.autodoc', - 'sphinx.ext.mathjax', + 'sphinx.ext.autosummary', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', # support for NumPy-style docstrings 'sphinx.ext.intersphinx', - 'sphinx.ext.extlinks', + 'sphinx.ext.doctest', 'sphinxcontrib.bibtex', + 'sphinx.ext.extlinks', 'matplotlib.sphinxext.plot_directive', 'nbsphinx', ] -# Override kernel name to allow running with Python 2 on Travis-CI -nbsphinx_kernel_name = 'python' +bibtex_bibfiles = ['references.bib'] + +nbsphinx_execute_arguments = [ + "--InlineBackend.figure_formats={'svg', 'pdf'}", + "--InlineBackend.rc={'figure.dpi': 96}", +] -linkcheck_timeout = 10 +# Tell autodoc that the documentation is being generated +sphinx.SFS_DOCS_ARE_BEING_BUILT = True autoclass_content = 'init' autodoc_member_order = 'bysource' -autodoc_default_flags = ['members', 'undoc-members'] +autodoc_default_options = { + 'members': True, + 'undoc-members': True, +} + +autosummary_generate = ['api'] napoleon_google_docstring = False napoleon_numpy_docstring = True @@ -66,16 +79,44 @@ 'python': ('https://docs.python.org/3/', None), 'numpy': ('https://docs.scipy.org/doc/numpy/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), - 'matplotlib': ('http://matplotlib.org/', None), + 'matplotlib': ('https://matplotlib.org/', None), } +extlinks = {'sfs': ('https://sfs.readthedocs.io/en/3.2/%s', + 'https://sfs.rtfd.io/')} + plot_include_source = True plot_html_show_source_link = False plot_html_show_formats = False -plot_pre_code = "" +plot_pre_code = '' +plot_rcparams = { + 'savefig.bbox': 'tight', +} +plot_formats = ['svg', 'pdf'] + +# use mathjax2 with +# https://github.com/spatialaudio/nbsphinx/issues/572#issuecomment-853389268 +# and 'TeX' dictionary +# in future we might switch to mathjax3 once the +# 'begingroup' extension is available +# http://docs.mathjax.org/en/latest/input/tex/extensions/begingroup.html#begingroup +# https://mathjax.github.io/MathJax-demos-web/convert-configuration/convert-configuration.html +mathjax_path = ('https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js' + '?config=TeX-AMS-MML_HTMLorMML') +mathjax2_config = { + 'tex2jax': { + 'inlineMath': [['$', '$'], ['\\(', '\\)']], + 'processEscapes': True, + 'ignoreClass': 'document', + 'processClass': 'math|output_area', + }, + 'TeX': { + 'extensions': ['newcommand.js', 'begingroup.js'], # Support for \gdef + }, +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ['_template'] # The suffix of source filenames. source_suffix = '.rst' @@ -88,8 +129,8 @@ # General information about the project. authors = 'SFS Toolbox Developers' -project = 'Sound Field Synthesis Toolbox' -copyright = '2017, ' + authors +project = 'SFS Toolbox' +copyright = '2019, ' + authors # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -104,10 +145,6 @@ except Exception: release = '' -binder_base_url = 'https://mybinder.org/v2/gh/sfstoolbox/sfs-python/' - -extlinks = {'binder': (binder_base_url + release + '?filepath=%s', 'binder:')} - # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None @@ -117,6 +154,11 @@ #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' +try: + today = check_output(['git', 'show', '-s', '--format=%ad', '--date=short']) + today = today.decode().strip() +except Exception: + today = '' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -146,9 +188,53 @@ # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False +jinja_define = """ +{% set docname = env.doc2path(env.docname, base='doc') %} +{% set latex_href = ''.join([ + '\href{https://github.com/sfstoolbox/sfs-python/blob/', + env.config.release, + '/', + docname | escape_latex, + '}{\sphinxcode{\sphinxupquote{', + docname | escape_latex, + '}}}', +]) %} +""" + +nbsphinx_prolog = jinja_define + r""" +.. only:: html + + .. role:: raw-html(raw) + :format: html + + .. nbinfo:: + + This page was generated from `{{ docname }}`__. + Interactive online version: + :raw-html:`Binder badge` + + __ https://github.com/sfstoolbox/sfs-python/blob/ + {{ env.config.release }}/{{ docname }} + +.. raw:: latex + + \nbsphinxstartnotebook{\scriptsize\noindent\strut + \textcolor{gray}{The following section was generated from {{ latex_href }} + \dotfill}} +""" + +nbsphinx_epilog = jinja_define + r""" +.. raw:: latex + + \nbsphinxstopnotebook{\scriptsize\noindent\strut + \textcolor{gray}{\dotfill\ {{ latex_href }} ends here.}} +""" + # -- Options for HTML output ---------------------------------------------- +html_css_files = ['css/title.css'] + # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' @@ -215,6 +301,7 @@ # If true, links to the reST sources are added to the pages. html_show_sourcelink = True +html_sourcelink_suffix = '' # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True @@ -233,20 +320,32 @@ # Output file base name for HTML help builder. htmlhelp_basename = 'SFS' +html_scaled_image_link = False # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -'papersize': 'a4paper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -'printindex': '', + 'papersize': 'a4paper', + 'printindex': '', + 'sphinxsetup': r""" + VerbatimColor={HTML}{F5F5F5}, + VerbatimBorderColor={HTML}{E0E0E0}, + noteBorderColor={HTML}{E0E0E0}, + noteborder=1.5pt, + warningBorderColor={HTML}{E0E0E0}, + warningborder=1.5pt, + warningBgColor={HTML}{FBFBFB}, + """, + 'preamble': r""" +\usepackage[sc,osf]{mathpazo} +\linespread{1.05} % see http://www.tug.dk/FontCatalogue/urwpalladio/ +\renewcommand{\sfdefault}{pplj} % Palatino instead of sans serif +\IfFileExists{zlmtt.sty}{ + \usepackage[light,scaled=1.05]{zlmtt} % light typewriter font from lmodern +}{ + \renewcommand{\ttdefault}{lmtt} % typewriter font from lmodern +} +""", } # Grouping the document tree into LaTeX files. List of tuples diff --git a/doc/example-python-scripts.rst b/doc/example-python-scripts.rst new file mode 100644 index 0000000..2e38f11 --- /dev/null +++ b/doc/example-python-scripts.rst @@ -0,0 +1,12 @@ +Example Python Scripts +====================== + +Various example scripts are located in the directory ``doc/examples/``, e.g. + +* :download:`examples/horizontal_plane_arrays.py`: Computes the sound fields + for various techniques, virtual sources and loudspeaker array configurations +* :download:`examples/animations_pulsating_sphere.py`: Creates animations of a + pulsating sphere, see also `the corresponding Jupyter notebook + `__ +* :download:`examples/soundfigures.py`: Illustrates the synthesis of sound + figures with Wave Field Synthesis diff --git a/doc/examples.rst b/doc/examples.rst new file mode 100644 index 0000000..0248604 --- /dev/null +++ b/doc/examples.rst @@ -0,0 +1,20 @@ +Examples +======== + +.. only:: html + + You can play with the Jupyter notebooks (without having to install anything) + by clicking |binder logo| on the respective example page. + + .. |binder logo| image:: https://mybinder.org/badge_logo.svg + :target: https://mybinder.org/v2/gh/sfstoolbox/sfs-python/master? + filepath=doc/examples + +.. toctree:: + :maxdepth: 1 + + examples/sound-field-synthesis + 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..f8b1e22 --- /dev/null +++ b/doc/examples/animations-pulsating-sphere.ipynb @@ -0,0 +1,359 @@ +{ + "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.fd.source.pulsating_sphere()](../sfs.fd.source.rst#sfs.fd.source.pulsating_sphere) and \n", + "- [sfs.fd.source.pulsating_sphere_velocity()](../sfs.fd.source.rst#sfs.fd.source.pulsating_sphere_velocity)\n", + "\n", + "while the last one can be obtained by using\n", + "\n", + "- [sfs.fd.displacement()](../sfs.fd.rst#sfs.fd.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_jshtml())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Click the arrow button to start the animation.\n", + "`to_jshtml()` allows you to play with the animation,\n", + "e.g. speed up/down the animation (+/- button).\n", + "Try to reverse the playback by clicking the left arrow.\n", + "You'll see a sound _sink_.\n", + "\n", + "You can also show the animation by using `to_html5_video()`.\n", + "See the [documentation](https://matplotlib.org/api/_as_gen/matplotlib.animation.ArtistAnimation.html#matplotlib.animation.ArtistAnimation.to_html5_video) for more detail.\n", + "\n", + "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_jshtml())" + ] + }, + { + "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_jshtml())" + ] + }, + { + "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_jshtml())" + ] + }, + { + "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_jshtml())" + ] + }, + { + "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_jshtml())" + ] + }, + { + "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_jshtml())" + ] + }, + { + "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..a415067 --- /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.fd.source.pulsating_sphere_velocity( + omega, center, radius, amplitude, grid) + displacement = sfs.fd.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.plot2d.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.fd.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.plot2d.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.fd.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.plot2d.amplitude(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/doc/examples/horizontal_plane_arrays.py b/doc/examples/horizontal_plane_arrays.py index cb42a36..daba525 100644 --- a/doc/examples/horizontal_plane_arrays.py +++ b/doc/examples/horizontal_plane_arrays.py @@ -29,126 +29,123 @@ def compute_and_plot_soundfield(title): """Compute and plot synthesized sound field.""" print('Computing', title) - twin = tapering(a, talpha) - p = sfs.mono.synthesized.generic(omega, x0, n0, d * twin * a0, grid, - source=sourcetype) + twin = tapering(selection, alpha=talpha) + p = sfs.fd.synthesize(d, twin, array, secondary_source, grid=grid) plt.figure(figsize=(15, 15)) plt.cla() - sfs.plot.soundfield(p, grid, xnorm) - sfs.plot.loudspeaker_2d(x0, n0, twin) - sfs.plot.virtualsource_2d(xs) - sfs.plot.virtualsource_2d([0, 0], npw, type='plane') + sfs.plot2d.amplitude(p, grid, xnorm=xnorm) + sfs.plot2d.loudspeakers(array.x, array.n, twin) + sfs.plot2d.virtualsource(xs) + sfs.plot2d.virtualsource([0, 0], npw, type='plane') plt.title(title) plt.grid() plt.savefig(title + '.png') # linear array, secondary point sources, virtual monopole -x0, n0, a0 = sfs.array.linear(N, dx, center=acenter, orientation=anormal) +array = sfs.array.linear(N, dx, center=acenter, orientation=anormal) -sourcetype = sfs.mono.source.point -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) - -d = sfs.mono.drivingfunction.wfs_3d_point(omega, x0, n0, xs) +d, selection, secondary_source = sfs.fd.wfs.point_3d( + omega, array.x, array.n, xs) compute_and_plot_soundfield('linear_ps_wfs_3d_point') -d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs, xref=xnorm) +d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs, xref=xnorm) compute_and_plot_soundfield('linear_ps_wfs_25d_point') -d = sfs.mono.drivingfunction.wfs_2d_point(omega, x0, n0, xs) +d, selection, secondary_source = sfs.fd.wfs.point_2d( + omega, array.x, array.n, xs) compute_and_plot_soundfield('linear_ps_wfs_2d_point') # linear array, secondary line sources, virtual line source -sourcetype = sfs.mono.source.line -d = sfs.mono.drivingfunction.wfs_2d_line(omega, x0, n0, xs) +d, selection, secondary_source = sfs.fd.wfs.line_2d( + omega, array.x, array.n, xs) compute_and_plot_soundfield('linear_ls_wfs_2d_line') # linear array, secondary point sources, virtual plane wave -sourcetype = sfs.mono.source.point -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) - -d = sfs.mono.drivingfunction.wfs_3d_plane(omega, x0, n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_3d( + omega, array.x, array.n, npw) compute_and_plot_soundfield('linear_ps_wfs_3d_plane') -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref=xnorm) +d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw, xref=xnorm) compute_and_plot_soundfield('linear_ps_wfs_25d_plane') -d = sfs.mono.drivingfunction.wfs_2d_plane(omega, x0, n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_2d( + omega, array.x, array.n, npw) compute_and_plot_soundfield('linear_ps_wfs_2d_plane') # non-uniform linear array, secondary point sources -x0, n0, a0 = sfs.array.linear_diff(N//3 * [dx] + N//3 * [dx/2] + N//3 * [dx], +array = sfs.array.linear_diff(N//3 * [dx] + N//3 * [dx/2] + N//3 * [dx], center=acenter, orientation=anormal) -d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) +d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs, xref=xnorm) compute_and_plot_soundfield('linear_nested_ps_wfs_25d_point') -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw, xref=xnorm) compute_and_plot_soundfield('linear_nested_ps_wfs_25d_plane') # random sampled linear array, secondary point sources -x0, n0, a0 = sfs.array.linear_random(N, dx/2, 1.5*dx, center=acenter, +array = sfs.array.linear_random(N, dx/2, 1.5*dx, center=acenter, orientation=anormal) -d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) +d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs, xref=xnorm) compute_and_plot_soundfield('linear_random_ps_wfs_25d_point') -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw, xref=xnorm) compute_and_plot_soundfield('linear_random_ps_wfs_25d_plane') # rectangular array, secondary point sources -x0, n0, a0 = sfs.array.rectangular((N, N//2), dx, center=acenter, orientation=anormal) -d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) +array = sfs.array.rectangular((N, N//2), dx, center=acenter, orientation=anormal) +d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs, xref=xnorm) compute_and_plot_soundfield('rectangular_ps_wfs_25d_point') -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw, xref=xnorm) compute_and_plot_soundfield('rectangular_ps_wfs_25d_plane') # circular array, secondary point sources N = 60 -x0, n0, a0 = sfs.array.circular(N, 1, center=acenter) -d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) +array = sfs.array.circular(N, 1, center=acenter) +d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs, xref=xnorm) compute_and_plot_soundfield('circular_ps_wfs_25d_point') -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref=xnorm) -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) +d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw, xref=xnorm) compute_and_plot_soundfield('circular_ps_wfs_25d_plane') # circular array, secondary line sources, NFC-HOA -x0, n0, a0 = sfs.array.circular(N, 1) +array = sfs.array.circular(N, 1) xnorm = [0, 0, 0] talpha = 0 # switches off tapering -sourcetype = sfs.mono.source.line -d = sfs.mono.drivingfunction.nfchoa_2d_plane(omega, x0, 1, npw) -a = sfs.mono.drivingfunction.source_selection_all(N) +d, selection, secondary_source = sfs.fd.nfchoa.plane_2d( + omega, array.x, 1, npw) compute_and_plot_soundfield('circular_ls_nfchoa_2d_plane') # circular array, secondary point sources, NFC-HOA -x0, n0, a0 = sfs.array.circular(N, 1) +array = sfs.array.circular(N, 1) xnorm = [0, 0, 0] talpha = 0 # switches off tapering -sourcetype = sfs.mono.source.point -d = sfs.mono.drivingfunction.nfchoa_25d_point(omega, x0, 1, xs) -a = sfs.mono.drivingfunction.source_selection_all(N) +d, selection, secondary_source = sfs.fd.nfchoa.point_25d( + omega, array.x, 1, xs) compute_and_plot_soundfield('circular_ps_nfchoa_25d_point') -d = sfs.mono.drivingfunction.nfchoa_25d_plane(omega, x0, 1, npw) -a = sfs.mono.drivingfunction.source_selection_all(N) +d, selection, secondary_source = sfs.fd.nfchoa.plane_25d( + omega, array.x, 1, npw) compute_and_plot_soundfield('circular_ps_nfchoa_25d_plane') diff --git a/doc/examples/index.rst b/doc/examples/index.rst deleted file mode 100644 index b2f707d..0000000 --- a/doc/examples/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -Examples -======== - -Various examples are located in the directory ``doc/examples/`` as Python -scripts, e.g. - -* sound_field_synthesis.py: - Illustrates the general usage of the toolbox -* horizontal_plane_arrays.py: - Computes the sound fields for various techniques, virtual sources and loudspeaker array configurations -* soundfigures.py: - Illustrates the synthesis of sound figures with Wave Field Synthesis - - -Or Jupyter notebooks, which are also available online as interactive examples: -:binder:`doc/examples`. - - -.. toctree:: - :maxdepth: 1 - - modal-room-acoustics diff --git a/doc/examples/ipython_kernel_config.py b/doc/examples/ipython_kernel_config.py new file mode 100644 index 0000000..4cbbcc1 --- /dev/null +++ b/doc/examples/ipython_kernel_config.py @@ -0,0 +1,6 @@ +# This is a configuration file that's used when opening the Jupyter notebooks +# in this directory. +# See https://nbviewer.jupyter.org/github/mgeier/python-audio/blob/master/plotting/matplotlib-inline-defaults.ipynb + +c.InlineBackend.figure_formats = {'svg'} +c.InlineBackend.rc = {'figure.dpi': 96} diff --git a/doc/examples/make_movie.py b/doc/examples/make_movie.py deleted file mode 100644 index 8f4b82a..0000000 --- a/doc/examples/make_movie.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Example how to generate an animation from a pre-computed sound field. - -p and grid should contain the pressure field and axes of the sound -field, respectively. - -""" - -import numpy as np -import matplotlib.pyplot as plt -import sfs - -# total number of frames -frames = 240 - -fig = plt.figure(figsize=(15, 15)) -for i in range(frames): - plt.cla() - ph = sfs.mono.synthesized.shiftphase(p, i / frames * 4 * np.pi) - sfs.plot.soundfield(2.5e-9 * ph, grid, colorbar=False, cmap=plt.cm.BrBG) - fname = '_tmp%03d.png' % i - print('Saving frame', fname) - plt.savefig(fname) - - -# mencoder command line to convert PNGs to movie -# mencoder 'mf://_tmp*.png' -mf type=png:fps=30 -ovc lavc -lavcopts vcodec=wmv2 -oac copy -o soundfigure.mpg diff --git a/doc/examples/mirror-image-source-model.ipynb b/doc/examples/mirror-image-source-model.ipynb new file mode 100644 index 0000000..c436dd2 --- /dev/null +++ b/doc/examples/mirror-image-source-model.ipynb @@ -0,0 +1,169 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Mirror Image Sources and the Sound Field in a Rectangular Room" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import sfs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "L = 2, 2.7, 3 # room dimensions\n", + "x0 = 1.2, 1.7, 1.5 # source position\n", + "max_order = 2 # maximum order of image sources\n", + "coeffs = .8, .8, .6, .6, .7, .7 # wall reflection coefficients" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2D Mirror Image Sources" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "xs, wall_count = sfs.util.image_sources_for_box(x0[0:2], L[0:2], max_order)\n", + "source_strength = np.prod(coeffs[0:4]**wall_count, axis=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib.patches import Rectangle" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots()\n", + "ax.scatter(*xs.T, source_strength * 20)\n", + "ax.add_patch(Rectangle((0, 0), L[0], L[1], fill=False))\n", + "ax.set_xlabel('x / m')\n", + "ax.set_ylabel('y / m')\n", + "ax.axis('equal');" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Monochromatic Sound Field" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "omega = 2 * np.pi * 1000 # angular frequency" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.02)\n", + "P = sfs.fd.source.point_image_sources(omega, x0, grid, L,\n", + " max_order=max_order, coeffs=coeffs)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sfs.plot2d.amplitude(P, grid, xnorm=[L[0]/2, L[1]/2, L[2]/2]);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Spatio-temporal Impulse Response" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fs = 44100 # sample rate\n", + "signal = [1, 0, 0], fs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.005)\n", + "p = sfs.td.source.point_image_sources(x0, signal, 0.004, grid, L, max_order,\n", + " coeffs=coeffs)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sfs.plot2d.level(p, grid)\n", + "sfs.plot2d.virtualsource(x0)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.7.2+" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/doc/examples/mirror_image_source_model.py b/doc/examples/mirror_image_source_model.py deleted file mode 100644 index 4c8148f..0000000 --- a/doc/examples/mirror_image_source_model.py +++ /dev/null @@ -1,51 +0,0 @@ -""" Computes the mirror image sources and the sound field in a rectangular - room -""" - -import numpy as np -import sfs -import matplotlib.pyplot as plt -from matplotlib.patches import Rectangle - - -L = 2, 2.7, 3 # room dimensions -x0 = 1.2, 1.7, 1.5 # source position -max_order = 2 # maximum order of image sources -coeffs = .8, .8, .6, .6, .7, .7 # wall reflection coefficients -omega = 2*np.pi*1000 # angular frequency of monocromatic sound field -fs = 44100 # sample rate for boadband response -signal = ([1, 0, 0], fs) # signal for broadband response - - -# get 2D mirror image sources and their strength -xs, wall_count = sfs.util.image_sources_for_box(x0[0:2], L[0:2], max_order) -source_strength = np.prod(coeffs[0:4]**wall_count, axis=1) -# plot mirror image sources -plt.figure() -plt.scatter(*xs.T, source_strength*20) -plt.gca().add_patch(Rectangle((0, 0), L[0], L[1], fill=False)) -plt.xlabel('x / m') -plt.ylabel('y / m') -plt.savefig('image_source_positions.png') - - -# compute monochromatic sound field -grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.02) -P = sfs.mono.source.point_image_sources(omega, x0, [1, 0, 0], grid, L, - max_order, coeffs=coeffs) -# plot monocromatic sound field -plt.figure() -sfs.plot.soundfield(P, grid, xnorm=[L[0]/2, L[1]/2, L[2]/2]) -sfs.plot.virtualsource_2d(x0) -plt.savefig('point_image_sources_mono.png') - - -# compute spatio-temporal impulse response -grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.005) -p = sfs.time.source.point_image_sources(x0, signal, 0.004, grid, L, max_order, - coeffs=coeffs) -# plot spatio-temporal impulse response -plt.figure() -sfs.plot.level(p, grid) -sfs.plot.virtualsource_2d(x0) -plt.savefig('point_image_sources_time_domain.png') diff --git a/doc/examples/modal-room-acoustics.ipynb b/doc/examples/modal-room-acoustics.ipynb index 1468b37..26164bd 100644 --- a/doc/examples/modal-room-acoustics.ipynb +++ b/doc/examples/modal-room-acoustics.ipynb @@ -36,7 +36,6 @@ "x0 = 1, 3, 1.80 # source position\n", "L = 6, 6, 3 # dimensions of room\n", "deltan = 0.01 # absorption factor of walls\n", - "n0 = 1, 0, 0 # normal vector of source (only for compatibility)\n", "N = 20 # maximum order of modes" ] }, @@ -88,7 +87,7 @@ "metadata": {}, "outputs": [], "source": [ - "p = sfs.mono.source.point_modal(omega, x0, n0, grid, L, N=N, deltan=deltan)" + "p = sfs.fd.source.point_modal(omega, x0, grid, L, N=N, deltan=deltan)" ] }, { @@ -115,7 +114,7 @@ "metadata": {}, "outputs": [], "source": [ - "sfs.plot.soundfield(p, grid);" + "sfs.plot2d.amplitude(p, grid);" ] }, { @@ -136,7 +135,7 @@ "\n", "receiver = 1, 1, 1.8\n", "\n", - "p = [sfs.mono.source.point_modal(om, x0, n0, receiver, L, N=N, deltan=deltan)\n", + "p = [sfs.fd.source.point_modal(om, x0, receiver, L, N=N, deltan=deltan)\n", " for om in omega]\n", " \n", "plt.plot(f, sfs.util.db(p))\n", diff --git a/doc/examples/plot_particle_density.py b/doc/examples/plot_particle_density.py index 1322fe0..b21cd20 100644 --- a/doc/examples/plot_particle_density.py +++ b/doc/examples/plot_particle_density.py @@ -19,11 +19,11 @@ def plot_particle_displacement(title): # compute displacement - X = grid + amplitude * sfs.util.displacement(v, omega) + X = grid + amplitude * sfs.fd.displacement(v, omega) # plot displacement plt.figure(figsize=(15, 15)) plt.cla() - sfs.plot.particles(X, facecolor='black', s=3, trim=[-3, 3, -3, 3]) + sfs.plot2d.particles(X, facecolor='black', s=3, trim=[-3, 3, -3, 3]) plt.axis('off') plt.title(title) plt.grid() @@ -31,16 +31,16 @@ def plot_particle_displacement(title): # point source -v = sfs.mono.source.point_velocity(omega, xs, npw, grid) +v = sfs.fd.source.point_velocity(omega, xs, grid) amplitude = 1.5e6 plot_particle_displacement('particle_displacement_point_source') # line source -v = sfs.mono.source.line_velocity(omega, xs, npw, grid) +v = sfs.fd.source.line_velocity(omega, xs, grid) amplitude = 1.3e6 plot_particle_displacement('particle_displacement_line_source') # plane wave -v = sfs.mono.source.plane_velocity(omega, xs, npw, grid) +v = sfs.fd.source.plane_velocity(omega, xs, npw, grid) amplitude = 1e5 plot_particle_displacement('particle_displacement_plane_wave') diff --git a/doc/examples/run_all.py b/doc/examples/run_all.py new file mode 100755 index 0000000..c446af7 --- /dev/null +++ b/doc/examples/run_all.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pathlib import Path +import subprocess +import sys + +if __name__ != '__main__': + raise ImportError(__name__ + ' is not meant be imported') + +self = Path(__file__) +cwd = self.parent + +for script in cwd.glob('*.py'): + if self == script: + # Don't call yourself! + continue + if script.name == 'ipython_kernel_config.py': + # This is a configuration file, not an example script + continue + print('Running', script, '...') + args = [sys.executable, str(script.relative_to(cwd))] + sys.argv[1:] + result = subprocess.run(args, cwd=str(cwd)) + if result.returncode: + print('Error running', script, file=sys.stderr) + sys.exit(result.returncode) diff --git a/doc/examples/sound-field-synthesis.ipynb b/doc/examples/sound-field-synthesis.ipynb new file mode 100644 index 0000000..89f298f --- /dev/null +++ b/doc/examples/sound-field-synthesis.ipynb @@ -0,0 +1,232 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sound Field Synthesis\n", + "\n", + "Illustrates the usage of the SFS toolbox for the simulation of different sound field synthesis methods." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt \n", + "import sfs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simulation parameters\n", + "number_of_secondary_sources = 56\n", + "frequency = 680 # in Hz\n", + "pw_angle = 30 # traveling direction of plane wave in degree\n", + "xs = [-2, -1, 0] # position of virtual point source in m\n", + "\n", + "grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02)\n", + "omega = 2 * np.pi * frequency # angular frequency\n", + "npw = sfs.util.direction_vector(np.radians(pw_angle)) # normal vector of plane wave" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define a helper function for synthesize and plot the sound field from the given driving signals." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def sound_field(d, selection, secondary_source, array, grid, tapering=True):\n", + " if tapering:\n", + " tapering_window = sfs.tapering.tukey(selection, alpha=0.3)\n", + " else:\n", + " tapering_window = sfs.tapering.none(selection)\n", + " p = sfs.fd.synthesize(d, tapering_window, array, secondary_source, grid=grid)\n", + " sfs.plot2d.amplitude(p, grid, xnorm=[0, 0, 0])\n", + " sfs.plot2d.loudspeakers(array.x, array.n, tapering_window)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Circular loudspeaker arrays\n", + "\n", + "In the following we show different sound field synthesis methods applied to a circular loudspeaker array." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "radius = 1.5 # in m\n", + "array = sfs.array.circular(number_of_secondary_sources, radius)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Wave Field Synthesis (WFS)\n", + "\n", + "#### Plane wave" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.wfs.plane_25d(omega, array.x, array.n, n=npw)\n", + "sound_field(d, selection, secondary_source, array, grid)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Point source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.wfs.point_25d(omega, array.x, array.n, xs)\n", + "sound_field(d, selection, secondary_source, array, grid)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Near-Field Compensated Higher Order Ambisonics (NFC-HOA)\n", + "\n", + "#### Plane wave" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.nfchoa.plane_25d(omega, array.x, radius, n=npw)\n", + "sound_field(d, selection, secondary_source, array, grid, tapering=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Point source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.nfchoa.point_25d(omega, array.x, radius, xs)\n", + "sound_field(d, selection, secondary_source, array, grid, tapering=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Linear loudspeaker array\n", + "\n", + "In the following we show different sound field synthesis methods applied to a linear loudspeaker array." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spacing = 0.07 # in m\n", + "array = sfs.array.linear(number_of_secondary_sources, spacing,\n", + " center=[0, -0.5, 0], orientation=[0, 1, 0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Wave Field Synthesis (WFS)\n", + "\n", + "#### Plane wave" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.wfs.plane_25d(omega, array.x, array.n, npw)\n", + "sound_field(d, selection, secondary_source, array, grid)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Point source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d, selection, secondary_source = sfs.fd.wfs.point_25d(omega, array.x, array.n, xs)\n", + "sound_field(d, selection, secondary_source, array, grid)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/doc/examples/sound_field_synthesis.py b/doc/examples/sound_field_synthesis.py deleted file mode 100644 index a664889..0000000 --- a/doc/examples/sound_field_synthesis.py +++ /dev/null @@ -1,87 +0,0 @@ -""" - Illustrates the usage of the SFS toolbox for the simulation of SFS. - - This script contains almost all possibilities than can be used - for the synthesis of sound fields generated by Wave Field Synthesis or - Higher-Order Ambisonics with various loudspeaker configurations. -""" - -import numpy as np -import matplotlib.pyplot as plt -import sfs - - -# simulation parameters -dx = 0.2 # secondary source distance -N = 16 # number of secondary sources -pw_angle = 30 # traveling direction of plane wave -xs = [2, 1, 0] # position of virtual source -xref = [0, 0, 0] # reference position for 2.5D -f = 680 # frequency -R = 1.5 # radius of spherical/circular array - -grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) - -# angular frequency -omega = 2 * np.pi * f -# normal vector of plane wave -npw = sfs.util.direction_vector(np.radians(pw_angle), np.radians(90)) - - -# === get secondary source positions === -#x0, n0, a0 = sfs.array.linear(N, dx, center=[-1, 0, 0]) -#x0, n0, a0 = sfs.array.linear_random(N, 0.2*dx, 5*dx) -#x0, n0, a0 = sfs.array.rectangular(N, dx, orientation=sfs.util.direction_vector(0*np.pi/4, np.pi/2)) -#x0, n0, a0 = sfs.array.circular(N, R) -x0, n0, a0 = sfs.array.load('../data/arrays/university_rostock.csv') - -#x0, n0, a0 = sfs.array.planar(N, dx, orientation=sfs.util.direction_vector(np.radians(0),np.radians(180))) -#x0, n0, a0 = sfs.array.cube(N, dx, orientation=sfs.util.direction_vector(0, np.pi/2)) - -#x0, n0, a0 = sfs.array.sphere_load('/Users/spors/Documents/src/SFS/data/spherical_grids/equally_spaced_points/006561points.mat', 1, center=[.5,0,0]) - - -# === compute driving function === -#d = sfs.mono.drivingfunction.delay_3d_plane(omega, x0, n0, npw) - -#d = sfs.mono.drivingfunction.wfs_2d_line(omega, x0, n0, xs) - -#d = sfs.mono.drivingfunction.wfs_2d_plane(omega, x0, n0, npw) -d = sfs.mono.drivingfunction.wfs_25d_plane(omega, x0, n0, npw, xref) -#d = sfs.mono.drivingfunction.wfs_3d_plane(omega, x0, n0, npw) - -#d = sfs.mono.drivingfunction.wfs_2d_point(omega, x0, n0, xs) -#d = sfs.mono.drivingfunction.wfs_25d_point(omega, x0, n0, xs) -#d = sfs.mono.drivingfunction.wfs_3d_point(omega, x0, n0, xs) - -#d = sfs.mono.drivingfunction.nfchoa_2d_plane(omega, x0, R, npw) - -#d = sfs.mono.drivingfunction.nfchoa_25d_point(omega, x0, R, xs) -#d = sfs.mono.drivingfunction.nfchoa_25d_plane(omega, x0, R, npw) - -# === determine active secondary sources === -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) -#a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) -#a = sfs.mono.drivingfunction.source_selection_all(len(x0)) - - -# === compute tapering window === -#twin = sfs.tapering.none(a) -#twin = sfs.tapering.kaiser(a, 8.6) -twin = sfs.tapering.tukey(a,.3) - -# === compute synthesized sound field === -p = sfs.mono.synthesized.generic(omega, x0, n0, d * twin * a0 , grid, - source=sfs.mono.source.point) - - -# === plot synthesized sound field === -plt.figure(figsize=(10, 10)) -sfs.plot.soundfield(p, grid, [0, 0, 0]) -sfs.plot.loudspeaker_2d(x0, n0, twin) -plt.grid() -plt.savefig('soundfield.png') - - -#sfs.plot.loudspeaker_3d(x0, n0, twin) -#plt.savefig('loudspeakers.png') diff --git a/doc/examples/soundfigures.py b/doc/examples/soundfigures.py index e337789..6bb763b 100644 --- a/doc/examples/soundfigures.py +++ b/doc/examples/soundfigures.py @@ -24,27 +24,27 @@ grid = sfs.util.xyz_grid([-3, 3], [-3, 3], 0, spacing=0.02) # get secondary source positions -x0, n0, a0 = sfs.array.cube(N, dx) +array = sfs.array.cube(N, dx) # driving function for sound figure figure = np.array(Image.open('figures/tree.png')) # read image from file figure = np.rot90(figure) # turn 0deg to the top -d = sfs.mono.soundfigure.wfs_3d_pw(omega, x0, n0, figure, npw=npw) +d, selection, secondary_source = sfs.fd.wfs.soundfigure_3d( + omega, array.x, array.n, figure, npw=npw) # compute synthesized sound field -p = sfs.mono.synthesized.generic(omega, x0, n0, d * a0, grid, - source=sfs.mono.source.point) +p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) # plot and save synthesized sound field plt.figure(figsize=(10, 10)) -sfs.plot.soundfield(p, grid, xnorm=[0, -2.2, 0], cmap='BrBG', colorbar=False, - vmin=-1, vmax=1) +sfs.plot2d.amplitude(p, grid, xnorm=[0, -2.2, 0], cmap='BrBG', colorbar=False, + vmin=-1, vmax=1) plt.title('Synthesized Sound Field') plt.savefig('soundfigure.png') # plot and save level of synthesized sound field plt.figure(figsize=(12.5, 12.5)) -im = sfs.plot.level(p, grid, xnorm=[0, -2.2, 0], vmin=-50, vmax=0, - colorbar_kwargs=dict(label='dB')) +im = sfs.plot2d.level(p, grid, xnorm=[0, -2.2, 0], vmin=-50, vmax=0, + colorbar_kwargs=dict(label='dB')) plt.title('Level of Synthesized Sound Field') plt.savefig('soundfigure_level.png') diff --git a/doc/examples/time_domain.py b/doc/examples/time_domain.py index 06c3374..9ad14a4 100644 --- a/doc/examples/time_domain.py +++ b/doc/examples/time_domain.py @@ -14,30 +14,32 @@ my_cmap = 'YlOrRd' N = 56 # number of secondary sources R = 1.5 # radius of spherical/circular array -x0, n0, a0 = sfs.array.circular(N, R) # get secondary source positions +array = sfs.array.circular(N, R) # get secondary source positions fs = 44100 # sampling rate # unit impulse signal = [1], fs # POINT SOURCE -xs = [2, 2, 0] # position of virtual source +xs = 2, 2, 0 # position of virtual source t = 0.008 # compute driving signals -d_delay, d_weight = sfs.time.drivingfunction.wfs_25d_point(x0, n0, xs) -d = sfs.time.drivingfunction.driving_signals(d_delay, d_weight, signal) +d_delay, d_weight, selection, secondary_source = \ + sfs.td.wfs.point_25d(array.x, array.n, xs) +d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) # test soundfield -a = sfs.mono.drivingfunction.source_selection_point(n0, x0, xs) -twin = sfs.tapering.tukey(a, .3) -p = sfs.time.soundfield.p_array(x0, d, twin * a0, t, grid) +twin = sfs.tapering.tukey(selection, alpha=0.3) + +p = sfs.td.synthesize(d, twin, array, + secondary_source, observation_time=t, grid=grid) p = p * 100 # scale absolute amplitude plt.figure(figsize=(10, 10)) -sfs.plot.level(p, grid, cmap=my_cmap) -sfs.plot.loudspeaker_2d(x0, n0, twin) +sfs.plot2d.level(p, grid, cmap=my_cmap) +sfs.plot2d.loudspeakers(array.x, array.n, twin) plt.grid() -sfs.plot.virtualsource_2d(xs) +sfs.plot2d.virtualsource(xs) plt.title('impulse_ps_wfs_25d') plt.savefig('impulse_ps_wfs_25d.png') @@ -47,19 +49,20 @@ t = -0.001 # compute driving signals -d_delay, d_weight = sfs.time.drivingfunction.wfs_25d_plane(x0, n0, npw) -d = sfs.time.drivingfunction.driving_signals(d_delay, d_weight, signal) +d_delay, d_weight, selection, secondary_source = \ + sfs.td.wfs.plane_25d(array.x, array.n, npw) +d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) # test soundfield -a = sfs.mono.drivingfunction.source_selection_plane(n0, npw) -twin = sfs.tapering.tukey(a, .3) -p = sfs.time.soundfield.p_array(x0, d, twin * a0, t, grid) +twin = sfs.tapering.tukey(selection, alpha=0.3) +p = sfs.td.synthesize(d, twin, array, + secondary_source, observation_time=t, grid=grid) plt.figure(figsize=(10, 10)) -sfs.plot.level(p, grid, cmap=my_cmap) -sfs.plot.loudspeaker_2d(x0, n0, twin) +sfs.plot2d.level(p, grid, cmap=my_cmap) +sfs.plot2d.loudspeakers(array.x, array.n, twin) plt.grid() -sfs.plot.virtualsource_2d([0, 0], npw, type='plane') +sfs.plot2d.virtualsource([0, 0], npw, type='plane') plt.title('impulse_pw_wfs_25d') plt.savefig('impulse_pw_wfs_25d.png') @@ -68,21 +71,20 @@ xref = np.r_[0, 0, 0] nfs = sfs.util.normalize_vector(xref - xs) # main n of fsource t = 0.003 # compute driving signals -d_delay, d_weight = sfs.time.drivingfunction.wfs_25d_focused(x0, n0, xs) -d = sfs.time.drivingfunction.driving_signals(d_delay, d_weight, signal) +d_delay, d_weight, selection, secondary_source = \ + sfs.td.wfs.focused_25d(array.x, array.n, xs, nfs) +d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) # test soundfield -a = sfs.mono.drivingfunction.source_selection_focused(nfs, x0, xs) -twin = sfs.tapering.tukey(a, .3) -p = sfs.time.soundfield.p_array(x0, d, twin * a0, t, grid) +twin = sfs.tapering.tukey(selection, alpha=0.3) +p = sfs.td.synthesize(d, twin, array, + secondary_source, observation_time=t, grid=grid) p = p * 100 # scale absolute amplitude plt.figure(figsize=(10, 10)) -sfs.plot.level(p, grid, cmap=my_cmap) -sfs.plot.loudspeaker_2d(x0, n0, twin) +sfs.plot2d.level(p, grid, cmap=my_cmap) +sfs.plot2d.loudspeakers(array.x, array.n, twin) plt.grid() -sfs.plot.virtualsource_2d(xs) +sfs.plot2d.virtualsource(xs) plt.title('impulse_fs_wfs_25d') plt.savefig('impulse_fs_wfs_25d.png') - -# plt.show() diff --git a/doc/examples/time_domain_nfchoa.py b/doc/examples/time_domain_nfchoa.py new file mode 100644 index 0000000..929c1ef --- /dev/null +++ b/doc/examples/time_domain_nfchoa.py @@ -0,0 +1,50 @@ +"""Create some examples of time-domain NFC-HOA.""" + +import numpy as np +import matplotlib.pyplot as plt +import sfs +from scipy.signal import unit_impulse + +# Parameters +fs = 44100 # sampling frequency +grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.005) +N = 60 # number of secondary sources +R = 1.5 # radius of circular array +array = sfs.array.circular(N, R) + +# Excitation signal +signal = unit_impulse(512), fs, 0 + +# Plane wave +max_order = None +npw = [0, -1, 0] # propagating direction +t = 0 # observation time +delay, weight, sos, phaseshift, selection, secondary_source = \ + sfs.td.nfchoa.plane_25d(array.x, R, npw, fs, max_order) +d = sfs.td.nfchoa.driving_signals_25d( + delay, weight, sos, phaseshift, signal) +p = sfs.td.synthesize(d, selection, array, secondary_source, + observation_time=t, grid=grid) + +plt.figure() +sfs.plot2d.level(p, grid) +sfs.plot2d.loudspeakers(array.x, array.n) +sfs.plot2d.virtualsource([0, 0], ns=npw, type='plane') +plt.savefig('impulse_pw_nfchoa_25d.png') + +# Point source +max_order = 100 +xs = [1.5, 1.5, 0] # position +t = np.linalg.norm(xs) / sfs.default.c # observation time +delay, weight, sos, phaseshift, selection, secondary_source = \ + sfs.td.nfchoa.point_25d(array.x, R, xs, fs, max_order) +d = sfs.td.nfchoa.driving_signals_25d( + delay, weight, sos, phaseshift, signal) +p = sfs.td.synthesize(d, selection, array, secondary_source, + observation_time=t, grid=grid) + +plt.figure() +sfs.plot2d.level(p, grid) +sfs.plot2d.loudspeakers(array.x, array.n) +sfs.plot2d.virtualsource(xs, type='point') +plt.savefig('impulse_ps_nfchoa_25d.png') diff --git a/doc/frequency-domain.rst b/doc/frequency-domain.rst deleted file mode 100644 index b741be7..0000000 --- a/doc/frequency-domain.rst +++ /dev/null @@ -1,19 +0,0 @@ -Frequency Domain -================ - -.. automodule:: sfs.mono - -Monochromatic Sources ---------------------- - -.. automodule:: sfs.mono.source - -Monochromatic Driving Functions -------------------------------- - -.. automodule:: sfs.mono.drivingfunction - -Monochromatic Sound Fields --------------------------- - -.. automodule:: sfs.mono.synthesized diff --git a/doc/index.rst b/doc/index.rst index fd5cea3..e56a16f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -5,12 +5,8 @@ .. toctree:: installation - examples/index - arrays - frequency-domain - time-domain - plotting - utilities + examples + api references contributing version-history diff --git a/doc/installation.rst b/doc/installation.rst index f77f2de..f751f90 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -5,17 +5,21 @@ Requirements ------------ Obviously, you'll need Python_. -We normally use Python 3.x, but it *should* also work with Python 2.x. +More specifically, you'll need Python 3. NumPy_ and SciPy_ are needed for the calculations. -If you also want to plot the resulting sound fields, you'll need matplotlib_. +If you want to use the provided functions for plotting sound fields, you'll need +Matplotlib_. +However, since all results are provided as plain NumPy_ arrays, you should also +be able to use any plotting library of your choice to visualize the sound +fields. -Instead of installing all of them separately, you should probably get a Python -distribution that already includes everything, e.g. Anaconda_. +Instead of installing all of the requirements separately, you should probably +get a Python distribution that already includes everything, e.g. Anaconda_. .. _Python: https://www.python.org/ .. _NumPy: http://www.numpy.org/ .. _SciPy: https://www.scipy.org/scipylib/ -.. _matplotlib: https://matplotlib.org/ +.. _Matplotlib: https://matplotlib.org/ .. _Anaconda: https://docs.anaconda.com/anaconda/ Installation @@ -34,4 +38,7 @@ To un-install, use:: python3 -m pip uninstall sfs +If you want to install the latest development version of the SFS Toolbox, have a +look at :doc:`contributing`. + .. _pip: https://pip.pypa.io/en/latest/installing/ diff --git a/doc/math-definitions.rst b/doc/math-definitions.rst index ee88d28..cec4c17 100644 --- a/doc/math-definitions.rst +++ b/doc/math-definitions.rst @@ -1,10 +1,22 @@ +.. raw:: latex + + \marginpar{% Avoid creating empty vertical space for the math definitions + .. rst-class:: hidden .. math:: - \newcommand{\dirac}[1]{\operatorname{\delta}\left(#1\right)} - \newcommand{\e}[1]{\operatorname{e}^{#1}} - \newcommand{\Hankel}[3]{\mathop{{}H_{#2}^{(#1)}}\!\left(#3\right)} - \newcommand{\hankel}[3]{\mathop{{}h_{#2}^{(#1)}}\!\left(#3\right)} - \newcommand{\i}{\mathrm{i}} - \newcommand{\scalarprod}[2]{\left\langle#1,#2\right\rangle} - \renewcommand{\vec}[1]{\mathbf{#1}} - \newcommand{\wc}{\frac{\omega}{c}} + + \gdef\dirac#1{\mathop{{}\delta}\left(#1\right)} + \gdef\e#1{\operatorname{e}^{#1}} + \gdef\Hankel#1#2#3{\mathop{{}H_{#2}^{(#1)}}\!\left(#3\right)} + \gdef\hankel#1#2#3{\mathop{{}h_{#2}^{(#1)}}\!\left(#3\right)} + \gdef\i{\mathrm{i}} + \gdef\scalarprod#1#2{\left\langle#1,#2\right\rangle} + \gdef\vec#1{\mathbf{#1}} + \gdef\wc{\frac{\omega}{c}} + \gdef\w{\omega} + \gdef\x{\vec{x}} + \gdef\n{\vec{n}} + +.. raw:: latex + + } diff --git a/doc/plotting.rst b/doc/plotting.rst deleted file mode 100644 index 75ebcf5..0000000 --- a/doc/plotting.rst +++ /dev/null @@ -1,4 +0,0 @@ -Plotting -======== - -.. automodule:: sfs.plot diff --git a/doc/readthedocs-environment.yml b/doc/readthedocs-environment.yml index 7d34622..d5ff057 100644 --- a/doc/readthedocs-environment.yml +++ b/doc/readthedocs-environment.yml @@ -1,7 +1,7 @@ channels: - conda-forge dependencies: - - python==3.5 + - python>=3 - sphinx>=1.3.6 - sphinx_rtd_theme - sphinxcontrib-bibtex diff --git a/doc/references.bib b/doc/references.bib index d1c2c02..8b149ba 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -54,7 +54,7 @@ @phdthesis{Wierstorf2014 } @article{Allen1979, author = {Allen, J. B. and Berkley, D. A.}, - title = {{Image method for efficiently simulating small‐room acoustics}}, + title = {{Image method for efficiently simulating small-room acoustics}}, journal = {Journal of the Acoustical Society of America}, volume = {65}, pages = {943--950}, @@ -70,3 +70,29 @@ @article{Borish1984 year = {1984}, doi = {10.1121/1.390983} } +@article{Firtha2017, + author = {Gergely Firtha AND P{\'e}ter Fiala AND Frank Schultz AND + Sascha Spors}, + title = {{Improved Referencing Schemes for 2.5D Wave Field Synthesis + Driving Functions}}, + journal = {IEEE/ACM Trans. Audio Speech Language Process.}, + volume = {25}, + number = {5}, + pages = {1117-1127}, + year = {2017}, + doi = {10.1109/TASLP.2017.2689245} +} +@phdthesis{Start1997, + author = {Evert W. Start}, + title = {{Direct Sound Enhancement by Wave Field Synthesis}}, + school = {Delft University of Technology}, + year = {1997} +} +@phdthesis{Schultz2016, + author = {Frank Schultz}, + title = {{Sound Field Synthesis for Line Source Array Applications in + Large-Scale Sound Reinforcement}}, + school = {University of Rostock}, + year = {2016}, + doi = {10.18453/rosdok_id00001765} +} diff --git a/doc/references.rst b/doc/references.rst index 4b3db95..8905446 100644 --- a/doc/references.rst +++ b/doc/references.rst @@ -1,6 +1,5 @@ References ========== -.. bibliography:: references.bib +.. bibliography:: :style: alpha - :all: diff --git a/doc/requirements.txt b/doc/requirements.txt index e2e3ce9..4f038ca 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -2,7 +2,7 @@ Sphinx>=1.3.6 Sphinx-RTD-Theme nbsphinx ipykernel -sphinxcontrib-bibtex +sphinxcontrib-bibtex>=2.1.4 NumPy SciPy diff --git a/doc/time-domain.rst b/doc/time-domain.rst deleted file mode 100644 index cd120a8..0000000 --- a/doc/time-domain.rst +++ /dev/null @@ -1,16 +0,0 @@ -Time Domain -=========== - -Time Domain Sources -------------------- - -.. automodule:: sfs.time.source - -Time Domain Driving Functions ------------------------------ - -.. automodule:: sfs.time.drivingfunction - -Time Domain Sound Fields ------------------------- -.. automodule:: sfs.time.soundfield diff --git a/doc/utilities.rst b/doc/utilities.rst deleted file mode 100644 index 861fdfd..0000000 --- a/doc/utilities.rst +++ /dev/null @@ -1,4 +0,0 @@ -Utilities -========= - -.. automodule:: sfs.util diff --git a/doc/version-history.rst b/doc/version-history.rst index 291074a..8f23a1f 100644 --- a/doc/version-history.rst +++ b/doc/version-history.rst @@ -1 +1,3 @@ +.. default-role:: py:obj + .. include:: ../NEWS.rst diff --git a/examples b/examples new file mode 120000 index 0000000..c75d4df --- /dev/null +++ b/examples @@ -0,0 +1 @@ +doc/examples/ \ No newline at end of file diff --git a/readthedocs.yml b/readthedocs.yml index 03d0604..65bb0f4 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,4 +1,4 @@ conda: file: doc/readthedocs-environment.yml python: - setup_py_install: true + pip_install: true diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0c9e0fc --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +license_file = LICENSE diff --git a/setup.py b/setup.py index 79cd4a5..9a7d870 100644 --- a/setup.py +++ b/setup.py @@ -24,11 +24,16 @@ keywords="audio SFS WFS Ambisonics".split(), url="http://github.com/sfstoolbox/", platforms='any', + python_requires='>=3.6', classifiers=[ "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3 :: Only", "Topic :: Scientific/Engineering", ], zip_safe=True, diff --git a/sfs/__init__.py b/sfs/__init__.py index da5048e..6e3d57c 100644 --- a/sfs/__init__.py +++ b/sfs/__init__.py @@ -1,18 +1,72 @@ """Sound Field Synthesis Toolbox. -http://sfs.rtfd.org/ +https://sfs-python.readthedocs.io/ + +.. rubric:: Submodules + +.. autosummary:: + :toctree: + + fd + td + array + tapering + plot2d + plot3d + util """ -__version__ = "0.3.1" +__version__ = "0.6.2" + + +class default: + """Get/set defaults for the *sfs* module. + + For example, when you want to change the default speed of sound:: + + import sfs + sfs.default.c = 330 + + """ + + c = 343 + """Speed of sound.""" + + rho0 = 1.2250 + """Static density of air.""" + + selection_tolerance = 1e-6 + """Tolerance used for secondary source selection.""" + + def __setattr__(self, name, value): + """Only allow setting existing attributes.""" + if name in dir(self) and name != 'reset': + super().__setattr__(name, value) + else: + raise AttributeError( + '"default" object has no attribute ' + repr(name)) + + def reset(self): + """Reset all attributes to their "factory default".""" + vars(self).clear() + + +import sys as _sys +if not getattr(_sys.modules.get('sphinx'), 'SFS_DOCS_ARE_BEING_BUILT', False): + # This object shadows the 'default' class, except when the docs are built: + default = default() from . import tapering from . import array from . import util -from . import defs try: - from . import plot + from . import plot2d +except ImportError: + pass +try: + from . import plot3d except ImportError: pass -from . import mono -from . import time +from . import fd +from . import td diff --git a/sfs/array.py b/sfs/array.py index 9a787ec..1727475 100644 --- a/sfs/array.py +++ b/sfs/array.py @@ -8,17 +8,16 @@ plt.rcParams['figure.figsize'] = 8, 4.5 # inch plt.rcParams['axes.grid'] = True -.. autoclass:: ArrayData - :members: take - """ -from __future__ import division # for Python 2.x -from collections import namedtuple -import numpy as np -from . import util +from collections import namedtuple as _namedtuple + +import numpy as _np + +from . import util as _util -class ArrayData(namedtuple('ArrayData', 'x n a')): +class SecondarySourceDistribution(_namedtuple('SecondarySourceDistribution', + 'x n a')): """Named tuple returned by array functions. See `collections.namedtuple`. @@ -37,17 +36,58 @@ class ArrayData(namedtuple('ArrayData', 'x n a')): __slots__ = () def __repr__(self): - return 'ArrayData(\n' + ',\n'.join( - ' {0}={1}'.format(name, repr(data).replace('\n', '\n ')) + return 'SecondarySourceDistribution(\n' + ',\n'.join( + ' {}={}'.format(name, repr(data).replace('\n', '\n ')) for name, data in zip('xna', self)) + ')' def take(self, indices): """Return a sub-array given by *indices*.""" - return ArrayData(self.x[indices], self.n[indices], self.a[indices]) + return SecondarySourceDistribution( + self.x[indices], self.n[indices], self.a[indices]) -def linear(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): - """Linear secondary source distribution. +def as_secondary_source_distribution(arg, **kwargs): + r"""Create a `SecondarySourceDistribution`. + + Parameters + ---------- + arg : sequence of between 1 and 3 array_like objects + All elements are converted to NumPy arrays. + If only 1 element is given, all normal vectors are set to *NaN*. + If only 1 or 2 elements are given, all weights are set to ``1.0``. + **kwargs + All keyword arguments are forwarded to :func:`numpy.asarray`. + + Returns + ------- + `SecondarySourceDistribution` + A named tuple consisting of three `numpy.ndarray`\s containing + positions, normal vectors and weights. + + """ + if len(arg) == 3: + x, n, a = arg + elif len(arg) == 2: + x, n = arg + a = 1.0 + elif len(arg) == 1: + x, = arg + n = _np.nan, _np.nan, _np.nan + a = 1.0 + else: + raise TypeError('Between 1 and 3 elements are required') + x = _util.asarray_of_rows(x, **kwargs) + n = _util.asarray_of_rows(n, **kwargs) + if len(n) == 1: + n = _np.tile(n, (len(x), 1)) + a = _util.asarray_1d(a, **kwargs) + if len(a) == 1: + a = _np.tile(a, len(x)) + return SecondarySourceDistribution(x, n, a) + + +def linear(N, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return linear, equidistantly sampled secondary source distribution. Parameters ---------- @@ -63,7 +103,7 @@ def linear(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -72,15 +112,17 @@ def linear(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): :context: close-figs x0, n0, a0 = sfs.array.linear(16, 0.2, orientation=[0, -1, 0]) - sfs.plot.loudspeaker_2d(x0, n0, a0) + sfs.plot2d.loudspeakers(x0, n0, a0) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ - return _linear_helper(np.arange(N) * spacing, center, orientation) + return _linear_helper(_np.arange(N) * spacing, center, orientation) -def linear_diff(distances, center=[0, 0, 0], orientation=[1, 0, 0]): - """Linear secondary source distribution from a list of distances. +def linear_diff(distances, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return linear secondary source distribution from a list of distances. Parameters ---------- @@ -91,7 +133,7 @@ def linear_diff(distances, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -101,18 +143,20 @@ def linear_diff(distances, center=[0, 0, 0], orientation=[1, 0, 0]): x0, n0, a0 = sfs.array.linear_diff(4 * [0.3] + 6 * [0.15] + 4 * [0.3], orientation=[0, -1, 0]) - sfs.plot.loudspeaker_2d(x0, n0, a0) + sfs.plot2d.loudspeakers(x0, n0, a0) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ - distances = util.asarray_1d(distances) - ycoordinates = np.concatenate(([0], np.cumsum(distances))) + distances = _util.asarray_1d(distances) + ycoordinates = _np.concatenate(([0], _np.cumsum(distances))) return _linear_helper(ycoordinates, center, orientation) -def linear_random(N, min_spacing, max_spacing, center=[0, 0, 0], +def linear_random(N, min_spacing, max_spacing, *, center=[0, 0, 0], orientation=[1, 0, 0], seed=None): - """Randomly sampled linear array. + """Return randomly sampled linear array. Parameters ---------- @@ -128,7 +172,7 @@ def linear_random(N, min_spacing, max_spacing, center=[0, 0, 0], Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -136,18 +180,23 @@ def linear_random(N, min_spacing, max_spacing, center=[0, 0, 0], .. plot:: :context: close-figs - x0, n0, a0 = sfs.array.linear_random(12, 0.15, 0.4, orientation=[0, -1, 0]) - sfs.plot.loudspeaker_2d(x0, n0, a0) + x0, n0, a0 = sfs.array.linear_random( + N=12, + min_spacing=0.15, max_spacing=0.4, + orientation=[0, -1, 0]) + sfs.plot2d.loudspeakers(x0, n0, a0) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ - r = np.random.RandomState(seed) + r = _np.random.RandomState(seed) distances = r.uniform(min_spacing, max_spacing, size=N-1) - return linear_diff(distances, center, orientation) + return linear_diff(distances, center=center, orientation=orientation) -def circular(N, R, center=[0, 0, 0]): - """Circular secondary source distribution parallel to the xy-plane. +def circular(N, R, *, center=[0, 0, 0]): + """Return circular secondary source distribution parallel to the xy-plane. Parameters ---------- @@ -160,7 +209,7 @@ def circular(N, R, center=[0, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -169,25 +218,27 @@ def circular(N, R, center=[0, 0, 0]): :context: close-figs x0, n0, a0 = sfs.array.circular(16, 1) - sfs.plot.loudspeaker_2d(x0, n0, a0, size=0.2, show_numbers=True) + sfs.plot2d.loudspeakers(x0, n0, a0, size=0.2, show_numbers=True) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ - center = util.asarray_1d(center) - alpha = np.linspace(0, 2 * np.pi, N, endpoint=False) - positions = np.zeros((N, len(center))) - positions[:, 0] = R * np.cos(alpha) - positions[:, 1] = R * np.sin(alpha) + center = _util.asarray_1d(center) + alpha = _np.linspace(0, 2 * _np.pi, N, endpoint=False) + positions = _np.zeros((N, len(center))) + positions[:, 0] = R * _np.cos(alpha) + positions[:, 1] = R * _np.sin(alpha) positions += center - normals = np.zeros_like(positions) - normals[:, 0] = np.cos(alpha + np.pi) - normals[:, 1] = np.sin(alpha + np.pi) - weights = np.ones(N) * 2 * np.pi * R / N - return ArrayData(positions, normals, weights) + normals = _np.zeros_like(positions) + normals[:, 0] = _np.cos(alpha + _np.pi) + normals[:, 1] = _np.sin(alpha + _np.pi) + weights = _np.ones(N) * 2 * _np.pi * R / N + return SecondarySourceDistribution(positions, normals, weights) -def rectangular(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): - """Rectangular secondary source distribution. +def rectangular(N, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return rectangular secondary source distribution. Parameters ---------- @@ -204,7 +255,7 @@ def rectangular(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -213,27 +264,33 @@ def rectangular(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): :context: close-figs x0, n0, a0 = sfs.array.rectangular((4, 8), 0.2) - sfs.plot.loudspeaker_2d(x0, n0, a0, show_numbers=True) + sfs.plot2d.loudspeakers(x0, n0, a0, show_numbers=True) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ - N1, N2 = (N, N) if np.isscalar(N) else N - offset1 = spacing * (N2 - 1) / 2 + spacing / np.sqrt(2) - offset2 = spacing * (N1 - 1) / 2 + spacing / np.sqrt(2) + N1, N2 = (N, N) if _np.isscalar(N) else N + offset1 = spacing * (N2 - 1) / 2 + spacing / _np.sqrt(2) + offset2 = spacing * (N1 - 1) / 2 + spacing / _np.sqrt(2) positions, normals, weights = concatenate( - linear(N1, spacing, [-offset1, 0, 0], [1, 0, 0]), # left - linear(N2, spacing, [0, offset2, 0], [0, -1, 0]), # upper - linear(N1, spacing, [offset1, 0, 0], [-1, 0, 0]), # right - linear(N2, spacing, [0, -offset2, 0], [0, 1, 0]), # lower + # left + linear(N1, spacing, center=[-offset1, 0, 0], orientation=[1, 0, 0]), + # upper + linear(N2, spacing, center=[0, offset2, 0], orientation=[0, -1, 0]), + # right + linear(N1, spacing, center=[offset1, 0, 0], orientation=[-1, 0, 0]), + # lower + linear(N2, spacing, center=[0, -offset2, 0], orientation=[0, 1, 0]), ) positions, normals = _rotate_array(positions, normals, [1, 0, 0], orientation) positions += center - return ArrayData(positions, normals, weights) + return SecondarySourceDistribution(positions, normals, weights) -def rounded_edge(Nxy, Nr, dx, center=[0, 0, 0], orientation=[1, 0, 0]): - """Array along the xy-axis with rounded edge at the origin. +def rounded_edge(Nxy, Nr, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return SSD along the xy-axis with rounded edge at the origin. Parameters ---------- @@ -242,6 +299,8 @@ def rounded_edge(Nxy, Nr, dx, center=[0, 0, 0], orientation=[1, 0, 0]): Nr : int Number of secondary sources in rounded edge. Radius of edge is adjusted to equdistant sampling along entire array. + spacing : float + Distance (in metres) between secondary sources. center : (3,) array_like, optional Position of edge. orientation : (3,) array_like, optional @@ -249,7 +308,7 @@ def rounded_edge(Nxy, Nr, dx, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -258,59 +317,65 @@ def rounded_edge(Nxy, Nr, dx, center=[0, 0, 0], orientation=[1, 0, 0]): :context: close-figs x0, n0, a0 = sfs.array.rounded_edge(8, 5, 0.2) - sfs.plot.loudspeaker_2d(x0, n0, a0) + sfs.plot2d.loudspeakers(x0, n0, a0) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ # radius of rounded edge Nr += 1 - R = 2/np.pi * Nr * dx + R = 2/_np.pi * Nr * spacing # array along y-axis - x00, n00, a00 = linear(Nxy, dx, center=[0, Nxy//2*dx+dx/2+R, 0]) - x00 = np.flipud(x00) + x00, n00, a00 = linear(Nxy, spacing, + center=[0, Nxy//2*spacing+spacing/2+R, 0]) + x00 = _np.flipud(x00) positions = x00 directions = n00 weights = a00 # round part - x00 = np.zeros((Nr, 3)) - n00 = np.zeros((Nr, 3)) - a00 = np.zeros(Nr) + x00 = _np.zeros((Nr, 3)) + n00 = _np.zeros((Nr, 3)) + a00 = _np.zeros(Nr) for n in range(0, Nr): - alpha = np.pi/2 * n/Nr - x00[n, 0] = R * (1-np.cos(alpha)) - x00[n, 1] = R * (1-np.sin(alpha)) - n00[n, 0] = np.cos(alpha) - n00[n, 1] = np.sin(alpha) - a00[n] = dx - positions = np.concatenate((positions, x00)) - directions = np.concatenate((directions, n00)) - weights = np.concatenate((weights, a00)) + alpha = _np.pi/2 * n/Nr + x00[n, 0] = R * (1 - _np.cos(alpha)) + x00[n, 1] = R * (1 - _np.sin(alpha)) + n00[n, 0] = _np.cos(alpha) + n00[n, 1] = _np.sin(alpha) + a00[n] = spacing + positions = _np.concatenate((positions, x00)) + directions = _np.concatenate((directions, n00)) + weights = _np.concatenate((weights, a00)) # array along x-axis - x00, n00, a00 = linear(Nxy, dx, center=[Nxy//2*dx-dx/2+R, 0, 0], + x00, n00, a00 = linear(Nxy, spacing, + center=[Nxy//2*spacing-spacing/2+R, 0, 0], orientation=[0, 1, 0]) - x00 = np.flipud(x00) - positions = np.concatenate((positions, x00)) - directions = np.concatenate((directions, n00)) - weights = np.concatenate((weights, a00)) + x00 = _np.flipud(x00) + positions = _np.concatenate((positions, x00)) + directions = _np.concatenate((directions, n00)) + weights = _np.concatenate((weights, a00)) # rotate array positions, directions = _rotate_array(positions, directions, [1, 0, 0], orientation) # shift array to desired position positions += center - return ArrayData(positions, directions, weights) + return SecondarySourceDistribution(positions, directions, weights) -def edge(Nxy, dx, center=[0, 0, 0], orientation=[1, 0, 0]): - """Array along the xy-axis with edge at the origin. +def edge(Nxy, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return SSD along the xy-axis with sharp edge at the origin. Parameters ---------- Nxy : int Number of secondary sources along x- and y-axis. + spacing : float + Distance (in metres) between secondary sources. center : (3,) array_like, optional Position of edge. orientation : (3,) array_like, optional @@ -318,7 +383,7 @@ def edge(Nxy, dx, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. Examples @@ -327,35 +392,39 @@ def edge(Nxy, dx, center=[0, 0, 0], orientation=[1, 0, 0]): :context: close-figs x0, n0, a0 = sfs.array.edge(8, 0.2) - sfs.plot.loudspeaker_2d(x0, n0, a0) + sfs.plot2d.loudspeakers(x0, n0, a0) plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') """ # array along y-axis - x00, n00, a00 = linear(Nxy, dx, center=[0, Nxy//2*dx+dx/2, 0]) - x00 = np.flipud(x00) + x00, n00, a00 = linear(Nxy, spacing, + center=[0, Nxy//2*spacing+spacing/2, 0]) + x00 = _np.flipud(x00) positions = x00 directions = n00 weights = a00 # array along x-axis - x00, n00, a00 = linear(Nxy, dx, center=[Nxy//2*dx-dx/2, 0, 0], + x00, n00, a00 = linear(Nxy, spacing, + center=[Nxy//2*spacing-spacing/2, 0, 0], orientation=[0, 1, 0]) - x00 = np.flipud(x00) - positions = np.concatenate((positions, x00)) - directions = np.concatenate((directions, n00)) - weights = np.concatenate((weights, a00)) + x00 = _np.flipud(x00) + positions = _np.concatenate((positions, x00)) + directions = _np.concatenate((directions, n00)) + weights = _np.concatenate((weights, a00)) # rotate array positions, directions = _rotate_array(positions, directions, [1, 0, 0], orientation) # shift array to desired position positions += center - return ArrayData(positions, directions, weights) + return SecondarySourceDistribution(positions, directions, weights) -def planar(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): - """Planar secondary source distribtion. +def planar(N, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return planar secondary source distribtion. Parameters ---------- @@ -371,24 +440,42 @@ def planar(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. + Examples + -------- + .. plot:: + :context: close-figs + + x0, n0, a0 = sfs.array.planar( + (4,3), 0.5, orientation=[0, 0, 1]) # 4 sources along y, 3 sources along x + x0, n0, a0 = sfs.array.planar( + (4,3), 0.5, orientation=[1, 0, 0]) # 4 sources along y, 3 sources along z + + x0, n0, a0 = sfs.array.planar( + (4,3), 0.5, orientation=[0, 1, 0]) # 4 sources along x, 3 sources along z + sfs.plot2d.loudspeakers(x0, n0, a0) # plot the last ssd in 2D + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + + """ - N1, N2 = (N, N) if np.isscalar(N) else N - zcoordinates = np.arange(N2) * spacing - zcoordinates -= np.mean(zcoordinates[[0, -1]]) # move center to origin + N1, N2 = (N, N) if _np.isscalar(N) else N + zcoordinates = _np.arange(N2) * spacing + zcoordinates -= _np.mean(zcoordinates[[0, -1]]) # move center to origin subarrays = [linear(N1, spacing, center=[0, 0, z]) for z in zcoordinates] positions, normals, weights = concatenate(*subarrays) weights *= spacing positions, normals = _rotate_array(positions, normals, [1, 0, 0], orientation) positions += center - return ArrayData(positions, normals, weights) + return SecondarySourceDistribution(positions, normals, weights) -def cube(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): - """Cube-shaped secondary source distribtion. +def cube(N, spacing, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Return cube-shaped secondary source distribtion. Parameters ---------- @@ -404,69 +491,152 @@ def cube(N, spacing, center=[0, 0, 0], orientation=[1, 0, 0]): Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. + Examples + -------- + .. plot:: + :context: close-figs + + x0, n0, a0 = sfs.array.cube( + N=2, spacing=0.5, + center=[0, 0, 0], orientation=[1, 0, 0]) + sfs.plot2d.loudspeakers(x0, n0, a0) + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + plt.title('view onto xy-plane') + """ - N1, N2, N3 = (N, N, N) if np.isscalar(N) else N - offset1 = spacing * (N2 - 1) / 2 + spacing / np.sqrt(2) - offset2 = spacing * (N1 - 1) / 2 + spacing / np.sqrt(2) - offset3 = spacing * (N3 - 1) / 2 + spacing / np.sqrt(2) + N1, N2, N3 = (N, N, N) if _np.isscalar(N) else N + d = spacing + offset1 = d * (N2 - 1) / 2 + d / _np.sqrt(2) + offset2 = d * (N1 - 1) / 2 + d / _np.sqrt(2) + offset3 = d * (N3 - 1) / 2 + d / _np.sqrt(2) positions, directions, weights = concatenate( - planar((N1, N3), spacing, [-offset1, 0, 0], [1, 0, 0]), # west - planar((N2, N3), spacing, [0, offset2, 0], [0, -1, 0]), # north - planar((N1, N3), spacing, [offset1, 0, 0], [-1, 0, 0]), # east - planar((N2, N3), spacing, [0, -offset2, 0], [0, 1, 0]), # south - planar((N2, N1), spacing, [0, 0, -offset3], [0, 0, 1]), # bottom - planar((N2, N1), spacing, [0, 0, offset3], [0, 0, -1]), # top + # west + planar((N1, N3), d, center=[-offset1, 0, 0], orientation=[1, 0, 0]), + # north + planar((N2, N3), d, center=[0, offset2, 0], orientation=[0, -1, 0]), + # east + planar((N1, N3), d, center=[offset1, 0, 0], orientation=[-1, 0, 0]), + # south + planar((N2, N3), d, center=[0, -offset2, 0], orientation=[0, 1, 0]), + # bottom + planar((N2, N1), d, center=[0, 0, -offset3], orientation=[0, 0, 1]), + # top + planar((N2, N1), d, center=[0, 0, offset3], orientation=[0, 0, -1]), ) positions, directions = _rotate_array(positions, directions, [1, 0, 0], orientation) positions += center - return ArrayData(positions, directions, weights) + return SecondarySourceDistribution(positions, directions, weights) -def sphere_load(fname, radius, center=[0, 0, 0]): - """Spherical secondary source distribution loaded from datafile. +def sphere_load(file, radius, *, center=[0, 0, 0]): + """Load spherical secondary source distribution from file. - ASCII Format (see MATLAB SFS Toolbox) with 4 numbers (3 position, 1 - weight) per secondary source located on the unit circle. + ASCII Format (see MATLAB SFS Toolbox) with 4 numbers (3 for the cartesian + position vector, 1 for the integration weight) per secondary source located + on the unit circle which is resized by the given radius and shifted to the + given center. Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. + Examples + -------- + content of ``example_array_6LS_3D.txt``:: + + 1 0 0 1 + -1 0 0 1 + 0 1 0 1 + 0 -1 0 1 + 0 0 1 1 + 0 0 -1 1 + + corresponds to the `3-dimensional 6-point spherical 3-design + `_. + + .. plot:: + :context: close-figs + + x0, n0, a0 = sfs.array.sphere_load( + '../data/arrays/example_array_6LS_3D.txt', + radius=2, + center=[0, 0, 0]) + sfs.plot2d.loudspeakers(x0, n0, a0, size=0.25) + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + plt.title('view onto xy-plane') + """ - data = np.loadtxt(fname) + data = _np.loadtxt(file) positions, weights = data[:, :3], data[:, 3] normals = -positions positions *= radius positions += center - return ArrayData(positions, normals, weights) + return SecondarySourceDistribution(positions, normals, weights) -def load(fname, center=[0, 0, 0], orientation=[1, 0, 0]): - """Load secondary source positions from datafile. +def load(file, *, center=[0, 0, 0], orientation=[1, 0, 0]): + """Load secondary source distribution from file. - Comma Seperated Values (CSV) format with 7 values - (3 positions, 3 normal vectors, 1 weight) per secondary source. + Comma Separated Values (CSV) format with 7 values + (3 for the cartesian position vector, 3 for the cartesian inward normal + vector, 1 for the integration weight) per secondary source. Returns ------- - `ArrayData` + `SecondarySourceDistribution` Positions, orientations and weights of secondary sources. + Examples + -------- + content of ``example_array_4LS_2D.csv``:: + + 1,0,0,-1,0,0,1 + 0,1,0,0,-1,0,1 + -1,0,0,1,0,0,1 + 0,-1,0,0,1,0,1 + + corresponds to 4 sources at 1, j, -1, -j in the complex plane. This setup + is typically used for Quadraphonic audio reproduction. + + .. plot:: + :context: close-figs + + x0, n0, a0 = sfs.array.load('../data/arrays/example_array_4LS_2D.csv') + sfs.plot2d.loudspeakers(x0, n0, a0) + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + + .. plot:: + :context: close-figs + + x0, n0, a0 = sfs.array.load( + '../data/arrays/wfs_university_rostock_2018.csv') + sfs.plot2d.loudspeakers(x0, n0, a0) + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + plt.title('top view of 64 channel WFS system at university of Rostock') + """ - data = np.loadtxt(fname, delimiter=',') + data = _np.loadtxt(file, delimiter=',') positions, normals, weights = data[:, :3], data[:, 3:6], data[:, 6] positions, normals = _rotate_array(positions, normals, [1, 0, 0], orientation) positions += center - return ArrayData(positions, normals, weights) + return SecondarySourceDistribution(positions, normals, weights) -def weights_midpoint(positions, closed): +def weights_midpoint(positions, *, closed): """Calculate loudspeaker weights for a simply connected array. The weights are calculated according to the midpoint rule. @@ -477,8 +647,8 @@ def weights_midpoint(positions, closed): positions : (N, 3) array_like Sequence of secondary source positions. - .. note:: The loudspeaker positions have to be ordered on the - contour! + .. note:: The loudspeaker positions have to be ordered along the + contour. closed : bool ``True`` if the loudspeaker contour is closed. @@ -488,39 +658,69 @@ def weights_midpoint(positions, closed): (N,) numpy.ndarray Weights of secondary sources. + Examples + -------- + >>> import sfs + >>> x0, n0, a0 = sfs.array.circular(2**5, 1) + >>> a = sfs.array.weights_midpoint(x0, closed=True) + >>> max(abs(a0-a)) + 0.0003152601902411123 + """ - positions = util.asarray_of_rows(positions) + positions = _util.asarray_of_rows(positions) if closed: before, after = -1, 0 # cyclic else: before, after = 1, -2 # mirrored - positions = np.row_stack((positions[before], positions, positions[after])) - distances = np.linalg.norm(np.diff(positions, axis=0), axis=1) + positions = _np.row_stack((positions[before], positions, positions[after])) + distances = _np.linalg.norm(_np.diff(positions, axis=0), axis=1) return (distances[:-1] + distances[1:]) / 2 def _rotate_array(positions, normals, n1, n2): """Rotate secondary sources from n1 to n2.""" - R = util.rotation_matrix(n1, n2) - positions = np.inner(positions, R) - normals = np.inner(normals, R) + R = _util.rotation_matrix(n1, n2) + positions = _np.inner(positions, R) + normals = _np.inner(normals, R) return positions, normals def _linear_helper(ycoordinates, center, orientation): """Create a full linear array from an array of y-coordinates.""" - center = util.asarray_1d(center) + center = _util.asarray_1d(center) N = len(ycoordinates) - positions = np.zeros((N, 3)) - positions[:, 1] = ycoordinates - np.mean(ycoordinates[[0, -1]]) + positions = _np.zeros((N, 3)) + positions[:, 1] = ycoordinates - _np.mean(ycoordinates[[0, -1]]) positions, normals = _rotate_array(positions, [1, 0, 0], [1, 0, 0], orientation) positions += center - normals = np.tile(normals, (N, 1)) + normals = _np.tile(normals, (N, 1)) weights = weights_midpoint(positions, closed=False) - return ArrayData(positions, normals, weights) + return SecondarySourceDistribution(positions, normals, weights) def concatenate(*arrays): - """Concatenate `ArrayData` objects.""" - return ArrayData._make(np.concatenate(i) for i in zip(*arrays)) + """Concatenate `SecondarySourceDistribution` objects. + + Returns + ------- + `SecondarySourceDistribution` + Positions, orientations and weights + of the concatenated secondary sources. + + Examples + -------- + .. plot:: + :context: close-figs + + ssd1 = sfs.array.edge(10, 0.2) + ssd2 = sfs.array.edge(20, 0.1, center=[2, 2, 0], orientation=[-1, 0, 0]) + x0, n0, a0 = sfs.array.concatenate(ssd1, ssd2) + sfs.plot2d.loudspeakers(x0, n0, a0) + plt.axis('equal') + plt.xlabel('x / m') + plt.ylabel('y / m') + + """ + return SecondarySourceDistribution._make(_np.concatenate(i) + for i in zip(*arrays)) diff --git a/sfs/defs.py b/sfs/defs.py deleted file mode 100644 index c7a36ad..0000000 --- a/sfs/defs.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Definition of constants.""" - -# speed of sound -c = 343 - -# static density of air -rho0 = 1.2250 - -# tolerance used for secondary source selection -selection_tolerance = 1e-6 diff --git a/sfs/fd/__init__.py b/sfs/fd/__init__.py new file mode 100644 index 0000000..1dfe2cb --- /dev/null +++ b/sfs/fd/__init__.py @@ -0,0 +1,95 @@ +"""Submodules for monochromatic sound fields. + +.. autosummary:: + :toctree: + + source + + wfs + nfchoa + sdm + esa + +""" +import numpy as _np + +from . import source +from .. import array as _array +from .. import util as _util + + +def shiftphase(p, phase): + """Shift phase of a sound field.""" + p = _np.asarray(p) + return p * _np.exp(1j * phase) + + +def displacement(v, omega): + r"""Particle displacement. + + .. math:: + + d(x, t) = \int_{-\infty}^t v(x, \tau) d\tau + + """ + return _util.as_xyz_components(v) / (1j * omega) + + +def synthesize(d, weights, ssd, secondary_source_function, **kwargs): + """Compute sound field for a generic driving function. + + Parameters + ---------- + d : array_like + Driving function. + weights : array_like + Additional weights applied during integration, e.g. source + selection and tapering. + ssd : sequence of between 1 and 3 array_like objects + Positions, normal vectors and weights of secondary sources. + A `SecondarySourceDistribution` can also be used. + secondary_source_function : callable + A function that generates the sound field of a secondary source. + This signature is expected:: + + secondary_source_function( + position, normal_vector, **kwargs) -> numpy.ndarray + + **kwargs + All keyword arguments are forwarded to *secondary_source_function*. + This is typically used to pass the *grid* argument. + + """ + ssd = _array.as_secondary_source_distribution(ssd) + if not (len(ssd.x) == len(ssd.n) == len(ssd.a) == len(d) == + len(weights)): + raise ValueError("length mismatch") + p = 0 + for x, n, a, d, weight in zip(ssd.x, ssd.n, ssd.a, d, weights): + if weight != 0: + p += a * weight * d * secondary_source_function(x, n, **kwargs) + return p + + +def secondary_source_point(omega, c): + """Create a point source for use in `sfs.fd.synthesize()`.""" + + def secondary_source(position, _, grid): + return source.point(omega, position, grid, c=c) + + return secondary_source + + +def secondary_source_line(omega, c): + """Create a line source for use in `sfs.fd.synthesize()`.""" + + def secondary_source(position, _, grid): + return source.line(omega, position, grid, c=c) + + return secondary_source + + +from . import esa +from . import nfchoa +from . import sdm +from . import wfs diff --git a/sfs/fd/esa.py b/sfs/fd/esa.py new file mode 100644 index 0000000..380769c --- /dev/null +++ b/sfs/fd/esa.py @@ -0,0 +1,360 @@ +"""Compute ESA driving functions for various systems. + +ESA is abbreviation for equivalent scattering approach. + +ESA driving functions for an edge-shaped SSD are provided below. +Further ESA for different geometries might be added here. + +Note that mode-matching (such as NFC-HOA, SDM) are equivalent +to ESA in their specific geometries (spherical/circular, planar/linear). + +""" +import numpy as _np +from scipy.special import jn as _jn, hankel2 as _hankel2 + +from . import secondary_source_line as _secondary_source_line +from . import secondary_source_point as _secondary_source_point +from .. import util as _util + + +def plane_2d_edge(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, Nc=None, + c=None): + r"""Driving function for 2-dimensional plane wave with edge ESA. + + Driving function for a virtual plane wave using the 2-dimensional ESA + for an edge-shaped secondary source distribution consisting of + monopole line sources. + + Parameters + ---------- + omega : float + Angular frequency. + x0 : int(N, 3) array_like + Sequence of secondary source positions. + n : (3,) array_like, optional + Normal vector of synthesized plane wave. + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. Estimated + if not given. + c : float, optional + Speed of sound + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + One leg of the secondary sources has to be located on the x-axis (y0=0), + the edge at the origin. + + Derived from :cite:`Spors2016` + + """ + x0 = _np.asarray(x0) + n = _util.normalize_vector(n) + k = _util.wavenumber(omega, c) + phi_s = _np.arctan2(n[1], n[0]) + _np.pi + L = x0.shape[0] + + r = _np.linalg.norm(x0, axis=1) + phi = _np.arctan2(x0[:, 1], x0[:, 0]) + phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) + + if Nc is None: + Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) + + epsilon = _np.ones(Nc) # weights for series expansion + epsilon[0] = 2 + + d = _np.zeros(L, dtype=complex) + for m in _np.arange(Nc): + nu = m * _np.pi / alpha + d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.sin(nu*phi_s) \ + * _np.cos(nu*phi) * nu/r * _jn(nu, k*r) + + d[phi > 0] = -d[phi > 0] + + selection = _util.source_selection_all(len(x0)) + return 4*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) + + +def plane_2d_edge_dipole_ssd(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, + Nc=None, c=None): + r"""Driving function for 2-dimensional plane wave with edge dipole ESA. + + Driving function for a virtual plane wave using the 2-dimensional ESA + for an edge-shaped secondary source distribution consisting of + dipole line sources. + + Parameters + ---------- + omega : float + Angular frequency. + x0 : int(N, 3) array_like + Sequence of secondary source positions. + n : (3,) array_like, optional + Normal vector of synthesized plane wave. + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. Estimated + if not given. + c : float, optional + Speed of sound + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + One leg of the secondary sources has to be located on the x-axis (y0=0), + the edge at the origin. + + Derived from :cite:`Spors2016` + + """ + x0 = _np.asarray(x0) + n = _util.normalize_vector(n) + k = _util.wavenumber(omega, c) + phi_s = _np.arctan2(n[1], n[0]) + _np.pi + L = x0.shape[0] + + r = _np.linalg.norm(x0, axis=1) + phi = _np.arctan2(x0[:, 1], x0[:, 0]) + phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) + + if Nc is None: + Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) + + epsilon = _np.ones(Nc) # weights for series expansion + epsilon[0] = 2 + + d = _np.zeros(L, dtype=complex) + for m in _np.arange(Nc): + nu = m * _np.pi / alpha + d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.cos(nu*phi_s) \ + * _np.cos(nu*phi) * _jn(nu, k*r) + + return 4*_np.pi/alpha * d + + +def line_2d_edge(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, c=None): + r"""Driving function for 2-dimensional line source with edge ESA. + + Driving function for a virtual line source using the 2-dimensional ESA + for an edge-shaped secondary source distribution consisting of line + sources. + + Parameters + ---------- + omega : float + Angular frequency. + x0 : int(N, 3) array_like + Sequence of secondary source positions. + xs : (3,) array_like + Position of synthesized line source. + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. Estimated + if not given. + c : float, optional + Speed of sound + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + One leg of the secondary sources has to be located on the x-axis (y0=0), + the edge at the origin. + + Derived from :cite:`Spors2016` + + """ + x0 = _np.asarray(x0) + k = _util.wavenumber(omega, c) + phi_s = _np.arctan2(xs[1], xs[0]) + if phi_s < 0: + phi_s = phi_s + 2 * _np.pi + r_s = _np.linalg.norm(xs) + L = x0.shape[0] + + r = _np.linalg.norm(x0, axis=1) + phi = _np.arctan2(x0[:, 1], x0[:, 0]) + phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) + + if Nc is None: + Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) + + epsilon = _np.ones(Nc) # weights for series expansion + epsilon[0] = 2 + + d = _np.zeros(L, dtype=complex) + idx = (r <= r_s) + for m in _np.arange(Nc): + nu = m * _np.pi / alpha + f = 1/epsilon[m] * _np.sin(nu*phi_s) * _np.cos(nu*phi) * nu/r + d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) + d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) + + d[phi > 0] = -d[phi > 0] + + selection = _util.source_selection_all(len(x0)) + return -1j*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) + + +def line_2d_edge_dipole_ssd(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, + c=None): + r"""Driving function for 2-dimensional line source with edge dipole ESA. + + Driving function for a virtual line source using the 2-dimensional ESA + for an edge-shaped secondary source distribution consisting of dipole line + sources. + + Parameters + ---------- + omega : float + Angular frequency. + x0 : (N, 3) array_like + Sequence of secondary source positions. + xs : (3,) array_like + Position of synthesized line source. + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. Estimated + if not given. + c : float, optional + Speed of sound + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + One leg of the secondary sources has to be located on the x-axis (y0=0), + the edge at the origin. + + Derived from :cite:`Spors2016` + + """ + x0 = _np.asarray(x0) + k = _util.wavenumber(omega, c) + phi_s = _np.arctan2(xs[1], xs[0]) + if phi_s < 0: + phi_s = phi_s + 2 * _np.pi + r_s = _np.linalg.norm(xs) + L = x0.shape[0] + + r = _np.linalg.norm(x0, axis=1) + phi = _np.arctan2(x0[:, 1], x0[:, 0]) + phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) + + if Nc is None: + Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) + + epsilon = _np.ones(Nc) # weights for series expansion + epsilon[0] = 2 + + d = _np.zeros(L, dtype=complex) + idx = (r <= r_s) + for m in _np.arange(Nc): + nu = m * _np.pi / alpha + f = 1/epsilon[m] * _np.cos(nu*phi_s) * _np.cos(nu*phi) + d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) + d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) + + return -1j*_np.pi/alpha * d + + +def point_25d_edge(omega, x0, xs, *, xref=[2, -2, 0], alpha=_np.pi*3/2, + Nc=None, c=None): + r"""Driving function for 2.5-dimensional point source with edge ESA. + + Driving function for a virtual point source using the 2.5-dimensional + ESA for an edge-shaped secondary source distribution consisting of point + sources. + + Parameters + ---------- + omega : float + Angular frequency. + x0 : int(N, 3) array_like + Sequence of secondary source positions. + xs : (3,) array_like + Position of synthesized line source. + xref: (3,) array_like or float + Reference position or reference distance + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. Estimated + if not given. + c : float, optional + Speed of sound + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + One leg of the secondary sources has to be located on the x-axis (y0=0), + the edge at the origin. + + Derived from :cite:`Spors2016` + + """ + x0 = _np.asarray(x0) + xs = _np.asarray(xs) + xref = _np.asarray(xref) + + if _np.isscalar(xref): + a = _np.linalg.norm(xref) / _np.linalg.norm(xref - xs) + else: + a = _np.linalg.norm(xref - x0, axis=1) / _np.linalg.norm(xref - xs) + + d, selection, _ = line_2d_edge(omega, x0, xs, alpha=alpha, Nc=Nc, c=c) + return 1j*_np.sqrt(a) * d, selection, _secondary_source_point(omega, c) diff --git a/sfs/fd/nfchoa.py b/sfs/fd/nfchoa.py new file mode 100644 index 0000000..d41c2b7 --- /dev/null +++ b/sfs/fd/nfchoa.py @@ -0,0 +1,237 @@ +"""Compute NFC-HOA driving functions. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + import sfs + + plt.rcParams['figure.figsize'] = 6, 6 + + xs = -1.5, 1.5, 0 + # normal vector for plane wave: + npw = sfs.util.direction_vector(np.radians(-45)) + f = 300 # Hz + omega = 2 * np.pi * f + R = 1.5 # Radius of circular loudspeaker array + + grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) + + array = sfs.array.circular(N=32, R=R) + + def plot(d, selection, secondary_source): + p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) + sfs.plot2d.amplitude(p, grid) + sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) + +""" +import numpy as _np +from scipy.special import hankel2 as _hankel2 + +from . import secondary_source_point as _secondary_source_point +from . import secondary_source_line as _secondary_source_line +from .. import util as _util + + +def plane_2d(omega, x0, r0, n=[0, 1, 0], *, max_order=None, c=None): + r"""Driving function for 2-dimensional NFC-HOA for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of circular secondary source distribution. + n : (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + max_order : float, optional + Maximum order of circular harmonics used for the calculation. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\phi_0, \omega) = + -\frac{2\i}{\pi r_0} + \sum_{m=-M}^M + \frac{\i^{-m}}{\Hankel{2}{m}{\wc r_0}} + \e{\i m (\phi_0 - \phi_\text{pw})} + + See :sfs:`d_nfchoa/#equation-fd-nfchoa-plane-2d` + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.nfchoa.plane_2d( + omega, array.x, R, npw) + plot(d, selection, secondary_source) + + """ + if max_order is None: + max_order = _util.max_order_circular_harmonics(len(x0)) + + x0 = _util.asarray_of_rows(x0) + k = _util.wavenumber(omega, c) + n = _util.normalize_vector(n) + phi, _, r = _util.cart2sph(*n) + phi0 = _util.cart2sph(*x0.T)[0] + d = 0 + for m in range(-max_order, max_order + 1): + d += 1j**-m / _hankel2(m, k * r0) * _np.exp(1j * m * (phi0 - phi)) + selection = _util.source_selection_all(len(x0)) + return -2j / (_np.pi*r0) * d, selection, _secondary_source_line(omega, c) + + +def point_25d(omega, x0, r0, xs, *, max_order=None, c=None): + r"""Driving function for 2.5-dimensional NFC-HOA for a virtual point source. + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of circular secondary source distribution. + xs : (3,) array_like + Position of point source. + max_order : float, optional + Maximum order of circular harmonics used for the calculation. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\phi_0, \omega) = + \frac{1}{2 \pi r_0} + \sum_{m=-M}^M + \frac{\hankel{2}{|m|}{\wc r}}{\hankel{2}{|m|}{\wc r_0}} + \e{\i m (\phi_0 - \phi)} + + See :sfs:`d_nfchoa/#equation-fd-nfchoa-point-25d` + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.nfchoa.point_25d( + omega, array.x, R, xs) + plot(d, selection, secondary_source) + + """ + if max_order is None: + max_order = _util.max_order_circular_harmonics(len(x0)) + + x0 = _util.asarray_of_rows(x0) + k = _util.wavenumber(omega, c) + xs = _util.asarray_1d(xs) + phi, _, r = _util.cart2sph(*xs) + phi0 = _util.cart2sph(*x0.T)[0] + hr = _util.spherical_hn2(range(0, max_order + 1), k * r) + hr0 = _util.spherical_hn2(range(0, max_order + 1), k * r0) + d = 0 + for m in range(-max_order, max_order + 1): + d += hr[abs(m)] / hr0[abs(m)] * _np.exp(1j * m * (phi0 - phi)) + selection = _util.source_selection_all(len(x0)) + return d / (2 * _np.pi * r0), selection, _secondary_source_point(omega, c) + + +def plane_25d(omega, x0, r0, n=[0, 1, 0], *, max_order=None, c=None): + r"""Driving function for 2.5-dimensional NFC-HOA for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of circular secondary source distribution. + n : (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + max_order : float, optional + Maximum order of circular harmonics used for the calculation. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\phi_0, \omega) = + \frac{2\i}{r_0} + \sum_{m=-M}^M + \frac{\i^{-|m|}}{\wc \hankel{2}{|m|}{\wc r_0}} + \e{\i m (\phi_0 - \phi_\text{pw})} + + See :sfs:`d_nfchoa/#equation-fd-nfchoa-plane-25d` + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.nfchoa.plane_25d( + omega, array.x, R, npw) + plot(d, selection, secondary_source) + + """ + if max_order is None: + max_order = _util.max_order_circular_harmonics(len(x0)) + + x0 = _util.asarray_of_rows(x0) + k = _util.wavenumber(omega, c) + n = _util.normalize_vector(n) + phi, _, r = _util.cart2sph(*n) + phi0 = _util.cart2sph(*x0.T)[0] + d = 0 + hn2 = _util.spherical_hn2(range(0, max_order + 1), k * r0) + for m in range(-max_order, max_order + 1): + d += (-1j)**abs(m) / (k * hn2[abs(m)]) * _np.exp(1j * m * (phi0 - phi)) + selection = _util.source_selection_all(len(x0)) + return 2*1j / r0 * d, selection, _secondary_source_point(omega, c) diff --git a/sfs/fd/sdm.py b/sfs/fd/sdm.py new file mode 100644 index 0000000..7682e3d --- /dev/null +++ b/sfs/fd/sdm.py @@ -0,0 +1,255 @@ +"""Compute SDM driving functions. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + import sfs + + plt.rcParams['figure.figsize'] = 6, 6 + + xs = -1.5, 1.5, 0 + # normal vector for plane wave: + npw = sfs.util.direction_vector(np.radians(-45)) + f = 300 # Hz + omega = 2 * np.pi * f + + grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) + + array = sfs.array.linear(32, 0.2, orientation=[0, -1, 0]) + + def plot(d, selection, secondary_source): + p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) + sfs.plot2d.amplitude(p, grid) + sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) + +""" +import numpy as _np +from scipy.special import hankel2 as _hankel2 + +from . import secondary_source_line as _secondary_source_line +from . import secondary_source_point as _secondary_source_point +from .. import util as _util + + +def line_2d(omega, x0, n0, xs, *, c=None): + r"""Driving function for 2-dimensional SDM for a virtual line source. + + Parameters + ---------- + omega : float + Angular frequency of line source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of line source. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + The secondary sources have to be located on the x-axis (y0=0). + Derived from :cite:`Spors2009`, Eq.(9), Eq.(4). + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.sdm.line_2d( + omega, array.x, array.n, xs) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = - 1j/2 * k * xs[1] / r * _hankel2(1, k * r) + selection = _util.source_selection_all(len(x0)) + return d, selection, _secondary_source_line(omega, c) + + +def plane_2d(omega, x0, n0, n=[0, 1, 0], *, c=None): + r"""Driving function for 2-dimensional SDM for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + n: (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + The secondary sources have to be located on the x-axis (y0=0). + Derived from :cite:`Ahrens2012`, Eq.(3.73), Eq.(C.5), Eq.(C.11): + + .. math:: + + D(\x_0,k) = k_\text{pw,y} \e{-\i k_\text{pw,x} x} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.sdm.plane_2d( + omega, array.x, array.n, npw) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + n = _util.normalize_vector(n) + k = _util.wavenumber(omega, c) + d = k * n[1] * _np.exp(-1j * k * n[0] * x0[:, 0]) + selection = _util.source_selection_all(len(x0)) + return d, selection, _secondary_source_line(omega, c) + + +def plane_25d(omega, x0, n0, n=[0, 1, 0], *, xref=[0, 0, 0], c=None): + r"""Driving function for 2.5-dimensional SDM for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + n: (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + xref : (3,) array_like, optional + Reference point for synthesized sound field. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + The secondary sources have to be located on the x-axis (y0=0). + Eq.(3.79) from :cite:`Ahrens2012`. + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.sdm.plane_25d( + omega, array.x, array.n, npw, xref=[0, -1, 0]) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + n = _util.normalize_vector(n) + xref = _util.asarray_1d(xref) + k = _util.wavenumber(omega, c) + d = 4j * _np.exp(-1j*k*n[1]*xref[1]) / _hankel2(0, k*n[1]*xref[1]) * \ + _np.exp(-1j*k*n[0]*x0[:, 0]) + selection = _util.source_selection_all(len(x0)) + return d, selection, _secondary_source_point(omega, c) + + +def point_25d(omega, x0, n0, xs, *, xref=[0, 0, 0], c=None): + r"""Driving function for 2.5-dimensional SDM for a virtual point source. + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs: (3,) array_like + Position of virtual point source. + xref : (3,) array_like, optional + Reference point for synthesized sound field. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + The secondary sources have to be located on the x-axis (y0=0). + Driving function from :cite:`Spors2010`, Eq.(24). + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.sdm.point_25d( + omega, array.x, array.n, xs, xref=[0, -1, 0]) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_1d(xref) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = 1/2 * 1j * k * _np.sqrt(xref[1] / (xref[1] - xs[1])) * \ + xs[1] / r * _hankel2(1, k * r) + selection = _util.source_selection_all(len(x0)) + return d, selection, _secondary_source_point(omega, c) diff --git a/sfs/fd/source.py b/sfs/fd/source.py new file mode 100644 index 0000000..6a0b860 --- /dev/null +++ b/sfs/fd/source.py @@ -0,0 +1,948 @@ +r"""Compute the sound field generated by a sound source. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import sfs + import numpy as np + import matplotlib.pyplot as plt + plt.rcParams['figure.figsize'] = 8, 4.5 # inch + + x0 = 1.5, 1, 0 + f = 500 # Hz + omega = 2 * np.pi * f + + normalization_point = 4 * np.pi + normalization_line = \ + np.sqrt(8 * np.pi * omega / sfs.default.c) * np.exp(1j * np.pi / 4) + + grid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.02) + + # Grid for vector fields: + vgrid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.1) + +""" +from itertools import product as _product + +import numpy as _np +from scipy import special as _special + +from .. import default as _default +from .. import util as _util + + +def point(omega, x0, grid, *, c=None): + r"""Sound pressure of a point source. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + 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 + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + Notes + ----- + .. math:: + + G(\x-\x_0,\w) = \frac{1}{4\pi} \frac{\e{-\i\wc|\x-\x_0|}}{|\x-\x_0|} + + Examples + -------- + .. plot:: + :context: close-figs + + p = sfs.fd.source.point(omega, x0, grid) + sfs.plot2d.amplitude(p, grid) + plt.title("Point Source at {} m".format(x0)) + + Normalization ... + + .. plot:: + :context: close-figs + + sfs.plot2d.amplitude(p * normalization_point, grid, + colorbar_kwargs=dict(label="p / Pa")) + plt.title("Point Source at {} m (normalized)".format(x0)) + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + grid = _util.as_xyz_components(grid) + + r = _np.linalg.norm(grid - x0) + # If r is 0, the sound pressure is complex infinity + numerator = _np.exp(-1j * k * r) / (4 * _np.pi) + with _np.errstate(invalid='ignore', divide='ignore'): + return numerator / r + + +def point_velocity(omega, x0, grid, *, c=None, rho0=None): + """Particle velocity of a point source. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + 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. + rho0 : float, optional + Static density of air. + + Returns + ------- + `XyzComponents` + Particle velocity at positions given by *grid*. + + Examples + -------- + The particle velocity can be plotted on top of the sound pressure: + + .. plot:: + :context: close-figs + + v = sfs.fd.source.point_velocity(omega, x0, vgrid) + sfs.plot2d.amplitude(p * normalization_point, grid) + sfs.plot2d.vectors(v * normalization_point, vgrid) + plt.title("Sound Pressure and Particle Velocity") + + """ + if c is None: + c = _default.c + if rho0 is None: + rho0 = _default.rho0 + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + grid = _util.as_xyz_components(grid) + offset = grid - x0 + r = _np.linalg.norm(offset) + v = point(omega, x0, grid, c=c) + v *= (1+1j*k*r) / (rho0 * c * 1j*k*r) + return _util.XyzComponents([v * o / r for o in offset]) + + +def point_averaged_intensity(omega, x0, grid, *, c=None, rho0=None): + """Velocity of a point source. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + 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. + rho0 : float, optional + Static density of air. + + Returns + ------- + `XyzComponents` + Averaged intensity at positions given by *grid*. + + """ + if c is None: + c = _default.c + if rho0 is None: + rho0 = _default.rho0 + x0 = _util.asarray_1d(x0) + grid = _util.as_xyz_components(grid) + offset = grid - x0 + r = _np.linalg.norm(offset) + i = 1 / (2 * rho0 * c) + return _util.XyzComponents([i * o / r**2 for o in offset]) + + +def point_dipole(omega, x0, n0, grid, *, c=None): + r"""Point source with dipole characteristics. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + n0 : (3,) array_like + Normal vector (direction) of dipole. + 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 + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + Notes + ----- + .. math:: + + G(\x-\x_0,\w) = \frac{1}{4\pi} \left(\i\wc + \frac{1}{|\x-\x_0|}\right) + \frac{\scalarprod{\x-\x_0}{\n_\text{s}}}{|\x-\x_0|^2} + \e{-\i\wc|\x-\x_0} + + Examples + -------- + .. plot:: + :context: close-figs + + n0 = 0, 1, 0 + p = sfs.fd.source.point_dipole(omega, x0, n0, grid) + sfs.plot2d.amplitude(p, grid) + plt.title("Dipole Point Source at {} m".format(x0)) + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + n0 = _util.asarray_1d(n0) + grid = _util.as_xyz_components(grid) + + offset = grid - x0 + r = _np.linalg.norm(offset) + return 1 / (4 * _np.pi) * (1j * k + 1 / r) * _np.inner(offset, n0) / \ + _np.power(r, 2) * _np.exp(-1j * k * r) + + +def point_modal(omega, x0, grid, L, *, N=None, deltan=0, c=None): + """Point source in a rectangular room using a modal room model. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + L : (3,) array_like + Dimensionons of the rectangular room. + N : (3,) array_like or int, optional + For all three spatial dimensions per dimension maximum order or + list of orders. A scalar applies to all three dimensions. If no + order is provided it is approximately determined. + deltan : float, optional + Absorption coefficient of the walls. + c : float, optional + Speed of sound. + + Returns + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + x, y, z = _util.as_xyz_components(grid) + + if _np.isscalar(N): + N = N * _np.ones(3, dtype=int) + + if N is None: + N = [None, None, None] + + orders = [0, 0, 0] + for i in range(3): + if N[i] is None: + # compute max order + orders[i] = range(int(_np.ceil(L[i] / _np.pi * k) + 1)) + elif _np.isscalar(N[i]): + # use given max order + orders[i] = range(N[i] + 1) + else: + # use given orders + orders[i] = N[i] + + kmp0 = [((kx + 1j * deltan)**2, _np.cos(kx * x) * _np.cos(kx * x0[0])) + for kx in [m * _np.pi / L[0] for m in orders[0]]] + kmp1 = [((ky + 1j * deltan)**2, _np.cos(ky * y) * _np.cos(ky * x0[1])) + for ky in [n * _np.pi / L[1] for n in orders[1]]] + kmp2 = [((kz + 1j * deltan)**2, _np.cos(kz * z) * _np.cos(kz * x0[2])) + for kz in [l * _np.pi / L[2] for l in orders[2]]] + ksquared = k**2 + p = 0 + for (km0, p0), (km1, p1), (km2, p2) in _product(kmp0, kmp1, kmp2): + km = km0 + km1 + km2 + p = p + 8 / (ksquared - km) * p0 * p1 * p2 + return p + + +def point_modal_velocity(omega, x0, grid, L, *, N=None, deltan=0, c=None): + """Velocity of point source in a rectangular room using a modal room model. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + L : (3,) array_like + Dimensionons of the rectangular room. + N : (3,) array_like or int, optional + Combination of modal orders in the three-spatial dimensions to + calculate the sound field for or maximum order for all + dimensions. If not given, the maximum modal order is + approximately determined and the sound field is computed up to + this maximum order. + deltan : float, optional + Absorption coefficient of the walls. + c : float, optional + Speed of sound. + + Returns + ------- + `XyzComponents` + Particle velocity at positions given by *grid*. + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + x, y, z = _util.as_xyz_components(grid) + + if N is None: + # determine maximum modal order per dimension + Nx = int(_np.ceil(L[0] / _np.pi * k)) + Ny = int(_np.ceil(L[1] / _np.pi * k)) + Nz = int(_np.ceil(L[2] / _np.pi * k)) + mm = range(Nx) + nn = range(Ny) + ll = range(Nz) + elif _np.isscalar(N): + # compute up to a given order + mm = range(N) + nn = range(N) + ll = range(N) + else: + # compute field for one order combination only + mm = [N[0]] + nn = [N[1]] + ll = [N[2]] + + kmp0 = [((kx + 1j * deltan)**2, _np.sin(kx * x) * _np.cos(kx * x0[0])) + for kx in [m * _np.pi / L[0] for m in mm]] + kmp1 = [((ky + 1j * deltan)**2, _np.sin(ky * y) * _np.cos(ky * x0[1])) + for ky in [n * _np.pi / L[1] for n in nn]] + kmp2 = [((kz + 1j * deltan)**2, _np.sin(kz * z) * _np.cos(kz * x0[2])) + for kz in [l * _np.pi / L[2] for l in ll]] + ksquared = k**2 + vx = 0+0j + vy = 0+0j + vz = 0+0j + for (km0, p0), (km1, p1), (km2, p2) in _product(kmp0, kmp1, kmp2): + km = km0 + km1 + km2 + vx = vx - 8*1j / (ksquared - km) * p0 + vy = vy - 8*1j / (ksquared - km) * p1 + vz = vz - 8*1j / (ksquared - km) * p2 + return _util.XyzComponents([vx, vy, vz]) + + +def point_image_sources(omega, x0, grid, L, *, max_order, coeffs=None, c=None): + """Point source in a rectangular room using the mirror image source model. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + L : (3,) array_like + Dimensions of the rectangular room. + max_order : int + Maximum number of reflections for each image source. + coeffs : (6,) array_like, optional + Reflection coeffecients of the walls. + If not given, the reflection coefficients are set to one. + c : float, optional + Speed of sound. + + Returns + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + """ + if coeffs is None: + coeffs = _np.ones(6) + + xs, order = _util.image_sources_for_box(x0, L, max_order) + source_strengths = _np.prod(coeffs**order, axis=1) + + p = 0 + for position, strength in zip(xs, source_strengths): + if strength != 0: + # point can be complex infinity + with _np.errstate(invalid='ignore'): + p += strength * point(omega, position, grid, c=c) + + return p + + +def line(omega, x0, grid, *, c=None): + r"""Line source parallel to the z-axis. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. Note: third component of x0 is ignored. + 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 + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + Notes + ----- + .. math:: + + G(\x-\x_0,\w) = -\frac{\i}{4} \Hankel{2}{0}{\wc|\x-\x_0|} + + Examples + -------- + .. plot:: + :context: close-figs + + p = sfs.fd.source.line(omega, x0, grid) + sfs.plot2d.amplitude(p, grid) + plt.title("Line Source at {} m".format(x0[:2])) + + Normalization ... + + .. plot:: + :context: close-figs + + sfs.plot2d.amplitude(p * normalization_line, grid, + colorbar_kwargs=dict(label="p / Pa")) + plt.title("Line Source at {} m (normalized)".format(x0[:2])) + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0)[:2] # ignore z-component + grid = _util.as_xyz_components(grid) + + r = _np.linalg.norm(grid[:2] - x0) + p = -1j/4 * _hankel2_0(k * r) + return _duplicate_zdirection(p, grid) + + +def line_velocity(omega, x0, grid, *, c=None, rho0=None): + """Velocity of line source parallel to the z-axis. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. Note: third component of x0 is ignored. + 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*. + + Examples + -------- + The particle velocity can be plotted on top of the sound pressure: + + .. plot:: + :context: close-figs + + v = sfs.fd.source.line_velocity(omega, x0, vgrid) + sfs.plot2d.amplitude(p * normalization_line, grid) + sfs.plot2d.vectors(v * normalization_line, vgrid) + plt.title("Sound Pressure and Particle Velocity") + + """ + if c is None: + c = _default.c + if rho0 is None: + rho0 = _default.rho0 + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0)[:2] # ignore z-component + grid = _util.as_xyz_components(grid) + + offset = grid[:2] - x0 + r = _np.linalg.norm(offset) + v = -1/(4 * c * rho0) * _special.hankel2(1, k * r) + v = [v * o / r for o in offset] + + assert v[0].shape == v[1].shape + + if len(grid) > 2: + v.append(_np.zeros_like(v[0])) + + return _util.XyzComponents([_duplicate_zdirection(vi, grid) for vi in v]) + + +def line_dipole(omega, x0, n0, grid, *, c=None): + r"""Line source with dipole characteristics parallel to the z-axis. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. Note: third component of x0 is ignored. + x0 : (3,) array_like + Normal vector of the source. + 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. + + Notes + ----- + .. math:: + + G(\x-\x_0,\w) = \frac{\i k}{4} \Hankel{2}{1}{\wc|\x-\x_0|} \cos{\phi} + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0)[:2] # ignore z-components + n0 = _util.asarray_1d(n0)[:2] + grid = _util.as_xyz_components(grid) + dx = grid[:2] - x0 + + r = _np.linalg.norm(dx) + p = 1j*k/4 * _special.hankel2(1, k * r) * _np.inner(dx, n0) / r + return _duplicate_zdirection(p, grid) + + +def line_bandlimited(omega, x0, grid, *, max_order=None, c=None): + r"""Spatially bandlimited (modal) line source parallel to the z-axis. + + Parameters + ---------- + omega : float + Frequency of source. + x0 : (3,) array_like + Position of source. Note: third component of x0 is ignored. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + max_order : int, optional + Number of elements for series expansion of the source. + No bandlimitation if not given. + c : float, optional + Speed of sound. + + Returns + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + Notes + ----- + .. math:: + + G(\x-\x_0,\w) = -\frac{\i}{4} \sum_{\nu = - N}^{N} + e^{j \nu (\alpha - \alpha_0)} + \begin{cases} + J_\nu(\frac{\omega}{c} r) H_\nu^\text{(2)}(\frac{\omega}{c} r_0) + & \text{for } r \leq r_0 \\ + J_\nu(\frac{\omega}{c} r_0) H_\nu^\text{(2)}(\frac{\omega}{c} r) + & \text{for } r > r_0 \\ + \end{cases} + + Examples + -------- + .. plot:: + :context: close-figs + + p = sfs.fd.source.line_bandlimited(omega, x0, grid, max_order=10) + sfs.plot2d.amplitude(p * normalization_line, grid) + plt.title("Bandlimited Line Source at {} m".format(x0[:2])) + + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0)[:2] # ignore z-components + r0 = _np.linalg.norm(x0) + phi0 = _np.arctan2(x0[1], x0[0]) + + grid = _util.as_xyz_components(grid) + r = _np.linalg.norm(grid[:2]) + phi = _np.arctan2(grid[1], grid[0]) + + if max_order is None: + max_order = int(_np.ceil(k * _np.max(r))) + + p = _np.zeros((grid[1].shape[0], grid[0].shape[1]), dtype=complex) + idxr = (r <= r0) + for m in range(-max_order, max_order + 1): + p[idxr] -= 1j/4 * _special.hankel2(m, k * r0) * \ + _special.jn(m, k * r[idxr]) * _np.exp(1j * m * (phi[idxr] - phi0)) + p[~idxr] -= 1j/4 * _special.hankel2(m, k * r[~idxr]) * \ + _special.jn(m, k * r0) * _np.exp(1j * m * (phi[~idxr] - phi0)) + + return _duplicate_zdirection(p, grid) + + +def line_dirichlet_edge(omega, x0, grid, *, alpha=_np.pi*3/2, Nc=None, c=None): + """Line source scattered at an edge with Dirichlet boundary conditions. + + :cite:`Moser2012`, eq.(10.18/19) + + Parameters + ---------- + omega : float + Angular frequency. + x0 : (3,) array_like + Position of line source. + grid : triple of array_like + The grid that is used for the sound field calculations. + See `sfs.util.xyz_grid()`. + alpha : float, optional + Outer angle of edge. + Nc : int, optional + Number of elements for series expansion of driving function. + Estimated if not given. + c : float, optional + Speed of sound + + Returns + ------- + numpy.ndarray + Complex pressure at grid positions. + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + phi_s = _np.arctan2(x0[1], x0[0]) + if phi_s < 0: + phi_s = phi_s + 2 * _np.pi + r_s = _np.linalg.norm(x0) + + grid = _util.XyzComponents(grid) + + r = _np.linalg.norm(grid[:2]) + phi = _np.arctan2(grid[1], grid[0]) + phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) + + if Nc is None: + Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) + + epsilon = _np.ones(Nc) # weights for series expansion + epsilon[0] = 2 + + p = _np.zeros((grid[1].shape[0], grid[0].shape[1]), dtype=complex) + idxr = (r <= r_s) + idxa = (phi <= alpha) + for m in _np.arange(Nc): + nu = m * _np.pi / alpha + f = 1/epsilon[m] * _np.sin(nu*phi_s) * _np.sin(nu*phi) + p[idxr & idxa] = p[idxr & idxa] + f[idxr & idxa] * \ + _special.jn(nu, k*r[idxr & idxa]) * _special.hankel2(nu, k*r_s) + p[~idxr & idxa] = p[~idxr & idxa] + f[~idxr & idxa] * \ + _special.jn(nu, k*r_s) * _special.hankel2(nu, k*r[~idxr & idxa]) + + p = p * -1j * _np.pi / alpha + + pl = line(omega, x0, grid, c=c) + p[~idxa] = pl[~idxa] + + return p + + +def plane(omega, x0, n0, grid, *, c=None): + r"""Plane wave. + + Parameters + ---------- + omega : float + Frequency of plane wave. + x0 : (3,) array_like + Position of plane wave. + n0 : (3,) array_like + Normal vector (direction) of plane wave. + 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 + ------- + numpy.ndarray + Sound pressure at positions given by *grid*. + + Notes + ----- + .. math:: + + G(\x,\w) = \e{-\i\wc\n\x} + + Examples + -------- + .. plot:: + :context: close-figs + + direction = 45 # degree + n0 = sfs.util.direction_vector(np.radians(direction)) + p = sfs.fd.source.plane(omega, x0, n0, grid) + sfs.plot2d.amplitude(p, grid, colorbar_kwargs=dict(label="p / Pa")) + plt.title("Plane wave with direction {} degree".format(direction)) + + """ + k = _util.wavenumber(omega, c) + x0 = _util.asarray_1d(x0) + n0 = _util.normalize_vector(n0) + grid = _util.as_xyz_components(grid) + return _np.exp(-1j * k * _np.inner(grid - x0, n0)) + + +def plane_velocity(omega, x0, n0, grid, *, c=None, rho0=None): + r"""Velocity of a plane wave. + + Parameters + ---------- + omega : float + Frequency of plane wave. + x0 : (3,) array_like + Position of plane wave. + n0 : (3,) array_like + Normal vector (direction) of plane wave. + 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. + rho0 : float, optional + Static density of air. + + Returns + ------- + `XyzComponents` + Particle velocity at positions given by *grid*. + + Notes + ----- + .. math:: + + V(\x,\w) = \frac{1}{\rho c} \e{-\i\wc\n\x} \n + + Examples + -------- + The particle velocity can be plotted on top of the sound pressure: + + .. plot:: + :context: close-figs + + v = sfs.fd.source.plane_velocity(omega, x0, n0, vgrid) + sfs.plot2d.amplitude(p, grid) + sfs.plot2d.vectors(v, vgrid) + plt.title("Sound Pressure and Particle Velocity") + + """ + if c is None: + c = _default.c + if rho0 is None: + rho0 = _default.rho0 + v = plane(omega, x0, n0, grid, c=c) / (rho0 * c) + return _util.XyzComponents([v * n for n in n0]) + + +def plane_averaged_intensity(omega, x0, n0, grid, *, c=None, rho0=None): + r"""Averaged intensity of a plane wave. + + Parameters + ---------- + omega : float + Frequency of plane wave. + x0 : (3,) array_like + Position of plane wave. + n0 : (3,) array_like + Normal vector (direction) of plane wave. + 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. + rho0 : float, optional + Static density of air. + + Returns + ------- + `XyzComponents` + Averaged intensity at positions given by *grid*. + + Notes + ----- + .. math:: + + I(\x,\w) = \frac{1}{2\rho c} \n + + """ + if c is None: + c = _default.c + if rho0 is None: + rho0 = _default.rho0 + i = 1 / (2 * rho0 * c) + 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.fd.source.pulsating_sphere(omega, x0, radius, amplitude, grid) + sfs.plot2d.amplitude(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.fd.source.pulsating_sphere_velocity(omega, x0, radius, amplitude, vgrid) + sfs.plot2d.amplitude(p, grid) + sfs.plot2d.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 + if len(gridshape) > 2: + return _np.tile(p, [1, 1, gridshape[2]]) + else: + return p + + +def _hankel2_0(x): + """Wrapper for Hankel function of the second type using fast versions + of the Bessel functions of first/second kind in scipy""" + return _special.j0(x) - 1j * _special.y0(x) diff --git a/sfs/fd/wfs.py b/sfs/fd/wfs.py new file mode 100644 index 0000000..44fb2c6 --- /dev/null +++ b/sfs/fd/wfs.py @@ -0,0 +1,707 @@ +"""Compute WFS driving functions. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + import sfs + + plt.rcParams['figure.figsize'] = 6, 6 + + xs = -1.5, 1.5, 0 + xs_focused = -0.5, 0.5, 0 + # normal vector for plane wave: + npw = sfs.util.direction_vector(np.radians(-45)) + # normal vector for focused source: + ns_focused = sfs.util.direction_vector(np.radians(-45)) + f = 300 # Hz + omega = 2 * np.pi * f + R = 1.5 # Radius of circular loudspeaker array + + grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) + + array = sfs.array.circular(N=32, R=R) + + def plot(d, selection, secondary_source): + p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) + sfs.plot2d.amplitude(p, grid) + sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) + +""" +import numpy as _np +from numpy.core.umath_tests import inner1d as _inner1d +from scipy.special import hankel2 as _hankel2 + +from . import secondary_source_line as _secondary_source_line +from . import secondary_source_point as _secondary_source_point +from .. import util as _util + + +def line_2d(omega, x0, n0, xs, *, c=None): + r"""Driving function for 2-dimensional WFS for a virtual line source. + + Parameters + ---------- + omega : float + Angular frequency of line source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of virtual line source. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\x_0,\w) = \frac{\i}{2} \wc + \frac{\scalarprod{\x-\x_0}{\n_0}}{|\x-\x_\text{s}|} + \Hankel{2}{1}{\wc|\x-\x_\text{s}|} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.line_2d( + omega, array.x, array.n, xs) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = -1j/2 * k * _inner1d(ds, n0) / r * _hankel2(1, k * r) + selection = _util.source_selection_line(n0, x0, xs) + return d, selection, _secondary_source_line(omega, c) + + +def _point(omega, x0, n0, xs, *, c=None): + r"""Driving function for 2/3-dimensional WFS for a virtual point source. + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of virtual point source. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\x_0, \w) = \i\wc \frac{\scalarprod{\x_0-\x_\text{s}}{\n_0}} + {|\x_0-\x_\text{s}|^{\frac{3}{2}}} + \e{-\i\wc |\x_0-\x_\text{s}|} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.point_3d( + omega, array.x, array.n, xs) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = 1j * k * _inner1d(ds, n0) / r ** (3 / 2) * _np.exp(-1j * k * r) + selection = _util.source_selection_point(n0, x0, xs) + return d, selection, _secondary_source_point(omega, c) + + +point_2d = _point + + +def point_25d(omega, x0, n0, xs, xref=[0, 0, 0], c=None, omalias=None): + r"""Driving function for 2.5-dimensional WFS of a virtual point source. + + .. versionchanged:: 0.5 + see notes, old handling of `point_25d()` is now `point_25d_legacy()` + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of virtual point source. + xref : (N, 3) array_like or (3,) array_like + Contour xref(x0) for amplitude correct synthesis, reference point xref. + c : float, optional + Speed of sound in m/s. + omalias: float, optional + Angular frequency where spatial aliasing becomes prominent. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + `point_25d()` derives 2.5D WFS from the 3D + Neumann-Rayleigh integral (i.e. the TU Delft approach). + The eq. (3.10), (3.11) in :cite:`Start1997`, equivalent to + Eq. (2.137) in :cite:`Schultz2016` + + .. math:: + + D(\x_0,\w) = \sqrt{8 \pi \, \i\wc} + \sqrt{\frac{|\x_\text{ref}-\x_0| \cdot + |\x_0-\x_\text{s}|}{|\x_\text{ref}-\x_0| + |\x_0-\x_\text{s}|}} + \scalarprod{\frac{\x_0-\x_\text{s}}{|\x_0-\x_\text{s}|}}{\n_0} + \frac{\e{-\i\wc |\x_0-\x_\text{s}|}}{4\pi\,|\x_0-\x_\text{s}|} + + is implemented. + The theoretical link of `point_25d()` and `point_25d_legacy()` was + introduced as *unified WFS framework* in :cite:`Firtha2017`. + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.point_25d( + omega, array.x, array.n, xs) + normalize_gain = 4 * np.pi * np.linalg.norm(xs) + plot(normalize_gain * d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_of_rows(xref) + k = _util.wavenumber(omega, c) + + ds = x0 - xs + dr = xref - x0 + s = _np.linalg.norm(ds, axis=1) + r = _np.linalg.norm(dr, axis=1) + + d = ( + preeq_25d(omega, omalias, c) * + _np.sqrt(8 * _np.pi) * + _np.sqrt((r * s) / (r + s)) * + _inner1d(n0, ds) / s * + _np.exp(-1j * k * s) / (4 * _np.pi * s)) + selection = _util.source_selection_point(n0, x0, xs) + return d, selection, _secondary_source_point(omega, c) + + +point_3d = _point + + +def point_25d_legacy(omega, x0, n0, xs, xref=[0, 0, 0], c=None, omalias=None): + r"""Driving function for 2.5-dimensional WFS for a virtual point source. + + .. versionadded:: 0.5 + `point_25d()` was renamed to `point_25d_legacy()` (and a new + function with the name `point_25d()` was introduced). See notes for + further details. + + Parameters + ---------- + omega : float + Angular frequency of point source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of virtual point source. + xref : (3,) array_like, optional + Reference point for synthesized sound field. + c : float, optional + Speed of sound. + omalias: float, optional + Angular frequency where spatial aliasing becomes prominent. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + `point_25d_legacy()` derives 2.5D WFS from the 2D + Neumann-Rayleigh integral (i.e. the approach by Rabenstein & Spors), cf. + :cite:`Spors2008`. + + .. math:: + + D(\x_0,\w) = \sqrt{\i\wc |\x_\text{ref}-\x_0|} + \frac{\scalarprod{\x_0-\x_\text{s}}{\n_0}} + {|\x_0-\x_\text{s}|^\frac{3}{2}} + \e{-\i\wc |\x_0-\x_\text{s}|} + + The theoretical link of `point_25d()` and `point_25d_legacy()` was + introduced as *unified WFS framework* in :cite:`Firtha2017`. + Also cf. Eq. (2.145)-(2.147) :cite:`Schultz2016`. + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.point_25d_legacy( + omega, array.x, array.n, xs) + normalize_gain = np.linalg.norm(xs) + plot(normalize_gain * d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_1d(xref) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = ( + preeq_25d(omega, omalias, c) * + _np.sqrt(_np.linalg.norm(xref - x0)) * _inner1d(ds, n0) / + r ** (3 / 2) * _np.exp(-1j * k * r)) + selection = _util.source_selection_point(n0, x0, xs) + return d, selection, _secondary_source_point(omega, c) + + +def _plane(omega, x0, n0, n=[0, 1, 0], *, c=None): + r"""Driving function for 2/3-dimensional WFS for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + n : (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + Eq.(17) from :cite:`Spors2008`: + + .. math:: + + D(\x_0,\w) = \i\wc \scalarprod{\n}{\n_0} + \e{-\i\wc\scalarprod{\n}{\x_0}} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.plane_3d( + omega, array.x, array.n, npw) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + n = _util.normalize_vector(n) + k = _util.wavenumber(omega, c) + d = 2j * k * _np.inner(n, n0) * _np.exp(-1j * k * _np.inner(n, x0)) + selection = _util.source_selection_plane(n0, n) + return d, selection, _secondary_source_point(omega, c) + + +plane_2d = _plane + + +def plane_25d(omega, x0, n0, n=[0, 1, 0], *, xref=[0, 0, 0], c=None, + omalias=None): + r"""Driving function for 2.5-dimensional WFS for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + n : (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + xref : (3,) array_like, optional + Reference point for synthesized sound field. + c : float, optional + Speed of sound. + omalias: float, optional + Angular frequency where spatial aliasing becomes prominent. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D_\text{2.5D}(\x_0,\w) = \sqrt{\i\wc |\x_\text{ref}-\x_0|} + \scalarprod{\n}{\n_0} + \e{-\i\wc \scalarprod{\n}{\x_0}} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.plane_25d( + omega, array.x, array.n, npw) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + n = _util.normalize_vector(n) + xref = _util.asarray_1d(xref) + k = _util.wavenumber(omega, c) + d = ( + preeq_25d(omega, omalias, c) * + _np.sqrt(8 * _np.pi * _np.linalg.norm(xref - x0, axis=-1)) * + _np.inner(n, n0) * _np.exp(-1j * k * _np.inner(n, x0))) + selection = _util.source_selection_plane(n0, n) + return d, selection, _secondary_source_point(omega, c) + + +plane_3d = _plane + + +def _focused(omega, x0, n0, xs, ns, *, c=None): + r"""Driving function for 2/3-dimensional WFS for a focused source. + + Parameters + ---------- + omega : float + Angular frequency of focused source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of focused source. + ns : (3,) array_like + Direction of focused source. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\x_0,\w) = \i\wc \frac{\scalarprod{\x_0-\x_\text{s}}{\n_0}} + {|\x_0-\x_\text{s}|^\frac{3}{2}} + \e{\i\wc |\x_0-\x_\text{s}|} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.focused_3d( + omega, array.x, array.n, xs_focused, ns_focused) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = 1j * k * _inner1d(ds, n0) / r ** (3 / 2) * _np.exp(1j * k * r) + selection = _util.source_selection_focused(ns, x0, xs) + return d, selection, _secondary_source_point(omega, c) + + +focused_2d = _focused + + +def focused_25d(omega, x0, n0, xs, ns, *, xref=[0, 0, 0], c=None, + omalias=None): + r"""Driving function for 2.5-dimensional WFS for a focused source. + + Parameters + ---------- + omega : float + Angular frequency of focused source. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + xs : (3,) array_like + Position of focused source. + ns : (3,) array_like + Direction of focused source. + xref : (3,) array_like, optional + Reference point for synthesized sound field. + c : float, optional + Speed of sound. + omalias: float, optional + Angular frequency where spatial aliasing becomes prominent. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\x_0,\w) = \sqrt{\i\wc |\x_\text{ref}-\x_0|} + \frac{\scalarprod{\x_0-\x_\text{s}}{\n_0}} + {|\x_0-\x_\text{s}|^\frac{3}{2}} + \e{\i\wc |\x_0-\x_\text{s}|} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.focused_25d( + omega, array.x, array.n, xs_focused, ns_focused) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_1d(xref) + k = _util.wavenumber(omega, c) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + d = ( + preeq_25d(omega, omalias, c) * + _np.sqrt(_np.linalg.norm(xref - x0)) * _inner1d(ds, n0) / + r ** (3 / 2) * _np.exp(1j * k * r)) + selection = _util.source_selection_focused(ns, x0, xs) + return d, selection, _secondary_source_point(omega, c) + + +focused_3d = _focused + + +def preeq_25d(omega, omalias, c): + r"""Pre-equalization filter for 2.5-dimensional WFS. + + Parameters + ---------- + omega : float + Angular frequency. + omalias: float + Angular frequency where spatial aliasing becomes prominent. + c : float + Speed of sound. + + Returns + ------- + float + Complex weight for given angular frequency. + + Notes + ----- + .. math:: + + H(\w) = \begin{cases} + \sqrt{\i \wc} & \text{for } \w \leq \w_\text{alias} \\ + \sqrt{\i \frac{\w_\text{alias}}{c}} & + \text{for } \w > \w_\text{alias} + \end{cases} + + """ + if omalias is None: + return _np.sqrt(1j * _util.wavenumber(omega, c)) + else: + if omega <= omalias: + return _np.sqrt(1j * _util.wavenumber(omega, c)) + else: + return _np.sqrt(1j * _util.wavenumber(omalias, c)) + + +def plane_3d_delay(omega, x0, n0, n=[0, 1, 0], *, c=None): + r"""Delay-only driving function for a virtual plane wave. + + Parameters + ---------- + omega : float + Angular frequency of plane wave. + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of normal vectors of secondary sources. + n : (3,) array_like, optional + Normal vector (traveling direction) of plane wave. + c : float, optional + Speed of sound. + + Returns + ------- + d : (N,) numpy.ndarray + Complex weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.fd.synthesize()`. + + Notes + ----- + .. math:: + + D(\x_0,\w) = \e{-\i\wc\scalarprod{\n}{\x_0}} + + Examples + -------- + .. plot:: + :context: close-figs + + d, selection, secondary_source = sfs.fd.wfs.plane_3d_delay( + omega, array.x, array.n, npw) + plot(d, selection, secondary_source) + + """ + x0 = _util.asarray_of_rows(x0) + n = _util.normalize_vector(n) + k = _util.wavenumber(omega, c) + d = _np.exp(-1j * k * _np.inner(n, x0)) + selection = _util.source_selection_plane(n0, n) + return d, selection, _secondary_source_point(omega, c) + + +def soundfigure_3d(omega, x0, n0, figure, npw=[0, 0, 1], *, c=None): + """Compute driving function for a 2D sound figure. + + Based on + [Helwani et al., The Synthesis of Sound Figures, MSSP, 2013] + + """ + x0 = _np.asarray(x0) + n0 = _np.asarray(n0) + k = _util.wavenumber(omega, c) + nx, ny = figure.shape + + # 2D spatial DFT of image + figure = _np.fft.fftshift(figure, axes=(0, 1)) # sign of spatial DFT + figure = _np.fft.fft2(figure) + # wavenumbers + kx = _np.fft.fftfreq(nx, 1./nx) + ky = _np.fft.fftfreq(ny, 1./ny) + # shift spectrum due to desired plane wave + figure = _np.roll(figure, int(k*npw[0]), axis=0) + figure = _np.roll(figure, int(k*npw[1]), axis=1) + # search and iterate over propagating plane wave components + kxx, kyy = _np.meshgrid(kx, ky, sparse=True) + rho = _np.sqrt((kxx) ** 2 + (kyy) ** 2) + d = 0 + for n in range(nx): + for m in range(ny): + if(rho[n, m] < k): + # dispertion relation + kz = _np.sqrt(k**2 - rho[n, m]**2) + # normal vector of plane wave + npw = 1/k * _np.asarray([kx[n], ky[m], kz]) + npw = npw / _np.linalg.norm(npw) + # driving function of plane wave with positive kz + d_component, selection, secondary_source = plane_3d( + omega, x0, n0, npw, c=c) + d += selection * figure[n, m] * d_component + + return d, _util.source_selection_all(len(d)), secondary_source diff --git a/sfs/mono/__init__.py b/sfs/mono/__init__.py deleted file mode 100644 index 8c09fd8..0000000 --- a/sfs/mono/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Submodules for monochromatic sound fields.""" -from . import drivingfunction -from . import source -from . import synthesized -from . import soundfigure diff --git a/sfs/mono/drivingfunction.py b/sfs/mono/drivingfunction.py deleted file mode 100644 index 06175c9..0000000 --- a/sfs/mono/drivingfunction.py +++ /dev/null @@ -1,674 +0,0 @@ -"""Compute driving functions for various systems. - -.. include:: math-definitions.rst - -""" - -import numpy as np -from numpy.core.umath_tests import inner1d # element-wise inner product -from scipy.special import jn, hankel2 -from .. import util -from .. import defs - - -def wfs_2d_line(omega, x0, n0, xs, c=None): - """Line source by 2-dimensional WFS. - - :: - - D(x0,k) = j/2 k (x0-xs) n0 / |x0-xs| * H1(k |x0-xs|) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - return -1j/2 * k * inner1d(ds, n0) / r * hankel2(1, k * r) - - -def _wfs_point(omega, x0, n0, xs, c=None): - """Point source by two- or three-dimensional WFS. - - :: - - (x0-xs) n0 - D(x0,k) = j k ------------- e^(-j k |x0-xs|) - |x0-xs|^(3/2) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - return 1j * k * inner1d(ds, n0) / r ** (3 / 2) * np.exp(-1j * k * r) - - -wfs_2d_point = _wfs_point - - -def wfs_25d_point(omega, x0, n0, xs, xref=[0, 0, 0], c=None, omalias=None): - """Point source by 2.5-dimensional WFS. - - :: - - ____________ (x0-xs) n0 - D(x0,k) = \|j k |xref-x0| ------------- e^(-j k |x0-xs|) - |x0-xs|^(3/2) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - xref = util.asarray_1d(xref) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - - return wfs_25d_preeq(omega, omalias, c) * \ - np.sqrt(np.linalg.norm(xref - x0)) * inner1d(ds, n0) / \ - r ** (3 / 2) * np.exp(-1j * k * r) - - -wfs_3d_point = _wfs_point - - -def _wfs_plane(omega, x0, n0, n=[0, 1, 0], c=None): - """Plane wave by two- or three-dimensional WFS. - - Eq.(17) from :cite:`Spors2008`:: - - D(x0,k) = j k n n0 e^(-j k n x0) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - k = util.wavenumber(omega, c) - return 2j * k * np.inner(n, n0) * np.exp(-1j * k * np.inner(n, x0)) - - -wfs_2d_plane = _wfs_plane - - -def wfs_25d_plane(omega, x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None, - omalias=None): - """Plane wave by 2.5-dimensional WFS. - - :: - - ____________ - D_2.5D(x0,w) = \|j k |xref-x0| n n0 e^(-j k n x0) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - xref = util.asarray_1d(xref) - k = util.wavenumber(omega, c) - return wfs_25d_preeq(omega, omalias, c) * \ - np.sqrt(2*np.pi * np.linalg.norm(xref - x0)) * \ - np.inner(n, n0) * np.exp(-1j * k * np.inner(n, x0)) - - -wfs_3d_plane = _wfs_plane - - -def _wfs_focused(omega, x0, n0, xs, c=None): - """Focused source by two- or three-dimensional WFS. - - :: - - (x0-xs) n0 - D(x0,k) = j k ------------- e^(j k |x0-xs|) - |x0-xs|^(3/2) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - return 1j * k * inner1d(ds, n0) / r ** (3 / 2) * np.exp(1j * k * r) - - -wfs_2d_focused = _wfs_focused - - -def wfs_25d_focused(omega, x0, n0, xs, xref=[0, 0, 0], c=None, omalias=None): - """Focused source by 2.5-dimensional WFS. - - :: - - ____________ (x0-xs) n0 - D(x0,w) = \|j k |xref-x0| ------------- e^(j k |x0-xs|) - |x0-xs|^(3/2) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - xref = util.asarray_1d(xref) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - - return wfs_25d_preeq(omega, omalias, c) * \ - np.sqrt(np.linalg.norm(xref - x0)) * inner1d(ds, n0) / \ - r ** (3 / 2) * np.exp(1j * k * r) - - -wfs_3d_focused = _wfs_focused - - -def wfs_25d_preeq(omega, omalias, c): - """Preqeualization for 2.5D WFS.""" - if omalias is None: - return np.sqrt(1j * util.wavenumber(omega, c)) - else: - if omega <= omalias: - return np.sqrt(1j * util.wavenumber(omega, c)) - else: - return np.sqrt(1j * util.wavenumber(omalias, c)) - - -def delay_3d_plane(omega, x0, n0, n=[0, 1, 0], c=None): - """Plane wave by simple delay of secondary sources.""" - x0 = util.asarray_of_rows(x0) - n = util.normalize_vector(n) - k = util.wavenumber(omega, c) - return np.exp(-1j * k * np.inner(n, x0)) - - -def source_selection_plane(n0, n): - """Secondary source selection for a plane wave. - - Eq.(13) from :cite:`Spors2008` - - """ - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - return np.inner(n, n0) >= defs.selection_tolerance - - -def source_selection_point(n0, x0, xs): - """Secondary source selection for a point source. - - Eq.(15) from :cite:`Spors2008` - - """ - n0 = util.asarray_of_rows(n0) - x0 = util.asarray_of_rows(x0) - xs = util.asarray_1d(xs) - ds = x0 - xs - return inner1d(ds, n0) >= defs.selection_tolerance - - -def source_selection_line(n0, x0, xs): - """Secondary source selection for a line source. - - compare Eq.(15) from :cite:`Spors2008` - - """ - return source_selection_point(n0, x0, xs) - - -def source_selection_focused(ns, x0, xs): - """Secondary source selection for a focused source. - - Eq.(2.78) from :cite:`Wierstorf2014` - - """ - x0 = util.asarray_of_rows(x0) - xs = util.asarray_1d(xs) - ns = util.normalize_vector(ns) - ds = xs - x0 - return inner1d(ns, ds) >= defs.selection_tolerance - - -def source_selection_all(N): - """Select all secondary sources.""" - return np.ones(N, dtype=bool) - - -def nfchoa_2d_plane(omega, x0, r0, n=[0, 1, 0], max_order=None, c=None): - r"""Plane wave by two-dimensional NFC-HOA. - - .. math:: - - D(\phi_0, \omega) = - -\frac{2\i}{\pi r_0} - \sum_{m=-M}^M - \frac{\i^{-m}}{\Hankel{2}{m}{\wc r_0}} - \e{\i m (\phi_0 - \phi_\text{pw})} - - See http://sfstoolbox.org/#equation-D.nfchoa.pw.2D. - - """ - x0 = util.asarray_of_rows(x0) - k = util.wavenumber(omega, c) - n = util.normalize_vector(n) - phi, _, r = util.cart2sph(*n) - phi0 = util.cart2sph(*x0.T)[0] - M = _max_order_circular_harmonics(len(x0), max_order) - d = 0 - for m in range(-M, M + 1): - d += 1j**-m / hankel2(m, k * r0) * np.exp(1j * m * (phi0 - phi)) - return -2j / (np.pi*r0) * d - - -def nfchoa_25d_point(omega, x0, r0, xs, max_order=None, c=None): - r"""Point source by 2.5-dimensional NFC-HOA. - - .. math:: - - D(\phi_0, \omega) = - \frac{1}{2 \pi r_0} - \sum_{m=-M}^M - \frac{\hankel{2}{|m|}{\wc r}}{\hankel{2}{|m|}{\wc r_0}} - \e{\i m (\phi_0 - \phi)} - - See http://sfstoolbox.org/#equation-D.nfchoa.ps.2.5D. - - """ - x0 = util.asarray_of_rows(x0) - k = util.wavenumber(omega, c) - xs = util.asarray_1d(xs) - phi, _, r = util.cart2sph(*xs) - phi0 = util.cart2sph(*x0.T)[0] - M = _max_order_circular_harmonics(len(x0), max_order) - hr = util.spherical_hn2(range(0, M + 1), k * r) - hr0 = util.spherical_hn2(range(0, M + 1), k * r0) - d = 0 - for m in range(-M, M + 1): - d += hr[abs(m)] / hr0[abs(m)] * np.exp(1j * m * (phi0 - phi)) - return d / (2 * np.pi * r0) - - -def nfchoa_25d_plane(omega, x0, r0, n=[0, 1, 0], max_order=None, c=None): - r"""Plane wave by 2.5-dimensional NFC-HOA. - - .. math:: - - D(\phi_0, \omega) = - \frac{2\i}{r_0} - \sum_{m=-M}^M - \frac{\i^{-|m|}}{\wc \hankel{2}{|m|}{\wc r_0}} - \e{\i m (\phi_0 - \phi_\text{pw})} - - See http://sfstoolbox.org/#equation-D.nfchoa.pw.2.5D. - - """ - x0 = util.asarray_of_rows(x0) - k = util.wavenumber(omega, c) - n = util.normalize_vector(n) - phi, _, r = util.cart2sph(*n) - phi0 = util.cart2sph(*x0.T)[0] - M = _max_order_circular_harmonics(len(x0), max_order) - d = 0 - hn2 = util.spherical_hn2(range(0, M + 1), k * r0) - for m in range(-M, M + 1): - d += 1j**-abs(m) / (k * hn2[abs(m)]) * np.exp(1j * m * (phi0 - phi)) - return -2 / r0 * d - - -def sdm_2d_line(omega, x0, n0, xs, c=None): - """Line source by two-dimensional SDM. - - The secondary sources have to be located on the x-axis (y0=0). - Derived from :cite:`Spors2009`, Eq.(9), Eq.(4):: - - D(x0,k) = - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - return - 1j/2 * k * xs[1] / r * hankel2(1, k * r) - - -def sdm_2d_plane(omega, x0, n0, n=[0, 1, 0], c=None): - """Plane wave by two-dimensional SDM. - - The secondary sources have to be located on the x-axis (y0=0). - Derived from :cite:`Ahrens2012`, Eq.(3.73), Eq.(C.5), Eq.(C.11):: - - D(x0,k) = kpw,y * e^(-j*kpw,x*x) - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - k = util.wavenumber(omega, c) - return k * n[1] * np.exp(-1j * k * n[0] * x0[:, 0]) - - -def sdm_25d_plane(omega, x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None): - """Plane wave by 2.5-dimensional SDM. - - The secondary sources have to be located on the x-axis (y0=0). - Eq.(3.79) from :cite:`Ahrens2012`:: - - D_2.5D(x0,w) = - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - xref = util.asarray_1d(xref) - k = util.wavenumber(omega, c) - return 4j * np.exp(-1j*k*n[1]*xref[1]) / hankel2(0, k*n[1]*xref[1]) * \ - np.exp(-1j*k*n[0]*x0[:, 0]) - - -def sdm_25d_point(omega, x0, n0, xs, xref=[0, 0, 0], c=None): - """Point source by 2.5-dimensional SDM. - - The secondary sources have to be located on the x-axis (y0=0). - Driving funcnction from :cite:`Spors2010`, Eq.(24):: - - D(x0,k) = - - """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - xref = util.asarray_1d(xref) - k = util.wavenumber(omega, c) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - return 1/2 * 1j * k * np.sqrt(xref[1] / (xref[1] - xs[1])) * \ - xs[1] / r * hankel2(1, k * r) - - -def esa_edge_2d_plane(omega, x0, n=[0, 1, 0], alpha=3/2*np.pi, Nc=None, c=None): - """Plane wave by two-dimensional ESA for an edge-shaped secondary source - distribution consisting of monopole line sources. - - One leg of the secondary sources has to be located on the x-axis (y0=0), - the edge at the origin. - - Derived from :cite:`Spors2016` - - Parameters - ---------- - omega : float - Angular frequency. - x0 : int(N, 3) array_like - Sequence of secondary source positions. - n : (3,) array_like, optional - Normal vector of synthesized plane wave. - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. Estimated - if not given. - c : float, optional - Speed of sound - - Returns - ------- - (N,) numpy.ndarray - Complex weights of secondary sources. - - """ - x0 = np.asarray(x0) - n = util.normalize_vector(n) - k = util.wavenumber(omega, c) - phi_s = np.arctan2(n[1], n[0]) + np.pi - L = x0.shape[0] - - r = np.linalg.norm(x0, axis=1) - phi = np.arctan2(x0[:, 1], x0[:, 0]) - phi = np.where(phi < 0, phi+2*np.pi, phi) - - if Nc is None: - Nc = np.ceil(2 * k * np.max(r) * alpha/np.pi) - - epsilon = np.ones(Nc) # weights for series expansion - epsilon[0] = 2 - - d = np.zeros(L, dtype=complex) - for m in np.arange(Nc): - nu = m*np.pi/alpha - d = d + 1/epsilon[m] * np.exp(1j*nu*np.pi/2) * np.sin(nu*phi_s) \ - * np.cos(nu*phi) * nu/r * jn(nu, k*r) - - d[phi > 0] = -d[phi > 0] - - return 4*np.pi/alpha * d - - -def esa_edge_dipole_2d_plane(omega, x0, n=[0, 1, 0], alpha=3/2*np.pi, Nc=None, c=None): - """Plane wave by two-dimensional ESA for an edge-shaped secondary source - distribution consisting of dipole line sources. - - One leg of the secondary sources has to be located on the x-axis (y0=0), - the edge at the origin. - - Derived from :cite:`Spors2016` - - Parameters - ---------- - omega : float - Angular frequency. - x0 : int(N, 3) array_like - Sequence of secondary source positions. - n : (3,) array_like, optional - Normal vector of synthesized plane wave. - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. Estimated - if not given. - c : float, optional - Speed of sound - - Returns - ------- - (N,) numpy.ndarray - Complex weights of secondary sources. - - """ - x0 = np.asarray(x0) - n = util.normalize_vector(n) - k = util.wavenumber(omega, c) - phi_s = np.arctan2(n[1], n[0]) + np.pi - L = x0.shape[0] - - r = np.linalg.norm(x0, axis=1) - phi = np.arctan2(x0[:, 1], x0[:, 0]) - phi = np.where(phi < 0, phi+2*np.pi, phi) - - if Nc is None: - Nc = np.ceil(2 * k * np.max(r) * alpha/np.pi) - - epsilon = np.ones(Nc) # weights for series expansion - epsilon[0] = 2 - - d = np.zeros(L, dtype=complex) - for m in np.arange(Nc): - nu = m*np.pi/alpha - d = d + 1/epsilon[m] * np.exp(1j*nu*np.pi/2) * np.cos(nu*phi_s) \ - * np.cos(nu*phi) * jn(nu, k*r) - - return 4*np.pi/alpha * d - - -def esa_edge_2d_line(omega, x0, xs, alpha=3/2*np.pi, Nc=None, c=None): - """Line source by two-dimensional ESA for an edge-shaped secondary source - distribution constisting of monopole line sources. - - One leg of the secondary sources have to be located on the x-axis (y0=0), - the edge at the origin. - - Derived from :cite:`Spors2016` - - Parameters - ---------- - omega : float - Angular frequency. - x0 : int(N, 3) array_like - Sequence of secondary source positions. - xs : (3,) array_like - Position of synthesized line source. - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. Estimated - if not given. - c : float, optional - Speed of sound - - Returns - ------- - (N,) numpy.ndarray - Complex weights of secondary sources. - - """ - x0 = np.asarray(x0) - k = util.wavenumber(omega, c) - phi_s = np.arctan2(xs[1], xs[0]) - if phi_s < 0: - phi_s = phi_s + 2*np.pi - r_s = np.linalg.norm(xs) - L = x0.shape[0] - - r = np.linalg.norm(x0, axis=1) - phi = np.arctan2(x0[:, 1], x0[:, 0]) - phi = np.where(phi < 0, phi+2*np.pi, phi) - - if Nc is None: - Nc = np.ceil(2 * k * np.max(r) * alpha/np.pi) - - epsilon = np.ones(Nc) # weights for series expansion - epsilon[0] = 2 - - d = np.zeros(L, dtype=complex) - idx = (r <= r_s) - for m in np.arange(Nc): - nu = m*np.pi/alpha - f = 1/epsilon[m] * np.sin(nu*phi_s) * np.cos(nu*phi) * nu/r - d[idx] = d[idx] + f[idx] * jn(nu, k*r[idx]) * hankel2(nu, k*r_s) - d[~idx] = d[~idx] + f[~idx] * jn(nu, k*r_s) * hankel2(nu, k*r[~idx]) - - d[phi > 0] = -d[phi > 0] - - return -1j*np.pi/alpha * d - - -def esa_edge_25d_point(omega, x0, xs, xref=[2, -2, 0], alpha=3/2*np.pi, Nc=None, c=None): - """Point source by 2.5-dimensional ESA for an edge-shaped secondary source - distribution constisting of monopole line sources. - - One leg of the secondary sources have to be located on the x-axis (y0=0), - the edge at the origin. - - Derived from :cite:`Spors2016` - - Parameters - ---------- - omega : float - Angular frequency. - x0 : int(N, 3) array_like - Sequence of secondary source positions. - xs : (3,) array_like - Position of synthesized line source. - xref: (3,) array_like or float - Reference position or reference distance - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. Estimated - if not given. - c : float, optional - Speed of sound - - Returns - ------- - (N,) numpy.ndarray - Complex weights of secondary sources. - - """ - x0 = np.asarray(x0) - xs = np.asarray(xs) - xref = np.asarray(xref) - - if np.isscalar(xref): - a = np.linalg.norm(xref)/np.linalg.norm(xref-xs) - else: - a = np.linalg.norm(xref-x0, axis=1)/np.linalg.norm(xref-xs) - - return 1j*np.sqrt(a) * esa_edge_2d_line(omega, x0, xs, alpha=alpha, Nc=Nc, c=c) - - -def esa_edge_dipole_2d_line(omega, x0, xs, alpha=3/2*np.pi, Nc=None, c=None): - """Line source by two-dimensional ESA for an edge-shaped secondary source - distribution constisting of dipole line sources. - - One leg of the secondary sources have to be located on the x-axis (y0=0), - the edge at the origin. - - Derived from :cite:`Spors2016` - - Parameters - ---------- - omega : float - Angular frequency. - x0 : (N, 3) array_like - Sequence of secondary source positions. - xs : (3,) array_like - Position of synthesized line source. - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. Estimated - if not given. - c : float, optional - Speed of sound - - Returns - ------- - (N,) numpy.ndarray - Complex weights of secondary sources. - - """ - x0 = np.asarray(x0) - k = util.wavenumber(omega, c) - phi_s = np.arctan2(xs[1], xs[0]) - if phi_s < 0: - phi_s = phi_s + 2*np.pi - r_s = np.linalg.norm(xs) - L = x0.shape[0] - - r = np.linalg.norm(x0, axis=1) - phi = np.arctan2(x0[:, 1], x0[:, 0]) - phi = np.where(phi < 0, phi+2*np.pi, phi) - - if Nc is None: - Nc = np.ceil(2 * k * np.max(r) * alpha/np.pi) - - epsilon = np.ones(Nc) # weights for series expansion - epsilon[0] = 2 - - d = np.zeros(L, dtype=complex) - idx = (r <= r_s) - for m in np.arange(Nc): - nu = m*np.pi/alpha - f = 1/epsilon[m] * np.cos(nu*phi_s) * np.cos(nu*phi) - d[idx] = d[idx] + f[idx] * jn(nu, k*r[idx]) * hankel2(nu, k*r_s) - d[~idx] = d[~idx] + f[~idx] * jn(nu, k*r_s) * hankel2(nu, k*r[~idx]) - - return -1j*np.pi/alpha * d - - -def _max_order_circular_harmonics(N, max_order): - """Compute order of 2D HOA.""" - return N // 2 if max_order is None else max_order diff --git a/sfs/mono/soundfigure.py b/sfs/mono/soundfigure.py deleted file mode 100644 index 957b830..0000000 --- a/sfs/mono/soundfigure.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Compute driving functions for sound figures.""" - -import numpy as np -from .. import util -from . import drivingfunction - - -def wfs_3d_pw(omega, x0, n0, figure, npw=[0, 0, 1], c=None): - """Compute driving function for a 2D sound figure. - - Based on - [Helwani et al., The Synthesis of Sound Figures, MSSP, 2013] - - """ - - x0 = np.asarray(x0) - n0 = np.asarray(n0) - k = util.wavenumber(omega, c) - nx, ny = figure.shape - - # 2D spatial DFT of image - figure = np.fft.fftshift(figure, axes=(0, 1)) # sign of spatial DFT - figure = np.fft.fft2(figure) - # wavenumbers - kx = np.fft.fftfreq(nx, 1./nx) - ky = np.fft.fftfreq(ny, 1./ny) - # shift spectrum due to desired plane wave - figure = np.roll(figure, int(k*npw[0]), axis=0) - figure = np.roll(figure, int(k*npw[1]), axis=1) - # search and iterate over propagating plane wave components - kxx, kyy = np.meshgrid(kx, ky, sparse=True) - rho = np.sqrt((kxx) ** 2 + (kyy) ** 2) - d = 0 - for n in range(nx): - for m in range(ny): - if(rho[n, m] < k): - # dispertion relation - kz = np.sqrt(k**2 - rho[n, m]**2) - # normal vector of plane wave - npw = 1/k * np.asarray([kx[n], ky[m], kz]) - npw = npw / np.linalg.norm(npw) - # driving function of plane wave with positive kz - a = drivingfunction.source_selection_plane(n0, npw) - a = a * figure[n, m] - d += a * drivingfunction.wfs_3d_plane(omega, x0, n0, npw, c) - - return d diff --git a/sfs/mono/source.py b/sfs/mono/source.py deleted file mode 100644 index 5f70e76..0000000 --- a/sfs/mono/source.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Compute the sound field generated by a sound source. - -.. plot:: - :context: reset - - import sfs - import numpy as np - import matplotlib.pyplot as plt - plt.rcParams['figure.figsize'] = 8, 4.5 # inch - - x0 = 1.5, 1, 0 - f = 500 # Hz - omega = 2 * np.pi * f - - normalization_point = 4 * np.pi - normalization_line = \\ - np.sqrt(8 * np.pi * omega / sfs.defs.c) * np.exp(1j * np.pi / 4) - - grid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.02) - - # Grid for vector fields: - vgrid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.1) - -""" - -import itertools -import numpy as np -from scipy import special -from .. import util -from .. import defs - - -def point(omega, x0, n0, grid, c=None): - """Point source. - - Notes - ----- - :: - - 1 e^(-j w/c |x-x0|) - G(x-x0, w) = --- ----------------- - 4pi |x-x0| - - Examples - -------- - .. plot:: - :context: close-figs - - p = sfs.mono.source.point(omega, x0, None, grid) - sfs.plot.soundfield(p, grid) - plt.title("Point Source at {} m".format(x0)) - - Normalization ... - - .. plot:: - :context: close-figs - - sfs.plot.soundfield(p * normalization_point, grid, - colorbar_kwargs=dict(label="p / Pa")) - plt.title("Point Source at {} m (normalized)".format(x0)) - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - grid = util.as_xyz_components(grid) - - r = np.linalg.norm(grid - x0) - return 1 / (4*np.pi) * np.exp(-1j * k * r) / r - - -def point_velocity(omega, x0, n0, grid, c=None): - """Velocity of a point source. - - Returns - ------- - `XyzComponents` - Particle velocity at positions given by *grid*. - - Examples - -------- - The particle velocity can be plotted on top of the sound pressure: - - .. plot:: - :context: close-figs - - v = sfs.mono.source.point_velocity(omega, x0, None, vgrid) - sfs.plot.soundfield(p * normalization_point, grid) - sfs.plot.vectors(v * normalization_point, vgrid) - plt.title("Sound Pressure and Particle Velocity") - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - grid = util.as_xyz_components(grid) - offset = grid - x0 - r = np.linalg.norm(offset) - v = point(omega, x0, n0, grid, c=c) - v *= (1+1j*k*r) / (defs.rho0 * defs.c * 1j*k*r) - return util.XyzComponents([v * o / r for o in offset]) - - -def point_dipole(omega, x0, n0, grid, c=None): - """Point source with dipole characteristics. - - Parameters - ---------- - omega : float - Frequency of source. - x0 : (3,) array_like - Position of source. - n0 : (3,) array_like - Normal vector (direction) of dipole. - 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 - ------- - numpy.ndarray - Sound pressure at positions given by *grid*. - - Notes - ----- - :: - - d 1 / iw 1 \ (x-x0) n0 - ---- G(x-x0,w) = --- | ----- + ------- | ----------- e^(-i w/c |x-x0|) - d ns 4pi \ c |x-x0| / |x-x0|^2 - - Examples - -------- - .. plot:: - :context: close-figs - - n0 = 0, 1, 0 - p = sfs.mono.source.point_dipole(omega, x0, n0, grid) - sfs.plot.soundfield(p, grid) - plt.title("Dipole Point Source at {} m".format(x0)) - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - n0 = util.asarray_1d(n0) - grid = util.as_xyz_components(grid) - - offset = grid - x0 - r = np.linalg.norm(offset) - return 1 / (4*np.pi) * (1j * k + 1 / r) * np.inner(offset, n0) / \ - np.power(r, 2) * np.exp(-1j * k * r) - - -def point_modal(omega, x0, n0, grid, L, N=None, deltan=0, c=None): - """Point source in a rectangular room using a modal room model. - - Parameters - ---------- - omega : float - Frequency of source. - x0 : (3,) array_like - Position of source. - n0 : (3,) array_like - Normal vector (direction) of source (only required for - compatibility). - grid : triple of array_like - The grid that is used for the sound field calculations. - See `sfs.util.xyz_grid()`. - L : (3,) array_like - Dimensionons of the rectangular room. - N : (3,) array_like or int, optional - For all three spatial dimensions per dimension maximum order or - list of orders. A scalar applies to all three dimensions. If no - order is provided it is approximately determined. - deltan : float, optional - Absorption coefficient of the walls. - c : float, optional - Speed of sound. - - Returns - ------- - numpy.ndarray - Sound pressure at positions given by *grid*. - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - x, y, z = util.as_xyz_components(grid) - - if np.isscalar(N): - N = N * np.ones(3, dtype=int) - - if N is None: - N = [None, None, None] - - orders = [0, 0, 0] - for i in range(3): - if N[i] is None: - # compute max order - orders[i] = range(int(np.ceil(L[i]/np.pi * k) + 1)) - elif np.isscalar(N[i]): - # use given max order - orders[i] = range(N[i] + 1) - else: - # use given orders - orders[i] = N[i] - - kmp0 = [((kx + 1j * deltan)**2, np.cos(kx * x) * np.cos(kx * x0[0])) - for kx in [m * np.pi / L[0] for m in orders[0]]] - kmp1 = [((ky + 1j * deltan)**2, np.cos(ky * y) * np.cos(ky * x0[1])) - for ky in [n * np.pi / L[1] for n in orders[1]]] - kmp2 = [((kz + 1j * deltan)**2, np.cos(kz * z) * np.cos(kz * x0[2])) - for kz in [l * np.pi / L[2] for l in orders[2]]] - ksquared = k**2 - p = 0 - for (km0, p0), (km1, p1), (km2, p2) in itertools.product(kmp0, kmp1, kmp2): - km = km0 + km1 + km2 - p = p + 8 / (ksquared - km) * p0 * p1 * p2 - return p - - -def point_modal_velocity(omega, x0, n0, grid, L, N=None, deltan=0, c=None): - """Velocity of point source in a rectangular room using a modal room model. - - Parameters - ---------- - omega : float - Frequency of source. - x0 : (3,) array_like - Position of source. - n0 : (3,) array_like - Normal vector (direction) of source (only required for - compatibility). - grid : triple of array_like - The grid that is used for the sound field calculations. - See `sfs.util.xyz_grid()`. - L : (3,) array_like - Dimensionons of the rectangular room. - N : (3,) array_like or int, optional - Combination of modal orders in the three-spatial dimensions to - calculate the sound field for or maximum order for all - dimensions. If not given, the maximum modal order is - approximately determined and the sound field is computed up to - this maximum order. - deltan : float, optional - Absorption coefficient of the walls. - c : float, optional - Speed of sound. - - Returns - ------- - `XyzComponents` - Particle velocity at positions given by *grid*. - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - x, y, z = util.as_xyz_components(grid) - - if N is None: - # determine maximum modal order per dimension - Nx = int(np.ceil(L[0]/np.pi * k)) - Ny = int(np.ceil(L[1]/np.pi * k)) - Nz = int(np.ceil(L[2]/np.pi * k)) - mm = range(Nx) - nn = range(Ny) - ll = range(Nz) - elif np.isscalar(N): - # compute up to a given order - mm = range(N) - nn = range(N) - ll = range(N) - else: - # compute field for one order combination only - mm = [N[0]] - nn = [N[1]] - ll = [N[2]] - - kmp0 = [((kx + 1j * deltan)**2, np.sin(kx * x) * np.cos(kx * x0[0])) - for kx in [m * np.pi / L[0] for m in mm]] - kmp1 = [((ky + 1j * deltan)**2, np.sin(ky * y) * np.cos(ky * x0[1])) - for ky in [n * np.pi / L[1] for n in nn]] - kmp2 = [((kz + 1j * deltan)**2, np.sin(kz * z) * np.cos(kz * x0[2])) - for kz in [l * np.pi / L[2] for l in ll]] - ksquared = k**2 - vx = 0+0j - vy = 0+0j - vz = 0+0j - for (km0, p0), (km1, p1), (km2, p2) in itertools.product(kmp0, kmp1, kmp2): - km = km0 + km1 + km2 - vx = vx - 8*1j / (ksquared - km) * p0 - vy = vy - 8*1j / (ksquared - km) * p1 - vz = vz - 8*1j / (ksquared - km) * p2 - return util.XyzComponents([vx, vy, vz]) - - -def point_image_sources(omega, x0, n0, grid, L, max_order, coeffs=None, - c=None): - """Point source in a rectangular room using the mirror image source model. - - Parameters - ---------- - omega : float - Frequency of source. - x0 : (3,) array_like - Position of source. - n0 : (3,) array_like - Normal vector (direction) of source (only required for - compatibility). - grid : triple of array_like - The grid that is used for the sound field calculations. - See `sfs.util.xyz_grid()`. - L : (3,) array_like - Dimensions of the rectangular room. - max_order : int - Maximum number of reflections for each image source. - coeffs : (6,) array_like, optional - Reflection coeffecients of the walls. - If not given, the reflection coefficients are set to one. - c : float, optional - Speed of sound. - - Returns - ------- - numpy.ndarray - Sound pressure at positions given by *grid*. - - """ - if coeffs is None: - coeffs = np.ones(6) - - xs, order = util.image_sources_for_box(x0, L, max_order) - source_strengths = np.prod(coeffs**order, axis=1) - - p = 0 - for position, strength in zip(xs, source_strengths): - if strength != 0: - p += strength * point(omega, position, n0, grid, c) - - return p - - -def line(omega, x0, n0, grid, c=None): - """Line source parallel to the z-axis. - - Note: third component of x0 is ignored. - - Notes - ----- - :: - - (2) - G(x-x0, w) = -j/4 H0 (w/c |x-x0|) - - Examples - -------- - .. plot:: - :context: close-figs - - p = sfs.mono.source.line(omega, x0, None, grid) - sfs.plot.soundfield(p, grid) - plt.title("Line Source at {} m".format(x0[:2])) - - Normalization ... - - .. plot:: - :context: close-figs - - sfs.plot.soundfield(p * normalization_line, grid, - colorbar_kwargs=dict(label="p / Pa")) - plt.title("Line Source at {} m (normalized)".format(x0[:2])) - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0)[:2] # ignore z-component - grid = util.as_xyz_components(grid) - - r = np.linalg.norm(grid[:2] - x0) - p = -1j/4 * _hankel2_0(k * r) - return _duplicate_zdirection(p, grid) - - -def line_velocity(omega, x0, n0, grid, c=None): - """Velocity of line source parallel to the z-axis. - - Returns - ------- - `XyzComponents` - Particle velocity at positions given by *grid*. - - Examples - -------- - The particle velocity can be plotted on top of the sound pressure: - - .. plot:: - :context: close-figs - - v = sfs.mono.source.line_velocity(omega, x0, None, vgrid) - sfs.plot.soundfield(p * normalization_line, grid) - sfs.plot.vectors(v * normalization_line, vgrid) - plt.title("Sound Pressure and Particle Velocity") - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0)[:2] # ignore z-component - grid = util.as_xyz_components(grid) - - offset = grid[:2] - x0 - r = np.linalg.norm(offset) - v = -1/(4*defs.c*defs.rho0) * special.hankel2(1, k * r) - v = [v * o / r for o in offset] - - assert v[0].shape == v[1].shape - - if len(grid) > 2: - v.append(np.zeros_like(v[0])) - - return util.XyzComponents([_duplicate_zdirection(vi, grid) for vi in v]) - - -def line_dipole(omega, x0, n0, grid, c=None): - """Line source with dipole characteristics parallel to the z-axis. - - Note: third component of x0 is ignored. - - Notes - ----- - :: - - (2) - G(x-x0, w) = jk/4 H1 (w/c |x-x0|) cos(phi) - - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0)[:2] # ignore z-components - n0 = util.asarray_1d(n0)[:2] - grid = util.as_xyz_components(grid) - dx = grid[:2] - x0 - - r = np.linalg.norm(dx) - p = 1j*k/4 * special.hankel2(1, k * r) * np.inner(dx, n0) / r - return _duplicate_zdirection(p, grid) - - -def line_dirichlet_edge(omega, x0, grid, alpha=3/2*np.pi, Nc=None, c=None): - """Line source scattered at an edge with Dirichlet boundary conditions. - - :cite:`Moser2012`, eq.(10.18/19) - - Parameters - ---------- - omega : float - Angular frequency. - x0 : (3,) array_like - Position of line source. - grid : triple of array_like - The grid that is used for the sound field calculations. - See `sfs.util.xyz_grid()`. - alpha : float, optional - Outer angle of edge. - Nc : int, optional - Number of elements for series expansion of driving function. - Estimated if not given. - c : float, optional - Speed of sound - - Returns - ------- - numpy.ndarray - Complex pressure at grid positions. - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - phi_s = np.arctan2(x0[1], x0[0]) - if phi_s < 0: - phi_s = phi_s + 2*np.pi - r_s = np.linalg.norm(x0) - - grid = util.XyzComponents(grid) - - r = np.linalg.norm(grid[:2]) - phi = np.arctan2(grid[1], grid[0]) - phi = np.where(phi < 0, phi+2*np.pi, phi) - - if Nc is None: - Nc = np.ceil(2 * k * np.max(r) * alpha/np.pi) - - epsilon = np.ones(Nc) # weights for series expansion - epsilon[0] = 2 - - p = np.zeros((grid[0].shape[1], grid[1].shape[0]), dtype=complex) - idxr = (r <= r_s) - idxa = (phi <= alpha) - for m in np.arange(Nc): - nu = m*np.pi/alpha - f = 1/epsilon[m] * np.sin(nu*phi_s) * np.sin(nu*phi) - p[idxr & idxa] = p[idxr & idxa] + f[idxr & idxa] * \ - special.jn(nu, k*r[idxr & idxa]) * special.hankel2(nu, k*r_s) - p[~idxr & idxa] = p[~idxr & idxa] + f[~idxr & idxa] * \ - special.jn(nu, k*r_s) * special.hankel2(nu, k*r[~idxr & idxa]) - - p = p * -1j*np.pi/alpha - - pl = line(omega, x0, None, grid, c=c) - p[~idxa] = pl[~idxa] - - return p - - -def plane(omega, x0, n0, grid, c=None): - """Plane wave. - - Notes - ----- - :: - - G(x, w) = e^(-i w/c n x) - - Examples - -------- - .. plot:: - :context: close-figs - - direction = 45 # degree - n0 = sfs.util.direction_vector(np.radians(direction)) - p = sfs.mono.source.plane(omega, x0, n0, grid) - sfs.plot.soundfield(p, grid, colorbar_kwargs=dict(label="p / Pa")) - plt.title("Plane wave with direction {} degree".format(direction)) - - """ - k = util.wavenumber(omega, c) - x0 = util.asarray_1d(x0) - n0 = util.normalize_vector(n0) - grid = util.as_xyz_components(grid) - return np.exp(-1j * k * np.inner(grid - x0, n0)) - - -def plane_velocity(omega, x0, n0, grid, c=None): - """Velocity of a plane wave. - - Notes - ----- - :: - - V(x, w) = 1/(rho c) e^(-i w/c n x) n - - Returns - ------- - `XyzComponents` - Particle velocity at positions given by *grid*. - - Examples - -------- - The particle velocity can be plotted on top of the sound pressure: - - .. plot:: - :context: close-figs - - v = sfs.mono.source.plane_velocity(omega, x0, n0, vgrid) - sfs.plot.soundfield(p, grid) - sfs.plot.vectors(v, vgrid) - plt.title("Sound Pressure and Particle Velocity") - - """ - v = plane(omega, x0, n0, grid, c=c) / (defs.rho0 * defs.c) - return util.XyzComponents([v * n for n in n0]) - - -def _duplicate_zdirection(p, grid): - """If necessary, duplicate field in z-direction.""" - gridshape = np.broadcast(*grid).shape - if len(gridshape) > 2: - return np.tile(p, [1, 1, gridshape[2]]) - else: - return p - -def _hankel2_0(x): - """Wrapper for Hankel function of the second type using fast versions - of the Bessel functions of first/second kind in scipy""" - return special.j0(x)-1j*special.y0(x) diff --git a/sfs/mono/synthesized.py b/sfs/mono/synthesized.py deleted file mode 100644 index 935562b..0000000 --- a/sfs/mono/synthesized.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Computation of synthesized sound fields.""" - -import numpy as np -from .source import point - - -def generic(omega, x0, n0, d, grid, c=None, source=point): - """Compute sound field for a generic driving function.""" - d = np.squeeze(np.asarray(d)) - if len(d) != len(x0): - raise ValueError("length mismatch") - p = 0 - for weight, position, direction in zip(d, x0, n0): - if weight != 0: - p += weight * source(omega, position, direction, grid, c) - return p - - -def shiftphase(p, phase): - """Shift phase of a sound field.""" - p = np.asarray(p) - return p * np.exp(1j * phase) diff --git a/sfs/plot.py b/sfs/plot2d.py similarity index 60% rename from sfs/plot.py rename to sfs/plot2d.py index 05a9af6..efadb9c 100644 --- a/sfs/plot.py +++ b/sfs/plot2d.py @@ -1,24 +1,25 @@ -"""Plot sound fields etc.""" -from __future__ import division -import matplotlib.pyplot as plt -from matplotlib.patches import PathPatch -from matplotlib.path import Path -from matplotlib.collections import PatchCollection -from mpl_toolkits import axes_grid1 -from mpl_toolkits.mplot3d import Axes3D -import numpy as np -from . import util -from . import defs +"""2D plots of sound fields etc.""" +import matplotlib as _mpl +import matplotlib.pyplot as _plt +from mpl_toolkits import axes_grid1 as _axes_grid1 +import numpy as _np + +from . import default as _default +from . import util as _util def _register_cmap_clip(name, original_cmap, alpha): """Create a color map with "over" and "under" values.""" from matplotlib.colors import LinearSegmentedColormap - cdict = plt.cm.datad[original_cmap] - cmap = LinearSegmentedColormap(name, cdict) + cdata = _plt.cm.datad[original_cmap] + if isinstance(cdata, dict): + cmap = LinearSegmentedColormap(name, cdata) + else: + cmap = LinearSegmentedColormap.from_list(name, cdata) cmap.set_over([alpha * c + 1 - alpha for c in cmap(1.0)[:3]]) cmap.set_under([alpha * c + 1 - alpha for c in cmap(0.0)[:3]]) - plt.cm.register_cmap(cmap=cmap) + _plt.cm.register_cmap(cmap=cmap) + # The 'coolwarm' colormap is based on the paper # "Diverging Color Maps for Scientific Visualization" by Kenneth Moreland @@ -35,23 +36,24 @@ def _register_cmap_transparent(name, color): 'blue': ((0, blue, blue), (1, blue, blue)), 'alpha': ((0, 0, 0), (1, 1, 1))} cmap = LinearSegmentedColormap(name, cdict) - plt.cm.register_cmap(cmap=cmap) + _plt.cm.register_cmap(cmap=cmap) + _register_cmap_transparent('blacktransparent', 'black') -def virtualsource_2d(xs, ns=None, type='point', ax=None): +def virtualsource(xs, ns=None, type='point', *, ax=None): """Draw position/orientation of virtual source.""" - xs = np.asarray(xs) - ns = np.asarray(ns) + xs = _np.asarray(xs) + ns = _np.asarray(ns) if ax is None: - ax = plt.gca() + ax = _plt.gca() if type == 'point': - vps = plt.Circle(xs, .05, edgecolor='k', facecolor='k') + vps = _plt.Circle(xs, .05, edgecolor='k', facecolor='k') ax.add_artist(vps) for n in range(1, 3): - vps = plt.Circle(xs, .05+n*0.05, edgecolor='k', fill=False) + vps = _plt.Circle(xs, .05+n*0.05, edgecolor='k', fill=False) ax.add_artist(vps) elif type == 'plane': ns = 0.2 * ns @@ -60,34 +62,46 @@ def virtualsource_2d(xs, ns=None, type='point', ax=None): head_length=0.1, fc='k', ec='k') -def reference_2d(xref, size=0.1, ax=None): +def reference(xref, *, size=0.1, ax=None): """Draw reference/normalization point.""" - xref = np.asarray(xref) + xref = _np.asarray(xref) if ax is None: - ax = plt.gca() + ax = _plt.gca() ax.plot((xref[0]-size, xref[0]+size), (xref[1]-size, xref[1]+size), 'k-') ax.plot((xref[0]-size, xref[0]+size), (xref[1]+size, xref[1]-size), 'k-') -def secondarysource_2d(x0, n0, grid=None): - """Simple plot of secondary source locations.""" - x0 = np.asarray(x0) - n0 = np.asarray(n0) - ax = plt.gca() +def secondary_sources(x0, n0, *, size=0.05, grid=None): + """Simple visualization of secondary source locations. + + Parameters + ---------- + x0 : (N, 3) array_like + Loudspeaker positions. + n0 : (N, 3) or (3,) array_like + Normal vector(s) of loudspeakers. + size : float, optional + Size of loudspeakers in metres. + grid : triple of array_like, optional + If specified, only loudspeakers within the *grid* are shown. + """ + x0 = _np.asarray(x0) + n0 = _np.asarray(n0) + ax = _plt.gca() # plot only secondary sources inside simulated area if grid is not None: - x0, n0 = _visible_secondarysources_2d(x0, n0, grid) + x0, n0 = _visible_secondarysources(x0, n0, grid) # plot symbols for x00 in x0: - ss = plt.Circle(x00[0:2], .05, edgecolor='k', facecolor='k') + ss = _plt.Circle(x00[0:2], size, edgecolor='k', facecolor='k') ax.add_artist(ss) -def loudspeaker_2d(x0, n0, a0=0.5, size=0.08, show_numbers=False, grid=None, - ax=None): +def loudspeakers(x0, n0, a0=0.5, *, size=0.08, show_numbers=False, grid=None, + ax=None): """Draw loudspeaker symbols at given locations and angles. Parameters @@ -109,76 +123,66 @@ def loudspeaker_2d(x0, n0, a0=0.5, size=0.08, show_numbers=False, grid=None, object or -- if not specified -- into the current axes. """ - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - a0 = util.asarray_1d(a0).reshape(-1, 1) + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + a0 = _util.asarray_1d(a0).reshape(-1, 1) # plot only secondary sources inside simulated area if grid is not None: - x0, n0 = _visible_secondarysources_2d(x0, n0, grid) + x0, n0 = _visible_secondarysources(x0, n0, grid) # normalized coordinates of loudspeaker symbol (see IEC 60617-9) codes, coordinates = zip(*( - (Path.MOVETO, [-0.62, 0.21]), - (Path.LINETO, [-0.31, 0.21]), - (Path.LINETO, [0, 0.5]), - (Path.LINETO, [0, -0.5]), - (Path.LINETO, [-0.31, -0.21]), - (Path.LINETO, [-0.62, -0.21]), - (Path.CLOSEPOLY, [0, 0]), - (Path.MOVETO, [-0.31, 0.21]), - (Path.LINETO, [-0.31, -0.21]), + (_mpl.path.Path.MOVETO, [-0.62, 0.21]), + (_mpl.path.Path.LINETO, [-0.31, 0.21]), + (_mpl.path.Path.LINETO, [0, 0.5]), + (_mpl.path.Path.LINETO, [0, -0.5]), + (_mpl.path.Path.LINETO, [-0.31, -0.21]), + (_mpl.path.Path.LINETO, [-0.62, -0.21]), + (_mpl.path.Path.CLOSEPOLY, [0, 0]), + (_mpl.path.Path.MOVETO, [-0.31, 0.21]), + (_mpl.path.Path.LINETO, [-0.31, -0.21]), )) - coordinates = np.column_stack([coordinates, np.zeros(len(coordinates))]) + coordinates = _np.column_stack([coordinates, _np.zeros(len(coordinates))]) coordinates *= size patches = [] - for x00, n00 in util.broadcast_zip(x0, n0): + for x00, n00 in _util.broadcast_zip(x0, n0): # rotate and translate coordinates - R = util.rotation_matrix([1, 0, 0], n00) - transformed_coordinates = np.inner(coordinates, R) + x00 + R = _util.rotation_matrix([1, 0, 0], n00) + transformed_coordinates = _np.inner(coordinates, R) + x00 - patches.append(PathPatch(Path(transformed_coordinates[:, :2], codes))) + patches.append(_mpl.patches.PathPatch(_mpl.path.Path( + transformed_coordinates[:, :2], codes))) # add collection of patches to current axis - p = PatchCollection(patches, edgecolor='0', facecolor=np.tile(1 - a0, 3)) + p = _mpl.collections.PatchCollection( + patches, edgecolor='0', facecolor=_np.tile(1 - a0, 3)) if ax is None: - ax = plt.gca() + ax = _plt.gca() ax.add_collection(p) if show_numbers: - for idx, (x00, n00) in enumerate(util.broadcast_zip(x0, n0)): + for idx, (x00, n00) in enumerate(_util.broadcast_zip(x0, n0)): x, y = x00[:2] - 1.2 * size * n00[:2] ax.text(x, y, idx + 1, horizontalalignment='center', - verticalalignment='center') + verticalalignment='center', clip_on=True) -def _visible_secondarysources_2d(x0, n0, grid): +def _visible_secondarysources(x0, n0, grid): """Determine secondary sources which lie within *grid*.""" - x, y = util.as_xyz_components(grid[:2]) - idx = np.where((x0[:, 0] > x.min()) & (x0[:, 0] < x.max()) & + x, y = _util.as_xyz_components(grid[:2]) + idx = _np.where((x0[:, 0] > x.min()) & (x0[:, 0] < x.max()) & (x0[:, 1] > y.min()) & (x0[:, 1] < x.max())) - idx = np.squeeze(idx) + idx = _np.squeeze(idx) return x0[idx, :], n0[idx, :] -def loudspeaker_3d(x0, n0, a0=None, w=0.08, h=0.08): - """Plot positions and normals of a 3D secondary source distribution.""" - fig = plt.figure(figsize=(15, 15)) - ax = fig.add_subplot(111, projection='3d') - ax.quiver(x0[:, 0], x0[:, 1], x0[:, 2], n0[:, 0], - n0[:, 1], n0[:, 2], length=0.1) - plt.xlabel('x (m)') - plt.ylabel('y (m)') - plt.title('Secondary Sources') - fig.show() - - -def soundfield(p, grid, xnorm=None, cmap='coolwarm_clip', vmin=-2.0, vmax=2.0, - xlabel=None, ylabel=None, colorbar=True, colorbar_kwargs={}, - ax=None, **kwargs): - """Two-dimensional plot of sound field. +def amplitude(p, grid, *, xnorm=None, cmap='coolwarm_clip', + vmin=-2.0, vmax=2.0, xlabel=None, ylabel=None, + colorbar=True, colorbar_kwargs={}, ax=None, **kwargs): + """Two-dimensional plot of sound field (real part). Parameters ---------- @@ -238,15 +242,15 @@ def soundfield(p, grid, xnorm=None, cmap='coolwarm_clip', vmin=-2.0, vmax=2.0, See Also -------- - sfs.plot.level + sfs.plot2d.level """ - p = np.asarray(p) - grid = util.as_xyz_components(grid) + p = _np.asarray(p) + grid = _util.as_xyz_components(grid) # normalize sound field wrt xnorm if xnorm is not None: - p = util.normalize(p, grid, xnorm) + p = _util.normalize(p, grid, xnorm) if p.ndim == 3: if p.shape[2] == 1: @@ -281,11 +285,18 @@ def soundfield(p, grid, xnorm=None, cmap='coolwarm_clip', vmin=-2.0, vmax=2.0, elif plotting_plane == 'yz': x, y = grid[[1, 2]] + dx = 0.5 * x.ptp() / p.shape[0] + dy = 0.5 * y.ptp() / p.shape[1] + if ax is None: - ax = plt.gca() + ax = _plt.gca() - im = ax.imshow(np.real(p), cmap=cmap, origin='lower', - extent=[x.min(), x.max(), y.min(), y.max()], + # see https://github.com/matplotlib/matplotlib/issues/10567 + if _mpl.__version__.startswith('2.1.'): + p = _np.clip(p, -1e15, 1e15) # clip to float64 range + + im = ax.imshow(_np.real(p), cmap=cmap, origin='lower', + extent=[x.min()-dx, x.max()+dx, y.min()-dy, y.max()+dy], vmax=vmax, vmin=vmin, **kwargs) if xlabel is None: xlabel = plotting_plane[0] + ' / m' @@ -298,11 +309,11 @@ def soundfield(p, grid, xnorm=None, cmap='coolwarm_clip', vmin=-2.0, vmax=2.0, return im -def level(p, grid, xnorm=None, power=False, cmap=None, vmax=3, vmin=-50, +def level(p, grid, *, xnorm=None, power=False, cmap=None, vmax=3, vmin=-50, **kwargs): """Two-dimensional plot of level (dB) of sound field. - Takes the same parameters as `sfs.plot.soundfield()`. + Takes the same parameters as `sfs.plot2d.amplitude()`. Other Parameters ---------------- @@ -312,37 +323,67 @@ def level(p, grid, xnorm=None, power=False, cmap=None, vmax=3, vmin=-50, """ # normalize before converting to dB! if xnorm is not None: - p = util.normalize(p, grid, xnorm) - L = util.db(p, power=power) - return soundfield(L, grid=grid, xnorm=None, cmap=cmap, - vmax=vmax, vmin=vmin, **kwargs) + p = _util.normalize(p, grid, xnorm) + L = _util.db(p, power=power) + return amplitude(L, grid=grid, xnorm=None, cmap=cmap, + vmax=vmax, vmin=vmin, **kwargs) -def particles(x, trim=None, ax=None, xlabel='x (m)', ylabel='y (m)', - edgecolor='', **kwargs): - """Plot particle positions as scatter plot""" - XX, YY = [np.real(c) for c in x[:2]] +def particles(x, *, trim=None, ax=None, xlabel='x (m)', ylabel='y (m)', + edgecolors=None, marker='.', s=15, **kwargs): + """Plot particle positions as scatter plot. + + Parameters + ---------- + x : triple or pair of array_like + x, y and optionally z components of particle positions. The z + components are ignored. + If the values are complex, the imaginary parts are ignored. + + Returns + ------- + Scatter + See :func:`matplotlib.pyplot.scatter`. + + Other Parameters + ---------------- + trim : array of float, optional + xmin, xmax, ymin, ymax limits for which the particles are plotted. + ax : Axes, optional + If given, the plot is created on *ax* instead of the current + axis (see :func:`matplotlib.pyplot.gca`). + xlabel, ylabel : str + Overwrite default x/y labels. Use ``xlabel=''`` and + ``ylabel=''`` to remove x/y labels. The labels can be changed + afterwards with :func:`matplotlib.pyplot.xlabel` and + :func:`matplotlib.pyplot.ylabel`. + edgecolors, markr, s, **kwargs + All further parameters are forwarded to + :func:`matplotlib.pyplot.scatter`. + + """ + XX, YY = [_np.real(c) for c in x[:2]] if trim is not None: xmin, xmax, ymin, ymax = trim - idx = np.where((XX > xmin) & (XX < xmax) & (YY > ymin) & (YY < ymax)) + idx = _np.where((XX > xmin) & (XX < xmax) & (YY > ymin) & (YY < ymax)) XX = XX[idx] YY = YY[idx] if ax is None: - ax = plt.gca() - - ax.scatter(XX, YY, edgecolor=edgecolor, **kwargs) + ax = _plt.gca() if xlabel: ax.set_xlabel(xlabel) if ylabel: ax.set_ylabel(ylabel) + return ax.scatter(XX, YY, edgecolors=edgecolors, marker=marker, s=s, + **kwargs) -def vectors(v, grid, cmap='blacktransparent', headlength=3, headaxislength=2.5, - ax=None, clim=None, **kwargs): +def vectors(v, grid, *, cmap='blacktransparent', headlength=3, + headaxislength=2.5, ax=None, clim=None, **kwargs): """Plot a vector field in the xy plane. Parameters @@ -373,28 +414,28 @@ def vectors(v, grid, cmap='blacktransparent', headlength=3, headaxislength=2.5, :func:`matplotlib.pyplot.quiver`. """ - v = util.as_xyz_components(v[:2]).apply(np.real) - X, Y = util.as_xyz_components(grid[:2]) - speed = np.linalg.norm(v) - with np.errstate(invalid='ignore'): - U, V = v.apply(np.true_divide, speed) + v = _util.as_xyz_components(v[:2]).apply(_np.real) + X, Y = _util.as_xyz_components(grid[:2]) + speed = _np.linalg.norm(v) + with _np.errstate(invalid='ignore'): + U, V = v.apply(_np.true_divide, speed) if ax is None: - ax = plt.gca() + ax = _plt.gca() if clim is None: - v_ref = 1 / (defs.rho0 * defs.c) # reference particle velocity + v_ref = 1 / (_default.rho0 * _default.c) # reference particle velocity clim = 0, 2 * v_ref return ax.quiver(X, Y, U, V, speed, cmap=cmap, pivot='mid', units='xy', angles='xy', headlength=headlength, headaxislength=headaxislength, clim=clim, **kwargs) -def add_colorbar(im, aspect=20, pad=0.5, **kwargs): - """Add a vertical color bar to a plot. +def add_colorbar(im, *, aspect=20, pad=0.5, **kwargs): + r"""Add a vertical color bar to a plot. Parameters ---------- im : ScalarMappable - The output of `sfs.plot.soundfield()`, `sfs.plot.level()` or any + The output of `sfs.plot2d.amplitude()`, `sfs.plot2d.level()` or any other `matplotlib.cm.ScalarMappable`. aspect : float, optional Aspect ratio of the colorbar. Strictly speaking, since the @@ -418,10 +459,10 @@ def add_colorbar(im, aspect=20, pad=0.5, **kwargs): """ ax = im.axes - divider = axes_grid1.make_axes_locatable(ax) - width = axes_grid1.axes_size.AxesY(ax, aspect=1/aspect) - pad = axes_grid1.axes_size.Fraction(pad, width) - current_ax = plt.gca() + divider = _axes_grid1.make_axes_locatable(ax) + width = _axes_grid1.axes_size.AxesY(ax, aspect=1/aspect) + pad = _axes_grid1.axes_size.Fraction(pad, width) + current_ax = _plt.gca() cax = divider.append_axes("right", size=width, pad=pad) - plt.sca(current_ax) + _plt.sca(current_ax) return ax.figure.colorbar(im, cax=cax, orientation='vertical', **kwargs) diff --git a/sfs/plot3d.py b/sfs/plot3d.py new file mode 100644 index 0000000..063a1d1 --- /dev/null +++ b/sfs/plot3d.py @@ -0,0 +1,14 @@ +"""3D plots of sound fields etc.""" +import matplotlib.pyplot as _plt + + +def secondary_sources(x0, n0, a0=None, *, w=0.08, h=0.08): + """Plot positions and normals of a 3D secondary source distribution.""" + fig = _plt.figure(figsize=(15, 15)) + ax = fig.add_subplot(111, projection='3d') + q = ax.quiver(x0[:, 0], x0[:, 1], x0[:, 2], n0[:, 0], + n0[:, 1], n0[:, 2], length=0.1) + _plt.xlabel('x (m)') + _plt.ylabel('y (m)') + _plt.title('Secondary Sources') + return q diff --git a/sfs/tapering.py b/sfs/tapering.py index f5d6d6f..20f3226 100644 --- a/sfs/tapering.py +++ b/sfs/tapering.py @@ -17,7 +17,7 @@ active2[30:-10] = False """ -import numpy as np +import numpy as _np def none(active): @@ -51,7 +51,7 @@ def none(active): return active -def tukey(active, alpha): +def tukey(active, *, alpha): """Tukey tapering window. This uses a function similar to :func:`scipy.signal.tukey`, except @@ -67,7 +67,7 @@ def tukey(active, alpha): Returns ------- - (len(active),) numpy.ndarray + (len(active),) `numpy.ndarray` Tapering weights. Examples @@ -75,41 +75,41 @@ def tukey(active, alpha): .. plot:: :context: close-figs - plt.plot(sfs.tapering.tukey(active1, 0), label='alpha = 0') - plt.plot(sfs.tapering.tukey(active1, 0.25), label='alpha = 0.25') - plt.plot(sfs.tapering.tukey(active1, 0.5), label='alpha = 0.5') - plt.plot(sfs.tapering.tukey(active1, 0.75), label='alpha = 0.75') - plt.plot(sfs.tapering.tukey(active1, 1), label='alpha = 1') + plt.plot(sfs.tapering.tukey(active1, alpha=0), label='alpha = 0') + plt.plot(sfs.tapering.tukey(active1, alpha=0.25), label='alpha = 0.25') + plt.plot(sfs.tapering.tukey(active1, alpha=0.5), label='alpha = 0.5') + plt.plot(sfs.tapering.tukey(active1, alpha=0.75), label='alpha = 0.75') + plt.plot(sfs.tapering.tukey(active1, alpha=1), label='alpha = 1') plt.axis([-3, 103, -0.1, 1.1]) plt.legend(loc='lower center') .. plot:: :context: close-figs - plt.plot(sfs.tapering.tukey(active2, 0.3)) + plt.plot(sfs.tapering.tukey(active2, alpha=0.3)) plt.axis([-3, 103, -0.1, 1.1]) """ idx = _windowidx(active) - alpha = np.clip(alpha, 0, 1) + alpha = _np.clip(alpha, 0, 1) if alpha == 0: return none(active) # design Tukey window - x = np.linspace(0, 1, len(idx) + 2) - tukey = np.ones_like(x) + x = _np.linspace(0, 1, len(idx) + 2) + tukey = _np.ones_like(x) first_part = x < alpha / 2 tukey[first_part] = 0.5 * ( - 1 + np.cos(2 * np.pi / alpha * (x[first_part] - alpha / 2))) + 1 + _np.cos(2 * _np.pi / alpha * (x[first_part] - alpha / 2))) third_part = x >= (1 - alpha / 2) tukey[third_part] = 0.5 * ( - 1 + np.cos(2 * np.pi / alpha * (x[third_part] - 1 + alpha / 2))) + 1 + _np.cos(2 * _np.pi / alpha * (x[third_part] - 1 + alpha / 2))) # fit window into tapering function - result = np.zeros(len(active)) + result = _np.zeros(len(active)) result[idx] = tukey[1:-1] return result -def kaiser(active, beta): +def kaiser(active, *, beta): """Kaiser tapering window. This uses :func:`numpy.kaiser`. @@ -123,7 +123,7 @@ def kaiser(active, beta): Returns ------- - (len(active),) numpy.ndarray + (len(active),) `numpy.ndarray` Tapering weights. Examples @@ -131,24 +131,24 @@ def kaiser(active, beta): .. plot:: :context: close-figs - plt.plot(sfs.tapering.kaiser(active1, 0), label='beta = 0') - plt.plot(sfs.tapering.kaiser(active1, 2), label='beta = 2') - plt.plot(sfs.tapering.kaiser(active1, 6), label='beta = 6') - plt.plot(sfs.tapering.kaiser(active1, 8.6), label='beta = 8.6') - plt.plot(sfs.tapering.kaiser(active1, 14), label='beta = 14') + plt.plot(sfs.tapering.kaiser(active1, beta=0), label='beta = 0') + plt.plot(sfs.tapering.kaiser(active1, beta=2), label='beta = 2') + plt.plot(sfs.tapering.kaiser(active1, beta=6), label='beta = 6') + plt.plot(sfs.tapering.kaiser(active1, beta=8.6), label='beta = 8.6') + plt.plot(sfs.tapering.kaiser(active1, beta=14), label='beta = 14') plt.axis([-3, 103, -0.1, 1.1]) plt.legend(loc='lower center') .. plot:: :context: close-figs - plt.plot(sfs.tapering.kaiser(active2, 7)) + plt.plot(sfs.tapering.kaiser(active2, beta=7)) plt.axis([-3, 103, -0.1, 1.1]) """ idx = _windowidx(active) - window = np.zeros(len(active)) - window[idx] = np.kaiser(len(idx), beta) + window = _np.zeros(len(active)) + window[idx] = _np.kaiser(len(idx), beta) return window @@ -159,11 +159,11 @@ def _windowidx(active): """ # find index where active loudspeakers begin (works for connected contours) - if (active[0] and not active[-1]) or np.all(active): + if (active[0] and not active[-1]) or _np.all(active): first_idx = 0 else: - first_idx = np.argmax(np.diff(active.astype(int))) + 1 + first_idx = _np.argmax(_np.diff(active.astype(int))) + 1 # shift generic index vector to get a connected list of indices - idx = np.roll(np.arange(len(active)), -first_idx) + idx = _np.roll(_np.arange(len(active)), -first_idx) # remove indices of inactive secondary sources - return idx[:np.count_nonzero(active)] + return idx[:_np.count_nonzero(active)] diff --git a/sfs/td/__init__.py b/sfs/td/__init__.py new file mode 100644 index 0000000..a786ea5 --- /dev/null +++ b/sfs/td/__init__.py @@ -0,0 +1,112 @@ +"""Submodules for broadband sound fields. + +.. autosummary:: + :toctree: + + source + + wfs + nfchoa + +""" +import numpy as _np + +from . import source +from .. import array as _array +from .. import util as _util + + +def synthesize(signals, weights, ssd, secondary_source_function, **kwargs): + """Compute sound field for an array of secondary sources. + + Parameters + ---------- + signals : (N, C) array_like + float + Driving signals consisting of audio data (C channels) and a + sampling rate (in Hertz). + A `DelayedSignal` object can also be used. + weights : (C,) array_like + Additional weights applied during integration, e.g. source + selection and tapering. + ssd : sequence of between 1 and 3 array_like objects + Positions (shape ``(C, 3)``), normal vectors (shape ``(C, 3)``) + and weights (shape ``(C,)``) of secondary sources. + A `SecondarySourceDistribution` can also be used. + secondary_source_function : callable + A function that generates the sound field of a secondary source. + This signature is expected:: + + secondary_source_function( + position, normal_vector, **kwargs) -> numpy.ndarray + + **kwargs + All keyword arguments are forwarded to *secondary_source_function*. + This is typically used to pass the *observation_time* and *grid* + arguments. + + Returns + ------- + numpy.ndarray + Sound pressure at grid positions. + + """ + ssd = _array.as_secondary_source_distribution(ssd) + data, samplerate, signal_offset = _util.as_delayed_signal(signals) + weights = _util.asarray_1d(weights) + channels = data.T + if not (len(ssd.x) == len(ssd.n) == len(ssd.a) == len(channels) == + len(weights)): + raise ValueError("Length mismatch") + p = 0 + for x, n, a, channel, weight in zip(ssd.x, ssd.n, ssd.a, + channels, weights): + if weight != 0: + signal = channel, samplerate, signal_offset + p += a * weight * secondary_source_function(x, n, signal, **kwargs) + return p + + +def apply_delays(signal, delays): + """Apply delays for every channel. + + Parameters + ---------- + signal : (N,) array_like + float + Excitation signal consisting of (mono) audio data and a sampling + rate (in Hertz). A `DelayedSignal` object can also be used. + delays : (C,) array_like + Delay in seconds for each channel (C), negative values allowed. + + Returns + ------- + `DelayedSignal` + A tuple containing the delayed signals (in a `numpy.ndarray` + with shape ``(N, C)``), followed by the sampling rate (in Hertz) + and a (possibly negative) time offset (in seconds). + + """ + data, samplerate, initial_offset = _util.as_delayed_signal(signal) + data = _util.asarray_1d(data) + delays = _util.asarray_1d(delays) + delays += initial_offset + + delays_samples = _np.rint(samplerate * delays).astype(int) + offset_samples = delays_samples.min() + delays_samples -= offset_samples + out = _np.zeros((delays_samples.max() + len(data), len(delays_samples))) + for column, row in enumerate(delays_samples): + out[row:row + len(data), column] = data + return _util.DelayedSignal(out, samplerate, offset_samples / samplerate) + + +def secondary_source_point(c): + """Create a point source for use in `sfs.td.synthesize()`.""" + + def secondary_source(position, _, signal, observation_time, grid): + return source.point(position, signal, observation_time, grid, c=c) + + return secondary_source + + +from . import nfchoa +from . import wfs diff --git a/sfs/td/nfchoa.py b/sfs/td/nfchoa.py new file mode 100644 index 0000000..87a2e5f --- /dev/null +++ b/sfs/td/nfchoa.py @@ -0,0 +1,517 @@ +"""Compute NFC-HOA driving functions. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + import sfs + from scipy.signal import unit_impulse + + # Plane wave + npw = sfs.util.direction_vector(np.radians(-45)) + + # Point source + xs = -1.5, 1.5, 0 + rs = np.linalg.norm(xs) # distance from origin + ts = rs / sfs.default.c # time-of-arrival at origin + + # Impulsive excitation + fs = 44100 + signal = unit_impulse(512), fs + + # Circular loudspeaker array + N = 32 # number of loudspeakers + R = 1.5 # radius + array = sfs.array.circular(N, R) + + grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) + + def plot(d, selection, secondary_source, t=0): + p = sfs.td.synthesize(d, selection, array, secondary_source, grid=grid, + observation_time=t) + sfs.plot2d.level(p, grid) + sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) + +""" +import numpy as _np +import scipy.signal as _sig +from scipy.special import eval_legendre as _legendre + +from . import secondary_source_point as _secondary_source_point +from .. import default as _default +from .. import util as _util + + +def matchedz_zpk(s_zeros, s_poles, s_gain, fs): + """Matched-z transform of poles and zeros. + + Parameters + ---------- + s_zeros : array_like + Zeros in the Laplace domain. + s_poles : array_like + Poles in the Laplace domain. + s_gain : float + System gain in the Laplace domain. + fs : int + Sampling frequency in Hertz. + + Returns + ------- + z_zeros : numpy.ndarray + Zeros in the z-domain. + z_poles : numpy.ndarray + Poles in the z-domain. + z_gain : float + System gain in the z-domain. + + See Also + -------- + :func:`scipy.signal.bilinear_zpk` + + """ + z_zeros = _np.exp(s_zeros / fs) + z_poles = _np.exp(s_poles / fs) + omega = 1j * _np.pi * fs + s_gain *= _np.prod((omega - s_zeros) / (omega - s_poles) + * (-1 - z_poles) / (-1 - z_zeros)) + return z_zeros, z_poles, _np.real(s_gain) + + +def plane_25d(x0, r0, npw, fs, max_order=None, c=None, s2z=matchedz_zpk): + r"""Virtual plane wave by 2.5-dimensional NFC-HOA. + + .. math:: + + D(\phi_0, s) = + 2\e{\frac{s}{c}r_0} + \sum_{m=-M}^{M} + (-1)^m + \Big(\frac{s}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu + \prod_{l=1}^{\nu} + \frac{s^2}{(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} + \e{\i m(\phi_0 - \phi_\text{pw})} + + The driving function is represented in the Laplace domain, + from which the recursive filters are designed. + :math:`\sigma_l + \i\omega_l` denotes the complex roots of + the reverse Bessel polynomial. + The number of second-order sections is + :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, + whereas the number of first-order section :math:`\mu` is either 0 or 1 + for even and odd :math:`|m|`, respectively. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of the circular secondary source distribution. + npw : (3,) array_like + Unit vector (propagation direction) of plane wave. + fs : int + Sampling frequency in Hertz. + max_order : int, optional + Ambisonics order. + c : float, optional + Speed of sound in m/s. + s2z : callable, optional + Function transforming s-domain poles and zeros into z-domain, + e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. + + Returns + ------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of numpy.ndarray + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) numpy.ndarray + Phase shift in radians. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Examples + -------- + .. plot:: + :context: close-figs + + delay, weight, sos, phaseshift, selection, secondary_source = \ + sfs.td.nfchoa.plane_25d(array.x, R, npw, fs) + d = sfs.td.nfchoa.driving_signals_25d( + delay, weight, sos, phaseshift, signal) + plot(d, selection, secondary_source) + + """ + if max_order is None: + max_order = _util.max_order_circular_harmonics(len(x0)) + if c is None: + c = _default.c + + x0 = _util.asarray_of_rows(x0) + npw = _util.asarray_1d(npw) + phi0, _, _ = _util.cart2sph(*x0.T) + phipw, _, _ = _util.cart2sph(*npw) + phaseshift = phi0 - phipw + _np.pi + + delay = -r0 / c + weight = 2 + sos = [] + for m in range(max_order + 1): + _, p, _ = _sig.besselap(m, norm='delay') + s_zeros = _np.zeros(m) + s_poles = c / r0 * p + s_gain = 1 + z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) + sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) + selection = _util.source_selection_all(len(x0)) + return (delay, weight, sos, phaseshift, selection, + _secondary_source_point(c)) + + +def point_25d(x0, r0, xs, fs, max_order=None, c=None, s2z=matchedz_zpk): + r"""Virtual Point source by 2.5-dimensional NFC-HOA. + + .. math:: + + D(\phi_0, s) = + \frac{1}{2\pi r_\text{s}} + \e{\frac{s}{c}(r_0-r_\text{s})} + \sum_{m=-M}^{M} + \Big(\frac{s-\frac{c}{r_\text{s}}\sigma_0}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu + \prod_{l=1}^{\nu} + \frac{(s-\frac{c}{r_\text{s}}\sigma_l)^2-(\frac{c}{r_\text{s}}\omega_l)^2} + {(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} + \e{\i m(\phi_0 - \phi_\text{s})} + + The driving function is represented in the Laplace domain, + from which the recursive filters are designed. + :math:`\sigma_l + \i\omega_l` denotes the complex roots of + the reverse Bessel polynomial. + The number of second-order sections is + :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, + whereas the number of first-order section :math:`\mu` is either 0 or 1 + for even and odd :math:`|m|`, respectively. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of the circular secondary source distribution. + xs : (3,) array_like + Virtual source position. + fs : int + Sampling frequency in Hertz. + max_order : int, optional + Ambisonics order. + c : float, optional + Speed of sound in m/s. + s2z : callable, optional + Function transforming s-domain poles and zeros into z-domain, + e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. + + Returns + ------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of numpy.ndarray + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) numpy.ndarray + Phase shift in radians. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Examples + -------- + .. plot:: + :context: close-figs + + delay, weight, sos, phaseshift, selection, secondary_source = \ + sfs.td.nfchoa.point_25d(array.x, R, xs, fs) + d = sfs.td.nfchoa.driving_signals_25d( + delay, weight, sos, phaseshift, signal) + plot(d, selection, secondary_source, t=ts) + + """ + if max_order is None: + max_order = _util.max_order_circular_harmonics(len(x0)) + if c is None: + c = _default.c + + x0 = _util.asarray_of_rows(x0) + xs = _util.asarray_1d(xs) + phi0, _, _ = _util.cart2sph(*x0.T) + phis, _, rs = _util.cart2sph(*xs) + phaseshift = phi0 - phis + + delay = (rs - r0) / c + weight = 1 / 2 / _np.pi / rs + sos = [] + for m in range(max_order + 1): + _, p, _ = _sig.besselap(m, norm='delay') + s_zeros = c / rs * p + s_poles = c / r0 * p + s_gain = 1 + z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) + sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) + selection = _util.source_selection_all(len(x0)) + return (delay, weight, sos, phaseshift, selection, + _secondary_source_point(c)) + + +def plane_3d(x0, r0, npw, fs, max_order=None, c=None, s2z=matchedz_zpk): + r"""Virtual plane wave by 3-dimensional NFC-HOA. + + .. math:: + + D(\phi_0, s) = + \frac{\e{\frac{s}{c}r_0}}{r_0} + \sum_{n=0}^{N} + (-1)^n (2n+1) P_{n}(\cos\Theta) + \Big(\frac{s}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu + \prod_{l=1}^{\nu} + \frac{s^2}{(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} + + The driving function is represented in the Laplace domain, + from which the recursive filters are designed. + :math:`\sigma_l + \i\omega_l` denotes the complex roots of + the reverse Bessel polynomial. + The number of second-order sections is + :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, + whereas the number of first-order section :math:`\mu` is either 0 or 1 + for even and odd :math:`|m|`, respectively. + :math:`P_{n}(\cdot)` denotes the Legendre polynomial of degree :math:`n`, + and :math:`\Theta` the angle between :math:`(\theta, \phi)` + and :math:`(\theta_\text{pw}, \phi_\text{pw})`. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of the spherical secondary source distribution. + npw : (3,) array_like + Unit vector (propagation direction) of plane wave. + fs : int + Sampling frequency in Hertz. + max_order : int, optional + Ambisonics order. + c : float, optional + Speed of sound in m/s. + s2z : callable, optional + Function transforming s-domain poles and zeros into z-domain, + e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. + + Returns + ------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of numpy.ndarray + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) numpy.ndarray + Phase shift in radians. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + """ + if max_order is None: + max_order = _util.max_order_spherical_harmonics(len(x0)) + if c is None: + c = _default.c + + x0 = _util.asarray_of_rows(x0) + npw = _util.asarray_1d(npw) + phi0, theta0, _ = _util.cart2sph(*x0.T) + phipw, thetapw, _ = _util.cart2sph(*npw) + phaseshift = _np.arccos(_np.dot(x0 / r0, -npw)) + + delay = -r0 / c + weight = 4 * _np.pi / r0 + sos = [] + for m in range(max_order + 1): + _, p, _ = _sig.besselap(m, norm='delay') + s_zeros = _np.zeros(m) + s_poles = c / r0 * p + s_gain = 1 + z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) + sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) + selection = _util.source_selection_all(len(x0)) + return (delay, weight, sos, phaseshift, selection, + _secondary_source_point(c)) + + +def point_3d(x0, r0, xs, fs, max_order=None, c=None, s2z=matchedz_zpk): + r"""Virtual point source by 3-dimensional NFC-HOA. + + .. math:: + + D(\phi_0, s) = + \frac{\e{\frac{s}{c}(r_0-r_\text{s})}}{4 \pi r_0 r_\text{s}} + \sum_{n=0}^{N} + (2n+1) P_{n}(\cos\Theta) + \Big(\frac{s-\frac{c}{r_\text{s}}\sigma_0}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu + \prod_{l=1}^{\nu} + \frac{(s-\frac{c}{r_\text{s}}\sigma_l)^2-(\frac{c}{r_\text{s}}\omega_l)^2} + {(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} + + The driving function is represented in the Laplace domain, + from which the recursive filters are designed. + :math:`\sigma_l + \i\omega_l` denotes the complex roots of + the reverse Bessel polynomial. + The number of second-order sections is + :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, + whereas the number of first-order section :math:`\mu` is either 0 or 1 + for even and odd :math:`|m|`, respectively. + :math:`P_{n}(\cdot)` denotes the Legendre polynomial of degree :math:`n`, + and :math:`\Theta` the angle between :math:`(\theta, \phi)` + and :math:`(\theta_\text{s}, \phi_\text{s})`. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + r0 : float + Radius of the spherial secondary source distribution. + xs : (3,) array_like + Virtual source position. + fs : int + Sampling frequency in Hertz. + max_order : int, optional + Ambisonics order. + c : float, optional + Speed of sound in m/s. + s2z : callable, optional + Function transforming s-domain poles and zeros into z-domain, + e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. + + Returns + ------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of numpy.ndarray + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) numpy.ndarray + Phase shift in radians. + selection : (N,) numpy.ndarray + Boolean array containing only ``True`` indicating that + all secondary source are "active" for NFC-HOA. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + """ + if max_order is None: + max_order = _util.max_order_spherical_harmonics(len(x0)) + if c is None: + c = _default.c + + x0 = _util.asarray_of_rows(x0) + xs = _util.asarray_1d(xs) + phi0, theta0, _ = _util.cart2sph(*x0.T) + phis, thetas, rs = _util.cart2sph(*xs) + phaseshift = _np.arccos(_np.dot(x0 / r0, xs / rs)) + + delay = (rs - r0) / c + weight = 1 / r0 / rs + sos = [] + for m in range(max_order + 1): + _, p, _ = _sig.besselap(m, norm='delay') + s_zeros = c / rs * p + s_poles = c / r0 * p + s_gain = 1 + z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) + sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) + selection = _util.source_selection_all(len(x0)) + return (delay, weight, sos, phaseshift, selection, + _secondary_source_point(c)) + + +def driving_signals_25d(delay, weight, sos, phaseshift, signal): + """Get 2.5-dimensional NFC-HOA driving signals. + + Parameters + ---------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of array_like + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) array_like + Phase shift in radians. + signal : (L,) array_like + float + Excitation signal consisting of (mono) audio data and a sampling + rate (in Hertz). A `DelayedSignal` object can also be used. + + Returns + ------- + `DelayedSignal` + A tuple containing the delayed signals (in a `numpy.ndarray` + with shape ``(L, N)``), followed by the sampling rate (in Hertz) + and a (possibly negative) time offset (in seconds). + + """ + data, fs, t_offset = _util.as_delayed_signal(signal) + N = len(phaseshift) + out = _np.tile(_np.expand_dims(_sig.sosfilt(sos[0], data), 1), (1, N)) + for m in range(1, len(sos)): + modal_response = _sig.sosfilt(sos[m], data)[:, _np.newaxis] + out += modal_response * _np.cos(m * phaseshift) + return _util.DelayedSignal(2 * weight * out, fs, t_offset + delay) + + +def driving_signals_3d(delay, weight, sos, phaseshift, signal): + """Get 3-dimensional NFC-HOA driving signals. + + Parameters + ---------- + delay : float + Overall delay in seconds. + weight : float + Overall weight. + sos : list of array_like + Second-order section filters :func:`scipy.signal.sosfilt`. + phaseshift : (N,) array_like + Phase shift in radians. + signal : (L,) array_like + float + Excitation signal consisting of (mono) audio data and a sampling + rate (in Hertz). A `DelayedSignal` object can also be used. + + Returns + ------- + `DelayedSignal` + A tuple containing the delayed signals (in a `numpy.ndarray` + with shape ``(L, N)``), followed by the sampling rate (in Hertz) + and a (possibly negative) time offset (in seconds). + + """ + data, fs, t_offset = _util.as_delayed_signal(signal) + N = len(phaseshift) + out = _np.tile(_np.expand_dims(_sig.sosfilt(sos[0], data), 1), (1, N)) + for m in range(1, len(sos)): + modal_response = _sig.sosfilt(sos[m], data)[:, _np.newaxis] + out += (2 * m + 1) * modal_response * _legendre(m, _np.cos(phaseshift)) + return _util.DelayedSignal(weight / 4 / _np.pi * out, fs, t_offset + delay) diff --git a/sfs/time/source.py b/sfs/td/source.py similarity index 59% rename from sfs/time/source.py rename to sfs/td/source.py index 0101daf..338dfdb 100644 --- a/sfs/time/source.py +++ b/sfs/td/source.py @@ -4,12 +4,29 @@ .. include:: math-definitions.rst +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + from scipy.signal import unit_impulse + import sfs + + xs = 1.5, 1, 0 # source position + rs = np.linalg.norm(xs) # distance from origin + ts = rs / sfs.default.c # time-of-arrival at origin + + # Impulsive excitation + fs = 44100 + signal = unit_impulse(512), fs + + grid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.02) + """ +import numpy as _np -from __future__ import division -import numpy as np -from .. import util -from .. import defs +from .. import default as _default +from .. import util as _util def point(xs, signal, observation_time, grid, c=None): @@ -46,21 +63,33 @@ def point(xs, signal, observation_time, grid, c=None): g(x-x_s,t) = \frac{1}{4 \pi |x - x_s|} \dirac{t - \frac{|x - x_s|}{c}} + Examples + -------- + .. plot:: + :context: close-figs + + p = sfs.td.source.point(xs, signal, ts, grid) + sfs.plot2d.level(p, grid) + """ - xs = util.asarray_1d(xs) - data, samplerate, signal_offset = util.as_delayed_signal(signal) - data = util.asarray_1d(data) - grid = util.as_xyz_components(grid) + xs = _util.asarray_1d(xs) + data, samplerate, signal_offset = _util.as_delayed_signal(signal) + data = _util.asarray_1d(data) + grid = _util.as_xyz_components(grid) if c is None: - c = defs.c - r = np.linalg.norm(grid - xs) - # evaluate g over grid - weights = 1 / (4 * np.pi * r) + c = _default.c + r = _np.linalg.norm(grid - xs) + # If r is +-0, the sound pressure is +-infinity + with _np.errstate(divide='ignore'): + weights = 1 / (4 * _np.pi * r) delays = r / c base_time = observation_time - signal_offset - return weights * np.interp(base_time - delays, - np.arange(len(data)) / samplerate, + points_at_time = _np.interp(base_time - delays, + _np.arange(len(data)) / samplerate, data, left=0, right=0) + # weights can be +-infinity + with _np.errstate(invalid='ignore'): + return weights * points_at_time def point_image_sources(x0, signal, observation_time, grid, L, max_order, @@ -95,12 +124,25 @@ def point_image_sources(x0, signal, observation_time, grid, L, max_order, Scalar sound pressure field, evaluated at positions given by *grid*. + Examples + -------- + .. plot:: + :context: close-figs + + room = 5, 3, 1.5 # room dimensions + order = 2 # image source order + coeffs = .8, .8, .6, .6, .7, .7 # wall reflection coefficients + grid = sfs.util.xyz_grid([0, room[0]], [0, room[1]], 0, spacing=0.01) + p = sfs.td.source.point_image_sources( + xs, signal, 1.5 * ts, grid, room, order, coeffs) + sfs.plot2d.level(p, grid) + """ if coeffs is None: - coeffs = np.ones(6) + coeffs = _np.ones(6) - positions, order = util.image_sources_for_box(x0, L, max_order) - source_strengths = np.prod(coeffs**order, axis=1) + positions, order = _util.image_sources_for_box(x0, L, max_order) + source_strengths = _np.prod(coeffs**order, axis=1) p = 0 for position, strength in zip(positions, source_strengths): diff --git a/sfs/td/wfs.py b/sfs/td/wfs.py new file mode 100644 index 0000000..3b59301 --- /dev/null +++ b/sfs/td/wfs.py @@ -0,0 +1,414 @@ +"""Compute WFS driving functions. + +.. include:: math-definitions.rst + +.. plot:: + :context: reset + + import matplotlib.pyplot as plt + import numpy as np + import sfs + from scipy.signal import unit_impulse + + # Plane wave + npw = sfs.util.direction_vector(np.radians(-45)) + + # Point source + xs = -1.5, 1.5, 0 + rs = np.linalg.norm(xs) # distance from origin + ts = rs / sfs.default.c # time-of-arrival at origin + + # Focused source + xf = -0.5, 0.5, 0 + nf = sfs.util.direction_vector(np.radians(-45)) # normal vector + rf = np.linalg.norm(xf) # distance from origin + tf = rf / sfs.default.c # time-of-arrival at origin + + # Impulsive excitation + fs = 44100 + signal = unit_impulse(512), fs + + # Circular loudspeaker array + N = 32 # number of loudspeakers + R = 1.5 # radius + array = sfs.array.circular(N, R) + + grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) + + def plot(d, selection, secondary_source, t=0): + p = sfs.td.synthesize(d, selection, array, secondary_source, grid=grid, + observation_time=t) + sfs.plot2d.level(p, grid) + sfs.plot2d.loudspeakers(array.x, array.n, + selection * array.a, size=0.15) + +""" +import numpy as _np +from numpy.core.umath_tests import inner1d as _inner1d + +from . import apply_delays as _apply_delays +from . import secondary_source_point as _secondary_source_point +from .. import default as _default +from .. import util as _util + + +def plane_25d(x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None): + r"""Plane wave model by 2.5-dimensional WFS. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of secondary source orientations. + n : (3,) array_like, optional + Normal vector (propagation direction) of synthesized plane wave. + xref : (3,) array_like, optional + Reference position + c : float, optional + Speed of sound + + Returns + ------- + delays : (N,) numpy.ndarray + Delays of secondary sources in seconds. + weights : (N,) numpy.ndarray + Weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Notes + ----- + 2.5D correction factor + + .. math:: + + g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} + + d using a plane wave as source model + + .. math:: + + d_{2.5D}(x_0,t) = + 2 g_0 \scalarprod{n}{n_0} + \dirac{t - \frac{1}{c} \scalarprod{n}{x_0}} \ast_t h(t) + + with wfs(2.5D) prefilter h(t), which is not implemented yet. + + See :sfs:`d_wfs/#equation-td-wfs-plane-25d` + + Examples + -------- + .. plot:: + :context: close-figs + + delays, weights, selection, secondary_source = \ + sfs.td.wfs.plane_25d(array.x, array.n, npw) + d = sfs.td.wfs.driving_signals(delays, weights, signal) + plot(d, selection, secondary_source) + + """ + if c is None: + c = _default.c + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + n = _util.normalize_vector(n) + xref = _util.asarray_1d(xref) + g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) + delays = _inner1d(n, x0) / c + weights = 2 * g0 * _inner1d(n, n0) + selection = _util.source_selection_plane(n0, n) + return delays, weights, selection, _secondary_source_point(c) + + +def point_25d(x0, n0, xs, xref=[0, 0, 0], c=None): + r"""Driving function for 2.5-dimensional WFS of a virtual point source. + + .. versionchanged:: 0.6.1 + see notes, old handling of `point_25d()` is now `point_25d_legacy()` + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of secondary source orientations. + xs : (3,) array_like + Virtual source position. + xref : (N, 3) array_like or (3,) array_like + Contour xref(x0) for amplitude correct synthesis, reference point xref. + c : float, optional + Speed of sound + + Returns + ------- + delays : (N,) numpy.ndarray + Delays of secondary sources in seconds. + weights: (N,) numpy.ndarray + Weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Notes + ----- + Eq. (2.138) in :cite:`Schultz2016`: + + .. math:: + + d_{2.5D}(x_0, x_{ref}, t) = + \sqrt{8\pi} + \frac{\scalarprod{(x_0 - x_s)}{n_0}}{|x_0 - x_s|} + \sqrt{\frac{|x_0 - x_s||x_0 - x_{ref}|}{|x_0 - x_s|+|x_0 - x_{ref}|}} + \cdot + \frac{\dirac{t - \frac{|x_0 - x_s|}{c}}}{4\pi |x_0 - x_s|} \ast_t h(t) + + .. math:: + + h(t) = F^{-1}(\sqrt{\frac{j \omega}{c}}) + + with wfs(2.5D) prefilter h(t), which is not implemented yet. + + `point_25d()` derives WFS from 3D to 2.5D via the stationary phase + approximation approach (i.e. the Delft approach). + The theoretical link of `point_25d()` and `point_25d_legacy()` was + introduced as *unified WFS framework* in :cite:`Firtha2017`. + + Examples + -------- + .. plot:: + :context: close-figs + + delays, weights, selection, secondary_source = \ + sfs.td.wfs.point_25d(array.x, array.n, xs) + d = sfs.td.wfs.driving_signals(delays, weights, signal) + plot(d, selection, secondary_source, t=ts) + + """ + if c is None: + c = _default.c + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_of_rows(xref) + + x0xs = x0 - xs + x0xref = x0 - xref + x0xs_n = _np.linalg.norm(x0xs, axis=1) + x0xref_n = _np.linalg.norm(x0xref, axis=1) + + g0 = 1/(_np.sqrt(2*_np.pi)*x0xs_n**2) + g0 *= _np.sqrt((x0xs_n*x0xref_n)/(x0xs_n+x0xref_n)) + + delays = x0xs_n/c + weights = g0*_inner1d(x0xs, n0) + selection = _util.source_selection_point(n0, x0, xs) + return delays, weights, selection, _secondary_source_point(c) + + +def point_25d_legacy(x0, n0, xs, xref=[0, 0, 0], c=None): + r"""Driving function for 2.5-dimensional WFS of a virtual point source. + + .. versionadded:: 0.6.1 + `point_25d()` was renamed to `point_25d_legacy()` (and a new + function with the name `point_25d()` was introduced). See notes below + for further details. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of secondary source orientations. + xs : (3,) array_like + Virtual source position. + xref : (3,) array_like, optional + Reference position + c : float, optional + Speed of sound + + Returns + ------- + delays : (N,) numpy.ndarray + Delays of secondary sources in seconds. + weights: (N,) numpy.ndarray + Weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Notes + ----- + 2.5D correction factor + + .. math:: + + g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} + + + d using a point source as source model + + .. math:: + + d_{2.5D}(x_0,t) = + \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} + {2\pi |x_0 - x_s|^{3/2}} + \dirac{t - \frac{|x_0 - x_s|}{c}} \ast_t h(t) + + with wfs(2.5D) prefilter h(t), which is not implemented yet. + + See :sfs:`d_wfs/#equation-td-wfs-point-25d` + + `point_25d_legacy()` derives 2.5D WFS from the 2D + Neumann-Rayleigh integral (i.e. the approach by Rabenstein & Spors), cf. + :cite:`Spors2008`. + The theoretical link of `point_25d()` and `point_25d_legacy()` was + introduced as *unified WFS framework* in :cite:`Firtha2017`. + + Examples + -------- + .. plot:: + :context: close-figs + + delays, weights, selection, secondary_source = \ + sfs.td.wfs.point_25d(array.x, array.n, xs) + d = sfs.td.wfs.driving_signals(delays, weights, signal) + plot(d, selection, secondary_source, t=ts) + + """ + if c is None: + c = _default.c + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_1d(xref) + g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + delays = r/c + weights = g0 * _inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) + selection = _util.source_selection_point(n0, x0, xs) + return delays, weights, selection, _secondary_source_point(c) + + +def focused_25d(x0, n0, xs, ns, xref=[0, 0, 0], c=None): + r"""Point source by 2.5-dimensional WFS. + + Parameters + ---------- + x0 : (N, 3) array_like + Sequence of secondary source positions. + n0 : (N, 3) array_like + Sequence of secondary source orientations. + xs : (3,) array_like + Virtual source position. + ns : (3,) array_like + Normal vector (propagation direction) of focused source. + This is used for secondary source selection, + see `sfs.util.source_selection_focused()`. + xref : (3,) array_like, optional + Reference position + c : float, optional + Speed of sound + + Returns + ------- + delays : (N,) numpy.ndarray + Delays of secondary sources in seconds. + weights: (N,) numpy.ndarray + Weights of secondary sources. + selection : (N,) numpy.ndarray + Boolean array containing ``True`` or ``False`` depending on + whether the corresponding secondary source is "active" or not. + secondary_source_function : callable + A function that can be used to create the sound field of a + single secondary source. See `sfs.td.synthesize()`. + + Notes + ----- + 2.5D correction factor + + .. math:: + + g_0 = \sqrt{\frac{|x_\mathrm{ref} - x_0|} + {|x_0-x_s| + |x_\mathrm{ref}-x_0|}} + + + d using a point source as source model + + .. math:: + + d_{2.5D}(x_0,t) = + \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} + {|x_0 - x_s|^{3/2}} + \dirac{t + \frac{|x_0 - x_s|}{c}} \ast_t h(t) + + with wfs(2.5D) prefilter h(t), which is not implemented yet. + + See :sfs:`d_wfs/#equation-td-wfs-focused-25d` + + Examples + -------- + .. plot:: + :context: close-figs + + delays, weights, selection, secondary_source = \ + sfs.td.wfs.focused_25d(array.x, array.n, xf, nf) + d = sfs.td.wfs.driving_signals(delays, weights, signal) + plot(d, selection, secondary_source, t=tf) + + """ + if c is None: + c = _default.c + x0 = _util.asarray_of_rows(x0) + n0 = _util.asarray_of_rows(n0) + xs = _util.asarray_1d(xs) + xref = _util.asarray_1d(xref) + ds = x0 - xs + r = _np.linalg.norm(ds, axis=1) + g0 = _np.sqrt(_np.linalg.norm(xref - x0, axis=1) + / (_np.linalg.norm(xref - x0, axis=1) + r)) + delays = -r/c + weights = g0 * _inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) + selection = _util.source_selection_focused(ns, x0, xs) + return delays, weights, selection, _secondary_source_point(c) + + +def driving_signals(delays, weights, signal): + """Get driving signals per secondary source. + + Returned signals are the delayed and weighted mono input signal + (with N samples) per channel (C). + + Parameters + ---------- + delays : (C,) array_like + Delay in seconds for each channel, negative values allowed. + weights : (C,) array_like + Amplitude weighting factor for each channel. + signal : (N,) array_like + float + Excitation signal consisting of (mono) audio data and a sampling + rate (in Hertz). A `DelayedSignal` object can also be used. + + Returns + ------- + `DelayedSignal` + A tuple containing the driving signals (in a `numpy.ndarray` + with shape ``(N, C)``), followed by the sampling rate (in Hertz) + and a (possibly negative) time offset (in seconds). + + """ + delays = _util.asarray_1d(delays) + weights = _util.asarray_1d(weights) + data, samplerate, signal_offset = _apply_delays(signal, delays) + return _util.DelayedSignal(data * weights, samplerate, signal_offset) diff --git a/sfs/time/__init__.py b/sfs/time/__init__.py deleted file mode 100644 index f4f826a..0000000 --- a/sfs/time/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import drivingfunction -from . import source -from . import soundfield \ No newline at end of file diff --git a/sfs/time/drivingfunction.py b/sfs/time/drivingfunction.py deleted file mode 100644 index 04922bd..0000000 --- a/sfs/time/drivingfunction.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Compute time based driving functions for various systems. - -.. include:: math-definitions.rst - -""" -from __future__ import division -import numpy as np -from numpy.core.umath_tests import inner1d # element-wise inner product -from .. import defs -from .. import util - - -def wfs_25d_plane(x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None): - r"""Plane wave model by 2.5-dimensional WFS. - - Parameters - ---------- - x0 : (N, 3) array_like - Sequence of secondary source positions. - n0 : (N, 3) array_like - Sequence of secondary source orientations. - n : (3,) array_like, optional - Normal vector (propagation direction) of synthesized plane wave. - xref : (3,) array_like, optional - Reference position - c : float, optional - Speed of sound - - Returns - ------- - delays : (N,) numpy.ndarray - Delays of secondary sources in seconds. - weights: (N,) numpy.ndarray - Weights of secondary sources. - - Notes - ----- - 2.5D correction factor - - .. math:: - - g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} - - d using a plane wave as source model - - .. math:: - - d_{2.5D}(x_0,t) = h(t) - 2 g_0 \scalarprod{n}{n_0} - \dirac{t - \frac{1}{c} \scalarprod{n}{x_0}} - - with wfs(2.5D) prefilter h(t), which is not implemented yet. - - References - ---------- - See http://sfstoolbox.org/en/latest/#equation-d.wfs.pw.2.5D - - """ - if c is None: - c = defs.c - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - n = util.normalize_vector(n) - xref = util.asarray_1d(xref) - g0 = np.sqrt(2 * np.pi * np.linalg.norm(xref - x0, axis=1)) - delays = inner1d(n, x0) / c - weights = 2 * g0 * inner1d(n, n0) - return delays, weights - - -def wfs_25d_point(x0, n0, xs, xref=[0, 0, 0], c=None): - r"""Point source by 2.5-dimensional WFS. - - Parameters - ---------- - x0 : (N, 3) array_like - Sequence of secondary source positions. - n0 : (N, 3) array_like - Sequence of secondary source orientations. - xs : (3,) array_like - Virtual source position. - xref : (3,) array_like, optional - Reference position - c : float, optional - Speed of sound - - Returns - ------- - delays : (N,) numpy.ndarray - Delays of secondary sources in seconds. - weights: (N,) numpy.ndarray - Weights of secondary sources. - - Notes - ----- - 2.5D correction factor - - .. math:: - - g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} - - - d using a point source as source model - - .. math:: - - d_{2.5D}(x_0,t) = h(t) - \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} - {2\pi |x_0 - x_s|^{3/2}} - \dirac{t - \frac{|x_0 - x_s|}{c}} - - with wfs(2.5D) prefilter h(t), which is not implemented yet. - - References - ---------- - See http://sfstoolbox.org/en/latest/#equation-d.wfs.ps.2.5D - - """ - if c is None: - c = defs.c - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - xref = util.asarray_1d(xref) - g0 = np.sqrt(2 * np.pi * np.linalg.norm(xref - x0, axis=1)) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - delays = r/c - weights = g0 * inner1d(ds, n0) / (2 * np.pi * r**(3/2)) - return delays, weights - - -def wfs_25d_focused(x0, n0, xs, xref=[0, 0, 0], c=None): - r"""Point source by 2.5-dimensional WFS. - - Parameters - ---------- - x0 : (N, 3) array_like - Sequence of secondary source positions. - n0 : (N, 3) array_like - Sequence of secondary source orientations. - xs : (3,) array_like - Virtual source position. - xref : (3,) array_like, optional - Reference position - c : float, optional - Speed of sound - - Returns - ------- - delays : (N,) numpy.ndarray - Delays of secondary sources in seconds. - weights: (N,) numpy.ndarray - Weights of secondary sources. - - Notes - ----- - 2.5D correction factor - - .. math:: - - g_0 = \sqrt{\frac{|x_\mathrm{ref} - x_0|} - {|x_0-x_s| + |x_\mathrm{ref}-x_0|}} - - - d using a point source as source model - - .. math:: - - d_{2.5D}(x_0,t) = h(t) - \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} - {|x_0 - x_s|^{3/2}} - \dirac{t + \frac{|x_0 - x_s|}{c}} - - with wfs(2.5D) prefilter h(t), which is not implemented yet. - - References - ---------- - See http://sfstoolbox.org/en/latest/#equation-d.wfs.fs.2.5D - - """ - if c is None: - c = defs.c - x0 = util.asarray_of_rows(x0) - n0 = util.asarray_of_rows(n0) - xs = util.asarray_1d(xs) - xref = util.asarray_1d(xref) - ds = x0 - xs - r = np.linalg.norm(ds, axis=1) - g0 = np.sqrt(np.linalg.norm(xref - x0, axis=1) - / (np.linalg.norm(xref - x0, axis=1) + r)) - delays = -r/c - weights = g0 * inner1d(ds, n0) / (2 * np.pi * r**(3/2)) - return delays, weights - - -def driving_signals(delays, weights, signal): - """Get driving signals per secondary source. - - Returned signals are the delayed and weighted mono input signal - (with N samples) per channel (C). - - Parameters - ---------- - delays : (C,) array_like - Delay in seconds for each channel, negative values allowed. - weights : (C,) array_like - Amplitude weighting factor for each channel. - signal : (N,) array_like + float - Excitation signal consisting of (mono) audio data and a sampling - rate (in Hertz). A `DelayedSignal` object can also be used. - - Returns - ------- - `DelayedSignal` - A tuple containing the driving signals (in a `numpy.ndarray` - with shape ``(N, C)``), followed by the sampling rate (in Hertz) - and a (possibly negative) time offset (in seconds). - - """ - delays = util.asarray_1d(delays) - weights = util.asarray_1d(weights) - data, samplerate, signal_offset = apply_delays(signal, delays) - return util.DelayedSignal(data * weights, samplerate, signal_offset) - - -def apply_delays(signal, delays): - """Apply delays for every channel. - - Parameters - ---------- - signal : (N,) array_like + float - Excitation signal consisting of (mono) audio data and a sampling - rate (in Hertz). A `DelayedSignal` object can also be used. - delays : (C,) array_like - Delay in seconds for each channel (C), negative values allowed. - - Returns - ------- - `DelayedSignal` - A tuple containing the delayed signals (in a `numpy.ndarray` - with shape ``(N, C)``), followed by the sampling rate (in Hertz) - and a (possibly negative) time offset (in seconds). - - """ - data, samplerate, initial_offset = util.as_delayed_signal(signal) - data = util.asarray_1d(data) - delays = util.asarray_1d(delays) - delays += initial_offset - - delays_samples = np.rint(samplerate * delays).astype(int) - offset_samples = delays_samples.min() - delays_samples -= offset_samples - out = np.zeros((delays_samples.max() + len(data), len(delays_samples))) - for column, row in enumerate(delays_samples): - out[row:row + len(data), column] = data - return util.DelayedSignal(out, samplerate, offset_samples / samplerate) diff --git a/sfs/time/soundfield.py b/sfs/time/soundfield.py deleted file mode 100644 index ff6d32d..0000000 --- a/sfs/time/soundfield.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Compute sound field.""" - -from __future__ import division -from .. import util -from .. import defs -from .source import point - - -def p_array(x0, signals, weights, observation_time, grid, source=point, - c=None): - """Compute sound field for an array of secondary sources. - - Parameters - ---------- - x0 : (N, 3) array_like - Sequence of secondary source positions. - signals : (N, C) array_like + float - Driving signals consisting of audio data (C channels) and a - sampling rate (in Hertz). - A `DelayedSignal` object can also be used. - weights : (C,) array_like - Additional weights applied during integration, e.g. source - tapering. - observation_time : float - Simulation point in time (seconds). - grid : triple of array_like - The grid that is used for the sound field calculations. - See `sfs.util.xyz_grid()`. - source: function, optional - Source type is a function, returning scalar field. - For default, see `sfs.time.source.point()`. - c : float, optional - Speed of sound. - - Returns - ------- - numpy.ndarray - Sound pressure at grid positions. - - """ - if c is None: - c = defs.c - x0 = util.asarray_of_rows(x0) - data, samplerate, signal_offset = util.as_delayed_signal(signals) - weights = util.asarray_1d(weights) - channels = data.T - if not (len(weights) == len(x0) == len(channels)): - raise ValueError("Length mismatch") - # synthesize soundfield - p = 0 - for channel, weight, position in zip(channels, weights, x0): - if weight != 0: - signal = channel, samplerate, signal_offset - p_s = source(position, signal, observation_time, grid, c) - p += p_s * weight # integrate over secondary sources - return p diff --git a/sfs/util.py b/sfs/util.py index ed3a3a8..c15358f 100644 --- a/sfs/util.py +++ b/sfs/util.py @@ -4,11 +4,11 @@ """ -from __future__ import division import collections import numpy as np +from numpy.core.umath_tests import inner1d from scipy.special import spherical_jn, spherical_yn -from . import defs +from . import default def rotation_matrix(n1, n2): @@ -21,7 +21,7 @@ def rotation_matrix(n1, n2): Returns ------- - (3, 3) numpy.ndarray + (3, 3) `numpy.ndarray` Rotation matrix. """ @@ -47,7 +47,7 @@ def rotation_matrix(n1, n2): def wavenumber(omega, c=None): """Compute the wavenumber for a given radial frequency.""" if c is None: - c = defs.c + c = default.c return omega / c @@ -72,23 +72,23 @@ def sph2cart(alpha, beta, r): alpha : float or array_like Azimuth angle in radiants beta : float or array_like - Elevation angle in radiants (with 0 denoting North pole) + Colatitude angle in radiants (with 0 denoting North pole) r : float or array_like Radius Returns ------- - x : float or array_like + x : float or `numpy.ndarray` x-component of Cartesian coordinates - y : float or array_like + y : float or `numpy.ndarray` y-component of Cartesian coordinates - z : float or array_like + z : float or `numpy.ndarray` z-component of Cartesian coordinates + """ x = r * np.cos(alpha) * np.sin(beta) y = r * np.sin(alpha) * np.sin(beta) z = r * np.cos(beta) - return x, y, z @@ -101,7 +101,7 @@ def cart2sph(x, y, z): \beta = \arccos \left( \frac{z}{r} \right) \\ r = \sqrt{x^2 + y^2 + z^2} - with :math:`\alpha \in [0, 2\pi), \beta \in [0, \pi], r \geq 0` + with :math:`\alpha \in [-pi, pi], \beta \in [0, \pi], r \geq 0` Parameters ---------- @@ -114,12 +114,13 @@ def cart2sph(x, y, z): Returns ------- - alpha : float or array_like + alpha : float or `numpy.ndarray` Azimuth angle in radiants - beta : float or array_like - Elevation angle in radiants (with 0 denoting North pole) - r : float or array_like + beta : float or `numpy.ndarray` + Colatitude angle in radiants (with 0 denoting North pole) + r : float or `numpy.ndarray` Radius + """ r = np.sqrt(x**2 + y**2 + z**2) alpha = np.arctan2(y, x) @@ -159,7 +160,7 @@ def asarray_of_rows(a, **kwargs): def as_xyz_components(components, **kwargs): - """Convert *components* to `XyzComponents` of `numpy.ndarray`\s. + r"""Convert *components* to `XyzComponents` of `numpy.ndarray`\s. The *components* are first converted to NumPy arrays (using :func:`numpy.asarray`) which are then assembled into an @@ -215,8 +216,7 @@ def as_delayed_signal(arg, **kwargs): """ try: - # In Python 3, this could be: data, samplerate, *time = arg - data, samplerate, time = arg[0], arg[1], arg[2:] + data, samplerate, *time = arg time, = time or [0] except (IndexError, TypeError, ValueError): pass @@ -230,7 +230,8 @@ def as_delayed_signal(arg, **kwargs): raise TypeError('expected audio data, samplerate, optional start time') -def strict_arange(start, stop, step=1, endpoint=False, dtype=None, **kwargs): +def strict_arange(start, stop, step=1, *, endpoint=False, dtype=None, + **kwargs): """Like :func:`numpy.arange`, but compensating numeric errors. Unlike :func:`numpy.arange`, but similar to :func:`numpy.linspace`, @@ -251,7 +252,7 @@ def strict_arange(start, stop, step=1, endpoint=False, dtype=None, **kwargs): Returns ------- - numpy.ndarray + `numpy.ndarray` Array of evenly spaced values. See :func:`numpy.arange`. """ @@ -266,7 +267,7 @@ def strict_arange(start, stop, step=1, endpoint=False, dtype=None, **kwargs): return np.arange(start, stop, step, dtype) -def xyz_grid(x, y, z, spacing, endpoint=True, **kwargs): +def xyz_grid(x, y, z, *, spacing, endpoint=True, **kwargs): """Create a grid with given range and spacing. Parameters @@ -338,18 +339,7 @@ def normalize_vector(x): return x / np.linalg.norm(x) -def displacement(v, omega): - """Particle displacement - - .. math:: - - d(x, t) = \int_0^t v(x, t) dt - - """ - return as_xyz_components(v) / (1j * omega) - - -def db(x, power=False): +def db(x, *, power=False): """Convert *x* to decibel. Parameters @@ -362,14 +352,14 @@ def db(x, power=False): """ with np.errstate(divide='ignore'): - return 10 if power else 20 * np.log10(np.abs(x)) + return (10 if power else 20) * np.log10(np.abs(x)) class XyzComponents(np.ndarray): """See __init__().""" def __init__(self, components): - """Triple (or pair) of components: x, y, and optionally z. + r"""Triple (or pair) of components: x, y, and optionally z. Instances of this class can be used to store coordinate grids (either regular grids like in `xyz_grid()` or arbitrary point @@ -431,7 +421,7 @@ def __getitem__(self, index): def __repr__(self): return 'XyzComponents(\n' + ',\n'.join( - ' {0}={1}'.format(name, repr(data).replace('\n', '\n ')) + ' {}={}'.format(name, repr(data).replace('\n', '\n ')) for name, data in zip('xyz', self)) + ')' def make_property(index, doc): @@ -477,7 +467,7 @@ def apply(self, func, *args, **kwargs): """ -def image_sources_for_box(x, L, N, prune=True): +def image_sources_for_box(x, L, N, *, prune=True): """Image source method for a cuboid room. The classical method by Allen and Berkley :cite:`Allen1979`. @@ -507,9 +497,9 @@ def image_sources_for_box(x, L, N, prune=True): Returns ------- - xs : (M, D) array_like + xs : (M, D) `numpy.ndarray` original & image source locations. - wall_count : (M, 2D) array_like + wall_count : (M, 2D) `numpy.ndarray` number of reflections at individual walls for each source. """ @@ -544,7 +534,7 @@ def _count_walls_1d(a): def spherical_hn2(n, z): r"""Spherical Hankel function of 2nd kind. - Defined as http://dlmf.nist.gov/10.47.E6, + Defined as https://dlmf.nist.gov/10.47.E6, .. math:: @@ -563,3 +553,96 @@ def spherical_hn2(n, z): """ return spherical_jn(n, z) - 1j * spherical_yn(n, z) + + +def source_selection_plane(n0, n): + """Secondary source selection for a plane wave. + + Eq.(13) from :cite:`Spors2008` + + """ + n0 = asarray_of_rows(n0) + n = normalize_vector(n) + return np.inner(n, n0) >= default.selection_tolerance + + +def source_selection_point(n0, x0, xs): + """Secondary source selection for a point source. + + Eq.(15) from :cite:`Spors2008` + + """ + n0 = asarray_of_rows(n0) + x0 = asarray_of_rows(x0) + xs = asarray_1d(xs) + ds = x0 - xs + return inner1d(ds, n0) >= default.selection_tolerance + + +def source_selection_line(n0, x0, xs): + """Secondary source selection for a line source. + + compare Eq.(15) from :cite:`Spors2008` + + """ + return source_selection_point(n0, x0, xs) + + +def source_selection_focused(ns, x0, xs): + """Secondary source selection for a focused source. + + Eq.(2.78) from :cite:`Wierstorf2014` + + """ + x0 = asarray_of_rows(x0) + xs = asarray_1d(xs) + ns = normalize_vector(ns) + ds = xs - x0 + return inner1d(ns, ds) >= default.selection_tolerance + + +def source_selection_all(N): + """Select all secondary sources.""" + return np.ones(N, dtype=bool) + + +def max_order_circular_harmonics(N): + r"""Maximum order of 2D/2.5D HOA. + + It returns the maximum order for which no spatial aliasing appears. + It is given on page 132 of :cite:`Ahrens2012` as + + .. math:: + \mathtt{max\_order} = + \begin{cases} + N/2 - 1 & \text{even}\;N \\ + (N-1)/2 & \text{odd}\;N, + \end{cases} + + which is equivalent to + + .. math:: + \mathtt{max\_order} = \big\lfloor \frac{N - 1}{2} \big\rfloor. + + Parameters + ---------- + N : int + Number of secondary sources. + + """ + return (N - 1) // 2 + + +def max_order_spherical_harmonics(N): + r"""Maximum order of 3D HOA. + + .. math:: + \mathtt{max\_order} = \lfloor \sqrt{N} \rfloor - 1. + + Parameters + ---------- + N : int + Number of secondary sources. + + """ + return int(np.sqrt(N) - 1) diff --git a/tests/test_array.py b/tests/test_array.py index 789c7a4..5100f41 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -11,31 +11,27 @@ def vectortypes(*coeffs): np.array(coeffs), np.array(coeffs).reshape(1, -1), np.array(coeffs).reshape(-1, 1), - np.matrix(coeffs), - np.matrix(coeffs).transpose(), ] def vector_id(vector): - if isinstance(vector, np.matrix): - return 'matrix, shape=' + repr(vector.shape) - elif isinstance(vector, np.ndarray): + if isinstance(vector, np.ndarray): return 'array, shape=' + repr(vector.shape) return type(vector).__name__ @pytest.mark.parametrize('N, spacing, result', [ - (2, 1, sfs.array.ArrayData( + (2, 1, sfs.array.SecondarySourceDistribution( x=[[0, -0.5, 0], [0, 0.5, 0]], n=[[1, 0, 0], [1, 0, 0]], a=[1, 1], )), - (3, 1, sfs.array.ArrayData( + (3, 1, sfs.array.SecondarySourceDistribution( x=[[0, -1, 0], [0, 0, 0], [0, 1, 0]], n=[[1, 0, 0], [1, 0, 0], [1, 0, 0]], a=[1, 1, 1], )), - (3, 0.5, sfs.array.ArrayData( + (3, 0.5, sfs.array.SecondarySourceDistribution( x=[[0, -0.5, 0], [0, 0, 0], [0, 0.5, 0]], n=[[1, 0, 0], [1, 0, 0], [1, 0, 0]], a=[0.5, 0.5, 0.5], diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..6eb7a92 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,70 @@ +import numpy as np +from numpy.testing import assert_allclose +import pytest +import sfs + + +cart_sph_data = [ + ((1, 1, 1), (np.pi / 4, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), + ((-1, 1, 1), (3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), + ((1, -1, 1), (-np.pi / 4, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), + ((-1, -1, 1), (-3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), + ((1, 1, -1), (np.pi / 4, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), + ((-1, 1, -1), (3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), + ((1, -1, -1), (-np.pi / 4, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), + ((-1, -1, -1), (-3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), +] + + +@pytest.mark.parametrize('coord, polar', cart_sph_data) +def test_cart2sph(coord, polar): + x, y, z = coord + a = sfs.util.cart2sph(x, y, z) + assert_allclose(a, polar) + + +@pytest.mark.parametrize('coord, polar', cart_sph_data) +def test_sph2cart(coord, polar): + alpha, beta, r = polar + b = sfs.util.sph2cart(alpha, beta, r) + assert_allclose(b, coord) + + +direction_vector_data = [ + ((np.pi / 4, np.pi / 4), (0.5, 0.5, np.sqrt(2) / 2)), + ((3 * np.pi / 4, 3 * np.pi / 4), (-1 / 2, 1 / 2, -np.sqrt(2) / 2)), + ((3 * np.pi / 4, -3 * np.pi / 4), (1 / 2, -1 / 2, -np.sqrt(2) / 2)), + ((np.pi / 4, -np.pi / 4), (-1 / 2, -1 / 2, np.sqrt(2) / 2)), + ((-np.pi / 4, np.pi / 4), (1 / 2, -1 / 2, np.sqrt(2) / 2)), + ((-3 * np.pi / 4, 3 * np.pi / 4), (-1 / 2, -1 / 2, -np.sqrt(2) / 2)), + ((-3 * np.pi / 4, -3 * np.pi / 4), (1 / 2, 1 / 2, -np.sqrt(2) / 2)), + ((-np.pi / 4, -np.pi / 4), (-1 / 2, 1 / 2, np.sqrt(2) / 2)), +] + + +@pytest.mark.parametrize('input, vector', direction_vector_data) +def test_direction_vector(input, vector): + alpha, beta = input + c = sfs.util.direction_vector(alpha, beta) + assert_allclose(c, vector) + + +db_data = [ + (0, -np.inf), + (0.5, -3.01029995663981), + (1, 0), + (2, 3.01029995663981), + (10, 10), +] + + +@pytest.mark.parametrize('linear, power_db', db_data) +def test_db_amplitude(linear, power_db): + d = sfs.util.db(linear) + assert_allclose(d, power_db * 2) + + +@pytest.mark.parametrize('linear, power_db', db_data) +def test_db_power(linear, power_db): + d = sfs.util.db(linear, power=True) + assert_allclose(d, power_db)