Quadriga-Lib
C++/MEX/Python Utility library for radio channel modelling and simulations
Python API Documentation for Quadriga-Lib v0.11.0
Notes

- The Quadriga-Lib Python interface of implemented using pybind11. Before getting started, make sure that the Python library is compiled for your system and python environment.
- Unless otherwise stated, the Quadriga-Lib Python API reads and writes data to and from Numpy arrays in double precision. Hence, you need to have numpy installed and imported in your python file.
- There is currently no Microsoft Windows version of the Quadriga-Lib Python interface. This is planned for a later stage.
- The Quadriga-Lib Python package is compiled into a single ".so" file located in the "lib" folder. To import it in Python, the "lib" folder must be on your Python path, which is usually not the case unless you install a package using pip or conda. To import the "quadriga_lib" from a custom location, you can do the following:

      import sys
      import numpy as np

      sys.path.append("/path_to/quadriga-lib/lib")
      import quadriga_lib

Overview

- Array antenna functions
- Channel functions
- Miscellaneous / Tools
- Site-Specific Simulation Tools

Array antenna functions

calc_directivity | Calculates the directivity (in dBi) of array antenna elements
combine_pattern | Calculate effective radiation patterns for array antennas
copy_element | Create copies of array antenna elements
export_obj_file | Creates a Wavefront OBJ file for visualizing the shape of the antenna pattern
generate | Generates predefined array antenna models
generate_speaker | Generates a parametric loudspeaker directivity model
get_channels_multifreq | Calculate channel coefficients for spherical waves across multiple frequencies
get_channels_planar | Calculate channel coefficients for planar waves
get_channels_spherical | Calculate channel coefficients from path data and antenna patterns
interpolate | Interpolate array antenna field patterns
qdant_read | Reads array antenna data from QDANT files
qdant_write | Writes array antenna data to QDANT files
rotate_pattern | Rotates antenna patterns

Channel functions

baseband_freq_response | Compute the frequency-domain channel response from time-domain coefficients
channel_export_obj_file | Export path data to a Wavefront OBJ file for visualization in Blender
get_ieee_indoor | Generate indoor MIMO channel realizations for IEEE TGn/TGac/TGax/TGah models
hdf5_create_file | Create a new HDF5 channel file with a custom storage layout
hdf5_read_channel | Reads channel data from HDF5 files
hdf5_read_dset | Read a single unstructured dataset from an HDF5 file
hdf5_read_dset_names | Read the names of unstructured data fields from an HDF5 file
hdf5_read_layout | Read the storage layout of channel data inside an HDF5 file
hdf5_reshape_layout | Reshapes the storage layout inside an existing HDF5 file
hdf5_write_channel | Writes channel data to HDF5 files
hdf5_write_dset | Writes unstructured data to a HDF5 file
qrt_file_parse | Read metadata from a QRT file
qrt_file_read | Read ray-tracing data from QRT file
quantize_delays | Fixes the path delays to a grid of delay bins

Miscellaneous / Tools

acdf | Calculate the empirical averaged cumulative distribution function (CDF)
calc_angular_spread | Calculate azimuth and elevation angular spreads with spherical wrapping
calc_cross_polarization_ratio | Calculate the cross-polarization ratio (XPR) for linear and circular polarization bases
calc_delay_spread | Calculate the RMS delay spread in [s]
calc_rician_k_factor | Calculate the Rician K-Factor from channel impulse response data
cart2geo | Transform Cartesian (x,y,z) coordinates to Geographic (az, el, length) coordinates
components | Returns the version numbers of all quadriga-lib sub-components
version | Returns the quadriga-lib version number
write_png | Write data to a PNG file

Site-Specific Simulation Tools

icosphere | Construct a geodesic polyhedron (icosphere), a convex polyhedron made from triangles
mitsuba_xml_file_write | Write geometry and material data to a Mitsuba 3 XML scene file.
obj_file_read | Reads a triangulated 3D polygon mesh from a Wavefront OBJ file
point_cloud_aabb | Calculates the axis-aligned bounding box (AABB) for a 3D point cloud or a set of sub-clouds
point_cloud_segmentation | Rearranges elements of a point cloud into smaller sub-clouds
point_inside_mesh | Test whether 3D points are inside a triangle mesh using raycasting
ray_point_intersect | Calculates which 3D points are intersected by volumetric ray beams
ray_triangle_intersect | Calculates the intersection of rays and triangles in three dimensions
triangle_mesh_aabb | Calculate the axis-aligned bounding box (AABB) of a triangle mesh and its sub-meshes
triangle_mesh_segmentation | Rearranges elements of a triangle mesh into smaller sub-meshes

Array antenna functions

calc_directivity - Calculates the directivity (in dBi) of array antenna elements

- Description:
  Directivity is a parameter of an antenna or which measures the degree to which the radiation emitted is concentrated in a single direction. It is the ratio of the radiation intensity in a given direction from the antenna to the radiation intensity averaged over all directions. Therefore, the directivity of a hypothetical isotropic radiator is 1, or 0 dBi.
- Usage:

      from quadriga_lib import arrayant
      directivity = arrayant.calc_directivity(arrayant, element)

- Input Arguments:
  - `arrayant_in`
    Dictionary containing the arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`

  - `element` (optional)
    Element index, 0-based. If not provided or empty, the directivity is calculated for all elements in the array antenna. Shape: `(n_out)` or empty
- Output Argument:
  - `directivity`
    Directivity of the antenna pattern in dBi, Shape: `[n_out)` or `[n_elements)`

combine_pattern - Calculate effective radiation patterns for array antennas

- Description:
  An array antenna consists of multiple individual elements. Each element occupies a specific position relative to the array's phase-center, its local origin. Elements can also be inter-coupled, represented by a coupling matrix. By integrating the element radiation patterns, their positions, and the coupling weights, one can determine an effective radiation pattern observable by a receiver in the antenna's far field. Leveraging these effective patterns is especially beneficial in antenna design, beamforming applications such as in 5G systems, and in planning wireless communication networks in complex environments like urban areas. This streamlined approach offers a significant boost in computation speed when calculating MIMO channel coefficients, as it reduces the number of necessary operations. The function `arrayant_combine_pattern` is designed to compute these effective radiation patterns.
- Usage:

      from quadriga_lib import arrayant

      # Minimal example
      arrayant_out = arrayant.combine_pattern(arrayant)

      # Optional inputs: freq, azimuth_grid, elevation_grid
      arrayant_out = arrayant.combine_pattern(arrayant, freq, azimuth_grid, elevation_grid)

- Input Arguments:
  - `arrayant`
    Dictionary containing the arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part | Shape: `(n_elements, n_ports)`
    `coupling_im` | Coupling matrix, imaginary part | Shape: `(n_elements, n_ports)`
    `center_freq` | Center frequency in [Hz], optional | Scalar
    `name` | Name of the array antenna object, optional | String

  - `freq` (optional)
    An alternative value for the center frequency. Overwrites the value given in `arrayant_in`. If neither `freq` not `arrayant_in["center_freq")` are given, an error is thrown.
  - `azimuth_grid` (optional)
    Alternative azimuth angles for the output in [rad], -pi to pi, sorted, Shape: `(n_azimuth_out)`, If not given, `arrayant_in["azimuth_grid")` is used instead.
  - `elevation_grid` (optional)
    Alternative elevation angles for the output in [rad], -pi/2 to pi/2, sorted, Shape: `(n_elevation_out)`, If not given, `arrayant_in["elevation_grid")` is used instead.
- Output Arguments:
  - `arrayant_out`
    Dictionary containing the arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_out, n_azimuth_out, n_ports)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_azimuth_out, n_azimuth_out, n_ports)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_azimuth_out, n_azimuth_out, n_ports)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_azimuth_out, n_azimuth_out, n_ports)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_out)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_azimuth_out)`
    `element_pos` | Antenna element (x,y,z) positions, set to 0 | Shape: `(3, n_ports)`
    `coupling_re` | Coupling matrix, real part, identity matrix | Shape: `(n_ports, n_ports)`
    `coupling_im` | Coupling matrix, imaginary part, zero matrix | Shape: `(n_ports, n_ports)`
    `center_freq` | Center frequency in [Hz] | Scalar
    `name` | Name of the array antenna object, same as input | String

copy_element - Create copies of array antenna elements

- Description:
  Copies one or more antenna elements to new positions within the arrayant, expanding the element count if necessary. Supports both single-frequency arrayants (3D pattern fields) and multi-frequency arrayants (4D pattern fields). For multi-frequency inputs, element copying is applied consistently across all frequency entries. The function auto-detects whether the input is single-frequency or multi-frequency by inspecting the dimensionality of the `e_theta_re` field (3D = single, 4D = multi).
- Usage:

      from quadriga_lib import arrayant

      # Single-frequency: copy element 0 to position 1
      arrayant_out = arrayant.copy_element(ant, source_element=[0], dest_element=[1])

      # Single-frequency: copy element 0 to positions 2 and 3
      arrayant_out = arrayant.copy_element(ant, source_element=[0], dest_element=[2, 3])

      # Multi-frequency (4D patterns): same interface
      speaker_out = arrayant.copy_element(speaker, source_element=[0], dest_element=[1])

      # Copy multiple sources to multiple destinations (must be same length)
      arrayant_out = arrayant.copy_element(ant, source_element=[0, 1], dest_element=[2, 3])

- Input Arguments:
  - `arrayant` [1] (required)
    Dictionary containing the arrayant data. Pattern fields may be 3D (single-frequency) or 4D (multi-frequency, 4th dimension = frequency). The following keys are expected:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `center_freq` | Center frequency in [Hz], optional | Scalar or 1D array `(n_freq)`
    `name` | Name of the array antenna object, optional | String

  - `source_element` [2] (required)
    Index of the source elements (0-based), scalar or vector
  - `dest_element` [3] (optional)
    Index of the destination elements (0-based), either as a vector or as a scalar. If `source_element` is also a vector, `dest_element` must have the same length.
- Output Arguments:
  - `arrayant_out`
    Dictionary containing the arrayant data with the copied elements. Output format matches the input format (3D for single-frequency, 4D for multi-frequency).

export_obj_file - Creates a Wavefront OBJ file for visualizing the shape of the antenna pattern

- Usage:

      from quadriga_lib import arrayant
      arrayant.export_obj_file( fn, arrayant, directivity_range, colormap,
                      object_radius, icosphere_n_div, i_element )

- Input Arguments:
  - `fn`
    Filename of the OBJ file, string
  - `arrayant`
    Dictionary containing array antenna data with at least the following keys:

    `e_theta_re` | Real part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_theta_im` | Imaginary part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_phi_re` | Real part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_phi_im` | Imaginary part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `azimuth_grid` | Azimuth angles in [rad] -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `name` | Name of the array antenna object | String

  - `directivity_range`
    Directivity range of the antenna pattern visualization in dB
  - `colormap`
    Colormap for the visualization, string, supported are 'jet', 'parula', 'winter', 'hot', 'turbo', 'copper', 'spring', 'cool', 'gray', 'autumn', 'summer', Optional, default = 'jet'
  - `object_radius`
    Radius in meters of the exported object
  - `icosphere_n_div`
    Map pattern to an Icosphere with given number of subdivisions
  - `element`
    Antenna element indices, 0-based, empty = export all

generate - Generates predefined array antenna models

- Description:
  This functions can be used to generate a variety of pre-defined array antenna models, including 3GPP array antennas used for 5G-NR simulations. The first argument is the array type. The following input arguments are then specific to this type.
- Usage:

      from quadriga_lib import arrayant

      # Isotropic radiator, vertical polarization
      arrayant = arrayant.generate('omni', res)

      # Short dipole radiating with vertical polarization
      arrayant = arrayant.generate('dipole', res)

      # Half-wave dipole radiating with vertical polarization
      arrayant = arrayant.generate('half-wave-dipole', res)

      # Cross-polarized isotropic radiator
      arrayant = arrayant.generate('xpol', res)

      # A unified linear array with isotropic patterns
      arrayant = arrayant.generate('ula', res=30, N=4, freq=2.4e9, spacing=0.7)

      # An antenna with a custom 3dB beam with (in degree)
      arrayant = arrayant.generate('custom', res, az_3dB, el_3db, rear_gain_lin)

      # 3GPP-NR antenna model (example for 2x2, V-polarized, 0.7λ spacing)
      arrayant = arrayant.generate('3gpp', M=2, N=2, freq=3.7e9, pol=1, spacing=0.7)

      # Planar multi-element antenna with support for multiple beam directions
      arrayant = arrayant.generate('multibeam', M=6, N=6, freq=3.7e9, pol=1, spacing=0.7, az=[-30.0, 30.0], el=[0.0, 0.0])

- Input Arguments:
  - `type`
    Antenna model type, string
  - `res`
    Pattern resolution in [deg], scalar, default = 1 deg
  - `freq`
    The center frequency in [Hz], scalar, default = 299792458 Hz
- Input arguments for type `custom`, `3gpp` and `multibeam`:
  - `az_3dB`
    3dB beam width in azimuth direction in [deg], scalar, default for `custom` = 90 deg, default for `3gpp` = 67 deg, `multibeam` = 120 deg
  - `el_3db`
    3dB beam width in elevation direction in [deg], scalar, default for `custom` = 90 deg, default for `3gpp` = 67 deg, `multibeam` = 120 deg
  - `rear_gain_lin`
    Isotropic gain (linear scale) at the back of the antenna, scalar, default = 0.0
- Input arguments for type `3gpp` and `multibeam`:
  - `M`
    Number of vertically stacked elements for `3gpp` and `multibeam`, scalar, default = 1
  - `N`
    Number of horizontally stacked elements for `3gpp`, `ula` and `multibeam`, scalar, default = 1
  - `pol`
    Polarization indicator to be applied for each of the M elements:

    `pol = 1` | vertical polarization (default value), `3gpp` and `multibeam`
    `pol = 2` | H/V polarized elements, results in 2NM elements, `3gpp` and `multibeam`
    `pol = 3` | +/-45° polarized elements, results in 2NM elements, `3gpp` and `multibeam`
    `pol = 4` | vertical polarization, combines elements in vertical direction, results in N elements, `3gpp` only
    `pol = 5` | H/V polarization, combines elements in vertical direction, results in 2N elements, `3gpp` only
    `pol = 6` | +/-45° polarization, combines elements in vertical direction, results in 2N elements, `3gpp` only

    Polarization indicator is ignored when a custom pattern is provided.
  - `tilt`
    The electric downtilt angle in [deg], Only relevant for `pol = 4/5/6`, `3gpp` only, scalar, default = 0
  - `spacing`
    Element spacing in [λ] for `3gpp`, `ula` and `multibeam`, scalar, default = 0.5
  - `Mg`
    Number of nested panels in a column, `3gpp` only, scalar, default = 1
  - `Ng`
    Number of nested panels in a row, `3gpp` only, scalar, default = 1
  - `dgv`
    Panel spacing in vertical direction in [λ], `3gpp` only, scalar, default = 0.5
  - `dgh`
    Panel spacing in horizontal direction in [λ], `3gpp` only, scalar, default = 0.5
  - `beam_az`
    Azimuth beam angles (degrees), `multibeam` only, Vector of length `n_beams`. Default: `[0.0]`
  - `beam_el`
    Elevation beam angles (degrees), `multibeam` only, Vector of length `n_beams`. Default: `[0.0]`
  - `beam_weight`
    Scaling factors for each beam, `multibeam` only, The vector must have the same length as `beam_az` and `beam_el`. Values are normalized so that their sum equals 1. Can be used to prioritize beams. Default: `{1.0}`
  - `separate_beams`
    If set to true, create a separate beam for each angle pair (ignores weights), `multibeam` only
  - `apply_weights`
    Switch to apply the beam-forming weights
  - `pattern` (optional)
    Dictionary containing a custom pattern (default = empty) with at least the following keys:

    `e_theta_re` | Real part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_theta_im` | Imaginary part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_phi_re` | Real part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `e_phi_im` | Imaginary part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements_c)`
    `azimuth_grid` | Azimuth angles in [rad] -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`

- Output Arguments:
  - `arrayant`
    Dictionary containing the arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements)`
    `azimuth_grid` | Azimuth angles in [rad] -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part | Shape: `(n_elements, n_ports)`
    `coupling_im` | Coupling matrix, imaginary part | Shape: `(n_elements, n_ports)`
    `center_freq` | Center frequency in [Hz], optional, default = 0.3 GHz | Scalar
    `name` | Name of the array antenna object | String

generate_speaker - Generates a parametric loudspeaker directivity model

- Description:
  This function generates frequency-dependent loudspeaker radiation patterns by combining a driver directivity model with an enclosure radiation modifier and a Butterworth-style bandpass frequency response. Returns a multi-frequency arrayant dictionary where the pattern fields are 4D arrays (the 4th dimension is frequency) and `center_freq` is a 1D array of frequency samples in Hz. Multi-driver speakers (e.g. two-way systems) are modelled by generating each driver separately and combining them via `arrayant_concat_multi`. Crossover behavior emerges naturally from overlapping bandpass responses.
  Three driver types are supported:
  - `piston`: Circular piston in a baffle using the classical Bessel J1 formula. Transitions from omnidirectional at low ka to progressively narrower beaming at high ka.
  - `horn`: Separable cosine-power directivity with frequency-dependent pattern control. Below the horn control frequency, the pattern blends toward omnidirectional.
  - `omni`: Frequency-independent omnidirectional pattern (suitable for subwoofers).
  Four enclosure radiation types modify the base driver pattern:
  - `monopole`: No modification (4π radiation). Appropriate for subwoofers in free space.
  - `hemisphere`: Sealed box on a finite baffle with frequency-dependent baffle step transition.
  - `dipole`: Open baffle / planar speaker with figure-8 pattern.
  - `cardioid`: Monopole + dipole combination with null at the rear.
  The frequency response follows a Butterworth-style bandpass filter. If no frequency vector is provided, third-octave band center frequencies are auto-generated covering the range from one band below the lower cutoff to one band above the upper cutoff, clipped to 20–20000 Hz.
- Usage:

      from quadriga_lib import arrayant

      # Default piston driver (4-inch, 80 Hz – 12 kHz)
      speaker = arrayant.generate_speaker()

      # Horn tweeter with custom coverage
      speaker = arrayant.generate_speaker(
          driver_type='horn',
          radius=0.025,
          lower_cutoff=1500.0,
          upper_cutoff=20000.0,
          radiation_type='hemisphere',
          hor_coverage=90.0,
          ver_coverage=60.0
      )

      # Omnidirectional subwoofer with steep rolloff
      speaker = arrayant.generate_speaker(
          driver_type='omni',
          radius=0.165,
          lower_cutoff=30.0,
          upper_cutoff=300.0,
          lower_rolloff_slope=24.0,
          upper_rolloff_slope=24.0,
          sensitivity=90.0,
          radiation_type='monopole'
      )

      # Piston driver at specific frequencies
      import numpy as np
      speaker = arrayant.generate_speaker(
          frequencies=np.array([100.0, 500.0, 1000.0, 5000.0, 10000.0]),
          angular_resolution=5.0
      )

- Input Arguments:
  - `driver_type`
    Driver directivity model, string. Supported values: `"piston"` (cone/dome via Bessel function), `"horn"` (cosine-power with frequency-dependent pattern control), `"omni"` (omnidirectional subwoofer). Default: `"piston"`
  - `radius`
    Effective radiating radius in meters, scalar. For `"piston"`: cone or dome radius. For `"horn"`: mouth radius (pattern control frequency auto-derived if not specified). Default: `0.05` (~4-inch driver)
  - `lower_cutoff`
    Lower −3 dB frequency of the bandpass response in Hz, scalar. Default: `80.0`
  - `upper_cutoff`
    Upper −3 dB frequency of the bandpass response in Hz, scalar. Default: `12000.0`
  - `lower_rolloff_slope`
    Low-frequency rolloff slope in dB per octave, scalar. Butterworth order = slope / 6 (e.g. 12 dB/oct = 2nd order). Default: `12.0`
  - `upper_rolloff_slope`
    High-frequency rolloff slope in dB per octave, scalar. Default: `12.0`
  - `sensitivity`
    On-axis sensitivity in dB SPL at 1W/1m, scalar. Scales amplitude linearly relative to 85 dB reference. Default: `85.0`
  - `radiation_type`
    Enclosure radiation modifier, string. Supported values: `"monopole"`, `"hemisphere"`, `"dipole"`, `"cardioid"`. Default: `"hemisphere"`
  - `hor_coverage`
    Horizontal coverage angle in degrees, scalar. Horn driver only. 0 = auto (90°). Default: `0.0`
  - `ver_coverage`
    Vertical coverage angle in degrees, scalar. Horn driver only. 0 = auto (60°). Default: `0.0`
  - `horn_control_freq`
    Horn pattern control frequency in Hz, scalar. 0 = auto-derived from mouth radius. Default: `0.0`
  - `baffle_width`
    Enclosure baffle width in meters, scalar. Piston driver only (used for baffle step model). Default: `0.15`
  - `baffle_height`
    Enclosure baffle height in meters, scalar. Piston driver only. Default: `0.25`
  - `frequencies`
    Frequency sample points in Hz, 1D numpy array. If empty, third-octave bands are auto-generated. Default: empty (auto)
  - `angular_resolution`
    Angular grid resolution in degrees, scalar. Used to generate azimuth and elevation grids. Default: `5.0`
- Output Argument:
  - `speaker`
    Dictionary containing the multi-frequency arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation, n_azimuth, n_elements, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad], −π to π, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], −π/2 to π/2 | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part | Shape: `(n_elements, n_ports)` or `(n_elements, n_ports, n_freq)`
    `coupling_im` | Coupling matrix, imaginary part | Shape: `(n_elements, n_ports)` or `(n_elements, n_ports, n_freq)`
    `center_freq` | Frequency samples in Hz | Shape: `(n_freq)` - 1D array
    `name` | Name of the array antenna object | String

get_channels_multifreq - Calculate channel coefficients for spherical waves across multiple frequencies

- Description:
  - Extends `get_channels_spherical` to support frequency-dependent antenna patterns, path gains, and polarization transfer (Jones) matrices across multiple output frequencies.
  - Geometry is computed once: departure angles, arrival angles, element-resolved path delays, and LOS path detection are frequency-independent and reused for all output frequencies. This avoids redundant trigonometry and distance calculations.
  - Four frequency grids are aligned by interpolation:

    1. | TX array frequencies (defined by `center_freq` in the `ant_tx` dictionary)
    2. | RX array frequencies (defined by `center_freq` in the `ant_rx` dictionary)
    3. | Input sample frequencies (`freq_in`) at which `path_gain` and `M` are provided
    4. | Target output frequencies (`freq_out`) at which coefficients and delays are returned

  - For each output frequency, TX and RX antenna patterns are interpolated from their respective multi-frequency entries using spherical interpolation (SLERP) with linear fallback, the same algorithm used in `arrayant_interpolate_multi`.
  - Path gain is interpolated linearly across frequency. The Jones matrix `M` is interpolated using SLERP for each complex entry pair to preserve phase coherence.
  - Extrapolation is handled by clamping to the nearest available frequency entry in all four grids.
  - Propagation speed can be set to support both radio (speed of light, default) and acoustic (speed of sound, ~343 m/s) simulations. This affects wavelength, wave number, and delay calculations.
  - The Jones matrix `M` supports two formats: 8 rows for full polarimetric (ReVV, ImVV, ReVH, ImVH, ReHV, ImHV, ReHH, ImHH), or 2 rows for scalar pressure waves (ReVV, ImVV only), where VH, HV, and HH entries are implicitly zero.
  - Antenna element coupling is applied using the coupling matrices from the antenna dictionary. If coupling varies with frequency, provide 3D coupling arrays `(n_elem, n_ports, n_freq)`.
  - Antenna dictionaries accept both 3D patterns (single-frequency, clamped for all output frequencies) and 4D patterns (multi-frequency, 4th dimension = frequency).
- Usage:

      from quadriga_lib import arrayant
      import numpy as np

      coeff_re, coeff_im, delays = arrayant.get_channels_multifreq( ant_tx, ant_rx,
          fbs_pos, lbs_pos, path_gain, path_length, M, tx_pos, tx_orientation, rx_pos, rx_orientation,
          freq_in, freq_out, use_absolute_delays, add_fake_los_path, propagation_speed )

- Input Arguments:
  - `ant_tx` (required)
    Dictionary containing the transmit (TX) arrayant data. Pattern fields may be 3D (single-frequency) or 4D (multi-frequency, 4th dimension = frequency). The following keys are expected:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `center_freq` | Center frequency in [Hz], optional | Scalar or 1D array `(n_freq)`
    `name` | Name of the array antenna object, optional | String

  - `ant_rx` (required)
    Dictionary containing the receive (RX) arrayant data (same format as `ant_tx`).
  - `fbs_pos` (required)
    First-bounce scatterer positions. For single-bounce models, identical to `lbs_pos`. Shape: `( 3, n_path )`
  - `lbs_pos` (required)
    Last-bounce scatterer positions. For single-bounce models, identical to `fbs_pos`. Shape: `( 3, n_path )`
  - `path_gain` (required)
    Path gain in linear scale. Each column corresponds to one input frequency. Shape: `( n_path, n_freq_in )`
  - `path_length` (required)
    Absolute path lengths from TX to RX phase center in meters. Shape: `( n_path )`
  - `M` (required)
    Polarization transfer matrix in interleaved complex format. Each slice along the 3rd dimension corresponds to one input frequency. Full polarimetric format uses 8 rows: (ReVV, ImVV, ReVH, ImVH, ReHV, ImHV, ReHH, ImHH). Scalar pressure format uses 2 rows: (ReVV, ImVV), with VH, HV, and HH implicitly zero. Shape: `( 8, n_path, n_freq_in )` or `( 2, n_path, n_freq_in )`
  - `tx_pos` (required)
    Transmitter position in 3D Cartesian coordinates [m], Shape: `(3)`
  - `tx_orientation` (required)
    Transmitter antenna orientation in Euler angles (bank, tilt, heading) [rad], Shape: `(3)`
  - `rx_pos` (required)
    Receiver position in 3D Cartesian coordinates [m], Shape: `(3)`
  - `rx_orientation` (required)
    Receiver antenna orientation in Euler angles (bank, tilt, heading) [rad], Shape: `(3)`
  - `freq_in` (required)
    Input sample frequencies in [Hz] at which `path_gain` and `M` are defined. Shape: `( n_freq_in )`
  - `freq_out` (required)
    Target frequencies in [Hz] at which to compute output coefficients and delays. Shape: `( n_freq_out )`
  - `use_absolute_delays` (optional)
    If true, the LOS delay is included in all path delays. Default: `False`, i.e. delays are normalized so that the LOS path has zero delay.
  - `add_fake_los_path` (optional)
    If true, adds a zero-power LOS path as the first path when no LOS path was detected. Default: `False`
  - `propagation_speed` (optional)
    Wave propagation speed in [m/s]. Default: `299792458.0` (speed of light for radio simulations). Set to ~`343.0` for acoustic simulations in air.
- Derived inputs:

  `n_freq_in` | Number of input frequency samples (columns of `path_gain`, slices of `M`)
  `n_freq_out` | Number of output frequency samples (length of `freq_out`)
  `n_path` | Number of propagation paths (columns of `fbs_pos`)
  `n_ports_tx` | Number of TX antenna ports after coupling
  `n_ports_rx` | Number of RX antenna ports after coupling

- Output Arguments:
  - `coeff_re`
    Channel coefficients, real part, 4D array with the 4th dimension being frequency. Shape: `( n_ports_rx, n_ports_tx, n_path, n_freq_out )`
  - `coeff_im`
    Channel coefficients, imaginary part, 4D array with the 4th dimension being frequency. Shape: `( n_ports_rx, n_ports_tx, n_path, n_freq_out )`
  - `delays`
    Propagation delays in seconds, 4D array with the 4th dimension being frequency. Shape: `( n_ports_rx, n_ports_tx, n_path, n_freq_out )`
- Example:

      from quadriga_lib import arrayant
      import numpy as np

      # Build a 2-way speaker as TX (source)
      tx_woofer = arrayant.generate_speaker(
          driver_type='piston', radius=0.083,
          lower_cutoff=50.0, upper_cutoff=3000.0,
          lower_rolloff_slope=12.0, upper_rolloff_slope=24.0, sensitivity=87.0,
          radiation_type='hemisphere', baffle_width=0.20, baffle_height=0.30,
          frequencies=np.array([100.0, 500.0, 1000.0, 5000.0, 10000.0]),
          angular_resolution=10.0)

      # Omnidirectional microphone as RX (single-frequency, clamped for all output frequencies)
      rx = arrayant.generate('omni')

      # Simple LOS path setup
      fbs_pos = np.array([[0.5], [0.0], [0.0]])     # Scatterer at RX position
      lbs_pos = np.array([[0.5], [0.0], [0.0]])
      path_length = np.array([1.0])                 # 1 meter distance

      # Frequency-flat path gain and scalar Jones matrix at two input frequencies
      freq_in = np.array([100.0, 10000.0])
      path_gain = np.ones((1, 2))                   # Unit gain at both frequencies
      M = np.zeros((2, 1, 2))                       # Scalar pressure (2 rows)
      M[0, 0, 0] = 1.0; M[0, 0, 1] = 1.0            # ReVV = 1 at both frequencies

      # Compute channel at 3 output frequencies using speed of sound
      freq_out = np.array([200.0, 1000.0, 5000.0])
      coeff_re, coeff_im, delays = arrayant.get_channels_multifreq(
          tx_woofer, rx, fbs_pos, lbs_pos, path_gain, path_length, M,
          np.zeros(3),                               # TX at origin
          np.zeros(3),                               # TX orientation (no rotation)
          np.array([1.0, 0.0, 0.0]),                 # RX at (1, 0, 0)
          np.zeros(3),                               # RX orientation (no rotation)
          freq_in, freq_out,
          propagation_speed=343.0)                   # Speed of sound for acoustics

      # coeff_re.shape = (1, n_tx_ports, 1, 3) — one RX port, one path, 3 output frequencies

- Caveat:
  - Input data is directly accessed from Python memory, without copying if it is provided in double precision and is in F-contiguous (column-major) order.
  - Other formats (e.g. single precision inputs or C-contiguous (row-major) order) will be converted to double automatically, causing additional computation steps.
  - To improve performance of repeated computations (e.g. in loops), consider preparing the data in F-contiguous double precision to avoid unnecessary copies.
- See also:
  - get_channels_spherical — single-frequency version
  - interpolate — multi-frequency antenna interpolation
  - generate_speaker — parametric loudspeaker directivity model

get_channels_planar - Calculate channel coefficients for planar waves

- Description:
  In this function, the wireless propagation channel between a transmitter and a receiver is calculated, based on a single transmit and receive position. Additionally, interaction points with the environment, which are derived from either Ray Tracing or Geometric Stochastic Models such as QuaDRiGa, are considered. The calculation is performed under the assumption of planar wave propagation. For accurate execution of this process, several pieces of input data are required:
  - The 3D Cartesian (local) coordinates of both the transmitter and the receiver.
  - The azimuth/elevation departure and arrval angles.
  - The polarization transfer matrix for each propagation path.
  - Antenna models for both the transmitter and the receiver.
  - The orientations of the antennas.
- Usage:

      from quadriga_lib import arrayant

      coeff_re, coeff_im, delays, rx_Doppler = arrayant.get_channels_planar( ant_tx, ant_rx,
          aod, eod, aoa, eoa, path_gain, path_length, M, tx_pos, tx_orientation, rx_pos, rx_orientation,
          center_freq, use_absolute_delays, add_fake_los_path );

- Input Arguments:
  - `ant_tx` (required)
    Dictionary containing the transmit (TX) arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_tx)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_tx)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_tx)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_tx, n_ports_tx)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_tx, n_ports_tx)`

  - `ant_rx` (required)
    Dictionary containing the receive (RX) arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_rx)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_rx)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_rx)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_rx, n_ports_rx)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_rx, n_ports_rx)`

  - `aod` (required)
    Departure azimuth angles in [rad], Shape: `( n_path )`
  - `eod` (required)
    Departure elevation angles in [rad], Shape: `( n_path )`
  - `aoa` (required)
    Arrival azimuth angles in [rad], Shape: `( n_path )`
  - `eoa` (required)
    Arrival elevation angles in [rad], Shape: `( n_path )`
  - `path_gain` (required)
    Path gain (linear scale), Shape: `( n_path )`
  - `path_length` (required)
    Total path length in meters, Shape: `( n_path )`
  - `M` (required)
    Polarization transfer matrix, interleaved complex values (ReVV, ImVV, ReVH, ImVH, ReHV, ImHV, ReHH, ImHH), Shape: `( 8, n_path )`
  - `tx_pos` (required)
    Transmitter position in 3D Cartesian coordinates; Shape: `(3)`
  - `tx_orientation` (required)
    3-element vector describing the orientation of the transmit antenna in Euler angles (bank, tilt, heading), Shape: `(3,1)` or `(1,3)`
  - `rx_pos` (required)
    Receiver position in 3D Cartesian coordinates, Shape: `(3)`
  - `rx_orientation` (required)]
    3-element vector describing the orientation of the receive antenna in Euler angles, Shape: `(3)`
  - `center_freq` (optional)
    Center frequency in [Hz]; optional; If the value is not provided or set to 0, phase calculation in coefficients is disabled, i.e. that path length has not influence on the results. This can be used to calculate the antenna response for a specific angle and polarization. Scalar value
  - `use_absolute_delays` (optional)
    If true, the LOS delay is included for all paths; Default is `false`, i.e. delays are normalized to the LOS delay.
  - `add_fake_los_path` (optional)
    If true, adds a zero-power LOS path as the first path in case where no LOS path was present. Default: `false`
- Derived inputs:

  `n_azimuth_tx` | Number of azimuth angles in the TX antenna pattern
  `n_elevation_tx` | Number of elevation angles in the TX antenna pattern
  `n_elements_tx` | Number of physical antenna elements in the TX array antenna
  `n_ports_tx` | Number of ports (after coupling) in the TX array antenna
  `n_azimuth_rx` | Number of azimuth angles in the RX antenna pattern
  `n_elevation_rx` | Number of elevation angles in the RX antenna pattern
  `n_elements_rx` | Number of physical antenna elements in the RX array antenna
  `n_ports_rx` | Number of ports (after coupling) in the RX array antenna
  `n_path` | Number of propagation paths

- Output Arguments:
  - `coeff_re`
    Channel coefficients, real part, Shape: `( n_ports_tx, n_ports_rx, n_path )`
  - `coeff_im`
    Channel coefficients, imaginary part, Shape: `( n_ports_tx, n_ports_rx, n_path )`
  - `delays`
    Propagation delay in seconds, Shape: `( n_ports_tx, n_ports_rx, n_path )`
  - `rx_Doppler`
    Doppler weights for moving RX, Shape: `( 1, n_path )`
- Caveat:
  - Input data is directly accessed from Python memory, without copying if it is provided in double precision and is in F-contiguous (column-major) order
  - Other formats (e.g. single precision inputs or C-contiguous (row-major) order) will be converted to double automatically, causing additional computation steps.
  - To improve performance of repeated computations (e.g. in loops), consider preparing the data accordingly to avoid unecessary computations.

get_channels_spherical - Calculate channel coefficients from path data and antenna patterns

- Description:
  In this function, the wireless propagation channel between a transmitter and a receiver is calculated, based on a single transmit and receive position. Additionally, interaction points with the environment, which are derived from either Ray Tracing or Geometric Stochastic Models such as QuaDRiGa, are considered. The calculation is performed under the assumption of spherical wave propagation. For accurate execution of this process, several pieces of input data are required:
  - The 3D Cartesian (local) coordinates of both the transmitter and the receiver.
  - The specific interaction positions of the propagation paths within the environment.
  - The polarization transfer matrix for each propagation path.
  - Antenna models for both the transmitter and the receiver.
  - The orientations of the antennas.
- Usage:

      from quadriga_lib import arrayant

      # Return only coefficients and delays
      coeff_re, coeff_im, delays = arrayant.get_channels_spherical( ant_tx, ant_rx,
          fbs_pos, lbs_pos, path_gain, path_length, M, tx_pos, tx_orientation, rx_pos, rx_orientation,
          center_freq, use_absolute_delays, add_fake_los_path );

      # Return additional departure and arrival angles
      coeff_re, coeff_im, delays, aod, eod, aoa, eoa = arrayant.get_channels_spherical( ant_tx, ant_rx,
          fbs_pos, lbs_pos, path_gain, path_length, M, tx_pos, tx_orientation, rx_pos, rx_orientation,
          center_freq, use_absolute_delays, add_fake_los_path, angles=1 );

- Input Arguments:
  - `ant_tx` (required)
    Dictionary containing the transmit (TX) arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_tx, n_azimuth_tx, n_elements_tx)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_tx)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_tx)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_tx)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_tx, n_ports_tx)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_tx, n_ports_tx)`

  - `ant_rx` (required)
    Dictionary containing the receive (RX) arrayant data with the following keys:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_rx, n_azimuth_rx, n_elements_rx)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_rx)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_rx)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_rx)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_rx, n_ports_rx)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_rx, n_ports_rx)`

  - `fbs_pos` (required)
    First interaction point of the rays and the environment, Shape: `( 3, n_path )`
  - `lbs_pos` (required)
    Last interaction point of the rays and the environment; For single-bounce models, this must be identical to `fbs_pos`, Shape: `( 3, n_path )`
  - `path_gain` (required)
    Path gain (linear scale), Shape: `( n_path )`
  - `path_length` (required)
    Total path length in meters, Shape: `( n_path )`
  - `M` (required)
    Polarization transfer matrix, interleaved complex values (ReVV, ImVV, ReVH, ImVH, ReHV, ImHV, ReHH, ImHH), Shape: `( 8, n_path )`
  - `tx_pos` (required)
    Transmitter position in 3D Cartesian coordinates; Shape: `(3)`
  - `tx_orientation` (required)
    3-element vector describing the orientation of the transmit antenna in Euler angles (bank, tilt, heading), Shape: `(3,1)` or `(1,3)`
  - `rx_pos` (required)
    Receiver position in 3D Cartesian coordinates, Shape: `(3)`
  - `rx_orientation` (required)]
    3-element vector describing the orientation of the receive antenna in Euler angles, Shape: `(3)`
  - `center_freq` (optional)
    Center frequency in [Hz]; optional; If the value is not provided or set to 0, phase calculation in coefficients is disabled, i.e. that path length has not influence on the results. This can be used to calculate the antenna response for a specific angle and polarization. Scalar value
  - `use_absolute_delays` (optional)
    If true, the LOS delay is included for all paths; Default is `false`, i.e. delays are normalized to the LOS delay.
  - `add_fake_los_path` (optional)
    If true, adds a zero-power LOS path as the first path in case where no LOS path was present. Default: `false`
  - `angles` (optional flag)
    Switch to return the angles in antenna-local coordinates. Default: 0, false
- Derived inputs:

  `n_azimuth_tx` | Number of azimuth angles in the TX antenna pattern
  `n_elevation_tx` | Number of elevation angles in the TX antenna pattern
  `n_elements_tx` | Number of physical antenna elements in the TX array antenna
  `n_ports_tx` | Number of ports (after coupling) in the TX array antenna
  `n_azimuth_rx` | Number of azimuth angles in the RX antenna pattern
  `n_elevation_rx` | Number of elevation angles in the RX antenna pattern
  `n_elements_rx` | Number of physical antenna elements in the RX array antenna
  `n_ports_rx` | Number of ports (after coupling) in the RX array antenna
  `n_path` | Number of propagation paths

- Output Arguments:
  - `coeff_re`
    Channel coefficients, real part, Shape: `( n_ports_rx, n_ports_tx, n_path )`
  - `coeff_im`
    Channel coefficients, imaginary part, Shape: `( n_ports_rx, n_ports_tx, n_path )`
  - `delays`
    Propagation delay in seconds, Shape: `( n_ports_rx, n_ports_tx, n_path )`
  - `aod` (optional)
    Azimuth of Departure angles in [rad], Shape: `( n_ports_rx, n_ports_tx, n_path )`, Only returned when `angles` flag is set to 1.
  - `eod` (optional)
    Elevation of Departure angles in [rad], Shape: `( n_ports_rx, n_ports_tx, n_path )`, Only returned when `angles` flag is set to 1.
  - `aoa` (optional)
    Azimuth of Arrival angles in [rad], Shape: `( n_ports_rx, n_ports_tx, n_path )`, Only returned when `angles` flag is set to 1.
  - `eoa` (optional)
    Elevation of Arrival angles in [rad], Shape: `( n_ports_rx, n_ports_tx, n_path )`, Only returned when `angles` flag is set to 1.
- Caveat:
  - Input data is directly accessed from Python memory, without copying if it is provided in double precision and is in F-contiguous (column-major) order
  - Other formats (e.g. single precision inputs or C-contiguous (row-major) order) will be converted to double automatically, causing additional computation steps.
  - To improve performance of repeated computations (e.g. in loops), consider preparing the data accordingly to avoid unecessary computations.

interpolate - Interpolate array antenna field patterns

- Description:
  - This function interpolates polarimetric antenna field patterns for a given set of azimuth and elevation angles. It supports both single-frequency arrayants (3D pattern fields) and multi-frequency arrayants (4D pattern fields). The function auto-detects the input format by inspecting the dimensionality of the `e_theta_re` field (3D = single-frequency, 4D = multi-frequency).
  - For multi-frequency inputs, the function interpolates both spatially (azimuth/elevation) and across frequency, producing output fields with an additional frequency dimension. Target frequencies for the multi-frequency path are specified via the `frequency` parameter. The options `dist` and `local_angles` are only available for the single-frequency path without `frequency`.
  - If a single-frequency arrayant (3D patterns) is used together with the `frequency` parameter, the function performs spatial interpolation once and replicates the 2D result across all requested frequencies, yielding 3D output of shape `(n_out, n_ang, n_freq_out)`. This allows uniform output shapes regardless of whether the underlying arrayant is frequency-dependent or not.
- Usage:

      from quadriga_lib import arrayant

      # Minimal example (single-frequency, 3D patterns)
      vr,vi,hr,hi = arrayant.interpolate(arrayant, azimuth, elevation)

      # Output as complex type
      v,h = arrayant.interpolate(arrayant, azimuth, elevation, complex=1)

      # Generate projected distance (single-frequency only)
      vr,vi,hr,hi,dist = arrayant.interpolate(arrayant, azimuth, elevation, dist=1)
      v,h,dist = arrayant.interpolate(arrayant, azimuth, elevation, complex=1, dist=1)

      # Additional inputs
      vr,vi,hr,hi = arrayant.interpolate(arrayant, azimuth, elevation, element, orientation, element_pos)

      # Output angles in antenna-local coordinates (single-frequency only)
      vr,vi,hr,hi,az_local,el_local,gamma = arrayant.interpolate(arrayant, azimuth, elevation, orientation=ori, local_angles=1)

      # Multi-frequency interpolation (4D patterns)
      vr,vi,hr,hi = arrayant.interpolate(speaker, azimuth, elevation, frequency=freqs)
      v,h = arrayant.interpolate(speaker, azimuth, elevation, frequency=freqs, complex=1)

      # Single-frequency arrayant with frequency duplication (output is 3D, duplicated across freqs)
      vr,vi,hr,hi = arrayant.interpolate(ant, azimuth, elevation, frequency=freqs)

- Input Arguments:
  - `arrayant` (required)
    Dictionary containing array antenna data. Pattern fields may be 3D (single-frequency) or 4D (multi-frequency, 4th dimension = frequency). The following keys are expected:

    `e_theta_re` | Real part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements)` or `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_theta_im` | Imaginary part of e-theta field component | Shape: `(n_elevation, n_azimuth, n_elements)` or `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_phi_re` | Real part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements)` or `(n_elevation, n_azimuth, n_elements, n_freq)`
    `e_phi_im` | Imaginary part of e-phi field component | Shape: `(n_elevation, n_azimuth, n_elements)` or `(n_elevation, n_azimuth, n_elements, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad] -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `center_freq` | Center frequency in [Hz], optional | Scalar or 1D array `(n_freq)`

  - `azimuth` (required)
    Azimuth angles in [rad] for which the field pattern should be interpolated. Values must be between -pi and pi.

    Option 1: | Use the same angles for all antenna elements (planar wave approximation)
    | Shape: `(1, n_ang)`
    Option 2: | Provide different angles for each array element (e.g. for spherical waves)
    | Shape: `(n_out, n_ang)`

  - `elevation` (required)
    Elevation angles in [rad] for which the field pattern should be interpolated. Values must be between -pi/2 and pi/2.

    Option 1: | Use the same angles for all antenna elements (planar wave approximation)
    | Shape: `(1, n_ang)`
    Option 2: | Provide different angles for each array element (e.g. for spherical waves)
    | Shape: `[n_out, n_ang)`

  - `element` (optional)
    The element indices for which the interpolation should be done. Optional parameter. Values must be between 0 and n_elements-1. It is possible to duplicate elements, i.e. by passing `[1,1,2]`. If this parameter is not provided (or an empty array is passed), `i_element` is initialized to `[0:n_elements-1]`. In this case, `n_out = n_elements`. Shape: `(1, n_out)` or `(n_out, 1)` or empty `()`
  - `orientation` (optional)
    This (optional) 3-element vector describes the orientation of the array antenna or of individual array elements using Euler angles in [rad]. Shape: `(3, 1)` or `(3, n_out)` or `(3, 1, n_ang)` or `(3, n_out, n_ang)` or empty `()`
  - `element_pos` (optional)
    Alternative positions of the array antenna elements in local cartesian coordinates (using units of [m]). If this parameter is not given, element positions from `arrayant` are used. If the `arrayant` has no positions, they are initialized to [0,0,0]. For example, when duplicating the first element by setting `element = [1,1]`, different element positions can be set for the two elements in the output. Shape: `(3, n_out)` or empty `()`
  - `frequency` (optional)
    Target frequencies in [Hz] for multi-frequency interpolation. When the input arrayant has 4D pattern fields, each requested frequency is interpolated between the two bracketing entries; out-of-range frequencies are clamped to the nearest entry. When the input arrayant has 3D pattern fields, the spatial interpolation is performed once and the result is duplicated across all requested frequencies. In both cases, the output shape gains a frequency dimension `(n_out, n_ang, n_freq_out)`. If empty, no frequency dimension is added (single-frequency path returns 2D outputs). The options `dist` and `local_angles` are not available when `frequency` is provided. Shape: `(n_freq_out)` or empty `()`
  - `complex` (optional flag)
    If set to 1, output is returned in complex notation. This reduces performance due to additional copies of the data in memory. Default: 0, false
  - `dist` (optional flag)
    Switch to calculate the effective distances for phase calculation. Only available for single-frequency arrayants (3D patterns). Default: 0, false
  - `local_angles` (optional flag)
    Switch to return the angles in antenna-local coordinates. These differ from the input when the orientation of the antenna is adjusted. Only available for single-frequency arrayants (3D patterns). Default: 0, false
  - `fast_access` (optional flag)
    If arrayant data is provided as numpy.ndarray of type double in Fortran-contiguous (column-major) order, `arrayant_interpolate` can access the Python memory directly without a conversion of the data. This will increase performance and is done by default. If the data is not in the correct format, a conversion is done in the background. Setting `fast_access` to 1 will skip the conversion and throw an error if the arrayant data is not correctly formatted. Only applies to the single- frequency path. Default: 0, false (convert)
- Derived inputs:

  `n_azimuth` | Number of azimuth angles in the field pattern
  `n_elevation` | Number of elevation angles in the field pattern
  `n_elements` | Number of antenna elements in the field pattern of the array antenna
  `n_ang` | Number of interpolation angles
  `n_out` | Number of antenna elements in the generated output (may differ from n_elements)
  `n_freq` | Number of frequency entries in the multi-frequency arrayant (4D input only)
  `n_freq_out` | Number of target frequencies (multi-frequency path only)

- Output Arguments (single-frequency path):
  - `vr`
    Real part of the interpolated e-theta (vertical) field component. Shape `(n_out, n_ang)`
  - `vi`
    Imaginary part of the interpolated e-theta (vertical) field component. Shape `(n_out, n_ang)`
  - `hr`
    Real part of the interpolated e-phi (horizontal) field component. Shape `(n_out, n_ang)`
  - `hi`
    Imaginary part of the interpolated e-phi (horizontal) field component. Shape `(n_out, n_ang)`
  - `dist` (optional)
    The effective distances between the antenna elements when seen from the direction of the incident path. The distance is calculated by a projection of the array positions on the normal plane of the incident path. This is needed for calculating the phase of the antenna response. Only returned when `dist` flag is set to 1. Shape `(n_out, n_ang)`
  - `azimuth_loc` (optional)
    The azimuth angles in [rad] for the local antenna coordinate system, i.e., after applying the 'orientation'. If no orientation vector is given, these angles are identical to the input azimuth angles. Only returned when `local_angles` flag is set to 1. Shape `(n_out, n_ang)`
  - `elevation_loc` (optional)
    The elevation angles in [rad] for the local antenna coordinate system, i.e., after applying the 'orientation'. If no orientation vector is given, these angles are identical to the input elevation angles. Only returned when `local_angles` flag is set to 1. Shape `(n_out, n_ang)`
  - `gamma` (optional)
    Polarization rotation angles in [rad]. Only returned when `local_angles` flag is set to 1. Shape `(n_out, n_ang)`
- Output Arguments (multi-frequency path):
  - `vr`
    Real part of the interpolated e-theta (vertical) field component. Shape `(n_out, n_ang, n_freq_out)`
  - `vi`
    Imaginary part of the interpolated e-theta (vertical) field component. Shape `(n_out, n_ang, n_freq_out)`
  - `hr`
    Real part of the interpolated e-phi (horizontal) field component. Shape `(n_out, n_ang, n_freq_out)`
  - `hi`
    Imaginary part of the interpolated e-phi (horizontal) field component. Shape `(n_out, n_ang, n_freq_out)`
  - `v` (complex mode)
    Complex-valued interpolated e-theta (vertical) field component. Shape `(n_out, n_ang, n_freq_out)`
  - `h` (complex mode)
    Complex-valued interpolated e-phi (horizontal) field component. Shape `(n_out, n_ang, n_freq_out)`

qdant_read - Reads array antenna data from QDANT files

- Description:
  - The QuaDRiGa array antenna exchange format (QDANT) is a file format used to store antenna pattern data in XML. This function reads pattern data from the specified file.
  - Supports both single-entry and multi-entry (multi-frequency) reading:
  - Single-entry (id ≥ 1): Reads one antenna entry from the file. Pattern fields are returned as 3D arrays `(n_el, n_az, n_elem)`. This is the default behavior.
  - Multi-entry (id = 0): Reads all antenna entries from the file and returns them as a single multi-frequency arrayant dict. Pattern fields are returned as 4D arrays `(n_el, n_az, n_elem, n_freq)`, center frequencies as a 1D array, and coupling matrices as 3D arrays if they vary across entries. This is the inverse of `qdant_write` with 4D input.
- Usage:

      from quadriga_lib import arrayant

      # Read a single antenna entry (default: first entry)
      data = arrayant.qdant_read('antenna.qdant')
      data = arrayant.qdant_read('antenna.qdant', id=2)

      # Read all entries as multi-frequency arrayant (4D patterns)
      data = arrayant.qdant_read('speaker.qdant', id=0)

- Input Arguments:
  - `fn`
    Filename of the QDANT file, string
  - `id` (optional)
    ID of the antenna to be read from the file. Default: `1` (read first entry). Set to `0` to read all entries as a multi-frequency arrayant.
- Output Arguments:
  - `data`
    Dictionary containing the data in the QDANT file with the following keys:
  For single-entry (id ≥ 1):

  `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem)`
  `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem)`
  `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem)`
  `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem)`
  `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
  `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
  `element_pos` | Antenna element (x,y,z) positions | Shape: `(3, n_elements)`
  `coupling_re` | Coupling matrix, real part | Shape: `(n_elements, n_ports)`
  `coupling_im` | Coupling matrix, imaginary part | Shape: `(n_elements, n_ports)`
  `center_freq` | Center frequency in [Hz] | Scalar
  `name` | Name of the array antenna object | String
  `layout` | Layout of multiple array antennas | Matrix

  For multi-entry (id = 0):

  `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem, n_freq)`
  `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem, n_freq)`
  `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem, n_freq)`
  `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem, n_freq)`
  `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
  `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
  `element_pos` | Antenna element (x,y,z) positions | Shape: `(3, n_elements)`
  `coupling_re` | Coupling matrix, real part | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
  `coupling_im` | Coupling matrix, imaginary part | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
  `center_freq` | Center frequencies in [Hz] | Shape: `(n_freq)`
  `name` | Name of the array antenna object | String
  `layout` | Layout of multiple array antennas | Matrix

- See also:
  - qdant_write (for writing QDANT data)
  - QuaDRiGa Array Antenna Exchange Format (QDANT)

qdant_write - Writes array antenna data to QDANT files

- Description:
  - The QuaDRiGa array antenna exchange format (QDANT) is a file format used to store antenna pattern data in XML. This function writes pattern data to the specified file.
  - Supports both single-frequency arrayants (3D pattern fields) and multi-frequency arrayants (4D pattern fields). The function auto-detects the format by inspecting the dimensionality of `e_theta_re` (3D = single, 4D = multi).
  - Single-frequency: Writes one antenna entry to the file. The `id` parameter controls where the entry is placed. Multiple antennas can be stored in the same file by calling this function repeatedly with different IDs.
  - Multi-frequency: Writes all frequency entries as sequential IDs (1-based) to the file. The file is overwritten if it already exists. A layout matrix is created automatically. The `id` and `layout` parameters are ignored for multi-frequency inputs.
- Usage:

      from quadriga_lib import arrayant

      # Single-frequency: write with optional ID
      id_in_file = arrayant.qdant_write('antenna.qdant', ant)
      id_in_file = arrayant.qdant_write('antenna.qdant', ant, id=2)

      # Multi-frequency (4D patterns): writes all frequencies sequentially
      arrayant.qdant_write('speaker.qdant', speaker)

- Input Arguments:
  - `fn` [1]
    Filename of the QDANT file, string
  - `arrayant` [2] (optional)
    Dictionary containing the arrayant data. Pattern fields may be 3D (single-frequency) or 4D (multi-frequency, 4th dimension = frequency). The following keys are expected:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `center_freq` | Center frequency in [Hz], optional | Scalar or 1D array `(n_freq)`
    `name` | Name of the array antenna object, optional | String

  - `id` [3] (optional, single-frequency only)
    ID of the antenna to be written to the file, optional, Default: Max-ID in existing file + 1. Ignored for multi-frequency inputs.
  - `layout` [4] (optional, single-frequency only)
    Layout of multiple array antennas. Must only contain element ids that are present in the file. Ignored for multi-frequency inputs.
- Output Argument:
  - `id_in_file`
    For single-frequency: ID of the antenna in the file after writing. For multi-frequency: always returns 0 (all entries are written sequentially starting at ID 1).
  # See also:
  - qdant_read (for reading QDANT data)
  - QuaDRiGa Array Antenna Exchange Format (QDANT)

rotate_pattern - Rotates antenna patterns

- Description:
  - This function transforms the radiation patterns of array antenna elements, allowing for precise rotations around the three principal axes (x, y, z) of the local Cartesian coordinate system. The 3 rotations are applied in the order: 1. rotation around the x-axis (bank angle); 2. rotation around the y-axis (tilt angle), 3. rotation around the z-axis (heading angle).
  - Supports both single-frequency arrayants (3D pattern fields) and multi-frequency arrayants (4D pattern fields). For multi-frequency inputs, the rotation is applied consistently across all frequency entries. The function auto-detects the format by inspecting the dimensionality of `e_theta_re` (3D = single, 4D = multi).
  - Note on usage modes for multi-frequency: Grid adjustment (usage 0 and 1) is not supported for multi-frequency arrayants because all frequency entries must share the same angular grid. The multi- frequency path automatically maps usage 0 → 3 (pattern + polarization, no grid adjust) and usage 1 → 4 (pattern only, no grid adjust). Usage 2 (polarization only) works identically in both paths.
- Usage:

      from quadriga_lib import arrayant

      # Single-frequency: rotate all elements by 45 deg bank
      arrayant_out = arrayant.rotate_pattern(ant, x_deg=45.0)

      # Single-frequency: rotate only elements 0 and 1 (0-based)
      arrayant_out = arrayant.rotate_pattern(ant, z_deg=90.0, element=[0, 1])

      # Multi-frequency (4D patterns): same interface
      speaker_out = arrayant.rotate_pattern(speaker, y_deg=10.0)

- Input Arguments:
  - `arrayant`
    Dictionary containing the arrayant data. Pattern fields may be 3D (single-frequency) or 4D (multi-frequency, 4th dimension = frequency). The following keys are expected:

    `e_theta_re` | e-theta field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_el, n_az, n_elem)` or `(n_el, n_az, n_elem, n_freq)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elem, n_ports)` or `(n_elem, n_ports, n_freq)`
    `center_freq` | Center frequency in [Hz], optional | Scalar or 1D array `(n_freq)`
    `name` | Name of the array antenna object, optional | String

  - `x_deg` (optional)
    The rotation angle around x-axis (bank angle) in [degrees]. Default: `0.0`
  - `y_deg` (optional)
    The rotation angle around y-axis (tilt angle) in [degrees]. Default: `0.0`
  - `z_deg` (optional)
    The rotation angle around z-axis (heading angle) in [degrees]. Default: `0.0`
  - `usage` (optional)
    The optional parameter 'usage' can limit the rotation procedure either to the pattern or polarization.

    `usage = 0` | Rotate both, pattern and polarization, adjusts sampling grid (default; multi-freq: no grid adjust)
    `usage = 1` | Rotate only pattern, adjusts sampling grid (multi-freq: no grid adjust)
    `usage = 2` | Rotate only polarization
    `usage = 3` | Rotate both, but do not adjust the sampling grid
    `usage = 4` | Rotate only pattern, do not adjust the sampling grid

  - `element` (optional)
    The element indices for which the pattern should be transformed. Optional parameter. Values must be between 0 and n_elements-1 (0-based). If this parameter is not provided (or an empty array is passed), all elements will be rotated by the same angles. Shape: `(n_elements)` or empty `()`
- Output Arguments:
  - `arrayant_out`
    Dictionary containing the arrayant data with the rotated patterns. Output format matches the input format (3D for single-frequency, 4D for multi-frequency).

Channel functions

baseband_freq_response - Compute the frequency-domain channel response from time-domain coefficients

- Description:
  - Transforms channel impulse response data into the frequency domain and returns the channel transfer function H(f) at specified sub-carrier or output frequencies.
  - Supports two operating modes determined by the dimensionality of the input coefficient arrays: 1. Single-frequency (3D inputs): Each list item is a 3D array of shape `(n_rx, n_tx, n_path)`. Calls `baseband_freq_response` per snapshot using a DFT with AVX2 acceleration. 2. Multi-frequency (4D inputs): Each list item is a 4D array of shape `(n_rx, n_tx, n_path, n_freq_in)`. Calls `baseband_freq_response_multi` per snapshot using SLERP interpolation of the coefficient envelope across frequency.
  - Coefficients can be provided as a single complex array (`coeff`) or as separate real and imaginary parts (`coeff_re`, `coeff_im`). Providing both is an error.
  - The output frequency grid can be defined in two ways: 1. `bandwidth` + `pilot_grid` (or `carriers`): baseband offsets, for single-frequency mode. 2. `freq_in` + `freq_out`: absolute frequencies in Hz, works for both single-frequency and multi-frequency mode. For single-frequency inputs, `bandwidth` and `pilot_grid` are derived internally from `freq_out`.
  - When using multi-frequency mode, the `remove_delay_phase` flag (default `True`) undoes the delay-induced phase baked into coefficients by `get_channels_multifreq` before SLERP interpolation, then re-applies it analytically at each output frequency. Set to `False` only if the input coefficients are pure slowly-varying envelopes without baked-in delay phase.
  - Snapshots are processed in parallel using OpenMP. The number of paths `n_path` may vary across snapshots, but `n_rx`, `n_tx`, and (for 4D) `n_freq_in` must be consistent.
- Usage:

      import quadriga_lib

      # Single-frequency with bandwidth + carriers
      hmat = quadriga_lib.channel.baseband_freq_response( coeff=coeff, delay=delay, bandwidth=100e6 )

      # Single-frequency with freq_in + freq_out
      hmat = quadriga_lib.channel.baseband_freq_response( coeff=coeff, delay=delay,
          freq_in=np.array([2.0e9]), freq_out=np.linspace(1.95e9, 2.05e9, 128) )

      # Multi-frequency with separate re/im
      hmat = quadriga_lib.channel.baseband_freq_response( coeff_re=cre, coeff_im=cim, delay=delay,
          freq_in=freq_in, freq_out=freq_out )

- Input Arguments:
  - `coeff` (optional)
    Channel coefficients, complex-valued. List of length `n_snap`. Each item is a numpy array of shape `(n_rx, n_tx, n_path)` (single-freq) or `(n_rx, n_tx, n_path, n_freq_in)` (multi-freq). Mutually exclusive with `coeff_re` / `coeff_im`.
  - `delay`
    Propagation delays in seconds, real-valued. List of length `n_snap`. Each item is a numpy array of shape `(n_rx, n_tx, n_path)` or `(1, 1, n_path)` (single-freq), or the same shapes with an additional 4th dimension `n_freq_in` (multi-freq). For multi-freq, a 3D delay is broadcast to all input frequencies (since delays are frequency-independent).
  - `bandwidth` (optional)
    Baseband bandwidth in Hz. Used with `pilot_grid` or `carriers` to define sub-carrier positions. Cannot be combined with `freq_out`. Default: `0.0`
  - `carriers` (optional)
    Number of equally spaced carriers across the bandwidth. Only used if `pilot_grid` is not given and `freq_out` is not given. Default: `128`
  - `pilot_grid` (optional)
    Sub-carrier positions relative to bandwidth (0 = center freq, 1 = center + bandwidth). 1D numpy array of length `n_carriers`.
  - `snap` (optional)
    0-based snapshot indices to process. If not given, all snapshots are processed. 1D numpy array of length `n_out`.
  - `coeff_re` (optional)
    Real part of channel coefficients. Same list/shape structure as `coeff`. Must be provided together with `coeff_im`. Mutually exclusive with `coeff`.
  - `coeff_im` (optional)
    Imaginary part of channel coefficients. Must match `coeff_re` in structure and shape.
  - `freq_in` (optional)
    Input sample frequencies in Hz, 1D numpy array. Required for multi-freq (4D) inputs. For single-freq (3D) inputs, used together with `freq_out` to derive baseband parameters.
  - `freq_out` (optional)
    Output carrier frequencies in Hz, 1D numpy array. Required for multi-freq (4D) inputs. For single-freq (3D) inputs, can replace `bandwidth` + `pilot_grid`.
  - `remove_delay_phase` (optional)
    If `True` (default), removes the delay-induced phase baked into coefficients by channel generation functions (e.g. `get_channels_multifreq`) before SLERP interpolation in multi-freq mode. Has no effect in single-freq mode.
- Output Argument:
  - `hmat`
    Complex-valued frequency-domain channel matrix, numpy array of shape `(n_rx, n_tx, n_carriers, n_out)`.

channel_export_obj_file - Export path data to a Wavefront OBJ file for visualization in Blender

- Description:
  This function exports path data to a Wavefront OBJ file, which can be used for visualization in 3D software such as Blender. It supports various colormaps for color-coding the paths based on their gain values. In addition, the function allows you to control the maximum number of paths displayed, set gain thresholds for color-coding and selection.
- Usage:

      from quadriga_lib import channel

      channel.channel_export_obj_file( fn, max_no_paths, gain_max, gain_min, colormap, radius_max,
          radius_min, n_edges, rx_position, tx_position, no_interact, interact_coord, center_freq,
          coeff, i_snap )

- Input Arguments:
  - `fn`
    Filename of the OBJ file, string, required
  - `max_no_paths` (optional)
    Maximum number of paths to be shown, optional, default: 0 = export all above `gain_min`
  - `gain_max` (optional)
    Maximum path gain in dB (only for color-coding), optional, default = -60.0
  - `gain_min` (optional)
    Minimum path gain in dB (for color-coding and path selection), optional, default = -140.0
  - `colormap` (optional)
    Colormap for the visualization, string, supported are 'jet', 'parula', 'winter', 'hot', 'turbo', 'copper', 'spring', 'cool', 'gray', 'autumn', 'summer', optional, default = 'jet'
  - `radius_max` (optional)
    Maximum tube radius in meters, optional, default = 0.05
  - `radius_min` (optional)
    Minimum tube radius in meters, optional, default = 0.01
  - `n_edges` (optional)
    Number of vertices in the circle building the tube, must be >= 3, optional, default = 5
  - `rx_position`
    Receiver positions, required, size `[3, n_snap]` or `[3, 1]`
  - `tx_position`
    Transmitter positions, required, size `[3, n_snap]` or `[3, 1]`
  - `no_interact`
    Number interaction points of paths with the environment, required, uint32, Size `[n_path, n_snap]`
  - `interact_coord`
    Interaction coordinates, required, Size `[3, max(sum(no_interact)), n_snap]`
  - `center_freq`
    Center frequency in [Hz], required, Size `[n_snap, 1]` or scalar
  - `coeff`
    Channel coefficients, complex valued, required only if `path_polarization` is not given, Size `[n_rx, n_tx, n_path, n_snap]`
  - `i_snap`
    (optional) Snapshot indices, optional, 0-based, range [0 ... n_snap - 1]

get_ieee_indoor - Generate indoor MIMO channel realizations for IEEE TGn/TGac/TGax/TGah models

- Description:
  - Generates one or multiple indoor channel realizations based on IEEE TGn/TGac/TGax/TGah model definitions.
  - 2D model: no elevation angles are used; azimuth angles and planar motion are considered.
  - For 3D antenna models (default models from arrayant generate), only the azimuth cut at `elevation_grid = 0` is used
  - Supports channel model types `A, B, C, D, E, F` (as defined by TGn) via `ChannelType`.
  - Can generate MU-MIMO channels (`n_users > 1`) with per-user distances/floors and optional angle offsets according to TGac.
  - Optional time evolution via `observation_time`, `update_rate`, and mobility parameters.
- Declaration:

      from quadriga_lib import channel

      chan = channel.get_ieee_indoor(ap_array, sta_array, ChannelType, CarrierFreq_Hz,
         tap_spacing_s, n_users, observation_time, update_rate, speed_station_kmh, speed_env_kmh,
         Dist_m, n_floors, uplink, offset_angles, n_subpath, Doppler_effect, seed,
         KF_linear, XPR_NLOS_linear, SF_std_dB_LOS, SF_std_dB_NLOS, dBP_m );

- ap_array:
  - `ap_array`
    Dictionary containing the access point array antenna with `n_tx` elements (= ports after element coupling)

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_ap, n_azimuth_ap, n_elements_ap)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_ap, n_azimuth_ap, n_elements_ap)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_ap, n_azimuth_ap, n_elements_ap)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_ap, n_azimuth_ap, n_elements_ap)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_ap)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_ap)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_ap)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_ap, n_ports_ap)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_ap, n_ports_ap)`

  - `sta_array`
    Dictionary containing the mobile station array antenna with `n_rx` elements (= ports after element coupling)

    `e_theta_re` | e-theta field component, real part | Shape: `(n_elevation_sta, n_azimuth_sta, n_elements_sta)`
    `e_theta_im` | e-theta field component, imaginary part | Shape: `(n_elevation_sta, n_azimuth_sta, n_elements_sta)`
    `e_phi_re` | e-phi field component, real part | Shape: `(n_elevation_sta, n_azimuth_sta, n_elements_sta)`
    `e_phi_im` | e-phi field component, imaginary part | Shape: `(n_elevation_sta, n_azimuth_sta, n_elements_sta)`
    `azimuth_grid` | Azimuth angles in [rad], -pi to pi, sorted | Shape: `(n_azimuth_sta)`
    `elevation_grid` | Elevation angles in [rad], -pi/2 to pi/2, sorted | Shape: `(n_elevation_sta)`
    `element_pos` | Antenna element (x,y,z) positions, optional | Shape: `(3, n_elements_sta)`
    `coupling_re` | Coupling matrix, real part, optional | Shape: `(n_elements_sta, n_ports_sta)`
    `coupling_im` | Coupling matrix, imaginary part, optional | Shape: `(n_elements_sta, n_ports_sta)`

  - `ChannelType`
    Channel model type as defined by TGn. String. Supported: `A, B, C, D, E, F`.
  - `CarrierFreq_Hz = 5.25e9` (optional)
    Carrier frequency in Hz.
  - `tap_spacing_s = 10e-9` (optional)
    Tap spacing in seconds. Must be equal to `10 ns / 2^k` (TGn default = `10e-9`).
  - `n_users = 1` (optional)
    Number of users (only for TGac, TGah). Output struct array length equals `n_users`.
  - `observation_time = 0` (optional)
    Channel observation time in seconds. `0` creates a static channel.
  - `update_rate = 1e-3` (optional)
    Channel update interval in seconds (only relevant when `observation_time > 0`).
  - `speed_station_kmh = 0` (optional)
    Station movement speed in km/h. Movement direction is `AoA_offset`. Only relevant when `observation_time > 0`.
  - `speed_env_kmh = 1.2` (optional)
    Environment movement speed in km/h. Default `1.2` for TGn, use `0.089` for TGac. Only relevant when `observation_time > 0`.
  - `vector Dist_m = [4.99]` (optional)
    TX-to-RX distance(s) in meters. Length `n_users` or length `1` (same distance for all users).
  - `vector n_floors = [0]` (optional)
    Number of floors for TGah model (per user), up to 4 floors. Length `n_users` or length `1`.
  - `uplink = false` (optional)
    Channel direction flag. Default is downlink; set to `true` to generate reverse (uplink) direction.
  - `offset_angles = []` (optional)
    Offset angles in degree for MU-MIMO channels. Empty uses model defaults (TGac auto for `n_users > 1`). Shape `[4, n_users]` with rows: `AoD LOS, AoD NLOS, AoA LOS, AoA NLOS`.
  - `n_subpath = 20` (optional)
    Number of sub-paths per path/cluster used for Laplacian angular spread mapping.
  - `Doppler_effect = 50` (optional)
    Special Doppler effects: models `D, E` (fluorescent lights, value = mains freq.) and `F` (moving vehicle speed in km/h). Use `0` to disable.
  - `seed = -1` (optional)
    Numeric seed for repeatability. `-1` disables the fixed seed and uses the system random device.
  - `KF_linear = NAN` (optional)
    Overwrites the model-specific KF-value. If this parameter is NAN (default) or negative, model defaults are used: A/B/C (KF = 1 for d < dBP, 0 otherwise); D (KF = 2 for d < dBP, 0 otherwise); E/F (KF = 4 for d < dBP, 0 otherwise). KF is applied to the first tap only. Breakpoint distance is ignored for `KF_linear >= 0`.
  - `XPR_NLOS_linear = NAN` (optional)
    Overwrites the model-specific Cross-polarization ratio. If this parameter is NAN (default) or negative, the model default of 2 (3 dB) is used. XPR is applied to all NLOS taps.
  - `SF_std_dB_LOS = NAN` (optional)
    Overwrites the model-specific shadow fading for LOS channels. If this parameter is NAN (default), the model default of 3 dB is used. `SF_std_dB_LOS` is applied to all LOS channels, where the AP-STA distance d < dBP.
  - `SF_std_dB_NLOS = NAN` (optional)
    Overwrites the model-specific shadow fading for LOS channels. If this parameter is NAN (default), the model defaults are A/B: 4 dB, C/D: 5 dB, E/F: 6 dB. `SF_std_dB_NLOS` is applied to all NLOS channels, where the AP-STA distance d >= dBP.
  - `dBP_m = NAN` (optional)
    Overwrites the model-specific breakpoint distance. If this parameter is NAN (default) or negative, the model defaults are A/B/C: 5 m, D: 10 m, E: 20 m, F: 30 m.
- Returns:
  - `chan`
    List of length `n_users` containing dictionaries of channel data with the following keys.

    `name` | Channel name | String
    `tx_position` | Transmitter positions (AP for downlink, STA for uplink) | Shape: `(3, 1)` or `(3, n_snap)`
    `rx_position` | Receiver positions (STA for downlink, AP for uplink) | Shape: `(3, 1)` or `(3, n_snap)`
    `tx_orientation` | Transmitter orientation, Euler angles (AP for downlink, STA for uplink) | Shape: `(3, 1)` or `(3, n_snap)`
    `rx_orientation` | Receiver orientation, Euler angles (STA for downlink, AP for uplink) | Shape: `(3, 1)` or `(3, n_snap)`
    `coeff` | Channel coefficients, complex valued | list of `[n_rx, n_tx, n_path_s]`
    `delay` | Propagation delays in seconds | list of `[n_rx, n_tx, n_path_s]`
    `path_gain` | Path gain before antenna, linear scale | list of `[n_path_s]`

hdf5_create_file - Create a new HDF5 channel file with a custom storage layout

- Description:
  Quadriga-Lib offers an HDF5-based method for storing and managing channel data. A key feature of this library is its ability to organize multiple channels within a single HDF5 file while enabling access to individual data sets without the need to read the entire file. In this system, channels can be structured in a multi-dimensional array. For instance, the first dimension might represent the Base Station (BS), the second the User Equipment (UE), and the third the frequency. However, it is important to note that the dimensions of the storage layout must be defined when the file is initially created and cannot be altered thereafter. The function `quadriga_lib.channel.hdf5_create_file` is used to create an empty file with a predetermined custom storage layout.
- Usage:

      from quadriga_lib import channel
      channel.hdf5_create_file( fn, nx, ny, nz, nw )

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `nx` (optional)
    Number of elements on the x-dimension, Default = 65536
  - `ny` (optional)
    Number of elements on the x-dimension, Default = 1
  - `nz` (optional)
    Number of elements on the x-dimension, Default = 1
  - `nw` (optional)
    Number of elements on the x-dimension, Default = 1

hdf5_read_channel - Reads channel data from HDF5 files

- Description:
  Quadriga-Lib provides an HDF5-based solution for storing and organizing channel data. This data comprises various well-defined sets, including channel coefficients, positions of transmitters and receivers, as well as path data that reflects the interaction of radio waves with the environment. Typically, these datasets are multi-dimensional, encompassing data for `n_rx` receive antennas, `n_tx` transmit antennas, `n_path` propagation paths, and `n_snap` snapshots. Snapshots are particularly useful for recording data across different locations (such as along a trajectory) or various frequencies. It is important to note that not all datasets include all these dimensions.
  The library also supports the addition of extra datasets of any type or shape, which can be useful for incorporating descriptive data or analysis results. To facilitate data access, the function `quadriga_lib.channel.hdf5_read_channel` is designed to read both structured and unstructured data from the file.
- Usage:

      from quadriga_lib import channel
      data = channel.hdf5_read_channel( fn, ix, iy, iz, iw, snap )

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `ix`
    Storage index for x-dimension, Default = 0
  - `iy`
    Storage index for y-dimension, Default = 0
  - `iz`
    Storage index for z-dimension, Default = 0
  - `iw`
    Storage index for w-dimension, Default = 0
  - `snap` (optional)
    Snapshot range, 0-based notation; optional; vector, default: empty = read all
- Output Arguments:
  - `data`
    Dictionary containing the data in the HDF file with the following keys:

    `par` | Dictionary of unstructured data | Variable
    `rx_position` | Receiver positions | `[3, n_snap]` or `[3, 1]`
    `tx_position` | Transmitter positions | `[3, n_snap]` or `[3, 1]`
    `coeff` | Channel coefficients, complex valued | list of `[n_rx, n_tx, n_path_s]`
    `delay` | Propagation delays in seconds | list of `[n_rx, n_tx, n_path_s]` or `[1, 1, n_path_s]`
    `center_freq` | Center frequency in [Hz] | `[n_snap]` or scalar
    `name` | Name of the channel | String
    `initial_pos` | Index of reference position, 1-based | uint32, scalar
    `path_gain` | Path gain before antenna, linear scale | list of `[n_path_s]`
    `path_length` | Path length from TX to RX phase center in m | list of `[n_path_s]`
    `polarization` | Polarization transfer function, complex valued | list of `[4, n_path_s]`
    `path_angles` | Departure and arrival angles {AOD, EOD, AOA, EOA} in rad | list of `[n_path, 4_s]`
    `path_fbs_pos` | First-bounce scatterer positions | list of `[3, n_path_s]`
    `path_lbs_pos` | Last-bounce scatterer positions | list of `[3, n_path_s]`
    `no_interact` | Number interaction points of paths with the environment | uint32, list of `[n_path_s]`
    `interact_coord` | Interaction coordinates | list of `[3, max(sum(no_interact))]`
    `rx_orientation` | Receiver orientation | `[3, n_snap]` or `[3]`
    `tx_orientation` | Transmitter orientation | `[3, n_snap]` or `[3]`

- Caveat:
  - Only datasets that are present in the HDF file are returned in the dictionary.
  - Although the data is stored in single precision, it is converted to double precision by default.

hdf5_read_dset - Read a single unstructured dataset from an HDF5 file

- Description:
  Quadriga-Lib offers a solution based on HDF5 for storing and organizing channel data. In addition to structured datasets, the library facilitates the inclusion of extra datasets of various types and shapes. This feature is particularly beneficial for integrating descriptive data or analysis results. The function `quadriga_lib.channel.hdf5_read_dset` retrieves a single unstructured dataset. The output type of the function is defined by the datatype in the file. An empty matrix is returned if the dataset does not exist in the file.
- Usage:

      from quadriga_lib import channel
      dset = channel.hdf5_read_dset( fn, ix, iy, iz, iw, name )

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `ix`
    Storage index for x-dimension, Default = 0
  - `iy`
    Storage index for y-dimension, Default = 0
  - `iz`
    Storage index for z-dimension, Default = 0
  - `iw`
    Storage index for w-dimension, Default = 0
  - `name`
    Name of the dataset; String
- Output Argument:
  - `dset`
    Output data. Type and size is defined by the dataspace in the file
- Caveat:
  - Only datasets that are present in the HDF file are returned in the dictionary.

hdf5_read_dset_names - Read the names of unstructured data fields from an HDF5 file

- Description:
  Quadriga-Lib offers a solution based on HDF5 for storing and organizing channel data. In addition to structured datasets, the library facilitates the inclusion of extra datasets of various types and shapes. This feature is particularly beneficial for integrating descriptive data or analysis results. Users can add any number of such unstructured datasets, each identified by a unique dataset name. The function `quadriga_lib.channel.hdf5_read_dset_names` retrieves the names of all these datasets, returning them as a list of strings.
- Usage:

      from quadriga_lib import channel
      names = channel.hdf5_read_dset_names( fn, ix, iy, iz, iw );

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `ix`
    Storage index for x-dimension, Default = 0
  - `iy`
    Storage index for y-dimension, Default = 0
  - `iz`
    Storage index for z-dimension, Default = 0
  - `iw`
    Storage index for w-dimension, Default = 0
- Output Argument:
  - `names`
    List of names of all these at the given location in the files; Cell array of strings

hdf5_read_layout - Read the storage layout of channel data inside an HDF5 file

- Description:
  Quadriga-Lib provides an HDF5-based solution for the storage and organization of channel data. A notable feature of this library is its capacity to manage multiple channels within a single HDF5 file. In this framework, channels can be arranged in a multi-dimensional array format. The function `quadriga_lib.channel.hdf5_read_layout` is designed to read the storage layout from an existing file. Furthermore, it also generates an array that marks the locations within the layout where data already exists. This functionality aids in efficiently managing and accessing channel data within the HDF5 file structure.
- Usage:

      from quadriga_lib import channel
      storage_dims, has_data = channel.hdf5_read_layout( fn )

- Input Argument:
  - `fn`
    Filename of the HDF5 file, string
- Output Arguments:
  - `storage_dims`
    Size of the dimensions of the storage space, vector with 4 elements, i.e. `[nx,ny,nz,nw]`.
  - `has_data`
    Array indicating if data exists (value 1) or not (value 0); uint32; Size: `[nx,ny,nz,nw]`

hdf5_reshape_layout - Reshapes the storage layout inside an existing HDF5 file

- Description:
  Quadriga-Lib provides an HDF5-based solution for the storage and organization of channel data. A notable feature of this library is its capacity to manage multiple channels within a single HDF5 file. In this framework, channels can be arranged in a multi-dimensional array format. Once an HDF5 file has been created, the number of channels in the storage layout is fixed. However, it is possible to reshape the layout using `quadriga_lib.channel.hdf5_reshape_layout`.
- Usage:

      from quadriga_lib import channel
      channel.hdf5_reshape_layout( fn, storage_dims );

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `nx` (optional)
    Number of elements on the x-dimension, Default = 65536
  - `ny` (optional)
    Number of elements on the x-dimension, Default = 1
  - `nz` (optional)
    Number of elements on the x-dimension, Default = 1
  - `nw` (optional)
    Number of elements on the x-dimension, Default = 1

hdf5_write_channel - Writes channel data to HDF5 files

- Description:
  Quadriga-Lib provides an HDF5-based solution for storing and organizing channel data. This function can be used to write structured and unstructured data to an HDF5 file.
- Usage:

      from quadriga_lib import channel

      storage_dims = channel.hdf5_write_channel( fn, ix, iy, iz, iw, par, rx_pos, tx_pos, ...
         coeff, delay, center_freq, name, initial_pos, path_gain, path_length, ...
         polarization, path_angles, path_fbs_pos, path_lbs_pos, no_interact, interact_coord, ...
         rx_orientation, tx_orientation )

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `ix`
    Storage index for x-dimension, Default = 0
  - `iy`
    Storage index for y-dimension, Default = 0
  - `iz`
    Storage index for z-dimension, Default = 0
  - `iw`
    Storage index for w-dimension, Default = 0
  - `par`
    Dictionary of unstructured data, can be empty if no unstructured data should be written
  - Structured data: (double precision)
    Each snapshot may have a different number of paths `n_path_s`. Variable-length data is provided as lists.

    `rx_pos` | Receiver positions | `[3, n_snap]` or `[3, 1]`
    `tx_pos` | Transmitter positions | `[3, n_snap]` or `[3, 1]`
    `coeff` | Channel coefficients, complex valued | list of `[n_rx, n_tx, n_path_s]`
    `delay` | Propagation delays in seconds | list of `[n_rx, n_tx, n_path_s]` or `[1, 1, n_path_s]`
    `center_freq` | Center frequency in [Hz] | `[n_snap]` or scalar
    `name` | Name of the channel | String
    `initial_pos` | Index of reference position, 1-based | uint32, scalar
    `path_gain` | Path gain before antenna, linear scale | list of `[n_path_s]`
    `path_length` | Path length from TX to RX phase center in m | list of `[n_path_s]`
    `polarization` | Polarization transfer function, complex valued | list of `[4, n_path_s]`
    `path_angles` | Departure and arrival angles {AOD, EOD, AOA, EOA} in rad | list of `[n_path, 4_s]`
    `path_fbs_pos` | First-bounce scatterer positions | list of `[3, n_path_s]`
    `path_lbs_pos` | Last-bounce scatterer positions | list of `[3, n_path_s]`
    `no_interact` | Number interaction points of paths with the environment | uint32, list of `[n_path_s]`
    `interact_coord` | Interaction coordinates | list of `[3, max(sum(no_interact))]`
    `rx_orientation` | Transmitter orientation | `[3, n_snap]` or `[3]`
    `tx_orientation` | Receiver orientation | `[3, n_snap]` or `[3]`

- Output Arguments:
  - `storage_dims`
    Size of the dimensions of the storage space, vector with 4 elements, i.e. `[nx,ny,nz,nw]`.
- Caveat:
  - If the file exists already, the new data is added to the exisiting file
  - If a new file is created, a storage layout is created to store the location of datasets in the file
  - For `location = [ix]` storage layout is `[65536,1,1,1]` or `[ix,1,1,1]` if (`ix > 65536`)
  - For `location = [ix,iy]` storage layout is `[1024,64,1,1]`
  - For `location = [ix,iy,iz]` storage layout is `[256,16,16,1]`
  - For `location = [ix,iy,iz,iw]` storage layout is `[128,8,8,8]`
  - You can create a custom storage layout by creating the file first using "`hdf5_create_file`"
  - You can reshape the storage layout by using "`hdf5_reshape_storage`", but the total number of elements must not change
  - Inputs can be empty or missing.
  - All structured data is written in single precision (but can can be provided as single or double)
  - Unstructured datatypes are maintained in the HDF file
  - Supported unstructured types: string, double, float, (u)int32, (u)int64
  - Supported unstructured size: up to 3 dimensions
  - Storage order of the unstructured data is maintained

hdf5_write_dset - Writes unstructured data to a HDF5 file

- Description:
  Quadriga-Lib offers a solution based on HDF5 for storing and organizing channel data. In addition to structured datasets, the library facilitates the inclusion of extra datasets of various types and shapes. This feature is particularly beneficial for integrating descriptive data or analysis results. The function `quadriga_lib.channel.hdf5_write_dset` writes a single unstructured dataset.
- Usage:

      from quadriga_lib import channel
      channel.hdf5_write_dset( fn, ix, iy, iz, iw, name, data );

- Input Arguments:
  - `fn`
    Filename of the HDF5 file, string
  - `ix`
    Storage index for x-dimension, Default = 0
  - `iy`
    Storage index for y-dimension, Default = 0
  - `iz`
    Storage index for z-dimension, Default = 0
  - `iw`
    Storage index for w-dimension, Default = 0
  - `name`
    Name of the dataset; String
  - `data`
    Data to be written
- Caveat:
  - Throws an error if dataset already exists at this location
  - Throws an error if file does not exist (use hdf5_create_file)
  - Supported types: string, double, float, (u)int32, (u)int64
  - Supported size: up to 3 dimensions
  - Storage order is maintained

qrt_file_parse - Read metadata from a QRT file

- Usage:

      from quadriga_lib import channel

      # Separate outputs
      no_cir, no_orig, no_dest, no_freq, cir_offset, orig_names, dest_names, version, fGHz, cir_pos, cir_orientation, orig_pos, orig_orientation = channel.qrt_file_parse( fn )

      # Output as tuple
      data = channel.qrt_file_parse( fn )

- Input Argument:
  - `fn`
    Filename of the QRT file, string
- Output Arguments:
  - `no_cir`
    Number of channel snapshots per origin point
  - `no_orig`
    Number of origin points (e.g., TXs)
  - `no_dest`
    Number of destinations (RX)
  - `no_freq`
    Number of frequencies
  - `cir_offset`
    CIR offset for each destination
  - `orig_names`
    Names of the origin points (TXs), list of strings
  - `dest_names`
    Names of the destination points (RXs), list of strings
  - `version`
    QRT file version
  - `fGHz`
    Center frequency in GHz as stored in the QRT file, numpy array of floats
  - `cir_pos`
    CIR positions in Cartesian coordinates, numpy array of shape [no_cir, 3]
  - `cir_orientation`
    CIR orientation in Euler angles in rad, numpy array of shape [no_cir, 3]
  - `orig_pos`
    Origin (TX) positions in Cartesian coordinates, numpy array of shape [no_orig, 3]
  - `orig_orientation`
    Origin (TX) orientations in Euler angles in rad, numpy array of shape [no_orig, 3]

qrt_file_read - Read ray-tracing data from QRT file

- Usage:

      from quadriga_lib import channel
      data = channel.qrt_file_read( fn, i_cir, i_orig, downlink )

- Input Arguments:
  - `fn`
    Filename of the QRT file, string
  - `cir`
    Snapshot index in the file, Default = 0
  - `orig`
    Origin index (for downlink Origin = TX), Default = 0
  - `downlink`
    Switch for uplink / downlink direction, Default = true (downlink)
  - `normalize_M`
    Switch for different normalization options:

    0 | M as in QRT file, path_gain as FSPL (no normalization)
    1 | M has sum-column power is 2, path_gain is FSPL + material losses (default)

- Output Arguments:
  - `data`
    Dictionary containing the data in the HDF file with the following keys:

    `center_freq` | Center frequency in [Hz] | Length `[n_freq]`
    `tx_pos` | Transmitter position | Length `[3]`
    `tx_orientation` | Transmitter orientation, Euler angles, rad | Length `[3]`
    `rx_pos` | Receiver position | Length `[3]`
    `rx_orientation` | Receiver orientation, Euler angles, rad | Length `[3]`
    `fbs_pos` | First-bounce scatterer positions | Size `[3, n_path]`
    `lbs_pos` | Last-bounce scatterer positions | Size `[3, n_path]`
    `path_gain` | Path gain before antenna, linear scale | Size `[n_path, n_freq]`
    `path_length` | Path length from TX to RX phase center in m | Length `[n_path]`
    `M` | Polarization transfer function, interleaved complex | Size `[8, n_path, n_freq]` or `[2, n_path, n_freq]`
    `aod` | Departure azimuth angles in [rad] | Length `[n_path]`
    `eod` | Departure elevation angles in [rad] | Length `[n_path]`
    `aoa` | Arrival azimuth angles in [rad] | Length `[n_path]`
    `eoa` | Arrival elevation angles in [rad] | Length `[n_path]`
    `path_coord` | Interaction coordinates | List of `[3, n_int_s]`

quantize_delays - Fixes the path delays to a grid of delay bins

- Description:
  - For channel emulation with finite delay resolution, path delays must be mapped to a fixed grid of delay bins (taps). This function approximates each path delay using two adjacent taps with power-weighted coefficients, producing smooth transitions in the frequency domain.
  - For a path at fractional offset δ between tap indices, two taps are created with complex coefficients scaled by (1−δ)^α and δ^α, where α is the power exponent.
  - Input delays may be per-antenna `[n_rx, n_tx, n_path_s]` or shared `[1, 1, n_path_s]`. Output delay shape depends on `fix_taps` mode.
  - The number of paths `n_path_s` may differ across snapshots.
- Usage:

      import quadriga_lib
      coeff_re_q, coeff_im_q, delay_q = quadriga_lib.channel.quantize_delays(
          coeff_re, coeff_im, delay, tap_spacing=5e-9, max_no_taps=48, power_exponent=1.0, fix_taps=0)

- Arguments:
  - `list *coeff_re` (input)
    Channel coefficients, real part. List of `n_snap` numpy arrays, each of shape `[n_rx, n_tx, n_path_s]`. The number of paths may differ per snapshot.
  - `list *coeff_im` (input)
    Channel coefficients, imaginary part. Same shapes as `coeff_re`.
  - `list *delay` (input)
    Path delays in seconds. List of `n_snap` numpy arrays, each of shape `[n_rx, n_tx, n_path_s]` or `[1, 1, n_path_s]`.
  - `float tap_spacing = 5e-9` (input)
    Spacing of the delay bins in seconds.
  - `int max_no_taps = 48` (input)
    Maximum number of output taps. 0 means unlimited.
  - `float power_exponent = 1.0` (input)
    Interpolation exponent. Use 1.0 for narrowband or 0.5 for wideband.
  - `int fix_taps = 0` (input)
    Delay sharing mode: 0 = per tx-rx pair and snapshot, 1 = single grid for all, 2 = per snapshot, 3 = per tx-rx pair.
- Returns:
  - `np.ndarray coeff_re_q` (output)
    Output coefficients, real part. 4D array of shape `[n_rx, n_tx, n_taps, n_snap]`.
  - `np.ndarray coeff_im_q` (output)
    Output coefficients, imaginary part. 4D array of shape `[n_rx, n_tx, n_taps, n_snap]`.
  - `np.ndarray delay_q` (output)
    Output delays in seconds. 4D array of shape `[n_rx, n_tx, n_taps, n_snap]` or `[1, 1, n_taps, n_snap]`.

Miscellaneous / Tools

acdf - Calculate the empirical averaged cumulative distribution function (CDF)

- Description:
  - Calculates the empirical CDF from the given data matrix, where each column represents an independent data set (e.g., repeated experiment runs).
  - Individual CDFs are computed per column and an averaged CDF is obtained by interpolation in quantile space.
  - `Inf` and `NaN` values in the data are excluded from the computation.
  - If `bins` is `None` or empty, 201 equally spaced bins spanning the data range are generated.
- Usage:

      import quadriga_lib
      result = quadriga_lib.tools.acdf( data, bins, n_bins )

- Arguments:
  - `np.ndarray data` (input)
    Input data matrix. Shape `(n_samples, n_sets)`. Each column is one data set.
  - `np.ndarray bins = None` (optional input)
    Bin centers for the histogram. Shape `(n_bins,)`. If `None` or empty, bins are auto-generated.
  - `int n_bins = 201` (input)
    Number of bins to generate when bins are auto-generated. Must be at least 2.
- Returns:
  - `dict` with keys:
    `"bins"` — Bin centers, shape `(n_bins,)`.
    `"Sh"` — Individual CDFs, shape `(n_bins, n_sets)`.
    `"Sc"` — Averaged CDF, shape `(n_bins,)`.
    `"mu"` — Mean quantiles at 0.1–0.9, shape `(9,)`.
    `"sig"` — Std of quantiles at 0.1–0.9, shape `(9,)`.
- Example:

      import numpy as np
      import quadriga_lib

      data = np.random.randn(10000, 5)
      result = quadriga_lib.tools.acdf(data)
      bins = result["bins"]   # 201 bin centers
      Sh = result["Sh"]       # Individual CDFs, shape (201, 5)
      Sc = result["Sc"]       # Averaged CDF, shape (201,)
      mu = result["mu"]       # Mean quantiles, shape (9,)
      sig = result["sig"]     # Std of quantiles, shape (9,)

calc_angular_spread - Calculate azimuth and elevation angular spreads with spherical wrapping

- Description:
  - Calculates the RMS azimuth and elevation angular spreads from a set of power-weighted angles.
  - Inputs use lists of 1D numpy arrays so that each CIR can have a different number of paths.
  - Uses optional spherical coordinate wrapping to avoid the pole singularity: the power-weighted mean direction is computed in Cartesian coordinates and all paths are rotated so the centroid lies on the equator before computing spreads.
  - Without spherical wrapping, azimuth spread near the poles is inflated (large azimuth spread despite energy being focused into a small solid angle). This method corrects for that.
  - Optionally computes an optimal bank (roll) angle that maximizes azimuth spread and minimizes elevation spread, corresponding to the principal axes of the angular power distribution.
  - Setting `wrapping` to true enables spherical wrapping.
- Usage:

      import quadriga_lib
      as_spread, es_spread, orientation, phi, theta = quadriga_lib.tools.calc_angular_spread( az, el, powers)
      as_spread, es_spread, orientation, phi, theta = quadriga_lib.tools.calc_angular_spread(
          az, el, powers, wrapping=True, calc_bank_angle=True, quantize=0.0)

- Arguments:
  - `list of np.ndarray az` (input)
    Azimuth angles in [rad]. List of length `n_cir`, each element is a 1D array of length `n_path`.
  - `list of np.ndarray el` (input)
    Elevation angles in [rad]. List of length `n_cir`, each element is a 1D array of length `n_path`.
  - `list of np.ndarray powers` (input)
    Path powers in [W]. List of length `n_cir`, each element is a 1D array of length `n_path`.
  - `bool wrapping = False` (input)
    If True, enable spherical rotation. Default: False (use raw angles)
  - `bool calc_bank_angle = False` (input)
    If True, compute the optimal bank angle analytically.
  - `float quantize = 0.0` (input)
    Angular quantization step in [deg]. Set to 0 for no quantization.
- Returns:
  - `np.ndarray azimuth_spread` (output)
    RMS azimuth angular spread in [rad]. Shape `(n_cir,)`.
  - `np.ndarray elevation_spread` (output)
    RMS elevation angular spread in [rad]. Shape `(n_cir,)`.
  - `np.ndarray orientation` (output)
    Mean-angle orientation [bank, tilt, heading] in [rad]. Shape `(3, n_cir)`.
  - `list of np.ndarray phi` (output)
    Rotated azimuth angles in [rad]. List of length `n_cir`.
  - `list of np.ndarray theta` (output)
    Rotated elevation angles in [rad]. List of length `n_cir`.
- Example:

      import numpy as np
      import quadriga_lib

      az = [np.array([0.1, -0.1, 0.05]), np.array([0.2, -0.2, 0.1, -0.1])]
      el = [np.array([0.0, 0.0, 0.0]), np.array([0.05, -0.05, 0.0, 0.0])]
      powers = [np.array([1.0, 1.0, 0.5]), np.array([2.0, 1.0, 1.5, 0.5])]
      as_spread, es_spread, orient, phi, theta = quadriga_lib.tools.calc_angular_spread(az, el, powers)

calc_cross_polarization_ratio - Calculate the cross-polarization ratio (XPR) for linear and circular polarization bases

- Description:
  - Computes the aggregate cross-polarization ratio (XPR) from the polarization transfer matrices of all channel impulse responses (CIRs) using the total-power-ratio method.
  - For each CIR, the total co-polarized and cross-polarized received powers are accumulated across all qualifying paths, and the XPR is obtained as a single ratio of the totals.
  - In addition to the linear V/H basis, the XPR is also computed in the circular LHCP/RHCP basis.
  - The LOS path is identified by comparing each path's absolute length against the direct TX-RX distance. All paths with `path_length < dTR + window_size` are excluded from the XPR calculation by default (controlled by `include_los`).
  - If the total cross-polarized power is zero, the XPR is set to 0 (undefined).
- Usage:

      import quadriga_lib
      xpr, pg = quadriga_lib.tools.calc_cross_polarization_ratio( powers, M, path_length, tx_pos, rx_pos )
      xpr, pg = quadriga_lib.tools.calc_cross_polarization_ratio( powers, M, path_length, tx_pos, rx_pos, include_los, window_size )

- Arguments:
  - `list of np.ndarray powers` (input)
    Path powers in Watts. List of length `n_cir`, each element is a 1D array of length `n_path`.
  - `list of np.ndarray M` (input)
    Polarization transfer matrices. List of length `n_cir`, each element is a 2D array of size `[8, n_path]`.
  - `list of np.ndarray path_length` (input)
    Absolute path length from TX to RX in meters. List of length `n_cir`, each element is a 1D array of length `n_path`.
  - `np.ndarray tx_pos` (input)
    Transmitter position. Size `[3, 1]` (fixed) or `[3, n_cir]` (mobile).
  - `np.ndarray rx_pos` (input)
    Receiver position. Size `[3, 1]` (fixed) or `[3, n_cir]` (mobile).
  - `bool include_los = False` (input)
    If `True`, include LOS paths in the XPR calculation.
  - `float window_size = 0.01` (input)
    LOS window size in meters.
- Returns:
  - `np.ndarray xpr` (output)
    Cross-polarization ratio, linear scale. Size `[n_cir, 6]`. Columns: 0=aggregate linear, 1=V-XPR, 2=H-XPR, 3=aggregate circular, 4=LHCP, 5=RHCP.
  - `np.ndarray pg` (output)
    Total path gain over all paths. 1D array of length `[n_cir]`.

calc_delay_spread - Calculate the RMS delay spread in [s]

- Description:
  - Computes the root-mean-square (RMS) delay spread from a given set of delays and corresponding linear-scale powers for each channel impulse response (CIR).
  - An optional power threshold in [dB] relative to the strongest path can be applied. Paths with power below `p_max(dB) - threshold` are excluded from the calculation.
  - An optional granularity parameter in [s] groups paths in the delay domain. Powers of paths falling into the same delay bin are summed before computing the delay spread.
  - Optionally returns the mean delay for each CIR.
- Usage:

      import quadriga_lib
      ds, mean_delay = quadriga_lib.tools.calc_delay_spread(delays, powers, threshold=100.0, granularity=0.0)

- Arguments:
  - `list of np.ndarray delays` (input)
    Delays in [s]. A list of length `n_cir`, where each element is a 1D numpy array of path delays.
  - `list of np.ndarray powers` (input)
    Path powers on a linear scale [W]. Same structure as `delays`.
  - `float threshold = 100.0` (input)
    Power threshold in [dB] relative to the strongest path. Default: 100 dB.
  - `float granularity = 0.0` (input)
    Window size in [s] for grouping paths in the delay domain. Default: 0 (no grouping).
- Returns:
  - `np.ndarray ds` (output)
    RMS delay spread in [s] for each CIR. Shape `(n_cir,)`.
  - `np.ndarray mean_delay` (output)
    Mean delay in [s] for each CIR. Shape `(n_cir,)`.
- Example:

      import numpy as np
      import quadriga_lib

      delays = [np.array([0.0, 1e-6, 2e-6])]
      powers = [np.array([1.0, 0.5, 0.25])]
      ds, mean_delay = quadriga_lib.calc_delay_spread(delays, powers)

calc_rician_k_factor - Calculate the Rician K-Factor from channel impulse response data

- Description:
  - The Rician K-Factor (KF) is defined as the ratio of signal power in the dominant line-of-sight (LOS) path to the power in the scattered (non-line-of-sight, NLOS) paths.
  - The LOS path is identified by matching the absolute path length with the direct distance between TX and RX positions (`dTR`).
  - All paths arriving within `dTR + window_size` are considered LOS and their power is summed.
  - Paths arriving after `dTR + window_size` are considered NLOS and their power is summed.
  - If the total NLOS power is zero, the K-Factor is set to infinity.
  - If the total LOS power is zero, the K-Factor is set to zero.
  - The transmitter and receiver positions can be fixed (shape `(3,)` or `(3, 1)`) or mobile (shape `(3, n_cir)`). Fixed positions are reused for all channel snapshots.
  - Output `pg` returns the total path gain (sum of all path powers) for each snapshot.
- Usage:

      import quadriga_lib
      kf, pg = quadriga_lib.tools.calc_rician_k_factor( powers, path_length, tx_pos, rx_pos, window_size=0.01 )

- Arguments:
  - `list of ndarray powers` (input)
    Path powers in Watts [W]. List of length `n_cir`, where each element is a 1D numpy array of length `n_path`.
  - `list of ndarray path_length` (input)
    Absolute path lengths from TX to RX phase center in meters. List of length `n_cir`, where each element is a 1D numpy array of length `n_path` matching the corresponding entry in `powers`.
  - `ndarray tx_pos` (input)
    Transmitter position in Cartesian coordinates. Shape `(3,)` or `(3, 1)` for fixed TX, or `(3, n_cir)` for mobile TX.
  - `ndarray rx_pos` (input)
    Receiver position in Cartesian coordinates. Shape `(3,)` or `(3, 1)` for fixed RX, or `(3, n_cir)` for mobile RX.
  - `float window_size = 0.01` (input)
    LOS window size in meters. Paths with length ≤ `dTR + window_size` are considered LOS.
- Returns:
  - `ndarray kf` (output)
    Rician K-Factor on linear scale. Shape `(n_cir,)`.
  - `ndarray pg` (output)
    Total path gain (sum of path powers). Shape `(n_cir,)`.

cart2geo - Transform Cartesian (x,y,z) coordinates to Geographic (az, el, length) coordinates

- Description:
  This function transforms Cartesian (x,y,z) coordinates to Geographic (azimuth, elevation, length) coordinates. A geographic coordinate system is a three-dimensional reference system that locates points on the surface of a sphere. A point has three coordinate values: azimuth, elevation and length where azimuth and elevation measure angles. In the geographic coordinate system, the elevation angle θ = 90° points to the zenith and θ = 0° points to the horizon.
- Usage:

      import quadriga_lib
      geo_coords = quadriga_lib.tools.cart2geo(cart_coords)

- Input Argument:
  - `cart_coords`
    Cartesian coordinates (x,y,z), Shape: `(3, n_row, n_col)`
- Output Arguments:
  - `geo_coords`
    Geographic coordinates, Shape: `(3, n_row, n_col)`
    First row: Azimuth angles in [rad], values between -pi and pi.
    Second row: Elevation angles in [rad], values between -pi/2 and pi/2.
    Third row: Vector length, i.e. the distance from the origin to the point defined by x,y,z.

components - Returns the version numbers of all quadriga-lib sub-components

- Usage:

      components = quadriga_lib.components()

version - Returns the quadriga-lib version number

- Usage:

      version = quadriga_lib.version();

- Caveat:
  - If Quadriga-Lib was compiled with AVX2 support and the CPU supports intrinsic AVX2 instructions, an suffix `_AVX2` is added after the version number

write_png - Write data to a PNG file

- Description:
  - Converts input data into a color-coded PNG file for visualization
  - Support optional selection of a colormap, as well a minimum and maximum value limits
  - Uses the LodePNG library for PNG writing
- Declaration:

      import quadriga_lib

      quadriga_lib.tools.write_png( fn, data, colormap, min_val, max_val, log_transform )

- Arguments:
  - `fn`
    Filename of the PNG file, string, required
  - `data`
    Data matrix, required, size `[N, M]`
  - `colormap` (optional)
    Colormap for the visualization, string, supported are 'jet', 'parula', 'winter', 'hot', 'turbo', 'copper', 'spring', 'cool', 'gray', 'autumn', 'summer', optional, default = 'jet'
  - `min_val` (optional)
    Minimum value. Values below this value will have be encoded with the color of the smallest value. If `NAN` is provided (default), the lowest values is determined from the data.
  - `max_val` (optional)
    Maximum value. Values above this value will have be encoded with the color of the largest value. If `NAN` is provided (default), the largest values is determined from the data.
  - `log_transform` (optional)
    If enabled, the `data` values are transformed to the log-domain (`10*log10(data)`) before processing. Default: false (disabled)

Site-Specific Simulation Tools

icosphere - Construct a geodesic polyhedron (icosphere), a convex polyhedron made from triangles

- Description:
  An icosphere is constructed by subdividing faces of an icosahedron, a polyhedron with 20 faces, 12 vertices and 30 edges, and then projecting the new vertices onto the surface of a sphere. The resulting mesh has 6 triangles at each vertex, except for 12 vertices which have 5 triangles. The approximate equilateral triangles have roughly the same edge length and surface area.
- Usage:

      from quadriga_lib import RTtools
      center, length, vert, direction = RTtools.icosphere( no_div, radius, direction_xyz )

- Input Arguments:
  - `no_div`
    Number of divisions per edge of the generating icosahedron. The resulting number of faces is equal to `no_face = 20 · no_div^2`
  - `radius`
    Radius of the sphere in meters
  - `direction_xyz`
    Direction format indicator: 0 = Spherical (default), 1 = Cartesian
- Output Arguments (tuple containing 4 values):
  - `center`
    Position of the center point of each triangle; Shape: `( no_face, 3 )`
  - `length`
    Length of the vector pointing from the origin to the center point. This number is smaller than 1 since the triangles are located inside the unit sphere; Shape: `( no_face )`
  - `vert`
    The 3 vectors pointing from the center point to the vertices of each triangle; the values are in the order `[ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ]`; Shape: `( no_face, 9 )`
  - `direction`
    The directions of the vertex-rays. If the format indicator `direction_xyz` is set to `0`, the output is in geographic coordinates (azimuth and elevation angle in rad); the values are in the order `( v1az, v1el, v2az, v2el, v3az, v3el ]`;Shape: `( no_face, 6 )` If the format indicator `direction_xyz` is set to `1`, the output is in Cartesian coordinates and the values are in the order `[ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ]`; Shape: `( no_face, 9 )`

mitsuba_xml_file_write - Write geometry and material data to a Mitsuba 3 XML scene file.

- Description:
  Converts a triangular surface mesh into the XML format understood by Mitsuba 3 www.mitsuba-renderer.org. The generated file can be loaded directly by NVIDIA Sionna RT for differentiable radio-propagation simulations.
  - Converts a 3D geometry mesh into Mitsuba 3 XML format for use with rendering tools.
  - Enables exporting models from `quadriga-lib` to be used with Mitsuba 3 or Sionna RT:
  - Mitsuba 3: Research-oriented retargetable rendering system.
  - NVIDIA Sionna: Hardware-accelerated differentiable ray tracer for wireless propagation, built on Mitsuba 3.
  - Supports grouping faces into named objects and assigning materials by name.
  - Optionally maps materials to ITU default presets used by Sionna RT.
- Usage:

      from quadriga_lib import RTtools
      RTtools.mitsuba_xml_file_write( fn, vert_list, face_ind, obj_id, mtl_id, obj_names, mtl_names, bsdf, map_to_itu )

- Input Arguments:
  - `fn`
    Output file name (including path and `.xml` extension).
  - `vert_list`
    Vertex list, size `[n_vert, 3]`, each row is a vertex (x, y, z) in Cartesian coordinates [m].
  - `face_ind`
    Face indices (0-based), size `[n_mesh, 3]`, each row defines a triangle via vertex indices.
  - `obj_id` (input)
    Object indices (1-based), size `[n_mesh]`. Assigns each triangle to an object.
  - `mtl_id` (input)
    Material indices (1-based), size `[n_mesh]`. Assigns each triangle to a material.
  - `obj_names`
    List of object names. Length must be equal to `max(obj_ind)`.
  - `mtl_names`
    List of material names. Length must be equal to `max(mtl_ind)`.
  - `bsdf = []` (optional input)
    Material reflectivity data (BSDF parameters), size `[len(mtl_names), 17]`. If omitted, the `null` BSDF is used. Note that Sionna RT ignores all BSDF parameters. They are only used by the Mitsuma rendering system. See obj_file_read for a definition of the data fields.
  - `map_to_itu = false` (optional input)
    If true, maps material names to ITU-defined presets used by Sionna RT. Default: `false`
- See also:
  - obj_file_read

obj_file_read - Reads a triangulated 3D polygon mesh from a Wavefront OBJ file

- Description:
  The function imports a polygon mesh from an OBJ file. The OBJ file format is a straightforward data format, representing 3D geometry. It details the position of each vertex and defines polygons as lists of these vertices. By default, vertices are arranged in a counter-clockwise order, eliminating the need for explicit declaration of face normals. When exporting the mesh from software like Blender, it's essential to triangulate the mesh and include material definitions. If the material name exists in the material database, the function loads the corresponding properties. Otherwise, it defaults to using standard properties.
- Usage:

      from quadriga_lib import RTtools

      # Return as separate variables
      mesh, mtl_prop, vert_list, face_ind, obj_ind, mtl_ind, obj_names, mtl_names, bsdf = RTtools.obj_file_read( fn )

      # Return as tuple with 8 elements
      data = RTtools.obj_file_read( fn )

      # Use a custom material definition file
      data = RTtools.obj_file_read( fn, materials_csv )

- Input Arguments:
  - `fn`
    Filename of the OBJ file, string
  - `materials_csv` (optional)
    Path to optional CSV file containing custom material properties. If empty, default ITU-R P.2040-3 materials are used. CSV format: Header row with columns 'name', 'a', 'b', 'c', 'd', 'att' (order can vary). Each row defines a material with: name (string), electromagnetic parameters a,b,c,d (doubles), and additional attenuation att (dB). Relative permittivity: eta = a * f_GHz^b; Conductivity: sigma = c * f_GHz^d
- Output Arguments:
  - `mesh`, `data[0]`
    Vertices of the triangular mesh in global Cartesian coordinates. Each face is described by 3 points in 3D-space. Hence, a face has 9 values in the order [ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ];
    Size: `[ no_mesh, 9 ]`
  - `mtl_prop`, `data[1]`
    Material properties of each mesh element; If no material is defined for an object, the properties for vacuum are used. Size: `[ no_mesh, 5 ]`
  - `vert_list`, `data[2]`
    List of vertices found in the OBJ file; Size: `[ no_vert, 3 ]`
  - `face_ind`, `data[3]`
    Triangular faces are defined by three vertices. Vertex indices match the corresponding vertex elements of the previously defined `vert_list` (0-based).
    uint32; Size: `[ no_mesh, 3 ]`
  - `obj_id`, `data[4]`
    Mesh elements in the OBJ file can be grouped into objects (e.g. 12 triangles define the walls of a cube). Each object is identified by a unique ID (1-based index of `obj_names`).
    uint32; Size: `[ no_mesh, 1 ]`
  - `mtl_id`, `data[5]`
    Each mesh element gets assigned a material and each unique material gets assigned an ID (1-based index of `mtl_names`). Different faces of an object can have different materials. If no material is defined in the OBJ file, the id is set to `0` and no entry is made in `mtl_names`.
    uint32; Size: `[ no_mesh, 1 ]`
  - `obj_names`, `data[6]`
    Names of the objects in the OBJ file; List of strings
  - `mtl_names`, `data[7]`
    Names of the materials in the OBJ file; List of strings
  - `bsdf`, `data[8]`
    Principled BSDF (Bidirectional Scattering Distribution Function) values extracted from the .MTL file. Size `[mtl_names.size(), 17]`. Values are:

    0 | Base Color Red | Range 0-1 | Default = 0.8
    1 | Base Color Green | Range 0-1 | Default = 0.8
    2 | Base Color Blue | Range 0-1 | Default = 0.8
    3 | Transparency (alpha) | Range 0-1 | Default = 1.0 (fully opaque)
    4 | Roughness | Range 0-1 | Default = 0.5
    5 | Metallic | Range 0-1 | Default = 0.0
    6 | Index of refraction (IOR) | Range 0-4 | Default = 1.45
    7 | Specular Adjustment to the IOR | Range 0-1 | Default = 0.5 (no adjustment)
    8 | Emission Color Red | Range 0-1 | Default = 0.0
    9 | Emission Color Green | Range 0-1 | Default = 0.0
    10 | Emission Color Blue | Range 0-1 | Default = 0.0
    11 | Sheen | Range 0-1 | Default = 0.0
    12 | Clearcoat | Range 0-1 | Default = 0.0
    13 | Clearcoat roughness | Range 0-1 | Default = 0.0
    14 | Anisotropic | Range 0-1 | Default = 0.0
    15 | Anisotropic rotation | Range 0-1 | Default = 0.0
    16 | Transmission | Range 0-1 | Default = 0.0

- Material properties:
  Each material is defined by its electrical properties. Radio waves that interact with a building will produce losses that depend on the electrical properties of the building materials, the material structure and the frequency of the radio wave. The fundamental quantities of interest are the electrical permittivity (ϵ) and the conductivity (σ). A simple regression model for the frequency dependence is obtained by fitting measured values of the permittivity and the conductivity at a number of frequencies. The five parameters returned in `mtl_prop` then are:
  - Real part of relative permittivity at f = 1 GHz (a)
  - Frequency dependence of rel. permittivity (b) such that ϵ = a · f^b
  - Conductivity at f = 1 GHz (c)
  - Frequency dependence of conductivity (d) such that σ = c· f^d
  - Fixed attenuation in dB applied to each transition
  A more detailed explanation together with a derivation can be found in ITU-R P.2040. The following list of material is currently supported and the material can be selected by using the `usemtl` tag in the OBJ file. When using Blender, the simply assign a material with that name to an object or face. In addition, custom properties can be set by assigning adding the 5 properties after the material name, separated by `:`, e.g.:

      usemtl custom::2.1:0.1:0.1:0.5:20

  The following materials are defined by default:

  Name | a | b | c | d | Att | max fGHz |
  --- | --- | --- | --- | --- | --- | --- | ---
  vacuum / air | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100 |
  textiles | 1.5 | 0.0 | 5e-5 | 0.62 | 0.0 | 100 |
  plastic | 2.44 | 0.0 | 2.33e-5 | 1.0 | 0.0 | 100 |
  ceramic | 6.5 | 0.0 | 0.0023 | 1.32 | 0.0 | 100 |
  sea_water | 80.0 | -0.25 | 4.0 | 0.58 | 0.0 | 100 |
  sea_ice | 3.2 | -0.022 | 1.1 | 1.5 | 0.0 | 100 |
  water | 80.0 | -0.18 | 0.6 | 1.52 | 0.0 | 20 |
  water_ice | 3.17 | -0.005 | 5.6e-5 | 1.7 | 0.0 | 20 |
  itu_concrete | 5.24 | 0.0 | 0.0462 | 0.7822 | 0.0 | 100 |
  itu_brick | 3.91 | 0.0 | 0.0238 | 0.16 | 0.0 | 40 |
  itu_plasterboard | 2.73 | 0.0 | 0.0085 | 0.9395 | 0.0 | 100 |
  itu_wood | 1.99 | 0.0 | 0.0047 | 1.0718 | 0.0 | 100 |
  itu_glass | 6.31 | 0.0 | 0.0036 | 1.3394 | 0.0 | 100 |
  itu_ceiling_board | 1.48 | 0.0 | 0.0011 | 1.075 | 0.0 | 100 |
  itu_chipboard | 2.58 | 0.0 | 0.0217 | 0.78 | 0.0 | 100 |
  itu_plywood | 2.71 | 0.0 | 0.33 | 0.0 | 0.0 | 40 |
  itu_marble | 7.074 | 0.0 | 0.0055 | 0.9262 | 0.0 | 60 |
  itu_floorboard | 3.66 | 0.0 | 0.0044 | 1.3515 | 0.0 | 100 |
  itu_metal | 1.0 | 0.0 | 1.0e7 | 0.0 | 0.0 | 100 |
  itu_very_dry_ground | 3.0 | 0.0 | 0.00015 | 2.52 | 0.0 | 10 |
  itu_medium_dry_ground | 15.0 | -0.1 | 0.035 | 1.63 | 0.0 | 10 |
  itu_wet_ground | 30.0 | -0.4 | 0.15 | 1.3 | 0.0 | 10 |
  itu_vegetation | 1.0 | 0.0 | 1.0e-4 | 1.1 | 0.0 | 100 |
  irr_glass | 6.27 | 0.0 | 0.0043 | 1.1925 | 23.0 | 100 |

point_cloud_aabb - Calculates the axis-aligned bounding box (AABB) for a 3D point cloud or a set of sub-clouds

- Description:
  The axis-aligned bounding box (AABB) of a set of points is the smallest bounding box whose edges are parallel to the Cartesian coordinate axes and that fully contains all points in the set. This function computes the AABB for either:
  - an entire point cloud, or
  - multiple sub-clouds defined by their starting indices.
  For each cloud, the function returns the minimum and maximum extents along the x, y, and z dimensions, producing a matrix where each row corresponds to one sub-cloud’s bounding box. When SIMD-friendly alignment is required, the output is zero-padded to the nearest multiple of vec_size; these padding rows should be ignored if the number of sub-clouds is known externally.
  If a sub_cloud_index is provided, the last index is assumed to extend to the end of the points matrix. This functionality is particularly useful for preprocessing in geometry analysis, rendering pipelines, and spatial acceleration structures such as BVHs or octrees. Sub-clouds can be conveniently generated using point_cloud_segmentation.
- Usage:

      from quadriga_lib import RTtools

      aabb = RTtools.point_cloud_aabb( points, sub_cloud_ind, vec_size )

- Input Arguments:
  - `points`
    Points in 3D-Cartesian space; Size: [ n_points, 3 ]
  - `sub_cloud_ind` (optional)
    Start indices of the sub-clouds in 0-based notation. If this parameter is not given, the AABB of the entire point cloud is returned. Vector of length `[ n_sub_cloud ]`
  - `vec_size` (optional)
    Vector size for SIMD processing (e.g. 8 for AVX2, 32 for CUDA). Default value = 1. For values > 1, the number of rows in the output is increased to a multiple of `vec_size`, padded with zeros.
- Output Argument:
  - `aabb`
    Axis-aligned bounding box of each sub-cloud. Each box is described by 6 values: `[ x_min, x_max, y_min, y_max, z_min, z_max ]`; Size: `[ n_sub_cloud, 6 ]`
- See also:
  - point_cloud_segmentation (for calculating sub clouds)

point_cloud_segmentation - Rearranges elements of a point cloud into smaller sub-clouds

- Description:
  This function processes a large 3D point cloud by clustering closely spaced points and recursively partitioning it into smaller sub-clouds, each below a specified size threshold. It minimizes the axis-aligned bounding box of each sub-cloud while striving to maintain a target number of points per cluster.
  Sub-clouds are aligned to a specified SIMD vector size (e.g., for AVX or CUDA), with padding applied as needed. The function outputs a reorganized version of the input points (pointsR), where points are grouped by sub-cloud, and provides forward and reverse index maps to track the reordering. This organization is particularly useful for optimizing spatial processing tasks such as bounding volume hierarchies or GPU batch execution.
- Usage:

      from quadriga_lib import RTtools

      # Output as tuple
      data = RTtools.point_cloud_segmentation( points, target_size, vec_size )

      # Unpacked outputs
      points_out, sub_cloud_ind, forward_ind, reverse_ind = RTtools.point_cloud_segmentation( points, target_size, vec_size )

- Input Arguments:
  - `points`
    Points in 3D-Cartesian space; Size: `[ n_points, 3 ]`
  - `target_size` (optional)
    The target number of elements of each sub-cloud. Default value = 1024. For best performance, the value should be around 10 * sgrt( n_points )
  - `vec_size` (optional)
    Vector size for SIMD processing (e.g. 8 for AVX2, 32 for CUDA). Default value = 1. For values > 1,the number of rows for each sub-cloud in the output is increased to a multiple of `vec_size`. For padding, zero-sized triangles are placed at the center of the AABB of the corresponding sub-cloud.
- Output Arguments:
  - `points_out`, `data[0]`
    Points in 3D-Cartesian space; singe or double precision; Size: `[ n_points_out, 3 ]`
  - `sub_cloud_ind`, `data[1]`
    Start indices of the sub-clouds in 0-based notation. Type: uint32; Vector of length `[ n_sub_cloud ]`
  - `forward_ind`, `data[2]`
    Indices for mapping elements of "points_in" to "points_out"; 1-based; Length: `[ n_points_out ]`; For `vec_size > 1`, the added elements not contained in the input are indicated by zeros.
  - `reverse_ind`, `data[3]`
    Indices for mapping elements of "points_out" to "points"; 0-based; Length: `[ n_points ]`

point_inside_mesh - Test whether 3D points are inside a triangle mesh using raycasting

- Description:
  - Uses raycasting to determine whether each 3D point lies inside a triangle mesh.
  - Requires that the mesh is watertight and all normals are pointing outwards.
  - For each point, multiple rays are cast in various directions.
  - If any ray intersects a mesh element with a negative incidence angle, the point is classified as inside.
  - Output can be binary (0 = outside, 1 = inside) or labeled with object indices.
- Usage:

      from quadriga_lib import RTtools
      result = RTtools.point_inside_mesh( points, mesh, obj_ind, distance )

- Input Arguments:
  - `points` (input)
    3D point coordinates to test, size `[n_points, 3]`.
  - `mesh` (input)
    Triangular mesh faces. Each row represents a triangle using 3 vertices in row-major format (x1,y1,z1,x2,y2,z2,x3,y3,z3), size `[n_mesh, 9]`.
  - `obj_ind` (optional input)
    Optional object index for each mesh element (1-based), size `[n_mesh]`. If provided, the return vector will contain the index of the enclosing object instead of binary values.
  - `distance` (optional input)
    Optional distance in meters from objects that should be considered as inside the object. Possible range: 0 - 20 m. Using this parameter significantly increases computation time.
- Output Arguments:
  - `result`
    For each point: Returns `0` if the point is outside the mesh (or all objects), `1` if inside (or close to) any mesh object (if `obj_ind` not given), or returns the 1-based object index if `obj_ind` is provided. Size: `[n_points]`

ray_point_intersect - Calculates which 3D points are intersected by volumetric ray beams

- Description:
  Unlike traditional ray tracing (rays are infinitesimal lines), beam tracing models rays as volumes. Each beam is defined by a triangular wavefront whose three vertices diverge as the beam propagates, capturing real-world spread (e.g., radio-wave divergence) and enabling realistic energy distribution across the beam’s cross-section. Because beams have volume, intersection tests are volumetric rather than line-to-geometry.
  A ray beam is specified by:
  - An origin point.
  - Three aperture vectors from the origin to the vertices of an initial triangular wavefront that defines the beam’s cross-section at the origin.
  - Three per-vertex direction vectors (one per vertex) that govern how each vertex, and thus the triangle, diverges as the beam extends. Directions need not be normalized.
  What the function does
  - Tests whether each point in a 3D Cartesian point cloud lies inside any of the defined ray beams.
  - For every input point, returns a list of 0-based ray indices of beams that intersect that point.
  Performance & usage notes
  - Optional support for pre-segmented point clouds (e.g., from point_cloud_segmentation) to reduce computation.
  - All internal computations use single-precision floats for speed.
  - Utilizes AVX2 vectorization when supported by the CPU.
  - For best accuracy, use a small tube radius and well-distributed points.
- Usage:

      from quadriga_lib import RTtools

      hit_count, ray_ind = RTtools.ray_point_intersect( orig, trivec, tridir, points, sub_cloud_ind, target_size )

- Input Arguments:
  - `orig`
    Ray origins in 3D Cartesian coordinates; Shape: `(n_ray, 3)`
  - `trivec`
    Three vectors from each ray’s origin to the vertices of the triangular propagation tube (beam); Order per row: [v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z]; Shape: `(n_ray, 9)`
  - `tridir`
    Directions of the three vertex rays in Cartesian coordinates. Normalization not required. Order per row: [d1x, d1y, d1z, d2x, d2y, d2z, d3x, d3y, d3z]; Shape: `(n_ray, 9)`
  - `points`
    3D Cartesian coordinates of the point cloud to test. Shape: `(n_points, 3)`
  - `sub_cloud_ind` (optional)
    0-based start indices of sub-clouds used to partition points for performance. If not provided (empty array), sub-clouds may be computed automatically. Passing a scalar 0 disables sub-cloud calculation. Shape: (n_sub_cloud,) (strictly increasing; typically starts with 0)
  - `target_size` (optional)
    Desired sub-cloud size used only if `sub_cloud_ind` is not given or empty (guides automatic segmentation). If it is not given (set to 0) and `sub_cloud_ind` is empty or also not given, the optimal valued is computed from the number of points.
- Output Arguments:
  - `hit_count`
    Number of ray beams that hit a point. Shape: `(n_points, )`
  - `ray_ind`
    List of length n_points; each list entry is a 1-D array of 0-based ray indices that hit that point. Entries may be empty if no hit was detected.
- See also:
  - icosphere (for generating beams)
  - point_cloud_segmentation (for generating point cloud segments)
  - ray_triangle_intersect (for calculating intersection of rays and triangles)

ray_triangle_intersect - Calculates the intersection of rays and triangles in three dimensions

- Description:
  - This function implements the Möller–Trumbore ray-triangle intersection algorithm, known for its efficiency in calculating the intersection of a ray and a triangle in three-dimensional space. This method achieves its speed by eliminating the need for precomputed plane equations of the plane containing the triangle.
  - For further information, refer to [Wikipedia: Möller–Trumbore intersection algorithm].
  - The algorithm defines the ray using two points: an origin and a destination. Similarly, the triangle is specified by its three vertices.
  - To enhance performance, this implementation leverages AVX2 intrinsic functions and OpenMP, when available, to speed up the computational process.
- Usage:

      from quadriga_lib import RTtools

      # Output as tuple
      data = RTtools.ray_triangle_intersect( orig, dest, mesh, sub_mesh_index )

      # Unpacked outputs
      fbs, sbs, no_interact, fbs_ind, sbs_ind = RTtools.ray_triangle_intersect( orig, dest, mesh, sub_mesh_index )

- Input Arguments:
  - `orig`
    Ray origins in 3D Cartesian coordinates; Size: `[ no_ray, 3 ]`
  - `dest`
    Ray destinations in 3D Cartesian coordinates; Size: `[ no_ray, 3 ]`
  - `mesh`
    Vertices of the triangular mesh in global Cartesian coordinates. Each face is described by 3 points in 3D-space. Hence, a face has 9 values in the order [ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ]; Size: `[ no_mesh, 9 ]`
  - `sub_mesh_index` (optional)
    Start indices of the sub-meshes in 0-based notation. If this parameter is not given, intersections are calculated for each mesh element, leading to poor performance for large meshes. Vector of length `[ n_sub_mesh ]`
- Output Arguments:
  - `fbs`, `data[0]`
    First interaction point between the rays and the triangular mesh. If no interaction was found, the FBS location is equal to `dest`. Size: `[ no_ray, 3 ]`
  - `sbs`, `data[1]`
    Second interaction point between the rays and the triangular mesh. If no interaction was found, the SBS location is equal to `dest`. Size: `[ no_ray, 3 ]`
  - `no_interact`, `data[2]`
    Total number of interactions between the origin point and the destination; uint32; Length: `[ no_ray ]`
  - `fbs_ind`, `data[3]`
    Index of the triangle that was hit by the ray at the FBS location; 1-based; uint32; Length: `[ no_ray ]`
  - `sbs_ind`, `data[4]`
    Index of the triangle that was hit by the ray at the SBS location; 1-based; uint32; Length: `[ no_ray ]`
- Caveat:
  - All internal computation are done in single precision to achieve an additional 2x improvement in speed compared to double precision when using AVX2 intrinsic instructions
- See also:
  - obj_file_read (for loading mesh from an OBJ file)
  - icosphere (for generating beams)
  - triangle_mesh_segmentation (for calculating sub-meshes)

triangle_mesh_aabb - Calculate the axis-aligned bounding box (AABB) of a triangle mesh and its sub-meshes

- Description:
  The axis-aligned minimum bounding box (or AABB) for a given set of triangles is its minimum bounding box subject to the constraint that the edges of the box are parallel to the (Cartesian) coordinate axes. Axis-aligned bounding boxes are used as an approximate location of the set of triangles. In order to find intersections with the triangles (e.g. using ray tracing), the initial check is the intersections between the rays and the AABBs. Since it is usually a much less expensive operation than the check of the actual intersection (because it only requires comparisons of coordinates), it allows quickly excluding checks of the pairs that are far apart.
- Usage:

      from quadriga_lib import RTtools

      aabb = RTtools.triangle_mesh_aabb( triangle_mesh, sub_mesh_index, vec_size );

- Input Arguments:
  - `triangle_mesh`
    Vertices of the triangle mesh in global Cartesian coordinates. Each face is described by 3 points in 3D-space: `[ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ]`; Size: `[ n_triangles, 9 ]`
  - `sub_mesh_index` (optional)
    Start indices of the sub-meshes in 0-based notation. If this parameter is not given, the AABB of the entire triangle mesh is returned. Vector of length `[ n_sub_mesh ]`
  - `vec_size` (optional)
    Vector size for SIMD processing (e.g. 8 for AVX2, 32 for CUDA). Default value = 1. For values > 1, the number of rows in the output is increased to a multiple of `vec_size`, padded with zeros.
- Output Argument:
  - `aabb`
    Axis-aligned bounding box of each sub-mesh. Each box is described by 6 values: `[ x_min, x_max, y_min, y_max, z_min, z_max ]`; Size: `[ n_sub_mesh, 6 ]`
- See also:
  - triangle_mesh_segmentation (for calculating sub-meshes)

triangle_mesh_segmentation - Rearranges elements of a triangle mesh into smaller sub-meshes

- Description:
  This function processes the elements of a large triangle mesh by clustering those that are closely spaced. The resulting mesh retains the same elements but rearranges their order. The function aims to minimize the size of the axis-aligned bounding box around each cluster, referred to as a sub-mesh, while striving to maintain a specific number of elements within each cluster.
  This approach is particularly useful in computer graphics and simulation applications where managing computational resources efficiently is crucial. By organizing the mesh elements into compact clusters, the function enhances rendering performance and accelerates computational tasks, such as collision detection and physics simulations. It allows for quicker processing and reduced memory usage, making it an essential technique in both real-time graphics rendering and complex simulation environments.
- Usage:

      from quadriga_lib import RTtools

      # Output as tuple
      data = RTtools.triangle_mesh_segmentation( triangles, target_size, vec_size, mtl_prop )

      # Unpacked outputs
      triangles_out, sub_mesh_index, mesh_index, mtl_prop_out = RTtools.triangle_mesh_segmentation( triangles, target_size, vec_size, mtl_prop )

- Input Arguments:
  - `triangles`
    Vertices of the triangular mesh in global Cartesian coordinates. Each face is described by 3 points in 3D-space: `[ v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z ]`; Size: `[ n_triangles, 9 ]`
  - `target_size` (optional)
    The target number of elements of each sub-mesh. Default value = 1024. For best performance, the value should be around `sgrt( n_triangles )`
  - `vec_size` (optional)
    Vector size for SIMD processing (e.g. 8 for AVX2, 32 for CUDA). Default value = 1. For values > 1,the number of rows for each sub-mesh in the output is increased to a multiple of `vec_size`. For padding, zero-sized triangles are placed at the center of the AABB of the corresponding sub-mesh.
  - `mtl_prop_in` (optional)
    Material properties of each mesh element; Size: `[ n_triangles, 5 ]` If this is not provided, the corresponding `mtl_prop_out` will be empty.
- Output Arguments:
  - `triangles_out`, `data[0]`
    Vertices of the clustered mesh in global Cartesian coordinates; Size: `[ n_triangles_out, 9 ]`
  - `sub_mesh_index`, `data[1]`
    Start indices of the sub-meshes in 0-based notation. Type: int; Vector of length `[ n_sub_mesh ]`
  - `mesh_index`, `data[2]`
    Indices for mapping elements of "triangles_in" to "triangles_out"; 1-based; Length: `[ n_triangles_out ]`; For `vec_size > 1`, the added elements not contained in the input are indicated by zeros.
  - `mtl_prop_out`, `data[3]`
    Material properties for the sub-divided triangle mesh elements. The values for the new faces are copied from `mtl_prop_in`; Size: `[ n_triangles_out, 5 ]`; For `vec_size > 1`, the added elements will contain the vacuum / air material.