diff --git a/package.json b/package.json index 08233519..f0f8f433 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@vitest/browser": "^4.0.17", "@vitest/browser-playwright": "^4.0.17", "@vitest/coverage-v8": "^4.0.17", + "csv-parse": "^5.3.3", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-eslint-comments": "^3.2.0", diff --git a/packages/cli/src/sde-causes.js b/packages/cli/src/sde-causes.js index 8942fce3..5f5662e8 100644 --- a/packages/cli/src/sde-causes.js +++ b/packages/cli/src/sde-causes.js @@ -18,11 +18,11 @@ let handler = argv => { } let causes = async (model, varname, opts) => { // Get the model name and directory from the model argument. - let { modelDirname, modelPathname, modelName } = modelPathProps(model) + let { modelDirname, modelPathname, modelName, modelKind } = modelPathProps(model) let spec = parseSpec(opts.spec) // Parse the model to get variable and subscript information. let input = readFileSync(modelPathname, 'utf8') - await parseAndGenerate(input, spec, ['printRefGraph'], modelDirname, modelName, '', varname) + await parseAndGenerate(input, modelKind, spec, ['printRefGraph'], modelDirname, modelName, '', varname) } export default { command, diff --git a/packages/cli/src/sde-generate.js b/packages/cli/src/sde-generate.js index 81d4e16c..8d806640 100644 --- a/packages/cli/src/sde-generate.js +++ b/packages/cli/src/sde-generate.js @@ -57,7 +57,7 @@ export let handler = async argv => { export let generate = async (model, opts) => { // Get the model name and directory from the model argument. - let { modelDirname, modelName, modelPathname } = modelPathProps(model) + let { modelDirname, modelName, modelPathname, modelKind } = modelPathProps(model) // Ensure the build directory exists. let buildDirname = buildDir(opts.builddir, modelDirname) let spec = parseSpec(opts.spec) @@ -87,7 +87,7 @@ export let generate = async (model, opts) => { if (opts.refidtest) { operations.push('printRefIdTest') } - await parseAndGenerate(mdlContent, spec, operations, modelDirname, modelName, buildDirname) + await parseAndGenerate(mdlContent, modelKind, spec, operations, modelDirname, modelName, buildDirname) } export default { diff --git a/packages/cli/src/sde-names.js b/packages/cli/src/sde-names.js index 6db8ff72..6432dfa0 100644 --- a/packages/cli/src/sde-names.js +++ b/packages/cli/src/sde-names.js @@ -26,11 +26,11 @@ let handler = argv => { } let names = async (model, namesPathname, opts) => { // Get the model name and directory from the model argument. - let { modelDirname, modelPathname, modelName } = modelPathProps(model) + let { modelDirname, modelPathname, modelName, modelKind } = modelPathProps(model) let spec = parseSpec(opts.spec) // Parse the model to get variable and subscript information. let input = readFileSync(modelPathname, 'utf8') - await parseAndGenerate(input, spec, ['convertNames'], modelDirname, modelName, '') + await parseAndGenerate(input, modelKind, spec, ['convertNames'], modelDirname, modelName, '') // Read each variable name from the names file and convert it. printNames(namesPathname, opts.toc ? 'to-c' : 'to-vensim') } diff --git a/packages/cli/src/sde-test.js b/packages/cli/src/sde-test.js index 8e16a62a..0fa66896 100644 --- a/packages/cli/src/sde-test.js +++ b/packages/cli/src/sde-test.js @@ -18,6 +18,10 @@ export let builder = { choices: ['js', 'c'], default: 'js' }, + tooldat: { + describe: 'pathname of the tool DAT file to compare to SDE output', + type: 'string' + }, builddir: { describe: 'build directory', type: 'string', @@ -53,12 +57,19 @@ export let test = async (model, opts) => { // Convert the TSV log file to a DAT file in the same directory. opts.dat = true await log(logPathname, opts) - // Assume there is a Vensim-created DAT file named {modelName}.dat in the model directory. - // Compare it to the SDE DAT file. - let vensimPathname = path.join(modelDirname, `${modelName}.dat`) + let toolDatPathname + if (opts.tooldat) { + // Use the provided DAT file for comparison + toolDatPathname = opts.tooldat + } else { + // Assume there is a DAT file created by the modeling tool named {modelName}.dat + // in the model directory + toolDatPathname = path.join(modelDirname, `${modelName}.dat`) + } let p = path.parse(logPathname) - let sdePathname = path.format({ dir: p.dir, name: p.name, ext: '.dat' }) - let noDiffs = await compare(vensimPathname, sdePathname, opts) + let sdeDatPathname = path.format({ dir: p.dir, name: p.name, ext: '.dat' }) + // Compare SDE-generated DAT file to the tool-generated DAT file + let noDiffs = await compare(toolDatPathname, sdeDatPathname, opts) if (!noDiffs) { // Exit with a non-zero error code if differences were detected console.error() diff --git a/packages/cli/src/utils.js b/packages/cli/src/utils.js index de8893a5..2a12c3db 100644 --- a/packages/cli/src/utils.js +++ b/packages/cli/src/utils.js @@ -25,25 +25,45 @@ export function execCmd(cmd) { } /** - * Normalize a model pathname that may or may not include the .mdl extension. + * Normalize a model pathname that may or may not include the .mdl or .xmile/.stmx/.itmx extension. + * If the pathname does not end with .mdl, .xmile, .stmx, or .itmx, this will attempt to find a + * file with one of those extensions. * If there is not a path in the model argument, default to the current working directory. + * * Return an object with properties that look like this: * modelDirname: '/Users/todd/src/models/arrays' * modelName: 'arrays' * modelPathname: '/Users/todd/src/models/arrays/arrays.mdl' + * modelKind: 'vensim' * * @param model A path to a Vensim model file. * @return An object with the properties specified above. */ export function modelPathProps(model) { - let p = R.merge({ ext: '.mdl' }, R.pick(['dir', 'name'], path.parse(model))) + const parsedPath = path.parse(model) + if (parsedPath.ext === '') { + const exts = ['.mdl', '.xmile', '.stmx', '.itmx'] + const paths = exts.map(ext => path.join(parsedPath.dir, parsedPath.name + ext)) + const existingPaths = paths.filter(path => fs.existsSync(path)) + if (existingPaths.length > 1) { + throw new Error( + `Found multiple files that match '${model}'; please specify a file with a .mdl, .xmile, .stmx, or .itmx extension` + ) + } + if (existingPaths.length === 0) { + throw new Error(`No {mdl,xmile,stmx,itmx} file found for ${model}`) + } + parsedPath.ext = path.extname(existingPaths[0]) + } + let p = R.merge({ ext: parsedPath.ext }, R.pick(['dir', 'name'], parsedPath)) if (R.isEmpty(p.dir)) { p.dir = process.cwd() } return { modelDirname: p.dir, modelName: p.name, - modelPathname: path.format(p) + modelPathname: path.format(p), + modelKind: p.ext === '.mdl' ? 'vensim' : 'xmile' } } diff --git a/packages/compile/src/_tests/test-support.ts b/packages/compile/src/_tests/test-support.ts index 543793cc..96eb9188 100644 --- a/packages/compile/src/_tests/test-support.ts +++ b/packages/compile/src/_tests/test-support.ts @@ -155,11 +155,69 @@ export function parseVensimModel(modelName: string): ParsedModel { const modelDir = sampleModelDir(modelName) const modelFile = resolve(modelDir, `${modelName}.mdl`) const mdlContent = readFileSync(modelFile, 'utf8') - return parseModel(mdlContent, modelDir) + return parseModel(mdlContent, 'vensim', modelDir) } export function parseInlineVensimModel(mdlContent: string, modelDir?: string): ParsedModel { - return parseModel(mdlContent, modelDir) + return parseModel(mdlContent, 'vensim', modelDir) +} + +export function parseXmileModel(modelName: string): ParsedModel { + const modelDir = sampleModelDir(modelName) + const modelFile = resolve(modelDir, `${modelName}.stmx`) + const mdlContent = readFileSync(modelFile, 'utf8') + return parseModel(mdlContent, 'xmile', modelDir) +} + +export function parseInlineXmileModel(mdlContent: string, modelDir?: string): ParsedModel { + return parseModel(mdlContent, 'xmile', modelDir) +} + +export function xmile(dimensions: string, variables: string): string { + function indent(text: string, indent: string): string { + return text + .split('\n') + .map(line => indent + line) + .join('\n') + } + + let dims: string + if (dimensions.length > 0) { + dims = `\ + +${indent(dimensions, ' ')} + ` + } else { + dims = '' + } + + let vars: string + if (variables.length > 0) { + vars = `\ + +${indent(variables, ' ')} + ` + } else { + vars = '' + } + + return `\ + +
+ + Ventana Systems, xmutil + Vensim, xmutil +
+ + 0 + 100 +
1
+
+${dims} + +${vars} + +
` } function prettyVar(variable: Variable): string { diff --git a/packages/compile/src/generate/gen-code-c.spec.ts b/packages/compile/src/generate/gen-code-c-from-vensim.spec.ts similarity index 100% rename from packages/compile/src/generate/gen-code-c.spec.ts rename to packages/compile/src/generate/gen-code-c-from-vensim.spec.ts diff --git a/packages/compile/src/generate/gen-code-js.spec.ts b/packages/compile/src/generate/gen-code-js-from-vensim.spec.ts similarity index 100% rename from packages/compile/src/generate/gen-code-js.spec.ts rename to packages/compile/src/generate/gen-code-js-from-vensim.spec.ts diff --git a/packages/compile/src/generate/gen-equation-c.spec.ts b/packages/compile/src/generate/gen-equation-c-from-vensim.spec.ts similarity index 100% rename from packages/compile/src/generate/gen-equation-c.spec.ts rename to packages/compile/src/generate/gen-equation-c-from-vensim.spec.ts diff --git a/packages/compile/src/generate/gen-equation-js.spec.ts b/packages/compile/src/generate/gen-equation-js-from-vensim.spec.ts similarity index 100% rename from packages/compile/src/generate/gen-equation-js.spec.ts rename to packages/compile/src/generate/gen-equation-js-from-vensim.spec.ts diff --git a/packages/compile/src/generate/gen-equation-js-from-xmile.spec.ts b/packages/compile/src/generate/gen-equation-js-from-xmile.spec.ts new file mode 100644 index 00000000..2eb8cf4c --- /dev/null +++ b/packages/compile/src/generate/gen-equation-js-from-xmile.spec.ts @@ -0,0 +1,2830 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readXlsx, resetHelperState } from '../_shared/helpers' +import { resetSubscriptsAndDimensions } from '../_shared/subscript' + +import Model from '../model/model' +// import { default as VariableImpl } from '../model/variable' + +import { parseInlineXmileModel, type Variable, xmile } from '../_tests/test-support' +import { generateEquation } from './gen-equation' + +type ExtData = Map> +type DirectDataSpec = Map + +function readInlineModel( + mdlContent: string, + opts?: { + modelDir?: string + extData?: ExtData + inputVarNames?: string[] + outputVarNames?: string[] + } +): Map { + // XXX: These steps are needed due to subs/dims and variables being in module-level storage + resetHelperState() + resetSubscriptsAndDimensions() + Model.resetModelState() + + let spec + if (opts?.inputVarNames || opts?.outputVarNames) { + spec = { + inputVarNames: opts?.inputVarNames || [], + outputVarNames: opts?.outputVarNames || [] + } + } else { + spec = {} + } + + const parsedModel = parseInlineXmileModel(mdlContent, opts?.modelDir) + Model.read(parsedModel, spec, opts?.extData, /*directData=*/ undefined, opts?.modelDir, { + reduceVariables: false + }) + + // Get all variables (note that `allVars` already excludes the `Time` variable, and we want to + // exclude that so that we have one less thing to check) + const map = new Map() + Model.allVars().forEach((v: Variable) => { + // Exclude control variables so that we have fewer things to check + switch (v.varName) { + case '_initial_time': + case '_final_time': + case '_time_step': + case '_saveper': + case '_starttime': + case '_stoptime': + case '_dt': + return + default: + map.set(v.refId, v) + break + } + }) + return map +} + +function genJS( + variable: Variable, + mode: 'decl' | 'init-constants' | 'init-lookups' | 'init-levels' | 'eval' = 'eval', + opts?: { + modelDir?: string + extData?: ExtData + directDataSpec?: DirectDataSpec + } +): string[] { + if (variable === undefined) { + throw new Error(`variable is undefined`) + } + + const directData = new Map() + if (opts?.modelDir && opts?.directDataSpec) { + for (const [file, xlsxFilename] of opts.directDataSpec.entries()) { + const xlsxPath = path.join(opts.modelDir, xlsxFilename) + directData.set(file, readXlsx(xlsxPath)) + } + } + + const lines = generateEquation(variable, mode, opts?.extData, directData, opts?.modelDir, 'js') + + // Strip the first comment line (containing the XMILE equation) + if (lines.length > 0 && lines[0].trim().startsWith('//')) { + lines.shift() + } + + // Trim the remaining lines to remove extra whitespace + return lines.map(line => line.trim()) +} + +describe('generateEquation (XMILE -> JS)', () => { + it('should work for simple equation with unary NOT op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(:NOT: x, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF NOT x THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((!_x) ? (1.0) : (0.0));']) + }) + + it('should work for simple equation with unary + op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = +x ~~| + // `) + + const xmileVars = `\ + + 1 + + + +x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x;']) + }) + + it('should work for simple equation with unary - op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = -x ~~| + // `) + + const xmileVars = `\ + + 1 + + + -x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = -_x;']) + }) + + it('should work for simple equation with binary + op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x + 2 ~~| + // `) + + const xmileVars = `\ + + 1 + + + x + 2 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x + 2.0;']) + }) + + it('should work for simple equation with binary - op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x - 2 ~~| + // `) + + const xmileVars = `\ + + 1 + + + x - 2 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x - 2.0;']) + }) + + it('should work for simple equation with binary * op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x * 2 ~~| + // `) + + const xmileVars = `\ + + 1 + + + x * 2 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x * 2.0;']) + }) + + it('should work for simple equation with binary / op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x / 2 ~~| + // `) + + const xmileVars = `\ + + 1 + + + x / 2 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x / 2.0;']) + }) + + it('should work for simple equation with binary ^ op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x ^ 2 ~~| + // `) + + const xmileVars = `\ + + 1 + + + x ^ 2 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.POW(_x, 2.0);']) + }) + + it('should work for simple equation with explicit parentheses', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = (x + 2) * 3 ~~| + // `) + + const xmileVars = `\ + + 1 + + + (x + 2) * 3 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = (_x + 2.0) * 3.0;']) + }) + + it('should work for conditional expression with = op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x = time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x = time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x === _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with <> op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x <> time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x <> time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x !== _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with < op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x < time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x < time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x < _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with <= op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x <= time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x <= time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x <= _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with > op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x > time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x > time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x > _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with >= op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x >= time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x >= time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x >= _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with AND op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x :AND: time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x AND time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x && _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with OR op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = ABS(1) ~~| + // y = IF THEN ELSE(x :OR: time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + ABS(1) + + + IF x OR time THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = fns.ABS(1.0);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x || _time) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with NOT op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = ABS(1) ~~| + // y = IF THEN ELSE(:NOT: x, 1, 0) ~~| + // `) + + const xmileVars = `\ + + ABS(1) + + + IF NOT x THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = fns.ABS(1.0);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((!_x) ? (1.0) : (0.0));']) + }) + + // TODO: This test is skipped because XMILE may not support :NA: keyword for missing values + it.skip('should work for expression using :NA: keyword', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time ~~| + // y = IF THEN ELSE(x <> :NA:, 1, 0) ~~| + // `) + + // TODO: Need to determine how XMILE handles missing values or NA values + const xmileVars = `\ + + Time + + + IF x <> NA THEN 1 ELSE 0 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = _time;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x !== _NA_) ? (1.0) : (0.0));']) + }) + + it('should work for conditional expression with reference to dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x = 1 ~~| + // y[DimA] = IF THEN ELSE(DimA = x, 1, 0) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + 1 + + + + + + IF DimA = x THEN 1 ELSE 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = (((i + 1) === _x) ? (1.0) : (0.0));', + '}' + ]) + }) + + it('should work for conditional expression with reference to dimension and subscript/index', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // y[DimA] = IF THEN ELSE(DimA = A2, 1, 0) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + IF DimA = A2 THEN 1 ELSE 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(1) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = (((i + 1) === 2) ? (1.0) : (0.0));', + '}' + ]) + }) + + // TODO: This test is skipped because XMILE handles external data variables differently + it.skip('should work for data variable definition', () => { + // Equivalent Vensim model for reference: + // const extData: ExtData = new Map([ + // [ + // '_x', + // new Map([ + // [0, 0], + // [1, 2], + // [2, 5] + // ]) + // ] + // ]) + // const vars = readInlineModel( + // ` + // x ~~| + // y = x * 10 ~~| + // `, + // { extData } + // ) + // TODO: Need to determine how XMILE handles external data variables + // const xmileVars = `\ + // + // data + // + // + // x * 10 + // ` + // const mdl = xmile('', xmileVars) + // const vars = readInlineModel(mdl, { extData }) + // expect(vars.size).toBe(2) + // expect(genJS(vars.get('_x'), 'decl', { extData })).toEqual(['const _x_data_ = [0.0, 0.0, 1.0, 2.0, 2.0, 5.0];']) + // expect(genJS(vars.get('_x'), 'init-lookups', { extData })).toEqual(['_x = fns.createLookup(3, _x_data_);']) + // expect(genJS(vars.get('_y'), 'eval', { extData })).toEqual(['_y = fns.LOOKUP(_x, _time) * 10.0;']) + }) + + // TODO: This test is skipped because XMILE handles external data variables differently + it.skip('should work for data variable definition (1D)', () => { + const extData: ExtData = new Map([ + [ + '_x[_a1]', + new Map([ + [0, 0], + [1, 2], + [2, 5] + ]) + ], + [ + '_x[_a2]', + new Map([ + [0, 10], + [1, 12], + [2, 15] + ]) + ] + ]) + const vars = readInlineModel( + ` + DimA: A1, A2 ~~| + x[DimA] ~~| + y[DimA] = x[DimA] * 10 ~~| + z = y[A2] ~~| + `, + { + extData + } + ) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_x'), 'decl', { extData })).toEqual([ + 'const _x_data__0_ = [0.0, 0.0, 1.0, 2.0, 2.0, 5.0];', + 'const _x_data__1_ = [0.0, 10.0, 1.0, 12.0, 2.0, 15.0];' + ]) + expect(genJS(vars.get('_x'), 'init-lookups', { extData })).toEqual([ + '_x[0] = fns.createLookup(3, _x_data__0_);', + '_x[1] = fns.createLookup(3, _x_data__1_);' + ]) + expect(genJS(vars.get('_y'), 'eval', { extData })).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = fns.LOOKUP(_x[i], _time) * 10.0;', + '}' + ]) + expect(genJS(vars.get('_z'), 'eval', { extData })).toEqual(['_z = _y[1];']) + }) + + it('should work for lookup definition', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) ) ~~| + // `) + + const xmileVars = `\ + + 0,0.1,0.5,1,1.5,2 + 0,0.01,0.7,1,1.2,1.3 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(1) + expect(genJS(vars.get('_x'), 'decl')).toEqual([ + 'const _x_data_ = [0.0, 0.0, 0.1, 0.01, 0.5, 0.7, 1.0, 1.0, 1.5, 1.2, 2.0, 1.3];' + ]) + expect(genJS(vars.get('_x'), 'init-lookups')).toEqual(['_x = fns.createLookup(6, _x_data_);']) + }) + + // TODO: This test is skipped until we support XMILE spec 4.5.3: + // 4.5.3 Apply-to-All Arrays with Non-Apply-to-All Graphical Functions + it.skip('should work for lookup definition (one dimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[A1]( (0,10), (1,20) ) ~~| + // x[A2]( (0,30), (1,40) ) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + + 0,1 + 10,20 + + + + + 0,1 + 30,40 + + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x[_a1]'), 'decl')).toEqual(['const _x_data__0_ = [0.0, 10.0, 1.0, 20.0];']) + expect(genJS(vars.get('_x[_a2]'), 'decl')).toEqual(['const _x_data__1_ = [0.0, 30.0, 1.0, 40.0];']) + expect(genJS(vars.get('_x[_a1]'), 'init-lookups')).toEqual(['_x[0] = fns.createLookup(2, _x_data__0_);']) + expect(genJS(vars.get('_x[_a2]'), 'init-lookups')).toEqual(['_x[1] = fns.createLookup(2, _x_data__1_);']) + }) + + // TODO: This test is skipped until we support XMILE spec 4.5.3: + // 4.5.3 Apply-to-All Arrays with Non-Apply-to-All Graphical Functions + it.skip('should work for lookup definition (two dimensions)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + x[A1,B1]( (0,10), (1,20) ) ~~| + x[A1,B2]( (0,30), (1,40) ) ~~| + x[A2,B1]( (0,50), (1,60) ) ~~| + x[A2,B2]( (0,70), (1,80) ) ~~| + `) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x[_a1,_b1]'), 'decl')).toEqual(['const _x_data__0__0_ = [0.0, 10.0, 1.0, 20.0];']) + expect(genJS(vars.get('_x[_a1,_b2]'), 'decl')).toEqual(['const _x_data__0__1_ = [0.0, 30.0, 1.0, 40.0];']) + expect(genJS(vars.get('_x[_a2,_b1]'), 'decl')).toEqual(['const _x_data__1__0_ = [0.0, 50.0, 1.0, 60.0];']) + expect(genJS(vars.get('_x[_a2,_b2]'), 'decl')).toEqual(['const _x_data__1__1_ = [0.0, 70.0, 1.0, 80.0];']) + expect(genJS(vars.get('_x[_a1,_b1]'), 'init-lookups')).toEqual(['_x[0][0] = fns.createLookup(2, _x_data__0__0_);']) + expect(genJS(vars.get('_x[_a1,_b2]'), 'init-lookups')).toEqual(['_x[0][1] = fns.createLookup(2, _x_data__0__1_);']) + expect(genJS(vars.get('_x[_a2,_b1]'), 'init-lookups')).toEqual(['_x[1][0] = fns.createLookup(2, _x_data__1__0_);']) + expect(genJS(vars.get('_x[_a2,_b2]'), 'init-lookups')).toEqual(['_x[1][1] = fns.createLookup(2, _x_data__1__1_);']) + }) + + it('should work for lookup call', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) ) ~~| + // y = x(2) ~~| + // `) + + const xmileVars = `\ + + 0,0.1,0.5,1,1.5,2 + 0,0.01,0.7,1,1.2,1.3 + + + x(2) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'decl')).toEqual([ + 'const _x_data_ = [0.0, 0.0, 0.1, 0.01, 0.5, 0.7, 1.0, 1.0, 1.5, 1.2, 2.0, 1.3];' + ]) + expect(genJS(vars.get('_x'), 'init-lookups')).toEqual(['_x = fns.createLookup(6, _x_data_);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.LOOKUP(_x, 2.0);']) + }) + + // TODO: This test is skipped until we support XMILE spec 4.5.3: + // 4.5.3 Apply-to-All Arrays with Non-Apply-to-All Graphical Functions + it.skip('should work for lookup call (with one dimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[A1]( [(0,0)-(2,2)], (0,0),(2,1.3) ) ~~| + // x[A2]( [(0,0)-(2,2)], (0,0.5),(2,1.5) ) ~~| + // y = x[A1](2) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + + 0,2 + 0,1.3 + + + + + 0,2 + 0.5,1.5 + + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_x[_a1]'), 'decl')).toEqual(['const _x_data__0_ = [0.0, 0.0, 2.0, 1.3];']) + expect(genJS(vars.get('_x[_a2]'), 'decl')).toEqual(['const _x_data__1_ = [0.0, 0.5, 2.0, 1.5];']) + expect(genJS(vars.get('_x[_a1]'), 'init-lookups')).toEqual(['_x[0] = fns.createLookup(2, _x_data__0_);']) + expect(genJS(vars.get('_x[_a2]'), 'init-lookups')).toEqual(['_x[1] = fns.createLookup(2, _x_data__1_);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.LOOKUP(_x[0], 2.0);']) + }) + + it('should work for constant definition (with one dimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1 ~~| + // y = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 1 + + + x[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'init-constants')).toEqual(['for (let i = 0; i < 3; i++) {', '_x[i] = 1.0;', '}']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[1];']) + }) + + // TODO: This test is skipped because XMILE doesn't support :EXCEPT: operator + it.skip('should work for constant definition (with two dimensions + except + subdimension)', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + SubA: A2, A3 ~~| + DimC: C1, C2 ~~| + x[DimC, SubA] = 1 ~~| + x[DimC, DimA] :EXCEPT: [DimC, SubA] = 2 ~~| + `) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_x[_dimc,_a1]'), 'init-constants')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_x[i][0] = 2.0;', + '}' + ]) + expect(genJS(vars.get('_x[_dimc,_a2]'), 'init-constants')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_x[i][1] = 1.0;', + '}' + ]) + expect(genJS(vars.get('_x[_dimc,_a3]'), 'init-constants')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_x[i][2] = 1.0;', + '}' + ]) + }) + + it('should work for constant definition (with separate subscripts)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[A1] = 1 ~~| + // x[A2] = 2 ~~| + // x[A3] = 3 ~~| + // y = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + x[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x[_a1]'), 'init-constants')).toEqual(['_x[0] = 1.0;']) + expect(genJS(vars.get('_x[_a2]'), 'init-constants')).toEqual(['_x[1] = 2.0;']) + expect(genJS(vars.get('_x[_a3]'), 'init-constants')).toEqual(['_x[2] = 3.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[1];']) + }) + + it('should work for const list definition (1D)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // y = x[A2] ~~| + // `) + + // XMILE doesn't have a const list shorthand like Vensim, so this test is basically the + // same as the previous one + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + x[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x[_a1]'), 'init-constants')).toEqual(['_x[0] = 1.0;']) + expect(genJS(vars.get('_x[_a2]'), 'init-constants')).toEqual(['_x[1] = 2.0;']) + expect(genJS(vars.get('_x[_a3]'), 'init-constants')).toEqual(['_x[2] = 3.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[1];']) + }) + + it('should work for const list definition (2D, dimensions in normal/alphabetized order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2, B3 ~~| + // x[DimA, DimB] = 1, 2, 3; 4, 5, 6; ~~| + // y = x[A2, B3] ~~| + // `) + + // XMILE doesn't have a const list shorthand like Vensim, so we use a non-apply-to-all definition + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + + x[A2, B3] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(7) + expect(genJS(vars.get('_x[_a1,_b1]'), 'init-constants')).toEqual(['_x[0][0] = 1.0;']) + expect(genJS(vars.get('_x[_a1,_b2]'), 'init-constants')).toEqual(['_x[0][1] = 2.0;']) + expect(genJS(vars.get('_x[_a1,_b3]'), 'init-constants')).toEqual(['_x[0][2] = 3.0;']) + expect(genJS(vars.get('_x[_a2,_b1]'), 'init-constants')).toEqual(['_x[1][0] = 4.0;']) + expect(genJS(vars.get('_x[_a2,_b2]'), 'init-constants')).toEqual(['_x[1][1] = 5.0;']) + expect(genJS(vars.get('_x[_a2,_b3]'), 'init-constants')).toEqual(['_x[1][2] = 6.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[1][2];']) + }) + + it('should work for const list definition (2D, dimensions not in normal/alphabetized order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimB: B1, B2, B3 ~~| + // DimA: A1, A2 ~~| + // x[DimB, DimA] = 1, 2; 3, 4; 5, 6; ~~| + // y = x[B3, A2] ~~| + // z = x[B2, A1] ~~| + // `) + + // XMILE doesn't have a const list shorthand like Vensim, so we use a non-apply-to-all definition + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + + x[B3, A2] + + + x[B2, A1] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(8) + expect(genJS(vars.get('_x[_b1,_a1]'), 'init-constants')).toEqual(['_x[0][0] = 1.0;']) + expect(genJS(vars.get('_x[_b1,_a2]'), 'init-constants')).toEqual(['_x[0][1] = 2.0;']) + expect(genJS(vars.get('_x[_b2,_a1]'), 'init-constants')).toEqual(['_x[1][0] = 3.0;']) + expect(genJS(vars.get('_x[_b2,_a2]'), 'init-constants')).toEqual(['_x[1][1] = 4.0;']) + expect(genJS(vars.get('_x[_b3,_a1]'), 'init-constants')).toEqual(['_x[2][0] = 5.0;']) + expect(genJS(vars.get('_x[_b3,_a2]'), 'init-constants')).toEqual(['_x[2][1] = 6.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[2][1];']) + expect(genJS(vars.get('_z'))).toEqual(['_z = _x[1][0];']) + }) + + it('should work for const list definition (2D separated, dimensions in normal/alphabetized order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // DimB: B1, B2 ~~| + // x[A1, DimB] = 1,2 ~~| + // x[A2, DimB] = 3,4 ~~| + // x[A3, DimB] = 5,6 ~~| + // y = x[A3, B2] ~~| + // `) + + // XMILE doesn't have a const list shorthand like Vensim, so we use a non-apply-to-all definition + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + + x[A3, B2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(7) + expect(genJS(vars.get('_x[_a1,_b1]'), 'init-constants')).toEqual(['_x[0][0] = 1.0;']) + expect(genJS(vars.get('_x[_a1,_b2]'), 'init-constants')).toEqual(['_x[0][1] = 2.0;']) + expect(genJS(vars.get('_x[_a2,_b1]'), 'init-constants')).toEqual(['_x[1][0] = 3.0;']) + expect(genJS(vars.get('_x[_a2,_b2]'), 'init-constants')).toEqual(['_x[1][1] = 4.0;']) + expect(genJS(vars.get('_x[_a3,_b1]'), 'init-constants')).toEqual(['_x[2][0] = 5.0;']) + expect(genJS(vars.get('_x[_a3,_b2]'), 'init-constants')).toEqual(['_x[2][1] = 6.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[2][1];']) + }) + + it('should work for const list definition (2D separated, dimensions not in normal/alphabetized order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // DimB: B1, B2 ~~| + // x[B1, DimA] = 1,2,3 ~~| + // x[B2, DimA] = 4,5,6 ~~| + // y = x[B2, A3] ~~| + // `) + + // XMILE doesn't have a const list shorthand like Vensim, so we use a non-apply-to-all definition + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + + x[B2, A3] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(7) + expect(genJS(vars.get('_x[_b1,_a1]'), 'init-constants')).toEqual(['_x[0][0] = 1.0;']) + expect(genJS(vars.get('_x[_b1,_a2]'), 'init-constants')).toEqual(['_x[0][1] = 2.0;']) + expect(genJS(vars.get('_x[_b1,_a3]'), 'init-constants')).toEqual(['_x[0][2] = 3.0;']) + expect(genJS(vars.get('_x[_b2,_a1]'), 'init-constants')).toEqual(['_x[1][0] = 4.0;']) + expect(genJS(vars.get('_x[_b2,_a2]'), 'init-constants')).toEqual(['_x[1][1] = 5.0;']) + expect(genJS(vars.get('_x[_b2,_a3]'), 'init-constants')).toEqual(['_x[1][2] = 6.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = _x[1][2];']) + }) + + it('should work for equation with one dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // y[DimA] = (x[DimA] + 2) * MIN(0, x[DimA]) ~~| + // z = y[A2] ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + (x[DimA] + 2) * MIN(0, x[DimA]) + + + y[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x[_a1]'), 'init-constants')).toEqual(['_x[0] = 1.0;']) + expect(genJS(vars.get('_x[_a2]'), 'init-constants')).toEqual(['_x[1] = 2.0;']) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = (_x[i] + 2.0) * fns.MIN(0.0, _x[i]);', + '}' + ]) + expect(genJS(vars.get('_z'))).toEqual(['_z = _y[1];']) + }) + + it('should work for equation with two dimensions', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1, 2; 3, 4; ~~| + // y[DimA, DimB] = (x[DimA, DimB] + 2) * MIN(0, x[DimA, DimB]) ~~| + // z = y[A2, B1] ~~| + // `) + + const xmileDims = `\ + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + + + + + + (x[DimA, DimB] + 2) * MIN(0, x[DimA, DimB]) + + + y[A2, B1] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_x[_a1,_b1]'), 'init-constants')).toEqual(['_x[0][0] = 1.0;']) + expect(genJS(vars.get('_x[_a1,_b2]'), 'init-constants')).toEqual(['_x[0][1] = 2.0;']) + expect(genJS(vars.get('_x[_a2,_b1]'), 'init-constants')).toEqual(['_x[1][0] = 3.0;']) + expect(genJS(vars.get('_x[_a2,_b2]'), 'init-constants')).toEqual(['_x[1][1] = 4.0;']) + expect(genJS(vars.get('_y'))).toEqual([ + 'for (let i = 0; i < 2; i++) {', + 'for (let j = 0; j < 2; j++) {', + '_y[i][j] = (_x[i][j] + 2.0) * fns.MIN(0.0, _x[i][j]);', + '}', + '}' + ]) + expect(genJS(vars.get('_z'))).toEqual(['_z = _y[1][0];']) + }) + + // + // NOTE: We omit the tests for all the different variations of subscripted variables (like we have in + // `gen-equation-{c,js}-from-vensim.spec.ts`) because XMILE has a simpler subset of supported cases, + // and these are already well covered by the other tests. If there are any XMILE-specific cases, we + // can add tests for those here. + // + + it('should work for ABS function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = ABS(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + ABS(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.ABS(_x);']) + }) + + // TODO: This test is skipped for now; in Stella, the function is called `ALLOCATE` and we will need to see + // if the Vensim `ALLOCATE AVAILABLE` function is compatible enough + it.skip('should work for ALLOCATE AVAILABLE function', () => { + const vars = readInlineModel(` + branch: Boston, Dayton, Fresno ~~| + pprofile: ptype, ppriority ~~| + supply available = 200 ~~| + demand[branch] = 500,300,750 ~~| + priority[Boston,pprofile] = 1,5 ~~| + priority[Dayton,pprofile] = 1,7 ~~| + priority[Fresno,pprofile] = 1,3 ~~| + shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[branch,ptype], supply available) ~~| + `) + expect(vars.size).toBe(11) + expect(genJS(vars.get('_supply_available'))).toEqual(['_supply_available = 200.0;']) + expect(genJS(vars.get('_demand[_boston]'))).toEqual(['_demand[0] = 500.0;']) + expect(genJS(vars.get('_demand[_dayton]'))).toEqual(['_demand[1] = 300.0;']) + expect(genJS(vars.get('_demand[_fresno]'))).toEqual(['_demand[2] = 750.0;']) + expect(genJS(vars.get('_priority[_boston,_ptype]'))).toEqual(['_priority[0][0] = 1.0;']) + expect(genJS(vars.get('_priority[_boston,_ppriority]'))).toEqual(['_priority[0][1] = 5.0;']) + expect(genJS(vars.get('_priority[_dayton,_ptype]'))).toEqual(['_priority[1][0] = 1.0;']) + expect(genJS(vars.get('_priority[_dayton,_ppriority]'))).toEqual(['_priority[1][1] = 7.0;']) + expect(genJS(vars.get('_priority[_fresno,_ptype]'))).toEqual(['_priority[2][0] = 1.0;']) + expect(genJS(vars.get('_priority[_fresno,_ppriority]'))).toEqual(['_priority[2][1] = 3.0;']) + // expect(genJS(vars.get('_shipments'))).toEqual([ + // 'let __t1 = fns.ALLOCATE_AVAILABLE(_demand, _priority, _supply_available, 3);', + // 'for (let i = 0; i < 3; i++) {', + // '_shipments[i] = __t1[_branch[i]];', + // '}' + // ]) + expect(() => genJS(vars.get('_shipments'))).toThrow( + 'ALLOCATE AVAILABLE function not yet implemented for JS code gen' + ) + }) + + // TODO: Copy more tests from gen-equation-c.spec.ts once we implement ALLOCATE AVAILABLE + // for JS code gen + + it('should work for ARCCOS function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = ARCCOS(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + ARCCOS(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.ARCCOS(_x);']) + }) + + it('should work for ARCSIN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = ARCSIN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + ARCSIN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.ARCSIN(_x);']) + }) + + it('should work for ARCTAN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = ARCTAN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + ARCTAN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.ARCTAN(_x);']) + }) + + it('should work for COS function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = COS(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + COS(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.COS(_x);']) + }) + + // TODO: Subscripted variants + it('should work for DELAY1 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = DELAY1(x, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + DELAY1(x, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _x * 5.0;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual(['__level1 = fns.INTEG(__level1, _x - _y);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual(['__aux1 = 5.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = (__level1 / __aux1);']) + }) + + it('should work for DELAY1 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // init = 2 ~~| + // y = DELAY1I(x, 5, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + DELAY1(x, 5, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(5) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_init'))).toEqual(['_init = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _init * 5.0;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual(['__level1 = fns.INTEG(__level1, _x - _y);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual(['__aux1 = 5.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = (__level1 / __aux1);']) + }) + + it('should work for DELAY3 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = DELAY3(x, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + DELAY3(x, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(9) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _x * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual(['__level1 = fns.INTEG(__level1, _x - __aux1);']) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual(['__level2 = _x * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level2'), 'eval')).toEqual(['__level2 = fns.INTEG(__level2, __aux1 - __aux2);']) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual(['__level3 = _x * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level3'), 'eval')).toEqual(['__level3 = fns.INTEG(__level3, __aux2 - __aux3);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual(['__aux1 = __level1 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux2'), 'eval')).toEqual(['__aux2 = __level2 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux3'), 'eval')).toEqual(['__aux3 = __level3 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux4'), 'eval')).toEqual(['__aux4 = ((5.0) / 3.0);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = (__level3 / __aux4);']) + }) + + it('should work for DELAY3 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // init = 2 ~~| + // y = DELAY3I(x, 5, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + DELAY3(x, 5, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(10) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_init'))).toEqual(['_init = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _init * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual(['__level1 = fns.INTEG(__level1, _x - __aux1);']) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual(['__level2 = _init * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level2'), 'eval')).toEqual(['__level2 = fns.INTEG(__level2, __aux1 - __aux2);']) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual(['__level3 = _init * ((5.0) / 3.0);']) + expect(genJS(vars.get('__level3'), 'eval')).toEqual(['__level3 = fns.INTEG(__level3, __aux2 - __aux3);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual(['__aux1 = __level1 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux2'), 'eval')).toEqual(['__aux2 = __level2 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux3'), 'eval')).toEqual(['__aux3 = __level3 / ((5.0) / 3.0);']) + expect(genJS(vars.get('__aux4'), 'eval')).toEqual(['__aux4 = ((5.0) / 3.0);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = (__level3 / __aux4);']) + }) + + it('should work for DELAY3 function (1D with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // init[DimA] = 2, 3 ~~| + // y[DimA] = DELAY3I(x[DimA], 5, init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + + 2 + + + 3 + + + + + + + DELAY3(x[DimA], 5, init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(12) + 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('_init[_a1]'))).toEqual(['_init[0] = 2.0;']) + expect(genJS(vars.get('_init[_a2]'))).toEqual(['_init[1] = 3.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = _init[i] * ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = fns.INTEG(__level1[i], _x[i] - __aux1[i]);', + '}' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = _init[i] * ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__level2'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = fns.INTEG(__level2[i], __aux1[i] - __aux2[i]);', + '}' + ]) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = _init[i] * ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__level3'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = fns.INTEG(__level3[i], __aux2[i] - __aux3[i]);', + '}' + ]) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__aux1[i] = __level1[i] / ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__aux2'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__aux2[i] = __level2[i] / ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__aux3'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__aux3[i] = __level3[i] / ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('__aux4'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__aux4[i] = ((5.0) / 3.0);', + '}' + ]) + expect(genJS(vars.get('_y'))).toEqual(['for (let i = 0; i < 2; i++) {', '_y[i] = (__level3[i] / __aux4[i]);', '}']) + }) + + it('should work for DELAY function (XMILE equivalent to Vensim DELAY FIXED)', () => { + // Stella's DELAY function is equivalent to Vensim's DELAY FIXED function. + // Note: JS code gen is not yet implemented for DELAY FIXED/DELAY, so this test + // just verifies that the parsing works correctly and throws the expected error. + + const xmileVars = `\ + + 1 + + + 5 + + + 2 + + + DELAY(x, delay_time, init) + +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_delay_time'))).toEqual(['_delay_time = 5.0;']) + expect(genJS(vars.get('_init'))).toEqual(['_init = 2.0;']) + // JS code gen is not yet implemented for DELAY, so verify it throws the expected error + expect(() => genJS(vars.get('_y'), 'init-levels')).toThrow('DELAY function not yet implemented for JS code gen') + expect(() => genJS(vars.get('_y'), 'eval')).toThrow('DELAY function not yet implemented for JS code gen') + }) + + // TODO: This test is skipped for now; the Vensim DELAY FIXED function JS code gen + // is not yet implemented + it.skip('should work for DELAY FIXED function', () => { + const vars = readInlineModel(` + x = 1 ~~| + init = 2 ~~| + y = DELAY FIXED(x, 5, init) ~~| + `) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_init'))).toEqual(['_init = 2.0;']) + // expect(genJS(vars.get('_y'), 'init-levels')).toEqual([ + // '_y = _init;', + // '__fixed_delay1 = __new_fixed_delay(__fixed_delay1, 5.0, _init);' + // ]) + // expect(genJS(vars.get('_y'), 'eval')).toEqual(['_y = fns.DELAY_FIXED(_x, __fixed_delay1);']) + expect(() => genJS(vars.get('_y'), 'init-levels')).toThrow( + 'DELAY FIXED function not yet implemented for JS code gen' + ) + expect(() => genJS(vars.get('_y'), 'eval')).toThrow('DELAY FIXED function not yet implemented for JS code gen') + }) + + it('should work for DEPRECIATE_STRAIGHTLINE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // dtime = 20 ~~| + // Capacity Cost = 1000 ~~| + // New Capacity = 2000 ~~| + // stream = Capacity Cost * New Capacity ~~| + // Depreciated Amount = DEPRECIATE STRAIGHTLINE(stream, dtime, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 20 + + + 1000 + + + 2000 + + + Capacity_Cost * New_Capacity + + + DEPRECIATE_STRAIGHTLINE(stream, dtime, 1, 0) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(5) + expect(genJS(vars.get('_dtime'))).toEqual(['_dtime = 20.0;']) + expect(genJS(vars.get('_capacity_cost'))).toEqual(['_capacity_cost = 1000.0;']) + expect(genJS(vars.get('_new_capacity'))).toEqual(['_new_capacity = 2000.0;']) + expect(genJS(vars.get('_stream'))).toEqual(['_stream = _capacity_cost * _new_capacity;']) + // JS code gen is not yet implemented for DEPRECIATE_STRAIGHTLINE, so verify it throws the expected error + expect(() => genJS(vars.get('_depreciated_amount'), 'init-levels')).toThrow( + 'DEPRECIATE_STRAIGHTLINE function not yet implemented for JS code gen' + ) + expect(() => genJS(vars.get('_depreciated_amount'), 'eval')).toThrow( + 'DEPRECIATE_STRAIGHTLINE function not yet implemented for JS code gen' + ) + }) + + it('should work for EXP function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = EXP(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + EXP(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.EXP(_x);']) + }) + + // TODO: Implement this test once we support the GAMMALN function for XMILE+JS + it.skip('should work for GAMMALN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = GAMMA LN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + GAMMALN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + // expect(genJS(vars.get('_y'))).toEqual(['_y = fns.GAMMA_LN(_x);']) + expect(() => genJS(vars.get('_y'))).toThrow('GAMMA_LN function not yet implemented for JS code gen') + }) + + it('should work for IF THEN ELSE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = ABS(1) ~~| + // y = IF THEN ELSE(x > 0, 1, x) ~~| + // `) + + const xmileVars = `\ + + ABS(1) + + + IF x > 0 THEN 1 ELSE x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = fns.ABS(1.0);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = ((_x > 0.0) ? (1.0) : (_x));']) + }) + + it('should work for INIT function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time * 2 ~~| + // y = INITIAL(x) ~~| + // `) + + const xmileVars = `\ + + Time * 2 + + + INIT(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'init-levels')).toEqual(['_x = _time * 2.0;']) + expect(genJS(vars.get('_x'), 'eval')).toEqual(['_x = _time * 2.0;']) + expect(genJS(vars.get('_y'), 'init-levels')).toEqual(['_y = _x;']) + }) + + it('should work for INTEG function (synthesized from variable definition)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time * 2 ~~| + // y = INTEG(x, 10) ~~| + // `) + + const xmileVars = `\ + + Time * 2 + + + 10 + x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'eval')).toEqual(['_x = _time * 2.0;']) + expect(genJS(vars.get('_y'), 'init-levels')).toEqual(['_y = 10.0;']) + expect(genJS(vars.get('_y'), 'eval')).toEqual(['_y = fns.INTEG(_y, _x);']) + }) + + it('should work for INTEG function (synthesized from variable definition with one dimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // rate[DimA] = 10, 20 ~~| + // init[DimA] = 1, 2 ~~| + // y[DimA] = INTEG(rate[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + + + + + + 1 + + + 2 + + + + + + + init[DimA] + rate[DimA] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(5) + expect(genJS(vars.get('_rate[_a1]'), 'init-constants')).toEqual(['_rate[0] = 10.0;']) + expect(genJS(vars.get('_rate[_a2]'), 'init-constants')).toEqual(['_rate[1] = 20.0;']) + expect(genJS(vars.get('_init[_a1]'), 'init-constants')).toEqual(['_init[0] = 1.0;']) + expect(genJS(vars.get('_init[_a2]'), 'init-constants')).toEqual(['_init[1] = 2.0;']) + expect(genJS(vars.get('_y'), 'init-levels')).toEqual(['for (let i = 0; i < 2; i++) {', '_y[i] = _init[i];', '}']) + expect(genJS(vars.get('_y'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '_y[i] = fns.INTEG(_y[i], _rate[i]);', + '}' + ]) + }) + + it('should work for INTEG function (synthesized from variable definition with SUM used in arguments)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // rate[DimA] = 10, 20 ~~| + // init[DimA] = 1, 2 ~~| + // y = INTEG(SUM(rate[DimA!]), SUM(init[DimA!])) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + + + + + + 1 + + + 2 + + + + SUM(init[*]) + SUM(rate[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(5) + expect(genJS(vars.get('_rate[_a1]'), 'init-constants')).toEqual(['_rate[0] = 10.0;']) + expect(genJS(vars.get('_rate[_a2]'), 'init-constants')).toEqual(['_rate[1] = 20.0;']) + expect(genJS(vars.get('_init[_a1]'), 'init-constants')).toEqual(['_init[0] = 1.0;']) + expect(genJS(vars.get('_init[_a2]'), 'init-constants')).toEqual(['_init[1] = 2.0;']) + expect(genJS(vars.get('_y'), 'init-levels')).toEqual([ + 'let __t1 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t1 += _init[u];', + '}', + '_y = __t1;' + ]) + expect(genJS(vars.get('_y'), 'eval')).toEqual([ + 'let __t2 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t2 += _rate[u];', + '}', + '_y = fns.INTEG(_y, __t2);' + ]) + }) + + it('should work for INT function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = INTEGER(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + INT(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.INTEGER(_x);']) + }) + + it('should work for LN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = LN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + LN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.LN(_x);']) + }) + + it('should work for LOOKUP function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x((0,0),(1,1),(2,2)) ~~| + // y = x(1.5) ~~| + // `) + + const xmileVars = `\ + + 0,1,2 + 0,1,2 + + + LOOKUP(x, 1.5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'decl')).toEqual(['const _x_data_ = [0.0, 0.0, 1.0, 1.0, 2.0, 2.0];']) + expect(genJS(vars.get('_x'), 'init-lookups')).toEqual(['_x = fns.createLookup(3, _x_data_);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.LOOKUP(_x, 1.5);']) + }) + + it('should work for LOOKUPINV function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x((0,0),(1,1),(2,2)) ~~| + // y = LOOKUP INVERT(x, 1.5) ~~| + // `) + + const xmileVars = `\ + + 0,1,2 + 0,1,2 + + + LOOKUPINV(x, 1.5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'), 'decl')).toEqual(['const _x_data_ = [0.0, 0.0, 1.0, 1.0, 2.0, 2.0];']) + expect(genJS(vars.get('_x'), 'init-lookups')).toEqual(['_x = fns.createLookup(3, _x_data_);']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.LOOKUP_INVERT(_x, 1.5);']) + }) + + it('should work for MAX function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = MAX(x, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + MAX(x, 0) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.MAX(_x, 0.0);']) + }) + + it('should work for MIN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = MIN(x, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + MIN(x, 0) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.MIN(_x, 0.0);']) + }) + + it('should work for MOD function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = MODULO(x, 2) ~~| + // `) + + const xmileVars = `\ + + 1 + + + MOD(x, 2) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.MODULO(_x, 2.0);']) + }) + + // TODO: This test is skipped because Stella's NPV function takes 2 or 3 arguments, but Vensim's + // takes 4 arguments, so it is not implemented yet in SDE + it.skip('should work for NPV function', () => { + const vars = readInlineModel(` + time step = 1 ~~| + stream = 100 ~~| + discount rate = 10 ~~| + init = 0 ~~| + factor = 2 ~~| + y = NPV(stream, discount rate, init, factor) ~~| + `) + expect(vars.size).toBe(9) + expect(genJS(vars.get('_stream'))).toEqual(['_stream = 100.0;']) + expect(genJS(vars.get('_discount_rate'))).toEqual(['_discount_rate = 10.0;']) + expect(genJS(vars.get('_init'))).toEqual(['_init = 0.0;']) + expect(genJS(vars.get('_factor'))).toEqual(['_factor = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = 1.0;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + '__level1 = fns.INTEG(__level1, (-__level1 * _discount_rate) / (1.0 + _discount_rate * _time_step));' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual(['__level2 = _init;']) + expect(genJS(vars.get('__level2'), 'eval')).toEqual(['__level2 = fns.INTEG(__level2, _stream * __level1);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual([ + '__aux1 = (__level2 + _stream * _time_step * __level1) * _factor;' + ]) + expect(genJS(vars.get('_y'))).toEqual(['_y = __aux1;']) + }) + + // TODO: This test is skipped because Stella's PULSE function takes 1 or 3 arguments, but Vensim's + // takes 2 arguments, so it is not implemented yet in SDE + it.skip('should work for PULSE function', () => { + const vars = readInlineModel(` + x = 10 ~~| + y = PULSE(x, 20) ~~| + `) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 10.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.PULSE(_x, 20.0);']) + }) + + it('should work for RAMP function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // slope = 100 ~~| + // start = 1 ~~| + // end = 10 ~~| + // y = RAMP(slope, start, end) ~~| + // `) + + const xmileVars = `\ + + 100 + + + 1 + + + 10 + + + RAMP(slope, start, end) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_slope'))).toEqual(['_slope = 100.0;']) + expect(genJS(vars.get('_start'))).toEqual(['_start = 1.0;']) + expect(genJS(vars.get('_end'))).toEqual(['_end = 10.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.RAMP(_slope, _start, _end);']) + }) + + it('should work for SAFEDIV function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = ZIDZ(x, 2) ~~| + // `) + + const xmileVars = `\ + + 1 + + + SAFEDIV(x, 2) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.ZIDZ(_x, 2.0);']) + }) + + it('should work for SIN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = SIN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + SIN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.SIN(_x);']) + }) + + it('should work for SMTH1 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH(input, delay) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH1(input, delay) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_input'))).toEqual(['_input = 1.0;']) + expect(genJS(vars.get('_delay'))).toEqual(['_delay = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _input;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + '__level1 = fns.INTEG(__level1, (_input - __level1) / _delay);' + ]) + expect(genJS(vars.get('_y'))).toEqual(['_y = __level1;']) + }) + + it('should work for SMTH1 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTHI(input, delay, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH1(input, delay, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_input'))).toEqual(['_input = 1.0;']) + expect(genJS(vars.get('_delay'))).toEqual(['_delay = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = 5.0;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + '__level1 = fns.INTEG(__level1, (_input - __level1) / _delay);' + ]) + expect(genJS(vars.get('_y'))).toEqual(['_y = __level1;']) + }) + + it('should work for SMTH3 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH3(input, delay) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH3(input, delay) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_input'))).toEqual(['_input = 1.0;']) + expect(genJS(vars.get('_delay'))).toEqual(['_delay = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _input;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + '__level1 = fns.INTEG(__level1, (_input - __level1) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual(['__level2 = _input;']) + expect(genJS(vars.get('__level2'), 'eval')).toEqual([ + '__level2 = fns.INTEG(__level2, (__level1 - __level2) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual(['__level3 = _input;']) + expect(genJS(vars.get('__level3'), 'eval')).toEqual([ + '__level3 = fns.INTEG(__level3, (__level2 - __level3) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('_y'))).toEqual(['_y = __level3;']) + }) + + it('should work for SMTH3 function (no dimensions with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH3I(input, delay, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH3(input, delay, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_input'))).toEqual(['_input = 1.0;']) + expect(genJS(vars.get('_delay'))).toEqual(['_delay = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = 5.0;']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + '__level1 = fns.INTEG(__level1, (_input - __level1) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual(['__level2 = 5.0;']) + expect(genJS(vars.get('__level2'), 'eval')).toEqual([ + '__level2 = fns.INTEG(__level2, (__level1 - __level2) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual(['__level3 = 5.0;']) + expect(genJS(vars.get('__level3'), 'eval')).toEqual([ + '__level3 = fns.INTEG(__level3, (__level2 - __level3) / (_delay / 3.0));' + ]) + expect(genJS(vars.get('_y'))).toEqual(['_y = __level3;']) + }) + + it('should work for SMTH3 function (1D with subscripted delay parameter and initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay[DimA] = 2 ~~| + // y[DimA] = SMOOTH3I(input[DimA], delay[DimA], 5) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + 2 + + + + + + SMTH3(input[DimA], delay[DimA], 5) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_input'))).toEqual(['for (let i = 0; i < 2; i++) {', '_input[i] = 1.0;', '}']) + expect(genJS(vars.get('_delay'))).toEqual(['for (let i = 0; i < 2; i++) {', '_delay[i] = 2.0;', '}']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = fns.INTEG(__level1[i], (_input[i] - __level1[i]) / (_delay[i] / 3.0));', + '}' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level2'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = fns.INTEG(__level2[i], (__level1[i] - __level2[i]) / (_delay[i] / 3.0));', + '}' + ]) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level3'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = fns.INTEG(__level3[i], (__level2[i] - __level3[i]) / (_delay[i] / 3.0));', + '}' + ]) + expect(genJS(vars.get('_y'))).toEqual(['for (let i = 0; i < 2; i++) {', '_y[i] = __level3[i];', '}']) + }) + + it('should work for SMTH3 function (1D with non-subscripted delay parameter and initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay = 2 ~~| + // y[DimA] = SMOOTH3I(input[DimA], delay, 5) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + 2 + + + + + + SMTH3(input[DimA], delay, 5) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(6) + expect(genJS(vars.get('_input'))).toEqual(['for (let i = 0; i < 2; i++) {', '_input[i] = 1.0;', '}']) + expect(genJS(vars.get('_delay'))).toEqual(['_delay = 2.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level1'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level1[i] = fns.INTEG(__level1[i], (_input[i] - __level1[i]) / (_delay / 3.0));', + '}' + ]) + expect(genJS(vars.get('__level2'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level2'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level2[i] = fns.INTEG(__level2[i], (__level1[i] - __level2[i]) / (_delay / 3.0));', + '}' + ]) + expect(genJS(vars.get('__level3'), 'init-levels')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = 5.0;', + '}' + ]) + expect(genJS(vars.get('__level3'), 'eval')).toEqual([ + 'for (let i = 0; i < 2; i++) {', + '__level3[i] = fns.INTEG(__level3[i], (__level2[i] - __level3[i]) / (_delay / 3.0));', + '}' + ]) + expect(genJS(vars.get('_y'))).toEqual(['for (let i = 0; i < 2; i++) {', '_y[i] = __level3[i];', '}']) + }) + + it('should work for SIZE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // a = ELMCOUNT(DimA) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + SIZE(DimA) + + + + + + 10*SIZE(DimA)+a +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_a'))).toEqual(['_a = 3;']) + expect(genJS(vars.get('_b'))).toEqual(['for (let i = 0; i < 3; i++) {', '_b[i] = 10.0 * 3 + _a;', '}']) + }) + + it('should work for SQRT function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = SQRT(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + SQRT(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.SQRT(_x);']) + }) + + it('should work for STEP function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = STEP(x, 10) ~~| + // `) + + const xmileVars = `\ + + 1 + + + STEP(x, 10) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.STEP(_x, 10.0);']) + }) + + it('should work for SUM function (single call)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // a[DimA] = 10, 20 ~~| + // x = SUM(a[DimA!]) + 1 ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + + SUM(a[*]) + 1 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_a[_a1]'), 'init-constants')).toEqual(['_a[0] = 10.0;']) + expect(genJS(vars.get('_a[_a2]'), 'init-constants')).toEqual(['_a[1] = 20.0;']) + expect(genJS(vars.get('_x'))).toEqual([ + 'let __t1 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t1 += _a[u];', + '}', + '_x = __t1 + 1.0;' + ]) + }) + + it('should work for SUM function (multiple calls)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // a[DimA] = 10, 20 ~~| + // b[DimB] = 50, 60 ~~| + // c[DimA] = 1, 2 ~~| + // x = SUM(a[DimA!]) + SUM(b[DimB!]) + SUM(c[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + + + + + + 50 + + + 60 + + + + + + + + 1 + + + 2 + + + + SUM(a[*]) + SUM(b[*]) + SUM(c[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(7) + expect(genJS(vars.get('_a[_a1]'), 'init-constants')).toEqual(['_a[0] = 10.0;']) + expect(genJS(vars.get('_a[_a2]'), 'init-constants')).toEqual(['_a[1] = 20.0;']) + expect(genJS(vars.get('_b[_b1]'), 'init-constants')).toEqual(['_b[0] = 50.0;']) + expect(genJS(vars.get('_b[_b2]'), 'init-constants')).toEqual(['_b[1] = 60.0;']) + expect(genJS(vars.get('_c[_a1]'), 'init-constants')).toEqual(['_c[0] = 1.0;']) + expect(genJS(vars.get('_c[_a2]'), 'init-constants')).toEqual(['_c[1] = 2.0;']) + expect(genJS(vars.get('_x'))).toEqual([ + 'let __t1 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t1 += _a[u];', + '}', + 'let __t2 = 0.0;', + 'for (let v = 0; v < 2; v++) {', + '__t2 += _b[v];', + '}', + 'let __t3 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t3 += _c[u];', + '}', + '_x = __t1 + __t2 + __t3;' + ]) + }) + + it('should work for SUM function (with nested function call)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // a[DimA] = 10, 20 ~~| + // x = SUM(IF THEN ELSE(a[DimA!] = 10, 0, a[DimA!])) + 1 ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + + SUM(IF a[*] = 10 THEN 0 ELSE a[*]) + 1 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(3) + expect(genJS(vars.get('_a[_a1]'), 'init-constants')).toEqual(['_a[0] = 10.0;']) + expect(genJS(vars.get('_a[_a2]'), 'init-constants')).toEqual(['_a[1] = 20.0;']) + expect(genJS(vars.get('_x'))).toEqual([ + 'let __t1 = 0.0;', + 'for (let u = 0; u < 2; u++) {', + '__t1 += ((_a[u] === 10.0) ? (0.0) : (_a[u]));', + '}', + '_x = __t1 + 1.0;' + ]) + }) + + it('should work for TAN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = TAN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + TAN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(2) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('_y'))).toEqual(['_y = fns.TAN(_x);']) + }) + + it('should work for TREND function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = TREND(x, 10, 2) ~~| + // `) + + const xmileVars = `\ + + 1 + + + TREND(x, 10, 2) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars.size).toBe(4) + expect(genJS(vars.get('_x'))).toEqual(['_x = 1.0;']) + expect(genJS(vars.get('__level1'), 'init-levels')).toEqual(['__level1 = _x / (1.0 + 2.0 * 10.0);']) + expect(genJS(vars.get('__level1'), 'eval')).toEqual(['__level1 = fns.INTEG(__level1, (_x - __level1) / 10.0);']) + expect(genJS(vars.get('__aux1'), 'eval')).toEqual(['__aux1 = fns.ZIDZ(_x - __level1, 10.0 * fns.ABS(__level1));']) + expect(genJS(vars.get('_y'))).toEqual(['_y = __aux1;']) + }) +}) diff --git a/packages/compile/src/generate/gen-expr.js b/packages/compile/src/generate/gen-expr.js index d05f5b98..d71b5a4e 100644 --- a/packages/compile/src/generate/gen-expr.js +++ b/packages/compile/src/generate/gen-expr.js @@ -162,8 +162,28 @@ export function generateExpr(expr, ctx) { * @return {string} The generated C/JS code. */ function generateFunctionCall(callExpr, ctx) { - const fnId = callExpr.fnId + function generateSimpleFunctionCall(fnId) { + const args = callExpr.args.map(argExpr => generateExpr(argExpr, ctx)) + if (ctx.outFormat === 'js' && fnId === '_IF_THEN_ELSE') { + // When generating conditional expressions for JS target, since we can't rely on macros like we do for C, + // it is better to translate it into a ternary instead of relying on a built-in function (since the latter + // would require always evaluating both branches, while the former can be more optimized by the interpreter) + return `((${args[0]}) ? (${args[1]}) : (${args[2]}))` + } else { + // For simple functions, emit a C/JS function call with a generated C/JS expression for each argument + return `${fnRef(fnId, ctx)}(${args.join(', ')})` + } + } + function generateLookupFunctionCall(fnId) { + // For LOOKUP* functions, the first argument must be a reference to the lookup variable. Emit + // 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(', ')})` + } + + const fnId = callExpr.fnId switch (fnId) { // // @@ -174,46 +194,54 @@ function generateFunctionCall(callExpr, ctx) { // // + // Simple functions that are common to Vensim and XMILE/Stella case '_ABS': case '_ARCCOS': case '_ARCSIN': case '_ARCTAN': case '_COS': case '_EXP': - case '_GAMMA_LN': case '_IF_THEN_ELSE': - case '_INTEGER': case '_LN': case '_MAX': case '_MIN': - case '_MODULO': - case '_POW': - case '_POWER': - case '_PULSE': - case '_PULSE_TRAIN': - case '_QUANTUM': case '_RAMP': case '_SIN': case '_SQRT': case '_STEP': case '_TAN': + return generateSimpleFunctionCall(fnId) + + // Simple functions supported by Vensim only + case '_GAMMA_LN': + case '_INTEGER': + case '_MODULO': + case '_POW': + case '_POWER': + case '_PULSE_TRAIN': + case '_PULSE': + case '_QUANTUM': case '_WITH_LOOKUP': case '_XIDZ': - case '_ZIDZ': { - const args = callExpr.args.map(argExpr => generateExpr(argExpr, ctx)) + case '_ZIDZ': if (ctx.outFormat === 'js' && fnId === '_GAMMA_LN') { throw new Error(`${callExpr.fnName} function not yet implemented for JS code gen`) } - if (ctx.outFormat === 'js' && fnId === '_IF_THEN_ELSE') { - // When generating conditional expressions for JS target, since we can't rely on macros like we do for C, - // it is better to translate it into a ternary instead of relying on a built-in function (since the latter - // would require always evaluating both branches, while the former can be more optimized by the interpreter) - return `((${args[0]}) ? (${args[1]}) : (${args[2]}))` - } else { - // For simple functions, emit a C/JS function call with a generated C/JS expression for each argument - return `${fnRef(fnId, ctx)}(${args.join(', ')})` - } - } + return generateSimpleFunctionCall(fnId) + + // Simple functions supported by XMILE/Stella only + case '_INT': + // XMILE/Stella uses `INT`, but it is the same as the Vensim `INTEGER` function, + // which is the name used in the runtime function implementation + return generateSimpleFunctionCall('_INTEGER') + case '_MOD': + // XMILE/Stella uses `MOD`, but it is the same as the Vensim `MODULO` function, + // which is the name used in the runtime function implementation + return generateSimpleFunctionCall('_MODULO') + case '_SAFEDIV': + // XMILE/Stella uses `SAFEDIV`, but it is the same as the Vensim `ZIDZ` function, + // which is the name used in the runtime function implementation + return generateSimpleFunctionCall('_ZIDZ') // // @@ -225,17 +253,12 @@ function generateFunctionCall(callExpr, ctx) { // // + // Lookup functions supported by Vensim only case '_GET_DATA_BETWEEN_TIMES': case '_LOOKUP_BACKWARD': case '_LOOKUP_FORWARD': - case '_LOOKUP_INVERT': { - // For LOOKUP* functions, the first argument must be a reference to the lookup variable. Emit - // 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 '_LOOKUP_INVERT': + return generateLookupFunctionCall(fnId) 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 @@ -244,6 +267,16 @@ function generateFunctionCall(callExpr, ctx) { return `${fnRef(fnId, ctx)}(${cLookupArg}, ${cDefaultArg})` } + // Lookup functions supported by XMILE/Stella only + case '_LOOKUP': + // XMILE/Stella has an explicit `LOOKUP` function while Vensim uses `x(y)` syntax, but + // underneath both are implemented at runtime by the `LOOKUP` function + return generateLookupFunctionCall('_LOOKUP') + case '_LOOKUPINV': + // XMILE/Stella uses `LOOKUPINV`, but it is the same as the Vensim `LOOKUP INVERT` function, + // which is the name used in the runtime function implementation + return generateLookupFunctionCall('_LOOKUP_INVERT') + // // // Level functions @@ -251,12 +284,16 @@ function generateFunctionCall(callExpr, ctx) { // case '_ACTIVE_INITIAL': + case '_DELAY': case '_DELAY_FIXED': case '_DEPRECIATE_STRAIGHTLINE': case '_SAMPLE_IF_TRUE': case '_INTEG': // Split level functions into init and eval expressions - if (ctx.outFormat === 'js' && (fnId === '_DELAY_FIXED' || fnId === '_DEPRECIATE_STRAIGHTLINE')) { + if ( + ctx.outFormat === 'js' && + (fnId === '_DELAY' || fnId === '_DELAY_FIXED' || fnId === '_DEPRECIATE_STRAIGHTLINE') + ) { throw new Error(`${callExpr.fnName} function not yet implemented for JS code gen`) } if (ctx.mode.startsWith('init')) { @@ -311,7 +348,12 @@ function generateFunctionCall(callExpr, ctx) { case '_SMOOTH': case '_SMOOTHI': case '_SMOOTH3': - case '_SMOOTH3I': { + case '_SMOOTH3I': + case '_SMTH1': + case '_SMTH3': { + // Note that Vensim uses `SMOOTH[I]` and `SMOOTH3[I]` while XMILE uses `SMTH1` and + // `SMTH3`, but otherwise they have been translated the same way during the read + // equations phase const smoothVar = Model.varWithRefId(ctx.variable.smoothVarRefId) return ctx.cVarRef(smoothVar.parsedEqn.lhs.varDef) } @@ -345,11 +387,13 @@ function generateFunctionCall(callExpr, ctx) { } return generateAllocateAvailableCall(callExpr, ctx) - case '_ELMCOUNT': { - // Emit the size of the dimension in place of the dimension name + case '_ELMCOUNT': + case '_SIZE': { + // Emit the size of the dimension in place of the dimension name. Note that Vensim uses + // `ELMCOUNT` while XMILE uses `SIZE`, but otherwise they are the same. const dimArg = callExpr.args[0] if (dimArg.kind !== 'variable-ref') { - throw new Error('Argument for ELMCOUNT must be a dimension name') + throw new Error(`Argument for ${callExpr.fnName} must be a dimension name`) } const dimId = dimArg.varId return `${sub(dimId).size}` @@ -362,7 +406,9 @@ function generateFunctionCall(callExpr, ctx) { throw new Error(`Unexpected function '${fnId}' in code gen for '${ctx.variable.modelLHS}'`) case '_INITIAL': - // In init mode, only emit the initial expression without the INITIAL function call + case '_INIT': + // Note that Vensim uses `INITIAL` while XMILE uses `INIT`, but otherwise they are the same. + // In init mode, only emit the initial expression without the INITIAL function call. if (ctx.mode.startsWith('init')) { return generateExpr(callExpr.args[0], ctx) } else { @@ -423,6 +469,7 @@ function generateLevelInit(callExpr, ctx) { case '_INTEG': initialArgIndex = 1 break + case '_DELAY': case '_DELAY_FIXED': { // Emit the code that initializes the `FixedDelay` support struct const fixedDelay = ctx.cVarRefWithLhsSubscripts(ctx.variable.fixedDelayVarName) @@ -474,12 +521,15 @@ function generateLevelEval(callExpr, ctx) { // For ACTIVE INITIAL, emit the first arg without a function call return generateExpr(callExpr.args[0], ctx) + case '_DELAY': case '_DELAY_FIXED': { - // For DELAY FIXED, emit the first arg followed by the FixedDelay support var + // Stella's DELAY function is behaviorally equivalent to Vensim's DELAY FIXED function, so + // they use the same `_DELAY_FIXED` runtime function. For these, emit the first arg + // followed by the FixedDelay support var. const args = [] args.push(generateExpr(callExpr.args[0], ctx)) args.push(ctx.cVarRefWithLhsSubscripts(ctx.variable.fixedDelayVarName)) - return generateCall(args) + return `${fnRef('_DELAY_FIXED', ctx)}(${args.join(', ')})` } case '_DEPRECIATE_STRAIGHTLINE': { diff --git a/packages/compile/src/index.js b/packages/compile/src/index.js index 084f89d4..9692bc7e 100644 --- a/packages/compile/src/index.js +++ b/packages/compile/src/index.js @@ -35,7 +35,15 @@ export function parseInlineVensimModel(mdlContent /*: string*/, modelDir /*?: st // the preprocess step, and in the case of the new parser (which implicitly runs the // preprocess step), don't sort the definitions. This makes it easier to do apples // to apples comparisons on the outputs from the two parser implementations. - return parseModel(mdlContent, modelDir, { sort: false }) + return parseModel(mdlContent, 'vensim', modelDir, { sort: false }) +} + +/** + * @hidden This is not yet part of the public API; it is exposed only for use + * in the experimental playground app. + */ +export function parseInlineXmileModel(mdlContent /*: string*/, modelDir /*?: string*/) /*: ParsedModel*/ { + return parseModel(mdlContent, 'xmile', modelDir) } /** diff --git a/packages/compile/src/model/model.js b/packages/compile/src/model/model.js index d1337844..243faf76 100644 --- a/packages/compile/src/model/model.js +++ b/packages/compile/src/model/model.js @@ -1,7 +1,9 @@ import * as R from 'ramda' +import { canonicalVarId, toPrettyString } from '@sdeverywhere/parse' + import B from '../_shared/bufx.js' -import { canonicalVensimName, decanonicalize, isIterable, strlist, vlog, vsort } from '../_shared/helpers.js' +import { decanonicalize, isIterable, strlist, vlog, vsort } from '../_shared/helpers.js' import { addIndex, allAliases, @@ -15,7 +17,7 @@ import { import { cName } from '../_shared/var-names.js' import { expandVar } from './expand-var-instances.js' -import { readEquation } from './read-equations.js' +import { readEquation, resolveXmileDimensionWildcards } from './read-equations.js' import { readDimensionDefs } from './read-subscripts.js' import { readVariables } from './read-variables.js' import { reduceVariables } from './reduce-variables.js' @@ -92,10 +94,80 @@ function read(parsedModel, spec, extData, directData, modelDirname, opts) { timeVar.varName = '_time' vars.push(timeVar) + // Helper function to define a control variable for XMILE models + function defineXmileControlVar(varName, varId, rhsValue) { + let rhsExpr + if (typeof rhsValue === 'number') { + rhsExpr = { + kind: 'number', + value: rhsValue, + text: rhsValue.toString() + } + } else { + rhsExpr = { + kind: 'variable-ref', + varName: rhsValue, + varId: canonicalVarId(rhsValue) + } + } + const v = new Variable() + v.modelLHS = varName + v.varName = varId + v.parsedEqn = { + lhs: { + varDef: { + varName, + varId + } + }, + rhs: { + kind: 'expr', + expr: rhsExpr + } + } + v.modelFormula = toPrettyString(rhsExpr, { compact: true }) + v.includeInOutput = false + vars.push(v) + } + + if (parsedModel.kind === 'xmile') { + // XXX: Unlike Vensim models, XMILE models do not include the control parameters as + // normal model equations; instead, they are defined in the `` element. + // In addition, XMILE allows these values to be accessed in equations (e.g., `` + // can be accessed as `STARTTIME`, `` as `STOPTIME`, and `
` as `DT`). + // For compatibility with the existing runtime code (which expects these variables + // to be defined using the Vensim names), we will synthesize variables using the + // Vensim names (e.g., `INITIAL TIME`) and also synthesize variables that derive + // from these using the XMILE names (e.g., `STARTTIME`). + defineXmileControlVar('INITIAL TIME', '_initial_time', parsedModel.root.simulationSpec.startTime) + defineXmileControlVar('FINAL TIME', '_final_time', parsedModel.root.simulationSpec.endTime) + defineXmileControlVar('TIME STEP', '_time_step', parsedModel.root.simulationSpec.timeStep) + defineXmileControlVar('STARTTIME', '_starttime', 'INITIAL TIME') + defineXmileControlVar('STOPTIME', '_stoptime', 'FINAL TIME') + defineXmileControlVar('DT', '_dt', 'TIME STEP') + // XXX: For now, also include a `SAVEPER` variable that is the same as `TIME STEP` (is there + // an equivalent of this in XMILE?) + defineXmileControlVar('SAVEPER', '_saveper', 'TIME STEP') + } + // Add the variables to the `Model` vars.forEach(addVariable) if (opts?.stopAfterReadVariables) return + if (parsedModel.kind === 'xmile') { + // XXX: In the case of XMILE, we need to resolve any wildcards used in dimension + // position in the RHS of the equation + for (const variable of vars) { + if (variable.parsedEqn?.rhs?.kind === 'expr') { + const updatedEqn = resolveXmileDimensionWildcards(variable) + if (updatedEqn) { + variable.parsedEqn = updatedEqn + variable.modelFormula = toPrettyString(updatedEqn.rhs.expr, { compact: true }) + } + } + } + } + if (spec) { // If the spec file contains `input/outputVarNames`, convert the full Vensim variable // names to C names first so that later phases only need to work with canonical names @@ -268,7 +340,7 @@ function resolveDimensions(dimensionFamilies) { } } -function analyze(parsedModelKind, inputVars, opts) { +function analyze(modelKind, inputVars, opts) { // Analyze the RHS of each equation in stages after all the variables are read. // Find non-apply-to-all vars that are defined with more than one equation. findNonAtoAVars() @@ -284,7 +356,9 @@ function analyze(parsedModelKind, inputVars, opts) { if (opts?.stopAfterReduceVariables === true) return // Read the RHS to list the refIds of vars that are referenced and set the var type. - variables.forEach(readEquation) + variables.forEach(v => { + readEquation(v, modelKind) + }) } function checkSpecVars(spec) { @@ -1221,7 +1295,7 @@ function jsonList() { const varInstances = expandVar(v) for (const { varName, subscriptIndices } of varInstances) { - const varId = canonicalVensimName(varName) + const varId = canonicalVarId(varName) const varItem = { varId, varName, diff --git a/packages/compile/src/model/read-equation-fn-delay.js b/packages/compile/src/model/read-equation-fn-delay.js index eea10c83..aae4e49a 100644 --- a/packages/compile/src/model/read-equation-fn-delay.js +++ b/packages/compile/src/model/read-equation-fn-delay.js @@ -16,10 +16,12 @@ import Model from './model.js' /** * Generate level and aux variables that implement one of the following `DELAY` function * call variants: - * - DELAY1 - * - DELAY1I - * - DELAY3 - * - DELAY3I + * - DELAY1 (Vensim) + * - DELAY1I (Vensim) + * - DELAY3 (Vensim) + * - DELAY3I (Vensim) + * - DELAY1 (Stella) + * - DELAY3 (Stella) * * TODO: Docs * diff --git a/packages/compile/src/model/read-equation-fn-smooth.js b/packages/compile/src/model/read-equation-fn-smooth.js index 28c77e39..8c44b368 100644 --- a/packages/compile/src/model/read-equation-fn-smooth.js +++ b/packages/compile/src/model/read-equation-fn-smooth.js @@ -15,10 +15,12 @@ import Model from './model.js' /** * Generate level and aux variables that implement one of the following `SMOOTH` function * call variants: - * - SMOOTH - * - SMOOTHI - * - SMOOTH3 - * - SMOOTH3I + * - SMOOTH (Vensim) + * - SMOOTHI (Vensim) + * - SMOOTH3 (Vensim) + * - SMOOTH3I (Vensim) + * - SMTH1 (Stella) + * - SMTH3 (Stella) * * TODO: Docs * @@ -43,7 +45,7 @@ export function generateSmoothVariables(v, callExpr, context) { } const fnId = callExpr.fnId - if (fnId === '_SMOOTH' || fnId === '_SMOOTHI') { + if (fnId === '_SMOOTH' || fnId === '_SMOOTHI' || fnId === '_SMTH1') { // Generate 1 level variable that will replace the `SMOOTH[I]` function call const level = generateSmoothLevel(v, context, argInput, argDelay, argInit, 1) // For `SMOOTH[I]`, the smoothVarRefId is the level var's refId diff --git a/packages/compile/src/model/read-equation-fn-trend.js b/packages/compile/src/model/read-equation-fn-trend.js index e1078375..63660aa6 100644 --- a/packages/compile/src/model/read-equation-fn-trend.js +++ b/packages/compile/src/model/read-equation-fn-trend.js @@ -3,7 +3,7 @@ import { toPrettyString } from '@sdeverywhere/parse' import { canonicalName, newAuxVarName, newLevelVarName } from '../_shared/helpers.js' /** - * Generate two level variables and one aux that implement an `NPV` function call. + * Generate two level variables and one aux that implement an `TREND` function call. * * TODO: Docs * diff --git a/packages/compile/src/model/read-equations.spec.ts b/packages/compile/src/model/read-equations-vensim.spec.ts similarity index 99% rename from packages/compile/src/model/read-equations.spec.ts rename to packages/compile/src/model/read-equations-vensim.spec.ts index b97f7a03..d155b9bd 100644 --- a/packages/compile/src/model/read-equations.spec.ts +++ b/packages/compile/src/model/read-equations-vensim.spec.ts @@ -100,7 +100,7 @@ function v(lhs: string, formula: string, overrides?: Partial): Variabl return variable as Variable } -describe('readEquations', () => { +describe('readEquations (from Vensim model)', () => { it('should work for simple equation with explicit parentheses', () => { const vars = readInlineModel(` x = 1 ~~| @@ -575,7 +575,47 @@ describe('readEquations', () => { ]) }) - it('should work when RHS variable is NON-apply-to-all (2D) and is accessed with specific subscripts', () => { + it('should work when RHS variable is NON-apply-to-all (2D with separated definitions) and is accessed with specific subscripts', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + x[A1, B1] = 1 ~~| + x[A1, B2] = 2 ~~| + x[A2, B1] = 3 ~~| + x[A2, B2] = 4 ~~| + y = x[A1, B2] ~~| + `) + expect(vars).toEqual([ + v('x[A1,B1]', '1', { + refId: '_x[_a1,_b1]', + subscripts: ['_a1', '_b1'], + varType: 'const' + }), + v('x[A1,B2]', '2', { + refId: '_x[_a1,_b2]', + subscripts: ['_a1', '_b2'], + varType: 'const' + }), + v('x[A2,B1]', '3', { + refId: '_x[_a2,_b1]', + subscripts: ['_a2', '_b1'], + varType: 'const' + }), + v('x[A2,B2]', '4', { + refId: '_x[_a2,_b2]', + subscripts: ['_a2', '_b2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1', '_b2']) + // -> ['_x[_a1,_b2]'] + v('y', 'x[A1,B2]', { + refId: '_y', + references: ['_x[_a1,_b2]'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (2D with shorthand definition) and is accessed with specific subscripts', () => { const vars = readInlineModel(` DimA: A1, A2 ~~| DimB: B1, B2 ~~| diff --git a/packages/compile/src/model/read-equations-xmile.spec.ts b/packages/compile/src/model/read-equations-xmile.spec.ts new file mode 100644 index 00000000..c5c6ba64 --- /dev/null +++ b/packages/compile/src/model/read-equations-xmile.spec.ts @@ -0,0 +1,7977 @@ +import { describe, expect, it } from 'vitest' + +import { canonicalName, cartesianProductOf, resetHelperState } from '../_shared/helpers' +import { resetSubscriptsAndDimensions } from '../_shared/subscript' + +import Model from './model' +import { default as VariableImpl } from './variable' + +import type { ParsedModel, Variable } from '../_tests/test-support' +import { parseInlineXmileModel, parseXmileModel, sampleModelDir, xmile } from '../_tests/test-support' + +/** + * This is a shorthand for the following steps to read equations: + * - parseXmileModel + * - readSubscriptRanges + * - resolveSubscriptRanges + * - readVariables + * - analyze (this includes readEquations) + */ +function readSubscriptsAndEquationsFromSource( + source: { + modelText?: string + modelName?: string + modelDir?: string + }, + opts?: { + specialSeparationDims?: { [key: string]: string } + separateAllVarsWithDims?: string[][] + } +): Variable[] { + // XXX: These steps are needed due to subs/dims and variables being in module-level storage + resetHelperState() + resetSubscriptsAndDimensions() + Model.resetModelState() + + let parsedModel: ParsedModel + if (source.modelText) { + parsedModel = parseInlineXmileModel(source.modelText) + } else { + parsedModel = parseXmileModel(source.modelName) + } + + const spec = { + specialSeparationDims: opts?.specialSeparationDims, + separateAllVarsWithDims: opts?.separateAllVarsWithDims + } + + let modelDir = source.modelDir + if (modelDir === undefined) { + if (source.modelName) { + modelDir = sampleModelDir(source.modelName) + } + } + + Model.read(parsedModel, spec, /*extData=*/ undefined, /*directData=*/ undefined, modelDir, { + reduceVariables: false, + stopAfterAnalyze: true + }) + + return Model.variables.map(v => { + // XXX: Strip out the new parsedEqn field, since we don't need it for comparing + delete v.parsedEqn + return v + }) +} + +function readInlineModel( + modelText: string, + modelDir?: string, + opts?: { + specialSeparationDims?: { [key: string]: string } + separateAllVarsWithDims?: string[][] + filterControlVars?: boolean + } +): Variable[] { + const vars = readSubscriptsAndEquationsFromSource({ modelText, modelDir }, opts) + + if (opts?.filterControlVars !== false) { + // Exclude the `Time` variable and other synthesized control variables so that we have + // fewer things to check + return vars.filter(v => { + switch (v.varName) { + case '_time': + case '_initial_time': + case '_final_time': + case '_time_step': + case '_saveper': + case '_starttime': + case '_stoptime': + case '_dt': + return false + default: + return true + } + }) + } else { + // Include the control variables + return vars + } +} + +// function readSubscriptsAndEquations(modelName: string): Variable[] { +// return readSubscriptsAndEquationsFromSource({ modelName }) +// } + +function v(lhs: string, formula: string, overrides?: Partial): Variable { + const variable = new VariableImpl() + variable.modelLHS = lhs + variable.modelFormula = formula + variable.varName = canonicalName(lhs.split('[')[0]) + variable.varType = 'aux' + variable.hasInitValue = false + variable.includeInOutput = true + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r = variable as Record + r[key] = value + } + } + return variable as Variable +} + +describe('readEquations (from XMILE model)', () => { + it('should work for simple equation with explicit parentheses', () => { + const xmileVars = `\ + + 1 + + + (x + 2) * 3 + +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', '(x+2)*3', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it('should work for conditional expression with = op', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = IF THEN ELSE(x = time, 1, 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + IF x = time THEN 1 ELSE 0 + +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', 'IF THEN ELSE(x=time,1,0)', { + refId: '_y', + references: ['_x', '_time'] + }) + ]) + }) + + it('should work for conditional expression with reference to dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x = 1 ~~| + // y[DimA] = IF THEN ELSE(DimA = x, 1, 0) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + 1 + + + + + + IF DimA = x THEN 1 ELSE 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y[DimA]', 'IF THEN ELSE(DimA=x,1,0)', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work for conditional expression with reference to dimension and subscript/index', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // y[DimA] = IF THEN ELSE(DimA = A2, 1, 0) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + IF THEN ELSE(DimA = A2, 1, 0) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('y[DimA]', 'IF THEN ELSE(DimA=A2,1,0)', { + refId: '_y', + subscripts: ['_dima'] + }) + ]) + }) + + it('should work for equation that uses specialSeparationDims', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel( + // ` + // DimA: A1, A2 ~~| + // y[DimA] = 0 ~~| + // `, + // undefined, + // { + // specialSeparationDims: { + // _y: '_dima' + // } + // } + // ) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl, undefined, { + specialSeparationDims: { + _y: '_dima' + } + }) + expect(vars).toEqual([ + v('y[DimA]', '0', { + refId: '_y[_a1]', + varType: 'const', + separationDims: ['_dima'], + subscripts: ['_a1'] + }), + v('y[DimA]', '0', { + refId: '_y[_a2]', + varType: 'const', + separationDims: ['_dima'], + subscripts: ['_a2'] + }) + ]) + }) + + it('should work for equations that are affected by separateAllVarsWithDims', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel( + // ` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2 ~~| + // x[DimA] = 0 ~~| + // y[DimB] = 0 ~~| + // z[DimA, DimB, DimC] = 0 ~~| + // `, + // undefined, + // { + // separateAllVarsWithDims: [['_dima', '_dimc'], ['_dimb']] + // } + // ) + + const xmileDims = `\ + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + 0 + + + + + + 0 + + + + + + + + 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl, undefined, { + separateAllVarsWithDims: [['_dima', '_dimc'], ['_dimb']] + }) + expect(vars).toEqual([ + // x should not be separated ('_dima' is not listed in `separateAllVarsWithDims`) + v('x[DimA]', '0', { + refId: '_x', + varType: 'const', + subscripts: ['_dima'] + }), + // y should be separated ('_dimb' is listed in `separateAllVarsWithDims`) + v('y[DimB]', '0', { + refId: '_y[_b1]', + varType: 'const', + separationDims: ['_dimb'], + subscripts: ['_b1'] + }), + v('y[DimB]', '0', { + refId: '_y[_b2]', + varType: 'const', + separationDims: ['_dimb'], + subscripts: ['_b2'] + }), + // z should be separated only on DimA and DimC + v('z[DimA,DimB,DimC]', '0', { + refId: '_z[_a1,_dimb,_c1]', + varType: 'const', + separationDims: ['_dima', '_dimc'], + subscripts: ['_a1', '_dimb', '_c1'] + }), + v('z[DimA,DimB,DimC]', '0', { + refId: '_z[_a1,_dimb,_c2]', + varType: 'const', + separationDims: ['_dima', '_dimc'], + subscripts: ['_a1', '_dimb', '_c2'] + }), + v('z[DimA,DimB,DimC]', '0', { + refId: '_z[_a2,_dimb,_c1]', + varType: 'const', + separationDims: ['_dima', '_dimc'], + subscripts: ['_a2', '_dimb', '_c1'] + }), + v('z[DimA,DimB,DimC]', '0', { + refId: '_z[_a2,_dimb,_c2]', + varType: 'const', + separationDims: ['_dima', '_dimc'], + subscripts: ['_a2', '_dimb', '_c2'] + }) + ]) + }) + + it('should work for equations when specialSeparationDims and separateAllVarsWithDims are used together', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel( + // ` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x1[DimA] = 0 ~~| + // x2[DimA] = 0 ~~| + // y[DimB] = 0 ~~| + // `, + // undefined, + // { + // specialSeparationDims: { _x1: '_dima' }, + // separateAllVarsWithDims: [['_dimb']] + // } + // ) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + 0 + + + + + + 0 + + + + + + 0 +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl, undefined, { + specialSeparationDims: { _x1: '_dima' }, + separateAllVarsWithDims: [['_dimb']] + }) + expect(vars).toEqual([ + // x1 should be separated ('_x1[_dima]' is listed in `specialSeparationDims`) + v('x1[DimA]', '0', { + refId: '_x1[_a1]', + varType: 'const', + separationDims: ['_dima'], + subscripts: ['_a1'] + }), + v('x1[DimA]', '0', { + refId: '_x1[_a2]', + varType: 'const', + separationDims: ['_dima'], + subscripts: ['_a2'] + }), + // x2 should not be separated ('_x2[_dima]' is listed in `specialSeparationDims`) + v('x2[DimA]', '0', { + refId: '_x2', + varType: 'const', + subscripts: ['_dima'] + }), + // y should be separated ('_dimb' is listed in `separateAllVarsWithDims`) + v('y[DimB]', '0', { + refId: '_y[_b1]', + varType: 'const', + separationDims: ['_dimb'], + subscripts: ['_b1'] + }), + v('y[DimB]', '0', { + refId: '_y[_b2]', + varType: 'const', + separationDims: ['_dimb'], + subscripts: ['_b2'] + }) + ]) + }) + + // TODO: Figure out equivalent XMILE model for this + it.skip('should work for data variable definition', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel( + // ` + // x ~~| + // ` + // ) + + const xmileVars = `\ + + +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'data' + }) + ]) + }) + + it('should work for lookup definition', () => { + // Somewhat equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) ) ~~| + // `) + + const xmileVars = `\ + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'lookup', + range: [], + points: [ + [0, 0], + [0.4, 0.1], + [0.5, 0.5], + [0.8, 0.9], + [1, 1] + ] + }) + ]) + }) + + it('should work for lookup call', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x( (0,0),(2,1.3) ) ~~| + // y = x(2) ~~| + // `) + + const xmileVars = `\ + + 0,2 + 0,1.3 + + + x(2) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'lookup', + range: [], + points: [ + [0, 0], + [2, 1.3] + ] + }), + v('y', 'x(2)', { + refId: '_y', + referencedFunctionNames: ['__x'] + }) + ]) + }) + + // TODO: This test is skipped because apply-to-all lookups are not fully supported in SDE yet + it.skip('should work for lookup call (with apply-to-all lookup variable)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA]( (0,0),(2,1.3) ) ~~| + // y = x[A1](2) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 0,2 + 0,1.3 + + + x[A1](2) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + console.log(vars) + expect(vars).toEqual([ + v('x[DimA]', '', { + points: [ + [0, 0], + [2, 1.3] + ], + refId: '_x', + subscripts: ['_dima'], + varType: 'lookup' + }), + v('y', 'x[A1](2)', { + refId: '_y', + referencedLookupVarNames: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped until we support XMILE spec 4.5.3: + // 4.5.3 Apply-to-All Arrays with Non-Apply-to-All Graphical Functions + it.skip('should work for lookup call (with separated lookup variable)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[A1]( (0,0),(2,1.3) ) ~~| + // x[A2]( (1,1),(4,3) ) ~~| + // y = x[A1](2) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + + 0,2 + 0,1.3 + + + + + 1,4 + 1,3 + + + + + x[A1](2) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '', { + points: [ + [0, 0], + [2, 1.3] + ], + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'lookup' + }), + v('x[A2]', '', { + points: [ + [1, 1], + [4, 3] + ], + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'lookup' + }), + v('y', 'x[A1](2)', { + refId: '_y', + referencedLookupVarNames: ['_x'] + }) + ]) + }) + + // + // NOTE: The following "should work for {0,1,2,3}D variable" tests are aligned with the ones + // in `gen-equation-{c,js}.spec.ts` (they exercise the same test models/equations). Having both + // sets of tests makes it easier to see whether a bug is in the "read equations" phase or + // in the "code gen" phase or both. + // + + describe('when LHS has no subscripts', () => { + it('should work when RHS variable has no subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = x ~~| + // `) + + const xmileVars = `\ + + 1 + + + x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', []) + // -> ['_x'] + v('y', 'x', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1 ~~| + // y = x[A1] ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 1 + + + x[A1] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1']) + // -> ['_x'] + v('y', 'x[A1]', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // y = x[A1] ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + x[A1] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1']) + // -> ['_x[_a1]'] + v('y', 'x[A1]', { + refId: '_y', + references: ['_x[_a1]'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with marked dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1 ~~| + // y = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 1 + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x'] + v('y', 'SUM(x[DimA!])', { + refId: '_y', + referencedFunctionNames: ['__sum'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with marked dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // y = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x[_a1]', '_x[_a2]'] + v('y', 'SUM(x[DimA!])', { + refId: '_y', + referencedFunctionNames: ['__sum'], + references: ['_x[_a1]', '_x[_a2]'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (2D) and is accessed with specific subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1 ~~| + // y = x[A1, B2] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + x[A1, B2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1', '_b2']) + // -> ['_x'] + v('y', 'x[A1,B2]', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (2D) and is accessed with specific subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1, 2; 3, 4; ~~| + // y = x[A1, B2] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + + x[A1, B2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1,B1]', '1', { + refId: '_x[_a1,_b1]', + subscripts: ['_a1', '_b1'], + varType: 'const' + }), + v('x[A1,B2]', '2', { + refId: '_x[_a1,_b2]', + subscripts: ['_a1', '_b2'], + varType: 'const' + }), + v('x[A2,B1]', '3', { + refId: '_x[_a2,_b1]', + subscripts: ['_a2', '_b1'], + varType: 'const' + }), + v('x[A2,B2]', '4', { + refId: '_x[_a2,_b2]', + subscripts: ['_a2', '_b2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1', '_b2']) + // -> ['_x[_a1,_b2]'] + v('y', 'x[A1,B2]', { + refId: '_y', + references: ['_x[_a1,_b2]'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (3D) and is accessed with specific subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2 ~~| + // x[DimA, DimC, DimB] = 1 ~~| + // y = x[A1, C2, B2] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + x[A1, C2, B2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimC,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimc', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a1', '_c2', '_b2']) + // -> ['_x'] + v('y', 'x[A1,C2,B2]', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (3D) and is accessed with specific subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2 ~~| + // x[DimA, DimC, DimB] :EXCEPT: [DimA, DimC, B1] = 1 ~~| + // x[DimA, DimC, B1] = 2 ~~| + // y = x[A1, C2, B2] ~~| + // `) + + // XXX: XMILE doesn't seem to have a shorthand like `:EXCEPT:` so we will + // build the combinations manually here + const dimA = ['A1', 'A2'] + const dimB = ['B1', 'B2'] + const dimC = ['C1', 'C2'] + const dims = [dimA, dimC, dimB] + const combos = cartesianProductOf(dims) + const elements = combos.map((combo: string[]) => { + const subscripts = combo.join(',') + const value = subscripts.endsWith('B1') ? '2' : '1' + return `\ + + ${value} + ` + }) + console.log() + + const xmileDims = `\ + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + +${elements.join('\n')} + + + x[A1, C2, B2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + // TODO: Verify the `x` variable instances + expect(vars.find(v => v.varName === '_y')).toEqual( + v('y', 'x[A1,C2,B2]', { + refId: '_y', + references: ['_x[_a1,_c2,_b2]'] + }) + ) + }) + }) + + describe('when LHS is apply-to-all (1D)', () => { + it('should work when RHS variable has no subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x = 1 ~~| + // y[DimA] = x ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + 1 + + + + + + x +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', []) + // -> ['_x'] + v('y[DimA]', 'x', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1 ~~| + // y[DimA] = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + x[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a2']) + // -> ['_x'] + v('y[DimA]', 'x[A2]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with same dimension that appears in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1 ~~| + // y[DimA] = x[DimA] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + x[DimA] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima']) + // -> ['_x'] + v('y[DimA]', 'x[DimA]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // y[DimA] = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[A2] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_a2']) + // -> ['_x[_a2]'] + v('y[DimA]', 'x[A2]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x[_a2]'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with same dimension that appears in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // y[DimA] = x[DimA] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[DimA] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima']) + // -> ['_x[_a1]', '_x[_a2]', '_x[_a3]'] + v('y[DimA]', 'x[DimA]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) with separated definitions and is accessed with same dimension that appears in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[A1] = 1 ~~| + // x[A2] = 2 ~~| + // y[DimA] = x[DimA] ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + x[DimA] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima']) + // -> ['_x[_a1]', '_x[_a2]'] + v('y[DimA]', 'x[DimA]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_x[_a1]', '_x[_a2]'] + }) + ]) + }) + + // This is adapted from the "except" sample model (see equation for `k`) + // TODO: This test is skipped because it's not clear if XMILE supports mapped subdimensions + it.skip('should work when RHS variable is NON-apply-to-all (1D) and is accessed with mapped version of LHS dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // DimB: B1, B2 -> (DimA: SubA, A1) ~~| + // a[DimA] = 1, 2, 3 ~~| + // b[DimB] = 4, 5 ~~| + // y[DimA] = a[DimA] + b[DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + 4 + + + 5 + + + + + + + a[DimA] + b[DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a[A1]', '1', { + refId: '_a[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('a[A2]', '2', { + refId: '_a[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('a[A3]', '3', { + refId: '_a[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'const' + }), + v('b[B1]', '4', { + refId: '_b[_b1]', + separationDims: ['_dimb'], + subscripts: ['_b1'], + varType: 'const' + }), + v('b[B2]', '5', { + refId: '_b[_b2]', + separationDims: ['_dimb'], + subscripts: ['_b2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_a', ['_dima']) + // -> ['_a[_a1]', '_a[_a2]', '_a[_a3]'] + // expandedRefIdsForVar(_y, '_b', ['_dimb']) + // -> ['_b[_b1]', '_b[_b2]'] + v('y[DimA]', 'a[DimA]+b[DimB]', { + refId: '_y', + subscripts: ['_dima'], + references: ['_a[_a1]', '_a[_a2]', '_a[_a3]', '_b[_b1]', '_b[_b2]'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with marked dimension that is different from one on LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA] = 1 ~~| + // y[DimB] = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x'] + v('y[DimB]', 'SUM(x[DimA!])', { + refId: '_y', + subscripts: ['_dimb'], + referencedFunctionNames: ['__sum'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with marked dimension that is same as one on LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1 ~~| + // y[DimA] = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x'] + v('y[DimA]', 'SUM(x[DimA!])', { + refId: '_y', + subscripts: ['_dima'], + referencedFunctionNames: ['__sum'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with marked dimension that is different from one on LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA] = 1, 2 ~~| + // y[DimB] = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x[_a1]', '_x[_a2]'] + v('y[DimB]', 'SUM(x[DimA!])', { + refId: '_y', + subscripts: ['_dimb'], + referencedFunctionNames: ['__sum'], + references: ['_x[_a1]', '_x[_a2]'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with marked dimension that is same as one on LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // y[DimA] = SUM(x[DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + SUM(x[*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x[_a1]', '_x[_a2]'] + v('y[DimA]', 'SUM(x[DimA!])', { + refId: '_y', + subscripts: ['_dima'], + referencedFunctionNames: ['__sum'], + references: ['_x[_a1]', '_x[_a2]'] + }) + ]) + }) + + // it.skip('should work when RHS variable is apply-to-all (2D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is NON-apply-to-all (2D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + it('should work when RHS variable is apply-to-all (2D) and is accessed with one normal dimension and one marked dimension that resolve to same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: DimA ~~| + // x[DimA,DimB] = 1 ~~| + // y[DimA] = SUM(x[DimA,DimA!]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + + + + SUM(x[DimA,*]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima!']) + // -> ['_x'] + v('y[DimA]', 'SUM(x[DimA,DimB!])', { + refId: '_y', + subscripts: ['_dima'], + referencedFunctionNames: ['__sum'], + references: ['_x'] + }) + ]) + }) + + // it.skip('should work when RHS variable is apply-to-all (3D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is NON-apply-to-all (3D) and is accessed with specific subscripts', () => { + // // TODO + // }) + }) + + describe('when LHS is NON-apply-to-all (1D)', () => { + it('should work when RHS variable has no subscripts', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x = 1 ~~| + // y[DimA] :EXCEPT: [A1] = x ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + 1 + + + + + + + x + + + x + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a2], '_x', []) + // -> ['_x'] + v('y[A2]', 'x', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_a3], '_x', []) + // -> ['_x'] + v('y[A3]', 'x', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1 ~~| + // y[DimA] :EXCEPT: [A1] = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + + x[A2] + + + x[A2] + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a2], '_x', ['_a2']) + // -> ['_x'] + v('y[A2]', 'x[A2]', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_a3], '_x', ['_a2']) + // -> ['_x'] + v('y[A3]', 'x[A2]', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped because it's failing; not sure if this is a valid construct in XMILE + it.skip('should work when RHS variable is apply-to-all (1D) and is accessed with same dimension that appears in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1 ~~| + // y[DimA] :EXCEPT: [A1] = x[DimA] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + + 1 + + + x[DimA] + + + x[DimA] + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[A1]', '1', { + refId: '_y[_a1]', + subscripts: ['_a1'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_a2], '_x', ['_dima']) + // -> ['_x'] + v('y[A2]', 'x[DimA]', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_a3], '_x', ['_dima']) + // -> ['_x'] + v('y[A3]', 'x[DimA]', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with specific subscript', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // y[DimA] :EXCEPT: [A1] = x[A2] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + x[A2] + + + x[A2] + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a2], '_x', ['_a2']) + // -> ['_x[_a2]'] + v('y[A2]', 'x[A2]', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_x[_a2]'] + }), + // expandedRefIdsForVar(_y[_a3], '_x', ['_a2']) + // -> ['_x[_a2]'] + v('y[A3]', 'x[A2]', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_x[_a2]'] + }) + ]) + }) + + // TODO: This test is skipped because it's failing; not sure if this is a valid construct in XMILE + it.skip('should work when RHS variable is NON-apply-to-all (1D) and is accessed with same dimension that appears in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // y[DimA] :EXCEPT: [A1] = x[DimA] ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + x[DimA] + + + x[DimA] + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a2], '_x', ['_dima']) + // -> ['_x[_a2]'] + v('y[A2]', 'x[DimA]', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_x[_a2]'] + }), + // expandedRefIdsForVar(_y[_a3], '_x', ['_dima']) + // -> ['_x[_a3]'] + v('y[A3]', 'x[DimA]', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_x[_a3]'] + }) + ]) + }) + + // This is adapted from the "except" sample model (see equation for `k`) + // TODO: This test is skipped because it's not clear if XMILE supports mapped subdimensions + it.skip('should work when RHS variable is NON-apply-to-all (1D) and is accessed with mapped version of LHS dimension', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // DimB: B1, B2 -> (DimA: SubA, A1) ~~| + // a[DimA] = 1, 2, 3 ~~| + // b[DimB] = 4, 5 ~~| + // y[DimA] :EXCEPT: [A1] = a[DimA] + b[DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + 4 + + + 5 + + + + + + + + a[DimA] + b[DimB] + + + a[DimA] + b[DimB] + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a[A1]', '1', { + refId: '_a[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('a[A2]', '2', { + refId: '_a[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('a[A3]', '3', { + refId: '_a[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('b[B1]', '4', { + refId: '_b[_b1]', + subscripts: ['_b1'], + varType: 'const' + }), + v('b[B2]', '5', { + refId: '_b[_b2]', + subscripts: ['_b2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a2], '_a', ['_dima']) + // -> ['_a[_a2]'] + // expandedRefIdsForVar(_y[_a2], '_b', ['_dimb']) + // -> ['_b[_b1]'] + v('y[A2]', 'a[DimA]+b[DimB]', { + refId: '_y[_a2]', + subscripts: ['_a2'], + references: ['_a[_a2]', '_b[_b1]'] + }), + // expandedRefIdsForVar(_y[_a3], '_a', ['_dima']) + // -> ['_a[_a3]'] + // expandedRefIdsForVar(_y[_a3], '_b', ['_dimb']) + // -> ['_b[_b1]'] + v('y[A3]', 'a[DimA]+b[DimB]', { + refId: '_y[_a3]', + subscripts: ['_a3'], + references: ['_a[_a3]', '_b[_b1]'] + }) + ]) + }) + + // This is adapted from the "ref" sample model (with updated naming for clarity) + // TODO: This test is skipped because it's not clear if XMILE supports this kind of mapping + it.skip('should work for complex mapping example', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // Target: (t1-t3) ~~| + // tNext: (t2-t3) -> tPrev ~~| + // tPrev: (t1-t2) -> tNext ~~| + // x[t1] = y[t1] + 1 ~~| + // x[tNext] = y[tNext] + 1 ~~| + // y[t1] = 1 ~~| + // y[tNext] = x[tPrev] + 1 ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + y[t1] + 1 + + + y[t2] + 1 + + + y[t3] + 1 + + + + + + + + 1 + + + x[t1] + 1 + + + x[t2] + 1 + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[t1]', 'y[t1]+1', { + refId: '_x[_t1]', + subscripts: ['_t1'], + references: ['_y[_t1]'] + }), + v('x[tNext]', 'y[tNext]+1', { + refId: '_x[_t2]', + separationDims: ['_tnext'], + subscripts: ['_t2'], + references: ['_y[_t2]'] + }), + v('x[tNext]', 'y[tNext]+1', { + refId: '_x[_t3]', + separationDims: ['_tnext'], + subscripts: ['_t3'], + references: ['_y[_t3]'] + }), + v('y[t1]', '1', { + refId: '_y[_t1]', + subscripts: ['_t1'], + varType: 'const' + }), + v('y[tNext]', 'x[tPrev]+1', { + refId: '_y[_t2]', + references: ['_x[_t1]'], + separationDims: ['_tnext'], + subscripts: ['_t2'] + }), + v('y[tNext]', 'x[tPrev]+1', { + refId: '_y[_t3]', + references: ['_x[_t2]'], + separationDims: ['_tnext'], + subscripts: ['_t3'] + }) + ]) + }) + }) + + describe('when LHS is apply-to-all (2D)', () => { + // it.skip('should work when RHS variable has no subscripts', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is apply-to-all (1D) and is accessed with specific subscript', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is NON-apply-to-all (1D) and is accessed with specific subscript', () => { + // // TODO + // }) + + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with LHS dimensions that resolve to the same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB <-> DimA ~~| + // x[DimA] = 1, 2 ~~| + // y[DimA, DimB] = x[DimA] + x[DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + + x[DimA] + x[DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_dima,_dimb], '_x', ['_dima']) + // -> ['_x[_a1]', '_x[_a2]'] + // expandedRefIdsForVar(_y[_dima,_dimb], '_x', ['_dimb']) + // -> ['_x[_a1]', '_x[_a2]'] + v('y[DimA,DimB]', 'x[DimA]+x[DimB]', { + refId: '_y', + subscripts: ['_dima', '_dimb'], + references: ['_x[_a1]', '_x[_a2]'] + }) + ]) + }) + + // it.skip('should work when RHS variable is apply-to-all (2D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is NON-apply-to-all (2D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + it('should work when RHS variable is apply-to-all (2D) and is accessed with same dimensions that appear in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1 ~~| + // y[DimB, DimA] = x[DimA, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + + + + + x[DimA, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_dimb,_dima], '_x', ['_dima', '_dimb']) + // -> ['_x'] + v('y[DimB,DimA]', 'x[DimA,DimB]', { + refId: '_y', + subscripts: ['_dimb', '_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is apply-to-all (2D) and is accessed with LHS dimensions that resolve to the same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB <-> DimA ~~| + // x[DimA, DimB] = 1 ~~| + // y[DimB, DimA] = x[DimA, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + + + + + x[DimA, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_dimb,_dima], '_x', ['_dima', '_dimb']) + // -> ['_x'] + v('y[DimB,DimA]', 'x[DimA,DimB]', { + refId: '_y', + subscripts: ['_dimb', '_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (2D) and is accessed with same dimensions that appear in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1, 2; 3, 4; ~~| + // y[DimB, DimA] = x[DimA, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + + + + + + x[DimA, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1,B1]', '1', { + refId: '_x[_a1,_b1]', + subscripts: ['_a1', '_b1'], + varType: 'const' + }), + v('x[A1,B2]', '2', { + refId: '_x[_a1,_b2]', + subscripts: ['_a1', '_b2'], + varType: 'const' + }), + v('x[A2,B1]', '3', { + refId: '_x[_a2,_b1]', + subscripts: ['_a2', '_b1'], + varType: 'const' + }), + v('x[A2,B2]', '4', { + refId: '_x[_a2,_b2]', + subscripts: ['_a2', '_b2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_dimb,_dima], '_x', ['_dima', '_dimb']) + // -> ['_x[_a1,_b1]', '_x[_a1,_b2]', '_x[_a2,_b1]', '_x[_a2,_b2]'] + v('y[DimB,DimA]', 'x[DimA,DimB]', { + refId: '_y', + subscripts: ['_dimb', '_dima'], + references: ['_x[_a1,_b1]', '_x[_a1,_b2]', '_x[_a2,_b1]', '_x[_a2,_b2]'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (2D) with separated definitions (for subscript in first position) and is accessed with same dimensions that appear in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[A1, DimB] = 1 ~~| + // x[A2, DimB] = 2 ~~| + // y[DimB, DimA] = x[DimA, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + 2 + + + + + + + + x[DimA, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1,DimB]', '1', { + refId: '_x[_a1,_dimb]', + subscripts: ['_a1', '_dimb'], + varType: 'const' + }), + v('x[A2,DimB]', '2', { + refId: '_x[_a2,_dimb]', + subscripts: ['_a2', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_dimb,_dima], '_x', ['_dima', '_dimb']) + // -> ['_x[_a1,_dimb]', '_x[_a2,_dimb]'] + v('y[DimB,DimA]', 'x[DimA,DimB]', { + refId: '_y', + subscripts: ['_dimb', '_dima'], + references: ['_x[_a1,_dimb]', '_x[_a2,_dimb]'] + }) + ]) + }) + + // it.skip('should work when RHS variable is apply-to-all (3D) and is accessed with specific subscripts', () => { + // // TODO + // }) + + // it.skip('should work when RHS variable is NON-apply-to-all (3D) and is accessed with specific subscripts', () => { + // // TODO + // }) + }) + + describe('when LHS is NON-apply-to-all (2D)', () => { + // The LHS in this test is partially separated (expanded only for first dimension position) + it('should work when RHS variable is apply-to-all (2D) and is accessed with same dimensions that appear in LHS', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // x[DimA, DimB] = 1 ~~| + // y[SubA, DimB] = x[SubA, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + + + + + x[SubA, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a1,_dimb], '_x', ['_suba', '_dimb']) + // -> ['_x'] + v('y[SubA,DimB]', 'x[SubA,DimB]', { + refId: '_y[_a1,_dimb]', + separationDims: ['_suba'], + subscripts: ['_a1', '_dimb'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_a2,_dimb], '_x', ['_suba', '_dimb']) + // -> ['_x'] + v('y[SubA,DimB]', 'x[SubA,DimB]', { + refId: '_y[_a2,_dimb]', + separationDims: ['_suba'], + subscripts: ['_a2', '_dimb'], + references: ['_x'] + }) + ]) + }) + + // This test is based on the example from #179 (simplified to use subdimensions to ensure separation) + it('should work when RHS variable is NON-apply-to-all (1D) and is accessed with 2 different dimensions from LHS that map to the same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A1, A2 ~~| + // SubB <-> SubA ~~| + // x[SubA] = 1, 2 ~~| + // y[SubA, SubB] = x[SubA] + x[SubB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + + x[SubA] + x[SubB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_a1,_a1], '_x', ['_suba']) + // -> ['_x[_a1]'] + // expandedRefIdsForVar(_y[_a1,_a1], '_x', ['_subb']) + // -> ['_x[_a1]'] + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a1,_a1]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a1', '_a1'], + references: ['_x[_a1]'] + }), + // expandedRefIdsForVar(_y[_a1,_a2], '_x', ['_suba']) + // -> ['_x[_a1]'] + // expandedRefIdsForVar(_y[_a1,_a2], '_x', ['_subb']) + // -> ['_x[_a2]'] + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a1,_a2]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a1', '_a2'], + references: ['_x[_a1]', '_x[_a2]'] + }), + // expandedRefIdsForVar(_y[_a2,_a1], '_x', ['_suba']) + // -> ['_x[_a2]'] + // expandedRefIdsForVar(_y[_a2,_a1], '_x', ['_subb']) + // -> ['_x[_a1]'] + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a2,_a1]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a2', '_a1'], + references: ['_x[_a2]', '_x[_a1]'] + }), + // expandedRefIdsForVar(_y[_a2,_a2], '_x', ['_suba']) + // -> ['_x[_a2]'] + // expandedRefIdsForVar(_y[_a2,_a2], '_x', ['_subb']) + // -> ['_x[_a2]'] + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a2,_a2]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a2', '_a2'], + references: ['_x[_a2]'] + }) + ]) + }) + + // This test is based on the example from #179 (simplified to use subdimensions to ensure separation). + // It is similar to the previous one, except in this one, `x` is apply-to-all (and refers to the parent + // dimension). + it('should work when RHS variable is apply-to-all (1D) and is accessed with 2 different dimensions from LHS that map to the same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A1, A2 ~~| + // SubB <-> SubA ~~| + // x[DimA] = 1 ~~| + // y[SubA, SubB] = x[SubA] + x[SubB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + + x[SubA] + x[SubB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1', { + refId: '_x', + subscripts: ['_dima'], + varType: 'const' + }), + // For all 4 instances of `y`, the following should hold true: + // expandedRefIdsForVar(_y[_aN,_aN], '_x', ['_suba']) + // -> ['_x'] + // expandedRefIdsForVar(_y[_aN,_aN], '_x', ['_subb']) + // -> ['_x'] + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a1,_a1]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a1', '_a1'], + references: ['_x'] + }), + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a1,_a2]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a1', '_a2'], + references: ['_x'] + }), + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a2,_a1]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a2', '_a1'], + references: ['_x'] + }), + v('y[SubA,SubB]', 'x[SubA]+x[SubB]', { + refId: '_y[_a2,_a2]', + separationDims: ['_suba', '_subb'], + subscripts: ['_a2', '_a2'], + references: ['_x'] + }) + ]) + }) + }) + + describe('when LHS is apply-to-all (3D)', () => { + it('should work when RHS variable is apply-to-all (3D) and is accessed with same dimensions that appear in LHS (but in a different order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2 ~~| + // x[DimA, DimC, DimB] = 1 ~~| + // y[DimC, DimB, DimA] = x[DimA, DimC, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + + + + + + x[DimA, DimC, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimC,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimc', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima', '_dimc', '_dimb']) + // -> ['_x'] + v('y[DimC,DimB,DimA]', 'x[DimA,DimC,DimB]', { + refId: '_y', + subscripts: ['_dimc', '_dimb', '_dima'], + references: ['_x'] + }) + ]) + }) + + it('should work when RHS variable is NON-apply-to-all (3D) and is accessed with same dimensions that appear in LHS (but in a different order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2 ~~| + // x[DimA, C1, DimB] = 1 ~~| + // x[DimA, C2, DimB] = 2 ~~| + // y[DimC, DimB, DimA] = x[DimA, DimC, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + + + 1 + + + 2 + + + + + + + + + x[DimA, DimC, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,C1,DimB]', '1', { + refId: '_x[_dima,_c1,_dimb]', + subscripts: ['_dima', '_c1', '_dimb'], + varType: 'const' + }), + v('x[DimA,C2,DimB]', '2', { + refId: '_x[_dima,_c2,_dimb]', + subscripts: ['_dima', '_c2', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y, '_x', ['_dima', '_dimc', '_dimb']) + // -> ['_x[_dima,_c1,_dimb]', '_x[_dima,_c2,_dimb]'] + v('y[DimC,DimB,DimA]', 'x[DimA,DimC,DimB]', { + refId: '_y', + subscripts: ['_dimc', '_dimb', '_dima'], + references: ['_x[_dima,_c1,_dimb]', '_x[_dima,_c2,_dimb]'] + }) + ]) + }) + }) + + describe('when LHS is NON-apply-to-all (3D)', () => { + it('should work when RHS variable is apply-to-all (3D) and is accessed with same dimensions that appear in LHS (but in a different order)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // DimB: B1, B2 ~~| + // DimC: C1, C2, C3 ~~| + // SubC: C2, C3 ~~| + // x[DimA, DimC, DimB] = 1 ~~| + // y[SubC, DimB, DimA] = x[DimA, SubC, DimB] ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + + 1 + + + + + + + + x[DimA, SubC, DimB] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA,DimC,DimB]', '1', { + refId: '_x', + subscripts: ['_dima', '_dimc', '_dimb'], + varType: 'const' + }), + // expandedRefIdsForVar(_y[_c2,_dimb,_dima], '_x', ['_dima', '_dimc', '_dimb']) + // -> ['_x'] + v('y[SubC,DimB,DimA]', 'x[DimA,SubC,DimB]', { + refId: '_y[_c2,_dimb,_dima]', + separationDims: ['_subc'], + subscripts: ['_c2', '_dimb', '_dima'], + references: ['_x'] + }), + // expandedRefIdsForVar(_y[_c3,_dimb,_dima], '_x', ['_dima', '_dimc', '_dimb']) + // -> ['_x'] + v('y[SubC,DimB,DimA]', 'x[DimA,SubC,DimB]', { + refId: '_y[_c3,_dimb,_dima]', + separationDims: ['_subc'], + subscripts: ['_c3', '_dimb', '_dima'], + references: ['_x'] + }) + ]) + }) + + // This test is based on the example from #278 + it('should work when RHS variable is NON-apply-to-all (2D) and is accessed with 2 different dimensions from LHS that map to the same family', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // Scenario: S1, S2 ~~| + // Sector: A1, A2, A3 ~~| + // Supplying Sector: A1, A2 -> Producing Sector ~~| + // Producing Sector: A1, A2 -> Supplying Sector ~~| + // x[A1,A1] = 101 ~~| + // x[A1,A2] = 102 ~~| + // x[A1,A3] = 103 ~~| + // x[A2,A1] = 201 ~~| + // x[A2,A2] = 202 ~~| + // x[A2,A3] = 203 ~~| + // x[A3,A1] = 301 ~~| + // x[A3,A2] = 302 ~~| + // x[A3,A3] = 303 ~~| + // y[S1] = 1000 ~~| + // y[S2] = 2000 ~~| + // z[Scenario, Supplying Sector, Producing Sector] = + // y[Scenario] + x[Supplying Sector, Producing Sector] + // ~~| + // `) + + const xmileDims = `\ + + + + + + + + + + + + + + + + + +` + const xmileVars = `\ + + + + + + + 101 + + + 102 + + + 103 + + + 201 + + + 202 + + + 203 + + + 301 + + + 302 + + + 303 + + + + + + + + 1000 + + + 2000 + + + + + + + + + y[Scenario] + x[Supplying Sector, Producing Sector] +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1,A1]', '101', { + refId: '_x[_a1,_a1]', + subscripts: ['_a1', '_a1'], + varType: 'const' + }), + v('x[A1,A2]', '102', { + refId: '_x[_a1,_a2]', + subscripts: ['_a1', '_a2'], + varType: 'const' + }), + v('x[A1,A3]', '103', { + refId: '_x[_a1,_a3]', + subscripts: ['_a1', '_a3'], + varType: 'const' + }), + v('x[A2,A1]', '201', { + refId: '_x[_a2,_a1]', + subscripts: ['_a2', '_a1'], + varType: 'const' + }), + v('x[A2,A2]', '202', { + refId: '_x[_a2,_a2]', + subscripts: ['_a2', '_a2'], + varType: 'const' + }), + v('x[A2,A3]', '203', { + refId: '_x[_a2,_a3]', + subscripts: ['_a2', '_a3'], + varType: 'const' + }), + v('x[A3,A1]', '301', { + refId: '_x[_a3,_a1]', + subscripts: ['_a3', '_a1'], + varType: 'const' + }), + v('x[A3,A2]', '302', { + refId: '_x[_a3,_a2]', + subscripts: ['_a3', '_a2'], + varType: 'const' + }), + v('x[A3,A3]', '303', { + refId: '_x[_a3,_a3]', + subscripts: ['_a3', '_a3'], + varType: 'const' + }), + v('y[S1]', '1000', { + refId: '_y[_s1]', + subscripts: ['_s1'], + varType: 'const' + }), + v('y[S2]', '2000', { + refId: '_y[_s2]', + subscripts: ['_s2'], + varType: 'const' + }), + // expandedRefIdsForVar(_z[_scenario,_a1,_a1], '_y', ['_scenario']) + // -> ['_y[_s1]', '_y[_s2]'] + // expandedRefIdsForVar(_z[_scenario,_a1,_a1], '_x', ['_supplying_sector', '_producing_sector']) + // -> ['_x[_a1,_a1]'] + v('z[Scenario,Supplying Sector,Producing Sector]', 'y[Scenario]+x[Supplying Sector,Producing Sector]', { + refId: '_z[_scenario,_a1,_a1]', + subscripts: ['_scenario', '_a1', '_a1'], + separationDims: ['_supplying_sector', '_producing_sector'], + references: ['_y[_s1]', '_y[_s2]', '_x[_a1,_a1]'], + varType: 'aux' + }), + // expandedRefIdsForVar(_z[_scenario,_a1,_a2], '_x', ['_supplying_sector', '_producing_sector']) + // -> ['_x[_a1,_a2]'] + v('z[Scenario,Supplying Sector,Producing Sector]', 'y[Scenario]+x[Supplying Sector,Producing Sector]', { + refId: '_z[_scenario,_a1,_a2]', + subscripts: ['_scenario', '_a1', '_a2'], + separationDims: ['_supplying_sector', '_producing_sector'], + references: ['_y[_s1]', '_y[_s2]', '_x[_a1,_a2]'], + varType: 'aux' + }), + // expandedRefIdsForVar(_z[_scenario,_a2,_a1], '_x', ['_supplying_sector', '_producing_sector']) + // -> ['_x[_a2,_a1]'] + v('z[Scenario,Supplying Sector,Producing Sector]', 'y[Scenario]+x[Supplying Sector,Producing Sector]', { + refId: '_z[_scenario,_a2,_a1]', + subscripts: ['_scenario', '_a2', '_a1'], + separationDims: ['_supplying_sector', '_producing_sector'], + references: ['_y[_s1]', '_y[_s2]', '_x[_a2,_a1]'], + varType: 'aux' + }), + // expandedRefIdsForVar(_z[_scenario,_a2,_a2], '_x', ['_supplying_sector', '_producing_sector']) + // -> ['_x[_a2,_a2]'] + v('z[Scenario,Supplying Sector,Producing Sector]', 'y[Scenario]+x[Supplying Sector,Producing Sector]', { + refId: '_z[_scenario,_a2,_a2]', + subscripts: ['_scenario', '_a2', '_a2'], + separationDims: ['_supplying_sector', '_producing_sector'], + references: ['_y[_s1]', '_y[_s2]', '_x[_a2,_a2]'], + varType: 'aux' + }) + ]) + }) + }) + + // + // NOTE: This is the end of the "should work for {0,1,2,3}D variable" tests. + // + + // In XMILE/Stella, ACTIVE INITIAL is expressed using a separate element. + // The XMILE parser synthesizes an ACTIVE INITIAL call when both and are present. + it('should work for ACTIVE INITIAL function (synthesized from init_eqn)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // Initial Target Capacity = 1 ~~| + // Capacity = 2 ~~| + // Target Capacity = ACTIVE INITIAL(Capacity, Initial Target Capacity) ~~| + // `) + + // Note: In XMILE, variable names with underscores are different from names with spaces. + // The references the variable using the exact name format (underscores here). + const xmileVars = `\ + + 1 + + + 2 + + + Capacity + Initial_Target_Capacity +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('Initial_Target_Capacity', '1', { + refId: '_initial_target_capacity', + varType: 'const' + }), + v('Capacity', '2', { + refId: '_capacity', + varType: 'const' + }), + v('Target_Capacity', 'ACTIVE INITIAL(Capacity,Initial_Target_Capacity)', { + refId: '_target_capacity', + references: ['_capacity'], + hasInitValue: true, + initReferences: ['_initial_target_capacity'], + referencedFunctionNames: ['__active_initial'] + }) + ]) + }) + + // TODO: This test is skipped for now; in Stella, the function is called `ALLOCATE` and we will need to see + // if the Vensim `ALLOCATE AVAILABLE` function is compatible enough + it.skip('should work for ALLOCATE AVAILABLE function (1D LHS, 1D demand, 2D pp, non-subscripted avail)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // branch: Boston, Dayton ~~| + // pprofile: ptype, ppriority ~~| + // supply available = 200 ~~| + // demand[branch] = 500,300 ~~| + // priority[Boston,pprofile] = 1,5 ~~| + // priority[Dayton,pprofile] = 1,7 ~~| + // shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[branch,ptype], supply available) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + 200 + + + + + + + 500 + + + 300 + + + + + + + + + 1 + + + 5 + + + 1 + + + 7 + + + + + + + ALLOCATE AVAILABLE(demand[branch], priority[branch,ptype], supply available) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('supply available', '200', { + refId: '_supply_available', + varType: 'const' + }), + v('demand[branch]', '500,300', { + refId: '_demand[_boston]', + separationDims: ['_branch'], + subscripts: ['_boston'], + varType: 'const' + }), + v('demand[branch]', '500,300', { + refId: '_demand[_dayton]', + separationDims: ['_branch'], + subscripts: ['_dayton'], + varType: 'const' + }), + v('priority[Boston,pprofile]', '1,5', { + refId: '_priority[_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_boston', '_ptype'], + varType: 'const' + }), + v('priority[Boston,pprofile]', '1,5', { + refId: '_priority[_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Dayton,pprofile]', '1,7', { + refId: '_priority[_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Dayton,pprofile]', '1,7', { + refId: '_priority[_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_dayton', '_ppriority'], + varType: 'const' + }), + v('shipments[branch]', 'ALLOCATE AVAILABLE(demand[branch],priority[branch,ptype],supply available)', { + refId: '_shipments', + referencedFunctionNames: ['__allocate_available'], + references: [ + '_demand[_boston]', + '_demand[_dayton]', + '_priority[_boston,_ptype]', + '_priority[_dayton,_ptype]', + '_priority[_boston,_ppriority]', + '_priority[_dayton,_ppriority]', + '_supply_available' + ], + subscripts: ['_branch'] + }) + ]) + }) + + // TODO: This test is skipped for now; in Stella, the function is called `ALLOCATE` and we will need to see + // if the Vensim `ALLOCATE AVAILABLE` function is compatible enough + it.skip('should work for ALLOCATE AVAILABLE function (1D LHS, 1D demand, 3D pp with specific first subscript, non-subscripted avail)', () => { + const vars = readInlineModel(` + branch: Boston, Dayton, Fresno ~~| + item: Item1, Item2 ~~| + pprofile: ptype, ppriority ~~| + supply available = 200 ~~| + demand[branch] = 500,300,750 ~~| + priority[Item1,Boston,pprofile] = 3,5 ~~| + priority[Item1,Dayton,pprofile] = 3,7 ~~| + priority[Item1,Fresno,pprofile] = 3,3 ~~| + priority[Item2,Boston,pprofile] = 3,6 ~~| + priority[Item2,Dayton,pprofile] = 3,8 ~~| + priority[Item2,Fresno,pprofile] = 3,4 ~~| + item 1 shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[Item1,branch,ptype], supply available) ~~| + item 2 shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[Item2,branch,ptype], supply available) ~~| + `) + expect(vars).toEqual([ + v('supply available', '200', { + refId: '_supply_available', + varType: 'const' + }), + v('demand[branch]', '500,300,750', { + refId: '_demand[_boston]', + separationDims: ['_branch'], + subscripts: ['_boston'], + varType: 'const' + }), + v('demand[branch]', '500,300,750', { + refId: '_demand[_dayton]', + separationDims: ['_branch'], + subscripts: ['_dayton'], + varType: 'const' + }), + v('demand[branch]', '500,300,750', { + refId: '_demand[_fresno]', + separationDims: ['_branch'], + subscripts: ['_fresno'], + varType: 'const' + }), + v('priority[Item1,Boston,pprofile]', '3,5', { + refId: '_priority[_item1,_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_boston', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Boston,pprofile]', '3,5', { + refId: '_priority[_item1,_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Item1,Dayton,pprofile]', '3,7', { + refId: '_priority[_item1,_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Dayton,pprofile]', '3,7', { + refId: '_priority[_item1,_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_dayton', '_ppriority'], + varType: 'const' + }), + v('priority[Item1,Fresno,pprofile]', '3,3', { + refId: '_priority[_item1,_fresno,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_fresno', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Fresno,pprofile]', '3,3', { + refId: '_priority[_item1,_fresno,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_fresno', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Boston,pprofile]', '3,6', { + refId: '_priority[_item2,_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_boston', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Boston,pprofile]', '3,6', { + refId: '_priority[_item2,_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Dayton,pprofile]', '3,8', { + refId: '_priority[_item2,_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Dayton,pprofile]', '3,8', { + refId: '_priority[_item2,_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_dayton', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Fresno,pprofile]', '3,4', { + refId: '_priority[_item2,_fresno,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_fresno', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Fresno,pprofile]', '3,4', { + refId: '_priority[_item2,_fresno,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_fresno', '_ppriority'], + varType: 'const' + }), + v( + 'item 1 shipments[branch]', + 'ALLOCATE AVAILABLE(demand[branch],priority[Item1,branch,ptype],supply available)', + { + refId: '_item_1_shipments', + referencedFunctionNames: ['__allocate_available'], + references: [ + '_demand[_boston]', + '_demand[_dayton]', + '_demand[_fresno]', + '_priority[_item1,_boston,_ptype]', + '_priority[_item1,_dayton,_ptype]', + '_priority[_item1,_fresno,_ptype]', + '_priority[_item1,_boston,_ppriority]', + '_priority[_item1,_dayton,_ppriority]', + '_priority[_item1,_fresno,_ppriority]', + '_supply_available' + ], + subscripts: ['_branch'] + } + ), + v( + 'item 2 shipments[branch]', + 'ALLOCATE AVAILABLE(demand[branch],priority[Item2,branch,ptype],supply available)', + { + refId: '_item_2_shipments', + referencedFunctionNames: ['__allocate_available'], + references: [ + '_demand[_boston]', + '_demand[_dayton]', + '_demand[_fresno]', + '_priority[_item2,_boston,_ptype]', + '_priority[_item2,_dayton,_ptype]', + '_priority[_item2,_fresno,_ptype]', + '_priority[_item2,_boston,_ppriority]', + '_priority[_item2,_dayton,_ppriority]', + '_priority[_item2,_fresno,_ppriority]', + '_supply_available' + ], + subscripts: ['_branch'] + } + ) + ]) + }) + + // TODO: This test is skipped for now; in Stella, the function is called `ALLOCATE` and we will need to see + // if the Vensim `ALLOCATE AVAILABLE` function is compatible enough + it.skip('should work for ALLOCATE AVAILABLE function (2D LHS, 2D demand, 2D pp, non-subscripted avail)', () => { + const vars = readInlineModel(` + branch: Boston, Dayton, Fresno ~~| + item: Item1, Item2 ~~| + pprofile: ptype, ppriority ~~| + supply available = 200 ~~| + demand[item,branch] = 500,300,750;501,301,751; ~~| + priority[Boston,pprofile] = 3,5 ~~| + priority[Dayton,pprofile] = 3,7 ~~| + priority[Fresno,pprofile] = 3,3 ~~| + shipments[item,branch] = ALLOCATE AVAILABLE(demand[item,branch], priority[branch,ptype], supply available) ~~| + `) + expect(vars).toEqual([ + v('supply available', '200', { + refId: '_supply_available', + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_boston]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_boston'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_dayton]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_dayton'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_fresno]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_fresno'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_boston]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_boston'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_dayton]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_dayton'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_fresno]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_fresno'], + varType: 'const' + }), + v('priority[Boston,pprofile]', '3,5', { + refId: '_priority[_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_boston', '_ptype'], + varType: 'const' + }), + v('priority[Boston,pprofile]', '3,5', { + refId: '_priority[_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Dayton,pprofile]', '3,7', { + refId: '_priority[_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Dayton,pprofile]', '3,7', { + refId: '_priority[_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_dayton', '_ppriority'], + varType: 'const' + }), + v('priority[Fresno,pprofile]', '3,3', { + refId: '_priority[_fresno,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_fresno', '_ptype'], + varType: 'const' + }), + v('priority[Fresno,pprofile]', '3,3', { + refId: '_priority[_fresno,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_fresno', '_ppriority'], + varType: 'const' + }), + v('shipments[item,branch]', 'ALLOCATE AVAILABLE(demand[item,branch],priority[branch,ptype],supply available)', { + refId: '_shipments', + referencedFunctionNames: ['__allocate_available'], + references: [ + '_demand[_item1,_boston]', + '_demand[_item1,_dayton]', + '_demand[_item1,_fresno]', + '_demand[_item2,_boston]', + '_demand[_item2,_dayton]', + '_demand[_item2,_fresno]', + '_priority[_boston,_ptype]', + '_priority[_dayton,_ptype]', + '_priority[_fresno,_ptype]', + '_priority[_boston,_ppriority]', + '_priority[_dayton,_ppriority]', + '_priority[_fresno,_ppriority]', + '_supply_available' + ], + subscripts: ['_item', '_branch'] + }) + ]) + }) + + // TODO: This test is skipped for now; in Stella, the function is called `ALLOCATE` and we will need to see + // if the Vensim `ALLOCATE AVAILABLE` function is compatible enough + it.skip('should work for ALLOCATE AVAILABLE function (2D LHS, 2D demand, 3D pp, 1D avail)', () => { + const vars = readInlineModel(` + branch: Boston, Dayton, Fresno ~~| + item: Item1, Item2 ~~| + pprofile: ptype, ppriority ~~| + supply available[item] = 200,400 ~~| + demand[item,branch] = 500,300,750;501,301,751; ~~| + priority[Item1,Boston,pprofile] = 3,5 ~~| + priority[Item1,Dayton,pprofile] = 3,7 ~~| + priority[Item1,Fresno,pprofile] = 3,3 ~~| + priority[Item2,Boston,pprofile] = 3,6 ~~| + priority[Item2,Dayton,pprofile] = 3,8 ~~| + priority[Item2,Fresno,pprofile] = 3,4 ~~| + shipments[item,branch] = ALLOCATE AVAILABLE(demand[item,branch], priority[item,branch,ptype], supply available[item]) ~~| + `) + expect(vars).toEqual([ + v('supply available[item]', '200,400', { + refId: '_supply_available[_item1]', + separationDims: ['_item'], + subscripts: ['_item1'], + varType: 'const' + }), + v('supply available[item]', '200,400', { + refId: '_supply_available[_item2]', + separationDims: ['_item'], + subscripts: ['_item2'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_boston]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_boston'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_dayton]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_dayton'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item1,_fresno]', + separationDims: ['_item', '_branch'], + subscripts: ['_item1', '_fresno'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_boston]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_boston'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_dayton]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_dayton'], + varType: 'const' + }), + v('demand[item,branch]', '500,300,750;501,301,751;', { + refId: '_demand[_item2,_fresno]', + separationDims: ['_item', '_branch'], + subscripts: ['_item2', '_fresno'], + varType: 'const' + }), + v('priority[Item1,Boston,pprofile]', '3,5', { + refId: '_priority[_item1,_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_boston', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Boston,pprofile]', '3,5', { + refId: '_priority[_item1,_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Item1,Dayton,pprofile]', '3,7', { + refId: '_priority[_item1,_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Dayton,pprofile]', '3,7', { + refId: '_priority[_item1,_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_dayton', '_ppriority'], + varType: 'const' + }), + v('priority[Item1,Fresno,pprofile]', '3,3', { + refId: '_priority[_item1,_fresno,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_fresno', '_ptype'], + varType: 'const' + }), + v('priority[Item1,Fresno,pprofile]', '3,3', { + refId: '_priority[_item1,_fresno,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item1', '_fresno', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Boston,pprofile]', '3,6', { + refId: '_priority[_item2,_boston,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_boston', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Boston,pprofile]', '3,6', { + refId: '_priority[_item2,_boston,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_boston', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Dayton,pprofile]', '3,8', { + refId: '_priority[_item2,_dayton,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_dayton', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Dayton,pprofile]', '3,8', { + refId: '_priority[_item2,_dayton,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_dayton', '_ppriority'], + varType: 'const' + }), + v('priority[Item2,Fresno,pprofile]', '3,4', { + refId: '_priority[_item2,_fresno,_ptype]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_fresno', '_ptype'], + varType: 'const' + }), + v('priority[Item2,Fresno,pprofile]', '3,4', { + refId: '_priority[_item2,_fresno,_ppriority]', + separationDims: ['_pprofile'], + subscripts: ['_item2', '_fresno', '_ppriority'], + varType: 'const' + }), + v( + 'shipments[item,branch]', + 'ALLOCATE AVAILABLE(demand[item,branch],priority[item,branch,ptype],supply available[item])', + { + refId: '_shipments', + referencedFunctionNames: ['__allocate_available'], + references: [ + '_demand[_item1,_boston]', + '_demand[_item1,_dayton]', + '_demand[_item1,_fresno]', + '_demand[_item2,_boston]', + '_demand[_item2,_dayton]', + '_demand[_item2,_fresno]', + '_priority[_item1,_boston,_ptype]', + '_priority[_item1,_dayton,_ptype]', + '_priority[_item1,_fresno,_ptype]', + '_priority[_item2,_boston,_ptype]', + '_priority[_item2,_dayton,_ptype]', + '_priority[_item2,_fresno,_ptype]', + '_priority[_item1,_boston,_ppriority]', + '_priority[_item1,_dayton,_ppriority]', + '_priority[_item1,_fresno,_ppriority]', + '_priority[_item2,_boston,_ppriority]', + '_priority[_item2,_dayton,_ppriority]', + '_priority[_item2,_fresno,_ppriority]', + '_supply_available[_item1]', + '_supply_available[_item2]' + ], + subscripts: ['_item', '_branch'] + } + ) + ]) + }) + + it('should work for DELAY1 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = DELAY1(x, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + DELAY1(x, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', 'DELAY1(x,5)', { + refId: '_y', + references: ['__level1', '__aux1'], + delayVarRefId: '__level1', + delayTimeVarName: '__aux1' + }), + v('_level1', 'INTEG(x-y,x*5)', { + refId: '__level1', + varType: 'level', + includeInOutput: false, + references: ['_x', '_y'], + hasInitValue: true, + initReferences: ['_x'], + referencedFunctionNames: ['__integ'] + }), + v('_aux1', '5', { + refId: '__aux1', + varType: 'const', + includeInOutput: false + }) + ]) + }) + + it('should work for DELAY1 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // init = 2 ~~| + // y = DELAY1I(x, 5, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + DELAY1(x, 5, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('init', '2', { + refId: '_init', + varType: 'const' + }), + v('y', 'DELAY1(x,5,init)', { + refId: '_y', + references: ['__level1', '__aux1'], + delayVarRefId: '__level1', + delayTimeVarName: '__aux1' + }), + v('_level1', 'INTEG(x-y,init*5)', { + refId: '__level1', + varType: 'level', + includeInOutput: false, + references: ['_x', '_y'], + hasInitValue: true, + initReferences: ['_init'], + referencedFunctionNames: ['__integ'] + }), + v('_aux1', '5', { + refId: '__aux1', + varType: 'const', + includeInOutput: false + }) + ]) + }) + + it('should work for DELAY1 function (with initial value argument; with subscripted variables)', () => { + // Note that we have a mix of non-apply-to-all (input, delay) and apply-to-all (init) + // variables here to cover both cases + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // input[DimA] = 10, 20, 30 ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[DimA] = DELAY1I(input[DimA], delay[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + 30 + + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + DELAY1(input[DimA], delay[DimA], init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[A1]', '10', { + refId: '_input[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('input[A2]', '20', { + refId: '_input[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('input[A3]', '30', { + refId: '_input[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[DimA]', 'DELAY1(input[DimA],delay[DimA],init[DimA])', { + delayTimeVarName: '__aux1', + delayVarRefId: '__level1', + refId: '_y', + references: ['__level1', '__aux1[_dima]'], + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG(input[DimA]-y[DimA],init[DimA]*delay[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input[_a1]', '_input[_a2]', '_input[_a3]', '_y'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_aux1[DimA]', 'delay[DimA]', { + includeInOutput: false, + refId: '__aux1', + references: ['_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'] + }) + ]) + }) + + // TODO: This test is not exactly equivalent to the Vensim one since it uses separated definitions + // for y[A1] and y[A2] instead of a single definition for y[SubA] + it.skip('should work for DELAY1 function (with initial value argument; with separated variables using subdimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // input[DimA] = 10, 20, 30 ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[A1] = 5 ~~| + // y[SubA] = DELAY1I(input[SubA], delay[SubA], init[SubA]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + 30 + + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + + 5 + + + DELAY1(input[A2], delay[A2], init[A2]) + + + DELAY1(input[A3], delay[A3], init[A3]) + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[A1]', '10', { + refId: '_input[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('input[A2]', '20', { + refId: '_input[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('input[A3]', '30', { + refId: '_input[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[A1]', '5', { + refId: '_y[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('y[A2]', 'DELAY1(input[A2],delay[A2],init[A2])', { + delayTimeVarName: '__aux1', + delayVarRefId: '__level_y_1[_a2]', + refId: '_y[_a2]', + references: ['__level_y_1[_a2]', '__aux1[_a2]'], + separationDims: ['_suba'], + subscripts: ['_a2'] + }), + v('y[A3]', 'DELAY1(input[A3],delay[A3],init[A3])', { + delayTimeVarName: '__aux2', + delayVarRefId: '__level_y_1[_a3]', + refId: '_y[_a3]', + references: ['__level_y_1[_a3]', '__aux2[_a3]'], + separationDims: ['_suba'], + subscripts: ['_a3'] + }), + v('_level_y_1[a2]', 'INTEG(input[a2]-y[a2],init[a2]*delay[a2])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a2]'], + refId: '__level_y_1[_a2]', + referencedFunctionNames: ['__integ'], + references: ['_input[_a2]', '_y[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_aux1[a2]', 'delay[a2]', { + includeInOutput: false, + refId: '__aux1[_a2]', + references: ['_delay[_a2]'], + subscripts: ['_a2'] + }), + v('_level_y_1[a3]', 'INTEG(input[a3]-y[a3],init[a3]*delay[a3])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a3]'], + refId: '__level_y_1[_a3]', + referencedFunctionNames: ['__integ'], + references: ['_input[_a3]', '_y[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_aux2[a3]', 'delay[a3]', { + includeInOutput: false, + refId: '__aux2[_a3]', + references: ['_delay[_a3]'], + subscripts: ['_a3'] + }) + ]) + }) + + it('should work for DELAY3 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = DELAY3(input, delay) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + DELAY3(input, delay) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y', 'DELAY3(input,delay)', { + delayTimeVarName: '__aux4', + delayVarRefId: '__level3', + refId: '_y', + references: ['__level3', '__level2', '__level1', '__aux4'] + }), + v('_level3', 'INTEG(_aux2-_aux3,input*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input', '_delay'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__aux2', '__aux3'], + varType: 'level' + }), + v('_level2', 'INTEG(_aux1-_aux2,input*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input', '_delay'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__aux1', '__aux2'], + varType: 'level' + }), + v('_level1', 'INTEG(input-_aux1,input*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input', '_delay'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '__aux1'], + varType: 'level' + }), + v('_aux1', '_level1/((delay)/3)', { + includeInOutput: false, + refId: '__aux1', + references: ['__level1', '_delay'] + }), + v('_aux2', '_level2/((delay)/3)', { + includeInOutput: false, + refId: '__aux2', + references: ['__level2', '_delay'] + }), + v('_aux3', '_level3/((delay)/3)', { + includeInOutput: false, + refId: '__aux3', + references: ['__level3', '_delay'] + }), + v('_aux4', '((delay)/3)', { + includeInOutput: false, + refId: '__aux4', + references: ['_delay'] + }) + ]) + }) + + it('should work for DELAY3 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // init = 3 ~~| + // y = DELAY3I(input, delay, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + 3 + + + DELAY3(input, delay, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('init', '3', { + refId: '_init', + varType: 'const' + }), + v('y', 'DELAY3(input,delay,init)', { + delayTimeVarName: '__aux4', + delayVarRefId: '__level3', + refId: '_y', + references: ['__level3', '__level2', '__level1', '__aux4'] + }), + v('_level3', 'INTEG(_aux2-_aux3,init*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__aux2', '__aux3'], + varType: 'level' + }), + v('_level2', 'INTEG(_aux1-_aux2,init*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__aux1', '__aux2'], + varType: 'level' + }), + v('_level1', 'INTEG(input-_aux1,init*((delay)/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '__aux1'], + varType: 'level' + }), + v('_aux1', '_level1/((delay)/3)', { + includeInOutput: false, + refId: '__aux1', + references: ['__level1', '_delay'] + }), + v('_aux2', '_level2/((delay)/3)', { + includeInOutput: false, + refId: '__aux2', + references: ['__level2', '_delay'] + }), + v('_aux3', '_level3/((delay)/3)', { + includeInOutput: false, + refId: '__aux3', + references: ['__level3', '_delay'] + }), + v('_aux4', '((delay)/3)', { + includeInOutput: false, + refId: '__aux4', + references: ['_delay'] + }) + ]) + }) + + it('should work for DELAY3 function (with initial value argument; with nested function calls)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // init = 3 ~~| + // y = DELAY3I(MIN(0, input), MAX(0, delay), ABS(init)) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + 3 + + + DELAY3(MIN(0, input), MAX(0, delay), ABS(init)) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('init', '3', { + refId: '_init', + varType: 'const' + }), + v('y', 'DELAY3(MIN(0,input),MAX(0,delay),ABS(init))', { + delayTimeVarName: '__aux4', + delayVarRefId: '__level3', + refId: '_y', + references: ['__level3', '__level2', '__level1', '__aux4'] + }), + v('_level3', 'INTEG(_aux2-_aux3,ABS(init)*((MAX(0,delay))/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level3', + referencedFunctionNames: ['__integ', '__abs', '__max'], + references: ['__aux2', '__aux3'], + varType: 'level' + }), + v('_level2', 'INTEG(_aux1-_aux2,ABS(init)*((MAX(0,delay))/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level2', + referencedFunctionNames: ['__integ', '__abs', '__max'], + references: ['__aux1', '__aux2'], + varType: 'level' + }), + v('_level1', 'INTEG(MIN(0,input)-_aux1,ABS(init)*((MAX(0,delay))/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay'], + refId: '__level1', + referencedFunctionNames: ['__integ', '__min', '__abs', '__max'], + references: ['_input', '__aux1'], + varType: 'level' + }), + v('_aux1', '_level1/((MAX(0,delay))/3)', { + includeInOutput: false, + refId: '__aux1', + referencedFunctionNames: ['__max'], + references: ['__level1', '_delay'] + }), + v('_aux2', '_level2/((MAX(0,delay))/3)', { + includeInOutput: false, + refId: '__aux2', + referencedFunctionNames: ['__max'], + references: ['__level2', '_delay'] + }), + v('_aux3', '_level3/((MAX(0,delay))/3)', { + includeInOutput: false, + refId: '__aux3', + referencedFunctionNames: ['__max'], + references: ['__level3', '_delay'] + }), + v('_aux4', '((MAX(0,delay))/3)', { + includeInOutput: false, + refId: '__aux4', + referencedFunctionNames: ['__max'], + references: ['_delay'] + }) + ]) + }) + + it('should work for DELAY3 function (with initial value argument; with subscripted variables)', () => { + // Note that we have a mix of non-apply-to-all (input, delay) and apply-to-all (init) + // variables here to cover both cases + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // input[DimA] = 10, 20, 30 ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[DimA] = DELAY3I(input[DimA], delay[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + 30 + + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + DELAY3(input[DimA], delay[DimA], init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[A1]', '10', { + refId: '_input[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('input[A2]', '20', { + refId: '_input[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('input[A3]', '30', { + refId: '_input[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[DimA]', 'DELAY3(input[DimA],delay[DimA],init[DimA])', { + delayTimeVarName: '__aux4', + delayVarRefId: '__level3', + refId: '_y', + references: ['__level3', '__level2', '__level1', '__aux4[_dima]'], // TODO: The last one is suspicious + subscripts: ['_dima'] + }), + v('_level3[DimA]', 'INTEG(_aux2[DimA]-_aux3[DimA],init[DimA]*((delay[DimA])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__aux2', '__aux3'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level2[DimA]', 'INTEG(_aux1[DimA]-_aux2[DimA],init[DimA]*((delay[DimA])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__aux1', '__aux2'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level1[DimA]', 'INTEG(input[DimA]-_aux1[DimA],init[DimA]*((delay[DimA])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input[_a1]', '_input[_a2]', '_input[_a3]', '__aux1'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_aux1[DimA]', '_level1[DimA]/((delay[DimA])/3)', { + includeInOutput: false, + refId: '__aux1', + references: ['__level1', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'] + }), + v('_aux2[DimA]', '_level2[DimA]/((delay[DimA])/3)', { + includeInOutput: false, + refId: '__aux2', + references: ['__level2', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'] + }), + v('_aux3[DimA]', '_level3[DimA]/((delay[DimA])/3)', { + includeInOutput: false, + refId: '__aux3', + references: ['__level3', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'] + }), + v('_aux4[DimA]', '((delay[DimA])/3)', { + includeInOutput: false, + refId: '__aux4', + references: ['_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'] + }) + ]) + }) + + // TODO: This test is not exactly equivalent to the Vensim one since it uses separated definitions + // for y[A1] and y[A2] instead of a single definition for y[SubA] + it.skip('should work for DELAY3 function (with initial value argument; with separated variables using subdimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // input[DimA] = 10, 20, 30 ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[A1] = 5 ~~| + // y[SubA] = DELAY3I(input[SubA], delay[SubA], init[SubA]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + + +` + const xmileVars = `\ + + + + + + 10 + + + 20 + + + 30 + + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + + 5 + + + DELAY3I(input[A2], delay[A2], init[A2]) + + + DELAY3I(input[A3], delay[A3], init[A3]) + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '10,20,30', { + refId: '_input[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('input[DimA]', '10,20,30', { + refId: '_input[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('input[DimA]', '10,20,30', { + refId: '_input[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'const' + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[A1]', '5', { + refId: '_y[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('y[SubA]', 'DELAY3I(input[SubA],delay[SubA],init[SubA])', { + delayTimeVarName: '__aux_y_4', + delayVarRefId: '__level_y_3[_a2]', + refId: '_y[_a2]', + references: ['__level_y_3[_a2]', '__level_y_2[_a2]', '__level_y_1[_a2]', '__aux_y_4[_a2]'], + separationDims: ['_suba'], + subscripts: ['_a2'] + }), + v('y[SubA]', 'DELAY3I(input[SubA],delay[SubA],init[SubA])', { + delayTimeVarName: '__aux_y_4', + delayVarRefId: '__level_y_3[_a3]', + refId: '_y[_a3]', + references: ['__level_y_3[_a3]', '__level_y_2[_a3]', '__level_y_1[_a3]', '__aux_y_4[_a3]'], + separationDims: ['_suba'], + subscripts: ['_a3'] + }), + v('_level_y_3[a2]', 'INTEG(_aux_y_2[a2]-_aux_y_3[a2],init[a2]*((delay[a2])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a2]'], + refId: '__level_y_3[_a2]', + referencedFunctionNames: ['__integ'], + references: ['__aux_y_2[_a2]', '__aux_y_3[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_2[a2]', 'INTEG(_aux_y_1[a2]-_aux_y_2[a2],init[a2]*((delay[a2])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a2]'], + refId: '__level_y_2[_a2]', + referencedFunctionNames: ['__integ'], + references: ['__aux_y_1[_a2]', '__aux_y_2[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_1[a2]', 'INTEG(input[a2]-_aux_y_1[a2],init[a2]*((delay[a2])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a2]'], + refId: '__level_y_1[_a2]', + referencedFunctionNames: ['__integ'], + references: ['_input[_a2]', '__aux_y_1[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_aux_y_1[a2]', '_level_y_1[a2]/((delay[a2])/3)', { + includeInOutput: false, + refId: '__aux_y_1[_a2]', + references: ['__level_y_1[_a2]', '_delay[_a2]'], + subscripts: ['_a2'] + }), + v('_aux_y_2[a2]', '_level_y_2[a2]/((delay[a2])/3)', { + includeInOutput: false, + refId: '__aux_y_2[_a2]', + references: ['__level_y_2[_a2]', '_delay[_a2]'], + subscripts: ['_a2'] + }), + v('_aux_y_3[a2]', '_level_y_3[a2]/((delay[a2])/3)', { + includeInOutput: false, + refId: '__aux_y_3[_a2]', + references: ['__level_y_3[_a2]', '_delay[_a2]'], + subscripts: ['_a2'] + }), + v('_aux_y_4[a2]', '((delay[a2])/3)', { + includeInOutput: false, + refId: '__aux_y_4[_a2]', + references: ['_delay[_a2]'], + subscripts: ['_a2'] + }), + v('_level_y_3[a3]', 'INTEG(_aux_y_2[a3]-_aux_y_3[a3],init[a3]*((delay[a3])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a3]'], + refId: '__level_y_3[_a3]', + referencedFunctionNames: ['__integ'], + references: ['__aux_y_2[_a3]', '__aux_y_3[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_level_y_2[a3]', 'INTEG(_aux_y_1[a3]-_aux_y_2[a3],init[a3]*((delay[a3])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a3]'], + refId: '__level_y_2[_a3]', + referencedFunctionNames: ['__integ'], + references: ['__aux_y_1[_a3]', '__aux_y_2[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_level_y_1[a3]', 'INTEG(input[a3]-_aux_y_1[a3],init[a3]*((delay[a3])/3))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init', '_delay[_a3]'], + refId: '__level_y_1[_a3]', + referencedFunctionNames: ['__integ'], + references: ['_input[_a3]', '__aux_y_1[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_aux_y_1[a3]', '_level_y_1[a3]/((delay[a3])/3)', { + includeInOutput: false, + refId: '__aux_y_1[_a3]', + references: ['__level_y_1[_a3]', '_delay[_a3]'], + subscripts: ['_a3'] + }), + v('_aux_y_2[a3]', '_level_y_2[a3]/((delay[a3])/3)', { + includeInOutput: false, + refId: '__aux_y_2[_a3]', + references: ['__level_y_2[_a3]', '_delay[_a3]'], + subscripts: ['_a3'] + }), + v('_aux_y_3[a3]', '_level_y_3[a3]/((delay[a3])/3)', { + includeInOutput: false, + refId: '__aux_y_3[_a3]', + references: ['__level_y_3[_a3]', '_delay[_a3]'], + subscripts: ['_a3'] + }), + v('_aux_y_4[a3]', '((delay[a3])/3)', { + includeInOutput: false, + refId: '__aux_y_4[_a3]', + references: ['_delay[_a3]'], + subscripts: ['_a3'] + }) + ]) + }) + + // TODO: This test is skipped for now; in Stella, the DELAY function can be called with or + // without an initial value argument, but the code that handles the Vensim DELAY FIXED function + // currently assumes the initial value argument + it.skip('should work for DELAY function (without initial value argument)', () => {}) + + // TODO: This test is skipped for now because the code that handles Stella's DELAY function + // will need to be updated to generate an internal level variable, since Stella's DELAY + // does not necessarily have to follow the "=" like Vensim's DELAY FIXED function does + it.skip('should work for DELAY function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = 2 ~~| + // delay = y + 5 ~~| + // init = 3 ~~| + // z = DELAY FIXED(x, delay, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + y + 5 + + + 3 + + + DELAY(x, delay, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', '2', { + refId: '_y', + varType: 'const' + }), + v('delay', 'y+5', { + refId: '_delay', + references: ['_y'] + }), + v('init', '3', { + refId: '_init', + varType: 'const' + }), + v('z', 'DELAY(x,delay,init)', { + refId: '_z', + varType: 'level', + varSubtype: 'fixedDelay', + fixedDelayVarName: '__fixed_delay1', + references: ['_x'], + hasInitValue: true, + initReferences: ['_delay', '_init'], + referencedFunctionNames: ['__delay_fixed'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the DEPRECIATE STRAIGHTLINE function + it.skip('should work for DEPRECIATE STRAIGHTLINE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // dtime = 20 ~~| + // fisc = 1 ~~| + // init = 5 ~~| + // Capacity Cost = 1000 ~~| + // New Capacity = 2000 ~~| + // stream = Capacity Cost * New Capacity ~~| + // Depreciated Amount = DEPRECIATE STRAIGHTLINE(stream, dtime, fisc, init) ~~| + // `) + + const xmileVars = `\ + + 20 + + + 1 + + + 5 + + + 1000 + + + 2000 + + + Capacity Cost * New Capacity + + + DEPRECIATE STRAIGHTLINE(stream, dtime, fisc, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('dtime', '20', { + refId: '_dtime', + varType: 'const' + }), + v('fisc', '1', { + refId: '_fisc', + varType: 'const' + }), + v('init', '5', { + refId: '_init', + varType: 'const' + }), + v('Capacity Cost', '1000', { + refId: '_capacity_cost', + varType: 'const' + }), + v('New Capacity', '2000', { + refId: '_new_capacity', + varType: 'const' + }), + v('stream', 'Capacity Cost*New Capacity', { + refId: '_stream', + references: ['_capacity_cost', '_new_capacity'] + }), + v('Depreciated Amount', 'DEPRECIATE STRAIGHTLINE(stream,dtime,fisc,init)', { + refId: '_depreciated_amount', + varSubtype: 'depreciation', + depreciationVarName: '__depreciation1', + references: ['_stream', '_init'], + hasInitValue: true, + initReferences: ['_dtime', '_fisc'], + referencedFunctionNames: ['__depreciate_straightline'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GAME function + it.skip('should work for GAME function (no dimensions)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = GAME(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + GAME(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + 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' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GAME function + it.skip('should work for GAME function (1D)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // x[DimA] = 1, 2 ~~| + // y[DimA] = GAME(x[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + GAME(x[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + 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' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GAME function + it.skip('should work for GAME function (2D)', () => { + // Equivalent Vensim model for reference: + // 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]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + + 1 + + + 2 + + + + + + + + GAME(a[DimA] + b[DimB]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a[A1]', '1', { + refId: '_a[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('a[A2]', '2', { + refId: '_a[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('b[B1]', '1', { + refId: '_b[_b1]', + subscripts: ['_b1'], + varType: 'const' + }), + v('b[B2]', '2', { + refId: '_b[_b2]', + 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 GAMMALN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // y = GAMMA LN(x) ~~| + // `) + + const xmileVars = `\ + + 1 + + + GAMMALN(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', 'GAMMALN(x)', { + refId: '_y', + referencedFunctionNames: ['__gammaln'], + references: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GET DIRECT CONSTANTS function + it.skip('should work for GET DIRECT CONSTANTS function (single value)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = GET DIRECT CONSTANTS('data/a.csv', ',', 'B2') ~~| + // `) + + const xmileVars = `\ + + GET DIRECT CONSTANTS('data/a.csv', ',', 'B2') +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', "GET DIRECT CONSTANTS('data/a.csv',',','B2')", { + directConstArgs: { file: 'data/a.csv', tab: ',', startCell: 'B2' }, + refId: '_x', + varType: 'const' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GET DIRECT CONSTANTS function + it.skip('should work for GET DIRECT CONSTANTS function (1D)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimB: B1, B2, B3 ~~| + // x[DimB] = GET DIRECT CONSTANTS('data/b.csv', ',', 'B2*') ~~| + // `) + + const xmileDims = `\ + + + + + +` + const xmileVars = `\ + + + + + GET DIRECT CONSTANTS('data/b.csv', ',', 'B2*') +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimB]', "GET DIRECT CONSTANTS('data/b.csv',',','B2*')", { + directConstArgs: { file: 'data/b.csv', tab: ',', startCell: 'B2*' }, + refId: '_x', + subscripts: ['_dimb'], + varType: 'const' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the GET DIRECT CONSTANTS function + it.skip('should work for GET DIRECT CONSTANTS function (2D)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimB: B1, B2, B3 ~~| + // DimC: C1, C2 ~~| + // x[DimB, DimC] = GET DIRECT CONSTANTS('data/c.csv', ',', 'B2') ~~| + // `) + + const xmileDims = `\ + + + + + + + + + +` + const xmileVars = `\ + + + + + + GET DIRECT CONSTANTS('data/c.csv', ',', 'B2') +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimB,DimC]', "GET DIRECT CONSTANTS('data/c.csv',',','B2')", { + directConstArgs: { file: 'data/c.csv', tab: ',', startCell: 'B2' }, + refId: '_x', + subscripts: ['_dimb', '_dimc'], + varType: 'const' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the GET DIRECT CONSTANTS function + // and it would need to be updated to not use :EXCEPT: + it.skip('should work for GET DIRECT CONSTANTS function (separate definitions)', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + SubA: A2, A3 ~~| + DimC: C1, C2 ~~| + x[DimC, SubA] = GET DIRECT CONSTANTS('data/f.csv',',','B2') ~~| + x[DimC, DimA] :EXCEPT: [DimC, SubA] = 0 ~~| + `) + expect(vars).toEqual([ + v('x[DimC,SubA]', "GET DIRECT CONSTANTS('data/f.csv',',','B2')", { + directConstArgs: { file: 'data/f.csv', tab: ',', startCell: 'B2' }, + refId: '_x[_dimc,_a2]', + separationDims: ['_suba'], + subscripts: ['_dimc', '_a2'], + varType: 'const' + }), + v('x[DimC,SubA]', "GET DIRECT CONSTANTS('data/f.csv',',','B2')", { + directConstArgs: { file: 'data/f.csv', tab: ',', startCell: 'B2' }, + refId: '_x[_dimc,_a3]', + separationDims: ['_suba'], + subscripts: ['_dimc', '_a3'], + varType: 'const' + }), + v('x[DimC,DimA]:EXCEPT:[DimC,SubA]', '0', { + refId: '_x[_dimc,_a1]', + separationDims: ['_dima'], + subscripts: ['_dimc', '_a1'], + varType: 'const' + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the GET DIRECT DATA function + it.skip('should work for GET DIRECT DATA function (single value)', () => { + const vars = readInlineModel(` + x = GET DIRECT DATA('g_data.csv', ',', 'A', 'B13') ~~| + y = x * 10 ~~| + `) + expect(vars).toEqual([ + v('x', "GET DIRECT DATA('g_data.csv',',','A','B13')", { + directDataArgs: { file: 'g_data.csv', tab: ',', timeRowOrCol: 'A', startCell: 'B13' }, + refId: '_x', + varType: 'data' + }), + v('y', 'x*10', { + refId: '_y', + references: ['_x'] + }) + ]) + }) + + it.skip('should work for GET DIRECT DATA function (1D)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + x[DimA] = GET DIRECT DATA('e_data.csv', ',', 'A', 'B5') ~~| + y = x[A2] * 10 ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', "GET DIRECT DATA('e_data.csv',',','A','B5')", { + directDataArgs: { file: 'e_data.csv', tab: ',', timeRowOrCol: 'A', startCell: 'B5' }, + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT DATA('e_data.csv',',','A','B5')", { + directDataArgs: { file: 'e_data.csv', tab: ',', timeRowOrCol: 'A', startCell: 'B5' }, + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'data' + }), + v('y', 'x[A2]*10', { + refId: '_y', + references: ['_x[_a2]'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the GET DIRECT DATA function + it.skip('should work for GET DIRECT DATA function (2D with separate definitions)', () => { + const vars = readInlineModel(` + DimA: A1, A2 ~~| + DimB: B1, B2 ~~| + x[A1, DimB] = GET DIRECT DATA('e_data.csv', ',', 'A', 'B5') ~~| + x[A2, DimB] = 0 ~~| + y = x[A2, B1] * 10 ~~| + `) + expect(vars).toEqual([ + v('x[A1,DimB]', "GET DIRECT DATA('e_data.csv',',','A','B5')", { + directDataArgs: { file: 'e_data.csv', tab: ',', timeRowOrCol: 'A', startCell: 'B5' }, + refId: '_x[_a1,_b1]', + separationDims: ['_dimb'], + subscripts: ['_a1', '_b1'], + varType: 'data' + }), + v('x[A1,DimB]', "GET DIRECT DATA('e_data.csv',',','A','B5')", { + directDataArgs: { file: 'e_data.csv', tab: ',', timeRowOrCol: 'A', startCell: 'B5' }, + refId: '_x[_a1,_b2]', + separationDims: ['_dimb'], + subscripts: ['_a1', '_b2'], + varType: 'data' + }), + v('x[A2,DimB]', '0', { + refId: '_x[_a2,_dimb]', + subscripts: ['_a2', '_dimb'], + varType: 'const' + }), + v('y', 'x[A2,B1]*10', { + refId: '_y', + references: ['_x[_a2,_dimb]'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the GET DIRECT LOOKUPS function + it.skip('should work for GET DIRECT LOOKUPS function', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + x[DimA] = GET DIRECT LOOKUPS('lookups.csv', ',', '1', 'AH2') ~~| + y[DimA] = x[DimA](Time) ~~| + z = y[A2] ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'data' + }), + v('y[DimA]', 'x[DimA](Time)', { + refId: '_y', + referencedLookupVarNames: ['_x'], + references: ['_time'], + subscripts: ['_dima'] + }), + v('z', 'y[A2]', { + refId: '_z', + references: ['_y'] + }) + ]) + }) + + it('should work for IF THEN ELSE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 100 ~~| + // y = 2 ~~| + // z = IF THEN ELSE(Time > x, 1, y) ~~| + // `) + + const xmileVars = `\ + + 100 + + + 2 + + + IF Time>x THEN 1 ELSE y +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '100', { + refId: '_x', + varType: 'const' + }), + v('y', '2', { + refId: '_y', + varType: 'const' + }), + v('z', 'IF THEN ELSE(Time>x,1,y)', { + refId: '_z', + references: ['_time', '_x', '_y'] + }) + ]) + }) + + it('should work for INIT function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time * 2 ~~| + // y = INITIAL(x) ~~| + // `) + + const xmileVars = `\ + + Time*2 + + + INIT(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', 'Time*2', { + refId: '_x', + references: ['_time'] + }), + v('y', 'INIT(x)', { + refId: '_y', + varType: 'initial', + hasInitValue: true, + initReferences: ['_x'], + referencedFunctionNames: ['__init'] + }) + ]) + }) + + it('should work for INTEG function (synthesized from variable definition)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time * 2 ~~| + // init = 5 ~~| + // y = INTEG(x, init) ~~| + // `) + + const xmileVars = `\ + + Time*2 + + + 5 + + + init + x +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', 'Time*2', { + refId: '_x', + references: ['_time'] + }), + v('init', '5', { + refId: '_init', + varType: 'const' + }), + v('y', 'INTEG(x,init)', { + refId: '_y', + varType: 'level', + references: ['_x'], + hasInitValue: true, + initReferences: ['_init'], + referencedFunctionNames: ['__integ'] + }) + ]) + }) + + it('should work for INTEG function (synthesized from variable definition, with nested function calls)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = Time * 2 ~~| + // init = 5 ~~| + // y = INTEG(ABS(x), SQRT(init)) ~~| + // `) + + const xmileVars = `\ + + Time*2 + + + 5 + + + SQRT(init) + ABS(x) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', 'Time*2', { + refId: '_x', + references: ['_time'] + }), + v('init', '5', { + refId: '_init', + varType: 'const' + }), + v('y', 'INTEG(ABS(x),SQRT(init))', { + refId: '_y', + varType: 'level', + references: ['_x'], + hasInitValue: true, + initReferences: ['_init'], + referencedFunctionNames: ['__integ', '__abs', '__sqrt'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the LOOKUP BACKWARD function + it.skip('should work for LOOKUP BACKWARD function (with lookup defined explicitly)', () => { + const vars = readInlineModel(` + x( (0,0),(2,1.3) ) ~~| + y = LOOKUP BACKWARD(x, 1) ~~| + `) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'lookup', + range: [], + points: [ + [0, 0], + [2, 1.3] + ] + }), + v('y', 'LOOKUP BACKWARD(x,1)', { + refId: '_y', + referencedFunctionNames: ['__lookup_backward'], + references: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the LOOKUP BACKWARD function + it.skip('should work for LOOKUP BACKWARD function (with lookup defined using GET DIRECT LOOKUPS)', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + x[DimA] = GET DIRECT LOOKUPS('lookups.csv', ',', '1', 'AH2') ~~| + y[DimA] = LOOKUP BACKWARD(x[DimA], Time) ~~| + z = y[A2] ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'data' + }), + v('y[DimA]', 'LOOKUP BACKWARD(x[DimA],Time)', { + refId: '_y', + referencedFunctionNames: ['__lookup_backward'], + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]', '_time'], + subscripts: ['_dima'] + }), + v('z', 'y[A2]', { + refId: '_z', + references: ['_y'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the LOOKUP FORWARD function + it.skip('should work for LOOKUP FORWARD function (with lookup defined explicitly)', () => { + const vars = readInlineModel(` + x( (0,0),(2,1.3) ) ~~| + y = LOOKUP FORWARD(x, 1) ~~| + `) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'lookup', + range: [], + points: [ + [0, 0], + [2, 1.3] + ] + }), + v('y', 'LOOKUP FORWARD(x,1)', { + refId: '_y', + referencedFunctionNames: ['__lookup_forward'], + references: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't appear to include the LOOKUP FORWARD function + it.skip('should work for LOOKUP FORWARD function (with lookup defined using GET DIRECT LOOKUPS)', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + x[DimA] = GET DIRECT LOOKUPS('lookups.csv', ',', '1', 'AH2') ~~| + y[DimA] = LOOKUP FORWARD(x[DimA], Time) ~~| + z = y[A2] ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'data' + }), + v('y[DimA]', 'LOOKUP FORWARD(x[DimA],Time)', { + refId: '_y', + referencedFunctionNames: ['__lookup_forward'], + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]', '_time'], + subscripts: ['_dima'] + }), + v('z', 'y[A2]', { + refId: '_z', + references: ['_y'] + }) + ]) + }) + + it('should work for LOOKUPINV function (with lookup defined explicitly)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x( (0,0),(2,1.3) ) ~~| + // y = LOOKUP INVERT(x, 1) ~~| + // `) + + const xmileVars = `\ + + 0,2 + 0,1.3 + + + LOOKUPINV(x,1) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '', { + refId: '_x', + varType: 'lookup', + range: [], + points: [ + [0, 0], + [2, 1.3] + ] + }), + v('y', 'LOOKUPINV(x,1)', { + refId: '_y', + referencedFunctionNames: ['__lookupinv'], + references: ['_x'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the GET DIRECT LOOKUPS function + it.skip('should work for LOOKUPINV function (with lookup defined using GET DIRECT LOOKUPS)', () => { + const vars = readInlineModel(` + DimA: A1, A2, A3 ~~| + x[DimA] = GET DIRECT LOOKUPS('lookups.csv', ',', '1', 'AH2') ~~| + y[DimA] = LOOKUP INVERT(x[DimA], Time) ~~| + z = y[A2] ~~| + `) + expect(vars).toEqual([ + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'data' + }), + v('x[DimA]', "GET DIRECT LOOKUPS('lookups.csv',',','1','AH2')", { + directDataArgs: { file: 'lookups.csv', tab: ',', timeRowOrCol: '1', startCell: 'AH2' }, + refId: '_x[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'data' + }), + v('y[DimA]', 'LOOKUPINV(x[DimA],Time)', { + refId: '_y', + referencedFunctionNames: ['__lookupinv'], + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]', '_time'], + subscripts: ['_dima'] + }), + v('z', 'y[A2]', { + refId: '_z', + references: ['_y'] + }) + ]) + }) + + it('should work for MAX function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // a = 10 ~~| + // b = 20 ~~| + // y = MAX(a, b) ~~| + // `) + + const xmileVars = `\ + + 10 + + + 20 + + + MAX(a,b) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a', '10', { + refId: '_a', + varType: 'const' + }), + v('b', '20', { + refId: '_b', + varType: 'const' + }), + v('y', 'MAX(a,b)', { + refId: '_y', + referencedFunctionNames: ['__max'], + references: ['_a', '_b'] + }) + ]) + }) + + it('should work for MIN function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // a = 10 ~~| + // b = 20 ~~| + // y = MIN(a, b) ~~| + // `) + + const xmileVars = `\ + + 10 + + + 20 + + + MIN(a,b) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a', '10', { + refId: '_a', + varType: 'const' + }), + v('b', '20', { + refId: '_b', + varType: 'const' + }), + v('y', 'MIN(a,b)', { + refId: '_y', + referencedFunctionNames: ['__min'], + references: ['_a', '_b'] + }) + ]) + }) + + it('should work for MOD function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // a = 20 ~~| + // b = 10 ~~| + // y = MODULO(a, b) ~~| + // `) + + const xmileVars = `\ + + 20 + + + 10 + + + MOD(a,b) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('a', '20', { + refId: '_a', + varType: 'const' + }), + v('b', '10', { + refId: '_b', + varType: 'const' + }), + v('y', 'MOD(a,b)', { + refId: '_y', + referencedFunctionNames: ['__mod'], + references: ['_a', '_b'] + }) + ]) + }) + + // TODO: Add a variant where discount rate is defined as (x+1) (old reader did not include + // parens and might generate incorrect equation) + // TODO: This test is skipped because Stella's NPV function takes 2 or 3 arguments, but Vensim's + // takes 4 arguments, so it is not implemented yet in SDE + it.skip('should work for NPV function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // stream = 100 ~~| + // discount rate = 10 ~~| + // init = 0 ~~| + // factor = 2 ~~| + // y = NPV(stream, discount rate, init, factor) ~~| + // `) + + const xmileVars = `\ + + 100 + + + 10 + + + 0 + + + 2 + + + NPV(stream,discount rate,init,factor) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('stream', '100', { + refId: '_stream', + varType: 'const' + }), + v('discount rate', '10', { + refId: '_discount_rate', + varType: 'const' + }), + v('init', '0', { + refId: '_init', + varType: 'const' + }), + v('factor', '2', { + refId: '_factor', + varType: 'const' + }), + v('y', 'NPV(stream,discount rate,init,factor)', { + refId: '_y', + references: ['__level2', '__level1', '__aux1'], + npvVarName: '__aux1' + }), + v('_level1', 'INTEG((-_level1*discount rate)/(1+discount rate*TIME STEP),1)', { + refId: '__level1', + varType: 'level', + includeInOutput: false, + references: ['_discount_rate', '_time_step'], + hasInitValue: true, + referencedFunctionNames: ['__integ'] + }), + v('_level2', 'INTEG(stream*_level1,init)', { + refId: '__level2', + varType: 'level', + includeInOutput: false, + references: ['_stream', '__level1'], + hasInitValue: true, + initReferences: ['_init'], + referencedFunctionNames: ['__integ'] + }), + v('_aux1', '(_level2+stream*TIME STEP*_level1)*factor', { + refId: '__aux1', + includeInOutput: false, + references: ['__level2', '_stream', '_time_step', '__level1', '_factor'] + }) + ]) + }) + + // TODO + it.skip('should work for NPV function (with subscripted variables)', () => {}) + + // TODO: This test is skipped because Stella's PULSE function takes 1 or 3 arguments, but Vensim's + // takes 2 arguments, so it is not implemented yet in SDE + it.skip('should work for PULSE function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // start = 10 ~~| + // width = 20 ~~| + // y = PULSE(start, width) ~~| + // `) + + const xmileVars = `\ + + 10 + + + 20 + + + PULSE(start,width) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('start', '10', { + refId: '_start', + varType: 'const' + }), + v('width', '20', { + refId: '_width', + varType: 'const' + }), + v('y', 'PULSE(start,width)', { + refId: '_y', + referencedFunctionNames: ['__pulse'], + references: ['_start', '_width'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the SAMPLE IF TRUE function + it.skip('should work for SAMPLE IF TRUE function', () => { + const vars = readInlineModel(` + initial = 10 ~~| + input = 5 ~~| + x = 1 ~~| + y = SAMPLE IF TRUE(Time > x, input, initial) ~~| + `) + expect(vars).toEqual([ + v('initial', '10', { + refId: '_initial', + varType: 'const' + }), + v('input', '5', { + refId: '_input', + varType: 'const' + }), + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('y', 'SAMPLE IF TRUE(Time>x,input,initial)', { + refId: '_y', + references: ['_time', '_x', '_input'], + hasInitValue: true, + initReferences: ['_initial'], + referencedFunctionNames: ['__sample_if_true'] + }) + ]) + }) + + it('should work for SMTH1 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH(input, delay) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH1(input, delay) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y', 'SMTH1(input,delay)', { + refId: '_y', + references: ['__level1'], + smoothVarRefId: '__level1' + }), + v('_level1', 'INTEG((input-_level1)/delay,input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH1 function (without initial value argument; with subscripted input and subscripted delay)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay[DimA] = 2, 3 ~~| + // y[DimA] = SMOOTH(input[DimA], delay[DimA]) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + + 2 + + + 3 + + + + + + + SMTH1(input[DimA], delay[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '1', { + refId: '_input', + subscripts: ['_dima'], + varType: 'const' + }), + v('delay[A1]', '2', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '3', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('y[DimA]', 'SMTH1(input[DimA],delay[DimA])', { + refId: '_y', + references: ['__level1'], + smoothVarRefId: '__level1', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/delay[DimA],input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a1]', '_delay[_a2]'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH1 function (without initial value argument; with subscripted input and non-subscripted delay)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay = 2 ~~| + // y[DimA] = SMOOTH(input[DimA], delay) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + 2 + + + + + + SMTH1(input[DimA],delay) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '1', { + refId: '_input', + subscripts: ['_dima'], + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y[DimA]', 'SMTH1(input[DimA],delay)', { + refId: '_y', + references: ['__level1'], + smoothVarRefId: '__level1', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/delay,input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH1 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // init = 5 ~~| + // y = SMOOTHI(input, delay, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + 5 + + + SMTH1(input, delay, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('init', '5', { + refId: '_init', + varType: 'const' + }), + v('y', 'SMTH1(input,delay,init)', { + refId: '_y', + references: ['__level1'], + smoothVarRefId: '__level1' + }), + v('_level1', 'INTEG((input-_level1)/delay,init)', { + includeInOutput: false, + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + hasInitValue: true, + initReferences: ['_init'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH1 function (with initial value argument; with subscripted variables)', () => { + // Note that we have a mix of non-apply-to-all (delay, init) and apply-to-all (input) + // variables here to cover both cases + + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // input[DimA] = x[DimA] ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 4, 5, 6 ~~| + // y[DimA] = SMOOTHI(input[DimA], delay[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[DimA] + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + 4 + + + 5 + + + 6 + + + + + + + SMTH1(input[DimA], delay[DimA], init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('input[DimA]', 'x[DimA]', { + refId: '_input', + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]'], + subscripts: ['_dima'] + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[A1]', '4', { + refId: '_init[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('init[A2]', '5', { + refId: '_init[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('init[A3]', '6', { + refId: '_init[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('y[DimA]', 'SMTH1(input[DimA],delay[DimA],init[DimA])', { + refId: '_y', + references: ['__level1'], + smoothVarRefId: '__level1', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/delay[DimA],init[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init[_a1]', '_init[_a2]', '_init[_a3]'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + // TODO: This test is not exactly equivalent to the Vensim one since it uses separated definitions + // for y[A1] and y[A2] instead of a single definition for y[SubA] + it.skip('should work for SMTH1 function (with initial value argument; with separated variables using subdimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // input[DimA] = x[DimA] ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[A1] = 5 ~~| + // y[SubA] = SMOOTHI(input[SubA], delay[SubA], init[SubA]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[DimA] + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + + 5 + + + SMTH1(input[A2], delay[A2], init[A2]) + + + SMTH1(input[A3], delay[A3], init[A3]) + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('input[DimA]', 'x[DimA]', { + refId: '_input', + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]'], + subscripts: ['_dima'] + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[A1]', '5', { + refId: '_y[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('y[A2]', 'SMTH1(input[A2],delay[A2],init[A2])', { + refId: '_y[_a2]', + references: ['__level_y_1[_a2]'], + separationDims: ['_suba'], + smoothVarRefId: '__level_y_1[_a2]', + subscripts: ['_a2'] + }), + v('y[A3]', 'SMTH1(input[A3],delay[A3],init[A3])', { + refId: '_y[_a3]', + references: ['__level_y_1[_a3]'], + separationDims: ['_suba'], + smoothVarRefId: '__level_y_1[_a3]', + subscripts: ['_a3'] + }), + v('_level_y_1[a2]', 'INTEG((input[a2]-_level_y_1[a2])/delay[a2],init[a2])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_1[_a2]', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_1[a3]', 'INTEG((input[a3]-_level_y_1[a3])/delay[a3],init[a3])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_1[_a3]', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (without initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH3(input, delay) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH3(input, delay) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y', 'SMTH3(input,delay)', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3' + }), + v('_level1', 'INTEG((input-_level1)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + varType: 'level' + }), + v('_level2', 'INTEG((_level1-_level2)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay'], + varType: 'level' + }), + v('_level3', 'INTEG((_level2-_level3)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (without initial value argument; when nested in another function)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = MAX(SMOOTH3(input, delay), 0) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + MAX(SMTH3(input, delay), 0) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y', 'MAX(SMTH3(input,delay),0)', { + refId: '_y', + referencedFunctionNames: ['__max'], + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3' + }), + v('_level1', 'INTEG((input-_level1)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + varType: 'level' + }), + v('_level2', 'INTEG((_level1-_level2)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay'], + varType: 'level' + }), + v('_level3', 'INTEG((_level2-_level3)/(delay/3),input)', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (without initial value argument; with subscripted input and subscripted delay)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay[DimA] = 2, 3 ~~| + // y[DimA] = SMOOTH3(input[DimA], delay[DimA]) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + + + + + 2 + + + 3 + + + + + + + SMTH3(input[DimA], delay[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '1', { + refId: '_input', + subscripts: ['_dima'], + varType: 'const' + }), + v('delay[A1]', '2', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '3', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('y[DimA]', 'SMTH3(input[DimA],delay[DimA])', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/(delay[DimA]/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a1]', '_delay[_a2]'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level2[DimA]', 'INTEG((_level1[DimA]-_level2[DimA])/(delay[DimA]/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay[_a1]', '_delay[_a2]'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level3[DimA]', 'INTEG((_level2[DimA]-_level3[DimA])/(delay[DimA]/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay[_a1]', '_delay[_a2]'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (without initial value argument; with subscripted input and non-subscripted delay)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay = 2 ~~| + // y[DimA] = SMOOTH3(input[DimA], delay) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + 2 + + + + + + SMTH3(input[DimA], delay) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '1', { + refId: '_input', + subscripts: ['_dima'], + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y[DimA]', 'SMTH3(input[DimA],delay)', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/(delay/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level2[DimA]', 'INTEG((_level1[DimA]-_level2[DimA])/(delay/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level3[DimA]', 'INTEG((_level2[DimA]-_level3[DimA])/(delay/3),input[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (with initial value argument)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // delay = 2 ~~| + // y = SMOOTH3I(input, delay, 5) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + SMTH3(input, delay, 5) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y', 'SMTH3(input,delay,5)', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3' + }), + v('_level1', 'INTEG((input-_level1)/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + varType: 'level' + }), + v('_level2', 'INTEG((_level1-_level2)/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay'], + varType: 'level' + }), + v('_level3', 'INTEG((_level2-_level3)/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (with initial value argument; with nested function calls)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // x = 1 ~~| + // input = 1 ~~| + // delay = 3 ~~| + // init = 0 ~~| + // y = SMOOTH3I(MIN(0, input), MIN(0, delay), ABS(init)) ~~| + // `) + + const xmileVars = `\ + + 1 + + + x + + + 3 + + + 0 + + + SMTH3(MIN(0, input), MIN(0, delay), ABS(init)) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x', '1', { + refId: '_x', + varType: 'const' + }), + v('input', 'x', { + refId: '_input', + references: ['_x'] + }), + v('delay', '3', { + refId: '_delay', + varType: 'const' + }), + v('init', '0', { + refId: '_init', + varType: 'const' + }), + v('y', 'SMTH3(MIN(0,input),MIN(0,delay),ABS(init))', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3' + }), + v('_level1', 'INTEG((MIN(0,input)-_level1)/(MIN(0,delay)/3),ABS(init))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level1', + referencedFunctionNames: ['__integ', '__min', '__abs'], + references: ['_input', '_delay'], + varType: 'level' + }), + v('_level2', 'INTEG((_level1-_level2)/(MIN(0,delay)/3),ABS(init))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level2', + referencedFunctionNames: ['__integ', '__min', '__abs'], + references: ['__level1', '_delay'], + varType: 'level' + }), + v('_level3', 'INTEG((_level2-_level3)/(MIN(0,delay)/3),ABS(init))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level3', + referencedFunctionNames: ['__integ', '__min', '__abs'], + references: ['__level2', '_delay'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (with initial value argument; with subscripted variables)', () => { + // Note that we have a mix of non-apply-to-all (input, delay) and apply-to-all (init) + // variables here to cover both cases + + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // input[DimA] = x[DimA] ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 4, 5, 6 ~~| + // y[DimA] = SMOOTH3I(input[DimA], delay[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[DimA] + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + 4 + + + 5 + + + 6 + + + + + + + SMTH3(input[DimA], delay[DimA], init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[A1]', '1', { + refId: '_x[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('x[A2]', '2', { + refId: '_x[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('x[A3]', '3', { + refId: '_x[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('input[DimA]', 'x[DimA]', { + refId: '_input', + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]'], + subscripts: ['_dima'] + }), + v('delay[A1]', '1', { + refId: '_delay[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[A2]', '2', { + refId: '_delay[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[A3]', '3', { + refId: '_delay[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('init[A1]', '4', { + refId: '_init[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('init[A2]', '5', { + refId: '_init[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('init[A3]', '6', { + refId: '_init[_a3]', + subscripts: ['_a3'], + varType: 'const' + }), + v('y[DimA]', 'SMTH3(input[DimA],delay[DimA],init[DimA])', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/(delay[DimA]/3),init[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init[_a1]', '_init[_a2]', '_init[_a3]'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level2[DimA]', 'INTEG((_level1[DimA]-_level2[DimA])/(delay[DimA]/3),init[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init[_a1]', '_init[_a2]', '_init[_a3]'], + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level3[DimA]', 'INTEG((_level2[DimA]-_level3[DimA])/(delay[DimA]/3),init[DimA])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init[_a1]', '_init[_a2]', '_init[_a3]'], + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay[_a1]', '_delay[_a2]', '_delay[_a3]'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + it('should work for SMTH3 function (with initial value argument; with subscripted input and non-subscripted delay)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1 ~~| + // delay = 2 ~~| + // y[DimA] = SMOOTH3I(input[DimA], delay, 5) ~~| + // `) + + const xmileDims = `\ + + + +` + const xmileVars = `\ + + + + + 1 + + + 2 + + + + + + SMTH3(input[DimA], delay, 5) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[DimA]', '1', { + refId: '_input', + subscripts: ['_dima'], + varType: 'const' + }), + v('delay', '2', { + refId: '_delay', + varType: 'const' + }), + v('y[DimA]', 'SMTH3(input[DimA],delay,5)', { + refId: '_y', + references: ['__level1', '__level2', '__level3'], + smoothVarRefId: '__level3', + subscripts: ['_dima'] + }), + v('_level1[DimA]', 'INTEG((input[DimA]-_level1[DimA])/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level2[DimA]', 'INTEG((_level1[DimA]-_level2[DimA])/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level2', + referencedFunctionNames: ['__integ'], + references: ['__level1', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }), + v('_level3[DimA]', 'INTEG((_level2[DimA]-_level3[DimA])/(delay/3),5)', { + hasInitValue: true, + includeInOutput: false, + refId: '__level3', + referencedFunctionNames: ['__integ'], + references: ['__level2', '_delay'], + subscripts: ['_dima'], + varType: 'level' + }) + ]) + }) + + // TODO: This test is not exactly equivalent to the Vensim one since it uses separated definitions + // for y[A1] and y[A2] instead of a single definition for y[SubA] + it.skip('should work for SMTH3 function (with initial value argument and separated variables using subdimension)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2, A3 ~~| + // SubA: A2, A3 ~~| + // x[DimA] = 1, 2, 3 ~~| + // input[DimA] = x[DimA] ~~| + // delay[DimA] = 1, 2, 3 ~~| + // init[DimA] = 0 ~~| + // y[A1] = 5 ~~| + // y[SubA] = SMOOTH3I(input[SubA], delay[SubA], init[SubA]) ~~| + // `) + + const xmileDims = `\ + + + + + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + 3 + + + + + + + x[DimA] + + + + + + + 1 + + + 2 + + + 3 + + + + + + + 0 + + + + + + + 5 + + + SMTH3(input[A2], delay[A2], init[A2]) + + + SMTH3(input[A3], delay[A3], init[A3]) + +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('x[DimA]', '1,2,3', { + refId: '_x[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('x[DimA]', '1,2,3', { + refId: '_x[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('x[DimA]', '1,2,3', { + refId: '_x[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'const' + }), + v('input[DimA]', 'x[DimA]+PULSE(10,10)', { + refId: '_input', + referencedFunctionNames: ['__pulse'], + references: ['_x[_a1]', '_x[_a2]', '_x[_a3]'], + subscripts: ['_dima'] + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a1]', + separationDims: ['_dima'], + subscripts: ['_a1'], + varType: 'const' + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a2]', + separationDims: ['_dima'], + subscripts: ['_a2'], + varType: 'const' + }), + v('delay[DimA]', '1,2,3', { + refId: '_delay[_a3]', + separationDims: ['_dima'], + subscripts: ['_a3'], + varType: 'const' + }), + v('init[DimA]', '0', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[A1]', '5', { + refId: '_y[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('y[SubA]', 'SMTH3(input[SubA],delay[SubA],init[SubA])', { + refId: '_y[_a2]', + references: ['__level_y_1[_a2]', '__level_y_2[_a2]', '__level_y_3[_a2]'], + separationDims: ['_suba'], + smoothVarRefId: '__level_y_3[_a2]', + subscripts: ['_a2'] + }), + v('y[SubA]', 'SMTH3(input[SubA],delay[SubA],init[SubA])', { + refId: '_y[_a3]', + references: ['__level_y_1[_a3]', '__level_y_2[_a3]', '__level_y_3[_a3]'], + separationDims: ['_suba'], + smoothVarRefId: '__level_y_3[_a3]', + subscripts: ['_a3'] + }), + v('_level_y_1[a2]', 'INTEG((input[a2]-_level_y_1[a2])/(delay[a2]/3),init[a2])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_1[_a2]', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_2[a2]', 'INTEG((_level_y_1[a2]-_level_y_2[a2])/(delay[a2]/3),init[a2])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_2[_a2]', + referencedFunctionNames: ['__integ'], + references: ['__level_y_1[_a2]', '_delay[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_3[a2]', 'INTEG((_level_y_2[a2]-_level_y_3[a2])/(delay[a2]/3),init[a2])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_3[_a2]', + referencedFunctionNames: ['__integ'], + references: ['__level_y_2[_a2]', '_delay[_a2]'], + subscripts: ['_a2'], + varType: 'level' + }), + v('_level_y_1[a3]', 'INTEG((input[a3]-_level_y_1[a3])/(delay[a3]/3),init[a3])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_1[_a3]', + referencedFunctionNames: ['__integ'], + references: ['_input', '_delay[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_level_y_2[a3]', 'INTEG((_level_y_1[a3]-_level_y_2[a3])/(delay[a3]/3),init[a3])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_2[_a3]', + referencedFunctionNames: ['__integ'], + references: ['__level_y_1[_a3]', '_delay[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }), + v('_level_y_3[a3]', 'INTEG((_level_y_2[a3]-_level_y_3[a3])/(delay[a3]/3),init[a3])', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_init'], + refId: '__level_y_3[_a3]', + referencedFunctionNames: ['__integ'], + references: ['__level_y_2[_a3]', '_delay[_a3]'], + subscripts: ['_a3'], + varType: 'level' + }) + ]) + }) + + it('should work for TREND function', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // input = 1 ~~| + // avg time = 2 ~~| + // init = 3 ~~| + // y = TREND(input, avg time, init) ~~| + // `) + + const xmileVars = `\ + + 1 + + + 2 + + + 3 + + + TREND(input, avg time, init) +` + const mdl = xmile('', xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input', '1', { + refId: '_input', + varType: 'const' + }), + v('avg time', '2', { + refId: '_avg_time', + varType: 'const' + }), + v('init', '3', { + refId: '_init', + varType: 'const' + }), + v('y', 'TREND(input,avg time,init)', { + refId: '_y', + references: ['__level1', '__aux1'], + trendVarName: '__aux1' + }), + v('_level1', 'INTEG((input-_level1)/avg time,input/(1+init*avg time))', { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input', '_init', '_avg_time'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input', '_avg_time'], + varType: 'level' + }), + v('_aux1', 'ZIDZ(input-_level1,avg time*ABS(_level1))', { + includeInOutput: false, + refId: '__aux1', + referencedFunctionNames: ['__zidz', '__abs'], + references: ['_input', '__level1', '_avg_time'] + }) + ]) + }) + + it('should work for TREND function (with subscripted variables)', () => { + // Equivalent Vensim model for reference: + // const vars = readInlineModel(` + // DimA: A1, A2 ~~| + // input[DimA] = 1, 2 ~~| + // avg time[DimA] = 3, 4 ~~| + // init[DimA] = 5 ~~| + // y[DimA] = TREND(input[DimA], avg time[DimA], init[DimA]) ~~| + // `) + + const xmileDims = `\ + + + + +` + const xmileVars = `\ + + + + + + 1 + + + 2 + + + + + + + + 3 + + + 4 + + + + + + + 5 + + + + + + TREND(input[DimA], avg time[DimA], init[DimA]) +` + const mdl = xmile(xmileDims, xmileVars) + const vars = readInlineModel(mdl) + expect(vars).toEqual([ + v('input[A1]', '1', { + refId: '_input[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('input[A2]', '2', { + refId: '_input[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('avg time[A1]', '3', { + refId: '_avg_time[_a1]', + subscripts: ['_a1'], + varType: 'const' + }), + v('avg time[A2]', '4', { + refId: '_avg_time[_a2]', + subscripts: ['_a2'], + varType: 'const' + }), + v('init[DimA]', '5', { + refId: '_init', + subscripts: ['_dima'], + varType: 'const' + }), + v('y[DimA]', 'TREND(input[DimA],avg time[DimA],init[DimA])', { + refId: '_y', + references: ['__level1', '__aux1'], + subscripts: ['_dima'], + trendVarName: '__aux1' + }), + v( + '_level1[DimA]', + 'INTEG((input[DimA]-_level1[DimA])/avg time[DimA],input[DimA]/(1+init[DimA]*avg time[DimA]))', + { + hasInitValue: true, + includeInOutput: false, + initReferences: ['_input[_a1]', '_input[_a2]', '_init', '_avg_time[_a1]', '_avg_time[_a2]'], + refId: '__level1', + referencedFunctionNames: ['__integ'], + references: ['_input[_a1]', '_input[_a2]', '_avg_time[_a1]', '_avg_time[_a2]'], + subscripts: ['_dima'], + varType: 'level' + } + ), + v('_aux1[DimA]', 'ZIDZ(input[DimA]-_level1[DimA],avg time[DimA]*ABS(_level1[DimA]))', { + includeInOutput: false, + refId: '__aux1', + referencedFunctionNames: ['__zidz', '__abs'], + references: ['_input[_a1]', '_input[_a2]', '__level1', '_avg_time[_a1]', '_avg_time[_a2]'], + subscripts: ['_dima'] + }) + ]) + }) + + // TODO: This test is skipped because Stella doesn't include the WITH LOOKUP function + it.skip('should work for WITH LOOKUP function', () => { + const vars = readInlineModel(` + y = WITH LOOKUP(Time, ( [(0,0)-(2,2)], (0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3) )) ~~| + `) + expect(vars).toEqual([ + v('y', 'WITH LOOKUP(Time,([(0,0)-(2,2)],(0,0),(0.1,0.01),(0.5,0.7),(1,1),(1.5,1.2),(2,1.3)))', { + lookupArgVarName: '__lookup1', + refId: '_y', + referencedFunctionNames: ['__with_lookup'], + referencedLookupVarNames: ['__lookup1'], + references: ['_time'] + }), + v('_lookup1', '', { + includeInOutput: false, + points: [ + [0, 0], + [0.1, 0.01], + [0.5, 0.7], + [1, 1], + [1.5, 1.2], + [2, 1.3] + ], + range: [ + [0, 0], + [2, 2] + ], + refId: '__lookup1', + varType: 'lookup' + }) + ]) + }) +}) diff --git a/packages/compile/src/model/read-equations.js b/packages/compile/src/model/read-equations.js index 0c630830..5c581caf 100644 --- a/packages/compile/src/model/read-equations.js +++ b/packages/compile/src/model/read-equations.js @@ -14,7 +14,10 @@ import { generateLookup } from './read-equation-fn-with-lookup.js' import { readVariables } from './read-variables.js' class Context { - constructor(eqnLhs, refId) { + constructor(modelKind, eqnLhs, refId) { + // The kind of model being read, either 'vensim' or 'xmile' + this.modelKind = modelKind + // The LHS of the equation being processed this.eqnLhs = eqnLhs @@ -109,7 +112,7 @@ class Context { * @param {string[]} eqnStrings An array of individual equation strings in Vensim format. */ defineVariables(eqnStrings) { - // Parse the equation text + // Parse the equation text, which is assumed to be in Vensim format const eqnText = eqnStrings.join('\n') const parsedModel = { kind: 'vensim', root: parseVensimModel(eqnText) } @@ -131,7 +134,7 @@ class Context { vars.forEach(v => { // Process each variable using the same process as above - readEquation(v) + readEquation(v, 'vensim') // Inhibit output for generated variables v.includeInOutput = false @@ -168,10 +171,11 @@ class Context { * TODO: Docs and types * * @param v {*} The `Variable` instance to process. + * @param {string} modelKind The kind of model being read, either 'vensim' or 'xmile'. */ -export function readEquation(v) { +export function readEquation(v, modelKind) { const eqn = v.parsedEqn - const context = new Context(eqn?.lhs, v.refId) + const context = new Context(modelKind, eqn?.lhs, v.refId) // Visit the RHS of the equation. If the equation is undefined, it is a synthesized // variable (e.g., `Time`), in which case we skip this step. @@ -383,257 +387,432 @@ function visitFunctionCall(v, callExpr, context) { // (they will be visited when processing the replacement equations) let visitArgs = true - switch (callExpr.fnId) { - // - // - // 1-argument functions... - // - // - - case '_ABS': - case '_ARCCOS': - case '_ARCSIN': - case '_ARCTAN': - case '_COS': - case '_ELMCOUNT': - case '_EXP': - case '_GAMMA_LN': - case '_INTEGER': - case '_LN': - case '_SIN': - case '_SQRT': - case '_SUM': - case '_TAN': - case '_VMAX': - case '_VMIN': - validateCallArgs(callExpr, 1) - break + // If the `unhandled` flag is set, it means we did not match a known function + let unhandled = false + + // Helper function that validates a function call for a Vensim model + function validateVensimFunctionCall() { + switch (callExpr.fnId) { + // + // + // 1-argument functions... + // + // + + case '_ABS': + case '_ARCCOS': + case '_ARCSIN': + case '_ARCTAN': + case '_COS': + case '_ELMCOUNT': + case '_EXP': + case '_GAMMA_LN': + case '_INTEGER': + case '_LN': + case '_SIN': + case '_SQRT': + case '_SUM': + case '_TAN': + case '_VMAX': + case '_VMIN': + validateCallArgs(callExpr, 1) + break - // - // - // 2-argument functions... - // - // - - case '_LOOKUP_BACKWARD': - case '_LOOKUP_FORWARD': - case '_LOOKUP_INVERT': - case '_MAX': - case '_MIN': - case '_MODULO': - case '_POW': - case '_POWER': - case '_PULSE': - case '_QUANTUM': - case '_STEP': - case '_VECTOR_ELM_MAP': - case '_VECTOR_SORT_ORDER': - case '_ZIDZ': - validateCallArgs(callExpr, 2) - break + // + // + // 2-argument functions... + // + // + + case '_LOOKUP_BACKWARD': + case '_LOOKUP_FORWARD': + case '_LOOKUP_INVERT': + case '_MAX': + case '_MIN': + case '_MODULO': + case '_POW': + case '_POWER': + case '_PULSE': + case '_QUANTUM': + case '_STEP': + case '_VECTOR_ELM_MAP': + case '_VECTOR_SORT_ORDER': + case '_ZIDZ': + validateCallArgs(callExpr, 2) + break - // - // - // 3-plus-argument functions... - // - // + // + // + // 3-plus-argument functions... + // + // - case '_GET_DATA_BETWEEN_TIMES': - case '_RAMP': - case '_XIDZ': - validateCallArgs(callExpr, 3) - break + case '_GET_DATA_BETWEEN_TIMES': + case '_RAMP': + case '_XIDZ': + validateCallArgs(callExpr, 3) + break - case '_PULSE_TRAIN': - validateCallArgs(callExpr, 4) - break + case '_PULSE_TRAIN': + validateCallArgs(callExpr, 4) + break - case '_VECTOR_SELECT': - validateCallArgs(callExpr, 5) - break + case '_VECTOR_SELECT': + validateCallArgs(callExpr, 5) + break - // - // - // Complex functions... - // - // - - case '_ACTIVE_INITIAL': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 2) - v.hasInitValue = true - // The 2nd argument is used at init time - argModes[1] = 'init' - break + // + // + // Complex functions... + // + // + + case '_ACTIVE_INITIAL': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 2) + v.hasInitValue = true + // The 2nd argument is used at init time + argModes[1] = 'init' + break - case '_ALLOCATE_AVAILABLE': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 3) - break + case '_ALLOCATE_AVAILABLE': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 3) + break - case '_DELAY1': - case '_DELAY1I': - case '_DELAY3': - case '_DELAY3I': - validateCallArgs(callExpr, callExpr.fnId.endsWith('I') ? 3 : 2) - addFnReference = false - visitArgs = false - generateDelayVariables(v, callExpr, context) - break + case '_DELAY1': + case '_DELAY1I': + case '_DELAY3': + case '_DELAY3I': + validateCallArgs(callExpr, callExpr.fnId.endsWith('I') ? 3 : 2) + addFnReference = false + visitArgs = false + generateDelayVariables(v, callExpr, context) + break - case '_DELAY_FIXED': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 3) - v.varType = 'level' - v.varSubtype = 'fixedDelay' - v.hasInitValue = true - v.fixedDelayVarName = canonicalName(newFixedDelayVarName()) - // The 2nd and 3rd arguments are used at init time - argModes[1] = 'init' - argModes[2] = 'init' - break + case '_DELAY_FIXED': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 3) + v.varType = 'level' + v.varSubtype = 'fixedDelay' + v.hasInitValue = true + v.fixedDelayVarName = canonicalName(newFixedDelayVarName()) + // The 2nd and 3rd arguments are used at init time + argModes[1] = 'init' + argModes[2] = 'init' + break - case '_DEPRECIATE_STRAIGHTLINE': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 4) - v.varSubtype = 'depreciation' - v.hasInitValue = true - v.depreciationVarName = canonicalName(newDepreciationVarName()) - // The 2nd and 3rd arguments are used at init time - // TODO: The 3rd (fisc) argument is not currently supported - // TODO: Shouldn't the last (init) argument be marked as 'init' here? (It's - // not treated as 'init' in the legacy reader.) - argModes[1] = 'init' - argModes[2] = 'init' - break + case '_DEPRECIATE_STRAIGHTLINE': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 4) + v.varSubtype = 'depreciation' + v.hasInitValue = true + v.depreciationVarName = canonicalName(newDepreciationVarName()) + // The 2nd and 3rd arguments are used at init time + // TODO: The 3rd (fisc) argument is not currently supported + // TODO: Shouldn't the last (init) argument be marked as 'init' here? (It's + // not treated as 'init' in the legacy reader.) + argModes[1] = 'init' + argModes[2] = 'init' + break - case '_GAME': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 1) - generateGameVariables(v, callExpr, context) - break + case '_GAME': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 1) + generateGameVariables(v, callExpr, context) + break - case '_GET_DIRECT_CONSTANTS': { - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 3) - validateCallArgType(callExpr, 0, 'string') - validateCallArgType(callExpr, 1, 'string') - validateCallArgType(callExpr, 2, 'string') - addFnReference = false - v.varType = 'const' - v.directConstArgs = { - file: callExpr.args[0].text, - tab: callExpr.args[1].text, - startCell: callExpr.args[2].text + case '_GET_DIRECT_CONSTANTS': { + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 3) + validateCallArgType(callExpr, 0, 'string') + validateCallArgType(callExpr, 1, 'string') + validateCallArgType(callExpr, 2, 'string') + addFnReference = false + v.varType = 'const' + v.directConstArgs = { + file: callExpr.args[0].text, + tab: callExpr.args[1].text, + startCell: callExpr.args[2].text + } + break } - break + + case '_GET_DIRECT_DATA': + case '_GET_DIRECT_LOOKUPS': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 4) + validateCallArgType(callExpr, 0, 'string') + validateCallArgType(callExpr, 1, 'string') + validateCallArgType(callExpr, 2, 'string') + validateCallArgType(callExpr, 3, 'string') + addFnReference = false + v.varType = 'data' + v.directDataArgs = { + file: callExpr.args[0].text, + tab: callExpr.args[1].text, + timeRowOrCol: callExpr.args[2].text, + startCell: callExpr.args[3].text + } + break + + case '_IF_THEN_ELSE': + validateCallArgs(callExpr, 3) + addFnReference = false + break + + case '_INITIAL': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 1) + v.varType = 'initial' + v.hasInitValue = true + // The single argument is used at init time + argModes[0] = 'init' + break + + case '_INTEG': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 2) + v.varType = 'level' + v.hasInitValue = true + // The 2nd argument is used at init time + argModes[1] = 'init' + break + + case '_NPV': + validateCallArgs(callExpr, 4) + addFnReference = false + visitArgs = false + generateNpvVariables(v, callExpr, context) + break + + case '_SAMPLE_IF_TRUE': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 3) + v.hasInitValue = true + // The 3rd argument is used at init time + argModes[2] = 'init' + break + + case '_SMOOTH': + case '_SMOOTHI': + case '_SMOOTH3': + case '_SMOOTH3I': + validateCallArgs(callExpr, callExpr.fnId.endsWith('I') ? 3 : 2) + addFnReference = false + visitArgs = false + generateSmoothVariables(v, callExpr, context) + break + + case '_TREND': + validateCallArgs(callExpr, 3) + addFnReference = false + visitArgs = false + generateTrendVariables(v, callExpr, context) + break + + case '_WITH_LOOKUP': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 2) + generateLookup(v, callExpr, context) + break + + default: + unhandled = true + break } + } - case '_GET_DIRECT_DATA': - case '_GET_DIRECT_LOOKUPS': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 4) - validateCallArgType(callExpr, 0, 'string') - validateCallArgType(callExpr, 1, 'string') - validateCallArgType(callExpr, 2, 'string') - validateCallArgType(callExpr, 3, 'string') - addFnReference = false - v.varType = 'data' - v.directDataArgs = { - file: callExpr.args[0].text, - tab: callExpr.args[1].text, - timeRowOrCol: callExpr.args[2].text, - startCell: callExpr.args[3].text - } - break + // Helper function that validates a function call for a Stella model + // XXX: Currently we conflate "XMILE model" with "XMILE model as generated by Stella", + // so this function only handles the subset of Stella functions that are supported in + // SDEverywhere's runtime library + function validateStellaFunctionCall() { + switch (callExpr.fnId) { + // + // + // 1-argument functions... + // + // + + case '_ABS': + case '_ARCCOS': + case '_ARCSIN': + case '_ARCTAN': + case '_COS': + case '_EXP': + case '_GAMMALN': + case '_INT': + case '_LN': + case '_SIN': + case '_SIZE': + case '_SQRT': + case '_SUM': + case '_TAN': + break - case '_IF_THEN_ELSE': - validateCallArgs(callExpr, 3) - addFnReference = false - break + // + // + // 2-argument functions... + // + // + + case '_LOOKUP': + case '_LOOKUPINV': + case '_MAX': + case '_MIN': + case '_MOD': + case '_SAFEDIV': + case '_STEP': + validateCallArgs(callExpr, 2) + break - case '_INITIAL': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 1) - v.varType = 'initial' - v.hasInitValue = true - // The single argument is used at init time - argModes[0] = 'init' - break + // + // + // 3-plus-argument functions... + // + // - case '_INTEG': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 2) - v.varType = 'level' - v.hasInitValue = true - // The 2nd argument is used at init time - argModes[1] = 'init' - break + case '_RAMP': + validateCallArgs(callExpr, 3) + break - case '_NPV': - validateCallArgs(callExpr, 4) - addFnReference = false - visitArgs = false - generateNpvVariables(v, callExpr, context) - break + // + // + // Complex functions... + // + // + + case '_ACTIVE_INITIAL': + // NOTE: Stella doesn't have a built-in `ACTIVE INITIAL` function, but our XMILE parser + // synthesizes an `ACTIVE INITIAL` function call for `` variable definitions that + // have both `` and `` elements. This is equivalent to Vensim's + // `ACTIVE INITIAL` function. + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 2) + v.hasInitValue = true + // The 2nd argument is used at init time + argModes[1] = 'init' + break - case '_SAMPLE_IF_TRUE': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 3) - v.hasInitValue = true - // The 3rd argument is used at init time - argModes[2] = 'init' - break + case '_DELAY': + // Stella's DELAY function is equivalent to Vensim's DELAY FIXED function + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 3) + v.varType = 'level' + v.varSubtype = 'fixedDelay' + v.hasInitValue = true + v.fixedDelayVarName = canonicalName(newFixedDelayVarName()) + // The 2nd and 3rd arguments are used at init time + argModes[1] = 'init' + argModes[2] = 'init' + break - case '_SMOOTH': - case '_SMOOTHI': - case '_SMOOTH3': - case '_SMOOTH3I': - validateCallArgs(callExpr, callExpr.fnId.endsWith('I') ? 3 : 2) - addFnReference = false - visitArgs = false - generateSmoothVariables(v, callExpr, context) - break + case '_DEPRECIATE_STRAIGHTLINE': + // Stella's DEPRECIATE_STRAIGHTLINE function has the same signature as Vensim's + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 4) + v.varSubtype = 'depreciation' + v.hasInitValue = true + v.depreciationVarName = canonicalName(newDepreciationVarName()) + // The 2nd and 3rd arguments are used at init time + argModes[1] = 'init' + argModes[2] = 'init' + break - case '_TREND': - validateCallArgs(callExpr, 3) - addFnReference = false - visitArgs = false - generateTrendVariables(v, callExpr, context) - break + case '_DELAY1': + case '_DELAY3': + // Stella's DELAY1 and DELAY3 functions can take a third "initial" argument (in which case + // they behave like Vensim's DELAY1I and DELAY3I functions) + validateCallArgs(callExpr, [2, 3]) + addFnReference = false + visitArgs = false + generateDelayVariables(v, callExpr, context) + break - case '_WITH_LOOKUP': - validateCallDepth(callExpr, context) - validateCallArgs(callExpr, 2) - generateLookup(v, callExpr, context) - break + case '_IF_THEN_ELSE': + validateCallArgs(callExpr, 3) + addFnReference = false + break - default: { - // See if the function name is actually the name of a lookup variable. For Vensim - // models, the antlr4-vensim grammar has separate definitions for lookup calls and - // function calls, but in practice they can only be differentiated in the case - // where the lookup has subscripts; when there are no subscripts, they get treated - // like normal function calls, and in that case we will end up here. If we find - // a variable with the given name, then we will assume it's a lookup call, otherwise - // we treat it as a call of an unimplemented function. - const varId = callExpr.fnId.toLowerCase() - const referencedVar = Model.varWithName(varId) - if (referencedVar === undefined || referencedVar.parsedEqn.rhs.kind !== 'lookup') { - // Throw an error if the function is not yet implemented in SDE - // TODO: This will report false positives in the case of user-defined macros. For now - // we provide the ability to turn off this check via an environment variable, but we - // should consider providing a way for the user to declare the names of any user-defined - // macros so that we can skip this check when those macros are detected. - if (process.env.SDE_REPORT_UNSUPPORTED_FUNCTIONS !== '0') { - const msg = `Unhandled function '${callExpr.fnId}' in readEquations for '${v.modelLHS}'` - if (process.env.SDE_REPORT_UNSUPPORTED_FUNCTIONS === 'warn') { - console.warn(`WARNING: ${msg}`) - } else { - throw new Error(msg) - } + case '_INIT': + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 1) + v.varType = 'initial' + v.hasInitValue = true + // The single argument is used at init time + argModes[0] = 'init' + break + + case '_INTEG': + // NOTE: Stella doesn't have a built-in `INTEG` function, but our XMILE parser synthesizes + // an `INTEG` function call for `` variable definitions using the `` and + // `` elements as the `rate` argument for the Vensim-style `INTEG` function call + validateCallDepth(callExpr, context) + validateCallArgs(callExpr, 2) + v.varType = 'level' + v.hasInitValue = true + // The 2nd argument is used at init time + argModes[1] = 'init' + break + + case '_SMTH1': + case '_SMTH3': + // Stella's SMTH1 and SMTH3 functions can take a third "initial" argument (in which case + // they behave like Vensim's SMOOTHI and SMOOTH3I functions) + validateCallArgs(callExpr, [2, 3]) + addFnReference = false + visitArgs = false + generateSmoothVariables(v, callExpr, context) + break + + case '_TREND': + validateCallArgs(callExpr, 3) + addFnReference = false + visitArgs = false + generateTrendVariables(v, callExpr, context) + break + + default: + unhandled = true + break + } + } + + // Validate the function call based on the model kind + if (context.modelKind === 'vensim') { + validateVensimFunctionCall() + } else if (context.modelKind === 'xmile') { + validateStellaFunctionCall() + } else { + throw new Error(`Unknown model kind: ${context.modelKind}`) + } + + if (unhandled) { + // We did not match a known function, so we need to check if this is a lookup call. + // See if the function name is actually the name of a lookup variable. For Vensim + // models, the antlr4-vensim grammar has separate definitions for lookup calls and + // function calls, but in practice they can only be differentiated in the case + // where the lookup has subscripts; when there are no subscripts, they get treated + // like normal function calls, and in that case we will end up here. If we find + // a variable with the given name, then we will assume it's a lookup call, otherwise + // we treat it as a call of an unimplemented function. + const varId = callExpr.fnId.toLowerCase() + const referencedVar = Model.varWithName(varId) + if (referencedVar === undefined || referencedVar.parsedEqn.rhs.kind !== 'lookup') { + // Throw an error if the function is not yet implemented in SDE + // TODO: This will report false positives in the case of user-defined macros. For now + // we provide the ability to turn off this check via an environment variable, but we + // should consider providing a way for the user to declare the names of any user-defined + // macros so that we can skip this check when those macros are detected. + if (process.env.SDE_REPORT_UNSUPPORTED_FUNCTIONS !== '0') { + const msg = `Unhandled function '${callExpr.fnId}' in readEquations for '${v.modelLHS}'` + if (process.env.SDE_REPORT_UNSUPPORTED_FUNCTIONS === 'warn') { + console.warn(`WARNING: ${msg}`) + } else { + throw new Error(msg) } } - break } } @@ -725,10 +904,20 @@ function validateCallDepth(callExpr, context) { * Throw an error if the given function call does not have the expected number of arguments. */ function validateCallArgs(callExpr, expectedArgCount) { - if (callExpr.args.length !== expectedArgCount) { - throw new Error( - `Expected '${callExpr.fnName}' function call to have ${expectedArgCount} arguments but got ${callExpr.args.length} ` - ) + if (Array.isArray(expectedArgCount)) { + if (!expectedArgCount.includes(callExpr.args.length)) { + throw new Error( + `Expected '${callExpr.fnName}' function call to have ${expectedArgCount.join('|')} arguments but got ${ + callExpr.args.length + }` + ) + } + } else { + if (callExpr.args.length !== expectedArgCount) { + throw new Error( + `Expected '${callExpr.fnName}' function call to have ${expectedArgCount} arguments but got ${callExpr.args.length}` + ) + } } } @@ -949,3 +1138,121 @@ function resolveRhsSubOrDim(lhsVariable, lhsSubIds, rhsSubId) { throw new Error(`Failed to find LHS dimension for RHS dimension ${rhsSubId} in lhs=${lhsVariable.refId}`) } } + +/** + * Resolve any XMILE dimension wildcards in the given equation and return a new equation + * that has the `_SDE_WILDCARD_` placeholder replaced with the actual dimension name. + * + * @param {*} variable The `Variable` instance to process. + * @returns {*} The parsed equation with the `_SDE_WILDCARD_` placeholder replaced with the + * actual dimension name, or `undefined` if the equation does not contain any wildcards. + */ +export function resolveXmileDimensionWildcards(variable) { + const eqn = variable.parsedEqn + if (!eqn.rhs || eqn.rhs.kind !== 'expr') { + return undefined + } + + // Create a deep copy of the equation and resolve wildcards + let hasWildcards = false + function resolveWildcardsInExpr(expr) { + switch (expr.kind) { + case 'variable-ref': { + if (!expr.subscriptRefs) { + return expr + } + + // Check if this variable reference has wildcards + let varRefHasWildcard = false + const newSubscriptRefs = expr.subscriptRefs.map((subRef, subIndex) => { + if (subRef.subId.startsWith('__sde_wildcard_')) { + varRefHasWildcard = true + hasWildcards = true + + // Look up the referenced variable to get its dimensions + const referencedVars = Model.varsWithName(expr.varId) + if (referencedVars && referencedVars.length > 0) { + // Get the dimension ID at this index from the referenced variable + const referencedDimOrSubId = referencedVars[0].subscripts[subIndex] + + // Get the dimension name for the ID + const referencedDimOrSub = sub(referencedDimOrSubId) + let referencedDimName + let referencedDimId + if (isIndex(referencedDimOrSubId)) { + // This is a subscript, so get the parent dimension name and ID + const parentDim = sub(referencedDimOrSub.family) + referencedDimName = parentDim.modelName + referencedDimId = parentDim.name + } else { + // This is a dimension, so take its name and ID directly + referencedDimName = referencedDimOrSub.modelName + referencedDimId = referencedDimOrSub.name + } + + // Preserve any trailing characters (like '!') from the wildcard + const trailingChars = subRef.subId.substring('__sde_wildcard_'.length) + return { + subName: referencedDimName + trailingChars, + subId: referencedDimId + trailingChars + } + } else { + // If we can't find the referenced variable or it has no dimensions, keep the wildcard + return subRef + } + } + return subRef + }) + + if (varRefHasWildcard) { + return { ...expr, subscriptRefs: newSubscriptRefs } + } + return expr + } + + case 'binary-op': + return { + ...expr, + lhs: resolveWildcardsInExpr(expr.lhs), + rhs: resolveWildcardsInExpr(expr.rhs) + } + + case 'parens': + case 'unary-op': + return { ...expr, expr: resolveWildcardsInExpr(expr.expr) } + + case 'function-call': { + const newArgs = expr.args.map(arg => resolveWildcardsInExpr(arg)) + return { ...expr, args: newArgs } + } + + case 'lookup-call': + return { ...expr, arg: resolveWildcardsInExpr(expr.arg) } + + case 'number': + case 'string': + case 'keyword': + case 'lookup-def': + return expr + + default: + throw new Error(`Unhandled expression kind '${expr.kind}' when reading '${variable.modelLHS}'`) + } + } + + const resolvedRhs = resolveWildcardsInExpr(eqn.rhs.expr) + if (!hasWildcards) { + // No wildcards were found, so return the original equation + return undefined + } + + // Wildcards were found, so return a new equation with the wildcards replaced with the + // actual dimension names + return { + ...eqn, + rhs: { + ...eqn.rhs, + expr: resolvedRhs + } + } +} diff --git a/packages/compile/src/model/read-subscripts.spec.ts b/packages/compile/src/model/read-subscripts-vensim.spec.ts similarity index 99% rename from packages/compile/src/model/read-subscripts.spec.ts rename to packages/compile/src/model/read-subscripts-vensim.spec.ts index 1a80b52e..03607598 100644 --- a/packages/compile/src/model/read-subscripts.spec.ts +++ b/packages/compile/src/model/read-subscripts-vensim.spec.ts @@ -70,7 +70,7 @@ function readAndResolveSubscripts(modelName: string): any[] { return readSubscriptsFromSource({ modelName }, /*resolve=*/ true) } -describe('readSubscriptRanges + resolveSubscriptRanges', () => { +describe('readSubscriptRanges + resolveSubscriptRanges (from Vensim model)', () => { it('should work for a subscript range with explicit subscripts', () => { const ranges = `DimA: A1, A2, A3 ~~|` diff --git a/packages/compile/src/model/read-subscripts-xmile.spec.ts b/packages/compile/src/model/read-subscripts-xmile.spec.ts new file mode 100644 index 00000000..9cc7779e --- /dev/null +++ b/packages/compile/src/model/read-subscripts-xmile.spec.ts @@ -0,0 +1,723 @@ +import { describe, expect, it } from 'vitest' + +import { allSubscripts, resetSubscriptsAndDimensions } from '../_shared/subscript' + +import Model from './model' + +import type { ParsedModel } from '../_tests/test-support' +import { + dim, + dimMapping, + parseInlineXmileModel, + parseXmileModel, + sampleModelDir, + sub, + xmile +} from '../_tests/test-support' + +/** + * This is a shorthand for the following steps to read (and optionally resolve) subscript ranges: + * - parseXmileModel + * - readSubscriptRanges + * - resolveSubscriptRanges (if `resolve` is true) + * - allSubscripts + * + * TODO: Update the return type once type info is added for `allSubscripts` + */ +function readSubscriptsFromSource( + source: { + modelText?: string + modelName?: string + modelDir?: string + }, + resolve: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any[] { + // XXX: This is needed due to subs/dims being in module-level storage + resetSubscriptsAndDimensions() + + let parsedModel: ParsedModel + if (source.modelText) { + parsedModel = parseInlineXmileModel(source.modelText) + } else { + parsedModel = parseXmileModel(source.modelName) + } + + let modelDir = source.modelDir + if (modelDir === undefined) { + if (source.modelName) { + modelDir = sampleModelDir(source.modelName) + } + } + + Model.read(parsedModel, /*spec=*/ {}, /*extData=*/ undefined, /*directData=*/ undefined, modelDir, { + stopAfterReadSubscripts: !resolve, + stopAfterResolveSubscripts: true + }) + + return allSubscripts() +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readInlineSubscripts(modelText: string, modelDir?: string): any[] { + return readSubscriptsFromSource({ modelText, modelDir }, /*resolve=*/ false) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readAndResolveInlineSubscripts(modelText: string, modelDir?: string): any[] { + return readSubscriptsFromSource({ modelText, modelDir }, /*resolve=*/ true) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readSubscripts(modelName: string): any[] { + return readSubscriptsFromSource({ modelName }, /*resolve=*/ false) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readAndResolveSubscripts(modelName: string): any[] { + return readSubscriptsFromSource({ modelName }, /*resolve=*/ true) +} + +describe('readSubscriptRanges + resolveSubscriptRanges (from XMILE model)', () => { + it('should work for a subscript range with explicit subscripts', () => { + const xmileDims = `\ + + + + +` + + const mdl = xmile(xmileDims, '') + const rawSubs = readInlineSubscripts(mdl) + expect(rawSubs).toEqual([dim('DimA', ['A1', 'A2', 'A3'])]) + + const resolvedSubs = readAndResolveInlineSubscripts(mdl) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with a single numeric range', () => { + const range = `DimA: (A1-A3) ~~|` + + const rawSubs = readInlineSubscripts(range) + expect(rawSubs).toEqual([dim('DimA', ['A1', 'A2', 'A3'])]) + + const resolvedSubs = readAndResolveInlineSubscripts(range) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with multiple numeric ranges', () => { + const ranges = `DimA: (A1-A3),A5,(A7-A8) ~~|` + + const rawSubs = readInlineSubscripts(ranges) + expect(rawSubs).toEqual([dim('DimA', ['A1', 'A2', 'A3', 'A5', 'A7', 'A8'])]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3', 'A5', 'A7', 'A8']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('A5', 'DimA', 3), + sub('A7', 'DimA', 4), + sub('A8', 'DimA', 5) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with one mapping (to dimension with explicit individual subscripts)', () => { + const ranges = ` + DimA: A1, A2, A3 -> DimB ~~| + DimB: B1, B2, B3 ~~| + ` + + const rawSubs = readInlineSubscripts(ranges) + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB')], { + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimb: [] + }), + dim('DimB', ['B1', 'B2', 'B3']) + ]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB')], { + _dimb: ['_a1', '_a2', '_a3'] + }), + dim('DimB', ['B1', 'B2', 'B3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with one mapping (to dimension with explicit mix of dimensions and subscripts)', () => { + const ranges = ` + DimA: A1, A2, A3 ~~| + SubA: A1, A2 ~~| + DimB: B1, B2 -> (DimA: SubA, A3) ~~| + ` + + const rawSubs = readInlineSubscripts(ranges) + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('SubA', ['A1', 'A2']), + dim('DimB', ['B1', 'B2'], 'DimB', undefined, [dimMapping('DimA', ['SubA', 'A3'])], { + // After resolve phase, this will be filled in with _b1,_b2,_b2 + _dima: ['_suba', '_a3'] + }) + ]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('SubA', ['A1', 'A2'], 'DimA'), + dim('DimB', ['B1', 'B2'], 'DimB', undefined, [dimMapping('DimA', ['SubA', 'A3'])], { + _dima: ['_b1', '_b1', '_b2'] + }), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with one mapping (to dimension without explicit subscripts)', () => { + const ranges = ` + DimA: SubA, A3 -> DimB ~~| + SubA: A1, A2 ~~| + DimB: B1, B2, B3 ~~| + ` + + const rawSubs = readInlineSubscripts(ranges) + expect(rawSubs).toEqual([ + dim('DimA', ['SubA', 'A3'], 'DimA', undefined, [dimMapping('DimB')], { + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimb: [] + }), + dim('SubA', ['A1', 'A2']), + dim('DimB', ['B1', 'B2', 'B3']) + ]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['SubA', 'A3'], 'DimA', ['A1', 'A2', 'A3'], [dimMapping('DimB', [])], { + _dimb: ['_a1', '_a2', '_a3'] + }), + dim('SubA', ['A1', 'A2'], 'DimA'), + dim('DimB', ['B1', 'B2', 'B3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range with two mappings', () => { + const ranges = ` + DimA: A1, A2, A3 -> (DimB: B3, B2, B1), DimC ~~| + DimB: B1, B2, B3 ~~| + DimC: C1, C2, C3 ~~| + ` + + const rawSubs = readInlineSubscripts(ranges) + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB', ['B3', 'B2', 'B1']), dimMapping('DimC')], { + // After resolve phase, this will be changed to _a3,_a2,_a1 + _dimb: ['_b3', '_b2', '_b1'], + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimc: [] + }), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']) + ]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB', ['B3', 'B2', 'B1']), dimMapping('DimC')], { + _dimb: ['_a3', '_a2', '_a1'], + _dimc: ['_a1', '_a2', '_a3'] + }), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2) + ]) + }) + + // TODO: This test is skipped until we update it to use the equivalent XMILE syntax + it.skip('should work for a subscript range alias (<-> operator)', () => { + const ranges = ` + DimA <-> DimB ~~| + DimB: B1, B2, B3 ~~| + ` + + const rawSubs = readInlineSubscripts(ranges) + // TODO: In "unresolved" dimensions, `value` is an empty string instead of an array + // (in the case of aliases). It would be good to fix it so that we don't need to mix types. + expect(rawSubs).toEqual([dim('DimA', '', 'DimB'), dim('DimB', ['B1', 'B2', 'B3'])]) + + const resolvedSubs = readAndResolveInlineSubscripts(ranges) + expect(resolvedSubs).toEqual([ + dim('DimA', ['B1', 'B2', 'B3']), + dim('DimB', ['B1', 'B2', 'B3'], 'DimA'), + sub('B1', 'DimA', 0), + sub('B2', 'DimA', 1), + sub('B3', 'DimA', 2) + ]) + }) + + // TODO: `GET DIRECT SUBSCRIPTS` is already covered by the "directsubs" test below. + // It would be nice if we had a simpler inline test here, but since it depends on + // reading files, it would end up doing the same as the "directsubs" test. Once + // we make it easier to provide in-memory (or mock) data sources, we can consider + // implementing this inline test. + it('should work for a subscript range defined with GET DIRECT SUBSCRIPTS', () => {}) + + it('should work for XMILE "active_initial" model', () => { + const rawSubs = readSubscripts('active_initial') + expect(rawSubs).toEqual([]) + + const resolvedSubs = readAndResolveSubscripts('active_initial') + expect(resolvedSubs).toEqual([]) + }) + + it('should work for XMILE "allocate" model', () => { + const rawSubs = readSubscripts('allocate') + expect(rawSubs).toEqual([ + dim('region', ['Boston', 'Dayton', 'Fresno']), + dim('XPriority', ['ptype', 'ppriority', 'pwidth', 'pextra']) + ]) + + const resolvedSubs = readAndResolveSubscripts('allocate') + expect(resolvedSubs).toEqual([ + dim('region', ['Boston', 'Dayton', 'Fresno']), + dim('XPriority', ['ptype', 'ppriority', 'pwidth', 'pextra']), + sub('Boston', 'Region', 0), + sub('Dayton', 'Region', 1), + sub('Fresno', 'Region', 2), + sub('ptype', 'XPriority', 0), + sub('ppriority', 'XPriority', 1), + sub('pwidth', 'XPriority', 2), + sub('pextra', 'XPriority', 3) + ]) + }) + + it('should work for XMILE "arrays" model', () => { + const rawSubs = readSubscripts('arrays') + expect(rawSubs).toEqual([ + // NOTE: XMILE does not have the concept of aliases/mappings, so the stmx file + // just uses {A1,A2,A3} + // dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB')], { + // // After resolve phase, this will be filled in with _a1,_a2,_a3 + // _dimb: [] + // }), + dim('DimA', ['A1', 'A2', 'A3']), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']), + // NOTE: XMILE does not have the concept of aliases/mappings, so the stmx file + // just uses {C1,C2,C3} + // // After resolve phase, DimC' will be expanded to individual subscripts, + // // and family will be changed from DimC' to DimC + // dim("DimC'", ['DimC'], "DimC'", ['DimC']), + dim("DimC'", ['C1', 'C2', 'C3']), + dim('DimD', ['D1', 'D2', 'D3', 'D4']), + // NOTE: XMILE does not seem to have the concept of putting a dimension name + // in another dimension definition, so the stmx file just uses {A2,A3,A1} + // // After resolve phase, DimX will be expanded to individual subscripts, + // // and family will be changed from DimX to DimA + // dim('DimX', ['SubA', 'A1'], 'DimX', ['SubA', 'A1']), + dim('DimX', ['A2', 'A3', 'A1']), + // After resolve phase, family will be changed from SubA to DimA + dim('SubA', ['A2', 'A3'], 'SubA') + ]) + + const resolvedSubs = readAndResolveSubscripts('arrays') + expect(resolvedSubs).toEqual([ + // NOTE: XMILE does not have the concept of aliases/mappings, so the stmx file + // just uses {A1,A2,A3} + // dim('DimA', ['A1', 'A2', 'A3'], 'DimA', undefined, [dimMapping('DimB')], { + // _dimb: ['_a1', '_a2', '_a3'] + // }), + dim('DimA', ['A1', 'A2', 'A3']), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']), + // NOTE: XMILE does not have the concept of aliases/mappings, so the stmx file + // just uses {C1,C2,C3} + dim("DimC'", ['C1', 'C2', 'C3'], 'DimC', ['C1', 'C2', 'C3']), + dim('DimD', ['D1', 'D2', 'D3', 'D4']), + // NOTE: XMILE does not seem to have the concept of putting a dimension name + // in another dimension definition, so the stmx file just uses {A2,A3,A1} + // dim('DimX', ['SubA', 'A1'], 'DimA', ['A2', 'A3', 'A1']), + dim('DimX', ['A2', 'A3', 'A1'], 'DimA', ['A2', 'A3', 'A1']), + dim('SubA', ['A2', 'A3'], 'DimA'), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2), + sub('D1', 'DimD', 0), + sub('D2', 'DimD', 1), + sub('D3', 'DimD', 2), + sub('D4', 'DimD', 3) + ]) + }) + + // TODO: This test is skipped because XMILE/Stella don't appear to have an equivalent function + it.skip('should work for XMILE "directconst" model', () => { + const rawSubs = readSubscripts('directconst') + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + // After resolve phase, family will be changed from SubA to DimA + dim('SubA', ['A2', 'A3'], 'SubA'), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2']), + // After resolve phase, "From DimC" will be expanded to individual subscripts, + // and family will be changed from "From DimC" to DimC + dim('From DimC', ['DimC'], 'From DimC', ['DimC']), + // After resolve phase, "To DimC" will be expanded to individual subscripts, + // and family will be changed from "To DimC" to DimC + dim('To DimC', ['DimC'], 'To DimC', ['DimC']), + dim('DimD', ['D1', 'D2']) + ]) + + const resolvedSubs = readAndResolveSubscripts('directconst') + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('SubA', ['A2', 'A3'], 'DimA'), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2']), + dim('From DimC', ['DimC'], 'DimC', ['C1', 'C2']), + dim('To DimC', ['DimC'], 'DimC', ['C1', 'C2']), + dim('DimD', ['D1', 'D2']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('D1', 'DimD', 0), + sub('D2', 'DimD', 1) + ]) + }) + + // TODO: This test is skipped because XMILE/Stella don't appear to have an equivalent function + it.skip('should work for XMILE "directdata" model', () => { + const rawSubs = readSubscripts('directdata') + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2']), + dim('DimB', ['B1', 'B2']), + // After resolve phase, DimC will be expanded to individual subscripts, + // and family will be changed from DimM to DimC + dim('DimC', '', 'DimM'), + // After resolve phase, family will be changed from DimM to DimC + dim('DimM', ['M1', 'M2', 'M3']), + // After resolve phase, family will be changed from SubM to DimC + dim('SubM', ['M2', 'M3'], 'SubM') + ]) + + const resolvedSubs = readAndResolveSubscripts('directdata') + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2']), + dim('DimB', ['B1', 'B2']), + dim('DimC', ['M1', 'M2', 'M3']), + dim('DimM', ['M1', 'M2', 'M3'], 'DimC'), + dim('SubM', ['M2', 'M3'], 'DimC'), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('M1', 'DimC', 0), + sub('M2', 'DimC', 1), + sub('M3', 'DimC', 2) + ]) + }) + + // TODO: This test is skipped because XMILE/Stella don't appear to have an equivalent function + it.skip('should work for XMILE "directsubs" model', () => { + const rawSubs = readSubscripts('directsubs') + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], undefined, undefined, [dimMapping('DimB'), dimMapping('DimC')], { + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimb: [], + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimc: [] + }), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']) + ]) + + const resolvedSubs = readAndResolveSubscripts('directsubs') + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3'], undefined, undefined, [dimMapping('DimB'), dimMapping('DimC')], { + _dimb: ['_a1', '_a2', '_a3'], + _dimc: ['_a1', '_a2', '_a3'] + }), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2) + ]) + }) + + // TODO: This test is skipped because we don't yet have an equivalent XMILE model + it.skip('should work for XMILE "mapping" model', () => { + const rawSubs = readSubscripts('mapping') + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('DimB', ['B1', 'B2'], undefined, undefined, [dimMapping('DimA', ['SubA', 'A3'])], { + // After resolve phase, this will be filled in with _b1,_b2,_b3 + _dima: ['_suba', '_a3'] + }), + // After resolve phase, DimC will be expanded to individual subscripts + dim('DimC', ['SubC', 'C3'], 'DimC', ['SubC', 'C3'], [dimMapping('DimD', [])], { + // After resolve phase, this will be filled in with _c1,_c2,_c3 + _dimd: [] + }), + dim('DimD', ['D1', 'D2', 'D3']), + // After resolve phase, family will be changed from SubA to DimA + dim('SubA', ['A1', 'A2'], 'SubA'), + // After resolve phase, family will be changed from SubC to DimC + dim('SubC', ['C1', 'C2'], 'SubC') + ]) + + const resolvedSubs = readAndResolveSubscripts('mapping') + expect(resolvedSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('DimB', ['B1', 'B2'], undefined, undefined, [dimMapping('DimA', ['SubA', 'A3'])], { + _dima: ['_b1', '_b1', '_b2'] + }), + dim('DimC', ['SubC', 'C3'], undefined, ['C1', 'C2', 'C3'], [dimMapping('DimD', [])], { + _dimd: ['_c1', '_c2', '_c3'] + }), + dim('DimD', ['D1', 'D2', 'D3']), + dim('SubA', ['A1', 'A2'], 'DimA'), + dim('SubC', ['C1', 'C2'], 'DimC'), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2), + sub('D1', 'DimD', 0), + sub('D2', 'DimD', 1), + sub('D3', 'DimD', 2) + ]) + }) + + // TODO: This test is skipped because we don't yet have an equivalent XMILE model + it.skip('should work for XMILE "multimap" model', () => { + const rawSubs = readSubscripts('multimap') + expect(rawSubs).toEqual([ + dim( + 'DimA', + ['A1', 'A2', 'A3'], + undefined, + undefined, + [dimMapping('DimB', ['B3', 'B2', 'B1']), dimMapping('DimC')], + { + // After resolve phase, these will be changed to _a3,_a2,_a1 + _dimb: ['_b3', '_b2', '_b1'], + // After resolve phase, this will be filled in with _a1,_a2,_a3 + _dimc: [] + } + ), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']) + ]) + + const resolvedSubs = readAndResolveSubscripts('multimap') + expect(resolvedSubs).toEqual([ + dim( + 'DimA', + ['A1', 'A2', 'A3'], + undefined, + undefined, + [dimMapping('DimB', ['B3', 'B2', 'B1']), dimMapping('DimC')], + { + _dimb: ['_a3', '_a2', '_a1'], + _dimc: ['_a1', '_a2', '_a3'] + } + ), + dim('DimB', ['B1', 'B2', 'B3']), + dim('DimC', ['C1', 'C2', 'C3']), + sub('A1', 'DimA', 0), + sub('A2', 'DimA', 1), + sub('A3', 'DimA', 2), + sub('B1', 'DimB', 0), + sub('B2', 'DimB', 1), + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2) + ]) + }) + + // TODO: This test is skipped because we don't yet have an equivalent XMILE model + it.skip('should work for XMILE "ref" model', () => { + const rawSubs = readSubscripts('ref') + expect(rawSubs).toEqual([ + dim('Target', ['t1', 't2', 't3']), + // After resolve phase, family will be changed from tNext to Target + dim('tNext', ['t2', 't3'], 'tNext', undefined, [dimMapping('tPrev')], { + // After resolve phase, this will be filled in with _t2,_t3 + _tprev: [] + }), + // After resolve phase, family will be changed from tPrev to Target + dim('tPrev', ['t1', 't2'], 'tPrev', undefined, [dimMapping('tNext')], { + // After resolve phase, this will be filled in with _t2,_t3 + _tnext: [] + }) + ]) + + const resolvedSubs = readAndResolveSubscripts('ref') + expect(resolvedSubs).toEqual([ + dim('Target', ['t1', 't2', 't3']), + dim('tNext', ['t2', 't3'], 'Target', undefined, [dimMapping('tPrev')], { + _tprev: ['_t2', '_t3'] + }), + dim('tPrev', ['t1', 't2'], 'Target', undefined, [dimMapping('tNext')], { + _tnext: ['_t1', '_t2'] + }), + sub('t1', 'Target', 0), + sub('t2', 'Target', 1), + sub('t3', 'Target', 2) + ]) + }) + + // TODO: This test is skipped because we don't yet have an equivalent XMILE model + it.skip('should work for XMILE "subalias" model', () => { + const rawSubs = readSubscripts('subalias') + expect(rawSubs).toEqual([ + // After resolve phase, DimE will be expanded to individual subscripts, + // and family will be changed from DimF to DimE + dim('DimE', '', 'DimF'), + // After resolve phase, family will be changed from DimF to DimE + dim('DimF', ['F1', 'F2', 'F3'], 'DimF') + ]) + + const resolvedSubs = readAndResolveSubscripts('subalias') + expect(resolvedSubs).toEqual([ + dim('DimE', ['F1', 'F2', 'F3']), + dim('DimF', ['F1', 'F2', 'F3'], 'DimE'), + sub('F1', 'DimE', 0), + sub('F2', 'DimE', 1), + sub('F3', 'DimE', 2) + ]) + }) + + // TODO: This test is skipped because we don't yet have an equivalent XMILE model + it.skip('should work for XMILE "subscript" model', () => { + const rawSubs = readSubscripts('subscript') + expect(rawSubs).toEqual([ + dim('DimA', ['A1', 'A2', 'A3']), + dim('DimB', ['B1', 'B2', 'B3'], undefined, undefined, [dimMapping('DimA')], { + _dima: [] + }), + dim('DimC', ['C1', 'C2', 'C3', 'C4', 'C5']), + dim('DimX', ['X1', 'X2', 'X3']), + dim('DimY', ['Y1', 'Y2', 'Y3']) + ]) + + const resolvedSubs = readAndResolveSubscripts('subscript') + // Note: The full pretty-printed objects are included as comments below to help + // show how they are expanded by each shorthand version (for reference purposes) + expect(resolvedSubs).toEqual([ + // { + // modelName: 'DimA', + // modelValue: ['A1', 'A2', 'A3'], + // modelMappings: [], + // name: '_dima', + // value: ['_a1', '_a2', '_a3'], + // size: 3, + // family: '_dima', + // mappings: {} + // }, + dim('DimA', ['A1', 'A2', 'A3']), + // { + // modelName: 'DimB', + // modelValue: ['B1', 'B2', 'B3'], + // modelMappings: [{ toDim: 'DimA', value: [] }], + // name: '_dimb', + // value: ['_b1', '_b2', '_b3'], + // size: 3, + // family: '_dimb', + // mappings: { + // _dima: ['_b1', '_b2', '_b3'] + // } + // }, + dim('DimB', ['B1', 'B2', 'B3'], undefined, undefined, [dimMapping('DimA')], { + _dima: ['_b1', '_b2', '_b3'] + }), + dim('DimC', ['C1', 'C2', 'C3', 'C4', 'C5']), + dim('DimX', ['X1', 'X2', 'X3']), + dim('DimY', ['Y1', 'Y2', 'Y3']), + // { name: '_a1', value: 0, size: 1, family: '_dima', mappings: {} }, + sub('A1', 'DimA', 0), + // { name: '_a2', value: 1, size: 1, family: '_dima', mappings: {} }, + sub('A2', 'DimA', 1), + // { name: '_a3', value: 2, size: 1, family: '_dima', mappings: {} }, + sub('A3', 'DimA', 2), + // { name: '_b1', value: 0, size: 1, family: '_dimb', mappings: {} }, + sub('B1', 'DimB', 0), + // { name: '_b2', value: 1, size: 1, family: '_dimb', mappings: {} }, + sub('B2', 'DimB', 1), + // { name: '_b3', value: 2, size: 1, family: '_dimb', mappings: {} } + sub('B3', 'DimB', 2), + sub('C1', 'DimC', 0), + sub('C2', 'DimC', 1), + sub('C3', 'DimC', 2), + sub('C4', 'DimC', 3), + sub('C5', 'DimC', 4), + sub('X1', 'DimX', 0), + sub('X2', 'DimX', 1), + sub('X3', 'DimX', 2), + sub('Y1', 'DimY', 0), + sub('Y2', 'DimY', 1), + sub('Y3', 'DimY', 2) + ]) + }) +}) diff --git a/packages/compile/src/parse-and-generate.js b/packages/compile/src/parse-and-generate.js index f6c978d8..aa007703 100644 --- a/packages/compile/src/parse-and-generate.js +++ b/packages/compile/src/parse-and-generate.js @@ -2,7 +2,7 @@ import path from 'path' -import { parseVensimModel } from '@sdeverywhere/parse' +import { parseVensimModel, parseXmileModel } from '@sdeverywhere/parse' import B from './_shared/bufx.js' import { readXlsx } from './_shared/helpers.js' @@ -14,7 +14,7 @@ import { getDirectSubscripts } from './model/read-subscripts.js' import { generateCode } from './generate/gen-code.js' /** - * Parse a Vensim model and generate C code. + * Parse a Vensim or XMILE model and generate C code. * * This is the primary entrypoint for the `sde generate` command. * @@ -26,7 +26,8 @@ import { generateCode } from './generate/gen-code.js' * - If `operations` has 'convertNames', no output will be generated, but the results of model * analysis will be available. * - * @param {string} input The preprocessed Vensim model text. + * @param {string} input The preprocessed Vensim or XMILE model text. + * @param {string} modelKind The kind of model to parse, either 'vensim' or 'xmile'. * @param {*} spec The model spec (from the JSON file). * @param {string[]} operations The set of operations to perform; can include 'generateC', 'generateJS', * 'printVarList', 'printRefIdTest', 'convertNames'. If the array is empty, the model will be @@ -38,7 +39,7 @@ import { generateCode } from './generate/gen-code.js' * @param {string} [varname] The variable name passed to the 'sde causes' command. * @return A string containing the generated C code. */ -export async function parseAndGenerate(input, spec, operations, modelDirname, modelName, buildDir, varname) { +export async function parseAndGenerate(input, modelKind, spec, operations, modelDirname, modelName, buildDir, varname) { // Read time series from external DAT files into a single object. // externalDatfiles is an array of either filenames or objects // giving a variable name prefix as the key and a filename as the value. @@ -69,7 +70,7 @@ export async function parseAndGenerate(input, spec, operations, modelDirname, mo } // Parse the model and generate code - let parsedModel = parseModel(input, modelDirname) + let parsedModel = parseModel(input, modelKind, modelDirname) let code = generateCode(parsedModel, { spec, operations, extData, directData, modelDirname, varname }) function writeOutput(filename, text) { @@ -132,33 +133,44 @@ export function printNames(namesPathname, operation) { * TODO: Fix return type * * @param {string} input The string containing the model text. + * @param {string} modelKind The kind of model to parse, either 'vensim' or 'xmile'. * @param {string} modelDir The absolute path to the directory containing the mdl file. * The dat, xlsx, and csv files referenced by the model will be relative to this directory. * @param {Object} [options] The options that control parsing. * @param {boolean} options.sort Whether to sort definitions alphabetically in the preprocess step. * @return {*} A parsed tree representation of the model. */ -export function parseModel(input, modelDir, options) { - // Prepare the parse context that provides access to external data files - let parseContext /*: VensimParseContext*/ - if (modelDir) { - parseContext = { - getDirectSubscripts(fileName, tabOrDelimiter, firstCell, lastCell /*, prefix*/) { - // Resolve the CSV file relative the model directory - const csvPath = path.resolve(modelDir, fileName) - - // Read the subscripts from the CSV file - return getDirectSubscripts(csvPath, tabOrDelimiter, firstCell, lastCell) +export function parseModel(input, modelKind, modelDir, options) { + if (modelKind === 'vensim') { + // Prepare the parse context that provides access to external data files + let parseContext /*: VensimParseContext*/ + if (modelDir) { + parseContext = { + getDirectSubscripts(fileName, tabOrDelimiter, firstCell, lastCell /*, prefix*/) { + // Resolve the CSV file relative the model directory + const csvPath = path.resolve(modelDir, fileName) + + // Read the subscripts from the CSV file + return getDirectSubscripts(csvPath, tabOrDelimiter, firstCell, lastCell) + } } } - } - // Parse the model - const sort = options?.sort === true - const root = parseVensimModel(input, parseContext, sort) + // Parse the Vensim model + const sort = options?.sort === true + const root = parseVensimModel(input, parseContext, sort) + + return { + kind: 'vensim', + root + } + } else { + // Parse the XMILE model + const root = parseXmileModel(input) - return { - kind: 'vensim', - root + return { + kind: 'xmile', + root + } } } diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index b80e3255..37519427 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -15,7 +15,7 @@ import { chooseInstallDeps } from './step-deps' import { chooseProjectDir } from './step-directory' import { chooseInstallEmsdk } from './step-emsdk' import { chooseGitInit } from './step-git' -import { chooseMdlFile } from './step-mdl' +import { chooseModelFile } from './step-model-file' import { chooseTemplate, copyTemplate } from './step-template' export async function main(): Promise { @@ -46,32 +46,32 @@ export async function main(): Promise { const template = await chooseTemplate(args) console.log() - // Prompt the user to select an mdl file - let mdlPath = await chooseMdlFile(projDir) - const mdlExisted = mdlPath !== undefined + // Prompt the user to select a model file + let modelPath = await chooseModelFile(projDir) + const modelExisted = modelPath !== undefined console.log() if (!args.dryRun) { // Copy the template files to the project directory - await copyTemplate(template, projDir, pkgManager, configDirExisted, mdlExisted) + await copyTemplate(template, projDir, pkgManager, configDirExisted, modelExisted) console.log() } - if (mdlPath === undefined) { - // There wasn't already an mdl file in the project directory, so we will use + if (modelPath === undefined) { + // There wasn't already a model file in the project directory, so we will use // the one supplied by the template. The template is expected to have a // `model/MODEL_NAME.mdl` file, which gets renamed to `model/sample.mdl` - // in the `copyTemplate` step. Note that `chooseMdlFile` returns a + // in the `copyTemplate` step. Note that `chooseModelFile` returns a // POSIX-style relative path, so we will also use a relative path here. - mdlPath = `model${posix.sep}sample.mdl` + modelPath = `model${posix.sep}sample.mdl` } // Prompt the user to select a code generation format const genFormat = await chooseCodeFormat() if (!args.dryRun) { - // Update the `sde.config.js` file to use the chosen mdl file - await updateSdeConfig(projDir, mdlPath, genFormat) + // Update the `sde.config.js` file to use the chosen model file + await updateSdeConfig(projDir, modelPath, genFormat) // Generate sample `checks.yaml` and `comparisons.yaml` files if needed const modelCheckFilesExist = @@ -90,11 +90,11 @@ export async function main(): Promise { ) console.log() } else { - // There wasn't already a `config` directory, but there was already an mdl file, + // There wasn't already a `config` directory, but there was already a model file, // so offer to set up CSV files const configDirExistsNow = existsSync(resolvePath(projDir, 'config')) - if (configDirExistsNow && mdlExisted && !args.dryRun) { - await chooseGenConfig(projDir, mdlPath) + if (configDirExistsNow && modelExisted && !args.dryRun) { + await chooseGenConfig(projDir, modelPath) console.log() } } diff --git a/packages/create/src/step-config.ts b/packages/create/src/step-config.ts index 2dae919d..adcf8bef 100644 --- a/packages/create/src/step-config.ts +++ b/packages/create/src/step-config.ts @@ -74,7 +74,7 @@ const sampleComparisonsContent = `\ at: 20 ` -export async function updateSdeConfig(projDir: string, mdlPath: string, genFormat: string): Promise { +export async function updateSdeConfig(projDir: string, modelPath: string, genFormat: string): Promise { // Read the `sde.config.js` file from the template const configPath = joinPath(projDir, 'sde.config.js') let configText = await readFile(configPath, 'utf8') @@ -82,8 +82,8 @@ export async function updateSdeConfig(projDir: string, mdlPath: string, genForma // Set the code generation format to the chosen format configText = configText.replace(`const genFormat = 'js'`, `const genFormat = '${genFormat}'`) - // Replace instances of `model/MODEL_NAME.mdl` with the path to the chosen mdl file - configText = configText.replaceAll('model/MODEL_NAME.mdl', mdlPath) + // Replace instances of `model/MODEL_NAME.mdl` with the path to the chosen model file + configText = configText.replaceAll('model/MODEL_NAME.mdl', modelPath) // Write the updated file await writeFile(configPath, configText) @@ -126,17 +126,17 @@ export async function generateSampleYamlFiles(projDir: string): Promise { await generateYaml(projDir, 'comparisons', sampleComparisonsContent) } -export async function chooseGenConfig(projDir: string, mdlPath: string): Promise { - // TODO: For now we eagerly read the mdl file; maybe change this to only load it if +export async function chooseGenConfig(projDir: string, modelPath: string): Promise { + // TODO: For now we eagerly read the model file; maybe change this to only load it if // the user chooses to generate graph and/or slider config let mdlVars: MdlVariable[] try { // Get the list of variables available in the model - mdlVars = await readModelVars(projDir, mdlPath) + mdlVars = await readModelVars(projDir, modelPath) } catch (e) { console.log(e) ora( - yellow('The mdl file failed to load. We will continue setting things up, and you can diagnose the issue later.') + yellow('The model file failed to load. We will continue setting things up, and you can diagnose the issue later.') ).warn() return } @@ -460,34 +460,36 @@ function escapeCsvField(s: string): string { return s.includes(',') ? `"${s}"` : s } -async function readModelVars(projDir: string, mdlPath: string): Promise { +async function readModelVars(projDir: string, modelPath: string): Promise { // TODO: This function contains a subset of the logic from `sde-generate.js` in // the `cli` package; should revisit - // let { modelDirname, modelName, modelPathname } = modelPathProps(model) // Ensure the `build` directory exists (under the `sde-prep` directory) const buildDir = resolvePath(projDir, 'sde-prep', 'build') await mkdir(buildDir, { recursive: true }) - // Use an empty model spec; this will make SDE look at all variables in the mdl + // Use an empty model spec; this will make SDE look at all variables in the model file const spec = {} - // Try parsing the mdl file to generate the list of variables + // Try parsing the model file to generate the list of variables // TODO: This depends on some `compile` package APIs that are not yet considered stable. // Ideally we'd use an API that does not write files but instead returns an in-memory // object in a specified format. // Read the model file - const mdlFile = resolvePath(projDir, mdlPath) - const mdlContent = await readFile(mdlFile, 'utf8') + const modelFile = resolvePath(projDir, modelPath) + const modelContent = await readFile(modelFile, 'utf8') + + // Determine the model kind based on the presence of an `` tag + const modelKind = modelContent.includes(' { - // Find all `.mdl` files in the project directory - // From https://stackoverflow.com/a/45130990 - async function getFiles(dir: string): Promise { - const dirents = await readdir(dir, { withFileTypes: true }) - const files = await Promise.all( - dirents.map(dirent => { - const res = resolvePath(dir, dirent.name) - return dirent.isDirectory() ? getFiles(res) : res - }) - ) - return files.flat() - } - const allFiles = await getFiles(projDir) - const mdlFiles = allFiles - // Only include files ending with '.mdl' - .filter(f => f.endsWith('.mdl')) - // Convert to a relative path with POSIX path separators (we want - // paths in the 'sde.config.js' file to use POSIX path style only, - // since that works on any OS including Windows) - .map(f => relative(projDir, f).replaceAll(sep, posix.sep)) - const mdlChoices = mdlFiles.map(f => { - return { - title: f, - value: f - } as Choice - }) - - let mdlFile: string - if (mdlFiles.length === 0) { - // No mdl files found; return undefined so that the mdl from the template is copied over - ora(yellow(`No mdl files were found in "${projDir}". The mdl file from the template will be used instead.`)).warn() - mdlFile = undefined - } else if (mdlFiles.length === 1) { - // Only one mdl file - mdlFile = mdlFiles[0] - ora().succeed(`Found "${mdlFile}", will configure the project to use that mdl file.`) - } else { - // Multiple mdl files found; allow the user to choose one - // TODO: Eventually we should allow the user to choose to flatten if there are multiple submodels - const options = await prompts( - [ - { - type: 'select', - name: 'mdlFile', - message: 'It looks like there are multiple mdl files. Which one would you like to use?', - choices: mdlChoices - } - ], - { - onCancel: () => { - ora().info(dim('Operation cancelled.')) - process.exit(0) - } - } - ) - mdlFile = options.mdlFile - ora(green(`Using "${bold(mdlFile)}" as the model for the project.`)).succeed() - } - - return mdlFile -} diff --git a/packages/create/src/step-model-file.ts b/packages/create/src/step-model-file.ts new file mode 100644 index 00000000..4deb6d41 --- /dev/null +++ b/packages/create/src/step-model-file.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import { readdir } from 'fs/promises' +import { relative, resolve as resolvePath, sep, posix, extname } from 'path' + +import { bold, dim, green, yellow } from 'kleur/colors' +import ora from 'ora' +import type { Choice } from 'prompts' +import prompts from 'prompts' + +// The set of supported model file extensions; this should match the set defined in the cli package +const supportedModelFileExtensions = new Set(['.mdl', '.xmile', '.stmx', '.itmx']) + +export async function chooseModelFile(projDir: string): Promise { + // Find all supported model files in the project directory + // From https://stackoverflow.com/a/45130990 + async function getFiles(dir: string): Promise { + const dirents = await readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + dirents.map(dirent => { + const res = resolvePath(dir, dirent.name) + return dirent.isDirectory() ? getFiles(res) : res + }) + ) + return files.flat() + } + const allFiles = await getFiles(projDir) + const modelFiles = allFiles + // Only include files that have a supported extension + .filter(f => { + const ext = extname(f).toLowerCase() + return supportedModelFileExtensions.has(ext) + }) + // Convert to a relative path with POSIX path separators (we want + // paths in the 'sde.config.js' file to use POSIX path style only, + // since that works on any OS including Windows) + .map(f => relative(projDir, f).replaceAll(sep, posix.sep)) + const modelChoices = modelFiles.map(f => { + return { + title: f, + value: f + } as Choice + }) + + let modelFile: string + if (modelFiles.length === 0) { + // No model files found; return undefined so that the model from the template is copied over + ora( + yellow(`No model files were found in "${projDir}". The model file from the template will be used instead.`) + ).warn() + modelFile = undefined + } else if (modelFiles.length === 1) { + // Only one model file + modelFile = modelFiles[0] + ora().succeed(`Found "${modelFile}", will configure the project to use that model file.`) + } else { + // Multiple model files found; allow the user to choose one + // TODO: Eventually we should allow the user to choose to flatten if there are multiple submodels + const options = await prompts( + [ + { + type: 'select', + name: 'modelFile', + message: 'It looks like there are multiple model files. Which one would you like to use?', + choices: modelChoices + } + ], + { + onCancel: () => { + ora().info(dim('Operation cancelled.')) + process.exit(0) + } + } + ) + modelFile = options.modelFile + ora(green(`Using "${bold(modelFile)}" as the model for the project.`)).succeed() + } + + return modelFile +} diff --git a/packages/create/src/step-template.ts b/packages/create/src/step-template.ts index 3be332a8..71143aff 100644 --- a/packages/create/src/step-template.ts +++ b/packages/create/src/step-template.ts @@ -101,7 +101,7 @@ export async function copyTemplate( projDir: string, pkgManager: string, configDirExisted: boolean, - mdlExisted: boolean + modelExisted: boolean ): Promise { // Show a spinner while copying the template files const templateSpinner = ora('Copying template files...').start() @@ -130,9 +130,9 @@ export async function copyTemplate( rmSync(joinPath(tmpDir, 'config'), { recursive: true, force: true }) } - if (mdlExisted) { - // There is already an mdl file in the project directory; remove the `model` - // directory (including the mdl file and the model-check yaml files) from the + if (modelExisted) { + // There is already a model file in the project directory; remove the `model` + // directory (including the model file and the model-check yaml files) from the // template so that we don't copy them into the project directory rmSync(joinPath(tmpDir, 'model'), { recursive: true, force: true }) } @@ -143,10 +143,13 @@ export async function copyTemplate( errorOnExist: false }) - if (!mdlExisted) { - // There wasn't already an mdl file in the project directory, so we will use + if (!modelExisted) { + // There wasn't already a model file in the project directory, so we will use // the one supplied by the template. Rename it from `MODEL_NAME.mdl` to // `sample.mdl`. + // TODO: For now we assume that all templates include a sample model in Vensim + // format. This will need to be updated if we add templates that include a + // sample model in a different format. renameSync(joinPath(projDir, 'model', 'MODEL_NAME.mdl'), joinPath(projDir, 'model', 'sample.mdl')) } diff --git a/packages/create/tests/step-config.spec.ts b/packages/create/tests/step-config.spec.ts index 2d2451dd..954b2a32 100644 --- a/packages/create/tests/step-config.spec.ts +++ b/packages/create/tests/step-config.spec.ts @@ -60,77 +60,57 @@ async function respondAndWaitForPrompt( }) } -describe('step - read model variables and create config files', () => { - it('should read model variables and suggest input/output variables to include', async () => { - // Create a scratch directory - const scratchDir = resolvePath(testsDir, dirs.scratch) - if (existsSync(scratchDir)) { - rmSync(scratchDir, { recursive: true, force: true }) - } - mkdirSync(scratchDir) - - // Add a sample model file to the scratch directory - const sampleMdlContent = `\ -{UTF-8} - -X = TIME - ~~| - -Y = 0 - ~ [-10,10,0.1] - ~ - | +async function runAndVerify(sampleModelContent: string, sampleModelExt: string) { + // Create a scratch directory + const scratchDir = resolvePath(testsDir, dirs.scratch) + if (existsSync(scratchDir)) { + rmSync(scratchDir, { recursive: true, force: true }) + } + mkdirSync(scratchDir) -Z = X + Y - ~~| + // Add a sample model file to the scratch directory + writeFileSync(resolvePath(scratchDir, `sample.${sampleModelExt}`), sampleModelContent) -INITIAL TIME = 2000 ~~| -FINAL TIME = 2100 ~~| -TIME STEP = 1 ~~| -SAVEPER = TIME STEP ~~| -` - writeFileSync(resolvePath(scratchDir, 'sample.mdl'), sampleMdlContent) + // Run the create command + const { stdin, stdout } = runCreate([dirs.scratch]) - // Run the create command - const { stdin, stdout } = runCreate([dirs.scratch]) + // Wait for the template prompt + await respondAndWaitForPrompt(stdin!, stdout!, undefined, promptMessages.template) - // Wait for the template prompt - await respondAndWaitForPrompt(stdin!, stdout!, undefined, promptMessages.template) + // Press enter to accept the default template then wait for the wasm prompt + await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.wasm) - // Press enter to accept the default template then wait for the wasm prompt - await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.wasm) + // Press enter to accept the default project kind (JS) then wait for the configure graph prompt + await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.configGraph) - // Press enter to accept the default project kind (JS) then wait for the configure graph prompt - await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.configGraph) + // Press enter to accept the default choice (yes, configure a graph) + await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.chooseOutputs) - // Press enter to accept the default choice (yes, configure a graph) - await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.chooseOutputs) + // Select the two suggested variables, press enter to accept, then wait for the configure sliders prompt + await respondAndWaitForPrompt( + stdin!, + stdout!, + `${keyCodes.space}${keyCodes.down}${keyCodes.space}${keyCodes.enter}`, + promptMessages.configSliders + ) - // Select the two suggested variables, press enter to accept, then wait for the configure sliders prompt - await respondAndWaitForPrompt( - stdin!, - stdout!, - `${keyCodes.space}${keyCodes.down}${keyCodes.space}${keyCodes.enter}`, - promptMessages.configSliders - ) + // Press enter to accept the default choice (yes, configure sliders) + await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.chooseInputs) - // Press enter to accept the default choice (yes, configure sliders) - await respondAndWaitForPrompt(stdin!, stdout!, keyCodes.enter, promptMessages.chooseInputs) + // Select the one suggested variable, press enter to accept, then wait for the install dependencies prompt + await respondAndWaitForPrompt(stdin!, stdout!, `${keyCodes.space}${keyCodes.enter}`, promptMessages.deps) - // Select the one suggested variable, press enter to accept, then wait for the install dependencies prompt - await respondAndWaitForPrompt(stdin!, stdout!, `${keyCodes.space}${keyCodes.enter}`, promptMessages.deps) + // Enter "n" to skip installing dependencies + await respondAndWaitForPrompt(stdin!, stdout!, `n${keyCodes.enter}`, promptMessages.git) - // Enter "n" to skip installing dependencies - await respondAndWaitForPrompt(stdin!, stdout!, `n${keyCodes.enter}`, promptMessages.git) - - // Enter "n" to skip initializing a git repository - const msg = `\ + // Enter "n" to skip initializing a git repository + const msg = `\ You can now cd into the fixtures/scratch-dir project directory. Run pnpm dev to start the local dev server. CTRL-C to close.` - await respondAndWaitForPrompt(stdin!, stdout!, `n${keyCodes.enter}`, msg) + await respondAndWaitForPrompt(stdin!, stdout!, `n${keyCodes.enter}`, msg) - // Verify the generated `config/colors.csv` file - const expectedColors = `\ + // Verify the generated `config/colors.csv` file + const expectedColors = `\ id,hex code,name,comment blue,#0072b2,, red,#d33700,, @@ -138,38 +118,92 @@ green,#53bb37,, gray,#a7a9ac,, black,#000000,, ` - const actualColors = readFileSync(resolvePath(scratchDir, 'config', 'colors.csv'), 'utf8') - expect(actualColors).toEqual(expectedColors) + const actualColors = readFileSync(resolvePath(scratchDir, 'config', 'colors.csv'), 'utf8') + expect(actualColors).toEqual(expectedColors) - // Verify the generated `config/graphs.csv` file - const expectedGraphs = `\ + // Verify the generated `config/graphs.csv` file + const expectedGraphs = `\ 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 1,,Graphs,Graph Title,,,,line,,,,,,,,,,,,,,,,,,,X,,line,X,blue,,,Z,,line,Z,red,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, ` - const actualGraphs = readFileSync(resolvePath(scratchDir, 'config', 'graphs.csv'), 'utf8') - expect(actualGraphs).toEqual(expectedGraphs) + const actualGraphs = readFileSync(resolvePath(scratchDir, 'config', 'graphs.csv'), 'utf8') + expect(actualGraphs).toEqual(expectedGraphs) - // Verify the generated `config/inputs.csv` file - const expectedInputs = `\ + // Verify the generated `config/inputs.csv` file + const expectedInputs = `\ 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,view1,Y,Y,,Sliders,-1,1,0,0.1,(units),,,,,,,,,,,,,,,, ` - const actualInputs = readFileSync(resolvePath(scratchDir, 'config', 'inputs.csv'), 'utf8') - expect(actualInputs).toEqual(expectedInputs) + const actualInputs = readFileSync(resolvePath(scratchDir, 'config', 'inputs.csv'), 'utf8') + expect(actualInputs).toEqual(expectedInputs) - // Verify the generated `config/outputs.csv` file - const expectedOutputs = `\ + // Verify the generated `config/outputs.csv` file + const expectedOutputs = `\ variable name ` - const actualOutputs = readFileSync(resolvePath(scratchDir, 'config', 'outputs.csv'), 'utf8') - expect(actualOutputs).toEqual(expectedOutputs) + const actualOutputs = readFileSync(resolvePath(scratchDir, 'config', 'outputs.csv'), 'utf8') + expect(actualOutputs).toEqual(expectedOutputs) - // Verify the generated `config/strings.csv` file - const expectedStrings = `\ + // Verify the generated `config/strings.csv` file + const expectedStrings = `\ id,string __model_name,My Model ` - const actualStrings = readFileSync(resolvePath(scratchDir, 'config', 'strings.csv'), 'utf8') - expect(actualStrings).toEqual(expectedStrings) + const actualStrings = readFileSync(resolvePath(scratchDir, 'config', 'strings.csv'), 'utf8') + expect(actualStrings).toEqual(expectedStrings) +} + +describe('step - read model variables and create config files', () => { + it('should read model variables from a Vensim model and suggest input/output variables to include', async () => { + const sampleModelContent = `\ +{UTF-8} + +X = TIME + ~~| + +Y = 0 + ~ [-10,10,0.1] + ~ + | + +Z = X + Y + ~~| + +INITIAL TIME = 2000 ~~| +FINAL TIME = 2100 ~~| +TIME STEP = 1 ~~| +SAVEPER = TIME STEP ~~| +` + await runAndVerify(sampleModelContent, 'mdl') + }) + + it('should read model variables from a Stella/XMILE model and suggest input/output variables to include', async () => { + const sampleModelContent = `\ + +
+ + Ventana Systems, xmutil + Vensim, xmutil +
+ + 2000 + 2100 +
1
+
+ + + + TIME + + + 0 + + + X + Y + + + +` + await runAndVerify(sampleModelContent, 'stmx') }) }) diff --git a/packages/parse/package.json b/packages/parse/package.json index 1182159a..27e0444f 100644 --- a/packages/parse/package.json +++ b/packages/parse/package.json @@ -31,6 +31,7 @@ "ci:build": "run-s clean lint prettier:check type-check test:ci build docs" }, "dependencies": { + "@rgrove/parse-xml": "^4.1.0", "antlr4": "4.12.0", "antlr4-vensim": "0.6.3", "assert-never": "^1.2.1", diff --git a/packages/parse/src/ast/ast-builders.ts b/packages/parse/src/ast/ast-builders.ts index c56f97ba..f45b85cd 100644 --- a/packages/parse/src/ast/ast-builders.ts +++ b/packages/parse/src/ast/ast-builders.ts @@ -20,6 +20,7 @@ import type { Model, NumberLiteral, ParensExpr, + SimulationSpec, StringLiteral, SubName, SubscriptMapping, @@ -244,8 +245,9 @@ export function lookupVarEqn(varDef: VariableDef, lookupDef: LookupDef, units = // MODEL // -export function model(dimensions: DimensionDef[], equations: Equation[]): Model { +export function model(dimensions: DimensionDef[], equations: Equation[], simulationSpec?: SimulationSpec): Model { return { + simulationSpec, dimensions, equations } diff --git a/packages/parse/src/ast/ast-types.ts b/packages/parse/src/ast/ast-types.ts index 87332d90..ef0de353 100644 --- a/packages/parse/src/ast/ast-types.ts +++ b/packages/parse/src/ast/ast-types.ts @@ -1,5 +1,19 @@ // Copyright (c) 2023 Climate Interactive / New Venture Fund +// +// SIMULATION SPEC +// + +/** The simulation parameters, such as start time, end time, and time step. */ +export interface SimulationSpec { + /** The start time of the simulation. */ + startTime: number + /** The end time of the simulation. */ + endTime: number + /** The time step of the simulation. */ + timeStep: number +} + // // DIMENSIONS + SUBSCRIPTS // @@ -391,6 +405,13 @@ export interface Equation { /** A complete model definition, including all defined dimensions and equations. */ export interface Model { + /** + * The simulation parameters, such as start time, end time, and time step. + * + * NOTE: This will be defined for XMILE models, but may be undefined for Vensim models + * for which the parameters are not compile-time constants. + */ + simulationSpec?: SimulationSpec /** The array of all dimension definitions in the model. */ dimensions: DimensionDef[] /** The array of all variable/equation definitions in the model. */ diff --git a/packages/parse/src/index.ts b/packages/parse/src/index.ts index 8330a1c4..89978601 100644 --- a/packages/parse/src/index.ts +++ b/packages/parse/src/index.ts @@ -12,3 +12,7 @@ export * from './vensim/parse-vensim-equation' export * from './vensim/parse-vensim-model' export * from './vensim/preprocess-vensim' export * from './vensim/vensim-parse-context' + +export * from './xmile/parse-xmile-dimension-def' +export * from './xmile/parse-xmile-model' +export * from './xmile/parse-xmile-variable-def' diff --git a/packages/parse/src/xmile/parse-xmile-dimension-def.spec.ts b/packages/parse/src/xmile/parse-xmile-dimension-def.spec.ts new file mode 100644 index 00000000..026b3629 --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-dimension-def.spec.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import { type XmlElement, parseXml } from '@rgrove/parse-xml' + +import { dimDef } from '../ast/ast-builders' + +import { parseXmileDimensionDef } from './parse-xmile-dimension-def' + +function xml(input: string): XmlElement { + let xml + try { + xml = parseXml(input) + } catch (e) { + throw new Error(`Invalid XML:\n${input}\n\n${e}`) + } + return xml.root +} + +describe('parseXmileDimensionDef', () => { + it('should parse a dimension def with explicit subscripts', () => { + const dim = xml(` + + + + + + `) + expect(parseXmileDimensionDef(dim)).toEqual(dimDef('DimA', 'DimA', ['A1', 'A2', 'A3'])) + }) + + it('should throw an error if dimension name is not defined', () => { + const dim = xml(` + + + + + + `) + expect(() => parseXmileDimensionDef(dim)).toThrow(' name attribute is required for dimension definition') + }) + + it('should throw an error if dimension element name is not defined', () => { + const dim = xml(` + + + + + + `) + expect(() => parseXmileDimensionDef(dim)).toThrow( + ' name attribute is required for dimension element definition' + ) + }) + + it('should throw an error if no dimension elements are defined', () => { + const dim = xml(` + + + `) + expect(() => parseXmileDimensionDef(dim)).toThrow(' must contain one or more elements') + }) +}) diff --git a/packages/parse/src/xmile/parse-xmile-dimension-def.ts b/packages/parse/src/xmile/parse-xmile-dimension-def.ts new file mode 100644 index 00000000..79728ef5 --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-dimension-def.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import type { XmlElement } from '@rgrove/parse-xml' + +import { canonicalId } from '../_shared/canonical-id' + +import type { DimensionDef, SubscriptRef } from '../ast/ast-types' + +import { elemsOf, firstElemOf, xmlError } from './xml' + +/** + * Parse the given XMILE dimension (``) definition and return a `DimensionDef` AST node. + * + * @param input A string containing the XMILE `` definition. + * @returns A `DimensionDef` AST node. + */ +export function parseXmileDimensionDef(dimElem: XmlElement): DimensionDef { + // Extract + const dimName = dimElem.attributes?.name + if (dimName === undefined) { + throw new Error(xmlError(dimElem, ' name attribute is required for dimension definition')) + } + + // Extract child elements + const elemElems = elemsOf(dimElem, ['elem']) + if (elemElems.length === 0) { + throw new Error(xmlError(dimElem, ' must contain one or more elements')) + } + const subscriptRefs: SubscriptRef[] = [] + for (const elem of elemElems) { + // Extract + const subName = elem.attributes?.name + if (subName === undefined) { + throw new Error(xmlError(dimElem, ' name attribute is required for dimension element definition')) + } + + const subId = canonicalId(subName) + subscriptRefs.push({ + subId, + subName + }) + } + + // Extract -> comment string + const comment = firstElemOf(dimElem, 'doc')?.text || '' + + const dimId = canonicalId(dimName) + return { + dimName, + dimId, + // TODO: For Vensim `DimA <-> DimB` aliases, the family name would be `DimB` + familyName: dimName, + familyId: dimId, + subscriptRefs, + // TODO: Does XMILE support mappings? + subscriptMappings: [], + comment + } +} diff --git a/packages/parse/src/xmile/parse-xmile-model.spec.ts b/packages/parse/src/xmile/parse-xmile-model.spec.ts new file mode 100644 index 00000000..7db6d2ac --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-model.spec.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import { dimDef, exprEqn, model, num, varDef } from '../ast/ast-builders' + +import { parseXmileModel } from './parse-xmile-model' + +function xmile(dimensions: string, variables: string, options?: { simSpecs?: string }): string { + let dims: string + if (dimensions.length > 0) { + dims = `\ + + ${dimensions} + ` + } else { + dims = '' + } + + let vars: string + if (variables.length > 0) { + vars = `\ + + ${variables} + ` + } else { + vars = '' + } + + let simSpecs: string + if (options?.simSpecs !== undefined) { + simSpecs = options.simSpecs + } else { + simSpecs = `\ + + 0 + 100 +
1
+
` + } + + return `\ + +
+ + Ventana Systems, xmutil + Vensim, xmutil +
+${simSpecs} +${dims} + + ${vars} + +
` +} + +describe('parseXmileModel', () => { + it('should throw an error if model XML cannot be parsed', () => { + const mdl = 'NOT XMILE' + + let msg = 'Failed to parse XMILE model definition:\n\n' + msg += 'Root element is missing or invalid (line 1, column 1)\n' + msg += ' NOT XMILE\n' + msg += ' ^' + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + // TODO: Verify error message + it.skip('should throw an error if model dimension definition cannot be parsed') + + it('should throw an error if model equation cannot be parsed', () => { + const vars = `\ + + x + ?! + y +` + const mdl = xmile('', vars) + + const msg = `\ +Failed to parse XMILE variable definition at line 15, col 4: + + x + ?! + y + + +Detail: + token recognition error at: '?'` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if sim specs element is missing', () => { + const simSpecs = '' + const mdl = xmile('', '', { simSpecs }) + const msg = ' element is required for XMILE model definition' + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if element is missing', () => { + const simSpecs = `\ + + 100 +
1
+
` + const mdl = xmile('', '', { simSpecs }) + const msg = `\ +Failed to parse XMILE model definition at line 7: + + 100 +
1
+
+ +Detail: + element is required in XMILE sim specs` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if element is not a number', () => { + const simSpecs = `\ + + NOT A NUMBER + 100 +
1
+
` + const mdl = xmile('', '', { simSpecs }) + const msg = `\ +Failed to parse XMILE model definition at line 7: + + NOT A NUMBER + 100 +
1
+
+ +Detail: + Invalid numeric value for element: NOT A NUMBER` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if element is missing', () => { + const simSpecs = `\ + + 0 +
1
+
` + const mdl = xmile('', '', { simSpecs }) + const msg = `\ +Failed to parse XMILE model definition at line 7: + + 0 +
1
+
+ +Detail: + element is required in XMILE sim specs` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if element is not a number', () => { + const simSpecs = `\ + + 0 + NOT A NUMBER +
1
+
` + const mdl = xmile('', '', { simSpecs }) + const msg = `\ +Failed to parse XMILE model definition at line 7: + + 0 + NOT A NUMBER +
1
+
+ +Detail: + Invalid numeric value for element: NOT A NUMBER` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should throw an error if
element is not a number', () => { + const simSpecs = `\ + + 0 + 100 +
NOT A NUMBER
+
` + const mdl = xmile('', '', { simSpecs }) + const msg = `\ +Failed to parse XMILE model definition at line 7: + + 0 + 100 +
NOT A NUMBER
+
+ +Detail: + Invalid numeric value for
element: NOT A NUMBER` + expect(() => parseXmileModel(mdl)).toThrow(msg) + }) + + it('should parse a model with dimension definition only (no equations)', () => { + const dims = `\ + + + + +` + const mdl = xmile(dims, '') + expect(parseXmileModel(mdl)).toEqual( + model([dimDef('DimA', 'DimA', ['A1', 'A2', 'A3'])], [], { + startTime: 0, + endTime: 100, + timeStep: 1 + }) + ) + }) + + it('should parse a model with dimension definition with comment', () => { + const dims = `\ + + comment is here + + + +` + const mdl = xmile(dims, '') + expect(parseXmileModel(mdl)).toEqual( + model([dimDef('DimA', 'DimA', ['A1', 'A2', 'A3'], undefined, 'comment is here')], [], { + startTime: 0, + endTime: 100, + timeStep: 1 + }) + ) + }) + + it('should parse a model with equation only (no dimension definitions)', () => { + const vars = `\ + + 1 +` + const mdl = xmile('', vars) + expect(parseXmileModel(mdl)).toEqual( + model([], [exprEqn(varDef('x'), num(1))], { + startTime: 0, + endTime: 100, + timeStep: 1 + }) + ) + }) + + it('should parse a model with no explicit dt value (defaults to 1)', () => { + const vars = `\ + + 1 +` + const mdl = xmile('', vars, { + simSpecs: `\ + + 2000 + 2100 +` + }) + expect(parseXmileModel(mdl)).toEqual( + model([], [exprEqn(varDef('x'), num(1))], { + startTime: 2000, + endTime: 2100, + timeStep: 1 + }) + ) + }) + + it('should parse a model with equation with units and single-line comment', () => { + const dims = `\ + + comment is here + + + +` + const vars = `\ + + + + + comment is here + meters + 1 +` + + const mdl = xmile(dims, vars) + expect(parseXmileModel(mdl)).toEqual( + model( + // dimension definitions + [dimDef('DimA', 'DimA', ['A1', 'A2', 'A3'], undefined, 'comment is here')], + + // variable definitions + [exprEqn(varDef('x', ['DimA']), num(1), 'meters', 'comment is here')], + + // simulation spec + { + startTime: 0, + endTime: 100, + timeStep: 1 + } + ) + ) + }) + + // it('should parse a model with equation with units and multi-line comment', () => { + // const mdl = ` + // x = 1 + // ~ watt/(meter*meter) + // ~ Something, Chapter 6. More things. p.358. More words \\ + // continued on next line. + // | + // ` + // expect(parseVensimModel(mdl)).toEqual( + // model( + // [], + // [ + // exprEqn( + // varDef('x'), + // num(1), + // 'watt/(meter*meter)', + // 'Something, Chapter 6. More things. p.358. More words continued on next line.' + // ) + // ] + // ) + // ) + // }) +}) diff --git a/packages/parse/src/xmile/parse-xmile-model.ts b/packages/parse/src/xmile/parse-xmile-model.ts new file mode 100644 index 00000000..32d22256 --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-model.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import type { XmlElement } from '@rgrove/parse-xml' +import { parseXml } from '@rgrove/parse-xml' + +import type { DimensionDef, Equation, Model, SimulationSpec } from '../ast/ast-types' + +import { parseXmileDimensionDef } from './parse-xmile-dimension-def' +import { parseXmileVariableDef } from './parse-xmile-variable-def' +import { firstElemOf, elemsOf, xmlError } from './xml' + +/** + * Parse the given XMILE model definition and return a `Model` AST node. + * + * @param input A string containing the XMILE model. + * @param context An object that provides access to file system resources (such as + * external data files) that are referenced during the parse phase. + * @returns A `Model` AST node. + */ +export function parseXmileModel(input: string): Model { + let xml + try { + // Enable position tracking to get byte offsets for line number calculation + xml = parseXml(input, { includeOffsets: true }) + } catch (e) { + // Include context such as line/column numbers in the error message if available + const msg = `Failed to parse XMILE model definition:\n\n${e.message}` + throw new Error(msg) + } + + // Extract -> SimulationSpec + const simulationSpec: SimulationSpec = parseSimSpecs(xml.root, input) + + // Extract -> DimensionDef[] + const dimensions: DimensionDef[] = parseDimensionDefs(xml.root, input) + + // Extract -> Equation[] + const equations: Equation[] = parseVariableDefs(xml.root, input) + + return { + simulationSpec, + dimensions, + equations + } +} + +function parseSimSpecs(rootElem: XmlElement | undefined, originalXml: string): SimulationSpec { + // Extract element + const simSpecsElem = firstElemOf(rootElem, 'sim_specs') + if (simSpecsElem === undefined) { + throw new Error(xmlError(rootElem, ' element is required for XMILE model definition')) + } + + function getSimSpecValue(name: string, required: boolean): number | undefined { + const elem = firstElemOf(simSpecsElem, name) + if (required && elem === undefined) { + const error = new Error(xmlError(simSpecsElem, `<${name}> element is required in XMILE sim specs`)) + throwXmileParseError(error, originalXml, simSpecsElem, 'model') + } + if (elem === undefined) { + return undefined + } + const value = Number(elem.text) + if (!isNaN(value)) { + return value + } else { + const error = new Error(xmlError(elem, `Invalid numeric value for <${name}> element: ${elem.text}`)) + throwXmileParseError(error, originalXml, simSpecsElem, 'model') + } + } + + // Extract element + const startTime = getSimSpecValue('start', true) + + // Extract element + const endTime = getSimSpecValue('stop', true) + + // Extract
element + let timeStep = getSimSpecValue('dt', false) + if (timeStep === undefined) { + // The default `dt` value is 1 according to the XMILE spec + timeStep = 1 + } + + return { + startTime, + endTime, + timeStep + } +} + +function parseDimensionDefs(rootElem: XmlElement | undefined, originalXml: string): DimensionDef[] { + const dimensionDefs: DimensionDef[] = [] + + const dimensionsElem = firstElemOf(rootElem, 'dimensions') + if (dimensionsElem) { + // Extract -> SubscriptRange + const dimElems = elemsOf(dimensionsElem, ['dim']) + for (const dimElem of dimElems) { + try { + dimensionDefs.push(parseXmileDimensionDef(dimElem)) + } catch (e) { + throwXmileParseError(e, originalXml, dimElem, 'dimension') + } + } + } + + return dimensionDefs +} + +function parseVariableDefs(rootElem: XmlElement | undefined, originalXml: string): Equation[] { + const modelElem = firstElemOf(rootElem, 'model') + if (modelElem === undefined) { + return [] + } + + const equations: Equation[] = [] + const variablesElem = firstElemOf(modelElem, 'variables') + if (variablesElem) { + // Extract variable definition (e.g., , , , ) -> Equation[] + const varElems = elemsOf(variablesElem, ['aux', 'stock', 'flow', 'gf']) + for (const varElem of varElems) { + try { + const eqns = parseXmileVariableDef(varElem) + if (eqns) { + equations.push(...eqns) + } + } catch (e) { + throwXmileParseError(e, originalXml, varElem, 'variable') + } + } + } + + return equations +} + +function throwXmileParseError( + originalError: Error, + originalXml: string, + elem: XmlElement, + elemKind: 'model' | 'dimension' | 'variable' +): void { + // Include context such as line/column numbers in the error message if available + let linePart = '' + // Try to get line number from the XML element's position + const lineNumInOriginalXml = getLineNumber(originalXml, elem.start) + if (lineNumInOriginalXml !== -1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cause = (originalError as any).cause as { code?: string; line?: number; column?: number } + if (cause?.code === 'VensimParseError') { + if (cause.line) { + // The line number reported by ANTLR is relative to the beginning of the + // preprocessed definition (since we parse each definition individually), + // so we need to add it to the line of the definition in the original source + const lineNum = cause.line - 1 + lineNumInOriginalXml + linePart += ` at line ${lineNum}` + if (cause.column) { + linePart += `, col ${cause.column}` + } + } + } else { + // Include the line number from the original XML + linePart += ` at line ${lineNumInOriginalXml}` + } + } + const elemString = extractXmlLines(originalXml, elem.start, elem.end) + const msg = `Failed to parse XMILE ${elemKind} definition${linePart}:\n${elemString}\n\nDetail:\n ${originalError.message}` + throw new Error(msg) +} + +/** + * Calculate the line number from a byte offset in the original XML string. + * + * @param xmlString The original XML string + * @param byteOffset The byte offset from the XmlElement + * @returns The line number (1-indexed) or -1 if offset is invalid + */ +function getLineNumber(xmlString: string, byteOffset: number): number { + if (byteOffset === -1 || byteOffset >= xmlString.length) { + return -1 + } + + // Count newlines up to the byte offset + const substring = xmlString.substring(0, byteOffset) + return substring.split('\n').length +} + +/** + * Extract relevant lines from the original XML string using start/end byte offsets. + * Includes full lines for context, even if start/end are not at line boundaries. + * + * @param originalXml The original XML string + * @param startOffset The starting byte offset + * @param endOffset The ending byte offset + * @returns A string containing the relevant lines with line numbers + */ +function extractXmlLines(originalXml: string, startOffset: number, endOffset: number): string { + if (startOffset === -1 || endOffset === -1 || startOffset >= originalXml.length || endOffset > originalXml.length) { + return '[Unable to extract XML lines - invalid offsets]' + } + + // Find the start of the line containing the start offset + let lineStart = startOffset + while (lineStart > 0 && originalXml[lineStart - 1] !== '\n') { + lineStart-- + } + + // Find the end of the line containing the end offset + let lineEnd = endOffset + while (lineEnd < originalXml.length && originalXml[lineEnd] !== '\n') { + lineEnd++ + } + + // Extract the lines + const relevantXml = originalXml.substring(lineStart, lineEnd) + + // Return just the XML content without line number prefix + return relevantXml +} diff --git a/packages/parse/src/xmile/parse-xmile-variable-def.spec.ts b/packages/parse/src/xmile/parse-xmile-variable-def.spec.ts new file mode 100644 index 00000000..783c02c6 --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-variable-def.spec.ts @@ -0,0 +1,1059 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import type { XmlElement } from '@rgrove/parse-xml' +import { parseXml } from '@rgrove/parse-xml' + +import { + binaryOp, + call, + exprEqn, + lookupDef, + lookupVarEqn, + num, + parens, + unaryOp, + varDef, + varRef +} from '../ast/ast-builders' + +import { parseXmileVariableDef } from './parse-xmile-variable-def' + +function xml(input: string): XmlElement { + let xml + try { + xml = parseXml(input) + } catch (e) { + throw new Error(`Invalid XML:\n${input}\n\n${e}`) + } + return xml.root +} + +describe('parseXmileVariableDef with ', () => { + it('should parse a stock variable definition (without subscripts, single inflow)', () => { + const v = xml(` + + y + 10 + z * 2 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x'), call('INTEG', binaryOp(varRef('z'), '*', num(2)), binaryOp(varRef('y'), '+', num(10)))) + ]) + }) + + it('should parse a stock variable definition (with one dimension, apply-to-all, single inflow)', () => { + const v = xml(` + + + + + y[DimA] + 10 + z * 2 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x', ['DimA']), + call('INTEG', binaryOp(varRef('z'), '*', num(2)), binaryOp(varRef('y', ['DimA']), '+', num(10))) + ) + ]) + }) + + it('should parse a stock variable definition (with one dimension, non-apply-to-all, single inflow)', () => { + const v = xml(` + + + + + + y[A1] + 10 + z * 2 + + + y[A2] + 20 + z * 3 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x', ['A1']), + call('INTEG', binaryOp(varRef('z'), '*', num(2)), binaryOp(varRef('y', ['A1']), '+', num(10))) + ), + exprEqn( + varDef('x', ['A2']), + call('INTEG', binaryOp(varRef('z'), '*', num(3)), binaryOp(varRef('y', ['A2']), '+', num(20))) + ) + ]) + }) + + it('should parse a stock variable definition (without subscripts, multiple inflows, no outflows)', () => { + const v = xml(` + + y + 10 + a + b + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + // INTEG(a + b, y + 10) + call('INTEG', binaryOp(varRef('a'), '+', varRef('b')), binaryOp(varRef('y'), '+', num(10))) + ) + ]) + }) + + it('should parse a stock variable definition (without subscripts, no inflows, multiple outflows)', () => { + const v = xml(` + + y + 10 + a + b + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + // INTEG(-a - b, y + 10) + call('INTEG', binaryOp(unaryOp('-', varRef('a')), '-', varRef('b')), binaryOp(varRef('y'), '+', num(10))) + ) + ]) + }) + + it('should parse a stock variable definition (without subscripts, multiple inflows, multiple outflows)', () => { + const v = xml(` + + y + 10 + a + b + c + d + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + // INTEG(a + b - c - d, y + 10) + call( + 'INTEG', + binaryOp(binaryOp(binaryOp(varRef('a'), '+', varRef('b')), '-', varRef('c')), '-', varRef('d')), + binaryOp(varRef('y'), '+', num(10)) + ) + ) + ]) + }) + + // TODO: We currently ignore `` elements during parsing; more work will be needed to + // match the behavior described in the XMILE spec for stocks + it('should parse a stock variable definition that has ', () => { + const v = xml(` + + 1000 + matriculating + graduating + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + // INTEG(matriculating - graduating, 1000) + call('INTEG', binaryOp(varRef('matriculating'), '-', varRef('graduating')), num(1000)) + ) + ]) + }) + + it('should parse a stock variable definition (with newline sequences in the name)', () => { + const v = xml(` + + 1000 + q + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x y z'), + // INTEG(q, 1000) + call('INTEG', varRef('q'), num(1000)) + ) + ]) + }) + + it('should throw an error if stock variable definition has no ', () => { + const v = xml(` + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('An is required for a variable') + }) + + it('should throw an error if stock variable definition has ', () => { + const v = xml(` + + + + 0,0.1,0.5,0.9,1 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' is only allowed for and variables') + }) + + // TODO: Support + it('should throw an error if stock variable definition has ', () => { + const v = xml(` + + 1000 + matriculating + graduating + + 4 + 1200 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently is not supported for a variable') + }) + + // TODO: Support + it('should throw an error if stock variable definition has ', () => { + const v = xml(` + + 1000 + matriculating + graduating + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently is not supported for a variable') + }) +}) + +describe('parseXmileVariableDef with ', () => { + it('should parse a flow variable definition (without subscripts, defined with )', () => { + const v = xml(` + + y + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x'), binaryOp(varRef('y'), '+', num(10)))]) + }) + + it('should parse a flow variable definition (without subscripts, defined with and )', () => { + const v = xml(` + + + + 0,0.1,0.5,0.9,1 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + lookupVarEqn( + varDef('x'), + lookupDef([ + [0, 0], + [0.25, 0.1], + [0.5, 0.5], + [0.75, 0.9], + [1, 1] + ]) + ) + ]) + }) + + it('should parse a flow variable definition (without subscripts, defined with and )', () => { + const v = xml(` + + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + lookupVarEqn( + varDef('x'), + lookupDef([ + [0, 0], + [0.4, 0.1], + [0.5, 0.5], + [0.8, 0.9], + [1, 1] + ]) + ) + ]) + }) + + it('should parse a flow variable definition (with one dimension, apply to all)', () => { + const v = xml(` + + + + + y[DimA] + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['DimA']), binaryOp(varRef('y', ['DimA']), '+', num(10))) + ]) + }) + + it('should parse a flow variable definition (with one dimension, non-apply-to-all, named subscripts)', () => { + const v = xml(` + + + + + + y[A1] + 10 + + + 20 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['A1']), binaryOp(varRef('y', ['A1']), '+', num(10))), + exprEqn(varDef('x', ['A2']), num(20)) + ]) + }) + + it('should parse a flow variable definition (with newline sequences in the name)', () => { + const v = xml(` + + y + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x y z'), binaryOp(varRef('y'), '+', num(10)))]) + }) + + // TODO: We currently ignore `` elements during parsing; more work will be needed to + // match the behavior described in the XMILE spec for flows + it('should parse a flow variable definition that has ', () => { + const v = xml(` + + 1000 + + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x'), num(1000))]) + }) + + it('should throw an error if flow variable definition has no or ', () => { + const v = xml(` + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently or is required for a variable') + }) + + // TODO: Support + it('should throw an error if flow variable definition has ', () => { + const v = xml(` + + y + 10 + 3 + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently is not supported for a variable') + }) + + // TODO: Support + it('should throw an error if flow variable definition has ', () => { + const v = xml(` + + 1000 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently is not supported for a variable') + }) + + // TODO: Support + it('should throw an error if flow variable definition has ', () => { + const v = xml(` + + 1000 + 0.1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently is not supported for a variable') + }) +}) + +describe('parseXmileVariableDef with ', () => { + it('should parse an aux variable definition (without subscripts)', () => { + const v = xml(` + + y + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x'), binaryOp(varRef('y'), '+', num(10)))]) + }) + + // TODO: According to the XMILE spec, "this is a long variable name" should be equivalent to the same name + // without quotes, but SDE doesn't support this yet (it keeps the quotes), so this test is skipped for now + it.skip('should parse an aux variable definition (with quoted variable name)', () => { + const v = xml(` + + "this is a long variable name y" + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('this is a long variable name x'), + binaryOp(varRef('this is a long variable name y'), '+', num(10)) + ) + ]) + }) + + it('should parse an aux variable definition (with one dimension, apply to all)', () => { + const v = xml(` + + + + + y[DimA] + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['DimA']), binaryOp(varRef('y', ['DimA']), '+', num(10))) + ]) + }) + + it('should parse an aux variable definition (with one dimension, non-apply-to-all, named subscripts)', () => { + const v = xml(` + + + + + + y[A1] + 10 + + + 20 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['A1']), binaryOp(varRef('y', ['A1']), '+', num(10))), + exprEqn(varDef('x', ['A2']), num(20)) + ]) + }) + + it('should parse an aux variable definition with array function using wildcard (one dimension)', () => { + const v = xml(` + + + + + SUM(y[*]) + + `) + // TODO: For now the parser will replace the wildcard with a placeholder dimension name; + // we should have a better way to express this in the AST + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['DimA']), call('SUM', varRef('y', ['_SDE_WILDCARD_!']))) + ]) + }) + + it('should parse an aux variable definition with array function using wildcard (two dimensions, first is wildcard)', () => { + const v = xml(` + + + + + + SUM(y[*, DimB]) + + `) + // TODO: For now the parser will replace the wildcard with a placeholder dimension name; + // we should have a better way to express this in the AST + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['DimA', 'DimB']), call('SUM', varRef('y', ['_SDE_WILDCARD_!', 'DimB']))) + ]) + }) + + it('should parse an aux variable definition with array function using wildcard (two dimensions, second is wildcard)', () => { + const v = xml(` + + + + + + SUM(y[DimA, *]) + + `) + // TODO: For now the parser will replace the wildcard with a placeholder dimension name; + // we should have a better way to express this in the AST + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['DimA', 'DimB']), call('SUM', varRef('y', ['DimA', '_SDE_WILDCARD_!']))) + ]) + }) + + it('should parse an aux variable definition with XMILE-style "IF ... THEN ... ELSE ..." conditional expression', () => { + const v = xml(` + + IF c > 10 THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp(varRef('c'), '>', num(10)), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE-style "IF ... THEN ... ELSE ..." conditional expression (over multiple lines)', () => { + const v = xml(` + + IF c > 10 + THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp(varRef('c'), '>', num(10)), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE-style "IF ... THEN ... ELSE ..." conditional expression (inside a function call)', () => { + const v = xml(` + + ABS(IF c > 10 THEN y + 3 ELSE z * 5) + 1 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + binaryOp( + call( + 'ABS', + call( + 'IF THEN ELSE', + binaryOp(varRef('c'), '>', num(10)), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ), + '+', + num(1) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE-style "IF ... THEN ... ELSE ..." conditional expression (with nested IF THEN ELSE)', () => { + const v = xml(` + + IF (vacation_switch = 1 AND total_weeks_vacation_taken < 4) + THEN (IF TIME - Last_Vacation_Start >= time_working_before_vacation THEN 1 + ELSE 0) + ELSE 0 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp( + binaryOp(varRef('vacation_switch'), '=', num(1)), + ':AND:', + binaryOp(varRef('total_weeks_vacation_taken'), '<', num(4)) + ), + parens( + call( + 'IF THEN ELSE', + binaryOp( + binaryOp(varRef('TIME'), '-', varRef('Last_Vacation_Start')), + '>=', + varRef('time_working_before_vacation') + ), + num(1), + num(0) + ) + ), + num(0) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE conditional expression with binary AND op', () => { + const v = xml(` + + IF c > 10 AND d < 20 THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp(binaryOp(varRef('c'), '>', num(10)), ':AND:', binaryOp(varRef('d'), '<', num(20))), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE conditional expression with binary OR op', () => { + const v = xml(` + + IF c > 10 OR d < 20 THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp(binaryOp(varRef('c'), '>', num(10)), ':OR:', binaryOp(varRef('d'), '<', num(20))), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + it('should parse an aux variable definition with XMILE conditional expression with binary NOT op', () => { + const v = xml(` + + IF NOT c THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + unaryOp(':NOT:', varRef('c')), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + // TODO: According to the XMILE spec, "this is a long variable name" should be equivalent to the same name + // without quotes, but SDE doesn't support this yet (it keeps the quotes), so this test is skipped for now + it.skip('should parse an aux variable definition with XMILE conditional expression (and should not replace boolean operators when inside quoted variable names)', () => { + const v = xml(` + + IF "This variable contains AND OR NOT keywords" > 10 AND d < 20 THEN y + 3 ELSE z * 5 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x'), + call( + 'IF THEN ELSE', + binaryOp( + binaryOp(varRef('This variable contains AND OR NOT keywords'), '>', num(10)), + ':AND:', + binaryOp(varRef('d'), '<', num(20)) + ), + binaryOp(varRef('y'), '+', num(3)), + binaryOp(varRef('z'), '*', num(5)) + ) + ) + ]) + }) + + it('should parse an aux variable definition (with newline sequences in the name)', () => { + const v = xml(` + + y + 10 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x y z'), binaryOp(varRef('y'), '+', num(10)))]) + }) + + it('should parse an aux variable definition with (synthesizes ACTIVE INITIAL call)', () => { + const v = xml(` + + Capacity_1*Utilization_Adjustment + Initial_Target_Capacity + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('Target Capacity'), + call( + 'ACTIVE INITIAL', + binaryOp(varRef('Capacity_1'), '*', varRef('Utilization_Adjustment')), + varRef('Initial_Target_Capacity') + ) + ) + ]) + }) + + it('should parse an aux variable definition with (with numeric init value)', () => { + const v = xml(` + + y + z + 100 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x'), call('ACTIVE INITIAL', binaryOp(varRef('y'), '+', varRef('z')), num(100))) + ]) + }) + + it('should parse an aux variable definition with (with one dimension, apply-to-all)', () => { + const v = xml(` + + + + + y[DimA] + z + 100 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn( + varDef('x', ['DimA']), + call('ACTIVE INITIAL', binaryOp(varRef('y', ['DimA']), '+', varRef('z')), num(100)) + ) + ]) + }) + + it('should throw an error if aux variable equation cannot be parsed', () => { + const v = xml(` + + y ? 10 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(`token recognition error at: '?'`) + }) + + it('should parse a constant definition (without subscripts)', () => { + const v = xml(` + + 1 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x'), num(1))]) + }) + + it('should parse a constant definition (with one dimension, apply-to-all)', () => { + const v = xml(` + + + + + 1 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x', ['DimA']), num(1))]) + }) + + it('should parse a constant definition (with one dimension, non-apply-to-all, named subscripts)', () => { + const v = xml(` + + + + + + 1 + + + 2 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['A1']), num(1)), + exprEqn(varDef('x', ['A2']), num(2)) + ]) + }) + + // TODO: Support numeric subscript indices + it('should parse a constant definition (with one dimension, non-apply-to-all, numeric subscripts)', () => { + const v = xml(` + + + + + + 1 + + + 1 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Numeric subscript indices are not currently supported') + }) + + it('should parse a constant definition (with two dimensions, apply-to-all)', () => { + const v = xml(` + + + + + + 1 + + `) + expect(parseXmileVariableDef(v)).toEqual([exprEqn(varDef('x', ['DimA', 'DimB']), num(1))]) + }) + + it('should parse a constant definition (with two dimensions, non-apply-to-all, named subscripts)', () => { + const v = xml(` + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + `) + expect(parseXmileVariableDef(v)).toEqual([ + exprEqn(varDef('x', ['A1', 'B1']), num(1)), + exprEqn(varDef('x', ['A1', 'B2']), num(2)), + exprEqn(varDef('x', ['A2', 'B1']), num(3)), + exprEqn(varDef('x', ['A2', 'B2']), num(4)) + ]) + }) + + // TODO: Support numeric subscript indices + it('should parse a constant definition (with two dimension, non-apply-to-all, numeric subscripts)', () => { + const v = xml(` + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Numeric subscript indices are not currently supported') + }) +}) + +describe('parseXmileVariableDef with ', () => { + it('should parse a graphical function (with and ', () => { + const v = xml(` + + + 0,0.1,0.5,0.9,1 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + lookupVarEqn( + varDef('x'), + lookupDef([ + [0, 0], + [0.25, 0.1], + [0.5, 0.5], + [0.75, 0.9], + [1, 1] + ]) + ) + ]) + }) + + it('should parse a graphical function (with and and explicit type)', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + lookupVarEqn( + varDef('x'), + lookupDef([ + [0, 0], + [0.4, 0.1], + [0.5, 0.5], + [0.8, 0.9], + [1, 1] + ]) + ) + ]) + }) + + it('should parse a graphical function (with custom separator)', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1 + 0;0.1;0.5;0.9;1 + + `) + expect(parseXmileVariableDef(v)).toEqual([ + lookupVarEqn( + varDef('x'), + lookupDef([ + [0, 0], + [0.4, 0.1], + [0.5, 0.5], + [0.8, 0.9], + [1, 1] + ]) + ) + ]) + }) + + it('should throw an error if name is undefined', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' name attribute is required') + }) + + it('should throw an error if name is empty', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' name attribute is required') + }) + + // TODO: Support other types (extrapolate and discrete) + it('should throw an error if type is defined and is not "continuous"', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow('Currently "continuous" is the only type supported for ') + }) + + it('should throw an error if neither nor is defined', () => { + const v = xml(` + + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' must contain either or ') + }) + + it('should throw an error if and are both defined', () => { + const v = xml(` + + + 0,0.4,0.5,0.8,1 + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' must contain or but not both') + }) + + it('should throw an error if min is undefined', () => { + const v = xml(` + + + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' min attribute is required') + }) + + it('should throw an error if max is undefined', () => { + const v = xml(` + + + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' max attribute is required') + }) + + it('should throw an error if min >= max', () => { + const v = xml(` + + + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' max attribute must be > min attribute') + }) + + it('should throw an error if is empty', () => { + const v = xml(` + + + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' must have at least one element') + }) + + it('should throw an error if is undefined', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1,666 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' must be defined for a ') + }) + + it('should throw an error if is empty', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1,666 + + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' must have at least one element') + }) + + it('should throw an error if and have different number of elements', () => { + const v = xml(` + + 0,0.4,0.5,0.8,1,666 + 0,0.1,0.5,0.9,1 + + `) + expect(() => parseXmileVariableDef(v)).toThrow(' and must have the same number of elements') + }) +}) diff --git a/packages/parse/src/xmile/parse-xmile-variable-def.ts b/packages/parse/src/xmile/parse-xmile-variable-def.ts new file mode 100644 index 00000000..ff578740 --- /dev/null +++ b/packages/parse/src/xmile/parse-xmile-variable-def.ts @@ -0,0 +1,568 @@ +// Copyright (c) 2023-2026 Climate Interactive / New Venture Fund + +import type { XmlElement } from '@rgrove/parse-xml' + +import { canonicalId } from '../_shared/canonical-id' + +import { call, lookupDef, subRef } from '../ast/ast-builders' +import type { Equation, Expr, LookupDef, LookupPoint, SubscriptRef } from '../ast/ast-types' + +import { parseVensimExpr } from '../vensim/parse-vensim-expr' + +import { elemsOf, firstElemOf, firstTextOf, xmlError } from './xml' + +/** + * Parse the given XMILE variable definition and return an array of `Equation` AST nodes + * corresponding to the variable definition (or definitions, in the case of a + * non-apply-to-all variable that is defined with an `` for each subscript). + * + * @param input A string containing the XMILE equation definition. + * @returns An `Equation` AST node. + */ +export function parseXmileVariableDef(varElem: XmlElement): Equation[] { + // Extract required variable name + let varName = parseRequiredAttr(varElem, varElem, 'name') + // XXX: Variable names in XMILE can contain newline sequences (represented as '\n'). For now + // we will replace them with spaces since `canonicalId` does not currently handle this case. + varName = varName.replace(/\\n/g, ' ') + const varId = canonicalId(varName) + + // Extract optional -> units string + const units = firstElemOf(varElem, 'units')?.text || '' + + // Extract optional -> comment string + const comment = firstElemOf(varElem, 'doc')?.text || '' + + // Helper function that creates a single `Equation` instance with an expression for + // this variable definition + function exprEquation(subscriptRefs: SubscriptRef[] | undefined, expr: Expr): Equation { + return { + lhs: { + varDef: { + kind: 'variable-def', + varName, + varId, + subscriptRefs + } + }, + rhs: { + kind: 'expr', + expr + }, + units, + comment + } + } + + // Helper function that creates a single `Equation` instance with a lookup for + // this variable definition + function lookupEquation(subscriptRefs: SubscriptRef[] | undefined, lookup: LookupDef): Equation { + return { + lhs: { + varDef: { + kind: 'variable-def', + varName, + varId, + subscriptRefs + } + }, + rhs: { + kind: 'lookup', + lookupDef: lookup + }, + units, + comment + } + } + + if (varElem.name === 'gf') { + // This is a top-level + // TODO: Are subscripted elements allowed? If so, handle them here. + const lookup = parseGfElem(varElem, varElem) + return [lookupEquation(undefined, lookup)] + } + + // Check for + const dimensionsElem = firstElemOf(varElem, 'dimensions') + const equationDefs: Equation[] = [] + if (dimensionsElem === undefined) { + // This is a non-subscripted variable + const gfElem = firstElemOf(varElem, 'gf') + if (gfElem) { + // The variable is defined with a graphical function + if (varElem.name !== 'flow' && varElem.name !== 'aux') { + throw new Error(xmlError(varElem, ' is only allowed for and variables')) + } + const lookup = parseGfElem(varElem, gfElem) + equationDefs.push(lookupEquation(undefined, lookup)) + } else { + // The variable is defined with an equation + const expr = parseEqnElem(varElem, varElem) + if (expr) { + equationDefs.push(exprEquation(undefined, expr)) + } + } + } else { + // This is an array (subscripted) variable. An array variable definition will include + // a element that declares which dimensions are used. + const dimElems = elemsOf(dimensionsElem, ['dim']) + const dimNames: string[] = [] + for (const dimElem of dimElems) { + const dimName = dimElem.attributes?.name + if (dimName === undefined) { + throw new Error(xmlError(varElem, ' name attribute is required in for variable definition')) + } + dimNames.push(dimName) + } + + // If it is an apply-to-all variable, there will be a single . If it is a + // non-apply-to-all variable, there will be one or more elements that define + // the separate equation for each "instance". + // TODO: Handle apply-to-all variable defs that contain ? + const elementElems = elemsOf(varElem, ['element']) + if (elementElems.length === 0) { + // This is an apply-to-all variable + const dimRefs = dimNames.map(subRef) + const expr = parseEqnElem(varElem, varElem) + if (expr) { + equationDefs.push(exprEquation(dimRefs, expr)) + } + } else { + // This is a non-apply-to-all variable + // TODO: We should change SubscriptRef so that it can include an explicit dimension + // name/ID (which we can pull from the section of the variable definition). + // Until then, we will include the subscript name only (and we do not yet support + // numeric subscript indices). + // TODO: Handle non-apply-to-all variable defs that contain ? + for (const elementElem of elementElems) { + const subscriptAttr = elementElem.attributes?.subscript + if (subscriptAttr === undefined) { + throw new Error(xmlError(varElem, ' subscript attribute is required in variable definition')) + } + const subscriptNames = subscriptAttr.split(',').map(s => s.trim()) + const subRefs: SubscriptRef[] = [] + for (const subscriptName of subscriptNames) { + if (!isNaN(parseInt(subscriptAttr))) { + throw new Error(xmlError(varElem, 'Numeric subscript indices are not currently supported')) + } + subRefs.push(subRef(subscriptName)) + } + const expr = parseEqnElem(varElem, elementElem) + if (expr) { + equationDefs.push(exprEquation(subRefs, expr)) + } + } + } + } + + return equationDefs +} + +function parseEqnElem(varElem: XmlElement, parentElem: XmlElement): Expr { + const varTagName = varElem.name + const eqnElem = firstElemOf(parentElem, 'eqn') + + // Interpret the element + // TODO: Handle the case where is defined using CDATA + const eqnText = eqnElem ? firstTextOf(eqnElem) : undefined + switch (varTagName) { + case 'aux': { + if (eqnText === undefined) { + // Technically the is optional for an ; if not defined, we will skip it + return undefined + } + + // Check for element. In XMILE, an aux variable can have a separate + // init-time equation using . This is equivalent to Vensim's ACTIVE_INITIAL + // function, so we synthesize an ACTIVE_INITIAL call when both elements are present. + const initEqnElem = firstElemOf(parentElem, 'init_eqn') + const initEqnText = initEqnElem ? firstTextOf(initEqnElem) : undefined + if (initEqnText !== undefined) { + // Synthesize ACTIVE INITIAL(eqnExpr, initEqnExpr) + const eqnExpr = parseExpr(eqnText.text) + const initEqnExpr = parseExpr(initEqnText.text) + return call('ACTIVE INITIAL', eqnExpr, initEqnExpr) + } + + return parseExpr(eqnText.text) + } + + case 'stock': { + // elements are currently translated to a Vensim-style aux: + // INTEG({inflow1} + {inflow2} + ... - {outflow1} - {outflow2} - ..., {eqn}) + if (eqnText === undefined) { + // An is currently required for a + throw new Error(xmlError(varElem, 'An is required for a variable')) + } + const inflowElems = elemsOf(parentElem, ['inflow']) + const outflowElems = elemsOf(parentElem, ['outflow']) + // TODO: Handle the case where or is defined using CDATA + const inflowTexts = inflowElems.map(inflowElem => { + const inflowText = firstTextOf(inflowElem) + if (inflowText === undefined) { + throw new Error(xmlError(varElem, 'An must be non-empty for a variable')) + } + return inflowText.text + }) + const outflowTexts = outflowElems.map(outflowElem => { + const outflowText = firstTextOf(outflowElem) + if (outflowText === undefined) { + throw new Error(xmlError(varElem, 'An must be non-empty for a variable')) + } + return outflowText.text + }) + + // TODO: We currently do not support certain options, so for now we + // fail fast if we encounter these + if (firstElemOf(parentElem, 'conveyor')) { + throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + } + if (firstElemOf(parentElem, 'queue')) { + throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + } + // TODO: We currently ignore `` elements during parsing since it is noted in the XMILE + // spec that it is not directly supported by XMILE and an optional vendor-specific feature. More + // work will be needed to make this work according to the spec, but for now it's OK to ignore it. + // if (firstElemOf(parentElem, 'non_negative')) { + // throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + // } + + // Combine the inflow and outflow expressions into a single expression + // TODO: Do we need to worry about parentheses here? + const inflowParts = inflowTexts.join(' + ') + let outflowParts = outflowTexts.join(' - ') + if (outflowTexts.length > 0) { + if (inflowParts.length > 0) { + outflowParts = `- ${outflowParts}` + } else { + outflowParts = `-${outflowParts}` + } + } + const flowsExpr = parseExpr(`${inflowParts} ${outflowParts}`) + + // Synthesize a Vensim-style `INTEG` function call + const initExpr = parseExpr(eqnText.text) + return call('INTEG', flowsExpr, initExpr) + } + + case 'flow': + // elements with an are currently translated to a Vensim-style aux + // TODO: The XMILE spec says some variants must not have an equation (in the case + // of conveyors or queues). For now, we don't support those, and we require an . + if (eqnText === undefined) { + // An is currently required for a + throw new Error(xmlError(varElem, 'Currently or is required for a variable')) + } + // TODO: We currently do not support certain options, so for now we + // fail fast if we encounter these + if (firstElemOf(parentElem, 'multiplier')) { + throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + } + // TODO: We currently ignore `` elements during parsing since it is noted in the XMILE + // spec that it is not directly supported by XMILE and an optional vendor-specific feature. More + // work will be needed to make this work according to the spec, but for now it's OK to ignore it. + // if (firstElemOf(parentElem, 'non_negative')) { + // throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + // } + if (firstElemOf(parentElem, 'overflow')) { + throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + } + if (firstElemOf(parentElem, 'leak')) { + throw new Error(xmlError(varElem, 'Currently is not supported for a variable')) + } + return parseExpr(eqnText.text) + + default: + throw new Error(xmlError(varElem, `Unhandled variable type '${varTagName}'`)) + } +} + +function parseExpr(exprText: string): Expr { + // Except for a few slight differences (e.g., `IF ... THEN ... ELSE ...`), the expression + // syntax in XMILE is the same as in Vensim, so we will use the existing Vensim expression + // parser here. The idiomatic way to do conditional statements in XMILE is: + // IF {condition} THEN {trueExpr} ELSE {falseExpr} + // But the spec allows for the Vensim form: + // IF_THEN_ELSE({condition}, {trueExpr}, {falseExpr}) + // Since we only support the latter in the compile package, it's better if we transform + // the XMILE form to Vensim form, and then we can use the Vensim expression parser. + exprText = convertConditionalExpressions(exprText) + + // XXX: XMILE uses different syntax for array functions than Vensim. XMILE uses an + // asterisk (wildcard), e.g., `SUM(x[*])`, while Vensim uses e.g., `SUM(x[DimA!])`. + // To allow for reusing the Vensim expression parser, we will replace the XMILE wildcard + // with the Vensim syntax (using a placeholder dimension name; the real one will be + // resolved later). + exprText = exprText.replace(/\[([^\]]*)\*([^\]]*)\]/g, '[$1_SDE_WILDCARD_!$2]') + + return parseVensimExpr(exprText) +} + +function parseGfElem(varElem: XmlElement, gfElem: XmlElement): LookupDef { + // Parse the optional `type` attribute + const typeAttr = parseOptionalAttr(gfElem, 'type') + if (typeAttr && typeAttr !== 'continuous') { + throw new Error(xmlError(varElem, 'Currently "continuous" is the only type supported for ')) + } + + // Parse the required + const yptsElem = firstElemOf(gfElem, 'ypts') + if (yptsElem === undefined) { + throw new Error(xmlError(varElem, ' must be defined for a ')) + } + const ypts = parseGfPts(varElem, yptsElem) + if (ypts.length === 0) { + throw new Error(xmlError(varElem, ' must have at least one element')) + } + + // Check for or (must be one or the other) + const xptsElem = firstElemOf(gfElem, 'xpts') + const xscaleElem = firstElemOf(gfElem, 'xscale') + if (xptsElem && xscaleElem) { + throw new Error(xmlError(varElem, ' must contain or but not both')) + } else if (xptsElem === undefined && xscaleElem === undefined) { + throw new Error(xmlError(varElem, ' must contain either or ')) + } + + let xpts: number[] + if (xptsElem) { + // Parse the + xpts = parseGfPts(varElem, xptsElem) + if (xpts.length === 0) { + throw new Error(xmlError(varElem, ' must have at least one element')) + } + } else { + // Parse the + const xMin = parseFloatAttr(varElem, xscaleElem, 'min') + const xMax = parseFloatAttr(varElem, xscaleElem, 'max') + if (xMin > xMax) { + throw new Error(xmlError(varElem, ' max attribute must be > min attribute')) + } + xpts = Array(ypts.length) + const xRange = xMax - xMin + if (ypts.length === 1) { + // TODO: Error? + xpts[0] = 0 + } else { + for (let i = 0; i < ypts.length; i++) { + const frac = i / (ypts.length - 1) + xpts[i] = xMin + xRange * frac + } + } + } + + // Check for same length as + if (xpts.length !== ypts.length) { + throw new Error(xmlError(varElem, ' and must have the same number of elements')) + } + + // Zip the arrays + const points: LookupPoint[] = [] + for (let i = 0; i < xpts.length; i++) { + points.push([xpts[i], ypts[i]]) + } + return lookupDef(points) +} + +function parseGfPts(varElem: XmlElement, ptsElem: XmlElement): number[] { + const ptsText = firstTextOf(ptsElem)?.text + if (ptsText === undefined) { + return [] + } + + const sep = ptsElem.attributes?.sep || ',' + const elems = ptsText.split(sep) + const nums: number[] = [] + for (const elem of elems) { + const numText = elem.trim() + const num = parseFloat(numText) + if (isNaN(num)) { + console.log(JSON.stringify(ptsElem)) + throw new Error(xmlError(varElem, `Invalid number value '${numText}' in <${ptsElem.name}>'`)) + } + nums.push(num) + } + return nums +} + +function parseRequiredAttr(varElem: XmlElement, elem: XmlElement, attrName: string): string { + let s = elem.attributes && elem.attributes[attrName] + s = s?.trim() + if (s === undefined || s.length === 0) { + throw new Error(xmlError(varElem, `<${elem.name}> ${attrName} attribute is required`)) + } + return s +} + +function parseOptionalAttr(elem: XmlElement, attrName: string): string { + const s = elem.attributes && elem.attributes[attrName] + return s?.trim() +} + +function parseFloatAttr(varElem: XmlElement, elem: XmlElement, attrName: string): number { + const s = parseRequiredAttr(varElem, elem, attrName) + const num = parseFloat(s) + if (isNaN(num)) { + throw new Error(xmlError(varElem, `Invalid number value '${s}' for <${elem.name}> ${attrName} attribute'`)) + } + return num +} + +/** + * Parse XMILE conditional expressions recursively to handle nested IF-THEN-ELSE statements. + * + * This transforms XMILE syntax: + * IF condition THEN trueExpr ELSE falseExpr + * to Vensim syntax: + * IF THEN ELSE(condition, trueExpr, falseExpr) + * + * Examples of supported nested structures: + * - Simple: IF x > 0 THEN 1 ELSE 0 + * - Nested: IF x > 0 THEN IF y > 0 THEN 2 ELSE 1 ELSE 0 + * - Complex: IF a > 0 THEN IF b > 0 THEN IF c > 0 THEN 3 ELSE 2 ELSE 1 ELSE 0 + * - In function call: ABS(IF x > 0 THEN 1 ELSE 0) + 1 + */ +function convertConditionalExpressions(exprText: string): string { + // XXX: This function implementation was generated by an LLM and is rather complex. + // We probably could remove it if we had a new ANTLR grammar/parser for XMILE-style + // expressions, including nested conditional expressions. + + // Trim whitespace and normalize newlines + const normalizedText = exprText.trim().replace(/\s+/g, ' ') + + // Find the first IF-THEN-ELSE pattern in the expression + const ifMatch = normalizedText.match(/\bIF\s+(.+)$/i) + if (!ifMatch) { + return exprText + } + + // Find the position of the IF keyword + const ifIndex = normalizedText.search(/\bIF\s+/i) + const beforeIf = normalizedText.substring(0, ifIndex) + const afterIf = normalizedText.substring(ifIndex + 3).trim() // Skip "IF " + + // Parse the condition part (everything after IF until THEN) + const thenMatch = afterIf.match(/^(.+?)\s+THEN\s+(.+)$/i) + if (!thenMatch) { + return exprText + } + + const condition = thenMatch[1].trim() + const afterThen = thenMatch[2] + + // Find the ELSE part by looking for the outermost ELSE that's not inside parentheses + let elseIndex = -1 + let parenCount = 0 + let inQuotes = false + let quoteChar = '' + + for (let i = 0; i < afterThen.length; i++) { + const char = afterThen[i] + + // Handle quoted strings + if ((char === '"' || char === "'") && (i === 0 || afterThen[i - 1] !== '\\')) { + if (!inQuotes) { + inQuotes = true + quoteChar = char + } else if (char === quoteChar) { + inQuotes = false + quoteChar = '' + } + continue + } + + if (inQuotes) { + continue + } + + // Handle parentheses for nested expressions + if (char === '(') { + parenCount++ + } else if (char === ')') { + parenCount-- + } + + // Look for ELSE, but only when not inside parentheses or quoted strings + if (parenCount === 0 && !inQuotes) { + const elseMatch = afterThen.substring(i).match(/^ELSE\s+(.+)$/i) + if (elseMatch) { + elseIndex = i + break + } + } + } + + if (elseIndex === -1) { + return exprText + } + + // Skip "ELSE " + const trueExpr = afterThen.substring(0, elseIndex).trim() + let falseExpr = afterThen.substring(elseIndex + 5).trim() + + // Find the end of the false expression by looking for the end of the conditional. The conditional + // ends when we reach a closing parenthesis that brings us back to the original level. + let endIndex = -1 + parenCount = 0 + inQuotes = false + quoteChar = '' + + for (let i = 0; i < falseExpr.length; i++) { + const char = falseExpr[i] + + // Handle quoted strings + if ((char === '"' || char === "'") && (i === 0 || falseExpr[i - 1] !== '\\')) { + if (!inQuotes) { + inQuotes = true + quoteChar = char + } else if (char === quoteChar) { + inQuotes = false + quoteChar = '' + } + continue + } + + if (inQuotes) { + continue + } + + // Handle parentheses for nested expressions + if (char === '(') { + parenCount++ + } else if (char === ')') { + if (parenCount === 0) { + // This is the closing parenthesis that ends the conditional + endIndex = i + break + } + parenCount-- + } + } + + if (endIndex !== -1) { + falseExpr = falseExpr.substring(0, endIndex).trim() + } + + // Recursively parse nested conditionals in the true and false expressions + const convertedTrueExpr = convertConditionalExpressions(trueExpr) + const convertedFalseExpr = convertConditionalExpressions(falseExpr) + + const convertedCondition = condition + // Replace logical operators for Vensim compatibility + .replace(/(? { + if (n.type === XmlNode.TYPE_ELEMENT) { + const e = n as XmlElement + return e.name === tagName + } else { + return undefined + } + }) as XmlElement +} + +export function firstTextOf(parent: XmlElement | undefined): XmlText | undefined { + return parent?.children.find(n => { + return n.type === XmlNode.TYPE_TEXT + }) as XmlText +} + +export function elemsOf(parent: XmlElement | undefined, tagNames: string[]): XmlElement[] { + if (parent === undefined) { + return [] + } + + const elems: XmlElement[] = [] + for (const n of parent.children) { + if (n.type === XmlNode.TYPE_ELEMENT) { + const e = n as XmlElement + if (tagNames.includes(e.name)) { + elems.push(e) + } + } + } + return elems +} + +export function xmlError(elem: XmlElement, msg: string): string { + return `${msg}: ${JSON.stringify(elem.toJSON(), null, 2)}` +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a425741b..365b9fb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.17 version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@20.19.19)(sass@1.97.2))(vitest@4.0.17))(vitest@4.0.17) + csv-parse: + specifier: ^5.3.3 + version: 5.3.3 eslint: specifier: ^9.37.0 version: 9.37.0 @@ -697,6 +700,9 @@ importers: packages/parse: dependencies: + '@rgrove/parse-xml': + specifier: ^4.1.0 + version: 4.2.0 antlr4: specifier: 4.12.0 version: 4.12.0 @@ -1474,6 +1480,10 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rgrove/parse-xml@4.2.0': + resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} + engines: {node: '>=14.0.0'} + '@rollup/plugin-node-resolve@16.0.3': resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} engines: {node: '>=14.0.0'} @@ -3834,7 +3844,7 @@ snapshots: dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: @@ -3946,6 +3956,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@rgrove/parse-xml@4.2.0': {} + '@rollup/plugin-node-resolve@16.0.3(rollup@4.53.3)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.3) diff --git a/tests/csv-to-dat.js b/tests/csv-to-dat.js new file mode 100644 index 00000000..edcc3878 --- /dev/null +++ b/tests/csv-to-dat.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +/** + * Convert a CSV file (exported from Stella/XMILE) to a DAT file (Vensim format). + * + * Usage: node csv-to-dat.js + * + * The CSV file should have: + * - A header row with variable names (first column is time, e.g., "Months") + * - Data rows with time values in the first column + * + * The DAT file will have: + * - Each variable on its own line + * - Followed by tab-separated time-value pairs + * - Empty values are skipped + * - Subscripts are converted from "var[A1, B1]" to "var[A1,B1]" (spaces removed) + */ + +import { readFileSync, writeFileSync } from 'fs' +import { parse as parseCsv } from 'csv-parse/sync' + +/** + * Convert a variable name from CSV format to DAT format. + * Remove spaces after commas inside subscript brackets. + * + * @param name The variable name from CSV (e.g., "f[A1, B1]"). + * @returns The variable name in DAT format (e.g., "f[A1,B1]"). + */ +function convertVarName(name) { + return name.replace(/\[([^\]]+)\]/g, (match, subscripts) => { + return '[' + subscripts.replace(/,\s+/g, ',') + ']' + }) +} + +/** + * Convert a CSV file to DAT format. + * + * @param inputPath Path to the input CSV file. + * @param outputPath Path to the output DAT file. + */ +function convertCsvToDat(inputPath, outputPath) { + const content = readFileSync(inputPath, 'utf-8') + const rows = parseCsv(content, { + columns: false, + skip_empty_lines: true, + relax_column_count: true + }) + + if (rows.length < 2) { + console.error('Error: CSV file must have at least a header row and one data row') + process.exit(1) + } + + const headers = rows[0] + const dataRows = rows.slice(1) + const output = [] + + // Process each variable (skip first column which is time) + for (let col = 1; col < headers.length; col++) { + const varName = convertVarName(headers[col]) + + // Collect time-value pairs where value is not empty + const pairs = [] + for (const row of dataRows) { + const time = row[0] + const value = row[col] + if (value !== undefined && value !== '') { + pairs.push([time, value]) + } + } + + // Only output variables that have at least one value + if (pairs.length > 0) { + output.push(varName) + for (const [time, value] of pairs) { + output.push(`${time}\t${value}`) + } + } + } + + writeFileSync(outputPath, output.join('\n') + '\n') +} + +// Main +const args = process.argv.slice(2) +if (args.length !== 2) { + console.error('Usage: node csv-to-dat.js ') + process.exit(1) +} + +convertCsvToDat(args[0], args[1]) diff --git a/tests/modeltests b/tests/modeltests index f7294ea3..913be005 100755 --- a/tests/modeltests +++ b/tests/modeltests @@ -4,6 +4,11 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJ_DIR=$SCRIPT_DIR/.. MODELS_DIR=$PROJ_DIR/models +# If INPUT_FORMAT is not set, default to "mdl" +if [[ -z $INPUT_FORMAT ]]; then + export INPUT_FORMAT=mdl +fi + # If GEN_FORMAT is not set, default to "c" if [[ -z $GEN_FORMAT ]]; then export GEN_FORMAT=c @@ -22,12 +27,33 @@ SDE_MAIN="$PROJ_DIR/packages/cli/src/main.js" function test { MODEL=$1 ARGS_MSG=$2 - echo "Testing the $MODEL model $ARGS_MSG" MODEL_DIR=$MODELS_DIR/$MODEL + if [[ $INPUT_FORMAT == "stmx" ]]; then + if [[ ! -f $MODEL_DIR/${MODEL}.stmx || ! -f $MODEL_DIR/${MODEL}.csv ]]; then + echo "Skipping test for $MODEL (no stmx or csv file found)" + echo + return + fi + fi + + echo "Testing the $MODEL model $ARGS_MSG" + # Clean up before node "$SDE_MAIN" clean --modeldir "$MODEL_DIR" + # If INPUT_FORMAT is stmx/xmile, convert the CSV file containing expected data from the + # modeling tool to DAT format for comparison with SDE output. This is a workaround for + # the fact that the `sde compare` and `sde test` commands do not yet support CSV input. + if [[ $INPUT_FORMAT == "mdl" ]]; then + TOOL_DAT=$MODEL_DIR/${MODEL}.dat + else + echo "Converting ${MODEL}.csv to DAT format" + mkdir -p $MODEL_DIR/output + TOOL_DAT=$MODEL_DIR/output/${MODEL}_${INPUT_FORMAT}.dat + node "$SCRIPT_DIR/csv-to-dat.js" "$MODEL_DIR/${MODEL}.csv" "$TOOL_DAT" + fi + # Test (only if there is a dat file to compare against) if [[ -f $MODEL_DIR/${MODEL}.dat ]]; then SPEC_FILE=$MODEL_DIR/${MODEL}_spec.json @@ -41,7 +67,7 @@ function test { if [[ $MODEL == "allocate" ]]; then PRECISION="1e-2" fi - node "$SDE_MAIN" test $TEST_ARGS --genformat=$GEN_FORMAT -p $PRECISION "$MODEL_DIR/$MODEL" + node "$SDE_MAIN" test $TEST_ARGS --genformat $GEN_FORMAT --tooldat $TOOL_DAT -p $PRECISION "$MODEL_DIR/$MODEL.$INPUT_FORMAT" fi # Run additional script to validate output @@ -121,6 +147,15 @@ else continue fi + if [[ $INPUT_FORMAT == "stmx" ]]; then + # Skip tests for models that are not yet supported by the Stella/XMILE backend + if [[ $m == "allocate" || $m == "arrays" ]]; then + echo "Skipping test for $m (not yet supported by Stella/XMILE backend)" + echo + continue + fi + fi + if [[ $GEN_FORMAT == "js" ]]; then # Skip tests for models that are not yet supported for JS target if [[ $m == "allocate" || $m == delayfixed* || $m == "depreciate" || $m == "gamma_ln" ]]; then