UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

627 lines (582 loc) 18.7 kB
import Constants from './constants.ts' import { readNullTerminatedStringFromBuffer } from './util.ts' import type CramContainerCompressionScheme from './container/compressionScheme.ts' import type decodeRecord from './slice/decodeRecord.ts' // 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 interface RefRegion { start: number end: number seq: string } interface ReadFeatureBase { pos: number refPos: number } /** * Read features describe differences between a read and the reference sequence. * Each feature has a code indicating the type of difference, a position in the * read (pos), and a position on the reference (refPos). */ export type ReadFeature = /** I=insertion, S=soft clip, b=bases, i=single-base insertion — all carry a sequence string */ | (ReadFeatureBase & { code: 'I' | 'S' | 'b' | 'i'; data: string }) /** B=base and quality pair — [substituted base, quality score] */ | (ReadFeatureBase & { code: 'B'; data: [string, number] }) /** X=base substitution — data is the substitution matrix index, ref/sub filled in by addReferenceSequence */ | (ReadFeatureBase & { code: 'X' data: number ref?: string sub?: string }) /** D=deletion, N=reference skip, H=hard clip, P=padding, Q=single quality score */ | (ReadFeatureBase & { code: 'D' | 'N' | 'H' | 'P' | 'Q'; data: number }) /** q=quality scores for a stretch of bases */ | (ReadFeatureBase & { code: 'q'; data: number[] }) export interface DecodeOptions { /** Whether to parse tags. If false, raw tag data is stored for lazy parsing. Default true. */ decodeTags?: boolean } export const defaultDecodeOptions: Required<DecodeOptions> = { decodeTags: true, } function decodeReadSequence(cramRecord: CramRecord, refRegion: 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: Record<string, number | undefined> = { a: 0, A: 0, c: 1, C: 1, g: 2, G: 2, t: 3, T: 3, n: 4, N: 4, } function decodeBaseSubstitution( cramRecord: CramRecord, refRegion: RefRegion, compressionScheme: CramContainerCompressionScheme, readFeature: ReadFeatureBase & { code: 'X' data: number ref?: string sub?: string }, ) { // 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 interface MateRecord { readName?: string sequenceId: number alignmentStart: number flags?: number uniqueId?: number } 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'], ] as const export const CramFlags = [ [0x1, 'PreservingQualityScores'], [0x2, 'Detached'], [0x4, 'WithMateDownstream'], [0x8, 'DecodeSequenceAsStar'], ] as const export const MateFlags = [ [0x1, 'OnNegativeStrand'], [0x2, 'Unmapped'], ] as const type FlagsDecoder<Type> = { [Property in Type as `is${Capitalize<string & Property>}`]: ( flags: number, ) => boolean } type FlagsEncoder<Type> = { [Property in Type as `set${Capitalize<string & Property>}`]: ( flags: number, ) => number } function makeFlagsHelper<T>( x: readonly (readonly [number, T])[], ): FlagsDecoder<T> & FlagsEncoder<T> { const r: Record<string, (flags: number) => boolean | number> = {} for (const [code, name] of x) { r[`is${name}`] = (flags: number) => !!(flags & code) r[`set${name}`] = (flags: number) => flags | code } return r as unknown as FlagsDecoder<T> & FlagsEncoder<T> } 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 { public tags: Record<string, string | number | number[] | undefined> public flags: number public cramFlags: number public readBases?: string | null public _refRegion?: RefRegion public readFeatures?: ReadFeature[] public alignmentStart: number public lengthOnRef: number | undefined public readLength: number // templateLength is computed post-hoc for intra-slice mate pairs, // templateSize is the raw CRAM-encoded TS data series value public templateLength?: number public templateSize?: number private _readName?: string private _readNameRaw?: Uint8Array public _syntheticReadName?: string public mateRecordNumber?: number public mate?: MateRecord public uniqueId: number public sequenceId: number public readGroupId: number public mappingQuality: number | undefined public qualityScores: Uint8Array | null | undefined 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, }: ReturnType<typeof decodeRecord>) { 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: number): number | undefined { 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(): string { 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: [number, string][] = [] const push = (len: number, op: string) => { 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: RefRegion, compressionScheme: CramContainerCompressionScheme, ) { 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: Record<string, unknown> = { 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 }