# -*- coding: utf-8 -*-
"""
Time and frequency grid utilities and other miscellaneous helper functions.
The submodules contain calculator-type functions for converting between
physically relevant parameters related to the linear and nonlinear
susceptibilities, as well as an efficient interface to fast Fourier transforms.
"""
__all__ = ["chi1", "chi2", "chi3", "fft",
"vacuum", "taylor_series",
"shift", "resample_v", "resample_t",
"TFGrid"]
# %% Imports
import collections
import copy
import numpy as np
from scipy.constants import pi, h
from pynlo.utility import chi1, chi2, chi3, fft
# %% Collections
_ResampledV = collections.namedtuple("ResampledV", ["v_grid", "f_v", "dv", "dt"])
_ResampledT = collections.namedtuple("ResampledT", ["t_grid", "f_t", "dt"])
_RTFGrid = collections.namedtuple("RTFGrid", ["n",
"v_grid", "v_ref", "dv", "v_window",
"t_grid", "t_ref", "dt", "t_window"])
# %% Routines
[docs]
def taylor_series(x0, fn):
"""
Calculate a Taylor series expansion given the derivatives of a function
about a point.
Parameters
----------
x0 : float
The center point of the Taylor series expansion.
fn : array_like
The value and derivatives of the function evaluated at `x0`. The
coefficients must be given in order of increasing degree, i.e.
``[f(x0), f'(x0), f''(x0), ...]``.
Returns
-------
pwr_series : numpy.polynomial.Polynomial
A NumPy `Polynomial` object representing the Taylor series expansion.
"""
window = np.array([-1, 1])
domain = window + x0
poly_coefs = [coef/np.math.factorial(n) for (n, coef) in enumerate(fn)]
pwr_series = np.polynomial.Polynomial(poly_coefs, domain=domain, window=window)
return pwr_series
[docs]
def vacuum(v_grid, rng=None):
"""
Generate a root-power spectrum due to quantum vacuum fluctuations.
Parameters
----------
v_grid : array_like of float
The frequency grid.
rng : np.random.Generator, optional
A NumPy random number generator. The default initializes a new
`Generator` on each function call.
Notes
-----
The combined noise of a coherent state's amplitude and phase quadratures is
equal to that of the vacuum. A coherent state :math:`|\\alpha\\rangle` is
defined by the displacement :math:`\\alpha = x_1 + i \\, x_2`, where
:math:`x_1` and :math:`x_2` are the "amplitude" and "phase" (real and
imaginary) quadrature amplitudes. In the number state basis
:math:`|n\\rangle`, a coherent state takes the form of a Poissonian
distribution:
.. math::
|\\alpha\\rangle = e^{-\\frac{|\\alpha|^2}{2}}
\sum_{n=0}^\\infty \\frac{\\alpha^n}{\sqrt{n!}} |n\\rangle
The probability :math:`P[\\alpha]` of measuring displacement
:math:`\\alpha` from a coherent state with average displacement
:math:`\\beta`, a simultaneous measurement of :math:`x_1` and :math:`x_2`,
is as follows:
.. math::
P[\\alpha] = \\frac{1}{\\pi} |\\langle \\alpha | \\beta\\rangle|^2
= \\frac{1}{\\pi} e^{-|\\alpha - \\beta|^2}
This probability distribution is Gaussian, and its noise can be completely
described by calculating the variance of each quadrature component. Scaled
to the number of photons (:math:`N=\\alpha^2`), the combined noise from
both quadratures gives a total variance of one photon per measurement:
.. math:: \\sigma_{x_1}^2 = \\sigma_{x_2}^2 = \\frac{1}{2}
.. math:: \\sigma_\\alpha^2 = \\sigma_{x_1}^2 + \\sigma_{x_2}^2 = 1
The width of the probability distribution is independent of the coherent
state's average displacement, which can be zero. This means that the
root-photon noise can be generated independent of the state by sampling a
normal distribution centered about zero mean. Also, since the Fourier
transform of Gaussian noise is also Gaussian noise, the root-photon noise
can be equivalently generated in either the time or frequency domains.
Normalizing to the number of photons per measurement interval, the root
photon noise for both quadratures becomes ``1/(2 * dt)**0.5`` for the time
domain and ``1/(2 * dv)**0.5`` for the frequency domain. The final
root-power noise is found by multiplying the frequency domain root-photon
noise by the square root of the photon energy associated with each bin's
frequency.
Returns
-------
a_v : ndarray of complex
The randomly-generated vacuum state root-power spectrum.
"""
if rng is None:
rng = np.random.default_rng()
v_grid = np.asarray(v_grid, dtype=float)
dv = np.mean(np.diff(v_grid))
n = v_grid.size
a_v = ((h*v_grid)/(2*dv))**0.5 * (rng.standard_normal(n) + 1j*rng.standard_normal(n))
return a_v
[docs]
def shift(f_t, dt, t_shift):
"""
Fourier shift.
The output array is at `f(t - t_shift)`.
Parameters
----------
f_t : array_like
The input array.
dt : float
The grid step size.
shift_t : float
The amount to shift.
Returns
-------
ndarray
The shifted array.
"""
f_t = np.asarray(f_t)
# Grid
n = f_t.shape[-1]
dv = 1/(n*dt)
v_grid = dv*(np.arange(n) - n//2)
# Shift
f_v = fft.fftshift(fft.fft(fft.ifftshift(f_t), fsc=dt))
shift_v = np.exp(-1j*2*pi * v_grid * t_shift)
shift_f_t = fft.fftshift(fft.ifft(fft.ifftshift(f_v * shift_v), fsc=dt))
#
if np.isreal(f_t).all():
shift_f_t = shift_f_t.real
return shift_f_t
[docs]
def resample_v(v_grid, f_v, n):
"""
Resample frequency-domain data to the given number of points.
The complementary time data is assumed to be of finite support, so the
resampling is accomplished by adding or removing trailing and leading time
bins. Discontinuities in the frequency-domain amplitude will manifest as
ringing when resampled.
Parameters
----------
v_grid : array_like of float
The frequency grid of the input data.
f_v : array_like of complex
The frequency-domain data to be resampled.
n : int
The number of points at which to resample the input data. When the
input corresponds to a real-valued time domain representation, this
number is the number of points in the time domain.
Returns
-------
v_grid : ndarray of float
The resampled frequency grid.
f_v : ndarray of real or complex
The resampled frequency-domain data.
dv : float
The spacing of the resampled frequency grid.
dt : float
The spacing of the resampled time grid.
Notes
-----
If the number of points is odd, there are an equal number of points on
the positive and negative side of the time grid. If even, there is one
extra point on the negative side.
This method checks if the origin is contained in `v_grid` to determine
whether real or complex transformations should be performed. In both cases
the resampling is accomplished by removing trailing and leading time bins.
For analytic representations, the returned frequency grid is defined
symmetrically about its reference, as in the `TFGrid` class, and for
real-valued representations the grid is defined starting at the origin.
"""
assert isinstance(n, (int, np.integer)), "The requested number of points must be an integer"
assert (n > 0), "The requested number of points must be greater than 0."
assert (len(v_grid) == len(f_v)), (
"The frequency grid and frequency-domain data must be the same length.")
#---- Inverse Transform
dv_0 = np.diff(v_grid).mean()
if v_grid[0] == 0:
assert np.isreal(f_v[0]), (
"When the input is in the real-valued representation, the amplitude at the origin must be real.")
# Real-Valued Representation
if np.isreal(f_v[-1]):
n_0 = 2*(len(v_grid)-1)
else:
n_0 = 2*(len(v_grid)-1) + 1
dt_0 = 1/(n_0*dv_0)
f_t = fft.fftshift(fft.irfft(f_v, fsc=dt_0, n=n_0))
else:
# Analytic Representation
n_0 = len(v_grid)
dt_0 = 1/(n_0*dv_0)
v_ref_0 = v_grid[n_0//2]
f_t = fft.fftshift(fft.ifft(fft.ifftshift(f_v), fsc=dt_0, overwrite_x=True))
#---- Resample
dn_n = n//2 - n_0//2 # leading time bins
dn_p = (n-1)//2 - (n_0-1)//2 # trailing time bins
if n > n_0:
f_t = np.pad(f_t, (dn_n, dn_p), mode="constant", constant_values=0)
elif n < n_0:
f_t = f_t[-dn_n:n_0+dn_p]
#---- Transform
dt = 1/(n_0*dv_0)
dv = 1/(n*dt)
if v_grid[0] == 0:
# Real-Valued Representation
f_v = fft.rfft(fft.ifftshift(f_t), fsc=dt)
v_grid = dv*np.arange(len(f_v))
else:
# Analytic Representation
f_v = fft.fftshift(fft.fft(fft.ifftshift(f_t), fsc=dt, overwrite_x=True))
v_grid = dv*(np.arange(n) - (n//2))
v_grid += v_ref_0
#---- Construct ResampledV
resampled = _ResampledV(v_grid=v_grid, f_v=f_v, dv=dv, dt=1/(n*dv))
return resampled
[docs]
def resample_t(t_grid, f_t, n):
"""
Resample time-domain data to the given number of points.
The complementary frequency data is assumed to be band-limited, so the
resampling is accomplished by adding or removing high frequency bins.
Discontinuities in the time-domain amplitude will manifest as ringing when
resampled.
Parameters
----------
t_grid : array_like of float
The time grid of the input data.
f_t : array_like of real or complex
The time-domain data to be resampled.
n : int
The number of points at which to resample the input data.
Returns
-------
t_grid : ndarray of float
The resampled time grid.
f_t : ndarray of real or complex
The resampled time-domain data.
dt : float
The spacing of the resampled time grid.
Notes
-----
If real, the resampling is accomplished by adding or removing the largest
magnitude frequency components (both positive and negative). If complex,
the input data is assumed to be analytic, so the resampling is accomplished
by adding or removing the largest positive frequencies. This method checks
the input data's type, not the magnitude of its imaginary component, to
determine if it is real or complex.
The returned time axis is defined symmetrically about the input's
reference, such as in the `TFGrid` class.
"""
assert isinstance(n, (int, np.integer)), "The requested number of points must be an integer"
assert (n > 0), "The requested number of points must be greater than 0."
assert (len(t_grid) == len(f_t)), (
"The time grid and time-domain data must be the same length.")
#---- Define Time Grid
n_0 = len(t_grid)
dt_0 = np.diff(t_grid).mean()
t_ref_0 = t_grid[n_0//2]
dv = 1/(n_0*dt_0)
dt = 1/(n*dv)
t_grid = dt*(np.arange(n) - (n//2))
t_grid += t_ref_0
#---- Resample
if np.isrealobj(f_t):
# Real-Valued Representation
f_v = fft.rfft(fft.ifftshift(f_t), fsc=dt_0)
if (n > n_0) and not (n % 2):
f_v[-1] /= 2 # renormalize aliased Nyquist component
f_t = fft.fftshift(fft.irfft(f_v, fsc=dt, n=n))
else:
# Analytic Representation
f_v = fft.fftshift(fft.fft(fft.ifftshift(f_t), fsc=dt_0, overwrite_x=True))
if n > n_0:
f_v = np.pad(f_v, (0, n-n_0), mode="constant", constant_values=0)
elif n < n_0:
f_v = f_v[:n]
f_t = fft.fftshift(fft.ifft(fft.ifftshift(f_v), fsc=dt, overwrite_x=True))
#---- Construct ResampledT
resampled = _ResampledT(t_grid=t_grid, f_t=f_t, dt=dt)
return resampled
# %% Time and Frequency Grids
[docs]
class TFGrid():
"""
Complementary time- and frequency-domain grids for the representation of
analytic functions with complex-valued envelopes.
The frequency grid is shifted and scaled such that it is aligned with the
origin and contains only positive frequencies. The values given to the
initializers are only targets and may be adjusted slightly. If necessary,
the reference frequency will be increased so that the grids can be formed
without any negative frequencies.
Parameters
----------
n : int
The number of grid points.
v_ref : float
The target central frequency of the grid.
dv : float
The frequency step size. This is equal to the reciprocal of the total
time window.
alias : int, optional
The number of harmonics supported by the real-valued time domain grid
without aliasing. The default is 1, which only generates enough points
for one alias-free Nyquist zone. A higher number may be useful when
simulating nonlinear interactions.
Notes
-----
For discrete Fourier transforms (DFT), the frequency step multiplied by
the time step is always equal to the reciprocal of the total number of
points::
dt*dv == 1/n
Each grid point represents the midpoint of a bin that extends 0.5 grid
spacings in both directions.
Aligning the frequency grid to the origin facilitates calculations using
real Fourier transforms, which have grids that start at zero frequency. The
`rtf_grids` method and the `rn_range` and `rn_slice` attributes are useful
when transitioning between the analytic representation of this class to the
real-valued representation.
By definition of the DFT, the time and frequency grids must range
symmetrically about the origin, with the time grid incrementing in unit
steps and the frequency grid in steps of ``1/n``. The grids of the `TFGrid`
class are scaled and shifted such that they represent absolute time or
frequency values. The scaling is accomplished by setting the forward scale
parameter of the Fourier transforms to ``dt``. The `v_ref` and `t_ref`
variables describe the amount that the `TFGrid` grids need to be shifted
to come into alignment with the origins of the grids implicitly defined by
the DFT.
"""
def __init__(self, n, v_ref, dv, alias=1):
assert isinstance(n, (int, np.integer)), "The number of points must be an integer."
assert (n > 1), "The number of points must be greater than 1."
assert (dv > 0), "The frequency grid step size must be greater than 0."
assert (v_ref > 0), "The target central frequency must be greater than 0."
self._n = n
self._dv = dv
#---- Align Frequency Grid
ref_idx = round(v_ref/self.dv)
if ref_idx < self.n//2 + 1:
ref_idx = self.n//2 + 1
self._v_ref = ref_idx * dv
min_idx = ref_idx - self.n//2
max_idx = ref_idx + ((self.n-1) - self.n//2)
self._rn_range = np.array([min_idx, max_idx])
self._rn_slice = slice(self.rn_range.min(), self.rn_range.max() + 1)
#---- Define Frequency Grid
self.__v_grid = self.dv*(np.arange(self.n) - self.n//2) + self.v_ref
self._v_ref = self.v_grid[self.n//2]
self._v_window = self.n*self.dv
#---- Define Complex Time Grid
self._dt = 1/(self.n*self.dv)
self.__t_grid = self.dt*(np.arange(self.n) - self.n//2)
self._t_ref = self.t_grid[self.n//2]
self._t_window = self.n*self.dt
#---- Define Real-Valued Time and Frequency Domain Grids
assert (alias >= 1), "There must be atleast 1 alias-free Nyquist zone."
self.rtf_grids(alias=alias, update=True)
#---- Class Methods
[docs]
@classmethod
def FromFreqRange(cls, n, v_min, v_max, **kwargs):
"""
Initialize a set of time and frequency grids given the total number of
grid points and a target minimum and maximum frequency.
Parameters
----------
n : int
The number of grid points.
v_min : float
The target minimum frequency.
v_max : float
The target maximum frequency.
"""
assert (v_max > v_min), (
"The target maximum frequency must be greater than the target minimum frequency.")
dv = (v_max - v_min)/(n-1)
v_ref = 0.5*(v_min + v_max)
self = cls(n, v_ref, dv, **kwargs)
return self
#---- General Properties
@property
def n(self):
"""
The number of grid points of the analytic representation.
This value is the same for both the time and frequency grids.
Returns
-------
int
"""
return self._n
@property
def rn(self):
"""
The number of grid points of the real-valued time domain
representation.
Returns
-------
int
"""
return self._rn
@property
def rn_range(self):
"""
The minimum and maximum indices of the origin-contiguous frequency
grid, associated with the real-valued time domain representation, that
correspond to the first and last points of the analytic frequency grid.
These values are useful for indexing and constructing frequency grids
for applications with real DFTs.
Returns
-------
ndarray of float
"""
return self._rn_range
@property
def rn_slice(self):
"""
A slice object that indexes the origin-contiguous frequency grid,
associated with the real-valued time domain representation, onto the
analytic frequency grid.
This is useful for indexing and constructing frequency gridded arrays
for applications with real DFTs. It is assumed that the arrays are
arranged such that the frequency coordinates are monotonically ordered.
Returns
-------
slice
"""
return self._rn_slice
#---- Frequency Grid Properties
@property
def v_grid(self):
"""
The frequency grid of the analytic representation, with units of
``Hz``.
The frequency grid is aligned to the origin and contains only positive
frequencies.
Returns
-------
ndarray of float
"""
return self.__v_grid
@property
def _v_grid(self):
"""
The frequency grid of the analytic representation, arranged in standard
fft order.
Returns
-------
ndarray of float
"""
return fft.ifftshift(self.v_grid)
@property
def v_ref(self):
"""
The grid reference frequency of the analytic representation, with units
of ``Hz``.
This is the offset between `v_grid` and the frequency grid of the
complex-envelope representation implicitly defined by a DFT with `n`
points.
Returns
-------
float
"""
return self._v_ref
@property
def dv(self):
"""
The frequency grid step size of the analytic representation, with units
of ``Hz``.
Returns
-------
float
"""
return self._dv
@property
def v_window(self):
"""
The span of the frequency grid in the analytic representation, with
units of ``Hz``.
This is equal to the number of grid points times the frequency grid
step size.
Returns
-------
float
"""
return self._v_window
#---- Time Grid Properties
@property
def t_grid(self):
"""
The time grid of the analytic representation, with units of ``s``.
The time grid is aligned symmetrically about the origin.
Returns
-------
ndarray of float
"""
return self.__t_grid
@property
def _t_grid(self):
"""
The time grid of the analytic representation arranged in standard fft
order.
Returns
-------
ndarray of float
"""
return fft.ifftshift(self.t_grid)
@property
def t_ref(self):
"""
The grid reference time of the analytic representation, with units of
``s``.
This is the offset between `t_grid` and the time grid of the
complex-envelope representation implicitly defined by a DFT with `n`
points.
Returns
-------
float
"""
return self._t_ref
@property
def dt(self):
"""
The time grid step size of the analytic representation, with units of
``s``.
The time step is used as the differential of Fourier transforms.
Multiplying the input of the transform by this factor will preserve the
integrated absolute squared magnitude of the transformed result::
a_v = fft.fft(a_t, fsc=dt)
np.sum(np.abs(a_t)**2 * dt) == np.sum(np.abs(a_v)**2 * dv)
Returns
-------
float
"""
return self._dt
@property
def t_window(self):
"""
The span of the time grid in the analytic representation, with units of
``s``.
This is equal to the number of grid points times the time grid step
size.
Returns
-------
float
"""
return self._t_window
#---- Real Time/Frequency Grid Properties
@property
def rv_grid(self):
"""
The origin-contiguous frequency grid of the real-valued time domain
representation, with units of ``Hz``.
Returns
-------
ndarray of float
"""
return self.__rv_grid
@property
def rv_ref(self):
"""
The grid reference frequency of the real-valued time domain
representation, with units of ``Hz``.
Returns
-------
float
"""
return self._rv_ref
@property
def rdv(self):
"""
The frequency grid step size of the real-valued time domain
representation, with units of ``Hz``.
This is equal to the frequency grid step size of the analytic
representation.
Returns
-------
float
"""
return self._dv
@property
def rv_window(self):
"""
The span of the frequency grid in the real-valued time domain
representation, with units of ``Hz``.
Returns
-------
float
"""
return self._rv_window
@property
def rt_grid(self):
"""
The time grid of the real-valued time domain representation, with
units of ``s``.
Returns
-------
ndarray of float
"""
return self.__rt_grid
@property
def _rt_grid(self):
"""
The time grid of the real-valued time domain representation, arranged
in standard fft order.
Returns
-------
ndarray of float
"""
return fft.ifftshift(self.rt_grid)
@property
def rt_ref(self):
"""
The grid reference time of the real-valued time domain representation,
with units of ``s``.
Returns
-------
float
"""
return self._rt_ref
@property
def rdt(self):
"""
The time grid step size of the real-valued time domain representation,
with units of ``s``.
Returns
-------
float
"""
return self._rdt
@property
def rt_window(self):
"""
The span of the time grid in the real-valued time domain
representation, with units of ``s``.
Returns
-------
float
"""
return self._rt_window
[docs]
def rtf_grids(self, alias=1, fast_n=True, update=False):
"""
Complementary time and frequency domain grids for the representation of
analytic functions with real-valued amplitudes.
The `alias` parameter determines the number of harmonics the time grid
supports without aliasing. In order to maintain efficient DFT behavior,
the number of points can be extended further based on the output of
`scipy.fft.next_fast_len` for aliases greater than or equal to 1. An
alias of 0 returns the set of time and frequency grids consistent with
a real-valued function defined over the original, analytic `t_grid`.
The resulting frequency grid contains the origin and positive
frequencies and is suitable for use with real DFTs (see `fft.rfft` and
`fft.irfft`).
Parameters
----------
alias : float, optional
The harmonic support of the generated grids (the number of
alias-free Nyquist zones). The default is 1, the fundamental
harmonic.
fast_n : bool, optional
A flag that determines whether the length of the new array is
extended up to the next fast fft length. The default is to extend.
This parameter has no effect when the `alias` is 0.
update : bool, optional
A flag that determines whether to update the real-valued time and
frequency domain grids of the parent object with the output of
this method. The default is to return the calculated grids without
updating the associated values stored in the class. This parameter
is only valid when `alias` is greater than or equal to 1.
Returns
-------
n : int
The number of grid points.
v_grid : array of float
The origin-contiguous frequency grid.
v_ref : float
The grid reference frequency.
dv : float
The frequency grid step size.
v_window : float
The span of the frequency grid.
t_grid : array of float
The time grid.
t_ref : float
The grid reference time.
dt : float
The time grid step size.
t_window : float
The span of the time grid.
Notes
-----
To avoid dealing with case-specific amplitude scale factors when
transforming between analytic and real-valued representations the
frequency grid for complex-valued functions must not contain the origin
and there must be enough points in the real-valued representation to
avoid aliasing the Nyquist frequency of the analytic representation.
The initializer of the `TFGrid` class enforces the first condition, the
frequency grid starts at minimum one step size away from the origin,
and this method enforces the second by making the minimum number of
points odd if the real grid only extends to the first harmonic.
The transformation between representations is performed as in the
following example, with `tf` an instance of the `TFGrid` class, `rtf`
the output of this method, `a_v` the spectrum of a complex-valued
envelope defined over `v_grid`, `ra_v` the spectrum of the real-valued
function defined over `rtf.v_grid`, and `ra_t` the real-valued
function defined over `rtf.t_grid`. The ``1/2**0.5`` scale factor
between `a_v` and `ra_v` preserves the integrated squared magnitude in
the time domain::
rtf = tf.rtf_grids()
ra_v = np.zeros_like(rtf.v_grid, dtype=complex)
ra_v[tf.rn_slice] = 2**-0.5 * a_v
ra_t = fft.irfft(ra_v, fsc=rtf.dt, n=rtf.n)
np.sum(ra_t**2 * rtf.dt) == np.sum(np.abs(a_v)**2 * tf.dv)
"""
#---- Number of Points
if alias==0:
n = self.n
else:
assert (alias >= 1), "The harmonic support must be atleast 1."
target_n_v = round(self.rn_range.max()*alias)
if alias == 1:
n = 2*target_n_v - 1 # odd
else:
n = 2*(target_n_v - 1) # even
if fast_n:
n = fft.next_fast_len(n)
n_v = n//2 + 1 # points in the frequency grid
#---- Define Frequency Grid
v_grid = self.dv*np.arange(n_v)
v_ref = v_grid[0]
#---- Define Time Grid
dt = 1/(n*self.dv)
t_grid = dt*(np.arange(n) - n//2)
t_ref = t_grid[n//2] # 0 by definition
#---- Construct RTFGrid
rtf_grids = _RTFGrid(
n=n,
v_grid=v_grid, v_ref=v_ref, dv=self.dv, v_window=n_v*self.dv,
t_grid=t_grid, t_ref=t_ref, dt=dt, t_window=n*dt)
if update and alias!=0:
self._rn = rtf_grids.n
# Frequency Grid
self.__rv_grid = rtf_grids.v_grid
self._rv_ref = rtf_grids.v_ref
self._rv_window = rtf_grids.v_window
# Time Grid
self.__rt_grid = rtf_grids.t_grid
self._rt_ref = rtf_grids.t_ref
self._rdt = rtf_grids.dt
self._rt_window = rtf_grids.t_window
return rtf_grids
#---- Misc
[docs]
def copy(self):
"""Copy the time and frequency grids."""
return copy.deepcopy(self)