Skip to main content

lindhard/tally/
voxel.rs

1//! Sparse 3D voxel tallies of implanted atoms, damage and deposited energy,
2//! with CSV and VTK export.
3//!
4//! [`VoxelTally`] implements [`BcaTally`]. It bins events by position on its
5//! own regular grid ([`VoxelTallyGrid`]), so it works with any
6//! [`crate::geometry::Geometry`] (a [`crate::geometry::VoxelGrid`], a mesh or
7//! a CSG solid), not only with a voxel grid; use
8//! [`VoxelTallyGrid::from_voxel_grid`] to tally on the voxels of a grid
9//! target. Build one prototype with [`VoxelTally::new`], hand clones to
10//! [`crate::ion::bca::Bca::run`], and turn the merged tally into a plain-data
11//! [`VoxelReport`] with [`VoxelTally::report`].
12//!
13//! Units: positions and lengths in metres, energies in eV. `x` is the depth.
14//!
15//! # What is tallied per voxel
16//!
17//! * `implanted`: beam particles that came to rest in the voxel (the voxel
18//!   counterpart of [`crate::tally::ion::RangeReport::stopped`]).
19//! * `displacements`: target atoms displaced in the voxel, counted where the
20//!   recoil is created (transfer above `E_d`), the simulated cascade count of
21//!   [`crate::tally::ion::CascadeDefects::displacements`]. This is the
22//!   damage measure of the tally. The NRT / Kinchin-Pease model estimate
23//!   ([`crate::ion::damage`]) needs a per-material Lindhard partition and is
24//!   not tallied per voxel; the two are never conflated (see
25//!   [`crate::ion::damage`] for the convention and its sources, Norgett,
26//!   Robinson and Torrens, Nucl. Eng. Des. 33 (1975) 50, and Lindhard et al.,
27//!   Mat. Fys. Medd. Dan. Vid. Selsk. 33 (1963) no. 10).
28//! * Per-element defects, when the tally knows the species of the run (see
29//!   [`VoxelTally::with_species`]): for every species index of the engine
30//!   ([`crate::ion::bca::Bca::species_z`]), the `vacancies` at the
31//!   displacement site, the `interstitials` where a displaced atom came to
32//!   rest without filling a site, and the `replacements`. These use the
33//!   definitions of [`crate::tally::ion::CascadeDefects`] and its
34//!   replacement rule (one shared implementation), so summed over voxels
35//!   (and [`VoxelReport::outside`]) and species they equal that struct's
36//!   totals exactly. A replacement is attributed to the species of the atom
37//!   it replaced, at the site of that atom, so `vacancies = displacements -
38//!   replacements` holds per species and voxel. Beam particles are not
39//!   interstitials here (they are `implanted`).
40//! * Deposited energy, split by channel so each can be checked against the
41//!   engine's [`crate::ion::bca::EnergyBudget`]:
42//!   `electronic_ev` (nonlocal and local electronic loss),
43//!   `lattice_ev` (nuclear energy left in the lattice: subthreshold and weak
44//!   transfers and the binding energy of displaced atoms) and `rest_ev` (the
45//!   kinetic energy of a particle when it stops below its cutoff).
46//!   [`VoxelCell::deposited_ev`] is their sum. The work done against the
47//!   planar surface barrier and the energy carried out of the target are not
48//!   deposited in any voxel and are not tallied here.
49//!
50//! The nonlocal electronic loss of a free flight is spread uniformly along
51//! the straight segment (the engine's convention, see
52//! [`crate::ion::bca::BcaTally::electronic`]), so a segment that crosses
53//! voxel faces is split between the voxels in proportion to the length in
54//! each. Everything else is a point event and goes to the voxel containing
55//! it. Voxels own the half-open box `[o + i h, o + (i + 1) h)` on each axis;
56//! events outside the grid are summed in [`VoxelReport::outside`].
57//!
58//! # Sparse storage
59//!
60//! Only voxels that received an event are stored, in a `BTreeMap` keyed on the
61//! flat index `i + nx (j + ny k)`, so empty voxels cost no memory and
62//! iteration, merging and export follow one fixed order.
63//!
64//! # Determinism
65//!
66//! Counts are integers and energies are `f64` sums, merged voxel by voxel in
67//! the order [`crate::rng::run_particles`] calls `merge` (chunk order). For a
68//! fixed `chunk_size` the result is bit-identical at any thread count
69//! (`lindhard/tests/voxel_tally.rs`).
70//!
71//! # Export formats
72//!
73//! [`VoxelReport::write_csv`] writes one row per occupied voxel after a
74//! `# key = value` header that carries the grid, so the file reads back
75//! ([`VoxelReport::read_csv`]) exactly: floats are written in the shortest
76//! form that parses back to the same bits. [`VoxelReport::write_vtk`] writes
77//! a legacy ASCII VTK 3.0 `UNSTRUCTURED_GRID` of `VTK_VOXEL` cells, one per
78//! occupied voxel (so the file is as sparse as the tally), with the tallies
79//! as cell scalars; format: Kitware, *The VTK User's Guide*, 11th ed. (2006),
80//! "VTK File Formats". The VTK file does not carry the `outside` sums.
81//! A tally built with [`VoxelTally::with_species`] writes format version 2:
82//! the species are named in the header and each adds `vacancies`,
83//! `interstitials` and `replacements` columns (scalars). A tally without
84//! species writes version 1 exactly as before, and both readers accept both
85//! versions.
86//! [`VoxelReport::read_vtk`] reads back what `write_vtk` writes.
87
88use std::collections::BTreeMap;
89use std::io::{self, Write};
90
91use serde::{Deserialize, Serialize};
92
93use super::ion::ReplacementTracker;
94use crate::elements::{element, element_by_symbol};
95use crate::geometry::VoxelGrid;
96use crate::ion::bca::{BcaTally, ElectronicChannel, Face, LatticeDeposit, Particle};
97
98/// Errors from building a [`VoxelTallyGrid`] or reading an exported file.
99#[derive(Debug, Clone, PartialEq, thiserror::Error)]
100pub enum VoxelTallyError {
101    /// A grid dimension was zero.
102    #[error("voxel tally grid dimension on axis {axis} is zero")]
103    ZeroDimension {
104        /// Axis (0 = x, 1 = y, 2 = z).
105        axis: usize,
106    },
107    /// A spacing was zero, negative or not finite.
108    #[error(
109        "voxel tally spacing {spacing_m} m on axis {axis} is invalid: must be finite and positive"
110    )]
111    InvalidSpacing {
112        /// Axis (0 = x, 1 = y, 2 = z).
113        axis: usize,
114        /// Offending value.
115        spacing_m: f64,
116    },
117    /// An origin coordinate was not finite.
118    #[error("voxel tally origin {origin_m} m on axis {axis} is not finite")]
119    InvalidOrigin {
120        /// Axis (0 = x, 1 = y, 2 = z).
121        axis: usize,
122        /// Offending value.
123        origin_m: f64,
124    },
125    /// The number of voxels does not fit in `u64`.
126    #[error("voxel tally dimensions {dims:?} overflow the voxel count")]
127    TooManyVoxels {
128        /// Requested dimensions.
129        dims: [usize; 3],
130    },
131    /// An exported file could not be read. `line` is 1-based.
132    #[error("voxel tally parse error (line {line}): {message}")]
133    Parse {
134        /// Line number in the file.
135        line: usize,
136        /// What was wrong.
137        message: String,
138    },
139}
140
141fn parse_err(line: usize, message: impl Into<String>) -> VoxelTallyError {
142    VoxelTallyError::Parse {
143        line,
144        message: message.into(),
145    }
146}
147
148/// The regular grid a [`VoxelTally`] bins on: `dims` voxels of edge
149/// `spacing_m` starting at `origin_m`. Voxel `(i, j, k)` has the flat index
150/// `i + nx (j + ny k)` (x fastest, as in [`VoxelGrid`]).
151#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
152pub struct VoxelTallyGrid {
153    origin_m: [f64; 3],
154    spacing_m: [f64; 3],
155    dims: [usize; 3],
156}
157
158impl VoxelTallyGrid {
159    /// A grid of `dims` voxels of edge lengths `spacing_m` whose lowest corner
160    /// is `origin_m`. Every dimension must be nonzero, every spacing finite
161    /// and positive, the origin finite, and the voxel count must fit in `u64`.
162    pub fn new(
163        origin_m: [f64; 3],
164        spacing_m: [f64; 3],
165        dims: [usize; 3],
166    ) -> Result<Self, VoxelTallyError> {
167        for axis in 0..3 {
168            if dims[axis] == 0 {
169                return Err(VoxelTallyError::ZeroDimension { axis });
170            }
171            let h = spacing_m[axis];
172            if !(h.is_finite() && h > 0.0) {
173                return Err(VoxelTallyError::InvalidSpacing { axis, spacing_m: h });
174            }
175            if !origin_m[axis].is_finite() {
176                return Err(VoxelTallyError::InvalidOrigin {
177                    axis,
178                    origin_m: origin_m[axis],
179                });
180            }
181        }
182        (dims[0] as u64)
183            .checked_mul(dims[1] as u64)
184            .and_then(|v| v.checked_mul(dims[2] as u64))
185            .ok_or(VoxelTallyError::TooManyVoxels { dims })?;
186        Ok(Self {
187            origin_m,
188            spacing_m,
189            dims,
190        })
191    }
192
193    /// The grid of the voxels of `grid` (origin at the corner of the front
194    /// face), so a tally voxel is a target voxel.
195    pub fn from_voxel_grid(grid: &VoxelGrid) -> Self {
196        Self {
197            origin_m: [0.0; 3],
198            spacing_m: grid.spacing_m(),
199            dims: grid.dims(),
200        }
201    }
202
203    /// Lowest corner `[x, y, z]`, m.
204    pub fn origin_m(&self) -> [f64; 3] {
205        self.origin_m
206    }
207
208    /// Voxel edge lengths `[hx, hy, hz]`, m.
209    pub fn spacing_m(&self) -> [f64; 3] {
210        self.spacing_m
211    }
212
213    /// Voxels per axis `[nx, ny, nz]`.
214    pub fn dims(&self) -> [usize; 3] {
215        self.dims
216    }
217
218    /// Total number of voxels `nx ny nz`.
219    pub fn n_voxels(&self) -> u64 {
220        self.dims.iter().map(|&n| n as u64).product()
221    }
222
223    /// The voxel `[i, j, k]` containing `pos`, or `None` outside the grid
224    /// (or for a non-finite position). Voxels are half-open, so a point on an
225    /// interior face belongs to the voxel with the higher index and a point
226    /// on the far end of the grid is outside.
227    pub fn locate(&self, pos: [f64; 3]) -> Option<[usize; 3]> {
228        let mut ijk = [0usize; 3];
229        for a in 0..3 {
230            let s = ((pos[a] - self.origin_m[a]) / self.spacing_m[a]).floor();
231            if !(s >= 0.0 && s < self.dims[a] as f64) {
232                return None;
233            }
234            ijk[a] = s as usize;
235        }
236        Some(ijk)
237    }
238
239    /// Flat index `i + nx (j + ny k)` of voxel `ijk`.
240    pub fn flat_index(&self, ijk: [usize; 3]) -> u64 {
241        ijk[0] as u64 + self.dims[0] as u64 * (ijk[1] as u64 + self.dims[1] as u64 * ijk[2] as u64)
242    }
243
244    /// The voxel `[i, j, k]` of flat index `flat` (the inverse of
245    /// [`Self::flat_index`]).
246    pub fn unflatten(&self, flat: u64) -> [usize; 3] {
247        let nx = self.dims[0] as u64;
248        let ny = self.dims[1] as u64;
249        [
250            (flat % nx) as usize,
251            ((flat / nx) % ny) as usize,
252            (flat / (nx * ny)) as usize,
253        ]
254    }
255
256    /// Lowest corner of voxel `ijk`, m.
257    pub fn corner_m(&self, ijk: [usize; 3]) -> [f64; 3] {
258        std::array::from_fn(|a| self.origin_m[a] + ijk[a] as f64 * self.spacing_m[a])
259    }
260
261    /// Centre of voxel `ijk`, m.
262    pub fn center_m(&self, ijk: [usize; 3]) -> [f64; 3] {
263        std::array::from_fn(|a| self.origin_m[a] + (ijk[a] as f64 + 0.5) * self.spacing_m[a])
264    }
265}
266
267/// Defects of one species in one voxel (or summed), as defined by
268/// [`crate::tally::ion::CascadeDefects`].
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
270pub struct SpeciesDefects {
271    /// Atoms displaced here and not replaced.
272    pub vacancies: u64,
273    /// Displaced atoms that came to rest here and did not fill a site.
274    pub interstitials: u64,
275    /// Replacement events here.
276    pub replacements: u64,
277}
278
279impl SpeciesDefects {
280    /// Field-wise sum.
281    pub fn add(&mut self, o: &SpeciesDefects) {
282        self.vacancies += o.vacancies;
283        self.interstitials += o.interstitials;
284        self.replacements += o.replacements;
285    }
286}
287
288/// Raw per-species counts; `vacancies = displacements - replacements`.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290struct DefectAcc {
291    displacements: u64,
292    replacements: u64,
293    interstitials: u64,
294}
295
296impl DefectAcc {
297    fn add(&mut self, o: &DefectAcc) {
298        self.displacements += o.displacements;
299        self.replacements += o.replacements;
300        self.interstitials += o.interstitials;
301    }
302
303    fn report(&self) -> SpeciesDefects {
304        SpeciesDefects {
305            vacancies: self.displacements - self.replacements,
306            interstitials: self.interstitials,
307            replacements: self.replacements,
308        }
309    }
310}
311
312fn report_defects(acc: &[DefectAcc]) -> Vec<SpeciesDefects> {
313    acc.iter().map(DefectAcc::report).collect()
314}
315
316/// The tallies of one voxel (or of the whole grid, for totals).
317#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
318pub struct VoxelCell {
319    /// Beam particles that came to rest here.
320    pub implanted: u64,
321    /// Target atoms displaced here (recoils created).
322    pub displacements: u64,
323    /// Electronic energy loss deposited here, eV.
324    pub electronic_ev: f64,
325    /// Nuclear energy left in the lattice here, eV.
326    pub lattice_ev: f64,
327    /// Kinetic energy of particles that stopped here, eV.
328    pub rest_ev: f64,
329}
330
331impl VoxelCell {
332    /// Energy deposited in the target: `electronic_ev + lattice_ev +
333    /// rest_ev`, eV.
334    pub fn deposited_ev(&self) -> f64 {
335        self.electronic_ev + self.lattice_ev + self.rest_ev
336    }
337
338    /// Field-wise sum.
339    pub fn add(&mut self, o: &VoxelCell) {
340        self.implanted += o.implanted;
341        self.displacements += o.displacements;
342        self.electronic_ev += o.electronic_ev;
343        self.lattice_ev += o.lattice_ev;
344        self.rest_ev += o.rest_ev;
345    }
346}
347
348/// A sparse voxel tally; see the [module docs](self).
349#[derive(Debug, Clone)]
350pub struct VoxelTally {
351    grid: VoxelTallyGrid,
352    histories: u64,
353    cells: BTreeMap<u64, VoxelCell>,
354    outside: VoxelCell,
355    /// `Z` of each species index; empty when defects are not tallied.
356    species_z: Vec<u8>,
357    /// Per-species defects of the voxels that have any (a subset of `cells`).
358    defects: BTreeMap<u64, Vec<DefectAcc>>,
359    outside_defects: Vec<DefectAcc>,
360    tracker: ReplacementTracker,
361    /// Scratch for splitting a flight segment (not merged).
362    cuts: Vec<f64>,
363}
364
365impl VoxelTally {
366    /// An empty tally on `grid`.
367    pub fn new(grid: VoxelTallyGrid) -> Self {
368        Self {
369            grid,
370            histories: 0,
371            cells: BTreeMap::new(),
372            outside: VoxelCell::default(),
373            species_z: Vec::new(),
374            defects: BTreeMap::new(),
375            outside_defects: Vec::new(),
376            tracker: ReplacementTracker::default(),
377            cuts: Vec::new(),
378        }
379    }
380
381    /// Also tally vacancies, interstitials and replacements per species.
382    /// `species_z` is [`crate::ion::bca::Bca::species_z`] of the run: entry
383    /// `s` is the atomic number of species index `s`.
384    pub fn with_species(mut self, species_z: &[u8]) -> Self {
385        self.species_z = species_z.to_vec();
386        self.outside_defects = vec![DefectAcc::default(); species_z.len()];
387        self
388    }
389
390    /// The species `Z` this tally keeps defects for (empty: none).
391    pub fn species_z(&self) -> &[u8] {
392        &self.species_z
393    }
394
395    fn defect_at(&mut self, pos: [f64; 3], species: usize) -> &mut DefectAcc {
396        let n = self.species_z.len();
397        match self.grid.locate(pos) {
398            Some(ijk) => &mut self
399                .defects
400                .entry(self.grid.flat_index(ijk))
401                .or_insert_with(|| vec![DefectAcc::default(); n])[species],
402            None => &mut self.outside_defects[species],
403        }
404    }
405
406    /// The grid this tally bins on.
407    pub fn grid(&self) -> &VoxelTallyGrid {
408        &self.grid
409    }
410
411    /// Number of stored (occupied) voxels.
412    pub fn occupied(&self) -> usize {
413        self.cells.len()
414    }
415
416    fn cell_at(&mut self, pos: [f64; 3]) -> &mut VoxelCell {
417        match self.grid.locate(pos) {
418            Some(ijk) => self.cells.entry(self.grid.flat_index(ijk)).or_default(),
419            None => &mut self.outside,
420        }
421    }
422
423    /// Spread `energy_ev` uniformly along the straight segment `from..to`,
424    /// adding each voxel's share with `add`.
425    fn spread(
426        &mut self,
427        from: [f64; 3],
428        to: [f64; 3],
429        energy_ev: f64,
430        add: fn(&mut VoxelCell, f64),
431    ) {
432        let (a, b) = (self.grid.locate(from), self.grid.locate(to));
433        if a.is_some() && a == b {
434            add(self.cell_at(from), energy_ev);
435            return;
436        }
437        // Fractions of the segment at which it crosses a voxel face, with
438        // faces clamped to the grid (beyond it everything is "outside").
439        let mut cuts = std::mem::take(&mut self.cuts);
440        cuts.clear();
441        cuts.push(0.0);
442        for ax in 0..3 {
443            let h = self.grid.spacing_m[ax];
444            let f0 = (from[ax] - self.grid.origin_m[ax]) / h;
445            let f1 = (to[ax] - self.grid.origin_m[ax]) / h;
446            if !(f0.is_finite() && f1.is_finite()) || f0 == f1 {
447                continue;
448            }
449            let n = self.grid.dims[ax] as f64;
450            let first = (f0.min(f1).floor() + 1.0).max(0.0);
451            let last = (f0.max(f1).ceil() - 1.0).min(n);
452            let mut k = first;
453            while k <= last {
454                let t = (k - f0) / (f1 - f0);
455                if t > 0.0 && t < 1.0 {
456                    cuts.push(t);
457                }
458                k += 1.0;
459            }
460        }
461        cuts.push(1.0);
462        cuts.sort_by(f64::total_cmp);
463        for w in cuts.windows(2) {
464            let (t0, t1) = (w[0], w[1]);
465            if t1 > t0 {
466                let tm = 0.5 * (t0 + t1);
467                let mid = std::array::from_fn(|i| from[i] + tm * (to[i] - from[i]));
468                add(self.cell_at(mid), energy_ev * (t1 - t0));
469            }
470        }
471        self.cuts = cuts;
472    }
473
474    /// The merged tally as plain data: occupied voxels in flat-index order.
475    pub fn report(&self) -> VoxelReport {
476        let n = self.species_z.len();
477        VoxelReport {
478            grid: self.grid,
479            histories: self.histories,
480            voxels: self
481                .cells
482                .iter()
483                .map(|(&flat, &cell)| VoxelRecord {
484                    index: self.grid.unflatten(flat),
485                    cell,
486                    defects: match self.defects.get(&flat) {
487                        Some(acc) => report_defects(acc),
488                        None => vec![SpeciesDefects::default(); n],
489                    },
490                })
491                .collect(),
492            outside: self.outside,
493            species_z: self.species_z.clone(),
494            outside_defects: report_defects(&self.outside_defects),
495        }
496    }
497}
498
499impl BcaTally for VoxelTally {
500    fn electronic(
501        &mut self,
502        p: &Particle,
503        from: [f64; 3],
504        channel: ElectronicChannel,
505        energy_ev: f64,
506    ) {
507        let add = |c: &mut VoxelCell, e: f64| c.electronic_ev += e;
508        match channel {
509            ElectronicChannel::NonLocal => self.spread(from, p.pos, energy_ev, add),
510            ElectronicChannel::Local => add(self.cell_at(p.pos), energy_ev),
511        }
512    }
513
514    fn begin_history(&mut self, _index: u64) {
515        self.tracker.clear();
516    }
517
518    fn lattice(&mut self, at: [f64; 3], _layer: usize, kind: LatticeDeposit, energy_ev: f64) {
519        if kind == LatticeDeposit::Subthreshold {
520            self.tracker.clear();
521        }
522        self.cell_at(at).lattice_ev += energy_ev;
523    }
524
525    fn recoil(&mut self, r: &Particle) {
526        self.cell_at(r.pos).displacements += 1;
527        self.tracker.recoil(r);
528        if !self.species_z.is_empty() {
529            self.defect_at(r.pos, r.species).displacements += 1;
530        }
531    }
532
533    fn stopped(&mut self, p: &Particle) {
534        let primary = p.is_primary();
535        let replaced = self.tracker.stopped(p);
536        if !self.species_z.is_empty() {
537            if let Some(species) = replaced {
538                self.defect_at(p.pos, species).replacements += 1;
539            } else if !primary {
540                self.defect_at(p.pos, p.species).interstitials += 1;
541            }
542        }
543        let cell = self.cell_at(p.pos);
544        cell.rest_ev += p.energy_ev;
545        if primary {
546            cell.implanted += 1;
547        }
548    }
549
550    fn escaped(&mut self, _p: &Particle, _face: Face) {
551        self.tracker.clear();
552    }
553
554    fn end_history(&mut self, _index: u64, _budget: &crate::ion::bca::EnergyBudget) {
555        self.tracker.clear();
556        self.histories += 1;
557    }
558
559    /// Voxel-by-voxel merge.
560    ///
561    /// # Panics
562    /// If `other` was built on a different grid or for different species.
563    fn merge(&mut self, o: Self) {
564        assert!(
565            self.grid == o.grid,
566            "merging voxel tallies built on different grids"
567        );
568        assert!(
569            self.species_z == o.species_z,
570            "merging voxel tallies built for different species"
571        );
572        for (a, b) in self.outside_defects.iter_mut().zip(&o.outside_defects) {
573            a.add(b);
574        }
575        for (flat, acc) in o.defects {
576            let mine = self
577                .defects
578                .entry(flat)
579                .or_insert_with(|| vec![DefectAcc::default(); acc.len()]);
580            for (a, b) in mine.iter_mut().zip(&acc) {
581                a.add(b);
582            }
583        }
584        self.histories += o.histories;
585        self.outside.add(&o.outside);
586        for (flat, cell) in o.cells {
587            self.cells.entry(flat).or_default().add(&cell);
588        }
589    }
590}
591
592/// One occupied voxel of a [`VoxelReport`].
593#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
594pub struct VoxelRecord {
595    /// Voxel indices `[i, j, k]`.
596    pub index: [usize; 3],
597    /// Its tallies.
598    pub cell: VoxelCell,
599    /// Defects per species index (same length as [`VoxelReport::species_z`];
600    /// empty when the tally had no species).
601    #[serde(default)]
602    pub defects: Vec<SpeciesDefects>,
603}
604
605/// A [`VoxelTally`] as plain data. Voxels are in increasing flat-index order
606/// (`i + nx (j + ny k)`).
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct VoxelReport {
609    /// The grid the voxels refer to.
610    pub grid: VoxelTallyGrid,
611    /// Primary histories tallied.
612    pub histories: u64,
613    /// Occupied voxels.
614    pub voxels: Vec<VoxelRecord>,
615    /// Sum of the events outside the grid.
616    pub outside: VoxelCell,
617    /// Atomic number of each species index; empty when defects were not
618    /// tallied ([`VoxelTally::with_species`]).
619    #[serde(default)]
620    pub species_z: Vec<u8>,
621    /// Defects outside the grid, per species index.
622    #[serde(default)]
623    pub outside_defects: Vec<SpeciesDefects>,
624}
625
626const CSV_COLUMNS: [&str; 12] = [
627    "i",
628    "j",
629    "k",
630    "x_m",
631    "y_m",
632    "z_m",
633    "implanted",
634    "displacements",
635    "electronic_ev",
636    "lattice_ev",
637    "rest_ev",
638    "deposited_ev",
639];
640
641const VTK_SCALARS: [(&str, &str); 6] = [
642    ("implanted", "unsigned_long"),
643    ("displacements", "unsigned_long"),
644    ("electronic_ev", "double"),
645    ("lattice_ev", "double"),
646    ("rest_ev", "double"),
647    ("deposited_ev", "double"),
648];
649
650/// Corner offsets of a `VTK_VOXEL` cell, in VTK point order (x fastest).
651const VOXEL_CORNERS: [[usize; 3]; 8] = [
652    [0, 0, 0],
653    [1, 0, 0],
654    [0, 1, 0],
655    [1, 1, 0],
656    [0, 0, 1],
657    [1, 0, 1],
658    [0, 1, 1],
659    [1, 1, 1],
660];
661
662const VTK_VOXEL: u8 = 11;
663
664fn join3(v: [f64; 3]) -> String {
665    format!("{:e},{:e},{:e}", v[0], v[1], v[2])
666}
667
668fn parse3<T: std::str::FromStr>(s: &str, line: usize) -> Result<[T; 3], VoxelTallyError> {
669    let v: Vec<T> = s
670        .split(',')
671        .map(|t| t.trim().parse::<T>())
672        .collect::<Result<_, _>>()
673        .map_err(|_| parse_err(line, format!("cannot parse the triple {s:?}")))?;
674    v.try_into()
675        .map_err(|_| parse_err(line, format!("expected three values in {s:?}")))
676}
677
678fn num<T: std::str::FromStr>(s: &str, line: usize) -> Result<T, VoxelTallyError> {
679    s.trim()
680        .parse::<T>()
681        .map_err(|_| parse_err(line, format!("cannot parse the number {s:?}")))
682}
683
684/// Whitespace-separated tokens of a VTK file with their 1-based lines.
685struct Tokens<'a> {
686    iter: Box<dyn Iterator<Item = (usize, &'a str)> + 'a>,
687    last_line: usize,
688}
689
690impl<'a> Tokens<'a> {
691    fn next(&mut self, what: &str) -> Result<(usize, &'a str), VoxelTallyError> {
692        self.iter
693            .next()
694            .ok_or_else(|| parse_err(self.last_line, format!("expected {what}")))
695    }
696
697    fn expect(&mut self, word: &str) -> Result<(), VoxelTallyError> {
698        let (n, t) = self.next(word)?;
699        if t == word {
700            Ok(())
701        } else {
702            Err(parse_err(n, format!("expected `{word}`, found `{t}`")))
703        }
704    }
705}
706
707fn symbol(z: u8) -> &'static str {
708    element(z).map_or("?", |e| e.symbol)
709}
710
711/// `0:Ar,1:Si`: the species of a v2 file, named by element symbol.
712fn species_list(species_z: &[u8]) -> String {
713    let v: Vec<String> = species_z
714        .iter()
715        .enumerate()
716        .map(|(i, &z)| format!("{i}:{}", symbol(z)))
717        .collect();
718    v.join(",")
719}
720
721fn parse_species(s: &str, line: usize) -> Result<Vec<u8>, VoxelTallyError> {
722    let mut out = Vec::new();
723    for (i, item) in s.split(',').enumerate() {
724        let item = item.trim();
725        let z = item
726            .split_once(':')
727            .filter(|(idx, _)| idx.parse::<usize>() == Ok(i))
728            .and_then(|(_, sym)| element_by_symbol(sym))
729            .ok_or_else(|| parse_err(line, format!("cannot parse the species {item:?}")))?
730            .z;
731        out.push(z);
732    }
733    Ok(out)
734}
735
736/// Names of the defect columns: per species `vacancies_<i>_<symbol>`,
737/// `interstitials_<i>_<symbol>`, `replacements_<i>_<symbol>`.
738fn defect_columns(species_z: &[u8]) -> Vec<String> {
739    let mut out = Vec::new();
740    for (i, &z) in species_z.iter().enumerate() {
741        for kind in ["vacancies", "interstitials", "replacements"] {
742            out.push(format!("{kind}_{i}_{}", symbol(z)));
743        }
744    }
745    out
746}
747
748fn defect_values(d: &[SpeciesDefects]) -> impl Iterator<Item = u64> + '_ {
749    d.iter()
750        .flat_map(|s| [s.vacancies, s.interstitials, s.replacements])
751}
752
753fn defects_from_values(v: &[u64]) -> Vec<SpeciesDefects> {
754    v.chunks(3)
755        .map(|c| SpeciesDefects {
756            vacancies: c[0],
757            interstitials: c[1],
758            replacements: c[2],
759        })
760        .collect()
761}
762
763/// The banner line of a damage-export file (see `docs/damage-export-format.md`).
764pub const EXPORT_BANNER: &str = "# lindhard damage export v1";
765
766/// Version of the damage-export format, the `format_version` header key.
767pub const EXPORT_FORMAT_VERSION: u32 = 1;
768
769/// Units statement written into every damage-export header.
770const EXPORT_UNITS: &str = "length=m; energy=eV; counts=1";
771
772/// Where a damage-export file came from: the header keys beyond the grid and
773/// the tallies. The CSV is an exchange format; these fields let a reader tie
774/// a file to the run that made it.
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
776pub struct ExportProvenance {
777    /// The engine that produced the map: `"ion"` or `"electron"`.
778    pub engine: String,
779    /// `lindhard` version that wrote the file ([`crate::VERSION`]).
780    pub lindhard_version: String,
781    /// Root seed of the run.
782    pub seed: u64,
783    /// SHA-256 (lower-case hex) of the resolved input echo, or of whatever
784    /// canonical input the caller defines; it must not be empty.
785    pub input_sha256: String,
786}
787
788impl ExportProvenance {
789    /// Provenance for a run of `engine` with the given seed and input hash,
790    /// stamped with this library's version.
791    pub fn new(engine: &str, seed: u64, input_sha256: &str) -> Self {
792        Self {
793            engine: engine.to_string(),
794            lindhard_version: crate::VERSION.to_string(),
795            seed,
796            input_sha256: input_sha256.to_string(),
797        }
798    }
799}
800
801impl VoxelReport {
802    /// Per-species defects summed over the occupied voxels (not including
803    /// [`Self::outside_defects`]); empty without species.
804    pub fn defect_totals(&self) -> Vec<SpeciesDefects> {
805        let mut t = vec![SpeciesDefects::default(); self.species_z.len()];
806        for v in &self.voxels {
807            for (a, b) in t.iter_mut().zip(&v.defects) {
808                a.add(b);
809            }
810        }
811        t
812    }
813
814    /// Per-species defects over the grid and the outside.
815    pub fn grand_defect_totals(&self) -> Vec<SpeciesDefects> {
816        let mut t = self.defect_totals();
817        for (a, b) in t.iter_mut().zip(&self.outside_defects) {
818            a.add(b);
819        }
820        t
821    }
822
823    /// Sum over the occupied voxels (not including [`Self::outside`]), added
824    /// in flat-index order.
825    pub fn totals(&self) -> VoxelCell {
826        let mut t = VoxelCell::default();
827        for v in &self.voxels {
828            t.add(&v.cell);
829        }
830        t
831    }
832
833    /// Sum over the grid and the outside: everything the tally received.
834    pub fn grand_totals(&self) -> VoxelCell {
835        let mut t = self.totals();
836        t.add(&self.outside);
837        t
838    }
839
840    /// Per-slab sums along `axis` (0 = x): entry `n` adds every occupied
841    /// voxel whose index on that axis is `n`. This is the laterally
842    /// integrated profile, comparable to a 1D depth tally of the same bins.
843    pub fn profile(&self, axis: usize) -> Vec<VoxelCell> {
844        let mut out = vec![VoxelCell::default(); self.grid.dims[axis]];
845        for v in &self.voxels {
846            out[v.index[axis]].add(&v.cell);
847        }
848        out
849    }
850
851    /// Write the CSV export: a `# key = value` header, a column row, then one
852    /// row per occupied voxel (indices, centre in m, tallies; energies in
853    /// eV). See the [module docs](self).
854    pub fn write_csv<W: Write>(&self, w: W) -> io::Result<()> {
855        self.write_csv_inner(w, None)
856    }
857
858    /// Write the damage-export file: the [`Self::write_csv`] layout under the
859    /// [`EXPORT_BANNER`], with the provenance and units keys in the header
860    /// (`docs/damage-export-format.md`). Floats are the shortest text that
861    /// reads back to the same bits, so [`Self::read_export_csv`] returns the
862    /// report unchanged.
863    pub fn write_export_csv<W: Write>(&self, w: W, p: &ExportProvenance) -> io::Result<()> {
864        self.write_csv_inner(w, Some(p))
865    }
866
867    /// The damage-export file as a string.
868    pub fn to_export_csv(&self, p: &ExportProvenance) -> String {
869        let mut buf = Vec::new();
870        self.write_export_csv(&mut buf, p)
871            .expect("writing to a Vec");
872        String::from_utf8(buf).expect("the CSV is ASCII")
873    }
874
875    fn write_csv_inner<W: Write>(
876        &self,
877        mut w: W,
878        export: Option<&ExportProvenance>,
879    ) -> io::Result<()> {
880        let g = &self.grid;
881        let o = &self.outside;
882        let v2 = !self.species_z.is_empty();
883        if let Some(p) = export {
884            writeln!(w, "{EXPORT_BANNER}")?;
885            writeln!(w, "# format_version = {EXPORT_FORMAT_VERSION}")?;
886            writeln!(w, "# engine = {}", p.engine)?;
887            writeln!(w, "# lindhard_version = {}", p.lindhard_version)?;
888            writeln!(w, "# seed = {}", p.seed)?;
889            writeln!(w, "# input_sha256 = {}", p.input_sha256)?;
890            writeln!(w, "# units = {EXPORT_UNITS}")?;
891        } else {
892            writeln!(w, "# lindhard voxel tally v{}", if v2 { 2 } else { 1 })?;
893        }
894        writeln!(w, "# origin_m = {}", join3(g.origin_m))?;
895        writeln!(w, "# spacing_m = {}", join3(g.spacing_m))?;
896        writeln!(w, "# dims = {},{},{}", g.dims[0], g.dims[1], g.dims[2])?;
897        writeln!(w, "# histories = {}", self.histories)?;
898        writeln!(
899            w,
900            "# outside = {},{},{:e},{:e},{:e}",
901            o.implanted, o.displacements, o.electronic_ev, o.lattice_ev, o.rest_ev
902        )?;
903        let mut columns: Vec<String> = CSV_COLUMNS.iter().map(|c| (*c).to_string()).collect();
904        if v2 {
905            writeln!(w, "# species = {}", species_list(&self.species_z))?;
906            let od: Vec<String> = defect_values(&self.outside_defects)
907                .map(|n| n.to_string())
908                .collect();
909            writeln!(w, "# outside_defects = {}", od.join(","))?;
910            columns.extend(defect_columns(&self.species_z));
911        }
912        writeln!(w, "{}", columns.join(","))?;
913        for v in &self.voxels {
914            let c = g.center_m(v.index);
915            let t = &v.cell;
916            write!(
917                w,
918                "{},{},{},{:e},{:e},{:e},{},{},{:e},{:e},{:e},{:e}",
919                v.index[0],
920                v.index[1],
921                v.index[2],
922                c[0],
923                c[1],
924                c[2],
925                t.implanted,
926                t.displacements,
927                t.electronic_ev,
928                t.lattice_ev,
929                t.rest_ev,
930                t.deposited_ev()
931            )?;
932            for n in defect_values(&v.defects) {
933                write!(w, ",{n}")?;
934            }
935            writeln!(w)?;
936        }
937        Ok(())
938    }
939
940    /// The CSV export as a string.
941    pub fn to_csv(&self) -> String {
942        let mut buf = Vec::new();
943        self.write_csv(&mut buf).expect("writing to a Vec");
944        String::from_utf8(buf).expect("the CSV is ASCII")
945    }
946
947    /// Read back a file written by [`Self::write_csv`].
948    pub fn read_csv(text: &str) -> Result<Self, VoxelTallyError> {
949        Self::parse_csv(text).map(|(r, _)| r)
950    }
951
952    /// Read back a file written by [`Self::write_export_csv`], with its
953    /// provenance. Errors if the banner, the format version or any
954    /// provenance key is missing.
955    pub fn read_export_csv(text: &str) -> Result<(Self, ExportProvenance), VoxelTallyError> {
956        let first = text.lines().map(str::trim).find(|l| !l.is_empty());
957        if first != Some(EXPORT_BANNER) {
958            return Err(parse_err(1, "not a damage-export file (banner missing)"));
959        }
960        let (report, keys) = Self::parse_csv(text)?;
961        let get = |k: &str| {
962            keys.get(k)
963                .cloned()
964                .ok_or_else(|| parse_err(1, format!("header is missing `{k}`")))
965        };
966        if get("format_version")? != EXPORT_FORMAT_VERSION.to_string() {
967            return Err(parse_err(1, "unsupported damage-export format_version"));
968        }
969        let seed = get("seed")?;
970        let prov = ExportProvenance {
971            engine: get("engine")?,
972            lindhard_version: get("lindhard_version")?,
973            seed: seed
974                .parse()
975                .map_err(|_| parse_err(1, format!("cannot parse the seed {seed:?}")))?,
976            input_sha256: get("input_sha256")?,
977        };
978        get("units")?;
979        Ok((report, prov))
980    }
981
982    fn parse_csv(
983        text: &str,
984    ) -> Result<(Self, std::collections::BTreeMap<String, String>), VoxelTallyError> {
985        let mut extra = std::collections::BTreeMap::new();
986        let mut origin = None;
987        let mut spacing = None;
988        let mut dims = None;
989        let mut histories = None;
990        let mut outside = None;
991        let mut species_z: Vec<u8> = Vec::new();
992        let mut outside_defects: Option<Vec<u64>> = None;
993        let mut lines = text.lines().enumerate().map(|(i, l)| (i + 1, l.trim()));
994        let mut header_row = None;
995        for (n, line) in lines.by_ref() {
996            if line.is_empty() {
997                continue;
998            }
999            let Some(meta) = line.strip_prefix('#') else {
1000                header_row = Some((n, line));
1001                break;
1002            };
1003            let Some((key, value)) = meta.split_once('=') else {
1004                continue; // the format banner
1005            };
1006            match key.trim() {
1007                "origin_m" => origin = Some(parse3::<f64>(value, n)?),
1008                "spacing_m" => spacing = Some(parse3::<f64>(value, n)?),
1009                "dims" => dims = Some(parse3::<usize>(value, n)?),
1010                "histories" => histories = Some(num::<u64>(value, n)?),
1011                "outside" => {
1012                    let f: Vec<&str> = value.split(',').collect();
1013                    if f.len() != 5 {
1014                        return Err(parse_err(n, "outside needs five values"));
1015                    }
1016                    outside = Some(VoxelCell {
1017                        implanted: num(f[0], n)?,
1018                        displacements: num(f[1], n)?,
1019                        electronic_ev: num(f[2], n)?,
1020                        lattice_ev: num(f[3], n)?,
1021                        rest_ev: num(f[4], n)?,
1022                    });
1023                }
1024                "species" => species_z = parse_species(value, n)?,
1025                "outside_defects" => {
1026                    outside_defects = Some(
1027                        value
1028                            .split(',')
1029                            .map(|t| num::<u64>(t, n))
1030                            .collect::<Result<_, _>>()?,
1031                    );
1032                }
1033                k @ ("format_version" | "engine" | "lindhard_version" | "seed" | "input_sha256"
1034                | "units") => {
1035                    extra.insert(k.to_string(), value.trim().to_string());
1036                }
1037                other => return Err(parse_err(n, format!("unknown header key {other:?}"))),
1038            }
1039        }
1040        let missing = |what: &str| parse_err(1, format!("header is missing `{what}`"));
1041        let grid = VoxelTallyGrid::new(
1042            origin.ok_or_else(|| missing("origin_m"))?,
1043            spacing.ok_or_else(|| missing("spacing_m"))?,
1044            dims.ok_or_else(|| missing("dims"))?,
1045        )?;
1046        let (hn, header_row) = header_row.ok_or_else(|| parse_err(1, "no column row"))?;
1047        let mut expected: Vec<String> = CSV_COLUMNS.iter().map(|c| (*c).to_string()).collect();
1048        expected.extend(defect_columns(&species_z));
1049        if header_row != expected.join(",") {
1050            return Err(parse_err(hn, "unexpected column row"));
1051        }
1052        let outside_defects = match outside_defects {
1053            Some(v) if v.len() == 3 * species_z.len() => defects_from_values(&v),
1054            Some(_) => {
1055                return Err(parse_err(
1056                    1,
1057                    "outside_defects needs three values per species",
1058                ))
1059            }
1060            None if species_z.is_empty() => Vec::new(),
1061            None => return Err(parse_err(1, "header is missing `outside_defects`")),
1062        };
1063        let mut voxels: Vec<VoxelRecord> = Vec::new();
1064        for (n, line) in lines {
1065            if line.is_empty() {
1066                continue;
1067            }
1068            let f: Vec<&str> = line.split(',').collect();
1069            if f.len() != expected.len() {
1070                return Err(parse_err(n, "wrong number of columns"));
1071            }
1072            let index = [num(f[0], n)?, num(f[1], n)?, num(f[2], n)?];
1073            if (0..3).any(|a| index[a] >= grid.dims[a]) {
1074                return Err(parse_err(n, "voxel index outside the grid"));
1075            }
1076            let record = VoxelRecord {
1077                index,
1078                cell: VoxelCell {
1079                    implanted: num(f[6], n)?,
1080                    displacements: num(f[7], n)?,
1081                    electronic_ev: num(f[8], n)?,
1082                    lattice_ev: num(f[9], n)?,
1083                    rest_ev: num(f[10], n)?,
1084                },
1085                defects: defects_from_values(
1086                    &f[CSV_COLUMNS.len()..]
1087                        .iter()
1088                        .map(|t| num::<u64>(t, n))
1089                        .collect::<Result<Vec<_>, _>>()?,
1090                ),
1091            };
1092            if let Some(last) = voxels.last() {
1093                if grid.flat_index(last.index) >= grid.flat_index(index) {
1094                    return Err(parse_err(
1095                        n,
1096                        "voxels must be in increasing flat-index order",
1097                    ));
1098                }
1099            }
1100            voxels.push(record);
1101        }
1102        let report = Self {
1103            grid,
1104            histories: histories.ok_or_else(|| missing("histories"))?,
1105            voxels,
1106            outside: outside.ok_or_else(|| missing("outside"))?,
1107            species_z,
1108            outside_defects,
1109        };
1110        Ok((report, extra))
1111    }
1112
1113    /// Write the legacy ASCII VTK export: an `UNSTRUCTURED_GRID` with one
1114    /// `VTK_VOXEL` cell per occupied voxel and the tallies as cell scalars.
1115    /// The grid and the history count are in the title line. See the
1116    /// [module docs](self).
1117    pub fn write_vtk<W: Write>(&self, mut w: W) -> io::Result<()> {
1118        let g = &self.grid;
1119        let v2 = !self.species_z.is_empty();
1120        let mut title = format!(
1121            "lindhard voxel tally v{} origin={} spacing={} dims={},{},{} histories={}",
1122            if v2 { 2 } else { 1 },
1123            join3(g.origin_m),
1124            join3(g.spacing_m),
1125            g.dims[0],
1126            g.dims[1],
1127            g.dims[2],
1128            self.histories
1129        );
1130        if v2 {
1131            title.push_str(&format!(" species={}", species_list(&self.species_z)));
1132        }
1133        if title.len() > 255 {
1134            return Err(io::Error::new(
1135                io::ErrorKind::InvalidInput,
1136                "the VTK title line would exceed 255 characters",
1137            ));
1138        }
1139        let n = self.voxels.len();
1140        writeln!(w, "# vtk DataFile Version 3.0")?;
1141        writeln!(w, "{title}")?;
1142        writeln!(w, "ASCII")?;
1143        writeln!(w, "DATASET UNSTRUCTURED_GRID")?;
1144        writeln!(w, "POINTS {} double", 8 * n)?;
1145        for v in &self.voxels {
1146            for c in VOXEL_CORNERS {
1147                let p = g.corner_m(std::array::from_fn(|a| v.index[a] + c[a]));
1148                writeln!(w, "{:e} {:e} {:e}", p[0], p[1], p[2])?;
1149            }
1150        }
1151        writeln!(w, "CELLS {} {}", n, 9 * n)?;
1152        for i in 0..n {
1153            let b = 8 * i;
1154            writeln!(
1155                w,
1156                "8 {} {} {} {} {} {} {} {}",
1157                b,
1158                b + 1,
1159                b + 2,
1160                b + 3,
1161                b + 4,
1162                b + 5,
1163                b + 6,
1164                b + 7
1165            )?;
1166        }
1167        writeln!(w, "CELL_TYPES {n}")?;
1168        for _ in 0..n {
1169            writeln!(w, "{VTK_VOXEL}")?;
1170        }
1171        writeln!(w, "CELL_DATA {n}")?;
1172        for (name, kind) in VTK_SCALARS {
1173            writeln!(w, "SCALARS {name} {kind} 1")?;
1174            writeln!(w, "LOOKUP_TABLE default")?;
1175            for v in &self.voxels {
1176                let c = &v.cell;
1177                match name {
1178                    "implanted" => writeln!(w, "{}", c.implanted)?,
1179                    "displacements" => writeln!(w, "{}", c.displacements)?,
1180                    "electronic_ev" => writeln!(w, "{:e}", c.electronic_ev)?,
1181                    "lattice_ev" => writeln!(w, "{:e}", c.lattice_ev)?,
1182                    "rest_ev" => writeln!(w, "{:e}", c.rest_ev)?,
1183                    _ => writeln!(w, "{:e}", c.deposited_ev())?,
1184                }
1185            }
1186        }
1187        for (col, name) in defect_columns(&self.species_z).iter().enumerate() {
1188            writeln!(w, "SCALARS {name} unsigned_long 1")?;
1189            writeln!(w, "LOOKUP_TABLE default")?;
1190            for v in &self.voxels {
1191                writeln!(w, "{}", defect_values(&v.defects).nth(col).unwrap_or(0))?;
1192            }
1193        }
1194        Ok(())
1195    }
1196
1197    /// The VTK export as a string.
1198    pub fn to_vtk(&self) -> String {
1199        let mut buf = Vec::new();
1200        self.write_vtk(&mut buf).expect("writing to a Vec");
1201        String::from_utf8(buf).expect("the VTK file is ASCII")
1202    }
1203
1204    /// Read back a file written by [`Self::write_vtk`]. The result has an
1205    /// empty [`Self::outside`], which the VTK file does not carry.
1206    pub fn read_vtk(text: &str) -> Result<Self, VoxelTallyError> {
1207        let mut lines = text.lines();
1208        if lines.next().map(str::trim) != Some("# vtk DataFile Version 3.0") {
1209            return Err(parse_err(1, "not a legacy VTK 3.0 file"));
1210        }
1211        let title = lines.next().ok_or_else(|| parse_err(2, "no title line"))?;
1212        let mut fields = title.split_whitespace();
1213        let head = fields.by_ref().take(4).collect::<Vec<_>>();
1214        if head[..head.len().min(3)] != ["lindhard", "voxel", "tally"]
1215            || !matches!(head.get(3), Some(&"v1" | &"v2"))
1216        {
1217            return Err(parse_err(2, "not a lindhard voxel tally"));
1218        }
1219        let (mut origin, mut spacing, mut dims, mut histories) = (None, None, None, None);
1220        let mut species_z: Vec<u8> = Vec::new();
1221        for f in fields {
1222            match f.split_once('=') {
1223                Some(("origin", v)) => origin = Some(parse3::<f64>(v, 2)?),
1224                Some(("spacing", v)) => spacing = Some(parse3::<f64>(v, 2)?),
1225                Some(("dims", v)) => dims = Some(parse3::<usize>(v, 2)?),
1226                Some(("histories", v)) => histories = Some(num::<u64>(v, 2)?),
1227                Some(("species", v)) => species_z = parse_species(v, 2)?,
1228                _ => return Err(parse_err(2, format!("unexpected title field {f:?}"))),
1229            }
1230        }
1231        let missing = |what: &str| parse_err(2, format!("title is missing `{what}`"));
1232        let grid = VoxelTallyGrid::new(
1233            origin.ok_or_else(|| missing("origin"))?,
1234            spacing.ok_or_else(|| missing("spacing"))?,
1235            dims.ok_or_else(|| missing("dims"))?,
1236        )?;
1237        let histories = histories.ok_or_else(|| missing("histories"))?;
1238
1239        // The rest is a token stream; each token remembers its line.
1240        let mut tk = Tokens {
1241            iter: Box::new(
1242                text.lines()
1243                    .enumerate()
1244                    .skip(2)
1245                    .flat_map(|(i, l)| l.split_whitespace().map(move |w| (i + 1, w))),
1246            ),
1247            last_line: text.lines().count().max(1),
1248        };
1249        tk.expect("ASCII")?;
1250        tk.expect("DATASET")?;
1251        tk.expect("UNSTRUCTURED_GRID")?;
1252        tk.expect("POINTS")?;
1253        let (n, t) = tk.next("a point count")?;
1254        let n_points: usize = num(t, n)?;
1255        if !n_points.is_multiple_of(8) {
1256            return Err(parse_err(n, "the point count must be a multiple of 8"));
1257        }
1258        tk.expect("double")?;
1259        let n_cells = n_points / 8;
1260        let mut corners = Vec::with_capacity(n_cells);
1261        for _ in 0..n_cells {
1262            let mut lo = [0.0f64; 3];
1263            for p in 0..8 {
1264                for coord in &mut lo {
1265                    let (n, t) = tk.next("a coordinate")?;
1266                    let v: f64 = num(t, n)?;
1267                    if p == 0 {
1268                        *coord = v;
1269                    }
1270                }
1271            }
1272            corners.push(lo);
1273        }
1274        tk.expect("CELLS")?;
1275        for want in [n_cells, 9 * n_cells] {
1276            let (n, t) = tk.next("a cell count")?;
1277            if num::<usize>(t, n)? != want {
1278                return Err(parse_err(n, "inconsistent cell counts"));
1279            }
1280        }
1281        for _ in 0..9 * n_cells {
1282            tk.next("a connectivity entry")?;
1283        }
1284        tk.expect("CELL_TYPES")?;
1285        let (n, t) = tk.next("a cell count")?;
1286        if num::<usize>(t, n)? != n_cells {
1287            return Err(parse_err(n, "inconsistent cell counts"));
1288        }
1289        for _ in 0..n_cells {
1290            let (n, t) = tk.next("a cell type")?;
1291            if num::<u8>(t, n)? != VTK_VOXEL {
1292                return Err(parse_err(n, "only VTK_VOXEL (11) cells are supported"));
1293            }
1294        }
1295        tk.expect("CELL_DATA")?;
1296        let (n, t) = tk.next("a cell count")?;
1297        if num::<usize>(t, n)? != n_cells {
1298            return Err(parse_err(n, "inconsistent cell counts"));
1299        }
1300        let mut cells = vec![VoxelCell::default(); n_cells];
1301        for (name, kind) in VTK_SCALARS {
1302            tk.expect("SCALARS")?;
1303            tk.expect(name)?;
1304            tk.expect(kind)?;
1305            tk.expect("1")?;
1306            tk.expect("LOOKUP_TABLE")?;
1307            tk.expect("default")?;
1308            for cell in &mut cells {
1309                let (n, t) = tk.next("a scalar")?;
1310                match name {
1311                    "implanted" => cell.implanted = num(t, n)?,
1312                    "displacements" => cell.displacements = num(t, n)?,
1313                    "electronic_ev" => cell.electronic_ev = num(t, n)?,
1314                    "lattice_ev" => cell.lattice_ev = num(t, n)?,
1315                    "rest_ev" => cell.rest_ev = num(t, n)?,
1316                    _ => {} // deposited_ev is derived
1317                }
1318            }
1319        }
1320        let mut counts = vec![vec![0u64; n_cells]; 3 * species_z.len()];
1321        for (name, col) in defect_columns(&species_z).iter().zip(&mut counts) {
1322            tk.expect("SCALARS")?;
1323            tk.expect(name)?;
1324            tk.expect("unsigned_long")?;
1325            tk.expect("1")?;
1326            tk.expect("LOOKUP_TABLE")?;
1327            tk.expect("default")?;
1328            for slot in col.iter_mut() {
1329                let (n, t) = tk.next("a scalar")?;
1330                *slot = num(t, n)?;
1331            }
1332        }
1333        let voxels = corners
1334            .iter()
1335            .zip(cells)
1336            .enumerate()
1337            .map(|(vi, (lo, cell))| {
1338                let mut index = [0usize; 3];
1339                for a in 0..3 {
1340                    let s = ((lo[a] - grid.origin_m[a]) / grid.spacing_m[a]).round();
1341                    if !(s >= 0.0 && s < grid.dims[a] as f64) {
1342                        return Err(parse_err(1, "a cell lies outside the grid"));
1343                    }
1344                    index[a] = s as usize;
1345                }
1346                let row: Vec<u64> = counts.iter().map(|c| c[vi]).collect();
1347                Ok(VoxelRecord {
1348                    index,
1349                    cell,
1350                    defects: defects_from_values(&row),
1351                })
1352            })
1353            .collect::<Result<Vec<_>, _>>()?;
1354        Ok(Self {
1355            grid,
1356            histories,
1357            voxels,
1358            outside: VoxelCell::default(),
1359            outside_defects: vec![SpeciesDefects::default(); species_z.len()],
1360            species_z,
1361        })
1362    }
1363}