treams_core/
channels.rs

1//! Plane-wave channels of periodic arrays: how the multipoles of a 2D array of spheres
2//! or a 1D array of cylinders couple to the diffracted plane waves on either side.
3//!
4//! Upstream: `treams.sw.periodic_to_pw`, `treams.cw.periodic_to_pw`, and the plane-wave
5//! expansions inside `treams.SMatrices.from_array`.
6//!
7//! | Item | Contents | Upstream |
8//! |---|---|---|
9//! | [`sw_periodic_to_pw`] | one plane-wave amplitude radiated by a spherical multipole of a 2D array | `sw.periodic_to_pw` |
10//! | [`cw_periodic_to_pw`] | one plane-wave amplitude radiated by a cylindrical mode of a 1D array | `cw.periodic_to_pw` |
11//! | [`spherical_channels`] | the incident and radiated coefficients of a whole spherical basis, with a pullback | `SMatrices.from_array` on a `TMatrix` |
12//! | [`cylindrical_channels`] | the same for a cylindrical basis | `SMatrices.from_array` on a `TMatrixC` |
13//!
14//! Each channel matrix has one column per plane mode and `4 d` rows for `d` multipoles:
15//! row `side * d + i` holds the regular expansion of the incident plane wave (the
16//! `pw.to_sw` or `pw.to_cw` coefficient, with the phase of the multipole position), and
17//! row `(2 + side) * d + i` the amplitude of the plane wave that multipole `i` radiates.
18//! On side 0 the plane wave travels up, toward `+z` for spheres and `+y` for cylinders;
19//! on side 1 it travels down.
20//!
21//! Both families share [`ChannelGradient`], input checks and matrix assembly. Each
22//! evaluates its own channel type and jet size; pullbacks share an ordered traversal
23//! while keeping the family-specific transverse-wavevector rules explicit.
24//!
25//! The inline `tests` module checks the error paths; the reciprocity, scaling and
26//! adjoint identities of the channels are in `properties/plane.rs`.
27#![allow(clippy::indexing_slicing)] // Validated modes and fixed-size local jets.
28
29use nalgebra::DMatrix;
30use rayon::prelude::*;
31
32use crate::{
33    Complex, Error, Result,
34    basis::ModeLabel,
35    numerics::{Jet, finite, parallel::try_fold_ordered},
36    saved::{self, Reader, SavedState, Writer},
37    special::polarized_angular,
38    sw::{Basis, Mode},
39};
40
41// Jet slots of the channel derivatives. Both families order their inputs as the three
42// position components, the medium wavenumber, the transverse wavevector, and last the
43// unit-cell measure.
44/// First of the three slots of the multipole position.
45const POSITION: usize = 0;
46/// Slot of the medium wavenumber `k`.
47const K: usize = 3;
48/// First transverse-wavevector slot: `(qx, qy)` for spheres, `kx` alone for cylinders.
49const Q: usize = 4;
50/// Slot of the unit-cell area of a 2D array.
51const SPHERICAL_MEASURE: usize = 6;
52/// Jet size of a spherical channel.
53const SPHERICAL_SLOTS: usize = SPHERICAL_MEASURE + 1;
54/// Slot of the period of a 1D array.
55const CYLINDRICAL_MEASURE: usize = 5;
56/// Jet size of a cylindrical channel.
57const CYLINDRICAL_SLOTS: usize = CYLINDRICAL_MEASURE + 1;
58
59/// Assemble incident/radiated pairs, constructing each channel once per side.
60/// Each worker owns complete plane-mode columns without buffering intermediate values.
61fn channel_matrix<I: Iterator<Item = [Complex; 2]>>(
62    multipoles: usize,
63    planes: usize,
64    entries: impl Fn(usize, usize) -> Result<I> + Sync,
65) -> Result<DMatrix<Complex>> {
66    let mut value = crate::numerics::zeros(4 * multipoles, planes)?;
67    crate::threads::install(|| {
68        value
69            .as_mut_slice()
70            .par_chunks_mut(4 * multipoles)
71            .enumerate()
72            .try_for_each(|(j, column)| -> Result<()> {
73                for side in 0..2 {
74                    for (i, [incident, radiated]) in entries(j, side)?.enumerate() {
75                        column[side * multipoles + i] = incident;
76                        column[(2 + side) * multipoles + i] = radiated;
77                    }
78                }
79                Ok(())
80            })
81    })?;
82    Ok(value)
83}
84
85/// One diffraction channel of a 2D array of spheres in one direction: the plane wave
86/// `(qx, qy, ±kz)` and the angles at which the multipole coefficients are evaluated,
87/// as jets in the slots above.
88struct SphericalChannel<const N: usize> {
89    /// The plane wavevector `(qx, qy, ±kz)`: `+kz` on side 0, `-kz` on side 1.
90    vector: [Jet<N>; 3],
91    /// Wavenumber of the medium on the chosen root, see [`SphericalChannel::new`].
92    k: Jet<N>,
93    /// Normal wavenumber `sqrt(k^2 - q^2)` with a positive imaginary part or a
94    /// nonnegative real value.
95    kz: Jet<N>,
96    /// `sin(theta)` with a nonnegative real part.
97    sine: Jet<N>,
98    /// `cos(theta) = ±kz / k`.
99    cosine: Jet<N>,
100    /// `exp(i phi) = (qx + i qy) / |q|`, or 1 at normal incidence.
101    azimuth: Jet<N>,
102    /// Unit-cell area.
103    area: Jet<N>,
104}
105impl<const N: usize> SphericalChannel<N> {
106    /// The channel of transverse wavevector `q` on `side` (0 up, 1 down).
107    ///
108    /// `k` is the principal root of `k^2`, as upstream `sw.periodic_to_pw` computes
109    /// `k = sqrt(kx^2 + ky^2 + kz^2)`, so a wavenumber with a negative real part gives the
110    /// same channel as its negative. `kz = sqrt(k^2 - q^2)` takes the root with a positive
111    /// imaginary part, or the nonnegative real root, as upstream `kz_s`: the wave decays
112    /// or propagates in its direction of travel. Upstream replaces `kz = 0` by
113    /// `1e-20 (1 + i)`; this returns an error when the computed `kz` is exactly zero.
114    fn new(k: Complex, q: [f64; 2], side: usize, area: f64, fixed_q: bool) -> Result<Self> {
115        let input = Jet::variable(k, K);
116        let xy: [Jet<N>; 2] = std::array::from_fn(|i| {
117            if fixed_q {
118                Jet::constant(q[i])
119            } else {
120                Jet::variable(q[i], Q + i)
121            }
122        });
123        let k = (input * input).sqrt();
124        let mut kz = (input * input - xy[0] * xy[0] - xy[1] * xy[1]).sqrt();
125        if kz.value == Complex::default() {
126            return Err(Error::InvalidInput(
127                "plane-wave channel is at a diffraction threshold".into(),
128            ));
129        }
130        if kz.value.im < 0.0 || (kz.value.im == 0.0 && kz.value.re < 0.0) {
131            kz = -kz;
132        }
133        let scale = q[0].abs().max(q[1].abs());
134        let (mut sine, azimuth) = if scale == 0.0 {
135            if N > 0 && !fixed_q {
136                return Err(Error::InvalidInput("normal-incidence azimuth is undefined; hold the transverse wavevector fixed (fixed_q) to differentiate at normal incidence".into()));
137            }
138            (Jet::default(), Jet::constant(1.0))
139        } else {
140            let x = xy[0] / scale;
141            let y = xy[1] / scale;
142            let norm = (x * x + y * y).sqrt();
143            (norm * scale / k, (x + Complex::i() * y) / norm)
144        };
145        if sine.value.re < 0.0 {
146            sine = -sine;
147        }
148        let axial = if side == 0 { kz } else { -kz };
149        Ok(Self {
150            vector: [xy[0], xy[1], axial],
151            k,
152            kz,
153            sine,
154            cosine: axial / k,
155            azimuth,
156            area: Jet::variable(area, SPHERICAL_MEASURE),
157        })
158    }
159
160    /// The incident and radiated coefficients of `mode` at `position` for the plane
161    /// polarization `pol`: `[pw.to_sw, sw.periodic_to_pw]` with the position phase.
162    fn entry(&self, mode: Mode, pol: u8, position: [f64; 3], helicity: bool) -> [Jet<N>; 2] {
163        if helicity && mode.pol != pol {
164            return [Jet::default(); 2];
165        }
166        let (l, m) = (mode.l, mode.m);
167        let angular = polarized_angular(l, m, [self.cosine, self.sine], [mode.pol, pol], helicity);
168        let normalization = (std::f64::consts::PI * f64::from(2 * l + 1) / f64::from(l * (l + 1)))
169            .sqrt()
170            * crate::special::factorial_ratio_sqrt(l, m);
171        let phase = (Complex::i()
172            * self
173                .vector
174                .into_iter()
175                .enumerate()
176                .map(|(i, k)| k * Jet::variable(position[i], POSITION + i))
177                .sum::<Jet<N>>())
178        .exp();
179        let incident =
180            2.0 * Complex::i().powi(l) * normalization * self.azimuth.powi(-m) * angular * phase;
181        let outgoing = (-Complex::i()).powi(l) * normalization * self.azimuth.powi(m) * angular
182            / (self.area * self.k * self.kz * phase);
183        [incident, outgoing]
184    }
185}
186
187/// Amplitude of the plane wave `vector` that the spherical multipole `mode` of a 2D
188/// array with unit-cell `area` radiates. The sign of `kz` (of its imaginary part for a
189/// complex `kz`) picks the side.
190///
191/// Upstream: `treams.sw.periodic_to_pw`.
192/// Differences: treams replaces `kz = 0` by `1e-20 (1 + i)`. Here `kz` is recomputed as
193/// `sqrt(k^2 - kx^2 - ky^2)`. When that is exactly zero, the call gives an error. When
194/// rounding leaves a tiny `kz`, the amplitude is large, about `1 / kz`.
195pub fn sw_periodic_to_pw(
196    mode: Mode,
197    vector: [Complex; 3],
198    pol: u8,
199    area: f64,
200    helicity: bool,
201) -> Result<Complex> {
202    mode.validate()?;
203    if pol > 1
204        || vector.iter().any(|&v| !finite(v))
205        || vector[0].im != 0.0
206        || vector[1].im != 0.0
207        || !area.is_finite()
208        || area == 0.0
209    {
210        return Err(Error::InvalidInput("real transverse wavevector, finite axial component, polarization 0/1 and nonzero area required".into()));
211    }
212    let side = usize::from(vector[2].im < 0.0 || (vector[2].im == 0.0 && vector[2].re < 0.0));
213    let k = crate::numerics::complex_sqrt(vector.iter().map(|v| v * v).sum());
214    let channel =
215        SphericalChannel::<0>::new(k, [vector[0].re, vector[1].re], side, area.abs(), true)?;
216    let value = channel.entry(mode, pol, [0.0; 3], helicity)[1].value;
217    if !finite(value) {
218        return Err(Error::NonFinite(
219            "non-finite plane radiation coefficient".into(),
220        ));
221    }
222    Ok(value)
223}
224
225/// What [`spherical_channels`] saves for its pullback: its inputs alone.
226///
227/// The pullback recomputes each channel with its derivatives with respect to the
228/// position, the wavenumber, the transverse wavevector and the area.
229///
230/// The pullback adds the per-column position, wavenumber and area gradients in chunks of
231/// consecutive columns fixed by the column count, and the chunk sums in chunk order
232/// (`numerics::parallel::try_fold_ordered`), so the thread count does not change them.
233/// Each transverse-wavevector gradient belongs to one column, which writes it in place.
234#[derive(Clone, Debug)]
235pub struct SphericalChannelsResidual {
236    basis: Basis,
237    ks: [Complex; 2],
238    q: Vec<[f64; 2]>,
239    polarizations: Vec<u8>,
240    area: f64,
241    helicity: bool,
242    fixed_q: bool,
243}
244
245/// Check the inputs both channel families share; `measure` is the area or the period.
246fn validate_channels(
247    ks: [Complex; 2],
248    q: &[[f64; 2]],
249    polarizations: &[u8],
250    measure: f64,
251    helicity: bool,
252) -> Result<()> {
253    if q.is_empty()
254        || q.len() != polarizations.len()
255        || q.iter().flatten().any(|v| !v.is_finite())
256        || polarizations.iter().any(|&p| p > 1)
257        || ks.iter().any(|&k| !finite(k) || k == Complex::default())
258        || !measure.is_finite()
259        || measure <= 0.0
260    {
261        return Err(Error::InvalidInput("channels require finite transverse vectors, polarizations 0/1, nonzero wavenumbers and positive unit-cell measure".into()));
262    }
263    if !helicity && ks[0] != ks[1] {
264        return Err(Error::InvalidInput(
265            "parity channels require an achiral medium".into(),
266        ));
267    }
268    Ok(())
269}
270
271/// Incident and radiated channels of a spherical basis in a 2D array with unit-cell
272/// `area`.
273///
274/// The plane modes have transverse wavevectors `q = (qx, qy)` and polarizations
275/// `polarizations`; polarization `pol` sees the medium wavenumber `ks[pol]`.
276///
277/// The rows of the matrix pack (incident/radiated, up/down, multipole), as the module
278/// documentation shows; its columns are plane modes. With `fixed_q`, the pullback holds
279/// the incident and diffraction directions fixed.
280///
281/// Upstream: the plane-wave expansions inside `treams.SMatrices.from_array`.
282pub fn spherical_channels(
283    basis: Basis,
284    ks: [Complex; 2],
285    q: Vec<[f64; 2]>,
286    polarizations: Vec<u8>,
287    area: f64,
288    helicity: bool,
289    fixed_q: bool,
290) -> Result<(DMatrix<Complex>, SphericalChannelsResidual)> {
291    basis.validate()?;
292    validate_channels(ks, &q, &polarizations, area, helicity)?;
293    // The value pass takes no derivatives (N = 0), so `fixed_q` has no effect.
294    let positions = &basis.positions;
295    let value = channel_matrix(basis.modes.len(), q.len(), |j, side| {
296        let pol = polarizations[j];
297        let channel = SphericalChannel::<0>::new(ks[usize::from(pol)], q[j], side, area, true)?;
298        Ok(basis.modes.iter().map(move |&(p, mode)| {
299            channel
300                .entry(mode, pol, positions[p], helicity)
301                .map(|v| v.value)
302        }))
303    })?;
304    if value.iter().any(|&v| !finite(v)) {
305        return Err(Error::NonFinite("non-finite plane-wave channel".into()));
306    }
307    Ok((
308        value,
309        SphericalChannelsResidual {
310            basis,
311            ks,
312            q,
313            polarizations,
314            area,
315            helicity,
316            fixed_q,
317        },
318    ))
319}
320
321/// Cotangents of positions, medium wavenumbers, transverse plane vectors and the unit-cell
322/// measure.
323#[derive(Clone, Debug)]
324pub struct ChannelGradient {
325    /// Multipole position cotangents.
326    pub positions: Vec<[f64; 3]>,
327    /// Complex medium wavenumber cotangents.
328    pub ks: [Complex; 2],
329    /// Transverse plane-wave cotangents (zero when `fixed_q` is requested).
330    pub q: Vec<[f64; 2]>,
331    /// Cotangent of the unit-cell measure: the area of a 2D array or the period of a 1D
332    /// array.
333    pub measure: f64,
334}
335impl ChannelGradient {
336    fn zeros(positions: usize) -> Self {
337        Self {
338            positions: vec![[0.0; 3]; positions],
339            ks: [Complex::default(); 2],
340            q: Vec::new(),
341            measure: 0.0,
342        }
343    }
344    // Transverse gradients belong to individual columns, not these partial sums.
345    fn add(&mut self, other: Self) {
346        for (a, b) in self.positions.iter_mut().zip(other.positions) {
347            for (a, b) in a.iter_mut().zip(b) {
348                *a += b;
349            }
350        }
351        for (a, b) in self.ks.iter_mut().zip(other.ks) {
352            *a += b;
353        }
354        self.measure += other.measure;
355    }
356}
357/// Reverse the shared matrix assembly in fixed column, side and multipole order.
358/// Each column writes its own transverse gradient; only the shared gradients enter
359/// the ordered reduction. Construct channels before skipping zero-weight entries,
360/// preserving validation and avoiding evaluation of unused, possibly overflowing jets.
361fn channel_pullback<M: Copy + Sync, const N: usize, E: Fn(M, [f64; 3]) -> [Jet<N>; 2]>(
362    basis: &crate::basis::Basis<M>,
363    polarizations: &[u8],
364    cotangent: &DMatrix<Complex>,
365    entries: impl Fn(usize, usize) -> Result<E> + Sync,
366    add_q: impl Fn(&mut [f64; 2], &[Complex; N]) + Sync,
367) -> Result<ChannelGradient> {
368    let d = basis.modes.len();
369    let mut q_gradients = vec![[0.0; 2]; polarizations.len()];
370    let columns: Vec<_> = q_gradients.iter_mut().collect();
371    let mut result = try_fold_ordered(
372        columns,
373        true,
374        || ChannelGradient::zeros(basis.positions.len()),
375        |mut result, j, q_gradient| -> Result<_> {
376            let pol = usize::from(polarizations[j]);
377            for side in 0..2 {
378                let entry = entries(j, side)?;
379                for (i, &(p, mode)) in basis.modes.iter().enumerate() {
380                    let weights = [
381                        cotangent[(side * d + i, j)],
382                        cotangent[((2 + side) * d + i, j)],
383                    ];
384                    if weights.iter().all(|&z| z == Complex::default()) {
385                        continue;
386                    }
387                    let pair = entry(mode, basis.positions[p]);
388                    let gradient: [Complex; N] = std::array::from_fn(|a| {
389                        weights[0] * pair[0].derivative[a].conj()
390                            + weights[1] * pair[1].derivative[a].conj()
391                    });
392                    for (a, g) in result.positions[p]
393                        .iter_mut()
394                        .zip(&gradient[POSITION..POSITION + 3])
395                    {
396                        *a += g.re;
397                    }
398                    result.ks[pol] += gradient[K];
399                    add_q(q_gradient, &gradient);
400                    result.measure += gradient[N - 1].re;
401                }
402            }
403            Ok(result)
404        },
405        |mut total, partial| {
406            total.add(partial);
407            total
408        },
409    )?;
410    result.q = q_gradients;
411    Ok(result)
412}
413
414impl SphericalChannelsResidual {
415    /// Bytes of saved derivative state for the fixed basis and channel counts.
416    pub fn state_size(multipoles: usize, positions: usize, channels: usize) -> Result<usize> {
417        channel_state_size(saved::sw_basis_size(multipoles, positions)?, channels)
418    }
419
420    /// The shape of the channel matrix: four rows per multipole, one column per plane mode.
421    #[must_use]
422    pub fn shape(&self) -> (usize, usize) {
423        (4 * self.basis.modes.len(), self.q.len())
424    }
425
426    /// Check finite input tangents and their position and plane counts.
427    pub fn validate_tangent(
428        &self,
429        positions: &[[f64; 3]],
430        ks: [Complex; 2],
431        q: &[[f64; 2]],
432        measure: f64,
433    ) -> Result<()> {
434        validate_channel_tangent(
435            positions,
436            ks,
437            q,
438            measure,
439            self.basis.positions.len(),
440            self.q.len(),
441        )
442    }
443
444    /// Directional derivative of the channel matrix. Tangents have the same shapes
445    /// and order as the pullback outputs; `q` is held fixed with `fixed_q`. A zero
446    /// transverse tangent also permits other input directions at normal incidence.
447    pub fn pushforward(
448        &self,
449        positions: &[[f64; 3]],
450        ks: [Complex; 2],
451        q: &[[f64; 2]],
452        measure: f64,
453    ) -> Result<DMatrix<Complex>> {
454        self.validate_tangent(positions, ks, q, measure)?;
455        channel_matrix(self.basis.modes.len(), self.q.len(), |j, side| {
456            let pol = self.polarizations[j];
457            // The azimuth gauge at normal incidence is irrelevant when this
458            // direction leaves the transverse wavevector fixed.
459            let fixed_q = self.fixed_q || q[j].iter().all(|&v| v == 0.0);
460            let channel = SphericalChannel::<SPHERICAL_SLOTS>::new(
461                self.ks[usize::from(pol)],
462                self.q[j],
463                side,
464                self.area,
465                fixed_q,
466            )?;
467            Ok(self.basis.modes.iter().map(move |&(p, mode)| {
468                channel
469                    .entry(mode, pol, self.basis.positions[p], self.helicity)
470                    .map(|entry| {
471                        channel_direction(entry, positions[p], ks[usize::from(pol)], &q[j], measure)
472                    })
473            }))
474        })
475    }
476
477    /// Gradients of the positions, the two medium wavenumbers, the transverse wavevectors
478    /// (zero with `fixed_q`) and the area, for a `cotangent` of the channel matrix's
479    /// shape. Both families traverse columns, sides and multipoles in the same order.
480    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<ChannelGradient> {
481        if cotangent.shape() != self.shape() || cotangent.iter().any(|&z| !finite(z)) {
482            return Err(Error::InvalidInput("invalid channel cotangent".into()));
483        }
484        channel_pullback(
485            &self.basis,
486            &self.polarizations,
487            cotangent,
488            |j, side| {
489                let pol = self.polarizations[j];
490                let channel = SphericalChannel::<SPHERICAL_SLOTS>::new(
491                    self.ks[usize::from(pol)],
492                    self.q[j],
493                    side,
494                    self.area,
495                    self.fixed_q,
496                )?;
497                Ok(move |mode, position| channel.entry(mode, pol, position, self.helicity))
498            },
499            |q_gradient, gradient| {
500                if !self.fixed_q {
501                    for (a, g) in q_gradient.iter_mut().zip(&gradient[Q..Q + 2]) {
502                        *a += g.re;
503                    }
504                }
505            },
506        )
507    }
508}
509
510impl SavedState for SphericalChannelsResidual {
511    fn save_state(&self) -> Result<Vec<u8>> {
512        let mut writer = Writer::new(Self::state_size(
513            self.basis.modes.len(),
514            self.basis.positions.len(),
515            self.q.len(),
516        )?);
517        saved::write_sw_basis(&mut writer, &self.basis);
518        write_channel_state(
519            &mut writer,
520            self.ks,
521            &self.q,
522            &self.polarizations,
523            self.area,
524        );
525        writer.byte(u8::from(self.helicity) | (u8::from(self.fixed_q) << 1));
526        Ok(writer.finish())
527    }
528
529    fn from_state(bytes: &[u8]) -> Result<Self> {
530        let mut reader = Reader::new(bytes);
531        let basis = saved::read_sw_basis(&mut reader)?;
532        let (ks, q, polarizations, area) = read_channel_state(&mut reader)?;
533        let flags = reader.byte()?;
534        if flags > 3 {
535            return Err(saved::invalid());
536        }
537        reader.finish()?;
538        let helicity = flags & 1 != 0;
539        validate_channels(ks, &q, &polarizations, area, helicity)?;
540        Ok(Self {
541            basis,
542            ks,
543            q,
544            polarizations,
545            area,
546            helicity,
547            fixed_q: flags & 2 != 0,
548        })
549    }
550}
551
552/// The shared numerical channel inputs, with one byte for the family's flags.
553fn channel_state_size(basis: usize, channels: usize) -> Result<usize> {
554    channels
555        .checked_mul(17)
556        .and_then(|n| n.checked_add(49))
557        .and_then(|n| n.checked_add(basis))
558        .ok_or_else(saved::invalid)
559}
560
561fn write_channel_state(
562    writer: &mut Writer,
563    ks: [Complex; 2],
564    q: &[[f64; 2]],
565    polarizations: &[u8],
566    measure: f64,
567) {
568    for k in ks {
569        writer.complex(k);
570    }
571    writer.usize(q.len());
572    for (&[x, y], &pol) in q.iter().zip(polarizations) {
573        writer.f64(x);
574        writer.f64(y);
575        writer.byte(pol);
576    }
577    writer.f64(measure);
578}
579
580type ChannelState = ([Complex; 2], Vec<[f64; 2]>, Vec<u8>, f64);
581
582fn read_channel_state(reader: &mut Reader<'_>) -> Result<ChannelState> {
583    let ks = [reader.complex()?, reader.complex()?];
584    let count = reader.count(17)?;
585    // Only the cell measure and the flag byte follow the channels. Check the exact
586    // remaining layout before allocating either of the channel vectors.
587    if reader.remaining_len() != channel_state_size(0, count)? - 40 {
588        return Err(saved::invalid());
589    }
590    let mut q = Vec::with_capacity(count);
591    let mut polarizations = Vec::with_capacity(count);
592    for _ in 0..count {
593        q.push([reader.f64()?, reader.f64()?]);
594        polarizations.push(reader.byte()?);
595    }
596    Ok((ks, q, polarizations, reader.f64()?))
597}
598
599/// Both families use the same input shapes and slot layout, except that cylinders
600/// have only the `kx` transverse slot. Contract each small local jet directly; no
601/// global Jacobian or output-sized cotangent seeds are needed.
602fn channel_direction<const N: usize>(
603    entry: Jet<N>,
604    position: [f64; 3],
605    k: Complex,
606    q: &[f64],
607    measure: f64,
608) -> Complex {
609    position
610        .iter()
611        .zip(&entry.derivative[POSITION..K])
612        .map(|(v, d)| *v * *d)
613        .sum::<Complex>()
614        + k * entry.derivative[K]
615        + q.iter()
616            .zip(&entry.derivative[Q..N - 1])
617            .map(|(v, d)| *v * *d)
618            .sum::<Complex>()
619        + measure * entry.derivative[N - 1]
620}
621
622fn validate_channel_tangent(
623    positions: &[[f64; 3]],
624    ks: [Complex; 2],
625    q: &[[f64; 2]],
626    measure: f64,
627    position_count: usize,
628    plane_count: usize,
629) -> Result<()> {
630    if positions.len() != position_count
631        || q.len() != plane_count
632        || positions.iter().flatten().any(|v| !v.is_finite())
633        || ks.iter().any(|&v| !finite(v))
634        || q.iter().flatten().any(|v| !v.is_finite())
635        || !measure.is_finite()
636    {
637        return Err(Error::InvalidInput(
638            "channel tangents must be finite and match the input shapes".into(),
639        ));
640    }
641    Ok(())
642}
643
644/// One diffraction channel of a 1D array of cylinders along x in one direction: the plane
645/// wave `(kx, ±ky, kz)`, as jets in the slots above. The axial `kz` is a fixed label.
646///
647/// Its assembly and pullback traversal are shared with [`SphericalChannel`].
648struct CylindricalChannel<const N: usize> {
649    /// The plane wavevector `(kx, ±ky, kz)`: `+ky` on side 0, `-ky` on side 1.
650    vector: [Jet<N>; 3],
651    /// `sqrt(k^2 - kz^2)` with a nonnegative imaginary part: the radial wavenumber of the
652    /// cylindrical modes.
653    transverse: Jet<N>,
654    /// Normal wavenumber `sqrt(k^2 - kz^2 - kx^2)` with a positive imaginary part or a
655    /// nonnegative real value.
656    normal: Jet<N>,
657    /// Lattice period.
658    period: Jet<N>,
659}
660impl<const N: usize> CylindricalChannel<N> {
661    /// The channel of `q = (kz, kx)` on `side` (0 toward `+y`, 1 toward `-y`). The normal
662    /// wavenumber takes the root with a positive imaginary part, or the nonnegative real
663    /// root, as upstream `cw.periodic_to_pw` does for `ky`; a channel at a cutoff or
664    /// diffraction threshold is an error.
665    fn new(k: Complex, q: [f64; 2], side: usize, period: f64, fixed_q: bool) -> Result<Self> {
666        let k = Jet::variable(k, K);
667        let kz = Jet::constant(q[0]);
668        let kx = if fixed_q {
669            Jet::constant(q[1])
670        } else {
671            Jet::variable(q[1], Q)
672        };
673        let mut transverse = (k * k - kz * kz).sqrt();
674        let normal = (k * k - kz * kz - kx * kx).sqrt();
675        if transverse.value == Complex::default() {
676            return Err(Error::InvalidInput(
677                "cylindrical plane channel is at a cutoff or diffraction threshold".into(),
678            ));
679        }
680        if transverse.value.im < 0.0 {
681            transverse = -transverse;
682        }
683        let mut channel = Self::from_vector([kx, normal, kz], transverse, period)?;
684        channel.vector[1] = if side == 0 {
685            channel.normal
686        } else {
687            -channel.normal
688        };
689        Ok(channel)
690    }
691
692    /// Preserve a supplied vector, including its propagation direction and the
693    /// scalar API's zero-transverse-wavenumber convention. Matrix channels check
694    /// that additional cutoff above because their derivatives are undefined there.
695    fn from_vector(vector: [Jet<N>; 3], transverse: Jet<N>, period: f64) -> Result<Self> {
696        let mut normal = vector[1];
697        if normal.value == Complex::default() {
698            return Err(Error::InvalidInput(
699                "plane channel is at a diffraction threshold".into(),
700            ));
701        }
702        if normal.value.im < 0.0 || (normal.value.im == 0.0 && normal.value.re < 0.0) {
703            normal = -normal;
704        }
705        Ok(Self {
706            vector,
707            transverse,
708            normal,
709            period: Jet::variable(period, CYLINDRICAL_MEASURE),
710        })
711    }
712    /// The incident and radiated coefficients of `mode` at `position` for the plane
713    /// polarization `pol`: `[pw.to_cw, cw.periodic_to_pw]` with the position phase.
714    fn entry(&self, mode: crate::cw::Mode, pol: u8, position: [f64; 3]) -> [Jet<N>; 2] {
715        if !crate::pw::cylindrical_mode_matches(mode, self.vector[2].value, pol) {
716            return [Jet::default(); 2];
717        }
718        let phase = (Complex::i()
719            * self
720                .vector
721                .iter()
722                .enumerate()
723                .map(|(a, &k)| k * Jet::variable(position[a], POSITION + a))
724                .sum::<Jet<N>>())
725        .exp();
726        let (incoming_angle, outgoing_angle) = if self.transverse.value == Complex::default() {
727            (
728                Jet::constant(Complex::i().powi(mode.m)),
729                Jet::constant((-Complex::i()).powi(mode.m)),
730            )
731        } else {
732            (
733                ((Complex::i() * self.vector[0] + self.vector[1]) / self.transverse).powi(mode.m),
734                ((-Complex::i() * self.vector[0] + self.vector[1]) / self.transverse).powi(mode.m),
735            )
736        };
737        let incident = incoming_angle * phase;
738        let outgoing = 2.0 * outgoing_angle / (self.period * self.normal * phase);
739        [incident, outgoing]
740    }
741}
742
743/// Amplitude of the plane wave `vector = (kx, ky, kz)` that the cylindrical mode `mode`
744/// of a 1D array along x with lattice `period` radiates.
745///
746/// The normal wavenumber is `ky` or `-ky`, whichever has a positive imaginary part or,
747/// when real, is nonnegative. A `kz` that differs from the mode's `kz` in any bit gives
748/// zero, as in treams.
749///
750/// Upstream: `treams.cw.periodic_to_pw`.
751/// Differences: a channel at a diffraction threshold (`ky = 0`) gives an error, where
752/// treams replaces `ky` by `1e-20 (1 + i)`.
753#[allow(clippy::float_cmp)] // Direct coefficients preserve exact axial labels.
754pub fn cw_periodic_to_pw(
755    mode: crate::cw::Mode,
756    vector: [Complex; 3],
757    pol: u8,
758    period: f64,
759) -> Result<Complex> {
760    mode.validate()?;
761    if pol > 1
762        || vector.iter().any(|&v| !finite(v))
763        || vector[0].im != 0.0
764        || vector[2].im != 0.0
765        || !period.is_finite()
766        || period == 0.0
767    {
768        return Err(Error::InvalidInput(
769            "finite vector with real kx/kz, polarization 0/1 and nonzero period required".into(),
770        ));
771    }
772    if mode.kz != vector[2].re || mode.pol != pol {
773        return Ok(Complex::default());
774    }
775    let channel = CylindricalChannel::<0>::from_vector(
776        vector.map(Jet::constant),
777        Jet::constant(crate::numerics::complex_sqrt(
778            vector[0] * vector[0] + vector[1] * vector[1],
779        )),
780        period.abs(),
781    )?;
782    let value = channel.entry(mode, pol, [0.0; 3])[1].value;
783    if !finite(value) {
784        return Err(Error::NonFinite(
785            "non-finite plane radiation coefficient".into(),
786        ));
787    }
788    Ok(value)
789}
790
791/// What [`cylindrical_channels`] saves for its pullback: its inputs alone.
792///
793/// The pullback recomputes each channel with its derivatives with respect to the
794/// position, the wavenumber, `kx` and the period.
795///
796/// The pullback adds the per-column position, wavenumber and period gradients as
797/// [`SphericalChannelsResidual`] adds its shared gradients, so the thread count does not
798/// change them. Each `kx` gradient belongs to one column, which writes it in place.
799#[derive(Clone, Debug)]
800pub struct CylindricalChannelsResidual {
801    basis: crate::cw::Basis,
802    ks: [Complex; 2],
803    q: Vec<[f64; 2]>,
804    polarizations: Vec<u8>,
805    period: f64,
806    fixed_q: bool,
807}
808/// Incident and radiated channels of a cylindrical basis in a 1D array along x with
809/// lattice `period`.
810///
811/// The plane modes are `q = (kz, kx)` with polarizations `polarizations`. The order
812/// `(kz, kx)` is that of a plane basis permuted to the zx plane, as treams permutes it
813/// in `SMatrices.from_array`. The axial `kz` values are fixed labels, and `pol` has the
814/// meaning of [`spherical_channels`].
815///
816/// The rows of the matrix pack (incident/radiated, up/down, cylindrical mode), as the
817/// module documentation shows; its columns are plane modes. With `fixed_q`, the pullback
818/// holds the `kx` components fixed.
819///
820/// Upstream: the plane-wave expansions inside `treams.SMatrices.from_array`.
821pub fn cylindrical_channels(
822    basis: crate::cw::Basis,
823    ks: [Complex; 2],
824    q: Vec<[f64; 2]>,
825    polarizations: Vec<u8>,
826    period: f64,
827    helicity: bool,
828    fixed_q: bool,
829) -> Result<(DMatrix<Complex>, CylindricalChannelsResidual)> {
830    basis.validate()?;
831    validate_channels(ks, &q, &polarizations, period, helicity)?;
832    // The value pass takes no derivatives (N = 0), so `fixed_q` has no effect.
833    let positions = &basis.positions;
834    let value = channel_matrix(basis.modes.len(), q.len(), |j, side| {
835        let pol = polarizations[j];
836        let channel = CylindricalChannel::<0>::new(ks[usize::from(pol)], q[j], side, period, true)?;
837        Ok(basis
838            .modes
839            .iter()
840            .map(move |&(p, mode)| channel.entry(mode, pol, positions[p]).map(|v| v.value)))
841    })?;
842    if value.iter().any(|&v| !finite(v)) {
843        return Err(Error::NonFinite(
844            "non-finite cylindrical plane-wave channel".into(),
845        ));
846    }
847    Ok((
848        value,
849        CylindricalChannelsResidual {
850            basis,
851            ks,
852            q,
853            polarizations,
854            period,
855            fixed_q,
856        },
857    ))
858}
859impl CylindricalChannelsResidual {
860    /// Bytes of saved derivative state for the fixed basis and channel counts.
861    pub fn state_size(multipoles: usize, positions: usize, channels: usize) -> Result<usize> {
862        channel_state_size(saved::cw_basis_size(multipoles, positions)?, channels)
863    }
864
865    /// The shape of the channel matrix: four rows per cylindrical mode, one column per
866    /// plane mode.
867    #[must_use]
868    pub fn shape(&self) -> (usize, usize) {
869        (4 * self.basis.modes.len(), self.q.len())
870    }
871
872    /// Check finite input tangents and their position and plane counts.
873    pub fn validate_tangent(
874        &self,
875        positions: &[[f64; 3]],
876        ks: [Complex; 2],
877        q: &[[f64; 2]],
878        measure: f64,
879    ) -> Result<()> {
880        validate_channel_tangent(
881            positions,
882            ks,
883            q,
884            measure,
885            self.basis.positions.len(),
886            self.q.len(),
887        )
888    }
889
890    /// Directional derivative of the channel matrix. The axial `q[j][0]` is a fixed
891    /// label, and `q[j][1]` is held fixed as well when `fixed_q` was requested.
892    pub fn pushforward(
893        &self,
894        positions: &[[f64; 3]],
895        ks: [Complex; 2],
896        q: &[[f64; 2]],
897        measure: f64,
898    ) -> Result<DMatrix<Complex>> {
899        self.validate_tangent(positions, ks, q, measure)?;
900        channel_matrix(self.basis.modes.len(), self.q.len(), |j, side| {
901            let pol = self.polarizations[j];
902            let channel = CylindricalChannel::<CYLINDRICAL_SLOTS>::new(
903                self.ks[usize::from(pol)],
904                self.q[j],
905                side,
906                self.period,
907                self.fixed_q,
908            )?;
909            Ok(self.basis.modes.iter().map(move |&(p, mode)| {
910                channel
911                    .entry(mode, pol, self.basis.positions[p])
912                    .map(|entry| {
913                        channel_direction(
914                            entry,
915                            positions[p],
916                            ks[usize::from(pol)],
917                            &q[j][1..],
918                            measure,
919                        )
920                    })
921            }))
922        })
923    }
924
925    /// Gradients of the positions, the two medium wavenumbers, the `kx` components and the
926    /// period, for a `cotangent` of the channel matrix's shape. The axial `kz = q[j][0]`
927    /// is a fixed label with a zero gradient, and `measure` holds the period gradient. The
928    /// traversal is shared with [`SphericalChannelsResidual::pullback`].
929    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<ChannelGradient> {
930        if cotangent.shape() != self.shape() || cotangent.iter().any(|&v| !finite(v)) {
931            return Err(Error::InvalidInput(
932                "invalid cylindrical channel cotangent".into(),
933            ));
934        }
935        channel_pullback(
936            &self.basis,
937            &self.polarizations,
938            cotangent,
939            |j, side| {
940                let pol = self.polarizations[j];
941                let channel = CylindricalChannel::<CYLINDRICAL_SLOTS>::new(
942                    self.ks[usize::from(pol)],
943                    self.q[j],
944                    side,
945                    self.period,
946                    self.fixed_q,
947                )?;
948                Ok(move |mode, position| channel.entry(mode, pol, position))
949            },
950            |q_gradient, gradient| q_gradient[1] += gradient[Q].re,
951        )
952    }
953}
954
955impl SavedState for CylindricalChannelsResidual {
956    fn save_state(&self) -> Result<Vec<u8>> {
957        let mut writer = Writer::new(Self::state_size(
958            self.basis.modes.len(),
959            self.basis.positions.len(),
960            self.q.len(),
961        )?);
962        saved::write_cw_basis(&mut writer, &self.basis);
963        write_channel_state(
964            &mut writer,
965            self.ks,
966            &self.q,
967            &self.polarizations,
968            self.period,
969        );
970        writer.byte(u8::from(self.fixed_q));
971        Ok(writer.finish())
972    }
973
974    fn from_state(bytes: &[u8]) -> Result<Self> {
975        let mut reader = Reader::new(bytes);
976        let basis = saved::read_cw_basis(&mut reader)?;
977        let (ks, q, polarizations, period) = read_channel_state(&mut reader)?;
978        let fixed_q = match reader.byte()? {
979            0 => false,
980            1 => true,
981            _ => return Err(saved::invalid()),
982        };
983        reader.finish()?;
984        // Cylindrical channels do not mix polarizations, so the original helicity
985        // convention has no further role once the forward inputs were checked.
986        validate_channels(ks, &q, &polarizations, period, true)?;
987        Ok(Self {
988            basis,
989            ks,
990            q,
991            polarizations,
992            period,
993            fixed_q,
994        })
995    }
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use crate::test_support::{assert_same_bits_on_pools, bits, cylindrical_basis, patterned};
1002
1003    /// The direct-vector API preserves the supplied normal component and the
1004    /// upstream zero-transverse convention, while rejecting a zero normal component.
1005    #[test]
1006    fn cylindrical_scalar_channels_preserve_the_supplied_vector() {
1007        let mode = crate::cw::Mode {
1008            kz: 0.2,
1009            m: 2,
1010            pol: 1,
1011        };
1012        for normal in [Complex::i(), -Complex::i()] {
1013            let vector = [Complex::from(1.0), normal, Complex::from(mode.kz)];
1014            let channel = CylindricalChannel::<0>::from_vector(
1015                vector.map(Jet::constant),
1016                Jet::default(),
1017                2.0,
1018            )
1019            .unwrap();
1020            assert_eq!(channel.vector.map(|v| v.value), vector);
1021            assert_eq!(channel.normal.value, Complex::i());
1022            assert_eq!(
1023                cw_periodic_to_pw(mode, vector, 1, -2.0).unwrap(),
1024                Complex::i()
1025            );
1026        }
1027        let vector = [
1028            Complex::from(1.0),
1029            Complex::default(),
1030            Complex::from(mode.kz),
1031        ];
1032        assert!(matches!(
1033            cw_periodic_to_pw(mode, vector, 1, 2.0),
1034            Err(Error::InvalidInput(_))
1035        ));
1036        // A mismatched polarization has no radiation even at the threshold.
1037        assert_eq!(
1038            cw_periodic_to_pw(mode, vector, 0, 2.0).unwrap(),
1039            Complex::default()
1040        );
1041    }
1042
1043    /// Zero weights must skip entry evaluation, including unused jets that would
1044    /// overflow. Channel validation still runs once on each side.
1045    #[test]
1046    fn channel_pullbacks_skip_zero_weight_entries_after_channel_validation() {
1047        use std::sync::atomic::{AtomicUsize, Ordering};
1048        let basis = cylindrical_basis(1, 0.2, [0.0; 3]);
1049        let weights = DMatrix::zeros(4 * basis.modes.len(), 1);
1050        let visits = AtomicUsize::new(0);
1051        let gradient = channel_pullback(
1052            &basis,
1053            &[1],
1054            &weights,
1055            |_, _| {
1056                visits.fetch_add(1, Ordering::Relaxed);
1057                Ok(|_, _| -> [Jet<CYLINDRICAL_SLOTS>; 2] { panic!("unused entry evaluated") })
1058            },
1059            |_, _| panic!("unused gradient accumulated"),
1060        )
1061        .unwrap();
1062        assert_eq!(visits.load(Ordering::Relaxed), 2);
1063        assert!(
1064            bits(&[
1065                &gradient.positions,
1066                &gradient.ks,
1067                &gradient.q,
1068                &gradient.measure,
1069            ])
1070            .iter()
1071            .all(|&bit| bit == 0)
1072        );
1073    }
1074
1075    fn roundtrip<R: SavedState>(residual: &R, expected_size: usize) -> R {
1076        let mut bytes = residual.save_state().unwrap();
1077        assert_eq!(bytes.len(), expected_size);
1078        let restored = R::from_state(&bytes).unwrap();
1079        assert_eq!(bytes, restored.save_state().unwrap());
1080        assert!(R::from_state(&bytes[..bytes.len() - 1]).is_err());
1081        bytes.push(0);
1082        assert!(R::from_state(&bytes).is_err());
1083        bytes.pop();
1084        bytes[..8].copy_from_slice(&u64::MAX.to_le_bytes());
1085        assert!(R::from_state(&bytes).is_err());
1086        restored
1087    }
1088
1089    fn assert_channel_gradient_same(left: &ChannelGradient, right: &ChannelGradient) {
1090        assert_eq!(
1091            bits(&[&left.positions, &left.ks, &left.q, &left.measure]),
1092            bits(&[&right.positions, &right.ks, &right.q, &right.measure]),
1093        );
1094    }
1095
1096    /// Restoring numerical state preserves both derivative directions, including
1097    /// the fixed-direction flags, and never consumes the original or restored state.
1098    #[test]
1099    fn saved_channels_preserve_both_derivatives() {
1100        let ks = [Complex::new(1.3, 0.05); 2];
1101        let dp = [[0.1, -0.2, 0.3]];
1102        let dk = [Complex::new(0.04, -0.02); 2];
1103        let dq = [[0.03, -0.02], [-0.01, 0.04]];
1104        for helicity in [false, true] {
1105            for fixed_q in [false, true] {
1106                let basis = crate::test_support::spherical_basis(1, [0.1, -0.2, 0.05]);
1107                let size = SphericalChannelsResidual::state_size(basis.modes.len(), 1, 2).unwrap();
1108                let (value, residual) = spherical_channels(
1109                    basis,
1110                    ks,
1111                    vec![[0.3, 0.2], [-0.4, 0.1]],
1112                    vec![1, 0],
1113                    2.0,
1114                    helicity,
1115                    fixed_q,
1116                )
1117                .unwrap();
1118                let restored = roundtrip(&residual, size);
1119                let g = patterned(value.nrows(), value.ncols(), 0.3);
1120                assert_channel_gradient_same(
1121                    &residual.pullback(&g).unwrap(),
1122                    &restored.pullback(&g).unwrap(),
1123                );
1124                assert_eq!(
1125                    residual.pushforward(&dp, dk, &dq, 0.07).unwrap(),
1126                    restored.pushforward(&dp, dk, &dq, 0.07).unwrap(),
1127                );
1128                assert_channel_gradient_same(
1129                    &residual.pullback(&g).unwrap(),
1130                    &restored.pullback(&g).unwrap(),
1131                );
1132
1133                let basis = cylindrical_basis(1, 0.2, [0.1, -0.2, 0.05]);
1134                let size =
1135                    CylindricalChannelsResidual::state_size(basis.modes.len(), 1, 2).unwrap();
1136                let (value, residual) = cylindrical_channels(
1137                    basis,
1138                    ks,
1139                    vec![[0.2, 0.3], [0.2, -0.4]],
1140                    vec![1, 0],
1141                    2.0,
1142                    helicity,
1143                    fixed_q,
1144                )
1145                .unwrap();
1146                let restored = roundtrip(&residual, size);
1147                let g = patterned(value.nrows(), value.ncols(), 0.3);
1148                assert_channel_gradient_same(
1149                    &residual.pullback(&g).unwrap(),
1150                    &restored.pullback(&g).unwrap(),
1151                );
1152                assert_eq!(
1153                    residual.pushforward(&dp, dk, &dq, 0.07).unwrap(),
1154                    restored.pushforward(&dp, dk, &dq, 0.07).unwrap(),
1155                );
1156                assert_channel_gradient_same(
1157                    &residual.pullback(&g).unwrap(),
1158                    &restored.pullback(&g).unwrap(),
1159                );
1160            }
1161        }
1162    }
1163
1164    /// A strongly evanescent order overflows the phase of a position far from the
1165    /// array axis; both families reject the non-finite channel instead of returning it.
1166    #[test]
1167    fn nonfinite_channels_are_rejected() {
1168        let k = [Complex::new(1.3, 0.0); 2];
1169        let cylinders = cylindrical_basis(1, 0.2, [0.0, 20.0, 0.0]);
1170        let result =
1171            cylindrical_channels(cylinders, k, vec![[0.2, 50.0]], vec![1], 2.0, true, false);
1172        assert!(matches!(result, Err(Error::NonFinite(_))), "{result:?}");
1173        let spheres = crate::test_support::spherical_basis(1, [0.0, 0.0, 20.0]);
1174        let result = spherical_channels(spheres, k, vec![[50.0, 0.0]], vec![1], 2.0, true, false);
1175        assert!(matches!(result, Err(Error::NonFinite(_))), "{result:?}");
1176    }
1177
1178    /// Both families add their shared gradients in chunks fixed by the column count:
1179    /// with more columns than chunks, every gradient repeats bit for bit on every pool
1180    /// size.
1181    #[test]
1182    fn pullbacks_do_not_depend_on_the_thread_count() {
1183        let ks = [Complex::new(1.2, 0.05), Complex::new(1.4, 0.03)];
1184        let columns = 150_u32;
1185        let q: Vec<[f64; 2]> = (0..columns)
1186            .map(|j| {
1187                let t = f64::from(j);
1188                [0.9 * (0.7 * t).sin(), 0.8 * (1.3 * t).cos()]
1189            })
1190            .collect();
1191        let polarizations: Vec<u8> = (0..columns).map(|j| u8::from(j % 3 != 0)).collect();
1192        let positions = vec![[0.1, -0.2, 0.05], [-0.15, 0.1, -0.1]];
1193        let modes = crate::sw::modes(2).unwrap();
1194        let spheres = Basis {
1195            modes: (0..2)
1196                .flat_map(|p| modes.iter().map(move |&mode| (p, mode)))
1197                .collect(),
1198            positions: positions.clone(),
1199        };
1200        let g = patterned(4 * spheres.modes.len(), q.len(), 0.3);
1201        assert_same_bits_on_pools(|| {
1202            let (_, residual) = spherical_channels(
1203                spheres.clone(),
1204                ks,
1205                q.clone(),
1206                polarizations.clone(),
1207                2.0,
1208                true,
1209                false,
1210            )
1211            .unwrap();
1212            let g = residual.pullback(&g).unwrap();
1213            bits(&[&g.positions, &g.ks, &g.q, &g.measure])
1214        });
1215        let cylinders = crate::cw::Basis {
1216            modes: (0..2)
1217                .flat_map(|p| {
1218                    (-3..=3).flat_map(move |m| {
1219                        [1, 0].map(|pol| (p, crate::cw::Mode { kz: 0.2, m, pol }))
1220                    })
1221                })
1222                .collect(),
1223            positions,
1224        };
1225        let q: Vec<_> = q.iter().map(|&[kx, _]| [0.2, kx]).collect();
1226        let g = patterned(4 * cylinders.modes.len(), q.len(), 0.6);
1227        assert_same_bits_on_pools(|| {
1228            let (_, residual) = cylindrical_channels(
1229                cylinders.clone(),
1230                ks,
1231                q.clone(),
1232                polarizations.clone(),
1233                2.0,
1234                true,
1235                false,
1236            )
1237            .unwrap();
1238            let g = residual.pullback(&g).unwrap();
1239            bits(&[&g.positions, &g.ks, &g.q, &g.measure])
1240        });
1241    }
1242}