Output files
lindhard run input.toml --out dir/ writes summary.json and up to five
CSV profiles into dir/. The first run shows excerpts of
each from a real run. The models behind the damage figures are on
Displacement damage.
This page is the output section of
docs/cli.md,
included here so there is one copy.
summary.json
| Key | Content |
|---|---|
format | {"name": "lindhard-summary", "version": 1} |
software | Crate version and git_describe of the binary |
input | The input as run: defaults filled in, CLI overrides applied, run.threads removed. Deserializes back to the same lindhard::input::Input, so a run can be reproduced from its own header |
physics.models | Every model in use: role, name, citation |
physics.crystal | Only with [[crystal]]: the engine’s metadata of each crystal (see [[crystal]]) |
physics.stopping_tables | Only with [stopping]: path, SHA-256, provenance and range of each user table (see [stopping]) |
physics.engine | Cutoffs, free path, weak collisions, electronic-loss mode, seed, chunk size as passed to the engine |
physics.scattering_table | Angle-table grid and its measured interpolation error |
physics.target | Each layer: extent (nm; back_nm is null for a substrate), atom density, and the fully resolved material (every E_d, E_b, E_s) |
results.histories | Primaries run |
results.primaries | stopped, backscattered, transmitted; mean and standard deviation of the stopped-primary depth, nm (stopped_depth_mean_nm, stopped_depth_std_nm; the same numbers as results.range.depth.mean_nm and std_dev_nm, the projected range and straggle, kept under their original keys) |
results.recoils | Atoms displaced, sputtered (left through the front face), transmitted |
results.yields | The above per incident ion |
results.energy_budget_ev_per_ion | Where the incident energy went, per ion, and the largest per-history relative bookkeeping residual |
results.range | Where the beam particles came to rest, lengths in nm. depth: n, mean_nm (projected range Rp), std_dev_nm (straggle), skewness, kurtosis (beta, Gaussian 3) and the standard error of each (null below two stopped primaries). pearson_iv: the Pearson IV density with those moments (m, nu, a_nm, lambda_nm), or null with the reason in pearson_iv_error. dual_pearson (or dual_pearson_error): only with tally.dual_pearson = true; head fraction, head and tail components, chi-square of the fit and of the single Pearson IV. lateral_y, lateral_z, radial: moments of the lateral positions. layers: stopped and depth moments by the layer where the particle stopped |
results.damage | nrt: Norgett-Robinson-Torrens and Kinchin-Pease displacement estimates from the primary knock-on atom damage energies (pka_count, pka_energy_ev, damage_energy_ev, nrt_displacements, kinchin_pease_displacements). cascade: defects counted event by event in the simulated cascades (displacements, replacements, vacancies, interstitials). The two are different quantities and are reported separately; see lindhard::ion::damage for the conventions, including the approximation used for compounds. Totals over all ions, with per_ion and a layers breakdown |
results.sputtering | yield_per_ion and by_element: target atoms leaving the front face, with count, per_ion and mean_energy_ev for each element |
results.escapes | backscatter_coefficient, transmission_coefficient, energy_reflection_coefficient (energy carried out of the front face by the beam particles, as a fraction of the incident energy), and species: every species (beam first) through the front and back face, with count, per_ion and mean_energy_ev |
files | Names of the other files written (null if not written) |
run | threads, table_build_s, transport_s, ions_per_s |
software.git_describe freshness. The CLI’s build script stamps git describe --always --dirty, and reruns it when a build input that the description reads changes: tracked sources and manifests of lindhard and lindhard-cli (plus the workspace Cargo.toml, Cargo.lock and rust-toolchain.toml), the index, HEAD, the branch ref, packed-refs and refs/heads/refs/tags of the common git directory (so linked worktrees follow shared tags and branches). A non-empty LINDHARD_GIT_DESCRIBE in the build environment is used verbatim (trimmed) and git is not consulted; outside a git checkout the value is unknown. Limitations: -dirty is a flag, not a fingerprint, so two different dirty builds report the same string; untracked files never affect the -dirty flag, while git describe --dirty covers the whole tracked checkout, so an edit to any tracked file (docs, tests, other crates included) marks the stamp -dirty whenever the script next reruns, but a tracked file outside the watched set does not by itself trigger a rerun, so the stamp can stay stale until a watched input or the index changes (for example after git add); and it describes the checkout at build time, not the commit of a binary built elsewhere.
depth_profile.csv
depth_lo_nm,depth_hi_nm,stopped_primaries,fraction_per_nm: stopped primaries
per depth bin, and that count per incident ion per nm. The last row’s upper
edge is inf (overflow) and its density is empty.
lateral_profile.csv
quantity,lo_nm,hi_nm,count,per_ion_per_nm: stopped primaries by y, z and
radial distance (quantity is y, z or radial), for the grid set by
lateral_bin_nm and lateral_bins. Each quantity ends with an underflow
row (lo_nm = -inf) and an overflow row (hi_nm = inf) with empty density.
damage_profile.csv
depth_lo_nm,depth_hi_nm,vacancies,interstitials,replacements, then the same
three per incident ion per nm: the cascade defect counts by depth, on the
depth grid. vacancies is displacements minus replacements. The last row,
with depth_hi_nm = inf, holds everything deeper than the grid (empty
densities). The totals equal results.damage.cascade.
escape_spectra.csv
species_z,symbol,beam,face,spectrum,lo,hi,count,per_ion_per_unit: the energy
(spectrum = energy_ev, lo/hi in eV) and polar-angle (polar_deg, degrees
from the outward normal) spectra of every species leaving each face (front
or back). Each spectrum ends with -inf and inf rows for entries outside
the grid, with empty densities. The density is per incident ion per eV or per
degree.
voxel_tally.csv and voxel_tally.vtk
Written only with a [tally.voxel] table (and removed from a reused output
directory when the table is absent); summary.json lists them under files.
They are the library’s VoxelReport exports (format version 2, see
Voxel tallies): per occupied voxel, the
beam atoms implanted, the aggregate displacements, the energy deposited
by channel, and for every species index of the run (0 is the beam, then the
target elements in order of first appearance) vacancies_<i>_<symbol>,
interstitials_<i>_<symbol> and replacements_<i>_<symbol>. The header
# species = 0:B,1:Si,2:O names the species. Vacancies are placed at the
displacement site and interstitials where the displaced atom stopped; the
definitions and the cutoff dependence of replacements are those of
Displacement damage. Summed over
all voxels, the header’s outside sums and all species they equal the
results.damage.cascade totals exactly.
damage_export.csv
Written with a [tally.voxel] table, removed from a reused output directory
without it, and listed under files in summary.json. The same voxel map as
voxel_tally.csv under the versioned exchange header (engine, version, seed,
SHA-256 of the input echo, units); see
Damage export format.
ions.csv
index,fate,x_nm,y_nm,z_nm,energy_ev,dir_x,dir_y,dir_z,layer: the final
state of every primary, by history index. fate is stopped,
backscattered or transmitted; for escaped primaries the position is on the
face and the energy and direction are outside the target. x is depth.
Dynamic runs ([dynamic])
A run with a [dynamic] table writes dynamic_summary.json,
dynamic_steps.csv and dynamic_composition.csv instead of the static files.
dynamic_summary.json (format lindhard-dynamic-summary): the echoed
input (with dynamic), physics-model list models, the species,
totals (ions, steps, rejected attempts, fluence, slab count and thickness
before and after, yields per ion), files and the timing run object.
dynamic_steps.csv: one row per accepted step; step 0 is the initial target.
step, first_index (global index of the step’s first ion), ions,
ions_done, fluence_cm2 (delivered so far), attempts (more than 1 if the
adaptive bound rejected the step), max_change, clamped, removed_slabs
(slabs that emptied), n_slabs, surface_nm (cumulative surface recession
in nm; always 0 with erosion = false), thickness_nm (total of the finite
slabs), then cumulative counts since the start: cum_backscattered,
cum_transmitted, cum_stopped_in_target, cum_stopped_in_substrate, cum_sputtered, sputter_yield (atoms per ion so
far), cum_recoils_transmitted and cum_sputtered_<Sym> per element.
dynamic_composition.csv: the slab profile after every step (step 0 is the
initial target), one row per step and slab: step, slab (0 is the front),
front_nm, back_nm, thickness_nm, then per element
atoms_per_cm2_<Sym> and fraction_<Sym> (atom fraction). Slabs that
emptied are gone from later steps.
Electron runs: electron_summary.json and electron_*.csv
An electron run writes these instead of the ion files.
electron_summary.json (format {"name": "lindhard-electron-summary", "version": 1}):
| Key | Content |
|---|---|
software | As for an ion run |
input | The input as run: defaults filled in (including tables.max_energy_ev, tally.escape_energy_max_ev and the secondary and barrier options), CLI overrides applied, run.threads removed. Deserializes to lindhard::input::electron::ElectronInput |
physics.models | Every model in use: role, name, citation (transport loop, elastic model and potential, corrections, inelastic model, secondaries, barrier, phonon and polaron channels, SE/BSE split) |
physics.transport | The engine’s RunMetadata: cutoff and its reference, escape rule, event cap, secondary and boundary models, seed, histories, chunk size, the primary, and per layer its extent (m), the model and provenance strings of both tables, the band parameters, phonon and polaron channels with their provenance |
physics.target | Each layer: extent (nm), atom density and the resolved material |
physics.materials | Each material: the ELF file (path, resolved_path, sha256, its material and provenance, energy range and point count), band, phonon, polaron, and for elastic_table and inelastic_table their model, material, provenance, cache format_version, energy range and grid sizes, source ("built" or "cache") and cache (null without --table-cache, else the table file’s path, sha256 and key_sha256) |
results | The ElectronReport (lindhard::tally::ElectronReport), lengths in m and energies in eV, summed over all histories unless named per primary: histories, metadata (split and its source, cutoff, stopping thresholds, tally settings), fates of the primaries, event_caps (see below), budget (the energy balance and its relative_imbalance; deposits are measured from the band bottom, so with secondaries in a layer with a Fermi energy deposited_ev includes the Fermi-sea energy of liberated conduction electrons and can exceed the energy imparted, which is incident_ev - escaped_ev = deposited_ev + trapped_ev + barrier_ev - fermi_sea_ev - phonon_absorbed_ev), yields (backscatter_eta, secondary_delta, total_sigma, transmitted), front and back (counts, energies, slow and fast classes), deposition (per_layer_ev; for each grid its binning, inside_ev and outside_ev), generation_volume, stopping_points (all electrons that fell below the stopping threshold, and under primaries the primaries alone: the penetration depth of stopped primaries), table_coverage (see below). The histograms and grid cells are in the CSV files, not here |
results.dose | Only with tally.dose: layers, one entry per layer with index, name, thickness_m, areal_mass_kg_m2, energy_ev and dose_gy_m2 (each {mean, std_err} per primary; the dose is per unit incident fluence in Gy m², null with the thickness and areal mass for a semi-infinite layer), and transmission, four entries per interface (forward and back, primary and secondary) with interface, from_layer, to_layer, secondary, count and energy_ev ({mean, std_err} per primary) and energy_fraction (of the total incident energy). The per-bin depth profile and the spectra are in the CSV files, so results.dose.depth and each entry’s spectrum are omitted |
results.deposition.exposure | Only with tally.exposure (else null): lambda, beam and per volume the volume, mean_ev, second_moment_ev2 with standard errors, zero_fraction, the histogram sums (histories, zero_count, bin_width_ev, overflow_count, sum_x to sum_x4; the per-bin counts are in electron_exposure_histogram.csv) and compound (mean_ev, variance_ev2 with errors, p_zero, k_max, truncated_mass, distribution with quantiles, below and above, each value a low / central / high bracket). electron_exposure_histogram.csv: volume,bin,lo_ev,hi_ev,count,fraction, bin 0 being X = 0 |
results.psf | Only with tally.psf: the profile totals (histories, depth_lo_m, depth_hi_m, bins, total_ev and total_std_err_ev of the slab, beyond_ev outside r_max_nm with its error) and per fit model, source, normalization, parameter_names, values, std_errors, reduced_chi2, dof and converged. A fit that fails (for example on an empty profile) is listed in fit_errors with the error text and does not stop the run. With tally.psf the per-bin profile is not repeated in the summary: the results.deposition.psf key is omitted. Without tally.psf that key is present and null. The per-bin profile (bin edges and centre, area, energy and its error, areal density and its error, and the fitted energy of the bin for each fit that succeeded) is in electron_psf_profile.csv; the fitted parameters and the chi2, dof, reduced_chi2, converged and iterations of each fit are in electron_psf_parameters.csv. Residuals are not written; the residual of a bin is energy_ev - model_<model>_ev |
files | Names of the CSV files (null if not written) |
run | threads, table_build_s, transport_s, histories_per_s |
results.damage (electron runs; an ion run’s results.damage is the NRT
block above) splits the deposited energy into ionisation (electronic
excitation, the source of electron-hole pairs) and lattice heating, and
tests the beam against the displacement threshold. beam_energy_ev is the
beam energy of the input and residual_channel is "ionisation". Per entry of
layers: layer, material, ionisation_ev and lattice_ev (summed over
histories) and the same _per_primary; with an e_pair for the layer’s
material also e_pair_ev, e_pair_source and pairs_per_primary = ionisation_ev_per_primary / e_pair_ev (the keys are absent without e_pair;
the top-level pairs_per_primary, the sum over layers, is present only when
every layer that has ionisation energy has an e_pair); and knock_on, one
object per element: element, z, t_max_ev = 2E(E + 2mc²)/(Mc²) (relativistic,
free atom at rest, head-on, M the atomic weight times the atomic mass unit),
e_d_ev, can_displace (T_max >= E_d) and threshold_beam_ev (the beam
energy at which T_max = E_d). An element without E_d shows "not set" in
those three fields. E_d is the material’s value ([materials] e_d_ev, or
the element default where the element table has one). It is a threshold test
only: no displacement cross section or dpa. For a step-barrier run E is the
vacuum energy of the beam.
Conventions of the split (lindhard::tally::electron, “The ionisation and
lattice channels”). Ionisation is the energy left in the solid at inelastic
events plus the residual energy of electrons that fell below the cutoff
(budget.residual_ev, the cutoff-dependent term; placed here by convention).
Lattice is LO-phonon emission plus polaron traps (budget.phonon_emitted_ev + budget.polaron_ev; both are zero without the insulator channels). Ionisation
plus lattice equals deposited_ev in the budget, per layer
(deposition.per_layer_ionisation_ev, per_layer_lattice_ev) and per cell, to
floating-point rounding, and like all deposits includes Fermi-sea energy with
secondaries. Elastic nuclear recoil energy is not tallied by the electron
engine and is in neither channel. budget.ionisation_ev and budget.lattice_ev
are the totals.
results.table_coverage is a numerical diagnostic: one entry per layer
(layer), and for its elastic and inelastic table the grid bounds
energy_min_ev and energy_max_ev and the counts below (E < energy_min_ev: the first row’s rate and distribution were used), within
(both bounds included: interpolated, or a row read exactly) and above (E > energy_max_ev: the last row’s were used), over primaries and secondaries.
They count rate evaluations, not collisions: the transport evaluates both
tables of the electron’s layer once before every free flight, including
flights cut short at a layer face, the flight after a face reflection and
flights with zero total rate, so the totals exceed the number of elastic and
inelastic events, and a layer’s elastic and inelastic totals are equal. They
are not fractions of path length or of deposited energy either. Nonzero
below or above counts say that part of the transport used the constant
continuation of a table beyond its grid ([electron.tables]); they do not say
how much that changed the result. Summaries written before the key existed
lack it.
results.event_caps is another numerical diagnostic, of the collision cap
([electron.transport] max_events): secondary_tracks is the number of
secondary electrons the cap cut off (one per capped track) and
affected_histories the number of primary histories in which the primary or
at least one secondary was cut off (each history counted once, however many
of its electrons were capped). Both are counts, not energies; the energy the
capped electrons still carried is budget.event_cap_ev. The primaries’ own
caps stay in fates.event_capped, so fates.event_capped = 0 alone does not
show that the secondary cascades ran to completion: check
event_caps.affected_histories. A capped track is a truncated one, not a
physical fate. Summaries written before the key existed read back with both
counts zero.
electron_escape_spectra.csv: face,spectrum,class,lo,hi,count,per_primary_per_unit.
For each face (front, back): the energy spectrum of all escaping electrons
(spectrum = energy_ev, class = all, eV) and the polar-angle spectrum of
each class (polar_deg, slow or fast, degrees from the outward normal).
Each spectrum ends with -inf and inf rows for entries outside the grid,
with empty densities. The density is per primary per eV or per degree.
electron_deposition_cylindrical.csv (with tally.cylindrical):
ir,ix,r_lo_nm,r_hi_nm,depth_lo_nm,depth_hi_nm,energy_ev,ev_per_primary_per_nm3,ionisation_ev,lattice_ev,
one row per cell. electron_deposition_cartesian.csv (with
tally.cartesian): ix,iy,iz,x_lo_nm,x_hi_nm,y_lo_nm,y_hi_nm,z_lo_nm,z_hi_nm,energy_ev,ev_per_primary_per_nm3,ionisation_ev,lattice_ev.
ionisation_ev + lattice_ev = energy_ev per cell (the two columns are new and
last, in eV summed over histories).
Energy deposited outside a grid is outside_ev in the summary (with
outside_ionisation_ev and outside_lattice_ev). Like budget.deposited_ev, the cell energies are
measured from the band bottom and, with secondaries, include Fermi-sea energy
the beam did not supply.
Charge (results.charge, and with a grid electron_charge_cylindrical.csv
/ electron_charge_cartesian.csv). Electron number, not energy, counted as
integers per incident electron:
- every electron that ends inside the target counts
+1(stopped) where it ends, whatever the fate except escape: below the stopping threshold, trapped without an interaction, absorbed at the back face, polaron-trapped, or cut off by the collision cap; - every liberated electron counts
-1(created, the hole it leaves) at the collision, followed or not. One below the stopping threshold is not followed: it is dropped where it was made and counted+1there, so the two cancel. The stopping threshold followselectron.transport.cutoff_evandcutoff_reference, so the cutoff decides how much of the creation and stopping is attributed to followed secondaries and where; - an electron leaving through a face carries its charge out
(
escaped_front,escaped_back, by generation).
primary is the incident electron, secondary any electron the secondary
model created. A created_by_* count is attributed to the generation of the
electron whose collision made the hole. The balance is exact:
incident + created = stopped + escaped, results.charge.imbalance is 0 on
every run, and the net electron number left in the target, stopped - created,
equals incident - escaped. results.charge.per_layer has the counts and the
squared per-history net charge for each layer; with a grid, results.charge.cylindrical
and .cartesian keep the grid and the totals inside_stopped,
inside_created, and cells.outside for positions outside it.
The CSV columns after the cell edges (same cells and order as the deposition
files) are stopped_primary,stopped_secondary,created_by_primary,created_by_secondary,net,net_per_primary,net_per_primary_se.
net_per_primary is net per incident electron (an excess of electrons is
positive); its standard error uses the net charge each history left in the
cell, so correlated stops in one cell are not treated as independent. Summing
the cylindrical rows over ir gives the depth profile. Counts are integers, so
the files are identical on any thread count. Summaries written before the key
existed read back with an empty charge report.
electron_psf_profile.csv (with tally.psf): one row per radial bin (the
central disc first), r_lo_m,r_hi_m,r_center_m,area_m2,energy_ev,std_err_ev,density_ev_per_m2,density_std_err_ev_per_m2,
then a model_<model>_ev column (the fitted energy of the bin) for each
fit that succeeded. electron_psf_parameters.csv: model,parameter,value,std_error,
the fitted parameters (lengths in m, energies in eV), then chi2, dof and
reduced_chi2 rows per fit, then converged (1 if the iterations
stopped on a converged step, 0 if the iteration budget ran out) and
iterations (the integer count used). These metadata rows, like the
chi2, dof and reduced_chi2 rows, have an empty std_error cell and are
not fitted parameters, so a consumer that reads every row as a parameter must
skip them. Parameters from a fit with converged 0 need inspection of the fit
diagnostics before use.
electron_depth_dose.csv (with tally.dose.depth_bins_per_layer): one row per
depth bin, bin,layer,x_lo_nm,x_hi_nm,energy_ev,energy_ev_std_err,ev_per_nm,ev_per_nm_std_err,dose_gy_m2,dose_gy_m2_std_err,
per primary; the dose cells are empty in a semi-infinite layer.
electron_interface_transmission.csv (with tally.dose.transmission and at
least two layers): one row per interface, direction, class (primary or
secondary) and spectrum bin, interface,from_layer,to_layer,class,count_per_primary,count_std_err,energy_ev_per_primary,energy_std_err,energy_fraction,bin,e_lo_ev,e_hi_ev,crossings,
the totals repeated on each row of a spectrum.
electron_tables.csv: material,energy_ev,elastic_inverse_mfp_per_nm,inelastic_inverse_mfp_per_nm,inelastic_mean_loss_ev,inelastic_stopping_ev_per_nm,
the tables the run used, per material and inelastic-table energy (the
stopping power is λ⁻¹ ⟨W⟩ of the stored loss distribution). The elastic
column is empty at the rows the rate refinement adds to the inelastic table
of a material with a band (#339), which the elastic table does not have.
Cross-section table cache (--table-cache)
Building the elastic and inelastic tables is the slow part of a short
electron run (tens of seconds with penn-single-pole, far longer with
penn-full; see docs/validation.md, “Electron oracles”). With
--table-cache DIR, lindhard run looks each table up in DIR (created if
missing) and builds and stores only the ones it does not find, so a series of
runs that differ only in seed, history count, tallies or transport settings
builds its tables once.
Each entry is three files, named by the SHA-256 of a key document:
<kind>-<sha256>.toml, the table in the versioned cache form of
lindhard::electron::data::CrossSectionTable, read back with that type’s
loader; <kind>-<sha256>.sha256, the hex SHA-256 of the table file’s bytes;
and <kind>-<sha256>.key.json, the key itself. The key spells out every input
the table depends on:
- the key schema version and the table cache
format_version; - the build: crate version,
git describe --always --dirty, and the SHA-256 of the runninglindhardexecutable, so any rebuild that changes the code (an uncommitted edit included) misses, and a rebuilt binary never reuses an older binary’s table; - the table kind and the exact energy grid (
electron.tables, after the defaults are filled in); - the material’s name, composition and density;
- elastic: the model (
electron.elastic.model), the potential, the exchange and correlation-polarization corrections with all their inputs, the starting probability grid and the refinement tolerance; - inelastic: the model (
electron.inelastic.model), its Fermi energy, the SHA-256 and provenance of the optical ELF file, the material’s band parameters (they set the table’s axis and Fermi energy for a material with a band) and, withmermin-melf, the material’s resolved Mermin fit options (mermin_fit; no table and a table of the defaults give the same key), and the rate refinement of a material with a band (rate_refinement: its window, tolerance and row bound;nullwithout a band).
Every f64 is written in shortest round-trip form, so a change in the last
bit of any number is a different key. A lookup must find the stored key
equal, byte for byte, to the run’s own (a mismatch under the same hash means
the file was edited, and is an error naming the differing field). The table
file’s bytes must then hash to the SHA-256 stored beside it: a table edited or
corrupted anywhere, even in a single cross-section or probability value that
still parses, is refused with an error naming the file, before it is parsed,
and a missing .sha256 file is an error too. The table is then loaded and
validated by the library, and its axis and energy grid are checked against the
run: an elastic table must have exactly the run’s grid, an inelastic table
must contain it in order (it may hold the refinement’s rows as well). A file found under the run’s key that fails any of these checks is an
error, never a silent rebuild; remove the entry’s files to rebuild it. A table
of another cache format_version can never be found, since the version is
part of the key. Writes go to a temporary file renamed into place, the table
first, then its hash, then its key, so a key on disk always has its table and
hash beside it.
Concurrent runs. Several runs may share one cache directory at once
(threads of one process or separate lindhard processes, such as a seed
sweep started in parallel on a cold cache). Each entry has a lock file,
<kind>-<sha256>.lock, and a run holds the entry’s lock while it looks the
entry up, builds the table and stores it. So for one key:
- one run builds the table; every other run waits for it, then finds the
stored entry (the lookup is made only once the lock is held) and reads it.
A run that has to wait for another process prints
note: waiting for another process to finish the table cache entry <lock file>on standard error. A run whose build fails stores nothing, and the next waiting run builds in turn; - runs that need different tables (another material, kind or key) take different locks and do not wait for each other;
- there is no timeout: a run waits as long as the holder is alive, since a build can take minutes. A holder that exits or is killed releases its lock (the operating system drops it with the process), so it never blocks the next run. A holder that is stopped but alive (for example suspended with Ctrl-Z) does block it. A waiting run can be interrupted safely;
- a holder killed while storing can leave temporary files
(
.<file>.tmp.<pid>.<n>) and a table without its key. The key is the commit marker, so that entry was never stored: the next run to take the lock removes the temporary files and rebuilds it. A stored entry that fails any check above is still an error, never a rebuild.
Temporary files are created under names unique to the process and the write, and removed if the write fails. The lock files are empty and stay in the directory; removing them while no run uses the cache is harmless.
Read-only caches. A cache the run can read but not write (a read-only
directory or file system, or one whose lock files belong to another user) is
still used for what it holds. When an entry’s lock file cannot be created or
opened for writing because permission is denied or the file system is
read-only, the run looks the entry up without the lock. An entry that is
there is read after every check above, the SHA-256 of the table’s bytes
included; the key is the commit marker, so a key that can be read always has
its complete table beside it. An entry that is not there is an error naming
the directory (... has no entry for this run's <kind> table of <material> and is not writable ...): the run does not build a table it could not store.
Make the directory writable, use another directory, or run without
--table-cache. If only the directory is read-only and the entry’s lock file
exists and is writable by the run, the lock is taken as usual; the outcome
is the same (the run checks that it can create a file in the directory before
it builds).
The lock is an operating-system advisory lock on the lock file (flock on
Unix, LockFileEx on Windows, through Rust’s std::fs::File::lock), and
inside one process the run also excludes its own threads without relying on
it. This is supported on a local file system. On a network file system
(NFS, SMB) whether locks are honoured between machines depends on the server
and mount options, and is not guaranteed; share a cache between machines at
your own risk, or give each machine its own. If the file system refuses the
lock altogether the run fails with an error naming the lock file (use a local
directory, or run without --table-cache). Older lindhard builds take no
lock, so do not point one at a directory a current run is writing.
A cached table is the built one bit for bit (the TOML cache form round-trips
every f64), so outputs do not depend on whether the tables were built or
read, nor on the thread count. Only physics.materials.*_table.source and
.cache, and the timings in run, differ. lindhard-cli/tests/examples.rs
checks a building run, a storing run and reading runs on 1 and 8 threads
against each other; lindhard-cli/tests/table_cache_concurrency.rs checks
the concurrency contract above (threads and processes on one cold entry
build once, different entries build at the same time, a killed holder does
not block, a read-only cache serves its entries and refuses a miss). Remove the directory to reclaim space; nothing else prunes it.
Exposure: lindhard exposure
Turns a fitted point-spread function into the dose of a list of shots (a
pattern exposure). It does not transport anything: the dose is the analytic
sum of Gaussians described in the module documentation of
lindhard::exposure, so it is fast and exact for point shots. --direct
adds an independent transport run of the same shots (see “Direct mode” below).
lindhard exposure --shots shots.csv \
--psf-parameters out/electron_psf_parameters.csv \
--blur-m 1e-8 --x0-m -5e-7 --y0-m -5e-7 --dx-m 1e-8 --dy-m 1e-8 \
--nx 100 --ny 100 --threshold-fraction 0.5 -o exposure/
PSF. Either a parameters file written by an electron run with
tally.psf (--psf-parameters; --model double_gaussian|triple_gaussian
picks a fit, default the triple fit if present), or typed in: --total-ev,
--alpha-m, --beta-m, --eta and, for the triple form, --gamma-m and
--nu. The dose is only as good as the fit; check its reduced chi-squared in
electron_summary.json first. The film is assumed laterally uniform.
Shot list (--shots): CSV with a header; required columns x_m, y_m,
weight (number of primaries, or a relative dwell); optional blur_m (extra
beam width of this shot), shape (point, disc, rect), size_m (disc
radius, or rectangle width) and size2_m (rectangle height, default
size_m). Blank lines and # lines are skipped. Lengths are in metres.
Beam blur (--blur-m, b in exp(-r^2/b^2)) adds in quadrature to every
width of the PSF (w^2 -> w^2 + b^2, exact for Gaussians; derivation in the
module docs), together with each shot’s blur_m. Shaped spots are a fixed
--subsamples x --subsamples midpoint sub-sampling of the spot (default
16); the error is stated in the module docs.
Grid: --x0-m --y0-m --dx-m --dy-m --nx --ny; the dose is evaluated at
the grid points. --threads never changes the results (each grid point sums
the shots in list order).
Contours. With --threshold (eV/m^2) or --threshold-fraction (of the
largest grid dose), iso-dose contours are found by marching squares. A grid
point is inside when its dose is at or above the threshold; each contour has
the inside on its left, so outer boundaries have positive area and holes
negative. A contour that leaves the grid is open and has no area or centroid
(enlarge the grid). The area is the polygon area, so it differs from the
smooth contour’s by O(h^2) in the grid spacing.
Files written to the output directory:
| File | Contents |
|---|---|
exposure_dose.csv | x_m,y_m,dose_ev_per_m2, one row per grid point, rows in grid order (x fastest) |
exposure_contours.csv | with a threshold: one row per contour vertex, with the contour’s index, closed, area and centroid |
exposure_features.csv | with a threshold: one row per contour: contour,closed,vertices,signed_area_m2,centroid_x_m,centroid_y_m |
exposure_summary.json | shot count, the PSF, blur, grid, largest dose, the large-area dose per unit weight density split into the forward (alpha) part and the long-range (backscatter) part, the contour count, and (with --direct) the direct block (settings and the number of flagged points; null otherwise) |
exposure_direct.csv | with --direct: x_m,y_m,dose_ev_per_m2,std_err_ev_per_m2 from transport, rows in grid order |
exposure_comparison.csv | with --direct: x_m,y_m,convolution_ev_per_m2,direct_ev_per_m2,combined_std_err_ev_per_m2,deviation_sigmas,flagged |
The large-area dose of a uniform exposure of w primaries per m^2 is w E
(the PSF integrates to E), of which the forward term gives the share
1 / (1 + eta [+ nu]).
Direct mode: --direct
The convolution above assumes a laterally uniform film. --direct target.toml
also evaluates the same shot list by electron transport, as an independent
answer at the same grid points. The model, frame and error are in the module
documentation of lindhard::exposure (“Direct Monte Carlo exposure”); in
short:
target.tomlis an electron input ([electron], see “Electron runs”). Its layers, cross-section tables and beam energy are used; where the primaries land (and the spot) comes from the shot list, so the input’s own beam profile, landing point and divergence are not used. The target is that input’s laterally uniform layer stack: a laterally varying (voxel) target is available through the library and not yet through TOML.- Each shot runs
--direct-historiesprimaries (split over--direct-replicasindependent replicas, at least 2) from the shot position with its spot shape; a point shot with blurbis a Gaussian beamexp(-r^2/b^2). A shaped spot with a nonzero blur is refused. Exposure coordinates are offsets from the target’s entry point,xandybeing the geometry’syandz. - The energy deposited in the film slab
--slab-lo-m(default 0) to--slab-hi-m(required) is tallied per grid cell and divided by the cell area; the point is the cell centre. The result is the mean over the replicas with its standard error (Monte Carlo noise only), reported next to the convolution averaged over the same cell. - A point is flagged in
exposure_comparison.csvwhen the doses differ by more than--tolerance-sigmas(default 5) combined standard errors,sqrt(se_direct^2 + se_conv^2), wherese_convis--conv-rel-errtimes the convolution dose (default 0: the PSF’s own uncertainty is not propagated). Points where both doses are below--floor-fraction(default 0.01) of the largest dose are not judged, as the run cannot resolve them. - Seeds follow the run’s thread-count-independent scheme (one SplitMix64 mix
per replica and shot), so the files are identical for any
--threads.
The PSF flags (--psf-parameters or the typed-in values) are still needed for
the comparison.
Reusing an output directory
--out may name an existing directory; the run overwrites the files it
writes and creates the directory if needed. The CLI also owns the reserved
optional file names of the run’s mode. After a successful run, an optional
file the run did not produce is removed if present: ions.csv (without
tally.per_ion), and electron_deposition_cartesian.csv or
electron_deposition_cylindrical.csv (without the matching deposition grid),
and electron_psf_profile.csv and electron_psf_parameters.csv (without
tally.psf), electron_exposure_histogram.csv (without tally.exposure),
damage_export.csv (without electron.tally.voxel), and
electron_depth_dose.csv and electron_interface_transmission.csv (without the
matching tally.dose part).
A missing file is not an error; a failed removal is, and names the path. The
summary is written last (also for dynamic runs, after both CSVs, so a failed
CSV write leaves no new summary) and lists only files that exist.
Once output starts, the run first removes the previous summary of its own mode
(summary.json, electron_summary.json or dynamic_summary.json) and only
then replaces CSVs. The new summary is written to a freshly created temporary
sibling (created exclusively, so an existing file or symlink of the same name is
never touched; another name is used) and
renamed into place after every other write succeeds. So, after a failed run,
the absence of the mode’s summary means the output set is incomplete (it may
hold a mix of old and new CSVs); a summary that is present describes a
completed run. If the previous summary cannot be removed, the run fails naming
its path before any CSV changes. A failure before output starts (simulation or
serialization) leaves existing outputs as they were. This is not a
transaction: the CSVs are replaced in place, and concurrent runs into one
directory are not supported. Other files in the
directory are never touched, and no cleanup happens between ion, electron and
dynamic runs. Do not keep your own data under a reserved name.
Reproducibility
Everything in summary.json except the trailing run object, and every CSV
file, is a function of the input and the binary only: byte-identical for the
same input and seed at any thread count (for a dynamic run: the same three
files, with run the only thread-dependent part of dynamic_summary.json).
For an electron run the same holds for electron_summary.json (apart from
run) and every electron_*.csv: the tables are built bit-identically on any
thread count, and histories run in chunks of a fixed size (16, recorded as
physics.transport.chunk_size) merged in chunk order.
lindhard-cli/tests/examples.rs checks this on 1 and 4 threads (1, 2 and 8
for the dynamic example; 1 and 4 for the electron example, and with
--table-cache 1 and 8). Floats are
written in shortest round-trip form.
Compatibility and extension
Consumers must ignore keys they do not know. New results are added as new keys, never by changing existing ones:
- New tallies become new objects under
results, with their settings as new keys under[tally]and their profiles as new CSV files listed underfiles.results.range,results.damage,results.sputteringandresults.escapeswere added this way, without a version bump. physics.crystal(the[[crystal]]metadata) was added this way, without a version bump; an amorphous run does not carry it.physics.beam_divergence(the[beam.divergence]metadata) likewise.- New model choices become new values of the existing
[physics]keys, or new keys with defaults, so existing inputs keep their meaning. format.versionis bumped only when an existing key is removed or changes meaning.
The electron schema and its output follow the same rules, with
lindhard-electron-summary counting its versions separately:
- A new library model becomes a new value of an existing key
(
electron.inelastic.model,electron.elastic.potential,electron.transport.secondaries,electron.transport.boundary, abandkind, aphononpreset) and a newphysics.modelsentry; existing values keep their meaning and defaults never change. - New per-material data (for example a precomputed cross-section cache)
becomes a new optional key of
[electron.materials.<name>]that names a file. It must be read with thelindhard::electron::dataloader of its type, so data without a provenance is refused, and recorded underphysics.materialswith its path, SHA-256 and provenance. - The cross-section table cache is the one exception to the rule above, by
decision (#168): it is a command-line flag (
--table-cache DIR), not an input key, because it never changes a result (as with--threads, the input and its echo stay the same whether or not tables are reused), and a content-addressed directory keyed on every input of the build cannot name a stale file the way a hand-written path can. Its tables are still read with thelindhard::electron::dataloader and echoed underphysics.materialswith their path, SHA-256 and provenance. - A new tally becomes a key under
[electron.tally], an object underresultsand, for profiles, a new CSV file listed underfiles. - Every table keeps
deny_unknown_fields, and every default is echoed, so an input written today still means the same thing.