← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/formats/chromatogram.ts
18.5 KB · Sep 30, 2026 · 23:01 UTC
import { parseScientificScfHeader } from "@openai/scientific-viewer-platform/sequence/scientific-sequence-consequential-formats";
import { biologicalFileBasename } from "../../compressed-file-name";
import { SEQUENCE_VIEWER_LIMITS } from "../../runtime-contract";
import type {
ChromatogramBase,
SequenceChromatogram,
SequenceParseWarning,
SequenceRecord,
} from "../types";
// Independently implemented from the ABIF layout and SCF specifications:
// https://github.com/biopython/biopython/blob/master/Bio/SeqIO/AbiIO.py
// https://archive.gfjc.fiu.edu/workshops/resources/articles/ABIF_File_Format.pdf
// https://staden.sourceforge.net/manual/formats_unix_3.html (header)
// https://staden.sourceforge.net/manual/formats_unix_4.html (signal encoding)
// https://staden.sourceforge.net/manual/formats_unix_5.html (called bases)
// Trace arrays are fully decoded, so bound them independently of source bytes.
export const CHROMATOGRAM_LIMITS = Object.freeze({
maxBaseCalls: 200_000,
maxDirectoryEntries: 10_000,
maxSamples: 500_000,
maxSourceBytes: SEQUENCE_VIEWER_LIMITS.session.maxArtifactBytes,
});
const BASES = ["A", "C", "G", "T"] as const;
const textDecoder = new TextDecoder("utf-8", { fatal: true });
type ParsedChromatogram = {
record: SequenceRecord;
warnings: Array<SequenceParseWarning>;
};
type AbifEntry = {
count: number;
dataOffset: number;
dataSize: number;
elementSize: number;
elementType: number;
key: string;
};
/** ABIF 1.01: PBAS/PLOC/PCON are matched by revision, DATA9–12 by FWO_1. */
export function parseAbifRecord({
bytes,
fileName,
}: {
bytes: Uint8Array;
fileName?: string;
}): ParsedChromatogram {
assertSourceBudget(bytes);
requireRange(bytes, 0, 128, "ABIF header");
if (ascii(bytes, 0, 4) !== "ABIF") throw new Error("Invalid ABIF signature.");
const view = dataView(bytes);
const version = view.getUint16(4, false);
if (version !== 101) {
throw new Error(
"Unsupported ABIF version. This viewer decodes ABIF 1.01 only.",
);
}
const root = readAbifEntry(bytes, 6, true);
if (
root.elementType !== 1023 ||
root.elementSize !== 28 ||
root.count === 0 ||
root.count > CHROMATOGRAM_LIMITS.maxDirectoryEntries ||
root.dataSize > CHROMATOGRAM_LIMITS.maxDirectoryEntries * 28 ||
root.dataSize % 28 !== 0 ||
root.dataOffset < 128
) {
throw new Error("Invalid or oversized ABIF root directory.");
}
const entries = new Map<string, AbifEntry>();
for (let index = 0; index < root.count; index += 1) {
const entryOffset = root.dataOffset + index * 28;
const entry = readAbifEntry(bytes, entryOffset);
if (entries.has(entry.key))
throw new Error("Duplicate ABIF directory tag.");
if (
entry.dataSize > 4 &&
(entry.dataOffset < 128 ||
overlaps(
entry.dataOffset,
entry.dataSize,
root.dataOffset,
root.dataSize,
))
) {
throw new Error("ABIF payload overlaps the header or directory.");
}
entries.set(entry.key, entry);
}
const warnings: Array<SequenceParseWarning> = [];
const revision = entries.has("PBAS2") && entries.has("PLOC2") ? 2 : 1;
const basesEntry = requireEntry(entries, `PBAS${revision}`);
const peaksEntry = requireEntry(entries, `PLOC${revision}`);
const called = readAbifCharacters(bytes, basesEntry).toUpperCase();
validateCalledBases(called);
if (revision === 1) {
warnings.push(
warning(
"abif-edited-calls",
"Displaying the source's edited PBAS1/PLOC1 base calls; unedited basecaller calls were unavailable.",
),
);
} else if (entries.has("PBAS1")) {
warnings.push(
warning(
"abif-basecaller-calls",
"Displaying the unedited PBAS2/PLOC2 basecaller calls. The separate edited-call revision is not applied.",
"info",
),
);
}
const reverseFlag = entries.get("RevC1");
if (reverseFlag != null && readAbifIntegers(bytes, reverseFlag)[0] !== 0) {
throw new Error(
"Reverse-complemented ABIF source traces are not supported; export the original-orientation trace.",
);
}
const order = readAbifCharacters(bytes, requireEntry(entries, "FWO_1"));
if (order.length !== 4 || BASES.some((base) => !order.includes(base))) {
throw new Error(
"ABIF FWO_1 must identify each of the four A/C/G/T trace channels exactly once.",
);
}
const channels: SequenceChromatogram["channels"] = {
A: [],
C: [],
G: [],
T: [],
};
for (const base of BASES) {
const entry = requireEntry(entries, `DATA${9 + order.indexOf(base)}`);
if (
entry.count === 0 ||
entry.count > CHROMATOGRAM_LIMITS.maxSamples ||
entry.elementSize !== 2 ||
(entry.elementType !== 3 && entry.elementType !== 4)
) {
throw new Error(
"ABIF processed trace channels must be bounded 16-bit integer arrays.",
);
}
channels[base] = readAbifIntegers(bytes, entry);
}
const sampleCount = channels.A.length;
if (BASES.some((base) => channels[base].length !== sampleCount)) {
throw new Error("ABIF trace channels have inconsistent sample counts.");
}
const peakLocations = readAbifIntegers(bytes, peaksEntry);
validatePeakLocations(peakLocations, called.length, sampleCount);
const qualityEntry = entries.get(`PCON${revision}`);
let quality: Array<number> | undefined;
if (qualityEntry != null) {
if (
qualityEntry.elementSize !== 1 ||
![1, 2].includes(qualityEntry.elementType)
) {
throw new Error("ABIF PCON quality values must be bytes.");
}
quality = Array.from(
bytes.subarray(
qualityEntry.dataOffset,
qualityEntry.dataOffset + qualityEntry.dataSize,
),
);
if (quality.length !== called.length) {
throw new Error("ABIF base calls and quality counts do not match.");
}
} else {
warnings.push(
warning(
"chromatogram-quality-absent",
"The source omits called-base quality scores. No quality values have been inferred.",
),
);
}
const decodedKeys = new Set([
`PBAS${revision}`,
`PLOC${revision}`,
`PCON${revision}`,
"FWO_1",
"DATA9",
"DATA10",
"DATA11",
"DATA12",
"RevC1",
]);
const undisplayedCount = [...entries.keys()].filter(
(key) => !decodedKeys.has(key),
).length;
if (undisplayedCount > 0) {
warnings.push(
warning(
"abif-undisplayed-tags",
`${undisplayedCount} additional ABIF directory tags are not interpreted by this trace view. Instrument settings, raw channels, audit data, and alternate analyses remain in the original file.`,
"info",
),
);
}
return {
record: makeRecord(
fileName,
called,
{
channels,
format: "abif",
peakLocations,
...(quality == null
? {}
: { quality, qualityEncoding: "phred" as const }),
sampleCount,
},
{
abif_version: "1.01",
base_call_revision: String(revision),
trace_channel_order: order,
trace_source: "processed DATA9-DATA12",
undisplayed_tag_count: String(undisplayedCount),
},
),
warnings,
};
}
/** SCF 2.00 interleaved and 3.00 double-delta arrays, in big-endian source order. */
export function parseScfRecord({
bytes,
fileName,
}: {
bytes: Uint8Array;
fileName?: string;
}): ParsedChromatogram {
assertSourceBudget(bytes);
const header = parseScientificScfHeader({
budget: { maxTraceSamples: CHROMATOGRAM_LIMITS.maxSamples },
bytes,
sourceSizeBytes: BigInt(bytes.byteLength),
});
if (header.version !== "2.00" && header.version !== "3.00") {
throw new Error(
"Unsupported SCF version. This viewer decodes SCF 2.00 and 3.00 only.",
);
}
if (header.bases === 0 || header.bases > CHROMATOGRAM_LIMITS.maxBaseCalls) {
throw new Error(
"SCF base-call count is empty or exceeds the bounded trace limit.",
);
}
const view = dataView(bytes);
const codeSet = view.getUint32(44, false);
if (![0, 2, 3, 4].includes(codeSet)) {
throw new Error(
"Unsupported SCF base code set. Export an IUPAC-coded SCF file.",
);
}
const sections = [
{ offset: 0, size: 128 },
{
offset: Number(header.samplesOffset),
size: header.samples * 4 * header.sampleSize,
},
{ offset: Number(header.basesOffset), size: header.bases * 12 },
{ offset: Number(header.commentsOffset), size: header.commentsSize },
{ offset: Number(header.privateOffset), size: header.privateSize },
].filter(({ size }) => size > 0);
for (const [index, section] of sections.entries()) {
if (
sections
.slice(index + 1)
.some((other) =>
overlaps(section.offset, section.size, other.offset, other.size),
)
) {
throw new Error(
"SCF sections overlap; the source cannot be decoded safely.",
);
}
}
const channels: SequenceChromatogram["channels"] = {
A: [],
C: [],
G: [],
T: [],
};
const mask = header.sampleSize === 1 ? 0xff : 0xffff;
for (const [baseIndex, base] of BASES.entries()) {
const samples = new Array<number>(header.samples);
let firstDifference = 0;
let amplitude = 0;
for (let sample = 0; sample < header.samples; sample += 1) {
const sampleIndex =
header.traceEncoding === "delta-delta"
? baseIndex * header.samples + sample
: sample * 4 + baseIndex;
const offset =
Number(header.samplesOffset) + sampleIndex * header.sampleSize;
const stored =
header.sampleSize === 1
? view.getUint8(offset)
: view.getUint16(offset, false);
if (header.traceEncoding === "delta-delta") {
firstDifference = (firstDifference + stored) & mask;
amplitude = (amplitude + firstDifference) & mask;
samples[sample] = amplitude;
} else {
samples[sample] = stored;
}
}
channels[base] = samples;
}
const baseConfidences: NonNullable<SequenceChromatogram["baseConfidences"]> =
{ A: [], C: [], G: [], T: [] };
const called: Array<string> = [];
const peakLocations: Array<number> = [];
const quality: Array<number | null> = [];
const baseOffset = Number(header.basesOffset);
for (let index = 0; index < header.bases; index += 1) {
const interleaved = header.traceEncoding === "interleaved";
peakLocations.push(
view.getUint32(baseOffset + index * (interleaved ? 12 : 4), false),
);
const base = String.fromCharCode(
view.getUint8(
baseOffset + (interleaved ? index * 12 + 8 : header.bases * 8 + index),
),
).toUpperCase();
called.push(base === "-" ? "N" : base);
for (const [baseIndex, candidate] of BASES.entries()) {
const offset =
baseOffset +
(interleaved
? index * 12 + 4 + baseIndex
: header.bases * (4 + baseIndex) + index);
baseConfidences[candidate].push(view.getUint8(offset));
}
quality.push(isBase(base) ? baseConfidences[base][index] : null);
}
const sequence = called.join("");
validateCalledBases(sequence);
validatePeakLocations(peakLocations, sequence.length, header.samples);
const warnings: Array<SequenceParseWarning> = [
warning(
"scf-source-confidence",
"SCF confidence bytes are shown as recorded, not assumed to be calibrated Phred scores. All four candidate-base confidences are retained; ambiguous calls have no derived called-base quality.",
"info",
),
];
if (header.commentsSize > 0) {
warnings.push(
warning(
"scf-comments-not-interpreted",
"SCF source comments are not interpreted by this view and remain in the original file.",
"info",
),
);
}
if (header.privateSize > 0) {
warnings.push(
warning(
"scf-private-data-not-interpreted",
"SCF private data are not decoded and remain in the original file.",
),
);
}
if (header.basesLeftClip !== 0 || header.basesRightClip !== 0) {
warnings.push(
warning(
"scf-clipping-not-applied",
"Historical SCF clipping hints are retained as metadata. This view shows the complete, untrimmed called sequence.",
"info",
),
);
}
if (quality.some((value) => value == null)) {
warnings.push(
warning(
"scf-ambiguous-calls",
"Ambiguous calls retain their source IUPAC symbol. SCF dash no-calls are represented as N; no confidence score is invented for either.",
"info",
),
);
}
return {
record: makeRecord(
fileName,
sequence,
{
baseConfidences,
channels,
format: "scf",
peakLocations,
quality,
qualityEncoding: "source-confidence",
sampleCount: header.samples,
},
{
scf_version: header.version,
scf_code_set: String(codeSet),
scf_sample_bytes: String(header.sampleSize),
scf_left_clip: String(header.basesLeftClip),
scf_right_clip: String(header.basesRightClip),
},
),
warnings,
};
}
function readAbifEntry(
bytes: Uint8Array,
offset: number,
isRoot = false,
): AbifEntry {
requireRange(bytes, offset, 28, "ABIF directory entry");
const view = dataView(bytes);
const name = ascii(bytes, offset, 4);
// Legal tags include punctuation, e.g. the standard S/N% signal-strength tag.
if (!/^[\x20-\x7e]{4}$/u.test(name))
throw new Error("Invalid ABIF directory tag name.");
const elementSize = view.getUint16(offset + 10, false);
const elementType = view.getUint16(offset + 8, false);
const count = view.getUint32(offset + 12, false);
const dataSize = view.getUint32(offset + 16, false);
// Real instrument root directories reserve unused entry slots; ordinary tag
// payloads must still match their declared element count exactly.
// The ABIF specification explicitly leaves element size/count undefined for
// unsupported and user-defined types. Their opaque payload is range-checked,
// but only interpreted integer/character types impose an element layout.
const interpretedType = [1, 2, 3, 4, 5].includes(elementType);
if (
(isRoot || interpretedType) &&
(elementSize === 0 ||
(isRoot
? dataSize < count * elementSize
: dataSize !== count * elementSize))
) {
throw new Error("ABIF directory element count and data size do not match.");
}
const dataOffset =
dataSize <= 4 ? offset + 20 : view.getUint32(offset + 20, false);
requireRange(bytes, dataOffset, dataSize, "ABIF tag payload");
return {
count,
dataOffset,
dataSize,
elementSize,
elementType,
key: `${name}${view.getUint32(offset + 4, false)}`,
};
}
function requireEntry(entries: Map<string, AbifEntry>, key: string): AbifEntry {
const entry = entries.get(key);
if (entry == null)
throw new Error(
`ABIF ${key} is missing. A called, four-channel Sanger trace is required.`,
);
return entry;
}
function readAbifCharacters(bytes: Uint8Array, entry: AbifEntry): string {
if (entry.elementType !== 2 || entry.elementSize !== 1) {
throw new Error(`Unsupported ABIF character encoding in ${entry.key}.`);
}
if (entry.count > CHROMATOGRAM_LIMITS.maxBaseCalls) {
throw new Error("ABIF base-call count exceeds the bounded trace limit.");
}
return ascii(bytes, entry.dataOffset, entry.dataSize);
}
function readAbifIntegers(bytes: Uint8Array, entry: AbifEntry): Array<number> {
if (
entry.count > CHROMATOGRAM_LIMITS.maxSamples ||
!(
(entry.elementSize === 2 && [3, 4].includes(entry.elementType)) ||
(entry.elementSize === 4 && entry.elementType === 5)
)
) {
throw new Error(
`Unsupported or oversized ABIF integer array in ${entry.key}.`,
);
}
const view = dataView(bytes);
return Array.from({ length: entry.count }, (_, index) => {
const offset = entry.dataOffset + index * entry.elementSize;
return entry.elementType === 3
? view.getUint16(offset, false)
: entry.elementType === 4
? view.getInt16(offset, false)
: view.getInt32(offset, false);
});
}
function makeRecord(
fileName: string | undefined,
sequence: string,
chromatogram: SequenceChromatogram,
metadata: SequenceRecord["metadata"],
): SequenceRecord {
const sourceLabel = biologicalFileBasename(fileName ?? "Sanger read");
return {
chromatogram,
features: [],
id: "trace-1",
length: sequence.length,
metadata: {
...metadata,
trace_orientation: "source",
trace_samples: String(chromatogram.sampleCount),
},
molecule: "dna",
sequence,
sourceLabel,
topology: "linear",
};
}
function validateCalledBases(sequence: string): void {
if (
sequence.length === 0 ||
sequence.length > CHROMATOGRAM_LIMITS.maxBaseCalls ||
!/^[ACGTRYSWKMBDHVN]+$/u.test(sequence)
) {
throw new Error(
"The trace must contain a bounded, nonempty IUPAC DNA base-call sequence.",
);
}
}
function validatePeakLocations(
peaks: Array<number>,
bases: number,
samples: number,
): void {
if (
peaks.length !== bases ||
peaks.some(
(peak, index) =>
!Number.isInteger(peak) ||
peak < 0 ||
peak >= samples ||
(index > 0 && peak < peaks[index - 1]),
)
) {
throw new Error(
"Trace peak locations must match the base calls and progress within the sample array.",
);
}
}
function assertSourceBudget(bytes: Uint8Array): void {
if (bytes.byteLength > CHROMATOGRAM_LIMITS.maxSourceBytes) {
throw new Error(
"This binary source exceeds the 8 MiB bounded trace viewer limit.",
);
}
}
function requireRange(
bytes: Uint8Array,
offset: number,
size: number,
label: string,
): void {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size < 0 ||
offset > bytes.byteLength - size
) {
throw new Error(`${label} is truncated or outside the source file.`);
}
}
function overlaps(
left: number,
leftSize: number,
right: number,
rightSize: number,
): boolean {
return left < right + rightSize && right < left + leftSize;
}
function dataView(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function ascii(bytes: Uint8Array, offset: number, size: number): string {
return textDecoder.decode(bytes.subarray(offset, offset + size));
}
function isBase(value: string): value is ChromatogramBase {
return value === "A" || value === "C" || value === "G" || value === "T";
}
function warning(
code: string,
message: string,
severity: "info" | "warning" = "warning",
): SequenceParseWarning {
return { code, message, severity };
}
SHA-256: ae33e555719ea88c60cf4c784551dba6e06ee184d210739d06f19245d52dac78