# SPDX-License-Identifier: MIT
# Copyright Contributors to the THzTools project.
r"""
Data analysis tools for terahertz time-domain spectroscopy.
Classes:
FitResult: Dataclass for the output of `fit`.
NoiseModel: Dataclass to describe the time-domain noise model.
NoiseResult: Dataclass for the output of `noisefit`.
Functions:
apply_frf: Apply a frequency response function to a waveform.
fit: Fit a parameterized frequency response function to time-domain data.
noisefit: Estimate noise model from a set of nominally identical waveforms.
scaleshift: Rescale and shift waveforms.
timebase: Timebase for time-domain waveforms.
wave: Simulate a terahertz waveform.
fft: Apply a fourier transform and windowing function.
Other Functions:
get_option: Get global option.
reset_option: Reset global option.
set_option: Set global option.
Example:
Create an ideal waveform and apply a frequency response function to it.
>>> import numpy as np
>>> import thztools as thz
>>> # Set the waveform parameters
>>> n = 256 # Number of samples
>>> dt = 0.05 # Sampling time [ps]
>>> a = 0.5 # Scale factor
>>> eta = 1.0 # Delay [ps]
>>> # Simulate the waveform
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> # Define a frequency response function
>>> def frfun(omega, _a, _eta):
... return _a * np.exp(-1j * omega * _eta)
>>> # Apply the frequency response function to the waveform
>>> psi = thz.apply_frf(frfun, mu, dt=dt, args=(a, eta))
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
import numdifftools as nd
import numpy as np
from numpy import pi
from numpy.random import default_rng
from scipy.fft import irfft, rfft, rfftfreq
from scipy.linalg import circulant, inv, svd
from scipy.optimize import (
OptimizeResult,
approx_fprime,
least_squares,
minimize,
)
from scipy.signal import unit_impulse
from scipy.signal.windows import __all__ as windowlist
from scipy.signal.windows import get_window, tukey
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Concatenate, Literal
from numpy.typing import ArrayLike, NDArray
NUM_NOISE_PARAMETERS = 3
NUM_NOISE_DATA_DIMENSIONS = 2
TWO_REAL_FREQUENCIES = 2
NUM_BOUNDS_PARAMETERS = 2
@dataclass
class GlobalOptions:
r"""
Class for storing global options.
Attributes
----------
sampling_time : float | None, optional
Global sampling time, normally in picoseconds. When set to None, the
default, times and frequencies are treated as dimensionless quantities
that are scaled by the (undetermined) sampling time.
workers : int | None, optional
Maximum number of workers to use for parallel computation of FFTs. When
set to None, the default, this number is equal to ``os.cpu_count()``.
When negative, this number wraps around from ``os.cpu_count()``.
"""
sampling_time: float | None = None
workers: int | None = None
#: Instance of ``GlobalOptions`` that stores global options.
options = GlobalOptions()
[docs]
def set_option(key: str, value: Any) -> None:
r"""
Set global option.
Parameters
----------
key : str
Option name.
value : object
Option value.
Returns
-------
None
No return value.
Notes
-----
Available options, with descriptions:
sampling_time : float | None
Global sampling time, normally in picoseconds. When set to ``None``,
the default, times and frequencies are treated as dimensionless
quantities that are scaled by the (undetermined) sampling time.
Examples
--------
The global ``sampling_time`` option is initialized to ``None`` at startup.
>>> import thztools as thz
>>> thz.get_option("sampling_time")
Use the :func:`set_option` function to set the global sampling time to the
preferred value.
>>> thz.set_option("sampling_time", 0.05)
>>> thz.get_option("sampling_time")
0.05
"""
setattr(options, key, value)
[docs]
def get_option(key: str) -> Any:
r"""
Get global option.
Parameters
----------
key : str
Option name.
Returns
-------
val : object
Option value.
Notes
-----
Available options, with descriptions:
sampling_time : float | None
Global sampling time, normally in picoseconds. When set to ``None``,
the default, times and frequencies are treated as dimensionless
quantities that are scaled by the (undetermined) sampling time.
Examples
--------
The global ``sampling_time`` option is initialized to ``None`` at startup.
>>> import thztools as thz
>>> thz.get_option("sampling_time")
Set the global sampling time to a preferred value.
>>> thz.set_option("sampling_time", 0.05)
>>> thz.get_option("sampling_time")
0.05
"""
return getattr(options, key)
[docs]
def reset_option(key: str | None = None) -> None:
r"""
Reset one or more global options to default values.
Parameters
----------
key : str, optional
Option name. When ``None``, the default, resets all options.
Returns
-------
None
No return value.
Notes
-----
Available options, with descriptions:
sampling_time : float | None
Global sampling time, normally in picoseconds. When set to ``None``,
the default, times and frequencies are treated as dimensionless
quantities that are scaled by the (undetermined) sampling time.
Examples
--------
The global ``sampling_time`` option is initialized to ``None`` at startup.
>>> import thztools as thz
>>> thz.get_option("sampling_time")
Use the :func:`set_option` function to change the value from the default.
>>> thz.set_option("sampling_time", 0.05)
>>> thz.get_option("sampling_time")
0.05
Reset all options to default values.
>>> thz.reset_option()
>>> thz.get_option("sampling_time")
"""
default_options = GlobalOptions()
if key is not None:
val = getattr(default_options, key)
set_option(key, val)
else:
for local_key in GlobalOptions.__dataclass_fields__:
local_val = getattr(default_options, local_key)
set_option(local_key, local_val)
def _assign_sampling_time(dt: float | None) -> float:
dt_out = 1.0
if dt is None and get_option("sampling_time") is not None:
dt_out = get_option("sampling_time")
elif dt is not None and get_option("sampling_time") is None:
dt_out = dt
elif dt is not None and get_option("sampling_time") is not None:
if np.isclose(dt, get_option("sampling_time")):
dt_out = dt
else:
opt_sampling_time = get_option("sampling_time")
msg = (
f"Input sampling time {dt=} conflicts with "
f"{opt_sampling_time=}, using {dt=}"
)
warnings.warn(msg, category=UserWarning, stacklevel=2)
dt_out = dt
return dt_out
def _assign_workers(workers: int | None) -> int:
workers_out = -1
if workers is None and get_option("workers") is not None:
workers_out = get_option("workers")
elif workers is not None and get_option("workers") is None:
workers_out = workers
elif workers is not None and get_option("workers") is not None:
if workers == get_option("workers") or (
workers == -1 and get_option("workers") is None
):
workers_out = workers
else:
opt_workers = get_option("workers")
msg = (
f"Input workers {workers=} conflicts with "
f"{opt_workers=}, using {workers=}"
)
warnings.warn(msg, category=UserWarning, stacklevel=2)
workers_out = workers
return workers_out
[docs]
@dataclass
class NoiseModel:
r"""
Dataclass to describe the time-domain noise model.
For noise parameters :math:`\sigma_\alpha`, :math:`\sigma_\beta`,
:math:`\sigma_\tau` and signal vector :math:`\boldsymbol{\mu}`, the
:math:`k`-th element of the time-domain noise variance vector
:math:`\boldsymbol{\sigma}^2` is given by [1]_
.. math:: \sigma_k^2 = \sigma_\alpha^2 + \sigma_\beta^2\mu_k^2 \
+ \sigma_\tau^2(\mathbf{D}\boldsymbol{\mu})_k^2,
where :math:`\mathbf{D}` is the time-domain derivative operator.
Parameters
----------
sigma_alpha : float
Additive noise amplitude.
sigma_beta : float
Multiplicative noise amplitude.
sigma_tau : float
Timebase noise amplitude.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
:attr:`dt` and ``thztools.get_option("sampling_time")`` are ``None``,
the :attr:`sigma_tau` parameter is given in units of the sampling time.
Attributes
----------
sigma_alpha : float
Additive noise amplitude.
sigma_beta : float
Multiplicative noise amplitude.
sigma_tau : float
Timebase noise amplitude.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
:attr:`dt` and ``thztools.get_option("sampling_time")`` are ``None``,
the :attr:`sigma_tau` parameter is given in units of the sampling time.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the :attr:`dt`
parameter are both not ``None`` and are set to different ``float``
values, the function will set the sampling time to :attr:`dt` and raise
a :class:`UserWarning`.
See Also
--------
noisefit : Estimate noise model parameters.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
The following example shows the noise variance :math:`\sigma^2(t)` for
noise parameters :math:`\sigma_\alpha = 10^{-4}`,
:math:`\sigma_\beta = 10^{-2}`, :math:`\sigma_\tau = 10^{-3}` and the
simulated signal :math:`\mu(t)`. The signal amplitude is normalized to
its peak magnitude, :math:`\mu_0`. The noise variance is normalized to
:math:`(\sigma_\beta\mu_0)^2`.
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> noise_variance = noise_model.noise_var(mu)
>>> _, axs = plt.subplots(2, 1, sharex=True, layout="constrained")
>>> axs[0].plot(t, noise_variance / beta**2)
>>> axs[0].set_ylabel(r"$\sigma^2/(\sigma_\beta\mu_0)^2$")
>>> axs[1].plot(t, mu)
>>> axs[1].set_ylabel(r"$\mu/\mu_0$")
>>> axs[1].set_xlabel("t (ps)")
>>> plt.show()
"""
sigma_alpha: float
sigma_beta: float
sigma_tau: float
dt: float | None = None
# noinspection PyShadowingNames
[docs]
def noise_var(
self, x: ArrayLike, *, axis: int = -1
) -> NDArray[np.float64]:
r"""
Compute the time-domain noise variance.
Parameters
----------
x : array_like
Time-domain signal array.
axis : int, optional
Signal axis. Default is the last axis in ``x``.
Returns
-------
noise_variance : ndarray
Time-domain noise variance, in signal units (squared).
Notes
-----
For noise parameters :math:`\sigma_\alpha`, :math:`\sigma_\beta`,
:math:`\sigma_\tau` and signal vector :math:`\boldsymbol{\mu}`, the
:math:`k`-th element of the time-domain noise variance
:math:`\boldsymbol{\sigma}^2` is given by [1]_
.. math:: \sigma_k^2 = \sigma_\alpha^2 + \sigma_\beta^2\mu_k^2 \
+ \sigma_\tau^2(\mathbf{D}\boldsymbol{\mu})_k^2,
where :math:`\mathbf{D}` is the time-domain derivative operator.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
The following example shows the noise variance :math:`\sigma^2(t)` for
noise parameters :math:`\sigma_\alpha = 10^{-4}`,
:math:`\sigma_\beta = 10^{-2}`, :math:`\sigma_\tau = 10^{-3}` and the
simulated signal :math:`\mu(t)`. The signal amplitude is normalized to
its peak magnitude, :math:`\mu_0`. The noise variance is normalized to
:math:`(\sigma_\beta\mu_0)^2`.
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> noise_variance = noise_model.noise_var(mu)
>>> _, axs = plt.subplots(2, 1, sharex=True, layout="constrained")
>>> axs[0].plot(t, noise_variance / beta**2)
>>> axs[0].set_ylabel(r"$\sigma^2/(\sigma_\beta\mu_0)^2$")
>>> axs[1].plot(t, mu)
>>> axs[1].set_ylabel(r"$\mu/\mu_0$")
>>> axs[1].set_xlabel("t (ps)")
>>> plt.show()
"""
dt = _assign_sampling_time(self.dt)
x = np.asarray(x, dtype=np.float64)
if x.ndim > 1 and axis != -1:
x = np.moveaxis(x, axis, -1)
n = x.shape[-1]
w_scaled = 2 * pi * rfftfreq(n)
xdot = irfft(1j * w_scaled * rfft(x), n=n) / dt
noise_variance = np.array(
self.sigma_alpha**2
+ (self.sigma_beta * x) ** 2
+ (self.sigma_tau * xdot) ** 2
)
if x.ndim > 1 and axis != -1:
noise_variance = np.moveaxis(noise_variance, -1, axis)
return noise_variance
# noinspection PyShadowingNames
[docs]
def noise_amp(
self, x: ArrayLike, *, axis: int = -1
) -> NDArray[np.float64]:
r"""
Compute the time-domain noise amplitude.
Parameters
----------
x : array_like
Time-domain signal array.
axis : int, optional
Signal axis. Default is the last axis in ``x``.
Returns
-------
noise_amplitude : ndarray
Time-domain noise amplitude, in signal units.
Notes
-----
For noise parameters :math:`\sigma_\alpha`, :math:`\sigma_\beta`,
:math:`\sigma_\tau` and signal vector :math:`\boldsymbol{\mu}`, the
:math:`k`-th element of the time-domain noise amplitude vector
:math:`\boldsymbol{\sigma}` is given by [1]_
.. math:: \sigma_k = \sqrt{\sigma_\alpha^2 + \sigma_\beta^2\mu_k^2 \
+ \sigma_\tau^2(\mathbf{D}\boldsymbol{\mu})_k^2},
where :math:`\mathbf{D}` is the time-domain derivative operator.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
The following example shows the noise amplitude :math:`\sigma(t)` for
noise parameters :math:`\sigma_\alpha = 10^{-4}`,
:math:`\sigma_\beta = 10^{-2}`, :math:`\sigma_\tau = 10^{-3}` and the
simulated signal :math:`\mu(t)`. The signal amplitude is normalized to
its peak magnitude, :math:`\mu_0`. The noise amplitude is normalized to
:math:`\sigma_\beta\mu_0`.
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> noise_amplitude = noise_model.noise_amp(mu)
>>> _, axs = plt.subplots(2, 1, sharex=True, layout="constrained")
>>> axs[0].plot(t, noise_amplitude / beta)
>>> axs[0].set_ylabel(r"$\sigma/(\sigma_\beta\mu_0)$")
>>> axs[1].plot(t, mu)
>>> axs[1].set_ylabel(r"$\mu/\mu_0$")
>>> axs[1].set_xlabel("t (ps)")
>>> plt.show()
"""
return np.sqrt(self.noise_var(x, axis=axis))
# noinspection PyShadowingNames
[docs]
def noise_sim(
self,
x: ArrayLike,
*,
axis: int = -1,
seed: int | None = None,
) -> NDArray[np.float64]:
r"""
Simulate time-domain noise.
Parameters
----------
x : array_like
Time-domain signal array.
axis : int, optional
Signal axis. Default is the last axis in ``x``.
seed : int or None, optional
Random number generator seed.
Returns
-------
noise_simulation : ndarray
Simulated time-domain noise, in signal units.
Notes
-----
For noise parameters :math:`\sigma_\alpha`, :math:`\sigma_\beta`,
:math:`\sigma_\tau` and signal vector :math:`\boldsymbol{\mu}`, the
:math:`k`-th element of the time-domain noise amplitude vector
:math:`\boldsymbol{\sigma}` is given by [1]_
.. math:: \sigma_k = \sqrt{\sigma_\alpha^2 + \sigma_\beta^2\mu_k^2 \
+ \sigma_\tau^2(\mathbf{D}\boldsymbol{\mu})_k^2},
where :math:`\mathbf{D}` is the time-domain derivative operator.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
The following example shows a noise sample
:math:`\sigma_\mu(t_k)\epsilon(t_k)` for noise parameters
:math:`\sigma_\alpha = 10^{-4}`, :math:`\sigma_\beta = 10^{-2}`,
:math:`\sigma_\tau = 10^{-3}` and the simulated signal :math:`\mu(t)`.
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> noise = noise_model.noise_sim(mu, seed=1234)
>>> _, axs = plt.subplots(2, 1, sharex=True, layout="constrained")
>>> axs[0].plot(t, noise / beta)
>>> axs[0].set_ylabel(r"$\sigma_\mu\epsilon/(\sigma_\beta\mu_0)$")
>>> axs[1].plot(t, mu)
>>> axs[1].set_ylabel(r"$\mu/\mu_0$")
>>> axs[1].set_xlabel("t (ps)")
>>> plt.show()
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim > 1 and axis != -1:
x = np.moveaxis(x, axis, -1)
amp = self.noise_amp(x)
rng = default_rng(seed)
noise = amp * rng.standard_normal(size=x.shape)
if x.ndim > 1 and axis != -1:
noise = np.moveaxis(noise, -1, axis)
return noise
# noinspection PyShadowingNames
[docs]
@dataclass
class NoiseResult:
r"""
Dataclass for the output of :func:`noisefit`.
Parameters
----------
noise_model : NoiseModel
Noise parameters, represented as a :class:`NoiseModel` object.
mu : ndarray, shape (n,)
Signal vector.
a : ndarray, shape (m,)
Signal amplitude drift vector.
eta : ndarray, shape (m,)
Signal delay drift vector.
fval : float
Value of optimized NLL cost function.
hess_inv : ndarray
Inverse Hessian matrix of optimized NLL cost function.
err_sigma_alpha, err_sigma_beta, err_sigma_tau : float
Estimated uncertainty in the noise model parameters. Set equal to 0.0
when the parameter is fixed.
err_mu : ndarray
Estimated uncertainty in ``mu``.
err_a : ndarray
Estimated uncertainty in ``a``.
err_eta : ndarray
Estimated uncertainty in ``eta``.
diagnostic : scipy.optimize.OptimizeResult
Instance of :class:`scipy.optimize.OptimizeResult` returned by
:func:`scipy.optimize.minimize`. Note that the attributes ``fun``,
``jac``, and ``hess_inv`` represent functions over the internally
scaled parameters.
Attributes
----------
noise_model : NoiseModel
Noise parameters, represented as a :class:`NoiseModel` object.
mu : ndarray, shape (n,)
Signal vector.
a : ndarray, shape (m,)
Signal amplitude drift vector.
eta : ndarray, shape (m,)
Signal delay drift vector.
fval : float
Value of optimized NLL cost function.
hess_inv : ndarray
Inverse Hessian matrix of optimized NLL cost function.
err_sigma_alpha, err_sigma_beta, err_sigma_tau : float
Estimated uncertainty in the noise model parameters. Set equal to 0.0
when the parameter is fixed.
err_mu : ndarray
Estimated uncertainty in ``mu``.
err_a : ndarray
Estimated uncertainty in ``a``.
err_eta : ndarray
Estimated uncertainty in ``eta``.
diagnostic : scipy.optimize.OptimizeResult
Instance of :class:`scipy.optimize.OptimizeResult` returned by
:func:`scipy.optimize.minimize`. Note that the attributes ``fun``,
``jac``, and ``hess_inv`` represent functions over the internally
scaled parameters.
See Also
--------
NoiseModel : Noise model class.
noisefit : Estimate noise model parameters.
"""
noise_model: NoiseModel
mu: NDArray[np.float64]
a: NDArray[np.float64]
eta: NDArray[np.float64]
fval: float
hess_inv: NDArray[np.float64]
err_sigma_alpha: float
err_sigma_beta: float
err_sigma_tau: float
err_mu: NDArray[np.float64]
err_a: NDArray[np.float64]
err_eta: NDArray[np.float64]
diagnostic: OptimizeResult
# noinspection PyShadowingNames
[docs]
def apply_frf(
frfun: Callable[
Concatenate[np.ndarray[tuple[int], np.dtype[np.float64]], ...],
NDArray[np.complex128],
],
x: ArrayLike,
*,
dt: float | None = None,
numpy_sign_convention: bool = True,
args: ArrayLike = (),
) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
r"""
Apply a frequency response function to a waveform.
Parameters
----------
frfun : callable
Frequency response function.
``frfun(omega, *args) -> ndarray``
where ``omega`` is an array of angular frequencies and ``args`` is a
tuple of the fixed parameters needed to completely specify
the function. The units of ``omega`` must be the inverse of the units
of ``dt``, such as radians/picosecond.
x : array_like
Data array.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to ``1.0``. In this case, the angular frequency
``omega`` must be given in units of radians per sampling time, and any
parameters in ``args`` must be expressed with the sampling time as the
unit of time.
numpy_sign_convention : bool, optional
Adopt NumPy sign convention for harmonic time dependence, e.g., express
a harmonic function with frequency :math:`\omega` as
:math:`x(t) = a e^{i\omega t}`. Default is ``True``. When set to
``False``, uses the convention more common in physics,
:math:`x(t) = a e^{-i\omega t}`.
args : array_like, optional
Extra arguments passed to the frequency response function. All elements
must be real quantities.
Returns
-------
y : ndarray
Result of applying the frequency response function to ``x``.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
See Also
--------
fit : Fit a frequency response function to time-domain data.
Notes
-----
The output waveform is computed by transforming :math:`x[n]` into the
frequency domain, multiplying by the frequency response function
:math:`H[n]`, then transforming back into the time domain.
.. math:: y[n] = \mathcal{F}^{-1}\{H[n] \mathcal{F}\{x[n]\}\}
Examples
--------
Apply a frequency response function that rescales the input by :math:`a`
and shifts it by :math:`\tau`.
.. math:: H(\omega) = a\exp(-i\omega\tau).
Note that this form assumes the :math:`e^{+i\omega t}` representation of
harmonic time dependence, which corresponds to the default setting
``numpy_sign_convention=True``.
>>> import numpy as np
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> x = thz.wave(n, dt=dt)
>>> def shiftscale(_w, _a, _tau):
... return _a * np.exp(-1j * _w * _tau)
>>>
>>> y = thz.apply_frf(
... shiftscale, x, dt=dt, numpy_sign_convention=True, args=(0.5, 1)
... )
>>> _, ax = plt.subplots()
>>>
>>> ax.plot(t, x, label="x")
>>> ax.plot(t, y, label="y")
>>> ax.legend()
>>> ax.set_xlabel("t (ps)")
>>> ax.set_ylabel("Amplitude (arb. units)")
>>> plt.show()
If the frequency response function is expressed using the
:math:`e^{-i\omega t}` representation more common in physics,
.. math:: H(\omega) = a\exp(i\omega\tau),
set ``numpy_sign_convention=False``.
>>> def shiftscale_phys(_w, _a, _tau):
... return _a * np.exp(1j * _w * _tau)
>>>
>>> y_p = thz.apply_frf(
... shiftscale_phys, x, dt=dt, numpy_sign_convention=False, args=(0.5, 1)
... )
>>> _, ax = plt.subplots()
>>>
>>> ax.plot(t, x, label="x")
>>> ax.plot(t, y_p, label="y")
>>>
>>> ax.legend()
>>> ax.set_xlabel("t (ps)")
>>> ax.set_ylabel("Amplitude (arb. units)")
>>>
>>> plt.show()
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim != 1:
msg = "x must be a one-dimensional array"
raise ValueError(msg)
args = np.atleast_1d(args)
dt = _assign_sampling_time(dt)
n = x.size
w_scaled = 2 * pi * rfftfreq(n)
h = frfun(w_scaled / dt, *args)
if numpy_sign_convention:
y = irfft(rfft(x) * h, n=n)
else:
y = irfft(rfft(x) * np.conj(h), n=n)
if TYPE_CHECKING:
y = cast("np.ndarray[tuple[int], np.dtype[np.float64]]", y)
return y
# noinspection PyShadowingNames
[docs]
def timebase(
n: int, *, dt: float | None = None, t_init: float = 0.0
) -> NDArray[np.float64]:
r"""
Timebase for time-domain waveforms.
Parameters
----------
n : int
Number of samples.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to 1.0.
t_init : float, optional
Value of the initial time point. Default is ``0.0``.
Returns
-------
t : ndarray
Array of time samples.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
Notes
-----
This is a helper function that computes
``t = t_init + dt * numpy.arange(n)``.
Examples
--------
The following example computes the timebase with different methods for
assigning the sampling time.
>>> import thztools as thz
>>> n, dt, t_init = 256, 0.05, 2.5
>>> t_1 = thz.timebase(n)
>>> t_2 = thz.timebase(n, dt=dt)
>>> thz.set_option("sampling_time", dt)
>>> t_3 = thz.timebase(n)
>>> t_4 = thz.timebase(n, t_init=t_init)
>>> print(t_1[:3])
[0. 1. 2.]
>>> print(t_2[:3])
[0. 0.05 0.1 ]
>>> print(t_3[:3])
[0. 0.05 0.1 ]
>>> print(t_4[:3])
[2.5 2.55 2.6 ]
"""
dt = _assign_sampling_time(dt)
return np.asarray(t_init + dt * np.arange(n), dtype=np.float64)
[docs]
def wave(
n: int,
*,
dt: float | None = None,
t0: float | None = None,
a: float = 1.0,
taur: float | None = None,
tauc: float | None = None,
fwhm: float | None = None,
) -> NDArray[np.float64]:
r"""
Simulate a terahertz waveform.
Parameters
----------
n : int
Number of samples.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to 1.0.
t0 : float or None, optional
Pulse location, normally in picoseconds. Default is ``0.3 * n * dt``.
a : float, optional
Peak amplitude. The default is one.
taur, tauc, fwhm : float or None, optional
Current pulse rise time, current pulse decay time, and laser pulse
FWHM, respectively. The defaults are ``6.0 * dt``, ``2.0 * dt``, and
``1.0 * dt``, respectively.
Returns
-------
x : ndarray
Signal array.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
Notes
-----
This function uses a simplified model for terahertz generation from a
photoconducting switch [1]_. The impulse response of the switch is a
current pulse with an exponential rise time :math:`\tau_r` and an
exponential decay (capture) time, :math:`\tau_c`,
.. math:: I(t) \propto (1 - e^{-t/\tau_r})e^{-t/\tau_c},
which is convolved with a Gaussian laser pulse with a full-width,
half-maximum pulsewidth of ``fwhm``.
References
----------
.. [1] D. Grischkowsky and N. Katzenellenbogen, "Femtosecond Pulses of THz
Radiation: Physics and Applications," in Picosecond Electronics and
Optoelectronics, Technical Digest Series (Optica Publishing Group,
1991), paper WA2,
`<https://doi.org/10.1364/PEO.1991.WA2>`_.
Examples
--------
The following example shows the simulated signal :math:`\mu(t)` normalized
to its peak magnitude, :math:`\mu_0`.
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> _, ax = plt.subplots(layout="constrained")
>>> ax.plot(t, mu)
>>> ax.set_xlabel("t (ps)")
>>> ax.set_ylabel(r"$\mu/\mu_0$")
>>> plt.show()
"""
dt = _assign_sampling_time(dt)
if t0 is None:
t0 = 0.3 * n * dt
if taur is None:
taur = 6.0 * dt
if tauc is None:
tauc = 2.0 * dt
if fwhm is None:
fwhm = dt
taul = fwhm / np.sqrt(2 * np.log(2))
f_scaled = rfftfreq(n)
w = 2 * pi * f_scaled / dt
ell = np.exp(-((w * taul) ** 2) / 2) / np.sqrt(2 * pi * taul**2)
r = 1 / (1 / taur - 1j * w) - 1 / (1 / taur + 1 / tauc - 1j * w)
s = -1j * w * (ell * r) ** 2 * np.exp(1j * w * t0)
x_unscaled = irfft(np.conj(s), n=n)
return np.asarray(a * x_unscaled / np.max(x_unscaled), dtype=np.float64)
# noinspection PyShadowingNames
[docs]
def scaleshift(
x: ArrayLike,
*,
dt: float | None = None,
a: ArrayLike | None = None,
eta: ArrayLike | None = None,
axis: int = -1,
) -> NDArray[np.float64]:
r"""
Rescale and shift waveforms.
Parameters
----------
x : array_like
Data array.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to ``1.0``. In this case, ``eta`` must be given in
units of the sampling time.
a : array_like, optional
Scale array.
eta : array_like, optional
Shift array.
axis : int, optional
Axis over which to apply the correction. If not given, applies over the
last axis in ``x``.
Returns
-------
x_adj : ndarray
Adjusted data array.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
Examples
--------
The following example makes an array with 4 identical copies of the
signal ``mu`` returned by :func:`wave`. It then uses :func:`scaleshift` to
rescale each copy by ``a = [1.0, 0.5, 0.25, 0.125]`` and shift it by
``eta = [0.0, 1.0, 2.0, 3.0]``.
>>> import numpy as np
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> m = 4
>>> x = np.repeat(np.atleast_2d(mu), m, axis=0)
>>> a = 0.5 ** np.arange(m)
>>> eta = np.arange(m)
>>> x_adj = thz.scaleshift(x, a=a, eta=eta, dt=dt)
>>> plt.plot(t, x_adj.T, label=[f"{k=}" for k in range(4)])
>>> plt.legend()
>>> plt.xlabel("t (ps)")
>>> plt.ylabel(r"$x_{\mathrm{adj}, k}$")
>>> plt.show()
"""
x = np.asarray(x, dtype=np.float64)
if x.size == 0:
return np.empty(x.shape)
dt = _assign_sampling_time(dt)
if x.ndim > 1 and axis != -1:
x = np.moveaxis(x, axis, -1)
n = x.shape[-1]
m = x.shape[:-1]
if a is None:
a = np.ones(m)
else:
a = np.asarray(a, dtype=np.float64)
if a.shape != m:
msg = (
f"Scale correction with shape {a.shape} can not be applied "
f"to data with shape {x.shape}"
)
raise ValueError(msg)
if eta is None:
eta = np.zeros(m)
else:
eta = np.asarray(eta, dtype=np.float64)
if eta.shape != m:
msg = (
f"Shift correction with shape {a.shape} can not be applied "
f"to data with shape {x.shape}"
)
raise ValueError(msg)
f_scaled = rfftfreq(n)
w = 2 * pi * f_scaled / dt
phase = np.expand_dims(eta, axis=eta.ndim) * w
x_adjusted = irfft(rfft(x) * np.exp(-1j * phase), n=n) * np.expand_dims(
a, axis=a.ndim
)
if x.ndim > 1 and axis != -1:
x_adjusted = np.moveaxis(x_adjusted, -1, axis)
return np.astype(x_adjusted, np.float64)
@dataclass
class CommonNLL:
r"""
Dataclass for passing intermediate cost function variables.
The functions `_nll_noisefit`, `_jac_noisefit`, and `_hess_noisefit`
involve computations with a common set of array variables for a given
input. The `_nll_common` function encapsulates this computation and
uses the `CommonNLL` dataclass to organize the output. A future release may
implement caching to improve speed.
Attributes
----------
ressq: ndarray with shape (m, n)
Square of residuals, equal to (x - zeta)**2.
vtot: ndarray with shape (m, n)
Estimate for time-domain variance, corrected for drift in amplitude
and time delay.
zeta: ndarray with shape (m, n)
Time-domain waveform estimates, corrected for drift in amplitude and
time delay.
dzeta: ndarray with shape (m, n)
Time-domain derivative of zeta.
a: ndarray with shape (m,)
Amplitude drift parameters.
zeta_f: ndarray with shape (m, n_f)
Fourier amplitudes of zeta.
exp_iweta: ndarray with shape (m, n_f)
Frequency response function array used to correct for time-delay drift.
"""
ressq: NDArray[np.float64]
vtot: NDArray[np.float64]
zeta: NDArray[np.float64]
dzeta: NDArray[np.float64]
a: NDArray[np.float64]
zeta_f: NDArray[np.complex128]
exp_iweta: NDArray[np.complex128]
def _nll_common(
x: NDArray[np.float64],
logv_alpha_scaled: float,
logv_beta_scaled: float,
logv_tau_scaled: float,
delta_mu_scaled: NDArray[np.float64],
delta_a_scaled: NDArray[np.float64],
eta_on_dt_scaled: NDArray[np.float64],
*,
est_mu: bool,
scale_logv_alpha: float,
scale_logv_beta: float,
scale_logv_tau: float,
scale_delta_mu: NDArray[np.float64],
scale_delta_a: NDArray[np.float64],
scale_eta: NDArray[np.float64],
workers: int,
) -> CommonNLL:
_, n = x.shape
valpha = np.exp(logv_alpha_scaled * scale_logv_alpha)
vbeta = np.exp(logv_beta_scaled * scale_logv_beta)
vtau = np.exp(logv_tau_scaled * scale_logv_tau)
a = 1.0 + np.insert(delta_a_scaled * scale_delta_a, 0, 0.0)
eta_on_dt = np.insert(eta_on_dt_scaled * scale_eta, 0, 0.0)
# Compute frequency vector and Fourier coefficients of mu
f = rfftfreq(n)
w = 2 * pi * f
exp_iweta = np.exp(1j * np.outer(eta_on_dt, w))
if est_mu:
scale = np.sum(a**2)
x_f = rfft(x, workers=workers)
mu_f = np.einsum("j,jk,jk->k", a, exp_iweta, x_f) / scale
zeta_f = np.einsum("i,ik,k->ik", a, np.conj(exp_iweta), mu_f)
else:
mu = x[0, :] - delta_mu_scaled * scale_delta_mu
mu_f = rfft(mu, workers=workers)
zeta_f = ((np.conj(exp_iweta) * mu_f).T * a).T
zeta = irfft(zeta_f, n=n, workers=workers)
dzeta = irfft(1j * w * zeta_f, n=n, workers=workers)
res = x - zeta
ressq = res**2
vtot = valpha + vbeta * zeta**2 + vtau * dzeta**2
return CommonNLL(
ressq=ressq,
vtot=vtot,
zeta=zeta,
dzeta=dzeta,
a=a,
zeta_f=zeta_f,
exp_iweta=exp_iweta,
)
def _nll_noisefit(
x: NDArray[np.float64],
logv_alpha_scaled: float,
logv_beta_scaled: float,
logv_tau_scaled: float,
delta_mu_scaled: NDArray[np.float64],
delta_a_scaled: NDArray[np.float64],
eta_on_dt_scaled: NDArray[np.float64],
*,
est_mu: bool,
scale_logv_alpha: float,
scale_logv_beta: float,
scale_logv_tau: float,
scale_delta_mu: NDArray[np.float64],
scale_delta_a: NDArray[np.float64],
scale_eta: NDArray[np.float64],
workers: int,
) -> float:
r"""
Compute the cost function for the time-domain noise model.
Computes the maximum-likelihood cost function for obtaining the
data matrix ``x`` given ``logv_alpha_scaled``, ``logv_beta_scaled``,
``logv_tau_scaled``, ``delta_mu_scaled``, ``delta_a_scaled``,
``eta_on_dt_scaled``, ``scale_logv_alpha``, ``scale_logv_beta``,
``scale_logv_tau``, ``scale_delta_mu``, ``scale_delta_a``, and
``scale_eta``.
Parameters
----------
x : ndarray
Data matrix with shape (m, n), row-oriented.
logv_alpha_scaled, logv_beta_scaled, logv_tau_scaled : float
Logarithm of the associated scaled noise variance parameter.
delta_mu_scaled : ndarray
Scaled signal deviation vector with shape (n,).
delta_a_scaled: ndarray
Scaled amplitude deviation vector with shape (m - 1,).
eta_on_dt_scaled : ndarray
Scaled delay deviation vector with shape (m - 1,).
est_mu : bool
Estimate the underlying waveform using a weighted average of the measured waveforms instead of fitting for it when
``True``.
scale_logv_alpha, scale_logv_beta, scale_logv_tau: float
Scale parameters for log variance parameters.
scale_delta_mu : ndarray
Array of scale parameters for ``delta`` with shape (n,).
scale_delta_a : ndarray
Array of scale parameters for ``alpha`` with shape (m - 1,).
scale_eta : ndarray
Array of scale parameters for ``eta`` with shape (m - 1,).
workers : int
The number of workers to use for parallel computation of FFTs. If
negative, this number wraps around from ``os.cpu_count()``.
Returns
-------
nll : float
Negative log-likelihood, with constant offset :math:`(MN/2)\ln(2\pi)`
subtracted.
"""
common = _nll_common(
x=x,
logv_alpha_scaled=logv_alpha_scaled,
logv_beta_scaled=logv_beta_scaled,
logv_tau_scaled=logv_tau_scaled,
delta_mu_scaled=delta_mu_scaled,
delta_a_scaled=delta_a_scaled,
eta_on_dt_scaled=eta_on_dt_scaled,
est_mu=est_mu,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
ressq = common.ressq
vtot = common.vtot
resnormsq_scaled = ressq / vtot
return np.float64(0.5 * (np.sum(np.log(vtot)) + np.sum(resnormsq_scaled)))
def _jac_noisefit(
x: NDArray[np.float64],
logv_alpha_scaled: float,
logv_beta_scaled: float,
logv_tau_scaled: float,
delta_mu_scaled: NDArray[np.float64],
delta_a_scaled: NDArray[np.float64],
eta_on_dt_scaled: NDArray[np.float64],
*,
fix_logv_alpha: bool,
fix_logv_beta: bool,
fix_logv_tau: bool,
est_mu: bool,
fix_delta_mu: bool,
fix_delta_a: bool,
fix_eta: bool,
scale_logv_alpha: float,
scale_logv_beta: float,
scale_logv_tau: float,
scale_delta_mu: NDArray[np.float64],
scale_delta_a: NDArray[np.float64],
scale_eta: NDArray[np.float64],
workers: int,
) -> NDArray[np.float64]:
r"""
Compute the Jacobian of ``_nll_noisefit`` w.r.t. the free parameters.
Parameters
----------
x : ndarray
Data matrix with shape (m, n), row-oriented.
logv_alpha_scaled, logv_beta_scaled, logv_tau_scaled : float
Logarithm of the associated scaled noise variance parameter.
delta_mu_scaled : ndarray
Scaled signal deviation vector with shape (n,).
delta_a_scaled: ndarray
Scaled amplitude deviation vector with shape (m - 1,).
eta_on_dt_scaled : ndarray
Scaled delay deviation vector with shape (m - 1,).
fix_logv_alpha, fix_logv_beta, fix_logv_tau : bool
Exclude noise parameter from gradiate calculation when ``True``.
est_mu : bool
Estimate the underlying waveform using a weighted average of the measured waveforms instead of fitting for it when
``True``.
fix_delta_mu : bool
Exclude signal deviation vector from gradiate calculation when
``True``.
fix_delta_a : bool
Exclude signal amplitude deviation vector from gradiate calculation
when ``True``.
fix_eta : bool
Exclude signal delay deviation vector from gradiate calculation when
``True``.
scale_logv_alpha, scale_logv_beta, scale_logv_tau: float
Scale parameters for log variance parameters.
scale_delta_mu : ndarray
Array of scale parameters for ``delta`` with shape (n,).
scale_delta_a : ndarray
Array of scale parameters for ``alpha`` with shape (m - 1,).
scale_eta : ndarray
Array of scale parameters for ``eta`` with shape (m - 1,).
workers : int
The number of workers to use for parallel computation of FFTs. If
negative, this number wraps around from ``os.cpu_count()``.
Returns
-------
gradnll_scaled : ndarray
Gradient of the negative log-likelihood function with respect to
the free parameters.
"""
m, n = x.shape
valpha = np.exp(logv_alpha_scaled * scale_logv_alpha)
vbeta = np.exp(logv_beta_scaled * scale_logv_beta)
vtau = np.exp(logv_tau_scaled * scale_logv_tau)
f = rfftfreq(n)
w = 2 * pi * f
common = _nll_common(
x=x,
logv_alpha_scaled=logv_alpha_scaled,
logv_beta_scaled=logv_beta_scaled,
logv_tau_scaled=logv_tau_scaled,
delta_mu_scaled=delta_mu_scaled,
delta_a_scaled=delta_a_scaled,
eta_on_dt_scaled=eta_on_dt_scaled,
est_mu=est_mu,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
ressq = common.ressq
vtot = common.vtot
zeta = common.zeta
dzeta = common.dzeta
zeta_f = common.zeta_f
a = common.a
exp_iweta = common.exp_iweta
# Compute residuals and their squares for subsequent computations
res = x - zeta
reswt = res / vtot
dvar = (vtot - ressq) / vtot**2
# Construct Jacobian subarrays
if fix_logv_alpha:
jac_logv_alpha = []
else:
jac_logv_alpha = [0.5 * np.sum(dvar) * valpha * scale_logv_alpha]
if fix_logv_beta:
jac_logv_beta = []
else:
jac_logv_beta = [
0.5 * np.sum(zeta**2 * dvar) * vbeta * scale_logv_beta
]
if fix_logv_tau:
jac_logv_tau = []
else:
jac_logv_tau = [0.5 * np.sum(dzeta**2 * dvar) * vtau * scale_logv_tau]
if fix_delta_mu or est_mu:
jac_delta_mu = np.array([])
else:
p = rfft(
vbeta * dvar * zeta - reswt, workers=workers
) - 1j * vtau * w * rfft(dvar * dzeta, workers=workers)
jac_delta_mu = (
-np.sum(
(irfft(exp_iweta * p, n=n, workers=workers).T * a).T,
axis=0,
)
* scale_delta_mu
)
if fix_delta_a:
jac_delta_a = np.array([])
else:
if est_mu:
scale = np.sum(a**2)
x_f = rfft(x, workers=workers)
x_back_forth_f = np.einsum(
"ik,jk,jk->ijk", np.conj(exp_iweta), exp_iweta, x_f
)
dzetada_f = (
np.einsum("ij,jn->ijn", np.diag(1 / a), zeta_f)
+ (
np.einsum("i,ijn->ijn", a, x_back_forth_f)
- 2 * np.einsum("j,in->ijn", a, zeta_f)
)
/ scale
)
dzetada = irfft(dzetada_f, n=n, workers=workers)
ddzetada = irfft(1j * w * dzetada_f, n=n, workers=workers)
term = (
np.einsum("ijn,in,in->jn", dzetada, zeta, dvar) * vbeta
+ np.einsum("ijn,in,in->jn", ddzetada, dzeta, dvar) * vtau
) - np.einsum("ijn,in->jn", dzetada, reswt)
else:
term = np.diag(1 / a) @ ((vtot - valpha) * dvar - reswt * zeta)
dnllda = np.sum(term, axis=1)
# Exclude first term, which is held fixed
jac_delta_a = dnllda[1:] * scale_delta_a
if fix_eta:
jac_eta = np.array([])
else:
if est_mu:
scale = np.sum(a**2)
x_f = rfft(x, workers=workers)
x_back_forth_f = np.einsum(
"ik,jk,jk->ijk", np.conj(exp_iweta), exp_iweta, x_f
)
dzetadeta_f = (
1j
* w
* (
-np.einsum("ij,jn->ijn", np.identity(m), zeta_f)
+ np.einsum("ij,ijn->ijn", np.outer(a, a), x_back_forth_f)
/ scale
)
)
dzetadeta = irfft(dzetadeta_f, n, workers=workers)
ddzetadeta = irfft(1j * w * dzetadeta_f, n, workers=workers)
term = (
np.einsum("ijn,in,in->jn", dzetadeta, zeta, dvar) * vbeta
+ np.einsum("ijn,in,in->jn", ddzetadeta, dzeta, dvar) * vtau
) - np.einsum("ijn,in->jn", dzetadeta, reswt)
dnlldeta = np.sum(term, axis=1)
else:
ddzeta = irfft(-(w**2) * zeta_f, n=n, workers=workers)
dnlldeta = -np.sum(
dvar * (zeta * dzeta * vbeta + dzeta * ddzeta * vtau)
- reswt * dzeta,
axis=1,
)
# Exclude first term, which is held fixed
jac_eta = dnlldeta[1:] * scale_eta
# Concatenate subarrays to produce full Jacobian wrt free parameters
return np.concatenate(
(
jac_logv_alpha,
jac_logv_beta,
jac_logv_tau,
jac_delta_mu,
jac_delta_a,
jac_eta,
)
)
def _hess_noisefit(
x: NDArray[np.float64],
logv_alpha_scaled: float,
logv_beta_scaled: float,
logv_tau_scaled: float,
delta_mu_scaled: NDArray[np.float64],
delta_a_scaled: NDArray[np.float64],
eta_on_dt_scaled: NDArray[np.float64],
*,
fix_logv_alpha: bool,
fix_logv_beta: bool,
fix_logv_tau: bool,
est_mu: bool,
fix_delta_mu: bool,
fix_delta_a: bool,
fix_eta: bool,
scale_logv_alpha: float,
scale_logv_beta: float,
scale_logv_tau: float,
scale_delta_mu: NDArray[np.float64],
scale_delta_a: NDArray[np.float64],
scale_eta: NDArray[np.float64],
workers: int,
) -> NDArray[np.float64]:
r"""
Compute the Hessian of ``_nll_noisefit`` w.r.t. the free parameters.
Parameters
----------
x : ndarray
Data matrix with shape (m, n), row-oriented.
logv_alpha_scaled, logv_beta_scaled, logv_tau_scaled : float
Logarithm of the associated scaled noise variance parameter.
delta_mu_scaled : ndarray
Scaled signal deviation vector with shape (n,).
delta_a_scaled: ndarray
Scaled amplitude deviation vector with shape (m - 1,).
eta_on_dt_scaled : ndarray
Scaled delay deviation vector with shape (m - 1,).
fix_logv_alpha, fix_logv_beta, fix_logv_tau : bool
Exclude noise parameter from gradiate calculation when ``True``.
est_mu : bool
Estimate the underlying waveform using a weighted average of the measured waveforms instead of fitting for it when
``True``.
fix_delta_mu : bool
Exclude signal deviation vector from gradiate calculation when
``True``.
fix_delta_a : bool
Exclude signal amplitude deviation vector from gradiate calculation
when ``True``.
fix_eta : bool
Exclude signal delay deviation vector from gradiate calculation when
``True``.
scale_logv_alpha, scale_logv_beta, scale_logv_tau: float
Scale parameters for log variance parameters.
scale_delta_mu : ndarray
Array of scale parameters for ``delta`` with shape (n,).
scale_delta_a : ndarray
Array of scale parameters for ``alpha`` with shape (m - 1,).
scale_eta : ndarray
Array of scale parameters for ``eta`` with shape (m - 1,).
workers : int
The number of workers to use for parallel computation of FFTs. If
negative, this number wraps around from ``os.cpu_count()``.
Returns
-------
gradnll_scaled : ndarray
Gradient of the negative log-likelihood function with respect to
the free parameters.
"""
_, n = x.shape
valpha = np.exp(logv_alpha_scaled * scale_logv_alpha)
vbeta = np.exp(logv_beta_scaled * scale_logv_beta)
vtau = np.exp(logv_tau_scaled * scale_logv_tau)
f = rfftfreq(n)
w = 2 * pi * f
common = _nll_common(
x=x,
logv_alpha_scaled=logv_alpha_scaled,
logv_beta_scaled=logv_beta_scaled,
logv_tau_scaled=logv_tau_scaled,
delta_mu_scaled=delta_mu_scaled,
delta_a_scaled=delta_a_scaled,
eta_on_dt_scaled=eta_on_dt_scaled,
est_mu=est_mu,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
# Compute residuals and their squares for subsequent computations
ressq = common.ressq
vtot = common.vtot
zeta = common.zeta
dzeta = common.dzeta
zeta_f = common.zeta_f
a = common.a
exp_iweta = common.exp_iweta
ddzeta = irfft(-(w**2) * zeta_f, n=n, workers=workers)
dddzeta = irfft(-1j * (w**3) * zeta_f, n=n, workers=workers)
res = x - zeta
dvar = (vtot - ressq) / vtot**2
ddvar = (2 * ressq - vtot) / vtot**3
dzeta_dmu = irfft(
a[:, np.newaxis, np.newaxis]
* np.conj(exp_iweta)[:, np.newaxis, :]
* rfft(np.eye(n), workers=workers)[np.newaxis, :, :],
n=n,
workers=workers,
)
ddzeta_dmu = irfft(
a[:, np.newaxis, np.newaxis]
* 1j
* w
* np.conj(exp_iweta)[:, np.newaxis, :]
* rfft(np.eye(n), workers=workers)[np.newaxis, :, :],
n=n,
workers=workers,
)
dddzeta_dmu = irfft(
a[:, np.newaxis, np.newaxis]
* -(w**2)
* np.conj(exp_iweta)[:, np.newaxis, :]
* rfft(np.eye(n), workers=workers)[np.newaxis, :, :],
n=n,
workers=workers,
)
# Hessian block for (logv, logv)
if fix_logv_alpha:
h_va_va = np.atleast_2d([])
else:
h_va_va = np.atleast_2d(
[0.5 * valpha * np.sum(dvar) + 0.5 * valpha**2 * np.sum(ddvar)]
)
if fix_logv_alpha or fix_logv_beta:
h_va_vb = np.atleast_2d([])
else:
h_va_vb = np.atleast_2d(
[0.5 * valpha * vbeta * np.sum(ddvar * zeta**2)]
)
if fix_logv_alpha or fix_logv_tau:
h_va_vt = np.atleast_2d([])
else:
h_va_vt = np.atleast_2d(
[0.5 * valpha * vtau * np.sum(ddvar * dzeta**2)]
)
if fix_logv_beta:
h_vb_vb = np.atleast_2d([])
else:
h_vb_vb = np.atleast_2d(
[
0.5 * vbeta * np.sum(dvar * zeta**2)
+ 0.5 * vbeta**2 * np.sum(ddvar * zeta**4)
]
)
if fix_logv_beta or fix_logv_tau:
h_vb_vt = np.atleast_2d([])
else:
h_vb_vt = np.atleast_2d(
[0.5 * vbeta * vtau * np.sum(ddvar * zeta**2 * dzeta**2)]
)
if fix_logv_tau:
h_vt_vt = np.atleast_2d([])
else:
h_vt_vt = np.atleast_2d(
[
0.5 * vtau * np.sum(dvar * dzeta**2)
+ 0.5 * vtau**2 * np.sum(ddvar * dzeta**4)
]
)
# Hessian block for (logv, delta_mu)
if fix_logv_alpha or fix_delta_mu:
h_va_mu = np.atleast_2d([])
else:
h_va_mu = np.atleast_2d(
np.einsum(
"jk, jpk",
ddvar * valpha * vbeta * zeta + res * valpha / vtot**2,
dzeta_dmu,
)
+ np.einsum("jk, jpk", ddvar * valpha * vtau * dzeta, ddzeta_dmu)
)
if fix_logv_beta or fix_delta_mu:
h_vb_mu = np.atleast_2d([])
else:
h_vb_mu = np.atleast_2d(
np.einsum(
"jk, jpk",
dvar * vbeta * zeta
+ ddvar * vbeta**2 * zeta**3
+ res * vbeta * zeta**2 / vtot**2,
dzeta_dmu,
)
+ np.einsum(
"jk, jpk", ddvar * vbeta * vtau * zeta**2 * dzeta, ddzeta_dmu
)
)
if fix_logv_tau or fix_delta_mu:
h_vt_mu = np.atleast_2d([])
else:
h_vt_mu = np.atleast_2d(
np.einsum(
"jk, jpk",
ddvar * vbeta * vtau * zeta * dzeta**2
+ res * vtau * dzeta**2 / vtot**2,
dzeta_dmu,
)
+ np.einsum(
"jk, jpk",
dvar * vtau * dzeta + ddvar * vtau**2 * dzeta**3,
ddzeta_dmu,
)
)
# Hessian block for (log_v, delta_a)
if fix_logv_alpha or fix_delta_a:
h_va_a = np.atleast_2d([])
else:
h_va_a = np.atleast_2d(
np.sum(
(
ddvar * valpha * (vbeta * zeta**2 + vtau * dzeta**2)
+ res * valpha * zeta / vtot**2
)[1:, :],
axis=1,
)
/ a[1:]
)
if fix_logv_beta or fix_delta_a:
h_vb_a = np.atleast_2d([])
else:
h_vb_a = np.atleast_2d(
np.sum(
(
dvar * vbeta * zeta**2
+ ddvar
* vbeta
* zeta**2
* (vbeta * zeta**2 + vtau * dzeta**2)
+ res * vbeta * zeta**3 / vtot**2
)[1:, :],
axis=1,
)
/ a[1:]
)
if fix_logv_tau or fix_delta_a:
h_vt_a = np.atleast_2d([])
else:
h_vt_a = np.atleast_2d(
np.sum(
(
dvar * vtau * dzeta**2
+ ddvar
* vtau
* dzeta**2
* (vbeta * zeta**2 + vtau * dzeta**2)
+ res * vtau * zeta * dzeta**2 / vtot**2
)[1:, :],
axis=1,
)
/ a[1:]
)
# Hessian block for (log_v, eta)
if fix_logv_alpha or fix_eta:
h_va_eta = np.atleast_2d([])
else:
h_va_eta = -np.atleast_2d(
np.sum(
ddvar * valpha * (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
+ res * valpha * dzeta / vtot**2,
axis=1,
)[1:]
)
if fix_logv_beta or fix_eta:
h_vb_eta = np.atleast_2d([])
else:
h_vb_eta = -np.atleast_2d(
np.sum(
dvar * vbeta * zeta * dzeta
+ ddvar
* vbeta
* zeta**2
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
+ res * vbeta * zeta**2 * dzeta / vtot**2,
axis=1,
)[1:]
)
if fix_logv_tau or fix_eta:
h_vt_eta = np.atleast_2d([])
else:
h_vt_eta = -np.atleast_2d(
np.sum(
dvar * vtau * dzeta * ddzeta
+ ddvar
* vtau
* dzeta**2
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
+ res * vtau * dzeta**3 / vtot**2,
axis=1,
)[1:]
)
# Hessian block for (delta_mu, delta_mu)
if fix_delta_mu:
h_mu_mu = np.atleast_2d([])
else:
a_array = (
1 / vtot
+ 4 * vbeta * zeta * res / vtot**2
+ vbeta * dvar
+ 2 * vbeta**2 * zeta**2 * ddvar
)
b_array = (
2 * vtau * dzeta * res / vtot**2
+ 2 * vbeta * vtau * zeta * dzeta * ddvar
)
c_array = vtau * dvar + 2 * vtau**2 * dzeta**2 * ddvar
h_mu_mu = np.atleast_2d(
np.einsum("jpk, jk, jqk", dzeta_dmu, a_array, dzeta_dmu)
+ np.einsum("jpk, jk, jqk", dzeta_dmu, b_array, ddzeta_dmu)
+ np.einsum("jpk, jk, jqk", ddzeta_dmu, b_array, dzeta_dmu)
+ np.einsum("jpk, jk, jqk", ddzeta_dmu, c_array, ddzeta_dmu)
)
# Hessian block for (delta_mu, delta_a)
if fix_delta_mu or fix_delta_a:
h_mu_a = np.atleast_2d([])
else:
a_array = (
2 * dvar * vbeta * zeta
+ 2 * ddvar * vbeta * zeta * (vbeta * zeta**2 + vtau * dzeta**2)
+ 2 * res * (vbeta * zeta**2 + vtau * dzeta**2) / vtot**2
+ (zeta - res) / vtot
+ 2 * res * vbeta * zeta**2 / vtot**2
)[1:, :]
b_array = (
2
* vtau
* dzeta
* (
dvar
+ ddvar * (vbeta * zeta**2 + vtau * dzeta**2)
+ res * zeta / vtot**2
)
)[1:, :]
h_mu_a = (
np.einsum("qj, qpj -> pq", a_array, dzeta_dmu[1:, :, :])
+ np.einsum("qj, qpj -> pq", b_array, ddzeta_dmu[1:, :, :])
) / a[np.newaxis, 1:]
# Hessian block for (delta_mu, eta)
if fix_delta_mu or fix_eta:
h_mu_eta = np.atleast_2d([])
else:
a_array = (
dvar * vbeta * dzeta
+ 2
* ddvar
* vbeta
* zeta
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
+ 2
* res
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
/ vtot**2
+ dzeta / vtot
+ 2 * vbeta * res * zeta * dzeta / vtot**2
)[1:, :]
b_array = (
dvar * (vbeta * zeta + vtau * ddzeta)
+ 2
* ddvar
* vtau
* dzeta
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
- res / vtot
+ 2 * vtau * res * dzeta**2 / vtot**2
)[1:, :]
c_array = (dvar * vtau * dzeta)[1:, :]
h_mu_eta = -(
np.einsum("qj, qpj -> pq", a_array, dzeta_dmu[1:, :, :])
+ np.einsum("qj, qpj -> pq", b_array, ddzeta_dmu[1:, :, :])
+ np.einsum("qj, qpj -> pq", c_array, dddzeta_dmu[1:, :, :])
)
# Hessian block for (delta_a, delta_a)
if fix_delta_a:
h_a_a = np.atleast_2d([])
else:
h_a_a = np.diag(
np.sum(
2
* ddvar[1:, :]
* (vbeta * zeta[1:, :] ** 2 + vtau * dzeta[1:, :] ** 2) ** 2
+ dvar[1:, :]
* (vbeta * zeta[1:, :] ** 2 + vtau * dzeta[1:, :] ** 2)
+ 4
* res[1:, :]
* zeta[1:, :]
* (vbeta * zeta[1:, :] ** 2 + vtau * dzeta[1:, :] ** 2)
/ vtot[1:, :] ** 2
+ zeta[1:, :] ** 2 / vtot[1:, :],
axis=1,
)
/ a[1:] ** 2
)
# Hessian block for (delta_a, eta)
if fix_delta_a or fix_eta:
h_a_eta = np.atleast_2d([])
else:
h_a_eta = -np.diag(
np.sum(
2 * dvar * (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
+ 2
* ddvar
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
* (vbeta * zeta**2 + vtau * dzeta**2)
+ 2
* res
* zeta
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
/ vtot**2
+ (zeta - res) * dzeta / vtot
+ 2
* res
* dzeta
* (vbeta * zeta**2 + vtau * dzeta**2)
/ vtot**2,
axis=1,
)[1:]
/ a[1:]
)
# Hessian block for (eta, eta)
if fix_eta:
h_eta_eta = np.atleast_2d([])
else:
h_eta_eta = np.diag(
np.sum(
dvar
* (
vbeta * (dzeta**2 + zeta * ddzeta)
+ vtau * (ddzeta**2 + dzeta * dddzeta)
)
+ 2
* ddvar
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta) ** 2
+ 4
* res
* dzeta
* (vbeta * zeta * dzeta + vtau * dzeta * ddzeta)
/ vtot**2
+ (dzeta**2 - res * ddzeta) / vtot,
axis=1,
)[1:]
)
# Boolean array used to compose full Hessian from multiple blocks
fix = np.array(
[
fix_logv_alpha,
fix_logv_beta,
fix_logv_tau,
fix_delta_mu,
fix_delta_a,
fix_eta,
]
)
# Arrange blocks in an object array to enable boolean indexing
hess_block = np.array(
[
[h_va_va, h_va_vb, h_va_vt, h_va_mu, h_va_a, h_va_eta],
[h_va_vb, h_vb_vb, h_vb_vt, h_vb_mu, h_vb_a, h_vb_eta],
[h_va_vt.T, h_vb_vt.T, h_vt_vt, h_vt_mu, h_vt_a, h_vt_eta],
[h_va_mu.T, h_vb_mu.T, h_vt_mu.T, h_mu_mu, h_mu_a, h_mu_eta],
[h_va_a.T, h_vb_a.T, h_vt_a.T, h_mu_a.T, h_a_a, h_a_eta],
[
h_va_eta.T,
h_vb_eta.T,
h_vt_eta.T,
h_mu_eta.T,
h_a_eta.T,
h_eta_eta,
],
],
dtype=object,
)
# Create an index array to select blocks
idx = np.ix_(~fix, ~fix)
# Compose the full Jacobian from the selected blocks
h = np.block(hess_block[idx].tolist())
# Arrange scale arrays in an object array, then use the fix array to select
# which arrays to include
scale_block = np.array(
[
[scale_logv_alpha],
[scale_logv_beta],
[scale_logv_tau],
-scale_delta_mu,
scale_delta_a,
scale_eta,
],
dtype=object,
)
scale = np.concatenate(scale_block[~fix].tolist())
# Return Hessian in scaled internal variables
return np.astype(np.diag(scale) @ h @ np.diag(scale), np.float64)
[docs]
def noisefit(
x: ArrayLike,
*,
dt: float | None = None,
sigma_alpha0: float | None = None,
sigma_beta0: float | None = None,
sigma_tau0: float | None = None,
mu0: ArrayLike | None = None,
a0: ArrayLike | None = None,
eta0: ArrayLike | None = None,
fix_sigma_alpha: bool = False,
fix_sigma_beta: bool = False,
fix_sigma_tau: bool = False,
est_mu: bool = True,
fix_mu: bool = False,
fix_a: bool = False,
fix_eta: bool = False,
scale_logv_alpha: float | None = None,
scale_logv_beta: float | None = None,
scale_logv_tau: float | None = None,
scale_delta_mu: ArrayLike | None = None,
scale_delta_a: ArrayLike | None = None,
scale_eta: ArrayLike | None = None,
min_options: dict[str, Any] | None = None,
workers: int | None = None,
) -> NoiseResult:
r"""
Estimate noise model from a set of nominally identical waveforms.
The data array ``x`` should include `m` nominally identical waveform
measurements, where each waveform comprises `n` samples that are spaced
equally in time. The ``noise_model`` attribute of the return object
is an instance of the :class:`NoiseModel` class that represents the
estimated noise parameters. See `Notes` for details.
Parameters
----------
x : array_like with shape (n, m)
Data array composed of ``m`` waveforms, each of which is sampled at
``n`` points.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to ``1.0``. In this case, the unit of time is the
sampling time for the noise model parameter ``sigma_tau``, the delay
parameter array ``eta``, and the initial guesses for both quantities.
sigma_alpha0, sigma_beta0, sigma_tau0 : float, optional
Initial values for noise parameters. When set to
``None``, the default, use a linear least-squares fit of the noise
model to ``np.var(x, 1, ddof=1)``.
mu0 : array_like with shape(n,), optional
Initial guess, signal vector with shape (n,). Default is first column
of ``x``.
a0 : array_like with shape(m,), optional
Initial guess, signal amplitude drift vector with shape (m,). Default
is ``np.ones(m)``.
eta0 : array_like with shape(m,), optional
Initial guess, signal delay drift vector with shape (m,). Default is
``np.zeros(m)``.
fix_sigma_alpha, fix_sigma_beta, fix_sigma_tau : bool, optional
Fix the associated noise parameter. Default is False.
est_mu : bool, optional
Estimate signal vector using a weighted average of the data array ``x`` instead of fitting for it. Default is True.
fix_mu : bool, optional
Fix signal vector. Default is False.
fix_a : bool, optional
Fix signal amplitude drift vector. Default is False.
fix_eta : bool, optional
Fix signal delay drift vector. Default is False.
Returns
-------
res : NoiseResult
Fit result represented as a :class:`NoiseResult` object. Important
attributes are: ``noise_model``, an instance of :class:`NoiseModel`
with the estimated noise parameters; ``mu``, the estimated signal
vector; ``a``, the estimated signal amplitude drift vector; ``eta``,
the estimated signal delay drift vector; ``fval``, the value of the
optimized NLL cost function; and ``diagnostic``, an instance of
:class:`scipy.optimize.OptimizeResult` returned by
:func:`scipy.optimize.minimize`. Note that the fit parameters are
scaled internally to improve convergence (see `Other Parameters`
below), which affects the attributes ``fun``, ``jac``, and ``hess_inv``
of ``diagnostic``. See :class:`NoiseResult` for more details and for a
description of other attributes.
Other Parameters
----------------
scale_logv_alpha, scale_logv_beta, scale_logv_tau : float, optional
Scale for varying the log of the variance parameters.
scale_delta_mu : array_like with shape(n,), optional
Scale for varying signal vector. When set to ``None``, the default,
use ``NoiseModel(sigma_alpha0, sigma_beta0,
sigma_tau0).noise_amp(mu0)``.
scale_delta_a : array_like with shape(m - 1,), optional
Scale for varying signal amplitude drift vector. Default is
``np.max((sigma_min / x_max, sigma_beta0))`` for all entries, where
``sigma_min = np.sqrt(np.min(np.var(x, 1, ddof=1)))`` and
``x_max = np.max(np.abs(x))``.
scale_eta : array_like with shape(m - 1,), optional
Scale for varying signal delay drift vector. Default is
``(sigma_min / diff_x_diff_t_max, sigma_tau0 / dt)``, for all entries,
where ``sigma_min = np.sqrt(np.min(np.var(x, 1, ddof=1)))``, and
``diff_x_diff_t_max = np.max(np.abs(np.diff(x, axis=0)))``.
min_options : dict or None, optional
Keyword options passed to the ``options`` parameter of
:func:`scipy.optimize.minimize`. See the documentation on the
`BFGS <https://docs.scipy.org/doc/scipy/reference/
optimize.minimize-bfgs.html#optimize-minimize-bfgs>`_
method for details. By default, ``gtol=1e-5 * x.size``. The options
``eps`` and ``finite_diff_rel_step`` are not used.
workers : int or None, optional
The number of workers to use for parallel computation of FFTs. Default
is None, which sets the number of workers to ``os.cpu_count()``. If
both ``workers`` and ``thztools.options.workers`` are ``None``, the
number of workers is set to ``os.cpu_count()``.
Raises
------
ValueError
If all parameters are held fixed or if the input arrays have
incompatible shapes.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
See Also
--------
NoiseModel : Noise model class.
Notes
-----
Given an :math:`N\times M` data array :math:`\mathbf{X}`, the function uses
the BFGS method of :func:`scipy.optimize.minimize` to minimize the
maximum-likelihood cost function [1]_
.. math:: \begin{split}\
Q_\text{ML}\
(\sigma_\alpha,\sigma_\beta,\sigma_\tau,\boldsymbol{\mu},\
\mathbf{A},\boldsymbol{\eta};\mathbf{X})\
= \frac{1}{2}\sum_{k=0}^{N-1}\sum_{l=0}^{M-1}& \
\left[\ln\sigma_{kl}^2 + \frac{(X_{kl} \
- Z_{kl})^2}{\sigma_{kl}^2}\right]\
\end{split}
with respect to the unknown model parameters :math:`\sigma_\alpha`,
:math:`\sigma_\beta`, :math:`\sigma_\tau`, :math:`\boldsymbol{\mu}`,
:math:`\mathbf{A}` and :math:`\boldsymbol{\eta}`. The model assumes that
the elements of :math:`\mathbf{X}` are normally distributed around the
corresponding elements of :math:`\mathbf{Z}` with variances given by
:math:`\boldsymbol{\sigma}^2`, which are in turn given by
.. math:: Z_{kl} = A_l\mu(t_k - \eta_l)
and
.. math:: \sigma_{kl}^2 = \sigma_\alpha^2 + \sigma_\beta^2 Z_{kl}^2 \
+ \sigma_\tau^2(\mathbf{D}\mathbf{Z})_{kl}^2,
where :math:`\mathbf{D}` is the time-domain derivative operator. The
function :math:`\mu(t)` represents an ideal primary waveform, which drifts
in amplitude and temporal location between each of the :math:`M` waveform
measurements. Each waveform comprises :math:`N` samples at the nominal
times :math:`t_k`, and the drift in the amplitude and the temporal location
of the :math:`l` waveform are given by :math:`A_l` and :math:`\eta_l`,
respectively, with initial values fixed at :math:`A_0 = 1.0` and
:math:`\eta_0 = 0.0` by definition.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
In basic usage, it should be possible to call ``noisefit`` with just
the data array. In the code below, we simulate ``m = 50`` noisy waveforms
with ``n = 256`` time points each, then verify that the fit results are
consistent with the true noise parameters.
>>> import numpy as np
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> rng = np.random.default_rng(0)
>>> n, m, dt = 256, 50, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>>
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> a = 1.0 + 1e-2 * np.concatenate(([0.0], rng.standard_normal(m - 1)))
>>> eta = 1e-3 * np.concatenate(([0.0], rng.standard_normal(m - 1)))
>>> z = thz.scaleshift(np.repeat(np.atleast_2d(mu), m, axis=0), dt=dt,
... a=a, eta=eta).T # Orient the array columnwise
>>> x = z + noise_model.noise_sim(z, axis=0, seed=12345)
>>>
>>> noise_res_direct = thz.noisefit(x, sigma_alpha0=alpha, sigma_beta0=beta,
... sigma_tau0=tau, dt=dt)
>>> noise_res_direct.noise_model
NoiseModel(sigma_alpha=0.000100..., sigma_beta=0.00985...,
sigma_tau=0.000892..., dt=0.05)
>>>
>>> plt.plot(t, np.std(thz.scaleshift(x, a=1/noise_res_direct.a,
... eta=-noise_res_direct.eta, axis=0), axis=1), "-", label="Data")
>>> plt.plot(t, noise_res_direct.noise_model.noise_amp(noise_res_direct.mu),
... "--", label=r"Direct Fit for $\mu$")
>>> plt.legend()
>>> plt.xlabel("t (ps)")
>>> plt.ylabel(r"$\sigma(t)$")
>>> plt.show()
"""
x = np.asarray(x, dtype=np.float64)
dt = _assign_sampling_time(dt)
workers = _assign_workers(workers)
parsed = _parse_noisefit_input(
x,
dt=dt,
sigma_alpha0=sigma_alpha0,
sigma_beta0=sigma_beta0,
sigma_tau0=sigma_tau0,
mu0=mu0,
a0=a0,
eta0=eta0,
fix_sigma_alpha=fix_sigma_alpha,
fix_sigma_beta=fix_sigma_beta,
fix_sigma_tau=fix_sigma_tau,
est_mu=est_mu,
fix_mu=fix_mu,
fix_a=fix_a,
fix_eta=fix_eta,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
objective, jac, x0, input_parsed = parsed
method: Literal["BFGS"] = "BFGS"
# Minimize cost function with respect to free parameters
out: OptimizeResult = minimize(
fun=objective,
x0=x0,
method=method,
jac=jac,
tol=1e-5 * x.size,
options=min_options, # type: ignore[arg-type]
)
return _parse_noisefit_output(out, x, dt=dt, **input_parsed)
def _parse_noisefit_input(
x: NDArray[np.float64],
dt: float,
*,
sigma_alpha0: float | None,
sigma_beta0: float | None,
sigma_tau0: float | None,
mu0: ArrayLike | None,
a0: ArrayLike | None,
eta0: ArrayLike | None,
fix_sigma_alpha: bool,
fix_sigma_beta: bool,
fix_sigma_tau: bool,
est_mu: bool,
fix_mu: bool,
fix_a: bool,
fix_eta: bool,
scale_logv_alpha: float | None,
scale_logv_beta: float | None,
scale_logv_tau: float | None,
scale_delta_mu: ArrayLike | None,
scale_delta_a: ArrayLike | None,
scale_eta: ArrayLike | None,
workers: int,
) -> tuple[
Callable[[NDArray[np.float64]], float],
Callable[[NDArray[np.float64]], NDArray[np.float64]],
NDArray[np.float64],
dict[str, Any],
]:
"""Parse noisefit inputs"""
if x.ndim != NUM_NOISE_DATA_DIMENSIONS:
msg = "Data array x must be 2D"
raise ValueError(msg)
n, m = x.shape
if (
fix_sigma_alpha
and fix_sigma_beta
and fix_sigma_tau
and fix_mu
and fix_a
and fix_eta
):
msg = "All variables are fixed"
raise ValueError(msg)
if fix_mu and est_mu:
msg = "est_mu and fix_mu cannot be used at the same time. est_mu has been disabled."
warnings.warn(msg, category=UserWarning, stacklevel=3)
est_mu = False
# Use the first column of x as a default
if mu0 is None:
mu0 = x[:, 0]
else:
mu0 = np.asarray(mu0, dtype=np.float64)
if mu0.size != n:
msg = "Size of mu0 is incompatible with data array x."
raise ValueError(msg)
# Compute time-dependent variance of signal array and
# the minimum value of the time-dependent noise amplitude
v_t = np.var(x, 1, ddof=1)
sigma_min = np.sqrt(np.min(v_t))
# If any initial guess for the noise parameters is unspecified,
# estimate all noise parameters with a linear least-squares fit
# to the time-dependent variance
if None in [sigma_alpha0, sigma_beta0, sigma_tau0]:
mu0_f = rfft(mu0, workers=workers)
w = 2 * pi * rfftfreq(n, dt)
dmu0_dt = irfft(1j * w * mu0_f, n=n, workers=workers)
a_matrix = np.stack([np.ones(n), mu0**2, dmu0_dt**2], axis=1)
sol = np.linalg.lstsq(a_matrix, v_t, rcond=None)
sigma_est = np.ma.sqrt(sol[0]).filled(sigma_min)
if sigma_alpha0 is None:
sigma_alpha0 = sigma_est[0]
if sigma_beta0 is None:
sigma_beta0 = sigma_est[1]
if sigma_tau0 is None:
sigma_tau0 = sigma_est[2]
noise_model = NoiseModel(
float(sigma_alpha0),
float(sigma_beta0),
float(sigma_tau0),
dt=dt,
)
if scale_delta_mu is None:
scale_delta_mu = noise_model.noise_amp(mu0)
scale_delta_mu[np.isclose(scale_delta_mu, 0.0)] = np.sqrt(
np.finfo(float).eps
)
scale_delta_mu = np.asarray(scale_delta_mu, dtype=np.float64)
if scale_delta_a is None:
# Estimate the appropriate scaling factor from the multiplicative noise amplitude
x_max = np.max(np.abs(x))
scale_delta_a_amp = np.max((sigma_min / x_max, sigma_beta0))
scale_delta_a = np.full((m - 1,), scale_delta_a_amp)
scale_delta_a = np.asarray(scale_delta_a, dtype=np.float64)
if scale_eta is None:
# Estimate the appropriate scaling factor from the timebase noise amplitude
diff_x_diff_t_max = np.max(np.abs(np.diff(x, axis=0)))
scale_eta_amp = np.max(
(sigma_min / diff_x_diff_t_max, sigma_tau0 / dt)
)
scale_eta = np.full((m - 1,), scale_eta_amp)
scale_eta = np.asarray(scale_eta, dtype=np.float64)
if scale_logv_alpha is None:
scale_logv_alpha = 1 / m
if scale_logv_beta is None:
scale_logv_beta = 1 / m
if scale_logv_tau is None:
scale_logv_tau = 1 / m
scale_logv = np.array(
[scale_logv_alpha, scale_logv_beta, scale_logv_tau],
dtype=np.float64,
)
# Replace log(x) with -1e2 when x <= 0
v0 = (
np.asarray(
[sigma_alpha0, sigma_beta0, sigma_tau0 / dt], dtype=np.float64
)
) ** 2
logv0_scaled = np.ma.log(v0).filled(-1.0e2) / scale_logv
delta0 = (x[:, 0] - mu0) / scale_delta_mu
if a0 is None:
a0 = np.ones(m)
else:
a0 = np.asarray(a0, dtype=np.float64)
if a0.size != m:
msg = "Size of a0 is incompatible with data array x."
raise ValueError(msg)
epsilon0 = (a0[1:] - 1.0) / (a0[0] * scale_delta_a)
if eta0 is None:
eta0 = np.zeros(m)
else:
eta0 = np.asarray(eta0, dtype=np.float64)
if eta0.size != m:
msg = "Size of eta0 is incompatible with data array x."
raise ValueError(msg)
eta_on_dt_scaled0 = eta0[1:] / dt / scale_eta
# Set initial guesses for all free parameters
x0 = np.array([], dtype=np.float64)
if not fix_sigma_alpha:
x0 = np.append(x0, logv0_scaled[0])
if not fix_sigma_beta:
x0 = np.append(x0, logv0_scaled[1])
if not fix_sigma_tau:
x0 = np.append(x0, logv0_scaled[2])
if not fix_mu and not est_mu:
x0 = np.concatenate((x0, delta0))
if not fix_a:
x0 = np.concatenate((x0, epsilon0))
if not fix_eta:
x0 = np.concatenate((x0, eta_on_dt_scaled0))
# Bundle free parameters together into objective function
def objective(
_p: NDArray[np.float64],
) -> float:
if fix_sigma_alpha:
_logv_alpha_scaled = logv0_scaled[0]
else:
_logv_alpha_scaled = _p[0]
_p = _p[1:]
if fix_sigma_beta:
_logv_beta_scaled = logv0_scaled[1]
else:
_logv_beta_scaled = _p[0]
_p = _p[1:]
if fix_sigma_tau:
_logv_tau_scaled = logv0_scaled[2]
else:
_logv_tau_scaled = _p[0]
_p = _p[1:]
if fix_mu:
_delta = delta0
elif est_mu:
_delta = np.array([], dtype=np.float64)
else:
_delta = _p[:n]
_p = _p[n:]
if fix_a:
_epsilon = epsilon0
else:
_epsilon = _p[: m - 1]
_p = _p[m - 1 :]
_eta_on_dt = eta_on_dt_scaled0 if fix_eta else _p[: m - 1]
return _nll_noisefit(
x.T,
_logv_alpha_scaled,
_logv_beta_scaled,
_logv_tau_scaled,
_delta,
_epsilon,
_eta_on_dt,
est_mu=est_mu,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
# Bundle free parameters together into objective function
def jac(_p: NDArray[np.float64]) -> NDArray[np.float64]:
if fix_sigma_alpha:
_logv_alpha_scaled = logv0_scaled[0]
else:
_logv_alpha_scaled = _p[0]
_p = _p[1:]
if fix_sigma_beta:
_logv_beta_scaled = logv0_scaled[1]
else:
_logv_beta_scaled = _p[0]
_p = _p[1:]
if fix_sigma_tau:
_logv_tau_scaled = logv0_scaled[2]
else:
_logv_tau_scaled = _p[0]
_p = _p[1:]
if fix_mu:
_delta = delta0
elif est_mu:
_delta = np.array([], dtype=np.float64)
else:
_delta = _p[:n]
_p = _p[n:]
if fix_a:
_epsilon = epsilon0
else:
_epsilon = _p[: m - 1]
_p = _p[m - 1 :]
_eta_on_dt_scaled = eta_on_dt_scaled0 if fix_eta else _p[: m - 1]
return _jac_noisefit(
x.T,
_logv_alpha_scaled,
_logv_beta_scaled,
_logv_tau_scaled,
_delta,
_epsilon,
_eta_on_dt_scaled,
fix_logv_alpha=fix_sigma_alpha,
fix_logv_beta=fix_sigma_beta,
fix_logv_tau=fix_sigma_tau,
est_mu=est_mu,
fix_delta_mu=fix_mu,
fix_delta_a=fix_a,
fix_eta=fix_eta,
scale_logv_alpha=scale_logv_alpha,
scale_logv_beta=scale_logv_beta,
scale_logv_tau=scale_logv_tau,
scale_delta_mu=scale_delta_mu,
scale_delta_a=scale_delta_a,
scale_eta=scale_eta,
workers=workers,
)
input_parsed = {
"sigma_alpha0": sigma_alpha0,
"sigma_beta0": sigma_beta0,
"sigma_tau0": sigma_tau0,
"mu0": mu0,
"a0": a0,
"eta0": eta0,
"fix_sigma_alpha": fix_sigma_alpha,
"fix_sigma_beta": fix_sigma_beta,
"fix_sigma_tau": fix_sigma_tau,
"est_mu": est_mu,
"fix_mu": fix_mu,
"fix_a": fix_a,
"fix_eta": fix_eta,
"scale_logv_alpha": scale_logv_alpha,
"scale_logv_beta": scale_logv_beta,
"scale_logv_tau": scale_logv_tau,
"scale_delta_mu": scale_delta_mu,
"scale_delta_a": scale_delta_a,
"scale_eta": scale_eta,
"workers": workers,
"jac": jac,
}
return objective, jac, x0, input_parsed
def _parse_noisefit_output(
out: OptimizeResult,
x: NDArray[np.float64],
dt: float,
*,
sigma_alpha0: float,
sigma_beta0: float,
sigma_tau0: float,
mu0: NDArray[np.float64],
a0: NDArray[np.float64],
eta0: NDArray[np.float64],
fix_sigma_alpha: bool,
fix_sigma_beta: bool,
fix_sigma_tau: bool,
est_mu: bool,
fix_mu: bool,
fix_a: bool,
fix_eta: bool,
scale_logv_alpha: float,
scale_logv_beta: float,
scale_logv_tau: float,
scale_delta_mu: NDArray[np.float64],
scale_delta_a: NDArray[np.float64],
scale_eta: NDArray[np.float64],
workers: int,
jac: Callable[[NDArray[np.float64]], NDArray[np.float64]],
) -> NoiseResult:
"""Parse noisefit output"""
# Parse output
n, m = x.shape
bias_correction = np.sqrt(m / (m - 1))
x_out = out.x
if fix_sigma_alpha:
alpha = sigma_alpha0
else:
alpha = np.exp(x_out[0] * scale_logv_alpha / 2) * bias_correction
x_out = x_out[1:]
if fix_sigma_beta:
beta = sigma_beta0
else:
beta = np.exp(x_out[0] * scale_logv_beta / 2) * bias_correction
x_out = x_out[1:]
if fix_sigma_tau:
tau = sigma_tau0
else:
tau = (
float(np.exp(x_out[0] * scale_logv_tau / 2) * dt) * bias_correction
)
x_out = x_out[1:]
noise_model = NoiseModel(float(alpha), float(beta), float(tau), dt=dt)
if fix_mu:
mu_out = mu0
elif not est_mu:
mu_out = x[:, 0] - x_out[:n] * scale_delta_mu
x_out = x_out[n:]
if fix_a:
a_out = a0
else:
a_out = np.concatenate(([1.0], 1.0 + x_out[: m - 1] * scale_delta_a))
x_out = x_out[m - 1 :]
if fix_eta:
eta_out = eta0
else:
eta_out = np.concatenate(([0.0], x_out[: m - 1] * scale_eta * dt))
diagnostic = out
fun = out.fun
# Concatenate scaling vectors for all sets of free parameters
scale_hess_inv = np.concatenate(
[
val
for tf, val in zip(
[
fix_sigma_alpha,
fix_sigma_beta,
fix_sigma_tau,
fix_mu or est_mu,
fix_a,
fix_eta,
],
[
[scale_logv_alpha * alpha / 2],
[scale_logv_beta * beta / 2],
[scale_logv_tau * tau / 2],
scale_delta_mu,
scale_delta_a,
scale_eta * dt,
],
strict=False,
)
if not tf
]
)
# Get or compute the inverse hessian
hess_inv_scaled = inv(nd.Jacobian(jac)(out.x))
# Convert inverse Hessian into unscaled parameters
hess_inv = (
np.diag(scale_hess_inv) @ hess_inv_scaled @ np.diag(scale_hess_inv)
)
# Determine parameter uncertainty vector from diagonal entries
err = np.sqrt(np.diag(hess_inv))
err_mu = np.array([])
err_a = np.array([])
err_eta = np.array([])
# Parse error vector
# Propagate error from log(V) to sigma
if not fix_sigma_alpha:
err_sigma_alpha = err[0] * bias_correction
err = err[1:]
else:
err_sigma_alpha = 0.0
if not fix_sigma_beta:
err_sigma_beta = err[0] * bias_correction
err = err[1:]
else:
err_sigma_beta = 0.0
if not fix_sigma_tau:
err_sigma_tau = err[0] * bias_correction
err = err[1:]
else:
err_sigma_tau = 0.0
if not fix_mu and not est_mu:
err_mu = err[:n]
err = err[n:]
if not fix_a:
err_a = np.concatenate(([0], err[: m - 1]))
err = err[m - 1 :]
if not fix_eta:
err_eta = np.concatenate(([0], err[: m - 1]))
# Parse mu_out and err_mu if est_mu
if not fix_mu and est_mu:
scale = np.sum(a_out**2)
f = rfftfreq(n)
w = 2 * pi * f
exp_iweta = np.exp(1j * np.outer(eta_out, w / dt))
x_f = rfft(x.T)
x_back_f = exp_iweta * x_f
mu_f = np.einsum("j,jk->k", a_out, x_back_f) / scale
delay_impulse = irfft(
rfft(unit_impulse(n)) * exp_iweta, n, workers=workers
)
delay_matrix = circulant(delay_impulse)
var_x = noise_model.noise_var(x.T)
var_x_average = (
np.einsum(
"ijk,ik,ikj,i->jk", delay_matrix, var_x, delay_matrix, a_out**2
)
/ scale**2
)
dmu_da_f = (x_back_f - 2 * np.einsum("j,k->jk", a_out, mu_f)) / scale
dmu_da = irfft(dmu_da_f, n, workers=workers)
dmu_deta_f = (
np.einsum("j,jk->jk", a_out, 1j * (w / dt) * x_back_f) / scale
)
dmu_deta = irfft(dmu_deta_f, n, workers=workers)
if fix_a and not fix_eta:
var_a_eta = (
dmu_deta[1:].T @ hess_inv[-m + 1 :, -m + 1 :] @ dmu_deta[1:]
)
elif not fix_a and fix_eta:
var_a_eta = (
dmu_da[1:].T @ hess_inv[-m + 1 :, -m + 1 :] @ dmu_da[1:]
)
elif fix_a and fix_eta:
var_a_eta = 0
else:
var_a_eta = (
np.concatenate((dmu_da[1:], dmu_deta[1:])).T
[docs]
@ hess_inv[-2 * m + 2 :, -2 * m + 2 :]
@ np.concatenate((dmu_da[1:], dmu_deta[1:]))
)
mu_out = irfft(mu_f, n, workers=workers)
err_mu = np.sqrt(np.diag(var_a_eta + var_x_average))
# Cast fun as a Python float in case it is a NumPy constant
return NoiseResult(
noise_model,
mu_out,
a_out,
eta_out,
float(fun),
hess_inv,
err_sigma_alpha,
err_sigma_beta,
err_sigma_tau,
err_mu,
err_a,
err_eta,
diagnostic,
)
@dataclass
class FitResult:
r"""
Dataclass for the output of :func:`fit`.
Parameters
----------
p_opt : ndarray
Optimal fit parameters.
p_err : ndarray
Uncertainty estimate for ``p_opt``,
``p_err = np.sqrt(np.diag(p_cov))``.
.. deprecated:: 0.6.0
This option is deprecated and will be removed in version 0.8.0.
p_cov : ndarray
Covariance matrix estimate for ``p_opt``, determined from the curvature
of the cost function at ``(p_opt, mu_opt)``. To compute one standard
deviation errors on the parameters, use ``p_err = np.sqrt(np.diag(p_cov))``.
mu_opt : ndarray
Optimal estimate of the input waveform.
mu_err : ndarray
Estimated uncertainty in ``mu_opt``, determined from the curvature of
the cost function at ``(p_opt, mu_opt)``.
.. deprecated:: 0.6.0
This option is deprecated and will be removed in version 0.8.0.
mu_cov : ndarray
Covariance matrix estimate for ``mu_opt``, determined from the curvature
of the cost function at ``(p_opt, mu_opt)``. To compute one standard
deviation errors of the input waveform, use ``mu_err = np.sqrt(np.diag(mu_cov))``.
psi_opt : ndarray
Optimal estimate of the output waveform.
psi_cov : ndarray
Covariance matrix estimate for ``psi_opt``. To compute one standard
deviation errors of the output waveform, use ``psi_err = np.sqrt(np.diag(psi_cov))``.
frfun_opt : complex ndarray
Estimated values of the frequency response function at non-negative
frequencies.
resnorm : float
Euclidean norm (i.e., sum of the squares) of the normalized total
least-squares residuals.
dof : int
Number of statistical degrees of freedom,
``dof = n - n_p - n_a - n_b``, where ``n`` is the number of samples in
each waveform, ``n_p`` is the number of fit parameters in the frequency
response function, and ``n_a + n_b`` is the number of real parameters
necessary to specify the frequency response function at the excluded
frequencies.
delta : ndarray
Residuals of the input waveform ``x``, defined as ``x - mu_opt``.
delta_norm : ndarray
Normalized residuals of the input waveform ``x``, defined as
``delta/noise_model.noise_amp(xdata)``.
delta_norm_cov : ndarray
Covariance matrix estimate for ``delta_norm``.
epsilon : ndarray
Residuals of the output waveform ``y``, defined as ``y - psi_opt``,
where ``psi_opt = thztools.apply_frf(frfun, mu, dt=dt, args=p_opt)``,
``frfun`` is the parameterized frequency response function, and
``p_opt`` is the array of optimized parameters.
epsilon_norm : ndarray
Normalized residuals of the output waveform ``y``, defined as
``epsilon/noise_model.noise_amp(ydata)``.
epsilon_norm_cov : ndarray
Covariance matrix estimate for ``epsilon_norm``.
delta_norm_epsilon_norm_cov : ndarray
Cross-covariance matrix of the normalized residuals ``delta_norm``
and ``epsilon_norm``.
r_tls : ndarray
Normalized total least-squares residuals.
success : bool
True if one of the convergence criteria is satisfied.
diagnostic : scipy.optimize.OptimizeResult
Instance of :class:`scipy.optimize.OptimizeResult` returned by
:func:`scipy.optimize.least_squares`.
Attributes
----------
p_opt : ndarray
Optimal fit parameters.
p_err : ndarray
Uncertainty estimate for ``p_opt``,
``p_err = np.sqrt(np.diag(p_cov))``.
p_cov : ndarray
Covariance matrix estimate for ``p_opt``, determined from the curvature
of the cost function at ``(p_opt, mu_opt)``. To compute the standard
errors for the parameters, use ``p_err = np.sqrt(np.diag(p_cov))``.
mu_opt : ndarray
Optimal estimate of the input waveform.
mu_err : ndarray
Estimated uncertainty in ``mu_opt``, determined from the curvature of
the cost function at ``(p_opt, mu_opt)``.
mu_cov : ndarray
Covariance matrix estimate for ``mu_opt``, determined from the curvature
of the cost function at ``(p_opt, mu_opt)``. To compute the standard
errors for the input waveform, use ``mu_err = np.sqrt(np.diag(mu_cov))``.
psi_opt : ndarray
Optimal estimate of the output waveform.
psi_cov : ndarray
Covariance matrix estimate for ``psi_opt``. To compute the standard
errors for the output waveform, use ``psi_err = np.sqrt(np.diag(psi_cov))``.
frfun_opt : complex ndarray
Estimated values of the frequency response function at non-negative
frequencies.
resnorm : float
Euclidean norm (i.e., sum of the squares) of the normalized total
least-squares residuals.
dof : int
Number of statistical degrees of freedom,
``dof = n - n_p - n_a - n_b``, where ``n`` is the number of samples in
each waveform, ``n_p`` is the number of fit parameters in the frequency
response function, and ``n_a + n_b`` is the number of real parameters
necessary to specify the frequency response function at the excluded
frequencies.
delta : ndarray
Residuals of the input waveform ``x``, defined as ``x - mu_opt``.
delta_norm : ndarray
Normalized residuals of the input waveform ``x``, defined as
``delta/noise_model.noise_amp(xdata)``.
delta_norm_cov : ndarray
Covariance matrix estimate for ``delta_norm``.
epsilon : ndarray
Residuals of the output waveform ``y``, defined as ``y - psi_opt``,
where ``psi_opt = thztools.apply_frf(frfun, mu, dt=dt, args=p_opt)``,
``frfun`` is the parameterized frequency response function, and
``p_opt`` is the array of optimized parameters.
epsilon_norm : ndarray
Normalized residuals of the output waveform ``y``, defined as
``epsilon/noise_model.noise_amp(ydata)``.
epsilon_norm_cov : ndarray
Covariance matrix estimate for ``epsilon_norm``.
delta_norm_epsilon_norm_cov : ndarray
Cross-covariance matrix of the normalized residuals ``delta_norm``
and ``epsilon_norm``.
r_tls : ndarray
Normalized total least-squares residuals.
success : bool
True if one of the convergence criteria is satisfied.
diagnostic : scipy.optimize.OptimizeResult
Instance of :class:`scipy.optimize.OptimizeResult` returned by
:func:`scipy.optimize.least_squares`.
See Also
--------
fit : Fit a frequency response function to time-domain data.
"""
p_opt: NDArray[np.float64]
_p_err: NDArray[np.float64]
p_cov: NDArray[np.float64]
mu_opt: NDArray[np.float64]
_mu_err: NDArray[np.float64]
mu_cov: NDArray[np.float64]
psi_opt: NDArray[np.float64]
psi_cov: NDArray[np.float64]
frfun_opt: NDArray[np.complex128]
resnorm: float
dof: int
delta: NDArray[np.float64]
delta_norm: NDArray[np.float64]
delta_norm_cov: NDArray[np.float64]
epsilon: NDArray[np.float64]
epsilon_norm: NDArray[np.float64]
epsilon_norm_cov: NDArray[np.float64]
delta_norm_epsilon_norm_cov: NDArray[np.float64]
r_tls: NDArray[np.float64]
success: bool
diagnostic: OptimizeResult
@property
def p_err(self) -> NDArray[np.float64]:
msg = (
"\np_err is deprecated since version 0.6.0 and will be removed in version 0.8.0. \n"
"Use numpy.sqrt(numpy.diag(p_cov)) instead."
)
warnings.warn(
msg,
DeprecationWarning,
stacklevel=2,
)
return self._p_err
@property
def mu_err(self) -> NDArray[np.float64]:
msg = (
"\nmu_err is deprecated since version 0.6.0 and will be removed in version 0.8.0. \n"
"Use numpy.sqrt(numpy.diag(mu_cov)) instead."
)
warnings.warn(
msg,
DeprecationWarning,
stacklevel=2,
)
return self._mu_err
def _costfuntls(
frfun: Callable[..., NDArray[np.complex128]],
theta: ArrayLike,
mu: ArrayLike,
x: ArrayLike,
y: ArrayLike,
sigma_x: ArrayLike,
sigma_y: ArrayLike,
dt: float | None = 1.0,
) -> NDArray[np.float64]:
r"""Computes the residual vector for the total least squares cost function.
Parameters
----------
frfun : callable
Frequency response function.
``frfun(w, *p, *args, **kwargs) -> ndarray``
Assumes the :math:`+i\omega t` convention for harmonic time dependence.
theta : array_like
Input parameters for the function.
mu : array_like
Estimated input signal.
x : array_like
Measured input signal.
y : array_like
Measured output signal.
sigma_x : array_like
Noise vector of the input signal.
sigma_y : array_like
Noise vector of the output signal.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to ``1.0``. In this case, the angular frequency
``omega`` must be given in units of radians per sampling time, and any
parameters in ``args`` must be expressed with the sampling time as the
unit of time.
Returns
-------
res : ndarray
Residual array.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
"""
theta = np.asarray(theta, dtype=np.float64)
mu = np.asarray(mu, dtype=np.float64)
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
sigma_x = np.asarray(sigma_x, dtype=np.float64)
sigma_y = np.asarray(sigma_y, dtype=np.float64)
dt = _assign_sampling_time(dt)
psi = apply_frf(frfun, mu, dt=dt, args=theta)
delta_norm = (x - mu) / sigma_x
eps_norm = (y - psi) / sigma_y
return np.concatenate((delta_norm, eps_norm))
[docs]
def fit(
frfun: Callable[..., NDArray[np.complex128]],
xdata: ArrayLike,
ydata: ArrayLike,
p0: ArrayLike,
noise_parms: ArrayLike | None = None,
*,
dt: float | None = None,
numpy_sign_convention: bool = True,
args: tuple[float, ...] = (),
kwargs: dict[str, Any] | None = None,
f_bounds: ArrayLike | None = None,
p_bounds: ArrayLike | None = None,
jac: Callable[
Concatenate[NDArray[np.float64], ...], NDArray[np.complex128]
]
| None = None,
lsq_options: dict[str, Any] | None = None,
) -> FitResult:
r"""
Fit a parameterized frequency response function to time-domain data.
Determines the total least-squares fit to ``xdata`` and ``ydata``, given
the parameterized frequency response function relationship ``frfun`` and
noise model parameters ``noise_parms``.
Parameters
----------
frfun : callable
Frequency response function with signature ``frfun(omega, *p, *args,
**kwargs)`` that returns a complex ``ndarray``. Assumes the
:math:`+i\omega t` convention for harmonic time dependence when
``numpy_sign_convention`` is ``True``, the default. All elements of
``p`` and ``args`` and all values of ``kwargs`` must be real
quantities.
xdata : array_like
Measured input signal.
ydata : array_like
Measured output signal.
p0 : array_like
Initial guess for the parameters ``p``.
noise_parms : array_like or None, optional
Noise parameters ``(sigma_alpha, sigma_beta, sigma_tau)`` used to
define the :class:``NoiseModel`` for the fit. Default is ``None``,
which sets ``noise_parms`` to ``(1.0, 0.0, 0.0)``.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is ``None``, which sets
``dt`` to ``thztools.options.sampling_time``. If both ``dt`` and
``thztools.options.sampling_time`` are ``None``, ``dt`` is set to
``1.0``. In this case, the angular frequency ``omega`` must be given in
units of radians per sampling time, and any parameters in ``args`` must
be expressed with ``dt`` as the unit of time.
numpy_sign_convention : bool, optional
Adopt NumPy sign convention for harmonic time dependence, e.g, express
a harmonic function with frequency :math:`\omega` as
:math:`x(t) = a e^{i\omega t}`. Default is ``True``. When set to
``False``, uses the convention more common in physics,
:math:`x(t) = a e^{-i\omega t}`.
args : tuple
Additional arguments for ``frfun``. All elements must be real.
kwargs : dict or None, optional
Additional keyword arguments for ``frfun``. Default is ``None``, which
passes no keyword arguments. All values must be real.
f_bounds : array_like, optional
Lower and upper bounds on the frequencies included in the fit. For
``f_bounds = (f_lo, f_hi)``, the included frequencies ``f`` satisfy
``f_lo <= f <= f_hi``.
The default is ``None``, which sets ``f_bounds`` to include all
frequencies except zero:
``f_bounds = (np.finfo(np.float64).smallest_normal, np.inf)``.
Set the lower bound to ``0.0`` or any negative number to include zero
frequency.
p_bounds : None, 2-tuple of array_like, or :class:`scipy.optimize.Bounds`, optional
Lower and upper bounds on fit parameter(s). Default is ``None``, which
sets all parameter bounds to ``(-np.inf, np.inf)``. Each bound
constraint ``(p_lo, p_hi)`` represents a closed interval for the
parameter ``p``,
``p_lo <= p <= p_hi``.
jac : callable or None, optional
Jacobian of the frequency response function with respect to the fit
parameters, with signature ``jac(w, *p, *args, **kwargs)``. Default is
``None``, which uses :func:`scipy.optimize.approx_fprime`.
lsq_options : dict or None, optional
Keyword options passed to :func:`scipy.optimize.least_squares`. The
following keywords are allowed:
* "ftol" : Tolerance for termination by the change of the cost
function. Default is 1e-8.
* "xtol" : Tolerance for termination by the change of the
independent variables. Default is 1e-8.
* "gtol" : Tolerance for termination by the norm of the gradient.
Default is 1e-8.
* "loss" : Determines the loss function. Default is "linear", which
gives a standard least-squares problem. Alternative settings have
not been tested and should be considered experimental.
* "f_scale" : Value of soft margin between inlier and outlier
residuals. This parameter has no effect with ``loss="linear"``.
Default is 1.0.
* "max_nfev" : Maximum number of function evaluations before the
termination. If None (default), the value is chosen
automatically.
* "verbose" : Level of algorithm's verbosity. Default is 0, which
works silently.
See the documentation for :func:`scipy.optimize.least_squares` for
details.
Returns
-------
res : FitResult
Fit result represented as a :class:`FitResult` object. Important
attributes are: ``p_opt``, the optimal fit parameter values; ``p_cov``,
the parameter covariance matrix estimated from fit; ``resnorm``,
the value of the total least-squares cost function for the fit;
``dof``, the number of statistical degrees of freedom in the fit;
``r_tls``, and ``success``, which is ``True`` when the fit converges.
See the *Notes* section below and the :class:`FitResult` documentation
for more details and for a description of other attributes.
Raises
------
ValueError
If ``noise_parms`` does not have 3 elements.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
See Also
--------
NoiseModel : Noise model class.
Notes
-----
This function computes the maximum-likelihood estimate for the parameters
:math:`\boldsymbol{\theta}` in the frequency response function model
:math:`H(\omega; \boldsymbol{\theta})` by minimizing
the total least-squares cost function
.. math:: Q_\text{TLS}(\boldsymbol{\theta}) \
= \sum_{k=0}^{N-1}\left[ \
\frac{(x_k - \mu_k)^2}{\sigma_{\mathbf{x},k}^2} \
+ \frac{(y_k - \psi_k)^2}{\sigma_{\mathbf{y},k}^2} \
\right],
where :math:`\mathbf{x}` is the input signal array, :math:`\mathbf{y}`
is the output signal array, and :math:`\boldsymbol{\sigma}_{\mathbf{y}}`,
:math:`\boldsymbol{\sigma}_{\mathbf{y}}` are their associated noise
amplitudes. The arrays :math:`\boldsymbol{\mu}` and
:math:`\boldsymbol{\psi}` are the ideal input and output signal arrays,
respectively, which are related by the constraint
.. math:: [\mathcal{F}\boldsymbol{\psi}]_k = \
[H(\omega_k; \boldsymbol{\theta})] \
[\mathcal{F}\boldsymbol{\mu}]_k
at all discrete Fourier transform frequencies :math:`\omega_k` within
the frequency bounds.
The optimization step uses :func:`scipy.optimize.least_squares` with ``p``
and ``mu`` as free parameters. It minimizes the Euclidean norm of the
residual vector
``np.concatenate((delta / sigma_x, epsilon / sigma_y)``,
where
* ``delta = xdata - mu``,
* ``epsilon = ydata - psi``,
* ``sigma_x = NoiseModel(*noise_parms, dt=dt).noise_amp(xdata)``,
* ``sigma_y = NoiseModel(*noise_parms, dt=dt).noise_amp(ydata)``, and
* ``psi = thz.apply_frf(frfun(omega, *p, *args, **kwargs), mu,
dt=dt)``.
References
----------
.. [1] Laleh Mohtashemi, Paul Westlund, Derek G. Sahota, Graham B. Lea,
Ian Bushfield, Payam Mousavi, and J. Steven Dodge, "Maximum-
likelihood parameter estimation in terahertz time-domain
spectroscopy," Opt. Express **29**, 4912-4926 (2021),
`<https://doi.org/10.1364/OE.417724>`_.
Examples
--------
>>> import numpy as np
>>> import thztools as thz
>>> from matplotlib import pyplot as plt
>>> n, dt = 256, 0.05
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> alpha, beta, tau = 1e-4, 1e-2, 1e-3
>>> noise_model = thz.NoiseModel(sigma_alpha=alpha, sigma_beta=beta,
... sigma_tau=tau, dt=dt)
>>> def frfun(w, amplitude, delay):
... return amplitude * np.exp(1j * w * delay)
>>>
>>> p0 = (0.5, 1.0)
>>> psi = thz.apply_frf(frfun, mu, dt=dt, args=p0)
>>> xdata = mu + noise_model.noise_sim(mu, seed=0)
>>> ydata = psi + noise_model.noise_sim(psi, seed=1)
>>> result = thz.fit(frfun, xdata, ydata, p0, (alpha, beta, tau), dt=dt)
>>> result.success
True
>>> result.p_opt
array([0.49..., 1.00...])
>>> result.resnorm
198.266...
>>> _, ax = plt.subplots()
>>> ax.plot(t, result.r_tls, ".")
>>> ax.set_xlabel("t (ps)")
>>> ax.set_ylabel(r"$r_\mathrm{TLS}$")
>>> plt.show()
"""
fit_method: Literal["lm", "trf"] = "trf"
if kwargs is None:
kwargs = {}
def _frfun_local(
_omega: NDArray[np.float64], *p: np.float64
) -> NDArray[np.complex128]:
out = frfun(_omega, *p, *args, **kwargs)
if not numpy_sign_convention:
return np.conj(out)
return out
p0 = np.atleast_1d(p0).astype(np.float64)
xdata = np.asarray(xdata, dtype=np.float64)
ydata = np.asarray(ydata, dtype=np.float64)
if noise_parms is None:
noise_parms = np.asarray((1.0, 0.0, 0.0), dtype=np.float64)
else:
noise_parms = np.asarray(noise_parms, dtype=np.float64)
if noise_parms.size != NUM_NOISE_PARAMETERS:
msg = f"sigma_parms must be a tuple of length {NUM_NOISE_PARAMETERS:d}"
raise ValueError(msg)
dt = _assign_sampling_time(dt)
n = ydata.shape[-1]
n_p = len(p0)
if f_bounds is None:
f_bounds = np.array(
(np.finfo(np.float64).smallest_normal, np.inf), dtype=np.float64
)
else:
f_bounds = np.asarray(f_bounds, dtype=np.float64)
f = rfftfreq(n, dt)
f_excl_lo_idx = f < f_bounds[0]
f_excl_hi_idx = f > f_bounds[1]
f_incl_idx = ~f_excl_lo_idx * ~f_excl_hi_idx
w = np.asarray(2 * pi * f, dtype=np.float64)
n_below = np.sum(f_excl_lo_idx)
n_in = np.sum(f_incl_idx)
n_above = np.sum(f_excl_hi_idx)
n_ex = n_below + n_above
n_a = 0
n_b = 0
if n_ex > 0:
n_a = n_ex
n_b = n_ex - 1
if n % 2 == 0 and n_below > 0 and n_above > 0:
n_b -= 1
elif n % 2 == 1 and n_below == 0:
n_b += 1
alpha = noise_parms[0]
beta = noise_parms[1]
tau = noise_parms[2]
noise_model = NoiseModel(alpha, beta, tau, dt=dt)
sigma_x = noise_model.noise_amp(xdata)
sigma_y = noise_model.noise_amp(ydata)
v_x = np.diag(sigma_x**2)
v_y = np.diag(sigma_y**2)
sigma_x_inv = np.diag(1 / sigma_x)
sigma_y_inv = np.diag(1 / sigma_y)
p0_est = np.concatenate((p0, np.zeros(n_a), np.zeros(n_b), np.zeros(n)))
if p_bounds is None:
p_bounds = (-np.inf, np.inf)
fit_method = "lm"
else:
p_bounds = np.asarray(p_bounds, dtype=np.float64)
if p_bounds.shape[0] == NUM_BOUNDS_PARAMETERS:
p_bounds = (
np.concatenate(
(p_bounds[0], np.full((n_a + n_b + n,), -np.inf))
),
np.concatenate(
(p_bounds[1], np.full((n_a + n_b + n,), np.inf))
),
)
else:
msg = "`bounds` must contain 2 elements."
raise ValueError(msg)
if lsq_options is None:
lsq_options = {}
else:
valid_keys = {
"ftol",
"xtol",
"gtol",
"loss",
"f_scale",
"max_nfev",
"verbose",
}
bad_keys = set(lsq_options.keys()) - valid_keys
if len(bad_keys) > 0:
msg = f"Invalid key(s) {list(bad_keys)} in `lsq_options`"
raise KeyError(msg)
def fun_ex(
_a: NDArray[np.float64], _b: NDArray[np.float64]
) -> NDArray[np.complex128]:
# Transfer function is purely real at the first and last frequencies
if n_a - n_b == TWO_REAL_FREQUENCIES:
return np.concatenate(
([_a[0]], _a[1:-1] + _b * 1j, [_a[-1]]), dtype=np.complex128
)
# Transfer function is purely real at either the first or last
# frequency
if n_a - n_b == 1:
# TF is real at last frequency
if n_below == 0:
return np.concatenate(
(_a[:-1] + _b * 1j, [_a[-1]]), dtype=np.complex128
)
# Otherwise TF is real at first frequency
return np.concatenate(([_a[0]], _a[1:] + _b * 1j))
# Transfer function is complex at all excluded frequencies
return np.asarray(_a + _b * 1j, dtype=np.complex128)
def function(
_w: NDArray[np.float64], /, *_theta: np.float64
) -> NDArray[np.complex128]:
_a = np.asarray(_theta[n_p : n_p + n_a], dtype=np.float64)
_b = np.asarray(_theta[n_p + n_a :], dtype=np.float64)
h_ex = fun_ex(_a, _b)
h_in = _frfun_local(_w[f_incl_idx], *_theta[:n_p])
return np.concatenate((h_ex[:n_below], h_in, h_ex[n_below:]))
def function_flat(_x: NDArray[np.float64], /) -> NDArray[np.float64]:
_tf = _frfun_local(w[f_incl_idx], *_x)
return np.concatenate((np.real(_tf), np.imag(_tf)))
def jacobian_fun(_p: NDArray[np.float64]) -> NDArray[np.complex128]:
if jac is None:
# If Jacobian is not supplied, compute it numerically
_tf_prime = approx_fprime(_p, function_flat)
_tf_prime_complex = _tf_prime[0:n_in] + 1j * _tf_prime[n_in:]
out = np.astype(np.atleast_2d(_tf_prime_complex).T, np.complex128)
else:
# Otherwise, return supplied Jacobian
out = np.astype(
np.atleast_2d(jac(w[f_incl_idx], *_p, *args, **kwargs))[
:, :n_p
].T,
np.complex128,
)
return out
def jacobian_bl(
_p: NDArray[np.float64], _fft_mu: NDArray[np.complex128]
) -> NDArray[np.float64]:
fft_jac_bl = np.concatenate(
(
np.zeros((n_p, n_below)),
jacobian_fun(_p),
np.zeros((n_p, n_above)),
),
axis=-1,
)
jac_bl: np.ndarray[tuple[Any, ...], np.dtype[np.float64]] = irfft(
fft_jac_bl * _fft_mu, n=n
)
if n_a > 0:
a_circ = circulant(unit_impulse(n_a))
jac_a = np.concatenate(
(
a_circ[:, :n_below],
np.zeros((n_a, n_in)),
a_circ[:, n_below:],
),
axis=-1,
)
jac_bl_a = irfft(jac_a * _fft_mu, n=n)
jac_bl = np.concatenate(
(jac_bl, jac_bl_a), axis=0, dtype=np.float64
)
if n_b > 0:
b_circ = circulant(unit_impulse(n_b) * 1j)
if n_a - n_b == TWO_REAL_FREQUENCIES:
jac_b = np.concatenate(
(
np.zeros((n_b, 1)),
b_circ[:, : n_below - 1],
np.zeros((n_b, n_in)),
b_circ[:, n_below - 1 :],
np.zeros((n_b, 1)),
),
axis=-1,
)
elif n_a - n_b == 1:
if n_below == 0:
jac_b = np.concatenate(
(
np.zeros((n_b, n_in)),
b_circ[:, :],
np.zeros((n_b, 1)),
),
axis=-1,
)
else:
jac_b = np.concatenate(
(
np.zeros((n_b, 1)),
b_circ[:, : n_below - 1],
np.zeros((n_b, n_in)),
b_circ[:, n_below - 1 :],
),
axis=-1,
)
else:
jac_b = np.concatenate(
(np.zeros((n_b, n_in)), b_circ[:, :]), axis=-1
)
jac_bl_b = irfft(jac_b * _fft_mu, n=n)
jac_bl = np.concatenate(
(jac_bl, jac_bl_b), axis=0, dtype=np.float64
)
return jac_bl
def jac_fun(_x: NDArray[np.float64]) -> NDArray[np.float64]:
p_est = _x[: n_p + n_a + n_b]
mu_est = xdata[:] - _x[n_p + n_a + n_b :]
jac_tl = np.zeros((n, n_p + n_a + n_b))
jac_tr = np.diag(1 / sigma_x)
fft_mu_est = rfft(mu_est)
jac_bl = -(jacobian_bl(p_est[:n_p], fft_mu_est) / sigma_y).T
impulse_response = apply_frf(
function, unit_impulse(n), dt=dt, args=p_est
)
jac_br = (circulant(impulse_response).T / sigma_y).T
return np.block([[jac_tl, jac_tr], [jac_bl, jac_br]])
def cfun_tls(_p: NDArray[np.float64]) -> NDArray[np.float64]:
return _costfuntls(
function,
_p[: n_p + n_a + n_b],
xdata[:] - _p[n_p + n_a + n_b :],
xdata[:],
ydata[:],
sigma_x[:],
sigma_y[:],
dt,
)
result = least_squares(
cfun_tls,
p0_est,
jac=jac_fun,
bounds=p_bounds,
method=fit_method,
x_scale=np.concatenate((np.ones(n_p + n_a + n_b), sigma_x)),
**lsq_options,
)
# Parse output
#
# The type information for least_squares allows the jac attribute to be a dense
# ndarray, a sparse csr_array, or a LinearOperator. Here, jac is dense, so cast
# it that way before sending it to svd, which expects a dense array.
jac_dense = cast("np.ndarray", result.jac)
_, s, vt = svd(jac_dense, full_matrices=False)
threshold = np.finfo(float).eps * max(result.jac.shape) * s[0]
s = s[s > threshold]
vt = vt[: s.size]
cov = np.dot(vt.T / s**2, vt)
p_opt_all = result.x[: n_p + n_a + n_b]
p_opt = result.x[:n_p]
p_cov = cov[:n_p, :n_p]
p_err = np.sqrt(np.diag(p_cov))
delta = result.x[n_p + n_a + n_b :]
mu_opt = xdata - delta
mu_cov = cov[n_p + n_a + n_b :, n_p + n_a + n_b :]
mu_err = np.sqrt(np.diag(mu_cov))
psi_opt = apply_frf(function, mu_opt, dt=dt, args=p_opt_all)
epsilon = ydata - psi_opt
resnorm = 2 * result.cost
dof = n - n_p - n_a - n_b
h_circ = circulant(
apply_frf(function, unit_impulse(n), dt=dt, args=p_opt_all)
)
h_delta = apply_frf(function, delta, dt=dt, args=p_opt_all)
u_x = h_circ @ v_x @ h_circ.T
# Use the eigenvalue decomposition to take the inverse matrix square root
# of (v_y + u_x). Assumes np.all(evals > 0).
evals, evecs = np.linalg.eigh(v_y + u_x)
inv_sqrt_cov = evecs @ np.diag(1 / np.sqrt(evals)) @ evecs.T
r_tls = np.asarray(inv_sqrt_cov @ (epsilon - h_delta), dtype=np.float64)
psi_cov = h_circ @ mu_cov @ h_circ.T
delta_norm = delta / sigma_x
epsilon_norm = epsilon / sigma_y
delta_norm_cov = np.identity(n) - sigma_x_inv @ mu_cov @ sigma_x_inv
epsilon_norm_cov = np.identity(n) - sigma_y_inv @ psi_cov @ sigma_y_inv
delta_norm_epsilon_norm_cov = (
-sigma_x_inv @ mu_cov @ h_circ.T @ sigma_y_inv
)
frfun_opt = function(w, *p_opt_all)
# Cast resnorm as a Python float and success as a Python bool, in case
# either is a NumPy constant
return FitResult(
p_opt=p_opt,
_p_err=p_err,
p_cov=p_cov,
mu_opt=mu_opt,
_mu_err=mu_err,
mu_cov=mu_cov,
psi_opt=psi_opt,
psi_cov=psi_cov,
frfun_opt=frfun_opt,
resnorm=float(resnorm),
dof=dof,
delta=delta,
delta_norm=delta_norm,
delta_norm_cov=delta_norm_cov,
epsilon=epsilon,
epsilon_norm=epsilon_norm,
epsilon_norm_cov=epsilon_norm_cov,
delta_norm_epsilon_norm_cov=delta_norm_epsilon_norm_cov,
r_tls=r_tls,
success=bool(result.success),
diagnostic=result,
)
[docs]
def etfe(
x: ArrayLike,
y: ArrayLike,
*,
n: int | None = None,
dt: float | None = None,
window: str | None = None,
axis: int = -1,
) -> tuple[NDArray[np.complex128], NDArray[np.float64]]:
r"""
Calculate the empirical transfer-function estimate and the frequency base.
Given the real input ``x`` and the real output ``y``, returns the ratio of their discrete Fourier
transforms, ``h_f = scipy.fft.rfft(y) / scipy.fft.rfft(x)``.
Parameters
----------
x : array_like
Input waveform.
y : array_like
Output waveform.
n : int or None, optional
Number of points along transformation axis to use.
If ``n`` is greater than ``len(x)``, pad the signal with zeroes to ``n``.
If ``n`` less than ``len(x)``, signal is truncated. Default is None, which sets ``n = len(x)``.
dt : float or None, optional
Sampling time, normally in picoseconds. Default is None, which sets
the sampling time to ``thztools.options.sampling_time``. If both
``dt`` and ``thztools.options.sampling_time`` are ``None``, the
sampling time is set to 1.0.
window : str or None, optional
Name of a window function from :mod:`scipy.signal.windows`. Default is None, which applies
a Tukey window with ``alpha = 0.5``.
axis : int or None, optional
Fourier transformed axis. If not given, the last axis is used.
Returns
-------
h_f : complex ndarray
Empirical transfer function estimate.
f : ndarray
Array of length ``n//2 + 1`` of frequency samples corresponding to ``numpy.fft.rfftfreq``.
Raises
------
ValueError
If ``window`` is not a valid name in :mod:`scipy.signal.windows`.
Warns
-----
UserWarning
If ``thztools.options.sampling_time`` and the ``dt`` parameter
are both not ``None`` and are set to different ``float`` values, the
function will set the sampling time to ``dt`` and raise a
:class:`UserWarning`.
Notes
-----
This function computes the frequency axis for the real FFT:
``f = np.fft.rfftfreq(n, d=dt)``
Examples
--------
>>> from matplotlib import pyplot as plt
>>> import numpy as np
>>> import thztools as thz
>>> n, dt = 256, 0.05
>>> a, eta = 0.5, 1
>>> sigma_alpha, sigma_beta, sigma_tau = 1e-4, 1e-2, 1e-3
>>> t = thz.timebase(n, dt=dt)
>>> mu = thz.wave(n, dt=dt)
>>> z = thz.scaleshift(mu, dt=dt, a=a, eta=eta)
>>> noise_model = thz.NoiseModel(sigma_alpha, sigma_beta, sigma_tau, dt=dt)
>>> x = mu + noise_model.noise_sim(mu, axis=0, seed=1234)
>>> y = z + noise_model.noise_sim(z, axis=0, seed=5678)
>>> h_f, f = thz.etfe(x, y, dt=dt, window=None, axis=-1)
>>> print(f[:3])
[0. 0.078125 0.15625 ]
>>> def frfun(omega, a, eta):
... return a * np.exp(-1j * omega * eta)
>>>
>>> p0 = (a, eta)
>>> result = thz.fit(
... frfun, x, y, p0, noise_parms=(sigma_alpha, sigma_beta, sigma_tau), dt=dt
... )
>>> fig, axs = plt.subplots(2, 1)
>>>
>>> axs[0].plot(f, np.real(h_f), ".", label=r"$H_{\mathrm{ETFE}}$")
>>> axs[0].plot(
... f, np.real(result.frfun_opt), "--", label=r"$\hat{H}_{\mathrm{FIT}}$"
... )
>>> axs[0].set_ylabel("Re{H}")
>>> axs[0].set_ylim(-1.8, 1.8)
>>> axs[0].tick_params(labelbottom=False)
>>> axs[0].legend(loc="upper right")
>>> axs[1].plot(f, np.imag(h_f), ".")
>>> axs[1].plot(f, np.imag(result.frfun_opt), "--")
>>> axs[1].set_xlabel("Frequency (THz)")
>>> axs[1].set_ylabel(r"Im{H}")
>>> axs[1].set_ylim(-1.8, 1.8)
>>>
>>> fig.subplots_adjust(hspace=0.2)
>>> plt.show()
"""
dt = _assign_sampling_time(dt)
x = np.asarray(x)
y = np.asarray(y)
ax_len = x.shape[axis]
nfft = ax_len if n is None else n
f = np.asarray(np.fft.rfftfreq(nfft, d=dt), dtype=np.float64)
if window is None:
win1d = tukey(ax_len)
elif window not in windowlist:
wind_out_of_range = (
"Window parameter only accepts functions in {windowlist}"
)
raise ValueError(wind_out_of_range)
else:
win1d = get_window(window, ax_len, fftbins=False)
shape = [1] * x.ndim
shape[axis] = ax_len
win = win1d.reshape(shape)
windx = x * win
windy = y * win
x_fft: np.ndarray[tuple[Any, ...], np.dtype[np.complex128]] = rfft(
windx, n=nfft, axis=axis
)
y_fft: np.ndarray[tuple[Any, ...], np.dtype[np.complex128]] = rfft(
windy, n=nfft, axis=axis
)
return y_fft / x_fft, f