UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

501 lines (450 loc) 13.5 kB
import Constants from './constants.ts' import CramContainerCompressionScheme from './container/compressionScheme.ts' import type decodeRecord from './slice/decodeRecord.ts' export interface RefRegion { start: number end: number seq: string } export interface ReadFeature { code: string pos: number refPos: number data: any ref?: string sub?: string } 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() } 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) { // process the read feature currentReadFeature += 1 if (feature.code === 'b') { // specify a base pair for some reason const added = feature.data bases += added regionPos += added.length } else if (feature.code === 'B') { // base pair and associated quality // TODO: do we need to set the quality in the qual scores? bases += feature.data[0] regionPos += 1 } else if (feature.code === 'X') { // base substitution bases += feature.sub regionPos += 1 } else if (feature.code === 'I') { // insertion bases += feature.data } else if (feature.code === 'D') { // deletion regionPos += feature.data } else if (feature.code === 'i') { // insert single base bases += feature.data } else if (feature.code === 'N') { // reference skip. delete some bases // do nothing // seqBases.splice(feature.pos - 1, feature.data) regionPos += feature.data } else if (feature.code === 'S') { // soft clipped bases that should be present in the read seq // seqBases.splice(feature.pos - 1, 0, ...feature.data.split('')) bases += feature.data } else if (feature.code === 'P') { // padding, do nothing } else if (feature.code === 'H') { // hard clip, do nothing } } else if (currentReadFeature < cramRecord.readFeatures.length) { // put down a chunk of sequence 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: CramRecord, refRegion: RefRegion, compressionScheme: CramContainerCompressionScheme, readFeature: 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 as any)[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: any = {} for (const [code, name] of x) { r[`is${name}`] = (flags: number) => !!(flags & code) r[`set${name}`] = (flags: number) => 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 { public tags: Record<string, string> 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 public templateLength?: number public templateSize?: number public readName?: string public mateRecordNumber?: number public mate?: MateRecord public uniqueId: number public sequenceId: number public readGroupId: number public mappingQuality: number | undefined public qualityScores: number[] | null | undefined constructor({ flags, cramFlags, readLength, mappingQuality, lengthOnRef, qualityScores, mateRecordNumber, readBases, readFeatures, mateToUse, readGroupId, readName, sequenceId, uniqueId, templateSize, alignmentStart, tags, }: ReturnType<typeof decodeRecord> & { uniqueId: number }) { this.flags = flags this.cramFlags = cramFlags this.readLength = readLength this.mappingQuality = mappingQuality this.lengthOnRef = lengthOnRef this.qualityScores = qualityScores if (readBases) { this.readBases = readBases } this.readGroupId = readGroupId this.readName = readName this.sequenceId = sequenceId! this.uniqueId = uniqueId this.templateSize = templateSize this.alignmentStart = alignmentStart this.tags = tags // backwards compatibility if (readFeatures) { this.readFeatures = readFeatures } if (mateToUse) { this.mate = { flags: mateToUse.mateFlags, readName: mateToUse.mateReadName, sequenceId: mateToUse.mateSequenceId, alignmentStart: mateToUse.mateAlignmentStart, } } if (mateRecordNumber) { this.mateRecordNumber = mateRecordNumber } } /** * @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 read itself 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) } /** @returns {boolean} true if this is read number 1 in a pair */ isRead1() { return !!(this.flags & Constants.BAM_FREAD1) } /** @returns {boolean} true if this is read number 2 in a pair */ isRead2() { return !!(this.flags & Constants.BAM_FREAD2) } /** @returns {boolean} true if this is a secondary alignment */ isSecondary() { return !!(this.flags & Constants.BAM_FSECONDARY) } /** @returns {boolean} true if this read has failed QC checks */ isFailedQc() { return !!(this.flags & Constants.BAM_FQCFAIL) } /** @returns {boolean} true if the read is an optical or PCR duplicate */ isDuplicate() { return !!(this.flags & Constants.BAM_FDUP) } /** @returns {boolean} true if this is a supplementary alignment */ isSupplementary() { return !!(this.flags & Constants.BAM_FSUPPLEMENTARY) } /** * @returns {boolean} true if the read is detached */ isDetached() { return !!(this.cramFlags & Constants.CRAM_FLAG_DETACHED) } /** @returns {boolean} true if the read has a mate in this same CRAM segment */ hasMateDownStream() { return !!(this.cramFlags & Constants.CRAM_FLAG_MATE_DOWNSTREAM) } /** @returns {boolean} true if the read contains qual scores */ isPreservingQualityScores() { return !!(this.cramFlags & Constants.CRAM_FLAG_PRESERVE_QUAL_SCORES) } /** @returns {boolean} true if the read has no sequence bases */ 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 pair orientation of a paired read. Adapted from igv.js * @returns {String} of paired orientatin */ getPairOrientation() { if ( !this.isSegmentUnmapped() && this.isPaired() && !this.isMateUnmapped() && this.mate && this.sequenceId === this.mate.sequenceId ) { const s1 = this.isReverseComplemented() ? 'R' : 'F' const s2 = this.isMateReverseComplemented() ? 'R' : 'F' let o1 = ' ' let o2 = ' ' if (this.isRead1()) { o1 = '1' o2 = '2' } else if (this.isRead2()) { o1 = '2' o2 = '1' } const tmp = [] let isize = this.templateLength || this.templateSize if (isize === undefined) { throw new Error('One of templateSize and templateLength must be set') } if (this.alignmentStart > this.mate.alignmentStart && isize > 0) { isize = -isize } if (isize > 0) { tmp[0] = s1 tmp[1] = o1 tmp[2] = s2 tmp[3] = o2 } else { tmp[2] = s1 tmp[3] = o1 tmp[0] = s2 tmp[1] = o2 } return tmp.join('') } return null } /** * 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 `getReadSequence()` 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 this.readFeatures.forEach(readFeature => { 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 } } toJSON() { const data: any = {} Object.keys(this).forEach(k => { if (k.startsWith('_')) { return } data[k] = (this as any)[k] }) data.readBases = this.getReadBases() return data } }