From 0d8193e980b13ef6162b60b65607f846a1adbca5 Mon Sep 17 00:00:00 2001 From: Travis Uhrig Date: Thu, 26 Mar 2026 16:48:44 -0600 Subject: [PATCH 1/3] fix: move export validation from backend errors to frontend warnings --- SBOLCanvasBackend/src/utils/MxToSBML.java | 165 +++++++----------- SBOLCanvasBackend/src/utils/MxToSBOL.java | 62 +++++-- SBOLCanvasFrontend/src/app/graph-helpers.ts | 4 +- SBOLCanvasFrontend/src/app/graph.service.ts | 12 +- .../src/app/problems/problems.component.ts | 92 +++++++++- 5 files changed, 206 insertions(+), 129 deletions(-) diff --git a/SBOLCanvasBackend/src/utils/MxToSBML.java b/SBOLCanvasBackend/src/utils/MxToSBML.java index 248e8277..24d26e84 100644 --- a/SBOLCanvasBackend/src/utils/MxToSBML.java +++ b/SBOLCanvasBackend/src/utils/MxToSBML.java @@ -158,8 +158,6 @@ public void toSBML(InputStream graphStream, OutputStream sbmlStream) SBMLDocument document = setupDocument(graphStream); - // Write to SBML document stream - // https://sbml.org/jsbml/files/doc/api/1.6.1/org/sbml/jsbml/SBMLWriter.html org.sbml.jsbml.TidySBMLWriter.write(document, sbmlStream, "SBOLCanvas", "1.0", ' ', (short) 2); } @@ -228,10 +226,8 @@ private HashMap createPromoterSpecies(Model sbmlModel, mxGraphMo } } - // TODO: For all IllegalArgumentExceptions, add user validation before export - if (promoterGlyph == null) { - throw new IllegalArgumentException("Backbone has no promoter glyph. Add a Promoter to the backbone for SBML export."); - } + if (promoterGlyph == null) + continue; GlyphInfo promoterInfo = (GlyphInfo) infoDict.get(promoterGlyph.getValue()); String promoterName = promoterInfo.getName(); @@ -280,6 +276,8 @@ private void createMolecularSpecies(Model sbmlModel, mxGraphModel graphModel, mx for (mxCell glyph : speciesGlyphs) { Species species = createSpecies(sbmlModel, glyph); + if (species == null) + continue; glyphToSpeciesData.put((String) glyph.getValue(), new SpeciesData(species, glyph.getGeometry())); } @@ -304,9 +302,8 @@ private void createProductionReactions(Model sbmlModel, mxGraphModel graphModel, if (cell.isEdge()) { InteractionInfo info = (InteractionInfo) interactionDict.get(cell.getValue()); - if (info == null) { + if (info == null) continue; - } String type = info.getInteractionType(); URI typeURI = SBOLData.interactions.getValue(type); @@ -345,13 +342,11 @@ private void createProductionReactions(Model sbmlModel, mxGraphModel graphModel, double np = getParam(promoterInfo.getSimulationData(), SBOLData.PARAM_NP, SequenceOntology.PROMOTER, "promoter '" + promoterId + "'"); for (mxCell productionEdge : tuData.productionEdges) { mxCell targetCell = (mxCell) productionEdge.getTarget(); - if (targetCell == null) { - throw new IllegalArgumentException("Production edge has no target cell (disconnected edge)"); - } + if (targetCell == null) + continue; SpeciesData productData = glyphToSpeciesData.get((String) targetCell.getValue()); - if (productData == null) { - throw new IllegalArgumentException("Product species not found for production edge"); - } + if (productData == null) + continue; SpeciesReference product = reaction.createProduct(productData.species); product.setConstant(true); product.setStoichiometry(np); @@ -372,19 +367,17 @@ private void createProductionReactions(Model sbmlModel, mxGraphModel graphModel, if (typeURI != null && modifierCell != null) { if (typeURI.equals(SBOLData.interactions.getValue("Inhibition"))) { - repressorEdge = inEdge; SpeciesData modifierData = glyphToSpeciesData.get((String) modifierCell.getValue()); - if (modifierData == null) { - throw new IllegalArgumentException("Repressor species not found for inhibition edge"); - } + if (modifierData == null) + continue; + repressorEdge = inEdge; ModifierSpeciesReference mod = reaction.createModifier(modifierData.species); mod.setSBOTerm(20); // SBO:0000020 Inhibitor } else if (typeURI.equals(SBOLData.interactions.getValue("Stimulation"))) { - activatorEdge = inEdge; SpeciesData modifierData = glyphToSpeciesData.get((String) modifierCell.getValue()); - if (modifierData == null) { - throw new IllegalArgumentException("Activator species not found for stimulation edge"); - } + if (modifierData == null) + continue; + activatorEdge = inEdge; ModifierSpeciesReference mod = reaction.createModifier(modifierData.species); mod.setSBOTerm(459); // SBO:0000459 Stimulator } @@ -392,22 +385,15 @@ private void createProductionReactions(Model sbmlModel, mxGraphModel graphModel, } } - if (repressorEdge != null && activatorEdge != null) { - throw new IllegalArgumentException( - "Mixed regulation (both activators and repressors) not supported for promoter: " + promoterId); - } + if (repressorEdge != null && activatorEdge != null) + continue; - // TODO: Unregulated promoters not supported if (repressorEdge != null) { buildRepressionFormula(reaction, promoterId, promoterInfo, repressorEdge); } else if (activatorEdge != null) { buildActivationFormula(reaction, promoterId, promoterInfo, activatorEdge); } else { - String promoterName = promoterInfo.getName(); - if (promoterName == null || promoterName.isEmpty()) { - promoterName = promoterInfo.getDisplayID(); - } - throw new IllegalArgumentException("Promoter '" + promoterName + "' has no regulator."); + // TODO: Add constitutive (unregulated) promoter formula } } } @@ -428,9 +414,8 @@ private void createDegradationReactions(Model sbmlModel, mxGraphModel graphModel if (cell.isEdge()) { InteractionInfo info = (InteractionInfo) interactionDict.get(cell.getValue()); - if (info == null) { + if (info == null) continue; - } String type = info.getInteractionType(); URI typeURI = SBOLData.interactions.getValue(type); @@ -496,9 +481,7 @@ private void buildRepressionFormula(Reaction reaction, String promoterId, GlyphI mxCell repCell = (mxCell) repressorEdge.getSource(); SpeciesData repData = glyphToSpeciesData.get((String) repCell.getValue()); - if (repData == null) { - throw new IllegalArgumentException("Repressor species not found for edge"); - } + if (repData == null) return; String repId = repData.species.getId(); InteractionInfo repInfo = (InteractionInfo) interactionDict.get(repressorEdge.getValue()); @@ -523,7 +506,7 @@ private void buildRepressionFormula(Reaction reaction, String promoterId, GlyphI try { law.setMath(new FormulaParser(new ByteArrayInputStream(formula.getBytes(StandardCharsets.UTF_8))).parse()); } catch (Exception e) { - throw new RuntimeException("Failed to parse repression kinetic law: " + e.getMessage(), e); + System.err.println("Warning: repression formula parse failed for " + promoterId + ": " + e.getMessage()); } } @@ -561,9 +544,7 @@ private void buildActivationFormula(Reaction reaction, String promoterId, GlyphI mxCell actCell = (mxCell) activatorEdge.getSource(); SpeciesData actData = glyphToSpeciesData.get((String) actCell.getValue()); - if (actData == null) { - throw new IllegalArgumentException("Activator species not found for edge"); - } + if (actData == null) return; String actId = actData.species.getId(); InteractionInfo actInfo = (InteractionInfo) interactionDict.get(activatorEdge.getValue()); @@ -592,7 +573,7 @@ private void buildActivationFormula(Reaction reaction, String promoterId, GlyphI try { law.setMath(new FormulaParser(new ByteArrayInputStream(formula.getBytes(StandardCharsets.UTF_8))).parse()); } catch (Exception e) { - throw new RuntimeException("Failed to parse activation kinetic law: " + e.getMessage(), e); + System.err.println("Warning: activation formula parse failed for " + promoterId + ": " + e.getMessage()); } } @@ -716,7 +697,7 @@ private void createComplexEdges(Layout layout, Reaction reaction, */ private void createVisualLayout(Model sbmlModel) { if (glyphToSpeciesData.isEmpty()) { - return; // No species — layout bounds are uninitialized + return; // No species -- layout bounds are uninitialized } Layout layout = setupLayout(sbmlModel); @@ -747,16 +728,14 @@ private void createEvents(Model sbmlModel) { String targetSpecies = getStringParam(simData, SBOLData.PARAM_EVENT_TARGET_SPECIES); if (targetSpecies == null || targetSpecies.isEmpty()) { - throw new IllegalArgumentException(context + " missing target species"); + continue; } // Resolve display name to SBML species ID. The user enters a display // name (e.g., "LacI protein") but SBML uses sanitized IDs ("LacI_protein"). String speciesId = resolveSpeciesId(sbmlModel, targetSpecies, displayNameToSpeciesId); - if (speciesId == null) { - throw new IllegalArgumentException( - context + " references unknown species '" + targetSpecies + "'"); - } + if (speciesId == null) + continue; String eventName = getStringParam(simData, SBOLData.PARAM_EVENT_NAME); if (eventName == null || eventName.isEmpty()) { @@ -805,10 +784,8 @@ private void createEvents(Model sbmlModel) { */ private Species createSpecies(Model model, mxCell glyph) { GlyphInfo glyphInfo = (GlyphInfo) infoDict.get(glyph.getValue()); - if (glyphInfo == null) { - throw new IllegalArgumentException( - "No GlyphInfo found for species glyph '" + glyph.getValue() + "' (orphaned glyph?)"); - } + if (glyphInfo == null) + return null; // SBML ID becomes the label. Pick Name over DisplayID String displayName = glyphInfo.getDisplayID(); @@ -877,13 +854,11 @@ private Species createSpecies(Model model, mxCell glyph) { */ private void createDegradationReaction(Model model, mxCell edge, InteractionInfo info, mxGraphModel graphModel) { mxCell source = (mxCell) edge.getSource(); - if (source == null) { - throw new IllegalArgumentException("Degradation edge has no source cell (disconnected edge)"); - } + if (source == null) + return; SpeciesData sourceData = glyphToSpeciesData.get((String) source.getValue()); - if (sourceData == null) { - throw new IllegalArgumentException("Source species not found for degradation edge"); - } + if (sourceData == null) + return; String speciesId = sourceData.species.getId(); String reactionId = "Degradation_" + speciesId; @@ -903,7 +878,8 @@ private void createDegradationReaction(Model model, mxCell edge, InteractionInfo law.setMath(new FormulaParser( new ByteArrayInputStream(("kd * " + speciesId).getBytes(StandardCharsets.UTF_8))).parse()); } catch (Exception e) { - throw new RuntimeException("Failed to parse degradation kinetic law: " + e.getMessage(), e); + System.err.println("Warning: degradation formula parse failed for " + speciesId + ": " + e.getMessage()); + return; } } @@ -916,19 +892,16 @@ private void createDegradationReaction(Model model, mxCell edge, InteractionInfo private void createComplexFormationReaction(Model model, mxCell node, InteractionInfo info, mxGraphModel graphModel) { Object[] outgoing = mxGraphModel.getOutgoingEdges(graphModel, node); - if (outgoing.length == 0) { - throw new IllegalArgumentException("Complex formation node has no product edge"); - } + if (outgoing.length == 0) + return; mxCell outEdge = (mxCell) outgoing[0]; mxCell target = (mxCell) outEdge.getTarget(); - if (target == null) { - throw new IllegalArgumentException("Complex formation product edge has no target cell (disconnected edge)"); - } + if (target == null) + return; SpeciesData productData = glyphToSpeciesData.get((String) target.getValue()); - if (productData == null) { - throw new IllegalArgumentException("Product species not found for complex formation"); - } + if (productData == null) + return; String productId = productData.species.getId(); String reactionId = "Complex_" + productId; @@ -945,13 +918,11 @@ private void createComplexFormationReaction(Model model, mxCell node, Interactio for (Object obj : incoming) { mxCell inEdge = (mxCell) obj; mxCell source = (mxCell) inEdge.getSource(); - if (source == null) { - throw new IllegalArgumentException("Complex formation reactant edge has no source cell (disconnected edge)"); - } + if (source == null) + continue; SpeciesData sourceData = glyphToSpeciesData.get((String) source.getValue()); - if (sourceData == null) { - throw new IllegalArgumentException("Reactant species not found for complex formation edge"); - } + if (sourceData == null) + continue; String speciesId = sourceData.species.getId(); SpeciesReference r = reaction.createReactant(sourceData.species); @@ -986,27 +957,28 @@ private void createComplexFormationReaction(Model model, mxCell node, Interactio law.setMath(new FormulaParser(new ByteArrayInputStream(rateLaw.toString().getBytes(StandardCharsets.UTF_8))) .parse()); } catch (Exception e) { - throw new RuntimeException("Failed to parse complex formation kinetic law: " + e.getMessage(), e); + System.err.println("Warning: complex formation formula parse failed for " + productId + ": " + e.getMessage()); + return; } } /** * Extracts a double from a value that may be a Number, a numeric String, - * or null. Returns defaultVal for null; throws for non-numeric values. + * or null. Returns defaultVal for null, unparseable strings, or unexpected types. */ - private static double extractDouble(Object val, double defaultVal, String context) { - if (val == null) return defaultVal; - if (val instanceof Number) return ((Number) val).doubleValue(); + private double extractDouble(Object val, double defaultVal, String context) { + if (val == null) + return defaultVal; + if (val instanceof Number) + return ((Number) val).doubleValue(); if (val instanceof String) { try { return Double.parseDouble((String) val); } catch (NumberFormatException e) { - throw new IllegalArgumentException( - "Non-numeric value for " + context + ": '" + val + "'", e); + return defaultVal; } } - throw new IllegalArgumentException( - "Invalid type for " + context + ". Expected number, got: " + val.getClass().getSimpleName()); + return defaultVal; } /** @@ -1031,7 +1003,8 @@ private double getParam(Hashtable simData, String paramName, URI * Returns null if the key is missing or the value is null. */ private String getStringParam(Hashtable simData, String paramName) { - if (simData == null || !simData.containsKey(paramName)) return null; + if (simData == null || !simData.containsKey(paramName)) + return null; Object val = simData.get(paramName); return val != null ? val.toString() : null; } @@ -1064,29 +1037,21 @@ private double getDefaultValue(URI type, String paramName, String context) { key = SBOLData.interactions.getKey(type); } - if (key == null) { - throw new IllegalArgumentException( - "Cannot find default for parameter '" + paramName + "' on " + context + ": unknown type"); - } + if (key == null) + return 0.0; LinkedHashMap params = SBOLData.getSimulationConfig().get(key); - if (params == null) { - throw new IllegalArgumentException( - "Cannot find default for parameter '" + paramName + "' on " + context + - ": no simulation config for type '" + key + "'"); - } + if (params == null) + return 0.0; Object val = params.get(paramName); - if (val == null) { - throw new IllegalArgumentException( - "Missing required parameter '" + paramName + "' on " + context + ". Set this value in the Model tab."); - } + if (val == null) + return 0.0; if (val instanceof Number) { return ((Number) val).doubleValue(); } - throw new IllegalArgumentException( - "Invalid default value type for parameter '" + paramName + "' on " + context); + return 0.0; } /** @@ -1123,7 +1088,7 @@ private static String resolveSpeciesId(Model sbmlModel, String targetSpecies, * * @param id The raw ID string * @return A valid, unique SBML SId - * @see Converter#sanitizeAnnotationKey for XML NCName sanitization (different spec, different rules) + * @see Converter#sanitizeAnnotationKey for XML NCName sanitization (different rules) */ private String sanitizeId(String id) { if (id == null || id.isEmpty()) { diff --git a/SBOLCanvasBackend/src/utils/MxToSBOL.java b/SBOLCanvasBackend/src/utils/MxToSBOL.java index a48b9b8c..7b02f91a 100644 --- a/SBOLCanvasBackend/src/utils/MxToSBOL.java +++ b/SBOLCanvasBackend/src/utils/MxToSBOL.java @@ -168,7 +168,11 @@ public SBOLDocument setupDocument(InputStream graphStream) throws IOException, U .toArray(mxCell[]::new); for(mxCell glyph: glyphs){ - createComponentDefinition(document, graph, model, glyph); + try { + createComponentDefinition(document, graph, model, glyph); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped glyph: " + e.getMessage()); + } } if (layoutHelper.getGraphicalLayout(URI.create((String) circuitContainer.getValue())) != null){ @@ -176,7 +180,11 @@ public SBOLDocument setupDocument(InputStream graphStream) throws IOException, U } // Create Component Definition for the container itself - createComponentDefinition(document, graph, model, circuitContainer); + try { + createComponentDefinition(document, graph, model, circuitContainer); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped container: " + e.getMessage()); + } } } @@ -190,12 +198,15 @@ public SBOLDocument setupDocument(InputStream graphStream) throws IOException, U mxCell[] molecularSpecies = Arrays.stream(mxGraphModel.filterCells(viewChildren, molecularSpeciesFilter)) .toArray(mxCell[]::new); - if (viewCell.getStyle().equals(STYLE_MODULE_VIEW) || circuitContainers.length > 1 || molecularSpecies.length > 0) { - // module definitions - createModuleDefinition(document, graph, model, viewCell); - } else { - // component definitions - attachTextBoxAnnotation(model, viewCell, URI.create(viewCell.getId())); + try { + if (STYLE_MODULE_VIEW.equals(viewCell.getStyle()) || circuitContainers.length > 1 || molecularSpecies.length > 0) { + createModuleDefinition(document, graph, model, viewCell); + } else { + // component definitions + attachTextBoxAnnotation(model, viewCell, URI.create(viewCell.getId())); + } + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped view cell: " + e.getMessage()); } } @@ -209,7 +220,11 @@ public SBOLDocument setupDocument(InputStream graphStream) throws IOException, U for (mxCell cell : cells) { if (handledContainers.contains((String) cell.getValue())) continue; - linkComponentDefinition(document, graph, model, cell); + try { + linkComponentDefinition(document, graph, model, cell); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped link: " + e.getMessage()); + } handledContainers.add((String) cell.getValue()); } } @@ -221,24 +236,39 @@ public SBOLDocument setupDocument(InputStream graphStream) throws IOException, U .toArray(mxCell[]::new); mxCell[] molecularSpecies = Arrays.stream(mxGraphModel.filterCells(viewChildren, molecularSpeciesFilter)) .toArray(mxCell[]::new); - if (viewCell.getStyle().equals(STYLE_MODULE_VIEW) || circuitContainers.length > 1 || molecularSpecies.length > 0) { - // module definitions - linkModuleDefinition(document, graph, model, viewCell); + if (STYLE_MODULE_VIEW.equals(viewCell.getStyle()) || circuitContainers.length > 1 || molecularSpecies.length > 0) { + try { + linkModuleDefinition(document, graph, model, viewCell); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped link: " + e.getMessage()); + } } } // create the combinatorials for (CombinatorialInfo info : combinatorialDict.values()) { - createCombinatorial(document, graph, model, info); + try { + createCombinatorial(document, graph, model, info); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped combinatorial: " + e.getMessage()); + } } // link the combinatorials for (CombinatorialInfo info : combinatorialDict.values()) { - linkCombinatorial(document, graph, model, info); + try { + linkCombinatorial(document, graph, model, info); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped combinatorial link: " + e.getMessage()); + } } // write events as GenericTopLevel objects - writeEvents(document, graph); + try { + writeEvents(document, graph); + } catch (Exception e) { + System.err.println("Warning: SBOL export skipped events: " + e.getMessage()); + } return document; } @@ -370,7 +400,7 @@ private void createComponentDefinition(SBOLDocument document, mxGraph graph, mxG // store extra mxGraph information URI identity = URI.create(glyphInfo.getFullURI()); layoutHelper.createGraphicalLayout(identity, glyphInfo.getDisplayID() + "_Layout"); - if(circuitContainer.getStyle().equals(STYLE_CIRCUIT_CONTAINER)){ + if(STYLE_CIRCUIT_CONTAINER.equals(circuitContainer.getStyle())){ Object[] containerChildren = mxGraphModel.getChildCells(model, circuitContainer, true, false); mxCell backboneCell = (mxCell) mxGraphModel.filterCells(containerChildren, backboneFilter)[0]; diff --git a/SBOLCanvasFrontend/src/app/graph-helpers.ts b/SBOLCanvasFrontend/src/app/graph-helpers.ts index 306a630b..00f5349f 100644 --- a/SBOLCanvasFrontend/src/app/graph-helpers.ts +++ b/SBOLCanvasFrontend/src/app/graph-helpers.ts @@ -2073,7 +2073,7 @@ export class GraphHelpers extends GraphBase { this.graph.getModel().execute(new GraphEdits.infoEdit(cell0, eventInfo, null, GraphBase.EVENT_DICT_INDEX)) } - protected getFromEventDict(eventURI: string): EventInfo { + public getFromEventDict(eventURI: string): EventInfo { const cell0 = this.graph.getModel().getCell(0) if (!cell0.value[GraphBase.EVENT_DICT_INDEX]) { return null @@ -2142,7 +2142,7 @@ export class GraphHelpers extends GraphBase { this.graph.getModel().execute(new GraphEdits.infoEdit(cell0, info, null, GraphBase.INTERACTION_DICT_INDEX)) } - protected getFromInteractionDict(interactionURI: string): InteractionInfo { + public getFromInteractionDict(interactionURI: string): InteractionInfo { const cell0 = this.graph.getModel().getCell(0) return cell0.value[GraphBase.INTERACTION_DICT_INDEX][interactionURI] } diff --git a/SBOLCanvasFrontend/src/app/graph.service.ts b/SBOLCanvasFrontend/src/app/graph.service.ts index e174097b..00c603d3 100644 --- a/SBOLCanvasFrontend/src/app/graph.service.ts +++ b/SBOLCanvasFrontend/src/app/graph.service.ts @@ -70,18 +70,14 @@ export class GraphService extends GraphHelpers { }, error: err => { console.error('[GraphService] SBOL export failed:', err) - const message = typeof err.error === 'string' ? err.error : err.message || 'SBOL export failed' - embeddedService.postMessage({ - error: { type: 'sbol-export', message: message } - }) } }) } }) - // SBML auto-export pipeline (2000ms debounce) + // SBML auto-export pipeline (1000ms debounce) modelChange$ - .pipe(debounceTime(2000)) + .pipe(debounceTime(1000)) .subscribe(graphXml => { if (embeddedService.isAppEmbedded()) { console.debug('[GraphService] Model changed. Sending SBML to parent.') @@ -91,10 +87,6 @@ export class GraphService extends GraphHelpers { }, error: err => { console.error('[GraphService] SBML export failed:', err) - const message = typeof err.error === 'string' ? err.error : err.message || 'SBML export failed' - embeddedService.postMessage({ - error: { type: 'sbml-export', message: message } - }) } }) } diff --git a/SBOLCanvasFrontend/src/app/problems/problems.component.ts b/SBOLCanvasFrontend/src/app/problems/problems.component.ts index f3846a56..23245443 100644 --- a/SBOLCanvasFrontend/src/app/problems/problems.component.ts +++ b/SBOLCanvasFrontend/src/app/problems/problems.component.ts @@ -29,6 +29,8 @@ export class ProblemsComponent { // Validation functions this.validateCurrentView(warnings, errors) this.validateComponents(warnings, errors) + this.validateBackbones(warnings) + this.validateInteractionsAndEvents(warnings) // more here... // Transpose to separate errors and warnings @@ -37,7 +39,6 @@ export class ProblemsComponent { } validateCurrentView(warnings: string[], errors: string[]) { - const currentView = this.graphService.getCurrentRoot() const children = currentView.children || [] @@ -63,6 +64,7 @@ export class ProblemsComponent { validateComponent(component, warnings: string[], errors: string[]) { const info = this.graphService.lookupInfo(component.value) + if (!info) return const sequence = (info.sequence || '').toUpperCase() const version = (info.version || '') @@ -92,4 +94,92 @@ export class ProblemsComponent { version && !compliant && warnings.push(`Component '${info.displayID}' has incompliant version: ${version}`) } + + validateBackbones(warnings: string[]) { + const currentView = this.graphService.getCurrentRoot() + const containers = (currentView.children || []).filter(c => c.isCircuitContainer()) + + for (const container of containers) { + const children = container.children || [] + const glyphs = children.filter(c => c.isSequenceFeatureGlyph && c.isSequenceFeatureGlyph()) + + if (glyphs.length > 0 && !glyphs.some(g => { + const info = this.graphService.lookupInfo(g.value) + return info && info.partRole && info.partRole.includes('Promoter') + })) { + const containerInfo = this.graphService.lookupInfo(container.value) + const name = (containerInfo && containerInfo.displayID) || 'unnamed' + warnings.push(`Backbone '${name}' has no promoter. Add a Promoter for SBML export.`) + } + } + } + + validateInteractionsAndEvents(warnings: string[]) { + const currentView = this.graphService.getCurrentRoot() + const directChildren = currentView.children || [] + const nestedChildren = directChildren.map(c => c.children || []).flat() + const allCells = [...directChildren, ...nestedChildren] + + // Disconnected interaction edges + const interactions = allCells.filter(c => c.isInteraction && c.isInteraction()) + const regulationByTarget: { [key: string]: { inhibition: boolean, stimulation: boolean } } = {} + + for (const edge of interactions) { + const info = this.graphService.getFromInteractionDict(edge.value) + if (!info) continue + + const type = info.interactionType + const isDegradation = type === 'Degradation' + + // Degradation edges naturally have no target (species degrades into nothing) + if (!edge.source || (!edge.target && !isDegradation)) { + warnings.push('Disconnected interaction edge found.') + continue + } + if (type === 'Inhibition' || type === 'Stimulation') { + const targetId = edge.target.value || 'unknown' + if (!regulationByTarget[targetId]) + regulationByTarget[targetId] = { inhibition: false, stimulation: false } + if (type === 'Inhibition') regulationByTarget[targetId].inhibition = true + if (type === 'Stimulation') regulationByTarget[targetId].stimulation = true + } + } + + for (const [targetId, reg] of Object.entries(regulationByTarget)) { + if (reg.inhibition && reg.stimulation) { + const info = this.graphService.lookupInfo(targetId) + const name = (info && info.name) || (info && info.displayID) || 'unknown' + warnings.push(`Mixed regulation on promoter '${name}' - not supported for SBML export.`) + } + } + + // Complex formation nodes + const interactionNodes = allCells.filter(c => c.isInteractionNode && c.isInteractionNode()) + for (const node of interactionNodes) { + const info = this.graphService.getFromInteractionDict(node.value) + if (!info) continue + const type = info.interactionType + if (type !== 'Biochemical Reaction' && type !== 'Non-Covalent Binding') continue + + const edges = this.graphService.graph.getModel().getEdges(node) || [] + const outgoing = edges.filter(e => e.source === node) + if (outgoing.length === 0 || !outgoing[0].target) + warnings.push('Complex formation node is missing a product connection.') + + for (const inEdge of edges.filter(e => e.target === node)) { + if (!inEdge.source) + warnings.push('Complex formation has a disconnected reactant edge.') + } + } + + // Events without target species + const events = allCells.filter(c => c.isEvent && c.isEvent()) + for (const event of events) { + const eventInfo = this.graphService.getFromEventDict(event.value) + if (!eventInfo) continue + const targetSpecies = (eventInfo.simulationData || {})['targetSpecies'] + if (!targetSpecies) + warnings.push(`Event '${eventInfo.displayID || 'unnamed'}' has no target species.`) + } + } } From 2c3daa013a2d8623c128da7159cb40d9616153aa Mon Sep 17 00:00:00 2001 From: Travis Uhrig Date: Thu, 26 Mar 2026 16:49:01 -0600 Subject: [PATCH 2/3] fix: clear validation timer on component destroy --- .../src/app/problems/problems.component.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/SBOLCanvasFrontend/src/app/problems/problems.component.ts b/SBOLCanvasFrontend/src/app/problems/problems.component.ts index 23245443..8156c85d 100644 --- a/SBOLCanvasFrontend/src/app/problems/problems.component.ts +++ b/SBOLCanvasFrontend/src/app/problems/problems.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core' +import { Component, OnDestroy } from '@angular/core' import { GraphService } from '../graph.service' @@ -8,10 +8,11 @@ import { GraphService } from '../graph.service' styleUrls: ['./problems.component.css'] }) -export class ProblemsComponent { +export class ProblemsComponent implements OnDestroy { warnings: string[] errors: string[] + private intervalId: number constructor(private graphService: GraphService) { } @@ -19,7 +20,11 @@ export class ProblemsComponent { this.warnings = [] this.errors = [] - setInterval(this.validate.bind(this), 750) + this.intervalId = window.setInterval(this.validate.bind(this), 750) + } + + ngOnDestroy() { + clearInterval(this.intervalId) } validate() { From 89244d9a457df5250f02035ce6d72037c4cb0f04 Mon Sep 17 00:00:00 2001 From: Travis Uhrig Date: Thu, 26 Mar 2026 16:50:06 -0600 Subject: [PATCH 3/3] fix: SBML parameter naming to match iBioSim --- SBOLCanvasBackend/src/utils/MxToSBML.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/SBOLCanvasBackend/src/utils/MxToSBML.java b/SBOLCanvasBackend/src/utils/MxToSBML.java index 24d26e84..5751bfa9 100644 --- a/SBOLCanvasBackend/src/utils/MxToSBML.java +++ b/SBOLCanvasBackend/src/utils/MxToSBML.java @@ -458,7 +458,7 @@ private void createComplexReactions(Model sbmlModel, mxGraphModel graphModel, mx * Builds the repression-only Hill equation formula. * * Parameters: - * ko, ko_f, ko_r, nr, kr_f_, kr_r_, nc_ + * ko, ko_f, ko_r, nr, kr__f, kr__r, nc__r * * Formula: * (P * ko * (ko_f/ko_r) * nr) / (1 + (ko_f/ko_r) * nr + ((kr_f/kr_r) * R)^nc) @@ -491,9 +491,9 @@ private void buildRepressionFormula(Reaction reaction, String promoterId, GlyphI double Kr_r = getParam(repSimData, SBOLData.PARAM_KR_R, SystemsBiologyOntology.INHIBITION, repContext); double nc = getParam(repSimData, SBOLData.PARAM_NC, SystemsBiologyOntology.INHIBITION, repContext); - String p_Krf = "kr_f_" + repId; - String p_Krr = "kr_r_" + repId; - String p_nc = "nc_" + repId; + String p_Krf = "kr_" + repId + "_f"; + String p_Krr = "kr_" + repId + "_r"; + String p_nc = "nc_" + repId + "_r"; law.createLocalParameter(p_Krf).setValue(Kr_f); law.createLocalParameter(p_Krr).setValue(Kr_r); law.createLocalParameter(p_nc).setValue(nc); @@ -518,7 +518,7 @@ private void buildRepressionFormula(Reaction reaction, String promoterId, GlyphI * / (1 + (ko_f/ko_r) * nr + (kao_f/kao_r) * nr * ((ka_f/ka_r) * A)^nc) * * Parameters: - * kb, ka, ko_f, ko_r, kao_f, kao_r, nr, ka_f_, ka_r_, nc_ + * kb, ka, ko_f, ko_r, kao_f, kao_r, nr, ka__f, ka__r, nc__a */ private void buildActivationFormula(Reaction reaction, String promoterId, GlyphInfo promoterInfo, mxCell activatorEdge) { @@ -554,9 +554,9 @@ private void buildActivationFormula(Reaction reaction, String promoterId, GlyphI double Ka_r = getParam(actSimData, SBOLData.PARAM_KA_R, SystemsBiologyOntology.STIMULATION, actContext); double nc = getParam(actSimData, SBOLData.PARAM_NC, SystemsBiologyOntology.STIMULATION, actContext); - String p_Kaf = "ka_f_" + actId; - String p_Kar = "ka_r_" + actId; - String p_nc = "nc_" + actId; + String p_Kaf = "ka_" + actId + "_f"; + String p_Kar = "ka_" + actId + "_r"; + String p_nc = "nc_" + actId + "_a"; law.createLocalParameter(p_Kaf).setValue(Ka_f); law.createLocalParameter(p_Kar).setValue(Ka_r); law.createLocalParameter(p_nc).setValue(nc);