@sdeverywhere/compile 0.7.31 → 0.7.33
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/README.md +2 -2
- package/package.json +6 -3
- package/src/_shared/helpers.js +11 -4
- package/src/_shared/model-spec.js +174 -0
- package/src/_shared/normalize-model-spec.js +32 -0
- package/src/_shared/read-dat.js +10 -3
- package/src/_shared/xlsx.js +60 -45
- package/src/generate/gen-code.js +10 -12
- package/src/generate/gen-equation.js +5 -1
- package/src/generate/gen-expr.js +171 -17
- package/src/index.js +20 -5
- 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/src/parse-and-generate.js +50 -19
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') {
|
|
@@ -472,7 +483,7 @@ function generateLevelInit(callExpr, ctx) {
|
|
|
472
483
|
const fnId = callExpr.fnId
|
|
473
484
|
|
|
474
485
|
// Get the index of the argument holding the initial value expression
|
|
475
|
-
let initialArgIndex
|
|
486
|
+
let initialArgIndex
|
|
476
487
|
switch (fnId) {
|
|
477
488
|
case '_ACTIVE_INITIAL':
|
|
478
489
|
case '_INTEG':
|
|
@@ -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
|
*
|
package/src/index.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// Copyright (c) 2022 Climate Interactive / New Venture Fund
|
|
2
2
|
|
|
3
|
+
//
|
|
4
|
+
// Note that the types below are declared (as JSDoc typedefs) alongside the code that
|
|
5
|
+
// defines and uses them. We use `export *` here rather than re-declaring each typedef
|
|
6
|
+
// so that the generated declarations refer to a single definition of each type.
|
|
7
|
+
//
|
|
8
|
+
export * from './_shared/model-spec.js'
|
|
9
|
+
|
|
3
10
|
// XXX: For now we re-export the preprocess function from the parse package
|
|
4
11
|
// mainly for use by the cli package (so that we don't need to have the cli
|
|
5
12
|
// package directly depend on the parse package)
|
|
6
13
|
export { preprocessVensimModel } from '@sdeverywhere/parse'
|
|
7
14
|
|
|
8
15
|
export { canonicalName } from './_shared/helpers.js'
|
|
9
|
-
export
|
|
16
|
+
export * from './_shared/read-dat.js'
|
|
10
17
|
export { generateCode } from './generate/gen-code.js'
|
|
11
|
-
export
|
|
18
|
+
export * from './parse-and-generate.js'
|
|
12
19
|
|
|
13
20
|
import { resetHelperState } from './_shared/helpers.js'
|
|
14
21
|
import { resetSubscriptsAndDimensions } from './_shared/subscript.js'
|
|
@@ -29,8 +36,12 @@ export function resetState() {
|
|
|
29
36
|
/**
|
|
30
37
|
* @hidden This is not yet part of the public API; it is exposed only for use
|
|
31
38
|
* in the experimental playground app.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} mdlContent The string containing the Vensim model text.
|
|
41
|
+
* @param {string} [modelDir] The absolute path to the directory containing data files.
|
|
42
|
+
* @return {import('./parse-and-generate.js').ParsedModel} A parsed tree representation of the model.
|
|
32
43
|
*/
|
|
33
|
-
export function parseInlineVensimModel(mdlContent
|
|
44
|
+
export function parseInlineVensimModel(mdlContent, modelDir) {
|
|
34
45
|
// For tests that parse inline model text, in the case of the legacy parser, don't run
|
|
35
46
|
// the preprocess step, and in the case of the new parser (which implicitly runs the
|
|
36
47
|
// preprocess step), don't sort the definitions. This makes it easier to do apples
|
|
@@ -41,8 +52,12 @@ export function parseInlineVensimModel(mdlContent /*: string*/, modelDir /*?: st
|
|
|
41
52
|
/**
|
|
42
53
|
* @hidden This is not yet part of the public API; it is exposed only for use
|
|
43
54
|
* in the experimental playground app.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} mdlContent The string containing the XMILE model text.
|
|
57
|
+
* @param {string} [modelDir] The absolute path to the directory containing data files.
|
|
58
|
+
* @return {import('./parse-and-generate.js').ParsedModel} A parsed tree representation of the model.
|
|
44
59
|
*/
|
|
45
|
-
export function parseInlineXmileModel(mdlContent
|
|
60
|
+
export function parseInlineXmileModel(mdlContent, modelDir) {
|
|
46
61
|
return parseModel(mdlContent, 'xmile', modelDir)
|
|
47
62
|
}
|
|
48
63
|
|
|
@@ -50,6 +65,6 @@ export function parseInlineXmileModel(mdlContent /*: string*/, modelDir /*?: str
|
|
|
50
65
|
* @hidden This is not yet part of the public API; it is exposed only for use
|
|
51
66
|
* in the experimental playground app.
|
|
52
67
|
*/
|
|
53
|
-
export function getModelListing()
|
|
68
|
+
export function getModelListing() {
|
|
54
69
|
return Model.jsonList()
|
|
55
70
|
}
|
|
@@ -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
|