enx-wasm-parser
v0.5.0
Published
Parse Teledyne RDI PD0/ENX acoustic data files via Rust/WebAssembly, off the main thread
Maintainers
Readme
enx-wasm-parser
A Rust/WebAssembly npm package that parses Teledyne RDI PD0/ENX binary acoustic Doppler current profiler (ADCP) files entirely off the main thread, converts GPS coordinates to local 3D Cartesian space at 64-bit precision, and exposes a clean TypeScript API.
Features
- Pure Wasm core — all binary parsing happens in Rust compiled to WebAssembly; no native Node addons, no server round-trips
- Off main thread — runs inside a Web Worker automatically; the UI never blocks
- Zero-copy transfers — the input
ArrayBufferis transferred (not copied) to the worker; the binary outputFloat32Arrayis transferred back - Bottom-track velocity correction — when the file contains a Bottom Track block (
0x0600), the boat's own velocity is automatically subtracted from each depth bin to produce true absolute water velocity; uncorrected pings are still returned (withbtCorrectionApplied: false) so no data is lost - Side-lobe contamination removal — bins within 2 cell-lengths of the measured riverbed are automatically discarded; this is the standard ADCP physics cutoff that causes valid bin count to vary dynamically with water depth, consistent with DOLfYN and Teledyne's own reference software
- Correct depth formula — bin range is computed as
bin1_dist + i × cell_sizeusing the firmware-computedbin1_distfield (Fixed Leader bytes 32–33), which already incorporates speed-of-sound and profiling-mode corrections; consistent with DOLfYN - Instrument config in metadata — every parse result includes
numCells,cellSizeM,bin1DistM,theoreticalMaxDepthM, andcoordSysfrom the Fixed Leader so callers can verify configuration without re-reading the file - Strict error handling — any malformed ensemble (bad magic bytes or checksum mismatch) throws immediately; no silent data corruption
- Missing-data aware — Teledyne's
-32768sentinel is detected per bin; corrupt bins are dropped and counted in metadata - Two output modes — structured JSON for inspection/debugging, or a flat interleaved
Float32Arrayready for WebGL upload - f64 geo-precision — GPS→Cartesian conversion is done in 64-bit Rust before downcasting to f32 for output
Supported File Types
| Extension | Description |
|---|---|
| .enx | VmDas ensemble file (includes GPS nav block) |
| .ens | VmDas ensemble file |
| .sta | VmDas short-term average |
| .lta | VmDas long-term average |
| .pd0 | Raw WorkHorse PD0 binary (GPS nav block may be absent) |
Note: Raw
.enrfiles do not contain the VmDas navigation block (0x2000). Pings without GPS data are skipped — they cannot be placed in Cartesian space.
Output Format
Binary mode (outputFormat: 'binary')
Returns a Float32Array with 7 floats per valid depth bin, across all pings, interleaved sequentially:
[ X, Y, Z, U, V, W, Intensity, X, Y, Z, U, V, W, Intensity, ... ]| Field | Unit | Description | |---|---|---| | X | meters | East offset from anchor | | Y | meters | Negative depth (−5 m = 5 m below surface) | | Z | meters | North offset from anchor | | U | m/s | Eastward velocity (BT-corrected when bottom track was present) | | V | m/s | Northward velocity (BT-corrected when bottom track was present) | | W | m/s | Vertical velocity (BT-corrected when bottom track was present) | | Intensity | counts (0–255) | Mean echo intensity across 4 beams |
JSON mode (outputFormat: 'json')
Returns a serialized JSON string of the form:
[
{
"timestamp": "2023-07-14T12:34:56.00Z",
"x": 12.3,
"z": -45.6,
"bt_correction_applied": true,
"bt_east_m_s": 1.234,
"bt_north_m_s": -0.456,
"bt_vertical_m_s": 0.012,
"bins": [
{ "y": -5.0, "u": 0.123, "v": -0.045, "w": 0.002, "intensity": 87.25 }
]
}
]u/v/w in each bin are absolute water velocities when bt_correction_applied is true. When false (no bottom track block in that ensemble), they are instrument-relative and include the boat's motion.
Installation
npm install enx-wasm-parserOr clone and build from source (see Building from Source).
Usage
import { ENXParser } from 'enx-wasm-parser';
const parser = new ENXParser();
// Get an ArrayBuffer from a file input, fetch, or fs.readFile
const fileBuffer: ArrayBuffer = /* ... */;
const result = await parser.parse(fileBuffer, {
outputFormat: 'binary', // 'binary' | 'json' (default: 'json')
anchor: {
lat: 45.27, // WGS-84 decimal degrees
lon: -66.05,
},
});
// Binary mode
const floats = result.data as Float32Array;
const totalBins = floats.length / 7;
console.log(`${totalBins} valid depth bins`);
// JSON mode
const pings = JSON.parse(result.data as string);
console.log(pings[0]);
// Metadata
console.log(result.metadata);
// {
// totalPings: 1200,
// validPings: 1198,
// droppedCorruptBins: 14,
// pingsWithoutGps: 0, // >0 for raw PD0 files (no GPS block)
// pingsWithBtCorrection: 1198, // 0 means file has no bottom track data
// durationMs: 42.3,
// // Instrument configuration (from Fixed Leader, constant per deployment):
// numCells: 80,
// cellSizeM: 0.5,
// bin1DistM: 1.67, // range to centre of bin 1 from transducer
// theoreticalMaxDepthM: 41.17, // bin1DistM + (numCells-1) × cellSizeM
// coordSys: 'earth' // 'beam' | 'inst' | 'ship' | 'earth'
// // ⚠ if not 'earth', u/v/w are NOT east/north/up
// }
// Release the worker when done
parser.dispose();File input example (browser)
document.querySelector('input[type=file]').addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files![0];
const buffer = await file.arrayBuffer();
const parser = new ENXParser();
const result = await parser.parse(buffer, {
outputFormat: 'binary',
anchor: { lat: 45.27, lon: -66.05 },
});
console.log(result.metadata);
});Important:
ArrayBufferis transferred zero-copy to the worker. After callingparse(), the originalbufferreference becomes detached (neutered). If you need to re-read the bytes, clone the buffer before passing it:buffer.slice(0).
API Reference
new ENXParser()
Creates a new parser instance. The Web Worker is not spawned until the first parse() call.
parser.parse(buffer, options): Promise<ParseResult>
Parses the binary file and returns a promise that resolves with the result.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
| buffer | ArrayBuffer | Yes | The raw ENX/PD0 file bytes |
| options.anchor | { lat: number, lon: number } | Yes | Geographic anchor point for Cartesian conversion |
| options.outputFormat | 'json' \| 'binary' | No | Output format (default: 'json') |
Returns: Promise<ParseResult>
interface ParseResult {
data: Float32Array | string; // Float32Array for binary, JSON string for json
metadata: {
totalPings: number; // all ensembles found in the file
validPings: number; // ensembles with all required acoustic blocks present
droppedCorruptBins: number; // bins dropped due to -32768 sentinel or side-lobe cutoff
pingsWithoutGps: number; // pings with no VmDas GPS block (raw PD0 files); x/z = null
pingsWithBtCorrection: number; // pings where boat velocity was subtracted; 0 = no BT data in file
durationMs: number; // parse wall-clock time in milliseconds
// Instrument configuration (Fixed Leader — constant for the deployment):
numCells: number; // depth cells per ping
cellSizeM: number; // cell size in metres
bin1DistM: number; // range to centre of bin 1 from transducer face (metres)
theoreticalMaxDepthM: number; // bin1DistM + (numCells-1) × cellSizeM
coordSys: 'beam' | 'inst' | 'ship' | 'earth'; // velocity coordinate system
};
}Throws: If the file fails magic-byte or checksum validation, the promise rejects with an Error describing the failure location.
parser.dispose()
Terminates the background Web Worker and rejects any pending parse() promises. Call this when the parser is no longer needed to free memory.
Building from Source
Prerequisites
| Tool | Install |
|---|---|
| Rust + Cargo | https://rustup.rs |
| wasm-pack | cargo install wasm-pack |
| Node.js ≥ 18 | https://nodejs.org |
Steps
# 1. Clone
git clone https://github.com/AmirhosseinAzimyzadeh/enx-wasm-parser
cd enx-wasm-parser
# 2. Build the Wasm module
npm run build:wasm
# Outputs to pkg/
# 3. Install JS dependencies
npm install
# 4. Type-check the TypeScript
npx tsc --noEmit
# 5. Run the example dev server
npm run dev
# → http://localhost:5173Build scripts
| Script | What it does |
|---|---|
| npm run build:wasm | Runs wasm-pack build --target bundler --out-dir pkg |
| npm run build:ts | Compiles TypeScript to dist/ |
| npm run build | Runs both of the above in sequence |
| npm run dev | Starts the Vite dev server for the example app |
Project Structure
enx-wasm-parser/
├── src/ # Rust source
│ ├── lib.rs / lib.md # wasm-bindgen entry point
│ ├── parser.rs / parser.md # ensemble loop + checksum
│ ├── header.rs / header.md # 0x7F 0x7F magic + offset table
│ ├── fixed_leader.rs # block 0x0000 — num_cells, cell_size, bin1_dist, coord_sys
│ ├── variable_leader.rs # block 0x0080 — timestamp
│ ├── velocity.rs # block 0x0100 — u/v/w per bin
│ ├── echo_intensity.rs # block 0x0300 — signal strength
│ ├── vmdas_nav.rs # block 0x2000 — GPS lat/lon
│ ├── bottom_track.rs # block 0x0600 — boat velocity + slant range to bed
│ ├── coords.rs # GPS→Cartesian (f64) + bin depth
│ ├── output.rs # JSON / Float32Array serialization
│ └── error.rs # ParseError type
├── ts/ # TypeScript source
│ ├── index.ts / index.md # ENXParser public API class
│ ├── worker.ts / worker.md # Web Worker entry point
│ └── types.ts # Shared interfaces
├── example/ # Vite demo app
│ ├── index.html
│ ├── main.ts
│ └── vite.config.ts
├── pkg/ # wasm-pack output (gitignored)
├── Cargo.toml
├── package.json
├── tsconfig.json
├── vite.config.ts
└── RESEARCH.md # Binary format spec + design decisionsEvery Rust and TypeScript module has a companion .md file documenting its exact purpose, binary layout, and data flow.
How It Works
Parse pipeline
File ArrayBuffer
└─► Web Worker (off main thread)
└─► Rust/Wasm: parse_enx()
├─ scan for 0x7F 0x7F ensemble boundaries
├─ validate 16-bit checksum per ensemble
├─ read Header → offset table
├─ parse Fixed Leader → num_cells, cell_size, bin1_dist, coord_sys
├─ parse Variable Leader → timestamp
├─ parse Velocity block → u/v/w per bin (drop -32768 sentinel bins)
├─ parse Echo Intensity → mean of 4 beams per bin
├─ parse VmDas Nav → GPS lat/lon (i32 × 1e-7)
├─ parse Bottom Track → boat ENU velocity + slant range to bed
├─ subtract BT velocity → true water velocity (when all 3 components valid)
├─ bin depth = bin1_dist + i × cell_size (range from transducer, metres)
├─ drop bins where depth > bt_range − 2 × cell_size (side-lobe zone)
├─ GPS → Cartesian (f64 equirectangular)
└─ serialize → Float32Array or JSON
└─► transfer result back to main thread
└─► Promise resolvesCoordinate system
The anchor point (user-supplied lat/lon) becomes the origin (0, 0, 0).
- X — East, meters
- Y — negative depth, meters (
Y = −5means 5 m below surface) - Z — North, meters
Conversion uses the equirectangular approximation with midpoint-latitude correction, accurate to sub-meter for survey distances under ~500 km.
Depth calculation
Bin depth is the range from the transducer face to the centre of the bin (not absolute depth below the water surface):
depth_m = bin1_dist_m + bin_index × cell_size_mbin1_dist_m comes from Fixed Leader bytes 32–33 — a firmware-computed value that already incorporates speed-of-sound corrections and profiling-mode adjustments. This is consistent with DOLfYN and Teledyne's own documentation. The older approximation (blank_distance + (i + 0.5) × cell_size) is not used.
Side-lobe contamination removal
ADCP beams have a finite opening angle (~20°). As the acoustic pulse approaches the riverbed, side-lobe energy reflects off the bottom before the main lobe does, contaminating the deepest bins with bed-echo rather than water velocity. The parser automatically discards any bin where:
depth_m > bt_range_m − 2 × cell_size_mbt_range_m is the mean slant range to the bed measured by the Bottom Track block. The 2-cell margin matches the BOTTOM_OFFSET_BINS = 2 constant used in Teledyne's reference software. This filter causes valid bin count to vary dynamically per ping as the water depth changes — this is expected and correct.
Missing-data handling
Teledyne RDI uses the value -32768 as a "no valid data" sentinel for the East velocity component. When the parser encounters this value in a bin:
- The entire bin (all four velocity components + intensity) is discarded
droppedCorruptBinsin the metadata is incremented- Surrounding bins in the same ping are unaffected
Error Handling
The parser is intentionally strict:
| Condition | Result |
|---|---|
| File does not start with 0x7F 0x7F | Promise rejects with fatal error |
| Ensemble checksum mismatch | Promise rejects with fatal error |
| Required block missing or malformed | Promise rejects with fatal error |
| Velocity bin = -32768 (missing data) | Bin silently dropped, droppedCorruptBins incremented |
| Bin depth > bt_range − 2 × cell_size | Bin silently dropped (side-lobe contamination zone), droppedCorruptBins incremented |
| Bottom Track block absent | Bins kept; no side-lobe cutoff applied; velocities uncorrected (btCorrectionApplied: false) |
| Bottom Track velocity component = -32768 | BT correction skipped for that ensemble; partial correction would introduce systematic error |
| VmDas nav block absent in an ensemble | Ping included with x/z = null; pingsWithoutGps incremented |
License
MIT
