Skip to main content

comnum_math/nlse/
mod.rs

1//! The symmetric split-step Fourier solvers of the nonlinear Schrödinger equation:
2//! scalar ([`SplitStep`], [`SplitStepF32`], both built from the [`Steps`] of a span)
3//! and dual-polarisation Manakov ([`ManakovSplitStep`]).
4//!
5//! The same solver propagates a field through a fibre (`comnum_channel::optical::fiber`)
6//! and back-propagates it at the receiver (`comnum_dsp::compensation::dbp`, with the
7//! coefficients negated), so it lives here, below both.
8//!
9//! # Model
10//!
11//! $$
12//! \frac{\partial A}{\partial z} = -\frac{\alpha}{2} A
13//!   - j \frac{\beta_{2}}{2} \frac{\partial^{2} A}{\partial t^{2}}
14//!   + j \gamma |A|^{2} A
15//!     $$
16//!
17//! Each step of length $h$ is a half linear step, the nonlinear phase
18//! $\gamma |A|^{2} h_{\mathrm{NL}}$ and a half linear step, with the linear operator applied
19//! in the frequency domain:
20//!
21//! $$
22//! \tilde{A}(\omega) \leftarrow \tilde{A}(\omega)\\, e^{-\alpha h / 4}\\, e^{j \beta_{2} \omega^{2} h / 4}
23//! $$
24//!
25//! The nonlinear length $h_{\mathrm{NL}}$ is the step itself for the forward model; a
26//! back-propagation with coarse steps uses the effective length of the step instead.
27//!
28//! # The Manakov equation
29//!
30//! Averaged over the fast random rotations of the birefringence, the two polarisations
31//! $A_{x}$, $A_{y}$ obey
32//!
33//! $$
34//! \frac{\partial A_{x,y}}{\partial z} = -\frac{\alpha}{2} A_{x,y} -
35//!   j \frac{\beta_{2}}{2} \frac{\partial^{2} A_{x,y}}{\partial t^{2}} +
36//!   j \frac{8}{9} \gamma \left(|A_{x}|^{2} + |A_{y}|^{2}\right) A_{x,y}.
37//! $$
38//!
39//! [`ManakovSplitStep`] is the symmetric scheme of OptiCommPy's `manakovSSF`: a half
40//! linear step, the nonlinear phase
41//!
42//! $$
43//! \phi(t) = \frac{8}{9} \gamma\, \frac{P(z, t) + P(z + h, t)}{2}\, h, \qquad
44//! P = |A_{x}|^{2} + |A_{y}|^{2},
45//! $$
46//!
47//! (the trapezoidal rule of OptiCommPy, iterated until the step converges since
48//! $P(z + h)$ depends on the step itself, [`NonlinearStep::Trapezoidal`]; by default the
49//! power at the middle of the step, [`NonlinearStep::Midpoint`], as [`SplitStep`]) and a
50//! half linear step. The step is either fixed or adapted so
51//! that the largest nonlinear phase of a step is a given rotation (the nonlinear phase
52//! rotation method of Sinkin et al., which concentrates the steps where the power is
53//! high, at the beginning of the spans).
54//!
55//! # Examples
56//! ```
57//! use comnum_math::nlse::{Medium, SplitStep, Steps};
58//! use comnum_math::C64;
59//! // without loss and nonlinearity, forward then negated propagation is the identity
60//! let m = Medium { alpha_np_km: 0.0, beta2_s2_km: -2.17e-23, gamma_w_km: 0.0, beta3_s3_km: 0.0 };
61//! let x: Vec<C64> = (0..64).map(|n| C64::new((n % 3) as f64, (n % 2) as f64)).collect();
62//! let mut e = x.clone();
63//! SplitStep::new(&m, Steps::new(8, 10.0), 64, 64e9).run(&mut e);
64//! SplitStep::new(&m.negated(), Steps::new(8, 10.0), 64, 64e9).run(&mut e);
65//! assert!(x.iter().zip(&e).all(|(a, b)| (a - b).norm() < 1e-9));
66//! ```
67//!
68//! # References
69//!
70//! 1. G. P. Agrawal, *Nonlinear Fiber Optics*, 5th ed., Academic Press, 2013.
71//! 2. E. Ip and J. M. Kahn, "Compensation of dispersion and nonlinear impairments
72//!    using digital backpropagation," *J. Lightw. Technol.*, 26(20), 2008.
73//! 3. D. Marcuse, C. R. Menyuk and P. K. A. Wai, "Application of the Manakov-PMD
74//!    equation to studies of signal propagation in optical fibers with randomly varying
75//!    birefringence," *J. Lightw. Technol.*, 15(9), 1735–1746, 1997.
76//! 4. O. V. Sinkin, R. Holzlöhner, J. Zweck and C. R. Menyuk, "Optimization of the
77//!    split-step Fourier method in modeling optical-fiber communications systems," *J.
78//!    Lightw. Technol.*, 21(1), 61–68, 2003.
79
80use crate::fft::{self, Fft};
81use crate::{C64, cis};
82use comnum_core::{Error, Result};
83use num_complex::Complex32 as C32;
84
85mod manakov;
86
87pub use manakov::{Birefringence, ManakovSplitStep, PropagationOptions};
88
89/// The coefficients of the scalar NLSE, per kilometre.
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct Medium {
92    /// Power attenuation `α` (Np/km).
93    pub alpha_np_km: f64,
94    /// Group-velocity dispersion `β₂` (s²/km).
95    pub beta2_s2_km: f64,
96    /// Nonlinear coefficient `γ` (1/(W·km)).
97    pub gamma_w_km: f64,
98    /// Third-order dispersion `β₃` (s³/km), 0 for none.
99    pub beta3_s3_km: f64,
100}
101
102impl Default for Medium {
103    /// No loss, no dispersion, no nonlinearity.
104    fn default() -> Medium {
105        Medium { alpha_np_km: 0.0, beta2_s2_km: 0.0, gamma_w_km: 0.0, beta3_s3_km: 0.0 }
106    }
107}
108
109impl Medium {
110    /// The medium with `α`, `β₂` and `γ` reversed: propagating through it undoes a
111    /// propagation through `self` (digital back-propagation).
112    pub fn negated(&self) -> Medium {
113        Medium {
114            alpha_np_km: -self.alpha_np_km,
115            beta2_s2_km: -self.beta2_s2_km,
116            gamma_w_km: -self.gamma_w_km,
117            beta3_s3_km: -self.beta3_s3_km,
118        }
119    }
120
121    /// The linear response `e^{-αL/2} e^{j(β₂ω²/2 + β₃ω³/6)L}` of `length_km` of the
122    /// medium at every angular frequency (loss and dispersion of the field).
123    pub fn linear_response(&self, omega: &[f64], length_km: f64) -> Vec<C64> {
124        let mut out = vec![C64::new(0.0, 0.0); omega.len()];
125        self.linear_response_into(omega, length_km, &mut out);
126        out
127    }
128
129    /// [`Medium::linear_response`] into `out` (one value per angular frequency).
130    fn linear_response_into(&self, omega: &[f64], length_km: f64, out: &mut [C64]) {
131        let (a, b2, b3) = (self.alpha_np_km, self.beta2_s2_km, self.beta3_s3_km);
132        let g = (-(a / 2.0) * length_km).exp();
133        if b3 == 0.0 {
134            for (o, wk) in out.iter_mut().zip(omega) {
135                *o = g * cis(b2 / 2.0 * wk * wk * length_km);
136            }
137        } else {
138            for (o, wk) in out.iter_mut().zip(omega) {
139                *o = g * cis((b2 / 2.0 + b3 / 6.0 * wk) * wk * wk * length_km);
140            }
141        }
142    }
143
144    /// The half linear step `e^{-αh/4} e^{jβ₂ω²h/4}` at every angular frequency.
145    fn half_step(&self, omega: &[f64], h: f64) -> Vec<C64> {
146        self.linear_response(omega, h / 2.0)
147    }
148
149    /// [`Medium::half_step`] into `out`.
150    fn half_step_into(&self, omega: &[f64], h: f64, out: &mut [C64]) {
151        self.linear_response_into(omega, h / 2.0, out);
152    }
153}
154
155/// The steps of one span: `n_steps` symmetric steps of `step_km`, the nonlinear phase
156/// of each over `nl_length_km` (the step itself for the forward model, the effective
157/// length of a coarse step for a back-propagation). Shared by [`SplitStep`] and
158/// [`SplitStepF32`], and given by a forward model to its back-propagation
159/// (`comnum_dsp::compensation::dbp::Dbp::forward`).
160#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct Steps {
162    /// Number of steps.
163    pub n_steps: usize,
164    /// Length of a step (km).
165    pub step_km: f64,
166    /// Nonlinear length of a step (km).
167    pub nl_length_km: f64,
168}
169
170impl Steps {
171    /// The steps of the forward model: `n_steps` of `step_km`, whose nonlinear length is
172    /// the step.
173    pub fn new(n_steps: usize, step_km: f64) -> Steps {
174        Steps { n_steps, step_km, nl_length_km: step_km }
175    }
176
177    /// The length covered, $n h$ (km).
178    pub fn length_km(&self) -> f64 {
179        self.n_steps as f64 * self.step_km
180    }
181}
182
183/// The [`Steps`] of one span in double precision.
184#[derive(Clone, Debug)]
185pub struct SplitStep {
186    lin: Vec<C64>,
187    /// `None` for an empty block (nothing to propagate).
188    plan: Option<Fft>,
189    gamma_w_km: f64,
190    steps: Steps,
191}
192
193impl SplitStep {
194    /// The `steps` through `medium` for a block of `n` samples at `fs` (no plan for an
195    /// empty block: the steps then leave it empty).
196    pub fn new(medium: &Medium, steps: Steps, n: usize, fs: f64) -> SplitStep {
197        let omega = fft::angular_frequencies(n, fs);
198        SplitStep {
199            lin: medium.half_step(&omega, steps.step_km),
200            plan: (n > 0).then(|| fft::plan(n)),
201            gamma_w_km: medium.gamma_w_km,
202            steps,
203        }
204    }
205
206    /// The steps.
207    pub fn steps(&self) -> Steps {
208        self.steps
209    }
210
211    /// Propagate `e` (√W, time domain) through the steps, in place.
212    ///
213    /// # Panics
214    /// If `e` has another length than the block of [`SplitStep::new`].
215    pub fn run(&self, e: &mut [C64]) {
216        let Some(p) = self.plan.as_ref() else {
217            assert!(e.is_empty(), "a field for an empty block");
218            return;
219        };
220        let (g, h) = (self.gamma_w_km, self.steps.nl_length_km);
221        p.forward(e);
222        for _ in 0..self.steps.n_steps {
223            mul(e, &self.lin);
224            p.inverse(e);
225            for v in e.iter_mut() {
226                *v *= cis(g * v.norm_sqr() * h);
227            }
228            p.forward(e);
229            mul(e, &self.lin);
230        }
231        p.inverse(e);
232    }
233
234    /// The steps for the Manakov equation: both polarisations (√W, time domain, equal
235    /// lengths) in place, with the nonlinear phase
236    /// $\frac{8}{9} \gamma (|A_{x}|^{2} + |A_{y}|^{2}) h_{\mathrm{NL}}$ at the middle of every
237    /// step (the back-propagation of a dual-polarisation link).
238    ///
239    /// # Panics
240    /// If the polarisations have different lengths, or another length than the block of
241    /// [`SplitStep::new`].
242    pub fn run_dual(&self, x: &mut [C64], y: &mut [C64]) {
243        assert_eq!(x.len(), y.len(), "the polarisations must have the same length");
244        let Some(p) = self.plan.as_ref() else {
245            assert!(x.is_empty(), "a field for an empty block");
246            return;
247        };
248        let k = 8.0 / 9.0 * self.gamma_w_km * self.steps.nl_length_km;
249        p.forward(x);
250        p.forward(y);
251        for _ in 0..self.steps.n_steps {
252            mul(x, &self.lin);
253            mul(y, &self.lin);
254            p.inverse(x);
255            p.inverse(y);
256            manakov_rotation(x, y, k);
257            p.forward(x);
258            p.forward(y);
259            mul(x, &self.lin);
260            mul(y, &self.lin);
261        }
262        p.inverse(x);
263        p.inverse(y);
264    }
265
266    /// [`SplitStep::run`] with a distributed gain: before the nonlinear phase and after
267    /// it, every half step also multiplies the spectrum by `gain(z_0, z_1, g)`, the gain
268    /// of the field at every frequency (FFT order) from $z_{0}$ to $z_{1}$ (km, from the
269    /// start of the steps). The medium then carries no attenuation: `gain` holds the
270    /// loss as well (the power profile of a Raman-pumped span, `comnum_channel::optical::raman`).
271    /// As in [`SplitStep::run`], the field stays in the frequency domain from the end of
272    /// a step to the start of the next: two FFTs per step.
273    ///
274    /// # Panics
275    /// If `e` has another length than the block of [`SplitStep::new`].
276    pub fn run_with_gain(&self, e: &mut [C64], gain: FieldGain<'_>) {
277        let Some(p) = self.plan.as_ref() else {
278            assert!(e.is_empty(), "a field for an empty block");
279            return;
280        };
281        let (g, h) = (self.gamma_w_km, self.steps.nl_length_km);
282        let step = self.steps.step_km;
283        let mut buf = vec![0.0; e.len()];
284        p.forward(e);
285        for k in 0..self.steps.n_steps {
286            let z0 = k as f64 * step;
287            mul(e, &self.lin);
288            gain(z0, z0 + step / 2.0, &mut buf);
289            scale(e, &buf);
290            p.inverse(e);
291            for v in e.iter_mut() {
292                *v *= cis(g * v.norm_sqr() * h);
293            }
294            p.forward(e);
295            mul(e, &self.lin);
296            gain(z0 + step / 2.0, z0 + step, &mut buf);
297            scale(e, &buf);
298        }
299        p.inverse(e);
300    }
301}
302
303/// The gain of the field at every frequency bin (FFT order) between two positions of a
304/// span (km): `gain(z_0, z_1, out)` writes $\sqrt{G(z_{1}, f) / G(z_{0}, f)}$ into `out`.
305pub type FieldGain<'a> = &'a dyn Fn(f64, f64, &mut [f64]);
306
307/// [`SplitStep`] in single precision: the field, the FFTs, the linear operator and the
308/// nonlinear phase are computed in `f32` (about twice as fast), with the same [`Steps`]
309/// (a back-propagation included); the plans and their work space are cached per thread
310/// as those of [`fft::plan`].
311///
312/// The rounding error grows like `ε √K` with `ε ≈ 6·10⁻⁸` and `K` the number of
313/// steps: below `10⁻⁴` relative for thousands of steps.
314#[derive(Clone)]
315pub struct SplitStepF32 {
316    lin: Vec<C32>,
317    lin_n: Vec<C32>,
318    /// `None` for an empty block.
319    plan: Option<fft::FftF32>,
320    gamma_h: f32,
321    steps: Steps,
322}
323
324impl std::fmt::Debug for SplitStepF32 {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("SplitStepF32")
327            .field("n", &self.lin.len())
328            .field("gamma_h", &self.gamma_h)
329            .field("steps", &self.steps)
330            .finish_non_exhaustive()
331    }
332}
333
334impl SplitStepF32 {
335    /// The `steps` through `medium` for a block of `n` samples at `fs` (no plan for an
336    /// empty block).
337    pub fn new(medium: &Medium, steps: Steps, n: usize, fs: f64) -> SplitStepF32 {
338        let omega = fft::angular_frequencies(n, fs);
339        let half = medium.half_step(&omega, steps.step_km);
340        // the 1/N of the inverse transform is folded into the first half step
341        let inv_n = 1.0 / n.max(1) as f64;
342        let lin = half.iter().map(|v| C32::new(v.re as f32, v.im as f32)).collect();
343        let lin_n = half.iter().map(|v| v * inv_n).map(|v| C32::new(v.re as f32, v.im as f32)).collect();
344        SplitStepF32 {
345            lin,
346            lin_n,
347            plan: (n > 0).then(|| fft::plan_f32(n)),
348            gamma_h: (medium.gamma_w_km * steps.nl_length_km) as f32,
349            steps,
350        }
351    }
352
353    /// The steps.
354    pub fn steps(&self) -> Steps {
355        self.steps
356    }
357
358    /// Propagate `e` through the steps, in place.
359    ///
360    /// # Panics
361    /// If `e` has another length than the block of [`SplitStepF32::new`].
362    pub fn run(&self, e: &mut [C32]) {
363        let Some(p) = self.plan.as_ref() else {
364            assert!(e.is_empty(), "a field for an empty block");
365            return;
366        };
367        assert_eq!(e.len(), self.lin.len(), "FFT length mismatch");
368        let n = e.len();
369        p.forward(e);
370        for _ in 0..self.steps.n_steps {
371            e.iter_mut().zip(&self.lin_n).for_each(|(u, l)| *u *= l);
372            p.inverse_unnormalized(e);
373            for v in e.iter_mut() {
374                let (sn, cs) = (self.gamma_h * v.norm_sqr()).sin_cos();
375                *v *= C32::new(cs, sn);
376            }
377            p.forward(e);
378            e.iter_mut().zip(&self.lin).for_each(|(u, l)| *u *= l);
379        }
380        p.inverse_unnormalized(e);
381        let s = 1.0 / n as f32;
382        for v in e.iter_mut() {
383            *v *= s;
384        }
385    }
386}
387
388/// The step size of [`ManakovSplitStep`].
389#[derive(Clone, Copy, Debug, PartialEq)]
390#[non_exhaustive]
391pub enum StepRule {
392    /// Steps of a fixed length (km); the last step of a span is shortened.
393    Fixed(f64),
394    /// Steps whose largest nonlinear phase $\max_{t} \phi(t)$ is this rotation (rad).
395    NonlinearPhase(f64),
396    /// The four-wave-mixing-aware rule of Musetti, Serena and Bononi (2018): the first step
397    /// of every span bounds the phase mismatch of the four-wave mixing across the signal
398    /// bandwidth $B$ (eq. (13)),
399    ///
400    /// $$
401    /// h_{1} = \frac{\Phi_{\mathrm{FWM}}}{|\beta_{2}| (2\pi B)^{2}},
402    /// $$
403    ///
404    /// and the next steps grow with the attenuation by `update`. The rule does not depend on
405    /// the power: the error it leaves on the SNR is fixed by $\Phi_{\mathrm{FWM}}$ (about
406    /// 0.01 dB for 20 rad with the constant local error, 4 rad with the nonlinear-phase
407    /// update, Sec. IV-B).
408    Fwm {
409        /// $\Phi_{\mathrm{FWM}}$ (rad).
410        phase_rad: f64,
411        /// $B$ (Hz): the bandwidth of the signal; `None` measures it on the fields at the
412        /// start of the propagation ([`signal_bandwidth`]).
413        bandwidth_hz: Option<f64>,
414        /// The growth of the steps along a span.
415        update: StepUpdate,
416        /// Also bound the largest nonlinear phase of a step to this rotation (rad), the
417        /// rule of [`StepRule::NonlinearPhase`]: the four-wave-mixing rule alone lets the
418        /// steps of a single channel exceed its dispersion length at high power.
419        max_nl_phase_rad: Option<f64>,
420    },
421}
422
423/// The growth of the steps of [`StepRule::Fwm`] along a span, with the power attenuation
424/// $\alpha$ (Musetti et al. 2018, eqs. (9)–(10)).
425#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
426#[non_exhaustive]
427pub enum StepUpdate {
428    /// The constant local error of Zhang and Hayee for the symmetric step,
429    /// $h_{k+1} = h_{k} e^{\alpha h_{k} / 3}$ (eq. (10)).
430    #[default]
431    ConstantLocalError,
432    /// The update of the nonlinear-phase criterion without the fluctuations of the peak
433    /// power, $L_{\mathrm{eff}}(h_{k+1}) = L_{\mathrm{eff}}(h_{k}) e^{\alpha h_{k}}$ (eq. (9), the
434    /// logarithmic step of Bosco et al. 2000).
435    NonlinearPhase,
436}
437
438impl StepRule {
439    /// The default rule: Musetti et al. for an SNR error of about 0.01 dB in WDM
440    /// ($\Phi_{\mathrm{FWM}}$ = 20 rad, the constant local error, the bandwidth measured on
441    /// the fields), and at most 0.02 rad of nonlinear phase per step, which a single
442    /// channel at high power needs: one DP-16QAM channel of 32 GBd over 10 × 80 km at
443    /// 2 to 8 dBm gets 0.3 to 0.7 dB too much SNR with the four-wave-mixing rule alone,
444    /// the converged values with both (`validation/cards/musetti2018_fig7.yaml`).
445    pub const FWM_DEFAULT: StepRule = StepRule::Fwm {
446        phase_rad: 20.0,
447        bandwidth_hz: None,
448        update: StepUpdate::ConstantLocalError,
449        max_nl_phase_rad: Some(2e-2),
450    };
451}
452
453/// The bandwidth (Hz) of the fields `fields` sampled at `fs`: the span of the frequencies
454/// whose power spectral density, summed over the fields, exceeds $10^{-4}$ of its peak
455/// ($-40$ dB). Measured on a clean signal (before any amplifier noise, which is white).
456pub fn signal_bandwidth(fields: &[&[C64]], fs: f64) -> f64 {
457    let Some(n) = fields.first().map(|f| f.len()) else { return 0.0 };
458    if n == 0 {
459        return 0.0;
460    }
461    let mut psd = vec![0.0; n];
462    for f in fields {
463        for (p, v) in psd.iter_mut().zip(fft::fft(f)) {
464            *p += v.norm_sqr();
465        }
466    }
467    let peak = psd.iter().copied().fold(0.0, f64::max);
468    if peak == 0.0 {
469        return 0.0;
470    }
471    // the bins in increasing frequency, from -fs/2
472    let signed = |k: usize| if k < n.div_ceil(2) { k as f64 } else { k as f64 - n as f64 };
473    let above: Vec<f64> = psd.iter().enumerate().filter(|(_, p)| **p > 1e-4 * peak).map(|(k, _)| signed(k)).collect();
474    let (lo, hi) = above.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &k| (a.min(k), b.max(k)));
475    (hi - lo + 1.0) * fs / n as f64
476}
477
478/// The nonlinear step of [`ManakovSplitStep`].
479#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
480#[non_exhaustive]
481pub enum NonlinearStep {
482    /// The phase of the power at the middle of the step, after the first half of the
483    /// dispersion, as [`SplitStep`] (eq. (3) of Sinkin et al. 2003): no iteration.
484    #[default]
485    Midpoint,
486    /// OptiCommPy's `manakovSSF`: the phase of the trapezoidal mean of the power at the
487    /// two ends of the step, iterated until the field converges. When channels walk off
488    /// within a step, the mean smooths the intensity that drives the cross-phase
489    /// modulation (see [`ManakovSplitStep`]).
490    Trapezoidal,
491}
492
493impl std::str::FromStr for NonlinearStep {
494    type Err = Error;
495
496    /// `trapezoidal` or `midpoint` (case-insensitive).
497    fn from_str(s: &str) -> Result<NonlinearStep> {
498        match s.to_ascii_lowercase().as_str() {
499            "trapezoidal" => Ok(NonlinearStep::Trapezoidal),
500            "midpoint" => Ok(NonlinearStep::Midpoint),
501            _ => Err(Error::Config(format!("the nonlinear step must be trapezoidal or midpoint, got {s:?}"))),
502        }
503    }
504}
505
506/// The sampling conditions of a split-step simulation of a signal of bandwidth $B$
507/// over a dispersive link (Musetti et al. 2018, Appendix B): the sampling rate must hold
508/// the four-wave mixing that falls on the signal, at least $\frac{3}{2} B$ (all of the
509/// first-order mixing needs $3 B$), and the block must be longer than the walk-off
510/// across $B$ over the whole link, $|\beta_{2}| \, 2\pi B \, L$, or the circular
511/// convolution wraps the fast channels onto the slow ones. [`sampling_check`] computes
512/// them; [`SamplingCheck::is_sufficient`] says whether both hold.
513#[derive(Clone, Copy, Debug, PartialEq)]
514pub struct SamplingCheck {
515    /// The bandwidth of the launched signal (Hz, [`signal_bandwidth`]).
516    pub bandwidth_hz: f64,
517    /// The sampling rate (Hz).
518    pub rate_hz: f64,
519    /// The smallest rate that holds the four-wave mixing on the signal, $\frac{3}{2} B$ (Hz).
520    pub min_rate_hz: f64,
521    /// The duration of the block (s).
522    pub window_s: f64,
523    /// The walk-off across the bandwidth over the link (s).
524    pub walkoff_s: f64,
525}
526
527impl SamplingCheck {
528    /// Whether the rate and the block are sufficient.
529    pub fn is_sufficient(&self) -> bool {
530        self.rate_hz >= self.min_rate_hz && self.window_s >= self.walkoff_s
531    }
532
533    /// Nothing when [`SamplingCheck::is_sufficient`].
534    ///
535    /// # Errors
536    /// [`Error::Config`] naming what is missing otherwise.
537    pub fn ensure(&self) -> Result<()> {
538        if self.is_sufficient() {
539            return Ok(());
540        }
541        let mut what = Vec::new();
542        if self.rate_hz < self.min_rate_hz {
543            what.push(format!(
544                "the sampling rate {:.1} GHz is below 3/2 of the signal bandwidth {:.1} GHz ({:.1} GHz): the four-wave mixing aliases onto the signal (Musetti et al. 2018, Table I)",
545                self.rate_hz / 1e9,
546                self.bandwidth_hz / 1e9,
547                self.min_rate_hz / 1e9
548            ));
549        }
550        if self.window_s < self.walkoff_s {
551            what.push(format!(
552                "the block of {:.2} ns is shorter than the walk-off {:.2} ns across the bandwidth over the link: the circular convolution wraps the channels (Musetti et al. 2018, Appendix B)",
553                self.window_s * 1e9,
554                self.walkoff_s * 1e9
555            ));
556        }
557        Err(Error::Config(format!("insufficient sampling: {} (check_sampling: false to run anyway)", what.join("; "))))
558    }
559}
560
561/// The [`SamplingCheck`] of `fields` sampled at `fs` through `length_km` of `medium`.
562pub fn sampling_check(fields: &[&[C64]], fs: f64, medium: &Medium, length_km: f64) -> SamplingCheck {
563    let b = signal_bandwidth(fields, fs);
564    let n = fields.first().map_or(0, |f| f.len()) as f64;
565    SamplingCheck {
566        bandwidth_hz: b,
567        rate_hz: fs,
568        min_rate_hz: 1.5 * b,
569        window_s: n / fs,
570        walkoff_s: medium.beta2_s2_km.abs() * 2.0 * std::f64::consts::PI * b * length_km,
571    }
572}
573
574/// The nonlinear step of the Manakov equation with a phase that does not change during
575/// the step: both polarisations rotated by the same phase,
576///
577/// $$
578/// A_{x,y}(t) \leftarrow A_{x,y}(t)\, e^{j k (|A_{x}(t)|^{2} + |A_{y}(t)|^{2})},
579/// $$
580///
581/// with $k = \frac{8}{9} \gamma h_{\mathrm{NL}}$ (negated to undo it). The step of
582/// [`SplitStep::run_dual`] and of the Manakov-PMD equation of
583/// `comnum_channel::optical::pmd::ManakovPmd`.
584///
585/// # Panics
586/// If the polarisations have different lengths.
587///
588/// # Examples
589/// ```
590/// use comnum_math::{C64, nlse::manakov_rotation};
591/// let (mut x, mut y) = ([C64::new(1.0, 0.0)], [C64::new(0.0, 1.0)]);
592/// manakov_rotation(&mut x, &mut y, std::f64::consts::FRAC_PI_4);
593/// assert!((x[0] - C64::new(0.0, 1.0)).norm() < 1e-15 && (y[0] - C64::new(-1.0, 0.0)).norm() < 1e-15);
594/// ```
595pub fn manakov_rotation(x: &mut [C64], y: &mut [C64], k: f64) {
596    assert_eq!(x.len(), y.len(), "the polarisations must have the same length");
597    for (u, v) in x.iter_mut().zip(y.iter_mut()) {
598        let e = cis(k * (u.norm_sqr() + v.norm_sqr()));
599        *u *= e;
600        *v *= e;
601    }
602}
603
604fn mul(a: &mut [C64], b: &[C64]) {
605    for (u, v) in a.iter_mut().zip(b) {
606        *u *= v;
607    }
608}
609
610fn scale(a: &mut [C64], b: &[f64]) {
611    for (u, v) in a.iter_mut().zip(b) {
612        *u *= v;
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn an_empty_field_propagates_to_an_empty_field_instead_of_panicking() {
622        let m = Medium { alpha_np_km: 0.046, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
623        let steps = Steps { n_steps: 3, step_km: 1.0, nl_length_km: 0.8 };
624        let s = SplitStep::new(&m, steps, 0, 64e9);
625        let (mut x, mut y): (Vec<C64>, Vec<C64>) = (Vec::new(), Vec::new());
626        s.run(&mut x);
627        s.run_dual(&mut x, &mut y);
628        s.run_with_gain(&mut x, &|_, _, _| {});
629        SplitStepF32::new(&m, steps, 0, 64e9).run(&mut []);
630        assert!(x.is_empty() && y.is_empty() && s.steps() == steps);
631        assert!((steps.length_km() - 3.0).abs() < 1e-15);
632    }
633
634    #[test]
635    fn a_uniform_gain_of_the_loss_is_the_attenuation_of_the_medium() {
636        // run_with_gain on the lossless medium with the field gain e^{-α Δz / 2} at every
637        // frequency is run on the lossy medium (to the rounding of the factorisation)
638        let m = Medium { alpha_np_km: 0.046, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
639        let lossless = Medium { alpha_np_km: 0.0, ..m };
640        let x: Vec<C64> = (0..256).map(|n| cis(0.3 * n as f64) * (1e-2 * (1 + n % 5) as f64)).collect();
641        let steps = Steps::new(40, 0.5);
642        let (mut a, mut b) = (x.clone(), x);
643        SplitStep::new(&m, steps, 256, 64e9).run(&mut a);
644        let loss = |z0: f64, z1: f64, out: &mut [f64]| out.fill((-(0.046 / 2.0) * (z1 - z0)).exp());
645        SplitStep::new(&lossless, steps, 256, 64e9).run_with_gain(&mut b, &loss);
646        let peak = a.iter().map(|v| v.norm()).fold(0.0, f64::max);
647        assert!(a.iter().zip(&b).all(|(u, v)| (u - v).norm() < 1e-13 * peak));
648    }
649
650    #[test]
651    fn lossless_split_step_conserves_energy() {
652        let m = Medium { alpha_np_km: 0.0, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
653        let mut e: Vec<C64> = (0..256).map(|n| cis(0.1 * n as f64) * (1e-2 * (n % 7) as f64)).collect();
654        let e0: f64 = e.iter().map(C64::norm_sqr).sum();
655        SplitStep::new(&m, Steps::new(80, 1.0), 256, 64e9).run(&mut e);
656        let e1: f64 = e.iter().map(C64::norm_sqr).sum();
657        assert!((e1 / e0 - 1.0).abs() < 1e-12);
658    }
659
660    /// Two polarisations of about 30 GHz of bandwidth at 256 GS/s, of mean power 10 mW.
661    fn dual_field(n: usize) -> (Vec<C64>, Vec<C64>) {
662        let w = |k: usize, f: f64, ph: f64| (2.0 * std::f64::consts::PI * f * k as f64 / n as f64 + ph).sin();
663        let x = (0..n)
664            .map(|k| C64::new(w(k, 61.0, 0.3), w(k, 23.0, 1.1)) * (0.07 * (1.0 + 0.5 * w(k, 7.0, 0.0))))
665            .collect();
666        let y = (0..n)
667            .map(|k| C64::new(w(k, 41.0, 2.0), w(k, 89.0, 0.4)) * (0.07 * (1.0 + 0.5 * w(k, 5.0, 1.0))))
668            .collect();
669        (x, y)
670    }
671
672    #[test]
673    fn manakov_with_one_polarisation_is_the_scalar_equation_with_8_9_gamma() {
674        let m = Medium { alpha_np_km: 0.046, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
675        let (x0, _) = dual_field(512);
676        let (mut x, mut y) = (x0.clone(), vec![C64::new(0.0, 0.0); 512]);
677        let s = ManakovSplitStep {
678            rule: StepRule::Fixed(0.01),
679            max_iterations: 20,
680            tolerance: 1e-12,
681            ..ManakovSplitStep::new(m)
682        };
683        s.run(&mut x, &mut y, 20.0, 128e9).unwrap();
684        let mut e = x0;
685        let scalar = Medium { gamma_w_km: 8.0 / 9.0 * 1.3, ..m };
686        SplitStep::new(&scalar, Steps::new(2000, 0.01), 512, 128e9).run(&mut e);
687        let err = x.iter().zip(&e).map(|(u, v)| (u - v).norm()).fold(0.0, f64::max);
688        let peak = e.iter().map(|v| v.norm()).fold(0.0, f64::max);
689        assert!(err < 1e-4 * peak, "{}", err / peak);
690        assert!(y.iter().all(|v| v.norm() == 0.0));
691    }
692
693    #[test]
694    fn manakov_rejects_a_step_that_does_not_advance_instead_of_looping() {
695        let m = Medium { alpha_np_km: 0.0, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
696        for rule in [
697            StepRule::Fixed(0.0),
698            StepRule::Fixed(-1.0),
699            StepRule::Fixed(f64::NAN),
700            StepRule::NonlinearPhase(0.0),
701            StepRule::NonlinearPhase(-2e-2),
702        ] {
703            let (mut x, mut y) = dual_field(64);
704            let s = ManakovSplitStep { rule, ..ManakovSplitStep::new(m) };
705            assert!(matches!(s.run(&mut x, &mut y, 1.0, 256e9), Err(Error::Config(_))), "{rule:?}");
706        }
707        let s = ManakovSplitStep::new(m);
708        let (mut x, mut y) = dual_field(64);
709        assert!(matches!(s.run(&mut x, &mut y, f64::INFINITY, 256e9), Err(Error::Config(_))));
710        assert!(matches!(s.run(&mut x, &mut y[..32], 1.0, 256e9), Err(Error::Shape(_))));
711        assert_eq!(s.run(&mut x, &mut y, 0.0, 256e9).unwrap(), 0);
712        // an infinite power: a nonlinear step of zero length
713        x[0] = C64::new(f64::INFINITY, 0.0);
714        assert!(matches!(s.run(&mut x, &mut y, 1.0, 256e9), Err(Error::Numerical(_))));
715    }
716
717    #[test]
718    fn manakov_conserves_the_total_energy_and_converges_with_the_step() {
719        let m = Medium { alpha_np_km: 0.0, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
720        let run = |rule| {
721            let (mut x, mut y) = dual_field(1024);
722            let s = ManakovSplitStep { rule, ..ManakovSplitStep::new(m) };
723            let steps = s.run(&mut x, &mut y, 80.0, 256e9).unwrap();
724            (x, y, steps)
725        };
726        let (x0, y0) = dual_field(1024);
727        let e0: f64 = x0.iter().chain(&y0).map(C64::norm_sqr).sum();
728        let (xa, ya, na) = run(StepRule::NonlinearPhase(2e-2));
729        let (xb, yb, nb) = run(StepRule::NonlinearPhase(2e-4));
730        let e1: f64 = xa.iter().chain(&ya).map(C64::norm_sqr).sum();
731        assert!((e1 / e0 - 1.0).abs() < 1e-10);
732        assert!(nb > 50 * na);
733        // second order: the error of the coarse solution is small against the fine one
734        let err: f64 = xa.iter().zip(&xb).chain(ya.iter().zip(&yb)).map(|(u, v)| (u - v).norm_sqr()).sum();
735        assert!((err / e0).sqrt() < 1e-3, "{}", (err / e0).sqrt());
736    }
737
738    #[test]
739    fn the_fwm_rule_follows_eq_13_of_musetti_and_grows_with_the_loss() {
740        // two tones 200 GHz apart: a bandwidth of 200 GHz plus one bin
741        let (n, fs) = (1024, 1.024e12);
742        let tone =
743            |f: f64| -> Vec<C64> { (0..n).map(|k| cis(2.0 * std::f64::consts::PI * f * k as f64 / fs)).collect() };
744        let x: Vec<C64> = tone(-100e9).iter().zip(tone(100e9)).map(|(a, b)| a + b).collect();
745        let y = vec![C64::new(0.0, 0.0); n];
746        assert!((signal_bandwidth(&[&x, &y], fs) - 201e9).abs() < 1.0);
747        assert_eq!(signal_bandwidth(&[&y], fs), 0.0);
748        // the first step of eq. (13): 20 rad over 350 GHz of SMF is 190 m
749        let m = Medium {
750            alpha_np_km: 0.2 / 10.0 * std::f64::consts::LN_10,
751            beta2_s2_km: -2.1683e-23,
752            gamma_w_km: 1.3,
753            beta3_s3_km: 0.0,
754        };
755        let s = ManakovSplitStep {
756            rule: StepRule::Fwm {
757                phase_rad: 20.0,
758                bandwidth_hz: Some(350e9),
759                update: StepUpdate::ConstantLocalError,
760                max_nl_phase_rad: None,
761            },
762            ..ManakovSplitStep::new(m)
763        };
764        let h1 = s.step_length(&[0.0], 0.0, 100.0, None, 350e9).unwrap();
765        assert!((h1 - 20.0 / (2.1683e-23 * (2.0 * std::f64::consts::PI * 350e9).powi(2))).abs() < 1e-12);
766        assert!((h1 - 0.190).abs() < 0.001, "{h1}");
767        // the constant local error stretches the step by e^{α h / 3}; without loss it stays
768        let h2 = s.step_length(&[0.0], h1, 100.0, Some(h1), 350e9).unwrap();
769        assert!((h2 / h1 - (m.alpha_np_km * h1 / 3.0).exp()).abs() < 1e-12);
770        let lossless = ManakovSplitStep { medium: Medium { alpha_np_km: 0.0, ..m }, ..s };
771        assert_eq!(lossless.step_length(&[0.0], h1, 100.0, Some(h1), 350e9).unwrap(), h1);
772        // the nonlinear-phase update keeps L_eff(h) e^{-α z}: it grows faster
773        let nlp = ManakovSplitStep {
774            rule: StepRule::Fwm {
775                phase_rad: 20.0,
776                bandwidth_hz: Some(350e9),
777                update: StepUpdate::NonlinearPhase,
778                max_nl_phase_rad: None,
779            },
780            ..s
781        };
782        let h2n = nlp.step_length(&[0.0], h1, 100.0, Some(h1), 350e9).unwrap();
783        let leff = |h: f64| (1.0 - (-m.alpha_np_km * h).exp()) / m.alpha_np_km;
784        assert!((leff(h2n) / leff(h1) - (m.alpha_np_km * h1).exp()).abs() < 1e-12);
785        assert!(h2n > h2);
786        // a non-positive phase or bandwidth is refused
787        for rule in [
788            StepRule::Fwm {
789                phase_rad: 0.0,
790                bandwidth_hz: None,
791                update: StepUpdate::ConstantLocalError,
792                max_nl_phase_rad: None,
793            },
794            StepRule::Fwm {
795                phase_rad: 20.0,
796                bandwidth_hz: Some(0.0),
797                update: StepUpdate::ConstantLocalError,
798                max_nl_phase_rad: None,
799            },
800            StepRule::Fwm {
801                phase_rad: 20.0,
802                bandwidth_hz: None,
803                update: StepUpdate::ConstantLocalError,
804                max_nl_phase_rad: Some(0.0),
805            },
806        ] {
807            let (mut a, mut b) = (x.clone(), y.clone());
808            assert!(ManakovSplitStep { rule, ..s }.run(&mut a, &mut b, 1.0, fs).is_err());
809        }
810    }
811
812    #[test]
813    fn the_third_order_dispersion_adds_a_cubic_phase() {
814        let m = Medium { beta2_s2_km: -2.17e-23, beta3_s3_km: 1.3e-37, ..Medium::default() };
815        let omega = fft::angular_frequencies(64, 64e9);
816        let r = m.linear_response(&omega, 50.0);
817        for (v, w) in r.iter().zip(&omega) {
818            let phase = (m.beta2_s2_km / 2.0 * w * w + m.beta3_s3_km / 6.0 * w * w * w) * 50.0;
819            assert!((v - cis(phase)).norm() < 1e-12);
820        }
821        // negated, the response undoes itself
822        let back = m.negated().linear_response(&omega, 50.0);
823        assert!(r.iter().zip(&back).all(|(a, b)| ((a * b) - C64::new(1.0, 0.0)).norm() < 1e-12));
824    }
825
826    #[test]
827    fn one_field_is_the_scalar_split_step_and_fixed_steps_are_equal() {
828        let m = Medium { alpha_np_km: 0.046, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
829        let (x0, _) = dual_field(1024);
830        let fs = 256e9;
831        // 10 km with steps of at most 3 km: four steps of 2.5 km
832        let mut scalar = x0.clone();
833        SplitStep::new(&m, Steps::new(4, 2.5), 1024, fs).run(&mut scalar);
834        let engine = ManakovSplitStep { rule: StepRule::Fixed(3.0), ..ManakovSplitStep::new(m) };
835        let mut x = x0.clone();
836        assert_eq!(engine.run_fields(&mut [&mut x], 10.0, fs, &PropagationOptions::default()).unwrap(), 4);
837        let e: f64 = scalar.iter().map(C64::norm_sqr).sum();
838        let d: f64 = x.iter().zip(&scalar).map(|(u, v)| (u - v).norm_sqr()).sum();
839        assert!((d / e).sqrt() < 1e-12, "{}", (d / e).sqrt());
840    }
841
842    #[test]
843    fn the_birefringence_of_the_sections_is_their_lumped_response() {
844        // without nonlinearity: the product of D_k R_k at every frequency
845        let m = Medium { alpha_np_km: 0.0, beta2_s2_km: 0.0, gamma_w_km: 0.0, beta3_s3_km: 0.0 };
846        let (n, fs) = (256, 256e9);
847        let (x0, y0) = dual_field(n);
848        let rot = |t: f64, p: f64| -> [C64; 4] {
849            let (s, c) = t.sin_cos();
850            [C64::new(c, 0.0), -cis(-p) * s, cis(p) * s, C64::new(c, 0.0)]
851        };
852        let rotations = [rot(0.3, 0.1), rot(1.1, -0.7), rot(-0.4, 2.0)];
853        let dgd = [3e-12, 1e-12, 2e-12];
854        let b = Birefringence { section_km: 2.0, rotations: &rotations, dgd_s_per_km: &dgd };
855        let engine = ManakovSplitStep { rule: StepRule::Fixed(0.7), ..ManakovSplitStep::new(m) };
856        let (mut x, mut y) = (x0.clone(), y0.clone());
857        let options = PropagationOptions { gain: None, birefringence: Some(b) };
858        let steps = engine.run_fields(&mut [&mut x, &mut y], 6.0, fs, &options).unwrap();
859        assert_eq!(steps, 9); // three sections of three steps
860        // the lumped response in the frequency domain
861        let omega = fft::angular_frequencies(n, fs);
862        let (fx, fy) = (fft::fft(&x0), fft::fft(&y0));
863        let mut ex = vec![C64::new(0.0, 0.0); n];
864        let mut ey = ex.clone();
865        for k in 0..n {
866            let (mut a, mut c) = (fx[k], fy[k]);
867            for (r, t) in rotations.iter().zip(&dgd) {
868                (a, c) = (r[0] * a + r[1] * c, r[2] * a + r[3] * c);
869                let ph = 0.5 * omega[k] * t * 2.0;
870                (a, c) = (a * cis(-ph), c * cis(ph));
871            }
872            ex[k] = a;
873            ey[k] = c;
874        }
875        let (ex, ey) = (fft::ifft(&ex), fft::ifft(&ey));
876        let e: f64 = ex.iter().chain(&ey).map(C64::norm_sqr).sum();
877        let d: f64 = x.iter().zip(&ex).chain(y.iter().zip(&ey)).map(|(u, v)| (u - v).norm_sqr()).sum();
878        assert!((d / e).sqrt() < 1e-12, "{}", (d / e).sqrt());
879        // invalid inputs
880        let mut z = x0.clone();
881        assert!(engine.run_fields(&mut [&mut z], 6.0, fs, &options).is_err());
882        assert!(engine.run_fields(&mut [&mut x, &mut y], 5.0, fs, &options).is_err());
883        let (mut a, mut b2, mut c) = (x0.clone(), x0.clone(), x0);
884        assert!(engine.run_fields(&mut [&mut a, &mut b2, &mut c], 1.0, fs, &PropagationOptions::default()).is_err());
885        assert!(engine.run_fields(&mut [], 1.0, fs, &PropagationOptions::default()).is_err());
886    }
887
888    #[test]
889    fn the_midpoint_manakov_step_is_the_scalar_split_step() {
890        // one polarisation, gamma scaled by 9/8: the same scheme as SplitStep
891        let m = Medium { alpha_np_km: 0.046, beta2_s2_km: -2.17e-23, gamma_w_km: 1.3, beta3_s3_km: 0.0 };
892        let (x0, _) = dual_field(1024);
893        let (h, n_steps, fs) = (0.5, 160, 256e9);
894        let mut scalar = x0.clone();
895        SplitStep::new(&m, Steps::new(n_steps, h), 1024, fs).run(&mut scalar);
896        let solver = ManakovSplitStep {
897            rule: StepRule::Fixed(h),
898            nonlinear: NonlinearStep::Midpoint,
899            ..ManakovSplitStep::new(Medium { gamma_w_km: 9.0 / 8.0 * m.gamma_w_km, ..m })
900        };
901        let (mut x, mut y) = (x0, vec![C64::new(0.0, 0.0); 1024]);
902        assert_eq!(solver.run(&mut x, &mut y, n_steps as f64 * h, fs).unwrap(), n_steps);
903        let e: f64 = scalar.iter().map(C64::norm_sqr).sum();
904        let d: f64 = x.iter().zip(&scalar).map(|(u, v)| (u - v).norm_sqr()).sum();
905        assert!((d / e).sqrt() < 1e-12, "{}", (d / e).sqrt());
906        assert!(y.iter().all(|v| v.norm() == 0.0));
907        assert_eq!("Midpoint".parse::<NonlinearStep>().unwrap(), NonlinearStep::Midpoint);
908        assert!("euler".parse::<NonlinearStep>().is_err());
909    }
910
911    #[test]
912    fn fundamental_soliton_keeps_its_shape() {
913        // T0 = 10 ps, β₂ = -20 ps²/km, P0 = |β₂| / (γ T0²): |A(z, t)| = |A(0, t)|
914        let (t0, b2, gamma): (f64, f64, f64) = (10e-12, -20e-24, 1.0);
915        let p0 = b2.abs() / (gamma * t0 * t0);
916        let (n, fs) = (1024, 2e12);
917        let a0: Vec<C64> = (0..n)
918            .map(|k| {
919                let t = (k as f64 - n as f64 / 2.0) / fs;
920                C64::new(p0.sqrt() / (t / t0).cosh(), 0.0)
921            })
922            .collect();
923        let m = Medium { alpha_np_km: 0.0, beta2_s2_km: b2, gamma_w_km: gamma, beta3_s3_km: 0.0 };
924        let mut a = a0.clone();
925        // two dispersion lengths (L_D = T0²/|β₂| = 5 km)
926        SplitStep::new(&m, Steps::new(1000, 0.01), n, fs).run(&mut a);
927        let err = a.iter().zip(&a0).map(|(u, v)| (u.norm() - v.norm()).abs()).fold(0.0, f64::max);
928        assert!(err < 1e-3 * p0.sqrt(), "relative envelope error {}", err / p0.sqrt());
929    }
930}