I/O API
File input/output for simulation data.
NetCDF Output
NetCDFFileHandler
Tarang.NetCDFFileHandler — Type
NetCDF File Handler matching Tarang H5FileHandler structure
Follows Tarang pattern:
- basepath/handlernames1/handlernames1p0.nc for processor files
- basepath/handlername_s1.nc for gathered files
- /scales/ group with time coordinates
- /tasks/ group with field data
addfilehandler
Tarang.add_file_handler — Function
Tarang-style helper to create a NetCDF file handler. Matches evaluator.addfilehandler(...) usage in Tarang.
Add file handler for output
Add file handler with automatic format detection
Supported formats:
- :netcdf or :nc - NetCDF format
- :auto - Auto-detect from file extension (defaults to NetCDF)
There are two ways to create a handler, and they behave differently.
Solver form (recommended). The handler is registered with the solver, so run! processes it every step and closes it at the end. It also takes its time metadata (sim_time, iteration, dt) from the solver, so the sim_dt/iter cadence works and every record is stamped with the simulation time:
handler = add_file_handler("snapshots", solver; sim_dt=0.002, max_writes=100)
add_task!(handler, T; name="T")
run!(solver; stop_iteration=6, progress=false) # writes at sim_time 0, 0.002, 0.004, 0.006Distributor form. Builds a handler that knows nothing about a solver: it does not auto-register (you must pass it in outputs=), and with no solver attached its sim_time/iteration are always 0, so a sim_dt or iter cadence never advances and only the first write lands. Pass solver= if you drive it with run!:
handler = add_netcdf_handler(
"snapshots", # Output path/name
dist, # Distributor
Dict("T" => T, "u" => u); # Dict of fields (namespace for string-expression tasks)
solver=solver, # so sim_time/iteration are real, not 0
sim_dt=0.002,
max_writes=100 # writes per file before rolling over to the next set
)
add_task!(handler, T; name="T")
run!(solver; stop_iteration=6, outputs=[handler], progress=false) # outputs= is requiredArguments:
base_path: base path for output files (a trailing.ncis stripped)dist: distributorvars: dictionary mapping names to fieldssolver: optional solver, used for time metadatasim_dt/iter/wall_dt: write cadence (simulation time, iterations, wall seconds)max_writes: maximum writes per file before starting a new file setparallel:"gather"or"virtual"— see the Parallel I/O section belowmode:"overwrite"(default) or"append"
Returns: NetCDFFileHandler
addnetcdfhandler
Tarang.add_netcdf_handler — Function
Convenience function to create NetCDF file handler (matching Tarang API) Usage: handler = addfilehandler("snapshots", dist, vars, simdt=0.25, maxwrites=50)
Same as the distributor form of add_file_handler above.
add_task!
Tarang.add_task! — Function
Add task to handler (matching Tarang API)
Example usage (matching Tarang style):
snapshots = evaluator.addfilehandler('snapshots', simdt=0.25, maxwrites=50) snapshots.addtask(b, name='buoyancy') snapshots.addtask(-d3.div(d3.skew(u)), name='vorticity')
Add field or operator to file handler
Add field or operator to file handler with explicit name
Add a field/operator task to the dictionary handler.
Add a field/array to be written by this file handler.
Add a field output task.
add_task
Tarang.add_task — Function
Alias without bang for Tarang-style task addition.
Add a field output task (alternative syntax).
Always pass the field or operator object, never a string expression: a string task (add_task!(handler, "T*T")) is accepted silently but writes a scalar of zeros instead of the field. Build the operator instead (add_task!(handler, T*T; name="T2")).
add_task!(handler, field; name="field_name")With postprocessing — postprocess receives the task's data array (this rank's slab) and its return value is what gets written, so the written shape follows from it:
using Statistics
add_task!(handler, field;
name="field_xmean",
postprocess = data -> dropdims(mean(data, dims=1), dims=1)
)addprofiletask!
Tarang.add_profile_task! — Function
Add a task that computes a 1D profile (mean over all but one dimension).
Arguments
handler: NetCDFFileHandler instancefield: Field to compute profile ofdim: The dimension to keep (profile along this dimension)name: Optional name for the output variable
Example
add_profile_task!(handler, u, dim=:z, name="u_profile_z")Add a task that reduces the field to a 1D profile: the mean over every dimension except dim. The keyword is dim (singular) and it names the dimension to keep — either an axis index or a coordinate symbol.
# z-profile: mean over x, keep z
add_profile_task!(handler, field; dim=:z, name="field_profile_z")
# same thing by axis index, on an (x, z) domain
add_profile_task!(handler, field; dim=2, name="field_profile_z")To average over several dimensions instead, use add_mean_task! with dims=.
addmeantask!
Tarang.add_mean_task! — Function
Add a task that computes the mean over specified dimensions.
Arguments
handler: NetCDFFileHandler instancefield: Field to compute mean ofdims: Dimensions to average over (e.g., (:x, :y) or (1, 2))name: Optional name for the output variable
Example
add_mean_task!(handler, u, dims=(:x, :y), name="u_mean_z")Add a task that computes mean values. With dims it averages over those dimensions; without dims it writes the single whole-domain mean (MPI-aware — combined across ranks).
add_mean_task!(handler, field; name="field_mean") # scalar, whole domain
add_mean_task!(handler, field; dims=(:x, :z), name="field_mean_xz")addslicetask!
Tarang.add_slice_task! — Function
Add a task that extracts a slice of a field.
Arguments
handler: NetCDFFileHandler instancefield: Field to sliceslices: Dictionary or named tuple specifying slice positions e.g., Dict(:z => 0.5) or (z=0.5,) for midplane slicename: Optional name for the output variable
Example
add_slice_task!(handler, u, slices=Dict(:z => 0.0), name="u_bottom")
add_slice_task!(handler, T, slices=(x=0.5, y=0.5), name="T_centerline")Add a task that extracts a slice, given the axis (dim) and the index along it (idx):
add_slice_task!(handler, field; dim=1, idx=8, name="field_slice")The slices=Dict(...) form advertised in the docstring above throws at write time (MethodError: Cannot convert an object of type Int64 to an object of type Colon). Use the dim/idx form.
addrmstask!
Tarang.add_rms_task! — Function
Add a task that computes the RMS (root mean square) over specified dimensions.
Add a task that computes RMS (root-mean-square) values — whole-domain by default, or per-remaining-axis with dims.
add_rms_task!(handler, field; name="field_rms")addvariancetask!
Tarang.add_variance_task! — Function
Add a task that computes the variance over specified dimensions.
Add a task that computes variance (same dims convention as add_rms_task!).
add_variance_task!(handler, field; name="field_variance")addextrematask!
Tarang.add_extrema_task! — Function
Add a task that computes min/max over the domain.
Add a task that tracks minimum and maximum values. It writes two variables, <name>_min and <name>_max (whole-domain, MPI-aware):
add_extrema_task!(handler, field; name="T") # writes vars/T_min and vars/T_maxprocess!
Tarang.process! — Function
Process handler: write all tasks to NetCDF (matching Tarang process method)
process!(handler::VirtualFileHandler, solver, wall_time, sim_time, iteration)Write per-rank data files (LOCAL slabs only — no gather) and rank-0 manifest. Each dataset name is stored with name_start (global offsets of the slab) and the manifest stores name_gshape (global shape) for reconstruction.
Write pending data to file (respecting the handler's sim_dt/iter/wall_dt cadence). This is the low-level call.
Recommended: create the handler with the solver and let run! drive output — the handler auto-registers, and run! calls process! every step and close! at the end, so you never write a manual loop or forget a process!:
handler = add_file_handler("output/snap", solver; sim_dt=0.5)
add_task!(handler, u; name="u")
run!(solver; stop_time=20.0, cfl=cfl) # auto: dt, process!(handler), close!Call process!/close! directly only when you write your own step loop:
process!(handler;
iteration=solver.iteration,
wall_time=time() - wall_start,
sim_time=solver.sim_time,
timestep=solver.dt
)It returns true if the write happened and false if the schedule said "not yet".
Handler Management
check_schedule
Tarang.check_schedule — Function
Check if handler should process based on schedule (matching Tarang logic)
Always writes initial conditions (iteration=0) when any scheduling mode is configured. For subsequent iterations, uses the configured cadence.
Check if the handler should write based on schedule.
createcurrentfile!
Tarang.create_current_file! — Function
Create NetCDF file with Tarang-style structure
Create a new output file.
current_path
Tarang.current_path — Function
Get current set path following Tarang naming: handlernames1/
Get the directory of the current file set (<name>_s<set>), not a file:
current_path(handler) # "snapshots/snapshots_s1"current_file
Tarang.current_file — Function
Get current file path following Tarang naming: handlernames1/handlernames1_p0.nc
Get the file the handler is writing into, inside that directory:
current_file(handler) # serial: "snapshots/snapshots_s1/snapshots_s1.nc"
# np=2: "snapshots/snapshots_s1/snapshots_s1_p0.nc" (per rank)getoutputfiles
Tarang.get_output_files — Function
Get list of all output files created by this handler.
List the files this handler has created (all sets).
gethandlerinfo
Tarang.get_handler_info — Function
Get metadata about the handler's output.
A Dict describing the handler state, with keys base_path, name, set_num, file_writes, total_writes, max_writes, num_tasks, task_names, parallel, precision, mpi_rank, mpi_size.
close!
Tarang.close! — Function
Close handler and finalize all files.
Writes final metadata attributes to the current NetCDF file. Also clears the internal variable-creation cache.
Note: NetCDF.jl's filename-based API (nccreate, ncwrite, ncread, ncputatt) opens and closes the underlying file descriptor on each call, so there is no persistent file handle to release here. This method exists to flush final metadata and reset handler state.
Close the handler and finalize output.
close!(handler)reset!
Tarang.reset! — Function
Reset handler for a new simulation run.
reset!(filter::ExponentialMean)Reset the filter state to zero.
reset!(filter::ButterworthFilter)Reset the filter state to zero.
reset!(filter::LagrangianFilter)Reset the Lagrangian filter state.
reset!(model::EddyViscosityModel)Reset the eddy viscosity field to zero. GPU-aware: fill!() works for both CPU and GPU arrays.
Reset the handler state.
File Merging
Under MPI each rank writes its own slab into its own file (see Parallel I/O below). Merging is the post-processing step that stitches those per-rank files back into one file holding the global field. It is a serial post-processing step: run it after the simulation, from the handler's output directory (the one that contains <name>_s<set>/). In a serial run there is nothing to merge — merging reports "No processor files found" and returns false.
NetCDFMerger
Tarang.NetCDFMerger — Type
NetCDF File Merger - handles merging of per-processor files
mergenetcdffiles
Tarang.merge_netcdf_files — Function
merge_netcdf_files(base_name; kwargs...)Merge per-processor NetCDF files into a single merged file.
Arguments
base_name::String: Base name of the handler (e.g., "snapshots", "analysis")set_number::Int=1: Set number to merge (default: 1)output_name::String="": Output filename (default: auto-generated)merge_mode::MergeMode=RECONSTRUCT: How to combine processor datacleanup::Bool=false: Delete source files after successful mergeverbose::Bool=true: Print progress information
Examples
# Basic merge
merge_netcdf_files("snapshots")
# Advanced options
merge_netcdf_files("analysis",
set_number=2,
output_name="analysis_complete.nc",
cleanup=true,
merge_mode=SIMPLE_CONCAT)Merge one file set written by a parallel run:
cd("snapshots") # the directory containing snapshots_s1/
merge_netcdf_files("snapshots"; set_number=1, output_name="snapshots_merged.nc")The per-rank slabs are reconstructed with the start/count/global_shape attributes each rank stored, so vars/T in the merged file has the full global shape ((writes, Nx, Nz)).
Whole-domain reduction tasks (add_mean_task! and friends without dims) store no such attributes, because their value is already global on every rank. The merger has nothing to reconstruct for them, and says so loudly — expect, for each one,
┌ Warning: No global_shape metadata found for 'T_mean'. Using single-processor shape (3, 1)
│ — the merged file may contain only a fraction of the full domain.
Error placing data from snapshots_s1_p1.nc: BoundsError(...)This is noise, not failure: the merger keeps rank 0's value, which is the correct global one, and the merge still returns true.
merge_files!
Tarang.merge_files! — Function
Main merge function - orchestrates the entire merging process
The lower-level call: build a NetCDFMerger yourself and run it.
batchmergenetcdf
Tarang.batch_merge_netcdf — Function
batch_merge_netcdf(handlers; kwargs...)Merge multiple handlers in batch mode.
Examples
# Merge multiple handlers
batch_merge_netcdf(["snapshots", "analysis", "checkpoints"])
# With cleanup
batch_merge_netcdf(["snapshots", "analysis"], cleanup=true)Merge several handlers in one go; returns a Dict of handler name to success flag. Like merge_netcdf_files, it looks for <name>_s<set>/ in the current directory — so every handler you name must have its set directories there. A handler created with a bare base_path roots its sets in a directory of its own (snapshots/snapshots_s1/), and no two such handlers ever share a parent; give them a common parent instead:
# at setup, so both handlers write into out/
h1 = add_file_handler("out/snapshots", solver; sim_dt=0.002)
h2 = add_file_handler("out/analysis", solver; sim_dt=0.002)
# → out/snapshots_s1/snapshots_s1_p*.nc, out/analysis_s1/analysis_s1_p*.nc
# after the run
cd("out")
find_mergeable_handlers(".") # Dict("snapshots" => [1], "analysis" => [1])
batch_merge_netcdf(["snapshots", "analysis"]) # Dict("snapshots" => true, "analysis" => true)Called from the wrong directory it finds nothing, warns No processor files found for merging, and returns false for every handler.
findmergeablehandlers
Tarang.find_mergeable_handlers — Function
find_mergeable_handlers(directory=".")Find all handlers with processor files ready for merging.
Returns a dictionary mapping handler names to available set numbers.
Scan a directory for handler file sets, returning a Dict of handler name to the set numbers found:
find_mergeable_handlers("snapshots") # Dict("snapshots" => [1])cleanupsourcefiles!
Tarang.cleanup_source_files! — Function
Clean up source processor files after successful merge
Remove source files after merging.
Equation Parsing
Internal functions for parsing equation strings.
split_equation
Tarang.split_equation — Function
split_equation(equation::AbstractString)Split an equation string into left- and right-hand sides, tracking bracket depth so that keyword arguments (e.g. $f(x=1)$) and array indices do not trigger false splits.
Tracks parentheses (), square brackets [], and curly braces {}.
Throws SymbolicParsingError if there is not exactly one top-level equals sign.
split_call
Tarang.split_call — Function
split_call(call::AbstractString)Split a function-style string $"f(x, y)"$ into a head $"f"$ and a tuple of argument names. Returns $(call, ())$ if the string does not have call syntax.
Handles nested parentheses correctly, e.g., $"f(g(x, y), z)"$ splits into $("f", ("g(x, y)", "z"))$.
lambdify_functions
Tarang.lambdify_functions — Function
lambdify_functions(call::AbstractString, result::AbstractString)Convert a math-style definition $"f(x, y)"$/$"x*y"$ into a Julia anonymous function encoded as a string $"(x,y) -> x*y"$. Returns the original result for non-call statements to preserve standard semantics.
Reading NetCDF
Using NetCDF.jl
Output files are NetCDF-4 with groups, so the task data is not at the file root: a plain NetCDF.ncread(file, "T") fails with "does not have a variable named T", and ncinfo lists no variables. Read through the group helpers instead:
using Tarang, NetCDF
file = "snapshots/snapshots_s1/snapshots_s1.nc"
Tarang.group_variable_names(file, "vars") # ["T", "T_mean", "T_min", "T_max", ...]
T = Tarang.group_ncread(file, "vars", "T") # size (writes, Nx, Nz)
times = Tarang.group_ncread(file, "time", "sim_time") # size (writes,)
x = Tarang.group_ncread(file, "grids", "x") # size (Nx,)
NetCDF.ncgetatt(file, "global", "title") # "Tarang.jl simulation output"File Structure
NetCDF Layout
Three groups: vars (the tasks), time (per-write metadata), grids (coordinates). The write index is the leading dimension of every task variable.
snapshots/snapshots_s1/snapshots_s1.nc
├── vars
│ ├── T (sim_time, x, z)
│ ├── T_profile_z (sim_time, T_profile_z_dim1)
│ └── T_min, T_max, ... (sim_time, 1)
├── time
│ ├── sim_time [N_writes] (unlimited dimension)
│ ├── wall_time [N_writes]
│ ├── timestep [N_writes]
│ ├── iteration [N_writes]
│ └── write_number[N_writes]
├── grids
│ ├── x [Nx]
│ ├── z [Nz]
│ └── <task>_dim1 [...] for reduced/derived tasks
└── global attributesEach task variable also carries layout ("g"/"c"), grid_space, and — for a distributed run — the start/count/global_shape/local_shape of that rank's slab, which is what merge_netcdf_files uses to reconstruct the global field.
Global Attributes
Written automatically:
| Attribute | Value |
|---|---|
title | "Tarang.jl simulation output" |
handler_name | Handler name from path |
software | "Tarang" |
software_repository | Repository URL |
tarang_version | Package version |
institution, source, history, Conventions | Provenance / CF-1.8 |
set_number, writes | Current file set and its write count |
mpi_size, processor_rank | Only when running on more than one rank |
Read them with NetCDF.ncgetatt(file, "global", name).
Checkpoint and restart
save_state writes every evolved field in solver.state plus sim_time, iteration and dt. load_state! reads it back.
save_state(solver, "checkpoints/run1")
# ... later, or in a new process ...
load_state!(solver, "checkpoints/run1")Serial runs produce run1.nc. Under MPI each rank writes its own slab to run1/run1_p<rank>.nc with no gather, so rank 0 never has to hold the whole field. load_state! reads a checkpoint written at any rank count: each rank works out the range it needs and reads only the overlapping hyperslabs.
GPU
NetCDF reads into host memory, so the loader stages through a host buffer and then performs one explicit upload into the field's existing device storage.
What is actually tested: single-device GPU staging, via JLArray emulation on a serial 1-D field — it proves load_field! uploads into the device array rather than replacing it with a host array. GPU + MPI checkpointing is untested. It takes a different geometry branch entirely (first-dims decomposition with the TransposableField convention, not the PencilArrays one), no test executes that branch, and this repository's GPU CI pipeline is inert. Treat a distributed GPU checkpoint as unverified.
Restart fidelity
One-step schemes restart exactly: RK111, RK222, RK443, RK443_IMEX, RKSMR, RKGFY, ETD_RK222, DiagonalIMEX_RK222 and DiagonalIMEX_RK443. So do the first-order multistep bootstraps CNAB1 and SBDF1 — they depend only on the current state, so there is no history to lose.
Every other scheme is multistep: it stores time levels the checkpoint does not carry, so it re-seeds on restart and warns, naming the number of reduced-order steps. The run stays correct but is not bit-identical to an uninterrupted one.
| Scheme | Reduced-order steps after restart |
|---|---|
| CNAB2, MCNAB2, CNLF2 | 1 |
| SBDF2, ETD_SBDF2, ETD_CNAB2 | 1 |
| SBDF3 | 2 |
| SBDF4 | 3 |
| DiagonalIMEX_SBDF2 | 1 |
Note DiagonalIMEX_SBDF2: despite the family name it is a multi-step method (so says its own docstring) and does not restart exactly. The other two DiagonalIMEX_* schemes do.
Field-level save and load
save_field and load_field! write and read a single ScalarField. They are the layer save_state/load_state! are built on, and are useful on their own for dumping one field outside the solver's checkpoint cadence.
save_field(u, "dumps/u") # -> "dumps/u.nc"; returns the path written
load_field!(v, "dumps/u", "u") # v must have the same global shapeThe signature is save_field(field, filename, dataset_name="field") and load_field!(field, filename, dataset_name="field"). dataset_name is the NetCDF variable name, so several fields can share one file — which is exactly how a VectorField's components are stored:
for (i, component) in enumerate(w.components)
save_field(component, "dumps/w", "component_$i")
endsave_field is additive. Writing a new variable into an existing file appends to it; re-writing a variable that is already there rewrites the file, which NetCDF can only do by deleting it — so that is allowed only when the variable is the file's sole occupant, and refused with an error naming what would be lost otherwise.
save_field returns <stem>/<stem>_p<rank>.nc on more than one rank, and each rank writes only its own slab — there is no gather. Point load_field! at the directory ("dumps/u"), not at an individual _p<rank>.nc, and it reassembles this rank's region from whichever slabs overlap it. That works at any rank count, including a count different from the one that wrote. On one rank both functions use a plain <stem>.nc.
load_field! also reads a NetCDFFileHandler output directory, since those variables carry the same start/count/global_shape attributes. Handler variables have a leading unlimited sim_time axis, and the most recent write is the one loaded.
Parallel I/O
There is no gather-to-rank-0 write path. On more than one rank every rank writes its own file, containing its own slab:
snapshots/snapshots_s1/snapshots_s1_p0.nc # rank 0's slab, e.g. (writes, 16, 8)
snapshots/snapshots_s1/snapshots_s1_p1.nc # rank 1's slabReassemble them afterwards with merge_netcdf_files (see File Merging). Whole-domain reductions (add_mean_task!, add_rms_task!, add_variance_task!, add_extrema_task! without dims) are MPI-aware: every rank's file already holds the global value.
The parallel keyword ("gather", the default, or "virtual") only chooses the serial file name: with parallel="gather" on one rank you get a single snapshots_s1.nc; otherwise the file is named ..._p<rank>.nc. It does not move data between ranks.
File Management
File Naming
Each file set lives in its own directory, and sets are numbered sequentially — a new set starts once max_writes writes have landed in the current one:
snapshots/snapshots_s1/snapshots_s1.nc # first set
snapshots/snapshots_s2/snapshots_s2.nc # after max_writes reached
snapshots/snapshots_s3/snapshots_s3.nc # etc.With mode="overwrite" (the default) the handler deletes pre-existing sets of the same name when it is constructed.
Performance Tips
- Batch writes: use a
sim_dt/itercadence rather than writing every iteration - Cap file size:
max_writesrolls over to a new set instead of one huge file - Write what you need: reduction tasks (profiles, means, extrema) are far cheaper to store than full fields
- Merge offline: reassemble per-rank files after the run, not during it
See Also
- Analysis Tutorial: Complete examples
- Parallelism: MPI considerations