diff --git a/examples/house-game/model/houses.dat b/examples/house-game/model/houses.dat deleted file mode 100644 index 84d37011..00000000 --- a/examples/house-game/model/houses.dat +++ /dev/null @@ -1,2 +0,0 @@ -planning data -0 0 diff --git a/examples/house-game/model/houses.mdl b/examples/house-game/model/houses.mdl index 27fec655..008f221a 100644 --- a/examples/house-game/model/houses.mdl +++ b/examples/house-game/model/houses.mdl @@ -3,11 +3,12 @@ replacement houses = demolishing ~ house/Month ~ | -planning data ~~| +initial planning = INITIAL ( MAX( 0, replacement houses + (gap in houses / time to respond to gap) ) ) + ~ house/Month + ~ | -planning = GET DATA BETWEEN TIMES(planning data, Time, -1) +planning = GAME(initial planning) ~ house/Month - ~ Originally: GAME( MAX( 0, replacement houses + (gap in houses / time to respond to gap)) ) ~ | average house life = 600 diff --git a/examples/house-game/packages/app/src/model/app-model.ts b/examples/house-game/packages/app/src/model/app-model.ts index 728abd4a..9cece9d2 100644 --- a/examples/house-game/packages/app/src/model/app-model.ts +++ b/examples/house-game/packages/app/src/model/app-model.ts @@ -120,7 +120,7 @@ export class AppModel { } ] } - const gameLookup = createLookupDef({ varName: 'planning data' }, this.gameLookupPoints) + const gameLookup = createLookupDef({ varName: 'planning game inputs' }, this.gameLookupPoints) const lookups = [gameLookup] // Set the "busy" flag (to put the UI into a non-editable state) diff --git a/examples/house-game/sde.config.js b/examples/house-game/sde.config.js index 7cc45b95..d42c94bc 100644 --- a/examples/house-game/sde.config.js +++ b/examples/house-game/sde.config.js @@ -25,7 +25,8 @@ export async function config() { 'time to respond to gap' ], outputs: ['number of houses required', 'houses completed'], - datFiles: ['../model/houses.dat'] + bundleListing: true, + customLookups: ['planning game inputs'] } }, diff --git a/packages/cli/src/c/vensim.c b/packages/cli/src/c/vensim.c index 51339260..77406b1d 100644 --- a/packages/cli/src/c/vensim.c +++ b/packages/cli/src/c/vensim.c @@ -260,6 +260,23 @@ double _LOOKUP_INVERT(Lookup* lookup, double y) { return __lookup(lookup, y, true, Interpolate); } +double _GAME(Lookup* lookup, double default_value) { + if (lookup == NULL || lookup->n <= 0) { + // The lookup is NULL or empty, so return the default value + return default_value; + } + + double x0 = lookup->data[0]; + if (_time < x0) { + // The current time is earlier than the first data point, so return the + // default value + return default_value; + } + + // For all other cases, we can use `__lookup` with `Backward` mode + return __lookup(lookup, _time, false, Backward); +} + typedef struct { double x; int ind; diff --git a/packages/cli/src/c/vensim.h b/packages/cli/src/c/vensim.h index 84f8b970..f38370d9 100644 --- a/packages/cli/src/c/vensim.h +++ b/packages/cli/src/c/vensim.h @@ -21,7 +21,6 @@ extern "C" { #define _ARCTAN(x) atan(x) #define _COS(x) cos(x) #define _EXP(x) exp(x) -#define _GAME(x) (x) #define _GAMMA_LN(x) lgamma(x) #define _IF_THEN_ELSE(c, t, f) (bool_cond(c) ? (t) : (f)) #define _INTEG(value, rate) ((value) + (rate) * _time_step) @@ -78,6 +77,8 @@ double __get_data_between_times(Lookup* lookup, double input, LookupMode mode); #define _GET_DATA_MODE_TO_LOOKUP_MODE(mode) ((mode) >= 1) ? Forward : (((mode) <= -1) ? Backward : Interpolate) #define _GET_DATA_BETWEEN_TIMES(lookup, x, mode) __get_data_between_times(lookup, x, _GET_DATA_MODE_TO_LOOKUP_MODE(mode)) +double _GAME(Lookup* lookup, double default_value); + // // DELAY FIXED // diff --git a/packages/compile/src/_tests/test-support.ts b/packages/compile/src/_tests/test-support.ts index 63724842..efe2c5ee 100644 --- a/packages/compile/src/_tests/test-support.ts +++ b/packages/compile/src/_tests/test-support.ts @@ -69,7 +69,7 @@ export interface Variable { refId: string varType: VariableType // TODO: Remove empty string variant - varSubtype: '' | 'fixedDelay' | 'depreciation' + varSubtype: '' | 'fixedDelay' | 'depreciation' | 'gameInputs' referencedFunctionNames?: string[] referencedLookupVarNames?: string[] references: string[] @@ -83,6 +83,7 @@ export interface Variable { delayTimeVarName: string fixedDelayVarName: string depreciationVarName: string + gameLookupVarName: string includeInOutput: boolean } diff --git a/packages/compile/src/generate/gen-code-c.spec.ts b/packages/compile/src/generate/gen-code-c.spec.ts index 69f7ab7b..28b3aa70 100644 --- a/packages/compile/src/generate/gen-code-c.spec.ts +++ b/packages/compile/src/generate/gen-code-c.spec.ts @@ -88,6 +88,7 @@ describe('generateC (Vensim -> C)', () => { b[DimA, DimB] = b data[DimA, DimB] ~~| c data ~~| c = c data ~~| + d[DimA] = GAME(x) ~~| w = WITH LOOKUP(x, ( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) )) ~~| INITIAL TIME = 0 ~~| FINAL TIME = 2 ~~| @@ -97,7 +98,7 @@ describe('generateC (Vensim -> C)', () => { const code = readInlineModelAndGenerateC(mdl, { extData, inputVarNames: ['input'], - outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'w'], + outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'd[A1]', 'w'], customLookups: true, customOutputs: true }) @@ -109,9 +110,11 @@ Lookup* __lookup1; Lookup* _a_data[2]; Lookup* _b_data[2][2]; Lookup* _c_data; +Lookup* _d_game_inputs[2]; double _a[2]; double _b[2][2]; double _c; +double _d[2]; double _final_time; double _initial_time; double _input; @@ -123,7 +126,7 @@ double _y; double _z; // Internal variables -const int numOutputs = 7; +const int numOutputs = 8; // Array dimensions const size_t _dima[2] = { 0, 1 }; @@ -220,6 +223,10 @@ void evalAux0() { _x = _input; // w = WITH LOOKUP(x,([(0,0)-(2,2)],(0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3))) _w = _WITH_LOOKUP(_x, __lookup1); + // d[DimA] = GAME(x) + for (size_t i = 0; i < 2; i++) { + _d[i] = _GAME(_d_game_inputs[i], _x); + } // y = :NOT: x _y = !_x; // z = ABS(y) @@ -273,12 +280,15 @@ void replaceLookup(Lookup** lookup, double* points, size_t numPoints) { void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPoints) { switch (varIndex) { case 6: - replaceLookup(&_a_data[subIndices[0]], points, numPoints); + replaceLookup(&_d_game_inputs[subIndices[0]], points, numPoints); break; case 7: - replaceLookup(&_b_data[subIndices[0]][subIndices[1]], points, numPoints); + replaceLookup(&_a_data[subIndices[0]], points, numPoints); break; case 8: + replaceLookup(&_b_data[subIndices[0]][subIndices[1]], points, numPoints); + break; + case 9: replaceLookup(&_c_data, points, numPoints); break; default: @@ -288,7 +298,7 @@ void setLookup(size_t varIndex, size_t* subIndices, double* points, size_t numPo } const char* getHeader() { - return "x\\ty\\tz\\ta[A1]\\tb[A2,B1]\\tc\\tw"; + return "x\\ty\\tz\\ta[A1]\\tb[A2,B1]\\tc\\td[A1]\\tw"; } void storeOutputData() { @@ -298,6 +308,7 @@ void storeOutputData() { outputVar(_a[0]); outputVar(_b[1][0]); outputVar(_c); + outputVar(_d[0]); outputVar(_w); } @@ -318,25 +329,28 @@ void storeOutput(size_t varIndex, size_t subIndex0, size_t subIndex1, size_t sub case 5: outputVar(_input); break; - case 9: + case 10: outputVar(_a[subIndex0]); break; - case 10: + case 11: outputVar(_b[subIndex0][subIndex1]); break; - case 11: + case 12: outputVar(_c); break; - case 12: + case 13: outputVar(_x); break; - case 13: + case 14: outputVar(_w); break; - case 14: + case 15: + outputVar(_d[subIndex0]); + break; + case 16: outputVar(_y); break; - case 15: + case 17: outputVar(_z); break; default: diff --git a/packages/compile/src/generate/gen-code-js.spec.ts b/packages/compile/src/generate/gen-code-js.spec.ts index 09acdb86..4290239e 100644 --- a/packages/compile/src/generate/gen-code-js.spec.ts +++ b/packages/compile/src/generate/gen-code-js.spec.ts @@ -197,6 +197,7 @@ describe('generateJS (Vensim -> JS)', () => { b[DimA, DimB] = b data[DimA, DimB] ~~| c data ~~| c = c data ~~| + d[DimA] = GAME(x) ~~| w = WITH LOOKUP(x, ( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) )) ~~| INITIAL TIME = 0 ~~| FINAL TIME = 2 ~~| @@ -206,7 +207,7 @@ describe('generateJS (Vensim -> JS)', () => { const code = readInlineModelAndGenerateJS(mdl, { extData, inputVarNames: ['input'], - outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'w'], + outputVarNames: ['x', 'y', 'z', 'a[A1]', 'b[A2,B1]', 'c', 'd[A1]', 'w'], bundleListing: true, customLookups: true, customOutputs: true @@ -220,6 +221,8 @@ let _b = multiDimArray([2, 2]); let _b_data = multiDimArray([2, 2]); let _c; let _c_data; +let _d = multiDimArray([2]); +let _d_game_inputs = multiDimArray([2]); let _final_time; let _initial_time; let _input; @@ -420,6 +423,10 @@ function evalAux0() { _x = _input; // w = WITH LOOKUP(x,([(0,0)-(2,2)],(0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3))) _w = fns.WITH_LOOKUP(_x, __lookup1); + // d[DimA] = GAME(x) + for (let i = 0; i < 2; i++) { + _d[i] = fns.GAME(_d_game_inputs[i], _x); + } // y = :NOT: x _y = !_x; // z = ABS(y) @@ -447,12 +454,15 @@ function evalAux0() { const subs = varSpec.subscriptIndices; switch (varIndex) { case 6: - _a_data[subs[0]] = fns.createLookup(points.length / 2, points); + _d_game_inputs[subs[0]] = fns.createLookup(points.length / 2, points); break; case 7: - _b_data[subs[0]][subs[1]] = fns.createLookup(points.length / 2, points); + _a_data[subs[0]] = fns.createLookup(points.length / 2, points); break; case 8: + _b_data[subs[0]][subs[1]] = fns.createLookup(points.length / 2, points); + break; + case 9: _c_data = fns.createLookup(points.length / 2, points); break; default: @@ -467,6 +477,7 @@ function evalAux0() { '_a[_a1]', '_b[_a2,_b1]', '_c', + '_d[_a1]', '_w' ]; @@ -477,6 +488,7 @@ function evalAux0() { 'a[A1]', 'b[A2,B1]', 'c', + 'd[A1]', 'w' ]; @@ -487,6 +499,7 @@ function evalAux0() { storeValue(_a[0]); storeValue(_b[1][0]); storeValue(_c); + storeValue(_d[0]); storeValue(_w); } @@ -512,25 +525,28 @@ function evalAux0() { case 5: storeValue(_input); break; - case 9: + case 10: storeValue(_a[subs[0]]); break; - case 10: + case 11: storeValue(_b[subs[0]][subs[1]]); break; - case 11: + case 12: storeValue(_c); break; - case 12: + case 13: storeValue(_x); break; - case 13: + case 14: storeValue(_w); break; - case 14: + case 15: + storeValue(_d[subs[0]]); + break; + case 16: storeValue(_y); break; - case 15: + case 17: storeValue(_z); break; default: @@ -577,30 +593,37 @@ function evalAux0() { index: 5 }, { - id: '_a_data', + id: '_d_game_inputs', dimIds: [ '_dima' ], index: 6 }, + { + id: '_a_data', + dimIds: [ + '_dima' + ], + index: 7 + }, { id: '_b_data', dimIds: [ '_dima', '_dimb' ], - index: 7 + index: 8 }, { id: '_c_data', - index: 8 + index: 9 }, { id: '_a', dimIds: [ '_dima' ], - index: 9 + index: 10 }, { id: '_b', @@ -608,27 +631,34 @@ function evalAux0() { '_dima', '_dimb' ], - index: 10 + index: 11 }, { id: '_c', - index: 11 + index: 12 }, { id: '_x', - index: 12 + index: 13 }, { id: '_w', - index: 13 + index: 14 + }, + { + id: '_d', + dimIds: [ + '_dima' + ], + index: 15 }, { id: '_y', - index: 14 + index: 16 }, { id: '_z', - index: 15 + index: 17 } ] } diff --git a/packages/compile/src/generate/gen-equation-c.spec.ts b/packages/compile/src/generate/gen-equation-c.spec.ts index ad897df1..612ca138 100644 --- a/packages/compile/src/generate/gen-equation-c.spec.ts +++ b/packages/compile/src/generate/gen-equation-c.spec.ts @@ -1081,15 +1081,52 @@ describe('generateEquation (Vensim -> C)', () => { expect(genC(vars.get('_y'))).toEqual(['_y = _EXP(_x);']) }) - // TODO: We do not currently have full support for the GAME function, so skip this test for now - it.skip('should work for GAME function', () => { + it('should work for GAME function (no dimensions)', () => { const vars = readInlineModel(` x = 1 ~~| y = GAME(x) ~~| `) - expect(vars.size).toBe(2) + expect(vars.size).toBe(3) expect(genC(vars.get('_x'))).toEqual(['_x = 1.0;']) - expect(genC(vars.get('_y'))).toEqual(['_y = _GAME(_x);']) + expect(genC(vars.get('_y'))).toEqual(['_y = _GAME(_y_game_inputs, _x);']) + }) + + it('should work for GAME function (1D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + x[DimA] = 1, 2 ~~| + y[DimA] = GAME(x[DimA]) ~~| + `) + expect(vars.size).toBe(4) + expect(genC(vars.get('_x[_a1]'))).toEqual(['_x[0] = 1.0;']) + expect(genC(vars.get('_x[_a2]'))).toEqual(['_x[1] = 2.0;']) + expect(genC(vars.get('_y'))).toEqual([ + 'for (size_t i = 0; i < 2; i++) {', + '_y[i] = _GAME(_y_game_inputs[i], _x[i]);', + '}' + ]) + }) + + it('should work for GAME function (2D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + a[DimA] = 1, 2 ~~| + b[DimB] = 1, 2 ~~| + y[DimA, DimB] = GAME(a[DimA] + b[DimB]) ~~| + `) + expect(vars.size).toBe(6) + expect(genC(vars.get('_a[_a1]'))).toEqual(['_a[0] = 1.0;']) + expect(genC(vars.get('_a[_a2]'))).toEqual(['_a[1] = 2.0;']) + expect(genC(vars.get('_b[_b1]'))).toEqual(['_b[0] = 1.0;']) + expect(genC(vars.get('_b[_b2]'))).toEqual(['_b[1] = 2.0;']) + expect(genC(vars.get('_y'))).toEqual([ + 'for (size_t i = 0; i < 2; i++) {', + 'for (size_t j = 0; j < 2; j++) {', + '_y[i][j] = _GAME(_y_game_inputs[i][j], _a[i] + _b[j]);', + '}', + '}' + ]) }) it('should work for GAMMA LN function', () => { diff --git a/packages/compile/src/generate/gen-equation-js.spec.ts b/packages/compile/src/generate/gen-equation-js.spec.ts index 10d634f9..42892e9e 100644 --- a/packages/compile/src/generate/gen-equation-js.spec.ts +++ b/packages/compile/src/generate/gen-equation-js.spec.ts @@ -1078,15 +1078,52 @@ describe('generateEquation (Vensim -> JS)', () => { expect(genJS(vars.get('_y'))).toEqual(['_y = fns.EXP(_x);']) }) - // TODO: We do not currently have full support for the GAME function, so skip this test for now - it.skip('should work for GAME function', () => { + it('should work for GAME function (no dimensions)', () => { const vars = readInlineModel(` x = 1 ~~| y = GAME(x) ~~| `) - expect(vars.size).toBe(2) + expect(vars.size).toBe(3) expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) - expect(genJS(vars.get('_y'))).toEqual(['_y = fns.GAME(_x);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.GAME(_y_game_inputs, _x);']) + }) + + it('should work for GAME function (1D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + x[DimA] = 1, 2 ~~| + y[DimA] = GAME(x[DimA]) ~~| + `) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x[_a1]'))).toEqual(['_x[0] = 1.0;']) + expect(genJS(vars.get('_x[_a2]'))).toEqual(['_x[1] = 2.0;']) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = fns.GAME(_y_game_inputs[i], _x[i]);', + '}' + ]) + }) + + it('should work for GAME function (2D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + a[DimA] = 1, 2 ~~| + b[DimB] = 1, 2 ~~| + y[DimA, DimB] = GAME(a[DimA] + b[DimB]) ~~| + `) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_a[_a1]'))).toEqual(['_a[0] = 1.0;']) + expect(genJS(vars.get('_a[_a2]'))).toEqual(['_a[1] = 2.0;']) + expect(genJS(vars.get('_b[_b1]'))).toEqual(['_b[0] = 1.0;']) + expect(genJS(vars.get('_b[_b2]'))).toEqual(['_b[1] = 2.0;']) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + 'for (let j = 0; j < 2; j++) {', + '_y[i][j] = fns.GAME(_y_game_inputs[i][j], _a[i] + _b[j]);', + '}', + '}' + ]) }) it('should work for GAMMA LN function', () => { diff --git a/packages/compile/src/generate/gen-equation.js b/packages/compile/src/generate/gen-equation.js index 51468aad..e0ffe371 100644 --- a/packages/compile/src/generate/gen-equation.js +++ b/packages/compile/src/generate/gen-equation.js @@ -108,7 +108,14 @@ export function generateEquation(variable, mode, extData, directData, modelDir, // Apply special handling for lookup variables. The data for lookup variables is already // defined as a set of explicit data points (stored in the `Variable` instance). if (variable.isLookup()) { - return generateLookupFromPoints(variable, mode, /*copy=*/ false, cLhs, loopIndexVars, outFormat) + if (variable.varSubtype === 'gameInputs') { + // For a synthesized game inputs lookup, there is no data array (the data is expected + // to be supplied at runtime), so don't emit decl or init code for these + return [] + } else { + // For all other lookups, emit decl/init code for the lookup + return generateLookupFromPoints(variable, mode, /*copy=*/ false, cLhs, loopIndexVars, outFormat) + } } // Keep a buffer of code that will be included before the innermost loop diff --git a/packages/compile/src/generate/gen-expr.js b/packages/compile/src/generate/gen-expr.js index 5ba5bc1b..02aea503 100644 --- a/packages/compile/src/generate/gen-expr.js +++ b/packages/compile/src/generate/gen-expr.js @@ -182,7 +182,6 @@ function generateFunctionCall(callExpr, ctx) { case '_ARCTAN': case '_COS': case '_EXP': - case '_GAME': case '_GAMMA_LN': case '_IF_THEN_ELSE': case '_INTEGER': @@ -222,7 +221,7 @@ function generateFunctionCall(callExpr, ctx) { // // Lookup functions // - // Each of these functions is implemented with a C function (like the simple functions above), + // Each of these functions is implemented with a C/JS function (like the simple functions above), // but we need to handle the first argument specially, otherwise we would get the default handling // for data variables, which generates a lookup call (see 'variable-ref' case in `generateExpr`). // @@ -233,12 +232,20 @@ function generateFunctionCall(callExpr, ctx) { case '_LOOKUP_FORWARD': case '_LOOKUP_INVERT': { // For LOOKUP* functions, the first argument must be a reference to the lookup variable. Emit - // a C function call with a generated C expression for each remaining argument. + // a C/JS function call with a generated C/JS expression for each remaining argument. const cVarRef = ctx.cVarRef(callExpr.args[0]) const cArgs = callExpr.args.slice(1).map(arg => generateExpr(arg, ctx)) return `${fnRef(fnId, ctx)}(${cVarRef}, ${cArgs.join(', ')})` } + case '_GAME': { + // For the GAME function, emit a C/JS function call that has the synthesized game inputs lookup + // as the first argument, followed by the default value argument from the function call + const cLookupArg = ctx.cVarRefWithLhsSubscripts(ctx.variable.gameLookupVarName) + const cDefaultArg = generateExpr(callExpr.args[0], ctx) + return `${fnRef(fnId, ctx)}(${cLookupArg}, ${cDefaultArg})` + } + // // // Level functions diff --git a/packages/compile/src/model/read-equation-fn-game.js b/packages/compile/src/model/read-equation-fn-game.js new file mode 100644 index 00000000..7642f6ee --- /dev/null +++ b/packages/compile/src/model/read-equation-fn-game.js @@ -0,0 +1,50 @@ +import { canonicalName } from '../_shared/helpers.js' + +/** + * Generate a lookup variable that can be used to provide inputs to a the `GAME` + * function at runtime. + * + * @param {*} v + * @param {*} callExpr + * @param {*} context + */ +export function generateGameVariables(v, callExpr, context) { + // If the LHS includes subscripts, use those same subscripts when generating + // the new lookup variable + let subs + if (context.eqnLhs.varDef.subscriptRefs) { + const subNames = context.eqnLhs.varDef.subscriptRefs.map(subRef => subRef.subName) + subs = `[${subNames.join(',')}]` + } else { + subs = '' + } + + // Synthesize a lookup variable name that is the same as the LHS variable + // name with ' game inputs' appended to it + const gameLookupVarName = context.eqnLhs.varDef.varName + ' game inputs' + + // Add a reference to the synthesized game inputs lookup + const gameLookupVarId = canonicalName(gameLookupVarName) + v.gameLookupVarName = gameLookupVarId + if (v.referencedLookupVarNames) { + v.referencedLookupVarNames.push(gameLookupVarId) + } else { + v.referencedLookupVarNames = [gameLookupVarId] + } + + // Define a variable for the synthesized game inputs lookup + const gameLookupVars = context.defineVariable(`${gameLookupVarName}${subs} ~~|`) + + // Normally `defineVariable` sets `includeInOutput` to false for generated + // variables, but we want the generated lookup variable to appear in the + // model listing so that the user can reference it, so set `includeInOutput` + // to true. Also change the `varType` to 'lookup' instead of 'data'. We + // will declare a `Lookup` variable in the generated code, but unlike a + // normal lookup, we won't initialize it with data by default (it can only + // be updated at runtime). + gameLookupVars.forEach(v => { + v.includeInOutput = true + v.varType = 'lookup' + v.varSubtype = 'gameInputs' + }) +} diff --git a/packages/compile/src/model/read-equations.js b/packages/compile/src/model/read-equations.js index 5d5c5c78..01af55d1 100644 --- a/packages/compile/src/model/read-equations.js +++ b/packages/compile/src/model/read-equations.js @@ -16,6 +16,7 @@ import { import Model from './model.js' import { generateDelayVariables } from './read-equation-fn-delay.js' +import { generateGameVariables } from './read-equation-fn-game.js' import { generateNpvVariables } from './read-equation-fn-npv.js' import { generateSmoothVariables } from './read-equation-fn-smooth.js' import { generateTrendVariables } from './read-equation-fn-trend.js' @@ -128,6 +129,8 @@ class Context { // Inhibit output for generated variables v.includeInOutput = false }) + + return vars } /** @@ -383,16 +386,6 @@ function visitFunctionCall(v, callExpr, context) { validateCallArgs(callExpr, 1) break - // TODO: We do not currently have full support for the GAME function, so report a warning for now - case '_GAME': - if (process.env.SDE_REPORT_UNSUPPORTED_FUNCTIONS !== '0') { - console.warn( - `WARNING: The GAME function (used in the definition of '${v.modelLHS}') is currently implemented as a no-op (it returns the input value).` - ) - } - validateCallArgs(callExpr, 1) - break - // // // 2-argument functions... @@ -491,6 +484,12 @@ function visitFunctionCall(v, callExpr, context) { argModes[2] = 'init' break + case '_GAME': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 1) + generateGameVariables(v, callExpr, context) + break + case '_GET_DIRECT_CONSTANTS': { validateCallDepth(callExpr, context) validateCallArgs(callExpr, 3) diff --git a/packages/compile/src/model/read-equations.spec.ts b/packages/compile/src/model/read-equations.spec.ts index b6c97450..3f71896d 100644 --- a/packages/compile/src/model/read-equations.spec.ts +++ b/packages/compile/src/model/read-equations.spec.ts @@ -1268,6 +1268,117 @@ describe('readEquations', () => { ]) }) + it('should work for GAME function (no dimensions)', () => { + const vars = readInlineModel(` + x = 1 ~~| + y = GAME(x) ~~| + `) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', 'GAME(x)', { + gameLookupVarName: '_y_game_inputs', + refId: '_y', + referencedFunctionNames: ['__game'], + referencedLookupVarNames: ['_y_game_inputs'], + references: ['_x'] + }), + v('y game inputs', '', { + refId: '_y_game_inputs', + varType: 'lookup', + varSubtype: 'gameInputs' + }) + ]) + }) + + it('should work for GAME function (1D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + x[DimA] = 1, 2 ~~| + y[DimA] = GAME(x[DimA]) ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', '1,2', { + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('x[DimA]', '1,2', { + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('y[DimA]', 'GAME(x[DimA])', { + gameLookupVarName: '_y_game_inputs', + refId: '_y', + referencedFunctionNames: ['__game'], + referencedLookupVarNames: ['_y_game_inputs'], + references: ['_x[_a1]', '_x[_a2]'], + subscripts: ['_dima'] + }), + v('y game inputs[DimA]', '', { + refId: '_y_game_inputs', + subscripts: ['_dima'], + varType: 'lookup', + varSubtype: 'gameInputs' + }) + ]) + }) + + it('should work for GAME function (2D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + a[DimA] = 1, 2 ~~| + b[DimB] = 1, 2 ~~| + y[DimA, DimB] = GAME(a[DimA] + b[DimB]) ~~| + `) + expect(vars).toEqual([ + v('a[DimA]', '1,2', { + refId: '_a[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('a[DimA]', '1,2', { + refId: '_a[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('b[DimB]', '1,2', { + refId: '_b[_b1]', + separationDims: ['_dimb'], + subscripts: ['_b1'], + varType: 'const' + }), + v('b[DimB]', '1,2', { + refId: '_b[_b2]', + separationDims: ['_dimb'], + subscripts: ['_b2'], + varType: 'const' + }), + v('y[DimA,DimB]', 'GAME(a[DimA]+b[DimB])', { + gameLookupVarName: '_y_game_inputs', + refId: '_y', + referencedFunctionNames: ['__game'], + referencedLookupVarNames: ['_y_game_inputs'], + references: ['_a[_a1]', '_a[_a2]', '_b[_b1]', '_b[_b2]'], + subscripts: ['_dima', '_dimb'] + }), + v('y game inputs[DimA,DimB]', '', { + refId: '_y_game_inputs', + subscripts: ['_dima', '_dimb'], + varType: 'lookup', + varSubtype: 'gameInputs' + }) + ]) + }) + it('should work for GAMMA LN function', () => { const vars = readInlineModel(` x = 1 ~~| diff --git a/packages/compile/src/model/variable.js b/packages/compile/src/model/variable.js index 11085e0a..64f72aa6 100644 --- a/packages/compile/src/model/variable.js +++ b/packages/compile/src/model/variable.js @@ -41,6 +41,8 @@ export default class Variable { // DELAY3* calls are expanded into new level vars and substituted during code generation. this.delayVarRefId = '' this.delayTimeVarName = '' + // GAME calls generate a Lookup support var. + this.gameLookupVarName = '' // DELAY FIXED calls generate a FixedDelay support var. this.fixedDelayVarName = '' // DEPRECIATE STRAIGHTLINE calls generate a Depreciation support var. @@ -67,6 +69,7 @@ export default class Variable { c.trendVarName = this.trendVarName c.delayVarRefId = this.delayVarRefId c.delayTimeVarName = this.delayTimeVarName + c.gameLookupVarName = this.gameLookupVarName c.includeInOutput = this.includeInOutput return c } diff --git a/packages/runtime/src/js-model/js-model-functions.spec.ts b/packages/runtime/src/js-model/js-model-functions.spec.ts index 827c8720..446500b2 100644 --- a/packages/runtime/src/js-model/js-model-functions.spec.ts +++ b/packages/runtime/src/js-model/js-model-functions.spec.ts @@ -48,7 +48,32 @@ describe('JsModelFunctions', () => { }) it('should expose GAME', () => { - expect(fns.GAME(1)).toBe(1) + // Verify that it returns the `x` value when `inputs` is undefined + expect(fns.GAME(undefined, 10)).toBe(10) + + // Verify that it returns the `x` value when `inputs` is empty + const empty = fns.createLookup(0, []) + expect(fns.GAME(empty, 10)).toBe(10) + + // Verify that it returns the `x` value when current time is earlier than the + // first defined input point + const lookup = fns.createLookup(2, [1, 2, 3, 6]) + expect(fnsAtTime(0.0).GAME(lookup, 10)).toBe(10) + expect(fnsAtTime(0.5).GAME(lookup, 10)).toBe(10) + + // Verify that it returns the correct value from `inputs` when the time + // is within the range of the lookup points + expect(fnsAtTime(1.0).GAME(lookup, 10)).toBe(2) + expect(fnsAtTime(1.5).GAME(lookup, 10)).toBe(2) + expect(fnsAtTime(2.0).GAME(lookup, 10)).toBe(2) + expect(fnsAtTime(2.5).GAME(lookup, 10)).toBe(2) + expect(fnsAtTime(3.0).GAME(lookup, 10)).toBe(6) + + // Verify that it holds the last value when the time is greater than the + // last defined input point + expect(fnsAtTime(3.5).GAME(lookup, 10)).toBe(6) + expect(fnsAtTime(4.0).GAME(lookup, 10)).toBe(6) + expect(fnsAtTime(100).GAME(lookup, 10)).toBe(6) }) // TODO diff --git a/packages/runtime/src/js-model/js-model-functions.ts b/packages/runtime/src/js-model/js-model-functions.ts index f6edef03..1d94f23c 100644 --- a/packages/runtime/src/js-model/js-model-functions.ts +++ b/packages/runtime/src/js-model/js-model-functions.ts @@ -34,7 +34,7 @@ export interface JsModelFunctions { ARCTAN(x: number): number COS(x: number): number EXP(x: number): number - GAME(x: number): number + GAME(inputs: JsModelLookup, x: number): number // TODO // GAMMA_LN(x: number): number INTEG(value: number, rate: number): number @@ -119,9 +119,8 @@ export function getJsModelFunctions(): JsModelFunctions { return Math.exp(x) }, - GAME(x: number): number { - // TODO: For now, the GAME function is a no-op (returns the input value) - return x + GAME(inputs: JsModelLookup, x: number): number { + return inputs ? inputs.getValueForGameTime(ctx.currentTime, x) : x }, // GAMMA_LN(): number { diff --git a/packages/runtime/src/js-model/js-model-lookup.ts b/packages/runtime/src/js-model/js-model-lookup.ts index 206fcd7b..871cb469 100644 --- a/packages/runtime/src/js-model/js-model-lookup.ts +++ b/packages/runtime/src/js-model/js-model-lookup.ts @@ -116,6 +116,40 @@ export class JsModelLookup { return data[max - 1] } + /** + * Return the most appropriate y value from the array of (x,y) pairs when + * this instance is used to provide inputs for the `GAME` function. + * + * NOTE: The x values are assumed to be monotonically increasing. + * + * This method is similar to `getValueForX` in concept, except that this one + * returns the provided `defaultValue` if the `time` parameter is earlier than + * the first data point in the lookup. Also, this method always uses the + * `backward` interpolation mode, meaning that it holds the "current" value + * constant instead of interpolating. + * + * @param time The time that is used to select the data point that has an + * `x` value less than or equal to the provided time. + * @param defaultValue The value that is returned if this lookup is empty (has + * no points) or if the provided time is earlier than the first data point. + */ + public getValueForGameTime(time: number, defaultValue: number): number { + if (this.n <= 0) { + // The lookup is empty, so return the default value + return defaultValue + } + + const x0 = this.data[0] + if (time < x0) { + // The provided time is earlier than the first data point, so return the + // default value + return defaultValue + } + + // For all other cases, we can use `getValue` with `backward` mode + return this.getValue(time, false, 'backward') + } + /** * Interpolate the y value from the array of (x,y) pairs. * NOTE: The x values are assumed to be monotonically increasing. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d990d216..890616f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -582,6 +582,27 @@ importers: specifier: workspace:* version: link:../../../packages/runtime-async + tests/integration/game-inputs: + 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/impl-var-access: dependencies: '@sdeverywhere/build': diff --git a/tests/integration/game-inputs/game-inputs.mdl b/tests/integration/game-inputs/game-inputs.mdl new file mode 100644 index 00000000..a15c4e04 --- /dev/null +++ b/tests/integration/game-inputs/game-inputs.mdl @@ -0,0 +1,18 @@ +{UTF-8} + +DimA: A1, A2 ~~| + +X = 0 + ~ dmnl [-10,10,0.1] + ~ This is an input variable. + | + +Y[DimA] = GAME(X + 100 * (Time - INITIAL TIME + 1)) + ~ dmnl + ~ This is a 1D subscripted output variable. + | + +INITIAL TIME = 2000 ~~| +FINAL TIME = 2002 ~~| +TIME STEP = 1 ~~| +SAVEPER = TIME STEP ~~| diff --git a/tests/integration/game-inputs/package.json b/tests/integration/game-inputs/package.json new file mode 100644 index 00000000..fc65e185 --- /dev/null +++ b/tests/integration/game-inputs/package.json @@ -0,0 +1,23 @@ +{ + "name": "game-inputs", + "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/game-inputs/run-tests.js b/tests/integration/game-inputs/run-tests.js new file mode 100755 index 00000000..e3fd5aea --- /dev/null +++ b/tests/integration/game-inputs/run-tests.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +import { readFile } from 'fs/promises' +import { join as joinPath } from 'path' + +import { createInputValue, createLookupDef, 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 providing GAME function + * inputs at runtime. + */ + +function verify(runnerKind, run, outputs, varId, expectedValues) { + 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 = expectedValues[time - 2000] + 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) + } + } +} + +async function runTests(runnerKind, modelRunner) { + // Create the set of inputs + const inputX = createInputValue('_x', 0) + const inputs = [inputX] + + // Create the buffer to hold the outputs + let outputs = modelRunner.createOutputs() + + // Run the model with input at default (0) + outputs = await modelRunner.runModel(inputs, outputs) + + // Verify declared output variables + verify(runnerKind, 1, outputs, '_y[_a1]', [100, 200, 300]) + verify(runnerKind, 1, outputs, '_y[_a2]', [100, 200, 300]) + + // Run the model with game inputs supplied for one variable. Note that the + // lookup does not contain a data point for the year 2000, so the `GAME` + // function should fall back on the value of the "default" argument for + // that year. + const p = (x, y) => ({ x, y }) + outputs = await modelRunner.runModel(inputs, outputs, { + lookups: [createLookupDef({ varName: 'Y game inputs[A1]' }, [p(2001, 260), p(2002, 360)])] + }) + + // Verify that the game inputs are reflected in the first output variable + // but not the second + verify(runnerKind, 2, outputs, '_y[_a1]', [100, 260, 360]) + verify(runnerKind, 2, outputs, '_y[_a2]', [100, 200, 300]) + + // Run the model again, but without specifying game inputs + outputs = await modelRunner.runModel(inputs, outputs) + + // Verify that the game inputs are still in effect + verify(runnerKind, 3, outputs, '_y[_a1]', [100, 260, 360]) + verify(runnerKind, 3, outputs, '_y[_a2]', [100, 200, 300]) + + // Run the model with empty game inputs for the one variable + outputs = await modelRunner.runModel(inputs, outputs, { + lookups: [createLookupDef({ varName: 'Y game inputs[A1]' }, [])] + }) + + // Verify that the empty game inputs lookup is in effect + verify(runnerKind, 4, outputs, '_y[_a1]', [100, 200, 300]) + verify(runnerKind, 4, outputs, '_y[_a2]', [100, 200, 300]) + + // Terminate the model runner + await modelRunner.terminate() +} + +async function createSynchronousRunner() { + // TODO: This test app is using ESM-style modules, and `__dirname` is not defined + // in an ESM context. The `generated-model.js` file (if it contains a Wasm model) + // may contain a reference to `__dirname`, so we need to define it here. We should + // fix the generated Wasm file so that it works for either ESM or CommonJS. + global.__dirname = '.' + + // Load the generated model and verify that it exposes `outputVarIds` + const generatedModel = await loadGeneratedModel() + const actualVarIds = generatedModel.outputVarIds || [] + const expectedVarIds = ['_y[_a1]', '_y[_a2]'] + if (actualVarIds.length !== expectedVarIds.length || !actualVarIds.every((v, i) => v === expectedVarIds[i])) { + const expected = JSON.stringify(expectedVarIds, null, 2) + const actual = JSON.stringify(actualVarIds, null, 2) + throw new Error( + `Test failed: outputVarIds in generated JS model don't match expected values\nexpected=${expected}\nactual=${actual}` + ) + } + + // Initialize the synchronous `ModelRunner` that drives the model + return createSynchronousModelRunner(generatedModel) +} + +async function createAsynchronousRunner() { + // Initialize the aynchronous `ModelRunner` that drives the generated model + const modelWorkerJs = await readFile(joinPath('sde-prep', 'worker.js'), 'utf8') + return await spawnAsyncModelRunner({ source: modelWorkerJs }) +} + +async function main() { + // TODO: Verify JSON + + // Verify with the synchronous model runner + const syncRunner = await createSynchronousRunner() + await runTests('synchronous', syncRunner) + + // Verify with the asynchronous model runner + const asyncRunner = await createAsynchronousRunner() + await runTests('asynchronous', asyncRunner) + + console.log('Tests passed!\n') +} + +main() diff --git a/tests/integration/game-inputs/sde.config.js b/tests/integration/game-inputs/sde.config.js new file mode 100644 index 00000000..3c04ba9d --- /dev/null +++ b/tests/integration/game-inputs/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: ['game-inputs.mdl'], + modelSpec: async () => { + return { + inputs: ['X'], + outputs: ['Y[A1]', 'Y[A2]'], + bundleListing: true, + customLookups: 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() + ] + } +}