From 47a40d1720bfb08903d3f41761a5befe88777616 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Tue, 21 Jul 2026 12:52:41 +1000 Subject: [PATCH 1/5] feat: add form validation for single prediction based on sequence length and number of entities --- .../single-prediction/single-prediction.html | 17 ++- .../single-prediction.spec.ts | 127 +++++++++++++++++- .../single-prediction/single-prediction.ts | 101 +++++++++++++- 3 files changed, 232 insertions(+), 13 deletions(-) diff --git a/src/app/features/workflows/single-prediction/single-prediction.html b/src/app/features/workflows/single-prediction/single-prediction.html index ca91b4d..af6c22f 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.html +++ b/src/app/features/workflows/single-prediction/single-prediction.html @@ -195,8 +195,8 @@ class="pointer-events-none absolute inset-0 overflow-hidden break-all px-4 py-1 font-mono text-sm text-gray-900" aria-hidden="true" > - @for (cell of getSequenceCells(row.sequence); track - $index) { - @if (creditsEnabled) { + @if (inputSummaryErrors().length > 0) { + + } @if (creditsEnabled) { { return rowId; } + function addProteinRow(sequence = "ACDEFGHIK"): number { + component.addEntityRow(); + const rowId = component.entityRows()[component.entityRows().length - 1].id; + component.updateRowMoleculeType(rowId, "protein"); + component.updateRowSequence(rowId, sequence); + return rowId; + } + it("should create", () => { expect(component).toBeTruthy(); }); @@ -217,6 +225,7 @@ describe("SinglePredictionComponent", () => { const rowId = component.entityRows()[0].id; component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); + addProteinRow(); component.updateRowSequence(rowId, "ACGT"); component.updateRowMoleculeType(rowId, "dna"); @@ -248,6 +257,7 @@ describe("SinglePredictionComponent", () => { const rowId = component.entityRows()[0].id; component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); + addProteinRow(); component.updateRowMoleculeType(rowId, "ccd"); component.updateRowSequence(rowId, "ATP"); @@ -360,6 +370,7 @@ describe("SinglePredictionComponent", () => { component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); + addProteinRow(); component.updateRowSequence(rowId, "ACGT"); component.updateRowMoleculeType(rowId, "dna"); component.updateRowCopyNumber(rowId, "2"); @@ -378,13 +389,17 @@ describe("SinglePredictionComponent", () => { component.updateRowMoleculeType(rowId, "protein"); expect(component.generatedFastaContent()).toBe(">seq1|protein\nACDEFGHIK"); - component.updateRowSequence(rowId, "CC(=O)O"); - component.updateRowMoleculeType(rowId, "ligand"); - expect(component.generatedFastaContent()).toBe(">seq1|smiles\nCC(=O)O"); + const secondRowId = addProteinRow(); - component.updateRowSequence(rowId, "ATP"); - component.updateRowMoleculeType(rowId, "ccd"); - expect(component.generatedFastaContent()).toBe(">seq1|ccd\nATP"); + component.updateRowSequence(secondRowId, "CC(=O)O"); + component.updateRowMoleculeType(secondRowId, "ligand"); + expect(component.generatedFastaContent()).toContain( + ">seq2|smiles\nCC(=O)O" + ); + + component.updateRowSequence(secondRowId, "ATP"); + component.updateRowMoleculeType(secondRowId, "ccd"); + expect(component.generatedFastaContent()).toContain(">seq2|ccd\nATP"); }); it("should normalize protein sequence content in summary", () => { @@ -732,4 +747,104 @@ describe("SinglePredictionComponent", () => { .args[0]; expect(payload["colabfold_use_templates"]).toBe(false); }); + + it("should reject more than 52 entities counting copies", () => { + const rowId = fillValidProteinRow("ACDEFGHIK", "52"); + expect(component.totalEntityCount()).toBe(52); + expect(component.isStep1Valid()).toBe(true); + + component.updateRowCopyNumber(rowId, "53"); + expect(component.totalEntityCount()).toBe(53); + expect(component.isStep1Valid()).toBe(false); + expect(component.inputSummaryErrors()).toContain( + jasmine.stringContaining("Too many entities") + ); + }); + + it("should sum copies across rows toward the entity limit", () => { + fillValidProteinRow("ACDEFGHIK", "50"); + const secondRowId = addProteinRow(); + component.updateRowCopyNumber(secondRowId, "3"); + + expect(component.totalEntityCount()).toBe(53); + expect(component.inputSummaryErrors()).toContain( + jasmine.stringContaining("Too many entities") + ); + }); + + it("should require at least one protein entity", () => { + const rowId = fillValidProteinRow("ACDEFGHIK"); + component.selectTool("boltz"); + expect(component.hasProteinInput()).toBe(true); + expect(component.isStep1Valid()).toBe(true); + + component.updateRowSequence(rowId, "ACGT"); + component.updateRowMoleculeType(rowId, "dna"); + + expect(component.hasProteinInput()).toBe(false); + expect(component.isStep1Valid()).toBe(false); + expect(component.inputSummaryErrors()).toContain( + jasmine.stringContaining("must be a protein") + ); + }); + + it("should count ligand and CCD entities as a fixed size of 30 per copy", () => { + const rowId = fillValidProteinRow("ACDEFGHIK"); + component.selectTool("boltz"); + const ligandRowId = addProteinRow(); + component.updateRowMoleculeType(ligandRowId, "ligand"); + component.updateRowSequence(ligandRowId, "CC(=O)OC1=CC=CC=C1C(=O)O"); + component.updateRowCopyNumber(ligandRowId, "2"); + + // protein (9) + ligand (30 * 2 copies) = 69 + expect(component.totalPredictionSize()).toBe(69); + expect(rowId).toBeDefined(); + }); + + it("should enforce the 2000 size limit for AlphaFold2", () => { + const rowId = fillValidProteinRow("A".repeat(1999)); + component.selectTool("alphafold2"); + expect(component.predictionSizeLimit()).toBe(2000); + expect(component.isStep1Valid()).toBe(true); + + component.updateRowSequence(rowId, "A".repeat(2000)); + expect(component.isStep1Valid()).toBe(false); + expect(component.inputSummaryErrors()).toContain( + jasmine.stringContaining("must be less than 2000") + ); + }); + + it("should enforce the 4000 size limit for ColabFold and Boltz", () => { + const rowId = fillValidProteinRow("A".repeat(3999)); + expect(component.selectedTool()).toBe("colabfold"); + expect(component.predictionSizeLimit()).toBe(4000); + expect(component.isStep1Valid()).toBe(true); + + component.updateRowSequence(rowId, "A".repeat(4000)); + expect(component.isStep1Valid()).toBe(false); + }); + + it("should reduce the Boltz size limit to 2000 when boltz_use_potentials is set", () => { + const rowId = fillValidProteinRow("A".repeat(3000)); + component.selectTool("boltz"); + expect(component.predictionSizeLimit()).toBe(4000); + expect(component.isStep1Valid()).toBe(true); + + component.boltzUsePotentials.set(true); + expect(component.predictionSizeLimit()).toBe(2000); + expect(component.isStep1Valid()).toBe(false); + + component.selectTool("colabfold"); + expect(component.predictionSizeLimit()).toBe(4000); + expect(component.isStep1Valid()).toBe(true); + expect(rowId).toBeDefined(); + }); + + it("should omit position labels for SMILES (ligand) sequence cells", () => { + const proteinCells = component.getSequenceCells("A".repeat(10), "protein"); + expect(proteinCells[9].label).toBe("10"); + + const ligandCells = component.getSequenceCells("A".repeat(10), "ligand"); + expect(ligandCells.every((cell) => cell.label === "")).toBe(true); + }); }); diff --git a/src/app/features/workflows/single-prediction/single-prediction.ts b/src/app/features/workflows/single-prediction/single-prediction.ts index c30d419..c7f60f7 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.ts @@ -88,6 +88,17 @@ interface SequenceCell { label: string; } +/** Maximum number of entities (copies included) Proteinfold can run. */ +const MAX_TOTAL_ENTITIES = 52; + +/** Fixed prediction size counted for every ligand copy (SMILES or CCD). */ +const LIGAND_FIXED_SIZE = 30; + +/** Total prediction size limits (exclusive upper bound) per tool. */ +const PREDICTION_SIZE_LIMIT_DEFAULT = 4000; +const PREDICTION_SIZE_LIMIT_ALPHAFOLD2 = 2000; +const PREDICTION_SIZE_LIMIT_BOLTZ_POTENTIALS = 2000; + @Component({ selector: "app-single-prediction", imports: [ @@ -205,6 +216,66 @@ export default class SinglePredictionComponent extends WorkflowPageBase { this.entityRows().map((row) => this.validateEntityRow(row)) ); readonly toolSettingErrors = computed(() => this.validateToolSettings()); + + /** Total number of entities to predict, counting copies. */ + readonly totalEntityCount = computed(() => + this.entityRows().reduce( + (sum, row) => sum + this.getParsedCopyNumber(row.copyNumber), + 0 + ) + ); + + /** Combined prediction size (residues + fixed ligand size), counting copies. */ + readonly totalPredictionSize = computed(() => + this.entityRows().reduce( + (sum, row) => sum + this.getEntityPredictionSize(row), + 0 + ) + ); + + /** Upper limit on the total prediction size for the selected tool. */ + readonly predictionSizeLimit = computed(() => { + const tool = this.selectedTool(); + if (tool === "boltz" && this.boltzUsePotentials()) { + return PREDICTION_SIZE_LIMIT_BOLTZ_POTENTIALS; + } + if (tool === "alphafold2") { + return PREDICTION_SIZE_LIMIT_ALPHAFOLD2; + } + return PREDICTION_SIZE_LIMIT_DEFAULT; + }); + + readonly hasProteinInput = computed(() => + this.entityRows().some((row) => row.moleculeType === "protein") + ); + + /** Form validation errors. */ + readonly inputSummaryErrors = computed(() => { + const errors: string[] = []; + + const totalEntities = this.totalEntityCount(); + if (totalEntities > MAX_TOTAL_ENTITIES) { + errors.push( + `Too many entities: ${totalEntities} including copies. The maximum allowed is ${MAX_TOTAL_ENTITIES}.` + ); + } + + if (!this.hasProteinInput()) { + errors.push( + "At least one entity must be a protein. This workflow cannot run without a protein input." + ); + } + + const limit = this.predictionSizeLimit(); + const size = this.totalPredictionSize(); + if (size >= limit) { + errors.push( + `Total prediction size (${size}) must be less than ${limit} for ${this.selectedToolLabel()}. Reduce sequence length or the number of copies.` + ); + } + + return errors; + }); readonly isStep1Valid = computed(() => { this.jobName(); return ( @@ -212,7 +283,8 @@ export default class SinglePredictionComponent extends WorkflowPageBase { this.entityRows().length > 0 && this.entityValidationResults().every( (errors) => !errors.sequence && !errors.copyNumber && !errors.tool - ) + ) && + this.inputSummaryErrors().length === 0 ); }); readonly isStep2Valid = computed( @@ -687,13 +759,22 @@ export default class SinglePredictionComponent extends WorkflowPageBase { return {}; } - /** 1-based position labels for the sequence ruler overlay: every 10th character and the last one. */ - getSequenceCells(sequence: string): SequenceCell[] { + /** + * 1-based position labels for the sequence ruler overlay: every 10th character + * and the last one. SMILES (ligand) is excleded. + */ + getSequenceCells( + sequence: string, + moleculeType: MoleculeType + ): SequenceCell[] { + const showLabels = moleculeType !== "ligand"; const length = sequence.length; return Array.from(sequence, (char, index) => { const position = index + 1; const label = - position % 10 === 0 || position === length ? String(position) : ""; + showLabels && (position % 10 === 0 || position === length) + ? String(position) + : ""; return { char, label }; }); } @@ -723,6 +804,18 @@ export default class SinglePredictionComponent extends WorkflowPageBase { return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; } + /** + * Prediction size an entity contributes, counting copies. Protein, RNA and DNA + * count one per residue; SMILES and CCD ligands use a fixed size per copy. + */ + private getEntityPredictionSize(row: EntityRow): number { + const copies = this.getParsedCopyNumber(row.copyNumber); + if (row.moleculeType === "ligand" || row.moleculeType === "ccd") { + return LIGAND_FIXED_SIZE * copies; + } + return this.getNormalizedSequence(row).length * copies; + } + private prepareSinglePredictionInput( onPrepared: (fastaUrl: string, s3InputKey: string) => void ): void { From 4f30493be7f078206f65ad7c47049d01e5441d41 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Tue, 21 Jul 2026 14:34:43 +1000 Subject: [PATCH 2/5] style: update error message border color --- .../features/workflows/single-prediction/single-prediction.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/workflows/single-prediction/single-prediction.html b/src/app/features/workflows/single-prediction/single-prediction.html index af6c22f..804ef9c 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.html +++ b/src/app/features/workflows/single-prediction/single-prediction.html @@ -289,7 +289,7 @@ @if (inputSummaryErrors().length > 0) {