diff --git a/__version__.py b/__version__.py index 445149aff..15e8610eb 100644 --- a/__version__.py +++ b/__version__.py @@ -5,7 +5,7 @@ # @website https://github.com/stochss/stochss # ============================================================================= -__version__ = '2.5.1' +__version__ = '2.5.2' __title__ = 'StochSS' __description__ = 'StochSS is an integrated development environment (IDE) \ for simulation of biochemical networks.' diff --git a/client/app.js b/client/app.js index f734e9b2d..6935bebc8 100644 --- a/client/app.js +++ b/client/app.js @@ -79,25 +79,6 @@ let changeCollapseButtonText = (view, e) => { text === '+' ? collapseBtn.text('-') : collapseBtn.text('+'); } } -let copyToClipboard = (text, success, error) => { - fullURL = window.location.protocol + '//' + window.location.hostname + text; - if (window.clipboardData && window.clipboardData.setData) { - // Internet Explorer-specific code path to prevent textarea being shown while dialog is visible. - return window.clipboardData.setData("Text", fullURL); - } - else { - navigator.clipboard.writeText(fullURL).then(success, error); - } -} -let documentSetup = () => { - tooltipSetup(); - $(document).on('shown.bs.modal', (e) => { - $('[autofocus]', e.target).focus(); - }); - $(document).on('hide.bs.modal', '.modal', (e) => { - e.target.remove(); - }); -} let getApiPath = () => path.join(getBasePath(), apiPrefix); let getBasePath = () => { try { @@ -133,6 +114,112 @@ let getXHR = (endpoint, { error(exception, response, body); } } + +let validateName = (input, {rename=false, saveAs=true}={}) => { + var error = ""; + if(input.endsWith('/')) { + error = 'forward'; + } + var invalidChars = "`~!@#$%^&*=+[{]}\"|:;'<,>?\\"; + if(rename || !saveAs) { + invalidChars += "/"; + } + for(var i = 0; i < input.length; i++) { + if(invalidChars.includes(input.charAt(i))) { + error = error === "" || error === "special" ? "special" : "both"; + } + } + return error; +} + +let newWorkflow = (parent, mdlPath, isSpatial, type) => { + if(document.querySelector('#newWorkflowModal')) { + document.querySelector('#newWorkflowModal').remove() + } + let typeCodes = { + "Ensemble Simulation": "_ES", + "Spatial Ensemble Simulation": "_SES", + "Parameter Sweep": "_PS", + "Model Inference": "_MI" + } + let ext = isSpatial ? /.smdl/g : /.mdl/g + let typeCode = typeCodes[type]; + let name = mdlPath.split('/').pop().replace(ext, typeCode) + let modal = $(modals.createWorkflowHtml(name, type)).modal(); + let okBtn = document.querySelector('#newWorkflowModal .ok-model-btn'); + let input = document.querySelector('#newWorkflowModal #workflowNameInput'); + okBtn.disabled = false; + input.addEventListener("keyup", (event) => { + if(event.keyCode === 13){ + event.preventDefault(); + okBtn.click(); + } + }); + input.addEventListener("input", (e) => { + let endErrMsg = document.querySelector('#newWorkflowModal #workflowNameInputEndCharError') + let charErrMsg = document.querySelector('#newWorkflowModal #workflowNameInputSpecCharError') + let error = validateName(input.value) + okBtn.disabled = error !== "" || input.value.trim() === "" + charErrMsg.style.display = error === "both" || error === "special" ? "block" : "none" + endErrMsg.style.display = error === "both" || error === "forward" ? "block" : "none" + }); + okBtn.addEventListener('click', (e) => { + modal.modal("hide"); + let wkflFile = `${input.value.trim()}.wkfl`; + if(mdlPath.includes(".proj") && !mdlPath.includes(".wkgp")){ + var wkflPath = path.join(path.dirname(mdlPath), "WorkflowGroup1.wkgp", wkflFile); + }else{ + var wkflPath = path.join(path.dirname(mdlPath), wkflFile); + } + let queryString = `?path=${wkflPath}&model=${mdlPath}&type=${type}`; + let endpoint = path.join(getApiPath(), "workflow/new") + queryString; + getXHR(endpoint, { + success: (err, response, body) => { + window.location.href = `${path.join(getBasePath(), "stochss/workflow/edit")}?path=${body.path}`; + } + }); + }); +} + +tooltipSetup = () => { + $(function () { + $('[data-toggle="tooltip"]').tooltip(); + $('[data-toggle="tooltip"]').on('click ', function () { + $('[data-toggle="tooltip"]').tooltip("hide"); + }); + }); +} + +documentSetup = () => { + tooltipSetup(); + $(document).on('shown.bs.modal', function (e) { + $('[autofocus]', e.target).focus(); + }); + $(document).on('hide.bs.modal', '.modal', function (e) { + e.target.remove(); + }); +} + +copyToClipboard = (text, success, error) => { + fullURL = window.location.protocol + '//' + window.location.hostname + text; + if (window.clipboardData && window.clipboardData.setData) { + // Internet Explorer-specific code path to prevent textarea being shown while dialog is visible. + return window.clipboardData.setData("Text", fullURL); + } + else { + navigator.clipboard.writeText(fullURL).then(success, error) + } +} + +let switchToEditTab = (view, section) => { + let elementID = Boolean(view.model && view.model.elementID) ? view.model.elementID + "-" : ""; + if($(view.queryByHook(elementID + 'view-' + section)).hasClass('active')) { + $(view.queryByHook(elementID + section + '-edit-tab')).tab('show'); + $(view.queryByHook(elementID + 'edit-' + section)).addClass('active'); + $(view.queryByHook(elementID + 'view-' + section)).removeClass('active'); + } +} + let maintenance = (view) => { getXHR("stochss/api/message", { always: (err, response, body) => { @@ -186,53 +273,6 @@ let maintenance = (view) => { } }); } -let newWorkflow = (parent, mdlPath, isSpatial, type) => { - if(document.querySelector('#newWorkflowModal')) { - document.querySelector('#newWorkflowModal').remove(); - } - let typeCodes = { - "Ensemble Simulation": "_ES", - "Spatial Ensemble Simulation": "_SES", - "Parameter Sweep": "_PS" - } - let ext = isSpatial ? /.smdl/g : /.mdl/g; - let typeCode = typeCodes[type]; - let name = mdlPath.split('/').pop().replace(ext, typeCode); - let modal = $(modals.createWorkflowHtml(name, type)).modal(); - let okBtn = document.querySelector('#newWorkflowModal .ok-model-btn'); - let input = document.querySelector('#newWorkflowModal #workflowNameInput'); - okBtn.disabled = false; - input.addEventListener("keyup", (event) => { - if(event.keyCode === 13){ - event.preventDefault(); - okBtn.click(); - } - }); - input.addEventListener("input", (e) => { - let endErrMsg = document.querySelector('#newWorkflowModal #workflowNameInputEndCharError'); - let charErrMsg = document.querySelector('#newWorkflowModal #workflowNameInputSpecCharError'); - let error = validateName(input.value); - okBtn.disabled = error !== "" || input.value.trim() === ""; - charErrMsg.style.display = error === "both" || error === "special" ? "block" : "none"; - endErrMsg.style.display = error === "both" || error === "forward" ? "block" : "none"; - }); - okBtn.addEventListener('click', (e) => { - modal.modal("hide"); - let wkflFile = `${input.value.trim()}.wkfl`; - if(mdlPath.includes(".proj") && !mdlPath.includes(".wkgp")){ - var wkflPath = path.join(path.dirname(mdlPath), "WorkflowGroup1.wkgp", wkflFile); - }else{ - var wkflPath = path.join(path.dirname(mdlPath), wkflFile); - } - let queryString = `?path=${wkflPath}&model=${mdlPath}&type=${type}`; - let endpoint = path.join(getApiPath(), "workflow/new") + queryString; - getXHR(endpoint, { - success: (err, response, body) => { - window.location.href = `${path.join(getBasePath(), "stochss/workflow/edit")}?path=${body.path}`; - } - }); - }); -} let postXHR = (endpoint, data, { always = function (err, response, body) {}, success = function (err, response, body) {}, error = function (err, response, body) {}}={}, isJSON) => { @@ -258,38 +298,6 @@ let registerRenderSubview = (parent, view, hook) => { parent.registerSubview(view); parent.renderSubview(view, parent.queryByHook(hook)); } -let switchToEditTab = (view, section) => { - let elementID = Boolean(view.model && view.model.elementID) ? view.model.elementID + "-" : ""; - if($(view.queryByHook(elementID + 'view-' + section)).hasClass('active')) { - $(view.queryByHook(elementID + section + '-edit-tab')).tab('show'); - $(view.queryByHook(elementID + 'edit-' + section)).addClass('active'); - $(view.queryByHook(elementID + 'view-' + section)).removeClass('active'); - } -} -let tooltipSetup = () => { - $(() => { - $('[data-toggle="tooltip"]').tooltip(); - $('[data-toggle="tooltip"]').on('click ', () => { - $('[data-toggle="tooltip"]').tooltip("hide"); - }); - }); -} -let validateName = (input, {rename=false, saveAs=true}={}) => { - var error = ""; - if(input.endsWith('/')) { - error = 'forward'; - } - var invalidChars = "`~!@#$%^&*=+[{]}\"|:;'<,>?\\"; - if(rename || !saveAs) { - invalidChars += "/"; - } - for(var i = 0; i < input.length; i++) { - if(invalidChars.includes(input.charAt(i))) { - error = error === "" || error === "special" ? "special" : "both"; - } - } - return error; -} module.exports = { changeCollapseButtonText: changeCollapseButtonText, diff --git a/client/domain-view/templates/actionsView.pug b/client/domain-view/templates/actionsView.pug index 69f253e7f..a3273c3e3 100644 --- a/client/domain-view/templates/actionsView.pug +++ b/client/domain-view/templates/actionsView.pug @@ -16,7 +16,7 @@ div#domain-actions-editor.card a.nav-link.tab(data-hook="actions-view-tab" data-toggle="tab" href="#view-actions") View - button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-actions" data-hook="collapse") + + button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-actions" data-hook="collapse-actions") + div.card-body diff --git a/client/domain-view/templates/shapesView.pug b/client/domain-view/templates/shapesView.pug index 7939c2a95..a77c5a8e5 100644 --- a/client/domain-view/templates/shapesView.pug +++ b/client/domain-view/templates/shapesView.pug @@ -16,7 +16,7 @@ div#domain-geometries-editor.card a.nav-link.tab(data-hook="shapes-view-tab" data-toggle="tab" href="#view-shapes") View - button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-shapes" data-hook="collapse") + + button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-shapes" data-hook="collapse-shapes") + div.card-body p.mb-0 diff --git a/client/domain-view/templates/transformationsView.pug b/client/domain-view/templates/transformationsView.pug index b9f1eadb4..9320cc43d 100644 --- a/client/domain-view/templates/transformationsView.pug +++ b/client/domain-view/templates/transformationsView.pug @@ -16,7 +16,7 @@ div#domain-transformation-editor.card a.nav-link.tab(data-hook="transformations-view-tab" data-toggle="tab" href="#view-transformations") View - button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-transformations" data-hook="collapse") + + button.btn.btn-outline-collapse.inline(data-toggle="collapse" data-target="#collapse-transformations" data-hook="collapse-transformations") + div.card-body diff --git a/client/domain-view/views/actions-view.js b/client/domain-view/views/actions-view.js index 9122f820f..b66b6a52f 100644 --- a/client/domain-view/views/actions-view.js +++ b/client/domain-view/views/actions-view.js @@ -29,7 +29,7 @@ let template = require('../templates/actionsView.pug'); module.exports = View.extend({ template: template, events: { - 'click [data-hook=collapse]' : 'changeCollapseButtonText', + 'click [data-hook=collapse-actions]' : 'changeCollapseButtonText', 'click [data-hook=fill-action]' : 'addAction', 'click [data-hook=set-action]' : 'addAction', 'click [data-hook=remove-action]' : 'addAction', diff --git a/client/domain-view/views/shapes-view.js b/client/domain-view/views/shapes-view.js index 26aa414bc..22572963a 100644 --- a/client/domain-view/views/shapes-view.js +++ b/client/domain-view/views/shapes-view.js @@ -29,7 +29,7 @@ let template = require('../templates/shapesView.pug'); module.exports = View.extend({ template: template, events: { - 'click [data-hook=collapse]' : 'changeCollapseButtonText', + 'click [data-hook=collapse-shapes]' : 'changeCollapseButtonText', 'click [data-hook=cartesian-lattice]' : 'addShape', 'click [data-hook=spherical-lattice]' : 'addShape', 'click [data-hook=cylindrical-lattice]' : 'addShape' diff --git a/client/domain-view/views/transformations-view.js b/client/domain-view/views/transformations-view.js index 09206e457..904b1384c 100644 --- a/client/domain-view/views/transformations-view.js +++ b/client/domain-view/views/transformations-view.js @@ -29,7 +29,7 @@ let template = require('../templates/transformationsView.pug'); module.exports = View.extend({ template: template, events: { - 'click [data-hook=collapse]' : 'changeCollapseButtonText', + 'click [data-hook=collapse-transformations]' : 'changeCollapseButtonText', 'click [data-hook=translate-transformation]' : 'addTransformation', 'click [data-hook=rotate-transformation]' : 'addTransformation', 'click [data-hook=reflect-transformation]' : 'addTransformation', diff --git a/client/domain-view/views/types-view.js b/client/domain-view/views/types-view.js index fc60f115a..bb6854a06 100644 --- a/client/domain-view/views/types-view.js +++ b/client/domain-view/views/types-view.js @@ -63,6 +63,19 @@ module.exports = View.extend({ changeCollapseButtonText: function (e) { app.changeCollapseButtonText(this, e); }, + getParticleCounts: function (particles) { + let particleCounts = {}; + particles.forEach((particle) => { + if(particleCounts[particle.type]) { + particleCounts[particle.type] += 1; + }else{ + particleCounts[particle.type] = 1; + } + }); + this.collection.forEach((type) => { + type.numParticles = particleCounts[type.typeID] ? particleCounts[type.typeID] : 0; + }); + }, handleAddDomainType: function () { let name = this.collection.addType(); this.collection.parent.trigger('update-particle-type-options', {currName: name}); @@ -135,17 +148,7 @@ module.exports = View.extend({ }); }, updateParticleCounts: function (particles) { - let particleCounts = {}; - particles.forEach((particle) => { - if(particleCounts[particle.type]) { - particleCounts[particle.type] += 1; - }else{ - particleCounts[particle.type] = 1; - } - }); - this.collection.forEach((type) => { - type.numParticles = particleCounts[type.typeID] ? particleCounts[type.typeID] : 0; - }); + this.getParticleCounts(particles); $(this.queryByHook('unassigned-type-count')).text(this.collection.models[0].numParticles); this.renderEditTypeView(); this.renderViewTypeView(); diff --git a/client/job-view/templates/gillespyResultsEnsembleView.pug b/client/job-view/templates/gillespyResultsEnsembleView.pug index 0c8d595de..410311ffe 100644 --- a/client/job-view/templates/gillespyResultsEnsembleView.pug +++ b/client/job-view/templates/gillespyResultsEnsembleView.pug @@ -75,7 +75,7 @@ div#workflow-results.card data-hook="stddevran-plot-csv" data-target="download-plot-csv" data-type="stddevran" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.card @@ -129,7 +129,7 @@ div#workflow-results.card data-hook="trajectories-plot-csv" data-target="download-plot-csv" data-type="trajectories" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.card @@ -181,7 +181,7 @@ div#workflow-results.card data-hook="stddev-plot-csv" data-target="download-plot-csv" data-type="stddev" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.card @@ -233,13 +233,13 @@ div#workflow-results.card data-hook="avg-plot-csv" data-target="download-plot-csv" data-type="avg" - ) Plot Results as .csv + ) Plot CSV Results as .zip div button.btn.btn-primary.box-shadow(id="convert-to-notebook" data-hook="convert-to-notebook") Convert to Notebook - button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full Results as .csv + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation") Publish diff --git a/client/job-view/templates/gillespyResultsView.pug b/client/job-view/templates/gillespyResultsView.pug index 3684e21ee..e408ee96c 100644 --- a/client/job-view/templates/gillespyResultsView.pug +++ b/client/job-view/templates/gillespyResultsView.pug @@ -75,13 +75,13 @@ div#workflow-results.card data-hook="trajectories-plot-csv" data-target="download-plot-csv" data-type="trajectories" - ) Plot Results as .csv + ) Plot CSV Results as .zip div button.btn.btn-primary.box-shadow(id="convert-to-notebook" data-hook="convert-to-notebook") Convert to Notebook - button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full Results as .csv + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation") Publish diff --git a/client/job-view/templates/inferenceResultsView.pug b/client/job-view/templates/inferenceResultsView.pug new file mode 100644 index 000000000..33f04693b --- /dev/null +++ b/client/job-view/templates/inferenceResultsView.pug @@ -0,0 +1,262 @@ +div#workflow-results.card + + div.card-header.pb-0 + + h3.inline Results + + button.btn.btn-outline-collapse(data-toggle="collapse" data-target="#collapse-results" data-hook="collapse-results-btn") - + + div.collapse.show(id="collapse-results" data-hook="workflow-results") + + div.card-body + + div.collapse(id="edit-plot-args" data-hook="edit-plot-args") + + table.table + thead + tr + th(scope="col") Title + th(scope="col") X-axis Label + th(scope="col") Y-axis Label + + tbody + tr + td: div(id="title" data-hook="title") + td: div(id="xaxis" data-hook="xaxis") + td: div(id="yaxis" data-hook="yaxis") + + div.card + + div.card-header.pb-0 + + h5.inline.mr-2 Plot Inference + + div.inline + + ul.nav.nav-tabs + + li.nav-item + + a.nav-link.tab.active(data-toggle="tab" href="#inference-histogram" data-hook="inference-histogram-tab") Histogram + + li.nav-item + + a.nav-link.tab(data-toggle="tab" href="#pdf" data-hook="inference-pdf-tab") Propability Distribution + + button.btn.btn-outline-collapse( + data-toggle="collapse" + data-target="#collapse-inference" + id="collapse-inference-btn" + data-hook="collapse-inference-btn" + data-trigger="collapse-plot-container" + data-type="inference" + ) - + + div.collapse.show(id="collapse-inference") + + div.card-body + + div.row + + div.col-sm-4 + + div.inference-cpv-line Current Parameter Values + + div.col-sm-8 + + div.inference-ipv-line Inferred Parameter Values (mean of the accepted samples) + + div.tab-content + + div.tab-pane.active(id="inference-histogram" data-hook="inference-histogram") + + div(data-hook="inference-histogram-plot") + + div.spinner-border.workflow-plot(data-hook="inference-histogram-plot-spinner") + + div.tab-pane(id="pdf" data-hook="pdf") + + div(data-hook="inference-pdf-plot") + + div.spinner-border.workflow-plot(data-hook="inference-pdf-plot-spinner") + + button.btn.btn-primary.box-shadow(data-hook="inference-model-export" data-target="model-export" disabled) Export Model + + button.btn.btn-primary.box-shadow(data-hook="inference-model-explore" data-target="model-explore" disabled) Export & Explore Model + + button.btn.btn-primary.box-shadow(data-hook="inference-edit-plot" data-target="edit-plot" disabled) Edit Plot + + button.btn.btn-primary.box-shadow.dropdown-toggle( + id="inference-download" + data-hook="inference-download" + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false", + type="button" + disabled + ) Download + + ul.dropdown-menu(aria-labelledby="#inference-download") + li.dropdown-item( + data-hook="inference-download-png-custom" + data-target="download-png-custom" + data-type="inference" + ) Plot as .png + li.dropdown-item( + data-hook="inference-download-json" + data-target="download-json" + data-type="inference" + ) Plot as .json + + div.card + + div.card-header.pb-0 + + h5.inline.mr-2 Plot Round + + div.inline + + ul.nav.nav-tabs + + li.nav-item + + a.nav-link.tab.active(data-toggle="tab" href="#round-histogram" data-hook="round-histogram-tab") Histogram + + li.nav-item + + a.nav-link.tab(data-toggle="tab" href="#intersection" data-hook="round-intersection-tab") Intersection + + button.btn.btn-outline-collapse( + data-toggle="collapse" + data-target="#collapse-round" + id="collapse-round-btn" + data-hook="collapse-round-btn" + data-trigger="collapse-plot-container" + data-type="round" + ) + + + div.card-body + + p.mb-0 + | View the results of any selected round.
+ | Histogram - View the histogram, pdf, and original and inferred values for all parameters as well as intersection between parameters.
+ + ul.mb-0 + li Click on any intersection subplot to view it in the intersection tab + + p.mb-0 + | Intersection - View an intersection of two parameters including their histogram, pdf, and original and inferred values. + + ul.mb-0 + li Click on the histogram tab to select a different intersection. + + div.collapse(id="collapse-round") + + div + + div(data-hook="round-slider-container") + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-12 + + h6 + + div.inline.mr-2 Round: + + div.inline(data-hook="round-index-value")=this.roundIndex + + div.mx-1.my-3.row + + div.col-sm-12 + + input.custom-range( + type="range" + min="1" + max=`${this.model.settings.inferenceSettings.numRounds}` + value=this.roundIndex + data-hook="round-index-slider" + ) + + hr + + div.row + + div.col-sm-4 + + div.inference-cpv-line Current Parameter Values + + div.col-sm-8 + + div.inference-ipv-line Inferred Parameter Values (mean of the accepted samples) + + div.tab-content + + div.tab-pane.active(id="round-histogram" data-hook="round-histogram") + + div(data-hook="round-histogram-plot" style="min-height: 1000;") + + div.spinner-border.workflow-plot(data-hook="round-histogram-plot-spinner") + + div.tab-pane(id="intersection" data-hook="intersection") + + div(data-hook="round-intersection-plot" style="min-height: 1000;") + + div.spinner-border.workflow-plot(data-hook="round-intersection-plot-spinner") + + div + + button.btn.btn-primary.box-shadow(data-hook="round-model-export" data-target="model-export" disabled) Export Model + + button.btn.btn-primary.box-shadow(data-hook="round-model-explore" data-target="model-explore" disabled) Export & Explore Model + + button.btn.btn-primary.box-shadow(data-hook="round-edit-plot" data-target="edit-plot" disabled) Edit Plot + + button.btn.btn-primary.box-shadow.dropdown-toggle( + id="round-download" + data-hook="round-download" + data-toggle="dropdown", + aria-haspopup="true", + aria-expanded="false", + type="button" + disabled + ) Download + + ul.dropdown-menu(aria-labelledby="#round-download") + li.dropdown-item( + data-hook="round-download-png-custom" + data-target="download-png-custom" + data-type="round" + ) Plot as .png + li.dropdown-item( + data-hook="round-download-json" + data-target="download-json" + data-type="round" + ) Plot as .json + + div + + button.btn.btn-primary.box-shadow(id="convert-to-notebook" data-hook="convert-to-notebook") Convert to Notebook + + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip + + button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation" disabled) Publish + + div.saving-status(data-hook="job-action-start") + + div.spinner-grow + + span Publishing ... + + div.saved-status(data-hook="job-action-end") + + span Published + + div.save-error-status(data-hook="job-action-err") + + span Error + + div.text-info(data-hook="update-format-message" style="display: none;") + | To publish you job the workflows format must be updated. diff --git a/client/job-view/templates/parameterScanResultsView.pug b/client/job-view/templates/parameterScanResultsView.pug index a3704a375..af40cdf14 100644 --- a/client/job-view/templates/parameterScanResultsView.pug +++ b/client/job-view/templates/parameterScanResultsView.pug @@ -95,7 +95,7 @@ div#workflow-results.card data-hook="ts-psweep-plot-csv" data-target="download-plot-csv" data-type="ts-psweep" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.col-md-3 @@ -201,7 +201,7 @@ div#workflow-results.card data-hook="psweep-plot-csv" data-target="download-plot-csv" data-type="psweep" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.col-md-3 @@ -213,7 +213,7 @@ div#workflow-results.card div - button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full Results as .csv + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation") Publish Presentation diff --git a/client/job-view/templates/parameterSweepResultsView.pug b/client/job-view/templates/parameterSweepResultsView.pug index b0a670bb0..a144521bd 100644 --- a/client/job-view/templates/parameterSweepResultsView.pug +++ b/client/job-view/templates/parameterSweepResultsView.pug @@ -95,7 +95,7 @@ div#workflow-results.card data-hook="ts-psweep-plot-csv" data-target="download-plot-csv" data-type="ts-psweep" - ) Plot Results as .csv + ) Plot CSV Results as .zip div.col-md-3 @@ -187,13 +187,13 @@ div#workflow-results.card data-hook="psweep-plot-csv" data-target="download-plot-csv" data-type="psweep" - ) Plot Results as .csv + ) Plot CSV Results as .zip div button.btn.btn-primary.box-shadow(id="convert-to-notebook" data-hook="convert-to-notebook") Convert to Notebook - button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full Results as .csv + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation") Publish diff --git a/client/job-view/templates/spatialResultsView.pug b/client/job-view/templates/spatialResultsView.pug index 98ef61d0a..9aa3704d0 100644 --- a/client/job-view/templates/spatialResultsView.pug +++ b/client/job-view/templates/spatialResultsView.pug @@ -115,13 +115,13 @@ div#workflow-results.card data-hook="spatial-plot-csv" data-target="download-plot-csv" data-type="spatial" - ) Plot Results as .csv + ) Plot CSV Results as .zip div button.btn.btn-primary.box-shadow(id="convert-to-notebook" data-hook="convert-to-notebook") Convert to Notebook - button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full Results as .csv + button.btn.btn-primary.box-shadow(id="download-results-csv" data-hook="download-results-csv") Download Full CSV Results as .zip button.btn.btn-primary.box-shadow(id="job-presentation" data-hook="job-presentation") Publish diff --git a/client/job-view/views/job-results-view.js b/client/job-view/views/job-results-view.js index 55f6328ac..39eff0284 100644 --- a/client/job-view/views/job-results-view.js +++ b/client/job-view/views/job-results-view.js @@ -18,22 +18,26 @@ along with this program. If not, see . let $ = require('jquery'); let path = require('path'); +let _ = require('underscore'); //support files let app = require('../../app'); let modals = require('../../modals'); let Tooltips = require('../../tooltips'); let Plotly = require('plotly.js-dist'); +//models +let Model = require('../../models/model'); //views let InputView = require('../../views/input'); let View = require('ampersand-view'); let SelectView = require('ampersand-select-view'); let SweepParametersView = require('./sweep-parameter-range-view'); //templates +let spatialTemplate = require('../templates/spatialResultsView.pug'); let wellMixedTemplate = require('../templates/gillespyResultsView.pug'); -let ensembleTemplate = require('../templates/gillespyResultsEnsembleView.pug'); -let sweepTemplate = require('../templates/parameterSweepResultsView.pug'); let scanTemplate = require('../templates/parameterScanResultsView.pug'); -let spatialTemplate = require('../templates/spatialResultsView.pug'); +let inferenceTemplate = require('../templates/inferenceResultsView.pug'); +let sweepTemplate = require('../templates/parameterSweepResultsView.pug'); +let ensembleTemplate = require('../templates/gillespyResultsEnsembleView.pug'); module.exports = View.extend({ events: { @@ -43,12 +47,19 @@ module.exports = View.extend({ 'change [data-hook=target-of-interest-list]' : 'getPlotForTarget', 'change [data-hook=target-mode-list]' : 'getPlotForTargetMode', 'change [data-hook=trajectory-index-slider]' : 'getPlotForTrajectory', + 'change [data-hook=round-index-slider]' : 'getPlotForRound', 'change [data-hook=specie-of-interest-list]' : 'getPlotForSpecies', 'change [data-hook=feature-extraction-list]' : 'getPlotForFeatureExtractor', 'change [data-hook=ensemble-aggragator-list]' : 'getPlotForEnsembleAggragator', 'change [data-hook=plot-type-select]' : 'getTSPlotForType', 'click [data-hook=collapse-results-btn]' : 'changeCollapseButtonText', + 'click [data-hook=inference-histogram-tab]' : 'handleInferenceResize', + 'click [data-hook=inference-pdf-tab]' : 'handlePDFResize', + 'click [data-hook=round-histogram-tab]' : 'handleRoundResize', + 'click [data-hook=round-intersection-tab]' : 'handleIntersectionResize', 'click [data-trigger=collapse-plot-container]' : 'handleCollapsePlotContainerClick', + 'click [data-target=model-export]' : 'handleExportInferredModel', + 'click [data-target=model-explore]' : 'handleExploreInferredModel', 'click [data-target=edit-plot]' : 'openPlotArgsSection', 'click [data-hook=multiple-plots]' : 'plotMultiplePlots', 'click [data-target=download-png-custom]' : 'handleDownloadPNGClick', @@ -57,7 +68,8 @@ module.exports = View.extend({ 'click [data-hook=convert-to-notebook]' : 'handleConvertToNotebookClick', 'click [data-hook=download-results-csv]' : 'handleFullCSVClick', 'click [data-hook=job-presentation]' : 'handlePresentationClick', - 'input [data-hook=trajectory-index-slider]' : 'viewTrajectoryIndex' + 'input [data-hook=trajectory-index-slider]' : 'viewTrajectoryIndex', + 'input [data-hook=round-index-slider]' : 'viewRoundIndex' }, initialize: function (attrs, options) { View.prototype.initialize.apply(this, arguments); @@ -67,7 +79,12 @@ module.exports = View.extend({ this.tooltips = Tooltips.jobResults; this.plots = {}; this.plotArgs = {}; + this.activePlots = {}; this.trajectoryIndex = 1; + this.pdfResized = false; + this.inferenceResized = false; + this.roundHistoResized = false; + this.intersectionResized = false; }, render: function (attrs, options) { let isEnsemble = this.model.settings.simulationSettings.realizations > 1 && @@ -76,7 +93,8 @@ module.exports = View.extend({ let templates = { "Ensemble Simulation": isEnsemble ? ensembleTemplate : wellMixedTemplate, "Parameter Sweep": isParameterScan ? scanTemplate : sweepTemplate, - "Spatial Ensemble Simulation": spatialTemplate + "Spatial Ensemble Simulation": spatialTemplate, + "Model Inference": inferenceTemplate } this.template = templates[this.titleType]; View.prototype.render.apply(this, arguments); @@ -110,7 +128,7 @@ module.exports = View.extend({ } this.getPlot("psweep"); this.renderSweepParameterView(); - }else{ + }else if(this.titleType === "Spatial Ensemble Simulation") { var type = "spatial"; this.spatialTarget = "type"; this.targetIndex = null; @@ -121,6 +139,23 @@ module.exports = View.extend({ $(this.queryByHook("spatial-trajectory-container")).css("display", "inline-block"); } $(this.queryByHook("spatial-plot-csv")).css('display', 'none'); + }else{ + var type = "inference"; + this.roundIndex = this.model.settings.inferenceSettings.numRounds === 0 ? 1 : this.model.settings.inferenceSettings.numRounds; + let parameters = this.model.settings.inferenceSettings.parameters; + this.intersectionNames = [parameters.at(0).name, parameters.at(1).name]; + if(this.model.exportLinks[this.roundIndex] !== null) { + $(this.queryByHook("inference-model-export")).text("Open Model"); + $(this.queryByHook("inference-model-explore")).text("Explore Model"); + } + if(this.roundIndex > 1) { + $(this.queryByHook("round-index-value")).text(this.roundIndex); + $(this.queryByHook("round-index-slider")).prop("value", this.roundIndex); + }else{ + $(this.queryByHook("round-slider-container")).css('display', 'none'); + } + // TODO: Enable inference presentations when implemented + $(this.queryByHook("job-presentation")).prop("disabled", true); } this.getPlot(type); }, @@ -143,18 +178,49 @@ module.exports = View.extend({ error.css("display", "none"); }, 5000); }, - cleanupPlotContainer: function (type) { - let el = this.queryByHook(`${type}-plot`); - Plotly.purge(el); - $(this.queryByHook(type + "-plot")).empty(); - if(type === "ts-psweep" || type === "psweep"){ - $(this.queryByHook(`${type}-download`)).prop("disabled", true); - $(this.queryByHook(`${type}-edit-plot`)).prop("disabled", true); - $(this.queryByHook("multiple-plots")).prop("disabled", true); - }else if(type === "spatial") { - $(this.queryByHook("spatial-plot-loading-msg")).css("display", "block"); + cleanupPlotContainer: function (type, {pdfOnly=false}={}) { + if(["inference", "round"].includes(type)) { + let histoEL = this.queryByHook(`${type}-histogram-plot`); + if(!pdfOnly) { + Plotly.purge(histoEL); + $(this.queryByHook(`${type}-histogram-plot`)).empty(); + $(this.queryByHook(`${type}-histogram-plot-spinner`)).css("display", "block"); + } + if(type === "inference") { + let pdfEL = this.queryByHook('inference-pdf-plot'); + Plotly.purge(pdfEL); + $(this.queryByHook('inference-pdf-plot')).empty(); + $(this.queryByHook('inference-pdf-plot-spinner')).css("display", "block"); + }else { + let interEL = this.queryByHook('round-intersection-plot'); + Plotly.purge(interEL); + $(this.queryByHook('round-intersection-plot')).empty(); + $(this.queryByHook('round-intersection-plot-spinner')).css("display", "block"); + if(!pdfOnly) { + $(this.queryByHook("round-model-export")).prop("disabled", true); + $(this.queryByHook("round-model-explore")).prop("disabled", true); + $(this.queryByHook("round-download")).prop("disabled", true); + $(this.queryByHook("round-edit-plot")).prop("disabled", true); + try { + histoEL.removeListener('plotly_click', _.bind(this.selectIntersection, this)); + }catch (err) { + //pass + } + } + } + }else { + let el = this.queryByHook(`${type}-plot`); + Plotly.purge(el); + $(this.queryByHook(type + "-plot")).empty(); + if(["ts-psweep", "psweep"].includes(type)) { + $(this.queryByHook(`${type}-download`)).prop("disabled", true); + $(this.queryByHook(`${type}-edit-plot`)).prop("disabled", true); + $(this.queryByHook("multiple-plots")).prop("disabled", true); + }else if(type === "spatial") { + $(this.queryByHook("spatial-plot-loading-msg")).css("display", "block"); + } + $(this.queryByHook(`${type}-plot-spinner`)).css("display", "block"); } - $(this.queryByHook(`${type}-plot-spinner`)).css("display", "block"); }, downloadCSV: function (csvType, data) { var queryStr = `?path=${this.model.directory}&type=${csvType}`; @@ -164,24 +230,83 @@ module.exports = View.extend({ let endpoint = `${path.join(app.getApiPath(), "job/csv")}${queryStr}`; window.open(endpoint); }, - getPlot: function (type) { - this.cleanupPlotContainer(type); + exportInferredModel: function (type, {cb=null}={}) { + let round = type === "round" ? this.roundIndex : this.model.settings.inferenceSettings.numRounds + if(this.model.exportLinks[round] === null) { + var queryStr = `?path=${this.model.directory}`; + if(type === "round") { + queryStr += `&round=${this.roundIndex}`; + } + let endpoint = `${path.join(app.getApiPath(), "job/export-inferred-model")}${queryStr}`; + app.getXHR(endpoint, { + success: (err, response, body) => { + if(cb === null) { + let editEP = `${path.join(app.getBasePath(), "stochss/models/edit")}?path=${body.path}`; + window.location.href = editEP; + }else { + cb(err, response, body); + } + } + }); + }else if(cb === null){ + let mdPath = this.model.exportLinks[round]; + let editEP = `${path.join(app.getBasePath(), "stochss/models/edit")}?path=${mdPath}`; + window.location.href = editEP; + }else { + cb(); + } + }, + fixPlotSize: function (type, plotID) { + let figID = plotID === "histogram" ? plotID : "pdf" + let plotEL = this.queryByHook(`${type}-${plotID}-plot`); + // Clear plot + Plotly.purge(plotEL); + $(plotEL).empty(); + // Re-render the plot + if(Object.keys(this.plots).includes(this.activePlots[type])) { + let figure = this.plots[this.activePlots[type]] + Plotly.newPlot(plotEL, figure[figID]); + if(type === "round" && plotID === "histogram") { + plotEL.on('plotly_click', _.bind(this.selectIntersection, this)); + } + return true; + } + return false; + }, + getPlot: function (type, {pdfOnly=false}={}) { + this.cleanupPlotContainer(type, {pdfOnly: pdfOnly}); let data = this.getPlotData(type); if(data === null) { return }; let storageKey = JSON.stringify(data); data['plt_data'] = this.getPlotLayoutData(); if(Boolean(this.plots[storageKey])) { - let renderTypes = ['psweep', 'ts-psweep', 'ts-psweep-mp', 'mltplplt', 'spatial']; + let renderTypes = ['psweep', 'ts-psweep', 'ts-psweep-mp', 'mltplplt', 'spatial', 'round']; if(renderTypes.includes(type)) { - this.plotFigure(this.plots[storageKey], type); + this.activePlots[type] = storageKey; + this.plotFigure(this.plots[storageKey], type, {pdfOnly: pdfOnly}); } }else{ let queryStr = `?path=${this.model.directory}&data=${JSON.stringify(data)}`; let endpoint = `${path.join(app.getApiPath(), "workflow/plot-results")}${queryStr}`; app.getXHR(endpoint, { success: (err, response, body) => { + if(type === "round") { + let xLabel = { + font: {size: 16}, showarrow: false, text: "", x: 0.5, xanchor: "center", xref: "paper", + y: 0, yanchor: "top", yref: "paper", yshift: -30 + } + let yLabel = { + font: {size: 16}, showarrow: false, text: "", textangle: -90, x: 0, xanchor: "right", + xref: "paper", xshift: -40, y: 0.5, yanchor: "middle", yref: "paper" + } + body.histogram.layout.annotations.push(xLabel); + body.histogram.layout.annotations.push(yLabel); + body.pdf.layout.annotations.push(xLabel); + body.pdf.layout.annotations.push(yLabel); + } + this.activePlots[type] = storageKey; this.plots[storageKey] = body; - this.plotFigure(body, type); + this.plotFigure(body, type, {pdfOnly: pdfOnly}); }, error: (err, response, body) => { if(type === "spatial") { @@ -228,6 +353,13 @@ module.exports = View.extend({ target: this.spatialTarget, index: this.targetIndex, mode: this.targetMode, trajectory: this.trajectoryIndex - 1 }; data['plt_key'] = type; + }else if(["inference", "round"].includes(type)) { + data['sim_type'] = "Inference"; + data['data_keys'] = { + "epoch": type === "inference" ? null : this.roundIndex - 1, + "names": type === "inference" ? null : this.intersectionNames + } + data['plt_key'] = "inference"; }else { data['sim_type'] = "GillesPy2"; data['data_keys'] = {}; @@ -245,6 +377,12 @@ module.exports = View.extend({ this.model.settings.resultsSettings.reducer = e.target.value; this.getPlot('psweep') }, + getPlotForRound: function (e) { + this.roundIndex = Number(e.target.value); + this.roundHistoResized = false; + this.intersectionResized = false; + this.getPlot('round'); + }, getPlotForFeatureExtractor: function (e) { this.model.settings.resultsSettings.mapper = e.target.value; this.getPlot('psweep') @@ -314,8 +452,12 @@ module.exports = View.extend({ }, getType: function (storageKey) { let plotData = JSON.parse(storageKey) - if(plotData.sim_type === "GillesPy2") { return plotData.plt_key } - if(plotData.sim_type === "GillesPy2_PS") { return "ts-psweep"} + if(plotData.sim_type === "GillesPy2") { return plotData.plt_key; } + if(plotData.sim_type === "GillesPy2_PS") { return "ts-psweep"; } + if(plotData.sim_type === "Inference") { + if(plotData.data_keys.round === null) { return "inference"; } + return "round" + } return "psweep" }, handleCollapsePlotContainerClick: function (e) { @@ -346,11 +488,28 @@ module.exports = View.extend({ }, handleDownloadJSONClick: function (e) { let type = e.target.dataset.type; - let storageKey = JSON.stringify(this.getPlotData(type)) - let jsonData = this.plots[storageKey]; + let storageKey = JSON.stringify(this.getPlotData(type)); + if(["inference", "round"].includes(type)) { + if(type === "inference") { + let classList = this.queryByHook("inference-histogram-tab").classList.value.split(" "); + var key = classList.includes("active") ? "histogram" : "pdf"; + var nameBase = `${type}-${key}`; + }else{ + let classList = this.queryByHook("round-histogram-tab").classList.value.split(" "); + var key = classList.includes("active") ? "histogram" : "pdf"; + let pltKey = key === "pdf" ? `${this.intersectionNames.join("-X-")}-intersection` : key; + var nameBase = `${type}${this.roundIndex}-${pltKey}`; + } + var jsonData = this.plots[storageKey][key]; + }else if(type === "spatial") { + var nameBase = `${type}${this.trajectoryIndex}` + }else{ + var jsonData = this.plots[storageKey]; + var nameBase = type + } let dataStr = JSON.stringify(jsonData); let dataURI = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); - let exportFileDefaultName = type + '-plot.json'; + let exportFileDefaultName = `${nameBase}-plot.json`; let linkElement = document.createElement('a'); linkElement.setAttribute('href', dataURI); @@ -359,11 +518,63 @@ module.exports = View.extend({ }, handleDownloadPNGClick: function (e) { let type = e.target.dataset.type; - let pngButton = $('div[data-hook=' + type + '-plot] a[data-title*="Download plot as a png"]')[0]; + if(["inference", "round"].includes(type)) { + if(type === "inference") { + let classList = this.queryByHook("inference-histogram-tab").classList.value.split(" "); + var key = classList.includes("active") ? "histogram" : "pdf"; + }else{ + let classList = this.queryByHook("round-histogram-tab").classList.value.split(" "); + var key = classList.includes("active") ? "histogram" : "intersection"; + } + var divEL = `div[data-hook=${type}-${key}-plot]`; + }else{ + var divEL = `div[data-hook=${type}-plot]`; + } + let pngButton = $(`${divEL} a[data-title*="Download plot as a png"]`)[0]; pngButton.click(); }, + handleExploreInferredModel: function (e) { + let type = e.target.dataset.hook.split('-')[0]; + this.exportInferredModel(type, {cb: _.bind(this.newWorkflow, this)}); + }, + handleExportInferredModel: function (e) { + let type = e.target.dataset.hook.split('-')[0]; + this.exportInferredModel(type); + }, handleFullCSVClick: function (e) { - this.downloadCSV("full", null); + if(this.titleType === "Model Inference") { + this.downloadCSV("inference", null); + }else { + this.downloadCSV("full", null); + } + }, + handleInferenceResize: function (e) { + if(!this.inferenceResized) { + setTimeout(() => { + this.inferenceResized = this.fixPlotSize("inference", "histogram"); + }, 0) + } + }, + handleIntersectionResize: function (e) { + if(!this.intersectionResized) { + setTimeout(() => { + this.intersectionResized = this.fixPlotSize("round", "intersection"); + }, 0) + } + }, + handlePDFResize: function (e) { + if(!this.pdfResized) { + setTimeout(() => { + this.pdfResized = this.fixPlotSize("inference", "pdf"); + }, 0) + } + }, + handleRoundResize: function (e) { + if(!this.roundHistoResized) { + setTimeout(() => { + this.roundHistoResized = this.fixPlotSize("round", "histogram"); + }, 0) + } }, handlePlotCSVClick: function (e) { let type = e.target.dataset.type; @@ -414,6 +625,30 @@ module.exports = View.extend({ } }); }, + newWorkflow: function (err, response, body) { + let type = "Parameter Sweep" + if([undefined, null].includes(body)) { + body = { path: this.model.exportLinks[this.roundIndex] }; + } + let model = new Model({ directory: body.path }); + app.getXHR(model.url(), { + success: (err, response, body) => { + model.set(body); + model.updateValid(); + if(model.valid){ + app.newWorkflow(this, model.directory, model.is_spatial, type); + }else{ + if(document.querySelector("#errorModal")) { + document.querySelector("#errorModal").remove(); + } + let title = "Model Errors Detected"; + let endpoint = `${path.join(app.getBasePath(), "stochss/models/edit")}?path=${model.directory}&validate`; + let message = `Errors were detected in you model click here to fix your model`; + $(modals.errorHtml(title, message)).modal(); + } + } + }); + }, openPlotArgsSection: function (e) { $(this.queryByHook("edit-plot-args")).collapse("show"); $(document).ready(function () { @@ -422,18 +657,54 @@ module.exports = View.extend({ }, false); }); }, - plotFigure: function (figure, type) { - let hook = `${type}-plot`; - let el = this.queryByHook(hook); - Plotly.newPlot(el, figure); - if(type === "spatial") { - $(this.queryByHook("spatial-plot-loading-msg")).css("display", "none"); + plotFigure: function (figure, type, {pdfOnly=false}={}) { + if(["inference", "round"].includes(type)) { + let histoHook = `${type}-histogram-plot`; + let histoEL = this.queryByHook(histoHook); + if(!pdfOnly) { + // Display histogram plot + Plotly.newPlot(histoEL, figure.histogram); + $(this.queryByHook(`${type}-histogram-plot-spinner`)).css("display", "none"); + $(this.queryByHook(`${type}-model-export`)).prop("disabled", false); + $(this.queryByHook(`${type}-model-explore`)).prop("disabled", false); + } + // Display pdf plot + if(type === "inference") { + let pdfHook = 'inference-pdf-plot'; + let pdfEL = this.queryByHook(pdfHook); + Plotly.newPlot(pdfEL, figure.pdf); + $(this.queryByHook('inference-pdf-plot-spinner')).css("display", "none"); + }else { + let interHook = 'round-intersection-plot'; + let interEL = this.queryByHook(interHook); + Plotly.newPlot(interEL, figure.pdf); + $(this.queryByHook('round-intersection-plot-spinner')).css("display", "none"); + if(!pdfOnly) { + histoEL.on('plotly_click', _.bind(this.selectIntersection, this)); + if(this.model.exportLinks[this.roundIndex] !== null) { + $(this.queryByHook("round-model-export")).text("Open Model"); + $(this.queryByHook("round-model-explore")).text("Explore Model"); + }else { + $(this.queryByHook("round-model-export")).text("Export Model"); + $(this.queryByHook("round-model-explore")).text("Export & Explore Model"); + } + } + } + }else { + let hook = `${type}-plot`; + let el = this.queryByHook(hook); + Plotly.newPlot(el, figure); + if(type === "spatial") { + $(this.queryByHook("spatial-plot-loading-msg")).css("display", "none"); + } + $(this.queryByHook(`${type}-plot-spinner`)).css("display", "none"); + if(type === "trajectories" || (this.tsPlotData && this.tsPlotData.type === "trajectories")) { + $(this.queryByHook("multiple-plots")).prop("disabled", false); + } } - $(this.queryByHook(`${type}-plot-spinner`)).css("display", "none"); - $(this.queryByHook(`${type}-edit-plot`)).prop("disabled", false); - $(this.queryByHook(`${type}-download`)).prop("disabled", false); - if(type === "trajectories" || (this.tsPlotData && this.tsPlotData.type === "trajectories")) { - $(this.queryByHook("multiple-plots")).prop("disabled", false); + if(!pdfOnly) { + $(this.queryByHook(`${type}-edit-plot`)).prop("disabled", false); + $(this.queryByHook(`${type}-download`)).prop("disabled", false); } }, plotMultiplePlots: function (e) { @@ -558,13 +829,34 @@ module.exports = View.extend({ }); app.registerRenderSubview(this, this.targetModeView, "target-mode-list"); }, + selectIntersection: function (data) { + let subplot = data.event.target.dataset.subplot; + let subplotID = subplot === "xy" ? 0 : Number(subplot.split('x').pop().split('y')[0]) - 1; + let parameters = this.model.settings.inferenceSettings.parameters + let col = subplotID % parameters.length; + let row = (subplotID - col) / parameters.length; + if(row < col) { + this.intersectionNames = [parameters.at(row).name, parameters.at(col).name]; + this.queryByHook('round-intersection-tab').click(); + setTimeout(() => { + this.getPlot("round", {pdfOnly: true}); + this.intersectionResized = false; + }, 0); + } + }, setTitle: function (e) { this.plotArgs['title'] = e.target.value for (var storageKey in this.plots) { let type = this.getType(storageKey); let fig = this.plots[storageKey] - fig.layout.title.text = e.target.value - this.plotFigure(fig, type) + if(Object.keys(fig.layout).includes('title')) { + fig.layout.title.text = e.target.value + }else{ + fig.layout.title = {'text': e.target.value, 'x': 0.5, 'xanchor': 'center'} + } + } + for (var [type, storageKey] of Object.entries(this.activePlots)) { + this.plotFigure(this.plots[storageKey], type); } }, setXAxis: function (e) { @@ -572,8 +864,14 @@ module.exports = View.extend({ for (var storageKey in this.plots) { let type = this.getType(storageKey); let fig = this.plots[storageKey] - fig.layout.xaxis.title.text = e.target.value - this.plotFigure(fig, type) + if(['inference', 'round'].includes(type)) { + fig.layout.annotations.at(-2).text = e.target.value + }else { + fig.layout.xaxis.title.text = e.target.value + } + } + for (var [type, storageKey] of Object.entries(this.activePlots)) { + this.plotFigure(this.plots[storageKey], type); } }, setYAxis: function (e) { @@ -581,8 +879,14 @@ module.exports = View.extend({ for (var storageKey in this.plots) { let type = this.getType(storageKey); let fig = this.plots[storageKey] - fig.layout.yaxis.title.text = e.target.value - this.plotFigure(fig, type) + if(['inference', 'round'].includes(type)) { + fig.layout.annotations.at(-1).text = e.target.value + }else { + fig.layout.xaxis.title.text = e.target.value + } + } + for (var [type, storageKey] of Object.entries(this.activePlots)) { + this.plotFigure(this.plots[storageKey], type); } }, startAction: function () { @@ -592,6 +896,9 @@ module.exports = View.extend({ }, update: function () {}, updateValid: function () {}, + viewRoundIndex: function (e) { + $(this.queryByHook("round-index-value")).html(e.target.value); + }, viewTrajectoryIndex: function (e) { $(this.queryByHook("trajectory-index-value")).html(e.target.value); }, diff --git a/client/modals.js b/client/modals.js index 11f76f703..483d42380 100644 --- a/client/modals.js +++ b/client/modals.js @@ -250,6 +250,27 @@ let templates = { ` + }, + previewPlot : (title) => { + return ` + ` } } @@ -511,5 +532,8 @@ module.exports = { let message = `StochSS ${fileType}s have a new format. Would you like to update this ${target} to the new format?`; return templates.confirmation_with_message(modalID, title, message); + }, + obsPreviewHtml: (title) => { + return templates.previewPlot(title); } } \ No newline at end of file diff --git a/client/models/inference-parameter.js b/client/models/inference-parameter.js new file mode 100644 index 000000000..01a0d48fb --- /dev/null +++ b/client/models/inference-parameter.js @@ -0,0 +1,57 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +//models +let State = require('ampersand-state'); + +module.exports = State.extend({ + props: { + hasChangedRange: 'boolean', + m: 'any', + min: 'any', + max: 'any', + name: 'string', + p: 'any', + paramID: 'number', + pm: 'any', + s: 'any', + u: 'any' + }, + derived: { + elementID: { + deps: ["collection"], + fn: function () { + if(this.collection) { + return "IT" + (this.collection.indexOf(this) + 1); + } + return "IT-" + } + } + }, + initialize: function(attrs, options) { + State.prototype.initialize.apply(this, arguments); + }, + updateVariable: function (parameter) { + let value = parameter.expression; + if(this.min <= 0 || !this.hasChangedRange) { + this.min = value * 0.5; + } + if(this.max <= 0 || !this.hasChangedRange) { + this.max = value * 1.5; + } + } +}); diff --git a/client/models/inference-parameters.js b/client/models/inference-parameters.js new file mode 100644 index 000000000..95117cdd1 --- /dev/null +++ b/client/models/inference-parameters.js @@ -0,0 +1,54 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +//models +let InferenceParameter = require('./inference-parameter'); +//collections +let Collection = require('ampersand-collection'); + +module.exports = Collection.extend({ + model: InferenceParameter, + addInferenceParameter: function (paramID, name) { + let variable = this.add({ + hasChangedRange: false, + m: null, + min: 0, + max: 0, + name: name, + p: null, + paramID: paramID, + pm: null, + s: null, + u: null + }); + }, + removeInferenceParameter: function (variable) { + this.remove(variable); + }, + updateVariables: function (parameters) { + this.forEach((variable) => { + let parameter = parameters.filter((parameter) => { + return parameter.compID === variable.paramID; + })[0]; + if(parameter === undefined) { + this.removeVariable(variable); + }else{ + variable.updateVariable(variable); + } + }); + } +}); diff --git a/client/models/inference-settings.js b/client/models/inference-settings.js new file mode 100644 index 000000000..c3574b616 --- /dev/null +++ b/client/models/inference-settings.js @@ -0,0 +1,60 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +//collections +let SummaryStats = require('./summary-stats'); +let InferenceParameters = require('./inference-parameters'); +//models +let State = require('ampersand-state'); + +module.exports = State.extend({ + props: { + batchSize: 'number', + chunkSize: 'number', + numRounds: 'number', + numSamples: 'number', + obsData: 'string', + priorMethod: 'string', + summaryStatsType: 'string' + }, + collections: { + parameters: InferenceParameters, + summaryStats: SummaryStats + }, + session: { + customCalculators: 'object' + }, + derived: { + elementID: { + deps: ["parent"], + fn: function () { + if(this.parent) { + return this.parent.elementID + "IS-"; + } + return "IS-" + } + } + }, + initialize: function(attrs, options) { + State.prototype.initialize.apply(this, arguments); + }, + resetSummaryStats: function () { + let summaryStats = this.summaryStats; + this.summaryStats = new SummaryStats(); + return summaryStats; + } +}); diff --git a/client/models/job.js b/client/models/job.js index 97d7b8317..de495f6a2 100644 --- a/client/models/job.js +++ b/client/models/job.js @@ -28,6 +28,7 @@ module.exports = State.extend({ }, session: { directory: 'string', + exportLinks: 'object', logs: 'string', startTime: 'string', status: 'string' diff --git a/client/models/model.js b/client/models/model.js index 7d126dae5..1927e0add 100644 --- a/client/models/model.js +++ b/client/models/model.js @@ -39,10 +39,11 @@ module.exports = Model.extend({ return path.join(app.getApiPath(), "file/json-data")+"?for="+this.for+"&path="+this.directory; }, props: { - is_spatial: 'boolean', + annotation: 'string', defaultID: 'number', defaultMode: 'string', - annotation: 'string', + is_spatial: 'boolean', + refLinks: 'object', volume: 'any', template_version: 'number' }, diff --git a/client/models/settings.js b/client/models/settings.js index 5dba94e6e..c4e865e81 100644 --- a/client/models/settings.js +++ b/client/models/settings.js @@ -15,19 +15,23 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ - //models -var State = require('ampersand-state'); +let State = require('ampersand-state'); +let ResultsSettings = require('./results-settings'); let TimespanSettings = require('./timespan-settings'); -var SimulationSettings = require('./simulation-settings'); -var ParameterSweepSettings = require('./parameter-sweep-settings'); -var ResultsSettings = require('./results-settings'); +let InferenceSettings = require('./inference-settings'); +let SimulationSettings = require('./simulation-settings'); +let ParameterSweepSettings = require('./parameter-sweep-settings'); module.exports = State.extend({ + props: { + template_version: 'number' + }, children: { timespanSettings: TimespanSettings, simulationSettings: SimulationSettings, parameterSweepSettings: ParameterSweepSettings, + inferenceSettings: InferenceSettings, resultsSettings: ResultsSettings }, initialize: function(attrs, options) { @@ -44,4 +48,4 @@ module.exports = State.extend({ } } } -}); \ No newline at end of file +}); diff --git a/client/models/summary-stat.js b/client/models/summary-stat.js new file mode 100644 index 000000000..e250ee71b --- /dev/null +++ b/client/models/summary-stat.js @@ -0,0 +1,65 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +//models +let State = require('ampersand-state'); + +module.exports = State.extend({ + props: { + args: 'object', + formula: 'string', + name: 'string' + }, + derived: { + elementID: { + deps: ["collection"], + fn: function () { + if(this.collection) { + return "ISS" + (this.collection.indexOf(this) + 1); + } + return "ISS-" + } + }, + argsDisplay: { + deps: ["args"], + fn: function () { + if([undefined, null].includes(this.args)) { return "None"; } + let argStrs = []; + this.args.forEach((arg) => { + argStrs.push(JSON.stringify(arg)); + }); + return argStrs.join(); + } + } + }, + initialize: function(attrs, options) { + State.prototype.initialize.apply(this, arguments); + }, + setArgs: function (argStr, {dryRun=false}={}) { + var args = null + if(argStr !== "") { + let argStrs = argStr.replace(/ /g, '').split(','); + args = []; + argStrs.forEach((arg) => { + args.push(JSON.parse(arg.replace(/'/g, '"'))); + }); + } + if(!dryRun) { + this.args = args; + } + } +}); diff --git a/client/models/summary-stats.js b/client/models/summary-stats.js new file mode 100644 index 000000000..80a4f558d --- /dev/null +++ b/client/models/summary-stats.js @@ -0,0 +1,51 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +let _ = require('underscore'); +//models +let SummaryStat = require('./summary-stat'); +//collections +let Collection = require('ampersand-collection'); + +module.exports = Collection.extend({ + model: SummaryStat, + indexes: ['name'], + addSummaryStat: function ({name=null}={}) { + if(name === null) { + name = this.getDefaultName(); + } + let summaryStat = new SummaryStat({ + formula: '', + name: name + }); + summaryStat.setArgs(""); + this.add(summaryStat); + }, + getDefaultName: function () { + var i = this.length + 1; + var name = 'obs' + i; + var names = this.map((summaryStat) => { return summaryStat.name; }); + while(_.contains(names, name)) { + i += 1; + name = 'obs' + i; + } + return name; + }, + removeSummaryStat: function (summaryStat) { + this.remove(summaryStat); + } +}); diff --git a/client/models/user-settings.js b/client/models/user-settings.js index 44cc65bc3..2177bf255 100644 --- a/client/models/user-settings.js +++ b/client/models/user-settings.js @@ -24,7 +24,7 @@ let Model = require('ampersand-model'); module.exports = Model.extend({ url: function () { - return path.join(app.getApiPath(), "user-settings"); + return path.join(app.getApiPath(), "load-user-settings"); }, props: { awsAccessKeyID: 'string', diff --git a/client/pages/model-editor.js b/client/pages/model-editor.js index 8fd76438b..0995d80bb 100644 --- a/client/pages/model-editor.js +++ b/client/pages/model-editor.js @@ -360,6 +360,11 @@ let ModelEditor = PageView.extend({ if(app.getBasePath() === "/") { $(this.queryByHook("presentation")).css("display", "none"); } + let infLink = this.model.refLinks.filter((refLink) => { return refLink.job; })[0] || null; + if(infLink) { + $(this.queryByHook('return-to-inf-btn')).css("display", "inline-block"); + $(this.queryByHook('return-to-inf-btn')).prop("href", `stochss/workflow/edit?path=${infLink.path}&type=none`); + } this.renderModelView(); this.modelSettings = new TimespanSettingsView({ parent: this, diff --git a/client/pages/model-presentation.js b/client/pages/model-presentation.js index 2c8fb413d..2c4979147 100644 --- a/client/pages/model-presentation.js +++ b/client/pages/model-presentation.js @@ -52,10 +52,22 @@ let ModelPresentationPage = PageView.extend({ let endpoint = `api/file/json-data?file=${this.model.directory}&owner=${owner}`; app.getXHR(endpoint, { success: (err, response, body) => { + let particles = body.model.domain.particles this.model.set(body.model); - if(Object.keys(body.model.domain).includes("particles")) { + if(this.model.is_spatial && Object.keys(body.model.domain).includes("particles")) { let particles = new Particles(body.model.domain.particles); this.model.domain.particles = particles; + this.model.domain.actions.forEach((action) => { + action.filename = action.filename.split('/').pop(); + action.subdomainFile = action.subdomainFile.split('/').pop(); + }); + this.model.domain.x_lim = body.domainLimits[0]; + this.model.domain.y_lim = body.domainLimits[1]; + this.model.domain.z_lim = body.domainLimits[2]; + let particleCounts = {}; + this.model.domain.types.forEach((type) => { particleCounts[type.typeID] = 0; }); + particles.forEach((particle) => { particleCounts[particle.type] += 1; }); + this.model.domain.types.forEach((type) => { type.numParticles = particleCounts[type.typeID]; }); } let domainPlot = Boolean(body.domainPlot) ? body.domainPlot : null; this.renderSubviews(false, domainPlot); diff --git a/client/pages/project-manager.js b/client/pages/project-manager.js index 234669747..554ebc9f7 100644 --- a/client/pages/project-manager.js +++ b/client/pages/project-manager.js @@ -89,42 +89,42 @@ let ProjectManager = PageView.extend({ let self = this let mdlListEP = path.join(app.getApiPath(), 'project/add-existing-model') + "?path=" + self.model.directory; app.getXHR(mdlListEP, { - always: function (err, response, body) { + always: (err, response, body) => { let modal = $(modals.importModelHtml(body.files)).modal(); let okBtn = document.querySelector('#importModelModal .ok-model-btn'); let select = document.querySelector('#importModelModal #modelFileSelect'); let location = document.querySelector('#importModelModal #modelPathSelect'); - select.addEventListener("change", function (e) { + select.addEventListener("change", (e) => { okBtn.disabled = e.target.value && body.paths[e.target.value].length >= 2; if(body.paths[e.target.value].length >= 2) { - var locations = body.paths[e.target.value].map(function (path) { + var locations = body.paths[e.target.value].map((path) => { return ``; }); locations.unshift(``); locations = locations.join(" "); - $("#modelPathInput").find('option').remove().end().append(locations); + $(location).find('option').remove().end().append(locations); $("#location-container").css("display", "block"); }else{ $("#location-container").css("display", "none"); - $("#modelPathInput").find('option').remove().end(); + $(location).find('option').remove().end(); } }); - location.addEventListener("change", function (e) { + location.addEventListener("change", (e) => { okBtn.disabled = !Boolean(e.target.value); }); - okBtn.addEventListener("click", function (e) { + okBtn.addEventListener("click", (e) => { modal.modal('hide'); let mdlPath = body.paths[select.value].length < 2 ? body.paths[select.value][0] : location.value; let queryString = "?path=" + self.model.directory + "&mdlPath=" + mdlPath; let endpoint = path.join(app.getApiPath(), 'project/add-existing-model') + queryString app.postXHR(endpoint, null, { - success: function (err, response, body) { + success: (err, response, body) => { if(document.querySelector("#successModal")) { document.querySelector("#successModal").remove(); } let successModal = $(modals.successHtml(body.message)).modal(); }, - error: function (err, response, body) { + error: (err, response, body) => { if(document.querySelector("#errorModal")) { document.querySelector("#errorModal").remove(); } @@ -423,22 +423,24 @@ let ProjectManager = PageView.extend({ } let fetchTypes = ["Model", "Workflow", "WorkflowGroup", "Archive"]; if(fetchTypes.includes(target)) { - let self = this; app.getXHR(this.model.url(), { - success: function (err, response, body) { - self.model.set(body) - if(self.model.newFormat) { - self.renderWorkflowGroupCollection(); + success: (err, response, body) => { + this.model.set(body); + if(this.model.newFormat) { + this.renderWorkflowGroupCollection(); + if(["Model", "WorkflowGroup", "Archive"].includes(target)) { + this.metaDataView.updateView(); + } }else{ if(target === "Workflow"){ - self.renderWorkflowsCollection(); + this.renderWorkflowsCollection(); } if(target === "Model"){ - self.models = new Collection(body.models, {model: Model}); - self.renderModelsCollection(); + this.models = new Collection(body.models, {model: Model}); + this.renderModelsCollection(); } } - $(self.queryByHook('empty-project-trash')).prop('disabled', body.trash_empty) + $(this.queryByHook('empty-project-trash')).prop('disabled', body.trash_empty); } }); } diff --git a/client/pages/workflow-manager.js b/client/pages/workflow-manager.js index 63b6d16b6..3f8290a10 100644 --- a/client/pages/workflow-manager.js +++ b/client/pages/workflow-manager.js @@ -54,32 +54,32 @@ let WorkflowManager = PageView.extend({ initialize: function (attrs, options) { PageView.prototype.initialize.apply(this, arguments); let urlParams = new URLSearchParams(window.location.search); + let jobID = urlParams.has('job') ? urlParams.get('job') : null; this.model = new Workflow({ directory: urlParams.get('path') }); - let self = this; app.getXHR(this.model.url(), { - success: function (err, response, body) { - self.model.set(body) - $("#page-title").text("Workflow: " + self.model.name); - if(self.model.directory.includes('.proj')) { - let index = self.model.directory.indexOf('.proj') + 5; - self.projectPath = self.model.directory.slice(0, index); - $(self.queryByHook('project-breadcrumb')).text(self.projectPath.split('/').pop().split('.proj')[0]); - $(self.queryByHook('workflow-breadcrumb')).text(self.model.name); - self.queryByHook("project-breadcrumb-links").style.display = "block"; - self.queryByHook("return-to-project-btn").style.display = "inline-block"; + success: (err, response, body) => { + this.model.set(body) + $("#page-title").text("Workflow: " + this.model.name); + if(this.model.directory.includes('.proj')) { + let index = this.model.directory.indexOf('.proj') + 5; + this.projectPath = this.model.directory.slice(0, index); + $(this.queryByHook('project-breadcrumb')).text(this.projectPath.split('/').pop().split('.proj')[0]); + $(this.queryByHook('workflow-breadcrumb')).text(this.model.name); + this.queryByHook("project-breadcrumb-links").style.display = "block"; + this.queryByHook("return-to-project-btn").style.display = "inline-block"; } if(body.models){ - self.renderModelSelectView(body.models); + this.renderModelSelectView(body.models); } - self.renderSubviews(); - if(!self.model.newFormat) { + this.renderSubviews(jobID); + if(!this.model.newFormat) { let modal = $(modals.updateFormatHtml("Workflow")).modal(); let yesBtn = document.querySelector("#updateWorkflowFormatModal .yes-modal-btn"); yesBtn.addEventListener("click", function (e) { modal.modal("hide"); - let queryStr = "?path=" + self.model.directory + "&action=update-workflow"; + let queryStr = "?path=" + this.model.directory + "&action=update-workflow"; let endpoint = path.join(app.getBasePath(), "stochss/loading-page") + queryStr; window.location.href = endpoint; }); @@ -273,7 +273,7 @@ let WorkflowManager = PageView.extend({ this.settingsView = new SettingsView(options); app.registerRenderSubview(this, this.settingsView, "settings-container"); }, - renderSubviews: function () { + renderSubviews: function (jobID) { let oldFormRdyState = !this.model.newFormat && this.model.activeJob.status === "ready"; let newFormNotArchive = this.model.newFormat && this.model.model; if(!this.models && (oldFormRdyState || newFormNotArchive)) { @@ -287,7 +287,13 @@ let WorkflowManager = PageView.extend({ }else if(this.model.activeJob.status !== "ready") { this.renderStatusView(); } - let detailsStatus = ["error", "complete"] + let detailsStatus = ["error", "complete"]; + if(jobID !== null) { + let activeJob = this.model.jobs.filter((job) => { return job.name === jobID; })[0] || null; + if(activeJob !== null) { + this.model.activeJob = activeJob; + } + } if(this.model.activeJob && detailsStatus.includes(this.model.activeJob.status)) { this.renderActiveJob(); } @@ -315,25 +321,33 @@ let WorkflowManager = PageView.extend({ if(this.model.settings.parameterSweepSettings.parameters.length < 1) { $(this.queryByHook("start-job")).prop("disabled", true); } - this.model.settings.parameterSweepSettings.parameters.on("add remove", _.bind(function (e) { + this.model.settings.parameterSweepSettings.parameters.on("add remove", _.bind((e) => { let numParams = this.model.settings.parameterSweepSettings.parameters.length; $(this.queryByHook("start-job")).prop("disabled", numParams < 1); }, this)) + }else if(this.model.type === "Model Inference") { + $(this.queryByHook("aws-start-job")).addClass("disabled") + if(this.model.settings.inferenceSettings.parameters.length < 1) { + $(this.queryByHook("start-job")).prop("disabled", true); + } + this.model.settings.inferenceSettings.parameters.on("add remove", _.bind((e) => { + let numParams = this.model.settings.inferenceSettings.parameters.length; + $(this.queryByHook("start-job")).prop("disabled", numParams < 1); + }, this)) } let options = { model: this.model.settings, newFormat: this.model.newFormat, type: this.model.type } - if(this.model.type === "Parameter Sweep") { - let self = this; + if(["Parameter Sweep", "Model Inference"].includes(this.model.type)) { options['stochssModel'] = new Model({ directory: this.model.model }); app.getXHR(options.stochssModel.url(), { - success: function (err, response, body) { + success: (err, response, body) => { options.stochssModel.set(body); - self.renderSettingsView(options); + this.renderSettingsView(options); } }); }else { @@ -344,7 +358,8 @@ let WorkflowManager = PageView.extend({ let types = { "Ensemble Simulation": "gillespy", "Parameter Sweep": "parameterSweep", - "Spatial Ensemble Simulation": "spatial" + "Spatial Ensemble Simulation": "spatial", + "Model Inference": "inference" }; let data = { "settings": this.model.settings.toJSON(), "mdl_path": this.model.model, diff --git a/client/settings-view/settings-view.js b/client/settings-view/settings-view.js index 0eb4c271f..9a402affa 100644 --- a/client/settings-view/settings-view.js +++ b/client/settings-view/settings-view.js @@ -20,10 +20,11 @@ along with this program. If not, see . let app = require('../app'); //views let View = require('ampersand-view'); +let SpatialSettingsView = require('./views/spatial-settings-view'); let TimespanSettingsView = require('./views/timespan-settings-view'); let ParameterSettingsView = require('./views/parameter-settings-view'); +let InferenceSettingsView = require('./views/inference-settings-view'); let WellMixedSettingsView = require('./views/well-mixed-settings-view'); -let SpatialSettingsView = require('./views/spatial-settings-view'); //templates let template = require('./settingsView.pug'); @@ -43,9 +44,23 @@ module.exports = View.extend({ } if(this.type === "Parameter Sweep") { this.renderParameterSettingsView(); + }else if(this.type === "Model Inference") { + this.renderInferenceSettingsView(); } this.renderSimulationSettingsView(); }, + renderInferenceSettingsView: function () { + if(this.inferenceSettingsView) { + this.inferenceSettingsView.remove(); + } + this.inferenceSettingsView = new InferenceSettingsView({ + model: this.model.inferenceSettings, + stochssModel: this.stochssModel, + readOnly: this.readOnly + }); + let hook = "inference-settings-container"; + app.registerRenderSubview(this, this.inferenceSettingsView, hook); + }, renderParameterSettingsView: function () { if(this.parameterSettingsView) { this.parameterSettingsView.remove(); diff --git a/client/settings-view/settingsView.pug b/client/settings-view/settingsView.pug index 9a83214c7..b3588a74f 100644 --- a/client/settings-view/settingsView.pug +++ b/client/settings-view/settingsView.pug @@ -4,4 +4,6 @@ div#workflow-settings div(data-hook="param-sweep-settings-container") + div(data-hook="inference-settings-container") + div(data-hook="sim-settings-container") diff --git a/client/settings-view/templates/editCustomSummaryStatView.pug b/client/settings-view/templates/editCustomSummaryStatView.pug new file mode 100644 index 000000000..f9df9e9b8 --- /dev/null +++ b/client/settings-view/templates/editCustomSummaryStatView.pug @@ -0,0 +1,18 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-2 + + div(data-target="custom-property" data-hook="summary-stat-name") + + div.col-sm-8 + + div(data-hook="summary-stat-args") + + div.col-sm-2 + + button.btn.btn-outline-secondary.box-shadow(data-hook="remove") X diff --git a/client/settings-view/templates/editCustomSummaryStats.pug b/client/settings-view/templates/editCustomSummaryStats.pug new file mode 100644 index 000000000..b044706c4 --- /dev/null +++ b/client/settings-view/templates/editCustomSummaryStats.pug @@ -0,0 +1,27 @@ +div + + div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-2 + + h6.inline Feature Calculator + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.customName) + + div.col-sm-8 + + h6.inline Args + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.customArgs) + + div.col-sm-2 + + h6 Remove + + div.my-3(data-hook="edit-summary-stat-collection") + + button.btn.btn-outline-secondary.box-shadow(data-hook="add-summary-stat") Add Summary Statistic diff --git a/client/settings-view/templates/editIdentitySummaryStatView.pug b/client/settings-view/templates/editIdentitySummaryStatView.pug new file mode 100644 index 000000000..b83a80bec --- /dev/null +++ b/client/settings-view/templates/editIdentitySummaryStatView.pug @@ -0,0 +1,18 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-2 + + div(data-target="identity-property" data-hook="summary-stat-name") + + div.col-sm-8 + + div(data-target="identity-property" data-hook="summary-stat-formula") + + div.col-sm-2 + + button.btn.btn-outline-secondary.box-shadow(data-hook="remove") X diff --git a/client/settings-view/templates/editIdentitySummaryStats.pug b/client/settings-view/templates/editIdentitySummaryStats.pug new file mode 100644 index 000000000..889657825 --- /dev/null +++ b/client/settings-view/templates/editIdentitySummaryStats.pug @@ -0,0 +1,27 @@ +div + + div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-2 + + h6.inline Name + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.identityName) + + div.col-sm-8 + + h6.inline Formula + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.identityFormula) + + div.col-sm-2 + + h6 Remove + + div.my-3(data-hook="edit-summary-stat-collection") + + button.btn.btn-outline-secondary.box-shadow(data-hook="add-summary-stat") Add Summary Statistic diff --git a/client/settings-view/templates/editMinimalSummaryStats.pug b/client/settings-view/templates/editMinimalSummaryStats.pug new file mode 100644 index 000000000..e40d16c67 --- /dev/null +++ b/client/settings-view/templates/editMinimalSummaryStats.pug @@ -0,0 +1,155 @@ +div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.my-3.ml-3.pl-3 + + div.row + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-sum-values" data-target="minimal" data-name="sum_values") + + div.col-sm-8 + + div Sum of Values + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-median" data-target="minimal" data-name="median") + + div.col-sm-8 + + div Median + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-mean" data-target="minimal" data-name="mean") + + div.col-sm-8 + + div Mean + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-length" data-target="minimal" data-name="length") + + div.col-sm-8 + + div Length + + div.row + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-standard-deviation" data-target="minimal" data-name="standard_deviation") + + div.col-sm-8 + + div Standard Deviation + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-variance" data-target="minimal" data-name="variance") + + div.col-sm-8 + + div Variance + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-maximum" data-target="minimal" data-name="maximum") + + div.col-sm-8 + + div Maximum + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-minimum" data-target="minimal" data-name="minimum") + + div.col-sm-8 + + div Minimum diff --git a/client/settings-view/templates/editUniformParameterView.pug b/client/settings-view/templates/editUniformParameterView.pug new file mode 100644 index 000000000..071d80cc9 --- /dev/null +++ b/client/settings-view/templates/editUniformParameterView.pug @@ -0,0 +1,26 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-3 + + div(data-hook=this.model.elementID + "-sweep-target") + + div.col-sm-3 + + div(data-hook=this.model.elementID + "-target-value")=this.parameter.expression + + div.col-sm-2 + + div(data-hook=this.model.elementID + "-target-min") + + div.col-sm-2 + + div(data-hook=this.model.elementID + "-target-max") + + div.col-sm-2 + + button.btn.btn-outline-secondary.box-shadow(data-hook=this.model.elementID + "-remove") X diff --git a/client/settings-view/templates/editUniformParameters.pug b/client/settings-view/templates/editUniformParameters.pug new file mode 100644 index 000000000..9a90eddf0 --- /dev/null +++ b/client/settings-view/templates/editUniformParameters.pug @@ -0,0 +1,44 @@ +div + + div + + h4 Parameter Space Configuration + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + h6.inline Sweep Target + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.variable) + + div.col-sm-3 + + h6.inline Current Value + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.value) + + div.col-sm-2 + + h6.inline Minimum Value + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.min) + + div.col-sm-2 + + h6.inline Maximum Value + + div.tooltip-icon(data-html="true" data-toggle="tooltip" title=this.tooltips.max) + + div.col-sm-2 + + h6 Remove + + div.my-3(data-hook="edit-mi-parameter-collection") + + div(data-hook="mi-parameter-collection-error"): p.text-danger A model inference job must have at least one parameter. + + button.btn.btn-outline-secondary.box-shadow(data-hook="add-mi-parameter") Add Parameter + diff --git a/client/settings-view/templates/inferenceSettingsView.pug b/client/settings-view/templates/inferenceSettingsView.pug new file mode 100644 index 000000000..5e25dfd1d --- /dev/null +++ b/client/settings-view/templates/inferenceSettingsView.pug @@ -0,0 +1,207 @@ +div#inference-settings.card + + div.card-header.pb-0 + + h3.inline.mr-3 Inference Settings + + div.inline.mr-3 + + ul.nav.nav-tabs.card-header-tabs(id="inference-settings-tabs") + + li.nav-item + + a.nav-link.tab.active(data-hook=this.model.elementID + "-inference-settings-edit-tab" data-toggle="tab" href="#" + this.model.elementID + "-edit-inference-settings") Edit + + li.nav-item + + a.nav-link.tab(data-hook=this.model.elementID + "-inference-settings-view-tab" data-toggle="tab" href="#" + this.model.elementID + "-view-inference-settings") View + + button.btn.btn-outline-collapse(data-toggle="collapse" data-target="#" + this.model.elementID + "collapse-inference-settings" data-hook="collapse") - + + div.collapse(class="show" id=this.model.elementID + "collapse-inference-settings") + + div.card-body.tab-content + + div.tab-pane.active(id=this.model.elementID + "-edit-inference-settings" data-hook=this.model.elementID + "-edit-inference-settings") + + div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + h6.inline Number of Rounds + + div.col-sm-3 + + h6.inline Number of Samples + + div.mx-1.my-3.row + + div.col-sm-3(data-hook="num-rounds") + + div.col-sm-3(data-hook="num-samples") + + div + + h4 Summary Statistics + + hr + + div.mr-3.inline + + span.mr-2.inline(for="#summaryStatsTypeSelect") + + h6 Summary Statistics Type: + + div.inline(id="summaryStatsTypeSelect" data-hook="summary-stats-type-select") + + div.inline.hidden(data-hook="tsfresh-docs-link") + + p See TSFresh documentation for a full list of supported feature calculators. + + div(data-hook="edit-summary-stats-container") + + div.my-3(data-hook="edit-parameter-space-container") + + div.accordion(id="obs-data-section" data-hook="obs-data-section") + + div.card + + div.card-header(id="importObsDataHeader") + + h4.mb-0.inline="Import Observed Data File" + + button.btn.btn-outline-collapse.collapsed.inline(type="button" data-toggle="collapse" data-target="#collapseImportObsData" data-hook="collapseImportObsData" aria-expanded="false" aria-controls="collapseImportObsData" style="float: right") + + div(data-hook="import-chevron") + + svg(xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 512 512") + + path(d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z") + + div.card-body + + p.mb-0 + | Valid files are Observed Data Files (a directory with the postfix .odf) compressed to a zip archive (.zip) or Observed Data File (.csv) + + div + + div.collapse(id="collapseImportObsData" aria-labelledby="importObsDataHeader" data-parent="#obs-data-section") + + hr + + div.my-3 + + span.inline(for="obsDataFile")=`Please specify a file to import: ` + + input.ml-2(id="obsDataFile" data-hook="obs-data-file" type="file" name="obs-data-file" size="30" accept='.zip, .csv' required) + + div.inline + + button.btn.btn-outline-primary.box-shadow(data-hook="import-obs-data-file" disabled)=`Import File` + + div.mdl-edit-btn.saving-status.inline(data-hook="iodf-in-progress") + + div.spinner-grow.mr-2 + + span Importing observed data ... + + div.mdl-edit-btn.saved-status.inline(data-hook="iodf-complete") + + span Successfully imported observed data! + + div.mdl-edit-btn.save-error-status(data-hook="iodf-error") + + span(data-hook="iodf-action-error") + + div.card + + div.card-header(id="uploadObsDataHeader") + + h4.mb-0.inline="Select Observed Data File" + + button.btn.btn-outline-collapse.collapsed.inline(type="button" data-toggle="collapse" data-target="#collapseUploadObsData" data-hook="collapseUploadObsData" aria-expanded="false" aria-controls="collapseUploadObsData" style="float: right") + + div(data-hook="upload-chevron") + + svg(xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 512 512") + + path(d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z") + + div.card-body + + p.mb-0 + | Valid files are Observed Data Files (a directory with the postfix .odf) or Observed Data File (.csv) + + div + + div.collapse(id="collapseUploadObsData" aria-labelledby="uploadObsDataHeader" data-parent="#obs-data-section") + + hr + + div.hidden.text-info(data-hook="obs-data-location-message")=`There are multiple observed data files with that name, please select a location` + + div + + div.inline.mr-3 + + span.inline(for="obs-data-file-select")=`Please specify a file: ` + + div.inline(id="obs-data-file-select" data-hook="obs-data-file-select") + + div.hidden.inline(data-hook="obs-data-location-container") + + span.inline.mr-2(for="obs-data-location-select") Location: + + div.inline(id="obs-data-location-select" data-hook="obs-data-location-select") + + div.inline + + button.btn.btn-outline-secondary.box-shadow(data-hook="preview-obs-data") Preview + + div.tab-pane(id=this.model.elementID + "-view-inference-settings" data-hook=this.model.elementID + "-view-inference-settings") + + div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + h6.inline Number of Rounds + + div.col-sm-3 + + h6.inline Number of Samples + + div.mx-1.my-3.row + + div.col-sm-3 + + div(data-hook="view-num-rounds")=this.model.numRounds + + div.col-sm-3 + + div(data-hook="view-num-samples")=this.model.numSamples + + h4 Summary Statistics + + hr + + div + + h6.mr-2.inline Summary Statistic Type: + + div.inline(data-hook="view-summary-type")=this.summaryType + + div.mb-3(data-hook="view-summary-stats-container") + + div(data-hook="view-parameter-space-container") + + h4 Observed Data + + div(data-hook="view-obs-data-file")=this.model.obsData diff --git a/client/settings-view/templates/viewCustomSummaryStatView.pug b/client/settings-view/templates/viewCustomSummaryStatView.pug new file mode 100644 index 000000000..25c8f5c31 --- /dev/null +++ b/client/settings-view/templates/viewCustomSummaryStatView.pug @@ -0,0 +1,12 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-2 + + div.pl-2=this.model.name + + div.col-sm-10=this.model.argsDisplay diff --git a/client/settings-view/templates/viewCustomSummaryStats.pug b/client/settings-view/templates/viewCustomSummaryStats.pug new file mode 100644 index 000000000..c80854873 --- /dev/null +++ b/client/settings-view/templates/viewCustomSummaryStats.pug @@ -0,0 +1,15 @@ +div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-2 + + h6.inline Name + + div.col-sm-10 + + h6.inline Args + + div.my-3(data-hook="view-summary-stat-collection") diff --git a/client/settings-view/templates/viewIdentitySummaryStatView.pug b/client/settings-view/templates/viewIdentitySummaryStatView.pug new file mode 100644 index 000000000..c99c9e973 --- /dev/null +++ b/client/settings-view/templates/viewIdentitySummaryStatView.pug @@ -0,0 +1,12 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-2 + + div.pl-2=this.model.name + + div.col-sm-10=this.model.formula ? this.model.formula : "None" diff --git a/client/settings-view/templates/viewIdentitySummaryStats.pug b/client/settings-view/templates/viewIdentitySummaryStats.pug new file mode 100644 index 000000000..0831ba2ea --- /dev/null +++ b/client/settings-view/templates/viewIdentitySummaryStats.pug @@ -0,0 +1,15 @@ +div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-2 + + h6.inline Name + + div.col-sm-10 + + h6.inline Formula + + div.my-3(data-hook="view-summary-stat-collection") diff --git a/client/settings-view/templates/viewMinimalSummaryStats.pug b/client/settings-view/templates/viewMinimalSummaryStats.pug new file mode 100644 index 000000000..a919236c9 --- /dev/null +++ b/client/settings-view/templates/viewMinimalSummaryStats.pug @@ -0,0 +1,155 @@ +div + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.col-sm-3 + + div.row + + div.col-sm-4 + + h6.inline Enable + + div.col-sm-8 + + h6.inline Feature Calculator + + div.my-3.ml-3.pl-3 + + div.row + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-sum-values" data-target="minimal" data-name="sum_values" disabled) + + div.col-sm-8 + + div Sum of Values + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-median" data-target="minimal" data-name="median" disabled) + + div.col-sm-8 + + div Median + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-mean" data-target="minimal" data-name="mean" disabled) + + div.col-sm-8 + + div Mean + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-length" data-target="minimal" data-name="length" disabled) + + div.col-sm-8 + + div Length + + div.row + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-standard-deviation" data-target="minimal" data-name="standard_deviation" disabled) + + div.col-sm-8 + + div Standard Deviation + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-variance" data-target="minimal" data-name="variance" disabled) + + div.col-sm-8 + + div Variance + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-maximum" data-target="minimal" data-name="maximum" disabled) + + div.col-sm-8 + + div Maximum + + div.pl-0.col-sm-3 + + div.row + + div.col-sm-4 + + input(type="checkbox" data-hook="minimal-minimum" data-target="minimal" data-name="minimum" disabled) + + div.col-sm-8 + + div Minimum diff --git a/client/settings-view/templates/viewUniformParameterView.pug b/client/settings-view/templates/viewUniformParameterView.pug new file mode 100644 index 000000000..316233caf --- /dev/null +++ b/client/settings-view/templates/viewUniformParameterView.pug @@ -0,0 +1,16 @@ +div.mx-1 + + if(this.model.collection.indexOf(this.model) !== 0) + hr + + div.row + + div.col-sm-3 + + div.pl-2=this.model.name + + div.col-sm-3=this.parameter.expression + + div.col-sm-3=this.model.min + + div.col-sm-3=this.model.max diff --git a/client/settings-view/templates/viewUniformParameters.pug b/client/settings-view/templates/viewUniformParameters.pug new file mode 100644 index 000000000..3062b7460 --- /dev/null +++ b/client/settings-view/templates/viewUniformParameters.pug @@ -0,0 +1,25 @@ +div + + h4.mt-3 Parameter Space Configuration + + hr + + div.mx-1.row.head.align-items-baseline + + div.col-sm-3 + + h6.inline Sweep Target + + div.col-sm-3 + + h6.inline Current Value + + div.col-sm-3 + + h6.inline Minimum Value + + div.col-sm-3 + + h6.inline Maximum Value + + div.my-3(data-hook="view-mi-parameter-collection") \ No newline at end of file diff --git a/client/settings-view/views/custom-summary-stat-view.js b/client/settings-view/views/custom-summary-stat-view.js new file mode 100644 index 000000000..71f0d2f53 --- /dev/null +++ b/client/settings-view/views/custom-summary-stat-view.js @@ -0,0 +1,101 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +//support files +let tests = require('../../views/tests'); +//views +let InputView = require('../../views/input'); +let View = require('ampersand-view'); +//templates +let editTemplate = require('../templates/editCustomSummaryStatView.pug'); +let viewTemplate = require('../templates/viewCustomSummaryStatView.pug'); + +module.exports = View.extend({ + events: { + 'change [data-target=custom-property]' : 'updateViewer', + 'change [data-hook=summary-stat-args]' : 'setCalculatorArgs', + 'click [data-hook=remove]' : 'removeSummaryStat' + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.viewMode = attrs.viewMode ? attrs.viewMode : false; + this.customCalculators = attrs.customCalculators ? attrs.customCalculators : null; + }, + render: function (attrs, options) { + this.template = this.viewMode ? viewTemplate : editTemplate; + View.prototype.render.apply(this, arguments); + }, + removeSummaryStat: function () { + this.model.collection.removeSummaryStat(this.model); + }, + setCalculatorArgs: function (e) { + this.model.setArgs(e.target.value); + this.updateViewer(); + }, + update: function () {}, + updateValid: function () {}, + updateViewer: function () { + try{ + this.parent.updateViewer(); + }catch(error){ + this.parent.parent.updateViewer(); + } + }, + subviews: { + nameInputView: { + hook: "summary-stat-name", + prepareView: function (el) { + return new InputView({ + parent: this, + required: true, + name: 'observable-name', + modelKey: 'name', + tests: tests.nameTests, + changeTests: [(text) => { + if(!this.customCalculators.includes(text)) { + return "This feature calculator is not currently supported." + } + }], + valueType: 'string', + value: this.model.name, + placeholder: "-- i.e. autocorrelation --" + }); + } + }, + formulaInputView: { + hook: "summary-stat-args", + prepareView: function (el) { + return new InputView({ + parent: this, + required: false, + name: 'summary-calculator-args', + changeTests: [(text) => { + try{ + this.model.setArgs(text, {dryRun: true}); + }catch(error) { + return `Args must be in one of the following formats '{"lag": 1}' or '{"lag": 1},{"lag": 2}'.` + } + }], + valueType: 'string', + value: this.model.argsDisplay === "None" ? "" : this.model.argsDisplay, + placeholder: '-- i.e. Requires arguments: {"lag":1} or leave blank if no arguments are required --' + }); + } + } + } +}); diff --git a/client/settings-view/views/identity-summary-stat-view.js b/client/settings-view/views/identity-summary-stat-view.js new file mode 100644 index 000000000..b453115fb --- /dev/null +++ b/client/settings-view/views/identity-summary-stat-view.js @@ -0,0 +1,83 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +//support files +let tests = require('../../views/tests'); +//views +let InputView = require('../../views/input'); +let View = require('ampersand-view'); +//templates +let editTemplate = require('../templates/editIdentitySummaryStatView.pug'); +let viewTemplate = require('../templates/viewIdentitySummaryStatView.pug'); + +module.exports = View.extend({ + events: { + 'change [data-target=identity-property]' : 'updateViewer', + 'click [data-hook=remove]' : 'removeSummaryStat' + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.viewMode = attrs.viewMode ? attrs.viewMode : false; + }, + render: function (attrs, options) { + this.template = this.viewMode ? viewTemplate : editTemplate; + View.prototype.render.apply(this, arguments); + }, + removeSummaryStat: function () { + this.model.collection.removeSummaryStat(this.model); + }, + update: function () {}, + updateValid: function () {}, + updateViewer: function () { + try{ + this.parent.updateViewer(); + }catch(error){ + this.parent.parent.updateViewer(); + } + }, + subviews: { + nameInputView: { + hook: "summary-stat-name", + prepareView: function (el) { + return new InputView({ + parent: this, + required: true, + name: 'observable-name', + modelKey: 'name', + tests: tests.nameTests, + valueType: 'string', + value: this.model.name + }); + } + }, + formulaInputView: { + hook: "summary-stat-formula", + prepareView: function (el) { + return new InputView({ + parent: this, + required: false, + name: 'observable-calculator', + modelKey: 'formula', + valueType: 'string', + value: this.model.formula, + placeholder: "-- i.e. Juvenile + Susceptible + Exposed + Infected + Diseased --" + }); + } + } + } +}); diff --git a/client/settings-view/views/inference-parameters-view.js b/client/settings-view/views/inference-parameters-view.js new file mode 100644 index 000000000..e1e779581 --- /dev/null +++ b/client/settings-view/views/inference-parameters-view.js @@ -0,0 +1,125 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +let $ = require('jquery'); +//support files +let Tooltips = require('../../tooltips'); +//views +let View = require('ampersand-view'); +let UniformParameterView = require('./uniform-parameter-view'); +//templates +let editUniformTemplate = require('../templates/editUniformParameters.pug'); +let viewUniformTemplate = require('../templates/viewUniformParameters.pug'); + +module.exports = View.extend({ + events: { + 'click [data-hook=add-mi-parameter]' : 'handleAddParameterClick' + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.readOnly = attrs.readOnly ? attrs.readOnly : false; + this.tooltips = Tooltips.inferenceSettings; + this.stochssModel = attrs.stochssModel; + this.priorMethod = attrs.priorMethod; + if(!this.readOnly) { + this.collection.updateVariables(this.stochssModel.parameters); + } + }, + render: function () { + this.template = this.readOnly ? viewUniformTemplate : editUniformTemplate; + View.prototype.render.apply(this, arguments); + if(!this.readOnly) { + this.collection.on("add remove", () => { + let disable = this.collection.length >= this.stochssModel.parameters.length; + $(this.queryByHook("add-mi-parameter")).prop("disabled", disable); + }, this) + this.renderEditInferenceParameter(); + this.toggleAddParameter(); + this.toggleParameterCollectionError(); + }else{ + this.renderViewInferenceParameter(); + } + }, + getParameter: function () { + let parameters = this.collection.map((param) => { return param.paramID; }); + let target = this.stochssModel.parameters.filter((param) => { + return !parameters.includes(param.compID); + })[0]; + return target; + }, + handleAddParameterClick: function (e) { + let target = this.getParameter(); + this.collection.addInferenceParameter(target.compID, target.name); + this.updateTargetOptions(); + this.toggleAddParameter(); + this.toggleParameterCollectionError(); + }, + renderEditInferenceParameter: function () { + if(this.editInferenceParameter) { + this.editInferenceParameter.remove(); + } + let options = {"viewOptions": { + parent: this, + stochssParams: this.stochssModel.parameters + }} + var inferenceParameterView = UniformParameterView; + this.editInferenceParameter = this.renderCollection( + this.collection, + inferenceParameterView, + this.queryByHook("edit-mi-parameter-collection"), + options + ); + }, + renderViewInferenceParameter: function () { + if(this.viewInferenceParameter) { + this.viewInferenceParameter.remove(); + } + let options = {"viewOptions": { + parent: this, viewMode: true, + stochssParams: this.stochssModel.parameters + }} + var inferenceParameterView = UniformParameterView; + this.viewInferenceParameter = this.renderCollection( + this.collection, + inferenceParameterView, + this.queryByHook("view-mi-parameter-collection"), + options + ); + }, + toggleAddParameter: function () { + let disable = this.collection.length >= this.stochssModel.parameters.length; + $(this.queryByHook("add-mi-parameter")).prop('disabled', disable); + }, + toggleParameterCollectionError: function () { + let errorMsg = $(this.queryByHook('mi-parameter-collection-error')); + if(this.collection.length <= 0) { + errorMsg.addClass('component-invalid'); + errorMsg.removeClass('component-valid'); + }else{ + errorMsg.addClass('component-valid'); + errorMsg.removeClass('component-invalid'); + } + }, + updateTargetOptions: function () { + this.editInferenceParameter.views.forEach((view) => { + view.renderTargetSelectView(); + }); + }, + updateViewer: function () { + this.parent.renderViewParameterSpace(); + } +}); \ No newline at end of file diff --git a/client/settings-view/views/inference-settings-view.js b/client/settings-view/views/inference-settings-view.js new file mode 100644 index 000000000..d3cd32ecd --- /dev/null +++ b/client/settings-view/views/inference-settings-view.js @@ -0,0 +1,377 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +let $ = require('jquery'); +let path = require('path'); +//support files +let app = require('../../app'); +let modals = require('../../modals'); +let Plotly = require('plotly.js-dist'); +let tests = require('../../views/tests'); +//views +let View = require('ampersand-view'); +let InputView = require('../../views/input'); +let SelectView = require('ampersand-select-view'); +let SummaryStatsView = require('./summary-stats-view'); +let InferenceParametersView = require('./inference-parameters-view'); +//templates +let template = require('../templates/inferenceSettingsView.pug'); + +module.exports = View.extend({ + template: template, + bindings: { + 'model.obsData' : { + type: function (el, value, previousValue) { + el.disabled = value == ""; + }, + hook: 'preview-obs-data' + } + }, + events: { + 'change [data-hook=num-rounds]' : 'updateRoundsView', + 'change [data-hook=num-samples]' : 'updateSamplesView', + 'change [data-hook=summary-stats-type-select]' : 'setSummaryStatsType', + 'change [data-hook=obs-data-file]' : 'setObsDataFile', + 'change [data-hook=obs-data-file-select]' : 'selectObsDataFile', + 'change [data-hook=obs-data-location-select]' : 'selectObsDataLocation', + 'click [data-hook=collapse]' : 'changeCollapseButtonText', + 'click [data-hook=collapseImportObsData]' : 'toggleImportFiles', + 'click [data-hook=collapseUploadObsData]' : 'toggleUploadFiles', + 'click [data-hook=import-obs-data-file]' : 'handleImportObsData', + 'click [data-hook=preview-obs-data]' : 'handlePreviewObsData' + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.readOnly = attrs.readOnly ? attrs.readOnly : false; + this.stochssModel = attrs.stochssModel; + this.obsDataFiles = null; + this.obsDataFile = null; + this.obsFig = null; + this.chevrons = { + hide: ` + + + + `, + show: ` + + + + ` + } + this.occordianStates = {import: "hide", select: "hide"} + this.summaryStatCollections = { + identity: null, minimal: null, custom: null + } + this.summaryTypes = {"identity": "Identity", "minimal": "TSFresh Minimal", "custom": "Custom TSFresh"}; + this.summaryType = this.summaryTypes[this.model.summaryStatsType]; + }, + render: function () { + View.prototype.render.apply(this, arguments); + if(this.readOnly) { + $(this.queryByHook(this.model.elementID + '-inference-settings-edit-tab')).addClass("disabled"); + $(".nav .disabled>a").on("click", function(e) { + e.preventDefault(); + return false; + }); + $(this.queryByHook(this.model.elementID + '-inference-settings-view-tab')).tab('show'); + $(this.queryByHook(this.model.elementID + '-edit-inference-settings')).removeClass('active'); + $(this.queryByHook(this.model.elementID + '-view-inference-settings')).addClass('active'); + }else{ + this.renderEditSummaryStats(); + this.renderEditParameterSpace(); + this.renderObsDataSelects(); + } + this.renderViewSummaryStats(); + this.renderViewParameterSpace(); + }, + changeCollapseButtonText: function (e) { + app.changeCollapseButtonText(this, e); + }, + completeAction: function () { + $(this.queryByHook("iodf-in-progress")).css("display", "none"); + $(this.queryByHook("iodf-complete")).css("display", "inline-block"); + setTimeout(() => { + $(this.queryByHook("iodf-complete")).css("display", "none"); + }, 5000); + }, + errorAction: function (action) { + $(this.queryByHook("iodf-in-progress")).css("display", "none"); + $(this.queryByHook("iodf-action-error")).text(action); + $(this.queryByHook("iodf-error")).css("display", "block"); + }, + handleImportObsData: function () { + this.startAction(); + let formData = new FormData(); + var filePath = this.model.parent.parent.directory; + formData.append("path", filePath); + formData.append("datafile", this.obsDataFile); + let endpoint = path.join(app.getApiPath(), 'workflow/import-obs-data'); + app.postXHR(endpoint, formData, { + success: (err, response, body) => { + body = JSON.parse(body); + this.obsFig = null; + this.model.obsData = path.join(body.obsDataPath, body.obsDataFile); + this.completeAction(); + $(this.queryByHook('collapseUploadObsData')).click(); + this.renderObsDataSelects(); + $(this.queryByHook("view-obs-data-file")).text(this.model.obsData); + }, + error: (err, response, body) => { + body = JSON.parse(body); + this.errorAction(body.Message); + } + }, false); + }, + handlePreviewObsData: function () { + if(this.obsFig !== null) { + this.previewObsData(); + }else{ + let queryStr = `?path=${this.model.obsData}` + let endpoint = `${path.join(app.getApiPath(), 'workflow/preview-obs-data')}${queryStr}`; + app.getXHR(endpoint, {success: (err, response, body) => { + this.obsFig = body.figure; + this.previewObsData(); + }}); + } + }, + previewObsData: function () { + if(document.querySelector('#modal-preview-plot')) { + document.querySelector('#modal-preview-plot').remove(); + } + let modal = $(modals.obsPreviewHtml(this.model.obsData.split('/').pop())).modal(); + let plotEl = document.querySelector('#modal-preview-plot #modal-plot-container'); + Plotly.newPlot(plotEl, this.obsFig); + }, + renderEditParameterSpace: function () { + if(this.editParameterSpace) { + this.editParameterSpace.remove(); + } + this.editParameterSpace = new InferenceParametersView({ + collection: this.model.parameters, + stochssModel: this.stochssModel, + priorMethod: this.model.priorMethod + }); + let hook = "edit-parameter-space-container"; + app.registerRenderSubview(this, this.editParameterSpace, hook); + }, + renderEditSummaryStats: function () { + if(this.editSummaryStats) { + this.editSummaryStats.remove(); + } + this.editSummaryStats = new SummaryStatsView({ + collection: this.model.summaryStats, + summariesType: this.model.summaryStatsType, + customCalculators: this.model.customCalculators + }); + let hook = "edit-summary-stats-container"; + app.registerRenderSubview(this, this.editSummaryStats, hook); + }, + renderObsDataSelects: function () { + let queryStr = "?ext=.odf,.csv" + let endpoint = `${path.join(app.getApiPath(), 'workflow/obs-data-files')}${queryStr}`; + app.getXHR(endpoint, {success: (err, response, body) => { + this.obsDataFiles = body.obsDataFiles; + this.renderObsDataSelectView(); + }}); + }, + renderObsDataSelectView: function () { + if(this.obsDataSelectView) { + this.obsDataSelectView.remove(); + } + let files = this.obsDataFiles.files.filter((file) => { + if(file[1] === this.model.obsData.split('/').pop()) { + return file; + } + }); + let value = files.length > 0 ? files[0] : ""; + this.obsDataSelectView = new SelectView({ + name: 'obs-data-files', + required: true, + idAttributes: 'cid', + options: this.obsDataFiles.files, + value: value, + unselectedText: "-- Select Data File --" + }); + let hook = "obs-data-file-select"; + app.registerRenderSubview(this, this.obsDataSelectView, hook); + if(value !== "" && this.obsDataFiles.paths[value[0]].length > 1) { + this.renderObsDataLocationSelectView(value[0]); + $(this.queryByHook("obs-data-location-container")).css("display", "inline-block"); + } + }, + renderObsDataLocationSelectView: function (index) { + if(this.obsDataLocationSelectView) { + this.obsDataLocationSelectView.remove(); + } + let value = this.model.obsData !== "" ? this.model.obsData : ""; + this.obsDataLocationSelectView = new SelectView({ + name: 'obs-data-locations', + required: true, + idAttributes: 'cid', + options: this.obsDataFiles.paths[index], + value: value, + unselectedText: "-- Select Data File Location --" + }); + let hook = "obs-data-location-select"; + app.registerRenderSubview(this, this.obsDataLocationSelectView, hook); + }, + renderViewParameterSpace: function () { + if(this.viewParameterSpace) { + this.viewParameterSpace.remove(); + } + this.viewParameterSpace = new InferenceParametersView({ + collection: this.model.parameters, + readOnly: true, + stochssModel: this.stochssModel, + priorMethod: this.model.priorMethod + }); + let hook = "view-parameter-space-container"; + app.registerRenderSubview(this, this.viewParameterSpace, hook); + }, + renderViewSummaryStats: function () { + if(this.viewSummaryStats) { + this.viewSummaryStats.remove(); + } + this.viewSummaryStats = new SummaryStatsView({ + collection: this.model.summaryStats, + summariesType: this.model.summaryStatsType, + readOnly: true + }); + let hook = "view-summary-stats-container"; + app.registerRenderSubview(this, this.viewSummaryStats, hook); + }, + selectObsDataFile: function (e) { + this.obsFig = null; + let value = e.target.value; + var msgDisplay = "none"; + var contDisplay = "none"; + if(value) { + if(this.obsDataFiles.paths[value].length > 1) { + msgDisplay = "block"; + contDisplay = "inline-block"; + this.model.obsData = ""; + this.renderObsDataLocationSelectView(value); + }else{ + this.model.obsData = this.obsDataFiles.paths[value][0]; + } + }else{ + this.model.obsData = ""; + } + $(this.queryByHook("view-obs-data-file")).text(this.model.obsData ? this.model.obsData : "None"); + $(this.queryByHook("obs-data-location-message")).css('display', msgDisplay); + $(this.queryByHook("obs-data-location-container")).css("display", contDisplay); + }, + selectObsDataLocation: function (e) { + this.obsFig = null; + this.model.obsData = e.target.value ? e.target.value : ""; + $(this.queryByHook("view-obs-data-file")).text(this.model.obsData ? this.model.obsData : "None"); + }, + startAction: function () { + $(this.queryByHook("iodf-complete")).css("display", "none"); + $(this.queryByHook("iodf-error")).css("display", "none"); + $(this.queryByHook("iodf-in-progress")).css("display", "inline-block"); + }, + setObsDataFile: function (e) { + this.obsDataFile = e.target.files[0]; + $(this.queryByHook("import-obs-data-file")).prop('disabled', !this.obsDataFile); + }, + setSummaryStatsType: function (e) { + if(this.summaryStatCollections[e.target.value] === null) { + var summaryStats = this.model.resetSummaryStats(); + }else{ + var summaryStats = this.model.summaryStats; + this.model.summaryStats = this.summaryStatCollections[e.target.value]; + } + this.summaryStatCollections[this.model.summaryStatsType] = summaryStats; + this.model.summaryStatsType = e.target.value; + let display = e.target.value === "custom" ? "inline-block" : "none"; + $(this.queryByHook("view-summary-type")).text(this.summaryTypes[this.model.summaryStatsType]); + $(this.queryByHook("tsfresh-docs-link")).css("display", display); + this.renderEditSummaryStats(); + this.renderViewSummaryStats(); + }, + toggleImportFiles: function (e) { + setTimeout(() => { + this.occordianStates.select = "hide"; + this.occordianStates.import = this.occordianStates.import === "hide" ? "show" : "hide"; + $(this.queryByHook('upload-chevron')).html(this.chevrons.hide); + $(this.queryByHook('import-chevron')).html(this.occordianStates.import === "show" ? this.chevrons.show : this.chevrons.hide); + }); + }, + toggleUploadFiles: function (e) { + setTimeout(() => { + this.occordianStates.import = "hide"; + this.occordianStates.select = this.occordianStates.select === "hide" ? "show" : "hide"; + $(this.queryByHook('import-chevron')).html(this.chevrons.hide); + $(this.queryByHook('upload-chevron')).html(this.occordianStates.select === "show" ? this.chevrons.show : this.chevrons.hide); + }); + }, + update: function (e) {}, + updateValid: function (e) {}, + updateRoundsView: function (e) { + $(this.queryByHook("view-num-rounds")).text(this.model.numRounds); + }, + updateSamplesView: function (e) { + $(this.queryByHook("view-num-samples")).text(this.model.numSamples); + }, + subviews: { + numRoundsInputView: { + hook: "num-rounds", + prepareView: function (el) { + return new InputView({ + parent: this, + required: true, + name: 'number-of-rounds', + tests: tests.valueTests, + modelKey: 'numRounds', + valueType: 'number', + value: this.model.numRounds + }); + } + }, + numSamplesInputView: { + hook: "num-samples", + prepareView: function (el) { + return new InputView({ + parent: this, + required: true, + name: 'number-of-samples', + tests: tests.valueTests, + modelKey: 'numSamples', + valueType: 'number', + value: this.model.numSamples + }); + } + }, + summaryStatsTypeView: { + hook: "summary-stats-type-select", + prepareView: function (el) { + let options = [ + ["identity", "Identity"], ["minimal", "TSFresh Minimal"], ["custom", "Custom TSFresh"] + ] + return new SelectView({ + name: 'summary-statistics-type', + required: true, + eagerValidate: true, + options: options, + value: this.model.summaryStatsType + }); + } + } + } +}); diff --git a/client/settings-view/views/summary-stats-view.js b/client/settings-view/views/summary-stats-view.js new file mode 100644 index 000000000..7b398932f --- /dev/null +++ b/client/settings-view/views/summary-stats-view.js @@ -0,0 +1,127 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +let $ = require('jquery'); +//support files +let Tooltips = require('../../tooltips'); +//views +let View = require('ampersand-view'); +let CustomSummaryStatView = require('./custom-summary-stat-view'); +let IdentitySummaryStatView = require('./identity-summary-stat-view'); +//templates +let editCustomTemplate = require('../templates/editCustomSummaryStats.pug'); +let viewCustomTemplate = require('../templates/viewCustomSummaryStats.pug'); +let editMinimalTemplate = require('../templates/editMinimalSummaryStats.pug'); +let viewMinimalTemplate = require('../templates/viewMinimalSummaryStats.pug'); +let editIdentityTemplate = require('../templates/editIdentitySummaryStats.pug'); +let viewIdentityTemplate = require('../templates/viewIdentitySummaryStats.pug'); + +module.exports = View.extend({ + events: { + 'change [data-target=minimal]' : 'updateMinimalFeatures', + 'click [data-hook=add-summary-stat]' : 'addSummaryStatistic' + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.readOnly = attrs.readOnly ? attrs.readOnly : false; + this.tooltips = Tooltips.summaryStats; + this.summariesType = attrs.summariesType; + this.customCalculators = attrs.customCalculators; + }, + render: function () { + if(this.summariesType === "identity") { + this.template = this.readOnly ? viewIdentityTemplate : editIdentityTemplate; + }else if(this.summariesType === "custom") { + this.template = this.readOnly ? viewCustomTemplate : editCustomTemplate; + }else if(this.summariesType === "minimal") { + this.template = this.readOnly ? viewMinimalTemplate : editMinimalTemplate; + } + View.prototype.render.apply(this, arguments); + if(!this.readOnly) { + this.renderEditSummaryStat(); + }else{ + this.renderViewSummaryStat(); + } + }, + addSummaryStatistic: function ({name=""}={}) { + if(this.summariesType === "identity") { + this.collection.addSummaryStat(); + }else{ + this.collection.addSummaryStat({name: name}); + } + }, + renderEditSummaryStat: function () { + if(this.editSummaryStat) { + this.editSummaryStat.remove(); + } + if(this.summariesType === "minimal") { + this.renderMinimalSummaryStats(); + }else{ + if(this.summariesType === "identity") { + var summaryStatView = IdentitySummaryStatView; + }else if(this.summariesType === "custom"){ + var summaryStatView = CustomSummaryStatView; + } + let options = {"viewOptions": { customCalculators: this.customCalculators }}; + this.editSummaryStat = this.renderCollection( + this.collection, + summaryStatView, + this.queryByHook("edit-summary-stat-collection"), + options + ); + } + }, + renderMinimalSummaryStats: function () { + this.collection.forEach((summaryStat) => { + let hook = `minimal-${summaryStat.name.replace('_', '-')}`; + $(this.queryByHook(hook)).prop('checked', true); + }); + }, + renderViewSummaryStat: function () { + if(this.viewSummaryStat) { + this.viewSummaryStat.remove(); + } + if(this.summariesType === "minimal") { + this.renderMinimalSummaryStats(); + }else{ + let options = {"viewOptions": { viewMode: true }}; + if(this.summariesType === "identity") { + var summaryStatView = IdentitySummaryStatView; + }else if(this.summariesType === "custom"){ + var summaryStatView = CustomSummaryStatView; + } + this.viewSummaryStat = this.renderCollection( + this.collection, + summaryStatView, + this.queryByHook("view-summary-stat-collection"), + options + ); + } + }, + updateMinimalFeatures: function (e) { + let name = e.target.dataset.name; + let model = this.collection.get(name, "name"); + if(e.target.checked && model === undefined) { + this.collection.addSummaryStat({name: name}); + }else if(!(e.target.checked || model === undefined)){ + this.collection.removeSummaryStat(model); + } + }, + updateViewer: function () { + this.parent.renderViewSummaryStats(); + } +}); diff --git a/client/settings-view/views/uniform-parameter-view.js b/client/settings-view/views/uniform-parameter-view.js new file mode 100644 index 000000000..a282efac0 --- /dev/null +++ b/client/settings-view/views/uniform-parameter-view.js @@ -0,0 +1,147 @@ +/* +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2022 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +let $ = require('jquery'); +let _ = require('underscore'); +//support files +let app = require('../../app'); +let tests = require('../../views/tests'); +//views +let InputView = require('../../views/input'); +let View = require('ampersand-view'); +let SelectView = require('ampersand-select-view'); +//templates +let editTemplate = require('../templates/editUniformParameterView.pug'); +let viewTemplate = require('../templates/viewUniformParameterView.pug'); + +module.exports = View.extend({ + events: function () { + let events = {}; + events['change [data-hook=' + this.model.elementID + '-sweep-target]'] = 'setSelectedTarget'; + events['change [data-hook=' + this.model.elementID + '-target-min]'] = 'setHasChangedRange'; + events['change [data-hook=' + this.model.elementID + '-target-max]'] = 'setHasChangedRange'; + events['click [data-hook=' + this.model.elementID + '-remove'] = 'removeSweepParameter'; + return events; + }, + initialize: function (attrs, options) { + View.prototype.initialize.apply(this, arguments); + this.viewMode = attrs.viewMode ? attrs.viewMode : false; + this.parameters = attrs.stochssParams; + this.parameter = this.parameters.filter((param) => { + return param.compID === this.model.paramID; + })[0]; + if(!this.viewMode) { + this.model.updateVariable(this.parameter); + } + }, + render: function (attrs, options) { + this.template = this.viewMode ? viewTemplate : editTemplate; + View.prototype.render.apply(this, arguments); + if(!this.viewMode){ + this.renderTargetSelectView(); + this.renderMinValInputView(); + this.renderMaxValInputView(); + } + }, + getAvailableParameters: function () { + let variableTargets = this.model.collection.map((variable) => { return variable.paramID; }); + let availableParameters = this.parameters.filter((param) => { + return !variableTargets.includes(param.compID); + }).map((param) => { return param.name; }); + if(!availableParameters.includes(this.parameter.name)) { + availableParameters.push(this.parameter.name); + } + return availableParameters; + }, + removeSweepParameter: function () { + this.model.collection.removeInferenceParameter(this.model); + this.parent.updateTargetOptions(); + this.parent.toggleAddParameter(); + this.parent.toggleParameterCollectionError(); + this.remove(); + }, + renderMaxValInputView: function () { + if(this.maxValInputView) { + this.maxValInputView.remove(); + } + this.maxValInputView = new InputView({ + parent: this, + required: true, + name: 'max-value', + tests: tests.valueTests, + modelKey: 'max', + valueType: 'number', + value: this.model.max + }); + app.registerRenderSubview(this, this.maxValInputView, this.model.elementID + "-target-max"); + }, + renderMinValInputView: function () { + if(this.minValInputView) { + this.minValInputView.remove(); + } + this.minValInputView = new InputView({ + parent: this, + required: true, + name: 'min-value', + tests: tests.valueTests, + modelKey: 'min', + valueType: 'number', + value: this.model.min + }); + app.registerRenderSubview(this, this.minValInputView, this.model.elementID + "-target-min"); + }, + renderTargetSelectView: function (e) { + if(this.vieWMode) { return } + if(this.targetSelectView) { + this.targetSelectView.remove(); + } + let options = this.getAvailableParameters(); + this.targetSelectView = new SelectView({ + name: 'variable-target', + required: true, + idAttribute: 'cid', + options: options, + value: this.parameter.name + }); + app.registerRenderSubview(this, this.targetSelectView, this.model.elementID + "-sweep-target"); + }, + setHasChangedRange: function () { + this.model.hasChangedRange = true; + this.updateViewer(); + }, + setSelectedTarget: function (e) { + let targetName = e.target.value; + this.parameter = this.parameters.filter(function (param) { + return param.name === targetName; + })[0]; + this.model.paramID = this.parameter.compID; + this.model.name = this.parameter.name; + this.model.hasChangedRange = false; + this.model.updateVariable(this.parameter); + this.parent.updateTargetOptions(); + this.renderMinValInputView(); + this.renderMaxValInputView(); + $(this.queryByHook(this.model.elementID + "-target-value")).text(this.parameter.expression) + this.updateViewer(); + }, + update: function () {}, + updateValid: function () {}, + updateViewer: function () { + this.parent.updateViewer(); + } +}); diff --git a/client/styles/styles.css b/client/styles/styles.css index c6eb3bbd9..b0d830753 100644 --- a/client/styles/styles.css +++ b/client/styles/styles.css @@ -428,6 +428,10 @@ input[type="file"]::-ms-browse { margin: 0.5rem auto; } +.modal-dialog.preview-plot { + max-width: 1000px; +} + .modal-backdrop { width: 100%; height: 100%; @@ -442,6 +446,10 @@ input[type="file"]::-ms-browse { width: 60%; } +.modal-content.preview-plot { + max-width: 1000px; +} + .spinner-border { display: none; position: relative; @@ -849,3 +857,31 @@ input:checked + .slider:before { .slider.round:before { border-radius: 50%; } + +.inference-cpv-line { + margin-left: 19%; +} + +.inference-cpv-line:before { + content: ""; + display: block; + width: 15%; + border-top: 4px solid black; + position: absolute; + left: 15px; + top: 40%; +} + +.inference-ipv-line { + margin-left: 10%; +} + +.inference-ipv-line:before { + content: ""; + display: block; + width: 8%; + border-top: 4px dashed black; + position: absolute; + left: 15px; + top: 40%; +} \ No newline at end of file diff --git a/client/templates/includes/archiveListing.pug b/client/templates/includes/archiveListing.pug index 0e3838b49..1605952df 100644 --- a/client/templates/includes/archiveListing.pug +++ b/client/templates/includes/archiveListing.pug @@ -1,6 +1,6 @@ div - div.row + div.ml-1.row div.col-md-11 @@ -10,7 +10,7 @@ div button.btn.btn-outline-secondary.box-shadow(data-hook=this.model.elementID + "-remove") - div.ml-2 + div.ml-2.mb-3.pl-2 div diff --git a/client/templates/includes/modelListing.pug b/client/templates/includes/modelListing.pug index c369e2092..453704fc7 100644 --- a/client/templates/includes/modelListing.pug +++ b/client/templates/includes/modelListing.pug @@ -22,6 +22,7 @@ div if(!this.model.is_spatial) li.dropdown-item(id=this.model.elementID + "-parameter-sweep") Parameter Sweep + li.dropdown-item(id=this.model.elementID + "-model-inference") Model Inference li.dropdown-item diff --git a/client/templates/includes/workflowGroupListing.pug b/client/templates/includes/workflowGroupListing.pug index eb5f735f4..638783734 100644 --- a/client/templates/includes/workflowGroupListing.pug +++ b/client/templates/includes/workflowGroupListing.pug @@ -26,6 +26,7 @@ div if(!this.model.model.is_spatial) li.dropdown-item(id=this.model.elementID + "-parameter-sweep") Parameter Sweep + li.dropdown-item(id=this.model.elementID + "-model-inference") Model Inference li.dropdown-item @@ -49,6 +50,10 @@ div a.nav-link.tab(data-toggle="tab" href="#" + this.model.elementID + "-notes-tab") Notes + li.nav-item + + a.nav-link.tab(data-toggle="tab" href="#" + this.model.elementID + "-reference-tab") Reference Links + button.btn.btn-outline-collapse(data-toggle="collapse" data-target="#" + this.model.elementID + "-tab-container" data-hook=this.model.elementID + "-tab-btn") - div.card.card-body.tab-content.collapse.show(id=this.model.elementID + "-tab-container") @@ -62,3 +67,7 @@ div div textarea(id=this.model.elementID + "-annotation" data-hook=this.model.elementID + "-annotation" rows="7" style="width: 100%;")=this.model.model.annotation + + div.tab-pane(id=this.model.elementID + "-reference-tab") + + div(data-hook=this.model.elementID + "-reference-links") diff --git a/client/templates/pages/modelEditor.pug b/client/templates/pages/modelEditor.pug index 306bb6e24..1e27e5c85 100644 --- a/client/templates/pages/modelEditor.pug +++ b/client/templates/pages/modelEditor.pug @@ -117,6 +117,8 @@ section.page button.btn.btn-primary.box-shadow(data-hook="return-to-project-btn" style="display: none;") Return to Project + a.btn.btn-primary.box-shadow.text-break.hidden(data-hook="return-to-inf-btn" href="" role="button") Return to Inference + button.btn.btn-primary.box-shadow.dropdown-toggle( id="simulate-model", data-hook="simulate-model", diff --git a/client/tooltips.js b/client/tooltips.js index 816537794..88d284e9e 100644 --- a/client/tooltips.js +++ b/client/tooltips.js @@ -153,7 +153,7 @@ module.exports = { variable: "The parameter(s) to sweep through.", - value: "The current value for the parameter you with to sweep through.", + value: "The current value for the parameter you wish to sweep through.", min: "The initial value of the sweep range. Defaults to half of the current value.", @@ -161,6 +161,26 @@ module.exports = { steps: "The number of steps used to determine the sweep values across the sweep range." }, + inferenceSettings: { + variable: "The parameter(s) to infer.", + + value: "The current value for the parameter you wish to infer.", + + min: "The lower bound of the parameter/dimension. Defaults to half of the current value.", + + max: "The upper bound of the parameter/dimension. Defaults to 1.5 times the current value." + }, + summaryStats: { + customArgs: "Arguments to the TSFresh feature calculator.
  • Leave the feild empty if no arguments are required.
  • " + + '
  • To run once provide a single dictionary. i.e. {"lag":1}
  • ' + + '
  • To run multiple times provide a list of dictionaries. i.e. {"lag":1},{"lag":2}
  • ', + + customName: "Name of the TSFresh feature calculator.", + + identityFormula: "An expression that is evaluable within a python environment and a namespace comprised of the model's species.", + + identityName: "Name of the observable data.", + }, jobResults: { species: "The variables to view sweep data for", diff --git a/client/views/input.js b/client/views/input.js index e510b2f0a..cdda3e42e 100644 --- a/client/views/input.js +++ b/client/views/input.js @@ -15,8 +15,8 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ - -var AmpersandInputView = require('ampersand-input-view'); +let $ = require('jquery'); +let AmpersandInputView = require('ampersand-input-view'); module.exports = AmpersandInputView.extend({ props: { @@ -24,10 +24,12 @@ module.exports = AmpersandInputView.extend({ label: 'string', modelKey: 'string', valueType: 'string', - disabled: 'boolean' + disabled: 'boolean', + changeTests: 'object' }, events: { - 'input input' : 'changeInputHandler', + 'change input' : 'runChangeTests', + 'input input' : 'changeInputHandler' }, initialize: function (attrs, options) { AmpersandInputView.prototype.initialize.apply(this, arguments); @@ -68,4 +70,22 @@ module.exports = AmpersandInputView.extend({ this.parent.updateValid() } }, + runChangeTests: function (e) { + if(this.changeTests === undefined) { return } + let text = e.target.value; + let messages = []; + this.changeTests.forEach((test) => { + let message = test(text); + if(message) { + messages.push(message); + } + }); + if(messages.length > 0){ + let html = messages.join("
    "); + $(this.queryByHook("message-text")).html(html); + $(this.queryByHook("message-container")).css("display", "block"); + }else{ + $(this.queryByHook("message-container")).css("display", "none"); + } + } }); \ No newline at end of file diff --git a/client/views/jstree-view.js b/client/views/jstree-view.js index a11020125..f7fd33f6d 100644 --- a/client/views/jstree-view.js +++ b/client/views/jstree-view.js @@ -495,6 +495,12 @@ module.exports = View.extend({ this.newWorkflow(node, "Parameter Sweep"); } }), + modelInference: this.buildContextBase({ + label: "Model Inference", + action: (data) => { + this.newWorkflow(node, "Model Inference"); + } + }), jupyterNotebook: this.getNotebookNewWorkflowContext(node) } }); @@ -781,11 +787,11 @@ module.exports = View.extend({ }); locations.unshift(``); locations = locations.join(" "); - $("#modelPathSelect").find('option').remove().end().append(locations); + $(location).find('option').remove().end().append(locations); $("#location-container").css("display", "block"); }else{ $("#location-container").css("display", "none"); - $("#modelPathSelect").find('option').remove().end(); + $(location).find('option').remove().end(); } }); location.addEventListener("change", (e) => { diff --git a/client/views/main.js b/client/views/main.js index f81ef9fa6..cec56b031 100644 --- a/client/views/main.js +++ b/client/views/main.js @@ -69,6 +69,14 @@ module.exports = View.extend({ $("#presentation-nav-link").css("display", "none"); } this.setupUserLogs(); + let endpoint = path.join(app.getApiPath(), "load-user-settings"); + app.getXHR(endpoint, { + always: (err, response, body) => { + if(!body.settings.userLogs) { + this.closeUserLogs(); + } + } + }); return this; }, addNewLogBlock: function () { @@ -133,11 +141,13 @@ module.exports = View.extend({ let logs = $("#user-logs"); let classes = logs.attr("class").split(/\s+/); if(classes.includes("show")) { + $(this.queryByHook('close-user-logs')).css('display', 'block'); logs.removeClass("show"); $(this.queryByHook(e.target.dataset.hook)).html("+"); $(".user-logs").removeClass("expand-logs"); $(".side-navbar").css("z-index", 0); }else{ + $(this.queryByHook('close-user-logs')).css('display', 'none'); logs.addClass("show"); $(this.queryByHook(e.target.dataset.hook)).html("-"); if($(".sidebar-sticky").css("position") === "fixed") { diff --git a/client/views/meta-data.js b/client/views/meta-data.js index b683d404f..a0da12ada 100644 --- a/client/views/meta-data.js +++ b/client/views/meta-data.js @@ -36,7 +36,7 @@ let template = require('../templates/includes/metaData.pug'); module.exports = View.extend({ template: template, events: { - 'change [data-hook=file-select-view]' : 'updateMetaData', + 'change [data-hook=file-select-view]' : 'handleUpdateMetaData', 'change [data-hook=description-input-view]' : 'updateFileDescription', 'change [data-hook=creator-select-view]' : 'updateCreator', 'change [data-hook=email-input-view]' : 'toggleAddCreatorBtn', @@ -46,13 +46,14 @@ module.exports = View.extend({ }, initialize: function(attrs, options) { View.prototype.initialize.apply(this, arguments); - this.files = this.model.workflowGroups.map(function (wkgp) { + this.files = this.model.workflowGroups.map((wkgp) => { return wkgp.name; }); this.files.unshift(this.model.name); + this.selectedFile = this.model.name this.metadata = this.model.metadata; this.selectedCreator = "New Creator"; - this.model.creators.forEach(function (creator) { + this.model.creators.forEach((creator) => { if(!creator.elementID) { creator.elementID = "C" + (creator.collection.indexOf(creator) + 1); } @@ -96,13 +97,16 @@ module.exports = View.extend({ this.addCreator("existing", creator); } }, + handleUpdateMetaData: function (e) { + this.selectedFile = e.target.selectedOptions.item(0).text; + this.updateMetaData(); + }, renderCreatorListingView: function () { if(this.creatorListingView) { this.creatorListingView.remove(); } - let self = this; - let listOfCreators = this.metadata.creators.map(function (key) { - return self.model.creators.get(key, "elementID"); + let listOfCreators = this.metadata.creators.map((key) => { + return this.model.creators.get(key, "elementID"); }); let creators = new Creators(listOfCreators); this.creatorListingView = this.renderCollection(creators, CreatorListingView, this.queryByHook("list-of-creators")); @@ -163,16 +167,15 @@ module.exports = View.extend({ textAttribute: 'name', eagerValidate: true, options: this.files, - value: this.model.name + value: this.selectedFile }); app.registerRenderSubview(this, this.filesSelectView, "file-select-view"); }, saved: function () { $(this.queryByHook('md-in-progress')).css("display", "none"); $(this.queryByHook('md-complete')).css("display", "inline-block"); - let self = this; - setTimeout(function () { - $(self.queryByHook("md-complete")).css("display", "none"); + setTimeout(() => { + $(this.queryByHook("md-complete")).css("display", "none"); }, 5000); }, saving: function () { @@ -183,14 +186,13 @@ module.exports = View.extend({ this.saving(); let data = {} data[this.model.directory] = {"metadata":this.model.metadata, "creators":this.model.creators}; - this.model.workflowGroups.forEach(function (wkgp) { + this.model.workflowGroups.forEach((wkgp) => { data[wkgp.name + ".wkgp"] = {"metadata": wkgp.metadata}; }); - let self = this; let endpoint = path.join(app.getApiPath(), "project/meta-data")+"?path="+this.model.directory; app.postXHR(endpoint, data, { - success: function (err, response, body) { - self.saved(); + success: (err, response, body) => { + this.saved(); } }); }, @@ -212,13 +214,23 @@ module.exports = View.extend({ $(this.queryByHook("add-creator-btn")).text("Add"); } }, - updateMetaData: function (e) { - let selectedFile = e.target.selectedOptions.item(0).text; - this.metadata = selectedFile !== this.model.name ? this.model.workflowGroups.filter(function (wkgp) { - return wkgp.name === selectedFile; + updateMetaData: function () { + this.metadata = this.selectedFile !== this.model.name ? this.model.workflowGroups.filter((wkgp) => { + return wkgp.name === this.selectedFile; })[0].metadata : this.model.metadata; $(this.queryByHook("description-input-view")).val(this.metadata.description); this.renderCreatorListingView(); }, - updateValid: function () {} + updateValid: function () {}, + updateView: function () { + this.files = this.model.workflowGroups.map((wkgp) => { + return wkgp.name; + }); + this.files.unshift(this.model.name); + if(!this.files.includes(this.selectedFile)) { + this.selectedFile = this.model.name; + this.updateMetaData(); + } + this.renderFilesSelectView(); + } }); \ No newline at end of file diff --git a/client/views/model-listing.js b/client/views/model-listing.js index c4b9756b5..0c827b71a 100644 --- a/client/views/model-listing.js +++ b/client/views/model-listing.js @@ -33,6 +33,7 @@ module.exports = View.extend({ events['change [data-hook=' + this.model.elementID + '-annotation]'] = 'updateAnnotation'; events['click #' + this.model.elementID + "-ensemble-simulation"] = 'handleEnsembleSimulationClick'; events['click #' + this.model.elementID + "-parameter-sweep"] = 'handleParameterSweepClick'; + events['click #' + this.model.elementID + "-model-inference"] = 'handleModelInferenceClick'; events['click [data-hook=' + this.model.elementID + '-notes-btn]'] = 'handleEditNotesClick'; events['click [data-hook=' + this.model.elementID + '-remove]'] = 'handleTrashModelClick'; events['click [data-hook=' + this.model.elementID + '-annotation-btn'] = 'changeCollapseButtonText'; @@ -73,8 +74,11 @@ module.exports = View.extend({ let type = this.model.is_spatial ? "Spatial Ensemble Simulation" : "Ensemble Simulation"; this.newWorkflow(type); }, + handleModelInferenceClick: function (e) { + this.newWorkflow("Model Inference"); + }, handleParameterSweepClick: function (e) { - this.newWorkflow("Parameter Sweep") + this.newWorkflow("Parameter Sweep"); }, handleTrashModelClick: function (e) { if(document.querySelector('#moveToTrashConfirmModal')) { diff --git a/client/views/workflow-group-listing.js b/client/views/workflow-group-listing.js index 820bf4ba0..23c15c9fd 100644 --- a/client/views/workflow-group-listing.js +++ b/client/views/workflow-group-listing.js @@ -36,12 +36,20 @@ module.exports = View.extend({ events['change [data-hook=' + this.model.elementID + '-annotation'] = 'updateAnnotation'; events['click #' + this.model.elementID + "-ensemble-simulation"] = 'handleEnsembleSimulationClick'; events['click #' + this.model.elementID + "-parameter-sweep"] = 'handleParameterSweepClick'; + events['click #' + this.model.elementID + "-model-inference"] = 'handleModelInferenceClick'; events['click [data-hook=' + this.model.elementID + '-remove'] = 'handleTrashModelClick'; events['click [data-hook=' + this.model.elementID + '-tab-btn'] = 'changeCollapseButtonText'; return events; }, initialize: function (attrs, options) { View.prototype.initialize.apply(this, arguments); + let links = []; + this.model.model.refLinks.forEach((link) => { + links.push( + `${link.name}` + ); + }); + this.htmlLinks = links.join('') }, render: function (attrs, options) { View.prototype.render.apply(this, arguments); @@ -50,6 +58,9 @@ module.exports = View.extend({ let endpoint = path.join(app.getBasePath(), 'stochss/workflow/selection') + queryString; $(this.queryByHook(this.model.elementID + "-jupyter-notebook")).prop("href", endpoint); this.renderWorkflowCollection(); + if(this.htmlLinks) { + $(this.queryByHook(`${this.model.elementID}-reference-links`)).html(this.htmlLinks); + } }, changeCollapseButtonText: function (e) { app.changeCollapseButtonText(this, e); @@ -58,8 +69,11 @@ module.exports = View.extend({ let type = this.model.model.is_spatial ? "Spatial Ensemble Simulation" : "Ensemble Simulation"; this.newWorkflow(type); }, + handleModelInferenceClick: function (e) { + this.newWorkflow("Model Inference"); + }, handleParameterSweepClick: function (e) { - this.newWorkflow("Parameter Sweep") + this.newWorkflow("Parameter Sweep"); }, handleTrashModelClick: function () { if(document.querySelector('#moveToTrashConfirmModal')) { diff --git a/jupyterhub/model_presentation.py b/jupyterhub/model_presentation.py index 825be8b89..53725a37d 100644 --- a/jupyterhub/model_presentation.py +++ b/jupyterhub/model_presentation.py @@ -18,6 +18,7 @@ import os import ast import json +import hashlib import logging import traceback @@ -106,7 +107,7 @@ async def get(self, owner, file): self.finish() -def process_wmmodel_presentation(path, file=None, for_download=False): +def process_wmmodel_presentation(path, for_download=False, **kwargs): ''' Get the model presentation data from the file. @@ -114,8 +115,6 @@ def process_wmmodel_presentation(path, file=None, for_download=False): ---------- path : str Path to the model presentation file. - file : str - Name of the presentation file. for_download : bool Whether or not the model presentation is being downloaded. ''' @@ -128,7 +127,7 @@ def process_wmmodel_presentation(path, file=None, for_download=False): file_obj.print_logs(log) return model_pres -def process_smodel_presentation(path, file=None, for_download=False): +def process_smodel_presentation(path, for_download=False, **kwargs): ''' Get the model presentation data from the file. @@ -136,8 +135,6 @@ def process_smodel_presentation(path, file=None, for_download=False): ---------- path : str Path to the model presentation file. - file : str - Name of the presentation file. for_download : bool Whether or not the model presentation is being downloaded. ''' @@ -157,6 +154,24 @@ def process_smodel_presentation(path, file=None, for_download=False): file_obj.print_logs(log) return model_pres +template = { + "is_spatial": False, "defaultID": 1, "defaultMode": "", "annotation": "", "volume": 1, + "modelSettings": {"endSim": 20, "timeStep": 0.05, "timestepSize": 1e-5 }, + "domain": { + "actions": [], + "boundary_condition": {"reflect_x": True, "reflect_y": True, "reflect_z": True}, + "c_0": 10, "gravity": [0, 0, 0], "p_0": 100.0, "rho_0": 1.0, "shapes": [], + "size": None, "static": True, "template_version": 2, "transformations": [], + "types": [{ + "c":10, "fixed":False, "mass":1.0, "name":"Un-Assigned", + "nu":0.0, "rho":1.0, "typeID":0, "volume":1.0 + }], + "x_lim": [0, 0], "y_lim": [0, 0], "z_lim": [0, 0] + }, + "species": [], "initialConditions": [], "parameters": [], "reactions": [], "rules": [], + "eventsCollection": [], "functionDefinitions": [], "boundaryConditions": [] +} + class StochSSModel(StochSSBase): ''' ################################################################################################ @@ -215,6 +230,8 @@ def __update_reactions(self): if "reactions" not in self.model.keys(): return for reaction in self.model['reactions']: + if "odePropensity" not in reaction.keys(): + reaction['odePropensity'] = reaction['propensity'] try: if reaction['rate'].keys() and isinstance(reaction['rate']['expression'], str): expression = ast.literal_eval(reaction['rate']['expression']) @@ -237,13 +254,10 @@ def __update_rules(self, param_ids): except ValueError: pass - def load(self): - ''' - Reads the model file, updates the model to the current format, and stores it in self.model + def __update_model_to_current(self): + if self.model['template_version'] == self.TEMPLATE_VERSION: + return - Attributes - ---------- - ''' if "annotation" not in self.model.keys(): self.model['annotation'] = "" if "volume" not in self.model.keys(): @@ -251,11 +265,29 @@ def load(self): self.model['volume'] = self.model['modelSettings']['volume'] else: self.model['volume'] = 1 + param_ids = self.__update_parameters() self.__update_reactions() self.__update_events(param_ids=param_ids) self.__update_rules(param_ids=param_ids) - return {"model": self.model} + + if "refLinks" not in self.model.keys(): + self.model['refLinks'] = [] + + self.model['template_version'] = self.TEMPLATE_VERSION + + def load(self): + ''' + Reads the model file, updates the model to the current format, and stores it in self.model + + Attributes + ---------- + ''' + if "template_version" not in self.model: + self.model['template_version'] = 0 + self.__update_model_to_current() + + return {"model": self.model, "diff": self.diff} class StochSSSpatialModel(StochSSBase): @@ -294,10 +326,8 @@ def __init__(self): pass def inside(self, point, on_boundary): # pylint: disable=no-self-use - ''' - Custom inside function for geometry - ''' - namespace = {'x': point[0], 'y': point[1], 'z': point[2]} + ''' Custom inside function for geometry. ''' + namespace = {'x': point[0], 'y': point[1], 'z': point[2], 'on_boundary': on_boundary} return eval(formula, {}, namespace) return NewGeometry() @@ -317,37 +347,31 @@ def __build_stochss_domain_particles(cls, domain): return particles def __convert_actions(self, domain, s_domain, type_ids): - geometries = self.__convert_geometries(s_domain) - lattices = self.__convert_lattices(s_domain) - transformations = self.__convert_transformations(s_domain, geometries, lattices) + geometries, lattices = self.__convert_shapes(s_domain) + transformations = self.__convert_transformations(s_domain) try: - actions = sorted(s_domain['actions'], key=lambda action: action['priority']) + actions = list(filter(lambda action: action['enable'], s_domain['actions'])) + actions = sorted(actions, key=lambda action: action['priority']) for i, action in enumerate(actions): - # Get geometry. 'Multi Particle' scope uses geometry from action - if action['geometry'] == "": - geometry = None - elif action['geometry'] in geometries: - geometry = geometries[action['geometry']] - else: - geometry = transformations[action['geometry']] # Build props arg - if action['type'] in ('Fill Action', 'Set Action'): + if action['type'] in ('Fill Action', 'Set Action', 'XML Mesh', 'Mesh IO'): kwargs = { - 'type_id': type_ids[action['typeID']], 'mass': action['mass'], - 'vol': action['vol'], 'rho': action['rho'], 'nu': action['nu'], - 'c': action['c'], 'fixed': action['fixed'] + 'mass': action['mass'], 'vol': action['vol'], 'rho': action['rho'], + 'nu': action['nu'], 'c': action['c'], 'fixed': action['fixed'] } + if action['type'] in ('Fill Action', 'Set Action'): + kwargs['type_id'] = type_ids[action['typeID']].replace("-", "") else: kwargs = {} # Apply actions if action['type'] == "Fill Action": - if not action['useProps']: - kwargs = {} if action['scope'] == 'Multi Particle': - if action['lattice'] in lattices: - lattice = lattices[action['lattice']] + geometry = geometries[f"{action['shape']}_geom"] + if action['transformation'] == "": + lattice = lattices[f"{action['shape']}_latt"] else: - lattice = transformations[action['lattice']] + lattice = transformations[action['transformation']] + lattice.lattice = lattices[f"{action['shape']}_latt"] _ = domain.add_fill_action( lattice=lattice, geometry=geometry, enable=action['enable'], apply_action=action['enable'], **kwargs @@ -355,6 +379,20 @@ def __convert_actions(self, domain, s_domain, type_ids): else: point = [action['point']['x'], action['point']['y'], action['point']['z']] domain.add_point(point, **kwargs) + elif action['type'] in ('XML Mesh', 'Mesh IO', 'StochSS Domain'): + lattices = { + 'XML Mesh': spatialpy.XMLMeshLattice, 'Mesh IO': spatialpy.MeshIOLattice, + 'StochSS Domain': spatialpy.StochSSLattice + } + filename = os.path.join(self.user_dir, action['filename']) + if action['type'] == "StochSS Domain" or action['subdomainFile'] == "": + lattice = lattices[action['type']](filename) + else: + subdomain_file = os.path.join(self.user_dir, action['subdomainFile']) + lattice = lattices[action['type']](filename, subdomain_file=subdomain_file) + _ = domain.add_fill_action( + lattice=lattice, enable=action['enable'], apply_action=action['enable'], **kwargs + ) else: # Get proper geometry for scope # 'Single Particle' scope creates a geometry using actions point. @@ -366,6 +404,11 @@ def __convert_actions(self, domain, s_domain, type_ids): geometry = self.__build_geometry( None, name=f"SPAGeometry{i + 1}", formula=formula ) + elif action['transformation'] == "": + geometry = geometries[f"{action['shape']}_geom"] + else: + geometry = transformations[action['transformation']] + geometry.geometry = geometries[f"{action['shape']}_geom"] if action['type'] == "Set Action": domain.add_set_action( geometry=geometry, enable=action['enable'], @@ -405,9 +448,7 @@ def __convert_domain(self, type_ids, s_domain): gravity = s_domain['gravity'] if gravity == [0, 0, 0]: gravity = None - domain = spatialpy.Domain( - 0, xlim, ylim, zlim, rho0=rho0, c0=c_0, P0=p_0, gravity=gravity - ) + domain = spatialpy.Domain(0, xlim, ylim, zlim, rho0=rho0, c0=c_0, P0=p_0, gravity=gravity) self.__convert_actions(domain, s_domain, type_ids) self.__convert_types(domain, type_ids) return domain @@ -416,122 +457,114 @@ def __convert_domain(self, type_ids, s_domain): message += f"are referenced incorrectly: {str(err)}" raise StochSSModelFormatError(message, traceback.format_exc()) from err - @classmethod - def __convert_types(cls, domain, type_ids): - domain.typeNdxMapping = {"type_UnAssigned": 0} - domain.typeNameMapping = {0: "type_UnAssigned"} - domain.listOfTypeIDs = [0] - for ndx, name in type_ids.items(): - name = f"type_{name}" - domain.typeNdxMapping[name] = ndx - domain.typeNameMapping[ndx] = name - domain.listOfTypeIDs.append(ndx) - - def __convert_geometries(self, s_domain): + def __convert_shapes(self, s_domain): try: geometries = {} comb_geoms = [] - for s_geometry in s_domain['geometries']: - if s_geometry['type'] == "Standard Geometry": - geometries[s_geometry['name']] = self.__build_geometry(s_geometry) + lattices = {} + for s_shape in s_domain['shapes']: + # Create geometry from shape + geo_name = f"{s_shape['name']}_geom" + if s_shape['type'] == "Standard": + if s_shape['formula'] in ("", "True"): + geometries[geo_name] = spatialpy.GeometryAll() + elif s_shape['formula'] == "on_boundary": + geometries[geo_name] = spatialpy.GeometryExterior() + elif s_shape['formula'] == "not on_boundary": + geometries[geo_name] = spatialpy.GeometryInterior() + else: + geometries[geo_name] = self.__build_geometry(None, name=geo_name, formula=s_shape['formula']) else: - name = s_geometry['name'] - comb_geometry = spatialpy.CombinatoryGeometry("", {}) - comb_geometry.formula = s_geometry['formula'] - geometries[name] = comb_geometry - comb_geoms.append(name) + geometry = spatialpy.CombinatoryGeometry("", {}) + geometry.formula = s_shape['formula'] + geometries[geo_name] = geometry + comb_geoms.append(geo_name) + # Create lattice from shape if fillable + if s_shape['fillable']: + lat_name = f"{s_shape['name']}_latt" + if s_shape['lattice'] == "Cartesian Lattice": + half_length = s_shape['length'] / 2 + half_height = s_shape['height'] / 2 + half_depth = s_shape['depth'] / 2 + lattice = spatialpy.CartesianLattice( + -half_length, half_length, s_shape['deltax'], + ymin=-half_height, ymax=half_height, deltay=s_shape['deltay'], + zmin=-half_depth, zmax=half_depth, deltaz=s_shape['deltaz'] + ) + elif s_shape['lattice'] == "Spherical Lattice": + lattice = spatialpy.SphericalLattice( + s_shape['radius'], s_shape['deltas'], deltar=s_shape['deltar'] + ) + elif s_shape['lattice'] == "Cylindrical Lattice": + lattice = spatialpy.CylindricalLattice( + s_shape['radius'], s_shape['length'], s_shape['deltas'], deltar=s_shape['deltar'] + ) + lattices[lat_name] = lattice + items = [' and ', ' or ', ' not ', '(', ')'] for name in comb_geoms: - geo_namespace = { - key: geometry for key, geometry in geometries.items() if key != name - } + formula = geometries[name].formula + if formula.startswith("not "): + formula = formula.replace("not ", "") + for item in items: + formula = formula.replace(item, " ") + formula = formula.split(" ") + geo_namespace = {} + for key, geometry in geometries.items(): + if key != name and key[:-5] in formula: + geo_namespace[key[:-5]] = geometry geometries[name].geo_namespace = geo_namespace - return geometries + return geometries, lattices except KeyError as err: - message = "Spatial geometries are not properly formatted or " + message = "Spatial domain shapes are not properly formatted or " message += f"are referenced incorrectly: {str(err)}" raise StochSSModelFormatError(message, traceback.format_exc()) from err @classmethod - def __convert_lattices(cls, s_domain): - try: - lattices = {} - for s_lattice in s_domain['lattices']: - name = s_lattice['name'] - center = [ - s_lattice['center']['x'], s_lattice['center']['y'], s_lattice['center']['z'] - ] - if s_lattice['type'] == "Cartesian Lattice": - lattice = spatialpy.CartesianLattice( - s_lattice['xmin'], s_lattice['xmax'], s_lattice['deltax'], center=center, - ymin=s_lattice['ymin'], ymax=s_lattice['ymax'], deltay=s_lattice['deltay'], - zmin=s_lattice['zmin'], zmax=s_lattice['zmax'], deltaz=s_lattice['deltaz'] - ) - elif s_lattice['type'] == "Spherical Lattice": - lattice = spatialpy.SphericalLattice( - s_lattice['radius'], s_lattice['deltas'], - center=center, deltar=s_lattice['deltar'] - ) - elif s_lattice['type'] == "Cylindrical Lattice": - lattice = spatialpy.CylindricalLattice( - s_lattice['radius'], s_lattice['length'], s_lattice['deltas'], - center=center, deltar=s_lattice['deltar'] - ) - elif s_lattice['type'] == "XML Mesh Lattice": - lattice = spatialpy.XMLMeshLattice( - s_lattice['filename'], center=center, - subdomain_file=s_lattice['subdomainFile'] - ) - elif s_lattice['type'] == "Mesh IO Lattice": - lattice = spatialpy.MeshIOLattice( - s_lattice['filename'], center=center, - subdomain_file=s_lattice['subdomainFile'] - ) - else: - lattice = spatialpy.StochSSLattice(s_lattice['filename'], center=center) - lattices[name] = lattice - return lattices - except KeyError as err: - message = "Spatial lattices are not properly formatted or " - message += f"are referenced incorrectly: {str(err)}" - raise StochSSModelFormatError(message, traceback.format_exc()) from err + def __convert_types(cls, domain, type_ids): + domain.typeNdxMapping = {"type_UnAssigned": 0} + domain.typeNameMapping = {0: "type_UnAssigned"} + domain.listOfTypeIDs = [0] + for ndx, name in type_ids.items(): + if ndx not in domain.typeNameMapping: + name = f"type_{name}" + domain.typeNdxMapping[name] = ndx + domain.typeNameMapping[ndx] = name + domain.listOfTypeIDs.append(ndx) + types = list(set(domain.type_id)) + for name in types: + if name not in domain.typeNdxMapping: + ndx = len(domain.typeNdxMapping) + domain.typeNdxMapping[name] = ndx + domain.typeNameMapping[ndx] = name + domain.listOfTypeIDs.append(ndx) @classmethod - def __convert_transformations(cls, s_domain, geometries, lattices): + def __convert_transformations(cls, s_domain): try: transformations = {} nested_trans = {} for s_transformation in s_domain['transformations']: name = s_transformation['name'] - vector = [ - [ - s_transformation['vector'][0]['x'], - s_transformation['vector'][0]['y'], - s_transformation['vector'][0]['z'] - ], - [ - s_transformation['vector'][1]['x'], - s_transformation['vector'][1]['y'], - s_transformation['vector'][1]['z'] - ] - ] - if s_transformation['geometry'] != "": - geometry = geometries[s_transformation['geometry']] - else: - geometry = None - if s_transformation['lattice'] != "": - lattice = lattices[s_transformation['lattice']] - else: - lattice = None if s_transformation['transformation'] != "": nested_trans[name] = s_transformation['transformation'] - if s_transformation['type'] == "Translate Transformation": - transformation = spatialpy.TranslationTransformation( - vector, geometry=geometry, lattice=lattice - ) - elif s_transformation['type'] == "Rotate Transformation": - transformation = spatialpy.RotationTransformation( - vector, s_transformation['angle'], geometry=geometry, lattice=lattice - ) + if s_transformation['type'] in ("Translate Transformation", "Rotate Transformation"): + vector = [ + [ + s_transformation['vector'][0]['x'], + s_transformation['vector'][0]['y'], + s_transformation['vector'][0]['z'] + ], + [ + s_transformation['vector'][1]['x'], + s_transformation['vector'][1]['y'], + s_transformation['vector'][1]['z'] + ] + ] + + if s_transformation['type'] == "Translate Transformation": + transformation = spatialpy.TranslationTransformation(vector) + else: + transformation = spatialpy.RotationTransformation(vector, s_transformation['angle']) elif s_transformation['type'] == "Reflect Transformation": normal = numpy.array([ s_transformation['normal']['x'], s_transformation['normal']['y'], @@ -556,18 +589,14 @@ def __convert_transformations(cls, s_domain, geometries, lattices): else: normal = None transformation = spatialpy.ReflectionTransformation( - point1, normal=normal, point2=point2, point3=point3, - geometry=geometry, lattice=lattice + point1, normal=normal, point2=point2, point3=point3 ) else: center = numpy.array([ s_transformation['center']['x'], s_transformation['center']['y'], s_transformation['center']['z'] ]) - transformation = spatialpy.ScalingTransformation( - s_transformation['factor'], center=center, - geometry=geometry, lattice=lattice - ) + transformation = spatialpy.ScalingTransformation(s_transformation['factor'], center=center) transformations[name] = transformation for trans, nested_tran in nested_trans.items(): transformations[trans].transformation = transformations[nested_tran] @@ -578,7 +607,7 @@ def __convert_transformations(cls, s_domain, geometries, lattices): raise StochSSModelFormatError(message, traceback.format_exc()) from err @classmethod - def __get_trace_data(cls, particles, name="", index=None): + def __get_trace_data(cls, particles, name="", index=None, dimensions=3): common_rgb_values = [ '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', @@ -600,8 +629,13 @@ def __get_trace_data(cls, particles, name="", index=None): marker = {"size":5} if index is not None: marker["color"] = common_rgb_values[(index) % len(common_rgb_values)] - return plotly.graph_objs.Scatter3d(ids=ids, x=x_data, y=y_data, z=z_data, - name=name, mode="markers", marker=marker) + if dimensions == 2: + return plotly.graph_objs.Scatter( + ids=ids, x=x_data, y=y_data, name=name, mode="markers", marker=marker + ) + return plotly.graph_objs.Scatter3d( + ids=ids, x=x_data, y=y_data, z=z_data, name=name, mode="markers", marker=marker + ) def __update_domain_to_current(self, domain=None): domain = self.model['domain'] @@ -637,28 +671,21 @@ def __update_domain_to_current(self, domain=None): 'pres_path': filename } - geometries = [] + shapes = [] for d_type in domain['types']: if 'geometry' in d_type and d_type['geometry']: - geometries.append({ - 'name': f"geometry{len(geometries) + 1}", - 'type': 'Standard Geometry', - 'formula': d_type['geometry'] + shapes.append({ + 'deltar': 0, 'deltas': 0, 'deltax': 0, 'deltay': 0, 'deltaz': 0, 'depth': 0, 'fillable': False, + 'formula': d_type['geometry'], 'height': 0, 'lattice': 'Cartesian Lattice', + 'length': 0, 'name': f"shape{len(shapes) + 1}", 'radius': 0, 'type': 'Standard' }) domain['actions'] = [{ - 'type': 'Fill Action', 'scope': 'Multi Particle', 'priority': 1, 'enable': True, - 'geometry': '', 'lattice': 'lattice1', 'useProps': False, + 'type': 'StochSS Domain', 'scope': 'Multi Particle', 'priority': 1, 'enable': True, 'shape': '', + 'transformation': '', 'filename': filename.replace(f'{self.user_dir}/', ''), 'subdomainFile': '', 'point': {'x': 0, 'y': 0, 'z': 0}, 'newPoint': {'x': 0, 'y': 0, 'z': 0}, 'c': 10, 'fixed': False, 'mass': 1.0, 'nu': 0.0, 'rho': 1.0, 'typeID': 0, 'vol': 0.0 }] - domain['geometries'] = geometries - domain['lattices'] = [{ - 'name': 'lattice1', 'type': 'StochSS Lattice', - 'filename': filename.replace(f'{self.user_dir}/', ''), 'subdomainFile': '', - 'center': {'x': 0, 'y': 0, 'z': 0}, 'length': 0, 'radius': 0, - 'deltar': 0,'deltas': 0,'deltax': 0,'deltay': 0,'deltaz': 0, - 'xmax': 0,'xmin': 0,'ymax': 0,'ymin': 0,'zmax': 0,'zmin': 0 - }] + domain['shapes'] = shapes domain['transformations'] = [] domain['template_version'] = self.DOMAIN_TEMPLATE_VERSION @@ -723,17 +750,15 @@ def __update_model_to_current(self): @classmethod def get_presentation(cls, model=None, files=None): ''' Get the presentation for download. ''' - # Check if the domain has lattices - if len(model['domain']['lattices']) == 0: - return {'model': model, 'files': files} # Process file based lattices - file_based_types = ('XML Mesh Lattice', 'Mesh IO Lattice', 'StochSS Lattice') - for lattice in model['domain']['lattices']: - if lattice['type'] in file_based_types: - lattice['filename'] = files[lattice['name']]['dwn_path'] - if lattice['subdomainFile'] != "": - entry = files[f"{lattice['name']}_sdf"] - lattice['subdomainFile'] = entry['dwn_path'] + file_based_types = ('XML Mesh', 'Mesh IO', 'StochSS Domain') + for action in model['domain']['actions']: + if action['type'] in file_based_types: + action_id = hashlib.md5(json.dumps(action, sort_keys=True, indent=4).encode('utf-8')).hexdigest() + action['filename'] = files[action_id]['dwn_path'] + if action['subdomainFile'] != "": + entry = files[f"{action_id}_sdf"] + action['subdomainFile'] = entry['dwn_path'] return {'model': model, 'files': files} def get_domain_plot(self, domain, s_domain): @@ -751,7 +776,7 @@ def get_domain_plot(self, domain, s_domain): # Case #3: 1 or more particles and one type if len(s_domain['types']) == 1: fig['data'][0]['name'] = "Un-Assigned" - ids = list(filter(lambda particle: particle['particle_id'], s_domain['particles'])) + ids = list(map(lambda particle: particle['particle_id'], s_domain['particles'])) fig['data'][0]['ids'] = ids # Case #4: 1 or more particles and multiple types else: @@ -763,7 +788,7 @@ def get_domain_plot(self, domain, s_domain): traces = list(filter(t_test, fig['data'])) if len(traces) == 0: fig['data'].insert(index, self.__get_trace_data( - particles=[], name=d_type['name'], index=index + particles=[], name=d_type['name'], index=index, dimensions=domain.dimensions )) else: particles = list(filter( @@ -786,22 +811,25 @@ def load(self, v1_domain=False): ''' if "template_version" not in self.model: self.model['template_version'] = 0 + self.__update_model_to_current() + if "template_version" not in self.model['domain']: self.model['domain']['template_version'] = 0 - - self.__update_model_to_current() files = self.__update_domain_to_current() + if v1_domain: return {'model': self.model, 'files': files} - plot = self.load_action_preview() - return {"model": self.model, "domainPlot": json.loads(plot)} + plot, limits = self.load_action_preview() + return {"model": self.model, "domainPlot": json.loads(plot), "domainLimits": limits} def load_action_preview(self): ''' Get a domain preview of all enabled actions. ''' s_domain = self.model['domain'] types = sorted(s_domain['types'], key=lambda d_type: d_type['typeID']) type_ids = {d_type['typeID']: d_type['name'] for d_type in types} - domain = self.__convert_domain(type_ids, s_domain) - # xlim, ylim, zlim = domain.get_bounding_box() + domain = self.__convert_domain(type_ids, s_domain=s_domain) + xlim, ylim, zlim = domain.get_bounding_box() + limits = [list(xlim), list(ylim), list(zlim)] s_domain['particles'] = self.__build_stochss_domain_particles(domain) - return self.get_domain_plot(domain, s_domain)#, [list(xlim), list(ylim), list(zlim)] + plot = self.get_domain_plot(domain, s_domain) + return plot, limits diff --git a/package.json b/package.json index 77a5bccd4..18a77692f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "jstree": "^3.3.8", "katex": "^0.11.0", "local-links": "^1.4.1", - "plotly.js-dist": "^1.58.5", + "plotly.js-dist": "^2.20.0", "popper.js": "^1.15.0", "pug": "^2.0.3", "underscore": "^1.9.1", diff --git a/requirements.txt b/requirements.txt index 671f6e35f..0f04c8eac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ python-libsedml==2.0.9 python-libcombine==0.2.7 pyabc==0.12.6 escapism==1.0.1 -gillespy2==1.8.1 -spatialpy==1.2.1 -stochss-compute[AWS]==1.0.1 +gillespy2==1.8.2 +spatialpy==1.2.2 +stochss-compute[AWS]==1.0.2 git+https://github.com/StochSS/sciope.git@master diff --git a/stochss/handlers/__init__.py b/stochss/handlers/__init__.py index ba5eadd17..24133bd65 100644 --- a/stochss/handlers/__init__.py +++ b/stochss/handlers/__init__.py @@ -53,7 +53,7 @@ def get_page_handlers(route_start): (r'/stochss/settings\/?', UserSettingsHandler), (r'/stochss/api/user-logs\/?', UserLogsAPIHandler), (r'/stochss/api/clear-user-logs\/?', ClearUserLogsAPIHandler), - (r'/stochss/api/user-settings\/?', LoadUserSettings), + (r'/stochss/api/load-user-settings\/?', LoadUserSettings), (r'/stochss/api/aws/job-config-check\/?', ConfirmAWSConfigHandler), (r'/stochss/api/aws/launch-cluster\/?', LaunchAWSClusterHandler), (r'/stochss/api/aws/cluster-status\/?', AWSClusterStatusHandler), @@ -128,8 +128,12 @@ def get_page_handlers(route_start): (r"/stochss/api/workflow/save-plot\/?", SavePlotAPIHandler), (r"/stochss/api/workflow/save-annotation\/?", SaveAnnotationAPIHandler), (r"/stochss/api/workflow/update-format\/?", UpadteWorkflowAPIHandler), + (r"/stochss/api/workflow/import-obs-data\/?", ImportObsDataAPIHandler), + (r"/stochss/api/workflow/obs-data-files\/?", LoadObsDataFiles), + (r"/stochss/api/workflow/preview-obs-data\/?", PreviewOBSDataAPIHandler), (r"/stochss/api/job/presentation\/?", JobPresentationAPIHandler), - (r"/stochss/api/job/csv\/?", DownloadCSVZipAPIHandler) + (r"/stochss/api/job/csv\/?", DownloadCSVZipAPIHandler), + (r"/stochss/api/job/export-inferred-model\/?", ExportInferredModelAPIHandler) ] full_handlers = list(map(lambda h: (url_path_join(route_start, h[0]), h[1]), handlers)) return full_handlers diff --git a/stochss/handlers/util/__init__.py b/stochss/handlers/util/__init__.py index c0b67a89e..5a3ddbd51 100644 --- a/stochss/handlers/util/__init__.py +++ b/stochss/handlers/util/__init__.py @@ -30,4 +30,5 @@ from .stochss_project import StochSSProject from .ensemble_simulation import EnsembleSimulation from .parameter_sweep import ParameterSweep +from .model_inference import ModelInference from .stochss_errors import StochSSAPIError, report_error, report_critical_error diff --git a/stochss/handlers/util/model_inference.py b/stochss/handlers/util/model_inference.py new file mode 100644 index 000000000..ea2eed8fd --- /dev/null +++ b/stochss/handlers/util/model_inference.py @@ -0,0 +1,1448 @@ +''' +StochSS is a platform for simulating biochemical systems +Copyright (C) 2019-2023 StochSS developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' +import os +import csv +import copy +import json +import shutil +import pickle +import logging +import tempfile +import traceback +from collections import UserDict, UserList + +import numpy +from scipy import stats + +try: + import plotly + from plotly import subplots + plotly_installed = True +except ImportError: + plotly_installed = False +import matplotlib.pyplot as plt +import matplotlib.text as mtext +from dask.distributed import Client + +import gillespy2 + +from sciope.inference import smc_abc +from sciope.utilities.priors import uniform_prior +from sciope.utilities.summarystats import auto_tsfresh, identity +from sciope.utilities.epsilonselectors import RelativeEpsilonSelector + +from .stochss_job import StochSSJob +from .stochss_errors import StochSSJobError, StochSSJobResultsError + +log = logging.getLogger("stochss") + +common_rgb_values = [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', + '#7f7f7f', '#bcbd22', '#17becf', '#ff0000', '#00ff00', '#0000ff', '#ffff00', + '#00ffff', '#ff00ff', '#800000', '#808000', '#008000', '#800080', '#008080', + '#000080', '#ff9999', '#ffcc99', '#ccff99', '#cc99ff', '#ffccff', '#62666a', + '#8896bb', '#77a096', '#9d5a6c', '#9d5a6c', '#eabc75', '#ff9600', '#885300', + '#9172ad', '#a1b9c4', '#18749b', '#dadecf', '#c5b8a8', '#000117', '#13a8fe', + '#cf0060', '#04354b', '#0297a0', '#037665', '#eed284', '#442244', + '#ffddee', '#702afb' +] + +def combine_colors(colors): + """ + Combine two colors into one with concentrations of 50% of the original colors. + + :param colors: Colors in hexidecimal form to combine. + :type colors: list(2) + + :returns: Returns the new color in hexidecimal form. + :rtype: str + """ + red = int(sum([int(k[:2], 16) * 0.5 for k in colors])) + green = int(sum([int(k[2:4], 16) * 0.5 for k in colors])) + blue = int(sum([int(k[4:6], 16) * 0.5 for k in colors])) + zpad = lambda x: x if len(x)==2 else '0' + x + color = f"#{zpad(hex(red)[2:])}{zpad(hex(green)[2:])}{zpad(hex(blue)[2:])}" + return color + +class LegendTitle(object): + """ + Custom handler map for legend group titles. + + :param text_props: \**kwargs: Keyword arguments passed to :py:class:`matplotlib.text`. + :type text_props: dict + """ + def __init__(self, text_props=None): + self.text_props = text_props or {} + super(LegendTitle, self).__init__() + + def legend_artist(self, legend, orig_handle, fontsize, handlebox): + """ + Return the artist that this HandlerBase generates for the given original artist/handle. + Full documentation can be found here: + https://matplotlib.org/stable/api/legend_handler_api.html#matplotlib.legend_handler.HandlerBase.legend_artist + """ + x0, y0 = handlebox.xdescent, handlebox.ydescent + title = mtext.Text(x0, y0, orig_handle, **self.text_props) + handlebox.add_artist(title) + return title + +class InferenceRound(UserDict): + """ + Inference Round Dict created by a StochSS Inference Simulation containing single round, extends the UserDict object. + + :param accepted_samples: A dictionary of accepted sample values created by an inference. + :type accepted_samples: dict + + :param distances: A list of distances values created by an inference. + :type distances: list + + :param accepted_count: The number of accepted samples for this round. + :type accepted_count: int + + :param trial_count: The number of total trials performed in order to converge this round. + :type trial_count: int + + :param inferred_parameters: The mean of accepted parameter samples. + :type inferred_parameters: dict + + :param inferred_method: Label for the method used to calculate the inferred parameters. + :type inferred_method: str + """ + def __init__(self, accepted_samples, distances, accepted_count, trial_count, inferred_parameters, inferred_method): + super().__init__(accepted_samples) + self.distances = distances + self.accepted_count = accepted_count + self.trial_count = trial_count + self.inferred_method = inferred_method + self.__inferred_parameters = {inferred_method: inferred_parameters} + + def __getattribute__(self, key): + # attribute_map = { + # 'accepted_samples': self.data, + # 'distances': self.distances, + # 'accepted_count': self.accepted_count, + # 'trial_count': self.trial_count, + # 'inferred_method': self.inferred_method, + # 'inferred_parameters': self.__inferred_parameters[self.inferred_method] + # } + # if key in attribute_map: + # return attribute_map[key] + if key == 'inferred_parameters': + return self.__inferred_parameters[self.inferred_method] + return UserList.__getattribute__(self, key) + + def __getitem__(self, key): + if isinstance(key, int): + param = list(self.data.keys())[key] + msg = "InferenceRound is of type dictionary. " + msg += f"Use inference_round['[{param}]'] instead of inference_round[{key}]['{param}']. " + msg += f"Retrieving inference_round['[{param}]']" + log.warning(msg) + return self.data[param] + if key in self.data: + return self.data[key] + attribute_map = { + 'accepted_samples': self.data, + 'distances': self.distances, + 'accepted_count': self.accepted_count, + 'trial_count': self.trial_count, + 'inferred_method': self.inferred_method, + 'inferred_parameters': self.__inferred_parameters[self.inferred_method] + } + if key in attribute_map: + return attribute_map[key] + if hasattr(self.__class__, "__missing__"): + return self.__class__.__missing__(self, key) + raise KeyError(key) + + def __plot(self, parameters, bounds, include_pdf=True, include_orig_values=True, + include_inferred_values=False, title=None, xaxis_label=None, yaxis_label=None): + nbins = 50 + names = list(self.data.keys()) + dims = len(names) + fig, axes = plt.subplots(nrows=dims, ncols=dims, figsize=[14, 14]) + if xaxis_label is not None: + _ = fig.text(0.5, 0.09, xaxis_label, size=18, ha='center', va='center') + if yaxis_label is not None: + _ = fig.text(0.08, 0.5, yaxis_label, size=18, ha='center', va='center', rotation='vertical') + + sing_param = [] + doub_param = [(["Parameter Intersections"], [""])] + pdf_axes = [None] * dims + for i, (param1, accepted_values1) in enumerate(self.data.items()): + row = i + for j, (param2, accepted_values2) in enumerate(self.data.items()): + col = j + if i == 0: + axes[row, col].set_title(names[j], size=16) + if j == dims - 1: + axes[row, col].set_ylabel(names[i], size=16, rotation=270) + axes[row, col].yaxis.set_label_position("right") + axes[row, col].yaxis.set_label_coords(1.4, 0.5) + + if i > j: + axes[row, col].axis('off') + elif i == j: + color = common_rgb_values[(i)%len(common_rgb_values)] + axes[row, col].hist( + accepted_values1, label="Histogram", color=color, alpha=0.75, + bins=nbins, range=(bounds[0][i], bounds[1][i]) + ) + axes[row, col].set_xlim(bounds[0][i], bounds[1][i]) + sing_param.append(([param1], [""])) + sing_param.append(axes[row, col].get_legend_handles_labels()) + if include_pdf: + mean, std = stats.norm.fit(accepted_values1) + points = numpy.linspace(min(accepted_values1), max(accepted_values1), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + + pdf_axes[row] = axes[row, col].twinx() + pdf_axes[row].plot(points, pdf, label="PDF", color=color) + y_ticks = pdf_axes[row].get_yticks() + pdf_axes[row].set_ylim(0, max(y_ticks)) + sing_param.append(pdf_axes[row].get_legend_handles_labels()) + if include_orig_values: + axes[row, col].axvline(parameters[param1], alpha=0.75, color='black') + if include_inferred_values: + axes[row, col].axvline( + self.inferred_parameters[param1], alpha=0.75, color='black', ls='dashed' + ) + else: + color = combine_colors([ + common_rgb_values[(i)%len(common_rgb_values)][1:], + common_rgb_values[(j)%len(common_rgb_values)][1:] + ]) + axes[row, col].scatter(accepted_values2, accepted_values1, c=color, label=f"{param2} X {param1}") + axes[row, col].set_xlim(bounds[0][j], bounds[1][j]) + axes[row, col].set_ylim(bounds[0][i], bounds[1][i]) + doub_param.append(axes[row, col].get_legend_handles_labels()) + + labels = numpy.array(sing_param)[:, 1, 0].tolist() + handles = numpy.array(sing_param)[:, 0, 0].tolist() + labels.extend(numpy.array(doub_param)[:, 1, 0].tolist()) + handles.extend(numpy.array(doub_param)[:, 0, 0].tolist()) + fig.legend( + handles, labels, loc=(0.05, 0.06), fontsize=12.5, frameon=False, markerscale=1.75, + handler_map={str: LegendTitle({'fontsize': 14})} + ) + fig.subplots_adjust(left=0.1, right=0.9, wspace=0.5, hspace=0.25) + if title is not None: + _ = fig.text(0.5, 0.92, title, size=20, ha='center', va='center') + + return fig + + def __plot_intersection(self, parameters, bounds, colors, include_pdf=True, include_orig_values=True, + include_inferred_values=False, title=None, xaxis_label=None, yaxis_label=None): + nbins = 50 + names = list(parameters.keys()) + + if xaxis_label is None: + xaxis_label = names[0] + if yaxis_label is None: + yaxis_label = names[1] + + fig, axes = plt.subplots( + nrows=3, ncols=3, figsize=[14, 14], sharex='col', sharey='row', + gridspec_kw={'width_ratios': [6, 1, 3], 'height_ratios': [3, 1, 6]} + ) + _ = fig.text(0.5, 0.09, xaxis_label, size=18, ha='center', va='center') + _ = fig.text(0.08, 0.5, yaxis_label, size=18, ha='center', va='center', rotation='vertical') + for row in range(2): + for col in range(1, 3): + axes[row, col].axis('off') + + rug_symbol = ['|', '_'] + histo_row = histo_col = [0, 2] + rug_row, rug_col = [1, 2], [0, 1] + x_key, y_key = ['x', 'y'], ['y', 'x'] + orientation = ['vertical', 'horizontal'] + line_func = [axes[histo_row[0], histo_col[0]].axvline, axes[histo_row[1], histo_col[1]].axhline] + + rugs = [(["Rug"], [""])] + legend = [] + pdf_axes = [None] * 2 + for i, (param, orig_val) in enumerate(parameters.items()): + if i >= 2: + break + + # Create histogram traces + axes[histo_row[i], histo_col[i]].hist( + self[param], label="Histogram", color=colors[i], alpha=0.75, orientation=orientation[i], + bins=nbins, range=(bounds[0][i], bounds[1][i]) + ) + legend.append(([param], [""])) + legend.append(axes[histo_row[i], histo_col[i]].get_legend_handles_labels()) + if include_pdf: + mean, std = stats.norm.fit(self[param]) + points = numpy.linspace(min(self[param]), max(self[param]), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + + if i == 0: + pdf_axes[i] = axes[histo_row[i], histo_col[i]].twinx() + pdf_axes[i].plot(points, pdf, label="PDF", color=colors[i]) + y_ticks = pdf_axes[i].get_yticks() + pdf_axes[i].set_ylim(0, max(y_ticks)) + else: + pdf_axes[i] = axes[histo_row[i], histo_col[i]].twiny() + pdf_axes[i].plot(pdf, points, label="PDF", color=colors[i]) + x_ticks = pdf_axes[i].get_xticks() + pdf_axes[i].set_xlim(0, max(x_ticks)) + legend.append(pdf_axes[i].get_legend_handles_labels()) + if include_orig_values: + line_func[i](orig_val, alpha=0.75, color='black') + if include_inferred_values: + line_func[i](self.inferred_parameters[param], alpha=0.75, color='black', ls='dashed') + # Create rug traces + rug_args = { + x_key[i]: self[param], y_key[i]: [param] * self.accepted_count, 's': 50, + 'c': colors[i], 'label': param, 'marker': rug_symbol[i] + } + axes[rug_row[i], rug_col[i]].scatter(**rug_args) + if i == 0: + axes[rug_row[i], rug_col[i]].set_yticks([]) + axes[rug_row[i], rug_col[i]].yaxis.set_tick_params(labelleft=False) + else: + axes[rug_row[i], rug_col[i]].set_xticks([]) + axes[rug_row[i], rug_col[i]].xaxis.set_tick_params(labelbottom=False) + rugs.append(axes[rug_row[i], rug_col[i]].get_legend_handles_labels()) + + legend.extend(rugs) + + axes[2, 0].scatter(self[names[0]], self[names[1]], c=colors[2], label=f"{names[0]} X {names[1]}") + legend.append((["Intersection"], [""])) + legend.append(axes[2, 0].get_legend_handles_labels()) + + labels = numpy.array(legend)[:, 1, 0].tolist() + handles = numpy.array(legend)[:, 0, 0].tolist() + fig.legend( + handles, labels, loc=(0.78, 0.68), fontsize=12.5, frameon=False, markerscale=1.75, + handler_map={str: LegendTitle({'fontsize': 14})} + ) + axes[2, 0].set_xlim(bounds[0][0], bounds[1][0]) + axes[2, 0].set_ylim(bounds[0][1], bounds[1][1]) + fig.subplots_adjust(wspace=0.04, hspace=0.04) + if title is not None: + _ = fig.text(0.5, 0.92, title, size=20, ha='center', va='center') + + return fig + + def __plotplotly(self, parameters, bounds, include_pdf=True, include_orig_values=True, + include_inferred_values=False, title=None, xaxis_label=None, yaxis_label=None): + nbins = 50 + names = list(self.data.keys()) + sizes = (numpy.array(bounds[1]) - numpy.array(bounds[0])) / nbins + + plotly.offline.init_notebook_mode() + dims = len(names) + specs = [[{"secondary_y": True} if x == y else {} for y in range(dims)] for x in range(dims)] + fig = subplots.make_subplots( + rows=dims, cols=dims, column_titles=names, row_titles=names, specs=specs, + x_title=xaxis_label, y_title=yaxis_label, vertical_spacing=0.05, horizontal_spacing=0.05 + ) + + for i, (param1, accepted_values1) in enumerate(self.data.items()): + row = i + 1 + for j, (param2, accepted_values2) in enumerate(self.data.items()): + col = j + 1 + if i > j: + continue + if i == j: + color = common_rgb_values[(i)%len(common_rgb_values)] + trace = plotly.graph_objs.Histogram( + x=accepted_values1, name="Histogram", legendgroup=param1, showlegend=True, marker_color=color, + opacity=0.75, xbins={"start": bounds[0][i], "end": bounds[1][i], "size": sizes[i]}, + legendgrouptitle={'text': param1}, legendrank=1 + ) + fig.add_trace(trace, row, col, secondary_y=False) + fig.update_xaxes(row=row, col=col, range=[bounds[0][i], bounds[1][i]]) + if include_pdf: + mean, std = stats.norm.fit(accepted_values1) + points = numpy.linspace(min(accepted_values1), max(accepted_values1), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + trace2 = plotly.graph_objs.Scatter( + x=points, y=pdf, mode='lines', line=dict(color=color), + name="PDF", legendgroup=param1, showlegend=True, legendrank=1 + ) + fig.add_trace(trace2, row, col, secondary_y=True) + if include_inferred_values: + fig.add_vline( + self.inferred_parameters[param1], row=row, col=col, exclude_empty_subplots=True, + layer='above', opacity=0.75, line={"color": "black", "dash": "dash"} + ) + if include_orig_values: + fig.add_vline( + parameters[param1], row=row, col=col, layer='above', opacity=0.75, line={"color": "black"} + ) + else: + color = combine_colors([ + common_rgb_values[(i)%len(common_rgb_values)][1:], + common_rgb_values[(j)%len(common_rgb_values)][1:] + ]) + scatter_kwa = { + 'x': accepted_values2, 'y': accepted_values1, 'mode': 'markers', 'marker_color': color, + 'name': f"{param2} X {param1}", 'legendgroup': "intersections", 'showlegend': True + } + if i == 0 and j == 1: + scatter_kwa['legendgrouptitle'] = {'text': "Parameter Intersections"} + trace = plotly.graph_objs.Scatter(**scatter_kwa) + fig.append_trace(trace, row, col) + fig.update_xaxes(row=row, col=col, range=[bounds[0][j], bounds[1][j]]) + fig.update_yaxes(row=row, col=col, range=[bounds[0][i], bounds[1][i]]) + + fig.update_layout(height=1000, legend=dict( + groupclick="toggleitem", x=0, y=0, tracegroupgap=0, itemsizing="constant" + )) + if title is not None: + title = {'text': title, 'x': 0.5, 'xanchor': 'center'} + fig.update_layout(title=title) + if include_pdf: + def update_annotations(annotation): + if annotation.x >= 0.94: + annotation.update(x=0.96) + fig.for_each_annotation(update_annotations) + + return fig + + def __plotplotly_intersection(self, parameters, bounds, colors, include_pdf=True, include_orig_values=True, + include_inferred_values=False, title=None, xaxis_label=None, yaxis_label=None): + nbins = 50 + names = list(parameters.keys()) + sizes = (numpy.array(bounds[1]) - numpy.array(bounds[0])) / nbins + + if xaxis_label is None: + xaxis_label = names[0] + if yaxis_label is None: + yaxis_label = names[1] + + specs = [[{"secondary_y": True}, {}, {}], [{}, {}, {}], [{}, {}, {}]] + fig = subplots.make_subplots( + rows=3, cols=3, x_title=xaxis_label, y_title=yaxis_label, horizontal_spacing=0.01, + vertical_spacing=0.01, column_widths=[0.6, 0.1, 0.3], row_heights=[0.3, 0.1, 0.6], + shared_xaxes=True, shared_yaxes=True, specs=specs + ) + + bins = ['xbins', 'ybins'] + histo_row = histo_col = [1, 3] + rug_row, rug_col = [2, 3], [1, 2] + x_key, y_key = ['x', 'y'], ['y', 'x'] + line_func = [fig.add_vline, fig.add_hline] + rug_symbol = ['line-ns-open', 'line-ew-open'] + xaxis_func = [fig.update_xaxes, fig.update_yaxes] + yaxis_func = [fig.update_yaxes, fig.update_xaxes] + secondary_y = [True, False] + + for i, (param, orig_val) in enumerate(parameters.items()): + if i >= 2: + break + + # Create histogram traces + histo_trace = plotly.graph_objs.Histogram( + marker_color=colors[i], opacity=0.75, + name="Histogram", legendgroup=param, showlegend=True, legendgrouptitle={'text': param}, legendrank=1 + ) + histo_trace[x_key[i]] = self[param] + histo_trace[bins[i]] = {"start": bounds[0][i], "end": bounds[1][i], "size": sizes[i]} + fig.add_trace(histo_trace, histo_row[i], histo_col[i], secondary_y=False) + xaxis_func[i](row=histo_row[i], col=histo_col[i], range=[bounds[0][i], bounds[1][i]]) + if include_pdf: + mean, std = stats.norm.fit(self[param]) + points = numpy.linspace(min(self[param]), max(self[param]), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + histo_trace2 = plotly.graph_objs.Scatter( + mode='lines', line=dict(color=colors[i]), + name="PDF", legendgroup=param, showlegend=True, legendrank=1 + ) + fig.add_trace(histo_trace2, histo_row[i], histo_col[i], secondary_y=secondary_y[i]) + if i > 0: + fig.data[-1].update(xaxis='x10') + fig.data[-1].update(**{x_key[i]: points, y_key[i]: pdf}) + fig.update_layout(xaxis10={ + 'overlaying': 'x9', 'side': 'top', 'layer': 'above traces', 'anchor': 'free', 'position': 0.59 + }) + if include_inferred_values: + line_func[i]( + self.inferred_parameters[param], row=histo_row[i], col=histo_col[i], exclude_empty_subplots=True, + layer='above', opacity=0.75, line={"color": "black", "dash": "dash"} + ) + if include_orig_values: + line_func[i]( + orig_val, row=histo_row[i], col=histo_col[i], layer='above', opacity=0.75, line={"color": "black"} + ) + # Create rug traces + rug_trace = plotly.graph_objs.Scatter( + mode='markers', marker={'color': colors[i], 'symbol': rug_symbol[i]}, + name=param, legendgroup="rug", showlegend=True, legendrank=2, + legendgrouptitle={'text': "Rug"} + ) + rug_trace[x_key[i]] = self[param] + rug_trace[y_key[i]] = [param] * self.accepted_count + fig.append_trace(rug_trace, rug_row[i], rug_col[i]) + xaxis_func[i](row=rug_row[i], col=rug_col[i], range=[bounds[0][i], bounds[1][i]]) + yaxis_func[i](row=rug_row[i], col=rug_col[i], showticklabels=False) + + trace = plotly.graph_objs.Scatter( + x=self[names[0]], y=self[names[1]], mode='markers', marker_color=colors[2], + name=f"{names[0]} X {names[1]}", legendgroup="intersection", showlegend=True, + legendgrouptitle={'text': "Intersection"} + ) + fig.append_trace(trace, 3, 1) + fig.update_xaxes(row=3, col=1, range=[bounds[0][0], bounds[1][0]]) + fig.update_yaxes(row=3, col=1, range=[bounds[0][1], bounds[1][1]]) + + fig.update_layout(height=1000, legend=dict( + groupclick="toggleitem", x=0.75, y=1, tracegroupgap=0, itemsizing="constant" + )) + if title is not None: + title = {'text': title, 'x': 0.5, 'xanchor': 'center'} + fig.update_layout(title=title) + + return fig + + @classmethod + def build_from_inference_round(cls, data, names): + """ + Build an InferenceRound object using the provided inference round results. + + :param data: The results from an inference round. + :type data: dict + + :param names: List of the parameter names. + :type names: list + + :returns: An InferenceRound object using the provided inference results. + :rtype: InferenceResult + """ + _accepted_samples = numpy.vstack(data['accepted_samples']).swapaxes(0, 1) + + accepted_samples = {} + inferred_parameters = {} + for i, name in enumerate(names): + accepted_samples[name] = _accepted_samples[i] + inferred_parameters[name] = data['inferred_parameters'][i] + + return InferenceRound( + accepted_samples, data['distances'], data['accepted_count'], data['trial_count'], + inferred_parameters, "mean" + ) + + def calculate_inferred_parameters(self, key=None, method=None): + """ + Calculate the inferred parameters using the given method and cached using the given key. + + :param key: Key used to cache the inferred parameters. + If method is None, key is a reference to a supported function. + :type key: str + + :param method: A callable function or method used to calculate the inferred parameters. + Needs to accept a single numpy.ndarray argument. + :type method: Callable + + :returns: The calculated inferred parameters. + :rtype: dict + + :raises ValueError: method and key are None or method is not callable. + """ + if key is None and method is None: + raise ValueError("key or method must be set.") + + if key is None: + i = 0 + key = "custom" + while key in self.__inferred_parameters: + i += 1 + key = f"custom{i}" + + if key in self.__inferred_parameters: + self.inferred_method = key + return self.__inferred_parameters[key] + + if method is None: + key_methods = {"mean": numpy.mean, "median": numpy.median} + if not (isinstance(key, str) and key in key_methods): + raise ValueError(f"{key} is not a supported function key. Supported keys: {tuple(key_methods.keys())}") + method = key_methods[key] + + if not callable(method) or type(method).__name__ not in ("function", "method"): + raise ValueError("method must be a callable function or method.") + + inferred_parameters = {} + for param, accepted_values in self.items(): + inferred_parameters[param] = method(accepted_values) + self.inferred_method = key + self.__inferred_parameters[key] = inferred_parameters + return inferred_parameters + + def plot(self, parameters, bounds, use_matplotlib=False, save_fig=None, return_plotly_figure=False, **kwargs): + """ + Plot the results of the inference round. + + :param parameters: Dictionary of the parameters and original values. + :type parameters: dict + + :param bounds: List of bounds for of the parameter space. + :type bounds: list + + :param use_matplotlib: Whether or not to plot using MatPlotLib. + :type use_matplotlib: bool + + :param save_fig: \**kwargs: Keyword arguments passed to :py:class:`matplotlib.pyplot.savefig` + for saving round plots. Ignored if use_matplotlib is False. + :type save_fig: dict + + :param return_plotly_figure: Whether or not to return the figure. Ignored if use_matplotlib is set. + :type return_plotly_figure: bool + + :param include_pdf: Whether or not to include the probability distribution curve. + :type include_pdf: bool + + :param include_orig_values: Whether or not to include a line marking the original parameter values. + :type include_orig_values: bool + + :param include_inferred_values: Whether or not to include a line marking the inferred parameter values. + :type include_inferred_values: bool + + :param xaxis_label: The label for the x-axis + :type xaxis_label: str + + :param yaxis_label: The label for the y-axis + :type yaxis_label: str + + :param title: The title of the graph + :type title: str + + :returns: Plotly figure object if return_plotly_figure is set else None. + :rtype: plotly.Figure + """ + if use_matplotlib: + fig = self.__plot(parameters, bounds, **kwargs) + if save_fig is not None: + fig.savefig(**save_fig) + return None + + if not plotly_installed: + raise ImportError("Unable to plot results. To continue, install plotly or set 'use_matplotlib' to 'True'") + + fig = self.__plotplotly(parameters, bounds, **kwargs) + + if return_plotly_figure: + return fig + plotly.offline.iplot(fig) + return None + + def plot_intersection(self, parameters, bounds, colors=None, color_ndxs=None, + use_matplotlib=False, save_fig=None, return_plotly_figure=False, **kwargs): + """ + Plot the results of the inference round. + + :param parameters: Dictionary of two parameters and their original values. + :type parameters: dict + + :param bounds: List of bounds for the provided parameters. + :type bounds: list + + :param colors: List of three colors. + :type colors: list + + :param color_ndxs: List of two color indicies. Ignored if colors is set. + :type color_ndxs: list + + :param use_matplotlib: Whether or not to plot using MatPlotLib. + :type use_matplotlib: bool + + :param save_fig: \**kwargs: Keyword arguments passed to :py:class:`matplotlib.pyplot.savefig` + for saving intersection plots. Ignored if use_matplotlib is False. + :type save_fig: dict + + :param return_plotly_figure: Whether or not to return the figure. Ignored if use_matplotlib is set. + :type return_plotly_figure: bool + + :param include_pdf: Whether or not to include the probability distribution curve. + :type include_pdf: bool + + :param include_orig_values: Whether or not to include a line marking the original parameter values. + :type include_orig_values: bool + + :param include_inferred_values: Whether or not to include a line marking the inferred parameter values. + :type include_inferred_values: bool + + :param xaxis_label: The label for the x-axis + :type xaxis_label: str + + :param yaxis_label: The label for the y-axis + :type yaxis_label: str + + :param title: The title of the graph + :type title: str + + :returns: Plotly figure object if return_plotly_figure is set else None. + :rtype: plotly.Figure + """ + if colors is None: + if color_ndxs is None: + colors = [ + common_rgb_values[(0)%len(common_rgb_values)], common_rgb_values[(1)%len(common_rgb_values)] + ] + else: + colors = [ + common_rgb_values[(color_ndxs[0])%len(common_rgb_values)], + common_rgb_values[(color_ndxs[1])%len(common_rgb_values)] + ] + colors.append(combine_colors([colors[0][1:], colors[1][1:]])) + + if use_matplotlib: + fig = self.__plot_intersection(parameters, bounds, colors, **kwargs) + if save_fig is not None: + fig.savefig(**save_fig) + return None + + if not plotly_installed: + raise ImportError("Unable to plot results. To continue, install plotly or set 'use_matplotlib' to 'True'") + + fig = self.__plotplotly_intersection(parameters, bounds, colors, **kwargs) + + if return_plotly_figure: + return fig + plotly.offline.iplot(fig) + return None + + def to_csv(self, path): + """ + Generate the csv results for the round. + + :param path: The path to the csv file. + :type path: str + """ + headers = ["Sample ID", *list(self.data.keys()), "Distances"] + accepted_samples = numpy.array(list(self.data.values())).swapaxes(0, 1) + + with open(path, 'w', newline='', encoding="utf-8") as csv_fd: + csv_writer = csv.writer(csv_fd) + csv_writer.writerow(headers) + for i, accepted_sample in enumerate(accepted_samples): + line = accepted_sample.tolist() + line.insert(0, i + 1) + if isinstance(self.distances[i], list): + line.extend(self.distances[i]) + else: + line.append(self.distances[i]) + csv_writer.writerow(line) + + def to_dict(self): + """ + Return the results of the round as a dictionary. + + :returns: The results of the round. + :rtype: dict + """ + accepted_samples = numpy.array(list(self.data.values())).swapaxes(0, 1) + return { + 'accepted_samples': accepted_samples, + 'distances': self.distances, + 'accepted_count': self.accepted_count, + 'trial_count': self.trial_count, + 'inferred_parameters': numpy.array(self.inferred_parameters.values()) + } + +class InferenceResults(UserList): + """ + List of InferenceRound objects created by a StochSS Inference Simulation, extends the UserList object. + + :param data: A list of inference round objects + :type data: list + + :param parameters: Dictionary of the parameters and original values. + :type parameters: dict + + :param bounds: List of bounds for of the parameter space. + :type bounds: list + """ + def __init__(self, data, parameters, bounds): + super().__init__(data) + self.parameters = parameters + self.bounds = bounds + + def __getattribute__(self, key): + if key in ('distances', 'accepted_count', 'trial_count', 'inferred_parameters'): + if len(self.data) > 1: + msg = f"Results is of type list. Use results[i]['{key}'] instead of results['{key}']" + log.warning(msg) + return getattr(InferenceResults.__getattribute__(self, key='data')[-1], key) + return UserList.__getattribute__(self, key) + + def __getitem__(self, key): + if key == 'data': + return UserList.__getitem__(self, key) + if isinstance(key, str): + if len(self.data) > 1: + msg = f"Results is of type list. Use results[i]['{key}'] instead of results['{key}']" + log.warning(msg) + return self.data[0][key] + return UserList.__getitem__(self,key) + + def __add__(self, other): + c_type = type(other).__name__ + if c_type != "InferenceResults": + raise ValueError(f'{c_type} cannot be added to InferenceResults.') + + if self.parameters != other.parameters: + raise ValueError("InferenceResults object contain difference parameters.") + + if not numpy.all(numpy.array(self.bounds) == numpy.array(other.bounds)): + raise ValueError("InferenceResults object contain difference priors.") + + return InferenceResults( + data=(self.data + other.data), parameters=self.parameters, bounds=self.bounds + ) + + def __radd__(self, other): + if other == 0: + return self + return self.__add__(other) + + def __plot(self, include_orig_values=True, include_inferred_values=False, + title=None, xaxis_label="Parameter Values", yaxis_label=None): + if yaxis_label is None: + yaxis_label = {"histo": "Accepted Samples", "pdf": "Probability"} + elif isinstance(yaxis_label, str): + yaxis_label = {"histo": yaxis_label, "pdf": yaxis_label} + + cols = 2 + rows = int(numpy.ceil(len(self.parameters)/cols)) + names = list(self.parameters.keys()) + histo_fig, histo_axes = plt.subplots(nrows=rows, ncols=cols, figsize=[14, 7 * rows]) + _ = histo_fig.text(0.5, 0.09, xaxis_label, size=18, ha='center', va='center') + _ = histo_fig.text(0.08, 0.5, yaxis_label['histo'], size=18, ha='center', va='center', rotation='vertical') + + pdf_fig, pdf_axes = plt.subplots(nrows=rows, ncols=cols, figsize=[14, 7 * rows]) + _ = pdf_fig.text(0.5, 0.09, xaxis_label, size=18, ha='center', va='center') + _ = pdf_fig.text(0.08, 0.5, yaxis_label['pdf'], size=18, ha='center', va='center', rotation='vertical') + + if len(self.parameters) < rows * cols: + histo_axes[-1, -1].axis('off') + pdf_axes[-1, -1].axis('off') + + nbins = 50 + for i, inf_round in enumerate(self.data): + base_opacity = 0.5 if len(self.data) <= 1 else (i / (len(self.data) - 1) * 0.5) + + for j, (param, accepted_values) in enumerate(inf_round.data.items()): + row = int(numpy.ceil((j + 1) / cols)) - 1 + col = (j % cols) + + name = f"round {i + 1}" + color = common_rgb_values[i % len(common_rgb_values)] + opacity = base_opacity + 0.25 + # Create histogram trace + histo_axes[row, col].hist( + accepted_values, label=name, color=color, alpha=opacity, + bins=nbins, range=(self.bounds[0][j], self.bounds[1][j]) + ) + # Create pdf trace + mean, std = stats.norm.fit(accepted_values) + points = numpy.linspace(min(accepted_values), max(accepted_values), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + pdf_axes[row, col].plot(points, pdf, label=name, color=color) + + if i == len(self.data) - 1: + histo_axes[row, col].set_title(names[j], size=16) + pdf_axes[row, col].set_title(names[j], size=16) + if include_orig_values: + histo_axes[row, col].axvline(self.parameters[param], alpha=0.75, color='black') + pdf_axes[row, col].axvline(self.parameters[param], alpha=0.75, color='black') + if include_inferred_values: + histo_axes[row, col].axvline( + inf_round.inferred_parameters[param], alpha=0.75, color='black', ls='dashed' + ) + pdf_axes[row, col].axvline( + inf_round.inferred_parameters[param], alpha=0.75, color='black', ls='dashed' + ) + + histo_handles, histo_labels = histo_axes[0, 0].get_legend_handles_labels() + histo_fig.legend( + histo_handles, histo_labels, loc=(0.905, 0.83), fontsize=12.5, frameon=False, labelspacing=1.2 + ) + pdf_handles, pdf_labels = pdf_axes[0, 0].get_legend_handles_labels() + pdf_fig.legend( + pdf_handles, pdf_labels, loc=(0.905, 0.83), fontsize=12.5, frameon=False, labelspacing=1.2 + ) + if title is not None: + _ = histo_fig.text(0.5, 0.92, title, size=20, ha='center', va='center') + _ = pdf_fig.text(0.5, 0.92, title, size=20, ha='center', va='center') + + return histo_fig, pdf_fig + + def __plotplotly(self, include_orig_values=True, include_inferred_values=False, + title=None, xaxis_label="Parameter Values", yaxis_label=None): + if yaxis_label is None: + yaxis_label = {"histo": "Accepted Samples", "pdf": "Probability"} + elif isinstance(yaxis_label, str): + yaxis_label = {"histo": yaxis_label, "pdf": yaxis_label} + + cols = 2 + rows = int(numpy.ceil(len(self.parameters)/cols)) + names = list(self.parameters.keys()) + histo_fig = subplots.make_subplots( + rows=rows, cols=cols, subplot_titles=names, vertical_spacing=0.075, + x_title=xaxis_label, y_title=yaxis_label['histo'] + ) + pdf_fig = subplots.make_subplots( + rows=rows, cols=cols, subplot_titles=names, vertical_spacing=0.075, + x_title=xaxis_label, y_title=yaxis_label['pdf'] + ) + + nbins = 50 + sizes = (numpy.array(self.bounds[1]) - numpy.array(self.bounds[0])) / nbins + for i, inf_round in enumerate(self.data): + base_opacity = 0.5 if len(self.data) <= 1 else (i / (len(self.data) - 1) * 0.5) + + for j, (param, accepted_values) in enumerate(inf_round.data.items()): + row = int(numpy.ceil((j + 1) / cols)) + col = (j % cols) + 1 + + name = f"round {i + 1}" + color = common_rgb_values[i % len(common_rgb_values)] + opacity = base_opacity + 0.25 + # Create histogram trace + trace = plotly.graph_objs.Histogram( + x=accepted_values, name=name, legendgroup=name, showlegend=j==0, marker_color=color, + opacity=opacity, xbins={"start": self.bounds[0][j], "end": self.bounds[1][j], "size": sizes[j]} + ) + histo_fig.append_trace(trace, row, col) + # Create PDF trace + mean, std = stats.norm.fit(accepted_values) + points = numpy.linspace(min(accepted_values), max(accepted_values), 500) + pdf = stats.norm.pdf(points, loc=mean, scale=std) + trace2 = plotly.graph_objs.Scatter( + x=points, y=pdf, name=name, legendgroup=name, showlegend=j==0, mode='lines', line=dict(color=color) + ) + pdf_fig.append_trace(trace2, row, col) + pdf_fig.update_xaxes(row=row, col=col, range=[self.bounds[0][j], self.bounds[1][j]]) + + if i == len(self.data) - 1: + if include_orig_values: + histo_fig.add_vline( + self.parameters[param], row=row, col=col, layer='above', opacity=0.75, + line={"color": "black"} + ) + pdf_fig.add_vline( + self.parameters[param], row=row, col=col, layer='above', opacity=0.75, + line={"color": "black"} + ) + if include_inferred_values: + histo_fig.add_vline( + inf_round.inferred_parameters[param], row=row, col=col, exclude_empty_subplots=True, + layer='above', opacity=0.75, line={"color": "black", "dash": "dash"} + ) + pdf_fig.add_vline( + inf_round.inferred_parameters[param], row=row, col=col, exclude_empty_subplots=True, + layer='above', opacity=0.75, line={"color": "black", "dash": "dash"} + ) + + height = 500 * rows + histo_fig.update_layout(barmode='overlay', height=height) + pdf_fig.update_layout(height=height) + if title is not None: + title = {'text': title, 'x': 0.5, 'xanchor': 'center'} + histo_fig.update_layout(title=title) + pdf_fig.update_layout(title=title) + + return histo_fig, pdf_fig + + @classmethod + def build_from_inference_results(cls, data, parameters, bounds): + """ + Build an InferenceResult object using the provided inference. + + :param data: The results from an inference result. + :type data: list or dict + + :param parameters: Dictionary of the parameters and original values. + :type parameters: dict + + :param bounds: List of bounds for of the parameter space. + :type bounds: list + + :returns: An InferenceResult object using the provided inference results. + :rtype: InferenceResult + """ + if isinstance(data, dict): + data = [data] + + inf_rounds = [] + names = list(parameters.keys()) + for inf_r in data: + inf_round = InferenceRound.build_from_inference_round(inf_r, names) + inf_rounds.append(inf_round) + return InferenceResults(inf_rounds, parameters, bounds) + + def calculate_inferred_parameters(self, key=None, method=None, ndx=None): + """ + Calculate the inferred parameters using the given method and cached using the given key. + + :param key: Key used to cache the inferred parameters. + If method is None, key is a reference to a supported function. + :type key: str + + :param method: A callable function or method used to calculate the inferred parameters. + Needs to accept a single numpy.ndarray argument. + :type method: Callable + + :param ndx: Index of the inference round to plot. + :type ndx: int + + :returns: The calculated inferred parameters for the indicated round if ndx is set else the final round. + :rtype: dict + + :raises ValueError: method and key are None or method is not callable. + """ + if ndx is not None: + return self[ndx].calculate_inferred_parameters(key=key, method=method) + + for inf_round in self: + inferred_parameters = inf_round.calculate_inferred_parameters(key=key, method=method) + return inferred_parameters + + def plot(self, histo_only=True, pdf_only=False, use_matplotlib=False, + save_histo=None, save_pdf=None, return_plotly_figure=False, **kwargs): + """ + Plot the results. + + :param : Indicates that only the histogram plot should be returned. + :type : bool + + :param : Indicates that only the PDF plot should be returned. + :type : bool + + :param use_matplotlib: Whether or not to plot using MatPlotLib. + :type use_matplotlib: bool + + :param save_histo: \**kwargs: Keyword arguments passed to :py:class:`matplotlib.pyplot.savefig` + for saving histogram plots. Ignored if use_matplotlib is False. + :type save_histo: dict + + :param save_pdf: \**kwargs: Keyword arguments passed to :py:class:`matplotlib.pyplot.savefig` + for saving pdf plots. Ignored if use_matplotlib is False. + :type save_pdf: dict + + :param return_plotly_figure: Whether or not to return the figure. Ignored if use_matplotlib is set. + :type return_plotly_figure: bool + + :param include_orig_values: Whether or not to include a line marking the original parameter values. + :type include_orig_values: bool + + :param include_inferred_values: Whether or not to include a line marking the + inferred parameter values of the final round. + :type include_inferred_values: bool + + :param xaxis_label: The label for the x-axis + :type xaxis_label: str + + :param yaxis_label: The label for the y-axis. Dictionaries should be in + the following format {'histo':<