Browse Source

add nctraj format support

Alexander Rose 3 years ago
parent
commit
55ff1d4999

+ 1 - 1
CHANGELOG.md

@@ -13,7 +13,7 @@ Note that since we don't clearly distinguish between a public and private interf
 - Fix loading of some compressed files within sessions
 - Fix wrong element assignment for atoms with Charmm ion names
 - Fix handling of empty symmetry cell data
-- Add support for ``trr`` coordinates files
+- Add support for ``trr`` and ``nctraj`` coordinates files
 - Add support for ``prmtop`` topology files
 
 ## [v3.3.1] - 2022-02-27

+ 453 - 0
src/mol-io/common/io-buffer.ts

@@ -0,0 +1,453 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * Adapted and converted to TypeScript from https://github.com/image-js/iobuffer
+ * MIT License, Copyright (c) 2015 Michaël Zasso
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { TypedArray } from '../../mol-util/type-helpers';
+
+const defaultByteLength = 1024 * 8;
+const charArray: string[] = [];
+
+export interface IOBufferParameters {
+    offset?: number // Ignore the first n bytes of the ArrayBuffer
+}
+
+/**
+ * Class for writing and reading binary data
+ */
+export class IOBuffer {
+    private _lastWrittenByte: number;
+    private _mark = 0;
+    private _marks: number[] = [];
+    private _data: DataView;
+
+    offset = 0; // The current offset of the buffer's pointer
+    littleEndian = true;
+    buffer: ArrayBuffer; // Reference to the internal ArrayBuffer object
+    length: number; // Byte length of the internal ArrayBuffer
+    byteLength: number; // Byte length of the internal ArrayBuffer
+    byteOffset: number; // Byte offset of the internal ArrayBuffer
+
+    /**
+     * If it's a number, it will initialize the buffer with the number as
+     * the buffer's length. If it's undefined, it will initialize the buffer
+     * with a default length of 8 Kb. If its an ArrayBuffer, a TypedArray,
+     * it will create a view over the underlying ArrayBuffer.
+     */
+    constructor(data: number | ArrayBuffer | TypedArray, params: IOBufferParameters = {}) {
+        let dataIsGiven = false;
+        if (data === undefined) {
+            data = defaultByteLength;
+        }
+        if (typeof data === 'number') {
+            data = new ArrayBuffer(data);
+        } else {
+            dataIsGiven = true;
+        }
+
+        const offset = params.offset ? params.offset >>> 0 : 0;
+        const byteLength = data.byteLength - offset;
+        let dvOffset = offset;
+        if (!(data instanceof ArrayBuffer)) {
+            if (data.byteLength !== data.buffer.byteLength) {
+                dvOffset = data.byteOffset + offset;
+            }
+            data = data.buffer;
+        }
+        if (dataIsGiven) {
+            this._lastWrittenByte = byteLength;
+        } else {
+            this._lastWrittenByte = 0;
+        }
+
+        this.buffer = data;
+        this.length = byteLength;
+        this.byteLength = byteLength;
+        this.byteOffset = dvOffset;
+
+        this._data = new DataView(this.buffer, dvOffset, byteLength);
+    }
+
+    /**
+     * Checks if the memory allocated to the buffer is sufficient to store more bytes after the offset
+     * @param byteLength The needed memory in bytes
+     */
+    available(byteLength: number = 1) {
+        return (this.offset + byteLength) <= this.length;
+    }
+
+    /**
+     * Check if little-endian mode is used for reading and writing multi-byte values
+     * Returns true if little-endian mode is used, false otherwise
+     */
+    isLittleEndian() {
+        return this.littleEndian;
+    }
+
+    /**
+     * Set little-endian mode for reading and writing multi-byte values
+     */
+    setLittleEndian() {
+        this.littleEndian = true;
+        return this;
+    }
+
+    /**
+     * Check if big-endian mode is used for reading and writing multi-byte values
+     * Returns true if big-endian mode is used, false otherwise
+     */
+    isBigEndian() {
+        return !this.littleEndian;
+    }
+
+    /**
+     * Switches to big-endian mode for reading and writing multi-byte values
+     */
+    setBigEndian() {
+        this.littleEndian = false;
+        return this;
+    }
+
+    /**
+     * Move the pointer n bytes forward
+     */
+    skip(n: number) {
+        if (n === undefined) n = 1;
+        this.offset += n;
+        return this;
+    }
+
+    /**
+     * Move the pointer to the given offset
+     */
+    seek(offset: number) {
+        this.offset = offset;
+        return this;
+    }
+
+    /**
+     * Store the current pointer offset.
+     */
+    mark() {
+        this._mark = this.offset;
+        return this;
+    }
+
+    /**
+     * Move the pointer back to the last pointer offset set by mark
+     */
+    reset() {
+        this.offset = this._mark;
+        return this;
+    }
+
+    /**
+     * Push the current pointer offset to the mark stack
+     */
+    pushMark() {
+        this._marks.push(this.offset);
+        return this;
+    }
+
+    /**
+     * Pop the last pointer offset from the mark stack, and set the current pointer offset to the popped value
+     */
+    popMark() {
+        const offset = this._marks.pop();
+        if (offset === undefined) throw new Error('Mark stack empty');
+        this.seek(offset);
+        return this;
+    }
+
+    /**
+     * Move the pointer offset back to 0
+     */
+    rewind() {
+        this.offset = 0;
+        return this;
+    }
+
+    /**
+     * Make sure the buffer has sufficient memory to write a given byteLength at the current pointer offset
+     * If the buffer's memory is insufficient, this method will create a new buffer (a copy) with a length
+     * that is twice (byteLength + current offset)
+     */
+    ensureAvailable(byteLength: number) {
+        if (byteLength === undefined) byteLength = 1;
+        if (!this.available(byteLength)) {
+            const lengthNeeded = this.offset + byteLength;
+            const newLength = lengthNeeded * 2;
+            const newArray = new Uint8Array(newLength);
+            newArray.set(new Uint8Array(this.buffer));
+            this.buffer = newArray.buffer;
+            this.length = this.byteLength = newLength;
+            this._data = new DataView(this.buffer);
+        }
+        return this;
+    }
+
+    /**
+     * Read a byte and return false if the byte's value is 0, or true otherwise
+     * Moves pointer forward
+     */
+    readBoolean() {
+        return this.readUint8() !== 0;
+    }
+
+    /**
+     * Read a signed 8-bit integer and move pointer forward
+     */
+    readInt8() {
+        return this._data.getInt8(this.offset++);
+    }
+
+    /**
+     * Read an unsigned 8-bit integer and move pointer forward
+     */
+    readUint8() {
+        return this._data.getUint8(this.offset++);
+    }
+
+    /**
+     * Alias for {@link IOBuffer#readUint8}
+     */
+    readByte() {
+        return this.readUint8();
+    }
+
+    /**
+     * Read n bytes and move pointer forward.
+     */
+    readBytes(n: number) {
+        if (n === undefined) n = 1;
+        const bytes = new Uint8Array(n);
+        for (let i = 0; i < n; i++) {
+            bytes[i] = this.readByte();
+        }
+        return bytes;
+    }
+
+    /**
+     * Read a 16-bit signed integer and move pointer forward
+     */
+    readInt16() {
+        const value = this._data.getInt16(this.offset, this.littleEndian);
+        this.offset += 2;
+        return value;
+    }
+
+    /**
+     * Read a 16-bit unsigned integer and move pointer forward
+     */
+    readUint16() {
+        const value = this._data.getUint16(this.offset, this.littleEndian);
+        this.offset += 2;
+        return value;
+    }
+
+    /**
+     * Read a 32-bit signed integer and move pointer forward
+     */
+    readInt32() {
+        const value = this._data.getInt32(this.offset, this.littleEndian);
+        this.offset += 4;
+        return value;
+    }
+
+    /**
+     * Read a 32-bit unsigned integer and move pointer forward
+     */
+    readUint32() {
+        const value = this._data.getUint32(this.offset, this.littleEndian);
+        this.offset += 4;
+        return value;
+    }
+
+    /**
+     * Read a 32-bit floating number and move pointer forward
+     */
+    readFloat32() {
+        const value = this._data.getFloat32(this.offset, this.littleEndian);
+        this.offset += 4;
+        return value;
+    }
+
+    /**
+     * Read a 64-bit floating number and move pointer forward
+     */
+    readFloat64() {
+        const value = this._data.getFloat64(this.offset, this.littleEndian);
+        this.offset += 8;
+        return value;
+    }
+
+    /**
+     * Read 1-byte ascii character and move pointer forward
+     */
+    readChar() {
+        return String.fromCharCode(this.readInt8());
+    }
+
+    /**
+     * Read n 1-byte ascii characters and move pointer forward
+     */
+    readChars(n = 1) {
+        charArray.length = n;
+        for (let i = 0; i < n; i++) {
+            charArray[i] = this.readChar();
+        }
+        return charArray.join('');
+    }
+
+    /**
+     * Write 0xff if the passed value is truthy, 0x00 otherwise
+     */
+    writeBoolean(value = false) {
+        this.writeUint8(value ? 0xff : 0x00);
+        return this;
+    }
+
+    /**
+     * Write value as an 8-bit signed integer
+     */
+    writeInt8(value: number) {
+        this.ensureAvailable(1);
+        this._data.setInt8(this.offset++, value);
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write value as a 8-bit unsigned integer
+     */
+    writeUint8(value: number) {
+        this.ensureAvailable(1);
+        this._data.setUint8(this.offset++, value);
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * An alias for IOBuffer#writeUint8
+     */
+    writeByte(value: number) {
+        return this.writeUint8(value);
+    }
+
+    /**
+     * Write bytes
+     */
+    writeBytes(bytes: number[] | Uint8Array) {
+        this.ensureAvailable(bytes.length);
+        for (let i = 0; i < bytes.length; i++) {
+            this._data.setUint8(this.offset++, bytes[i]);
+        }
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write value as an 16-bit signed integer
+     */
+    writeInt16(value: number) {
+        this.ensureAvailable(2);
+        this._data.setInt16(this.offset, value, this.littleEndian);
+        this.offset += 2;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write value as a 16-bit unsigned integer
+     */
+    writeUint16(value: number) {
+        this.ensureAvailable(2);
+        this._data.setUint16(this.offset, value, this.littleEndian);
+        this.offset += 2;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write a 32-bit signed integer at the current pointer offset
+     */
+    writeInt32(value: number) {
+        this.ensureAvailable(4);
+        this._data.setInt32(this.offset, value, this.littleEndian);
+        this.offset += 4;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write a 32-bit unsigned integer at the current pointer offset
+     */
+    writeUint32(value: number) {
+        this.ensureAvailable(4);
+        this._data.setUint32(this.offset, value, this.littleEndian);
+        this.offset += 4;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write a 32-bit floating number at the current pointer offset
+     */
+    writeFloat32(value: number) {
+        this.ensureAvailable(4);
+        this._data.setFloat32(this.offset, value, this.littleEndian);
+        this.offset += 4;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write a 64-bit floating number at the current pointer offset
+     */
+    writeFloat64(value: number) {
+        this.ensureAvailable(8);
+        this._data.setFloat64(this.offset, value, this.littleEndian);
+        this.offset += 8;
+        this._updateLastWrittenByte();
+        return this;
+    }
+
+    /**
+     * Write the charCode of the passed string's first character to the current pointer offset
+     */
+    writeChar(str: string) {
+        return this.writeUint8(str.charCodeAt(0));
+    }
+
+    /**
+     * Write the charCodes of the passed string's characters to the current pointer offset
+     */
+    writeChars(str: string) {
+        for (let i = 0; i < str.length; i++) {
+            this.writeUint8(str.charCodeAt(i));
+        }
+        return this;
+    }
+
+    /**
+     * Export a Uint8Array view of the internal buffer.
+     * The view starts at the byte offset and its length
+     * is calculated to stop at the last written byte or the original length.
+     */
+    toArray() {
+        return new Uint8Array(this.buffer, this.byteOffset, this._lastWrittenByte);
+    }
+
+    /**
+     * Update the last written byte offset
+     */
+    private _updateLastWrittenByte() {
+        if (this.offset > this._lastWrittenByte) {
+            this._lastWrittenByte = this.offset;
+        }
+    }
+}

+ 527 - 0
src/mol-io/common/netcdf/reader.ts

@@ -0,0 +1,527 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * Adapted from https://github.com/cheminfo-js/netcdfjs
+ * MIT License, Copyright (c) 2016 cheminfo
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+
+import { IOBuffer } from '../io-buffer';
+
+export interface NetCDFRecordDimension {
+    length: number,
+    id?: number,
+    name?: string,
+    recordStep?: number
+}
+
+export interface NetCDFVariable {
+    name: string
+    dimensions: any[]
+    attributes: any[]
+    type: string
+    size: number
+    offset: number
+    record: boolean
+}
+
+export interface NetCDFHeader {
+    recordDimension: NetCDFRecordDimension,
+    version: number,
+    dimensions: { name: string, size: number }[],
+    globalAttributes: { name: string, type: string, value: string | number }[],
+    variables: NetCDFVariable[]
+}
+
+export interface NetCDFDimension {
+    name: string,
+    size: number
+}
+
+/**
+ * Throws a non-valid NetCDF exception if the statement it's true
+ */
+function notNetcdf(statement: boolean, reason: string) {
+    if (statement) {
+        throw new TypeError('Not a valid NetCDF v3.x file: ' + reason);
+    }
+}
+
+/**
+ * Moves 1, 2, or 3 bytes to next 4-byte boundary
+ */
+function padding(buffer: IOBuffer) {
+    if ((buffer.offset % 4) !== 0) {
+        buffer.skip(4 - (buffer.offset % 4));
+    }
+}
+
+/**
+ * Reads the name
+ */
+function readName(buffer: IOBuffer) {
+    // Read name
+    const nameLength = buffer.readUint32();
+    const name = buffer.readChars(nameLength);
+
+    // validate name
+    // TODO
+
+    // Apply padding
+    padding(buffer);
+    return name;
+}
+
+const types = {
+    BYTE: 1,
+    CHAR: 2,
+    SHORT: 3,
+    INT: 4,
+    FLOAT: 5,
+    DOUBLE: 6
+};
+
+/**
+ * Parse a number into their respective type
+ */
+function num2str(type: number) {
+    switch (Number(type)) {
+        case types.BYTE:
+            return 'byte';
+        case types.CHAR:
+            return 'char';
+        case types.SHORT:
+            return 'short';
+        case types.INT:
+            return 'int';
+        case types.FLOAT:
+            return 'float';
+        case types.DOUBLE:
+            return 'double';
+        default:
+            return 'undefined';
+    }
+}
+
+/**
+ * Parse a number type identifier to his size in bytes
+ */
+function num2bytes(type: number) {
+    switch (Number(type)) {
+        case types.BYTE:
+            return 1;
+        case types.CHAR:
+            return 1;
+        case types.SHORT:
+            return 2;
+        case types.INT:
+            return 4;
+        case types.FLOAT:
+            return 4;
+        case types.DOUBLE:
+            return 8;
+        default:
+            return -1;
+    }
+}
+
+/**
+ * Reverse search of num2str
+ */
+function str2num(type: string) {
+    switch (String(type)) {
+        case 'byte':
+            return types.BYTE;
+        case 'char':
+            return types.CHAR;
+        case 'short':
+            return types.SHORT;
+        case 'int':
+            return types.INT;
+        case 'float':
+            return types.FLOAT;
+        case 'double':
+            return types.DOUBLE;
+        default:
+            return -1;
+    }
+}
+
+/**
+ * Auxiliary function to read numeric data
+ */
+function readNumber(size: number, bufferReader: Function) {
+    if (size !== 1) {
+        const numbers = new Array(size);
+        for (let i = 0; i < size; i++) {
+            numbers[i] = bufferReader();
+        }
+        return numbers;
+    } else {
+        return bufferReader();
+    }
+}
+
+/**
+ * Given a type and a size reads the next element
+ */
+function readType(buffer: IOBuffer, type: number, size: number) {
+    switch (type) {
+        case types.BYTE:
+            return buffer.readBytes(size);
+        case types.CHAR:
+            return trimNull(buffer.readChars(size));
+        case types.SHORT:
+            return readNumber(size, buffer.readInt16.bind(buffer));
+        case types.INT:
+            return readNumber(size, buffer.readInt32.bind(buffer));
+        case types.FLOAT:
+            return readNumber(size, buffer.readFloat32.bind(buffer));
+        case types.DOUBLE:
+            return readNumber(size, buffer.readFloat64.bind(buffer));
+        default:
+            notNetcdf(true, 'non valid type ' + type);
+            return undefined;
+    }
+}
+
+/**
+ * Removes null terminate value
+ */
+function trimNull(value: string) {
+    if (value.charCodeAt(value.length - 1) === 0) {
+        return value.substring(0, value.length - 1);
+    }
+    return value;
+}
+
+// const STREAMING = 4294967295;
+
+/**
+ * Read data for the given non-record variable
+ */
+function nonRecord(buffer: IOBuffer, variable: { type: string, size: number }) {
+    // variable type
+    const type = str2num(variable.type);
+
+    // size of the data
+    const size = variable.size / num2bytes(type);
+
+    // iterates over the data
+    const data = new Array(size);
+    for (let i = 0; i < size; i++) {
+        data[i] = readType(buffer, type, 1);
+    }
+
+    return data;
+}
+
+/**
+ * Read data for the given record variable
+ */
+function record(buffer: IOBuffer, variable: { type: string, size: number }, recordDimension: NetCDFRecordDimension) {
+    // variable type
+    const type = str2num(variable.type);
+    const width = variable.size ? variable.size / num2bytes(type) : 1;
+
+    // size of the data
+    // TODO streaming data
+    const size = recordDimension.length;
+
+    // iterates over the data
+    const data = new Array(size);
+    const step = recordDimension.recordStep;
+
+    for (let i = 0; i < size; i++) {
+        const currentOffset = buffer.offset;
+        data[i] = readType(buffer, type, width);
+        buffer.seek(currentOffset + step!);
+    }
+
+    return data;
+}
+
+// Grammar constants
+const ZERO = 0;
+const NC_DIMENSION = 10;
+const NC_VARIABLE = 11;
+const NC_ATTRIBUTE = 12;
+
+/**
+ * Read the header of the file
+ * Returns object with the fields:
+ *  - `recordDimension`: Number with the length of record dimension
+ *  - `dimensions`: List of dimensions
+ *  - `globalAttributes`: List of global attributes
+ *  - `variables`: List of variables
+ */
+function header(buffer: IOBuffer, version: number) {
+    // Length of record dimension
+    // sum of the varSize's of all the record variables.
+    const header: Partial<NetCDFHeader> = { recordDimension: { length: buffer.readUint32() } };
+
+    // Version
+    header.version = version;
+
+    // List of dimensions
+    const dimList = dimensionsList(buffer) as { dimensions: NetCDFDimension[], recordId: number, recordName: string };
+    header.recordDimension!.id = dimList.recordId;
+    header.recordDimension!.name = dimList.recordName;
+    header.dimensions = dimList.dimensions;
+
+    // List of global attributes
+    header.globalAttributes = attributesList(buffer);
+
+    // List of variables
+    const variables = variablesList(buffer, dimList.recordId, version) as { variables: any[], recordStep: number };
+    header.variables = variables.variables;
+    header.recordDimension!.recordStep = variables.recordStep;
+
+    return header;
+}
+
+/**
+ * List of dimensions
+ */
+function dimensionsList(buffer: IOBuffer) {
+    let dimensions: NetCDFDimension[], recordId, recordName;
+    const dimList = buffer.readUint32();
+    if (dimList === ZERO) {
+        notNetcdf((buffer.readUint32() !== ZERO), 'wrong empty tag for list of dimensions');
+        return [];
+    } else {
+        notNetcdf((dimList !== NC_DIMENSION), 'wrong tag for list of dimensions');
+
+        // Length of dimensions
+        const dimensionSize = buffer.readUint32();
+        dimensions = new Array(dimensionSize);
+        for (let dim = 0; dim < dimensionSize; dim++) {
+            // Read name
+            const name = readName(buffer);
+
+            // Read dimension size
+            const size = buffer.readUint32();
+            if (size === 0) {
+                recordId = dim;
+                recordName = name;
+            }
+
+            dimensions[dim] = {
+                name: name,
+                size: size
+            };
+        }
+        return {
+            dimensions: dimensions,
+            recordId: recordId,
+            recordName: recordName
+        };
+    }
+}
+
+/**
+ * List of attributes
+ */
+function attributesList(buffer: IOBuffer) {
+    let attributes: { name: string, type: ReturnType<typeof num2str>, value: any }[];
+    const gAttList = buffer.readUint32();
+    if (gAttList === ZERO) {
+        notNetcdf((buffer.readUint32() !== ZERO), 'wrong empty tag for list of attributes');
+        return [];
+    } else {
+        notNetcdf((gAttList !== NC_ATTRIBUTE), 'wrong tag for list of attributes');
+
+        // Length of attributes
+        const attributeSize = buffer.readUint32();
+        attributes = new Array(attributeSize);
+        for (let gAtt = 0; gAtt < attributeSize; gAtt++) {
+            // Read name
+            const name = readName(buffer);
+
+            // Read type
+            const type = buffer.readUint32();
+            notNetcdf(((type < 1) || (type > 6)), 'non valid type ' + type);
+
+            // Read attribute
+            const size = buffer.readUint32();
+            const value = readType(buffer, type, size);
+
+            // Apply padding
+            padding(buffer);
+
+            attributes[gAtt] = {
+                name: name,
+                type: num2str(type),
+                value: value
+            };
+        }
+    }
+    return attributes;
+}
+
+/**
+ * List of variables
+ */
+function variablesList(buffer: IOBuffer, recordId: number, version: number) {
+    const varList = buffer.readUint32();
+    let recordStep = 0;
+    let variables;
+    if (varList === ZERO) {
+        notNetcdf(
+            (buffer.readUint32() !== ZERO),
+            'wrong empty tag for list of variables'
+        );
+        return [];
+    } else {
+        notNetcdf((varList !== NC_VARIABLE), 'wrong tag for list of variables');
+
+        // Length of variables
+        const variableSize = buffer.readUint32();
+        variables = new Array(variableSize);
+        for (let v = 0; v < variableSize; v++) {
+            // Read name
+            const name = readName(buffer);
+
+            // Read dimensionality of the variable
+            const dimensionality = buffer.readUint32();
+
+            // Index into the list of dimensions
+            const dimensionsIds = new Array(dimensionality);
+            for (let dim = 0; dim < dimensionality; dim++) {
+                dimensionsIds[dim] = buffer.readUint32();
+            }
+
+            // Read variables size
+            const attributes = attributesList(buffer);
+
+            // Read type
+            const type = buffer.readUint32();
+            notNetcdf(((type < 1) && (type > 6)), 'non valid type ' + type);
+
+            // Read variable size
+            // The 32-bit varSize field is not large enough to contain the
+            // size of variables that require more than 2^32 - 4 bytes,
+            // so 2^32 - 1 is used in the varSize field for such variables.
+            const varSize = buffer.readUint32();
+
+            // Read offset
+            let offset = buffer.readUint32();
+            if (version === 2) {
+                notNetcdf((offset > 0), 'offsets larger than 4GB not supported');
+                offset = buffer.readUint32();
+            }
+
+            // Count amount of record variables
+            if (dimensionsIds[0] === recordId) {
+                recordStep += varSize;
+            }
+
+            variables[v] = {
+                name: name,
+                dimensions: dimensionsIds,
+                attributes: attributes,
+                type: num2str(type),
+                size: varSize,
+                offset: offset,
+                record: (dimensionsIds[0] === recordId)
+            };
+        }
+    }
+
+    return {
+        variables: variables,
+        recordStep: recordStep
+    };
+}
+
+/**
+ * Reads a NetCDF v3.x file
+ * https://www.unidata.ucar.edu/software/netcdf/docs/file_format_specifications.html
+ */
+export class NetcdfReader {
+    header: Partial<NetCDFHeader>;
+    buffer: IOBuffer;
+
+    constructor(data: ArrayBuffer) {
+        const buffer = new IOBuffer(data);
+        buffer.setBigEndian();
+
+        // Validate that it's a NetCDF file
+        notNetcdf((buffer.readChars(3) !== 'CDF'), 'should start with CDF');
+
+        // Check the NetCDF format
+        const version = buffer.readByte();
+        notNetcdf((version > 2), 'unknown version');
+
+        // Read the header
+        this.header = header(buffer, version);
+        this.buffer = buffer;
+    }
+
+    /**
+     * Version for the NetCDF format
+     */
+    get version() {
+        if (this.header.version === 1) {
+            return 'classic format';
+        } else {
+            return '64-bit offset format';
+        }
+    }
+
+    get recordDimension() {
+        return this.header.recordDimension;
+    }
+
+    get dimensions() {
+        return this.header.dimensions;
+    }
+
+    get globalAttributes() {
+        return this.header.globalAttributes;
+    }
+
+    get variables() {
+        return this.header.variables;
+    }
+
+    /**
+     * Checks if a variable is available
+     * @param {string|object} variableName - Name of the variable to check
+     * @return {Boolean} - Variable existence
+     */
+    hasDataVariable(variableName: string) {
+        return this.header.variables && this.header.variables.findIndex(val => val.name === variableName) !== -1;
+    }
+
+    /**
+     * Retrieves the data for a given variable
+     * @param {string|object} variableName - Name of the variable to search or variable object
+     * @return {Array} - List with the variable values
+     */
+    getDataVariable(variableName: string | NetCDFVariable) {
+        let variable: NetCDFVariable | undefined;
+        if (typeof variableName === 'string') {
+            // search the variable
+            variable = this.header.variables?.find((val) => val.name === variableName);
+        } else {
+            variable = variableName;
+        }
+
+        // throws if variable not found
+        if (variable === undefined) throw new Error('variable not found');
+
+        // go to the offset position
+        this.buffer.seek(variable.offset);
+
+        if (variable.record) {
+            // record variable case
+            return record(this.buffer, variable, this.header.recordDimension!);
+        } else {
+            // non-record variable case
+            return nonRecord(this.buffer, variable);
+        }
+    }
+}

+ 89 - 0
src/mol-io/reader/nctraj/parser.ts

@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Task } from '../../../mol-task';
+import { Mutable } from '../../../mol-util/type-helpers';
+import { NetcdfReader } from '../../common/netcdf/reader';
+import { ReaderResult as Result } from '../result';
+
+export interface NctrajFile {
+    coordinates: number[][],
+    velocities?: number[][],
+    forces?: number[][],
+    cell_lengths?: number[][],
+    cell_angles?: number[][],
+    time?: number[],
+    timeOffset: number,
+    deltaTime: number
+}
+
+async function parseInternal(data: Uint8Array) {
+    // http://ambermd.org/netcdf/nctraj.xhtml
+
+    const nc = new NetcdfReader(data);
+
+    const f: Mutable<NctrajFile> = {
+        coordinates: [],
+        time: [],
+        timeOffset: 0,
+        deltaTime: 1
+    };
+
+    for (const c of nc.getDataVariable('coordinates')) f.coordinates.push(c);
+
+    if (nc.hasDataVariable('velocities')) {
+        const velocities: number[][] = [];
+        for (const v of nc.getDataVariable('velocities')) velocities.push(v);
+        f.velocities = velocities;
+    }
+
+    if (nc.hasDataVariable('forces')) {
+        const forces: number[][] = [];
+        for (const f of nc.getDataVariable('forces')) forces.push(f);
+        f.forces = forces;
+    }
+
+    if (nc.hasDataVariable('cell_lengths')) {
+        const cell_lengths: number[][] = [];
+        for (const l of nc.getDataVariable('cell_lengths')) cell_lengths.push(l);
+        f.cell_lengths = cell_lengths;
+    }
+
+    if (nc.hasDataVariable('cell_angles')) {
+        const cell_angles: number[][] = [];
+        for (const a of nc.getDataVariable('cell_angles')) cell_angles.push(a);
+        f.cell_angles = cell_angles;
+    }
+
+    if (nc.hasDataVariable('time')) {
+        const time: number[] = [];
+        for (const t of nc.getDataVariable('time')) time.push(t);
+        f.time = time;
+    }
+
+    if (f.time) {
+        if (f.time.length >= 1) {
+            f.timeOffset = f.time[0];
+        }
+        if (f.time.length >= 2) {
+            f.deltaTime = f.time[1] - f.time[0];
+        }
+    }
+
+    return f;
+}
+
+export function parseNctraj(data: Uint8Array) {
+    return Task.create<Result<NctrajFile>>('Parse NCTRAJ', async ctx => {
+        try {
+            ctx.update({ canAbort: true, message: 'Parsing trajectory...' });
+            const file = await parseInternal(data);
+            return Result.success(file);
+        } catch (e) {
+            return Result.error('' + e);
+        }
+    });
+}

+ 52 - 0
src/mol-model-formats/structure/nctraj.ts

@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Task } from '../../mol-task';
+import { NctrajFile } from '../../mol-io/reader/nctraj/parser';
+import { Coordinates, Frame, Time } from '../../mol-model/structure/coordinates';
+import { Cell } from '../../mol-math/geometry/spacegroup/cell';
+import { Vec3 } from '../../mol-math/linear-algebra';
+import { Mutable } from '../../mol-util/type-helpers';
+
+export function coordinatesFromNctraj(file: NctrajFile): Task<Coordinates> {
+    return Task.create('Parse NCTRAJ', async ctx => {
+        await ctx.update('Converting to coordinates');
+
+        const deltaTime = Time(file.deltaTime, 'step');
+        const offsetTime = Time(file.timeOffset, deltaTime.unit);
+
+        const frames: Frame[] = [];
+        for (let i = 0, il = file.coordinates.length; i < il; ++i) {
+            const c = file.coordinates[i];
+            const elementCount = c.length / 3;
+            const x = new Float32Array(elementCount);
+            const y = new Float32Array(elementCount);
+            const z = new Float32Array(elementCount);
+            for (let j = 0, jl = c.length; j < jl; j += 3) {
+                x[j / 3] = c[j];
+                y[j / 3] = c[j + 1];
+                z[j / 3] = c[j + 2];
+            }
+            const frame: Mutable<Frame> = {
+                elementCount,
+                x, y, z,
+                xyzOrdering: { isIdentity: true },
+                time: Time(offsetTime.value + deltaTime.value * i, deltaTime.unit)
+            };
+            // TODO: handle case where cell_lengths and cell_angles are set, i.e., angles not 90deg
+            if (file.cell_lengths) {
+                const lengths = file.cell_lengths[i];
+                const x = Vec3.scale(Vec3(), Vec3.unitX, lengths[0]);
+                const y = Vec3.scale(Vec3(), Vec3.unitY, lengths[1]);
+                const z = Vec3.scale(Vec3(), Vec3.unitZ, lengths[2]);
+                frame.cell = Cell.fromBasis(x, y, z);
+            }
+            frames.push(frame);
+        }
+
+        return Coordinates.create(frames, deltaTime, offsetTime);
+    });
+}

+ 17 - 0
src/mol-plugin-state/formats/coordinates.ts

@@ -58,12 +58,29 @@ const TrrProvider = DataFormatProvider({
 });
 type TrrProvider = typeof TrrProvider;
 
+export { NctrajProvider };
+const NctrajProvider = DataFormatProvider({
+    label: 'NCTRAJ',
+    description: 'NCTRAJ',
+    category: CoordinatesFormatCategory,
+    binaryExtensions: ['nc', 'nctraj'],
+    parse: (plugin, data) => {
+        const coordinates = plugin.state.data.build()
+            .to(data)
+            .apply(StateTransforms.Model.CoordinatesFromNctraj);
+
+        return coordinates.commit();
+    }
+});
+type NctrajProvider = typeof NctrajProvider;
+
 export type CoordinatesProvider = DcdProvider | XtcProvider | TrrProvider;
 
 export const BuiltInCoordinatesFormats = [
     ['dcd', DcdProvider] as const,
     ['xtc', XtcProvider] as const,
     ['trr', TrrProvider] as const,
+    ['nctraj', NctrajProvider] as const,
 ] as const;
 
 export type BuiltInCoordinatesFormat = (typeof BuiltInCoordinatesFormats)[number][0]