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}