@gmod/cram
Version:
read CRAM files with pure Javascript
627 lines (582 loc) • 18.7 kB
text/typescript
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 }