UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

418 lines (377 loc) 12 kB
import { CramMalformedError } from '../../errors.ts' import { Cursors, DataTypeMapping } from '../codecs/_base.ts' import { DataSeriesEncodingKey } from '../codecs/dataSeriesTypes.ts' import CramContainerCompressionScheme, { DataSeriesTypes, } from '../container/compressionScheme.ts' import { BamFlagsDecoder, CramFlagsDecoder, MateFlagsDecoder, ReadFeature, } from '../record.ts' import CramSlice, { SliceHeader } from './index.ts' import { CramFileBlock } from '../file.ts' import { isMappedSliceHeader } from '../sectionParsers.ts' /** * given a Buffer, read a string up to the first null character * @private */ function readNullTerminatedString(buffer: Uint8Array) { let r = '' for (let i = 0; i < buffer.length && buffer[i] !== 0; i++) { r += String.fromCharCode(buffer[i]!) } return r } /** * parse a BAM tag's array value from a binary buffer * @private */ function parseTagValueArray(buffer: Uint8Array) { const arrayType = String.fromCharCode(buffer[0]!) const dataView = new DataView(buffer.buffer) const littleEndian = true const length = dataView.getUint32(1, littleEndian) const array: number[] = new Array(length) buffer = buffer.slice(5) if (arrayType === 'c') { const arr = new Int8Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 'C') { const arr = new Uint8Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 's') { const arr = new Int16Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 'S') { const arr = new Uint16Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 'i') { const arr = new Int32Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 'I') { const arr = new Uint32Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else if (arrayType === 'f') { const arr = new Float32Array(buffer.buffer) for (let i = 0; i < length; i++) { array[i] = arr[i]! } } else { throw new Error(`unknown type: ${arrayType}`) } return array } function parseTagData(tagType: string, buffer: Uint8Array) { if (tagType === 'Z') { return readNullTerminatedString(buffer) } if (tagType === 'A') { return String.fromCharCode(buffer[0]!) } if (tagType === 'I') { return new Uint32Array(buffer.buffer)[0] } if (tagType === 'i') { return new Int32Array(buffer.buffer)[0] } if (tagType === 's') { return new Int16Array(buffer.buffer)[0] } if (tagType === 'S') { return new Uint16Array(buffer.buffer)[0] } if (tagType === 'c') { return new Int8Array(buffer.buffer)[0] } if (tagType === 'C') { return buffer[0]! } if (tagType === 'f') { return new Float32Array(buffer.buffer)[0] } if (tagType === 'H') { return Number.parseInt( readNullTerminatedString(buffer).replace(/^0x/, ''), 16, ) } if (tagType === 'B') { return parseTagValueArray(buffer) } throw new CramMalformedError(`Unrecognized tag type ${tagType}`) } function decodeReadFeatures( alignmentStart: number, readFeatureCount: number, decodeDataSeries: any, compressionScheme: CramContainerCompressionScheme, majorVersion: number, ) { let currentReadPos = 0 let currentRefPos = alignmentStart - 1 const readFeatures: ReadFeature[] = new Array(readFeatureCount) function decodeRFData([type, dataSeriesName]: readonly [ type: string, dataSeriesName: string, ]) { const data = decodeDataSeries(dataSeriesName) if (type === 'character') { return String.fromCharCode(data) } else if (type === 'string') { let r = '' for (let i = 0; i < data.byteLength; i++) { r += String.fromCharCode(data[i]) } return r } else if (type === 'numArray') { return Array.from(data) } // else if (type === 'number') { // return data[0] // } return data } for (let i = 0; i < readFeatureCount; i++) { const code = String.fromCharCode(decodeDataSeries('FC')) const readPosDelta = decodeDataSeries('FP') // map of operator name -> data series name const data1Schema = { B: ['character', 'BA'] as const, S: ['string', majorVersion > 1 ? 'SC' : 'IN'] as const, // IN if cram v1, SC otherwise X: ['number', 'BS'] as const, D: ['number', 'DL'] as const, I: ['string', 'IN'] as const, i: ['character', 'BA'] as const, b: ['string', 'BB'] as const, q: ['numArray', 'QQ'] as const, Q: ['number', 'QS'] as const, H: ['number', 'HC'] as const, P: ['number', 'PD'] as const, N: ['number', 'RS'] as const, }[code] if (!data1Schema) { throw new CramMalformedError(`invalid read feature code "${code}"`) } let data = decodeRFData(data1Schema) // if this is a tag with two data items, make the data an array and add the second item const data2Schema = { B: ['number', 'QS'] as const }[code] if (data2Schema) { data = [data, decodeRFData(data2Schema)] } currentReadPos += readPosDelta const pos = currentReadPos currentRefPos += readPosDelta const refPos = currentRefPos // for gapping features, adjust the reference position for read features that follow if (code === 'D' || code === 'N') { currentRefPos += data } else if (code === 'I' || code === 'S') { currentRefPos -= data.length } else if (code === 'i') { currentRefPos -= 1 } readFeatures[i] = { code, pos, refPos, data } } return readFeatures } export type DataSeriesDecoder = <T extends DataSeriesEncodingKey>( dataSeriesName: T, ) => DataTypeMapping[DataSeriesTypes[T]] | undefined export default function decodeRecord( slice: CramSlice, decodeDataSeries: DataSeriesDecoder, compressionScheme: CramContainerCompressionScheme, sliceHeader: SliceHeader, coreDataBlock: CramFileBlock, blocksByContentId: Record<number, CramFileBlock>, cursors: Cursors, majorVersion: number, recordNumber: number, ) { let flags = decodeDataSeries('BF')! // note: the C data type of compressionFlags is byte in cram v1 and int32 in // cram v2+, but that does not matter for us here in javascript land. const cramFlags = decodeDataSeries('CF')! if (!isMappedSliceHeader(sliceHeader.parsedContent)) { throw new Error('slice header not mapped') } const sequenceId = majorVersion > 1 && sliceHeader.parsedContent.refSeqId === -2 ? decodeDataSeries('RI') : sliceHeader.parsedContent.refSeqId const readLength = decodeDataSeries('RL')! // if APDelta, will calculate the true start in a second pass let alignmentStart = decodeDataSeries('AP')! if (compressionScheme.APdelta) { alignmentStart = alignmentStart + cursors.lastAlignmentStart } cursors.lastAlignmentStart = alignmentStart const readGroupId = decodeDataSeries('RG')! let readName: string | undefined if (compressionScheme.readNamesIncluded) { readName = readNullTerminatedString(decodeDataSeries('RN')!) } let mateToUse: | { mateFlags: number mateSequenceId: number mateAlignmentStart: number mateReadName: string | undefined } | undefined let templateSize: number | undefined let mateRecordNumber: number | undefined // mate record if (CramFlagsDecoder.isDetached(cramFlags)) { // note: the MF is a byte in 1.0, int32 in 2+, but once again this doesn't // matter for javascript const mateFlags = decodeDataSeries('MF')! let mateReadName: string | undefined if (!compressionScheme.readNamesIncluded) { mateReadName = readNullTerminatedString(decodeDataSeries('RN')!) readName = mateReadName } const mateSequenceId = decodeDataSeries('NS')! const mateAlignmentStart = decodeDataSeries('NP')! if (mateFlags || mateSequenceId > -1) { mateToUse = { mateFlags, mateSequenceId, mateAlignmentStart, mateReadName, } } templateSize = decodeDataSeries('TS')! // set mate unmapped if needed if (MateFlagsDecoder.isUnmapped(mateFlags)) { flags = BamFlagsDecoder.setMateUnmapped(flags) } // set mate reversed if needed if (MateFlagsDecoder.isOnNegativeStrand(mateFlags)) { flags = BamFlagsDecoder.setMateReverseComplemented(flags) } // detachedCount++ } else if (CramFlagsDecoder.isWithMateDownstream(cramFlags)) { mateRecordNumber = decodeDataSeries('NF')! + recordNumber + 1 } // TODO: the aux tag parsing will have to be refactored if we want to support // cram v1 const TLindex = decodeDataSeries('TL')! if (TLindex < 0) { /* TODO: check nTL: TLindex >= compressionHeader.tagEncoding.size */ throw new CramMalformedError('invalid TL index') } const tags: Record<string, any> = {} // TN = tag names const TN = compressionScheme.getTagNames(TLindex)! const ntags = TN.length for (let i = 0; i < ntags; i++) { const tagId = TN[i]! const tagName = tagId.slice(0, 2) const tagType = tagId.slice(2, 3) const tagData = compressionScheme .getCodecForTag(tagId) .decode(slice, coreDataBlock, blocksByContentId, cursors) tags[tagName] = tagData === undefined ? undefined : typeof tagData === 'number' ? tagData : parseTagData(tagType, tagData) } let readFeatures: ReadFeature[] | undefined let lengthOnRef: number | undefined let mappingQuality: number | undefined let qualityScores: number[] | undefined | null let readBases = undefined if (!BamFlagsDecoder.isSegmentUnmapped(flags)) { // reading read features const readFeatureCount = decodeDataSeries('FN')! if (readFeatureCount) { readFeatures = decodeReadFeatures( alignmentStart, readFeatureCount, decodeDataSeries, compressionScheme, majorVersion, ) } // compute the read's true span on the reference sequence, and the end // coordinate of the alignment on the reference lengthOnRef = readLength if (readFeatures) { for (const { code, data } of readFeatures) { if (code === 'D' || code === 'N') { lengthOnRef += data } else if (code === 'I' || code === 'S') { lengthOnRef = lengthOnRef - data.length } else if (code === 'i') { lengthOnRef = lengthOnRef - 1 } } } if (Number.isNaN(lengthOnRef)) { console.warn( `${ readName || `${sequenceId}:${alignmentStart}` } record has invalid read features`, ) lengthOnRef = readLength } // mapping quality mappingQuality = decodeDataSeries('MQ')! if (CramFlagsDecoder.isPreservingQualityScores(cramFlags)) { qualityScores = new Array(readLength) for (let i = 0; i < qualityScores.length; i++) { qualityScores[i] = decodeDataSeries('QS')! } } } else if (CramFlagsDecoder.isDecodeSequenceAsStar(cramFlags)) { readBases = null qualityScores = null } else { const bases = new Array(readLength) as number[] for (let i = 0; i < bases.length; i++) { bases[i] = decodeDataSeries('BA')! } readBases = String.fromCharCode(...bases) if (CramFlagsDecoder.isPreservingQualityScores(cramFlags)) { qualityScores = new Array(readLength) for (let i = 0; i < bases.length; i++) { qualityScores[i] = decodeDataSeries('QS')! } } } return { readLength, sequenceId, cramFlags, flags, alignmentStart, readGroupId, readName, mateToUse, templateSize, mateRecordNumber, readFeatures, lengthOnRef, mappingQuality, qualityScores, readBases, tags, } }