Imported from jmineau/PYSTILT (
AGENTS.md). Install upstream withnpx skills add jmineau/PYSTILT. Copyright stays with the author.
AGENTS.md
Orientation for contributors and coding agents working on PYSTILT. It covers
the architecture, the invariants that must not break, and the dev workflow.
User-facing documentation lives in docs/ and at
https://jmineau.github.io/PYSTILT; contribution mechanics and recipes for
adding config fields, execution backends, and particle transforms are in
CONTRIBUTING.md.
Keep this file current. If you change the module layout, the build/test commands, or learn a new invariant or gotcha, update the matching section in the same change. A section that no longer matches the code is worse than no section: fix it or delete it.
Personal or machine-specific notes (local paths, cluster setup) belong in an untracked file, not here. Anything matching
*.local.mdis gitignored for this (e.g.CLAUDE.local.md); add your tool's local files to.gitignoreif they are not covered. Some agents stop readingAGENTS.mdonce a local instruction file exists, so import or reference it from yours.
Overview
PYSTILT is a Python implementation of STILT (Stochastic Time-Inverted
Lagrangian Transport). It drives HYSPLIT for backward (or forward) particle
trajectories and computes gridded footprints for receptors. It is alpha
software (0.1.0a*): there are no backward-compatibility guarantees before
v1.0, so prefer the clean design over a compatibility shim.
Naming
| Thing | Name |
|---|---|
| PyPI distribution | pystilt |
| Import name | stilt (import stilt, never import pystilt) |
| Source directory | src/stilt/ |
| CLI entry point | stilt (Typer; see [project.scripts]) |
| Config | What the user writes: always a class (ProjectConfig, MetConfig, FootprintConfig, ExecutionConfig, a model's HysplitConfig), each next to the code that uses it |
| Settings | What a result was made with, as recorded: _settings.yaml, the settings= folders, a settings hash. Never a class |
| Parameters | Plain English for a config's fields; names no class or module |
Use pystilt only for installation, the repository, and documentation URLs;
tracebacks say stilt, pip and uv say pystilt. The R implementation
(uataq/stilt) is STILT-R, never
"R-STILT", in docs, docstrings, comments, changelog entries, and commit
messages. Existing identifiers such as the r_stilt test fixtures and
STILT_R_DIR keep their names.
Relationship to sister projects
- STILT-R: PYSTILT inherits its transport
science. Footprints match STILT-R at
rtol=1e-7per cell against a pinned upstream commit, checked by thefidelitytest suite. The parity policy is in docs/development.rst; read it before touching trajectory or footprint math. - stiltctl: source of the
thin CLI →
Project→ worker call path. Its queue-backed and Kubernetes execution was implemented here and then removed (#67, #87). - X-STILT: source of the observation layer and column science. PYSTILT ports the concepts, not the scripts, and does not aim for X-STILT feature parity.
Architecture
There is no index, manifest, or registry. A project is a directory of
inputs (stilt.project.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
receptors × variants: receptors.csv crossed with the named variants in
config.yaml (one per met when none are declared). Each variant resolves
into a Variant (stilt.variants) with two records of settings
(stilt.identity): its run settings, whose hash identifies a run and
names its particles folder, and optional footprint settings. Variants with
equal run settings share one run per receptor and differ only in the
footprint made from it. Whether
a simulation is complete is decided by the files in the output
directory, by Simulation.is_complete(): that method is the single
definition of "done". config.yaml and receptors.csv are the user's
inputs: Project.init (or stilt init) writes config.yaml once, PYSTILT
never rewrites it, and Project.add_receptors only appends to
receptors.csv. Changing a setting never overwrites a result: it hashes to
a new folder.
Project reads; 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 write is add_receptors, which
appends to receptors.csv. It knows no scheduler or scratch directory.
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, which are the only code that
writes results.
Plurals are handles that hand you a table; singles are values or data.
project.simulations is a Simulations: a pandas table plus the project
it came from. Select it as in pandas (sims[sims.variant == "hrrr"]), then
ask the selection: status(), incomplete(), load_particles() (one long
table), load_footprints(), jacobian(). A selection forwards only column
access and row masks; everything else is sims.frame, and
Simulations(project, frame) turns a table back into a selection. Do not
give it query methods of its own (sel, where): that is how the old
collection classes grew. Particles and Footprints are the folder
handles of the output directory. project.receptors stays a plain
DataFrame. project.receptor(id) and project.simulation(id, variant)
return the values; sim.particles and sim.footprint are data.
stilt.__all__ (plus the __all__ of each subpackage) is the public surface;
everything else is internal and can change.
Module layout
src/stilt/
cli.py Typer CLI; a thin adapter over Project and execution
project.py Project: the project directory, its receptors (a
DataFrame), run/submit; Simulations: a selection's
status, loading, and Jacobian
output.py Output: the output directory. Particles and Footprints are
its folders, one per settings hash: finding, listing,
and reading many files at once; Jacobian assembly
simulation.py Simulation, SimID: 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, checks each
declared variant (`config.variant(name)`)
variants.py Variant: one variant resolved (its configs, the model build,
its two hashes); resolve() validates each variant's
transport settings, expands realizations, and reads
geometries and the model version once each
identity.py settings records and their hashes: what a run and a footprint
were made with, written to and read back from _settings.yaml
receptors/ receptors and receptors.csv
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
without checking them again
validation.py the checks a receptor must pass, written once for many
points and shared by the models and the table
particles.py the particle table (a DataFrame): prepare, read, and write
particle files; the `.stilt` pandas accessor (endpoints,
enhancement from a flux field)
footprint/ the footprint (a DataArray)
config.py FootprintConfig and the geometry specs
gridding.py `calculate`, STILT-R's calc_footprint (fidelity-guarded)
aggregation.py summing footprints onto other geometries: `aggregate`,
`jacobian`, their time binning and target weights
io.py the footprint array and its attributes; footprint files
(`read_footprint`, `write_footprint`) and CF-1.8 NetCDF
targets.py the geometries footprints are summed onto (Mesh, Zones),
the overlap weights, and `to_grid`
accessor.py the `.stilt` xarray accessor (enhancement from a flux
field, aggregation)
sampling.py sampling a gridded field (a flux, a mole fraction) at points
spatial.py rasters and CRS, no shapely: Bounds, Grid (with its cell
and CF helpers), horizontal_dims, is_longlat, same_crs
meteorology.py MetConfig, and Met: ARL file discovery, download, and
cropping (via arlmet)
transforms.py pre-footprint particle transforms (averaging kernel,
pressure weighting, lifetime decay) and their YAML I/O
exceptions.py every exception class, all under StiltError
visualization.py matplotlib helpers (optional dependency)
execution/ ExecutionConfig (config.py), the runner (saves a model's inputs, plans what is missing,
runs it here or submits batches to Slurm through submitit)
and the worker (runs HYSPLIT on scratch and writes results
for one or many simulations)
observations/ the X-STILT port, all before or after the transport run:
product readers, overpass grouping and sounding
selection, slant geometry, transport error, wind-error
statistics, backgrounds, plume backgrounds. Arrays in,
plain values out; there is no observation object.
transport/ the TransportModel and TransportConfig protocols, ModelInfo,
and get_model with its MODELS table (__init__.py); one
subpackage per transport model, which owns its config
hysplit/ HYSPLIT, the one model today: HysplitConfig (config.py, its
parameters), HysplitModel, the driver (driver.py:
write_inputs, which knows which file each setting goes
to, and read_particle_dat), failure reasons read from
its log (failures.py), and the bundled binaries (bin/)
and data tables (data/)
tests/ pytest; markers `integration` and `fidelity`. Folders
follow src/stilt (execution/, observations/,
transport/hysplit/); r_stilt/ holds the STILT-R comparisons
docs/ Sphinx (pydata-sphinx-theme)
Two ways work starts: do not conflate them
- A run (
Project.run(),Project.submit(), orstilt run):stilt.execution.runfinds the receptors with missing results and either runs them in this process (backend: local) or submits them as one Slurm job array through submitit (backend: slurm), oneBatchof receptors per task, and waits.submitreturns the jobs at once. The unit of work is a receptor:run_receptorruns HYSPLIT once per distinct transport hash, then writes the footprint of every variant that shares those particles. - Observation-driven: a reader yields a DataFrame of soundings;
stilt.observationshelpers thin and group it; each row becomes aReceptor;averaging_kernel_tablewrites the kernels into the project; thenadd_receptorsand run as usual. This layer sits above the transport core. Keep observation logic out ofproject.py. An import-linter contract inpyproject.toml(run bylint-importsin CI) fails when a core module importsstilt.observations; a new top-level module goes on that contract's list. A second contract keeps HYSPLIT's package (stilt.transport.hysplit) out of the core, which reaches it only throughstilt.transport.get_model, with no exceptions.
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.
Configuration
ProjectConfigis the root:model(the transport model,hysplitunless set), that model's parameters and the footprint (FootprintConfig) fields as flat defaults,mets, andvariants(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. A model is a variant axis, like a met: a variant that names anothermodelgives that model's parameters itself and inherits only the met and the footprint fields. The top-level footprint fields areconfig.footprint, as the model's areconfig.transport. When it loads,ProjectConfigchecks each declared variant against the defaults (config.variant(name): names, met, model,realizations, grid merging), reading no other file.stilt.variants.resolve(behindproject.variants) turns each intoVariants: it validates the variant's transport settings with its model's config class (once), expandsrealizations: Ninto<name>-0..N-1withseed + k, reads each geometry once to derive the grid, and asks the transport model its version once per build.Project.initresolves before it writesconfig.yaml. Variants whose run settings match share the particles;from:is rejected.grid: nullmeans particles only, and footprint settings without a grid are an error. There is no named-footprints dict.- Each part owns its config.
FootprintConfigis instilt.footprint.config,MetConfiginstilt.meteorology,ExecutionConfiginstilt.execution.config, a model's config in its package.BoundsandGridare instilt.spatial, the raster and CRS layer that needs no shapely, since more than footprints use rasters (a flux put on the footprint grid).stilt.configcomposes them, so nothing below the project importsstilt.config(an import-linter contract). Config classes read no file; anything that reads one or asks a model goes instilt.variants. - A config's fields that change no result are listed in its
UNRECORDEDclass variable (exe_diranddata_dir; a met's directories,download_from, andn_min), and left out of the settings records. A project requires each met'sdirectory, so a met config read back from a record, without one, still validates. - Every field is a plain pydantic
Field(default, description=...)and the public config stays flat (ProjectConfig(numpar=..., seed=...)). CONTRIBUTING explains how a field is routed toSETUP.CFG,CONTROL,WINDERR, orZIERR. - The scratch directory comes from the
PYSTILT_COMPUTE_ROOTenvironment variable, whichresolve_compute_rootin the runner reads;Projectdoes not. 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 in YAML
(
transforms: [{kind: ...}]);kindmay also be the import path of a user class.
Project layout on disk
A project is a local directory; results go to the output directory its
config.yaml names (./output by default, relative to the project). Every
folder below a kind is hive-style, so each tree reads as one dataset:
<project>/
config.yaml ProjectConfig (written once by Project.init or stilt init; never rewritten)
receptors.csv receptor list; add_receptors() appends new receptors
slurm/<stamp>/ one folder per Slurm submission: script, task logs,
and submitit's pickles
<output>/
particles/settings=<variant>-<hash>/_settings.yaml
particles/settings=<variant>-<hash>/date=YYYY-MM-DD/<receptor_id>.parquet
footprints/settings=<variant>-<hash>/_settings.yaml names the particles folder
footprints/settings=<variant>-<hash>/date=YYYY-MM-DD/<receptor_id>.parquet
logs/settings=<variant>-<hash>/date=YYYY-MM-DD/<receptor_id>.log
scratch/settings=<variant>-<hash>/date=YYYY-MM-DD/<receptor_id>/ failed runs' working dirs
A particles folder's hash is Variant.particles_hash; a footprint
folder's is Variant.footprint_hash, over the run settings' hash and the
footprint settings together. "Particles" is the one word for the particle table in code
(Particles, sim.particles, has_particles); "run" is only the verb, and
"trajectory" means one particle's path. Lookup
reads the stored _settings.yaml back through the current config classes
(stilt.identity) and re-hashes, so a field added later with a default
still matches. compute_root
is scratch: HYSPLIT runs there and the directory is discarded after success.
Footprints are sparse tables (hour, y, x, foot, float32); an empty
footprint is a file with no rows and the reason in its metadata, and counts
as complete.
Invariants
- STILT-R numerical parity at
rtol=1e-7per footprint cell. Run thefidelitysuite before merging any change to trajectory or footprint math. NetCDF output is CF-1.8 and deliberately not byte-compatible with STILT-R. - Completion is by file. A simulation is complete iff its files exist
in the output directory.
Simulation.is_complete()says so for one simulation, andSimulations._complete()applies the same rule to many, from the listing of date folders that_present()reads (a test holds the two together). Never add a second "does this output exist" check, a completion registry, or a manifest; call theSimulationmethod. - Identity is content. A results folder is its settings hash; a changed setting is a new folder, never an overwrite, and PYSTILT never deletes a folder.
- State lives in the project directory and the output directory. Anything kept in a process-local variable is lost to a Slurm task, which opens the project again from its directory.
- The CLI stays thin.
cli.pyadapts arguments toProjectand execution calls; orchestration logic does not belong there. - Meteorology I/O goes through arlmet. Do not reimplement ARL reading here.
- Bundled HYSPLIT is package data. Reach the binaries and tables through
importlib.resourcesvia the existing helpers, never a repo path. They ship through[tool.setuptools.package-data]; moving them means updating that. Each wheel is tagged for one platform and carries only that platform'shycs_std(setup.py); the sdist carries none (MANIFEST.in). A new platform build needs a case inbundled_buildinsetup.py, the driver's_bundled_exe_dir, and thejust dist/just check-distrecipes. - Pydantic for all configuration, with a description on every field.
Development
Commands
| Command | What it does |
|---|---|
just install |
uv sync --group dev |
just test |
uv run pytest -v (unit tests only) |
just quality-check |
ruff, pyright, and the import contracts (lint-imports), then the tests |
just ruff |
ruff check --fix and ruff format on src/stilt |
just build-docs |
clean Sphinx HTML build into docs/_build |
just dist |
the sdist and one wheel per bundled HYSPLIT build, into dist/ |
just check-dist |
check each wheel's tag and that it holds only its own hycs_std |
just pre-commit |
all pre-commit hooks on all files |
just clean |
remove build artifacts, caches, coverage, docs build |
CI (.github/workflows/): tests.yml, quality.yml, docs.yml, and
publish.yml for releases. docs.yml builds the docs on every pull request
but deploys the site only from a vX.Y.Z tag, so the site matches the
latest release; a docs change on main goes live with the next release.
Tests
Plain pytest runs the unit tests. Two opt-in markers:
-m integration: end-to-end runs with real met files and HYSPLIT. Slow. Themet_dirfixture needsSTILT_TEST_MET_DIRpointing at a directory of HRRR ARL files, orSTILT_TEST_FETCH_MET=1to download the seven 6 h blocks it needs into the ignoredtests/met_cache/. Once downloaded, setSTILT_TEST_MET_DIR=tests/met_cache; without either variable every integration test is skipped.-m fidelity: live comparison against STILT-R. Slow; needsSTILT_R_DIRpointing at a STILT-R checkout andRscriptonPATH.
A local .env is loaded by pytest-dotenv, which is the place for
STILT_R_DIR and similar settings.
Test folders follow the package: tests for stilt.execution go in
tests/execution/, for HYSPLIT in tests/transport/hysplit/, and so on.
Modules without a subpackage stay at the top of tests/. tests/observations/
is self-contained: its tests use only its own conftest.py and data/, never
the shared fixtures in tests/conftest.py, so the observation layer can leave
the repository with its tests. Keep it that way.
Committed test data (tests/observations/data/) stays small and synthetic. Do
not commit real instrument retrievals or met files: they are large and often
not ours to redistribute. Synthetic samples should keep the real format's
quirks.
Code conventions
- Python 3.11+ (
ruff target-version = "py311"). Use the standard library for what 3.11 added (typing.Self,enum.StrEnum,tomllib,datetime.UTC) rather thantyping_extensionsor backports. - Ruff rules
E, F, UP, B, SIM, I, D213; line length is left to the formatter. - NumPy-style docstrings (Sphinx napoleon is configured for NumPy only),
with the summary on the line after the opening quotes (
D213). - Pyright in
basicmode against the project.venv.py.typedships. CI type-checks under Python 3.11, the oldest supported version. To reproduce it, note thatpyproject.tomlpins pyright to.venv, so--pythonpathdoes not switch environments; build a 3.11 environment elsewhere (UV_PROJECT_ENVIRONMENT=<dir>/.venv uv sync --python 3.11 --group dev) and runpyright --venvpath <dir> src/stilt. Fix types at the source rather than reaching fortyping.cast. - Keep the
from __future__ import annotationsheaders. - Exception classes live in
stilt/exceptions.py. Each subclassesStiltErrorand the builtin that describes it; a failed run is aSimulationError. Plain input checks raise builtins such asValueError. - Runtime dependencies live in
[project]; optional extras aregeometry,visualization,cloud, andcomplete. Thedevdependency group pulls inpystilt[complete]plus the test, lint, type, and docs tooling.
Documentation
Sphinx sources in docs/: getting_started/ (install, quickstart,
concepts), guides/ (task-oriented how-tos), tutorials/ (end-to-end
worked examples), advanced/ (design and internals), and reference/ (API
pages built from docstrings). A user-facing change needs a guide or reference
update, and just build-docs should build without new warnings.
Voice
Docs, docstrings, config field descriptions, and CLI help all follow this voice. Most readers are scientists who want to run the model and trust the result; write for them. Good examples are the STILT-R docs and the xarray, pandas, and MetPy user guides.
- Start with what it is for. Open a page or docstring with what the
thing does, in plain words. Then show an example. Internals and edge cases
go last, or in
docs/advanced/. - Keep sentences short. One idea per sentence. If a sentence needs a colon, a semicolon, and a parenthesis, split it.
- Use the reader's words. Receptor, particles, footprint, meteorology. Internal names (store key, publish, resolve) belong only on pages about internals.
- Show it. A short code block, with its output when that helps, is clearer than a paragraph describing it.
- Say it plainly. No metaphors ("a dial worth turning"), no selling ("powerful", "seamless"), no filler ("note that", "it's worth noting").
Some habits make text read as machine-written. Avoid them:
- Colon reveals: "PYSTILT does one thing: it follows the air." Write "PYSTILT follows the air."
- "X, not Y" contrasts when nobody suggested Y.
- A bold sentence at the start of a paragraph that states its point.
- Dashes (
—or--) joining clauses. Use a period or parentheses. - Lists of three out of habit, and closing sentences that restate the paragraph.
- Asides about what the code does "deliberately" or used to do.
- Validation statistics in a user guide. Give the reader the setting to use and cite the study.
- Labels invented on one line and referred to later ("the first case").
Docstrings follow the NumPy style. The summary line says what the object is or what the function returns. Parameter descriptions give the meaning and units, not the type again. Check every example and claim against the code when you write it.
Commits, changelog, releases
- Commit messages follow Conventional Commits (
fix(slurm): ...,docs: ...). - User-visible changes go under
## [Unreleased]inCHANGELOG.md, which follows Keep a Changelog. - The version is a static string in
pyproject.toml; pushing avX.Y.Ztag publishes to PyPI viapublish.yml. Do not bump the version, cut a release, or push a tag unless the maintainer asks.
Issues, pull requests, and roadmap
Open work is tracked in GitHub issues, not in this file. Feature status lives in the roadmap tables in README.md and docs/roadmap.rst; when a feature lands, update both.
- Code that looks over-engineered goes on the running list in
#48 (label
simplify), not into an unrelated change. - Do not act on GitHub for the user unless asked. No new issues, pull requests, comments, or review replies on their behalf. Summarize findings for the user and let them post in their own words.
- Before working on an issue, read it (
gh issue view <n>) for the current scope and discussion. - A pull request fixes one thing, links the issue it resolves
(
Fixes #N), and has a short description of what changed and why.
Judgment calls
- Keep changes small and focused. Don't bundle unrelated cleanups into a fix.
- Prefer code that is easy to read and debug over clever or abstract code; most users are scientists who will read the source when a result looks wrong.
- Every behavior change gets a test; every bug fix gets a test that fails without it.
- Prefer readability over micro-optimizations unless a profile shows the cost. Real runs are dominated by HYSPLIT and I/O.
Gotchas and science notes
- Results are plain data.
sim.particlesis a pandas DataFrame andsim.footprintan xarray DataArray; PYSTILT's methods on them live in.stiltaccessors. 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. Do not add wrapper classes back. Every result file records what it needs to be read alone (read_particles,read_footprint). project.simulationsis cached.add_receptorsdrops the cache; aconfig.yamledited by hand needs a newProject(path).- Empty footprints are successes, and not footprints. When no particle
reaches the grid,
footprint.calculateraisesEmptyFootprintand the worker'smake_footprintwrites a footprint file with no rows and the reason in its metadata.sim.is_complete()is true,sim.footprintisNone,sim.empty_reasonsays why, andload_footprints()leaves the simulation out. Never synthesize a zero-valued footprint for it: a zero enhancement would flow into a comparison or an inversion unnoticed. - A failure record is a note, not a result. When a step fails the
worker writes
<receptor id>.failure.yamlbeside the receptor's log (particles failures for the group, footprint failures by variant) and removes the entry when the step later succeeds. Completion never reads it: a simulation is done by its result files alone.sim.failure,sims.failures(), thereasoncolumn ofstatus(), andstilt statusread it. - A result that is not written yet raises.
sim.particlesandsim.footprintarecached_propertyon a frozen value; a missing file raisesFileNotFoundError(never cached, so the next read tries again) rather than caching aNonethat would hide the result when it lands.Noneis only for final states (no grid, empty footprint). Do not write to a simulation's__dict__by hand; usehas_particles/has_footprintto test presence. - Declaring
realizationsmakes a numbered group, even at 1.hrrr-errwithrealizations: 1ishrrr-err-0, so raising the count later only adds simulations. Realization 0 is never aliased to the unsuffixed name. - Declared variants replace the per-met defaults. With a
variantssection only its entries run; the starter config writeshrrr: {}so the unchanged run stays visible. - HYSPLIT line-source chaining. In
emspnt.f, consecutive CONTROL starting locations at the same lat/lon become one vertical line source and only the last pair is released. That is howColumnReceptorworks (two lines: bottom and top), and why aMultiPointReceptormay not repeat a horizontal location (the constructor raises). The bundled build releases column particles bottom-to-top inindxorder, whichadd_release_heights(transport/hysplit/release.py) relies on forxhgt. - Pressure weighting is derived from the particles.
PressureWeightingfitsln p = b + a·zto the particles' first-step(zagl, pres)and gives each distinct release height the pressure slab centred 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: foraltitude_ref="msl"it useszagl + zsfc(sozsfcmust be invarsiwant) and the ground closing the bottom slab is the terrain under the lowest point.PressureWeighting.applyreadsaltitude_reffrom the receptor it is given; with none it assumes AGL.AveragingKernelholds only the kernel.footprint.calculatedivides by the particle count, so weights are scaled byN. Weights sum to the column's mass fraction (< 1) by design; the rest of the atmosphere is above the column top. This deliberately differs from X-STILT's "layer below each particle" convention, which gives a surface-released particle zero weight. - The hypsometric fit is load-bearing. HYSPLIT's first output step is
already one timestep of turbulence past release: on a real 1000-particle
column, 394 of 999 adjacent particles are non-monotone in pressure versus
release height. Raw first-step pressures give neighbouring particles weights
spanning 145×; the fit gives 1.49×, the true hydrostatic ratio across 3 km.
Never "simplify"
particle_pwfto usepresdirectly (tests/test_pwf_integration.pyguards this). - Exact release positions would not help PWF. PARTICLE.DAT starts at
t = -DELT, nevert = 0, but the scatter is not only transport:emspnt.fplaces each particle uniformly at random inside its own1/numparslab. The air a particle represents is its slab, known analytically fromnumparand the column, which is whatxhgtis. Particle data is needed only for the two-parameterp(z)fit. - Multipoint and slant
xhgtrecovery (_multipoint_release_heightsintransport/hysplit/release.py) prefers, in order:t = 0rows if present (exact), a match on height when release altitudes are distinct (about 20 m apart), then horizontal position with a warning under 1 km. The bundled HYSPLIT v5.1.0 writes not = 0row; until a published build does,exe_dircan point at a patchedhycs_std, and nothing needs undoing when one lands. The existing multipoint tests space points about 17 km apart, the one regime where horizontal matching works; they do not cover close-spaced slants (tests/transport/hysplit/test_release_assignment.pydoes). - Transport-error settings behave in ways that are easy to misread; see
docs/guides/transport_error.rstbefore changing or validating them. - The HYSPLIT seed is remapped.
SETUP.CFGgetsSEED = -(|seed| + 1)(setup_seedin the HYSPLIT driver), not the user's value: HYSPLIT sets its generator state to-1 + SEED, and underkrand=2the generator (ran1) re-initializes only from a negative value and collapses every state>= -1onto one stream, so a positiveSEEDis inert.krand=4discards the seed (clock draw with ~5000 distinct values), andkrand=1uses it only for the initial turbulent velocity, soseedrequireskrand=2.krandis restricted to HYSPLIT's documented modes because any other value silently degenerates the turbulence draws. Realizationkof a variant runs withseed + k; realization 0, and a single wind-error variant, share the default seed, as STILT-R's error run does, which is what thewinderrfidelity scenario (anhrrr-errvariant) relies on. The R fidelity fixture applies the same seed mapping. - HYSPLIT binaries in
src/stilt/transport/hysplit/bin/are Linux x86-64. Real runs are heavy; on a shared HPC system run them through the Slurm backend or an allocation, not on a login node.
