@sdeverywhere/compile 0.7.31 → 0.7.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/_shared/xlsx.js +60 -45
- package/src/generate/gen-equation.js +5 -1
- package/src/generate/gen-expr.js +170 -16
- package/src/model/analyze-cycles.js +208 -0
- package/src/model/model.js +73 -1
- package/src/model/read-equations.js +27 -8
- package/src/model/read-variables.js +19 -14
- package/src/model/toposort.js +83 -1
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdeverywhere/compile",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.32",
|
|
4
4
|
"description": "The core Vensim to C compiler for the SDEverywhere tool suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@sdeverywhere/parse": "^0.1.
|
|
8
|
+
"@sdeverywhere/parse": "^0.1.5",
|
|
9
9
|
"byline": "^5.0.0",
|
|
10
10
|
"csv-parse": "^5.3.3",
|
|
11
11
|
"fflate": "^0.8.3",
|
package/src/_shared/xlsx.js
CHANGED
|
@@ -278,10 +278,22 @@ function getAttr(attrs, name) {
|
|
|
278
278
|
return end < 0 ? undefined : attrs.slice(start, end)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Normalize line endings the way a conformant XML parser (and SheetJS) does:
|
|
283
|
+
* `\r\n` and lone `\r` both become `\n`. Applied after entity decoding so a
|
|
284
|
+
* CR encoded as ` ` is normalized too.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} s The text to normalize.
|
|
287
|
+
* @returns The normalized string.
|
|
288
|
+
*/
|
|
289
|
+
function normalizeEol(s) {
|
|
290
|
+
return s.indexOf('\r') === -1 ? s : s.replace(/\r\n?/g, '\n')
|
|
291
|
+
}
|
|
292
|
+
|
|
281
293
|
/**
|
|
282
294
|
* Decode the standard XML entities (`<`, `>`, `&`, `"`,
|
|
283
295
|
* `'`) along with numeric character references (`&#NN;` and `&#xNN;`)
|
|
284
|
-
* in the given text
|
|
296
|
+
* in the given text, and normalize line endings to `\n`.
|
|
285
297
|
*
|
|
286
298
|
* @param {string} s The raw text from an XML element body or attribute.
|
|
287
299
|
* @returns The decoded string.
|
|
@@ -289,20 +301,22 @@ function getAttr(attrs, name) {
|
|
|
289
301
|
function decodeXmlText(s) {
|
|
290
302
|
// Fast path: most cell text contains no entities, so skip the regex chain
|
|
291
303
|
if (s.indexOf('&') === -1) {
|
|
292
|
-
return s
|
|
304
|
+
return normalizeEol(s)
|
|
293
305
|
}
|
|
294
306
|
|
|
295
307
|
// Decode the named entities, then decimal and hex numeric refs, and finally
|
|
296
308
|
// `&` — leaving `&` last avoids accidentally producing `<` etc.
|
|
297
309
|
// from a literal `&lt;` in the source
|
|
298
|
-
return
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
310
|
+
return normalizeEol(
|
|
311
|
+
s
|
|
312
|
+
.replace(/</g, '<')
|
|
313
|
+
.replace(/>/g, '>')
|
|
314
|
+
.replace(/"/g, '"')
|
|
315
|
+
.replace(/'/g, "'")
|
|
316
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
317
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
|
318
|
+
.replace(/&/g, '&')
|
|
319
|
+
)
|
|
306
320
|
}
|
|
307
321
|
|
|
308
322
|
//
|
|
@@ -392,6 +406,19 @@ function parseWorkbookRels(xml) {
|
|
|
392
406
|
return rels
|
|
393
407
|
}
|
|
394
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Extract the text content of the `<v>` element in a cell body, tolerating
|
|
411
|
+
* attributes on the tag (e.g. `<v xml:space="preserve">`). Returns undefined
|
|
412
|
+
* when there is no `<v>` element (e.g. an uncalculated formula cell).
|
|
413
|
+
*
|
|
414
|
+
* @param {string} body The inner XML of a `<c>` element.
|
|
415
|
+
* @returns The raw text between `<v...>` and `</v>`, or undefined.
|
|
416
|
+
*/
|
|
417
|
+
function getVText(body) {
|
|
418
|
+
const m = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)
|
|
419
|
+
return m ? m[1] : undefined
|
|
420
|
+
}
|
|
421
|
+
|
|
395
422
|
/**
|
|
396
423
|
* Scan a worksheet's XML and build a sparse cell map shaped like the SheetJS
|
|
397
424
|
* worksheet object: `{ [cellRef]: { v }, '!ref': 'A1:Z99' }`. Skips empty
|
|
@@ -429,16 +456,7 @@ function parseSheetXml(xml, sharedStrings) {
|
|
|
429
456
|
const t = getAttr(attrs, 't')
|
|
430
457
|
|
|
431
458
|
let value
|
|
432
|
-
if (t === '
|
|
433
|
-
// Shared string: <v>N</v> where N indexes sharedStrings
|
|
434
|
-
const vStart = body.indexOf('<v>')
|
|
435
|
-
if (vStart < 0) {
|
|
436
|
-
continue
|
|
437
|
-
}
|
|
438
|
-
const vEnd = body.indexOf('</v>', vStart + 3)
|
|
439
|
-
const idx = parseInt(body.slice(vStart + 3, vEnd), 10)
|
|
440
|
-
value = sharedStrings[idx]
|
|
441
|
-
} else if (t === 'inlineStr') {
|
|
459
|
+
if (t === 'inlineStr') {
|
|
442
460
|
// Inline string: <is><t>...</t></is>
|
|
443
461
|
const tStart = body.indexOf('<t')
|
|
444
462
|
if (tStart < 0) {
|
|
@@ -447,38 +465,35 @@ function parseSheetXml(xml, sharedStrings) {
|
|
|
447
465
|
const tOpenEnd = body.indexOf('>', tStart)
|
|
448
466
|
const tEnd = body.indexOf('</t>', tOpenEnd)
|
|
449
467
|
value = decodeXmlText(body.slice(tOpenEnd + 1, tEnd))
|
|
450
|
-
} else if (t === 'str') {
|
|
451
|
-
// Formula result as string: <v>...</v>
|
|
452
|
-
const vStart = body.indexOf('<v>')
|
|
453
|
-
if (vStart < 0) {
|
|
454
|
-
continue
|
|
455
|
-
}
|
|
456
|
-
const vEnd = body.indexOf('</v>', vStart + 3)
|
|
457
|
-
value = decodeXmlText(body.slice(vStart + 3, vEnd))
|
|
458
|
-
} else if (t === 'b') {
|
|
459
|
-
// Boolean: <v>0</v> or <v>1</v>
|
|
460
|
-
const vStart = body.indexOf('<v>')
|
|
461
|
-
if (vStart < 0) {
|
|
462
|
-
continue
|
|
463
|
-
}
|
|
464
|
-
value = body.charCodeAt(vStart + 3) === 49 // '1'
|
|
465
468
|
} else if (t === 'e') {
|
|
466
469
|
// Error cell, skip
|
|
467
470
|
continue
|
|
468
471
|
} else {
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// skip the cell so the
|
|
472
|
-
|
|
473
|
-
|
|
472
|
+
// The remaining cell types carry their value in a <v> element, which
|
|
473
|
+
// may have attributes (e.g. <v xml:space="preserve">). If <v> is
|
|
474
|
+
// missing (e.g. an uncalculated formula), skip the cell so the
|
|
475
|
+
// caller's missing-cell handling kicks in.
|
|
476
|
+
const vText = getVText(body)
|
|
477
|
+
if (vText === undefined) {
|
|
474
478
|
continue
|
|
475
479
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
+
if (t === 's') {
|
|
481
|
+
// Shared string: <v>N</v> where N indexes sharedStrings
|
|
482
|
+
value = sharedStrings[parseInt(vText, 10)]
|
|
483
|
+
} else if (t === 'str') {
|
|
484
|
+
// Formula result as string: <v>...</v>
|
|
485
|
+
value = decodeXmlText(vText)
|
|
486
|
+
} else if (t === 'b') {
|
|
487
|
+
// Boolean: <v>0</v> or <v>1</v>
|
|
488
|
+
value = vText.charCodeAt(0) === 49 // '1'
|
|
489
|
+
} else {
|
|
490
|
+
// Numeric (t === 'n' or absent)
|
|
491
|
+
const num = +vText
|
|
492
|
+
if (Number.isNaN(num)) {
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
value = num
|
|
480
496
|
}
|
|
481
|
-
value = num
|
|
482
497
|
}
|
|
483
498
|
|
|
484
499
|
// Store the cell under its A1 ref, matching the SheetJS sheet shape
|
|
@@ -128,6 +128,9 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Keep a buffer of code that will be included before all subscript loops
|
|
132
|
+
const preLoopLines = []
|
|
133
|
+
|
|
131
134
|
// Keep a buffer of code that will be included before the innermost loop
|
|
132
135
|
const preInnerLoopLines = []
|
|
133
136
|
|
|
@@ -145,6 +148,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
145
148
|
cLhs,
|
|
146
149
|
loopIndexVars,
|
|
147
150
|
arrayIndexVars,
|
|
151
|
+
emitPreLoop: s => preLoopLines.push(s),
|
|
148
152
|
emitPreInnerLoop: s => preInnerLoopLines.push(s),
|
|
149
153
|
emitPreFormula: s => preFormulaLines.push(s),
|
|
150
154
|
emitPostFormula: s => postFormulaLines.push(s),
|
|
@@ -161,7 +165,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
// Combine all lines of comments and code into a single array
|
|
164
|
-
return [comment, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
|
|
168
|
+
return [comment, ...preLoopLines, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
/**
|
package/src/generate/gen-expr.js
CHANGED
|
@@ -369,6 +369,9 @@ function generateFunctionCall(callExpr, ctx) {
|
|
|
369
369
|
//
|
|
370
370
|
//
|
|
371
371
|
|
|
372
|
+
case '_INVERT_MATRIX':
|
|
373
|
+
return generateInvertMatrixCall(callExpr, ctx)
|
|
374
|
+
|
|
372
375
|
case '_VECTOR_ELM_MAP':
|
|
373
376
|
return generateVectorElmMapCall(callExpr, ctx)
|
|
374
377
|
|
|
@@ -382,10 +385,18 @@ function generateFunctionCall(callExpr, ctx) {
|
|
|
382
385
|
//
|
|
383
386
|
|
|
384
387
|
case '_ALLOCATE_AVAILABLE':
|
|
388
|
+
case '_DEMAND_AT_PRICE':
|
|
389
|
+
case '_SUPPLY_AT_PRICE':
|
|
385
390
|
if (ctx.outFormat === 'js') {
|
|
386
391
|
throw new Error(`${callExpr.fnName} function not yet implemented for JS code gen`)
|
|
387
392
|
}
|
|
388
|
-
return
|
|
393
|
+
return generateAllocationFunctionCall(callExpr, ctx)
|
|
394
|
+
|
|
395
|
+
case '_FIND_MARKET_PRICE':
|
|
396
|
+
if (ctx.outFormat === 'js') {
|
|
397
|
+
throw new Error(`FIND MARKET PRICE function not yet implemented for JS code gen`)
|
|
398
|
+
}
|
|
399
|
+
return generateFindMarketPriceFunctionCall(callExpr, ctx)
|
|
389
400
|
|
|
390
401
|
case '_ALLOCATE_BY_PRIORITY':
|
|
391
402
|
if (ctx.outFormat === 'js') {
|
|
@@ -875,19 +886,89 @@ function generateVectorSortOrderCall(callExpr, ctx) {
|
|
|
875
886
|
}
|
|
876
887
|
|
|
877
888
|
/**
|
|
878
|
-
* Generate C/JS code for an `
|
|
889
|
+
* Generate C/JS code for an `INVERT MATRIX` function call.
|
|
890
|
+
*
|
|
891
|
+
* The function inverts the entire 2D matrix argument at once, so the call is emitted
|
|
892
|
+
* once before the LHS subscript loops are opened, and the per-element expression reads
|
|
893
|
+
* from the resulting temporary.
|
|
879
894
|
*
|
|
880
895
|
* @param {*} callExpr The function call expression from the parsed model.
|
|
881
896
|
* @param {GenExprContext} ctx The context used when generating code for the expression.
|
|
882
897
|
* @return {string} The generated C/JS code.
|
|
883
898
|
*/
|
|
884
|
-
function
|
|
899
|
+
function generateInvertMatrixCall(callExpr, ctx) {
|
|
900
|
+
// Process the matrix argument
|
|
901
|
+
const matrixArg = callExpr.args[0]
|
|
902
|
+
if (matrixArg.kind !== 'variable-ref') {
|
|
903
|
+
throw new Error(`INVERT MATRIX argument 'matrix' must be a variable reference`)
|
|
904
|
+
}
|
|
905
|
+
const matrixSubIds = matrixArg.subscriptRefs?.map(subRef => subRef.subId) || []
|
|
906
|
+
if (matrixSubIds.length !== 2) {
|
|
907
|
+
throw new Error(`INVERT MATRIX argument 'matrix' must be a 2D matrix variable`)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// The result fills the entire LHS variable, so the LHS must be a square 2D matrix
|
|
911
|
+
const lhsSubIds = ctx.variable.subscripts
|
|
912
|
+
if (lhsSubIds.length !== 2) {
|
|
913
|
+
throw new Error(`The LHS of an equation with INVERT MATRIX must have two dimensions`)
|
|
914
|
+
}
|
|
915
|
+
const rowDimId = lhsSubIds[0]
|
|
916
|
+
const colDimId = lhsSubIds[1]
|
|
917
|
+
const matrixSize = sub(colDimId).size
|
|
918
|
+
if (sub(rowDimId).size !== matrixSize) {
|
|
919
|
+
throw new Error(`The LHS of an equation with INVERT MATRIX must be a square matrix`)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Process the size argument. When it resolves to a constant at code gen time (e.g., a
|
|
923
|
+
// numeric literal or an `ELMCOUNT` call), verify that it matches the LHS dimension size,
|
|
924
|
+
// since the generated code always inverts the full LHS-sized matrix.
|
|
925
|
+
const nArg = generateExpr(callExpr.args[1], ctx)
|
|
926
|
+
const staticN = Number.parseFloat(nArg)
|
|
927
|
+
if (!Number.isNaN(staticN) && staticN !== matrixSize) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
`The size argument for INVERT MATRIX (${staticN}) must match the LHS dimension size (${matrixSize})`
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Generate the code that is emitted before the entire block (before any loops are opened)
|
|
934
|
+
const tmpVarId = newTmpVarName()
|
|
935
|
+
switch (ctx.outFormat) {
|
|
936
|
+
case 'c':
|
|
937
|
+
ctx.emitPreLoop(` double* ${tmpVarId} = _INVERT_MATRIX((double*)${matrixArg.varId}, ${matrixSize});`)
|
|
938
|
+
break
|
|
939
|
+
case 'js':
|
|
940
|
+
ctx.emitPreLoop(` let ${tmpVarId} = fns.INVERT_MATRIX(${matrixArg.varId}, ${matrixSize});`)
|
|
941
|
+
break
|
|
942
|
+
default:
|
|
943
|
+
throw new Error(`Unhandled output format '${ctx.outFormat}'`)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Generate the RHS expression used in the inner loop. The C runtime function returns
|
|
947
|
+
// a flat array in row-major order, while the JS one returns a nested array.
|
|
948
|
+
const rowIndexVar = ctx.loopIndexVars.index(rowDimId)
|
|
949
|
+
const colIndexVar = ctx.loopIndexVars.index(colDimId)
|
|
950
|
+
if (ctx.outFormat === 'c') {
|
|
951
|
+
return `${tmpVarId}[${rowIndexVar} * ${matrixSize} + ${colIndexVar}]`
|
|
952
|
+
} else {
|
|
953
|
+
return `${tmpVarId}[${rowIndexVar}][${colIndexVar}]`
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Generate C/JS code for an allocation function call.
|
|
959
|
+
* This includes `_ALLOCATE_AVAILABLE`, `_DEMAND_AT_PRICE`, and `_SUPPLY_AT_PRICE`.
|
|
960
|
+
*
|
|
961
|
+
* @param {*} callExpr The function call expression from the parsed model.
|
|
962
|
+
* @param {GenExprContext} ctx The context used when generating code for the expression.
|
|
963
|
+
* @return {string} The generated C/JS code.
|
|
964
|
+
*/
|
|
965
|
+
function generateAllocationFunctionCall(callExpr, ctx) {
|
|
885
966
|
function validateArg(index, name) {
|
|
886
967
|
const arg = callExpr.args[index]
|
|
887
968
|
if (arg.kind === 'variable-ref') {
|
|
888
969
|
return arg
|
|
889
970
|
} else {
|
|
890
|
-
throw new Error(
|
|
971
|
+
throw new Error(`${callExpr.fnName} argument '${name}' must be a variable reference`)
|
|
891
972
|
}
|
|
892
973
|
}
|
|
893
974
|
|
|
@@ -896,8 +977,9 @@ function generateAllocateAvailableCall(callExpr, ctx) {
|
|
|
896
977
|
function cVarRefWithoutLastIndices(arg, count) {
|
|
897
978
|
const varRef = ctx.cVarRef(arg)
|
|
898
979
|
const origIndexParts = Model.splitRefId(varRef).subscripts
|
|
899
|
-
if (origIndexParts < count) {
|
|
900
|
-
|
|
980
|
+
if (origIndexParts.length < count) {
|
|
981
|
+
const plural = count === 1 ? '' : 's'
|
|
982
|
+
throw new Error(`${callExpr.fnName} argument '${arg.varName}' should have at least ${count} subscript${plural}`)
|
|
901
983
|
}
|
|
902
984
|
const newIndexParts = origIndexParts.slice(0, -count)
|
|
903
985
|
if (newIndexParts.length > 0) {
|
|
@@ -918,11 +1000,10 @@ function generateAllocateAvailableCall(callExpr, ctx) {
|
|
|
918
1000
|
const ppArg = validateArg(1, 'pp')
|
|
919
1001
|
const ppRef = cVarRefWithoutLastIndices(ppArg, 2)
|
|
920
1002
|
|
|
921
|
-
// Process the avail argument; include any subscripts
|
|
922
|
-
const availArg =
|
|
923
|
-
const availRef = ctx.cVarRef(availArg)
|
|
1003
|
+
// Process the avail argument; include any subscripts. The avail arg can be any expression.
|
|
1004
|
+
const availArg = generateExpr(callExpr.args[2], ctx)
|
|
924
1005
|
|
|
925
|
-
//
|
|
1006
|
+
// Allocation functions iterate over the last subscript in its first arg.
|
|
926
1007
|
// The `readEquation` process will have already verified that the last dimension matches
|
|
927
1008
|
// the last dimension for the LHS.
|
|
928
1009
|
const allocDimId = reqArg.subscriptRefs[reqArg.subscriptRefs.length - 1].subId
|
|
@@ -934,13 +1015,14 @@ function generateAllocateAvailableCall(callExpr, ctx) {
|
|
|
934
1015
|
switch (ctx.outFormat) {
|
|
935
1016
|
case 'c':
|
|
936
1017
|
ctx.emitPreInnerLoop(
|
|
937
|
-
` double* ${tmpVarId} =
|
|
1018
|
+
` double* ${tmpVarId} = ${callExpr.fnId}(${reqRef}, (double*)${ppRef}, ${availArg}, ${numRequesters});`
|
|
938
1019
|
)
|
|
939
1020
|
break
|
|
940
1021
|
case 'js':
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
)
|
|
1022
|
+
// TODO: Implement allocation functions for JS
|
|
1023
|
+
// ctx.emitPreInnerLoop(
|
|
1024
|
+
// ` let ${tmpVarId} = ${fnRef(callExpr.fnId, ctx)}(${reqRef}, ${ppRef}, ${availArg}, ${numRequesters});`
|
|
1025
|
+
// )
|
|
944
1026
|
break
|
|
945
1027
|
default:
|
|
946
1028
|
throw new Error(`Unhandled output format '${ctx.outFormat}'`)
|
|
@@ -972,8 +1054,9 @@ function generateAllocateByPriorityCall(callExpr, ctx) {
|
|
|
972
1054
|
function cVarRefWithoutLastIndices(arg, count) {
|
|
973
1055
|
const varRef = ctx.cVarRef(arg)
|
|
974
1056
|
const origIndexParts = Model.splitRefId(varRef).subscripts
|
|
975
|
-
if (origIndexParts < count) {
|
|
976
|
-
|
|
1057
|
+
if (origIndexParts.length < count) {
|
|
1058
|
+
const plural = count === 1 ? '' : 's'
|
|
1059
|
+
throw new Error(`ALLOCATE BY PRIORITY argument '${arg.varName}' should have at least ${count} subscript${plural}`)
|
|
977
1060
|
}
|
|
978
1061
|
const newIndexParts = origIndexParts.slice(0, -count)
|
|
979
1062
|
if (newIndexParts.length > 0) {
|
|
@@ -1032,6 +1115,77 @@ function generateAllocateByPriorityCall(callExpr, ctx) {
|
|
|
1032
1115
|
return `${tmpVarId}[${allocDimId}[${allocLoopIndexVar}]]`
|
|
1033
1116
|
}
|
|
1034
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Generate C/JS code for a `FIND MARKET PRICE` function call.
|
|
1120
|
+
*
|
|
1121
|
+
* @param {*} callExpr The function call expression from the parsed model.
|
|
1122
|
+
* @param {GenExprContext} ctx The context used when generating code for the expression.
|
|
1123
|
+
* @return {string} The generated C/JS code.
|
|
1124
|
+
*/
|
|
1125
|
+
function generateFindMarketPriceFunctionCall(callExpr, ctx) {
|
|
1126
|
+
function validateArg(index, name) {
|
|
1127
|
+
const arg = callExpr.args[index]
|
|
1128
|
+
if (arg.kind === 'variable-ref') {
|
|
1129
|
+
return arg
|
|
1130
|
+
} else {
|
|
1131
|
+
throw new Error(`${callExpr.fnName} argument '${name}' must be a variable reference`)
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Given a C/JS variable reference string (e.g., '_var[i][j]'), return that
|
|
1136
|
+
// string without the last N array index parts
|
|
1137
|
+
function cVarRefWithoutLastIndices(arg, count) {
|
|
1138
|
+
const varRef = ctx.cVarRef(arg)
|
|
1139
|
+
const origIndexParts = Model.splitRefId(varRef).subscripts
|
|
1140
|
+
if (origIndexParts.length < count) {
|
|
1141
|
+
throw new Error(`${callExpr.fnName} argument '${arg}' should have at least ${count} subscripts`)
|
|
1142
|
+
}
|
|
1143
|
+
const newIndexParts = origIndexParts.slice(0, -count)
|
|
1144
|
+
if (newIndexParts.length > 0) {
|
|
1145
|
+
return `${arg.varId}${newIndexParts.map(x => `[${x}]`).join('')}`
|
|
1146
|
+
} else {
|
|
1147
|
+
return arg.varId
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Process the demand quantities argument. Only include subscripts up until the last one;
|
|
1152
|
+
// the implementation function will iterate over the demand quantities array.
|
|
1153
|
+
const demandQtysArg = validateArg(0, 'demandQtys')
|
|
1154
|
+
const demandQtysRef = cVarRefWithoutLastIndices(demandQtysArg, 1)
|
|
1155
|
+
|
|
1156
|
+
// Process the demand profiles argument. Only include subscripts up until the
|
|
1157
|
+
// second to last one; the implementation function will iterate over the priority
|
|
1158
|
+
// profile array.
|
|
1159
|
+
const demandProfilesArg = validateArg(1, 'demandProfiles')
|
|
1160
|
+
const demandProfilesRef = cVarRefWithoutLastIndices(demandProfilesArg, 2)
|
|
1161
|
+
|
|
1162
|
+
// The `FIND MARKET PRICE` implementation sums total demand over all demanders.
|
|
1163
|
+
// When the subscript is an individual index (the Vensim convention of passing the
|
|
1164
|
+
// first element of the array), the count is the size of the index's family dimension;
|
|
1165
|
+
// when it is a dimension (possibly a subdimension), the count is that dimension's size.
|
|
1166
|
+
const demandSubId = demandQtysArg.subscriptRefs[demandQtysArg.subscriptRefs.length - 1].subId
|
|
1167
|
+
const numDemanders = isIndex(demandSubId) ? sub(sub(demandSubId).family).size : sub(demandSubId).size
|
|
1168
|
+
|
|
1169
|
+
// Process the supply quantities argument. Only include subscripts up until the last one;
|
|
1170
|
+
// the implementation function will iterate over the supply quantities array.
|
|
1171
|
+
const supplyQtysArg = validateArg(2, 'supplyQtys')
|
|
1172
|
+
const supplyQtysRef = cVarRefWithoutLastIndices(supplyQtysArg, 1)
|
|
1173
|
+
|
|
1174
|
+
// Process the supply profiles argument. Only include subscripts up until the
|
|
1175
|
+
// second to last one; the implementation function will iterate over the priority
|
|
1176
|
+
// profile array.
|
|
1177
|
+
const supplyProfilesArg = validateArg(3, 'supplyProfiles')
|
|
1178
|
+
const supplyProfilesRef = cVarRefWithoutLastIndices(supplyProfilesArg, 2)
|
|
1179
|
+
|
|
1180
|
+
// The `FIND MARKET PRICE` implementation sums total supply over all suppliers.
|
|
1181
|
+
// The count is determined the same way as for demanders above.
|
|
1182
|
+
const supplySubId = supplyQtysArg.subscriptRefs[supplyQtysArg.subscriptRefs.length - 1].subId
|
|
1183
|
+
const numSuppliers = isIndex(supplySubId) ? sub(sub(supplySubId).family).size : sub(supplySubId).size
|
|
1184
|
+
|
|
1185
|
+
// Generate the RHS expression
|
|
1186
|
+
return `_FIND_MARKET_PRICE(${demandQtysRef}, (double*)${demandProfilesRef}, ${supplyQtysRef}, (double*)${supplyProfilesRef}, ${numDemanders}, ${numSuppliers})`
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1035
1189
|
/**
|
|
1036
1190
|
* Recursively traverse the given expression and call the function when visiting a variable ref.
|
|
1037
1191
|
*
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Copyright (c) 2026 Climate Interactive / New Venture Fund
|
|
2
|
+
|
|
3
|
+
import { isDimension, isIndex, sub } from '../_shared/subscript.js'
|
|
4
|
+
|
|
5
|
+
import Model from './model.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Analyze the dependency cycle clusters reported by toposort and find variables that
|
|
9
|
+
* could be separated into individual index instances to break the cycles. A false
|
|
10
|
+
* cycle can appear when a variable keeps a dimension for which the variables it
|
|
11
|
+
* references are defined (or separated) element by element. The whole-array variable
|
|
12
|
+
* then depends on all elements of its references, merging the otherwise independent
|
|
13
|
+
* dependency chains of each element into a single node. Separating the variable on
|
|
14
|
+
* that dimension restores the element-level dependency structure that Vensim uses
|
|
15
|
+
* when it orders equations.
|
|
16
|
+
*
|
|
17
|
+
* Each cycle cluster is a strongly connected component of the dependency graph.
|
|
18
|
+
* For each variable v in a cluster, propose separating v on a dimension D when a
|
|
19
|
+
* successor of v in the cluster carries an individual index in the family of D and
|
|
20
|
+
* a predecessor of v in the cluster references v by an individual element of D
|
|
21
|
+
* (so that the separation actually removes the edge into the other elements of v).
|
|
22
|
+
* If no candidate satisfies the predecessor condition, fall back to the candidates
|
|
23
|
+
* that satisfy the successor condition alone.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array} cycles The cycle clusters (strongly connected components), where each
|
|
26
|
+
* cluster is an array of the ref IDs of the variables that it contains.
|
|
27
|
+
* @param {Map} outgoingEdges A map of each ref ID to the set of ref IDs that it depends on.
|
|
28
|
+
* @returns {Map} A map from variable name to the set of dimension IDs to separate on.
|
|
29
|
+
*/
|
|
30
|
+
export function separationCandidatesForCycles(cycles, outgoingEdges) {
|
|
31
|
+
const candidates = new Map()
|
|
32
|
+
const looseCandidates = new Map()
|
|
33
|
+
const addCandidate = (map, varName, dimId) => {
|
|
34
|
+
let dimIds = map.get(varName)
|
|
35
|
+
if (!dimIds) {
|
|
36
|
+
dimIds = new Set()
|
|
37
|
+
map.set(varName, dimIds)
|
|
38
|
+
}
|
|
39
|
+
dimIds.add(dimId)
|
|
40
|
+
}
|
|
41
|
+
// The set of (variable name, family) pairs accepted as candidates so far; a variable
|
|
42
|
+
// that will be separated on a family satisfies the predecessor condition for the
|
|
43
|
+
// variables it references, so acceptance is iterated to a fixpoint below
|
|
44
|
+
const acceptedFamilies = new Set()
|
|
45
|
+
for (const scc of cycles) {
|
|
46
|
+
const inScc = new Set(scc)
|
|
47
|
+
// Build a predecessor map for the nodes in this cluster
|
|
48
|
+
const predsOf = new Map(scc.map(refId => [refId, []]))
|
|
49
|
+
for (const refId of scc) {
|
|
50
|
+
for (const succ of outgoingEdges.get(refId) || []) {
|
|
51
|
+
if (inScc.has(succ)) {
|
|
52
|
+
predsOf.get(succ).push(refId)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Collect the possible (v, D) pairs for this cluster
|
|
57
|
+
const sccLooseCandidates = []
|
|
58
|
+
for (const refId of scc) {
|
|
59
|
+
const v = Model.varWithRefId(refId)
|
|
60
|
+
if (!v || !v.subscripts || v.subscripts.length === 0) {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
// Find the families of the individual indices carried by the successors
|
|
64
|
+
// of this node within the cluster
|
|
65
|
+
const succIndexFamilies = new Set()
|
|
66
|
+
for (const succ of outgoingEdges.get(refId) || []) {
|
|
67
|
+
if (inScc.has(succ)) {
|
|
68
|
+
for (const subId of Model.splitRefId(succ).subscripts) {
|
|
69
|
+
if (isIndex(subId)) {
|
|
70
|
+
succIndexFamilies.add(sub(subId).family)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const subId of v.subscripts) {
|
|
76
|
+
if (isDimension(subId) && succIndexFamilies.has(sub(subId).family)) {
|
|
77
|
+
// Skip the candidate when every predecessor references this variable
|
|
78
|
+
// exclusively through a marked full dimension (e.g., `SUM(x[DimA!])`):
|
|
79
|
+
// such references span all elements regardless of separation, so
|
|
80
|
+
// separating this variable can never narrow the incoming edges
|
|
81
|
+
const familyId = sub(subId).family
|
|
82
|
+
const possiblyNarrowing = predsOf.get(refId).some(predRefId => {
|
|
83
|
+
const pv = Model.varWithRefId(predRefId)
|
|
84
|
+
if (!pv) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
const refKinds = elementRefKinds(pv, v.varName, familyId)
|
|
88
|
+
return refKinds.elementRef || refKinds.fullDimRef || !refKinds.markedFullDimRef
|
|
89
|
+
})
|
|
90
|
+
if (possiblyNarrowing) {
|
|
91
|
+
sccLooseCandidates.push({ refId, v, dimId: subId })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Accept the candidates that satisfy the predecessor condition, iterating to a
|
|
97
|
+
// fixpoint since accepting one variable can qualify the variables it references
|
|
98
|
+
const sccAccepted = new Set()
|
|
99
|
+
let changed
|
|
100
|
+
do {
|
|
101
|
+
changed = false
|
|
102
|
+
for (const c of sccLooseCandidates) {
|
|
103
|
+
if (sccAccepted.has(c)) {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const familyId = sub(c.dimId).family
|
|
107
|
+
const predQualifies = predRefId => {
|
|
108
|
+
const pv = Model.varWithRefId(predRefId)
|
|
109
|
+
if (!pv) {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
const refKinds = elementRefKinds(pv, c.v.varName, familyId)
|
|
113
|
+
if (refKinds.markedFullDimRef) {
|
|
114
|
+
// The predecessor operates on all elements in the family (e.g., in a
|
|
115
|
+
// `SUM` expression), so separating this variable does not narrow the edge
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
if (refKinds.elementRef) {
|
|
119
|
+
// The predecessor references this variable by an individual element
|
|
120
|
+
// (or through a subdimension, which Vensim maps element by element)
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
if (refKinds.fullDimRef) {
|
|
124
|
+
// The predecessor references this variable through the full dimension;
|
|
125
|
+
// that reference narrows to an element when the predecessor itself is
|
|
126
|
+
// (or will be) separated on the same family
|
|
127
|
+
if (pv.subscripts?.some(s => isIndex(s) && sub(s).family === familyId)) {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
return acceptedFamilies.has(`${pv.varName}|${familyId}`)
|
|
131
|
+
}
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
if (predsOf.get(c.refId).some(predQualifies)) {
|
|
135
|
+
sccAccepted.add(c)
|
|
136
|
+
acceptedFamilies.add(`${c.v.varName}|${familyId}`)
|
|
137
|
+
addCandidate(candidates, c.v.varName, c.dimId)
|
|
138
|
+
changed = true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} while (changed)
|
|
142
|
+
if (sccAccepted.size === 0) {
|
|
143
|
+
// No candidate in this cluster satisfied the predecessor condition, so fall
|
|
144
|
+
// back to the candidates that satisfied the successor condition alone
|
|
145
|
+
for (const c of sccLooseCandidates) {
|
|
146
|
+
addCandidate(looseCandidates, c.v.varName, c.dimId)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (candidates.size > 0) {
|
|
151
|
+
return candidates
|
|
152
|
+
}
|
|
153
|
+
return looseCandidates
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Examine how the given variable's parsed equation references the named variable
|
|
158
|
+
* in subscript positions of the given family.
|
|
159
|
+
*
|
|
160
|
+
* @param {*} referencingVar The `Variable` instance whose equation is examined.
|
|
161
|
+
* @param {string} varName The name (in canonical form) of the referenced variable.
|
|
162
|
+
* @param {string} familyId The ID of the subscript family of interest.
|
|
163
|
+
* @returns {object} An object with three flags:
|
|
164
|
+
* - `elementRef` is set when a reference uses an individual index or a subdimension
|
|
165
|
+
* (Vensim maps subdimension references element by element, as in the common
|
|
166
|
+
* `x[current pass] = f(x[preceeding pass])` iteration idiom)
|
|
167
|
+
* - `fullDimRef` is set when a reference uses the full dimension for the family
|
|
168
|
+
* - `markedFullDimRef` is set when a reference uses the full dimension marked for
|
|
169
|
+
* vector operations (e.g., `SUM(x[DimA!])`), which always spans all elements
|
|
170
|
+
*/
|
|
171
|
+
function elementRefKinds(referencingVar, varName, familyId) {
|
|
172
|
+
const kinds = { elementRef: false, fullDimRef: false, markedFullDimRef: false }
|
|
173
|
+
const visit = node => {
|
|
174
|
+
if (node === null || typeof node !== 'object') {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(node)) {
|
|
178
|
+
node.forEach(visit)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (node.kind === 'variable-ref' && node.varId === varName && node.subscriptRefs) {
|
|
182
|
+
for (const subRef of node.subscriptRefs) {
|
|
183
|
+
// Remove the mark from a marked dimension (e.g., `_dima!`)
|
|
184
|
+
const marked = subRef.subId.includes('!')
|
|
185
|
+
const subId = subRef.subId.replace('!', '')
|
|
186
|
+
const s = sub(subId)
|
|
187
|
+
if (s?.family !== familyId) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if (isIndex(subId) || s.size < sub(familyId).size) {
|
|
191
|
+
kinds.elementRef = true
|
|
192
|
+
} else if (marked) {
|
|
193
|
+
kinds.markedFullDimRef = true
|
|
194
|
+
} else {
|
|
195
|
+
kinds.fullDimRef = true
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const key of Object.keys(node)) {
|
|
200
|
+
visit(node[key])
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const eqn = referencingVar.parsedEqn
|
|
204
|
+
if (eqn?.rhs?.kind === 'expr') {
|
|
205
|
+
visit(eqn.rhs.expr)
|
|
206
|
+
}
|
|
207
|
+
return kinds
|
|
208
|
+
}
|
package/src/model/model.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as R from 'ramda'
|
|
|
3
3
|
import { canonicalVarId, toPrettyString } from '@sdeverywhere/parse'
|
|
4
4
|
|
|
5
5
|
import B from '../_shared/bufx.js'
|
|
6
|
-
import { decanonicalize, isIterable, strlist, vlog, vsort } from '../_shared/helpers.js'
|
|
6
|
+
import { decanonicalize, isIterable, resetHelperState, strlist, vlog, vsort } from '../_shared/helpers.js'
|
|
7
7
|
import {
|
|
8
8
|
addIndex,
|
|
9
9
|
allAliases,
|
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
indexNamesForSubscript,
|
|
12
12
|
isDimension,
|
|
13
13
|
isIndex,
|
|
14
|
+
resetSubscriptsAndDimensions,
|
|
14
15
|
sub,
|
|
15
16
|
subscriptFamilies
|
|
16
17
|
} from '../_shared/subscript.js'
|
|
17
18
|
import { cName } from '../_shared/var-names.js'
|
|
18
19
|
|
|
20
|
+
import { separationCandidatesForCycles } from './analyze-cycles.js'
|
|
19
21
|
import { expandVar } from './expand-var-instances.js'
|
|
20
22
|
import { readEquation, resolveXmileDimensionWildcards } from './read-equations.js'
|
|
21
23
|
import { readDimensionDefs } from './read-subscripts.js'
|
|
@@ -60,6 +62,12 @@ function resetModelState() {
|
|
|
60
62
|
* Note that this function currently does not return anything and instead stores the parsed subscript
|
|
61
63
|
* definitions in the `subscript` module and the parsed/analyzed variables in this module.
|
|
62
64
|
*
|
|
65
|
+
* After a full read, the variables are sorted in dependency order (and the sorted lists are cached
|
|
66
|
+
* for later use by code generation and variable listings). A false cyclic dependency detected
|
|
67
|
+
* during sorting (one that Vensim's element-by-element evaluation order would not produce) is
|
|
68
|
+
* repaired by separating the variables identified by the cycle analysis and re-reading the model,
|
|
69
|
+
* as if those variables had been listed in `specialSeparationDims` in the spec file.
|
|
70
|
+
*
|
|
63
71
|
* TODO: FIX TYPE
|
|
64
72
|
* @param {*} parsedModel The parsed model structure.
|
|
65
73
|
* @param {*} spec The parsed `spec.json` object.
|
|
@@ -71,6 +79,70 @@ function resetModelState() {
|
|
|
71
79
|
* @param {*} [opts] An optional object used by tests to stop the read process after a specific phase.
|
|
72
80
|
*/
|
|
73
81
|
function read(parsedModel, spec, extData, directData, modelDirname, opts) {
|
|
82
|
+
const maxAttempts = 20
|
|
83
|
+
for (let attempt = 1; ; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
readModel(parsedModel, spec, extData, directData, modelDirname, opts)
|
|
86
|
+
if (
|
|
87
|
+
opts?.stopAfterReadSubscripts ||
|
|
88
|
+
opts?.stopAfterResolveSubscripts ||
|
|
89
|
+
opts?.stopAfterReadVariables ||
|
|
90
|
+
opts?.stopAfterAnalyze
|
|
91
|
+
) {
|
|
92
|
+
// The read was stopped early (used by tests), so skip the dependency sorting
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
// Sort the variables in dependency order now so that any cyclic dependency is
|
|
96
|
+
// detected here; the sorted lists are cached for later use
|
|
97
|
+
auxVars()
|
|
98
|
+
levelVars()
|
|
99
|
+
initVars()
|
|
100
|
+
return
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (!e.cycles || !spec || attempt >= maxAttempts) {
|
|
103
|
+
throw e
|
|
104
|
+
}
|
|
105
|
+
if (process.env.SDE_PRINT_CYCLES === '1') {
|
|
106
|
+
console.error(`Cycle found on attempt ${attempt}:\n${e.cycle.join(' →\n')}\n`)
|
|
107
|
+
}
|
|
108
|
+
// Find variables in the cycle clusters that can be separated to break the cycles
|
|
109
|
+
const candidates = separationCandidatesForCycles(e.cycles, e.outgoingEdges)
|
|
110
|
+
const specialSeparationDims = spec.specialSeparationDims || {}
|
|
111
|
+
let addedDims = false
|
|
112
|
+
for (const [varName, dimIds] of candidates) {
|
|
113
|
+
let dims = specialSeparationDims[varName] || []
|
|
114
|
+
if (!Array.isArray(dims)) {
|
|
115
|
+
dims = [dims]
|
|
116
|
+
}
|
|
117
|
+
for (const dimId of dimIds) {
|
|
118
|
+
if (!dims.includes(dimId)) {
|
|
119
|
+
dims.push(dimId)
|
|
120
|
+
addedDims = true
|
|
121
|
+
if (process.env.SDE_PRINT_CYCLES === '1') {
|
|
122
|
+
console.error(`Breaking a dependency cycle by separating ${varName} on dimension ${dimId}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
specialSeparationDims[varName] = dims
|
|
127
|
+
}
|
|
128
|
+
if (!addedDims) {
|
|
129
|
+
// The cycle analysis did not find any new separations, so the cycle cannot
|
|
130
|
+
// be broken this way; report it to the user
|
|
131
|
+
throw e
|
|
132
|
+
}
|
|
133
|
+
spec.specialSeparationDims = specialSeparationDims
|
|
134
|
+
// Reset the model state and read the model again with the added separations
|
|
135
|
+
resetHelperState()
|
|
136
|
+
resetSubscriptsAndDimensions()
|
|
137
|
+
resetModelState()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Perform a single pass of the model read process (see `read` above).
|
|
144
|
+
*/
|
|
145
|
+
function readModel(parsedModel, spec, extData, directData, modelDirname, opts) {
|
|
74
146
|
// Some arrays need to be separated into variables with individual indices to
|
|
75
147
|
// prevent eval cycles. They are manually added to the spec file.
|
|
76
148
|
let specialSeparationDims = spec.specialSeparationDims
|
|
@@ -508,6 +508,10 @@ function visitFunctionCall(v, callExpr, context) {
|
|
|
508
508
|
argModes[2] = 'init'
|
|
509
509
|
break
|
|
510
510
|
|
|
511
|
+
case '_DEMAND_AT_PRICE':
|
|
512
|
+
validateCallArgs(callExpr, 3)
|
|
513
|
+
break
|
|
514
|
+
|
|
511
515
|
case '_DEPRECIATE_STRAIGHTLINE':
|
|
512
516
|
validateCallDepth(callExpr, context)
|
|
513
517
|
validateCallArgs(callExpr, 4)
|
|
@@ -522,6 +526,10 @@ function visitFunctionCall(v, callExpr, context) {
|
|
|
522
526
|
argModes[2] = 'init'
|
|
523
527
|
break
|
|
524
528
|
|
|
529
|
+
case '_FIND_MARKET_PRICE':
|
|
530
|
+
validateCallArgs(callExpr, 4)
|
|
531
|
+
break
|
|
532
|
+
|
|
525
533
|
case '_GAME':
|
|
526
534
|
validateCallDepth(callExpr, context)
|
|
527
535
|
validateCallArgs(callExpr, 1)
|
|
@@ -587,6 +595,11 @@ function visitFunctionCall(v, callExpr, context) {
|
|
|
587
595
|
argModes[1] = 'init'
|
|
588
596
|
break
|
|
589
597
|
|
|
598
|
+
case '_INVERT_MATRIX':
|
|
599
|
+
validateCallDepth(callExpr, context)
|
|
600
|
+
validateCallArgs(callExpr, 2)
|
|
601
|
+
break
|
|
602
|
+
|
|
590
603
|
case '_NPV':
|
|
591
604
|
validateCallArgs(callExpr, 4)
|
|
592
605
|
addFnReference = false
|
|
@@ -612,6 +625,10 @@ function visitFunctionCall(v, callExpr, context) {
|
|
|
612
625
|
generateSmoothVariables(v, callExpr, context)
|
|
613
626
|
break
|
|
614
627
|
|
|
628
|
+
case '_SUPPLY_AT_PRICE':
|
|
629
|
+
validateCallArgs(callExpr, 3)
|
|
630
|
+
break
|
|
631
|
+
|
|
615
632
|
case '_TREND':
|
|
616
633
|
validateCallArgs(callExpr, 3)
|
|
617
634
|
addFnReference = false
|
|
@@ -847,25 +864,27 @@ function visitFunctionCall(v, callExpr, context) {
|
|
|
847
864
|
if (callExpr.fnId === '_WITH_LOOKUP' && index > 1) {
|
|
848
865
|
// XXX: For `WITH LOOKUP` calls, only process the first argument; need to generalize this
|
|
849
866
|
break
|
|
850
|
-
} else if (
|
|
851
|
-
|
|
867
|
+
} else if (
|
|
868
|
+
(callExpr.fnId === '_ALLOCATE_AVAILABLE' && index === 1) ||
|
|
869
|
+
(callExpr.fnId === '_DEMAND_AT_PRICE' && index === 1) ||
|
|
870
|
+
(callExpr.fnId === '_SUPPLY_AT_PRICE' && index === 1) ||
|
|
871
|
+
(callExpr.fnId === '_FIND_MARKET_PRICE' && (index === 1 || index === 3))
|
|
872
|
+
) {
|
|
873
|
+
// Handle the second (`pp` or priority profile) argument of allocation function calls
|
|
852
874
|
// specially. An example call with a 2D `pp` looks like this:
|
|
853
875
|
// shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[branch,ptype], avail) ~~|
|
|
854
876
|
// Or a 3D `pp` with a dimension:
|
|
855
877
|
// shipments[item,branch] = ALLOCATE AVAILABLE(demand[branch], priority[item,branch,ptype], avail) ~~|
|
|
856
878
|
// Or a 3D `pp` with a specific subscript:
|
|
857
879
|
// shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[item1,branch,ptype], avail) ~~|
|
|
858
|
-
// Vensim requires passing a reference with `ptype` as the last subscript
|
|
859
|
-
// implementation uses the `ppriority` and `pwidth` values (the `ptype` is currently assumed
|
|
860
|
-
// to be 3). Therefore we need to add references to all variants of the variable, not just
|
|
861
|
-
// the ones for `ptype`.
|
|
880
|
+
// Vensim requires passing a reference with `ptype` as the last subscript.
|
|
862
881
|
if (argExpr.kind !== 'variable-ref') {
|
|
863
|
-
throw new Error(
|
|
882
|
+
throw new Error(`${callExpr.fnName} argument 'pp' must be a variable reference`)
|
|
864
883
|
}
|
|
865
884
|
// TODO: Throw an error if the last dimension of arg0 does not match last dimension of LHS
|
|
866
885
|
// TODO: Throw an error if the second-to-last dimension of arg1 does not match last dimension of LHS
|
|
867
886
|
// TODO: Throw an error if the last subscript of arg1 does not have the "shape" of a `ppriority` dimension
|
|
868
|
-
// TODO: Throw an error if the `ptype` value is
|
|
887
|
+
// TODO: Throw an error if the `ptype` value is unsupported
|
|
869
888
|
// Get the RHS subscript/dimension IDs
|
|
870
889
|
const rhsVarBaseRefId = argExpr.varId
|
|
871
890
|
const rhsVarSubIds = argExpr.subscriptRefs?.map(subRef => subRef.subId) || []
|
|
@@ -120,22 +120,27 @@ function variablesForEquation(eqn, specialSeparationDims, separateAllVarsWithDim
|
|
|
120
120
|
let separationDims = specialSeparationDims[baseVarId] || []
|
|
121
121
|
if (!Array.isArray(separationDims)) {
|
|
122
122
|
separationDims = [separationDims]
|
|
123
|
+
} else {
|
|
124
|
+
// Copy the array so that the spec object is not mutated below
|
|
125
|
+
separationDims = [...separationDims]
|
|
123
126
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
separationDims
|
|
137
|
-
|
|
127
|
+
// Additionally, separate on dims from `separateAllVarsWithDims` if the var
|
|
128
|
+
// matches one of the dim lists.
|
|
129
|
+
for (let dimList of separateAllVarsWithDims) {
|
|
130
|
+
// Technically we allow each entry in the spec array to be either a single
|
|
131
|
+
// dim ID string or an array of dim IDs, so convert to array if needed
|
|
132
|
+
if (!Array.isArray(dimList)) {
|
|
133
|
+
dimList = [dimList]
|
|
134
|
+
}
|
|
135
|
+
// The list entry from the spec is only considered a match if every dimension
|
|
136
|
+
// in the spec list appears on the LHS
|
|
137
|
+
if (dimList.every(dim => subIds.includes(dim))) {
|
|
138
|
+
for (const dim of dimList) {
|
|
139
|
+
if (!separationDims.includes(dim)) {
|
|
140
|
+
separationDims.push(dim)
|
|
141
|
+
}
|
|
138
142
|
}
|
|
143
|
+
break
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
positionsToExpand = subscriptPositionsToExpand(subIds, exceptSubIdSets, separationDims, variable.modelFormula)
|
package/src/model/toposort.js
CHANGED
|
@@ -42,7 +42,19 @@ function toposort(nodes, edges) {
|
|
|
42
42
|
} catch (_) {
|
|
43
43
|
nodeRep = ''
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
var chain = [...predecessors]
|
|
46
|
+
var error = new Error('Found cyclic dependency during toposort:\n' + chain.join(' →\n') + ' →' + nodeRep)
|
|
47
|
+
// Attach the cycle itself (the portion of the dependency chain from the first
|
|
48
|
+
// occurrence of the repeated node) so that callers can analyze it.
|
|
49
|
+
error.cycle = chain.slice(chain.indexOf(node))
|
|
50
|
+
// Also attach all cycle clusters in the graph (the strongly connected components
|
|
51
|
+
// with more than one node, plus any single node that depends on itself) along
|
|
52
|
+
// with the graph edges so that callers can analyze every cycle at once.
|
|
53
|
+
error.cycles = stronglyConnectedComponents(nodes, outgoingEdges).filter(
|
|
54
|
+
scc => scc.length > 1 || (outgoingEdges.get(scc[0]) || new Set()).has(scc[0])
|
|
55
|
+
)
|
|
56
|
+
error.outgoingEdges = outgoingEdges
|
|
57
|
+
throw error
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
if (!nodesHash.has(node)) {
|
|
@@ -70,6 +82,76 @@ function toposort(nodes, edges) {
|
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Find the strongly connected components of the graph using an iterative form of
|
|
87
|
+
* Tarjan's algorithm (iterative to avoid stack overflow on the deep dependency
|
|
88
|
+
* chains found in large models).
|
|
89
|
+
*
|
|
90
|
+
* @param {Array} nodes The nodes in the graph.
|
|
91
|
+
* @param {Map} outgoingEdges A map of each node to the set of nodes that it points to.
|
|
92
|
+
* @returns {Array} An array of strongly connected components, where each component is
|
|
93
|
+
* an array of the nodes that it contains.
|
|
94
|
+
*/
|
|
95
|
+
function stronglyConnectedComponents(nodes, outgoingEdges) {
|
|
96
|
+
var index = 0
|
|
97
|
+
var nodeIndex = new Map()
|
|
98
|
+
var lowlink = new Map()
|
|
99
|
+
var onStack = new Set()
|
|
100
|
+
var stack = []
|
|
101
|
+
var sccs = []
|
|
102
|
+
for (var start of nodes) {
|
|
103
|
+
if (nodeIndex.has(start)) {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
var frames = [{ node: start, edges: null, i: 0, child: undefined }]
|
|
107
|
+
while (frames.length > 0) {
|
|
108
|
+
var frame = frames[frames.length - 1]
|
|
109
|
+
var node = frame.node
|
|
110
|
+
if (frame.edges === null) {
|
|
111
|
+
// First visit to this node
|
|
112
|
+
nodeIndex.set(node, index)
|
|
113
|
+
lowlink.set(node, index)
|
|
114
|
+
index++
|
|
115
|
+
stack.push(node)
|
|
116
|
+
onStack.add(node)
|
|
117
|
+
frame.edges = Array.from(outgoingEdges.get(node) || [])
|
|
118
|
+
} else if (frame.child !== undefined) {
|
|
119
|
+
// Returning from a child visit
|
|
120
|
+
lowlink.set(node, Math.min(lowlink.get(node), lowlink.get(frame.child)))
|
|
121
|
+
frame.child = undefined
|
|
122
|
+
}
|
|
123
|
+
var descended = false
|
|
124
|
+
while (frame.i < frame.edges.length) {
|
|
125
|
+
var w = frame.edges[frame.i++]
|
|
126
|
+
if (!nodeIndex.has(w)) {
|
|
127
|
+
frame.child = w
|
|
128
|
+
frames.push({ node: w, edges: null, i: 0, child: undefined })
|
|
129
|
+
descended = true
|
|
130
|
+
break
|
|
131
|
+
} else if (onStack.has(w)) {
|
|
132
|
+
lowlink.set(node, Math.min(lowlink.get(node), nodeIndex.get(w)))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (descended) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
// All edges have been visited, so the node is complete
|
|
139
|
+
if (lowlink.get(node) === nodeIndex.get(node)) {
|
|
140
|
+
var scc = []
|
|
141
|
+
var member
|
|
142
|
+
do {
|
|
143
|
+
member = stack.pop()
|
|
144
|
+
onStack.delete(member)
|
|
145
|
+
scc.push(member)
|
|
146
|
+
} while (member !== node)
|
|
147
|
+
sccs.push(scc)
|
|
148
|
+
}
|
|
149
|
+
frames.pop()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return sccs
|
|
153
|
+
}
|
|
154
|
+
|
|
73
155
|
function uniqueNodes(arr) {
|
|
74
156
|
var res = new Set()
|
|
75
157
|
for (var i = 0, len = arr.length; i < len; i++) {
|