diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv index debd9f54..9614c9f1 100644 --- a/examples/sir/config/model.csv +++ b/examples/sir/config/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,100,,false,false,false +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,100,,false,false,false,false diff --git a/examples/template-jquery/config/model.csv b/examples/template-jquery/config/model.csv index debd9f54..9614c9f1 100644 --- a/examples/template-jquery/config/model.csv +++ b/examples/template-jquery/config/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,100,,false,false,false +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,100,,false,false,false,false diff --git a/examples/template-svelte/config/model.csv b/examples/template-svelte/config/model.csv index debd9f54..9614c9f1 100644 --- a/examples/template-svelte/config/model.csv +++ b/examples/template-svelte/config/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,100,,false,false,false +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,100,,false,false,false,false diff --git a/package.json b/package.json index 8f4d950b..08233519 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "@playwright/test": "^1.56.1", "@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/parser": "^8.46.0", - "@vitest/browser": "^4.0.16", - "@vitest/coverage-v8": "^4.0.16", + "@vitest/browser": "^4.0.17", + "@vitest/browser-playwright": "^4.0.17", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-eslint-comments": "^3.2.0", @@ -42,7 +43,7 @@ "typedoc": "0.25.0", "typedoc-plugin-markdown": "3.16.0", "typescript": "^5.2.2", - "vitest": "^4.0.16" + "vitest": "^4.0.17" }, "pnpm": { "peerDependencyRules": { diff --git a/packages/build/docs/interfaces/ModelSpec.md b/packages/build/docs/interfaces/ModelSpec.md index 135d4a7e..cd3b8d88 100644 --- a/packages/build/docs/interfaces/ModelSpec.md +++ b/packages/build/docs/interfaces/ModelSpec.md @@ -53,6 +53,23 @@ if it is needed. ___ +### customConstants + + `Optional` **customConstants**: `boolean` \| `string`[] + +Whether to allow constants to be overridden at runtime using `setConstant`. + +If undefined or false, the generated model will implement `setConstant` +as a no-op, meaning that constants cannot be overridden at runtime. + +If true, all constants in the generated model will be available to be +overridden. + +If an array is provided, only those variable names in the array will +be available to be overridden. + +___ + ### customLookups `Optional` **customLookups**: `boolean` \| `string`[] diff --git a/packages/build/docs/interfaces/ResolvedModelSpec.md b/packages/build/docs/interfaces/ResolvedModelSpec.md index a1cc884d..2d9a723e 100644 --- a/packages/build/docs/interfaces/ResolvedModelSpec.md +++ b/packages/build/docs/interfaces/ResolvedModelSpec.md @@ -72,6 +72,23 @@ if it is needed. ___ +### customConstants + + **customConstants**: `boolean` \| `string`[] + +Whether to allow constants to be overridden at runtime using `setConstant`. + +If false, the generated model will contain a `setConstant` function that +throws an error, meaning that constants cannot be overridden at runtime. + +If true, all constants in the generated model will be available to be +overridden. + +If an array is provided, only those variable names in the array will +be available to be overridden. + +___ + ### customLookups **customLookups**: `boolean` \| `string`[] diff --git a/packages/build/src/_shared/model-spec.ts b/packages/build/src/_shared/model-spec.ts index 81c61ae2..c946f76c 100644 --- a/packages/build/src/_shared/model-spec.ts +++ b/packages/build/src/_shared/model-spec.ts @@ -77,6 +77,20 @@ export interface ModelSpec { */ bundleListing?: boolean + /** + * Whether to allow constants to be overridden at runtime using `setConstant`. + * + * If undefined or false, the generated model will implement `setConstant` + * as a no-op, meaning that constants cannot be overridden at runtime. + * + * If true, all constants in the generated model will be available to be + * overridden. + * + * If an array is provided, only those variable names in the array will + * be available to be overridden. + */ + customConstants?: boolean | VarName[] + /** * Whether to allow lookups to be overridden at runtime using `setLookup`. * @@ -166,6 +180,20 @@ export interface ResolvedModelSpec { */ bundleListing: boolean + /** + * Whether to allow constants to be overridden at runtime using `setConstant`. + * + * If false, the generated model will contain a `setConstant` function that + * throws an error, meaning that constants cannot be overridden at runtime. + * + * If true, all constants in the generated model will be available to be + * overridden. + * + * If an array is provided, only those variable names in the array will + * be available to be overridden. + */ + customConstants: boolean | VarName[] + /** * Whether to allow lookups to be overridden at runtime using `setLookup`. * diff --git a/packages/build/src/build/impl/build-once.ts b/packages/build/src/build/impl/build-once.ts index ecf1f2ad..5845a262 100644 --- a/packages/build/src/build/impl/build-once.ts +++ b/packages/build/src/build/impl/build-once.ts @@ -76,8 +76,9 @@ export async function buildOnce( outputVarNames: modelSpec.outputVarNames, externalDatfiles: modelSpec.datFiles, bundleListing: modelSpec.bundleListing, - customLookups: modelSpec.customLookups, - customOutputs: modelSpec.customOutputs, + customConstants: modelSpec.customConstants || false, + customLookups: modelSpec.customLookups || false, + customOutputs: modelSpec.customOutputs || false, ...modelSpec.options } const specPath = joinPath(config.prepDir, 'spec.json') @@ -220,6 +221,13 @@ function resolveModelSpec(modelSpec: ModelSpec): ResolvedModelSpec { outputSpecs = [] } + let customConstants: boolean | VarName[] + if (modelSpec.customConstants !== undefined) { + customConstants = modelSpec.customConstants + } else { + customConstants = false + } + let customLookups: boolean | VarName[] if (modelSpec.customLookups !== undefined) { customLookups = modelSpec.customLookups @@ -241,6 +249,7 @@ function resolveModelSpec(modelSpec: ModelSpec): ResolvedModelSpec { outputs: outputSpecs, datFiles: modelSpec.datFiles || [], bundleListing: modelSpec.bundleListing === true, + customConstants, customLookups, customOutputs, options: modelSpec.options diff --git a/packages/build/tests/build-prod/build-prod.spec.ts b/packages/build/tests/build-prod/build-prod.spec.ts index bef5873e..0ac7b005 100644 --- a/packages/build/tests/build-prod/build-prod.spec.ts +++ b/packages/build/tests/build-prod/build-prod.spec.ts @@ -101,6 +101,7 @@ describe('build in production mode', () => { expect(resolvedModelSpec!.outputs).toEqual([{ varName: 'Z' }]) expect(resolvedModelSpec!.datFiles).toEqual([]) expect(resolvedModelSpec!.bundleListing).toBe(false) + expect(resolvedModelSpec!.customConstants).toBe(false) expect(resolvedModelSpec!.customLookups).toBe(false) expect(resolvedModelSpec!.customOutputs).toBe(false) }) @@ -144,11 +145,12 @@ describe('build in production mode', () => { expect(resolvedModelSpec!.outputs).toEqual([{ varName: 'Z' }]) expect(resolvedModelSpec!.datFiles).toEqual([]) expect(resolvedModelSpec!.bundleListing).toBe(true) + expect(resolvedModelSpec!.customConstants).toBe(false) expect(resolvedModelSpec!.customLookups).toEqual(['lookup1']) expect(resolvedModelSpec!.customOutputs).toEqual(['output1']) }) - it('should resolve model spec (when boolean is provided for customLookups and customOutputs)', async () => { + it('should resolve model spec (when boolean is provided for customConstants, customLookups, and customOutputs)', async () => { let resolvedModelSpec: ResolvedModelSpec const userConfig: UserConfig = { genFormat: 'c', @@ -161,6 +163,7 @@ describe('build in production mode', () => { inputs: ['Y'], outputs: ['Z'], bundleListing: true, + customConstants: true, customLookups: true, customOutputs: true } @@ -182,6 +185,7 @@ describe('build in production mode', () => { expect(result.value.exitCode).toBe(0) expect(resolvedModelSpec!).toBeDefined() expect(resolvedModelSpec!.bundleListing).toBe(true) + expect(resolvedModelSpec!.customConstants).toEqual(true) expect(resolvedModelSpec!.customLookups).toEqual(true) expect(resolvedModelSpec!.customOutputs).toEqual(true) }) diff --git a/packages/check-ui-shell/vitest.config.js b/packages/check-ui-shell/vitest.config.js index 96dc28b3..b5e831ae 100644 --- a/packages/check-ui-shell/vitest.config.js +++ b/packages/check-ui-shell/vitest.config.js @@ -2,6 +2,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig, mergeConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' @@ -36,7 +37,7 @@ export default defineConfig(() => name: 'storybook', browser: { enabled: true, - provider: 'playwright', + provider: playwright(), headless: true, instances: [{ browser: 'chromium' }] }, diff --git a/packages/cli/src/c/main.c b/packages/cli/src/c/main.c index ad7b0463..2d07f888 100644 --- a/packages/cli/src/c/main.c +++ b/packages/cli/src/c/main.c @@ -111,7 +111,7 @@ int main(int argc, char** argv) { double* outputBuffer = (double*)malloc(numOutputs * numSavePoints * sizeof(double)); // Run the model with the sparse input arrays and output buffer - runModelWithBuffers(inputValues, inputIndices, outputBuffer, NULL); + runModelWithBuffers(inputValues, inputIndices, outputBuffer, NULL, NULL, NULL); if (!suppress_data_output) { if (raw_output) { diff --git a/packages/cli/src/c/model.c b/packages/cli/src/c/model.c index 2d4f4ef9..e65c1d27 100644 --- a/packages/cli/src/c/model.c +++ b/packages/cli/src/c/model.c @@ -63,6 +63,39 @@ double getSaveper() { return _saveper; } +/** + * Set constant overrides from the given buffers. + * + * The `constantIndices` buffer contains the variable indices and subscript indices + * for each constant to override. The format is: + * [count, varIndex1, subCount1, subIndex1_1, ..., varIndex2, subCount2, ...] + * + * The `constantValues` buffer contains the corresponding values for each constant. + */ +void setConstantOverridesFromBuffers(double* constantValues, int32_t* constantIndices) { + if (constantValues == NULL || constantIndices == NULL) { + return; + } + + size_t indexBufferOffset = 0; + size_t valueBufferOffset = 0; + size_t constantCount = (size_t)constantIndices[indexBufferOffset++]; + + for (size_t i = 0; i < constantCount; i++) { + size_t varIndex = (size_t)constantIndices[indexBufferOffset++]; + size_t subCount = (size_t)constantIndices[indexBufferOffset++]; + size_t* subIndices; + if (subCount > 0) { + subIndices = (size_t*)(constantIndices + indexBufferOffset); + indexBufferOffset += subCount; + } else { + subIndices = NULL; + } + double value = constantValues[valueBufferOffset++]; + setConstant(varIndex, subIndices, value); + } +} + /** * Run the model, reading inputs from the given `inputs` buffer, and writing outputs * to the given `outputs` buffer. @@ -90,21 +123,28 @@ double getSaveper() { * values. See above for details on the expected format. */ void runModel(double* inputs, double* outputs) { - runModelWithBuffers(inputs, NULL, outputs, NULL); + runModelWithBuffers(inputs, NULL, outputs, NULL, NULL, NULL); } /** * Run the model, reading inputs from the given `inputs` buffer, and writing outputs * to the given `outputs` buffer. * + * INPUTS + * ------ + * * If `inputIndices` is NULL, the `inputs` buffer is assumed to have one double value * for each input variable, in exactly the same order as the variables are listed in * the spec file. * * If `inputIndices` is non-NULL, it specifies which inputs are being set: - * - inputIndices[0] is the count of inputs being specified - * - inputIndices[1..N] are the indices of the inputs to set - * - inputs[0..N-1] are the corresponding values + * - inputIndices[0] is the count (C) of inputs being specified + * - inputIndices[1...C] are the indices of the inputs to set (where each index + * corresponds to the index of the input variable in the spec.json file) + * - inputs[0...C-1] are the corresponding values + * + * OUTPUTS + * ------- * * After each step of the run, the `outputs` buffer will be updated with the output * variables. The `outputs` buffer needs to be at least as large as: @@ -118,31 +158,59 @@ void runModel(double* inputs, double* outputs) { * outputs will begin, and so on. * * If `outputIndices` is non-NULL, it specifies which outputs are being stored: - * - outputIndices[0] is the count of output variables being stored - * - outputIndices[1..N] are the indices of the output variables to store (unlike - * `inputIndices`, these indices refer to the ones defined in the `{model}.json` - * listing file, NOT the list of output variables spec file) - * - outputs[0..N-1] are the corresponding values + * - outputIndices[0] is the count (C) of output variables being stored + * - outputIndices[1...] are the indices of the output variables to store, in + * the following format: + * [count, varIndex1, subCount1, subIndex1_1, ..., varIndex2, subCount2, ...] + * where `count` is the number of variables to store, `varIndexN` is the index + * of the variable to store (from the {model}.json listing file), `subCountN` is + * the number of subscripts for that variable, and `subIndexN_M` is the index of + * the subscript at the Mth position for that variable + * - outputs[0...C-1] are the corresponding values * - * @param inputs The buffer that contains the model input values. If NULL, - * no inputs will be set and the model will use the default values for all - * constants as defined in the generated model. If non-NULL, the buffer is - * assumed to have one double value for each input variable. The number of - * values provided depends on `inputIndices`; see above for details on the - * expected format of these two parameters. - * @param inputIndices The optional buffer that specifies which input values - * from the `inputs` buffer are being set. See above for details on the - * expected format. - * @param outputs The required buffer that will receive the model output - * values. See above for details on the expected format. - * @param outputIndices The optional buffer that specifies which output values - * will be stored in the `outputs` buffer. See above for details on the - * expected format. + * CONSTANT OVERRIDES + * ------------------ + * + * If `constants` and `constantIndices` are non-NULL, the provided constant values will + * override the default values for those constants as defined in the generated model. + * + * The `constantIndices` buffer specifies which constants are being overridden. The + * format is the same as described above for `outputIndices`: + * - constantIndices[0] is the count (C) of constants being overridden + * - constantIndices[1...] are the indices of the constants to override, in the + * following format: + * [count, varIndex1, subCount1, subIndex1_1, ..., varIndex2, subCount2, ...] + * where `count` is the number of constants to override, `varIndexN` is the index + * of the variable to store (from the {model}.json listing file), `subCountN` is + * the number of subscripts for that variable, and `subIndexN_M` is the index of + * the subscript at the Mth position for that variable + * - constants[0...C-1] are the corresponding values + * + * @param inputs The buffer that contains the model input values. If NULL, no inputs + * will be set and the model will use the default values for all constants as defined + * in the generated model. If non-NULL, the buffer is assumed to have one double value + * for each input variable. The number of values provided depends on `inputIndices`; + * see above for details on the expected format of these two parameters. + * @param inputIndices The optional buffer that specifies which input values from the + * `inputs` buffer are being set. See above for details on the expected format. + * @param outputs The required buffer that will receive the model output values. See + * above for details on the expected format. + * @param outputIndices The optional buffer that specifies which output values will be + * stored in the `outputs` buffer. See above for details on the expected format. + * @param constants An optional buffer that contains the values of the constants to + * override. Pass NULL if not overriding any constants. Each value in the buffer + * corresponds to the value of the constant at the corresponding index. + * @param constantIndices An optional buffer that contains the indices of the constants + * to override. Pass NULL if not overriding any constants. See above for details on + * the expected format. */ -void runModelWithBuffers(double* inputs, int32_t* inputIndices, double* outputs, int32_t* outputIndices) { +void runModelWithBuffers(double* inputs, int32_t* inputIndices, double* outputs, int32_t* outputIndices, double* constants, int32_t* constantIndices) { outputBuffer = outputs; outputIndexBuffer = outputIndices; initConstants(); + if (constants != NULL && constantIndices != NULL) { + setConstantOverridesFromBuffers(constants, constantIndices); + } if (inputs != NULL) { setInputs(inputs, inputIndices); } diff --git a/packages/cli/src/c/sde.h b/packages/cli/src/c/sde.h index 098c900d..1abc28a2 100644 --- a/packages/cli/src/c/sde.h +++ b/packages/cli/src/c/sde.h @@ -56,7 +56,7 @@ double getInitialTime(void); double getFinalTime(void); double getSaveper(void); void runModel(double* inputs, double* outputs); -void runModelWithBuffers(double* inputs, int32_t* inputIndices, double* outputs, int32_t* outputIndices); +void runModelWithBuffers(double* inputs, int32_t* inputIndices, double* outputs, int32_t* outputIndices, double* constants, int32_t* constantIndices); void run(void); void startOutput(void); void outputVar(double value); @@ -66,6 +66,7 @@ void finish(void); void initConstants(void); void initLevels(void); void setInputs(double* inputValues, int32_t* inputIndices); +void setConstant(size_t varIndex, size_t* subIndices, double value); void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints); void evalAux(void); void evalLevels(void); diff --git a/packages/compile/src/generate/gen-code-c.js b/packages/compile/src/generate/gen-code-c.js index b4020c71..5b43ffb0 100644 --- a/packages/compile/src/generate/gen-code-c.js +++ b/packages/compile/src/generate/gen-code-c.js @@ -146,6 +146,24 @@ ${chunkedFunctions('evalLevels', Model.levelVars(), ' // Evaluate levels.')}` // Input/output section // function emitIOCode() { + // Configure the body of the `setConstant` function depending on the value + // of the `customConstants` property in the spec file + let setConstantBody + if (spec.customConstants === true || Array.isArray(spec.customConstants)) { + setConstantBody = `\ + switch (varIndex) { +${setConstantImpl(Model.varIndexInfo(), spec.customConstants)} + default: + fprintf(stderr, "No constant found for var index %zu in setConstant\\n", varIndex); + break; + }` + } else { + let msg = 'The setConstant function was not enabled for the generated model. ' + msg += 'Set the customConstants property in the spec/config file to allow for overriding constants at runtime.' + setConstantBody = `\ + fprintf(stderr, "${msg}\\n");` + } + // Configure the body of the `setLookup` function depending on the value // of the `customLookups` property in the spec file // TODO: The fprintf calls should be replaced with a mechanism that throws @@ -204,6 +222,10 @@ void setInputs(double* inputValues, int32_t* inputIndices) { ${setInputsImpl()} } +void setConstant(size_t varIndex, size_t* subIndices, double value) { +${setConstantBody} +} + void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { ${setLookupBody} } @@ -430,6 +452,39 @@ ${inputVarPtrs} }; } }` } + function setConstantImpl(varIndexInfo, customConstants) { + // Emit case statements for all const variables that can be overridden at runtime + let includeCase + if (Array.isArray(customConstants)) { + // Only include a case statement if the variable was explicitly included + // in the `customConstants` array in the spec file + const customConstantVarNames = customConstants.map(varName => { + // The developer might specify a variable name that includes subscripts, + // but we will ignore the subscript part and only match on the base name + return canonicalVensimName(varName.split('[')[0]) + }) + includeCase = varName => customConstantVarNames.includes(varName) + } else { + // Include a case statement for all constant variables + includeCase = () => true + } + const constVars = R.filter(info => { + return info.varType === 'const' && includeCase(info.varName) + }) + const code = R.map(info => { + let constVar = info.varName + for (let i = 0; i < info.subscriptCount; i++) { + constVar += `[subIndices[${i}]]` + } + let c = '' + c += ` case ${info.varIndex}:\n` + c += ` ${constVar} = value;\n` + c += ` break;` + return c + }) + const section = R.pipe(constVars, code, lines) + return section(varIndexInfo) + } function setLookupImpl(varIndexInfo, customLookups) { // Emit case statements for all lookups and data variables that can be overridden // at runtime diff --git a/packages/compile/src/generate/gen-code-c.spec.ts b/packages/compile/src/generate/gen-code-c.spec.ts index 725aa791..96b03171 100644 --- a/packages/compile/src/generate/gen-code-c.spec.ts +++ b/packages/compile/src/generate/gen-code-c.spec.ts @@ -21,6 +21,7 @@ function readInlineModelAndGenerateC( directDataSpec?: DirectDataSpec inputVarNames?: string[] outputVarNames?: string[] + customConstants?: boolean | string[] customLookups?: boolean | string[] customOutputs?: boolean | string[] } @@ -33,6 +34,7 @@ function readInlineModelAndGenerateC( const spec = { inputVarNames: opts?.inputVarNames, outputVarNames: opts?.outputVarNames, + customConstants: opts?.customConstants, customLookups: opts?.customLookups, customOutputs: opts?.customOutputs } @@ -268,6 +270,10 @@ void setInputs(double* inputValues, int32_t* inputIndices) { } } +void setConstant(size_t varIndex, size_t* subIndices, double value) { + fprintf(stderr, "The setConstant function was not enabled for the generated model. Set the customConstants property in the spec/config file to allow for overriding constants at runtime.\\n"); +} + void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { Lookup** pLookup = NULL; switch (varIndex) { @@ -360,6 +366,58 @@ void storeOutput(size_t varIndex, size_t* subIndices) { `) }) + it('should generate setConstant that reports error when customConstants is disabled', () => { + const mdl = ` + DimA: A1, A2 ~~| + x[DimA] = 10, 20 ~~| + y = 42 ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + inputVarNames: [], + outputVarNames: ['x[A1]', 'y'] + }) + expect(code).toMatch(`\ +void setConstant(size_t varIndex, size_t* subIndices, double value) { + fprintf(stderr, "The setConstant function was not enabled for the generated model. Set the customConstants property in the spec/config file to allow for overriding constants at runtime.\\n"); +}`) + }) + + it('should generate setConstant that includes a subset of cases when customConstants is an array', () => { + const mdl = ` + DimA: A1, A2 ~~| + x[DimA] = 10, 20 ~~| + y = 42 ~~| + z = 99 ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + inputVarNames: [], + outputVarNames: ['x[A1]', 'y', 'z'], + customConstants: ['x[A1]', 'z'] + }) + expect(code).toMatch(`\ +void setConstant(size_t varIndex, size_t* subIndices, double value) { + switch (varIndex) { + case 5: + _x[subIndices[0]] = value; + break; + case 7: + _z = value; + break; + default: + fprintf(stderr, "No constant found for var index %zu in setConstant\\n", varIndex); + break; + } +}`) + }) + it('should generate setLookup that reports error when customLookups is disabled', () => { const mdl = ` x = 1 ~~| diff --git a/packages/compile/src/generate/gen-code-js.js b/packages/compile/src/generate/gen-code-js.js index 33aebb03..324c93d1 100644 --- a/packages/compile/src/generate/gen-code-js.js +++ b/packages/compile/src/generate/gen-code-js.js @@ -241,6 +241,27 @@ ${chunkedFunctions('evalLevels', true, Model.levelVars(), ' // Evaluate levels' function emitIOCode() { mode = 'io' + // Configure the body of the `setConstant` function depending on the value + // of the `customConstants` property in the spec file + let setConstantBody + if (spec.customConstants === true || Array.isArray(spec.customConstants)) { + setConstantBody = `\ + if (!varSpec) { + throw new Error('Got undefined varSpec in setConstant'); + } + const varIndex = varSpec.varIndex; + const subs = varSpec.subscriptIndices; + switch (varIndex) { +${setConstantImpl(Model.varIndexInfo(), spec.customConstants)} + default: + throw new Error(\`No constant found for var index \${varIndex} in setConstant\`); + }` + } else { + let msg = 'The setConstant function was not enabled for the generated model. ' + msg += 'Set the customConstants property in the spec/config file to allow for overriding constants at runtime.' + setConstantBody = ` throw new Error('${msg}');` + } + // Configure the body of the `setLookup` function depending on the value // of the `customLookups` property in the spec file let setLookupBody @@ -312,6 +333,10 @@ ${customOutputSection(Model.varIndexInfo(), spec.customOutputs)} return `\ /*export*/ function setInputs(valueAtIndex /*: (index: number) => number*/) {${inputsFromBufferImpl()}} +/*export*/ function setConstant(varSpec /*: VarSpec*/, value /*: number*/) { +${setConstantBody} +} + /*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array | undefined*/) { ${setLookupBody} } @@ -521,6 +546,39 @@ ${section(chunk)} } return inputVars } + function setConstantImpl(varIndexInfo, customConstants) { + // Emit case statements for all const variables that can be overridden at runtime + let overrideAllowed + if (Array.isArray(customConstants)) { + // Only include a case statement if the variable was explicitly included + // in the `customConstants` array in the spec file + const customConstantVarNames = customConstants.map(varName => { + // The developer might specify a variable name that includes subscripts, + // but we will ignore the subscript part and only match on the base name + return canonicalVensimName(varName.split('[')[0]) + }) + overrideAllowed = varName => customConstantVarNames.includes(varName) + } else { + // Include a case statement for all constant variables + overrideAllowed = () => true + } + const constVars = R.filter(info => { + return info.varType === 'const' && overrideAllowed(info.varName) + }) + const code = R.map(info => { + let constVar = info.varName + for (let i = 0; i < info.subscriptCount; i++) { + constVar += `[subs[${i}]]` + } + let c = '' + c += ` case ${info.varIndex}:\n` + c += ` ${constVar} = value;\n` + c += ` break;` + return c + }) + const section = R.pipe(constVars, code, lines) + return section(varIndexInfo) + } function setLookupImpl(varIndexInfo, customLookups) { // Emit case statements for all lookups and data variables that can be overridden // at runtime @@ -605,6 +663,7 @@ export default async function () { setTime, setInputs, + setConstant, setLookup, storeOutputs, diff --git a/packages/compile/src/generate/gen-code-js.spec.ts b/packages/compile/src/generate/gen-code-js.spec.ts index 8bd59ca6..c2058b65 100644 --- a/packages/compile/src/generate/gen-code-js.spec.ts +++ b/packages/compile/src/generate/gen-code-js.spec.ts @@ -22,6 +22,7 @@ function readInlineModelAndGenerateJS( inputVarNames?: string[] outputVarNames?: string[] bundleListing?: boolean + customConstants?: boolean | string[] customLookups?: boolean | string[] customOutputs?: boolean | string[] } @@ -35,6 +36,7 @@ function readInlineModelAndGenerateJS( inputVarNames: opts?.inputVarNames, outputVarNames: opts?.outputVarNames, bundleListing: opts?.bundleListing, + customConstants: opts?.customConstants, customLookups: opts?.customLookups, customOutputs: opts?.customOutputs } @@ -454,6 +456,10 @@ function evalAux0() { _input = valueAtIndex(0); } +/*export*/ function setConstant(varSpec /*: VarSpec*/, value /*: number*/) { + throw new Error('The setConstant function was not enabled for the generated model. Set the customConstants property in the spec/config file to allow for overriding constants at runtime.'); +} + /*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array | undefined*/) { if (!varSpec) { throw new Error('Got undefined varSpec in setLookup'); @@ -693,6 +699,7 @@ export default async function () { setTime, setInputs, + setConstant, setLookup, storeOutputs, @@ -723,6 +730,62 @@ export default async function () { expect(code).toMatch(`/*export*/ const modelListing = undefined;`) }) + it('should generate setConstant that throws error when customConstants is disabled', () => { + const mdl = ` + DimA: A1, A2 ~~| + x[DimA] = 10, 20 ~~| + y = 42 ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['x[A1]', 'y'] + }) + expect(code).toMatch(`\ +/*export*/ function setConstant(varSpec /*: VarSpec*/, value /*: number*/) { + throw new Error('The setConstant function was not enabled for the generated model. Set the customConstants property in the spec/config file to allow for overriding constants at runtime.'); +}`) + }) + + it('should generate setConstant that includes a subset of cases when customConstants is an array', () => { + const mdl = ` + DimA: A1, A2 ~~| + x[DimA] = 10, 20 ~~| + y = 42 ~~| + z = 99 ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['x[A1]', 'y', 'z'], + customConstants: ['x[A1]', 'z'] + }) + expect(code).toMatch(`\ +/*export*/ function setConstant(varSpec /*: VarSpec*/, value /*: number*/) { + if (!varSpec) { + throw new Error('Got undefined varSpec in setConstant'); + } + const varIndex = varSpec.varIndex; + const subs = varSpec.subscriptIndices; + switch (varIndex) { + case 5: + _x[subs[0]] = value; + break; + case 7: + _z = value; + break; + default: + throw new Error(\`No constant found for var index \${varIndex} in setConstant\`); + } +}`) + }) + it('should generate setLookup that throws error when customLookups is disabled', () => { const mdl = ` x = 1 ~~| diff --git a/packages/create/src/step-config.ts b/packages/create/src/step-config.ts index 07d46a06..2dae919d 100644 --- a/packages/create/src/step-config.ts +++ b/packages/create/src/step-config.ts @@ -193,11 +193,12 @@ export async function chooseGenConfig(projDir: string, mdlPath: string): Promise // Disable bundled listing and customization features by default const bundleListing = 'false' + const customConstants = 'false' const customLookups = 'false' const customOutputs = 'false' // Add line and write out updated `model.csv` - const modelCsvLine = `${initialTime},${finalTime},${datPart},${bundleListing},${customLookups},${customOutputs}` + const modelCsvLine = `${initialTime},${finalTime},${datPart},${bundleListing},${customConstants},${customLookups},${customOutputs}` const newModelCsvContent = `${modelCsvHeader}\n${modelCsvLine}\n` await writeFile(modelCsvFile, newModelCsvContent) diff --git a/packages/plugin-config/src/__tests__/config1/model.csv b/packages/plugin-config/src/__tests__/config1/model.csv index e78d93c7..73d17eab 100644 --- a/packages/plugin-config/src/__tests__/config1/model.csv +++ b/packages/plugin-config/src/__tests__/config1/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,200,Data1.dat;Data2.dat,false,false,false +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,200,Data1.dat;Data2.dat,false,false,false,false diff --git a/packages/plugin-config/src/__tests__/config2/model.csv b/packages/plugin-config/src/__tests__/config2/model.csv index 5422e220..b7c9d2ea 100644 --- a/packages/plugin-config/src/__tests__/config2/model.csv +++ b/packages/plugin-config/src/__tests__/config2/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,200,,true,true,true +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,200,,true,true,true,true diff --git a/packages/plugin-config/src/__tests__/config3/model.csv b/packages/plugin-config/src/__tests__/config3/model.csv index 5422e220..b7c9d2ea 100644 --- a/packages/plugin-config/src/__tests__/config3/model.csv +++ b/packages/plugin-config/src/__tests__/config3/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,200,,true,true,true +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,200,,true,true,true,true diff --git a/packages/plugin-config/src/context.ts b/packages/plugin-config/src/context.ts index 045dd7fb..2bcb6925 100644 --- a/packages/plugin-config/src/context.ts +++ b/packages/plugin-config/src/context.ts @@ -18,6 +18,7 @@ export interface ModelOptions { readonly graphDefaultMaxTime: number readonly datFiles: string[] readonly bundleListing: boolean + readonly customConstants: boolean readonly customLookups: boolean readonly customOutputs: boolean } @@ -174,10 +175,11 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin const datFiles = origDatFiles.map(f => joinPath(relative(prepDir, projDir), f)) // Read other boolean properties from `model.csv` - // TODO: If customLookups is true, see if there is a `config/custom-lookups.csv` file + // TODO: If customConstants is true, see if there is a `config/custom-constants.csv` file // and if so, make an array of variable names instead of setting a boolean in `spec.json`. - // (Same thing for customOutputs.) + // (Same thing for customLookups and customOutputs.) const bundleListing = modelCsv['bundle listing'] === 'true' + const customConstants = modelCsv['custom constants'] === 'true' const customLookups = modelCsv['custom lookups'] === 'true' const customOutputs = modelCsv['custom outputs'] === 'true' @@ -186,6 +188,7 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin graphDefaultMaxTime, datFiles, bundleListing, + customConstants, customLookups, customOutputs } diff --git a/packages/plugin-config/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts index bc1e95af..eaf3ab9f 100644 --- a/packages/plugin-config/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -74,6 +74,7 @@ const specJson1 = `\ "../Data2.dat" ], "bundleListing": false, + "customConstants": false, "customLookups": false, "customOutputs": false }\ @@ -94,6 +95,7 @@ const specJson2 = `\ "../Data2.dat" ], "bundleListing": false, + "customConstants": false, "customLookups": false, "customOutputs": false, "directData": { @@ -113,6 +115,7 @@ const specJson3 = `\ ], "externalDatfiles": [], "bundleListing": true, + "customConstants": true, "customLookups": true, "customOutputs": true }\ diff --git a/packages/plugin-config/src/processor.ts b/packages/plugin-config/src/processor.ts index 884b413b..d09159d3 100644 --- a/packages/plugin-config/src/processor.ts +++ b/packages/plugin-config/src/processor.ts @@ -135,6 +135,7 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigPro outputs: context.getOrderedOutputs(), datFiles: modelOptions.datFiles, bundleListing: modelOptions.bundleListing, + customConstants: modelOptions.customConstants, customLookups: modelOptions.customLookups, customOutputs: modelOptions.customOutputs, options: options.spec diff --git a/packages/plugin-config/template-config/model.csv b/packages/plugin-config/template-config/model.csv index a5d2eea1..9657ea5d 100644 --- a/packages/plugin-config/template-config/model.csv +++ b/packages/plugin-config/template-config/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs -0,100,Data1.dat;Data2.dat,false,false,false +graph default min time,graph default max time,model dat files,bundle listing,custom constants,custom lookups,custom outputs +0,100,Data1.dat;Data2.dat,false,false,false,false diff --git a/packages/runtime-async/tests/runner.spec.ts b/packages/runtime-async/tests/runner.spec.ts index 21298127..6a3f4eda 100644 --- a/packages/runtime-async/tests/runner.spec.ts +++ b/packages/runtime-async/tests/runner.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ModelRunner } from '@sdeverywhere/runtime' -import { ModelListing, createInputValue, createLookupDef } from '@sdeverywhere/runtime' +import { ModelListing, createConstantDef, createInputValue, createLookupDef } from '@sdeverywhere/runtime' import { spawnAsyncModelRunner } from '../src/runner' @@ -31,6 +31,14 @@ const listingJson = ` { "id": "_output_2_data", "index": 5 + }, + { + "id": "_constant_1", + "index": 6 + }, + { + "id": "_constant_2", + "index": 7 } ] } @@ -58,9 +66,12 @@ function createMockJsModel() { finalTime: endTime, outputVarIds: ['_output_1', '_output_2'], listingJson: \`${listingJson}\`, - onEvalAux: (vars, lookups) => { + onEvalAux: (vars, constants, lookups) => { const time = vars.get('_time') - if (lookups.size > 0) { + // Get constant values, defaulting to 1 and 4 + const constant1 = constants?.get('_constant_1') ?? 1 + const constant2 = constants?.get('_constant_2') ?? 4 + if (lookups && lookups.size > 0) { const lookup1 = lookups.get('_output_1_data') const lookup2 = lookups.get('_output_2_data') // expect(lookup1).toBeDefined() @@ -68,8 +79,8 @@ function createMockJsModel() { vars.set('_output_1', lookup1.getValueForX(time, 'interpolate')) vars.set('_output_2', lookup2.getValueForX(time, 'interpolate')) } else { - vars.set('_output_1', time - startTime + 1) - vars.set('_output_2', time - startTime + 4) + vars.set('_output_1', time - startTime + constant1) + vars.set('_output_2', time - startTime + constant2) vars.set('_x', time - startTime + 7) } } @@ -93,8 +104,11 @@ async function createMockWasmModule() { finalTime: endTime, outputVarIds: ['_output_1', '_output_2'], listingJson: \`${listingJson}\`, - onRunModel: (inputs, outputs, lookups, outputIndices) => { - if (lookups.size > 0) { + onRunModel: (inputs, outputs, constants, lookups, outputIndices) => { + // Get constant values, defaulting to 1 and 4 + const constant1 = constants?.get('_constant_1') ?? 1 + const constant2 = constants?.get('_constant_2') ?? 4 + if (lookups && lookups.size > 0) { // Pretend that outputs are derived from lookup data const lookup1 = lookups.get('_output_1_data') const lookup2 = lookups.get('_output_2_data') @@ -107,10 +121,10 @@ async function createMockWasmModule() { } else { if (outputIndices === undefined) { // Store 3 values for the _output_1, and 3 for _output_2 - outputs.set([1, 2, 3, 4, 5, 6]) + outputs.set([constant1, constant1 + 1, constant1 + 2, constant2, constant2 + 1, constant2 + 2]) } else { // Store 3 values for each of the three variables - outputs.set([7, 8, 9, 4, 5, 6, 1, 2, 3]) + outputs.set([7, 8, 9, constant2, constant2 + 1, constant2 + 2, constant1, constant1 + 1, constant1 + 2]) } } } @@ -210,6 +224,34 @@ describe.each([ expect(outputs.getSeriesForVar('_output_2')!.points).toEqual(lookup2Points) }) + it('should run the model (with constant overrides)', async () => { + const inputs = [createInputValue('_input_1', 7), createInputValue('_input_2', 8), createInputValue('_input_3', 9)] + let outputs = runner.createOutputs() + + // Run once without constant overrides + outputs = await runner.runModel(inputs, outputs) + + // Verify that outputs contain the original values + expect(outputs.getSeriesForVar('_output_1')!.points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_output_2')!.points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + + // Run again, this time with constant overrides + outputs = await runner.runModel(inputs, outputs, { + constants: [createConstantDef({ varId: '_constant_1' }, 100), createConstantDef({ varId: '_constant_2' }, 400)] + }) + + // Verify that outputs contain the values using the overridden constants + expect(outputs.getSeriesForVar('_output_1')!.points).toEqual([p(2000, 100), p(2001, 101), p(2002, 102)]) + expect(outputs.getSeriesForVar('_output_2')!.points).toEqual([p(2000, 400), p(2001, 401), p(2002, 402)]) + + // Run again without constant overrides + outputs = await runner.runModel(inputs, outputs) + + // Verify that the constant overrides are NOT in effect (they do NOT persist like lookups) + expect(outputs.getSeriesForVar('_output_1')!.points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_output_2')!.points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + }) + it('should run the model in a worker (when output var specs are included)', async () => { const listing = new ModelListing(JSON.parse(listingJson)) const inputs = [7, 8, 9] diff --git a/packages/runtime/docs/functions/createConstantDef.md b/packages/runtime/docs/functions/createConstantDef.md new file mode 100644 index 00000000..2e61ae6e --- /dev/null +++ b/packages/runtime/docs/functions/createConstantDef.md @@ -0,0 +1,18 @@ +[@sdeverywhere/runtime](../index.md) / createConstantDef + +# Function: createConstantDef + +**createConstantDef**(`varRef`, `value`): [`ConstantDef`](../interfaces/ConstantDef.md) + +Create a `ConstantDef` instance. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `varRef` | [`VarRef`](../interfaces/VarRef.md) | The reference to the constant variable to be modified. | +| `value` | `number` | The new constant value. | + +#### Returns + +[`ConstantDef`](../interfaces/ConstantDef.md) diff --git a/packages/runtime/docs/interfaces/ConstantDef.md b/packages/runtime/docs/interfaces/ConstantDef.md new file mode 100644 index 00000000..e0d7b5f4 --- /dev/null +++ b/packages/runtime/docs/interfaces/ConstantDef.md @@ -0,0 +1,22 @@ +[@sdeverywhere/runtime](../index.md) / ConstantDef + +# Interface: ConstantDef + +Specifies the constant value that will be used to override a constant in a +generated model. + +## Properties + +### varRef + + **varRef**: [`VarRef`](VarRef.md) + +The reference that identifies the constant variable to be modified. + +___ + +### value + + **value**: `number` + +The new constant value. diff --git a/packages/runtime/docs/interfaces/RunModelOptions.md b/packages/runtime/docs/interfaces/RunModelOptions.md index dd5369ae..7f2307ed 100644 --- a/packages/runtime/docs/interfaces/RunModelOptions.md +++ b/packages/runtime/docs/interfaces/RunModelOptions.md @@ -6,6 +6,21 @@ Additional options that can be passed to a `runModel` call to influence the mode ## Properties +### constants + + `Optional` **constants**: [`ConstantDef`](ConstantDef.md)[] + +If defined, override the values for the specified constant variables. + +Note that constant overrides do not persist after the `runModel` call. Because +`initConstants` is called at the beginning of each `runModel` call, all constants +are reset to their default values before each model run. If you want to override +constants, you must provide them in the options for each `runModel` call. To +reset constants to their original values, simply stop passing them in the options +(or pass an empty array). + +___ + ### lookups `Optional` **lookups**: [`LookupDef`](LookupDef.md)[] diff --git a/packages/runtime/src/_shared/constant-def.ts b/packages/runtime/src/_shared/constant-def.ts new file mode 100644 index 00000000..46ecb805 --- /dev/null +++ b/packages/runtime/src/_shared/constant-def.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 Climate Interactive / New Venture Fund + +import type { VarRef } from './types' + +/** + * Specifies the constant value that will be used to override a constant in a + * generated model. + */ +export interface ConstantDef { + /** The reference that identifies the constant variable to be modified. */ + varRef: VarRef + + /** The new constant value. */ + value: number +} + +/** + * Create a `ConstantDef` instance. + * + * @param varRef The reference to the constant variable to be modified. + * @param value The new constant value. + */ +export function createConstantDef(varRef: VarRef, value: number): ConstantDef { + return { + varRef, + value + } +} diff --git a/packages/runtime/src/_shared/index.ts b/packages/runtime/src/_shared/index.ts index fa767fb9..5011715d 100644 --- a/packages/runtime/src/_shared/index.ts +++ b/packages/runtime/src/_shared/index.ts @@ -4,4 +4,5 @@ export * from './types' export * from './inputs' export * from './outputs' export * from './var-indices' +export * from './constant-def' export * from './lookup-def' diff --git a/packages/runtime/src/_shared/var-indices.spec.ts b/packages/runtime/src/_shared/var-indices.spec.ts index 9d4bda7b..fae5b4a0 100644 --- a/packages/runtime/src/_shared/var-indices.spec.ts +++ b/packages/runtime/src/_shared/var-indices.spec.ts @@ -2,12 +2,16 @@ import { describe, expect, it } from 'vitest' +import { createConstantDef, type ConstantDef } from './constant-def' import { createLookupDef, type LookupDef } from './lookup-def' import type { VarSpec } from './types' import { + decodeConstants, decodeLookups, + encodeConstants, encodeLookups, encodeVarIndices, + getEncodedConstantBufferLengths, getEncodedLookupBufferLengths, getEncodedVarIndicesLength } from './var-indices' @@ -62,6 +66,68 @@ describe('encodeVarIndices', () => { }) }) +const constantDefs: ConstantDef[] = [ + createConstantDef({ varSpec: { varIndex: 1 } }, 42), + createConstantDef({ varSpec: { varIndex: 2, subscriptIndices: [1, 2] } }, 100), + createConstantDef({ varSpec: { varIndex: 3 } }, 3.14) +] + +describe('getEncodedConstantBufferLengths', () => { + it('should return the correct length', () => { + const { constantIndicesLength, constantsLength } = getEncodedConstantBufferLengths(constantDefs) + expect(constantIndicesLength).toBe(9) + expect(constantsLength).toBe(3) + }) +}) + +describe('encodeConstants and decodeConstants', () => { + it('should encode and decode the correct values', () => { + const constantIndices = new Int32Array(11) + const constantValues = new Float64Array(5) + encodeConstants(constantDefs, constantIndices, constantValues) + + expect(constantIndices).toEqual( + new Int32Array([ + 3, // constant count + + 1, // var0 index + 0, // var0 subscript count + + 2, // var1 index + 2, // var1 subscript count + 1, // var1 sub0 index + 2, // var1 sub1 index + + 3, // var2 index + 0, // var2 subscript count + + // zero padding + 0, + 0 + ]) + ) + + expect(constantValues).toEqual( + new Float64Array([ + // var0 value + 42, + + // var1 value + 100, + + // var2 value + 3.14, + + // zero padding + 0, 0 + ]) + ) + + const decodedConstantDefs = decodeConstants(constantIndices, constantValues) + expect(decodedConstantDefs).toEqual(constantDefs) + }) +}) + const p = (x: number, y: number) => ({ x, y }) const lookupDefs: LookupDef[] = [ createLookupDef({ varSpec: { varIndex: 1 } }, [p(0, 0), p(1, 1)]), diff --git a/packages/runtime/src/_shared/var-indices.ts b/packages/runtime/src/_shared/var-indices.ts index 67aa733e..9cc6b3e2 100644 --- a/packages/runtime/src/_shared/var-indices.ts +++ b/packages/runtime/src/_shared/var-indices.ts @@ -1,5 +1,6 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund +import type { ConstantDef } from './constant-def' import type { LookupDef } from './lookup-def' import type { VarSpec } from './types' @@ -68,6 +69,151 @@ export function encodeVarIndices(varSpecs: VarSpec[], indicesArray: Int32Array): } } +/** + * Return the lengths of the arrays that are required to store the constant values + * and indices for the given `ConstantDef` instances. + * + * @hidden This is not part of the public API; it is exposed here for use by + * the synchronous and asynchronous model runner implementations. + * + * @param constantDefs The `ConstantDef` instances to encode. + */ +export function getEncodedConstantBufferLengths(constantDefs: ConstantDef[]): { + constantIndicesLength: number + constantsLength: number +} { + // The constants buffer includes all constant values for the provided constant overrides + // (added sequentially, one value per constant). The constant indices buffer has the + // following format: + // constant count + // constantN var index + // constantN subscript count + // constantN sub1 index + // constantN sub2 index + // ... + // constantN subM index + // ... (repeat for each constant) + + // Start with one element for the total constant variable count + let constantIndicesLength = 1 + let constantsLength = 0 + + for (const constantDef of constantDefs) { + // Ensure that the var spec has already been resolved + const varSpec = constantDef.varRef.varSpec + if (varSpec === undefined) { + throw new Error('Cannot compute constant buffer lengths until all constant var specs are defined') + } + + // Include one element for the variable index and one for the subscript count + constantIndicesLength += 2 + + // Include one element for each subscript + const subCount = varSpec.subscriptIndices?.length || 0 + constantIndicesLength += subCount + + // Add one element for the constant value + constantsLength += 1 + } + + return { + constantIndicesLength, + constantsLength + } +} + +/** + * Encode constant values and indices to the given arrays. + * + * @hidden This is not part of the public API; it is exposed here for use by + * the synchronous and asynchronous model runner implementations. + * + * @param constantDefs The `ConstantDef` instances to encode. + * @param constantIndicesArray The view on the constant indices buffer. + * @param constantsArray The view on the constant values buffer. + */ +export function encodeConstants( + constantDefs: ConstantDef[], + constantIndicesArray: Int32Array, + constantsArray: Float64Array +): void { + // Write the constant variable count + let ci = 0 + constantIndicesArray[ci++] = constantDefs.length + + // Write the indices and values for each constant + let constantDataOffset = 0 + for (const constantDef of constantDefs) { + // Write the constant variable index + const varSpec = constantDef.varRef.varSpec + constantIndicesArray[ci++] = varSpec.varIndex + + // Write the subscript count + const subs = varSpec.subscriptIndices + const subCount = subs?.length || 0 + constantIndicesArray[ci++] = subCount + + // Write the subscript indices + for (let i = 0; i < subCount; i++) { + constantIndicesArray[ci++] = subs[i] + } + + // Write the constant value + constantsArray[constantDataOffset++] = constantDef.value + } +} + +/** + * Decode constant values and indices from the given buffer views and return the + * reconstructed `ConstantDef` instances. + * + * @hidden This is not part of the public API; it is exposed here for use by + * the synchronous and asynchronous model runner implementations. + * + * @param constantIndicesArray The view on the constant indices buffer. + * @param constantsArray The view on the constant values buffer. + */ +export function decodeConstants(constantIndicesArray: Int32Array, constantsArray: Float64Array): ConstantDef[] { + const constantDefs: ConstantDef[] = [] + let ci = 0 + + // Read the constant variable count + const constantCount = constantIndicesArray[ci++] + + // Read the metadata for each variable from the constant indices buffer + for (let i = 0; i < constantCount; i++) { + // Read the constant variable index + const varIndex = constantIndicesArray[ci++] + + // Read the subscript count + const subCount = constantIndicesArray[ci++] + + // Read the subscript indices + const subscriptIndices: number[] = subCount > 0 ? Array(subCount) : undefined + for (let subIndex = 0; subIndex < subCount; subIndex++) { + subscriptIndices[subIndex] = constantIndicesArray[ci++] + } + + // Create a `VarSpec` for the variable + const varSpec: VarSpec = { + varIndex, + subscriptIndices + } + + // Read the constant value + const value = constantsArray[i] + + constantDefs.push({ + varRef: { + varSpec + }, + value + }) + } + + return constantDefs +} + /** * Return the lengths of the arrays that are required to store the lookup data * and indices for the given `LookupDef` instances. diff --git a/packages/runtime/src/js-model/_mocks/mock-js-model.ts b/packages/runtime/src/js-model/_mocks/mock-js-model.ts index f4797576..a84185ab 100644 --- a/packages/runtime/src/js-model/_mocks/mock-js-model.ts +++ b/packages/runtime/src/js-model/_mocks/mock-js-model.ts @@ -10,7 +10,11 @@ import { JsModelLookup } from '../js-model-lookup' * @hidden This type is not part of the public API; it is exposed only for use in * tests in the runtime-async package. */ -export type OnEvalAux = (vars: Map, lookups: Map) => void +export type OnEvalAux = ( + vars: Map, + constants: Map | undefined, + lookups: Map +) => void /** * @hidden This type is not part of the public API; it is exposed only for use in @@ -35,6 +39,7 @@ export class MockJsModel implements JsModel { private readonly finalTime: number private readonly vars: Map = new Map() + private readonly constants: Map = new Map() private readonly lookups: Map = new Map() private fns: JsModelFunctions @@ -109,6 +114,15 @@ export class MockJsModel implements JsModel { // TODO } + // from JsModel interface + setConstant(varSpec: VarSpec, value: number): void { + const varId = this.varIdForSpec(varSpec) + if (varId === undefined) { + throw new Error(`No constant variable found for spec ${varSpec}`) + } + this.constants.set(varId, value) + } + // from JsModel interface setLookup(varSpec: VarSpec, points: Float64Array | undefined): void { const varId = this.varIdForSpec(varSpec) @@ -136,14 +150,17 @@ export class MockJsModel implements JsModel { } // from JsModel interface - initConstants(): void {} + initConstants(): void { + // Clear all constant overrides (they don't persist across runs) + this.constants.clear() + } // from JsModel interface initLevels(): void {} // from JsModel interface evalAux(): void { - this.onEvalAux?.(this.vars, this.lookups) + this.onEvalAux?.(this.vars, this.constants.size > 0 ? this.constants : undefined, this.lookups) } // from JsModel interface diff --git a/packages/runtime/src/js-model/js-model.ts b/packages/runtime/src/js-model/js-model.ts index 27e1660f..abb19a74 100644 --- a/packages/runtime/src/js-model/js-model.ts +++ b/packages/runtime/src/js-model/js-model.ts @@ -1,6 +1,6 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund -import { type LookupDef, type VarSpec } from '../_shared' +import { type ConstantDef, type LookupDef, type VarSpec } from '../_shared' import type { RunnableModel } from '../runnable-model' import { BaseRunnableModel } from '../runnable-model/base-runnable-model' @@ -49,6 +49,9 @@ export interface JsModel { /** @hidden */ setInputs(inputValue: (index: number) => number): void + /** @hidden */ + setConstant(varSpec: VarSpec, value: number): void + /** @hidden */ setLookup(varSpec: VarSpec, points: Float64Array | undefined): void @@ -108,6 +111,7 @@ export function initJsModel(model: JsModel): RunnableModel { inputs, outputs, options?.outputIndices, + options?.constants, options?.lookups, undefined ) @@ -125,6 +129,7 @@ function runJsModel( inputs: Float64Array | undefined, outputs: Float64Array, outputIndices: Int32Array | undefined, + constants: ConstantDef[] | undefined, lookups: LookupDef[] | undefined, stopAfterTime: number | undefined ): void { @@ -143,6 +148,13 @@ function runJsModel( // Initialize constants to their default values model.initConstants() + // Apply constant overrides, if provided + if (constants !== undefined) { + for (const constantDef of constants) { + model.setConstant(constantDef.varRef.varSpec, constantDef.value) + } + } + // Apply lookup overrides, if provided if (lookups !== undefined) { for (const lookupDef of lookups) { @@ -152,7 +164,8 @@ function runJsModel( if (inputs?.length > 0) { // Set the user-defined input values. This needs to happen after `initConstants` - // since the input values will override the default constant values. + // and the `setConstant` override calls, since the input values will override + // the default and custom constant values. model.setInputs(index => inputs[index]) } diff --git a/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts b/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts index 84ba456a..60289aea 100644 --- a/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts +++ b/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createInputValue, createLookupDef } from '../_shared' +import { createConstantDef, createInputValue, createLookupDef } from '../_shared' import { ModelListing } from '../model-listing' @@ -39,6 +39,14 @@ const listingJson = ` { "id": "_x", "index": 5 + }, + { + "id": "_constant_1", + "index": 6 + }, + { + "id": "_constant_2", + "index": 7 } ] } @@ -50,9 +58,12 @@ function createMockJsModel(): MockJsModel { finalTime: endTime, outputVarIds: ['_output_1', '_output_2'], listingJson, - onEvalAux: (vars, lookups) => { + onEvalAux: (vars, constants, lookups) => { const time = vars.get('_time') - if (lookups.size > 0) { + // Get constant values, defaulting to 1 and 4 + const constant1 = constants?.get('_constant_1') ?? 1 + const constant2 = constants?.get('_constant_2') ?? 4 + if (lookups && lookups.size > 0) { const lookup1 = lookups.get('_output_1_data') const lookup2 = lookups.get('_output_2_data') expect(lookup1).toBeDefined() @@ -60,8 +71,8 @@ function createMockJsModel(): MockJsModel { vars.set('_output_1', lookup1.getValueForX(time, 'interpolate')) vars.set('_output_2', lookup2.getValueForX(time, 'interpolate')) } else { - vars.set('_output_1', time - startTime + 1) - vars.set('_output_2', time - startTime + 4) + vars.set('_output_1', time - startTime + constant1) + vars.set('_output_2', time - startTime + constant2) vars.set('_x', time - startTime + 7) } } @@ -74,13 +85,17 @@ function createMockWasmModule(): MockWasmModule { finalTime: endTime, outputVarIds: ['_output_1', '_output_2'], listingJson, - onRunModel: (inputs, outputs, lookups, outputIndices) => { + onRunModel: (inputs, outputs, constants, lookups, outputIndices) => { // Verify inputs if (inputs.length > 0) { expect(inputs).toEqual(new Float64Array([7, 8, 9])) } - if (lookups.size > 0) { + // Get constant values, defaulting to 1 and 4 + const constant1 = constants?.get('_constant_1') ?? 1 + const constant2 = constants?.get('_constant_2') ?? 4 + + if (lookups && lookups.size > 0) { // Pretend that outputs are derived from lookup data const lookup1 = lookups.get('_output_1_data') const lookup2 = lookups.get('_output_2_data') @@ -93,7 +108,7 @@ function createMockWasmModule(): MockWasmModule { } else { if (outputIndices === undefined) { // Store 3 values for the _output_1, and 3 for _output_2 - outputs.set([1, 2, 3, 4, 5, 6]) + outputs.set([constant1, constant1 + 1, constant1 + 2, constant2, constant2 + 1, constant2 + 2]) } else { // Verify output indices expect(outputIndices).toEqual( @@ -109,7 +124,7 @@ function createMockWasmModule(): MockWasmModule { ]) ) // Store 3 values for each of the three variables - outputs.set([7, 8, 9, 4, 5, 6, 1, 2, 3]) + outputs.set([7, 8, 9, constant2, constant2 + 1, constant2 + 2, constant1, constant1 + 1, constant1 + 2]) } } } @@ -173,6 +188,39 @@ describe.each([ expect(outOutputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) }) + it('should run the model (with constant overrides)', async () => { + const inputs = [createInputValue('_input_1', 7), createInputValue('_input_2', 8), createInputValue('_input_3', 9)] + let outputs = runner.createOutputs() + + // Run once without constant overrides + outputs = await runner.runModel(inputs, outputs) + + // Verify that outputs contain the original values + expect(outputs.getSeriesForVar('_output_1').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + + // Run again, this time with constant overrides + outputs = await runner.runModel(inputs, outputs, { + constants: [ + // Reference the first constant by name (adds 100 instead of 1) + createConstantDef({ varName: 'constant 1' }, 100), + // Reference the second constant by ID (adds 400 instead of 4) + createConstantDef({ varId: '_constant_2' }, 400) + ] + }) + + // Verify that outputs contain the values using the overridden constants + expect(outputs.getSeriesForVar('_output_1').points).toEqual([p(2000, 100), p(2001, 101), p(2002, 102)]) + expect(outputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 400), p(2001, 401), p(2002, 402)]) + + // Run again without constant overrides + outputs = await runner.runModel(inputs, outputs) + + // Verify that the constant overrides are NOT in effect (they do NOT persist like lookups) + expect(outputs.getSeriesForVar('_output_1').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + }) + it('should run the model (with lookup overrides)', async () => { const inputs = [createInputValue('_input_1', 7), createInputValue('_input_2', 8), createInputValue('_input_3', 9)] let outputs = runner.createOutputs() diff --git a/packages/runtime/src/runnable-model/base-runnable-model.ts b/packages/runtime/src/runnable-model/base-runnable-model.ts index 70d1f507..c32ee63f 100644 --- a/packages/runtime/src/runnable-model/base-runnable-model.ts +++ b/packages/runtime/src/runnable-model/base-runnable-model.ts @@ -1,6 +1,6 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund -import type { LookupDef, OutputVarId } from '../_shared' +import type { ConstantDef, LookupDef, OutputVarId } from '../_shared' import type { ModelListing } from '../model-listing' import { perfElapsed, perfNow } from '../perf' import type { RunModelParams } from './run-model-params' @@ -14,6 +14,7 @@ export type OnRunModelFunc = ( outputs: Float64Array, options?: { outputIndices?: Int32Array + constants?: ConstantDef[] lookups?: LookupDef[] } ) => void @@ -97,6 +98,7 @@ export class BaseRunnableModel implements RunnableModel { const t0 = perfNow() this.onRunModel?.(inputsArray, outputsArray, { outputIndices: outputIndicesArray, + constants: params.getConstants(), lookups: params.getLookups() }) const elapsed = perfElapsed(t0) diff --git a/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts b/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts index 2a71e57d..cfbb758d 100644 --- a/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts +++ b/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' -import { Outputs, createLookupDef, type LookupDef } from '../_shared' +import { Outputs, createConstantDef, createLookupDef, type ConstantDef, type LookupDef } from '../_shared' import { BufferedRunModelParams } from './buffered-run-model-params' import { ModelListing } from '../model-listing' @@ -330,7 +330,58 @@ describe('BufferedRunModelParams', () => { expect(outputs.runTimeInMillis).toBe(42) }) - it('should copy lookups', () => { + it('should copy constant overrides', () => { + const listing = new ModelListing(JSON.parse(listingJson)) + + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const constants: ConstantDef[] = [ + // Reference the first variable by name + createConstantDef({ varName: 'A' }, 42), + // Reference the second variable by ID + createConstantDef({ varId: '_b' }, 100) + ] + + const runnerParams = new BufferedRunModelParams(listing) + const workerParams = new BufferedRunModelParams(listing) + + // Run once without providing constants + runnerParams.updateFromParams(inputs, outputs) + workerParams.updateFromEncodedBuffer(runnerParams.getEncodedBuffer()) + + // Verify that constants array is undefined + expect(workerParams.getConstants()).toBeUndefined() + + // Run again with constants + runnerParams.updateFromParams(inputs, outputs, { constants }) + workerParams.updateFromEncodedBuffer(runnerParams.getEncodedBuffer()) + + // Verify that constants array on the worker side contains the expected values + expect(workerParams.getConstants()).toEqual([ + createConstantDef({ varSpec: { varIndex: 1 } }, 42), + createConstantDef({ varSpec: { varIndex: 2 } }, 100) + ]) + + // Run again without constants + runnerParams.updateFromParams(inputs, outputs) + workerParams.updateFromEncodedBuffer(runnerParams.getEncodedBuffer()) + + // Verify that constants array is undefined + expect(workerParams.getConstants()).toBeUndefined() + + // Run again with a constant referenced by spec + const constantBySpec = createConstantDef({ varSpec: listing.varSpecs.get('_a') }, 999) + runnerParams.updateFromParams(inputs, outputs, { + constants: [constantBySpec] + }) + workerParams.updateFromEncodedBuffer(runnerParams.getEncodedBuffer()) + + // Verify that constants array on the worker side contains the expected values + expect(workerParams.getConstants()).toEqual([constantBySpec]) + }) + + it('should copy lookup overrides', () => { const listing = new ModelListing(JSON.parse(listingJson)) const inputs = [1, 2, 3] diff --git a/packages/runtime/src/runnable-model/buffered-run-model-params.ts b/packages/runtime/src/runnable-model/buffered-run-model-params.ts index e0a5674b..7ada326c 100644 --- a/packages/runtime/src/runnable-model/buffered-run-model-params.ts +++ b/packages/runtime/src/runnable-model/buffered-run-model-params.ts @@ -1,19 +1,22 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund import { + decodeConstants, decodeLookups, + encodeConstants, encodeLookups, encodeVarIndices, + getEncodedConstantBufferLengths, getEncodedLookupBufferLengths, getEncodedVarIndicesLength } from '../_shared' -import type { InputValue, LookupDef, Outputs } from '../_shared' +import type { ConstantDef, InputValue, LookupDef, Outputs } from '../_shared' import type { ModelListing } from '../model-listing' import { resolveVarRef } from './resolve-var-ref' import type { RunModelOptions } from './run-model-options' import type { RunModelParams } from './run-model-params' -const headerLengthInElements = 16 +const headerLengthInElements = 20 const extrasLengthInElements = 1 interface Section { @@ -72,6 +75,8 @@ export class BufferedRunModelParams implements RunModelParams { * inputs * outputs * outputIndices + * constants (values) + * constantIndices * lookups (data) * lookupIndices */ @@ -95,6 +100,12 @@ export class BufferedRunModelParams implements RunModelParams { /** The output indices section of the `encoded` buffer. */ private readonly outputIndices = new Int32Section() + /** The constant values section of the `encoded` buffer. */ + private readonly constants = new Float64Section() + + /** The constant indices section of the `encoded` buffer. */ + private readonly constantIndices = new Int32Section() + /** The lookup data section of the `encoded` buffer. */ private readonly lookups = new Float64Section() @@ -200,6 +211,17 @@ export class BufferedRunModelParams implements RunModelParams { } } + // from RunModelParams interface + getConstants(): ConstantDef[] | undefined { + if (this.constantIndices.lengthInElements === 0) { + return undefined + } + + // Reconstruct the `ConstantDef` instances using the data from the constant values and + // indices buffers + return decodeConstants(this.constantIndices.view, this.constants.view) + } + // from RunModelParams interface getLookups(): LookupDef[] | undefined { if (this.lookupIndices.lengthInElements === 0) { @@ -262,6 +284,26 @@ export class BufferedRunModelParams implements RunModelParams { outputIndicesLengthInElements = 0 } + // Determine the number of elements in the constant values and indices sections + let constantsLengthInElements: number + let constantIndicesLengthInElements: number + if (options?.constants !== undefined && options.constants.length > 0) { + // Resolve the `varSpec` for each `ConstantDef`. If the variable can be resolved, this + // will fill in the `varSpec` for the `ConstantDef`, otherwise it will throw an error. + for (const constantDef of options.constants) { + resolveVarRef(this.listing, constantDef.varRef, 'constant') + } + + // Compute the required lengths + const encodedLengths = getEncodedConstantBufferLengths(options.constants) + constantsLengthInElements = encodedLengths.constantsLength + constantIndicesLengthInElements = encodedLengths.constantIndicesLength + } else { + // Don't use the constant values and indices buffers when constant overrides are not provided + constantsLengthInElements = 0 + constantIndicesLengthInElements = 0 + } + // Determine the number of elements in the lookup data and indices sections let lookupsLengthInElements: number let lookupIndicesLengthInElements: number @@ -301,6 +343,8 @@ export class BufferedRunModelParams implements RunModelParams { const inputsOffsetInBytes = section('float64', inputsLengthInElements) const outputsOffsetInBytes = section('float64', outputsLengthInElements) const outputIndicesOffsetInBytes = section('int32', outputIndicesLengthInElements) + const constantsOffsetInBytes = section('float64', constantsLengthInElements) + const constantIndicesOffsetInBytes = section('int32', constantIndicesLengthInElements) const lookupsOffsetInBytes = section('float64', lookupsLengthInElements) const lookupIndicesOffsetInBytes = section('int32', lookupIndicesLengthInElements) @@ -329,6 +373,10 @@ export class BufferedRunModelParams implements RunModelParams { headerView[headerIndex++] = outputsLengthInElements headerView[headerIndex++] = outputIndicesOffsetInBytes headerView[headerIndex++] = outputIndicesLengthInElements + headerView[headerIndex++] = constantsOffsetInBytes + headerView[headerIndex++] = constantsLengthInElements + headerView[headerIndex++] = constantIndicesOffsetInBytes + headerView[headerIndex++] = constantIndicesLengthInElements headerView[headerIndex++] = lookupsOffsetInBytes headerView[headerIndex++] = lookupsLengthInElements headerView[headerIndex++] = lookupIndicesOffsetInBytes @@ -341,6 +389,8 @@ export class BufferedRunModelParams implements RunModelParams { this.extras.update(this.encoded, extrasOffsetInBytes, extrasLengthInElements) this.outputs.update(this.encoded, outputsOffsetInBytes, outputsLengthInElements) this.outputIndices.update(this.encoded, outputIndicesOffsetInBytes, outputIndicesLengthInElements) + this.constants.update(this.encoded, constantsOffsetInBytes, constantsLengthInElements) + this.constantIndices.update(this.encoded, constantIndicesOffsetInBytes, constantIndicesLengthInElements) this.lookups.update(this.encoded, lookupsOffsetInBytes, lookupsLengthInElements) this.lookupIndices.update(this.encoded, lookupIndicesOffsetInBytes, lookupIndicesLengthInElements) @@ -366,6 +416,11 @@ export class BufferedRunModelParams implements RunModelParams { encodeVarIndices(outputVarSpecs, this.outputIndices.view) } + // Copy the constant values and indices into the internal buffers, if needed + if (constantIndicesLengthInElements > 0) { + encodeConstants(options.constants, this.constantIndices.view, this.constants.view) + } + // Copy the lookup data and indices into the internal buffers, if needed if (lookupIndicesLengthInElements > 0) { encodeLookups(options.lookups, this.lookupIndices.view, this.lookups.view) @@ -403,6 +458,10 @@ export class BufferedRunModelParams implements RunModelParams { const outputsLengthInElements = headerView[headerIndex++] const outputIndicesOffsetInBytes = headerView[headerIndex++] const outputIndicesLengthInElements = headerView[headerIndex++] + const constantsOffsetInBytes = headerView[headerIndex++] + const constantsLengthInElements = headerView[headerIndex++] + const constantIndicesOffsetInBytes = headerView[headerIndex++] + const constantIndicesLengthInElements = headerView[headerIndex++] const lookupsOffsetInBytes = headerView[headerIndex++] const lookupsLengthInElements = headerView[headerIndex++] const lookupIndicesOffsetInBytes = headerView[headerIndex++] @@ -413,6 +472,8 @@ export class BufferedRunModelParams implements RunModelParams { const inputsLengthInBytes = inputsLengthInElements * Float64Array.BYTES_PER_ELEMENT const outputsLengthInBytes = outputsLengthInElements * Float64Array.BYTES_PER_ELEMENT const outputIndicesLengthInBytes = outputIndicesLengthInElements * Int32Array.BYTES_PER_ELEMENT + const constantsLengthInBytes = constantsLengthInElements * Float64Array.BYTES_PER_ELEMENT + const constantIndicesLengthInBytes = constantIndicesLengthInElements * Int32Array.BYTES_PER_ELEMENT const lookupsLengthInBytes = lookupsLengthInElements * Float64Array.BYTES_PER_ELEMENT const lookupIndicesLengthInBytes = lookupIndicesLengthInElements * Int32Array.BYTES_PER_ELEMENT const requiredLengthInBytes = @@ -421,6 +482,8 @@ export class BufferedRunModelParams implements RunModelParams { inputsLengthInBytes + outputsLengthInBytes + outputIndicesLengthInBytes + + constantsLengthInBytes + + constantIndicesLengthInBytes + lookupsLengthInBytes + lookupIndicesLengthInBytes if (buffer.byteLength < requiredLengthInBytes) { @@ -432,6 +495,8 @@ export class BufferedRunModelParams implements RunModelParams { this.inputs.update(this.encoded, inputsOffsetInBytes, inputsLengthInElements) this.outputs.update(this.encoded, outputsOffsetInBytes, outputsLengthInElements) this.outputIndices.update(this.encoded, outputIndicesOffsetInBytes, outputIndicesLengthInElements) + this.constants.update(this.encoded, constantsOffsetInBytes, constantsLengthInElements) + this.constantIndices.update(this.encoded, constantIndicesOffsetInBytes, constantIndicesLengthInElements) this.lookups.update(this.encoded, lookupsOffsetInBytes, lookupsLengthInElements) this.lookupIndices.update(this.encoded, lookupIndicesOffsetInBytes, lookupIndicesLengthInElements) } diff --git a/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts b/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts index 7abe1e56..a49e0841 100644 --- a/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts +++ b/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' -import { Outputs, createLookupDef, type LookupDef } from '../_shared' +import { Outputs, createConstantDef, createLookupDef, type ConstantDef, type LookupDef } from '../_shared' import { ReferencedRunModelParams } from './referenced-run-model-params' import { ModelListing } from '../model-listing' @@ -222,7 +222,53 @@ describe('ReferencedRunModelParams', () => { expect(outputs.getSeriesForVar('_y').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) }) - it('should copy lookups', () => { + it('should copy constant overrides', () => { + const listing = new ModelListing(JSON.parse(listingJson)) + + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const constants: ConstantDef[] = [ + // Reference the first variable by name + createConstantDef({ varName: 'A' }, 42), + // Reference the second variable by ID + createConstantDef({ varId: '_b' }, 100) + ] + + const params = new ReferencedRunModelParams(listing) + + // Run once without providing constants + params.updateFromParams(inputs, outputs) + + // Verify that constants array is undefined + expect(params.getConstants()).toBeUndefined() + + // Run again with constants + params.updateFromParams(inputs, outputs, { constants }) + + // Verify that constants array contains the expected values + expect(params.getConstants()).toEqual([ + createConstantDef({ varName: 'A', varSpec: { varIndex: 1 } }, 42), + createConstantDef({ varId: '_b', varSpec: { varIndex: 2 } }, 100) + ]) + + // Run again without constants + params.updateFromParams(inputs, outputs) + + // Verify that constants array is undefined + expect(params.getConstants()).toBeUndefined() + + // Run again with a constant referenced by spec + const constantBySpec = createConstantDef({ varSpec: listing.varSpecs.get('_a') }, 999) + params.updateFromParams(inputs, outputs, { + constants: [constantBySpec] + }) + + // Verify that constants array contains the expected values + expect(params.getConstants()).toEqual([constantBySpec]) + }) + + it('should copy lookup overrides', () => { const listing = new ModelListing(JSON.parse(listingJson)) const inputs = [1, 2, 3] diff --git a/packages/runtime/src/runnable-model/referenced-run-model-params.ts b/packages/runtime/src/runnable-model/referenced-run-model-params.ts index b29dce21..5a1df168 100644 --- a/packages/runtime/src/runnable-model/referenced-run-model-params.ts +++ b/packages/runtime/src/runnable-model/referenced-run-model-params.ts @@ -1,6 +1,6 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund -import type { InputValue, LookupDef, Outputs } from '../_shared' +import type { ConstantDef, InputValue, LookupDef, Outputs } from '../_shared' import { encodeVarIndices, getEncodedVarIndicesLength } from '../_shared' import type { ModelListing } from '../model-listing' import { resolveVarRef } from './resolve-var-ref' @@ -20,6 +20,7 @@ export class ReferencedRunModelParams implements RunModelParams { private outputs: Outputs private outputsLengthInElements = 0 private outputIndicesLengthInElements = 0 + private constants: ConstantDef[] private lookups: LookupDef[] /** @@ -111,6 +112,15 @@ export class ReferencedRunModelParams implements RunModelParams { } } + // from RunModelParams interface + getConstants(): ConstantDef[] | undefined { + if (this.constants !== undefined && this.constants.length > 0) { + return this.constants + } else { + return undefined + } + } + // from RunModelParams interface getLookups(): LookupDef[] | undefined { if (this.lookups !== undefined && this.lookups.length > 0) { @@ -146,8 +156,17 @@ export class ReferencedRunModelParams implements RunModelParams { this.inputs = inputs this.outputs = outputs this.outputsLengthInElements = outputs.varIds.length * outputs.seriesLength + this.constants = options?.constants this.lookups = options?.lookups + if (this.constants) { + // Resolve the `varSpec` for each `ConstantDef`. If the variable can be resolved, this + // will fill in the `varSpec` for the `ConstantDef`, otherwise it will throw an error. + for (const constantDef of this.constants) { + resolveVarRef(this.listing, constantDef.varRef, 'constant') + } + } + if (this.lookups) { // Resolve the `varSpec` for each `LookupDef`. If the variable can be resolved, this // will fill in the `varSpec` for the `LookupDef`, otherwise it will throw an error. diff --git a/packages/runtime/src/runnable-model/run-model-options.ts b/packages/runtime/src/runnable-model/run-model-options.ts index 505350ed..28edd03f 100644 --- a/packages/runtime/src/runnable-model/run-model-options.ts +++ b/packages/runtime/src/runnable-model/run-model-options.ts @@ -1,11 +1,23 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund -import type { LookupDef } from '../_shared' +import type { ConstantDef, LookupDef } from '../_shared' /** * Additional options that can be passed to a `runModel` call to influence the model run. */ export interface RunModelOptions { + /** + * If defined, override the values for the specified constant variables. + * + * Note that constant overrides do not persist after the `runModel` call. Because + * `initConstants` is called at the beginning of each `runModel` call, all constants + * are reset to their default values before each model run. If you want to override + * constants, you must provide them in the options for each `runModel` call. To + * reset constants to their original values, simply stop passing them in the options + * (or pass an empty array). + */ + constants?: ConstantDef[] + /** * If defined, override the data for the specified lookups and/or data variables. * diff --git a/packages/runtime/src/runnable-model/run-model-params.ts b/packages/runtime/src/runnable-model/run-model-params.ts index d8c8e336..6c935b96 100644 --- a/packages/runtime/src/runnable-model/run-model-params.ts +++ b/packages/runtime/src/runnable-model/run-model-params.ts @@ -1,6 +1,6 @@ // Copyright (c) 2024 Climate Interactive / New Venture Fund -import type { LookupDef, Outputs } from '../_shared' +import type { ConstantDef, LookupDef, Outputs } from '../_shared' /** * Encapsulates the parameters that are passed to a `runModel` call. @@ -74,6 +74,12 @@ export interface RunModelParams { */ storeOutputs(array: Float64Array): void + /** + * Return an array containing constant overrides, or undefined if no constants were passed + * to the latest `runModel` call. + */ + getConstants(): ConstantDef[] | undefined + /** * Return an array containing lookup overrides, or undefined if no lookups were passed to * the latest `runModel` call. diff --git a/packages/runtime/src/wasm-model/_mocks/mock-wasm-module.ts b/packages/runtime/src/wasm-model/_mocks/mock-wasm-module.ts index 7968d108..76e8945c 100644 --- a/packages/runtime/src/wasm-model/_mocks/mock-wasm-module.ts +++ b/packages/runtime/src/wasm-model/_mocks/mock-wasm-module.ts @@ -12,6 +12,7 @@ import type { WasmModule } from '../wasm-module' export type OnRunModel = ( inputs: Float64Array, outputs: Float64Array, + constants: Map | undefined, lookups: Map, outputIndices?: Int32Array ) => void @@ -48,6 +49,7 @@ export class MockWasmModule implements WasmModule { private readonly allocs: Map = new Map() private readonly lookups: Map = new Map() + private readonly constants: Map = new Map() public readonly onRunModel: OnRunModel @@ -108,12 +110,50 @@ export class MockWasmModule implements WasmModule { inputsAddress: number, _inputIndicesAddress: number, outputsAddress: number, - outputIndicesAddress: number + outputIndicesAddress: number, + constantValuesAddress: number, + constantIndicesAddress: number ) => { const inputs = this.getHeapView('float64', inputsAddress) as Float64Array const outputs = this.getHeapView('float64', outputsAddress) as Float64Array const outputIndices = this.getHeapView('int32', outputIndicesAddress) as Int32Array - this.onRunModel(inputs, outputs, this.lookups, outputIndices) + + // Decode constant buffers if provided + this.constants.clear() + if (constantValuesAddress !== 0 && constantIndicesAddress !== 0) { + const constantValues = this.getHeapView('float64', constantValuesAddress) as Float64Array + const constantIndices = this.getHeapView('int32', constantIndicesAddress) as Int32Array + + // Read count + const numConstants = constantIndices[0] + let indicesOffset = 1 + let valuesOffset = 0 + + // Decode each constant + for (let i = 0; i < numConstants; i++) { + const varIndex = constantIndices[indicesOffset++] + const subCount = constantIndices[indicesOffset++] + // TODO: We skip subscript indices for now, but we should test them here too + indicesOffset += subCount + + const varId = this.varIdForSpec({ varIndex }) + if (varId) { + this.constants.set(varId, constantValues[valuesOffset++]) + } + } + } + + // Run the model + this.onRunModel( + inputs, + outputs, + this.constants.size > 0 ? this.constants : undefined, + this.lookups, + outputIndices + ) + + // Clear constants after the run (they don't persist) + this.constants.clear() } default: throw new Error(`Unhandled call to cwrap with function name '${fname}'`) diff --git a/packages/runtime/src/wasm-model/wasm-model.ts b/packages/runtime/src/wasm-model/wasm-model.ts index 012af10b..bdd1e2ed 100644 --- a/packages/runtime/src/wasm-model/wasm-model.ts +++ b/packages/runtime/src/wasm-model/wasm-model.ts @@ -33,6 +33,8 @@ class WasmModel implements RunnableModel { private outputIndicesBuffer: WasmBuffer private lookupDataBuffer: WasmBuffer private lookupSubIndicesBuffer: WasmBuffer + private constantValuesBuffer: WasmBuffer + private constantIndicesBuffer: WasmBuffer private readonly wasmSetLookup: ( varIndex: number, @@ -44,7 +46,9 @@ class WasmModel implements RunnableModel { inputsAddress: number, inputIndicesAddress: number, outputsAddress: number, - outputIndicesAddress: number + outputIndicesAddress: number, + constantValuesAddress: number, + constantIndicesAddress: number ) => void /** @@ -70,7 +74,14 @@ class WasmModel implements RunnableModel { // Make the native functions callable this.wasmSetLookup = wasmModule.cwrap('setLookup', null, ['number', 'number', 'number', 'number']) - this.wasmRunModel = wasmModule.cwrap('runModelWithBuffers', null, ['number', 'number', 'number', 'number']) + this.wasmRunModel = wasmModule.cwrap('runModelWithBuffers', null, [ + 'number', + 'number', + 'number', + 'number', + 'number', + 'number' + ]) } // from RunnableModel interface @@ -128,6 +139,70 @@ class WasmModel implements RunnableModel { } } + // Prepare constant overrides buffer, if provided + let constantIndicesBuffer: WasmBuffer + let constantValuesBuffer: WasmBuffer + const constants = params.getConstants() + if (constants !== undefined && constants.length > 0) { + // Calculate the size needed for the constantIndices buffer: + // count (1) + for each constant: varIndex (1) + subCount (1) + subIndices (variable) + let totalIndicesSize = 1 // for count + for (const constantDef of constants) { + const numSubElements = constantDef.varRef.varSpec.subscriptIndices?.length || 0 + totalIndicesSize += 2 + numSubElements // varIndex + subCount + subIndices + } + + // Allocate the constantIndices buffer + if (this.constantIndicesBuffer === undefined || this.constantIndicesBuffer.numElements < totalIndicesSize) { + this.constantIndicesBuffer?.dispose() + this.constantIndicesBuffer = createInt32WasmBuffer(this.wasmModule, totalIndicesSize) + } + + // Allocate the constantValues buffer (one value per constant) + const numConstants = constants.length + if (this.constantValuesBuffer === undefined || this.constantValuesBuffer.numElements < numConstants) { + this.constantValuesBuffer?.dispose() + this.constantValuesBuffer = createFloat64WasmBuffer(this.wasmModule, numConstants) + } + + // Build the constantIndices and constantValues buffers + const indicesView = this.constantIndicesBuffer.getArrayView() + const valuesView = this.constantValuesBuffer.getArrayView() + let indicesOffset = 0 + let valuesOffset = 0 + + // Write count + indicesView[indicesOffset++] = numConstants + + // Write each constant's data + for (const constantDef of constants) { + const varSpec = constantDef.varRef.varSpec + const numSubElements = varSpec.subscriptIndices?.length || 0 + + // Write varIndex + indicesView[indicesOffset++] = varSpec.varIndex + + // Write subCount + indicesView[indicesOffset++] = numSubElements + + // Write subIndices + if (numSubElements > 0) { + for (let i = 0; i < numSubElements; i++) { + indicesView[indicesOffset++] = varSpec.subscriptIndices[i] + } + } + + // Write value + valuesView[valuesOffset++] = constantDef.value + } + + constantIndicesBuffer = this.constantIndicesBuffer + constantValuesBuffer = this.constantValuesBuffer + } else { + constantIndicesBuffer = undefined + constantValuesBuffer = undefined + } + // Copy the inputs to the `WasmBuffer`. If we don't have an existing `WasmBuffer`, // or the existing one is not big enough, the callback will allocate a new one. params.copyInputs(this.inputsBuffer?.getArrayView(), numElements => { @@ -167,7 +242,9 @@ class WasmModel implements RunnableModel { // provided and are in the same order as the input variables defined in the model spec 0, this.outputsBuffer.getAddress(), - outputIndicesBuffer?.getAddress() || 0 + outputIndicesBuffer?.getAddress() || 0, + constantValuesBuffer?.getAddress() || 0, + constantIndicesBuffer?.getAddress() || 0 ) const elapsed = perfElapsed(t0) @@ -189,6 +266,12 @@ class WasmModel implements RunnableModel { this.outputIndicesBuffer?.dispose() this.outputIndicesBuffer = undefined + this.constantValuesBuffer?.dispose() + this.constantValuesBuffer = undefined + + this.constantIndicesBuffer?.dispose() + this.constantIndicesBuffer = undefined + // TODO: Dispose the `WasmModule` too? } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de36380..2c26b43a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,14 @@ importers: specifier: ^8.46.0 version: 8.46.0(eslint@9.37.0)(typescript@5.2.2) '@vitest/browser': - specifier: ^4.0.16 - version: 4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)) + specifier: ^4.0.17 + version: 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) + '@vitest/browser-playwright': + specifier: ^4.0.17 + version: 4.0.17(playwright@1.56.1)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) '@vitest/coverage-v8': - specifier: ^4.0.16 - version: 4.0.16(@vitest/browser@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)) + specifier: ^4.0.17 + version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17))(vitest@4.0.17) eslint: specifier: ^9.37.0 version: 9.37.0 @@ -72,8 +75,8 @@ importers: specifier: ^5.2.2 version: 5.2.2 vitest: - specifier: ^4.0.16 - version: 4.0.16(@types/node@20.19.19)(sass@1.97.2) + specifier: ^4.0.17 + version: 4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2) examples/hello-world: dependencies: @@ -560,7 +563,7 @@ importers: version: 5.0.10(@storybook/svelte@10.1.11(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(svelte@5.39.10))(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.10)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(svelte@5.39.10)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) '@storybook/addon-vitest': specifier: ^10.1.11 - version: 10.1.11(@vitest/browser@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)))(@vitest/runner@4.0.16)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)) + version: 10.1.11(@vitest/browser-playwright@4.0.17)(@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17))(@vitest/runner@4.0.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.0.17) '@storybook/svelte': specifier: ^10.1.11 version: 10.1.11(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(svelte@5.39.10) @@ -989,6 +992,27 @@ importers: specifier: ^20.14.8 version: 20.19.19 + tests/integration/override-constants: + dependencies: + '@sdeverywhere/build': + specifier: workspace:* + version: link:../../../packages/build + '@sdeverywhere/cli': + specifier: workspace:* + version: link:../../../packages/cli + '@sdeverywhere/plugin-wasm': + specifier: workspace:* + version: link:../../../packages/plugin-wasm + '@sdeverywhere/plugin-worker': + specifier: workspace:* + version: link:../../../packages/plugin-worker + '@sdeverywhere/runtime': + specifier: workspace:* + version: link:../../../packages/runtime + '@sdeverywhere/runtime-async': + specifier: workspace:* + version: link:../../../packages/runtime-async + tests/integration/override-lookups: dependencies: '@sdeverywhere/build': @@ -1844,16 +1868,22 @@ packages: resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/browser@4.0.16': - resolution: {integrity: sha512-t4toy8X/YTnjYEPoY0pbDBg3EvDPg1elCDrfc+VupPHwoN/5/FNQ8Z+xBYIaEnOE2vVEyKwqYBzZ9h9rJtZVcg==} + '@vitest/browser-playwright@4.0.17': + resolution: {integrity: sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==} peerDependencies: - vitest: 4.0.16 + playwright: '*' + vitest: 4.0.17 - '@vitest/coverage-v8@4.0.16': - resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==} + '@vitest/browser@4.0.17': + resolution: {integrity: sha512-cgf2JZk2fv5or3efmOrRJe1V9Md89BPgz4ntzbf84yAb+z2hW6niaGFinl9aFzPZ1q3TGfWZQWZ9gXTFThs2Qw==} peerDependencies: - '@vitest/browser': 4.0.16 - vitest: 4.0.16 + vitest: 4.0.17 + + '@vitest/coverage-v8@4.0.17': + resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} + peerDependencies: + '@vitest/browser': 4.0.17 + vitest: 4.0.17 peerDependenciesMeta: '@vitest/browser': optional: true @@ -1861,8 +1891,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.0.16': - resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@4.0.17': + resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -1875,8 +1905,8 @@ packages: vite: optional: true - '@vitest/mocker@4.0.16': - resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + '@vitest/mocker@4.0.17': + resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -1889,26 +1919,26 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.0.16': - resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@4.0.17': + resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} - '@vitest/runner@4.0.16': - resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/runner@4.0.17': + resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} - '@vitest/snapshot@4.0.16': - resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/snapshot@4.0.17': + resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.0.16': - resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@4.0.17': + resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.16': - resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@4.0.17': + resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -2638,10 +2668,6 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -3570,18 +3596,18 @@ packages: vite: optional: true - vitest@4.0.16: - resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + vitest@4.0.17: + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.16 - '@vitest/browser-preview': 4.0.16 - '@vitest/browser-webdriverio': 4.0.16 - '@vitest/ui': 4.0.16 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -4129,15 +4155,16 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@storybook/addon-vitest@10.1.11(@vitest/browser@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)))(@vitest/runner@4.0.16)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2))': + '@storybook/addon-vitest@10.1.11(@vitest/browser-playwright@4.0.17)(@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17))(@vitest/runner@4.0.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.0.17)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) optionalDependencies: - '@vitest/browser': 4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)) - '@vitest/runner': 4.0.16 - vitest: 4.0.16(@types/node@20.19.19)(sass@1.97.2) + '@vitest/browser': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) + '@vitest/browser-playwright': 4.0.17(playwright@1.56.1)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) + '@vitest/runner': 4.0.17 + vitest: 4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2) transitivePeerDependencies: - react - react-dom @@ -4407,16 +4434,29 @@ snapshots: '@typescript-eslint/types': 8.46.0 eslint-visitor-keys: 4.2.1 - '@vitest/browser@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2))': + '@vitest/browser-playwright@4.0.17(playwright@1.56.1)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17)': dependencies: - '@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) - '@vitest/utils': 4.0.16 + '@vitest/browser': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) + '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) + playwright: 1.56.1 + tinyrainbow: 3.0.3 + vitest: 4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17)': + dependencies: + '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) + '@vitest/utils': 4.0.17 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@20.19.19)(sass@1.97.2) + vitest: 4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -4424,24 +4464,21 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.16(@vitest/browser@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2))': + '@vitest/coverage-v8@4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17))(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.16 + '@vitest/utils': 4.0.17 ast-v8-to-istanbul: 0.3.10 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 magicast: 0.5.1 obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@20.19.19)(sass@1.97.2) + vitest: 4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2) optionalDependencies: - '@vitest/browser': 4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2)) - transitivePeerDependencies: - - supports-color + '@vitest/browser': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) '@vitest/expect@3.2.4': dependencies: @@ -4451,12 +4488,12 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.16': + '@vitest/expect@4.0.17': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 chai: 6.2.2 tinyrainbow: 3.0.3 @@ -4468,9 +4505,9 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.19)(sass@1.97.2) - '@vitest/mocker@4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))': + '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))': dependencies: - '@vitest/spy': 4.0.16 + '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -4480,18 +4517,18 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.16': + '@vitest/pretty-format@4.0.17': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.16': + '@vitest/runner@4.0.17': dependencies: - '@vitest/utils': 4.0.16 + '@vitest/utils': 4.0.17 pathe: 2.0.3 - '@vitest/snapshot@4.0.16': + '@vitest/snapshot@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 @@ -4499,7 +4536,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.16': {} + '@vitest/spy@4.0.17': {} '@vitest/utils@3.2.4': dependencies: @@ -4507,9 +4544,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.16': + '@vitest/utils@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.17 tinyrainbow: 3.0.3 acorn-jsx@5.3.2(acorn@8.15.0): @@ -5209,14 +5246,6 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 @@ -6072,15 +6101,15 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.19)(sass@1.97.2) - vitest@4.0.16(@types/node@20.19.19)(sass@1.97.2): + vitest@4.0.17(@types/node@20.19.19)(@vitest/browser-playwright@4.0.17)(sass@1.97.2): dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.21 @@ -6096,6 +6125,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.19 + '@vitest/browser-playwright': 4.0.17(playwright@1.56.1)(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17) transitivePeerDependencies: - jiti - less diff --git a/tests/integration/override-constants/override-constants.mdl b/tests/integration/override-constants/override-constants.mdl new file mode 100644 index 00000000..ae1481f2 --- /dev/null +++ b/tests/integration/override-constants/override-constants.mdl @@ -0,0 +1,44 @@ +{UTF-8} + +DimA: A1, A2 ~~| +DimB: B1, B2, B3 ~~| + +X = 0 + ~ dmnl [-10,10,0.1] + ~ This is an input variable. + | + +Constant A[DimA] = 100, 200 + ~ dmnl + ~ This is a 1D subscripted constant. + | + +A[DimA] = X + Constant A[DimA] + ~ dmnl + ~ Output based on constant A. + | + +Constant B[DimA, DimB] = 1, 2, 3; 4, 5, 6; + ~ dmnl + ~ This is a 2D subscripted constant. + | + +B[DimA, DimB] = X + Constant B[DimA, DimB] + ~ dmnl + ~ Output based on constant B. + | + +Constant C = 42 + ~ dmnl + ~ This is a non-subscripted constant. + | + +C = X + Constant C + ~ dmnl + ~ Output based on constant C. + | + +INITIAL TIME = 2000 ~~| +FINAL TIME = 2002 ~~| +TIME STEP = 1 ~~| +SAVEPER = TIME STEP ~~| diff --git a/tests/integration/override-constants/package.json b/tests/integration/override-constants/package.json new file mode 100644 index 00000000..2a5294ea --- /dev/null +++ b/tests/integration/override-constants/package.json @@ -0,0 +1,23 @@ +{ + "name": "override-constants", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rm -rf sde-prep", + "build-js": "GEN_FORMAT=js sde bundle", + "build-wasm": "GEN_FORMAT=c sde bundle", + "run-tests": "./run-tests.js", + "test-js": "run-s build-js run-tests", + "test-wasm": "run-s build-wasm run-tests", + "ci:int-test": "run-s clean test-js clean test-wasm" + }, + "dependencies": { + "@sdeverywhere/build": "workspace:*", + "@sdeverywhere/cli": "workspace:*", + "@sdeverywhere/plugin-wasm": "workspace:*", + "@sdeverywhere/plugin-worker": "workspace:*", + "@sdeverywhere/runtime": "workspace:*", + "@sdeverywhere/runtime-async": "workspace:*" + } +} diff --git a/tests/integration/override-constants/run-tests.js b/tests/integration/override-constants/run-tests.js new file mode 100755 index 00000000..955af0d3 --- /dev/null +++ b/tests/integration/override-constants/run-tests.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { readFile } from 'fs/promises' +import { join as joinPath } from 'path' + +import { createInputValue, createConstantDef, createSynchronousModelRunner } from '@sdeverywhere/runtime' +import { spawnAsyncModelRunner } from '@sdeverywhere/runtime-async' + +import loadGeneratedModel from './sde-prep/generated-model.js' + +/* + * This is a JS-level integration test that verifies that both the synchronous + * and asynchronous `ModelRunner` implementations work when overriding constant + * values at runtime. + */ + +function verify(runnerKind, run, outputs, inputX, varId, expectedValue) { + const varName = varId.replaceAll('_', '') + const series = outputs.getSeriesForVar(varId) + if (series === undefined) { + console.error(`Test failed for ${runnerKind} runner: no outputs found for ${varName}\n`) + process.exit(1) + } + + for (let time = 2000; time <= 2002; time++) { + const actual = series.getValueAtTime(time) + const expected = expectedValue(time, inputX) + if (actual !== expected) { + console.error( + `Test failed for ${runnerKind} runner for run=${run} at time=${time}: expected ${varName}=${expected}, got ${varName}=${actual}\n` + ) + process.exit(1) + } + } +} + +function verifyOutputs(runnerKind, run, outputs, inputX, constantAA1Offset, constantBA2B1Offset) { + verify(runnerKind, run, outputs, inputX, '_a[_a1]', (time, x) => x + 100 + constantAA1Offset) + verify(runnerKind, run, outputs, inputX, '_a[_a2]', (time, x) => x + 200) + verify(runnerKind, run, outputs, inputX, '_b[_a1,_b1]', (time, x) => x + 1) + verify(runnerKind, run, outputs, inputX, '_b[_a1,_b2]', (time, x) => x + 2) + verify(runnerKind, run, outputs, inputX, '_b[_a1,_b3]', (time, x) => x + 3) + verify(runnerKind, run, outputs, inputX, '_b[_a2,_b1]', (time, x) => x + 4 + constantBA2B1Offset) + verify(runnerKind, run, outputs, inputX, '_b[_a2,_b2]', (time, x) => x + 5) + verify(runnerKind, run, outputs, inputX, '_b[_a2,_b3]', (time, x) => x + 6) + verify(runnerKind, run, outputs, inputX, '_c', (time, x) => x + 42) +} + +async function runTests(runnerKind, modelRunner) { + const inputX = createInputValue('_x', 0) + const inputs = [inputX] + + let outputs = modelRunner.createOutputs() + + // Run 1: Default constant values + outputs = await modelRunner.runModel(inputs, outputs) + verifyOutputs(runnerKind, 1, outputs, 0, 0, 0) + + // Run 2: Override a couple constants + outputs = await modelRunner.runModel(inputs, outputs, { + constants: [ + createConstantDef({ varName: 'Constant A[A1]' }, 150), + createConstantDef({ varId: '_constant_b[_a2,_b1]' }, 10) + ] + }) + verifyOutputs(runnerKind, 2, outputs, 0, 50, 6) + + // Run 3: Constants do NOT persist (unlike lookups), so they reset to defaults + outputs = await modelRunner.runModel(inputs, outputs) + verifyOutputs(runnerKind, 3, outputs, 0, 0, 0) + + // Run 4: Override constants again + outputs = await modelRunner.runModel(inputs, outputs, { + constants: [ + createConstantDef({ varId: '_constant_a[_a1]' }, 110) + ] + }) + verifyOutputs(runnerKind, 4, outputs, 0, 10, 0) + + // Run 5: Reset one constant back to original by passing the original value + outputs = await modelRunner.runModel(inputs, outputs, { + constants: [ + createConstantDef({ varId: '_constant_a[_a1]' }, 100) + ] + }) + verifyOutputs(runnerKind, 5, outputs, 0, 0, 0) + + await modelRunner.terminate() +} + +async function createSynchronousRunner() { + global.__dirname = '.' + const generatedModel = await loadGeneratedModel() + return createSynchronousModelRunner(generatedModel) +} + +async function createAsynchronousRunner() { + const modelWorkerJs = await readFile(joinPath('sde-prep', 'worker.js'), 'utf8') + return await spawnAsyncModelRunner({ source: modelWorkerJs }) +} + +async function main() { + const syncRunner = await createSynchronousRunner() + await runTests('synchronous', syncRunner) + + const asyncRunner = await createAsynchronousRunner() + await runTests('asynchronous', asyncRunner) + + console.log('Tests passed!\n') +} + +main() diff --git a/tests/integration/override-constants/sde.config.js b/tests/integration/override-constants/sde.config.js new file mode 100644 index 00000000..695930e7 --- /dev/null +++ b/tests/integration/override-constants/sde.config.js @@ -0,0 +1,28 @@ +import { wasmPlugin } from '@sdeverywhere/plugin-wasm' +import { workerPlugin } from '@sdeverywhere/plugin-worker' + +const genFormat = process.env.GEN_FORMAT === 'c' ? 'c' : 'js' + +export async function config() { + return { + genFormat, + modelFiles: ['override-constants.mdl'], + modelSpec: async () => { + return { + inputs: ['X'], + outputs: ['A[A1]', 'A[A2]', 'B[A1,B1]', 'B[A1,B2]', 'B[A1,B3]', 'B[A2,B1]', 'B[A2,B2]', 'B[A2,B3]', 'C'], + bundleListing: true, + customConstants: true + } + }, + + plugins: [ + // If targeting WebAssembly, generate a `generated-model.js` file + // containing the Wasm model + genFormat === 'c' && wasmPlugin(), + + // Generate a `worker.js` file that runs the generated model in a worker + workerPlugin() + ] + } +}