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
4 changes: 4 additions & 0 deletions .github/workflows/build-wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ jobs:
test -s _site/gui/wasm/intarna.wasm
test -s _site/gui/vendor/plotly-cartesian.min.js
test -s _site/gui/vendor/plotly.css
test -s _site/gui/vendor/pyodide/pyodide.asm.wasm
test -s _site/gui/copomus-worker.js
test -s _site/gui/python/copomus/browser.py
test -s _site/gui/licenses/Plotly.txt
test -s _site/gui/licenses/Pyodide.txt
- uses: actions/upload-artifact@v4
with:
name: intarna-pages-preview
Expand Down
23 changes: 23 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

## Interface and handling

- integrate CopomuS as the browser Mutation Design task with single-pair input,
native controls, extended tables and four-state vaRRI visualization (#307)

- fix IntaRNApvalue both-strand sampling and parameter consistency, bound retries,
validate FASTA/fits and expose empirical p-values (PR #298)

Expand Down Expand Up @@ -137,6 +140,9 @@

## Technical changes and Optimizations

- run the existing CopomuS Python workflow in checksum-pinned, self-hosted
Pyodide and replace subprocesses with validated JSON calls to IntaRNA WASM (#307)

- share Python companion-tool command execution, input validation and atomic
output handling after rebasing onto IntaRNA-fuse (PR #298 review)

Expand Down Expand Up @@ -332,6 +338,23 @@ energies, restricted partition sums, and trackers.
################################################################################

261026 Alexander Mitrofanov
* gui/{index.html,app.js,styles.css,copomus*.js,runner.js,sessions.js},
gui/examples/, gui/tests/, scripts/wasm/test-browser.py
+ Add Mutation Design after Prediction and Evaluation, enforce one unambiguous
sequence per strand, and expose CopomuS measures, selection, filters,
generators, thresholds and explicit encodings in the Sequences panel.
+ Retain ranked extended TSV output in sessions and compare wildtype, both
single mutants and the compensatory mutant in vaRRI (issue #307).
* python/copomus/{browser.py,workflow.py}, python/CopomuS.py,
python/tests/test_copomus.py
+ Share CLI/browser ranking and output logic; run the existing mutation,
filter and measure classes asynchronously against JSON prediction callbacks
without changing CopomuS CLI behavior or output.
* scripts/wasm/{build.sh,stage.sh,write-build-info.mjs},
.github/workflows/build-wasm.yml, README.md, gui/docs/
+ Download and verify self-hosted Pyodide 314.0.7, stage the required Python
sources and MPL notice, record runtime digests, and validate the complete
Pyodide-to-IntaRNA-WASM workflow in Chromium and Firefox.
* gui/output-visualization.js, gui/styles.css, gui/tests/output-visualization.test.mjs
+ Add resizable Plotly canvases and matrix-axis transposition controls.
* Export PNGs at the current plotting dimensions instead of a fixed size.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
**Efficient RNA-RNA interaction prediction incorporating accessibility and
seeding of interaction sites**

Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/).
Predict short RNA pairs, evaluate supplied structures or rank compensatory mutations locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/).
The browser accepts up to 50 records per strand, 2–5,000 nucleotides each, with longest selected query × longest selected target at most 1,000,000. Prediction defaults to 10 interactions per pair and includes opening and seed energies; evaluation selects all energy columns.
Mutation Design accepts one unambiguous query/target pair, runs CopomuS in a self-hosted Pyodide worker, and uses the same IntaRNA WebAssembly engine for every wildtype, single-mutant and compensatory prediction. Its extended table and vaRRI controls compare all four mutation states.
Computed runs stay in separate, foldable sessions under Results in the current tab. Name and select sessions to restore their inputs and compare results; editing or resetting parameters keeps earlier runs.
Select an interaction to view it in vaRRI, or download a session’s ZIP with inputs, compiled versions, reproducible parameters and all outputs. Sessions are temporary and are cleared when the page is reloaded.
Minimum-energy and interaction-probability output files include local Plotly visualizations with PNG export; every additional file retains its original text view and download.
See the [WebAssembly build instructions](gui/docs/step1-wasm-compilation.md) and
[interface/deployment notes](gui/docs/step4-results-and-deployment.md).
The [CopomuS browser architecture](gui/docs/copomus.md) describes its controls,
JSON boundary, workload limit and reproducibility behavior.


During the last few years, several new small regulatory RNAs
Expand Down
68 changes: 60 additions & 8 deletions gui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { downloadText } from './results.js';
import { SessionsView } from './sessions.js';
import { importParameters, serializeParameters, MAX_PARAMETER_BYTES } from './parameter-file.js';
import { readFastaFile } from './uploads.js';
import { COPOMUS_DEFAULTS, COPOMUS_FILTERS, COPOMUS_MEASURES } from './copomus-options.js';

const byId = (id) => document.getElementById(id);
const form = byId('prediction-form'), runner = new PredictionRunner();
Expand All @@ -24,6 +25,28 @@ function element(tag, text, className) {
if (className) node.className = className;
return node;
}
const copomusMeasureInputs = [], copomusMeasureRows = new Map();
for (const [key, labelText] of COPOMUS_MEASURES) {
const input = checkbox(byId('copomus-measures'), key, labelText), label = input.parentElement;
const row = element('div', '', 'measure-choice'), actions = element('span', '', 'measure-priority');
label.replaceWith(row); row.append(label, actions); copomusMeasureRows.set(key, row); copomusMeasureInputs.push(input);
for (const [text, direction] of [['↑', -1], ['↓', 1]]) {
const button = element('button', text, 'text-button'); button.type = 'button';
button.setAttribute('aria-label', `Move ${labelText} ${direction < 0 ? 'earlier' : 'later'} in ranking priority`);
button.addEventListener('click', () => {
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling;
if (!sibling) return;
if (direction < 0) row.parentElement.insertBefore(row, sibling); else row.parentElement.insertBefore(sibling, row);
inputsChanged('CopomuS measure priority changed. Compute to update the mutations.');
});
actions.append(button);
}
}
const copomusFilterInputs = COPOMUS_FILTERS.map(([key, label]) => checkbox(byId('copomus-filters'), key, label));
function orderCopomusMeasures(selected) {
const order = [...selected, ...COPOMUS_MEASURES.map(([key]) => key).filter((key) => !selected.includes(key))];
for (const key of order) byId('copomus-measures').append(copomusMeasureRows.get(key));
}
function activateGroup(key, focus = false) {
for (const tab of byId('parameter-tabs').children) {
const active = tab.getAttribute('aria-controls') === key;
Expand Down Expand Up @@ -178,22 +201,25 @@ function updateLength(name) {
} catch { byId(`${name}-length`).textContent = 'Check sequence format and length'; }
}
function updateAvailability() {
const evaluation = getTask() === 'evaluation';
const evaluation = getTask() === 'evaluation', mutation = getTask() === 'mutation';
for (const button of byId('example-options').querySelectorAll('[data-example-task]')) {
button.hidden = button.dataset.exampleTask !== getTask();
}
byId('evaluation-input').hidden = !evaluation;
byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : 'Predict interactions for all query–target sequence pairs.';
byId('compute').textContent = evaluation ? 'Evaluate interactions' : 'Compute interactions';
byId('mutation-input').hidden = !mutation;
byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : mutation ? 'Rank compensatory point mutations for one query and one target with CopomuS.' : 'Predict interactions for all query–target sequence pairs.';
byId('sequence-help').textContent = mutation ? 'One plain sequence or one FASTA record per field, A/C/G/U/T. CopomuS ranks mutations with repeated local IntaRNA predictions.' : '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.';
byId('compute').textContent = evaluation ? 'Evaluate interactions' : mutation ? 'Design mutations' : 'Compute interactions';
for (const definition of GROUPS) {
const tab = byId(`tab-${definition.key}`); tab.hidden = evaluation && !!definition.prediction;
const unavailable = evaluation && !!definition.prediction || mutation && ['output-options', 'csv-options'].includes(definition.key);
const tab = byId(`tab-${definition.key}`); tab.hidden = unavailable;
if (tab.hidden) {
byId(definition.key).hidden = true;
if (tab.getAttribute('aria-selected') === 'true') activateGroup('sequence-input');
}
for (const field of definition.fields) {
const input = byId(field.key);
input.disabled = evaluation && !!field.prediction || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K';
input.disabled = unavailable || evaluation && !!field.prediction || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K';
}
}
byId('noSeed').checked = byId('seed-mode').value === 'none';
Expand All @@ -209,7 +235,10 @@ function updateAvailability() {
}
// Upload controls follow the text area they populate, including async reads.
for (const upload of form.querySelectorAll('input[data-for]')) upload.disabled = byId(upload.dataset.for).disabled;
for (const input of outputInputs) { input.disabled = evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; }
for (const input of outputInputs) { input.disabled = mutation || evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; }
const explicitMutations = !!byId('copomus-mutation-encoding').value.trim();
for (const input of [byId('copomus-candidate-selection'), byId('copomus-generator'), ...copomusFilterInputs]) input.disabled = !mutation || explicitMutations;
for (const input of [...copomusMeasureInputs, byId('copomus-alpha'), byId('copomus-beta'), byId('copomus-mutation-encoding')]) input.disabled = !mutation;
updateLength('query'); updateLength('target');
}
function resetParameters({ clearInputs = false } = {}) {
Expand All @@ -219,12 +248,19 @@ function resetParameters({ clearInputs = false } = {}) {
for (const input of form.querySelectorAll('input[type=file]')) input.value = '';
byId('examples-menu').open = false; activateGroup('sequence-input');
}
csvOrder = defaultColumns(getTask());
csvOrder = getTask() === 'mutation' ? CSV_COLUMNS.split(',') : defaultColumns(getTask());
byId('seed-mode').value = 'identify';
for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = UI_DEFAULTS[field.key]; else input.value = UI_DEFAULTS[field.key]; }
syncAccessibilityFlags();
for (const input of csvInputs) input.checked = csvOrder.includes(input.value);
for (const input of outputInputs) input.checked = false;
for (const input of copomusMeasureInputs) input.checked = COPOMUS_DEFAULTS.measures.includes(input.value);
orderCopomusMeasures(COPOMUS_DEFAULTS.measures);
for (const input of copomusFilterInputs) input.checked = false;
byId('copomus-candidate-selection').value = COPOMUS_DEFAULTS.candidateSelection;
byId('copomus-generator').value = COPOMUS_DEFAULTS.generator;
byId('copomus-mutation-encoding').value = COPOMUS_DEFAULTS.mutationEncoding;
byId('copomus-alpha').value = COPOMUS_DEFAULTS.alpha; byId('copomus-beta').value = COPOMUS_DEFAULTS.beta;
byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = '';
updateAvailability(); errorBox.hidden = true; inputsChanged(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to task defaults. Compute to update the interactions.');
}
Expand Down Expand Up @@ -259,6 +295,14 @@ function applyParameters(request, message = 'Parameters loaded. Compute to analy
csvOrder = [...request.csvColumns];
for (const input of csvInputs) input.checked = request.csvColumns.includes(input.value);
for (const input of outputInputs) input.checked = request.outputs.includes(input.value);
const copomus = request.copomus ?? COPOMUS_DEFAULTS;
orderCopomusMeasures(copomus.measures);
for (const input of copomusMeasureInputs) input.checked = copomus.measures.includes(input.value);
for (const input of copomusFilterInputs) input.checked = copomus.candidateFilters.includes(input.value);
byId('copomus-candidate-selection').value = copomus.candidateSelection;
byId('copomus-generator').value = copomus.generator;
byId('copomus-mutation-encoding').value = copomus.mutationEncoding;
byId('copomus-alpha').value = copomus.alpha; byId('copomus-beta').value = copomus.beta;
updateAvailability(); activateGroup('sequence-input'); inputsChanged(message);
}
for (const strand of ['query', 'target']) {
Expand Down Expand Up @@ -360,6 +404,14 @@ function currentRequest() {
task: getTask(), query: byId('query').value, target: byId('target').value,
qIdxPos0: byId('qIdxPos0').value, tIdxPos0: byId('tIdxPos0').value, rri: byId('rri').value,
parameters: currentParameters(),
copomus: {
measures: [...byId('copomus-measures').querySelectorAll('input:checked')].map((input) => input.value),
candidateSelection: byId('copomus-candidate-selection').value,
candidateFilters: copomusFilterInputs.filter((input) => input.checked).map((input) => input.value),
generator: byId('copomus-generator').value,
mutationEncoding: byId('copomus-mutation-encoding').value,
alpha: byId('copomus-alpha').value, beta: byId('copomus-beta').value,
},
csvColumns: columns, outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value),
};
}
Expand Down Expand Up @@ -394,5 +446,5 @@ const buildInfo = fetch(new URL('./build-info.txt', import.meta.url)).then((resp
buildInfo.catch(() => {});
fetch(new URL('./build-info.json', import.meta.url)).then((response) => {
if (!response.ok) throw new Error('Missing build provenance'); return response.json();
}).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; });
}).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}${info.pyodide ? ` · Pyodide ${info.pyodide}` : ''}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; });
if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; }
58 changes: 58 additions & 0 deletions gui/copomus-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export const COPOMUS_MEASURES = Object.freeze([
['mfeCover', 'MFE coverage'], ['E', 'Interaction energy'],
['minDeltaE', 'Minimum energy difference'], ['EDqi', 'Query accessibility'],
['cEDqi', 'Constant query accessibility'], ['Eqi', 'Query energy profile'],
['Eti', 'Target energy profile'],
]);
export const COPOMUS_FILTERS = Object.freeze([
['GU', 'Exclude GU/UG pairs'], ['AU', 'Exclude AU/UA pairs'],
['GC', 'Exclude GC/CG pairs'], ['lp', 'Exclude unstackable lonely pairs'],
['lpMfe', 'Exclude lonely pairs in the MFE'], ['he', 'Exclude potential helix ends'],
['heMfe', 'Exclude helix ends in the MFE'],
]);
export const COPOMUS_DEFAULTS = Object.freeze({
measures: ['mfeCover', 'E', 'minDeltaE'],
candidateSelection: 'mfe', candidateFilters: [], generator: 'flip',
mutationEncoding: '', alpha: 1, beta: 1,
});

const choices = (catalog) => new Set(catalog.map(([key]) => key));
const measureChoices = choices(COPOMUS_MEASURES), filterChoices = choices(COPOMUS_FILTERS);
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const sequenceOffset = (index, start) => index - start - (start < 0 && index > 0 ? 1 : 0);

export function normalizeCopomus(options = {}, context) {
if (!isObject(options)) throw new Error('Mutation Design settings must be an object.');
const normalized = { ...COPOMUS_DEFAULTS, ...options };
for (const [key, catalog, label, nonempty] of [
['measures', measureChoices, 'measures', true],
['candidateFilters', filterChoices, 'candidate filters', false],
]) {
const values = normalized[key];
if (!Array.isArray(values) || nonempty && !values.length || new Set(values).size !== values.length || values.some((value) => !catalog.has(value))) throw new Error(`Choose available CopomuS ${label}, without duplicates.`);
normalized[key] = [...values];
}
if (!['mfe', 'mfeSO'].includes(normalized.candidateSelection)) throw new Error('Choose an available CopomuS candidate-selection method.');
if (!['flip', 'any'].includes(normalized.generator)) throw new Error('Choose an available CopomuS mutation generator.');
if (typeof normalized.mutationEncoding !== 'string' || normalized.mutationEncoding.length > 10000 || normalized.mutationEncoding.includes('\0')) throw new Error('Mutation encoding must be at most 10,000 text characters.');
normalized.mutationEncoding = normalized.mutationEncoding.trim().toUpperCase().replaceAll(' ', '');
for (const key of ['alpha', 'beta']) {
const value = typeof normalized[key] === 'number' ? normalized[key] : typeof normalized[key] === 'string' && normalized[key].trim() ? Number(normalized[key]) : NaN;
if (!Number.isFinite(value)) throw new Error(`${key}: enter a finite number.`);
normalized[key] = value;
}
if (!context) return normalized;
const { query, target, qIdxPos0, tIdxPos0 } = context;
if (query.sequence.includes('N') || target.sequence.includes('N')) throw new Error('Mutation Design requires unambiguous A, C, G or U sequences.');
if (!normalized.mutationEncoding) return normalized;
for (const entry of normalized.mutationEncoding.split(',')) {
const match = /^([ACGU])(-?\d+)([ACGU])&([ACGU])(-?\d+)([ACGU])$/.exec(entry);
if (!match) throw new Error(`Invalid mutation encoding: “${entry}”. Use query-first substitutions such as G1C&C10G.`);
const [, queryWild, queryPosition, queryMutated, targetWild, targetPosition, targetMutated] = match;
const queryIndex = sequenceOffset(Number(queryPosition), qIdxPos0), targetIndex = sequenceOffset(Number(targetPosition), tIdxPos0);
if (Number(queryPosition) === 0 || Number(targetPosition) === 0 || queryIndex < 0 || queryIndex >= query.sequence.length || targetIndex < 0 || targetIndex >= target.sequence.length) throw new Error('A mutation position is outside its sequence or uses skipped coordinate zero.');
if (query.sequence[queryIndex] !== queryWild || target.sequence[targetIndex] !== targetWild) throw new Error('Mutation encoding does not match the wildtype sequences.');
if (queryWild === queryMutated || targetWild === targetMutated) throw new Error('Both encoded bases must change.');
}
return normalized;
}
Loading
Loading