diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv index 28cadb5f..debd9f54 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 -0,100, +graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs +0,100,,false,false,false diff --git a/examples/template-default/config/model.csv b/examples/template-default/config/model.csv index 28cadb5f..debd9f54 100644 --- a/examples/template-default/config/model.csv +++ b/examples/template-default/config/model.csv @@ -1,2 +1,2 @@ -graph default min time,graph default max time,model dat files -0,100, +graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs +0,100,,false,false,false diff --git a/packages/build/docs/interfaces/ModelSpec.md b/packages/build/docs/interfaces/ModelSpec.md index 15734015..135d4a7e 100644 --- a/packages/build/docs/interfaces/ModelSpec.md +++ b/packages/build/docs/interfaces/ModelSpec.md @@ -37,6 +37,59 @@ model. ___ +### bundleListing + + `Optional` **bundleListing**: `boolean` + +Whether to bundle a model listing with the generated model. + +If undefined, defaults to false. + +When this is true, a model listing will be bundled with the generated +model to allow the `runtime` package to resolve variables that are +referenced by name or identifier. This listing will increase the size +of the generated model, so it is recommended to set this to true only +if it is needed. + +___ + +### customLookups + + `Optional` **customLookups**: `boolean` \| `string`[] + +Whether to allow lookups to be overridden at runtime using `setLookup`. + +If undefined or false, the generated model will implement `setLookup` +as a no-op, meaning that lookups cannot be overridden at runtime. + +If true, all lookups 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. + +___ + +### customOutputs + + `Optional` **customOutputs**: `boolean` \| `string`[] + +Whether to allow for capturing the data for arbitrary variables at +runtime (including variables that are not configured in the `outputs` +array). + +If undefined or false, the generated model will implement `storeOutput` +as a no-op, meaning that the data for arbitrary variables cannot be +captured at runtime. + +If true, all variables in the generated model will be available to be +captured at runtime. + +If an array is provided, only those variable names in the array will +be available to be captured at runtime. + +___ + ### options `Optional` **options**: `Object` diff --git a/packages/build/docs/interfaces/ResolvedModelSpec.md b/packages/build/docs/interfaces/ResolvedModelSpec.md index 34eb129c..a1cc884d 100644 --- a/packages/build/docs/interfaces/ResolvedModelSpec.md +++ b/packages/build/docs/interfaces/ResolvedModelSpec.md @@ -58,6 +58,57 @@ model. ___ +### bundleListing + + **bundleListing**: `boolean` + +Whether to bundle a model listing with the generated model. + +When this is true, a model listing will be bundled with the generated +model to allow the `runtime` package to resolve variables that are +referenced by name or identifier. This listing will increase the size +of the generated model, so it is recommended to set this to true only +if it is needed. + +___ + +### customLookups + + **customLookups**: `boolean` \| `string`[] + +Whether to allow lookups to be overridden at runtime using `setLookup`. + +If false, the generated model will contain a `setLookup` function that +throws an error, meaning that lookups cannot be overridden at runtime. + +If true, all lookups 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. + +___ + +### customOutputs + + **customOutputs**: `boolean` \| `string`[] + +Whether to allow for capturing the data for arbitrary variables at +runtime (including variables that are not configured in the `outputs` +array). + +If false, the generated model will contain a `storeOutput` function +that throws an error, meaning that the data for arbitrary variables +cannot be captured at runtime. + +If true, all variables in the generated model will be available to be +captured at runtime. + +If an array is provided, only those variable names in the array will +be available to be captured at runtime. + +___ + ### options `Optional` **options**: `Object` diff --git a/packages/build/src/_shared/model-spec.ts b/packages/build/src/_shared/model-spec.ts index e2e3a4c7..81c61ae2 100644 --- a/packages/build/src/_shared/model-spec.ts +++ b/packages/build/src/_shared/model-spec.ts @@ -64,6 +64,50 @@ export interface ModelSpec { */ datFiles?: string[] + /** + * Whether to bundle a model listing with the generated model. + * + * If undefined, defaults to false. + * + * When this is true, a model listing will be bundled with the generated + * model to allow the `runtime` package to resolve variables that are + * referenced by name or identifier. This listing will increase the size + * of the generated model, so it is recommended to set this to true only + * if it is needed. + */ + bundleListing?: boolean + + /** + * Whether to allow lookups to be overridden at runtime using `setLookup`. + * + * If undefined or false, the generated model will implement `setLookup` + * as a no-op, meaning that lookups cannot be overridden at runtime. + * + * If true, all lookups 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?: boolean | VarName[] + + /** + * Whether to allow for capturing the data for arbitrary variables at + * runtime (including variables that are not configured in the `outputs` + * array). + * + * If undefined or false, the generated model will implement `storeOutput` + * as a no-op, meaning that the data for arbitrary variables cannot be + * captured at runtime. + * + * If true, all variables in the generated model will be available to be + * captured at runtime. + * + * If an array is provided, only those variable names in the array will + * be available to be captured at runtime. + */ + customOutputs?: boolean | VarName[] + /** Additional options included with the SDE `spec.json` file. */ // TODO: Remove references to `spec.json` // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -111,6 +155,48 @@ export interface ResolvedModelSpec { */ datFiles: string[] + /** + * Whether to bundle a model listing with the generated model. + * + * When this is true, a model listing will be bundled with the generated + * model to allow the `runtime` package to resolve variables that are + * referenced by name or identifier. This listing will increase the size + * of the generated model, so it is recommended to set this to true only + * if it is needed. + */ + bundleListing: boolean + + /** + * Whether to allow lookups to be overridden at runtime using `setLookup`. + * + * If false, the generated model will contain a `setLookup` function that + * throws an error, meaning that lookups cannot be overridden at runtime. + * + * If true, all lookups 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: boolean | VarName[] + + /** + * Whether to allow for capturing the data for arbitrary variables at + * runtime (including variables that are not configured in the `outputs` + * array). + * + * If false, the generated model will contain a `storeOutput` function + * that throws an error, meaning that the data for arbitrary variables + * cannot be captured at runtime. + * + * If true, all variables in the generated model will be available to be + * captured at runtime. + * + * If an array is provided, only those variable names in the array will + * be available to be captured at runtime. + */ + customOutputs: boolean | VarName[] + /** Additional options included with the SDE `spec.json` file. */ // TODO: Remove references to `spec.json` // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/build/src/build/impl/build-once.ts b/packages/build/src/build/impl/build-once.ts index 642729ce..ecf1f2ad 100644 --- a/packages/build/src/build/impl/build-once.ts +++ b/packages/build/src/build/impl/build-once.ts @@ -75,6 +75,9 @@ export async function buildOnce( inputVarNames: modelSpec.inputVarNames, outputVarNames: modelSpec.outputVarNames, externalDatfiles: modelSpec.datFiles, + bundleListing: modelSpec.bundleListing, + customLookups: modelSpec.customLookups, + customOutputs: modelSpec.customOutputs, ...modelSpec.options } const specPath = joinPath(config.prepDir, 'spec.json') @@ -217,12 +220,29 @@ function resolveModelSpec(modelSpec: ModelSpec): ResolvedModelSpec { outputSpecs = [] } + let customLookups: boolean | VarName[] + if (modelSpec.customLookups !== undefined) { + customLookups = modelSpec.customLookups + } else { + customLookups = false + } + + let customOutputs: boolean | VarName[] + if (modelSpec.customOutputs !== undefined) { + customOutputs = modelSpec.customOutputs + } else { + customOutputs = false + } + return { inputVarNames, inputs: inputSpecs, outputVarNames, outputs: outputSpecs, datFiles: modelSpec.datFiles || [], + bundleListing: modelSpec.bundleListing === true, + customLookups, + customOutputs, options: modelSpec.options } } diff --git a/packages/build/src/config/config-loader.spec.ts b/packages/build/src/config/config-loader.spec.ts new file mode 100644 index 00000000..d89d1dcb --- /dev/null +++ b/packages/build/src/config/config-loader.spec.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import { loadConfig } from './config-loader' +import { type UserConfig } from './user-config' + +function config(genFormat: string | undefined): UserConfig { + return { + ...(genFormat ? { genFormat: genFormat as 'js' | 'c' } : {}), + modelFiles: [], + modelSpec: async () => { + return { + inputs: [], + outputs: [] + } + } + } +} + +describe('config loader', () => { + it('should resolve genFormat when left undefined', async () => { + const userConfig = config(undefined) + const result = await loadConfig('production', userConfig, '', '') + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + expect(result.value.resolvedConfig.genFormat).toBe('js') + }) + + it('should resolve genFormat when set to js', async () => { + const userConfig = config('js') + const result = await loadConfig('production', userConfig, '', '') + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + expect(result.value.resolvedConfig.genFormat).toBe('js') + }) + + it('should resolve genFormat when set to c', async () => { + const userConfig = config('c') + const result = await loadConfig('production', userConfig, '', '') + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + expect(result.value.resolvedConfig.genFormat).toBe('c') + }) + + it('should fail if genFormat is invalid', async () => { + const userConfig = config('JS') + const result = await loadConfig('production', userConfig, '', '') + if (result.isOk()) { + throw new Error('Expected err result but got: ' + result.value) + } + expect(result.error.message).toBe(`The configured genFormat value is invalid; must be either 'js' or 'c'`) + }) +}) diff --git a/packages/build/tests/build-prod/build-prod.spec.ts b/packages/build/tests/build-prod/build-prod.spec.ts index a529e7b5..bef5873e 100644 --- a/packages/build/tests/build-prod/build-prod.spec.ts +++ b/packages/build/tests/build-prod/build-prod.spec.ts @@ -100,6 +100,9 @@ describe('build in production mode', () => { expect(resolvedModelSpec!.outputVarNames).toEqual(['Z']) expect(resolvedModelSpec!.outputs).toEqual([{ varName: 'Z' }]) expect(resolvedModelSpec!.datFiles).toEqual([]) + expect(resolvedModelSpec!.bundleListing).toBe(false) + expect(resolvedModelSpec!.customLookups).toBe(false) + expect(resolvedModelSpec!.customOutputs).toBe(false) }) it('should resolve model spec (when input/output var names are provided)', async () => { @@ -113,7 +116,10 @@ describe('build in production mode', () => { // Note that we return only variable names here return { inputs: ['Y'], - outputs: ['Z'] + outputs: ['Z'], + bundleListing: true, + customLookups: ['lookup1'], + customOutputs: ['output1'] } }, plugins: [ @@ -137,6 +143,47 @@ describe('build in production mode', () => { expect(resolvedModelSpec!.outputVarNames).toEqual(['Z']) expect(resolvedModelSpec!.outputs).toEqual([{ varName: 'Z' }]) expect(resolvedModelSpec!.datFiles).toEqual([]) + expect(resolvedModelSpec!.bundleListing).toBe(true) + expect(resolvedModelSpec!.customLookups).toEqual(['lookup1']) + expect(resolvedModelSpec!.customOutputs).toEqual(['output1']) + }) + + it('should resolve model spec (when boolean is provided for customLookups and customOutputs)', async () => { + let resolvedModelSpec: ResolvedModelSpec + const userConfig: UserConfig = { + genFormat: 'c', + rootDir: resolvePath(__dirname, '..'), + prepDir: resolvePath(__dirname, 'sde-prep'), + modelFiles: [resolvePath(__dirname, '..', '_shared', 'sample.mdl')], + modelSpec: async () => { + // Note that we return only variable names here + return { + inputs: ['Y'], + outputs: ['Z'], + bundleListing: true, + customLookups: true, + customOutputs: true + } + }, + plugins: [ + { + preGenerate: async (_context, modelSpec) => { + resolvedModelSpec = modelSpec + } + } + ] + } + + const result = await build('production', buildOptions(userConfig)) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + expect(result.value.exitCode).toBe(0) + expect(resolvedModelSpec!).toBeDefined() + expect(resolvedModelSpec!.bundleListing).toBe(true) + expect(resolvedModelSpec!.customLookups).toEqual(true) + expect(resolvedModelSpec!.customOutputs).toEqual(true) }) it('should write listing.json file (when absolute path is provided)', async () => { diff --git a/packages/cli/src/c/model.c b/packages/cli/src/c/model.c index b4ccf320..1854fd23 100644 --- a/packages/cli/src/c/model.c +++ b/packages/cli/src/c/model.c @@ -75,13 +75,6 @@ double getSaveper() { return _saveper; } -/** - * Return the constant `maxOutputIndices` value. - */ -int getMaxOutputIndices() { - return maxOutputIndices; -} - char* run_model(const char* inputs) { // run_model does everything necessary to run the model with the given inputs. // It may be called multiple times. Call finish() after all runs are complete. @@ -156,8 +149,10 @@ void run() { } outputVarIndex = 0; if (outputIndexBuffer != NULL) { - // Store the outputs as specified in the current output index buffer - for (size_t i = 0; i < maxOutputIndices; i++) { + // Store the outputs as specified in the current output index buffer. This + // iterates over the output indices buffer until we reach the first zero index. + size_t i = 0; + while (true) { size_t indexBufferOffset = i * INDICES_PER_OUTPUT; size_t varIndex = (size_t)outputIndexBuffer[indexBufferOffset]; if (varIndex > 0) { @@ -169,6 +164,7 @@ void run() { // Stop when we reach the first zero index break; } + i++; } } else { // Store the normal outputs diff --git a/packages/cli/src/c/sde.h b/packages/cli/src/c/sde.h index 67453e6b..ae96e45e 100644 --- a/packages/cli/src/c/sde.h +++ b/packages/cli/src/c/sde.h @@ -42,7 +42,6 @@ EXTERN double _epsilon; // Internal variables EXTERN const int numOutputs; -EXTERN const int maxOutputIndices; // Standard simulation control parameters EXTERN double _time; diff --git a/packages/compile/src/generate/gen-code-c.js b/packages/compile/src/generate/gen-code-c.js index d5dae23a..451b4564 100644 --- a/packages/compile/src/generate/gen-code-c.js +++ b/packages/compile/src/generate/gen-code-c.js @@ -1,6 +1,6 @@ import * as R from 'ramda' -import { asort, lines, strlist, mapIndexed } from '../_shared/helpers.js' +import { asort, canonicalVensimName, lines, strlist, mapIndexed } from '../_shared/helpers.js' import { sub, allDimensions, allMappings, subscriptFamilies } from '../_shared/subscript.js' import Model from '../model/model.js' @@ -146,8 +146,50 @@ ${chunkedFunctions('evalLevels', Model.levelVars(), ' // Evaluate levels.')}` // Input/output section // function emitIOCode() { + // 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 + // an error (we could add a wrapper function at the JS level) + let setLookupBody + if (spec.customLookups === true || Array.isArray(spec.customLookups)) { + setLookupBody = `\ + switch (varIndex) { +${setLookupImpl(Model.varIndexInfo(), spec.customLookups)} + default: + fprintf(stderr, "No lookup found for var index %zu in setLookup\\n", varIndex); + break; + }` + } else { + let msg = 'The setLookup function was not enabled for the generated model. ' + msg += 'Set the customLookups property in the spec/config file to allow for overriding lookups at runtime.' + setLookupBody = `\ + fprintf(stderr, "${msg}\\n");` + } + + // Configure the output variables that appear in the generated `getHeader` + // and `storeOutputData` functions let headerVarNames = outputAllVars ? expandedVarNames(true) : spec.outputVarNames let outputVarIds = outputAllVars ? expandedVarNames() : spec.outputVars + + // Configure the body of the `storeOutput` function depending on the value + // of the `customOutputs` property in the spec file + let storeOutputBody + if (spec.customOutputs === true || Array.isArray(spec.customOutputs)) { + storeOutputBody = `\ + switch (varIndex) { +${customOutputSection(Model.varIndexInfo(), spec.customOutputs)} + default: + fprintf(stderr, "No variable found for var index %zu in storeOutput\\n", varIndex); + break; + }` + } else { + let msg = 'The storeOutput function was not enabled for the generated model. ' + msg += + 'Set the customOutputs property in the spec/config file to allow for capturing arbitrary variables at runtime.' + storeOutputBody = `\ + fprintf(stderr, "${msg}\\n");` + } + mode = 'io' return `\ void setInputs(const char* inputData) { @@ -172,11 +214,7 @@ void replaceLookup(Lookup** lookup, double* points, size_t numPoints) { } void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { - switch (varIndex) { -${setLookupImpl(Model.varIndexInfo())} - default: - break; - } +${setLookupBody} } const char* getHeader() { @@ -188,13 +226,7 @@ ${specOutputSection(outputVarIds)} } void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t subIndex2) { -#if SDE_USE_OUTPUT_INDICES - switch (varIndex) { -${fullOutputSection(Model.varIndexInfo())} - default: - break; - } -#endif +${storeOutputBody} } ` } @@ -301,9 +333,6 @@ ${section(chunk)} } else { decls = `const int numOutputs = ${spec.outputVars.length};` } - decls += `\n#define SDE_USE_OUTPUT_INDICES 0` - decls += `\n#define SDE_MAX_OUTPUT_INDICES 1000` - decls += `\nconst int maxOutputIndices = SDE_USE_OUTPUT_INDICES ? SDE_MAX_OUTPUT_INDICES : 0;` return decls } function arrayDimensionsSection() { @@ -338,16 +367,33 @@ ${section(chunk)} // Input/output section helpers // function specOutputSection(varNames) { - // Emit output calls using varNames in C format. + // Emit `outputVar` calls for all variables listed in the `outputVarNames` + // array in the spec file using varNames in C format. let code = R.map(varName => ` outputVar(${varName});`) let section = R.pipe(code, lines) return section(varNames) } - function fullOutputSection(varIndexInfo) { - // Emit `storeValue` calls for all variables that can be accessed as an output. + function customOutputSection(varIndexInfo, customOutputs) { + // Emit `outputVar` calls for all variables that can be accessed as an output. // This excludes data and lookup variables; at this time, the data for these // cannot be output like for other types of variables. - const outputVars = R.filter(info => info.varType !== 'lookup' && info.varType !== 'data') + let includeCase + if (Array.isArray(customOutputs)) { + // Only include a case statement if the variable was explicitly included + // in the `customOutputs` array in the spec file + const customOutputVarNames = customOutputs.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 => customOutputVarNames.includes(varName) + } else { + // Include a case statement for all accessible variables + includeCase = () => true + } + const outputVars = R.filter(info => { + return info.varType !== 'lookup' && info.varType !== 'data' && includeCase(info.varName) + }) const code = R.map(info => { let varAccess = info.varName if (info.subscriptCount > 0) { @@ -400,10 +446,26 @@ ${section(chunk)} } return inputVars.join('\n') } - function setLookupImpl(varIndexInfo) { - // Emit `createLookup` calls for all lookups and data variables that can be overridden + function setLookupImpl(varIndexInfo, customLookups) { + // Emit `replaceLookup` calls for all lookups and data variables that can be overridden // at runtime - const lookupAndDataVars = R.filter(info => info.varType === 'lookup' || info.varType === 'data') + let includeCase + if (Array.isArray(customLookups)) { + // Only include a case statement if the variable was explicitly included + // in the `customLookups` array in the spec file + const customLookupVarNames = customLookups.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 => customLookupVarNames.includes(varName) + } else { + // Include a case statement for all lookup and data variables + includeCase = () => true + } + const lookupAndDataVars = R.filter(info => { + return (info.varType === 'lookup' || info.varType === 'data') && includeCase(info.varName) + }) const code = R.map(info => { let lookupVar = info.varName for (let i = 0; i < info.subscriptCount; i++) { diff --git a/packages/compile/src/generate/gen-code-c.spec.ts b/packages/compile/src/generate/gen-code-c.spec.ts index 5d78d5ba..69f7ab7b 100644 --- a/packages/compile/src/generate/gen-code-c.spec.ts +++ b/packages/compile/src/generate/gen-code-c.spec.ts @@ -21,6 +21,8 @@ function readInlineModelAndGenerateC( directDataSpec?: DirectDataSpec inputVarNames?: string[] outputVarNames?: string[] + customLookups?: boolean | string[] + customOutputs?: boolean | string[] } ): string { // XXX: These steps are needed due to subs/dims and variables being in module-level storage @@ -28,14 +30,11 @@ function readInlineModelAndGenerateC( resetSubscriptsAndDimensions() Model.resetModelState() - let spec - if (opts?.inputVarNames || opts?.outputVarNames) { - spec = { - inputVarNames: opts?.inputVarNames || [], - outputVarNames: opts?.outputVarNames || [] - } - } else { - spec = {} + const spec = { + inputVarNames: opts?.inputVarNames, + outputVarNames: opts?.outputVarNames, + customLookups: opts?.customLookups, + customOutputs: opts?.customOutputs } const directData = new Map() @@ -96,9 +95,11 @@ describe('generateC (Vensim -> C)', () => { SAVEPER = 1 ~~| ` const code = readInlineModelAndGenerateC(mdl, { + extData, inputVarNames: ['input'], outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'w'], - extData + customLookups: true, + customOutputs: true }) expect(code).toEqual(`\ #include "sde.h" @@ -123,9 +124,6 @@ double _z; // Internal variables const int numOutputs = 7; -#define SDE_USE_OUTPUT_INDICES 0 -#define SDE_MAX_OUTPUT_INDICES 1000 -const int maxOutputIndices = SDE_USE_OUTPUT_INDICES ? SDE_MAX_OUTPUT_INDICES : 0; // Array dimensions const size_t _dima[2] = { 0, 1 }; @@ -284,6 +282,7 @@ void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPo replaceLookup(&_c_data, points, numPoints); break; default: + fprintf(stderr, "No lookup found for var index %zu in setLookup\\n", varIndex); break; } } @@ -303,7 +302,6 @@ void storeOutputData() { } void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t subIndex2) { -#if SDE_USE_OUTPUT_INDICES switch (varIndex) { case 1: outputVar(_final_time); @@ -342,13 +340,136 @@ void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t sub outputVar(_z); break; default: + fprintf(stderr, "No variable found for var index %zu in storeOutput\\n", varIndex); break; } -#endif } `) }) + it('should generate setLookup that reports error when customLookups is disabled', () => { + const mdl = ` + x = 1 ~~| + y = WITH LOOKUP(x, ( [(0,0)-(2,2)], (0,0),(2,1.3) )) ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + inputVarNames: [], + outputVarNames: ['x', 'y'] + }) + expect(code).toMatch(`\ +void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { + fprintf(stderr, "The setLookup function was not enabled for the generated model. Set the customLookups property in the spec/config file to allow for overriding lookups at runtime.\\n"); +}`) + }) + + it('should generate setLookup that includes a subset of cases when customLookups is an array', () => { + const extData: ExtData = new Map() + function addData(varId: string) { + extData.set( + varId, + new Map([ + [0, 0], + [1, 2], + [2, 5] + ]) + ) + } + addData('_y_data[_a1]') + addData('_y_data[_a2]') + addData('_z_data') + addData('_q_data') + const mdl = ` + DimA: A1, A2 ~~| + x = 1 ~~| + y data[DimA] ~~| + y[DimA] = y data[DimA] ~~| + z data ~~| + z = z data ~~| + q data ~~| + q = q data ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + extData, + inputVarNames: [], + outputVarNames: ['x', 'y[A1]', 'z', 'q'], + customLookups: ['y data[A1]', 'q data'] + }) + expect(code).toMatch(`\ +void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { + switch (varIndex) { + case 6: + replaceLookup(&_q_data, points, numPoints); + break; + case 7: + replaceLookup(&_y_data[subIndices[0]], points, numPoints); + break; + default: + fprintf(stderr, "No lookup found for var index %zu in setLookup\\n", varIndex); + break; + } +}`) + }) + + it('should generate storeOutput that reports error when customOutputs is disabled', () => { + const mdl = ` + x = 1 ~~| + y = x ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + inputVarNames: [], + outputVarNames: ['y'] + }) + expect(code).toMatch(`\ +void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t subIndex2) { + fprintf(stderr, "The storeOutput function was not enabled for the generated model. Set the customOutputs property in the spec/config file to allow for capturing arbitrary variables at runtime.\\n"); +}`) + }) + + it('should generate storeOutput that includes a subset of cases when customOutputs is an array', () => { + const mdl = ` + DimA: A1, A2 ~~| + u[DimA] = 10, 20 ~~| + v[DimA] = u[DimA] + 1 ~~| + x = 1 ~~| + y = x ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateC(mdl, { + inputVarNames: [], + outputVarNames: ['v[A1]', 'y'], + customOutputs: ['u[A1]', 'x'] + }) + expect(code).toMatch(`\ +void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t subIndex2) { + switch (varIndex) { + case 5: + outputVar(_u[subIndex0]); + break; + case 6: + outputVar(_x); + break; + default: + fprintf(stderr, "No variable found for var index %zu in storeOutput\\n", varIndex); + break; + } +}`) + }) + it('should work when valid input variable name without subscript is provided in spec file', () => { const mdl = ` x = 10 ~~| diff --git a/packages/compile/src/generate/gen-code-js.js b/packages/compile/src/generate/gen-code-js.js index 6eff6293..694d536f 100644 --- a/packages/compile/src/generate/gen-code-js.js +++ b/packages/compile/src/generate/gen-code-js.js @@ -44,7 +44,7 @@ let codeGenerator = (parsedModel, opts) => { code += emitInitLevelsCode() code += emitEvalCode() code += emitIOCode() - code += emitModelListing() + code += emitModelListing(spec.bundleListing) code += emitDefaultFunction() return code } @@ -241,6 +241,27 @@ ${chunkedFunctions('evalLevels', true, Model.levelVars(), ' // Evaluate levels' function emitIOCode() { mode = 'io' + // Configure the body of the `setLookup` function depending on the value + // of the `customLookups` property in the spec file + let setLookupBody + if (spec.customLookups === true || Array.isArray(spec.customLookups)) { + setLookupBody = `\ + if (!varSpec) { + throw new Error('Got undefined varSpec in setLookup'); + } + const varIndex = varSpec.varIndex; + const subs = varSpec.subscriptIndices; + switch (varIndex) { +${setLookupImpl(Model.varIndexInfo(), spec.customLookups)} + default: + throw new Error(\`No lookup found for var index \${varIndex} in setLookup\`); + }` + } else { + let msg = 'The setLookup function was not enabled for the generated model. ' + msg += 'Set the customLookups property in the spec/config file to allow for overriding lookups at runtime.' + setLookupBody = ` throw new Error('${msg}');` + } + // This is the list of original output variable names (as supplied by the user in // the `spec.json` file), for example, `a[A2,B1]`. These are exported mainly for // use in the implementation of the `sde exec` command, which generates a TSV file @@ -261,20 +282,33 @@ ${chunkedFunctions('evalLevels', true, Model.levelVars(), ' // Evaluate levels' // `_a[1][0]`. These are used in the implementation of `storeOutputs`. const outputVarAccesses = outputAllVars ? expandedVarNames() : spec.outputVars - return `\ -/*export*/ function setInputs(valueAtIndex /*: (index: number) => number*/) {${inputsFromBufferImpl()}} - -/*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array*/) { + // Configure the body of the `storeOutput` function depending on the value + // of the `customOutputs` property in the spec file + let storeOutputBody + if (spec.customOutputs === true || Array.isArray(spec.customOutputs)) { + storeOutputBody = `\ if (!varSpec) { - throw new Error('Got undefined varSpec in setLookup'); + throw new Error('Got undefined varSpec in storeOutput'); } const varIndex = varSpec.varIndex; const subs = varSpec.subscriptIndices; switch (varIndex) { -${setLookupImpl(Model.varIndexInfo())} +${customOutputSection(Model.varIndexInfo(), spec.customOutputs)} default: - break; - } + throw new Error(\`No variable found for var index \${varIndex} in storeOutput\`); + }` + } else { + let msg = 'The storeOutput function was not enabled for the generated model. ' + msg += + 'Set the customOutputs property in the spec/config file to allow for capturing arbitrary variables at runtime.' + storeOutputBody = ` throw new Error('${msg}');` + } + + return `\ +/*export*/ function setInputs(valueAtIndex /*: (index: number) => number*/) {${inputsFromBufferImpl()}} + +/*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array*/) { +${setLookupBody} } /*export*/ const outputVarIds = [ @@ -290,16 +324,7 @@ ${specOutputSection(outputVarAccesses)} } /*export*/ function storeOutput(varSpec /*: VarSpec*/, storeValue /*: (value: number) => void*/) { - if (!varSpec) { - throw new Error('Got undefined varSpec in storeOutput'); - } - const varIndex = varSpec.varIndex; - const subs = varSpec.subscriptIndices; - switch (varIndex) { -${fullOutputSection(Model.varIndexInfo())} - default: - break; - } +${storeOutputBody} } ` @@ -406,20 +431,6 @@ ${section(chunk)} ) return decls(Model.allVars()) + fixedDelayDecls + depreciationDecls } - // function internalVarsSection() { - // // Declare internal variables to run the model. - // let decls - // if (outputAllVars) { - // decls = `const numOutputs = ${expandedVarNames().length};` - // } else { - // decls = `const numOutputs = ${spec.outputVars.length};` - // } - // // TODO - // // decls += `\n#define SDE_USE_OUTPUT_INDICES 0` - // // decls += `\n#define SDE_MAX_OUTPUT_INDICES 1000` - // // decls += `\nconst int maxOutputIndices = SDE_USE_OUTPUT_INDICES ? SDE_MAX_OUTPUT_INDICES : 0;` - // return decls - // } function arrayDimensionsSection() { // Emit a declaration for each array dimension's index numbers. // These index number arrays will be used to indirectly reference array elements. @@ -453,16 +464,33 @@ ${section(chunk)} // Input/output section helpers // function specOutputSection(varNames) { - // Emit output calls using varNames in C format. + // Emit `storeValue` calls for all variables listed in the `outputVarNames` + // array in the spec file using varNames in C/JS format. let code = R.map(varName => ` storeValue(${varName});`) let section = R.pipe(code, lines) return section(varNames) } - function fullOutputSection(varIndexInfo) { + function customOutputSection(varIndexInfo, customOutputs) { // Emit `storeValue` calls for all variables that can be accessed as an output. // This excludes data and lookup variables; at this time, the data for these // cannot be output like for other types of variables. - const outputVars = R.filter(info => info.varType !== 'lookup' && info.varType !== 'data') + let includeCase + if (Array.isArray(customOutputs)) { + // Only include a case statement if the variable was explicitly included + // in the `customOutputs` array in the spec file + const customOutputVarNames = customOutputs.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 => customOutputVarNames.includes(varName) + } else { + // Include a case statement for all accessible variables + includeCase = () => true + } + const outputVars = R.filter(info => { + return info.varType !== 'lookup' && info.varType !== 'data' && includeCase(info.varName) + }) const code = R.map(info => { let varAccess = info.varName for (let i = 0; i < info.subscriptCount; i++) { @@ -488,10 +516,26 @@ ${section(chunk)} } return inputVars } - function setLookupImpl(varIndexInfo) { + function setLookupImpl(varIndexInfo, customLookups) { // Emit `createLookup` calls for all lookups and data variables that can be overridden // at runtime - const lookupAndDataVars = R.filter(info => info.varType === 'lookup' || info.varType === 'data') + let overrideAllowed + if (Array.isArray(customLookups)) { + // Only include a case statement if the variable was explicitly included + // in the `customLookups` array in the spec file + const customLookupVarNames = customLookups.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 => customLookupVarNames.includes(varName) + } else { + // Include a case statement for all lookup and data variables + overrideAllowed = () => true + } + const lookupAndDataVars = R.filter(info => { + return (info.varType === 'lookup' || info.varType === 'data') && overrideAllowed(info.varName) + }) const code = R.map(info => { let lookupVar = info.varName for (let i = 0; i < info.subscriptCount; i++) { @@ -514,9 +558,14 @@ ${section(chunk)} // // Module exports // - function emitModelListing() { - const minimalListingJson = JSON.stringify(Model.jsonList().minimal, null, 2) - const minimalListingJs = minimalListingJson.replace(/"(\w+)"\s*:/g, '$1:').replaceAll('"', "'") + function emitModelListing(bundleListing) { + let minimalListingJs + if (bundleListing !== false) { + const minimalListingJson = JSON.stringify(Model.jsonList().minimal, null, 2) + minimalListingJs = minimalListingJson.replace(/"(\w+)"\s*:/g, '$1:').replaceAll('"', "'") + } else { + minimalListingJs = 'undefined;' + } return `\ /*export*/ const modelListing = ${minimalListingJs} diff --git a/packages/compile/src/generate/gen-code-js.spec.ts b/packages/compile/src/generate/gen-code-js.spec.ts index 207f67a1..09acdb86 100644 --- a/packages/compile/src/generate/gen-code-js.spec.ts +++ b/packages/compile/src/generate/gen-code-js.spec.ts @@ -21,6 +21,9 @@ function readInlineModelAndGenerateJS( directDataSpec?: DirectDataSpec inputVarNames?: string[] outputVarNames?: string[] + bundleListing?: boolean + customLookups?: boolean | string[] + customOutputs?: boolean | string[] } ): string { // XXX: These steps are needed due to subs/dims and variables being in module-level storage @@ -28,14 +31,12 @@ function readInlineModelAndGenerateJS( resetSubscriptsAndDimensions() Model.resetModelState() - let spec - if (opts?.inputVarNames || opts?.outputVarNames) { - spec = { - inputVarNames: opts?.inputVarNames || [], - outputVarNames: opts?.outputVarNames || [] - } - } else { - spec = {} + const spec = { + inputVarNames: opts?.inputVarNames, + outputVarNames: opts?.outputVarNames, + bundleListing: opts?.bundleListing, + customLookups: opts?.customLookups, + customOutputs: opts?.customOutputs } const directData = new Map() @@ -63,6 +64,7 @@ interface JsModel { readonly kind: 'js' readonly outputVarIds: string[] readonly outputVarNames: string[] + readonly modelListing?: any getInitialTime(): number getFinalTime(): number @@ -202,9 +204,12 @@ describe('generateJS (Vensim -> JS)', () => { SAVEPER = 1 ~~| ` const code = readInlineModelAndGenerateJS(mdl, { + extData, inputVarNames: ['input'], outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'w'], - extData + bundleListing: true, + customLookups: true, + customOutputs: true }) expect(code).toEqual(`\ // Model variables @@ -451,7 +456,7 @@ function evalAux0() { _c_data = fns.createLookup(points.length / 2, points); break; default: - break; + throw new Error(\`No lookup found for var index \${varIndex} in setLookup\`); } } @@ -529,7 +534,7 @@ function evalAux0() { storeValue(_z); break; default: - break; + throw new Error(\`No variable found for var index \${varIndex} in storeOutput\`); } } @@ -659,6 +664,154 @@ export default async function () { `) }) + it('should generate undefined modelListing when bundleListing is disabled', () => { + const mdl = ` + x = 1 ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['x'], + bundleListing: false + }) + expect(code).toMatch(`/*export*/ const modelListing = undefined;`) + }) + + it('should generate setLookup that throws error when customLookups is disabled', () => { + const mdl = ` + x = 1 ~~| + y = WITH LOOKUP(x, ( [(0,0)-(2,2)], (0,0),(2,1.3) )) ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['x', 'y'], + bundleListing: true + }) + expect(code).toMatch(`\ +/*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array*/) { + throw new Error('The setLookup function was not enabled for the generated model. Set the customLookups property in the spec/config file to allow for overriding lookups at runtime.'); +}`) + }) + + it('should generate setLookup that includes a subset of cases when customLookups is an array', () => { + const extData: ExtData = new Map() + function addData(varId: string) { + extData.set( + varId, + new Map([ + [0, 0], + [1, 2], + [2, 5] + ]) + ) + } + addData('_y_data[_a1]') + addData('_y_data[_a2]') + addData('_z_data') + addData('_q_data') + const mdl = ` + DimA: A1, A2 ~~| + x = 1 ~~| + y data[DimA] ~~| + y[DimA] = y data[DimA] ~~| + z data ~~| + z = z data ~~| + q data ~~| + q = q data ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + extData, + inputVarNames: [], + outputVarNames: ['x', 'y[A1]', 'z', 'q'], + customLookups: ['y data[A1]', 'q data'] + }) + expect(code).toMatch(`\ +/*export*/ function setLookup(varSpec /*: VarSpec*/, points /*: Float64Array*/) { + if (!varSpec) { + throw new Error('Got undefined varSpec in setLookup'); + } + const varIndex = varSpec.varIndex; + const subs = varSpec.subscriptIndices; + switch (varIndex) { + case 6: + _q_data = fns.createLookup(points.length / 2, points); + break; + case 7: + _y_data[subs[0]] = fns.createLookup(points.length / 2, points); + break; + default: + throw new Error(\`No lookup found for var index \${varIndex} in setLookup\`); + } +}`) + }) + + it('should generate storeOutput that reports error when customOutputs is disabled', () => { + const mdl = ` + x = 1 ~~| + y = x ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['y'] + }) + expect(code).toMatch(`\ +/*export*/ function storeOutput(varSpec /*: VarSpec*/, storeValue /*: (value: number) => void*/) { + throw new Error('The storeOutput function was not enabled for the generated model. Set the customOutputs property in the spec/config file to allow for capturing arbitrary variables at runtime.'); +}`) + }) + + it('should generate storeOutput that includes a subset of cases when customOutputs is an array', () => { + const mdl = ` + DimA: A1, A2 ~~| + u[DimA] = 10, 20 ~~| + v[DimA] = u[DimA] + 1 ~~| + x = 1 ~~| + y = x ~~| + INITIAL TIME = 0 ~~| + FINAL TIME = 2 ~~| + TIME STEP = 1 ~~| + SAVEPER = 1 ~~| + ` + const code = readInlineModelAndGenerateJS(mdl, { + inputVarNames: [], + outputVarNames: ['v[A1]', 'y'], + customOutputs: ['u[A1]', 'x'] + }) + expect(code).toMatch(`\ +/*export*/ function storeOutput(varSpec /*: VarSpec*/, storeValue /*: (value: number) => void*/) { + if (!varSpec) { + throw new Error('Got undefined varSpec in storeOutput'); + } + const varIndex = varSpec.varIndex; + const subs = varSpec.subscriptIndices; + switch (varIndex) { + case 5: + storeValue(_u[subs[0]]); + break; + case 6: + storeValue(_x); + break; + default: + throw new Error(\`No variable found for var index \${varIndex} in storeOutput\`); + } +}`) + }) + it('should generate a model that can be run', async () => { // TODO: Change this test to call each exported function diff --git a/packages/plugin-config/src/__tests__/config1/model.csv b/packages/plugin-config/src/__tests__/config1/model.csv index 1693fd5b..e78d93c7 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 -0,200,Data1.dat;Data2.dat +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 diff --git a/packages/plugin-config/src/__tests__/config2/colors.csv b/packages/plugin-config/src/__tests__/config2/colors.csv new file mode 100644 index 00000000..78aafaa4 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/colors.csv @@ -0,0 +1,3 @@ +id,hex code,name,comment +baseline,#000000,black,baseline +current_scenario,#0000ff,blue,current scenario diff --git a/packages/plugin-config/src/__tests__/config2/graphs.csv b/packages/plugin-config/src/__tests__/config2/graphs.csv new file mode 100644 index 00000000..10a5e520 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/graphs.csv @@ -0,0 +1,2 @@ +id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2,description +1,,Parent Menu 1,Graph 1 Title,,,,line,,,,,,,50,100,X-Axis,,,,300,,Y-Axis,,,,Var 1,Ref,line,Baseline,baseline,,,Var 1,,line,Current Scenario,current_scenario,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, \ No newline at end of file diff --git a/packages/plugin-config/src/__tests__/config2/inputs.csv b/packages/plugin-config/src/__tests__/config2/inputs.csv new file mode 100644 index 00000000..5eb9e645 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/inputs.csv @@ -0,0 +1,2 @@ +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description +1,slider,v1,Input A,Slider A Label,,Input Group 1,-50,50,0,1,%,,,-25,-10,10,25,,lowest,low,status quo,high,highest,,,,This is a description of Slider A diff --git a/packages/plugin-config/src/__tests__/config2/model.csv b/packages/plugin-config/src/__tests__/config2/model.csv new file mode 100644 index 00000000..5422e220 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/model.csv @@ -0,0 +1,2 @@ +graph default min time,graph default max time,model dat files,bundle listing,custom lookups,custom outputs +0,200,,true,true,true diff --git a/packages/plugin-config/src/__tests__/config2/outputs.csv b/packages/plugin-config/src/__tests__/config2/outputs.csv new file mode 100644 index 00000000..72a4edf6 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/outputs.csv @@ -0,0 +1 @@ +variable name diff --git a/packages/plugin-config/src/__tests__/config2/strings.csv b/packages/plugin-config/src/__tests__/config2/strings.csv new file mode 100644 index 00000000..75dc1672 --- /dev/null +++ b/packages/plugin-config/src/__tests__/config2/strings.csv @@ -0,0 +1,3 @@ +id,string +__string_1,String 1 +__string_2,String 2 diff --git a/packages/plugin-config/src/context.ts b/packages/plugin-config/src/context.ts index 8b572dce..2dee9c80 100644 --- a/packages/plugin-config/src/context.ts +++ b/packages/plugin-config/src/context.ts @@ -15,6 +15,15 @@ import { sdeNameForVensimVarName } from './var-names' export type CsvRow = { [key: string]: string } export type ColorId = string +export interface ModelOptions { + readonly graphDefaultMinTime: number + readonly graphDefaultMaxTime: number + readonly datFiles: string[] + readonly bundleListing: boolean + readonly customLookups: boolean + readonly customOutputs: boolean +} + export class ConfigContext { private readonly inputSpecs: Map = new Map() private readonly outputVarNames: Map = new Map() @@ -25,9 +34,7 @@ export class ConfigContext { private readonly configDir: string, public readonly strings: Strings, private readonly colorMap: Map, - public readonly graphDefaultMinTime: number, - public readonly graphDefaultMaxTime: number, - public readonly datFiles: string[] + public readonly modelOptions: ModelOptions ) {} /** @@ -156,6 +163,23 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin const projDir = buildContext.config.rootDir 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 + // and if so, make an array of variable names instead of setting a boolean in `spec.json`. + // (Same thing for customOutputs.) + const bundleListing = modelCsv['bundle listing'] === 'true' + const customLookups = modelCsv['custom lookups'] === 'true' + const customOutputs = modelCsv['custom outputs'] === 'true' + + const modelOptions: ModelOptions = { + graphDefaultMinTime, + graphDefaultMaxTime, + datFiles, + bundleListing, + customLookups, + customOutputs + } + // Read the static strings from `strings.csv` const strings = readStringsCsv(configDir) @@ -168,7 +192,7 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin colors.set(colorId, hexColor) } - return new ConfigContext(buildContext, configDir, strings, colors, graphDefaultMinTime, graphDefaultMaxTime, datFiles) + return new ConfigContext(buildContext, configDir, strings, colors, modelOptions) } function configFilePath(configDir: string, name: string, ext: string): string { diff --git a/packages/plugin-config/src/gen-graphs.ts b/packages/plugin-config/src/gen-graphs.ts index e4636bbf..f9af954b 100644 --- a/packages/plugin-config/src/gen-graphs.ts +++ b/packages/plugin-config/src/gen-graphs.ts @@ -150,8 +150,9 @@ function graphSpecFromCsv(g: CsvRow, context: ConfigContext): GraphSpec | undefi } } - const xMin = optionalNumber(g['x axis min']) || context.graphDefaultMinTime - const xMax = optionalNumber(g['x axis max']) || context.graphDefaultMaxTime + const modelOptions = context.modelOptions + const xMin = optionalNumber(g['x axis min']) || modelOptions.graphDefaultMinTime + const xMax = optionalNumber(g['x axis max']) || modelOptions.graphDefaultMaxTime const xAxisLabel = optionalString(g['x axis label']) let xAxisLabelKey: StringKey if (xAxisLabel) { diff --git a/packages/plugin-config/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts index 04abf2ea..4bd865f3 100644 --- a/packages/plugin-config/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -67,7 +67,10 @@ const specJson1 = `\ "externalDatfiles": [ "../Data1.dat", "../Data2.dat" - ] + ], + "bundleListing": false, + "customLookups": false, + "customOutputs": false }\ ` @@ -85,6 +88,9 @@ const specJson2 = `\ "../Data1.dat", "../Data2.dat" ], + "bundleListing": false, + "customLookups": false, + "customOutputs": false, "directData": { "?data1": "data1.xlsx", "?data2": "data2.xlsx" @@ -92,6 +98,21 @@ const specJson2 = `\ }\ ` +const specJson3 = `\ +{ + "inputVarNames": [ + "Input A" + ], + "outputVarNames": [ + "Var 1" + ], + "externalDatfiles": [], + "bundleListing": true, + "customLookups": true, + "customOutputs": true +}\ +` + const modelSpec1 = `\ // This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually! export const inputVarIds: string[] = [ @@ -359,4 +380,23 @@ describe('configProcessor', () => { const specJsonFile = joinPath(testEnv.projDir, 'sde-prep', 'spec.json') expect(await readFile(specJsonFile, 'utf8')).toEqual(specJson2) }) + + it('should include other options from model.csv', async () => { + const configDir = joinPath(__dirname, '__tests__', 'config2') + const testEnv = await prepareForBuild(corePkgDir => ({ + config: configDir, + out: { + modelSpecsDir: joinPath(corePkgDir, 'mgen'), + configSpecsDir: joinPath(corePkgDir, 'cgen'), + stringsDir: joinPath(corePkgDir, 'sgen') + } + })) + const result = await build('production', testEnv.buildOptions) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + const specJsonFile = joinPath(testEnv.projDir, 'sde-prep', 'spec.json') + expect(await readFile(specJsonFile, 'utf8')).toEqual(specJson3) + }) }) diff --git a/packages/plugin-config/src/processor.ts b/packages/plugin-config/src/processor.ts index c1510a55..884b413b 100644 --- a/packages/plugin-config/src/processor.ts +++ b/packages/plugin-config/src/processor.ts @@ -104,6 +104,7 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigPro // Create a container for strings, variables, etc const context = createConfigContext(buildContext, options.config) + const modelOptions = context.modelOptions // Write the generated files context.log('info', 'Generating files...') @@ -132,7 +133,10 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigPro return { inputs: context.getOrderedInputs(), outputs: context.getOrderedOutputs(), - datFiles: context.datFiles, + datFiles: modelOptions.datFiles, + bundleListing: modelOptions.bundleListing, + 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 f970f945..a5d2eea1 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 -0,100,Data1.dat;Data2.dat +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 diff --git a/packages/plugin-wasm/docs/interfaces/WasmPluginOptions.md b/packages/plugin-wasm/docs/interfaces/WasmPluginOptions.md index 893b8baa..3fa427f3 100644 --- a/packages/plugin-wasm/docs/interfaces/WasmPluginOptions.md +++ b/packages/plugin-wasm/docs/interfaces/WasmPluginOptions.md @@ -31,7 +31,7 @@ with) Emscripten versions 2.0.34 and 3.1.46, among others. -s EXPORT_ES6=1 -s USE_ES6_IMPORT_META=0 -s ENVIRONMENT='web,webview,worker' - -s EXPORTED_FUNCTIONS=['_malloc','_free','_getMaxOutputIndices','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers'] + -s EXPORTED_FUNCTIONS=['_malloc','_free','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers'] -s EXPORTED_RUNTIME_METHODS=['cwrap'] ``` diff --git a/packages/plugin-wasm/src/options.ts b/packages/plugin-wasm/src/options.ts index 72fb80e1..152740a5 100644 --- a/packages/plugin-wasm/src/options.ts +++ b/packages/plugin-wasm/src/options.ts @@ -22,7 +22,7 @@ export interface WasmPluginOptions { * -s EXPORT_ES6=1 * -s USE_ES6_IMPORT_META=0 * -s ENVIRONMENT='web,webview,worker' - * -s EXPORTED_FUNCTIONS=['_malloc','_free','_getMaxOutputIndices','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers'] + * -s EXPORTED_FUNCTIONS=['_malloc','_free','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers'] * -s EXPORTED_RUNTIME_METHODS=['cwrap'] * ``` */ diff --git a/packages/plugin-wasm/src/plugin.ts b/packages/plugin-wasm/src/plugin.ts index 619bd925..689c6380 100644 --- a/packages/plugin-wasm/src/plugin.ts +++ b/packages/plugin-wasm/src/plugin.ts @@ -17,14 +17,18 @@ export function wasmPlugin(options?: WasmPluginOptions): Plugin { } class WasmPlugin implements Plugin { - /** The output var IDs captured in `preGenerate`. */ + // The properties from `modelSpec` captured in `preGenerate` private outputVarIds: string[] + private bundleListing: boolean constructor(private readonly options?: WasmPluginOptions) {} async preGenerate(_context: BuildContext, modelSpec: ResolvedModelSpec): Promise { - // Save the output var IDs for later processing + // Save some properties for later processing. This is a workaround for the fact + // that `modelSpec` is not passed to `postGenerateCode`, so we need to capture + // these values here. this.outputVarIds = modelSpec.outputs.map(o => sdeNameForVensimVarName(o.varName)) + this.bundleListing = modelSpec.bundleListing } async postGenerateCode(context: BuildContext, format: 'js' | 'c', content: string): Promise { @@ -34,12 +38,18 @@ class WasmPlugin implements Plugin { context.log('info', ' Generating WebAssembly module') - // Read the minimal model listing const buildDir = joinPath(context.config.prepDir, 'build') - const modelListingPath = joinPath(buildDir, 'processed_min.json') - const modelListingJson = await readFile(modelListingPath, 'utf8') - const modelListingObj = JSON.parse(modelListingJson) - const modelListingJs = JSON.stringify(modelListingObj).replace(/"(\w+)"\s*:/g, '$1:') + let modelListingJs: string + if (this.bundleListing === true) { + // Include the minimal model listing + const modelListingPath = joinPath(buildDir, 'processed_min.json') + const modelListingJson = await readFile(modelListingPath, 'utf8') + const modelListingObj = JSON.parse(modelListingJson) + modelListingJs = JSON.stringify(modelListingObj).replace(/"(\w+)"\s*:/g, '$1:') + } else { + // Omit the minimal model listing + modelListingJs = 'undefined;' + } // Write a file that will be folded into the generated Wasm module const preJsFile = joinPath(buildDir, 'processed_extras.js') @@ -161,7 +171,7 @@ async function buildWasm( // and Node.js contexts (tested in Emscripten 2.0.34 and 3.1.46). addFlag(`ENVIRONMENT='web,webview,worker'`) addFlag( - `EXPORTED_FUNCTIONS=['_malloc','_free','_getMaxOutputIndices','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers']` + `EXPORTED_FUNCTIONS=['_malloc','_free','_getInitialTime','_getFinalTime','_getSaveper','_setLookup','_runModelWithBuffers']` ) addFlag(`EXPORTED_RUNTIME_METHODS=['cwrap']`) } diff --git a/tests/integration/impl-var-access-no-time/sde.config.js b/tests/integration/impl-var-access-no-time/sde.config.js index 3b8085d7..b05784f2 100644 --- a/tests/integration/impl-var-access-no-time/sde.config.js +++ b/tests/integration/impl-var-access-no-time/sde.config.js @@ -11,24 +11,12 @@ export async function config() { modelSpec: async () => { return { inputs: ['X'], - outputs: ['Z', 'D[A1]'] + outputs: ['Z', 'D[A1]'], + customOutputs: true } }, plugins: [ - // Include a custom plugin that applies post-processing steps - { - postGenerateCode: (_, format, content) => { - if (format === 'c') { - // Edit the generated C code so that it enables the `SDE_USE_OUTPUT_INDICES` flag; this is - // required in order to access impl (non-exported) model variables - return content.replace('#define SDE_USE_OUTPUT_INDICES 0', '#define SDE_USE_OUTPUT_INDICES 1') - } else { - return content - } - } - }, - // If targeting WebAssembly, generate a `generated-model.js` file // containing the Wasm model genFormat === 'c' && wasmPlugin(), diff --git a/tests/integration/impl-var-access/run-tests.js b/tests/integration/impl-var-access/run-tests.js index 75bcd4f2..4ff828a4 100755 --- a/tests/integration/impl-var-access/run-tests.js +++ b/tests/integration/impl-var-access/run-tests.js @@ -11,8 +11,8 @@ 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 with a generated model that - * has the `SDE_USE_OUTPUT_INDICES` flag enabled (which allows for accessing - * internal/impl variables). + * allows for accessing internal/impl variables (i.e., was generated with the + * `customOutputs` flag enabled in the model spec). */ function verify(runnerKind, outputs, inputX, varId, checkValue) { diff --git a/tests/integration/impl-var-access/sde.config.js b/tests/integration/impl-var-access/sde.config.js index 4b25d8f9..a3884ea0 100644 --- a/tests/integration/impl-var-access/sde.config.js +++ b/tests/integration/impl-var-access/sde.config.js @@ -11,24 +11,12 @@ export async function config() { modelSpec: async () => { return { inputs: ['X'], - outputs: ['Z', 'D[A1]', 'E[A2,B1]'] + outputs: ['Z', 'D[A1]', 'E[A2,B1]'], + customOutputs: true } }, plugins: [ - // Include a custom plugin that applies post-processing steps - { - postGenerateCode: (_, format, content) => { - if (format === 'c') { - // Edit the generated C code so that it enables the `SDE_USE_OUTPUT_INDICES` flag; this is - // required in order to access impl (non-exported) model variables - return content.replace('#define SDE_USE_OUTPUT_INDICES 0', '#define SDE_USE_OUTPUT_INDICES 1') - } else { - return content - } - } - }, - // If targeting WebAssembly, generate a `generated-model.js` file // containing the Wasm model genFormat === 'c' && wasmPlugin(), diff --git a/tests/integration/override-lookups/sde.config.js b/tests/integration/override-lookups/sde.config.js index 92427c62..50ed439e 100644 --- a/tests/integration/override-lookups/sde.config.js +++ b/tests/integration/override-lookups/sde.config.js @@ -12,7 +12,9 @@ export async function config() { 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'], - datFiles: ['../override-lookups.dat'] + datFiles: ['../override-lookups.dat'], + bundleListing: true, + customLookups: true } },