solprop.nonlinear.responses#

Nonlinear response definitions and their declared traits.

A response says how a medium answers a field: the nonlinear polarisation P(t) it produces from the field E(t) at a point, and the declared traits the translation layer needs in order to compile a set of responses into one fused time-domain pass. This module holds the Kerr and plasma responses, SEP 6’s frequency-dependent electronic response (DispersiveKerrResponse), and the trait vocabulary the delayed Raman response of solprop.nonlinear.raman extends.

The evaluate method here is the NumPy reference: the oracle every backend kernel is tested against, written for clarity rather than for speed (docs/dev/architecture.md section 3). Units, signs and the analytic-signal convention follow docs/dev/conventions.md sections 2 and 4, and the derivation of every numerical factor is in docs/guide/media/nonlinear.md.

Module Attributes

Domain

How a response is evaluated: sample by sample, with memory along t, as a convolution, or after the forward transform (docs/dev/architecture.md section 6.6).

Polarisation

How a response treats the polarisation axis.

Evaluator

SEP 6's two dispersive evaluators: the symmetric factorisation of the Kerr diagonal, and the gas dispersion polynomial itself.

Sector

Which quartets a dispersive cubic keeps: all of them, or the number-conserving ones with two positive and two negative signed legs (SEP 6, Phase 0, decision Q4).

MAX_N_POL

Largest polarisation axis a response accepts: (x, y) (conventions.md 5).

Functions

dispersive_cubic(field_t, weights, *, kind, ...)

Return SEP 6's dispersive cubic of a field, per unit eps0 gamma3_0 rho.

mixture_filter(filters, factors)

Return the symmetric evaluator's filter of a gas mixture and its coefficient.

thg_carrier(omega0, t)

Return the carrier factor an envelope-grid third-harmonic term needs.

Classes

DispersiveKerrResponse(model, evaluator[, ...])

The frequency-dependent electronic third-order response of a gas (SEP 6).

DispersiveWeights(evaluator, positive, filter)

The spectral multipliers of one species' dispersive cubic on a fine grid.

KerrResponse(chi3[, thg, species])

The instantaneous third-order (Kerr) polarisation of an isotropic medium.

PlasmaResponse(gas, rate, *[, ...])

The polarisation of the free electrons a strong field tears from a gas.

ResponseTraits(domain, grids, polarisation, ...)

What the translation layer must know about a response to compile it.

class solprop.nonlinear.responses.DispersiveKerrResponse(model, evaluator, thg=True, species=None)[source]#

Bases: object

The frequency-dependent electronic third-order response of a gas (SEP 6).

One broadband polarisation from optmat’s whole hyperpolarizability model of the gas, P = eps0 gamma3_0 rho x (a filtered cubic of the field), the kernel evaluated at the absolute frequencies of every quartet the cubic couples. No SPM, XPM, FWM or THG event is identified: the four-frequency rule assigns each its coefficient.

Two evaluators (docs/dev/seps/archive/sep-0006-dispersive-nonlinearity.md, sections 4.4-4.5 and the Phase 0 report):

"polynomial"

The gas dispersion polynomial itself, \(\gamma_0[1 + a S_2 + b S_2^2 + b' S_4]\), by its grouped FFT form: exact evaluation of the fit on the resolved grid.

"symmetric"

The symmetric factorisation of the Kerr diagonal, \(\gamma_0\prod_j g(\omega_j)\) with \(g = [\gamma_K(\tilde\nu)/\gamma_0]^{1/4}\): exact on the Kerr diagonal and to second order elsewhere, one filtered product.

Every leg is held at the gas’s leg bound \(\nu_c\) (section 4.10): the weight \(\tilde d = \min((\omega/2\pi c)^2, \nu_c^2)\), real and even, so the lossless work identity holds and no frequency a field occupies is refused.

thg selects the sector (Phase 0, decision Q4): True is the full cubic, all quartets, harmonic and inverse harmonic exchange included; False is the number-conserving sector, the quartets with two positive and two negative signed legs, with \(\Pi_+\) on absolute frequency. Below an octave the latter equals the constant cubic’s thg=False rule; beyond it, unlike that rule, it does no net work. Two polarisation components contract through the isotropic Kleinman tensor, the scalar kernel’s named vector approximation (decision Q3).

Attributes:
modelsolprop.physdata.nonlinear_data.DispersiveKerrModel

optmat’s model of the gas in SI, with its leg bound and band edges.

evaluator{‘symmetric’, ‘polynomial’}

Which evaluator.

thgbool, optional

True for the full cubic, False for the number-conserving sector. Default True.

speciesstr or None, optional

Component label in a mixture; None retains the single-medium spelling.

Raises:
MaterialError

If the evaluator is not one of the two.

Examples

>>> from solprop.nonlinear.responses import DispersiveKerrResponse
>>> from solprop.physdata.nonlinear_data import OptmatKerr
>>> model = OptmatKerr("Ar").dispersive_model()
>>> response = DispersiveKerrResponse(model, "polynomial", thg=False)
>>> response.sector, response.traits.domain
('conserving', 'convolution')
property coefficient: float[source]#

Return eps0 gamma3_0, the static coefficient per unit density.

Returns:
float

In C m^2/V^3 (m^5/V^2 times eps0), multiplying the number density.

evaluate(field_t, density, *, kind, omega, carrier=None, window=None)[source]#

Return the polarisation on the fine time grid: the reference evaluation.

The oracle every backend is tested against, itself held to the triplet sum of SEP 6’s Phase 0 (tests/dispersive_kerr_oracle.py). It allocates and is not written for speed.

Parameters:
field_tnumpy.ndarray

The field on the fine time grid, shape (n_pol, n_to): real E(t) in V/m on a field grid, the complex envelope A(t) in V/m on an envelope grid. One, two or three components: three is the general modal route’s full vector field, contracted through the same isotropic tensor.

densityfloat

Number density in m^-3, non-negative.

kind{‘field’, ‘envelope’}

The spectral layout the samples belong to.

omeganumpy.ndarray

The absolute angular frequency of every bin of the fine transform in rad/s: shape (n_to // 2 + 1,) on a field grid, (n_to,) in FFT order on an envelope grid (TimeGrid.omega_fine).

carriernumpy.ndarray or None, optional

exp(2 i omega0 t) on the fine grid, shape (n_to,); required when kind='envelope' and thg is true, rejected otherwise (thg_carrier()).

windownumpy.ndarray or None, optional

The temporal collar in [0, 1], shape (n_to,): the grid’s t_window_fine, which a propagation applies. It multiplies each product in time before the output multipliers, G[w (G E)^3] (dispersive_cubic()); None, the default, is a unit collar.

Returns:
numpy.ndarray

The polarisation in C/m^2, shape (n_pol, n_to): real on a field grid, the envelope of the analytic polarisation on an envelope grid.

Raises:
GridError

If the layout is unknown, the arrays do not match it, the frequencies do not match the transform, the carrier is missing, unwanted or the wrong length, or the collar’s length is not the field’s.

evaluator: str#
model: DispersiveKerrModel#
property sector: Literal['full', 'conserving'][source]#

Return which quartets the response keeps.

Returns:
{‘full’, ‘conserving’}

"full" with thg, "conserving" without.

species: str | None = None#
thg: bool = True#
property traits: ResponseTraits[source]#

Return the declared traits of the response.

Returns:
ResponseTraits

A convolution in time (spectral multipliers on the inputs and the output), both grids, vector-isotropic, consuming the density path.

weights(omega, *, kind)[source]#

Return the multipliers on a fine grid’s spectral layout.

Parameters:
omeganumpy.ndarray

The absolute angular frequency of every bin, rad/s: k domega on a field grid’s one-sided layout, omega0 + v in FFT order on an envelope grid.

kind{‘field’, ‘envelope’}

The layout.

Returns:
DispersiveWeights

The response’s multipliers there, read-only.

class solprop.nonlinear.responses.DispersiveWeights(evaluator, positive, filter, output=None, products=(0.0, 0.0))[source]#

Bases: object

The spectral multipliers of one species’ dispersive cubic on a fine grid.

Every multiplier is a real, even function of the absolute angular frequency of each bin of the fine transform’s spectrum: the one-sided rfft layout, (n_to // 2 + 1,), on a field grid, and the full FFT-order layout, (n_to,), on an envelope grid. The compiled bundle of the translation layer carries the same arrays, one row per species (solprop.translate.responses.DispersiveKerrTerms).

Attributes:
evaluator{‘symmetric’, ‘polynomial’}

Which evaluator the arrays belong to.

positivenumpy.ndarray

Pi_+: the bins of strictly positive absolute frequency, bool. On a field grid the DC and the Nyquist bins are excluded.

filternumpy.ndarray

The input multiplier, float64: the symmetric factor g (dimensionless), or the polynomial’s bounded leg weight d in m^-2.

outputnumpy.ndarray or None

The polynomial’s two output multipliers, float64, shape (2, n_omega_fine): 1 + a d + (b + b') d^2 (dimensionless) and 3 a + 6 b d (m^2). None for the symmetric evaluator, whose output multiplier is filter again.

productstuple of float

The polynomial’s constant weights of E^2 D^2 E and E (D E)^2, 3 (b + b') and 6 b in m^4; (0, 0) for the symmetric evaluator.

evaluator: Literal['symmetric', 'polynomial']#
filter: Float[ndarray, 'n_omega_fine']#
output: Float[ndarray, '2 n_omega_fine_out'] | None = None#
positive: Bool[ndarray, 'n_omega_fine']#
products: tuple[float, float] = (0.0, 0.0)#
solprop.nonlinear.responses.Domain#

How a response is evaluated: sample by sample, with memory along t, as a convolution, or after the forward transform (docs/dev/architecture.md section 6.6).

alias of Literal[‘pointwise’, ‘memory’, ‘convolution’, ‘spectral’]

solprop.nonlinear.responses.Evaluator#

SEP 6’s two dispersive evaluators: the symmetric factorisation of the Kerr diagonal, and the gas dispersion polynomial itself.

alias of Literal[‘symmetric’, ‘polynomial’]

class solprop.nonlinear.responses.KerrResponse(chi3, thg=True, species=None)[source]#

Bases: object

The instantaneous third-order (Kerr) polarisation of an isotropic medium.

P = eps0 chi3 rho x (a cubic form of the field), on either grid, for one or two polarisation components. The cubic form differs between the grids because a field grid resolves the carrier and an envelope grid does not; the four cases and their numerical factors are derived in docs/guide/media/nonlinear.md.

chi3 is the susceptibility per unit of the density argument, so a gas passes the per-particle hyperpolarisability with its number density in m^-3 and a fixed medium passes chi3 itself with a density of 1.0. This is the convention the kernel contract’s density profile assumes.

Attributes:
chi3float

Third-order susceptibility per unit density, m^2/V^2 when the density is dimensionless, m^5/V^2 when it is a number density in m^-3.

thgbool, optional

Whether the response radiates the third harmonic. On a field grid True is the full cubic and False removes the harmonic; on an envelope grid True adds the harmonic term, which needs a grid built with thg=True and the carrier from thg_carrier(). Default True.

speciesstr or None, optional

Component label in a mixture; None retains the single-medium spelling.

Examples

>>> import numpy as np
>>> from solprop.constants import EPS0
>>> from solprop.nonlinear.responses import KerrResponse
>>> kerr = KerrResponse(chi3=1.0, thg=True)
>>> kerr.traits.domain
'pointwise'
>>> field = np.array([[1.0, 2.0]])
>>> polarisation = kerr.evaluate(field, 1.0, kind="field")
>>> bool(np.array_equal(polarisation, EPS0 * np.array([[1.0, 8.0]])))
True
chi3: float#
evaluate(field_t, density, *, kind, carrier=None)[source]#

Return the Kerr polarisation on the fine time grid.

The reference implementation, and therefore the oracle for every backend. It allocates and is not written for speed.

Parameters:
field_tnumpy.ndarray

The field on the fine time grid, shape (n_pol, n_to): real E(t) in V/m on a field grid, complex A(t) in V/m on an envelope grid.

densityfloat

Density multiplying chi3: a number density in m^-3 for a gas, 1.0 for a medium whose chi3 is already absolute. Non-negative.

kind{‘field’, ‘envelope’}

The spectral layout the samples belong to.

carriernumpy.ndarray or None, optional

exp(2 i omega0 t) on the fine grid, shape (n_to,); required when kind='envelope' and thg is true, rejected otherwise. Build it with thg_carrier().

Returns:
numpy.ndarray

The polarisation in C/m^2, shape (n_pol, n_to), real on a field grid and complex on an envelope grid.

Raises:
GridError

If the layout is unknown, the array shape or dtype does not match it, or the carrier is missing, unwanted or the wrong length.

Examples

>>> import numpy as np
>>> from solprop.constants import EPS0
>>> from solprop.nonlinear.responses import KerrResponse
>>> envelope = np.array([[2.0 + 0.0j]])
>>> out = KerrResponse(chi3=1.0, thg=False).evaluate(
...     envelope, 1.0, kind="envelope"
... )
>>> bool(np.allclose(out, 0.75 * EPS0 * 8.0))
True
species: str | None = None#
thg: bool = True#
property traits: ResponseTraits[source]#

Return the declared traits of the Kerr response.

Returns:
ResponseTraits

Pointwise, both grids, vector-isotropic, consuming the density path.

Examples

>>> from solprop.nonlinear.responses import KerrResponse
>>> KerrResponse(1.0).traits.z_profiles
('density',)
solprop.nonlinear.responses.MAX_N_POL = 2#

Largest polarisation axis a response accepts: (x, y) (conventions.md 5).

class solprop.nonlinear.responses.PlasmaResponse(gas, rate, *, preionisation=0.0, species=None, integration='band')[source]#

Bases: object

The polarisation of the free electrons a strong field tears from a gas.

Two currents, both per unit density and both integrated along the time axis (Geissler et al. 1999). Write f(t) for the fraction of atoms ionised by time t and W for the ionisation rate at the instantaneous field:

  • the free-electron current J_free = (e^2/m_e) int f E dt, the current of electrons already freed and being accelerated by the field. It is what lowers the refractive index and blue-shifts the pulse;

  • the ionisation loss J_loss = I_p W (1-f) E / |E|^2, added pointwise. Its form is chosen so that E . J_loss = I_p W (1-f) = I_p df/dt exactly: the field pays one ionisation potential for every electron it frees.

The polarisation is rho times the integral of their sum, so evaluate does three cumulative integrals in all. It is a memory response: the value at one sample depends on every earlier sample. Under the plain trapezoid it depends on none of the later ones; under the band rule (SEP 18) each integral reads K samples ahead, so the polarisation at one sample depends on samples up to 3K later, a band-limited approximation to the causal response whose precursor is confined to those samples.

Production forms the held-current source directly in frequency (SEP 27), using only the population and free-current integrals. This does not change the physical polarisation returned by evaluate.

integration selects the three physical-response integrals. 'band' filters each integrand with symmetric taps designed for the grid, so every integral is exact to a certified 1e-5 across the active band. The plain trapezoid is short by 1 - (theta/2) cot(theta/2) per integral at theta = omega dt, 11 % at the top of the default active band. 'trapezoid' is that plain rule, bit for bit, and is what the frozen witness comparisons use. The taps belong to the grid, not to the response: a propagation designs them in translation, and a direct call to evaluate() or ionised_fraction() under the band rule is passed them as integral (solprop.translate.plasma_integration.design_running_integral()).

The ionised fraction is f = 1 - (1 - f_0) exp(-int W dt), with the pre-ionised fraction inside the exponential. Adding it outside, f_0 + 1 - exp(-int W dt), would exceed one, make 1 - f negative and break the energy bookkeeping above, because df/dt would then no longer be W (1 - f).

preionisation is a number or a path along z: the fraction ionised before the pulse arrives at each position. The trait declares it as a path because that is what the translation layer supplies – a value per plane – and the reference evaluation sees one plane at a time. Where the path varies, the oracle for a plane is dataclasses.replace() with that plane’s value, which rebuilds and revalidates the response; the profile itself is never resolved here, and evaluate() and ionised_fraction() refuse a response that still carries one.

Attributes:
gasGas or str or Species

The species, for its ionisation potential. Beside a rate that carries its own energy per event (an OptmatRate), the species is stored with that energy as its potential (bind_rate_energy(), SEP 3, D8).

rateEvaluableRate

The ionisation rate in s^-1 as a function of field amplitude in V/m: optmat’s solprop.nonlinear.optmat_rates.OptmatRate, a caller’s rate, or the solprop.translate.ionisation.RateTable a propagation actually uses. It must be exactly zero at zero field, which is what makes the loss term finite.

preionisationfloat or ZProfile, optional

Fraction already ionised before the pulse, in [0, 1]: a number, or a path along z in m giving that fraction at each position. A crude model: it says nothing about where those electrons came from or what they were doing.

speciesstr or None, optional

Component label, distinct from the chemical species when populations split.

integration{‘band’, ‘trapezoid’}, optional

The running-integral rule of the column (SEP 18): the band rule, the default, or the plain cumulative trapezoid.

Raises:
MaterialError

If the gas is unknown or has no ionisation potential, the pre-ionised fraction is neither a number nor a path or leaves [0, 1], the rate is not zero at zero field, it carries an energy per event for another species, or integration is not a known rule.

Examples

>>> import numpy as np
>>> from solprop.nonlinear.optmat_rates import OptmatRate
>>> from solprop.nonlinear.responses import PlasmaResponse
>>> plasma = PlasmaResponse("Ar", OptmatRate("Ar", 800e-9))
>>> plasma.traits.domain
'memory'

Evaluated directly, the band rule is handed its grid’s design:

>>> from solprop.grid import FieldGrid
>>> from solprop.translate.plasma_integration import design_running_integral
>>> grid = FieldGrid.from_limits(1.0, 800e-9, (200e-9, 2e-6), 40e-15)
>>> t = (np.arange(grid.n_to) - grid.n_to // 2) * grid.dt_fine
>>> field = 4e10 * np.exp(-((t / 8e-15) ** 2))
>>> rule = design_running_integral(grid, "band")
>>> fraction = plasma.ionised_fraction(
...     field[None, :], dt=grid.dt_fine, kind="field", integral=rule
... )
>>> f"{fraction[-1]:.4f}"
'0.3209'
evaluate(field_t, density, *, dt, kind, integral=None)[source]#

Return the plasma polarisation on the fine time grid.

Return the physical, ungated polarisation, including its post-pulse drift. This reference integrates the population, free current and polarisation in three separate passes. Field-grid propagation instead holds the same total current at the temporal boundary: it assembles DFT[g J]/(i omega) - DFT[g' J]/(i omega)**2 on active bins (SEP 27). That assembly omits the final running integral; it does not change this physical-response oracle.

Parameters:
field_tnumpy.ndarray

The real field on the fine time grid, V/m, shape (n_pol, n_to).

densityfloat

Number density of the gas in m^-3, non-negative. The rate and the fraction are per atom; this is what turns them into a polarisation.

dtfloat

Sample spacing of the fine time grid, s, positive.

kind{‘field’}

The spectral layout; only a field grid is defined.

integralRunningIntegral or None, optional

The grid’s running-integral rule, required under integration='band'.

Returns:
numpy.ndarray

The polarisation in C/m^2, shape (n_pol, n_to), real.

Raises:
GridError

If the layout is not a field grid, the array shape or dtype does not match it, the time step is not positive, the density is negative, or the rate returns the wrong shape or a value that is not finite and non-negative; or if the integration rule is missing, is another rule than the response’s, or was designed for another dt.

MaterialError

If the preionisation is a path along z rather than one plane’s value.

Examples

>>> import numpy as np
>>> from solprop.nonlinear.optmat_rates import OptmatRate
>>> from solprop.nonlinear.responses import PlasmaResponse
>>> t = np.linspace(-2e-14, 2e-14, 257)
>>> dt = float(t[1] - t[0])
>>> field = 4e10 * np.exp(-((t / 8e-15) ** 2)) * np.cos(2.35e15 * t)
>>> plasma = PlasmaResponse(
...     "Ar", OptmatRate("Ar", 800e-9), integration="trapezoid"
... )
>>> out = plasma.evaluate(field[None, :], 2.5e25, dt=dt, kind="field")
>>> f"{out[0, -1]:.4e}"
'8.9313e-07'
gas: Gas | str | Species#
integration: Literal['band', 'trapezoid'] = 'band'#
ionised_fraction(field_t, *, dt, kind, integral=None)[source]#

Return the fraction of atoms ionised, sample by sample.

Multiply by the density for the electron density, which is what a run reports as a statistic. The rate is taken on the magnitude of the field, so an elliptically polarised pulse ionises according to how hard it pulls, not according to one component.

Parameters:
field_tnumpy.ndarray

The real field on the fine time grid, V/m, shape (n_pol, n_to). It is annotated as any number and checked at run time, as in KerrResponse.evaluate(), so that the guard is the authority on the dtype rather than the test session’s type checker.

dtfloat

Sample spacing of the fine time grid, s, positive.

kind{‘field’}

The spectral layout; only a field grid is defined.

integralRunningIntegral or None, optional

The grid’s running-integral rule, required under integration='band'.

Returns:
numpy.ndarray

Fraction ionised, in [preionisation, 1], shape (n_to,), non-decreasing in time (under the band rule, to within the filter’s undershoot).

Raises:
GridError

If the layout is not a field grid, the array shape or dtype does not match it, the time step is not positive, or the rate returns the wrong shape or a value that is not finite and non-negative; or if the integration rule is missing, is another rule than the response’s, or was designed for another dt.

MaterialError

If the preionisation is a path along z rather than one plane’s value.

Examples

>>> import numpy as np
>>> from solprop.nonlinear.optmat_rates import OptmatRate
>>> from solprop.nonlinear.responses import PlasmaResponse
>>> plasma = PlasmaResponse(
...     "Ar", OptmatRate("Ar", 800e-9), preionisation=0.25,
...     integration="trapezoid",
... )
>>> field = np.full((1, 8), 1e6)
>>> plasma.ionised_fraction(field, dt=1e-16, kind="field").tolist()
[0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25]
property potential: float[source]#

Return the ionisation potential without inventing custom species data.

Returns:
float

First ionisation potential in J.

Raises:
MaterialError

If the named custom species has no ionisation data.

preionisation: float | ZProfile = 0.0#
rate: EvaluableRate#
species: str | None = None#
property traits: ResponseTraits[source]#

Return the declared traits of the plasma response.

Returns:
ResponseTraits

With memory, field grid only, vector-isotropic, consuming the density and pre-ionisation paths.

Examples

>>> from solprop.nonlinear.optmat_rates import OptmatRate
>>> from solprop.nonlinear.responses import PlasmaResponse
>>> PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)).traits.grids
('field',)
solprop.nonlinear.responses.Polarisation#

How a response treats the polarisation axis.

alias of Literal[‘scalar’, ‘vector_isotropic’, ‘tensor’]

class solprop.nonlinear.responses.ResponseTraits(domain, grids, polarisation, z_profiles)[source]#

Bases: object

What the translation layer must know about a response to compile it.

The four declared traits (see docs/dev/decisions.md). They are data, not behaviour: the compiler reads them to choose a fused traversal, and a response that lies about them produces a wrong kernel rather than an error.

Attributes:
domain{‘pointwise’, ‘memory’, ‘convolution’, ‘spectral’}

How the response is evaluated along the time axis.

gridstuple of {‘field’, ‘envelope’}

The spectral layouts the response is defined on.

polarisation{‘scalar’, ‘vector_isotropic’, ‘tensor’}

How the response couples the polarisation components.

z_profilestuple of str

Names of the scalar paths along z the response consumes, for example ("density",). Empty for a response that does not vary with z.

Examples

>>> from solprop.nonlinear.responses import ResponseTraits
>>> traits = ResponseTraits("pointwise", ("field",), "scalar", ())
>>> traits.domain, traits.grids
('pointwise', ('field',))
domain: Literal['pointwise', 'memory', 'convolution', 'spectral']#
grids: tuple[Literal['field', 'envelope'], ...]#
polarisation: Literal['scalar', 'vector_isotropic', 'tensor']#
z_profiles: tuple[str, ...]#
solprop.nonlinear.responses.Sector#

Which quartets a dispersive cubic keeps: all of them, or the number-conserving ones with two positive and two negative signed legs (SEP 6, Phase 0, decision Q4).

alias of Literal[‘full’, ‘conserving’]

solprop.nonlinear.responses.dispersive_cubic(field_t, weights, *, kind, sector, carrier=None, window=None)[source]#

Return SEP 6’s dispersive cubic of a field, per unit eps0 gamma3_0 rho.

The forms Phase 0 derived and holds to an explicit triplet sum. The symmetric evaluator is one product of the filtered field, G[(G E)^3], with G the multiplier g. The polynomial evaluator is the grouped sum

\[[1 + a\tilde d + (b + b')\tilde d^2]\,E^3 + [3a + 6b\tilde d]\,E^2\tilde DE + 3(b + b')\,E^2\tilde D^2E + 6b\,E(\tilde DE)^2,\]

each bracket an output multiplier, with D~ the bounded leg weight. A product of three fields is the isotropic Kleinman contraction of _isotropic, and the sector restricts it to the quartets kept (_product_spectrum). The output multipliers commute with Pi_+ and with taking the real part, so every product’s spectrum is weighted and summed before one inverse transform.

The temporal collar w multiplies each product in time, before Pi_+ and the output multipliers: G[w (G E)^3], and the polynomial’s output multipliers act on the windowed products (decided by John Travers, 2026-10-08, SEP 6 Phase 2a, open question 1). With a unit collar this is the operator without one; the order matters only where the collar varies.

The products are formed on the fine grid, which must hold the cubic’s support for the result to be the convolution it stands for: the same oversampling rule as the constant cubic’s (conventions.md section 3).

Parameters:
field_tnumpy.ndarray

The field on the fine time grid, shape (..., n_pol, n_to): real E(t) on a field grid, the complex envelope A(t) on an envelope grid, in V/m.

weightsDispersiveWeights

The species’ multipliers on the fine grid’s spectral layout.

kind{‘field’, ‘envelope’}

The spectral layout.

sector{‘full’, ‘conserving’}

Which quartets are kept.

carriernumpy.ndarray or None, optional

exp(2 i omega0 t) on the fine grid; required for the full sector on an envelope grid and ignored otherwise.

windownumpy.ndarray or None, optional

The temporal collar grids.t_window_fine in [0, 1], shape (n_to,); None is a unit collar.

Returns:
numpy.ndarray

The cubic in V^3/m^3 (times m^0: every multiplier is normalised by the static coefficient), real on a field grid and complex on an envelope grid, of the input’s shape.

solprop.nonlinear.responses.mixture_filter(filters, factors)[source]#

Return the symmetric evaluator’s filter of a gas mixture and its coefficient.

SEP 6, section 6, adopted for the symmetric evaluator of a mixture (decided by John Travers, 2026-10-08, Phase 2b, open question 3): one filter from the mixture’s own Kerr diagonal, \(\gamma_{K,\rm mix}(\nu) = \sum_s N_s\gamma_{K,s}(\tilde \nu_s)\), factorised exactly as section 4.4 factorises a pure gas’s. With \(f_s = \epsilon_0\gamma_{3,s} N_s\), each species’ static coefficient times its density, and \(\hat K_s = g_s^4 = \gamma_{K,s}(\tilde\nu_s)/\gamma_{3,s}\), its own Kerr diagonal under its own leg bound,

\[g_{\rm mix} = \Big[\sum_s f_s \hat K_s \Big/ \sum_s f_s\Big]^{1/4}, \qquad P = \Big(\sum_s f_s\Big)\,G_{\rm mix}[w\,(G_{\rm mix}E)^3].\]

On the Kerr diagonal the four legs give \((\sum_s f_s) g_{\rm mix}^4 = \sum_s f_s\hat K_s = \epsilon_0\gamma_{K,\rm mix}\), the mixture’s own degenerate coefficient. The reference is the mixture’s static coefficient \(\sum_s f_s\), so \(g_{\rm mix}(0) = 1\); as in section 4.4 any other positive reference \(\gamma_*\) cancels, moving a factor between the coefficient and \(g^4\). Off the diagonal this approximates the exact per-species sum \(\sum_s f_s G_s[w (G_s E)^3]\), as Phase 2b measured; for one species it is that species’ operator, which this function returns unchanged rather than recomputed.

The filter depends on the composition \(f_s / \sum f_s\), not on the total density, so a backend rebuilds it wherever the composition changes along z. The densities are non-negative (the problem’s domain check), so \(g_{\rm mix}^4\) is a convex combination of the species’ \(\hat K_s\); at a vacuum, where every \(f_s\) is zero, the response is zero whatever the filter, which is then taken as one.

Parameters:
filtersnumpy.ndarray

Each species’ own symmetric filter \(g_s\) on one spectral layout, dimensionless, shape (n_species, n_omega_fine).

factorsnumpy.ndarray

\(f_s\) per species, in C/V^3 (DispersiveKerrResponse.coefficient times the number density in m^-3), non-negative.

Returns:
tuple

(sum_s f_s, g_mix): the mixture’s static coefficient and its filter, shape (n_omega_fine,). One species returns its own factor and filter.

Examples

>>> import numpy as np
>>> from solprop.nonlinear.responses import mixture_filter
>>> filters = np.array([[1.0, 2.0], [1.0, 1.0]])
>>> total, g = mixture_filter(filters, np.array([1.0, 1.0]))
>>> total, float(g[0]), float(g[1] ** 4)
(2.0, 1.0, 8.5)
solprop.nonlinear.responses.thg_carrier(omega0, t)[source]#

Return the carrier factor an envelope-grid third-harmonic term needs.

On an envelope grid the state is A(t) = E_a(t) exp(-i omega0 t) (docs/dev/conventions.md section 4), so the part of the cubic response that radiates near 3 omega0 carries an explicit exp(2 i omega0 t) to place it at the right offset from the carrier. On a field grid nothing is needed: the harmonic is already in the cubic.

The +2i sign follows from E_a = A exp(+i omega0 t) in the internal FFT convention and is derived in docs/guide/media/nonlinear.md; it depends on the envelope convention, so it is derived here rather than transcribed.

Parameters:
omega0float

Carrier angular frequency, rad/s, positive.

tnumpy.ndarray

Time samples of the fine grid, s, shape (n_to,).

Returns:
numpy.ndarray

exp(2 i omega0 t), complex, shape (n_to,).

Raises:
GridError

If omega0 is not positive.

Examples

>>> import numpy as np
>>> from solprop.nonlinear.responses import thg_carrier
>>> carrier = thg_carrier(1.0, np.array([0.0, np.pi / 2]))
>>> bool(np.allclose(carrier, [1.0, -1.0]))
True