diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 22ff31b21..c2018c93d 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -10,7 +10,14 @@ import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; import type * as Checkers from "./analyses/checker_types"; import { defaultSchemaERDConfig, type SchemaERDConfig } from "./analyses/schema_erd_config"; -import type * as Simulators from "./analyses/simulator_types"; +import type { + DecapodesAnalysisContent, + KuramotoProblemData, + LinearODEProblemData, + LotkaVolterraProblemData, + MassActionEquations, + MassActionProblemData, +} from "./analyses/simulator_types"; import type * as SQLDownloadConfig from "./analyses/sql"; import { SQLBackend, type SQLRenderer } from "./analyses/sql_types"; @@ -23,7 +30,7 @@ type AnalysisOptions = { export const decapodes = ( options: AnalysisOptions, -): DiagramAnalysisMeta => ({ +): DiagramAnalysisMeta => ({ ...options, component: (props) => , initialContent: () => ({ @@ -60,20 +67,18 @@ const TabularView = lazy(() => import("./analyses/tabular_view")); export function kuramoto( options: Partial & { - simulate: Simulators.KuramotoSimulator; parameterLabels?: { coupling?: string; damping?: string; forcing?: string; }; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "kuramoto", name = "Kuramoto dynamics", description = "Simulate the system using the Kuramoto dynamical model", help = "kuramoto", - simulate, } = options; return { id, @@ -82,7 +87,7 @@ export function kuramoto( help, component: (props) => ( import("./analyses/kuramoto")); export function linearODE( - options: Partial & { - simulate: Simulators.LinearODESimulator; - }, -): ModelAnalysisMeta { + options: Partial = {}, +): ModelAnalysisMeta { const { id = "linear-ode", name = "Linear ODE dynamics", description = "Simulate the system using a constant-coefficient linear first-order ODE", help = "linear-ode", - simulate, } = options; return { id, name, description, help, - component: (props) => , + component: (props) => , initialContent: () => ({ coefficients: {}, initialValues: {}, @@ -133,23 +135,20 @@ export function linearODE( const LinearODE = lazy(() => import("./analyses/linear_ode")); export function lotkaVolterra( - options: Partial & { - simulate: Simulators.LotkaVolterraSimulator; - }, -): ModelAnalysisMeta { + options: Partial = {}, +): ModelAnalysisMeta { const { id = "lotka-volterra", name = "Lotka-Volterra dynamics", description = "Simulate the system using a Lotka-Volterra ODE", help = "lotka-volterra", - simulate, } = options; return { id, name, description, help, - component: (props) => , + component: (props) => , initialContent: () => ({ interactionCoefficients: {}, growthRates: {}, @@ -164,11 +163,10 @@ const LotkaVolterra = lazy(() => import("./analyses/lotka_volterra")); export function massAction( options: Partial & { ratesHaveGranularity: boolean; - simulate: Simulators.MassActionSimulator; stateType?: ObType; transitionType?: MorType; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "mass-action", name = "Mass-action dynamics", @@ -181,7 +179,9 @@ export function massAction( name, description, help, - component: (props) => , + component: (props) => ( + + ), initialContent: () => ({ massConservationType: { type: "Balanced" }, rates: {}, @@ -200,7 +200,7 @@ const MassAction = lazy(() => import("./analyses/mass_action")); export function massActionEquations( options: Partial & { ratesHaveGranularity: boolean; - getEquations: Simulators.MassActionEquations; + getEquations: MassActionEquations; }, ): ModelAnalysisMeta { const { @@ -227,10 +227,9 @@ const MassActionEquationsDisplay = lazy(() => import("./analyses/mass_action_equ export function stochasticMassAction( options: Partial & { - simulate: Simulators.StochasticMassActionSimulator; stateType?: ObType; transitionType?: MorType; - }, + } = {}, ): ModelAnalysisMeta { const { id = "stochastic-mass-action", @@ -244,7 +243,9 @@ export function stochasticMassAction( name, description, help, - component: (props) => , + component: (props) => ( + + ), initialContent: () => ({ rates: {}, initialValues: {}, diff --git a/packages/frontend/src/stdlib/analyses/kuramoto.tsx b/packages/frontend/src/stdlib/analyses/kuramoto.tsx index 8c0cdb9ae..04e4c318b 100644 --- a/packages/frontend/src/stdlib/analyses/kuramoto.tsx +++ b/packages/frontend/src/stdlib/analyses/kuramoto.tsx @@ -7,19 +7,18 @@ import { FixedTableEditor, Foldable, } from "catcolab-ui-components"; -import type { DblModel, KuramotoProblemData, QualifiedName } from "catlog-wasm"; +import type { KuramotoProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; import { createModelODEPlot } from "./model_ode_plot"; -import type { KuramotoSimulator } from "./simulator_types"; import "./simulation.css"; /** Analyse a model using first- or second-order Kuramoto dynamics. */ export default function Kuramoto( props: ModelAnalysisProps & { - simulate: KuramotoSimulator; + analysisId: string; title?: string; couplingLabel?: string; dampingLabel?: string; @@ -130,9 +129,10 @@ export default function Kuramoto( }), ]; - const plotResult = createModelODEPlot( - () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + const { data: plotResult, loading } = createModelODEPlot( + props.liveModel, + props.analysisId, + () => props.content, ); return ( @@ -153,6 +153,7 @@ export default function Kuramoto( & { - simulate: LinearODESimulator; + analysisId: string; title?: string; }, ) { @@ -70,9 +69,10 @@ export default function LinearODE( }), ]; - const plotResult = createModelODEPlot( - () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + const { data: plotResult, loading } = createModelODEPlot( + props.liveModel, + props.analysisId, + () => props.content, ); return ( @@ -91,7 +91,7 @@ export default function LinearODE( - + ); } diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 9d6006800..ab982c37d 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx @@ -5,19 +5,18 @@ import { FixedTableEditor, Foldable, } from "catcolab-ui-components"; -import type { DblModel, LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; +import type { LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; import { createModelODEPlot } from "./model_ode_plot"; -import type { LotkaVolterraSimulator } from "./simulator_types"; import "./simulation.css"; /** Analyze a model using Lotka-Volterra dynamics. */ export default function LotkaVolterra( props: ModelAnalysisProps & { - simulate: LotkaVolterraSimulator; + analysisId: string; title?: string; }, ) { @@ -78,9 +77,10 @@ export default function LotkaVolterra( }), ]; - const plotResult = createModelODEPlot( - () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + const { data: plotResult, loading } = createModelODEPlot( + props.liveModel, + props.analysisId, + () => props.content, ); return ( @@ -99,7 +99,7 @@ export default function LotkaVolterra( - + ); } diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 6c045a5c2..ac1e48546 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -22,15 +22,14 @@ import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; import { MassActionConfigForm } from "./mass_action_config_form"; import { createModelODEPlotWithEquations } from "./model_ode_plot"; -import type { MassActionSimulator } from "./simulator_types"; import "./simulation.css"; /** Analyze a model using mass-action dynamics. */ export default function MassAction( props: ModelAnalysisProps & { + analysisId: string; ratesHaveGranularity: boolean; - simulate: MassActionSimulator; stateType?: ObType; title?: string; transitionType?: MorType; @@ -291,12 +290,13 @@ export default function MassAction( ]; const result = createModelODEPlotWithEquations( - () => props.liveModel.validatedModel(), - (model) => props.simulate(model, props.content), + props.liveModel, + props.analysisId, + () => props.content, ); - const plotResult = () => result()?.plotData; - const latexEquations = () => result()?.latexEquations ?? []; + const plotResult = () => result.data()?.plotData; + const latexEquations = () => result.data()?.latexEquations ?? []; return (
@@ -329,7 +329,10 @@ export default function MassAction( /> - +
+ +
+
); diff --git a/packages/frontend/src/stdlib/analyses/model_ode_plot.ts b/packages/frontend/src/stdlib/analyses/model_ode_plot.ts index 703a30d08..c5256acd1 100644 --- a/packages/frontend/src/stdlib/analyses/model_ode_plot.ts +++ b/packages/frontend/src/stdlib/analyses/model_ode_plot.ts @@ -1,15 +1,16 @@ -import { type Accessor, createMemo } from "solid-js"; +import { type Accessor, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; +import { unwrap } from "solid-js/store"; -import type { - DblModel, - JsResult, - LatexEquation, - ODELatex, - ODEResult, - ODEResultWithEquations, -} from "catlog-wasm"; -import type { ValidatedModel } from "../../model"; +import type { DblModel, JsResult, LatexEquation, ModelNotebook, ODELatex } from "catlog-wasm"; +import type { LiveModelDoc, ValidatedModel } from "../../model"; +import { debounce } from "../../util/debounce"; import type { ODEPlotData, StateVarData } from "../../visualization"; +import type { + SerializedODESolution, + SimulationRequest, + SimulationResponse, + WorkerReadyMessage, +} from "./simulation_worker_types"; /** Result of simulating an ODE with equations, containing both plot data and LaTeX equations. */ export type ODEPlotDataWithEquations = { @@ -17,19 +18,90 @@ export type ODEPlotDataWithEquations = { latexEquations: LatexEquation[]; }; -/** Convert an ODE solution result to plot data for a model. */ -function solutionToPlotData( - model: DblModel, - solutionResult: ODEResult, -): JsResult { - if (solutionResult?.tag !== "Ok") { - return solutionResult; +/** Return type of the worker-based ODE plot functions. */ +export type ODEPlotResult = { + data: Accessor | undefined>; + loading: Accessor; +}; + +/** Return type of the worker-based ODE plot functions with equations. */ +export type ODEPlotResultWithEquations = { + data: Accessor; + loading: Accessor; +}; + +let workerInstance: Worker | null = null; +let workerReady = false; +let pendingQueue: SimulationRequest[] = []; +const pendingCallbacks = new Map void>(); +let requestIdCounter = 0; + +function getWorker(): Worker { + if (workerInstance) { + return workerInstance; } - const solution = solutionResult.content; + workerReady = false; + pendingQueue = []; + workerInstance = new Worker(new URL("./simulation_worker.ts", import.meta.url), { + type: "module", + }); + + workerInstance.onmessage = (event: MessageEvent) => { + const data = event.data; + + if ("type" in data && data.type === "ready") { + for (const req of pendingQueue) { + // TODO: how to handle failure? This fails silently + workerInstance?.postMessage(req); + } + pendingQueue = []; + + workerReady = true; + return; + } + + const response = data as SimulationResponse; + const callback = pendingCallbacks.get(response.requestId); + if (callback) { + pendingCallbacks.delete(response.requestId); + callback(response); + } + }; + + workerInstance.onerror = (event) => { + const error = event.message || "Worker error"; + for (const [id, callback] of pendingCallbacks) { + callback({ requestId: id, tag: "Err", error }); + } + pendingCallbacks.clear(); + workerInstance = null; + workerReady = false; + pendingQueue = []; + }; + + return workerInstance; +} + +function postToWorker(request: SimulationRequest): Promise { + return new Promise((resolve) => { + pendingCallbacks.set(request.requestId, resolve); + const worker = getWorker(); + if (workerReady) { + worker.postMessage(request); + } else { + pendingQueue.push(request); + } + }); +} + +function serializedSolutionToPlotData( + model: DblModel, + solution: SerializedODESolution, +): ODEPlotData { const states: StateVarData[] = []; for (const id of model.obGenerators()) { - const data = solution.states.get(id); + const data = solution.states[id]; if (data !== undefined) { states.push({ name: model.obGeneratorLabel(id)?.join(".") ?? "", @@ -37,58 +109,146 @@ function solutionToPlotData( }); } } - return { tag: "Ok", content: { time: solution.time, states } }; + return { time: solution.time, states }; } -/** Reactively simulate and plot an ODE derived from a model. +/** Internal helper that both `createModelODEPlot` and `createModelODEPlotWithEquations` delegate to. */ +function createWorkerSimulation( + liveModel: LiveModelDoc, + analysisId: string, + params: Accessor, + mapOk: (response: SimulationResponse & { tag: "Ok" }, model: DblModel) => T, + mapErr: (error: string) => T, +): { data: Accessor; loading: Accessor } { + const DEBOUNCE_MS = 150; -Assumes that the variables in the ODE come from objects in the model. - */ -export function createModelODEPlot( - validatedModel: Accessor, - simulate: (model: DblModel) => ODEResult, -) { - return createMemo | undefined>( - () => { - const validated = validatedModel(); - if (validated?.tag !== "Valid") { + const [data, setData] = createSignal(undefined); + const [loading, setLoading] = createSignal(false); + + let latestRequestId = 0; + + const sendRequest = debounce((currentParams: unknown, currentNotebook: ModelNotebook) => { + const requestId = ++requestIdCounter; + latestRequestId = requestId; + + const request: SimulationRequest = { + requestId, + theoryId: liveModel.liveDoc.doc.theory, + analysisId, + notebook: unwrap(currentNotebook), + refId: liveModel.liveDoc.docHandle.documentId, + params: unwrap(currentParams), + }; + + postToWorker(request) + .then((response) => { + if (requestId !== latestRequestId) { + return; + } + + setLoading(false); + + if (response.tag === "Ok") { + const model = liveModel.validatedModel(); + if (model?.tag === "Valid") { + setData(() => mapOk(response, model.model)); + } else { + setData(undefined); + } + } else { + setData(() => mapErr(response.error)); + } + }) + .catch((err) => { + if (requestId !== latestRequestId) { + return; + } + setLoading(false); + setData(() => mapErr(String(err))); + }); + }, DEBOUNCE_MS); + + const inputSignal = createMemo(() => ({ + validatedModel: liveModel.validatedModel(), + params: params(), + notebook: liveModel.liveDoc.doc.notebook, + })); + + createEffect( + on(inputSignal, (input) => { + sendRequest.cancel(); + + const { validatedModel, params: currentParams, notebook: currentNotebook } = input; + + if (!validatedModel || validatedModel.tag !== "Valid" || !currentNotebook) { + setData(undefined); + setLoading(false); return; } - const model = validated.model; - const solutionResult = simulate(model); - return solutionToPlotData(model, solutionResult); + + setLoading(true); + sendRequest(currentParams, currentNotebook); + }), + ); + + onCleanup(() => { + sendRequest.cancel(); + }); + + return { data, loading }; +} + +/** Reactively simulate and plot an ODE via web worker. + +Sends the simulation to a background worker thread and returns the result +reactively. The result is debounced to avoid firing many simulations on +rapid parameter changes. + */ +export function createModelODEPlot( + liveModel: LiveModelDoc, + analysisId: string, + params: Accessor, +): ODEPlotResult { + return createWorkerSimulation>( + liveModel, + analysisId, + params, + (response, model) => { + const plotData = serializedSolutionToPlotData(model, response.solution); + return { tag: "Ok", content: plotData }; }, - undefined, - { equals: false }, + (error) => ({ tag: "Err", content: error }), ); } -/** Reactively simulate an ODE with equations derived from a model. +/** Reactively simulate an ODE with equations via web worker. Returns both plot data and LaTeX equations. */ export function createModelODEPlotWithEquations( - validatedModel: Accessor, - simulate: (model: DblModel) => ODEResultWithEquations, -) { - return createMemo( - () => { - const validated = validatedModel(); - if (validated?.tag !== "Valid") { - return; - } - const model = validated.model; - const result = simulate(model); - const plotData = solutionToPlotData(model, result.solution); - return { plotData, latexEquations: result.latexEquations }; + liveModel: LiveModelDoc, + analysisId: string, + params: Accessor, +): ODEPlotResultWithEquations { + return createWorkerSimulation( + liveModel, + analysisId, + params, + (response, model) => { + const plotData = serializedSolutionToPlotData(model, response.solution); + return { + plotData: { tag: "Ok", content: plotData }, + latexEquations: response.hasEquations ? response.latexEquations : [], + }; }, - undefined, - { equals: false }, + (error) => ({ + plotData: { tag: "Err", content: error }, + latexEquations: [], + }), ); } /** Reactively compute the symbolic ODE equations for a model in LaTeX. - */ export function createModelODELatex( validatedModel: Accessor, diff --git a/packages/frontend/src/stdlib/analyses/simulation.css b/packages/frontend/src/stdlib/analyses/simulation.css index 9219549fb..872308452 100644 --- a/packages/frontend/src/stdlib/analyses/simulation.css +++ b/packages/frontend/src/stdlib/analyses/simulation.css @@ -18,3 +18,10 @@ width: 100%; height: 400px; } + +.simulation .plot.loading-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #666); +} diff --git a/packages/frontend/src/stdlib/analyses/simulation_config.ts b/packages/frontend/src/stdlib/analyses/simulation_config.ts new file mode 100644 index 000000000..2e639c4cf --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/simulation_config.ts @@ -0,0 +1,52 @@ +import type { DblModel, ODEResult, ODEResultWithEquations } from "catlog-wasm"; +import { + ThCategoryLinks, + ThCategorySignedLinks, + ThPowerSystem, + ThSignedCategory, + ThSymMonoidalCategory, +} from "catlog-wasm"; + +/// This allows us to rebuild a subset of the theory library in the web worker without needing to +// serialize the stdTheories library + +export type SimulationResult = + | { hasEquations: false; result: ODEResult } + | { hasEquations: true; result: ODEResultWithEquations }; + +// biome-ignore lint/suspicious/noExplicitAny: theory class types and params are dynamically typed +export type AnalysisDispatch = (th: any, model: DblModel, params: any) => SimulationResult; + +/** Theory ID -> WASM class constructor. */ +export const theoryClassCtors: Record object> = { + "petri-net": () => new ThSymMonoidalCategory(), + "primitive-stock-flow": () => new ThCategoryLinks(), + "primitive-signed-stock-flow": () => new ThCategorySignedLinks(), + "reg-net": () => new ThSignedCategory(), + "causal-loop": () => new ThSignedCategory(), + "power-system": () => new ThPowerSystem(), +}; + +/** Analysis ID -> dispatch function. Independent of theory. */ +export const analysisDispatches: Record = { + "mass-action": (th, model, params) => ({ + hasEquations: true, + result: th.massAction(model, params), + }), + "stochastic-mass-action": (th, model, params) => ({ + hasEquations: false, + result: th.stochasticMassAction(model, params), + }), + "linear-ode": (th, model, params) => ({ + hasEquations: false, + result: th.linearODE(model, params), + }), + "lotka-volterra": (th, model, params) => ({ + hasEquations: false, + result: th.lotkaVolterra(model, params), + }), + kuramoto: (th, model, params) => ({ + hasEquations: false, + result: th.kuramoto(model, params), + }), +}; diff --git a/packages/frontend/src/stdlib/analyses/simulation_worker.ts b/packages/frontend/src/stdlib/analyses/simulation_worker.ts new file mode 100644 index 000000000..f2e004fa3 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/simulation_worker.ts @@ -0,0 +1,133 @@ +import { + type DblModel, + DblModelMap, + type DblTheory, + elaborateModel, + type ModelNotebook, +} from "catlog-wasm"; +import { analysisDispatches, theoryClassCtors } from "./simulation_config"; +import type { + SerializedODESolution, + SimulationRequest, + SimulationResponse, + WorkerReadyMessage, +} from "./simulation_worker_types"; + +const theoryClassCache = new Map(); + +function getTheoryClass(theoryId: string): object { + let instance = theoryClassCache.get(theoryId); + if (!instance) { + const ctor = theoryClassCtors[theoryId]; + if (!ctor) { + throw new Error(`Unknown theory ID: ${theoryId}`); + } + instance = ctor(); + theoryClassCache.set(theoryId, instance); + } + return instance; +} + +function getTheory(theoryId: string): DblTheory { + const instance = getTheoryClass(theoryId); + return (instance as { theory(): DblTheory }).theory(); +} + +let cachedElaboration: { + theoryId: string; + refId: string; + notebookJson: string; + model: DblModel; +} | null = null; + +function elaborateWithCache(notebook: ModelNotebook, theoryId: string, refId: string): DblModel { + const notebookJson = JSON.stringify(notebook); + + if ( + cachedElaboration && + cachedElaboration.theoryId === theoryId && + cachedElaboration.refId === refId && + cachedElaboration.notebookJson === notebookJson + ) { + return cachedElaboration.model; + } + + const theory = getTheory(theoryId); + const instantiated = new DblModelMap(); + const model = elaborateModel(notebook, instantiated, theory, refId); + + cachedElaboration = { theoryId, refId, notebookJson, model }; + return model; +} + +function serializeODESolution(solution: { + time: number[]; + states: Map; +}): SerializedODESolution { + const states: Record = {}; + for (const [key, value] of solution.states) { + states[key] = value; + } + return { time: solution.time, states }; +} + +function handleRequest(request: SimulationRequest): SimulationResponse { + const { requestId, theoryId, analysisId, notebook, refId, params } = request; + + try { + const classCtor = theoryClassCtors[theoryId]; + if (!classCtor) { + return { requestId, tag: "Err", error: `Unknown theory: ${theoryId}` }; + } + + const dispatch = analysisDispatches[analysisId]; + if (!dispatch) { + return { + requestId, + tag: "Err", + error: `Unknown analysis: ${analysisId}`, + }; + } + + const th = getTheoryClass(theoryId); + const model = elaborateWithCache(notebook, theoryId, refId); + const simResult = dispatch(th, model, params); + + if (simResult.hasEquations) { + const { solution, latexEquations } = simResult.result; + if (solution.tag === "Ok") { + return { + requestId, + tag: "Ok", + hasEquations: true, + solution: serializeODESolution(solution.content), + latexEquations, + }; + } + return { requestId, tag: "Err", error: solution.content }; + } + + const result = simResult.result; + if (result.tag === "Ok") { + return { + requestId, + tag: "Ok", + hasEquations: false, + solution: serializeODESolution(result.content), + }; + } + return { requestId, tag: "Err", error: result.content }; + } catch (e) { + return { requestId, tag: "Err", error: String(e) }; + } +} + +// Signal that the worker module (including WASM) has finished loading. +// Messages posted to a module worker before `self.onmessage` is set are +// silently dropped, so the main thread must wait for this before sending work. +self.postMessage({ type: "ready" } satisfies WorkerReadyMessage); + +self.onmessage = (event: MessageEvent) => { + const response = handleRequest(event.data); + self.postMessage(response); +}; diff --git a/packages/frontend/src/stdlib/analyses/simulation_worker_types.ts b/packages/frontend/src/stdlib/analyses/simulation_worker_types.ts new file mode 100644 index 000000000..bf89a8d3f --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/simulation_worker_types.ts @@ -0,0 +1,43 @@ +import type { LatexEquation, ModelNotebook } from "catlog-wasm"; + +/** Request sent from the main thread to the simulation worker. */ +export type SimulationRequest = { + requestId: number; + theoryId: string; + analysisId: string; + notebook: ModelNotebook; + refId: string; + params: unknown; +}; + +/** Serializable ODE solution data (uses plain object instead of Map). */ +export type SerializedODESolution = { + time: number[]; + states: Record; +}; + +/** Response sent from the simulation worker to the main thread. */ +export type SimulationResponse = { + requestId: number; +} & ( + | { + tag: "Ok"; + hasEquations: false; + solution: SerializedODESolution; + } + | { + tag: "Ok"; + hasEquations: true; + solution: SerializedODESolution; + latexEquations: LatexEquation[]; + } + | { tag: "Err"; error: string } +); + +/** Message sent by the worker after its module (including WASM) has loaded. + * + * Module workers with top-level WASM imports drop messages posted before the + * module finishes evaluating. The main thread must wait for this signal before + * sending any requests. + */ +export type WorkerReadyMessage = { type: "ready" }; diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index 6dc5640b3..8b6f66e5c 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -6,9 +6,6 @@ import type { MassActionEquationsData, MassActionProblemData, ODELatex, - ODEResult, - ODEResultWithEquations, - StochasticMassActionProblemData, } from "catlog-wasm"; export type { @@ -18,17 +15,6 @@ export type { MassActionProblemData, }; -export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; -export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResult; -export type LotkaVolterraSimulator = (model: DblModel, data: LotkaVolterraProblemData) => ODEResult; -export type MassActionSimulator = ( - model: DblModel, - data: MassActionProblemData, -) => ODEResultWithEquations; -export type StochasticMassActionSimulator = ( - model: DblModel, - data: StochasticMassActionProblemData, -) => ODEResult; export type MassActionEquations = (model: DblModel, data: MassActionEquationsData) => ODELatex; /** Configuration for a Decapodes analysis of a diagram. */ diff --git a/packages/frontend/src/stdlib/analyses/stochastic_mass_action.tsx b/packages/frontend/src/stdlib/analyses/stochastic_mass_action.tsx index f21fd56b0..8a9915d87 100644 --- a/packages/frontend/src/stdlib/analyses/stochastic_mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/stochastic_mass_action.tsx @@ -7,25 +7,18 @@ import { FixedTableEditor, Foldable, } from "catcolab-ui-components"; -import type { - DblModel, - MorType, - ObType, - QualifiedName, - StochasticMassActionProblemData, -} from "catlog-wasm"; +import type { MorType, ObType, QualifiedName, StochasticMassActionProblemData } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; import { createModelODEPlot } from "./model_ode_plot"; -import type { StochasticMassActionSimulator } from "./simulator_types"; import "./simulation.css"; /** Analyze a model using stochastic mass-action dynamics. */ export default function StochasticMassAction( props: ModelAnalysisProps & { - simulate: StochasticMassActionSimulator; + analysisId: string; stateType?: ObType; transitionType?: MorType; title?: string; @@ -98,9 +91,10 @@ export default function StochasticMassAction( }), ]; - const plotResult = createModelODEPlot( - () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + const { data: plotResult, loading } = createModelODEPlot( + props.liveModel, + props.analysisId, + () => props.content, ); return ( @@ -113,7 +107,7 @@ export default function StochasticMassAction( - + ); } diff --git a/packages/frontend/src/stdlib/theories/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index a31e94322..0122f2b24 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -65,12 +65,8 @@ export default function createCausalLoopTheory(theoryMeta: TheoryMeta): Theory { return thSignedCategory.positiveLoops(model, options); }, }), - analyses.linearODE({ - simulate: (model, data) => thSignedCategory.linearODE(model, data), - }), - analyses.lotkaVolterra({ - simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), - }), + analyses.linearODE(), + analyses.lotkaVolterra(), ], }); } diff --git a/packages/frontend/src/stdlib/theories/petri-net.ts b/packages/frontend/src/stdlib/theories/petri-net.ts index e20e10ae8..74d4701a1 100644 --- a/packages/frontend/src/stdlib/theories/petri-net.ts +++ b/packages/frontend/src/stdlib/theories/petri-net.ts @@ -43,9 +43,6 @@ export default function createPetriNetTheory(theoryMeta: TheoryMeta): Theory { }), analyses.massAction({ ratesHaveGranularity: true, - simulate(model, data) { - return thSymMonoidalCategory.massAction(model, data); - }, }), analyses.massActionEquations({ ratesHaveGranularity: true, @@ -58,9 +55,6 @@ export default function createPetriNetTheory(theoryMeta: TheoryMeta): Theory { name: "Stochastic mass-action dynamics", description: "Simulate a stochastic system using the law of mass action", help: "stochastic-mass-action", - simulate(model, data) { - return thSymMonoidalCategory.stochasticMassAction(model, data); - }, }), analyses.reachability({ check(model, data) { diff --git a/packages/frontend/src/stdlib/theories/power-system.ts b/packages/frontend/src/stdlib/theories/power-system.ts index f54ca069d..5cd272f31 100644 --- a/packages/frontend/src/stdlib/theories/power-system.ts +++ b/packages/frontend/src/stdlib/theories/power-system.ts @@ -54,7 +54,6 @@ export default function createPowerSystemsTheory(theoryMeta: TheoryMeta): Theory help: "visualization", }), analyses.kuramoto({ - simulate: (model, data) => thPowerSystem.kuramoto(model, data), parameterLabels: { coupling: "Capacity", forcing: "Input power", diff --git a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts index 654e2dc85..c0ca33949 100644 --- a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts @@ -60,9 +60,6 @@ export default function createPrimitiveSignedStockFlowTheory(theoryMeta: TheoryM }), analyses.massAction({ ratesHaveGranularity: false, - simulate(model, data) { - return thCategorySignedLinks.massAction(model, data); - }, transitionType: { tag: "Hom", content: { tag: "Basic", content: "Object" }, diff --git a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts index 99f2e8cd6..6053647b9 100644 --- a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts @@ -51,9 +51,6 @@ export default function createPrimitiveStockFlowTheory(theoryMeta: TheoryMeta): }), analyses.massAction({ ratesHaveGranularity: false, - simulate(model, data) { - return thCategoryLinks.massAction(model, data); - }, transitionType: { tag: "Hom", content: { tag: "Basic", content: "Object" }, diff --git a/packages/frontend/src/stdlib/theories/reg-net.ts b/packages/frontend/src/stdlib/theories/reg-net.ts index efdfdd31b..1012700fa 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -64,14 +64,8 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T return thSignedCategory.negativeLoops(model, options); }, }), - analyses.linearODE({ - simulate: (model, data) => thSignedCategory.linearODE(model, data), - }), - analyses.lotkaVolterra({ - simulate(model, data) { - return thSignedCategory.lotkaVolterra(model, data); - }, - }), + analyses.linearODE(), + analyses.lotkaVolterra(), ], }); } diff --git a/packages/frontend/src/user/documents.tsx b/packages/frontend/src/user/documents.tsx index 517d71813..6a812c98c 100644 --- a/packages/frontend/src/user/documents.tsx +++ b/packages/frontend/src/user/documents.tsx @@ -23,6 +23,7 @@ import X from "lucide-solid/icons/x"; import invariant from "tiny-invariant"; import { IconButton, Spinner } from "catcolab-ui-components"; +import { debounce } from "../util/debounce"; export default function UserDocuments() { const appTitle = import.meta.env.VITE_APP_TITLE; @@ -50,10 +51,9 @@ function DocumentsSearch() { const [page, setPage] = createSignal(0); const pageSize = 15; - let debounceTimer: ReturnType; + const setDebouncedQueryDebounced = debounce((value: string) => setDebouncedQuery(value), 300); const updateQuery = (value: string) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => setDebouncedQuery(value), 300); + setDebouncedQueryDebounced(value); setSearchQuery(value); setPage(0); }; diff --git a/packages/frontend/src/user/trash.tsx b/packages/frontend/src/user/trash.tsx index 1559094af..ec666f3dd 100644 --- a/packages/frontend/src/user/trash.tsx +++ b/packages/frontend/src/user/trash.tsx @@ -11,6 +11,7 @@ import { rpcResourceErr, rpcResourceOk, useApi } from "../api"; import { BrandedToolbar } from "../page"; import "./documents.css"; +import { debounce } from "../util/debounce"; import { LoginGate } from "./login"; export default function TrashBin() { @@ -39,10 +40,9 @@ function TrashBinSearch() { const [page, setPage] = createSignal(0); const pageSize = 15; - let debounceTimer: ReturnType; + const setDebouncedQueryDebounced = debounce((value: string) => setDebouncedQuery(value), 300); const updateQuery = (value: string) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => setDebouncedQuery(value), 300); + setDebouncedQueryDebounced(value); setSearchQuery(value); setPage(0); }; diff --git a/packages/frontend/src/util/debounce.ts b/packages/frontend/src/util/debounce.ts new file mode 100644 index 000000000..e65ef4c94 --- /dev/null +++ b/packages/frontend/src/util/debounce.ts @@ -0,0 +1,24 @@ +/** Create a debounced version of a function. + +The returned function delays invoking `fn` until `ms` milliseconds have elapsed +since the last invocation. Each new call resets the timer. + +Also returns a `cancel` method to clear any pending invocation. + */ +export function debounce( + fn: (...args: Args) => void, + ms: number, +): ((...args: Args) => void) & { cancel: () => void } { + let timer: ReturnType | undefined; + + const debounced = (...args: Args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; + + debounced.cancel = () => { + clearTimeout(timer); + }; + + return debounced; +} diff --git a/packages/frontend/src/visualization/ode_plot.tsx b/packages/frontend/src/visualization/ode_plot.tsx index dab804340..384302193 100644 --- a/packages/frontend/src/visualization/ode_plot.tsx +++ b/packages/frontend/src/visualization/ode_plot.tsx @@ -27,19 +27,27 @@ type ODEPlotOptions = { /** Display the results from an ODE simulation. Plots the output data if the simulation was successful and shows an error -message otherwise. +message otherwise. Shows a loading indicator when a simulation is in progress. */ export function ODEResultPlot( allProps: { result?: JsResult; + loading?: boolean; } & ODEPlotOptions, ) { - const [props, options] = splitProps(allProps, ["result"]); + const [props, options] = splitProps(allProps, ["result", "loading"]); return ( + +
Computing...
+
- {(data) => } + {(data) => ( +
+ +
+ )}
{(err) => {err()}} diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index e78f30085..ad494bfbb 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -25,6 +25,10 @@ export default defineConfig({ }), solid(), ], + worker: { + plugins: () => [wasm()], + format: "es", + }, build: { chunkSizeWarningLimit: 2000, sourcemap: true,