UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

489 lines 18 kB
import Constants from "./constants.js"; import { readNullTerminatedStringFromBuffer } from "./util.js"; // precomputed pair orientation strings indexed by ((flags >> 4) & 0xF) | (isize > 0 ? 16 : 0) // bits 0-3 encode flag bits 0x10(reverse),0x20(mate reverse),0x40(read1),0x80(read2) // bit 4 encodes whether isize > 0 // prettier-ignore const PAIR_ORIENTATION_TABLE = [ 'F F ', 'F R ', 'R F ', 'R R ', 'F2F1', 'F2R1', 'R2F1', 'R2R1', 'F1F2', 'F1R2', 'R1F2', 'R1R2', 'F2F1', 'F2R1', 'R2F1', 'R2R1', 'F F ', 'R F ', 'F R ', 'R R ', 'F1F2', 'R1F2', 'F1R2', 'R1R2', 'F2F1', 'R2F1', 'F2R1', 'R2R1', 'F1F2', 'R1F2', 'F1R2', 'R1R2', ]; export const defaultDecodeOptions = { decodeTags: true, }; function decodeReadSequence(cramRecord, refRegion) { // if it has no length, it has no sequence if (!cramRecord.lengthOnRef && !cramRecord.readLength) { return null; } if (cramRecord.isUnknownBases()) { return null; } // remember: all coordinates are 1-based closed const regionSeqOffset = cramRecord.alignmentStart - refRegion.start; if (!cramRecord.readFeatures) { return refRegion.seq .slice(regionSeqOffset, regionSeqOffset + (cramRecord.lengthOnRef || 0)) .toUpperCase(); } // Walk read features against the reference to reconstruct the read sequence. // See CRAMv3 §10.2 (Read features): https://samtools.github.io/hts-specs/CRAMv3.pdf let bases = ''; let regionPos = regionSeqOffset; let currentReadFeature = 0; while (bases.length < cramRecord.readLength) { if (currentReadFeature < cramRecord.readFeatures.length) { const feature = cramRecord.readFeatures[currentReadFeature]; if (feature.code === 'Q' || feature.code === 'q') { currentReadFeature += 1; } else if (feature.pos === bases.length + 1) { currentReadFeature += 1; switch (feature.code) { case 'b': { bases += feature.data; regionPos += feature.data.length; break; } case 'B': { bases += feature.data[0]; regionPos += 1; break; } case 'X': { bases += feature.sub; regionPos += 1; break; } case 'I': case 'i': case 'S': { bases += feature.data; break; } case 'D': case 'N': { regionPos += feature.data; break; } // H (hard clip), P (padding): do nothing } } else { // put down a chunk of reference up to the next read feature const chunk = refRegion.seq.slice(regionPos, regionPos + feature.pos - bases.length - 1); bases += chunk; regionPos += chunk.length; } } else { // put down a chunk of reference up to the full read length const chunk = refRegion.seq.slice(regionPos, regionPos + cramRecord.readLength - bases.length); bases += chunk; regionPos += chunk.length; } } return bases.toUpperCase(); } const baseNumbers = { a: 0, A: 0, c: 1, C: 1, g: 2, G: 2, t: 3, T: 3, n: 4, N: 4, }; function decodeBaseSubstitution(cramRecord, refRegion, compressionScheme, readFeature) { // decode base substitution code using the substitution matrix const refCoord = readFeature.refPos - refRegion.start; const refBase = refRegion.seq.charAt(refCoord); if (refBase) { readFeature.ref = refBase; } let baseNumber = baseNumbers[refBase]; if (baseNumber === undefined) { baseNumber = 4; } const substitutionScheme = compressionScheme.substitutionMatrix[baseNumber]; const base = substitutionScheme[readFeature.data]; if (base) { readFeature.sub = base; } } export const BamFlags = [ [0x1, 'Paired'], [0x2, 'ProperlyPaired'], [0x4, 'SegmentUnmapped'], [0x8, 'MateUnmapped'], [0x10, 'ReverseComplemented'], // the mate is mapped to the reverse strand [0x20, 'MateReverseComplemented'], // this is read1 [0x40, 'Read1'], // this is read2 [0x80, 'Read2'], // not primary alignment [0x100, 'Secondary'], // QC failure [0x200, 'FailedQc'], // optical or PCR duplicate [0x400, 'Duplicate'], // supplementary alignment [0x800, 'Supplementary'], ]; export const CramFlags = [ [0x1, 'PreservingQualityScores'], [0x2, 'Detached'], [0x4, 'WithMateDownstream'], [0x8, 'DecodeSequenceAsStar'], ]; export const MateFlags = [ [0x1, 'OnNegativeStrand'], [0x2, 'Unmapped'], ]; function makeFlagsHelper(x) { const r = {}; for (const [code, name] of x) { r[`is${name}`] = (flags) => !!(flags & code); r[`set${name}`] = (flags) => flags | code; } return r; } export const BamFlagsDecoder = makeFlagsHelper(BamFlags); export const CramFlagsDecoder = makeFlagsHelper(CramFlags); export const MateFlagsDecoder = makeFlagsHelper(MateFlags); /** * Class of each CRAM record returned by this API. */ export default class CramRecord { tags; flags; cramFlags; readBases; _refRegion; readFeatures; alignmentStart; lengthOnRef; readLength; // templateLength is computed post-hoc for intra-slice mate pairs, // templateSize is the raw CRAM-encoded TS data series value templateLength; templateSize; _readName; _readNameRaw; _syntheticReadName; mateRecordNumber; mate; uniqueId; sequenceId; readGroupId; mappingQuality; qualityScores; get readName() { if (this._readName === undefined) { if (this._readNameRaw) { this._readName = readNullTerminatedStringFromBuffer(this._readNameRaw); this._readNameRaw = undefined; } else { return this._syntheticReadName; } } return this._readName; } constructor({ flags, cramFlags, readLength, mappingQuality, lengthOnRef, qualityScores, mateRecordNumber, readBases, readFeatures, mate, readGroupId, readNameRaw, sequenceId, uniqueId, templateSize, alignmentStart, tags, }) { this.flags = flags; this.cramFlags = cramFlags; this.readLength = readLength; this.mappingQuality = mappingQuality; this.lengthOnRef = lengthOnRef; this.qualityScores = qualityScores; this.readGroupId = readGroupId; this.sequenceId = sequenceId; this.uniqueId = uniqueId; this.alignmentStart = alignmentStart; this.tags = tags; if (readNameRaw) { this._readNameRaw = readNameRaw; } if (readBases) { this.readBases = readBases; } this.templateSize = templateSize; if (readFeatures) { this.readFeatures = readFeatures; } if (mate) { this.mate = mate; } if (mateRecordNumber) { this.mateRecordNumber = mateRecordNumber; } } /** * Get a single quality score at the given index. * @param index 0-based index into the quality scores * @returns the quality score at that index, or undefined if not available */ qualityScoreAt(index) { return this.qualityScores?.[index]; } // BAM flags — see SAM/BAM spec §1.4 (Flag field): // https://samtools.github.io/hts-specs/SAMv1.pdf /** @returns {boolean} true if the read is paired, regardless of whether both segments are mapped */ isPaired() { return !!(this.flags & Constants.BAM_FPAIRED); } /** @returns {boolean} true if the read is paired, and both segments are mapped */ isProperlyPaired() { return !!(this.flags & Constants.BAM_FPROPER_PAIR); } /** @returns {boolean} true if the read itself is unmapped; conflictive with isProperlyPaired */ isSegmentUnmapped() { return !!(this.flags & Constants.BAM_FUNMAP); } /** @returns {boolean} true if the mate is unmapped; conflictive with isProperlyPaired */ isMateUnmapped() { return !!(this.flags & Constants.BAM_FMUNMAP); } /** @returns {boolean} true if the read is mapped to the reverse strand */ isReverseComplemented() { return !!(this.flags & Constants.BAM_FREVERSE); } /** @returns {boolean} true if the mate is mapped to the reverse strand */ isMateReverseComplemented() { return !!(this.flags & Constants.BAM_FMREVERSE); } isRead1() { return !!(this.flags & Constants.BAM_FREAD1); } isRead2() { return !!(this.flags & Constants.BAM_FREAD2); } isSecondary() { return !!(this.flags & Constants.BAM_FSECONDARY); } isFailedQc() { return !!(this.flags & Constants.BAM_FQCFAIL); } isDuplicate() { return !!(this.flags & Constants.BAM_FDUP); } isSupplementary() { return !!(this.flags & Constants.BAM_FSUPPLEMENTARY); } // CRAM-specific compression flags — see CRAMv3 §8.4 (Bit Flags): // https://samtools.github.io/hts-specs/CRAMv3.pdf isDetached() { return !!(this.cramFlags & Constants.CRAM_FLAG_DETACHED); } hasMateDownStream() { return !!(this.cramFlags & Constants.CRAM_FLAG_MATE_DOWNSTREAM); } isPreservingQualityScores() { return !!(this.cramFlags & Constants.CRAM_FLAG_PRESERVE_QUAL_SCORES); } isUnknownBases() { return !!(this.cramFlags & Constants.CRAM_FLAG_NO_SEQ); } /** * Get the original sequence of this read. * @returns {String} sequence basepairs */ getReadBases() { if (!this.readBases && this._refRegion) { const decoded = decodeReadSequence(this, this._refRegion); if (decoded) { this.readBases = decoded; } } return this.readBases; } /** * Get the CIGAR string describing this read's alignment against the * reference, reconstructed from the read features. Substitutions and * verbatim bases are reported as alignment matches (M), following the plain * CIGAR convention where M covers both matches and mismatches. Unmapped * reads return '*'. * * See CRAMv3 §10.2 (Read features): * https://samtools.github.io/hts-specs/CRAMv3.pdf * * @returns {string} the CIGAR string, e.g. "50M2I48M" */ getCigarString() { if (this.isSegmentUnmapped()) { return '*'; } // build up (length, op) pairs, merging adjacent runs of the same op so // e.g. consecutive single-base insertions collapse into one I operation const ops = []; const push = (len, op) => { if (len > 0) { const last = ops.at(-1); if (last?.[1] === op) { last[0] += len; } else { ops.push([len, op]); } } }; let readConsumed = 0; let refPos = this.alignmentStart; if (this.readFeatures !== undefined) { for (const feature of this.readFeatures) { // 'q'/'Q' carry only quality information; their refPos does not track // the alignment geometry, so they must not perturb position tracking if (feature.code !== 'q' && feature.code !== 'Q') { // reference bases between the last position and this feature are matches const gap = feature.refPos - refPos; push(gap, 'M'); readConsumed += gap; refPos = feature.refPos; switch (feature.code) { case 'b': { // verbatim stretch of bases, aligned as matches push(feature.data.length, 'M'); readConsumed += feature.data.length; refPos += feature.data.length; break; } case 'B': case 'X': { // single-base (substitution or base+quality), aligned as a match push(1, 'M'); readConsumed += 1; refPos += 1; break; } case 'D': case 'N': { push(feature.data, feature.code); refPos += feature.data; break; } case 'I': case 'S': { push(feature.data.length, feature.code); readConsumed += feature.data.length; break; } case 'i': { // single-base insertion push(1, 'I'); readConsumed += 1; break; } case 'P': case 'H': { push(feature.data, feature.code); break; } } } } } // any read bases past the last feature are trailing matches push(this.readLength - readConsumed, 'M'); return ops.map(([len, op]) => `${len}${op}`).join(''); } // adapted from igv.js // uses precomputed lookup table indexed by flag bits + isize sign. // the BAM spec defines tlen as positive for the leftmost segment and // negative for the rightmost, so isize > 0 reliably indicates which // read comes first without needing position-based correction // (see also: gmod/bam-js src/record.ts pair_orientation getter) getPairOrientation() { const f = this.flags; // paired (0x1) set, unmapped (0x4) clear, mate unmapped (0x8) clear if ((f & 0xd) !== 0x1 || this.sequenceId !== this.mate?.sequenceId) { return undefined; } const isize = this.templateLength || this.templateSize || 0; return PAIR_ORIENTATION_TABLE[((f >> 4) & 0xf) | (isize > 0 ? 16 : 0)]; } /** * Annotates this feature with the given reference sequence basepair * information. This will add a `sub` and a `ref` item to base * substitution read features given the actual substituted and reference * base pairs, and will make the `getReadBases()` method work. * * @param {object} refRegion * @param {number} refRegion.start * @param {number} refRegion.end * @param {string} refRegion.seq * @param {CramContainerCompressionScheme} compressionScheme * @returns {undefined} nothing */ addReferenceSequence(refRegion, compressionScheme) { if (this.readFeatures) { // use the reference bases to decode the bases substituted in each base // substitution for (const readFeature of this.readFeatures) { if (readFeature.code === 'X') { decodeBaseSubstitution(this, refRegion, compressionScheme, readFeature); } } } // if this region completely covers this read, // keep a reference to it if (!this.readBases && refRegion.start <= this.alignmentStart && refRegion.end >= this.alignmentStart + (this.lengthOnRef || this.readLength) - 1) { this._refRegion = refRegion; } } // Serializer used by snapshot tests and consumers that JSON.stringify a // record. qualityScores (Uint8Array) is converted to number[] so snapshots // stay diffable. Optional fields are added only when defined to match the // historical shape of the output. toJSON() { const data = { alignmentStart: this.alignmentStart, cramFlags: this.cramFlags, flags: this.flags, readGroupId: this.readGroupId, readLength: this.readLength, sequenceId: this.sequenceId, tags: this.tags, uniqueId: this.uniqueId, readName: this.readName, readBases: this.getReadBases(), qualityScores: this.qualityScores ? Array.from(this.qualityScores) : this.qualityScores, }; if (this.lengthOnRef !== undefined) { data.lengthOnRef = this.lengthOnRef; } if (this.mappingQuality !== undefined) { data.mappingQuality = this.mappingQuality; } if (this.templateSize !== undefined) { data.templateSize = this.templateSize; } if (this.templateLength !== undefined) { data.templateLength = this.templateLength; } if (this.readFeatures !== undefined) { data.readFeatures = this.readFeatures; } if (this.mate !== undefined) { data.mate = this.mate; } if (this.mateRecordNumber !== undefined) { data.mateRecordNumber = this.mateRecordNumber; } return data; } } export { CramRecord }; //# sourceMappingURL=record.js.map