Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The repository contains a neutral simulated demo only. Raw reads are processed l
- Optional YAML/UI `donor_ID`, same-donor contamination protection, and a donor-level combined collapsed/functional alignment and phylogeny workbench with sample-colored tips.
- A bundled Alivibe pop-out editor with permissive biological-edit warnings, validated return, explicit tree recalculation, separately persisted alignment/frame/tree edits, and a detailed append-only edit audit.
- Interactive UMI, artefact, agreement, MDS/APOBEC, and dinucleotide figures with labelled axes and SVG export.
- Reference-clipped codon-aware nucleotide/protein alignments for every functional-filtered collapsed variant. Sample proteins are aligned first; the reference is then added to that fixed profile without rearranging sample rows and is retained first under its supplied FASTA name. Passing variants are labelled `sample_vN_K rm=0.yy`, retaining the family-count suffix. Protein rows are verified before backtranslation; a sequence-preserving fallback repairs a structurally invalid MSA, while an unrecoverable functional-filter error is isolated to that sample and does not stop other samples. Sequences without a complete start-to-stop ORF are rejected.
- Codon-aware nucleotide/protein alignments for every functional-filtered collapsed variant. Functional screening aligns eligible sample ORFs, adds the reference to that fixed profile, and clips to the reference endpoints. Passing trimmed proteins are then realigned from scratch with bundled MAFFT 7.520 FFT-NS-2; MAFFT adds the reference to the passing-only profile without rearranging sample columns. Export retains every residue of these trimmed proteins, including terminal residues outside the newly aligned reference endpoints, and places the reference first under its supplied FASTA name. Passing variants retain `sample_vN_K rm=0.yy`; `rm` is the screening match score, before export realignment. Sequence preservation is checked before codon backtranslation, and a functional-filter error remains isolated to its sample. Sequences without a complete start-to-stop ORF are rejected.
- A single compressed `.webporpid` result file, component exports, and a complete gzip-compressed tar bundle.
- A subtle package-derived version label in the page header, so a deployed build can be identified immediately.

Expand Down
46 changes: 13 additions & 33 deletions cli-src/direct-mafft.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,13 @@ import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { Worker as NodeWorker } from "node:worker_threads";

const FFT_NS_2_ARGUMENTS = [
"-q", "0", "-E", "2", "-V", "-1.53", "-s", "0.0", "-W", "6", "-O",
"-C", "0-0", "-D", "-b", "62", "-g", "0", "-f", "-1.53", "-Q", "100.0",
"-h", "0", "-F", "-X", "0.1", "-x", "-1", "-i", "/input.fa",
];

const numericFasta = (sequences) => sequences.map((sequence, index) => `>${index}\n${sequence}\n`).join("");

function parseNumericAlignment(source, expected) {
const rows = new Map(); let index = -1, sequence = "";
const finish = () => { if (index >= 0) { if (rows.has(index)) throw new Error("MAFFT returned a duplicate sequence identifier."); rows.set(index, sequence.toUpperCase()); } };
for (const raw of source.split(/\r?\n/)) {
const line = raw.trim(); if (!line) continue;
if (line.startsWith(">")) {
finish(); index = Number(line.slice(1).trim()); sequence = "";
if (!Number.isSafeInteger(index) || index < 0 || index >= expected.length) throw new Error("MAFFT returned an unknown sequence identifier.");
} else { if (index < 0) throw new Error("MAFFT returned sequence data before its first header."); sequence += line.replace(/\s/g, ""); }
}
finish();
if (rows.size !== expected.length) throw new Error("MAFFT returned the wrong number of sequences.");
const aligned = expected.map((input, row) => {
const output = rows.get(row); if (output.replaceAll("-", "") !== input.toUpperCase()) throw new Error(`MAFFT changed sequence ${row + 1}.`); return output;
});
const width = aligned[0]?.length ?? 0;
if (!width || aligned.some((row) => row.length !== width)) throw new Error("MAFFT returned a non-rectangular alignment.");
return aligned;
}
import { mafftArguments, numericMafftFasta, parseMafftAlignment, mafftProfileAddition } from "../src/mafft-msa-codec.ts";

export function createDirectMafftRunner(javascriptPath, wasmPath) {
const factoryPromise = import(pathToFileURL(javascriptPath).href).then((module) => module.default);
const wasmPromise = readFile(wasmPath);
return async (sequences, signal, _iterations = 0, scoringMode = "nucleotide", onProgress) => {
const run = async (sequences, signal, _iterations = 0, scoringMode = "nucleotide", onProgress, profileRows) => {
if (signal?.aborted) throw new Error("Analysis cancelled.");
if (scoringMode === "amino-acid") throw new Error("The reference-panel MAFFT runner expects nucleotide sequences.");
if (sequences.length < 2) return [...sequences];
const input = sequences.map((sequence) => String(sequence).toUpperCase()), stdout = [];
if (input.some((sequence) => !sequence.length || /[^A-Z?*.-]/.test(sequence))) throw new Error("MAFFT input contains an unsupported symbol.");
Expand All @@ -47,11 +20,14 @@ export function createDirectMafftRunner(javascriptPath, wasmPath) {
lastProgress = now; onProgress?.({ detail });
}
} });
runtime.FS.writeFile("/input.fa", new TextEncoder().encode(numericFasta(input)));
try { const status = runtime.callMain([...FFT_NS_2_ARGUMENTS]); if (status) throw new Error(`MAFFT exited with status ${status}.`); }
runtime.FS.writeFile("/input.fa", new TextEncoder().encode(numericMafftFasta(input, scoringMode)));
try { const status = runtime.callMain(mafftArguments(scoringMode, profileRows)); if (status) throw new Error(`MAFFT exited with status ${status}.`); }
finally { try { runtime.FS.unlink("/input.fa"); } catch { /* best effort */ } }
return parseNumericAlignment(stdout.join("\n"), input);
return parseMafftAlignment(stdout.join("\n"), input, scoringMode);
};
run.addToProfile = async (profileRows, sequence, signal) => mafftProfileAddition(profileRows,
await run([...profileRows, sequence], signal, 0, "amino-acid", undefined, profileRows.length));
return run;
}

class MafftWorkerClient {
Expand All @@ -71,14 +47,18 @@ class MafftWorkerClient {
close() { return this.worker.terminate(); }
}

/** Run each sample's batch MAFFT alignment on an isolated CPU worker. */
/** Run each sample's MAFFT alignment on an isolated CPU worker. */
export function createMafftRunner(javascriptPath, wasmPath, size = 1, workerPath = new URL("../porpid-mafft-worker.mjs", import.meta.url)) {
const count = Math.max(1, Math.floor(size));
const clients = Array.from({ length: count }, () => new MafftWorkerClient(new NodeWorker(workerPath))); let cursor = 0;
const run = async (sequences, signal, iterations = 0, scoringMode = "nucleotide", onProgress) => {
if (signal?.aborted) throw new Error("Analysis cancelled.");
return clients[cursor++ % clients.length].call({ javascriptPath, wasmPath, sequences: sequences.map(String), iterations, scoringMode }, onProgress);
};
run.addToProfile = async (profileRows, sequence, signal) => {
if (signal?.aborted) throw new Error("Analysis cancelled.");
return clients[cursor++ % clients.length].call({ javascriptPath, wasmPath, profileRows: profileRows.map(String), sequence });
};
run.close = async () => { await Promise.all(clients.map((client) => client.close())); };
return run;
}
3 changes: 2 additions & 1 deletion cli-src/porpid-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ async function runPipeline({ inputPath, configPath, outputPath, workers, assets,
const panelFilterRunner = createIndependentPanelFilterRunner(assets.panelWorkerPath);
const downstreamStarted = performance.now(); let downstream;
try { downstream = await postprocess(consensuses, contamination, config, undefined, msaRunner, workers, undefined,
{ panelMsa: panelMsaRunner, panelFilter: panelFilterRunner }); }
{ panelMsa: panelMsaRunner, panelFilter: panelFilterRunner,
functionalAlignment: { msa: panelMsaRunner, addToProfile: panelMsaRunner.addToProfile } }); }
finally { await Promise.all([msaRunner.close?.(), panelMsaRunner.close?.()]); }
downstream.summaries.forEach((summary, index) => {
summary.demultiplexedReads = quality.perSample[index] ?? 0;
Expand Down
2 changes: 1 addition & 1 deletion cli-src/porpid-mafft-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ parentPort.on("message", async (message) => {
try {
const nextKey = `${message.javascriptPath}\0${message.wasmPath}`;
if (!runner || key !== nextKey) { key = nextKey; runner = createDirectMafftRunner(message.javascriptPath, message.wasmPath); }
const result = await runner(message.sequences, undefined, message.iterations, message.scoringMode,
const result = message.profileRows ? await runner.addToProfile(message.profileRows, message.sequence) : await runner(message.sequences, undefined, message.iterations, message.scoringMode,
({ detail }) => parentPort.postMessage({ id: message.id, progress: detail }));
parentPort.postMessage({ id: message.id, result });
} catch (cause) { parentPort.postMessage({ id: message.id, error: cause instanceof Error ? cause.message : String(cause) }); }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,60 @@
import { readFile } from "node:fs/promises";
import { Worker } from "node:worker_threads";
import { pathToFileURL } from "node:url";
//#region cli-src/direct-mafft.mjs
const FFT_NS_2_ARGUMENTS = [
"-q",
"0",
"-E",
"2",
"-V",
"-1.53",
"-s",
"0.0",
"-W",
"6",
"-O",
"-C",
"0-0",
"-D",
"-b",
"62",
"-g",
"0",
"-f",
"-1.53",
"-Q",
"100.0",
"-h",
"0",
"-F",
"-X",
"0.1",
"-x",
"-1",
"-i",
"/input.fa"
];
const numericFasta = (sequences) => sequences.map((sequence, index) => `>${index}\n${sequence}\n`).join("");
function parseNumericAlignment(source, expected) {
//#region src/mafft-msa-codec.ts
/** disttbfast flags used by MAFFT 7.520's FFT-NS-2 and --add wrappers. */
function mafftArguments(mode, profileRows) {
return [
"-q",
"0",
"-E",
"2",
"-V",
"-1.53",
"-s",
"0.0",
"-W",
"6",
"-O",
"-C",
"0-0",
mode === "amino-acid" ? "-P" : "-D",
"-b",
"62",
"-g",
"0",
"-f",
"-1.53",
"-Q",
"100.0",
"-h",
"0",
"-F",
"-X",
"0.1",
"-x",
"-1",
...profileRows === void 0 ? [] : [
"-K",
"-I",
"1"
],
"-i",
"/input.fa"
];
}
function numericMafftFasta(sequences, mode) {
return sequences.map((sequence, index) => {
return `>${index}\n${mode === "amino-acid" ? sequence.replaceAll("*", "X") : sequence}\n`;
}).join("");
}
function parseMafftAlignment(source, expected, mode) {
const rows = /* @__PURE__ */ new Map();
let index = -1, sequence = "";
const finish = () => {
if (index >= 0) {
if (rows.has(index)) throw new Error("MAFFT returned a duplicate sequence identifier.");
rows.set(index, sequence.toUpperCase());
}
if (index < 0) return;
if (rows.has(index)) throw new Error("MAFFT returned a duplicate sequence identifier.");
rows.set(index, sequence.toUpperCase());
};
for (const raw of source.split(/\r?\n/)) {
const line = raw.trim();
Expand All @@ -61,20 +72,37 @@ function parseNumericAlignment(source, expected) {
finish();
if (rows.size !== expected.length) throw new Error("MAFFT returned the wrong number of sequences.");
const aligned = expected.map((input, row) => {
const output = rows.get(row);
if (output.replaceAll("-", "") !== input.toUpperCase()) throw new Error(`MAFFT changed sequence ${row + 1}.`);
return output;
const original = input.replaceAll("-", "").toUpperCase(), output = rows.get(row);
const normalized = mode === "amino-acid" ? original.replaceAll("*", "X") : original;
if (output.replaceAll("-", "") !== normalized) throw new Error(`MAFFT changed sequence ${row + 1}.`);
let cursor = 0;
return [...output].map((residue) => residue === "-" ? "-" : original[cursor++]).join("");
});
const width = aligned[0]?.length ?? 0;
if (!width || aligned.some((row) => row.length !== width)) throw new Error("MAFFT returned a non-rectangular alignment.");
return aligned;
}
function mafftProfileAddition(profile, aligned) {
if (aligned.length !== profile.length + 1) throw new Error("MAFFT profile addition returned the wrong number of rows.");
const sampleRows = aligned.slice(0, profile.length);
const withoutGapColumns = (rows) => {
const columns = Array.from({ length: rows[0]?.length ?? 0 }, (_, column) => column).filter((column) => rows.some((row) => row[column] !== "-"));
return rows.map((row) => columns.map((column) => row[column]).join(""));
};
const before = withoutGapColumns(profile), after = withoutGapColumns(sampleRows);
if (before.some((row, index) => row !== after[index])) throw new Error("MAFFT reference addition changed the existing sample protein alignment.");
return {
profileRows: sampleRows,
sequence: aligned[profile.length]
};
}
//#endregion
//#region cli-src/direct-mafft.mjs
function createDirectMafftRunner(javascriptPath, wasmPath) {
const factoryPromise = import(pathToFileURL(javascriptPath).href).then((module) => module.default);
const wasmPromise = readFile(wasmPath);
return async (sequences, signal, _iterations = 0, scoringMode = "nucleotide", onProgress) => {
const run = async (sequences, signal, _iterations = 0, scoringMode = "nucleotide", onProgress, profileRows) => {
if (signal?.aborted) throw new Error("Analysis cancelled.");
if (scoringMode === "amino-acid") throw new Error("The reference-panel MAFFT runner expects nucleotide sequences.");
if (sequences.length < 2) return [...sequences];
const input = sequences.map((sequence) => String(sequence).toUpperCase()), stdout = [];
if (input.some((sequence) => !sequence.length || /[^A-Z?*.-]/.test(sequence))) throw new Error("MAFFT input contains an unsupported symbol.");
Expand All @@ -92,17 +120,19 @@ function createDirectMafftRunner(javascriptPath, wasmPath) {
}
}
});
runtime.FS.writeFile("/input.fa", new TextEncoder().encode(numericFasta(input)));
runtime.FS.writeFile("/input.fa", new TextEncoder().encode(numericMafftFasta(input, scoringMode)));
try {
const status = runtime.callMain([...FFT_NS_2_ARGUMENTS]);
const status = runtime.callMain(mafftArguments(scoringMode, profileRows));
if (status) throw new Error(`MAFFT exited with status ${status}.`);
} finally {
try {
runtime.FS.unlink("/input.fa");
} catch {}
}
return parseNumericAlignment(stdout.join("\n"), input);
return parseMafftAlignment(stdout.join("\n"), input, scoringMode);
};
run.addToProfile = async (profileRows, sequence, signal) => mafftProfileAddition(profileRows, await run([...profileRows, sequence], signal, 0, "amino-acid", void 0, profileRows.length));
return run;
}
var MafftWorkerClient = class {
constructor(worker) {
Expand Down Expand Up @@ -146,7 +176,7 @@ var MafftWorkerClient = class {
return this.worker.terminate();
}
};
/** Run each sample's batch MAFFT alignment on an isolated CPU worker. */
/** Run each sample's MAFFT alignment on an isolated CPU worker. */
function createMafftRunner(javascriptPath, wasmPath, size = 1, workerPath = new URL("../porpid-mafft-worker.mjs", import.meta.url)) {
const count = Math.max(1, Math.floor(size));
const clients = Array.from({ length: count }, () => new MafftWorkerClient(new Worker(workerPath)));
Expand All @@ -161,10 +191,19 @@ function createMafftRunner(javascriptPath, wasmPath, size = 1, workerPath = new
scoringMode
}, onProgress);
};
run.addToProfile = async (profileRows, sequence, signal) => {
if (signal?.aborted) throw new Error("Analysis cancelled.");
return clients[cursor++ % clients.length].call({
javascriptPath,
wasmPath,
profileRows: profileRows.map(String),
sequence
});
};
run.close = async () => {
await Promise.all(clients.map((client) => client.close()));
};
return run;
}
//#endregion
export { createMafftRunner as n, createDirectMafftRunner as t };
export { createMafftRunner as n, mafftProfileAddition as r, createDirectMafftRunner as t };
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { t as WASI } from "./dist-BPeWFyil.mjs";
import { t as WASI } from "./dist-DaUuEvmK.mjs";
import { readFile } from "node:fs/promises";
import { Worker } from "node:worker_threads";
//#region src/alivibe-msa-codec.ts
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { n as BinaryWriter, t as BinaryReader } from "./binary-68r8u1WT.mjs";
import { t as WASI } from "./dist-BPeWFyil.mjs";
import { t as WASI } from "./dist-DaUuEvmK.mjs";
//#region src/wasm-runtime.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();
Expand Down
Loading
Loading