Skip to main content

ems/engine/
dump.rs

1//! Field dump operators: time-domain snapshots and frequency-domain (running
2//! DFT) fields over a box of nodes (design §3, "Dumps").
3//!
4//! # Overview
5//!
6//! * A [`DumpRegion`] is an inclusive box of primary-node indices (it may be
7//!   degenerate along one to three axes: a plane, a line or a point) with
8//!   an optional sub-sampling stride per axis.
9//! * A [`FieldSampler`] turns the engine's integral-form unknowns of one
10//!   [`DumpQuantity`] (E or H) into field values in V/m or A/m at the
11//!   region's sample points (see [interpolation](#interpolation)), with
12//!   the sample coordinates in metres.
13//! * A [`TimeDump`] operator records snapshots on a [`Schedule`] and hands
14//!   each one to a [`DumpSink`]: in memory ([`MemorySink`]), streamed to
15//!   `.vtr` files plus a `.pvd` index ([`VtrSeries`]), or to any closure.
16//! * A [`FreqDump`] operator accumulates a running DFT of every sample point
17//!   and component at a list of frequencies.
18//!
19//! ```
20//! use ems::engine::{DumpPosition, DumpQuantity, DumpRegion, Engine, FieldSampler, Grid,
21//!     Schedule, TimeDump};
22//!
23//! let grid = Grid::uniform([8, 8, 8], [1e-3; 3])?;
24//! let mut engine = Engine::builder(grid).threads(1).build()?;
25//! // The z = 4 plane, every other node in x and y.
26//! let region = DumpRegion::new([0, 0, 4], [8, 8, 4]).stride([2, 2, 1]);
27//! let sampler = FieldSampler::new(engine.grid(), region, DumpQuantity::E, DumpPosition::Nodes)?;
28//! assert_eq!(sampler.dims(), [5, 5, 1]);
29//! let h = engine.add_operator(TimeDump::new(sampler).schedule(Schedule::Every(10)));
30//! engine.run(20);
31//! let steps: Vec<u64> = engine.operator(&h).sink().snapshots().iter().map(|s| s.step).collect();
32//! assert_eq!(steps, [0, 10, 20]);
33//! # Ok::<(), ems::Error>(())
34//! ```
35//!
36//! # Units
37//!
38//! The engine stores `u = ∫E·dl` along primary edges \[V\] and `i = ∫H·dl`
39//! along dual edges \[A\] ([module docs](super)). A dump divides each by its
40//! edge length: `E = u/Δ` with `Δ` the primary edge length
41//! ([`Grid::delta`]) and `H = i/Δ*` with `Δ*` the dual edge length
42//! ([`Grid::dual_delta`], half a cell on the outer nodes). This is the
43//! average of the field along the edge, which is second-order accurate at
44//! the edge midpoint. On a cylindrical grid ([`Grid::cylindrical`]) the
45//! components are `(E_ρ, E_α, E_z)` (likewise H), the α edge lengths are
46//! `ρ·Δα` ([`Grid::edge_length`]), and the sample coordinates are
47//! `(ρ, α, z)` (metres, radians, metres); a VTK file then shows the
48//! `(ρ, α, z)` parameter box, not the physical cylinder.
49//!
50//! # Interpolation
51//!
52//! Every value is a separable (tensor-product) interpolation of one field
53//! component, built from 1-D rules per axis. Write `Δ_j` for the size of
54//! cell `j` along the axis in question. For component `a` and axis `b`:
55//!
56//! | quantity | `b` | [`DumpPosition::Nodes`] (node `j`) | [`DumpPosition::CellCenters`] (cell `j`) |
57//! |---|---|---|---|
58//! | E | `b = a` | linear interpolation of the two edges `j−1`, `j` (midpoints): weights `Δ_j/(Δ_{j−1}+Δ_j)` and `Δ_{j−1}/(Δ_{j−1}+Δ_j)` | edge `j` itself |
59//! | E | `b ≠ a` | edge on line `j` itself | mean of lines `j` and `j+1` |
60//! | H | `b = a` | dual edge through node `j` itself | mean of dual edges `j` and `j+1` |
61//! | H | `b ≠ a` | linear interpolation of cells `j−1`, `j` (midpoints): weights `Δ_j/(Δ_{j−1}+Δ_j)` and `Δ_{j−1}/(Δ_{j−1}+Δ_j)` | cell `j` itself |
62//!
63//! So at the nodes, E is interpolated along its own axis and H across its
64//! two transverse axes (the four surrounding dual edges, bilinearly); at
65//! cell centres E is the mean of the four parallel edges of the cell and H
66//! the mean of the two dual edges through the opposite faces. All rules
67//! are exact for fields that are linear along each axis, on any
68//! non-uniform grid (the midpoint rules are exact because cell centres are
69//! exactly half-way between node lines).
70//!
71//! **Boundaries.** At an outer node (index 0 or `n−1`) the linear rules
72//! have only one neighbour inside the domain and use it alone
73//! (constant extrapolation from half a cell away): E along its axis takes
74//! the single adjacent edge, H across a transverse axis the single
75//! adjacent cell. No value outside the domain is ever read.
76//!
77//! With [`DumpPosition::CellCenters`], an axis on which the region is
78//! degenerate (`lo = hi`) has no cell; it is sampled at the node `lo`
79//! with the node rule. Along other axes the samples are the cells
80//! `lo, lo+s, … < hi` (so `hi − lo` cells at stride 1).
81//!
82//! # Sample layout
83//!
84//! A sampler has [`dims`](FieldSampler::dims) `[dx, dy, dz]` points with
85//! coordinates [`coords`](FieldSampler::coords). Data vectors hold one
86//! `[x, y, z]` tuple per point at index `(p·dy + q)·dz + r` (z fastest,
87//! [`Layout::ZFastest`]), like the engine arrays. VTR output always uses
88//! **point data** on a rectilinear grid whose lines are the sample
89//! coordinates (node lines, or cell midpoints for
90//! [`DumpPosition::CellCenters`]).
91//!
92//! # Time stamps
93//!
94//! A dump samples the state *after `m` completed steps* (engine
95//! [`timestep`](super::Engine::timestep) `= m`), in which E is at `m·dt` and
96//! H at `(m − ½)·dt` (leapfrog; [module docs](super#time-staggering-leapfrog)).
97//! Each [`Snapshot`] records `step = m` and the time of *its* quantity
98//! ([`FieldSampler::time`]); frequency-domain dumps use the same instants in
99//! the DFT kernel.
100//!
101//! The state `m + 1` is sampled in the `post_e` hook of step `m`, i.e. after
102//! every operator registered earlier has finished the step (register dumps
103//! **after** sources, boundaries and lumped elements). The very first state
104//! an operator sees (usually `m = 0`, the initial conditions) is sampled in
105//! the `pre_h` hook of the first step; [`FieldSampler::snapshot`] samples
106//! any state on demand (e.g. the final one).
107//!
108//! # Threads
109//!
110//! Sampling runs on the stepping thread in a fixed order (f64 arithmetic),
111//! so dumps are bit-identical for any engine thread count.
112
113use core::f64::consts::TAU;
114use std::path::{Path, PathBuf};
115
116use super::Engine;
117use super::fields::{FieldArrays, Fields};
118use super::grid::Grid;
119use super::operator::{Operator, StepContext};
120use crate::axis::Axis;
121use crate::io::{ComplexForm, Field, Layout, PvdCollection, VtrWriter};
122use crate::post::Complex64;
123use crate::post::dft::phase_cycles;
124use crate::{Error, Result};
125
126/// An inclusive box of primary-node indices with a sub-sampling stride.
127///
128/// Along axis `a` the sampled node indices are `lo[a], lo[a] + s[a], …`
129/// up to and including `hi[a]` (`hi[a]` itself is sampled only if
130/// `hi[a] − lo[a]` is a multiple of `s[a]`).
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132pub struct DumpRegion {
133    lo: [usize; 3],
134    hi: [usize; 3],
135    stride: [usize; 3],
136}
137
138impl DumpRegion {
139    /// Box between the nodes `a` and `b` (inclusive; corners in any order),
140    /// stride 1.
141    pub fn new(a: [usize; 3], b: [usize; 3]) -> Self {
142        Self {
143            lo: [0, 1, 2].map(|i| a[i].min(b[i])),
144            hi: [0, 1, 2].map(|i| a[i].max(b[i])),
145            stride: [1; 3],
146        }
147    }
148
149    /// The whole grid.
150    pub fn whole(grid: &Grid) -> Self {
151        Self::new([0; 3], grid.nodes().map(|n| n - 1))
152    }
153
154    /// Sub-sampling stride per axis (each ≥ 1; 0 is treated as 1).
155    pub fn stride(mut self, stride: [usize; 3]) -> Self {
156        self.stride = stride.map(|s| s.max(1));
157        self
158    }
159
160    /// Lower corner (node indices).
161    pub fn lo(&self) -> [usize; 3] {
162        self.lo
163    }
164
165    /// Upper corner (node indices, inclusive).
166    pub fn hi(&self) -> [usize; 3] {
167        self.hi
168    }
169
170    /// The stride per axis.
171    pub fn strides(&self) -> [usize; 3] {
172        self.stride
173    }
174
175    /// Fails with [`Error::Invalid`] unless the box lies inside a grid of
176    /// `nodes` nodes.
177    pub fn check_fits(&self, nodes: [usize; 3]) -> Result<()> {
178        for a in Axis::ALL {
179            let i = a.index();
180            if self.hi[i] >= nodes[i] {
181                return Err(Error::Invalid(format!(
182                    "dump box {:?}..={:?} exceeds the grid ({} nodes along {a})",
183                    self.lo, self.hi, nodes[i]
184                )));
185            }
186        }
187        Ok(())
188    }
189}
190
191/// The field a dump records.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193pub enum DumpQuantity {
194    /// Electric field E \[V/m\], at `m·dt`.
195    E,
196    /// Magnetic field H \[A/m\], at `(m − ½)·dt`.
197    H,
198}
199
200impl DumpQuantity {
201    /// Array name used in VTR files: `"E"` or `"H"`.
202    pub fn name(self) -> &'static str {
203        match self {
204            DumpQuantity::E => "E",
205            DumpQuantity::H => "H",
206        }
207    }
208
209    /// Time offset of the quantity relative to `m·dt`, in steps (0 or −½).
210    fn offset(self) -> f64 {
211        match self {
212            DumpQuantity::E => 0.0,
213            DumpQuantity::H => -0.5,
214        }
215    }
216}
217
218/// Where a dump places its sample points; see the
219/// [module docs](self#interpolation).
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
221pub enum DumpPosition {
222    /// At the primary nodes of the region (default).
223    #[default]
224    Nodes,
225    /// At the centres of the primary cells inside the region (at the node
226    /// along degenerate axes).
227    CellCenters,
228}
229
230/// One 1-D interpolation rule: up to two `(index, weight)` taps (unused
231/// taps have weight 0).
232type Taps = [(usize, f64); 2];
233
234/// A sample position along one axis.
235#[derive(Debug, Clone, Copy)]
236enum Pos {
237    Node(usize),
238    Mid(usize),
239}
240
241fn taps(grid: &Grid, q: DumpQuantity, comp: Axis, axis: Axis, pos: Pos) -> Taps {
242    let n = grid.nodes()[axis.index()];
243    let d = |i: usize| grid.delta(axis, i);
244    let one = |i: usize, w: f64| [(i, w), (i, 0.0)];
245    // Linear interpolation from the midpoints of cells j−1, j to node j,
246    // clamped at the outer nodes.
247    let lin = |j: usize| -> [(usize, f64); 2] {
248        if j == 0 {
249            one(0, 1.0)
250        } else if j == n - 1 {
251            one(n - 2, 1.0)
252        } else {
253            let (dl, dh) = (d(j - 1), d(j));
254            [(j - 1, dh / (dl + dh)), (j, dl / (dl + dh))]
255        }
256    };
257    let mut t = match (q, comp == axis, pos) {
258        (DumpQuantity::E, true, Pos::Node(j)) => {
259            let [(a, wa), (b, wb)] = lin(j);
260            [(a, wa / d(a)), (b, wb / d(b))]
261        }
262        (DumpQuantity::E, true, Pos::Mid(j)) => one(j, 1.0 / d(j)),
263        (DumpQuantity::E, false, Pos::Node(j)) => one(j, 1.0),
264        (DumpQuantity::E, false, Pos::Mid(j)) => [(j, 0.5), (j + 1, 0.5)],
265        (DumpQuantity::H, true, Pos::Node(j)) => one(j, 1.0 / grid.dual_delta(axis, j)),
266        (DumpQuantity::H, true, Pos::Mid(j)) => [
267            (j, 0.5 / grid.dual_delta(axis, j)),
268            (j + 1, 0.5 / grid.dual_delta(axis, j + 1)),
269        ],
270        (DumpQuantity::H, false, Pos::Node(j)) => lin(j),
271        (DumpQuantity::H, false, Pos::Mid(j)) => one(j, 1.0),
272    };
273    // Cylindrical grids: the α edges have length ρ·Δα (E on the ρ lines,
274    // H on the dual lines ρ_{i+½}); the α rule above divides by Δα only, so
275    // the ρ taps divide by ρ. On the axis α edges do not exist: their
276    // weight goes to the other tap (0 if there is none).
277    if grid.is_cylindrical() && comp == Axis::Y && axis == Axis::X {
278        let r = grid.lines(Axis::X);
279        let rho = |i: usize| match q {
280            DumpQuantity::E => r[i],
281            DumpQuantity::H => grid.dual_line(Axis::X, i.min(n - 2)),
282        };
283        let total: f64 = t.iter().map(|x| x.1).sum();
284        let valid: f64 = t.iter().filter(|x| rho(x.0) > 0.0).map(|x| x.1).sum();
285        for x in &mut t {
286            let p = rho(x.0);
287            x.1 = if p > 0.0 && valid != 0.0 {
288                x.1 * (total / valid) / p
289            } else {
290                0.0
291            };
292        }
293    }
294    t
295}
296
297/// Converts and interpolates one field over a [`DumpRegion`]; see the
298/// [module documentation](self) for units, interpolation and layout.
299///
300/// A sampler is tied to the grid it was built for.
301#[derive(Debug, Clone)]
302pub struct FieldSampler {
303    region: DumpRegion,
304    quantity: DumpQuantity,
305    position: DumpPosition,
306    coords: [Vec<f64>; 3],
307    /// `taps[component][axis][sample index along axis]`.
308    taps: [[Vec<Taps>; 3]; 3],
309}
310
311impl FieldSampler {
312    /// Sampler of `quantity` over `region` of `grid`, at `position`.
313    /// Fails with [`Error::Invalid`] if the region does not fit the grid.
314    pub fn new(
315        grid: &Grid,
316        region: DumpRegion,
317        quantity: DumpQuantity,
318        position: DumpPosition,
319    ) -> Result<Self> {
320        region.check_fits(grid.nodes())?;
321        let pos = Axis::ALL.map(|a| {
322            let i = a.index();
323            let (lo, hi, s) = (region.lo[i], region.hi[i], region.stride[i]);
324            match position {
325                DumpPosition::CellCenters if hi > lo => {
326                    (lo..hi).step_by(s).map(Pos::Mid).collect::<Vec<_>>()
327                }
328                _ => (lo..=hi).step_by(s).map(Pos::Node).collect(),
329            }
330        });
331        let coords = Axis::ALL.map(|a| {
332            pos[a.index()]
333                .iter()
334                .map(|&p| match p {
335                    Pos::Node(j) => grid.lines(a)[j],
336                    Pos::Mid(j) => grid.dual_line(a, j),
337                })
338                .collect()
339        });
340        let taps = Axis::ALL.map(|c| {
341            Axis::ALL.map(|a| {
342                pos[a.index()]
343                    .iter()
344                    .map(|&p| taps(grid, quantity, c, a, p))
345                    .collect()
346            })
347        });
348        Ok(Self {
349            region,
350            quantity,
351            position,
352            coords,
353            taps,
354        })
355    }
356
357    /// The region.
358    pub fn region(&self) -> &DumpRegion {
359        &self.region
360    }
361
362    /// The sampled quantity.
363    pub fn quantity(&self) -> DumpQuantity {
364        self.quantity
365    }
366
367    /// The sample placement.
368    pub fn position(&self) -> DumpPosition {
369        self.position
370    }
371
372    /// Number of sample points per axis.
373    pub fn dims(&self) -> [usize; 3] {
374        [0, 1, 2].map(|i| self.coords[i].len())
375    }
376
377    /// Total number of sample points.
378    pub fn len(&self) -> usize {
379        self.dims().iter().product()
380    }
381
382    /// Always `false` (a region holds at least one point).
383    pub fn is_empty(&self) -> bool {
384        self.len() == 0
385    }
386
387    /// Coordinates \[m\] of the sample points along `axis`.
388    pub fn coords(&self, axis: Axis) -> &[f64] {
389        &self.coords[axis.index()]
390    }
391
392    /// Time \[s\] of this quantity in the state after `step` completed steps:
393    /// `step·dt` for E, `(step − ½)·dt` for H.
394    pub fn time(&self, step: u64, dt: f64) -> f64 {
395        (step as f64 + self.quantity.offset()) * dt
396    }
397
398    /// Calls `f(index, [vx, vy, vz])` for every sample point, in data order
399    /// (z fastest), with the values in f64.
400    pub fn sample_with(&self, fields: &Fields<'_>, mut f: impl FnMut(usize, [f64; 3])) {
401        let arr: &FieldArrays<'_> = match self.quantity {
402            DumpQuantity::E => &fields.u,
403            DumpQuantity::H => &fields.i,
404        };
405        let [dx, dy, dz] = self.dims();
406        let mut idx = 0;
407        for p in 0..dx {
408            for q in 0..dy {
409                for r in 0..dz {
410                    let mut v = [0.0; 3];
411                    for c in Axis::ALL {
412                        let t = &self.taps[c.index()];
413                        let mut s = 0.0;
414                        for &(i, wx) in &t[0][p] {
415                            if wx == 0.0 {
416                                continue;
417                            }
418                            for &(j, wy) in &t[1][q] {
419                                if wy == 0.0 {
420                                    continue;
421                                }
422                                for &(k, wz) in &t[2][r] {
423                                    if wz != 0.0 {
424                                        s += wx * wy * wz * f64::from(arr.get(c, [i, j, k]));
425                                    }
426                                }
427                            }
428                        }
429                        v[c.index()] = s;
430                    }
431                    f(idx, v);
432                    idx += 1;
433                }
434            }
435        }
436    }
437
438    /// Samples `fields` (see [`sample_with`](Self::sample_with)) into a
439    /// vector of `f32` tuples.
440    pub fn sample(&self, fields: &Fields<'_>) -> Vec<[f32; 3]> {
441        let mut out = vec![[0.0f32; 3]; self.len()];
442        self.sample_with(fields, |i, v| out[i] = v.map(|x| x as f32));
443        out
444    }
445
446    /// Samples the current state of `engine` (step
447    /// [`Engine::timestep`]); `self` must have been built for its grid.
448    ///
449    /// # Panics
450    /// If the region does not fit the engine's grid.
451    pub fn snapshot(&self, engine: &Engine) -> Snapshot {
452        let fields = engine.shared.store.lock();
453        let step = engine.timestep();
454        Snapshot {
455            step,
456            time: self.time(step, engine.dt()),
457            data: self.sample(&fields),
458        }
459    }
460
461    /// The VTR writer for `data` (one tuple per sample point) at `time`,
462    /// with the quantity's name as point-vector array.
463    fn vtr<'a>(&'a self, data: &'a [[f32; 3]], time: f64) -> VtrWriter<'a> {
464        VtrWriter::new(&self.coords[0], &self.coords[1], &self.coords[2])
465            .time(time)
466            .point_vector(self.quantity.name(), data, Layout::ZFastest)
467    }
468
469    /// Writes `snapshot` as a `.vtr` file at `path`: point data named
470    /// `"E"` or `"H"` on the sample coordinates, `TimeValue` = its time.
471    pub fn write_vtr(&self, path: impl AsRef<Path>, snapshot: &Snapshot) -> Result<()> {
472        self.vtr(&snapshot.data, snapshot.time).write(path)
473    }
474}
475
476/// One time-domain sample of a dump.
477#[derive(Debug, Clone, PartialEq)]
478pub struct Snapshot {
479    /// Number of completed engine steps `m` of the sampled state.
480    pub step: u64,
481    /// Time of the sampled quantity \[s\]: `m·dt` (E) or `(m − ½)·dt` (H).
482    pub time: f64,
483    /// One `[x, y, z]` value per sample point, z fastest (V/m or A/m).
484    pub data: Vec<[f32; 3]>,
485}
486
487/// When a [`TimeDump`] records a snapshot, in terms of the state index `m`
488/// (completed steps).
489#[derive(Debug, Clone, PartialEq)]
490pub enum Schedule {
491    /// Every state with `m % k == 0` (`k ≥ 1`; 0 is treated as 1).
492    Every(u64),
493    /// The listed states only (any order, duplicates ignored).
494    Steps(Vec<u64>),
495    /// The first state at or after each multiple of the interval \[s\] of
496    /// the E time `m·dt` (the same steps for E and H dumps). Non-positive
497    /// or non-finite intervals sample every step.
498    Interval(f64),
499}
500
501/// A recording window on the sampled quantity's own time `t`
502/// ([`FieldSampler::time`]): `start ≤ t ≤ stop`, with `stop ≤ 0` meaning
503/// "no upper limit" (the csx `StartTime` / `StopTime` convention), both
504/// with a tolerance of `1e-9·dt` against rounding of the sample times.
505#[derive(Debug, Clone, Copy, PartialEq)]
506struct Window {
507    start: f64,
508    stop: f64,
509}
510
511impl Default for Window {
512    fn default() -> Self {
513        Self {
514            start: f64::NEG_INFINITY,
515            stop: 0.0,
516        }
517    }
518}
519
520impl Window {
521    fn contains(&self, t: f64, dt: f64) -> bool {
522        let eps = 1e-9 * dt;
523        t >= self.start - eps && (self.stop <= 0.0 || t <= self.stop + eps)
524    }
525}
526
527/// Schedule with its running state.
528#[derive(Debug, Clone)]
529struct Scheduler {
530    schedule: Schedule,
531    next: f64,
532}
533
534impl Scheduler {
535    fn new(mut schedule: Schedule) -> Self {
536        if let Schedule::Steps(v) = &mut schedule {
537            v.sort_unstable();
538            v.dedup();
539        }
540        Self {
541            schedule,
542            next: f64::NEG_INFINITY,
543        }
544    }
545
546    fn due(&mut self, m: u64, dt: f64) -> bool {
547        match &self.schedule {
548            Schedule::Every(k) => m.is_multiple_of((*k).max(1)),
549            Schedule::Steps(v) => v.binary_search(&m).is_ok(),
550            Schedule::Interval(t) => {
551                let t = *t;
552                if !(t > 0.0 && t.is_finite()) {
553                    return true;
554                }
555                let now = m as f64 * dt;
556                // Tolerate rounding of m·dt just below a multiple of t.
557                if now < self.next - 1e-9 * dt {
558                    return false;
559                }
560                self.next = ((now / t + 1e-9).floor() + 1.0) * t;
561                true
562            }
563        }
564    }
565}
566
567/// Receives the snapshots of a [`TimeDump`] as they are taken.
568///
569/// Implemented by [`MemorySink`], [`VtrSeries`] and by closures
570/// `FnMut(&FieldSampler, Snapshot) -> Result<()>`. An error stops the dump
571/// (no further snapshots are taken); it is kept in [`TimeDump::error`].
572pub trait DumpSink: Send + 'static {
573    /// Handles one snapshot.
574    fn snapshot(&mut self, sampler: &FieldSampler, snapshot: Snapshot) -> Result<()>;
575}
576
577impl<F> DumpSink for F
578where
579    F: FnMut(&FieldSampler, Snapshot) -> Result<()> + Send + 'static,
580{
581    fn snapshot(&mut self, sampler: &FieldSampler, snapshot: Snapshot) -> Result<()> {
582        self(sampler, snapshot)
583    }
584}
585
586/// Keeps every snapshot in memory (`4·3·points` bytes per snapshot).
587#[derive(Debug, Clone, Default)]
588pub struct MemorySink {
589    snapshots: Vec<Snapshot>,
590}
591
592impl MemorySink {
593    /// An empty sink.
594    pub fn new() -> Self {
595        Self::default()
596    }
597
598    /// The snapshots taken so far, in time order.
599    pub fn snapshots(&self) -> &[Snapshot] {
600        &self.snapshots
601    }
602
603    /// Removes and returns the snapshots taken so far.
604    pub fn take(&mut self) -> Vec<Snapshot> {
605        core::mem::take(&mut self.snapshots)
606    }
607}
608
609impl DumpSink for MemorySink {
610    fn snapshot(&mut self, _: &FieldSampler, snapshot: Snapshot) -> Result<()> {
611        self.snapshots.push(snapshot);
612        Ok(())
613    }
614}
615
616/// File name of the snapshot at `step`: `<basename>_<step, 8 digits>.vtr`.
617fn snapshot_file(basename: &str, step: u64) -> String {
618    format!("{basename}_{step:08}.vtr")
619}
620
621/// Streams snapshots to `<dir>/<basename>_<step>.vtr` (step zero-padded to
622/// 8 digits) and keeps `<dir>/<basename>.pvd` up to date (rewritten after
623/// every snapshot, so it is valid even if the run stops early). The
624/// directory is created on the first snapshot.
625#[derive(Debug, Clone)]
626pub struct VtrSeries {
627    dir: PathBuf,
628    basename: String,
629    pvd: PvdCollection,
630}
631
632impl VtrSeries {
633    /// Series in `dir` with file names starting with `basename`.
634    pub fn new(dir: impl Into<PathBuf>, basename: impl Into<String>) -> Self {
635        Self {
636            dir: dir.into(),
637            basename: basename.into(),
638            pvd: PvdCollection::new(),
639        }
640    }
641
642    /// The output directory.
643    pub fn dir(&self) -> &Path {
644        &self.dir
645    }
646
647    /// Path of the `.pvd` index.
648    pub fn pvd_path(&self) -> PathBuf {
649        self.dir.join(format!("{}.pvd", self.basename))
650    }
651
652    /// The files written so far (relative to [`dir`](Self::dir)) with their
653    /// times.
654    pub fn collection(&self) -> &PvdCollection {
655        &self.pvd
656    }
657}
658
659impl DumpSink for VtrSeries {
660    fn snapshot(&mut self, sampler: &FieldSampler, snapshot: Snapshot) -> Result<()> {
661        if self.pvd.entries().is_empty() {
662            std::fs::create_dir_all(&self.dir)?;
663        }
664        let file = snapshot_file(&self.basename, snapshot.step);
665        sampler.write_vtr(self.dir.join(&file), &snapshot)?;
666        self.pvd.push(snapshot.time, file);
667        self.pvd.write(self.pvd_path())
668    }
669}
670
671/// Tracks which state an operator samples next (shared by both dumps).
672#[derive(Debug, Clone, Copy, Default)]
673struct Cursor {
674    started: bool,
675}
676
677impl Cursor {
678    /// State index to sample in `pre_h`, if this is the first hook call.
679    fn pre_h(&mut self, ctx: &StepContext<'_>) -> Option<u64> {
680        (!core::mem::replace(&mut self.started, true)).then_some(ctx.step)
681    }
682
683    /// State index to sample in `post_e`.
684    fn post_e(&mut self, ctx: &StepContext<'_>) -> u64 {
685        self.started = true;
686        ctx.step + 1
687    }
688}
689
690/// Time-domain field dump operator: samples a [`FieldSampler`] on a
691/// [`Schedule`] and passes each [`Snapshot`] to a [`DumpSink`]. See the
692/// [module docs](self#time-stamps) for when states are sampled.
693///
694/// The default sink is a [`MemorySink`]; read it back with
695/// [`Engine::operator`]`(&handle).`[`sink`](Self::sink)`()`.
696#[derive(Debug)]
697pub struct TimeDump<S = MemorySink> {
698    sampler: FieldSampler,
699    window: Window,
700    scheduler: Scheduler,
701    sink: S,
702    cursor: Cursor,
703    error: Option<Error>,
704}
705
706impl TimeDump<MemorySink> {
707    /// In-memory dump of every state (change with
708    /// [`schedule`](Self::schedule)).
709    pub fn new(sampler: FieldSampler) -> Self {
710        Self::with_sink(sampler, MemorySink::new())
711    }
712
713    /// Writes all recorded snapshots as `<dir>/<basename>_<step>.vtr` plus
714    /// `<dir>/<basename>.pvd` (creating `dir`), like [`VtrSeries`].
715    pub fn write_vtr(&self, dir: impl AsRef<Path>, basename: &str) -> Result<()> {
716        let mut series = VtrSeries::new(dir.as_ref(), basename);
717        for s in self.sink.snapshots() {
718            series.snapshot(&self.sampler, s.clone())?;
719        }
720        Ok(())
721    }
722}
723
724impl<S: DumpSink> TimeDump<S> {
725    /// Dump of every state into `sink`.
726    pub fn with_sink(sampler: FieldSampler, sink: S) -> Self {
727        Self {
728            sampler,
729            window: Window::default(),
730            scheduler: Scheduler::new(Schedule::Every(1)),
731            sink,
732            cursor: Cursor::default(),
733            error: None,
734        }
735    }
736
737    /// Sets the schedule (default: every step).
738    pub fn schedule(mut self, schedule: Schedule) -> Self {
739        self.scheduler = Scheduler::new(schedule);
740        self
741    }
742
743    /// Records only states whose quantity time `t` ([`FieldSampler::time`])
744    /// satisfies `start ≤ t ≤ stop` \[s\]; `stop ≤ 0` means no upper limit
745    /// (the csx `StartTime` / `StopTime` convention). The schedule is
746    /// evaluated only inside the window. Default: no window.
747    pub fn window(mut self, start: f64, stop: f64) -> Self {
748        self.window = Window { start, stop };
749        self
750    }
751
752    /// The sampler.
753    pub fn sampler(&self) -> &FieldSampler {
754        &self.sampler
755    }
756
757    /// The sink.
758    pub fn sink(&self) -> &S {
759        &self.sink
760    }
761
762    /// Mutable access to the sink (e.g. [`MemorySink::take`]).
763    pub fn sink_mut(&mut self) -> &mut S {
764        &mut self.sink
765    }
766
767    /// The first sink error, after which the dump stopped.
768    pub fn error(&self) -> Option<&Error> {
769        self.error.as_ref()
770    }
771
772    fn sample(&mut self, fields: &Fields<'_>, m: u64, dt: f64) {
773        if self.error.is_some()
774            || !self.window.contains(self.sampler.time(m, dt), dt)
775            || !self.scheduler.due(m, dt)
776        {
777            return;
778        }
779        let snap = Snapshot {
780            step: m,
781            time: self.sampler.time(m, dt),
782            data: self.sampler.sample(fields),
783        };
784        if let Err(e) = self.sink.snapshot(&self.sampler, snap) {
785            self.error = Some(e);
786        }
787    }
788}
789
790impl<S: DumpSink> Operator for TimeDump<S> {
791    fn pre_h(&mut self, fields: &mut Fields<'_>, ctx: &StepContext<'_>) {
792        if let Some(m) = self.cursor.pre_h(ctx) {
793            self.sample(fields, m, ctx.dt);
794        }
795    }
796
797    fn post_e(&mut self, fields: &mut Fields<'_>, ctx: &StepContext<'_>) {
798        let m = self.cursor.post_e(ctx);
799        self.sample(fields, m, ctx.dt);
800    }
801}
802
803/// Frequency-domain field dump operator: a running DFT of every sample
804/// point and component at a list of frequencies.
805///
806/// With states `m` sampled every `K` steps ([`interval`](Self::interval),
807/// `m % K == 0`), the result at frequency `f` is
808///
809/// ```text
810/// X(f) = Σ_m x(t_m) · e^{-j2πf·t_m} · K·dt
811/// ```
812///
813/// with `t_m` the quantity's own time (`m·dt` for E, `(m − ½)·dt` for H),
814/// the convention of [`mod@crate::post::dft`]. E and H transforms therefore
815/// share one time origin and need no half-step phase correction. Sampling
816/// every `K` steps aliases frequencies above `1/(2K·dt)`; keep `K` small
817/// enough. Like [`RunningDft`](crate::post::RunningDft), each sample's
818/// phasors are computed from the exactly reduced absolute phase, so there
819/// is no drift with the run length.
820///
821/// No normalisation is applied: to get a transfer function, divide by the
822/// excitation spectrum (e.g. [`Waveform::spectrum`](crate::Waveform::spectrum))
823/// at each frequency.
824///
825/// # Memory
826///
827/// Accumulators are `f64` complex: `48` bytes per sample point per
828/// frequency (three components × 16 bytes), e.g. 48 MB for a
829/// 100 × 100 × 100 node box at 1 frequency. Use a plane or a stride for
830/// many frequencies. The cost per sample is one interpolation per point
831/// plus three complex multiply-adds per point and frequency.
832#[derive(Debug, Clone)]
833pub struct FreqDump {
834    sampler: FieldSampler,
835    freqs: Vec<f64>,
836    interval: u64,
837    window: Window,
838    acc: Vec<[Complex64; 3]>,
839    samples: u64,
840    dt: f64,
841    cursor: Cursor,
842    phasors: Vec<Complex64>,
843}
844
845impl FreqDump {
846    /// DFT dump of `sampler`'s quantity at `freqs` \[Hz\], sampling every
847    /// step.
848    pub fn new(sampler: FieldSampler, freqs: impl Into<Vec<f64>>) -> Self {
849        let freqs = freqs.into();
850        let n = sampler.len() * freqs.len();
851        Self {
852            sampler,
853            phasors: vec![Complex64::ZERO; freqs.len()],
854            freqs,
855            interval: 1,
856            window: Window::default(),
857            acc: vec![[Complex64::ZERO; 3]; n],
858            samples: 0,
859            dt: 0.0,
860            cursor: Cursor::default(),
861        }
862    }
863
864    /// Samples only the states with `m % k == 0` (`k ≥ 1`; 0 is treated as
865    /// 1). Default 1.
866    pub fn interval(mut self, k: u64) -> Self {
867        self.interval = k.max(1);
868        self
869    }
870
871    /// Accumulates only states whose quantity time `t`
872    /// ([`FieldSampler::time`]) satisfies `start ≤ t ≤ stop` \[s\];
873    /// `stop ≤ 0` means no upper limit (see [`TimeDump::window`]).
874    /// Default: no window.
875    pub fn window(mut self, start: f64, stop: f64) -> Self {
876        self.window = Window { start, stop };
877        self
878    }
879
880    /// The sampler.
881    pub fn sampler(&self) -> &FieldSampler {
882        &self.sampler
883    }
884
885    /// The analysis frequencies \[Hz\].
886    pub fn freqs(&self) -> &[f64] {
887        &self.freqs
888    }
889
890    /// Number of states accumulated so far.
891    pub fn samples(&self) -> u64 {
892        self.samples
893    }
894
895    /// Sampling interval `K·dt` \[s\] (0 before the first step).
896    pub fn sample_interval(&self) -> f64 {
897        self.interval as f64 * self.dt
898    }
899
900    /// The transform at frequency index `k`: one complex `[x, y, z]` per
901    /// sample point, z fastest (V/m·s or A/m·s).
902    ///
903    /// # Panics
904    /// If `k` is out of range.
905    pub fn values(&self, k: usize) -> Vec<[Complex64; 3]> {
906        assert!(k < self.freqs.len(), "frequency index {k} out of range");
907        let n = self.sampler.len();
908        let s = self.sample_interval();
909        self.acc[k * n..(k + 1) * n]
910            .iter()
911            .map(|v| v.map(|c| c * s))
912            .collect()
913    }
914
915    /// Adds `j·` the transform of `imag`, the same dump of the imaginary
916    /// engine of a Bloch pair, so that this dump holds the transform of
917    /// the complex field (`F_re + j·F_im`).
918    pub(crate) fn add_imaginary(&mut self, imag: &FreqDump) {
919        debug_assert_eq!(self.acc.len(), imag.acc.len());
920        for (a, b) in self.acc.iter_mut().zip(&imag.acc) {
921            for c in 0..3 {
922                a[c] += Complex64::I * b[c];
923            }
924        }
925    }
926
927    /// Clears the accumulators (the next sampled state starts a new
928    /// transform).
929    pub fn reset(&mut self) {
930        self.acc.fill([Complex64::ZERO; 3]);
931        self.samples = 0;
932    }
933
934    /// Writes one `<dir>/<basename>_f<k>.vtr` per frequency index `k`
935    /// (creating `dir`) with point arrays `<Q>_re` and `<Q>_im` (`Q` = `E` or
936    /// `H`), `TimeValue` and a `Frequency` field-data entry set to the
937    /// frequency, plus `<dir>/<basename>.pvd` indexed by frequency.
938    pub fn write_vtr(&self, dir: impl AsRef<Path>, basename: &str) -> Result<()> {
939        let dir = dir.as_ref();
940        std::fs::create_dir_all(dir)?;
941        let s = &self.sampler;
942        let mut pvd = PvdCollection::new();
943        for (k, &f) in self.freqs.iter().enumerate() {
944            let v = self.values(k);
945            let re: Vec<[f64; 3]> = v.iter().map(|c| c.map(|z| z.re)).collect();
946            let im: Vec<[f64; 3]> = v.iter().map(|c| c.map(|z| z.im)).collect();
947            let fa = [f];
948            let file = format!("{basename}_f{k}.vtr");
949            VtrWriter::new(&s.coords[0], &s.coords[1], &s.coords[2])
950                .time(f)
951                .field("Frequency", &fa)
952                .point_complex(
953                    s.quantity.name(),
954                    Field::Vector(&re),
955                    Field::Vector(&im),
956                    Layout::ZFastest,
957                    ComplexForm::ReIm,
958                )
959                .write(dir.join(&file))?;
960            pvd.push(f, file);
961        }
962        pvd.write(dir.join(format!("{basename}.pvd")))
963    }
964
965    fn sample(&mut self, fields: &Fields<'_>, m: u64, dt: f64) {
966        self.dt = dt;
967        if !m.is_multiple_of(self.interval) || !self.window.contains(self.sampler.time(m, dt), dt) {
968            return;
969        }
970        let t0 = self.sampler.quantity.offset() * dt;
971        for (p, &f) in self.phasors.iter_mut().zip(&self.freqs) {
972            *p = Complex64::cis(-TAU * phase_cycles(f, t0, dt, m as f64));
973        }
974        let n = self.sampler.len();
975        let (acc, phasors) = (&mut self.acc, &self.phasors);
976        self.sampler.sample_with(fields, |i, v| {
977            for (k, &p) in phasors.iter().enumerate() {
978                let a = &mut acc[k * n + i];
979                for (a, &x) in a.iter_mut().zip(&v) {
980                    *a += p * x;
981                }
982            }
983        });
984        self.samples += 1;
985    }
986}
987
988impl Operator for FreqDump {
989    fn pre_h(&mut self, fields: &mut Fields<'_>, ctx: &StepContext<'_>) {
990        if let Some(m) = self.cursor.pre_h(ctx) {
991            self.sample(fields, m, ctx.dt);
992        }
993    }
994
995    fn post_e(&mut self, fields: &mut Fields<'_>, ctx: &StepContext<'_>) {
996        let m = self.cursor.post_e(ctx);
997        self.sample(fields, m, ctx.dt);
998    }
999}