Architecture#
How the code is organized, how a run goes from a project to result files,
how PYSTILT knows which work is finished, and the implementation details that
are easy to break. This page is for contributors, for anyone building on
PYSTILT, and for anyone running at scale or sharing an output directory
between projects. AGENTS.md at the repository root has the short version
and the rules a change must keep.
The design in brief#
There is no index, manifest, or registry. A project is a directory of inputs
(stilt.Project: config.yaml and receptors.csv) and an output
directory (stilt.output.Output) that config.yaml names and that
several projects can share. The simulations a project defines are its
receptors crossed with the named variants in config.yaml, which must
declare at least one.
Each variant resolves into a Variant (stilt.config) with two records
of settings (stilt.identity), and their hashes name its folders.
Variants with equal run settings share one run per receptor and differ only
in the footprint made from it. Changing a setting never overwrites a result:
it hashes to a new folder. Output Layout describes the files.
Whether a simulation is complete is decided by the files in the output
directory, by stilt.output.completed: the particles exist, and the
footprint when the variant has a grid. Output.complete,
Simulation.is_complete, and Project.status all apply it.
How PYSTILT tracks finished work explains the consequences.
Project reads, and the workers write results. Project(path) opens a
project directory: its config, its receptors, the simulations they define,
and a view of their results. Its only writes are to its inputs.
Project.init (or stilt init) writes config.yaml once and PYSTILT
never rewrites it, add_receptors appends to receptors.csv, and
add_table writes tables/<name>.parquet. It knows no scheduler or
compute root. stilt.execution.run and submit, which Project.run()
and Project.submit() call, find the receptors with missing results,
resolve the compute root, and start the workers. The workers are the only
code that writes results.
Tables are plain DataFrames, and the verbs are on Project.
project.simulations is a pandas DataFrame, one row per simulation. Select
it as in pandas (sims[sims.variant == "hrrr"]) and pass the selection to
the project: status, incomplete, particles (one long table),
footprints (one lazy dataset stacked on the hour), jacobian, or
transport_error (the selection with the estimate’s columns). A
selection is any table with receptor and variant columns (pandas,
polars, pyarrow) or a boolean mask over project.simulations, and the
methods read only those two columns. project.receptors is a plain
DataFrame too. project.receptor(id) and project.simulation(id,
variant) return values, and sim.particles and sim.footprint are
data. Output has no folder classes: its methods (path, read,
present, complete, table, the failure records, and the writers)
take the kind of result ("particles" or "footprints"), the variant,
and receptor ids.
stilt.__all__, plus the __all__ of each subpackage, is the public
surface. Everything else is internal and can change.
Module map#
src/stilt/
cli.py Typer CLI; a thin adapter over Project and execution
project.py Project: the project directory, its receptors and
simulations (DataFrames), run/submit, and a
selection's status, particles, footprints, Jacobian
check.py check_project: what would stop a project's runs,
found from its inputs before any run (Project.check,
stilt check); a transport model's check_met finds the
met's part
fetch.py fetch_project: gets the met files the unfinished
simulations need (Project.fetch, stilt fetch); a
transport model's fetch_met does the model's part
output.py Output: the output directory, a folder per kind and
settings hash; finding a variant's folder, which
receptors have results (present, complete), reading
one receptor's file (read) or many at once (table),
failure records, writing
migration.py read_stilt_r: STILT-R and X-STILT by-id output as
receptors, particle tables, and footprints;
import_stilt_r writes it into an output directory
with the settings its files record
simulation.py Simulation: a frozen value (receptor, variant, output)
that knows where its results are and whether they exist
config.py ProjectConfig: reads config.yaml, splits the flat keys
into the model's config and the footprint's, rejects
unknown keys naming the nearest setting, checks each
declared variant; resolve() makes each a Variant (its
configs, the model build, its two hashes)
identity.py settings records and their hashes: what a run and a
footprint were made with, in _settings.yaml
receptors/
models.py receptor types (frozen pydantic models: point, column,
multipoint), their times, and their ids
table.py the receptor table behind the CSV reader, writer, and
appender: receptor_rows checks it once, and
receptors_from_rows builds receptors from checked rows
validation.py the checks a receptor must pass, written once for many
points and shared by the models and the table
particles/ the particle table (a DataFrame)
table.py its columns, particle files, release heights, and the
near-field plume correction
accessor.py the .stilt pandas accessor
background.py the background at a receptor (sim.background)
transport_error.py the transport error of the enhancement, one
receptor or a selection in threads
footprint/ the footprint (a DataArray)
config.py FootprintConfig and the geometry specs
gridding.py calc_footprint, as STILT-R's (fidelity-guarded)
aggregation.py aggregate and jacobian: footprints summed onto other
geometries, with time binning and target weights
io.py footprint files and CF-1.8 NetCDF; open_footprints
targets.py the geometries footprints are summed onto (Mesh,
Zones), the overlap weights, and to_grid
accessor.py the .stilt xarray accessor
sampling.py sampling a gridded field (a flux, a mole fraction) at
points; sample_met, ARL met at particle positions
spatial.py rasters and CRS without shapely: Bounds, Grid,
horizontal_dims, is_longlat, same_crs, haversine_km
meteorology.py run_window, and the wind-error statistics
(variogram, fit_variogram)
transforms/ pre-footprint particle transforms, one module each
(averaging_kernel, pressure_weighting, lifetime); the
loader, YAML I/O, and apply_transforms in __init__.py
exceptions.py every exception class, all under StiltError
visualization.py matplotlib helpers (optional dependency)
_paths.py local paths and object-store paths behind one interface
execution/ ExecutionConfig (config.py); the runner (plans what is
missing, runs it here or submits a Slurm job array);
the worker (runs the transport model in a workdir and
writes results for one or many simulations)
observations/ the X-STILT port: product readers and SOUNDING_SCHEMA,
overpass grouping and sounding selection, slant
geometry and receptors_from_soundings, modelled_column,
plume backgrounds; arrays in, plain values out
transport/ the TransportModel and TransportConfig protocols,
ModelRun, ModelInfo, get_model and its MODELS table,
run_model and run_trajectories (__init__.py); one
subpackage per transport model, which owns its config;
stilt_r.py, the stilt-r model of imported STILT-R
runs, which runs nothing
hysplit/ HYSPLIT: HysplitConfig (config.py); MetConfig and Met,
ARL file discovery, download, and cropping through
arlmet (met.py); HysplitModel (model.py); the driver
(driver.py: write_inputs, read_particle_dat); the
CONTROL file (control.py) and SETUP.CFG namelist
(namelist.py) writers and readers; failure reasons read from its
log (failures.py); the bundled binaries (bin/, Linux
and macOS x86-64) and data tables (data/)
tests/ pytest; folders follow src/stilt (execution/,
footprint/, observations/, particles/,
transport/hysplit/); integration/ holds the end-to-end
runs, r_stilt/ the STILT-R comparisons, and fixtures/
the factories and helpers tests share
How a run goes#
A run starts from Project.run(), Project.submit(), or stilt run.
stilt.execution.run finds the receptors with missing results and either
runs them in this process (backend: local) or submits them as one Slurm
job array (backend: slurm).
For Slurm, submit writes
_slurm/<stamp>/{receptors.txt,receptors.parquet,execution.yaml,job.sh} in
the project and calls sbatch. Each task runs stilt run --receptors
receptors.parquet --task $SLURM_ARRAY_TASK_ID/N --execution execution.yaml.
The parquet file holds the submitted receptors’ checked rows, which the
task’s project takes in place of receptors.csv. submit returns the job
id at once, and run waits by polling sacct.
A task that stops with work left requeues itself (scontrol requeue) when
it got SIGUSR1, which the script asks for two minutes before the time limit
(--signal=B:USR1@120), or when it was preempted (Slurm set the job’s
PreemptTime). Slurm checks --signal times only about once a minute,
so a preempted task may never get SIGUSR1. A task stopped by scancel is
not requeued, and a task stops for good after MAX_REQUEUES requeues.
run(task=(i, n)) (stilt run --task i/n) runs one share here, whatever
the backend. The share is taken from all the receptors before the complete
ones are dropped (task_share), so tasks that start at different times
never overlap. Any scheduler that can start a command line with an index can
run a project this way.
The unit of work is a receptor. run_receptor runs the transport model
once per distinct run-settings hash, then writes the footprint of every
variant that shares those particles. A worker handles the variants of its
receptor in config order. A variant whose particles are missing runs the
model, the variants that share those particles reuse them, and their
footprints are remade if the particles were replaced in the same call. A batched model (batched = True,
with run_many) is the exception: run_receptors first runs each of its
variant groups once for all the receptors that need particles, then the
per-receptor loop does the rest. HYSPLIT is not batched.
HYSPLIT has to run in a local folder that holds its input files, the run’s
workdir. Each run’s workdir is a new path in the compute root
(_new_workdir in the worker: the receptor id, the variant, the
realization in an ensemble, and a random part, as in
202301011200_-111.85_40.77_5_hrrr_3f9a1c2e), so projects can share a
compute root and two runs never clear each other’s files. A simulation that
runs twice at once keeps its own files in each. The compute root comes from
--compute-root, the PYSTILT_COMPUTE_ROOT environment variable, or
$TMPDIR/pystilt/<project> (resolve_compute_root in the runner;
Project does not read it). It changes where PYSTILT runs and never what it
calculates, so it is not in config.yaml.
After a successful run the particle file and the log are written to the output
directory and the workdir is removed. A failed run’s workdir, or every one
with keep_workdir: true under execution: in config.yaml, is first
copied to the output’s scratch/ folder, replacing an earlier copy for the
same simulation, so CONTROL, SETUP.CFG, and MESSAGE survive the job. A run
killed outright (SIGKILL, or a node that fails) cannot clean up, and its
workdir stays in the compute root until that folder is cleaned.
Files are written through a temporary name and renamed into place, so a reader never sees a partial file, and two workers that run the same receptor by accident produce equivalent files with the last rename winning. Each of them runs in its own workdir. No lock is needed.
Slurm tasks rebuild the model from the project in another process on another node, so anything a worker needs must be in the project or the output directory, never only in memory.
The observation-driven path sits above this. A reader yields a DataFrame of
soundings, stilt.observations helpers thin and group it, and each row
becomes a receptor (receptors_from_soundings, which also gives the kernel
table, kept with project.add_table). Then add_receptors and a run go
as usual. Observation logic stays out of project.py.
How PYSTILT tracks finished work#
This section explains what happens behind stilt status and reruns. You
don’t need it to use PYSTILT, but it helps when running at scale, across
machines, or with several projects sharing one output directory.
The result files are the record#
PYSTILT keeps no separate list of which simulations have run. A simulation
is complete when the result files its variant makes exist in the output
directory (stilt.Simulation.is_complete). Today that is:
the particle file,
particles/settings=<variant>-<hash>/date=<day>/<receptor>.parquetthe footprint file in the variant’s
footprints/folder, when the variant has a grid. A footprint that no particle reached is a file with no rows, markedstilt:empty: truein its metadata, and it counts.
Each result has one expected path, so checking one simulation is a file
lookup. For many simulations (stilt status, or planning a run) PYSTILT
lists the date folders the selection falls in, once each, and reads the
answer for every receptor from that. That keeps a project of a hundred
thousand receptors quick to check, and a few simulations of it cheap.
A notebook, a Slurm task, and another project sharing the directory all see
the same files, so they agree on what’s finished without talking to each
other. Deleting a result file makes that simulation unfinished again.
A project’s simulations are its receptors (receptors.csv) run under each
of its variants (config.yaml), and project.simulations is exactly that
set, one row per simulation. Both files are in the project folder before
anything runs, so any worker on any machine can open the project from the
folder alone.
Settings name the folders#
Output Layout lists what each _settings.yaml and result
file holds.
Each variant resolves into two records of settings (stilt.identity).
Its run settings hold everything that changes the particles: the HYSPLIT
parameters, the content of the meteorology, and the transport model’s
build. Their hash names the particles/ folder. Its footprint settings
hold the footprint config, its grid, and the hash of the geometry the grid
was derived for. Hashed together with the run settings, they name the
footprints/ folder. Each folder holds a _settings.yaml with the
settings written out in full.
That is how PYSTILT knows a setting changed: the variant now hashes to a
folder that does not exist yet, so its receptors run again into it. The old
folder is untouched. It is also how two variants come to share particles:
if only their footprint fields differ, their transport settings hash the
same and they resolve to one particles/ folder.
A folder is found by reading its _settings.yaml back through the current
config classes and hashing that, rather than by comparing stored digests.
So a setting added in a later version, with a default, still matches folders
written before it existed; a default whose meaning changed does not, which
is right, because the particles would differ.
stilt status lists the folders that no variant of the project uses.
PYSTILT never deletes a folder, because another project may share the
directory; deletion is by hand.
Import layers#
The import contracts in pyproject.toml, checked by lint-imports in CI
(just imports), hold this shape. One layers contract lists every
top-level module from the CLI down to _paths and exceptions. A module
imports only the layers below it, and modules that import each other share a
layer. The contract is exhaustive, so a new top-level module fails until it
is placed. stilt.migration sits above the project config, because
import_stilt_r writes into an output directory through variants. Two
short contracts hold what a layer cannot: only stilt.transport (its
get_model) imports HYSPLIT’s package, and the project config reads no
results and runs nothing. STILT-R ran HYSPLIT, so the STILT-R reader and the
stilt-r model are the named exceptions: they read HYSPLIT’s CONTROL
and SETUP.CFG files and record HYSPLIT’s settings.
Configuration#
ProjectConfig is the root. It holds model (the transport model,
hysplit unless set), that model’s parameters and the footprint
(FootprintConfig) fields as flat defaults, mets, and variants
(overrides of the defaults).
The model’s parameters are checked by its own config class (
stilt.transport.hysplit.HysplitConfig;config.transport), reached throughstilt.transport.get_model. The top-level footprint fields areconfig.footprint.A model is a variant axis, like a met. A variant that names another
modelgives that model’s own parameters itself, and inherits the met, the footprint fields, and the parameters PYSTILT’s own code reads (the baseTransportConfig:n_hours,seed,hnf_plume,veght). A parameter goes on the base only when the core reads it;numparand everySETUP.CFGsetting are HYSPLIT’s.When it loads,
ProjectConfigchecks each declared variant against the defaults (names, met, model,realizations, grid merging) and rejects an unknown key with the nearest known one (difflib), reading no other file.from:is rejected.grid: nullmeans particles only, and footprint settings without a grid are an error.ProjectConfig.resolve(directory)(behindproject.variants) turns each variant intoVariantobjects. It validates the variant’s transport settings with its model’s config class once, keepsrealizations: Nas one ensemble variant (realizationkruns withseed + k), reads each geometry once (a relative file from the project directory) to derive the grid, and asks the transport model its version once per build.Project.initresolves before it writesconfig.yaml.Each part owns its config.
FootprintConfigis instilt.footprint.config,ExecutionConfiginstilt.execution.config, and a model’s config and met config in its package (met_config_class; HYSPLIT’sMetConfigis instilt.transport.hysplit).ProjectConfig.metsholds each met as written, andProjectConfig.met_config(name, model)checks it with the met config of the model that reads it.BoundsandGridare instilt.spatial, which needs no shapely, since more than footprints use rasters.stilt.configcomposes them, so nothing below the project’s layer importsstilt.config.Config classes do no I/O when they validate, so a
config.yamlloads offline. Reading a geometry or asking a model its build happens inProjectConfig.resolve, and reading which weather a met holds happens when a variant’s hash is first needed (Variant.met_settings).A transport config’s fields that change no result are listed in its
UNRECORDEDclass variable (HYSPLIT’s:exe_diranddata_dir) and left out of the settings records. A met’s record is built, not dumped: its config’ssettings(), which for HYSPLIT is the source id in the ARL headers (from the first file underdirectory, or the archive’ssourcefordownload) and the crop (MetConfig.crop(), fromsubgrid_boundsandsubgrid_levels). Where the files are and how they are named are never recorded, so moving them keeps every result, and two products named alike hash apart. The grid and levels are not in the record either, since a product’s header changes over its archive’s life; the met files a run read are recorded in its particle file.Lookup reads each stored
_settings.yamlback through the current config classes (stilt.identity) and re-hashes, so a field added later with a default still matches. Stored records ignore keys this version does not know.Every field is a plain pydantic
Field(default, description=...), and the public config stays flat (ProjectConfig(numpar=..., seed=...)).CONTRIBUTING.mdexplains how a HYSPLIT field is routed toSETUP.CFG,CONTROL,WINDERR, orZIERR.ExecutionConfig(execution:inconfig.yaml) says where receptors run and with what Slurm resources. It forbids unknown keys; othersbatchoptions go under itsslurm:mapping.Particle transforms are declared as a default or per variant (
transforms: [{kind: ...}]);kindmay also be the import path of a user class.
Implementation notes#
Results are plain data#
sim.particles is a pandas DataFrame and sim.footprint an xarray
DataArray. PYSTILT’s methods on them live in .stilt accessors. A
footprint carries its receptor id as a scalar coordinate (kept through
arithmetic) and the full receptor and settings as attributes
(stilt_receptor, stilt_footprint), which the accessor reads. Every
result file records what it needs to be read alone (read_particles,
read_footprint).
sim.particles and sim.footprint are cached_property attributes of
a frozen value. A missing file raises FileNotFoundError, which is never
cached, so the next read tries again, rather than caching a None that
would hide the result when it lands. None is only for final states (no
grid, an empty footprint). Use has_particles and has_footprint to
test presence; never write to a simulation’s __dict__.
project.simulations is built once, and each access returns a copy, so a
user’s new column stays theirs. add_receptors drops the cache. A
config.yaml edited by hand needs a new Project(path).
Bulk methods and listings#
A method of Project that takes a selection works from folder listings and
receptor ids. It never builds a Simulation per row, which costs
milliseconds each and adds up to many minutes on tens of thousands of
footprints. It opens a file per row only when the file’s contents are the
answer (footprints, jacobian, transport_error), and builds receptors together
(Project._receptors) when it needs them.
Output.directory is a Path on a filesystem and a universal path
(UPath) for a URL (stilt._paths.location). Code that touches the
output checks isinstance(path, Path) for the local fast path
(os.scandir, a temporary file and rename, pyarrow on the path) and
otherwise uses the store’s fsspec filesystem (path.fs), putting a file
directly. Parquet is read through stilt._paths.readable.
Empty footprints#
When no particle reaches the grid, calc_footprint raises
EmptyFootprint and the worker’s make_footprint writes a footprint file
with no rows, marked stilt:empty in its metadata. sim.is_complete and
sim.has_footprint are true, sim.footprint is None, and
project.footprints() gives it no row and lists it in attrs["empty"].
PYSTILT never synthesizes a zero-valued footprint for it: a zero enhancement
would flow into a comparison or an inversion unnoticed.
Failure records#
When a step fails, the worker writes <receptor id>.failure.yaml in the
logs of the folder whose result failed (logs/settings=<key>/date=.../:
the particles’ folder for the run, the footprint’s for one variant), and
removes it when that result is written. Completion never reads it: a
simulation is done by its result files alone. sim.failure, the
state, step, reason, and message columns of status(), and
stilt status read it, only for simulations missing a result.
Met that ran out#
Met that ran out is a failure, and a domain exit is not. The transport model
tells them apart, not the core. HYSPLIT’s driver reads its WARNING file
after each run and fails no more meteorology (and the other metpos
time warnings in FAILURE_PHRASES) as MET_COVERAGE. Particles that all
left the met’s domain or its crop are a complete run, so a particle file may
stop before n_hours.
HYSPLIT’s warning is not reliable. It writes only its first metpos
warning per run, so a domain exit can hide met that ran out later. With
krand: 4, about one run in five that reaches a met file cut short drops
every particle there with no warning at all and reports Complete Hysplit.
So Met.files_for fails before running when a file is missing, naming the
hours, and Met.check fails when a file is damaged or the files lack a time
step the run needs (arlmet’s File.check() and File.times, read once
per file in a process).
After the run, the driver’s check_met_stop catches a silent stop that the
check before it missed, whatever its cause. A run that stopped before
n_hours fails as MET_COVERAGE when any particle at its last time step
is more than MET_STOP_CELLS (5) grid cells from every side of the first
met file’s grid. The top is not an edge: HYSPLIT’s particle mode holds a
particle that rises above it at the top (advpnt.f), so the top cannot end
a run. HYSPLIT limits a step to tratio (0.75) of a cell, so the limit is in
cells, not distance. On HRRR (3 km), NAM (12 km) and NARR (32 km) grids and
their crops, particles that left ended at most 2.0 cells in; on domains a
hundred cells across or more, the furthest-inside particle of runs the met
stopped was 34 cells in or more. On crops 45 to 120 cells across, 3 to 9 % of
the met stops tested passed as domain exits, none in a run’s first 6 hours,
with at most one particle in six still in the air and under 3 % of the
footprint lost. HYSPLIT writes off spatial domain of all grids after a
domain exit and after a silent met stop, so its log cannot tell them apart. The check runs only when outdt is 0: with fewer rows, a
particle’s last row can be far inside the domain though it left. It takes a
particle table and a grid, nothing of HYSPLIT’s, so the core
can call it for another transport model that knows its domain.
Realizations#
realizations: N makes one variant whose simulations are (receptor,
variant, k), with k in 0..N-1, the realization column of
project.simulations. They share one settings folder, with a
realization=k/ partition each. The run record says ensemble: true
with the base seed, and realization k runs with seed + k
(Variant.transport_for(k)). An ensemble of one is still an ensemble, so
raising N only adds partitions. A single run records neither key.
The HYSPLIT seed#
SETUP.CFG gets SEED = -(|seed| + 1) (setup_seed in the HYSPLIT
driver), not the user’s value. HYSPLIT sets its generator state to
-1 + SEED, and under krand=2 the generator (ran1) re-initializes
only from a negative value and collapses every state >= -1 onto one
stream, so a positive SEED would be inert. krand=4 discards the seed
(a clock draw with about 5000 distinct values), and krand=1 uses it only
for the initial turbulent velocity, so seed requires krand=2.
krand is restricted to HYSPLIT’s documented modes because any other value
silently degenerates the turbulence draws.
Realization 0, and a single wind-error variant, use the default seed, as
STILT-R’s error run does. The winderr fidelity scenario (an hrrr-err
variant) relies on this, and the R fidelity fixture applies the same seed
mapping.
Line sources#
In HYSPLIT’s emspnt.f, consecutive CONTROL starting locations at the
same latitude and longitude become one vertical line source, and only the
last pair is released. That is how ColumnReceptor works (two lines:
bottom and top), and why a MultiPointReceptor may not repeat a horizontal
location (the constructor raises). The bundled build releases column
particles bottom to top in indx order (the table’s particle), which
add_release_heights falls back to when a model writes no age = 0
release row.
Pressure weighting#
PressureWeighting fits ln p = b + a·z to the particles’ first-step
(zagl, pres) and gives each distinct release height the pressure slab
centered on it (particle_pwf), split evenly among the particles released
there (a multipoint receptor releases many per point). The fit is made in the
receptor’s own datum: for altitude_ref="msl" it uses zagl + zsfc (so
zsfc must be in varsiwant), and the ground closing the bottom slab is
the terrain under the lowest point. PressureWeighting.apply reads
altitude_ref from the receptor it is given; with none it assumes AGL.
AveragingKernel holds only the kernel. calc_footprint divides by the
particle count, so weights are scaled by N. Weights sum to the column’s
mass fraction (less than 1) by design: the rest of the atmosphere is above
the column top.
The hypsometric fit is load-bearing. HYSPLIT’s first output step is already
one time step of turbulence past release: on a real 1000-particle column, 394
of 999 adjacent particles are non-monotone in pressure against release
height. Raw first-step pressures give neighboring particles weights spanning
a factor of 145; the fit gives 1.49, the true hydrostatic ratio across 3 km.
Never “simplify” particle_pwf to use pres directly
(tests/integration/test_pressure_weighting.py guards this).
Exact release positions would not help either. PARTICLE.DAT starts at
t = -DELT, never t = 0, but the scatter is not only transport:
emspnt.f places each particle uniformly at random inside its own
1/numpar slab. The air a particle represents is its slab, known from
numpar and the column, which is what release_height is. Particle data
is needed only for the two-parameter p(z) fit.
Release heights#
The particle table’s contract asks a model to write each particle’s release
at age = 0. From it add_release_heights (particles/table.py, a
core step the worker applies to any model’s particles, as is
correct_near_field) finds which point of a multipoint receptor a particle
left from, or which slab of a column. release_height is that point’s
altitude or that slab’s center, never the random height inside the slab.
For a multipoint or slant receptor, _multipoint_release_heights uses, in
order: age = 0 rows when present (exact); a match on height when every
release altitude differs; then horizontal position, with a warning when
points are closer than 1 km. The bundled HYSPLIT v5.1.0 writes no age = 0
row. A build that does can be used through exe_dir, and nothing in
PYSTILT changes. tests/transport/hysplit/test_release_assignment.py
covers closely spaced slant points.