Predict interactions for all query–target sequence pairs.
@@ -50,6 +51,20 @@
Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 50 records per field and 2–5,000 nt per record; longest selected query × longest selected target ≤ 1,000,000. FASTA names stop at the first whitespace. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.
Each strand uses its start coordinate and dots/bars in 5′ to 3′ order; pair bars antiparallel. Example: 1|||&1|||. Separate structures with a colon. Evaluation retains energy/accessibility settings and ignores prediction filters.
+
+
Compensatory mutation settings
+
+
+
+
+
+
+
+
+
+
+
Query-first substitutions separated by commas. When supplied, explicit mutations replace candidate selection, filters and the generator. Browser runs are limited to 100 generated mutations.
+
@@ -85,6 +100,22 @@
+
+
+ Show or hide mutation columns
+
Ranked compensatory mutations. Select View to compare wildtype, single-mutant and compensatory interactions.
+
No mutations match this filter.
+
Ranks and scores follow the selected CopomuS measures. NA denotes an unavailable interaction or profile score.
Select View on a mutation to compare its four interaction states.
+
The compensatory state mutates both strands; the single-mutant states show the intended interaction disruption.
+
+
+
Additional output files
Computation diagnostics
diff --git a/gui/parameter-file.js b/gui/parameter-file.js
index 7e52f341..376050e5 100644
--- a/gui/parameter-file.js
+++ b/gui/parameter-file.js
@@ -1,6 +1,7 @@
import { JSON_SCHEMA, CSV_COLUMNS, LIMITS, normalizeParameters } from './protocol.js';
import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS } from './options.js';
import { CSV_CATALOG } from './columns.js';
+import { COPOMUS_DEFAULTS, normalizeCopomus } from './copomus-options.js';
export const MAX_PARAMETER_BYTES = 5000000;
const reports = { normal: 'N', detailed: 'D', ensemble: 'E' };
@@ -44,10 +45,16 @@ export function exportParameters(request, provenance) {
const selectedReports = outputs.filter((key) => Object.hasOwn(reports, key));
const comments = [
...provenance.trimEnd().split(/\r?\n/),
- 'Reproduce: IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv',
+ request.task === 'mutation'
+ ? 'Reproduce the mutation workflow with python3 CopomuS.py and the embedded sequences/settings.'
+ : 'Reproduce: IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv',
...selectedReports.map((key) => `Additional report: IntaRNA --parameterFile=parameter.json --outMode=${reports[key]} --outCsvCols=${CSV_COLUMNS} --outCsvSort '' --out=intarna-${key}.txt`),
];
const gui = {};
+ if (request.task === 'mutation') {
+ gui.task = 'mutation';
+ gui.copomus = normalizeCopomus(request.copomus);
+ }
if (selectedReports.length) gui.reports = selectedReports;
if (normalized.outSpotProbSpots) gui.outSpotProbSpots = normalized.outSpotProbSpots;
return { schema: JSON_SCHEMA, _comment: comments, parameters, ...(Object.keys(gui).length ? { gui } : {}) };
@@ -64,7 +71,7 @@ export function importParameters(source) {
const document = typeof source === 'string' ? JSON.parse(source.replace(/^\uFEFF/, '')) : source;
if (!object(document) || document.schema !== JSON_SCHEMA || !object(document.parameters)) throw new Error(`Expected a ${JSON_SCHEMA} parameter document.`);
for (const key of Object.keys(document)) if (!['schema', '_comment', 'parameters', 'gui'].includes(key)) throw new Error(`Unknown JSON document field: ${key}`);
- const request = { task: 'prediction', query: '', target: '', qIdxPos0: 1, tIdxPos0: 1, rri: '', parameters: {}, outputs: [], csvColumns: CSV_COLUMNS.split(',') };
+ const request = { task: 'prediction', query: '', target: '', qIdxPos0: 1, tIdxPos0: 1, rri: '', parameters: {}, outputs: [], csvColumns: CSV_COLUMNS.split(','), copomus: { ...COPOMUS_DEFAULTS, measures: [...COPOMUS_DEFAULTS.measures], candidateFilters: [] } };
const known = new Set(FIELDS.map((field) => field.key));
for (const [key, value] of Object.entries(document.parameters)) {
if (value === null) throw new Error(`${key}: null is not a parameter value.`);
@@ -98,8 +105,14 @@ export function importParameters(source) {
}
if (document.gui !== undefined) {
if (!object(document.gui)) throw new Error('gui must be an object.');
+ if (document.gui.task !== undefined) {
+ if (document.gui.task !== 'mutation' || request.task === 'evaluation') throw new Error('gui.task must select Mutation Design without an IntaRNAeval personality.');
+ request.task = 'mutation';
+ }
for (const [key, value] of Object.entries(document.gui)) {
- if (key === 'reports' && Array.isArray(value) && value.every((entry) => Object.hasOwn(reports, entry))) request.outputs.push(...value);
+ if (key === 'task') continue;
+ if (key === 'copomus') request.copomus = normalizeCopomus(value);
+ else if (key === 'reports' && Array.isArray(value) && value.every((entry) => Object.hasOwn(reports, entry))) request.outputs.push(...value);
else if (key === 'outSpotProbSpots') {
if (request.parameters.outSpotProbSpots && request.parameters.outSpotProbSpots !== value) throw new Error('Conflicting spotProb selections.');
request.parameters.outSpotProbSpots = text(value, key, 10000);
@@ -107,10 +120,13 @@ export function importParameters(source) {
}
}
selections(request);
+ if (request.task === 'mutation' && request.outputs.length) throw new Error('Mutation Design parameter files cannot request IntaRNA additional outputs.');
+ if (request.task !== 'mutation' && document.gui?.copomus !== undefined) throw new Error('CopomuS settings require gui.task Mutation Design.');
// Keep prediction-only controls from being silently ignored on import.
if (request.task === 'evaluation' && FIELDS.some((field) => field.prediction && Object.hasOwn(request.parameters, field.key))) throw new Error('Evaluation documents must omit prediction-only parameters.');
const normalized = normalizeParameters(request);
request.qIdxPos0 = normalized.qIdxPos0; request.tIdxPos0 = normalized.tIdxPos0;
request.parameters = normalized;
+ if (request.task === 'mutation') request.copomus = normalizeCopomus(request.copomus);
return request;
}
diff --git a/gui/protocol.js b/gui/protocol.js
index d4c71c2d..e5598e5c 100644
--- a/gui/protocol.js
+++ b/gui/protocol.js
@@ -1,5 +1,6 @@
import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS, ENSEMBLE_COLUMNS } from './options.js';
import { CSV_CATALOG } from './columns.js';
+import { normalizeCopomus } from './copomus-options.js';
// Bound the largest pair's matrix while allowing batches of shorter sequences.
export const LIMITS = Object.freeze({ minLength: 2, maxLength: 5000, maxRecords: 50, maxPairs: 1000000, maxFastaBytes: 1000000 });
@@ -25,7 +26,7 @@ export function parseFasta(text, label, multiple = true) {
current.sequence += line.replace(/\s/g, '').toUpperCase().replaceAll('T', 'U');
}
}
- if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 FASTA records' : 'evaluation requires exactly one FASTA record'}.`);
+ if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 FASTA records' : 'this task requires exactly one FASTA record'}.`);
const ids = new Set();
for (const record of records) {
if (!record.sequence || /[^ACGUN]/.test(record.sequence)) throw new Error(`${label}: use only A, C, G, U, T or N in every sequence.`);
@@ -111,7 +112,7 @@ export function validateInteractions(encoding, query, target, qStart, tStart, se
// Validate scalar controls independently so unfinished forms can be saved/loaded.
export function normalizeParameters(request) {
- if (!['prediction', 'evaluation'].includes(request.task ?? 'prediction')) throw new Error('Choose prediction or evaluation.');
+ if (!['prediction', 'evaluation', 'mutation'].includes(request.task ?? 'prediction')) throw new Error('Choose prediction, evaluation or Mutation Design.');
const evaluating = request.task === 'evaluation';
const parameters = {};
const supplied = request.parameters ?? {};
@@ -142,10 +143,15 @@ export function normalizeParameters(request) {
export function prepareRequest(request) {
const task = request?.task ?? 'prediction';
- if (!['prediction', 'evaluation'].includes(task)) throw new Error('Choose prediction or evaluation.');
+ if (!['prediction', 'evaluation', 'mutation'].includes(task)) throw new Error('Choose prediction, evaluation or Mutation Design.');
const evaluating = task === 'evaluation';
- const query = parseFasta(request.query, 'Query', !evaluating), target = parseFasta(request.target, 'Target', !evaluating);
+ const mutationDesign = task === 'mutation';
+ const query = parseFasta(request.query, 'Query', task === 'prediction'), target = parseFasta(request.target, 'Target', task === 'prediction');
const parameters = normalizeParameters(request);
+ const copomus = mutationDesign ? normalizeCopomus(request.copomus, {
+ query: query.records[0], target: target.records[0],
+ qIdxPos0: parameters.qIdxPos0, tIdxPos0: parameters.tIdxPos0,
+ }) : undefined;
const qRecords = selectedRecords(query, parameters.qSet, 'qSet'), tRecords = selectedRecords(target, parameters.tSet, 'tSet');
if (parameters.outPairwise && qRecords.length !== tRecords.length) throw new Error('Pairwise output requires the same number of selected query and target records.');
const work = Math.max(...qRecords.map((q) => q.sequence.length)) * Math.max(...tRecords.map((t) => t.sequence.length));
@@ -180,6 +186,7 @@ export function prepareRequest(request) {
if (parameters.outCsvSort && !csvColumns.includes(parameters.outCsvSort)) throw new Error('The CSV sort column must be selected for output.');
const outputs = request.outputs ?? [];
if (!Array.isArray(outputs) || new Set(outputs).size !== outputs.length || outputs.some((key) => !EXTRA_OUTPUTS.some((entry) => entry.key === key && (!evaluating || !entry.prediction)))) throw new Error('Choose available additional output formats for this task.');
+ if (mutationDesign && outputs.length) throw new Error('Mutation Design produces its own extended CopomuS output; disable IntaRNA additional output files.');
if (outputs.includes('ensemble') && (qRecords.length !== 1 || tRecords.length !== 1)) throw new Error('Ensemble reports require one query and one target.');
const needsEnsemble = csvColumns.some((c) => ENSEMBLE_COLUMNS.includes(c)) || outputs.some((c) => ['ensemble', 'spotProb', 'qSpotProb', 'tSpotProb'].includes(c));
if (evaluating) {
@@ -215,7 +222,7 @@ export function prepareRequest(request) {
}
}
}
- return { task, query, target, parameters, csvColumns, outputs, rri: evaluating ? request.rri : '' };
+ return { task, query, target, parameters, csvColumns, outputs, rri: evaluating ? request.rri : '', ...(copomus ? { copomus } : {}) };
}
export function makeArguments(request, format = 'C') {
diff --git a/gui/runner.js b/gui/runner.js
index 91ccc6b2..4ac79ecc 100644
--- a/gui/runner.js
+++ b/gui/runner.js
@@ -1,6 +1,6 @@
// The main thread stays available even while synchronous C++ is calculating.
export class PredictionRunner {
- constructor(workerFactory = () => new Worker(new URL('./intarna-worker.js', import.meta.url), { type: 'module' })) {
+ constructor(workerFactory = (request) => new Worker(new URL(request.task === 'mutation' ? './copomus-worker.js' : './intarna-worker.js', import.meta.url), { type: 'module' })) {
this.workerFactory = workerFactory;
this.worker = null;
this.cancel = null;
@@ -9,7 +9,7 @@ export class PredictionRunner {
run(request, onStatus = () => {}) {
if (this.worker) return Promise.reject(new Error('A prediction is already running.'));
return new Promise((resolve, reject) => {
- const worker = this.workerFactory();
+ const worker = this.workerFactory(request);
this.worker = worker;
const finish = (error, result) => {
if (this.worker !== worker) return; // Ignore queued messages after abort.
@@ -32,7 +32,7 @@ export class PredictionRunner {
};
worker.onerror = (event) => {
event.preventDefault();
- finish(new Error('Could not load or run the prediction engine. Reload the page and check that intarna.js and intarna.wasm are served together.'));
+ finish(new Error('Could not load or run the local analysis engine. Reload the page and check that the staged IntaRNA, Pyodide and Python assets are served together.'));
};
worker.onmessageerror = () => finish(new Error('Could not read the prediction engine response.'));
try {
diff --git a/gui/sessions.js b/gui/sessions.js
index ed3c873a..f7fa033c 100644
--- a/gui/sessions.js
+++ b/gui/sessions.js
@@ -2,6 +2,7 @@ import { ResultsView, downloadBlob } from './results.js';
import { serializeParameters, importParameters } from './parameter-file.js';
import { zipFiles } from './archive.js';
import { AdditionalOutputView } from './output-visualization.js';
+import { CopomusResultsView } from './copomus.js';
// Sessions live only in this page. Each owns a JSON snapshot and its own view;
// editing the form or exploring another session cannot change an earlier run.
@@ -47,13 +48,16 @@ export class SessionsView {
const parameterJson = serializeParameters({ ...request, qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0 }, provenance);
const root = this.template.content.firstElementChild.cloneNode(true);
const find = (role) => root.querySelector(`[data-role=${role}]`);
- const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : 'Prediction';
+ const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : request.task === 'mutation' ? 'Mutation Design' : 'Prediction';
+ const mutation = request.task === 'mutation';
const session = {
id, name: `${task} ${this.nextNumber}`, parameterJson, root,
- panel: find('panel'), toggle: find('toggle'), view: new ResultsView(find('results-view')), fileViews: [],
+ panel: find('panel'), toggle: find('toggle'),
+ view: mutation ? new CopomusResultsView(find('mutation-results-view')) : new ResultsView(find('results-view')),
+ fileViews: [],
};
- const count = session.view.show(result.csv, result.csvColumns, request);
- const summary = count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.';
+ const count = mutation ? session.view.show(result.mutation, request) : session.view.show(result.csv, result.csvColumns, request);
+ const summary = mutation ? `Mutation Design complete: ${count} ranked mutation${count === 1 ? '' : 's'}.` : count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.';
find('summary').textContent = summary;
root.dataset.sessionId = id;
session.panel.id = `${id}-panel`; session.toggle.setAttribute('aria-controls', session.panel.id);
@@ -93,7 +97,7 @@ export class SessionsView {
// filtered table, or the session selected at download time.
find('download-zip').addEventListener('click', () => {
try {
- const files = [{ name: 'parameter.json', text: parameterJson }, { name: 'intarna-interactions.csv', text: session.view.csv }, ...result.files];
+ const files = [{ name: 'parameter.json', text: parameterJson }, { name: mutation ? 'copomus-mutations.tsv' : 'intarna-interactions.csv', text: session.view.csv }, ...result.files];
const filename = session.name.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 80) || id;
downloadBlob(`${filename}.zip`, new Blob([zipFiles(files)], { type: 'application/zip' }));
} catch (error) { this.reportError(new Error(`ZIP download failed: ${error.message}`)); }
diff --git a/gui/styles.css b/gui/styles.css
index 35942d40..00906995 100644
--- a/gui/styles.css
+++ b/gui/styles.css
@@ -356,6 +356,50 @@ fieldset {
margin-top:20px;
}
+.mutation-input {
+ margin-top:20px;
+ padding-top:18px;
+ border-top:1px solid var(--border);
+}
+
+.mutation-option-grid {
+ display:grid;
+ grid-template-columns:repeat(2,minmax(0,1fr));
+ gap:16px;
+ margin:10px 0 14px;
+}
+
+.mutation-option-grid fieldset {
+ border:1px solid var(--border);
+ border-radius:5px;
+ padding:8px 12px;
+}
+
+.mutation-option-grid legend {
+ font-weight:600;
+ font-size:.9rem;
+}
+
+.measure-choice {
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:6px;
+}
+
+.measure-priority {
+ display:flex;
+}
+
+.measure-priority .text-button {
+ padding:.05rem .25rem;
+ text-decoration:none;
+}
+
+#copomus-mutation-encoding {
+ min-height:5rem;
+}
+
.field {
display:flex;
flex-direction:column;
@@ -463,7 +507,7 @@ fieldset {
font-size:.9rem;
}
-[data-role=visible-count] {
+[data-role=visible-count],[data-role=mutation-visible-count] {
font-size:.85rem;
color:var(--muted);
}
@@ -563,7 +607,7 @@ tbody tr.selected { background:var(--table-selection); }
gap:16px;
}
-[data-role=varri-frame] {
+[data-role=varri-frame],[data-role=mutation-varri-frame] {
display:block;
width:100%;
height:600px;
@@ -573,12 +617,31 @@ tbody tr.selected { background:var(--table-selection); }
background:white;
}
-[data-role=visualization-status] {
+[data-role=visualization-status],[data-role=mutation-visualization-status] {
max-width:none;
overflow-wrap:anywhere;
font-size:.9rem;
}
+.mutation-modes {
+ display:flex;
+ flex-wrap:wrap;
+ gap:8px;
+ margin-bottom:12px;
+}
+
+.mutation-modes button {
+ padding:.35rem .7rem;
+ background:var(--surface);
+ color:var(--brand-blue-dark);
+}
+
+.mutation-modes button[aria-pressed=true] {
+ color:white;
+ background:var(--brand-blue);
+ border-color:var(--brand-blue);
+}
+
[data-role=output-files] {
margin-top:24px;
}
@@ -820,7 +883,7 @@ dialog::backdrop {
.field-grid {
grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));
}
- .field-grid[class*=columns-],.accessibility-columns { grid-template-columns:1fr; }
+ .field-grid[class*=columns-],.accessibility-columns,.mutation-option-grid { grid-template-columns:1fr; }
.seed-primary-row { grid-template-columns:1fr; }
#csv-options .field-grid.columns-1 { grid-template-columns:1fr; }
.results {
diff --git a/gui/tests/parameter-file.test.mjs b/gui/tests/parameter-file.test.mjs
index 25f9ba6d..40357c6b 100644
--- a/gui/tests/parameter-file.test.mjs
+++ b/gui/tests/parameter-file.test.mjs
@@ -9,6 +9,7 @@ test('JSON round trip retains embedded inputs, typed options, selected columns a
for (const request of [
{ ...DEFAULTS, query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5, parameters: { temperature: 22, noSeed: true, mode: 'M', qRegion: '-3-7', outSpotProbSpots: '-5&-3' }, csvColumns: ['E', 'id2'], outputs: ['normal', 'qPu', 'spotProb'] },
{ ...DEFAULTS, task: 'evaluation', rri: EVALUATION_EXAMPLE, parameters: { qShape: '1 0.2\n2 0.3' }, outputs: ['detailed', 'ensemble'] },
+ { ...DEFAULTS, task: 'mutation', qIdxPos0: -3, tIdxPos0: -5, copomus: { measures: ['EDqi', 'E'], candidateSelection: 'mfeSO', candidateFilters: ['GU'], generator: 'any', mutationEncoding: 'G-3C&C5G', alpha: 2, beta: 3 } },
]) {
const document = exportParameters(request, provenance);
const imported = importParameters(JSON.stringify(document));
@@ -47,12 +48,12 @@ test('invalid/native-only settings are rejected before replacing the form', () =
test('maintainer examples use the same JSON import and prediction validation', async () => {
const { readFile } = await import('node:fs/promises');
const examples = JSON.parse(await readFile(new URL('../examples/index.json', import.meta.url)));
- assert.equal(examples.length, 2);
+ assert.equal(examples.length, 3);
const tasks = new Set();
for (const file of examples) {
const example = JSON.parse(await readFile(new URL(`../examples/${file}`, import.meta.url)));
assert.ok(example.name && example.description);
tasks.add(prepareRequest(importParameters(example.configuration)).task);
}
- assert.deepEqual([...tasks].sort(), ['evaluation', 'prediction']);
+ assert.deepEqual([...tasks].sort(), ['evaluation', 'mutation', 'prediction']);
});
diff --git a/gui/tests/protocol.test.mjs b/gui/tests/protocol.test.mjs
index c20f2502..14dbb8f8 100644
--- a/gui/tests/protocol.test.mjs
+++ b/gui/tests/protocol.test.mjs
@@ -49,6 +49,23 @@ test('evaluation validates antiparallel supplied structures in signed coordinate
assert.throws(() => prepareRequest({ ...evalRequest, outputs: ['bpsvg'] }), /available/);
});
+test('Mutation Design accepts one unambiguous pair and validates CopomuS settings', () => {
+ const mutation = request({}, { task: 'mutation', copomus: {
+ measures: ['E', 'minDeltaE'], candidateSelection: 'mfe', candidateFilters: ['GU'],
+ generator: 'flip', mutationEncoding: 'G1C&C10G', alpha: 1, beta: 2,
+ } });
+ const prepared = prepareRequest(mutation);
+ assert.equal(prepared.task, 'mutation');
+ assert.equal(prepared.query.records.length, 1);
+ assert.equal(prepared.copomus.mutationEncoding, 'G1C&C10G');
+ assert.deepEqual(prepared.copomus.measures, ['E', 'minDeltaE']);
+ assert.throws(() => prepareRequest({ ...mutation, query: '>one\nGGGAGGAGGG\n>two\nGGGAGGAGGG' }), /exactly one/);
+ assert.throws(() => prepareRequest({ ...mutation, query: 'GGNAGGAGGG' }), /unambiguous/);
+ assert.throws(() => prepareRequest({ ...mutation, copomus: { ...mutation.copomus, mutationEncoding: 'A1C&C10G' } }), /does not match/);
+ assert.throws(() => prepareRequest({ ...mutation, copomus: { ...mutation.copomus, measures: [] } }), /measures/);
+ assert.throws(() => prepareRequest({ ...mutation, outputs: ['qAcc'] }), /own extended/);
+});
+
test('cross-option constraints, regions, probabilities, SHAPE and local file arguments', () => {
for (const parameters of [{ noSeed: true, mode: 'S' }, { noSeed: true, outNumberPerSeed: 1 }, { mode: 'K', model: 'P' }, { model: 'B', mode: 'M' }, { model: 'B', helixMaxBP: 4 }, { qRegion: '8-2' }, { qAccConstr: 'b:1-20' }, { qAccConstr: 'bbb' }, { qShapeMethod: 'X' }, { tShapeConversion: 'bad' }, { qShape: '1 nope' }, { qAcc: 'P' }, { qAcc: 'P', qAccFile: 'bad' }, { outSpotProbSpots: '20&1' }, { windowWidth: 100 }, { qIntLenMax: 3 }, { qAcc: 'N', qRegionLenMax: 20 }, { qRegion: '1-10', qRegionLenMax: 20 }, { seedTQ: '1.|||||||||&1|||||||||.' }]) assert.throws(() => prepareRequest(request(parameters)));
assert.doesNotThrow(() => prepareRequest(request({ noSeed: true, qAccConstr: 'b:1-2,p:8-9', qRegion: '1-10' })));
diff --git a/gui/uploads.js b/gui/uploads.js
index 81748047..8a852463 100644
--- a/gui/uploads.js
+++ b/gui/uploads.js
@@ -4,6 +4,6 @@ import { parseFasta, LIMITS } from './protocol.js';
export async function readFastaFile(file, label, task) {
if (!file || file.size > LIMITS.maxFastaBytes) throw new Error(`${label}: choose a FASTA text file of at most 1 MB.`);
const text = await file.text();
- parseFasta(text, label, task !== 'evaluation');
+ parseFasta(text, label, task === 'prediction');
return text;
}
diff --git a/gui/visualization.js b/gui/visualization.js
index 059bea0a..03fdd498 100644
--- a/gui/visualization.js
+++ b/gui/visualization.js
@@ -46,9 +46,9 @@ export function visualizationUrl(row, request) {
}
export class InteractionVisualization {
- constructor(root) {
- this.frame = root.querySelector('[data-role=varri-frame]'); this.link = root.querySelector('[data-role=varri-link]');
- this.status = root.querySelector('[data-role=visualization-status]');
+ constructor(root, rolePrefix = '') {
+ this.frame = root.querySelector(`[data-role=${rolePrefix}varri-frame]`); this.link = root.querySelector(`[data-role=${rolePrefix}varri-link]`);
+ this.status = root.querySelector(`[data-role=${rolePrefix}visualization-status]`);
}
clear() {
this.frame.removeAttribute('src'); this.frame.hidden = true;
diff --git a/python/CopomuS.py b/python/CopomuS.py
index d2460fc9..f06252c9 100644
--- a/python/CopomuS.py
+++ b/python/CopomuS.py
@@ -20,6 +20,7 @@
from copomus.candidate_selectors import get_selector
from copomus.candidate_filters import get_filter
from copomus.mutation_generators import get_generator
+from copomus.workflow import calculate_ranks, write_csv
from intarna_tools import atomic_text_output, nonnegative_integer, read_sequence
@@ -186,28 +187,7 @@ def run_tests(self, mutations: List[Mutation]):
measure.post_compute()
def calculate_ranks(self, mutations: List[Mutation]):
- # Calculate total ranking order
- rank = 1
- mutations[0].ranks['total'] = rank
- for i in range(1, len(mutations)):
- if mutations[i - 1] < mutations[i]:
- rank += 1
- mutations[i].ranks['total'] = rank
-
- # Calculate ranking for each measurement
- for i in range(len(self.measures)):
- rank = 1
- m_id = self.measures[i]
-
- measures = [(x.measures[i], j) for j, x in enumerate(mutations)]
- measures.sort(key=lambda x: x[0])
-
- mutations[measures[0][1]].ranks[m_id] = rank
- for j in range(1, len(measures)):
- measure, mut_id = measures[j]
- if measures[j - 1][0] < measures[j][0]:
- rank += 1
- mutations[mut_id].ranks[m_id] = rank
+ calculate_ranks(mutations, self.measures)
def output_csv(self, mutations: List[Mutation]):
# Open a destination only after all predictions and ranking succeeded.
@@ -218,26 +198,7 @@ def output_csv(self, mutations: List[Mutation]):
self.write_csv(mutations, sys.stdout)
def write_csv(self, mutations: List[Mutation], handle):
- # CREATE CSV WRITER
- writer = csv.writer(handle, delimiter=self.delimiter, lineterminator="\n")
-
- # WRITE CSV
- headers = [j for i in
- [[f'{m.id}_rank'] + [f'{m.id}_{k}' for k in m.score.keys()] for m in mutations[0].measures]
- for j in i]
- writer.writerow(['mutation', 'rank', 'qIndex', 'tIndex', 'bpWildtype', 'bpMutated'] + headers)
-
- for m in mutations:
- values = []
- for x in m.measures:
- values.append(m.ranks[x.id])
- scores = list(x.score.values())
- for i in range(len(scores)): # replace None with 'NA'
- if scores[i] is None:
- scores[i] = 'NA'
- values += scores
- # values = [j for i in [list(x.score.values()) for x in r.measures] for j in i]
- writer.writerow([str(m), m.ranks['total'], m.query_n, m.target_n, m.bp_ww, m.bp_mm] + values) # write csv
+ write_csv(mutations, handle, self.delimiter)
def main(argv=None):
diff --git a/python/copomus/Makefile.am b/python/copomus/Makefile.am
index 0dc9467e..42d9c2d5 100644
--- a/python/copomus/Makefile.am
+++ b/python/copomus/Makefile.am
@@ -3,11 +3,13 @@ dist_copomus_DATA = \
__init__.py \
candidate_filters.py \
candidate_selectors.py \
+ browser.py \
indexing.py \
IntaRNA.py \
measures.py \
mutation.py \
- mutation_generators.py
+ mutation_generators.py \
+ workflow.py
copomusdir = $(bindir)/copomus
diff --git a/python/copomus/__init__.py b/python/copomus/__init__.py
index 04b7a3e9..becbe3ec 100644
--- a/python/copomus/__init__.py
+++ b/python/copomus/__init__.py
@@ -1 +1 @@
-__all__ = ['mutation', 'indexing', 'IntaRNA', 'measures']
+__all__ = ['mutation', 'indexing', 'IntaRNA', 'measures', 'workflow', 'browser']
diff --git a/python/copomus/browser.py b/python/copomus/browser.py
new file mode 100644
index 00000000..f9d7c988
--- /dev/null
+++ b/python/copomus/browser.py
@@ -0,0 +1,187 @@
+# Copyright 2026
+# Author: Alexander Mitrofanov
+
+"""Asynchronous CopomuS workflow for a JSON-to-WASM browser boundary."""
+
+import io
+import json
+import math
+
+from copomus.IntaRNA import IntaRNA
+from copomus.candidate_filters import get_filter
+from copomus.mutation import parse_mutation_encoding
+from copomus.mutation_generators import get_generator
+from copomus.workflow import calculate_ranks, table, write_csv
+
+
+MEASURES = ('E', 'minDeltaE', 'mfeCover', 'EDqi', 'cEDqi', 'Eqi', 'Eti')
+SELECTORS = ('mfe', 'mfeSO')
+FILTERS = ('GU', 'AU', 'GC', 'lp', 'lpMfe', 'he', 'heMfe')
+GENERATORS = ('flip', 'any')
+MAX_CANDIDATES = 100
+CONTEXT_COLUMNS = ('id1', 'start1', 'end1', 'id2', 'start2', 'end2',
+ 'subseqDP', 'hybridDP', 'E')
+
+
+async def _call(predict, query, target, request, *, columns, number=1,
+ outputs=(), parameters=None):
+ payload = {
+ 'query': query,
+ 'target': target,
+ 'qIdxPos0': request['qIdxPos0'],
+ 'tIdxPos0': request['tIdxPos0'],
+ 'columns': list(dict.fromkeys(columns)),
+ 'outNumber': number,
+ 'outputs': list(dict.fromkeys(outputs)),
+ 'parameters': parameters or {},
+ }
+ response = json.loads(await predict(json.dumps(payload, separators=(',', ':'))))
+ if response.get('error'):
+ raise RuntimeError(response['error'])
+ rows = IntaRNA.output_to_dict(response.get('csv', ''))
+ return rows, response.get('files', {})
+
+
+def _base_pairs(rows):
+ candidates = []
+ seen = set()
+ for row in rows or []:
+ for pair in (row.get('bpList') or '').split(':'):
+ if not pair:
+ continue
+ target_index, query_index = [int(value) for value in pair.strip('()').split(',')]
+ candidate = (query_index, target_index)
+ if candidate not in seen:
+ seen.add(candidate)
+ candidates.append(candidate)
+ return candidates
+
+
+async def _select_candidates(request, predict):
+ options = request['copomus']
+ if options['mutationEncoding']:
+ return [(query, target) for query, target, _ in parse_mutation_encoding(
+ options['mutationEncoding'], request['query'], request['target'],
+ request['qIdxPos0'], request['tIdxPos0'])]
+
+ if options['candidateSelection'] == 'mfe':
+ rows, _ = await _call(predict, request['query'], request['target'], request,
+ columns=['bpList'])
+ return _base_pairs(rows)
+
+ optimum, _ = await _call(
+ predict, request['query'], request['target'], request,
+ columns=['start1', 'end1', 'start2', 'end2'])
+ if not optimum:
+ return []
+ optimum = optimum[0]
+ rows, _ = await _call(
+ predict, request['query'], request['target'], request,
+ columns=['bpList'], number=1000,
+ parameters={'qRegion': f"{optimum['start2']}-{optimum['end2']}",
+ 'tRegion': f"{optimum['start1']}-{optimum['end1']}"})
+ return _base_pairs(rows)
+
+
+def _validate(request):
+ required = {'query', 'target', 'qIdxPos0', 'tIdxPos0', 'copomus'}
+ if not isinstance(request, dict) or not required <= request.keys():
+ raise ValueError('Incomplete mutation-design request')
+ options = request['copomus']
+ if (not isinstance(options, dict)
+ or not isinstance(options.get('measures'), list)
+ or not options['measures']
+ or len(set(options['measures'])) != len(options['measures'])
+ or any(measure not in MEASURES for measure in options['measures'])):
+ raise ValueError('Choose one or more available CopomuS measures')
+ if options.get('candidateSelection') not in SELECTORS:
+ raise ValueError('Choose an available candidate-selection method')
+ if (not isinstance(options.get('candidateFilters'), list)
+ or len(set(options['candidateFilters'])) != len(options['candidateFilters'])
+ or any(candidate_filter not in FILTERS
+ for candidate_filter in options['candidateFilters'])):
+ raise ValueError('Choose available candidate filters without duplicates')
+ if options.get('generator') not in GENERATORS:
+ raise ValueError('Choose an available mutation generator')
+ if not isinstance(options.get('mutationEncoding'), str):
+ raise ValueError('Mutation encoding must be text')
+ if not all(isinstance(options.get(key), (int, float))
+ and math.isfinite(options[key]) for key in ('alpha', 'beta')):
+ raise ValueError('alpha and beta must be finite numbers')
+
+
+async def run(request_json, predict, progress=lambda _message: None):
+ """Run CopomuS using an awaitable JSON predictor supplied by JavaScript."""
+ request = json.loads(request_json)
+ _validate(request)
+ options = request['copomus']
+ progress('Selecting compensatory-mutation candidates…')
+ base_pairs = await _select_candidates(request, predict)
+ if not options['mutationEncoding']:
+ for candidate_filter in options['candidateFilters']:
+ base_pairs = get_filter(candidate_filter)(
+ base_pairs, request['query'], request['target'],
+ request['qIdxPos0'], request['tIdxPos0'])
+ if not base_pairs:
+ raise ValueError('There are no base-pair candidates left after filtering')
+
+ if options['mutationEncoding']:
+ mutations = get_generator('specific')(
+ request['query'], request['target'], request['qIdxPos0'],
+ request['tIdxPos0'], options['measures'], options['alpha'],
+ options['beta'], options['mutationEncoding'])
+ else:
+ mutations = get_generator(options['generator'])(
+ request['query'], request['target'], request['qIdxPos0'],
+ request['tIdxPos0'], base_pairs, options['measures'],
+ options['alpha'], options['beta'])
+ if len(mutations) > MAX_CANDIDATES:
+ raise ValueError(
+ f'This browser run produced {len(mutations)} mutations; limit the '
+ f'candidate set to at most {MAX_CANDIDATES} with filters or an explicit encoding')
+
+ wildtype = None
+ for mutation_index, mutation in enumerate(mutations, 1):
+ progress(f'Evaluating mutation {mutation_index} of {len(mutations)}…')
+ columns = list(dict.fromkeys(
+ [column for measure in mutation.measures for column in measure.outcsvcols]
+ + list(CONTEXT_COLUMNS)))
+ outputs = list(dict.fromkeys(
+ output for measure in mutation.measures for output in measure.out_pipes))
+ mutation_visualizations = {}
+ for mode in ('ww', 'wm', 'mw', 'mm'):
+ if mode == 'ww' and wildtype is not None:
+ rows, profiles = wildtype
+ else:
+ query = mutation.qw if mode[0] == 'w' else mutation.qm
+ target = mutation.tw if mode[1] == 'w' else mutation.tm
+ rows, profiles = await _call(
+ predict, query, target, request, columns=columns, outputs=outputs)
+ if mode == 'ww':
+ wildtype = rows, profiles
+ data = rows[0] if rows else False
+ mutation_visualizations[mode] = data or None
+ for measure in mutation.measures:
+ measure.profiles = profiles
+ measure.calculate_score(data, mode, mutation, '')
+ for measure in mutation.measures:
+ measure.post_compute()
+ mutation.browser_visualizations = mutation_visualizations
+
+ mutations.sort()
+ calculate_ranks(mutations, options['measures'])
+ header, rows = table(mutations)
+ output = io.StringIO()
+ write_csv(mutations, output)
+ candidates = []
+ for mutation, values in zip(mutations, rows):
+ candidates.append({
+ 'values': dict(zip(header, values)),
+ 'queryWildtype': mutation.qw,
+ 'targetWildtype': mutation.tw,
+ 'queryMutated': mutation.qm,
+ 'targetMutated': mutation.tm,
+ 'interactions': mutation.browser_visualizations,
+ })
+ return json.dumps({'tsv': output.getvalue(), 'columns': header,
+ 'candidates': candidates}, separators=(',', ':'))
diff --git a/python/copomus/workflow.py b/python/copomus/workflow.py
new file mode 100644
index 00000000..378d5050
--- /dev/null
+++ b/python/copomus/workflow.py
@@ -0,0 +1,53 @@
+# Copyright 2026
+# Author: Alexander Mitrofanov
+
+"""Shared CopomuS ranking and tabular output helpers."""
+
+import csv
+
+
+def calculate_ranks(mutations, measure_ids):
+ """Populate total and per-measure ranks after mutations were sorted."""
+ rank = 1
+ mutations[0].ranks['total'] = rank
+ for index in range(1, len(mutations)):
+ if mutations[index - 1] < mutations[index]:
+ rank += 1
+ mutations[index].ranks['total'] = rank
+
+ for measure_index, measure_id in enumerate(measure_ids):
+ rank = 1
+ measures = [(mutation.measures[measure_index], index)
+ for index, mutation in enumerate(mutations)]
+ measures.sort(key=lambda entry: entry[0])
+ mutations[measures[0][1]].ranks[measure_id] = rank
+ for index in range(1, len(measures)):
+ measure, mutation_index = measures[index]
+ if measures[index - 1][0] < measure:
+ rank += 1
+ mutations[mutation_index].ranks[measure_id] = rank
+
+
+def table(mutations):
+ """Return the stable CopomuS header and rows for ranked mutations."""
+ headers = [column for measure in mutations[0].measures
+ for column in ([f'{measure.id}_rank']
+ + [f'{measure.id}_{key}' for key in measure.score])]
+ header = ['mutation', 'rank', 'qIndex', 'tIndex', 'bpWildtype', 'bpMutated', *headers]
+ rows = []
+ for mutation in mutations:
+ values = []
+ for measure in mutation.measures:
+ values.append(mutation.ranks[measure.id])
+ values.extend('NA' if value is None else value for value in measure.score.values())
+ rows.append([str(mutation), mutation.ranks['total'], mutation.query_n,
+ mutation.target_n, mutation.bp_ww, mutation.bp_mm, *values])
+ return header, rows
+
+
+def write_csv(mutations, handle, delimiter='\t'):
+ """Write ranked mutations with deterministic UTF-8/LF-compatible CSV."""
+ writer = csv.writer(handle, delimiter=delimiter, lineterminator='\n')
+ header, rows = table(mutations)
+ writer.writerow(header)
+ writer.writerows(rows)
diff --git a/python/tests/test_copomus.py b/python/tests/test_copomus.py
index 11ffcfc3..0e4ff5d8 100644
--- a/python/tests/test_copomus.py
+++ b/python/tests/test_copomus.py
@@ -1,7 +1,9 @@
"""CopomuS regressions for coordinates, mutation selection, profiles and ranking."""
import csv
+import asyncio
import io
+import json
from pathlib import Path
import sys
import tempfile
@@ -17,6 +19,7 @@
from copomus.measures import MinDeltaEnergy, MFECover, Measure
from copomus.mutation import Mutation, parse_mutation_encoding
from copomus.mutation_generators import gen_specific
+from copomus.browser import run as run_browser
class CopomuSTests(unittest.TestCase):
@@ -151,6 +154,42 @@ def test_cli_explicit_mutation_zero_energy_csv(self):
self.assertEqual(row['minDeltaE_min'], '0')
self.assertNotIn('\r', stdout.getvalue())
+ def test_browser_json_runner_reuses_scientific_workflow(self):
+ calls = []
+ energies = iter((-5, -1, -1, -4))
+
+ async def predict(payload):
+ call = json.loads(payload)
+ calls.append(call)
+ energy = next(energies)
+ columns = call['columns']
+ values = {
+ 'id1': 'target', 'start1': 1, 'end1': 2,
+ 'id2': 'query', 'start2': 1, 'end2': 2,
+ 'subseqDP': 'GC&GC', 'hybridDP': '()&()', 'E': energy,
+ }
+ return json.dumps({'csv': ';'.join(columns) + '\n'
+ + ';'.join(str(values[column]) for column in columns) + '\n',
+ 'files': {}})
+
+ progress = []
+ request = {
+ 'query': 'GC', 'target': 'GC', 'qIdxPos0': 1, 'tIdxPos0': 1,
+ 'copomus': {
+ 'measures': ['E'], 'candidateSelection': 'mfe',
+ 'candidateFilters': [], 'generator': 'flip',
+ 'mutationEncoding': 'G1C&C2G', 'alpha': 1, 'beta': 1,
+ },
+ }
+ result = json.loads(asyncio.run(run_browser(json.dumps(request), predict, progress.append)))
+ self.assertEqual(len(calls), 4)
+ self.assertTrue(all(call['query'] and call['target'] for call in calls))
+ self.assertEqual(result['candidates'][0]['values']['mutation'], 'G1C&C2G')
+ self.assertEqual(result['candidates'][0]['values']['E_is_valid'], 1)
+ self.assertEqual(result['candidates'][0]['interactions']['mm']['E'], -4)
+ self.assertTrue(result['tsv'].startswith('mutation\trank\tqIndex\ttIndex'))
+ self.assertIn('Evaluating mutation 1 of 1', progress[-1])
+
if __name__ == '__main__':
unittest.main()
diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh
index 10499d68..14268dbe 100644
--- a/scripts/wasm/build.sh
+++ b/scripts/wasm/build.sh
@@ -12,7 +12,7 @@ jobs=${JOBS:-2}
for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node python3; do
command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; }
done
-mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm"
+mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" "$build/site/vendor/pyodide"
fetch() {
bash "$repo/scripts/wasm/download.sh" "$1" "$build/downloads/$2" "$3"
}
@@ -20,6 +20,19 @@ fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \
9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23
fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \
af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89
+pyodide_version=314.0.7
+fetch "https://registry.npmjs.org/pyodide/-/pyodide-$pyodide_version.tgz" "pyodide-$pyodide_version.tgz" \
+ 66b7949e5a0cba2250b586d7e368bf12fb482800748d182c4618254b685ffaa0
+fetch "https://raw.githubusercontent.com/pyodide/pyodide/$pyodide_version/LICENSE" pyodide-LICENSE.txt \
+ 1f256ecad192880510e84ad60474eab7589218784b9a50bc7ceee34c2b91f1d5
+pyodide_source="$build/sources/pyodide-$pyodide_version"
+mkdir -p "$pyodide_source"
+if [[ ! -s "$pyodide_source/pyodide.mjs" ]]; then
+ tar -xzf "$build/downloads/pyodide-$pyodide_version.tgz" -C "$pyodide_source" --strip-components=1
+fi
+for file in pyodide.mjs pyodide.asm.mjs pyodide.asm.wasm pyodide-lock.json python_stdlib.zip; do
+ cp "$pyodide_source/$file" "$build/site/vendor/pyodide/$file"
+done
# CI supplies the newest stable 2.x manifest; local builds use the checked-in pin.
read -r vienna_version vienna_url vienna_sha256 < <(
python3 "$repo/scripts/wasm/vienna-release.py" --fields \
@@ -109,5 +122,5 @@ printf '{"type":"module"}\n' > "$build/site/wasm/package.json"
node "$repo/scripts/wasm/write-build-info.mjs" "$repo" "$build/site" \
"$(emcc -dumpversion)" "$(git -C "$repo" rev-parse HEAD)" \
"$(git -C "$repo" status --porcelain --untracked-files=no)" \
- "$vienna_version" "$vienna_url" "$vienna_sha256"
+ "$vienna_version" "$vienna_url" "$vienna_sha256" "$pyodide_version"
echo "WASM assets: $build/site/wasm"
diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh
index 62b6cb3c..da34865a 100644
--- a/scripts/wasm/stage.sh
+++ b/scripts/wasm/stage.sh
@@ -7,20 +7,22 @@ site="$build/site"
test -s "$site/wasm/intarna.js"
test -s "$site/wasm/intarna.wasm"
# Select notices from the archive used by these assets, including dlib upgrades.
-read -r vienna_version vienna_sha256 dlib_directory < <(python3 - "$site" "$build" <<'PYSOURCE'
+read -r vienna_version vienna_sha256 dlib_directory pyodide_version < <(python3 - "$site" "$build" <<'PYSOURCE'
import json, re, sys
from pathlib import Path
site, build = map(Path, sys.argv[1:])
metadata = json.loads((site / "build-info.json").read_text())
archive = metadata["viennarnaArchive"]
-version, digest = archive["version"], archive["sha256"]
+version, digest, pyodide = archive["version"], archive["sha256"], metadata["pyodide"]
if version != metadata["viennarna"] or not re.fullmatch(r"2\.\d+\.\d+", version) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise SystemExit("Invalid compiled ViennaRNA archive identity")
+if not re.fullmatch(r"\d+\.\d+\.\d+", pyodide):
+ raise SystemExit("Invalid staged Pyodide version")
source = build / "sources" / f"vienna-{version}-{digest}" / f"ViennaRNA-{version}"
dlib = list((source / "src").glob("dlib-*/dlib/LICENSE.txt"))
if len(dlib) != 1 or not re.fullmatch(r"dlib-[0-9.]+", dlib[0].parent.parent.name):
raise SystemExit("Expected one bundled dlib license in the compiled ViennaRNA source")
-print(version, digest, dlib[0].parent.parent.name)
+print(version, digest, dlib[0].parent.parent.name, pyodide)
PYSOURCE
)
vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version"
@@ -28,13 +30,20 @@ mkdir -p "$site/licenses"
cp -R "$repo/gui/assets" "$repo/gui/examples" "$repo/gui/vendor" "$site/"
test "$(sha256sum "$repo/gui/vendor/plotly-cartesian.min.js" | cut -d' ' -f1)" = "aaf12ff1d4671f7e93f6df4291651ab4ca6b76c0ea5f1576ec1030e3c2819a20"
test "$(sha256sum "$repo/gui/vendor/plotly.css" | cut -d' ' -f1)" = "3bcf2679b557067d8a3492b74092e946544d1cb8b2a72f278bcba95df59afd33"
-for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js output-visualization.js parameter-file.js uploads.js; do
+for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js output-visualization.js parameter-file.js uploads.js copomus.js copomus-options.js; do
cp "$repo/gui/$file" "$site/$file"
done
cp "$repo/gui/intarna-worker.js" "$site/intarna-worker.js"
+cp "$repo/gui/copomus-worker.js" "$site/copomus-worker.js"
+mkdir -p "$site/python/copomus"
+cp "$repo/python/intarna_tools.py" "$site/python/intarna_tools.py"
+for file in __init__.py browser.py workflow.py IntaRNA.py indexing.py candidate_filters.py measures.py mutation.py mutation_generators.py; do
+ cp "$repo/python/copomus/$file" "$site/python/copomus/$file"
+done
touch "$site/.nojekyll"
cp "$repo/src/bin/nlohmann/LICENSE.MIT" "$site/licenses/nlohmann-json.txt"
cp "$repo/gui/vendor/plotly-LICENSE.txt" "$site/licenses/Plotly.txt"
+cp "$build/downloads/pyodide-LICENSE.txt" "$site/licenses/Pyodide.txt"
cp "$repo/LICENSE" "$site/licenses/IntaRNA.txt"
cp "$repo/src/easylogging++.LICENCE.txt" "$site/licenses/easylogging.txt"
cp "$repo/src/mdspan/LICENSE" "$site/licenses/mdspan.txt"
@@ -53,11 +62,12 @@ cat > "$site/licenses/index.html" <IntaRNA licenses
IntaRNA and third-party notices
IntaRNA: Bioinformatics Group of Prof. Backofen, University of Freiburg. ViennaRNA: authors and Institute for Theoretical Chemistry, University of Vienna.
-
This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Plotly.js 4.1.2, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.
+
This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Plotly.js 4.1.2, Pyodide $pyodide_version, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.