Skip to content

Commit 15fab9d

Browse files
v0.7.0 core: Newton solitons and crystals, principal quadratures, thermal baths, marginal-mode guard
1 parent 9253632 commit 15fab9d

5 files changed

Lines changed: 342 additions & 24 deletions

File tree

src/sqzcomb/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,22 @@
2222
logarithmic_negativity, ppt_symplectic_eigenvalue,
2323
two_mode_reduction)
2424
from .gaussian import (covariance_xxpp, drift_from_qutip,
25-
intracavity_covariance, symplectic_eigenvalues)
25+
intracavity_covariance, principal_quadratures,
26+
symplectic_eigenvalues)
27+
from .soliton import continuation, newton_state, soliton_seed
28+
from .thermal import thermal_occupation
2629

27-
__version__ = "0.6.0"
30+
__version__ = "0.7.0"
2831
__all__ = [
2932
"lle_evolve", "homogeneous_steady_states",
3033
"fluctuation_matrix", "single_mode_parametric",
3134
"photonic_molecule", "output_variance_ports", "molecule_threshold",
3235
"molecule_fluctuation_matrix",
36+
"newton_state", "soliton_seed", "continuation",
3337
"output_quadrature_variance", "squeezing_db",
3438
"intracavity_covariance", "covariance_xxpp",
35-
"symplectic_eigenvalues", "drift_from_qutip",
39+
"symplectic_eigenvalues", "principal_quadratures",
40+
"thermal_occupation", "drift_from_qutip",
3641
"two_mode_reduction", "ppt_symplectic_eigenvalue",
3742
"logarithmic_negativity", "duan_epr_sum", "entanglement_report",
3843
"detected_variance", "detected_squeezing_db", "dark_from_clearance_db",

src/sqzcomb/gaussian.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
* Doubled basis z = (a_1..a_n, a*_1..a*_n); dz/dt = M z + inputs, with
2020
mode j carrying amplitude decay gamma_j (total input coupling
2121
sqrt(2 gamma_j), as everywhere in this package).
22-
* Vacuum input: <z_in(t) z_in(t')^dag> = N delta(t - t'), N = [[I, 0],
23-
[0, 0]]; hence the diffusion matrix D = [[2 Gamma, 0], [0, 0]].
22+
* Bath input: <z_in(t) z_in(t')^dag> = N delta(t - t') with N =
23+
[[(n_th + 1) I, 0], [0, n_th I]] (vacuum: n_th = 0); hence the
24+
diffusion matrix D = [[2 Gamma (n_th + 1), 0], [0, 2 Gamma n_th]].
2425
* Steady covariance V = <z z^dag> solves M V + V M^dag + D = 0 (solved
2526
here with numpy alone via the Kronecker-vectorized linear system).
2627
* Quadratures x = (a + a^dag)/sqrt(2), p = -i (a - a^dag)/sqrt(2);
@@ -39,19 +40,27 @@
3940
import numpy as np
4041

4142

42-
def _diffusion(gammas):
43+
def _diffusion(gammas, n_th=0.0):
4344
gammas = np.asarray(gammas, dtype=float)
4445
n = gammas.size
46+
nb = np.broadcast_to(np.asarray(n_th, dtype=float), (n,))
47+
if np.any(nb < 0.0):
48+
raise ValueError("thermal occupations must be non-negative")
4549
D = np.zeros((2 * n, 2 * n), dtype=complex)
46-
D[:n, :n] = 2.0 * np.diag(gammas)
50+
D[:n, :n] = 2.0 * np.diag(gammas * (nb + 1.0))
51+
D[n:, n:] = 2.0 * np.diag(gammas * nb)
4752
return D
4853

4954

50-
def intracavity_covariance(M, gammas):
55+
def intracavity_covariance(M, gammas, n_th=0.0):
5156
"""Steady-state complex covariance V = <z z^dag> of the doubled basis.
5257
53-
Solves M V + V M^dag + D = 0 with the vacuum-input diffusion matrix
54-
D = diag(2 gamma, 0). Requires a stable M (all drift eigenvalues in
58+
Solves M V + V M^dag + D = 0 with the bath diffusion matrix
59+
D = diag(2 gamma (n_th + 1), 2 gamma n_th) -- vacuum baths for the
60+
default n_th = 0, a Bose occupation per bath otherwise (scalar or
61+
one value per mode; see `thermal_occupation` for the physical
62+
number). A passive mode then holds exactly <a^dag a> = n_th, which
63+
the tests assert. Requires a stable M (all drift eigenvalues in
5564
the open left half-plane); raises ValueError otherwise, for the same
5665
reason the spectra do: the linearized state does not exist above
5766
threshold.
@@ -67,7 +76,7 @@ def intracavity_covariance(M, gammas):
6776
gammas = np.asarray(gammas, dtype=float)
6877
if gammas.shape != (n,):
6978
raise ValueError("gammas must have one decay rate per mode")
70-
D = _diffusion(gammas)
79+
D = _diffusion(gammas, n_th)
7180
ident = np.eye(m2, dtype=complex)
7281
# row-major vec: vec(M V) = (M kron I) vec(V); vec(V M^dag) =
7382
# (I kron conj(M)) vec(V)
@@ -200,3 +209,45 @@ def fock(occ):
200209
B = -1j * G
201210
M = np.block([[A, B], [np.conj(B), np.conj(A)]])
202211
return M, gammas
212+
213+
214+
def principal_quadratures(sigma, hbar=2.0):
215+
"""Supermode decomposition of a multimode covariance matrix: the
216+
principal quadratures and their variances.
217+
218+
For any real unit vector u, the generalized quadrature u . r
219+
(r = (x_1..x_n, p_1..p_n)) has variance u^T sigma u, so the
220+
eigendecomposition of sigma answers, exactly and completely, the
221+
question "what is the most squeezed collective quadrature this
222+
state contains, and along which mode combination does it lie" --
223+
the smallest eigenvalue is the deepest squeezing any generalized
224+
quadrature attains, its eigenvector the supermode that carries it.
225+
That statement is linear algebra, not approximation, and the tests
226+
pin it: the two-mode squeezed vacuum yields the exact pairs
227+
(hbar/2) e^{-2r} and (hbar/2) e^{+2r} with the EPR combinations
228+
(x_1 -/+ x_2)/sqrt(2), (p_1 +/- p_2)/sqrt(2) as supermodes, vacuum
229+
yields hbar/2 in every direction, and on the photonic molecule the
230+
principal variance is verified to lower-bound every tested
231+
twin-beam quadrature.
232+
233+
Returns (variances, vectors): eigenvalues ascending, vectors[:, i]
234+
the unit xxpp vector of principal quadrature i.
235+
236+
This is the orthogonal decomposition of the noise ellipsoid --
237+
deliberately distinct from the *symplectic* (Williamson)
238+
decomposition `symplectic_eigenvalues`, which measures mixedness:
239+
a pure squeezed state has all symplectic eigenvalues at hbar/2
240+
while its principal variances split as e^{-/+ 2r}. Both views are
241+
exported because they answer different questions.
242+
"""
243+
sigma = np.asarray(sigma, dtype=float)
244+
m2 = sigma.shape[0]
245+
if sigma.shape != (m2, m2) or m2 % 2:
246+
raise ValueError("sigma must be a square (2n, 2n) xxpp matrix")
247+
if np.abs(sigma - sigma.T).max() > 1e-9 * max(1.0, np.abs(sigma).max()):
248+
raise ValueError("sigma must be symmetric")
249+
w, v = np.linalg.eigh(0.5 * (sigma + sigma.T))
250+
if w[0] < -1e-12 * max(1.0, abs(w[-1])):
251+
raise ValueError("sigma is not positive semidefinite; not a "
252+
"covariance matrix")
253+
return w, v

src/sqzcomb/soliton.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Localized steady states of the LLE: solitons, soliton crystals,
2+
and parameter continuation.
3+
4+
`newton_state` solves the stationary Lugiato-Lefever equation
5+
6+
0 = -(1 + i alpha) psi + i |psi|^2 psi + i D psi + F
7+
8+
by Newton's method in Fourier space -- and its Jacobian is the same
9+
linearization the package's `fluctuation_matrix` builds around a
10+
steady state, evaluated at the current iterate: the operator that
11+
damps a perturbation of a steady state and the operator Newton inverts
12+
to find that steady state are one and the same. (The solver keeps the
13+
grid's wrapped convolution entries that the physically mode-truncated
14+
`fluctuation_matrix` sets to zero -- the exact Jacobian of the
15+
*discrete* equations, needed for machine-precision convergence. The
16+
wrapped entries only transfer amplitude across the band edge, so on
17+
the resolved soliton's own modes the two operators act identically:
18+
the test suite asserts that both annihilate the translation mode to
19+
1e-9.)
20+
21+
Exact facts the test suite asserts, rather than states:
22+
23+
* the converged residual is below the requested tolerance (the solver
24+
refuses to return otherwise), and the converged state, handed to the
25+
independent split-step evolver `lle_evolve`, stays put;
26+
* from a flat seed, Newton lands on the exact root of the homogeneous
27+
cubic;
28+
* an N-fold soliton crystal on the 2 pi ring equals, to machine
29+
precision, the single soliton of the same equation with dispersion
30+
d_m scaled by N^m, resampled onto the crystal grid -- the exact
31+
rescaling theta -> N theta of the periodic domain;
32+
* the linearization around any localized steady state carries an
33+
*exact* zero eigenvalue whose eigenvector is the translation mode
34+
d psi / d theta (Goldstone mode of the broken translation symmetry);
35+
the drift matrix is therefore marginally, not asymptotically, stable,
36+
and the spectra machinery refuses it unless told that the marginal
37+
direction is understood (`allow_marginal`, see `spectra`).
38+
39+
The sech seed uses the standard soliton asymptotics of the anomalous
40+
LLE (amplitude ~ sqrt(2 alpha), width ~ sqrt(|d2| / alpha), pump phase
41+
cos phi_0 = sqrt(8 alpha) / (pi F); T. Herr et al., Nature Photonics 8,
42+
145 (2014)); the *converged* answers never depend on the seed quality,
43+
only Newton's basin does.
44+
"""
45+
from __future__ import annotations
46+
47+
import numpy as np
48+
49+
from .lle import _linear_symbol, homogeneous_steady_states
50+
51+
__all__ = ["newton_state", "soliton_seed", "continuation"]
52+
53+
54+
def _residual_k(psi, F, alpha, dispersion):
55+
"""Stationary-LLE residual in Fourier space (FFT convention)."""
56+
n = psi.size
57+
k = np.fft.fftfreq(n, d=1.0 / n)
58+
L = _linear_symbol(alpha, dispersion, k)
59+
Rk = L * np.fft.fft(psi) + np.fft.fft(1j * np.abs(psi) ** 2 * psi)
60+
Rk[0] += F * n
61+
return Rk
62+
63+
64+
def _jacobian(psi, alpha, dispersion):
65+
"""Exact Jacobian of the discrete (pseudospectral) residual, in the
66+
doubled Fourier basis.
67+
68+
Identical to `fluctuation_matrix` except that the products
69+
2 |psi|^2 delta and psi^2 conj(delta) are the *grid's* circular
70+
convolutions (mode transfers taken modulo n): the exact Jacobian
71+
of the discrete pseudospectral residual, which Newton needs to
72+
converge to machine precision. The entries where the two differ
73+
are exactly the wrapped transfers across the band edge; on
74+
perturbations resolved inside the band the two operators act
75+
identically, which the test suite asserts on the soliton's
76+
translation mode.
77+
"""
78+
psi = np.asarray(psi, dtype=complex)
79+
n = psi.size
80+
kgrid = np.fft.fftfreq(n, d=1.0 / n).astype(int)
81+
f_abs2 = np.fft.fft(np.abs(psi) ** 2) / n
82+
f_sq = np.fft.fft(psi ** 2) / n
83+
L = _linear_symbol(alpha, dispersion, kgrid.astype(float))
84+
# FFT layout: the component of mode value v sits at slot v mod n
85+
A = 2j * f_abs2[(kgrid[:, None] - kgrid[None, :]) % n]
86+
B = 1j * f_sq[(kgrid[:, None] + kgrid[None, :]) % n]
87+
A[np.arange(n), np.arange(n)] += L
88+
return np.block([[A, B], [np.conj(B), np.conj(A)]])
89+
90+
91+
def newton_state(psi0, F, alpha, dispersion=(0.0,), tol=1e-12,
92+
maxiter=60):
93+
"""Newton solution of the stationary LLE from the seed psi0.
94+
95+
Returns (psi, info) with info = {"residual", "iterations"}; the
96+
residual is the max-norm of the stationary equation on the theta
97+
grid. Raises RuntimeError if the tolerance is not reached -- a
98+
state is never returned unverified. The Jacobian at each iterate
99+
is the exact discrete form of `fluctuation_matrix(psi, alpha,
100+
dispersion)` (see `_jacobian`).
101+
"""
102+
psi = np.asarray(psi0, dtype=complex).copy()
103+
n = psi.size
104+
res = np.inf
105+
for it in range(int(maxiter)):
106+
Rk = _residual_k(psi, F, alpha, dispersion)
107+
res = float(np.abs(np.fft.ifft(Rk)).max())
108+
if res < tol:
109+
return psi, {"residual": res, "iterations": it}
110+
J = _jacobian(psi, alpha, dispersion)
111+
rhs = np.concatenate([Rk / n, np.conj(Rk) / n])
112+
# minimal-norm least-squares step: at a localized state the
113+
# Jacobian is exactly singular along the translation (Goldstone)
114+
# mode, and the minimal-norm solution is the Newton step that
115+
# does not slide along the soliton position
116+
delta = np.linalg.lstsq(J, -rhs, rcond=1e-9)[0]
117+
psi = psi + np.fft.ifft(delta[:n] * n)
118+
raise RuntimeError(
119+
f"Newton did not reach tol = {tol:g} in {maxiter} iterations "
120+
f"(residual {res:.3e}); refusing to return an unconverged "
121+
"state")
122+
123+
124+
def soliton_seed(n, F, alpha, dispersion, n_pulses=1):
125+
"""Sech ansatz seed for `newton_state`: the lower flat state plus
126+
``n_pulses`` equally spaced sech pulses with the standard
127+
asymptotic amplitude sqrt(2 alpha), width sqrt(|d2| / (2 alpha))
128+
and pump phase cos phi_0 = sqrt(8 alpha) / (pi F) (Herr et al.,
129+
Nature Photonics 8, 145 (2014)). Anomalous dispersion (d2 < 0 in
130+
this package's sign convention) required.
131+
"""
132+
d2 = float(dispersion[0])
133+
if d2 >= 0.0:
134+
raise ValueError("bright-soliton seeds need anomalous "
135+
"dispersion (d2 < 0 in this convention)")
136+
roots = homogeneous_steady_states(F, alpha)
137+
rho_low = float(roots[0])
138+
psi_low = F / (1.0 + 1j * (alpha - rho_low))
139+
arg = np.sqrt(8.0 * alpha) / (np.pi * F)
140+
phi0 = np.arccos(min(arg, 1.0))
141+
theta = 2.0 * np.pi * np.arange(n) / n
142+
width = np.sqrt(abs(d2) / (2.0 * alpha))
143+
psi = np.full(n, psi_low, dtype=complex)
144+
for p in range(int(n_pulses)):
145+
center = 2.0 * np.pi * (p + 0.5) / n_pulses
146+
d = np.angle(np.exp(1j * (theta - center))) # wrapped distance
147+
psi += np.sqrt(2.0 * alpha) * np.exp(1j * phi0) / np.cosh(d / width)
148+
return psi
149+
150+
151+
def continuation(psi0, F, alphas, dispersion=(0.0,), tol=1e-12,
152+
maxiter=60):
153+
"""Sweep the detuning: Newton-solve at each alpha in ``alphas``,
154+
seeding each step with the previous converged state.
155+
156+
Returns (states, infos) as lists. Every step is verified to the
157+
tolerance or the sweep raises (naming the failing alpha), so a
158+
returned branch contains no unconverged member. Sweeping F at
159+
fixed alpha works by transposing the roles: call in a loop, this
160+
function stays deliberately simple.
161+
"""
162+
psi = np.asarray(psi0, dtype=complex).copy()
163+
states, infos = [], []
164+
for a in alphas:
165+
try:
166+
psi, info = newton_state(psi, F, float(a), dispersion,
167+
tol=tol, maxiter=maxiter)
168+
except RuntimeError as exc:
169+
raise RuntimeError(
170+
f"continuation failed at alpha = {a}: {exc}") from exc
171+
states.append(psi.copy())
172+
infos.append(info)
173+
return states, infos

src/sqzcomb/spectra.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,38 +17,72 @@
1717

1818
import numpy as np
1919

20-
from .linearize import is_stable
2120

21+
def _bath_covariance(m2, n_bar):
22+
"""<z_in z_in^dagger> of a thermal bath in the doubled ordering:
23+
<a a^dagger> = n_bar + 1, <a^dagger a> = n_bar (vacuum: n_bar = 0)."""
24+
half = m2 // 2
25+
N = np.zeros((m2, m2), dtype=complex)
26+
N[:half, :half] = (float(n_bar) + 1.0) * np.eye(half)
27+
N[half:, half:] = float(n_bar) * np.eye(half)
28+
return N
29+
30+
31+
def _check_spectra_stability(M, allow_marginal, tol=1e-6):
32+
lam = float(np.max(np.linalg.eigvals(np.asarray(M)).real))
33+
if lam < -tol:
34+
return
35+
if allow_marginal and lam < tol:
36+
return
37+
if lam < tol:
38+
raise ValueError(
39+
"drift matrix is marginally stable (an eigenvalue's real "
40+
"part is numerically zero). Around a localized steady "
41+
"state this is the exact translation (Goldstone) mode of "
42+
"the soliton; spectra at omega != 0 remain finite, so pass "
43+
"allow_marginal=True if that marginal direction is "
44+
"understood. A genuinely positive growth rate is still "
45+
"refused.")
46+
raise ValueError("drift matrix is unstable (above threshold); "
47+
"linearized spectra are meaningless there")
2248

23-
def _output_covariance(M, eta, omega):
49+
50+
def _output_covariance(M, eta, omega, n_th_port=0.0, n_th_loss=0.0):
2451
m2 = M.shape[0]
2552
ident = np.eye(m2, dtype=complex)
2653
G = np.linalg.inv(-1j * omega * ident - M)
2754
T_ex = 2.0 * eta * G - ident
2855
T_0 = 2.0 * np.sqrt(eta * (1.0 - eta)) * G
29-
# vacuum covariance <z_in z_in^dagger> in the (a, a*) doubled ordering:
30-
# <a a^dagger> = 1, all other blocks zero
31-
half = m2 // 2
32-
N = np.zeros((m2, m2), dtype=complex)
33-
N[:half, :half] = np.eye(half)
34-
S = T_ex @ N @ T_ex.conj().T + T_0 @ N @ T_0.conj().T
56+
S = T_ex @ _bath_covariance(m2, n_th_port) @ T_ex.conj().T \
57+
+ T_0 @ _bath_covariance(m2, n_th_loss) @ T_0.conj().T
3558
return S
3659

3760

3861
def output_quadrature_variance(M, eta, omega, mode_index, n_modes,
39-
phi=0.0, mode_index_b=None):
62+
phi=0.0, mode_index_b=None,
63+
n_th_port=0.0, n_th_loss=0.0,
64+
allow_marginal=False):
4065
"""Symmetrized variance of an output quadrature at frequency omega.
4166
4267
mode_index : index of the mode (within the retained mode list) whose
4368
quadrature is detected. If mode_index_b is given, the joint
4469
two-mode quadrature (a + b)/sqrt(2) rotated by phi is used, the
4570
natural variable for twin-beam squeezing.
71+
n_th_port, n_th_loss : Bose occupations of the extraction-port
72+
input and of the intrinsic-loss bath (default vacuum, 0; see
73+
`thermal_occupation` for the physical number). A passive cavity
74+
with both baths at n_bar emits exactly (2 n_bar + 1)/2 at every
75+
frequency, coupling and phase -- asserted in the tests.
76+
allow_marginal : accept a drift matrix whose largest eigenvalue
77+
real part is numerically zero (the soliton's exact translation
78+
Goldstone mode); spectra at omega != 0 stay finite. Genuinely
79+
unstable matrices are refused regardless.
4680
Vacuum level is 0.5. Requires a stable M.
4781
"""
48-
if not is_stable(M):
49-
raise ValueError("drift matrix is unstable (above threshold); "
50-
"linearized spectra are meaningless there")
51-
S = _output_covariance(M, eta, omega)
82+
if float(n_th_port) < 0.0 or float(n_th_loss) < 0.0:
83+
raise ValueError("thermal occupations must be non-negative")
84+
_check_spectra_stability(M, allow_marginal)
85+
S = _output_covariance(M, eta, omega, n_th_port, n_th_loss)
5286
u = np.zeros(2 * n_modes, dtype=complex)
5387
if mode_index_b is None:
5488
u[mode_index] = np.exp(-1j * phi) / np.sqrt(2.0)

0 commit comments

Comments
 (0)