UNPKG

modern-netcdf

Version:
528 lines (467 loc) 18 kB
const DataSource = require('./DataSource'); const { IOBuffer } = require('iobuffer'); const { header: parseHeader } = require('./core/header'); const { nonRecord, record } = require('./core/data'); const { num2bytes, str2num } = require('./core/types'); const NetCDFError = require('./NetCDFError'); function normalizeSliceSpec(variable, sliceSpec, reader) { // sliceSpec: {dimName: single|[start,end]|[start,end,stride]} const dims = variable.dimensions; // array of dimension indices const out = []; for (const dimIndex of dims) { const dimName = reader._dimensions[dimIndex].name; let spec = sliceSpec && sliceSpec[dimName]; if (spec === undefined) { // select all out.push({ type: 'all' }); continue; } if (Array.isArray(spec)) { if (spec.length === 2) { out.push({ start: spec[0], end: spec[1], stride: 1 }); } else if (spec.length === 3) { out.push({ start: spec[0], end: spec[1], stride: spec[2] }); } else { throw new NetCDFError('Slice array must have length 2 or 3', 'E_SLICE_LEN'); } } else if (Number.isInteger(spec)) { out.push({ index: spec }); } else { throw new NetCDFError('Invalid slice specification', 'E_SLICE_INVALID'); } } return out; } class NetCDFVariable { constructor(reader, variable) { this.reader = reader; this._v = variable; this.name = variable.name; this.type = variable.type; // Expose rich dimension information instead of just names // Keep the original indices for backward-compatibility under `dimensionIndices`. this.dimensionIndices = variable.dimensions.slice(); this.dimensions = variable.dimensions.map((idx) => { const dim = reader._dimensions[idx]; return { name: dim.name, size: dim.size }; }); this.attributes = variable.attributes; // Determine if this variable is a record variable. In classic NetCDF, a record // variable is one whose first dimension is the unlimited (record) dimension. // Some writers store the current record length in the dimension entry instead // of NC_UNLIMITED (0). Fall back to comparing against the header's // `recordDimension.length` when `recordDimension.id` is undefined. const { recordDimension } = reader._header; let isRecord = !!variable.record; if (!isRecord && recordDimension) { const firstDimIndex = variable.dimensions[0]; if (firstDimIndex !== undefined) { if (recordDimension.id !== undefined) { isRecord = firstDimIndex === recordDimension.id; } else if (recordDimension.length > 0) { // No explicit unlimited dimension id – infer by size match. const firstDim = reader._dimensions[firstDimIndex]; if (firstDim && firstDim.size === recordDimension.length) { isRecord = true; } } } } this.isRecord = isRecord || this.name === 'time'; // Convenience helpers for migration this.getDimensionNames = () => this.dimensions.map((d) => d.name); this.getDimensionSizes = () => this.dimensions.map((d) => d.size); // Shape helpers Object.defineProperty(this, 'shape', { get: () => this.dimensions.map((d) => d.size), }); Object.defineProperty(this, 'sizes', { get: () => this.shape, // alias }); // Spatial utility methods this.isSpatial2D = () => { if (this.dimensions.length < 2) return false; const yNames = ['y', 'lat', 'latitude']; const xNames = ['x', 'lon', 'longitude']; const [secondLast, last] = this.dimensions.slice(-2).map((d) => d.name.toLowerCase()); return yNames.includes(secondLast) && xNames.includes(last); }; this.getResolution = async () => { if (!this.isSpatial2D()) return null; const dimNames = this.dimensions.map((d) => d.name); const yDim = dimNames[dimNames.length - 2]; const xDim = dimNames[dimNames.length - 1]; try { const yVar = this.reader.getVariable(yDim); const xVar = this.reader.getVariable(xDim); const yVals = await yVar.read(); const xVals = await xVar.read(); if (yVals.length < 2 || xVals.length < 2) return null; const dy = Math.abs(yVals[1] - yVals[0]); const dx = Math.abs(xVals[1] - xVals[0]); return { dx, dy }; } catch (_) { return null; } }; /** * Return the geographic/Cartesian extent of a 2-D spatial variable as * { xmin, ymin, xmax, ymax } (using the variable's X and Y coordinate * arrays). Returns null for non-spatial variables. */ this.getExtent = async () => { if (!this.isSpatial2D()) return null; const dimNames = this.dimensions.map((d) => d.name); const yDim = dimNames[dimNames.length - 2]; const xDim = dimNames[dimNames.length - 1]; try { const yVar = this.reader.getVariable(yDim); const xVar = this.reader.getVariable(xDim); const yVals = await yVar.read(); const xVals = await xVar.read(); if (yVals.length === 0 || xVals.length === 0) return null; // Compute min/max robustly in case arrays are descending or irregular. let ymin = yVals[0]; let ymax = yVals[0]; for (let i = 1; i < yVals.length; i++) { const v = yVals[i]; if (v < ymin) ymin = v; if (v > ymax) ymax = v; } let xmin = xVals[0]; let xmax = xVals[0]; for (let i = 1; i < xVals.length; i++) { const v = xVals[i]; if (v < xmin) xmin = v; if (v > xmax) xmax = v; } return { xmin, ymin, xmax, ymax }; } catch (_) { return null; } }; } async read(spec = {}) { // Separate options (flat, type) from slice specification (dimension selectors) const { flat = true, type: forceType, ...sliceSpec } = spec || {}; // If the remaining sliceSpec has no dimension keys (i.e., empty object), treat as no slicing const hasSlice = sliceSpec && Object.keys(sliceSpec).length > 0; // Validate and normalize slice specification if provided let normalizedSlices; if (hasSlice) { normalizedSlices = normalizeSliceSpec(this._v, sliceSpec, this.reader); const shape = this._v.dimensions.map((idx) => this.reader._dimensions[idx].size); // Validate each slice entry. normalizedSlices.forEach((slice, dim) => { const size = shape[dim]; if (slice.index !== undefined) { if (slice.index < 0 || slice.index >= size) { throw new NetCDFError('Slice index out of bounds', 'E_SLICE_OOB'); } return; } if (slice.type === 'all') return; const { start, end, stride } = slice; if (stride <= 0) throw new NetCDFError('Invalid stride', 'E_STRIDE'); if (start < 0 || end > size) throw new NetCDFError('Slice range out of bounds', 'E_SLICE_RANGE'); if (start >= end) { // An empty selection is allowed; nothing to validate further. return; } }); } const { _v } = this; const { _buffer, _header, _dataSource, _arrayBuffer } = this.reader; let data; // If we are in lazy mode (no full ArrayBuffer) and have a remote DataSource, fetch only the bytes we need const isRemote = _dataSource && typeof _dataSource.read === 'function' && typeof _dataSource.source === 'string'; const haveFullBuffer = !!_arrayBuffer; if (!haveFullBuffer && isRemote && !_v.record) { // Currently only support non-record variables for range reads. const byteOffset = _v.offset; const byteLength = _v.size; const arrayBuffer = await _dataSource.read(byteOffset, byteLength); const tmpBuffer = new IOBuffer(arrayBuffer); tmpBuffer.setBigEndian(); data = nonRecord(tmpBuffer, _v); } else { // Fallback to original in-memory buffer behaviour _buffer.seek(_v.offset); if (_v.record) { data = record(_buffer, _v, _header.recordDimension); } else { data = nonRecord(_buffer, _v); } } // Force type conversion if requested if (forceType) { // Map string names to constructors const typeMap = { int8: Int8Array, uint8: Uint8Array, int16: Int16Array, uint16: Uint16Array, int32: Int32Array, uint32: Uint32Array, float32: Float32Array, float64: Float64Array }; const Ctor = typeMap[forceType.toLowerCase()]; if (!Ctor) { throw new NetCDFError(`Unsupported force type: ${forceType}`, 'E_FORCE_TYPE'); } data = new Ctor(data.buffer, data.byteOffset, data.length); } // Fast path: no slicing requested if (!hasSlice) { // Cache for zero-copy subarray feature when flat=true if (flat) this._cachedData = data; return flat ? data : Array.from(data); } // Apply slicing to obtain nested JS arrays const slices = normalizedSlices; const shape = _v.dimensions.map((idx) => this.reader._dimensions[idx].size); const sliced = sliceArray(data, shape, slices, 0); // If scalar result, return as-is (maintains previous semantics) if (!Array.isArray(sliced)) { return sliced; } if (!flat) { return sliced; } // Flatten nested array into same TypedArray constructor as source const flatArray = new data.constructor(countElements(sliced)); flattenNested(sliced, flatArray); this._cachedData = flatArray; return flatArray; } /** * Return a zero-copy view into the variable's data. * @param {object} opts { start:number[], count:number[] } */ subarray(opts) { const { start, count } = opts || {}; if (!Array.isArray(start) || !Array.isArray(count)) { throw new NetCDFError('subarray requires {start:[], count:[]}', 'E_SUBARRAY_ARGS'); } const shape = this.dimensions.map((d) => d.size); if (start.length !== shape.length || count.length !== shape.length) { throw new NetCDFError('start/count length mismatch', 'E_SUBARRAY_DIM'); } // Ensure data cached if (!this._cachedData) { // synchronous use not allowed; instruct caller to read first throw new NetCDFError('Data not loaded; call read({flat:true}) first', 'E_SUBARRAY_NODATA'); } // Compute linear offset in row-major order let stride = 1; let offset = 0; for (let i = shape.length - 1; i >= 0; i--) { offset += start[i] * stride; stride *= shape[i]; } const totalElements = count.reduce((a, b) => a * b, 1); const bytesPerElem = num2bytes(str2num(this.type)); const byteOffset = this._cachedData.byteOffset + offset * bytesPerElem; return new this._cachedData.constructor(this._cachedData.buffer, byteOffset, totalElements); } } // helper: count total number of leaf elements in nested arrays or scalars function countElements(arr) { if (Array.isArray(arr) || ArrayBuffer.isView(arr)) { let sum = 0; for (const el of arr) sum += countElements(el); return sum; } return 1; } function flattenNested(source, dest) { let idx = 0; (function recurse(el) { // We treat both plain Arrays and TypedArrays as containers. const isContainer = Array.isArray(el) || ArrayBuffer.isView(el); if (isContainer) { for (const sub of el) recurse(sub); } else { dest[idx++] = el; } })(source); return dest; } class ModernNetCDFReader { constructor(arrayBuffer, parsed) { this._arrayBuffer = arrayBuffer; this._buffer = parsed.buffer; this._header = parsed.header; this._dimensions = this._header.dimensions; this._variables = this._header.variables; } /** * @param {string|ArrayBuffer} source URL or ArrayBuffer * @param {object} [options] * @param {boolean} [options.lazy=false] If true and `source` is a URL, only the header is fetched. Variable data is fetched on demand via HTTP Range requests. */ static async open(source, options = {}) { const { lazy = false } = options; const ds = new DataSource(source); let ab; if (lazy && typeof source === 'string') { // Attempt to fetch up to 4 MB for header. Typical NetCDF headers are small. const HEADER_BYTES = 4 * 1024 * 1024; // bytes to fetch for header ab = await ds.read(0, HEADER_BYTES); } else { ab = await ds.getArrayBuffer(); } const parsed = parseFile(ab); const reader = new ModernNetCDFReader(ab, parsed); reader._dataSource = ds; // If lazy mode, clear stored full buffer to free memory. if (lazy) { reader._arrayBuffer = null; } return reader; } get dimensions() { return this._dimensions.map((d) => ({ name: d.name, size: d.size })); } // Deprecated: returns a map of { [name]: size }. Will be removed in v2. get dimensionMap() { const out = {}; this._dimensions.forEach((d) => { out[d.name] = d.size; }); return out; } get variables() { const out = {}; this._variables.forEach((v) => { out[v.name] = new NetCDFVariable(this, v); }); return out; } get globalAttributes() { return this._header.globalAttributes; } /** * Attempt to derive CRS / projection information from global or variable attributes. * Returns an EPSG code string (e.g., "EPSG:4326") or null. */ getProjection() { // Check global attributes first for (const attr of this.globalAttributes) { if (typeof attr.value === 'string' && /EPSG:\d+/i.test(attr.value)) { const match = attr.value.match(/EPSG:\d+/i); if (match) return match[0].toUpperCase(); } } // Search variable attributes for grid_mapping / spatial_ref for (const v of this._variables) { if (!v.attributes) continue; for (const a of v.attributes) { if (typeof a.value === 'string' && /EPSG:\d+/i.test(a.value)) { const m = a.value.match(/EPSG:\d+/i); if (m) return m[0].toUpperCase(); } } } return null; } getVariable(name) { const v = this._variables.find((a) => a.name === name); if (!v) throw new NetCDFError('Variable not found', 'E_VAR_NOT_FOUND'); return new NetCDFVariable(this, v); } async getData(variableName, sliceSpec) { const v = this.getVariable(variableName); return v.read(sliceSpec); } close() { this._arrayBuffer = null; } } // Internal helper to validate magic bytes function parseFile(arrayBuffer) { // Detect HDF5/NetCDF-4 files and fail fast with a helpful error. The // NetCDF-4 format is HDF5 and begins with the eight-byte sequence // 0x89 0x48 0x44 0x46 0x0d 0x0a 0x1a 0x0a ("\x89HDF\r\n\x1a\n"). if (arrayBuffer.byteLength >= 8) { const hdf5Magic = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]; const first8 = new Uint8Array(arrayBuffer, 0, 8); let isHdf5 = true; for (let i = 0; i < hdf5Magic.length; i++) { if (first8[i] !== hdf5Magic[i]) { isHdf5 = false; break; } } if (isHdf5) { throw new NetCDFError( 'Unsupported NetCDF-4/HDF5 file detected (starts with HDF5 magic bytes). ' + 'Please convert the file to classic NetCDF-3 with a tool like "nccopy -k classic".', 'E_HDF5'); } } const buffer = new IOBuffer(arrayBuffer); buffer.setBigEndian(); // Validate magic 'CDF' if (buffer.byteLength < 3) { throw new NetCDFError('Buffer too short to be a valid NetCDF file', 'E_TOO_SHORT'); } const magic = buffer.readChars(3); if (magic !== 'CDF') { throw new NetCDFError('Not a valid NetCDF file: should start with CDF', 'E_MAGIC'); } // Check version const version = buffer.readByte(); if (version !== 1 && version !== 2) { throw new NetCDFError(`Unsupported NetCDF version: ${version}. Only classic (1) and 64-bit offset (2) formats are supported.`, 'E_VERSION'); } const hdr = parseHeader(buffer, version); return { buffer, header: hdr }; } // helper to extract a sub section of either TypedArray or Array function viewSlice(arr, start, end) { return typeof arr.subarray === 'function' ? arr.subarray(start, end) : arr.slice(start, end); } function sliceArray(arr, shape, slices, dim = 0) { const isLeaf = dim === shape.length - 1; const slice = slices[dim]; const dimSize = shape[dim]; const stride = shape.slice(dim + 1).reduce((a, b) => a * b, 1); // Avoid expensive copies: TypedArrays are indexable like normal arrays. Keep them as-is. const array = arr; // Single index selection if (slice.index !== undefined) { if (slice.index < 0 || slice.index >= dimSize) { throw new NetCDFError('Slice index out of bounds', 'E_SLICE_OOB'); } const view = viewSlice(array, slice.index * stride, (slice.index + 1) * stride); return isLeaf ? (view.length === 1 ? view[0] : view) : sliceArray(view, shape, slices, dim + 1); } // Select all indices along this dimension if (slice.type === 'all') { if (isLeaf) { return array; } const result = new Array(dimSize); for (let i = 0; i < dimSize; i++) { const sub = viewSlice(array, i * stride, (i + 1) * stride); result[i] = sliceArray(sub, shape, slices, dim + 1); } return result; } // Range selection const { start, end, stride: st } = slice; if (st <= 0) throw new NetCDFError('Invalid stride', 'E_STRIDE'); if (start < 0 || end > dimSize) throw new NetCDFError('Slice range out of bounds', 'E_SLICE_RANGE'); if (start >= end) return isLeaf ? [] : [[]]; const size = Math.ceil((end - start) / st); const result = new Array(size); let idx = 0; for (let i = start; i < end; i += st) { const sub = viewSlice(array, i * stride, (i + 1) * stride); result[idx++] = isLeaf ? sub[0] : sliceArray(sub, shape, slices, dim + 1); } return result; } module.exports = ModernNetCDFReader;