linny-r 1.1.23 → 1.2.0

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.
@@ -54,7 +54,8 @@ class LinnyRModel {
54
54
  this.currency_unit = CONFIGURATION.default_currency_unit;
55
55
  this.default_unit = CONFIGURATION.default_scale_unit;
56
56
  this.decimal_comma = CONFIGURATION.decimal_comma;
57
- this.scale_units = [this.default_unit];
57
+ // NOTE: Default scale unit list comprises only the primitive base unit
58
+ this.scale_units = {'1': new ScaleUnit('1', '1', '1')};
58
59
  this.actors = {};
59
60
  this.products = {};
60
61
  this.processes = {};
@@ -735,8 +736,9 @@ class LinnyRModel {
735
736
  }
736
737
  // Then infer dimensions from the datasets (which have been changed)
737
738
  this.inferDimensions();
738
- // Find dimension that has been removed or (only reduced)
739
- let removed = null, reduced = null;
739
+ // Find dimension that has been removed (or only reduced)
740
+ let removed = null,
741
+ reduced = null;
740
742
  if(od.length > this.dimensions.length) {
741
743
  // Dimension removed => find out which one
742
744
  let rd = null;
@@ -781,7 +783,14 @@ class LinnyRModel {
781
783
  UI.updateControllerDialogs('X');
782
784
  }
783
785
  }
784
-
786
+
787
+ renameSelectorInExperiments(olds, news) {
788
+ // Replace all occurrences of `olds` in dimension strings by `news`
789
+ for(let i = 0; i < this.experiments.length; i++) {
790
+ this.experiments[i].renameSelectorInDimensions(olds, news);
791
+ }
792
+ }
793
+
785
794
  ignoreClusterInThisRun(c) {
786
795
  // Returns TRUE iff an experiment is running and cluster `c` is in the
787
796
  // clusters-to-ignore list and its selectors in this list overlap with the
@@ -823,16 +832,183 @@ class LinnyRModel {
823
832
  return this.actors[id];
824
833
  }
825
834
 
826
- addScaleUnit(name) {
835
+ addScaleUnit(name, scalar='1', base_unit='1') {
836
+ // Add a scale unit to the model, and return its symbol
837
+ // (1) To permit things like 1 kWh = 3.6 MJ, and 1 GJ = 1000 MJ,
838
+ // scale units have a multiplier and a base unit; by default,
839
+ // multiplier = 1 and base unit = '1' to denote "atomic unit"
840
+ // (2) Linny-R remains agnostic about physics, SI standards etc.
841
+ // so modelers can do anything they like
842
+ // (3) Linny-R may in the future be extended with a unit consistency
843
+ // check
827
844
  name = UI.cleanName(name);
828
- if(name === '' || name === ' ') return this.default_unit;
829
- // scale units (case sensitive!) are assumed to be universal
830
- if(this.scale_units.indexOf(name) === -1) {
831
- this.scale_units.push(name);
845
+ // NOTE: empty string denotes default unit, so test this first to
846
+ // avoid a warning
847
+ if(!name) return this.default_unit;
848
+ // NOTE: do not replace or modify an existing scale unit
849
+ if(!this.scale_units.hasOwnProperty(name)) {
850
+ this.scale_units[name] = new ScaleUnit(name, scalar, base_unit);
851
+ UI.updateScaleUnitList();
832
852
  }
833
853
  return name;
834
854
  }
835
855
 
856
+ addPreconfiguredScaleUnits() {
857
+ // Add scale units defined in file `config.js` (by default: none)
858
+ for(let i = 0; i < CONFIGURATION.scale_units.length; i++) {
859
+ const su = CONFIGURATION.scale_units[i];
860
+ this.addScaleUnit(...su);
861
+ }
862
+ }
863
+
864
+ cleanUpScaleUnits() {
865
+ // Remove all scale units that are not used and have base unit '1'
866
+ const suiu = {};
867
+ // Collect all non-empty product units
868
+ for(let p in this.products) if(this.products.hasOwnProperty(p)) {
869
+ const su = this.products[p].scale_unit;
870
+ if(su) suiu[su] = true;
871
+ }
872
+ // Likewise collect all non-empty dataset units
873
+ for(let ds in this.datasets) if(this.datasets.hasOwnProperty(ds)) {
874
+ const su = this.datasets[ds].scale_unit;
875
+ if(su) suiu[su] = true;
876
+ }
877
+ // Also collect base units and units having base unit other than '1'
878
+ for(let su in this.scale_units) if(this.scale_units.hasOwnProperty(su)) {
879
+ const u = this.scale_units[su];
880
+ suiu[u.base_unit] = true;
881
+ if(u.base_unit !== '1') suiu[u.name] = true;
882
+ }
883
+ // Now all scale units NOT in `suiu` can be removed
884
+ for(let su in this.scale_units) if(this.scale_units.hasOwnProperty(su)) {
885
+ if(!suiu.hasOwnProperty(su)) {
886
+ delete this.scale_units[su];
887
+ }
888
+ }
889
+ }
890
+
891
+ renameScaleUnit(oldu, newu) {
892
+ let nr = 0;
893
+ // Update the default product unit
894
+ if(MODEL.default_unit === oldu) {
895
+ MODEL.default_unit = newu;
896
+ nr++;
897
+ }
898
+ // Rename product scale units
899
+ for(let p in MODEL.products) if(MODEL.products.hasOwnProperty(p)) {
900
+ if(MODEL.products[p].scale_unit === oldu) {
901
+ MODEL.products[p].scale_unit = newu;
902
+ nr++;
903
+ }
904
+ }
905
+ // Rename product and dataset units
906
+ for(let ds in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(ds)) {
907
+ if(MODEL.datasets[ds].scale_unit === oldu) {
908
+ MODEL.datasets[ds].scale_unit = newu;
909
+ nr++;
910
+ }
911
+ }
912
+ // Also rename conversion units in note fields
913
+ for(let k in MODEL.clusters) if(MODEL.clusters.hasOwnProperty(k)) {
914
+ const c = MODEL.clusters[k];
915
+ for(let i = 0; i < c.notes.length; i++) {
916
+ const
917
+ n = c.notes[i],
918
+ tags = n.contents.match(/\[\[[^\]]+\]\]/g);
919
+ if(tags) {
920
+ for(let i = 0; i < tags.length; i++) {
921
+ const
922
+ ot = tags[i],
923
+ parts = ot.split('->'),
924
+ last = parts.pop().trim();
925
+ if(last === oldu + ']]') {
926
+ const nt = parts.join('->') + `->${newu}]]`;
927
+ n.contents = n.contents.replace(ot, nt);
928
+ }
929
+ }
930
+ n.parsed = false;
931
+ }
932
+ }
933
+ }
934
+ // Also rename scale units in expressions (quoted if needed)
935
+ oldu = UI.nameAsConstantString(oldu);
936
+ newu = UI.nameAsConstantString(newu);
937
+ const ax = MODEL.allExpressions;
938
+ if(oldu.startsWith("'")) {
939
+ // Simple case: replace quoted old unit by new
940
+ for(let i = 0; i < ax.length; i++) {
941
+ let parts = ax[i].text.split(oldu);
942
+ nr += parts.length - 1;
943
+ ax[i].text = parts.join(newu);
944
+ }
945
+ } else {
946
+ // Old unit is not enclosed in quotes; then care must be taken
947
+ // not to replace partial matches, e.g., kton => ktonne when 'ton'
948
+ // is renamed to 'tonne'; solution is to ensure that old unit must
949
+ // have a separator character on both sides, or it is not replaced
950
+ const
951
+ sep = SEPARATOR_CHARS + "]'",
952
+ esep = escapeRegex(sep),
953
+ eou = escapeRegex(oldu),
954
+ raw = `\[[^\[]*\]|(^|\s|[${esep}])(${eou})($|\s|[${esep}])`;
955
+ const
956
+ // NOTE: this will match anything within brackets, and the unit
957
+ re = new RegExp(raw, 'g');
958
+ // Iterate over all expressions
959
+ for(let i = 0; i < ax.length; i++) {
960
+ let ot = ax[i].text,
961
+ nt = '',
962
+ m = re.exec(ot);
963
+ while (m !== null) {
964
+ // NOTE: A match with the unit name will have 3 groups, and the
965
+ // middle one (so element with index 2) should then be equal to
966
+ // the unit name; other matches will be bracketed text that
967
+ // should be ignored
968
+ if(m.length > 2 && m[2] === oldu) {
969
+ // NOTE: lastIndex points right after the complete match,
970
+ // and this match m[0] can have separator characters on both
971
+ // sides which should not be removed
972
+ const
973
+ parts = m[0].split(oldu),
974
+ left = parts[0].split('').pop(),
975
+ right = (parts[1] ? parts[1].split('')[0] : '');
976
+ // NOTE: if one separator is a single quote, the the other
977
+ // must also be a single quote (to avoid 'ton/m3' to be
978
+ // renamed when 'ton' is renamed)
979
+ if(!((left === "'" || right === "'") && left !== right) &&
980
+ sep.indexOf(left) >= 0 && sep.indexOf(right) >= 0) {
981
+ // Separator chars on both side =>
982
+ nt += ot.slice(0, re.lastIndex - m[0].length) + parts.join(newu);
983
+ ot = ot.slice(re.lastIndex);
984
+ }
985
+ }
986
+ m = re.exec(ot);
987
+ }
988
+ if(nt) {
989
+ ax[i].text = nt + ot;
990
+ nr++;
991
+ }
992
+ }
993
+ }
994
+ if(nr) {
995
+ UI.notify(pluralS(nr, 'scale unit') + ' renamed');
996
+ UI.drawDiagram(MODEL);
997
+ }
998
+ }
999
+
1000
+ unitConversionMultiplier(from, to) {
1001
+ // Compute and return the FROM : TO unit conversion rate
1002
+ // NOTE: no conversion if TO is the primitive unit
1003
+ if(from === to || to === '1' || to === '') return 1;
1004
+ const fsu = this.scale_units[from];
1005
+ if(fsu) {
1006
+ const fcr = fsu.conversionRates();
1007
+ if(fcr.hasOwnProperty(to)) return fcr[to];
1008
+ }
1009
+ return VM.UNDEFINED;
1010
+ }
1011
+
836
1012
  addNote(node=null) {
837
1013
  // Add a note to the focal cluster
838
1014
  let n = new Note(this.focal_cluster);
@@ -840,7 +1016,7 @@ class LinnyRModel {
840
1016
  this.focal_cluster.notes.push(n);
841
1017
  return n;
842
1018
  }
843
-
1019
+
844
1020
  addCluster(name, actor_name, node=null) {
845
1021
  // NOTE: Adapt XML saved by legacy Linny-R software
846
1022
  if(name === UI.FORMER_TOP_CLUSTER_NAME ||
@@ -1006,7 +1182,7 @@ class LinnyRModel {
1006
1182
  const id = UI.nameToID(name);
1007
1183
  let d = this.namedObjectByID(id);
1008
1184
  if(d) {
1009
- if(IO_CONTEXT) {
1185
+ if(IO_CONTEXT && d !== this.equations_dataset) {
1010
1186
  IO_CONTEXT.supersede(d);
1011
1187
  } else {
1012
1188
  // Preserve name uniqueness
@@ -1015,11 +1191,27 @@ class LinnyRModel {
1015
1191
  }
1016
1192
  }
1017
1193
  d = new Dataset(name);
1018
- // NOTE: when equations dataset is added, recognize it as such, or its
1019
- // modifier selectors may be rejected while initializing from XML
1020
- if(name === UI.EQUATIONS_DATASET_NAME) this.equations_dataset = d;
1194
+ let eqds = null;
1195
+ if(name === UI.EQUATIONS_DATASET_NAME) {
1196
+ // When including a module, the current equations must be saved,
1197
+ // then the newly parsed dataset must have its modifiers prefixed,
1198
+ // and then be merged with the original equations dataset
1199
+ if(IO_CONTEXT) eqds = this.equations_dataset;
1200
+ // When equations dataset is added, recognize it as such, or its
1201
+ // modifier selectors may be rejected while initializing from XML
1202
+ this.equations_dataset = d;
1203
+ }
1021
1204
  if(node) d.initFromXML(node);
1022
- this.datasets[id] = d;
1205
+ if(eqds) {
1206
+ // Restore pointer to original equations dataset
1207
+ this.equations_dataset = eqds;
1208
+ // Add included equations with prefixed names
1209
+ console.log('HERE', d);
1210
+ // Return the extended equations dataset
1211
+ return eqds;
1212
+ } else {
1213
+ this.datasets[id] = d;
1214
+ }
1023
1215
  return d;
1024
1216
  }
1025
1217
 
@@ -1269,8 +1461,8 @@ class LinnyRModel {
1269
1461
 
1270
1462
  setSelection() {
1271
1463
  // Set selection to contain all selected entities in the focal cluster
1272
- // NOTE: to be called after loading a model, and after UNDO/REDO (and then
1273
- // before drawing the diagram)
1464
+ // NOTE: to be called after loading a model, and after UNDO/REDO (and
1465
+ // then before drawing the diagram)
1274
1466
  const fc = this.focal_cluster;
1275
1467
  this.selection.length = 0;
1276
1468
  this.selection_related_arrows.length = 0;
@@ -1835,67 +2027,90 @@ class LinnyRModel {
1835
2027
  }
1836
2028
  }
1837
2029
 
1838
- replaceEntityInExpressions(en1, en2) {
1839
- // Replace entity name `en1` by `en2` in all variables in all expressions
1840
- // (provided that they are not identical)
1841
- if(en1 === en2) return;
1842
- // NOTE: ignore case and multiple spaces in `en1`, but conserve those in
1843
- // new name `en2` (except for leading and trailing spaces)
1844
- en1 = en1.trim().replace(/\s+/g, ' ').toLowerCase();
1845
- en2 = en2.trim();
1846
- // NOTE: Neither entity name may be empty
1847
- if(!en1 || !en2) return;
1848
- // NOTE: use the `rewrite` method of class IOContext; this will keep track
1849
- // of the number of replacements made
1850
- const ioc = new IOContext();
1851
- // Check all actor weight expressions
2030
+ get allExpressions() {
2031
+ // Returns list of all Expression objects
2032
+ // NOTE: start with dataset expressions, so that when recompiling
2033
+ // their `level-based` property is set before recompiling the
2034
+ // other expressions
2035
+ const x = [];
2036
+ for(let k in this.datasets) if(this.datasets.hasOwnProperty(k)) {
2037
+ const ds = this.datasets[k];
2038
+ // NOTE: dataset modifier expressions include the equations
2039
+ for(let m in ds.modifiers) if(ds.modifiers.hasOwnProperty(m)) {
2040
+ x.push(ds.modifiers[m].expression);
2041
+ }
2042
+ }
1852
2043
  for(let k in this.actors) if(this.actors.hasOwnProperty(k)) {
1853
- ioc.rewrite(this.actors[k].weight, en1, en2);
2044
+ x.push(this.actors[k].weight);
1854
2045
  }
1855
- // Check all process attribute expressions
1856
2046
  for(let k in this.processes) if(this.processes.hasOwnProperty(k)) {
1857
2047
  const p = this.processes[k];
1858
- ioc.rewrite(p.lower_bound, en1, en2);
1859
- ioc.rewrite(p.upper_bound, en1, en2);
1860
- ioc.rewrite(p.initial_level, en1, en2);
1861
- ioc.rewrite(p.pace_expression, en1, en2);
2048
+ x.push(p.lower_bound, p.upper_bound, p.initial_level, p.pace_expression);
1862
2049
  }
1863
- // Check all product attribute expressions
1864
2050
  for(let k in this.products) if(this.products.hasOwnProperty(k)) {
1865
2051
  const p = this.products[k];
1866
- ioc.rewrite(p.lower_bound, en1, en2);
1867
- ioc.rewrite(p.upper_bound, en1, en2);
1868
- ioc.rewrite(p.initial_level, en1, en2);
1869
- ioc.rewrite(p.price, en1, en2);
2052
+ x.push(p.lower_bound, p.upper_bound, p.initial_level, p.price);
1870
2053
  }
1871
- // Check all notes in clusters for their color expressions and fields
1872
2054
  for(let k in this.clusters) if(this.clusters.hasOwnProperty(k)) {
1873
2055
  const c = this.clusters[k];
1874
2056
  for(let i = 0; i < c.notes.length; i++) {
1875
2057
  const n = c.notes[i];
1876
- ioc.rewrite(n.color, en1, en2);
1877
- // Also rename entities in note fields
1878
- n.rewriteFields(en1, en2);
2058
+ x.push(n.color);
1879
2059
  }
1880
2060
  }
1881
- // Check all link rate & delay expressions
1882
2061
  for(let k in this.links) if(this.links.hasOwnProperty(k)) {
1883
- ioc.rewrite(this.links[k].relative_rate, en1, en2);
1884
- ioc.rewrite(this.links[k].flow_delay, en1, en2);
2062
+ const l = this.links[k];
2063
+ x.push(l.relative_rate, l.flow_delay);
1885
2064
  }
1886
- // Check all dataset modifier expressions
1887
- for(let k in this.datasets) if(this.datasets.hasOwnProperty(k)) {
1888
- const ds = this.datasets[k];
1889
- for(let m in ds.modifiers) if(ds.modifiers.hasOwnProperty(m)) {
1890
- ioc.rewrite(ds.modifiers[m].expression, en1, en2);
2065
+ return x;
2066
+ }
2067
+
2068
+ replaceEntityInExpressions(en1, en2) {
2069
+ // Replace entity name `en1` by `en2` in all variables in all expressions
2070
+ // (provided that they are not identical)
2071
+ if(en1 === en2) return;
2072
+ // NOTE: ignore case and multiple spaces in `en1`, but conserve those in
2073
+ // new name `en2` (except for leading and trailing spaces)
2074
+ en1 = en1.trim().replace(/\s+/g, ' ').toLowerCase();
2075
+ en2 = en2.trim();
2076
+ // NOTE: Neither entity name may be empty
2077
+ if(!en1 || !en2) return;
2078
+ // NOTE: use the `rewrite` method of class IOContext; this will keep track
2079
+ // of the number of replacements made
2080
+ const ioc = new IOContext();
2081
+ // Iterate over all expressions
2082
+ const ax = this.allExpressions;
2083
+ for(let i = 0; i < ax.length; i++) {
2084
+ ioc.rewrite(ax[i], en1, en2);
2085
+ }
2086
+ // Iterate over all notes in clusters to rename entities in note fields
2087
+ for(let k in this.clusters) if(this.clusters.hasOwnProperty(k)) {
2088
+ const cn = this.clusters[k].notes;
2089
+ for(let i = 0; i < cn.length; i++) {
2090
+ cn[i].rewriteFields(en1, en2);
1891
2091
  }
1892
2092
  }
1893
2093
  if(ioc.replace_count) {
1894
2094
  UI.notify(`Renamed ${pluralS(ioc.replace_count, 'variable')} in ` +
1895
2095
  pluralS(ioc.expression_count, 'expression'));
1896
2096
  }
2097
+ // Also rename entities in parameters and outcomes of sensitivity analysis
2098
+ for(let i = 0; i < this.sensitivity_parameters.length; i++) {
2099
+ const sp = this.sensitivity_parameters[i].split('|');
2100
+ if(sp[0].toLowerCase() === en1) {
2101
+ sp[0] = en2;
2102
+ this.sensitivity_parameters[i] = sp.join('|');
2103
+ }
2104
+ }
2105
+ for(let i = 0; i < this.sensitivity_outcomes.length; i++) {
2106
+ const so = this.sensitivity_outcomes[i].split('|');
2107
+ if(so[0].toLowerCase() === en1) {
2108
+ so[0] = en2;
2109
+ this.sensitivity_outcomes[i] = so.join('|');
2110
+ }
2111
+ }
1897
2112
  // Name was changed, so update controller dialogs to display the new name
1898
- UI.updateControllerDialogs('CDEFX');
2113
+ UI.updateControllerDialogs('CDEFJX');
1899
2114
  }
1900
2115
 
1901
2116
  replaceAttributeInExpressions(ena, a) {
@@ -1905,58 +2120,40 @@ class LinnyRModel {
1905
2120
  // or in the new attribute `a` (except for leading and trailing spaces)
1906
2121
  a = a.trim();
1907
2122
  ena = ena.split('|');
1908
- // Double-check that `a` is no empty and `ena` contains a vertical bar
2123
+ // Double-check that `a` is not empty and `ena` contains a vertical bar
1909
2124
  if(!a || ena.length < 2) return;
1910
2125
  // Prepare regex to match [entity|attribute] including brackets, but case-
1911
2126
  // tolerant and spacing-tolerant
1912
2127
  const
1913
2128
  en = escapeRegex(ena[0].trim().replace(/\s+/g, ' ').toLowerCase()),
1914
2129
  at = ena[1].trim(),
1915
- raw = en.replace(/\s/, '\\s+') + '\\s*\\|\\s*' + escapeRegex(at),
1916
- re = new RegExp(
1917
- '\\[\\s*' + raw + '\\s*(\\@[^\\]]+)?\\s*\\]',
1918
- 'gi');
2130
+ raw = en.replace(/\s/, `\s+`) + `\s*\|\s*` + escapeRegex(at),
2131
+ re = new RegExp(`\[\s*${raw}\s*(\@[^\]]+)?\s*\]`, 'gi');
1919
2132
  // Count replacements made
1920
2133
  let n = 0;
1921
- // Check all actor weight expressions
1922
- for(let k in this.actors) if(this.actors.hasOwnProperty(k)) {
1923
- n += this.actors[k].weight.replaceAttribute(re, at, a);
1924
- }
1925
- // Check all process attribute expressions
1926
- for(let k in this.processes) if(this.processes.hasOwnProperty(k)) {
1927
- const p = this.processes[k];
1928
- n += p.lower_bound.replaceAttribute(re, at, a);
1929
- n += p.upper_bound.replaceAttribute(re, at, a);
1930
- n += p.initial_level.replaceAttribute(re, at, a);
1931
- n += p.pace_expression.replaceAttribute(re, at, a);
1932
- }
1933
- // Check all product attribute expressions
1934
- for(let k in this.products) if(this.products.hasOwnProperty(k)) {
1935
- const p = this.products[k];
1936
- n += p.lower_bound.replaceAttribute(re, at, a);
1937
- n += p.upper_bound.replaceAttribute(re, at, a);
1938
- n += p.initial_level.replaceAttribute(re, at, a);
1939
- n += p.price.replaceAttribute(re, at, a);
2134
+ // Iterate over all expressions
2135
+ const ax = this.allExpressions;
2136
+ for(let i = 0; i < ax.length; i++) {
2137
+ n += ax[i].replaceAttribute(re, at, a);
1940
2138
  }
1941
- // Check all notes in clusters for their color expressions and fields
1942
- for(let k in this.clusters) if(this.clusters.hasOwnProperty(k)) {
1943
- const c = this.clusters[k];
1944
- for(let i = 0; i < c.notes.length; i++) {
1945
- n += c.notes[i].color.replaceAttribute(re, at, a);
2139
+ // Also rename attributes in parameters and outcomes of sensitivity analysis
2140
+ let sa_cnt = 0;
2141
+ const enat = en + '|' + at;
2142
+ for(let i = 0; i < this.sensitivity_parameters.length; i++) {
2143
+ const sp = this.sensitivity_parameters[i];
2144
+ if(sp.toLowerCase() === enat) {
2145
+ this.sensitivity_parameters[i] = sp.split('|')[0] + '|' + a;
2146
+ sa_cnt++;
1946
2147
  }
1947
2148
  }
1948
- // Check all link rate and delay expressions
1949
- for(let k in this.links) if(this.links.hasOwnProperty(k)) {
1950
- n += this.links[k].relative_rate.replaceAttribute(re, at, a);
1951
- n += this.links[k].flow_delay.replaceAttribute(re, at, a);
1952
- }
1953
- // Check all dataset modifier expressions
1954
- for(let k in this.datasets) if(this.datasets.hasOwnProperty(k)) {
1955
- const ds = this.datasets[k];
1956
- for(let m in ds.modifiers) if(ds.modifiers.hasOwnProperty(m)) {
1957
- n += ds.modifiers[m].expression.replaceAttribute(re, at, a);
2149
+ for(let i = 0; i < this.sensitivity_outcomes.length; i++) {
2150
+ const so = this.sensitivity_outcomes[i];
2151
+ if(so.toLowerCase() === enat) {
2152
+ this.sensitivity_outcomes[i] = so.split('|')[0] + '|' + a;
2153
+ sa_cnt++;
1958
2154
  }
1959
2155
  }
2156
+ if(sa_cnt > 0) SENSITIVITY_ANALYSIS.updateDialog();
1960
2157
  return n;
1961
2158
  }
1962
2159
 
@@ -2056,7 +2253,9 @@ class LinnyRModel {
2056
2253
  for(i = 0; i < n.childNodes.length; i++) {
2057
2254
  c = n.childNodes[i];
2058
2255
  if(c.nodeName === 'scaleunit') {
2059
- this.addScaleUnit(xmlDecoded(nodeContentByTag(c, 'name')));
2256
+ this.addScaleUnit(xmlDecoded(nodeContentByTag(c, 'name')),
2257
+ nodeContentByTag(c, 'scalar'),
2258
+ xmlDecoded(nodeContentByTag(c, 'base-unit')));
2060
2259
  }
2061
2260
  }
2062
2261
  }
@@ -2196,14 +2395,14 @@ class LinnyRModel {
2196
2395
  // will then add the module prefix to the selector
2197
2396
  if(IO_CONTEXT) {
2198
2397
  if(name === UI.EQUATIONS_DATASET_NAME) {
2199
- const mn = childNodeByTag(node, 'modifiers');
2398
+ const mn = childNodeByTag(c, 'modifiers');
2200
2399
  if(mn && mn.childNodes) {
2201
2400
  for(let j = 0; j < mn.childNodes.length; j++) {
2202
- const c = mn.childNodes[j];
2203
- if(c.nodeName === 'modifier') {
2401
+ const cc = mn.childNodes[j];
2402
+ if(cc.nodeName === 'modifier') {
2204
2403
  this.equations_dataset.addModifier(
2205
- xmlDecoded(nodeContentByTag(c, 'selector')),
2206
- c, IO_CONTEXT);
2404
+ xmlDecoded(nodeContentByTag(cc, 'selector')),
2405
+ cc, IO_CONTEXT);
2207
2406
  }
2208
2407
  }
2209
2408
  }
@@ -2367,12 +2566,11 @@ class LinnyRModel {
2367
2566
  '</end-period><look-ahead-period>', this.look_ahead,
2368
2567
  '</look-ahead-period><round-sequence>', this.round_sequence,
2369
2568
  '</round-sequence><scaleunits>'].join('');
2370
- for(let i = 0; i < this.scale_units.length; i++) {
2371
- xml += '<scaleunit><name>' + xmlEncoded(this.scale_units[i]) +
2372
- '</name></scaleunit>';
2569
+ let obj;
2570
+ for(obj in this.scale_units) if(this.scale_units.hasOwnProperty(obj)) {
2571
+ xml += this.scale_units[obj].asXML;
2373
2572
  }
2374
2573
  xml += '</scaleunits><actors>';
2375
- let obj;
2376
2574
  for(obj in this.actors) {
2377
2575
  // NOTE: do not to save "(no actor)"
2378
2576
  if(this.actors.hasOwnProperty(obj) && obj != UI.nameToID(UI.NO_ACTOR)) {
@@ -2589,6 +2787,10 @@ class LinnyRModel {
2589
2787
  this.cleanVector(p.cash_flow, 0, 0);
2590
2788
  this.cleanVector(p.cash_in, 0, 0);
2591
2789
  this.cleanVector(p.cash_out, 0, 0);
2790
+ // NOTE: note fields also must be reset
2791
+ for(let i = 0; i < p.notes.length; i++) {
2792
+ p.notes[i].parsed = false;
2793
+ }
2592
2794
  }
2593
2795
  for(obj in this.processes) if(this.processes.hasOwnProperty(obj)) {
2594
2796
  p = this.processes[obj];
@@ -2679,30 +2881,9 @@ class LinnyRModel {
2679
2881
 
2680
2882
  compileExpressions() {
2681
2883
  // Compile all expression attributes of all model entities
2682
- let obj,
2683
- p;
2684
- // NOTE: start with dataset expressions, so that their level-based
2685
- // property is set before compiling the other expressions
2686
- for(obj in this.datasets) if(this.datasets.hasOwnProperty(obj)) {
2687
- this.datasets[obj].compileExpressions();
2688
- }
2689
- for(obj in this.actors) if(this.actors.hasOwnProperty(obj)) {
2690
- this.actors[obj].weight.compile();
2691
- }
2692
- for(obj in this.processes) if(this.processes.hasOwnProperty(obj)) {
2693
- p = this.processes[obj];
2694
- p.lower_bound.compile();
2695
- p.upper_bound.compile();
2696
- }
2697
- for(obj in this.products) if(this.products.hasOwnProperty(obj)) {
2698
- p = this.products[obj];
2699
- p.lower_bound.compile();
2700
- p.upper_bound.compile();
2701
- p.price.compile();
2702
- }
2703
- for(obj in this.links) if(this.links.hasOwnProperty(obj)) {
2704
- this.links[obj].relative_rate.compile();
2705
- this.links[obj].flow_delay.compile();
2884
+ const ax = this.allExpressions;
2885
+ for(let i = 0; i < ax.length; i++) {
2886
+ ax[i].compile();
2706
2887
  }
2707
2888
  }
2708
2889
 
@@ -3344,17 +3525,6 @@ class LinnyRModel {
3344
3525
  // Start with the Linny-R model properties
3345
3526
  let diff = differences(this, m, Object.keys(UI.MC.SETTINGS_PROPS));
3346
3527
  if(Object.keys(diff).length > 0) d.settings = diff;
3347
- // Then check for differences in scale unit lists
3348
- diff = {};
3349
- for(let i = 0; i < this.scale_units.length; i++) {
3350
- const su = this.scale_units[i];
3351
- if(m.scale_units.indexOf(su) < 0) diff[su] = [UI.MC.ADDED, su];
3352
- }
3353
- for(let i = 0; i < m.scale_units.length; i++) {
3354
- const su = m.scale_units[i];
3355
- if(this.scale_units.indexOf(su) < 0) diff[su] = [UI.MC.DELETED, su];
3356
- }
3357
- if(Object.keys(diff).length > 0) d.units = diff;
3358
3528
  // NOTE: dataset differences will also detect equation differences
3359
3529
  for(let i = 0; i < UI.MC.ENTITY_PROPS.length; i++) {
3360
3530
  const ep = UI.MC.ENTITY_PROPS[i];
@@ -3691,8 +3861,8 @@ class IOContext {
3691
3861
  }
3692
3862
 
3693
3863
  bind(fn, an) {
3694
- // Binds the formal name `n` of an entity in a module to the actual name
3695
- // `an` it will have in the current model
3864
+ // Binds the formal name `fn` of an entity in a module to the actual
3865
+ // name `an` it will have in the current model
3696
3866
  const id = UI.nameToID(fn);
3697
3867
  if(this.bindings.hasOwnProperty(id)) {
3698
3868
  this.bindings[id].bind(an);
@@ -3712,7 +3882,6 @@ class IOContext {
3712
3882
  // (and for processes and clusters: with actor name `an` if specified and
3713
3883
  // not "(no actor)")
3714
3884
  // NOTE: do not modify (no actor), nor the "dataset dot"
3715
- // @@TO DO: correctly handle equations!
3716
3885
  if(n === UI.NO_ACTOR || n === '.') return n;
3717
3886
  // NOTE: the top cluster of the included model has the prefix as its name
3718
3887
  if(n === UI.TOP_CLUSTER_NAME || n === UI.FORMER_TOP_CLUSTER_NAME) {
@@ -3847,7 +4016,7 @@ class IOContext {
3847
4016
  a,
3848
4017
  stat;
3849
4018
  while(true) {
3850
- p = x.text.indexOf('[', p + 1);
4019
+ p = x.text.indexOf('[', q + 1);
3851
4020
  if(p < 0) {
3852
4021
  // No more '[' => add remaining part of text, and quit
3853
4022
  s += x.text.slice(q + 1);
@@ -3952,6 +4121,79 @@ class IOContext {
3952
4121
  } // END of class IOContext
3953
4122
 
3954
4123
 
4124
+ // CLASS ScaleUnit
4125
+ class ScaleUnit {
4126
+ constructor(name, scalar, base_unit) {
4127
+ this.name = name;
4128
+ // NOTES:
4129
+ // (1) Undefined or empty strings default to '1'
4130
+ // (2) Multiplier is stored as string to preserve modeler's notation
4131
+ this.scalar = scalar || '1';
4132
+ this.base_unit = base_unit || '1';
4133
+ }
4134
+
4135
+ get multiplier() {
4136
+ // Returns scalar as number
4137
+ return safeStrToFloat(this.scalar, 1);
4138
+ }
4139
+
4140
+ conversionRates() {
4141
+ // Returns a "dictionary" {U1: R1, U2: R2, ...} such that Ui is a
4142
+ // scale unit that can be converted to *this* scaleunit U at rate Ri
4143
+ const cr = {};
4144
+ let p = 0, // previous count of entries
4145
+ n = 1;
4146
+ // At least one conversion: U -> U with rate 1
4147
+ cr[this.name] = 1;
4148
+ if(this.base_unit !== '1') {
4149
+ // Second conversion: U -> base of U with modeler-defined rate
4150
+ cr[this.base_unit] = this.multiplier;
4151
+ n++;
4152
+ }
4153
+ // Keep track of the number of keys; terminate as no new keys
4154
+ while(p < n) {
4155
+ p = n;
4156
+ // Iterate over all convertible scale units discovered so far
4157
+ for(let u in cr) if(cr.hasOwnProperty(u)) {
4158
+ // Look for conversions to units NOT yet detected
4159
+ for(let k in MODEL.scale_units) if(k != '1' &&
4160
+ MODEL.scale_units.hasOwnProperty(k)) {
4161
+ const
4162
+ su = MODEL.scale_units[k],
4163
+ b = su.base_unit;
4164
+ if(b === '1') continue;
4165
+ if(!cr.hasOwnProperty(k) && cr.hasOwnProperty(b)) {
4166
+ // Add unit if new while base unit is convertible
4167
+ cr[k] = cr[b] / su.multiplier;
4168
+ n++;
4169
+ } else if(cr.hasOwnProperty(k) && !cr.hasOwnProperty(b)) {
4170
+ // Likewise, add base unit if new while unit is convertible
4171
+ cr[b] = cr[k] * su.multiplier;
4172
+ n++;
4173
+ }
4174
+ }
4175
+ }
4176
+ }
4177
+ return cr;
4178
+ }
4179
+
4180
+ get asXML() {
4181
+ return ['<scaleunit><name>', xmlEncoded(this.name),
4182
+ '</name><scalar>', this.scalar,
4183
+ '</scalar><base-unit>', xmlEncoded(this.base_unit),
4184
+ '</base-unit></scaleunit>'].join('');
4185
+ }
4186
+
4187
+ // NOTE: NO initFromXML because scale units are added directly
4188
+
4189
+ differences(u) {
4190
+ // Return "dictionary" of differences, or NULL if none
4191
+ const d = differences(this, u, UI.MC.UNIT_PROPS);
4192
+ if(Object.keys(d).length > 0) return d;
4193
+ return null;
4194
+ }
4195
+ }
4196
+
3955
4197
  // CLASS Actor
3956
4198
  class Actor {
3957
4199
  constructor(name) {
@@ -4107,35 +4349,45 @@ class ObjectWithXYWH {
4107
4349
 
4108
4350
  // CLASS NoteField: numeric value of "field" [[dataset]] in note text
4109
4351
  class NoteField {
4110
- constructor(f, o) {
4111
- // `f` holds the unmodified tag string [[dataset]] to be replaced by the
4112
- // value of vector or expression `o` for the current time step
4352
+ constructor(f, o, u='1', m=1) {
4353
+ // `f` holds the unmodified tag string [[dataset]] to be replaced by
4354
+ // the value of vector or expression `o` for the current time step;
4355
+ // if specified, `u` is the unit of the value to be displayed, and
4356
+ // `m` is the multiplier for the value to be displayed
4113
4357
  this.field = f;
4114
4358
  this.object = o;
4359
+ this.unit = u;
4360
+ this.multiplier = m;
4115
4361
  }
4116
4362
 
4117
4363
  get value() {
4118
- // Returns the numeric value of this note field
4364
+ // Returns the numeric value of this note field as a numeric string
4365
+ // followed by its unit (unless this is 1)
4366
+ let v = VM.UNDEFINED;
4119
4367
  const t = MODEL.t;
4120
4368
  if(Array.isArray(this.object)) {
4121
4369
  // Object is a vector
4122
- if(t < this.object.length) {
4123
- return this.object[t];
4124
- } else {
4125
- return VM.UNDEFINED;
4126
- }
4127
- } else if(this.object.hasOwnProperty('c') && this.object.hasOwnProperty('u')) {
4370
+ if(t < this.object.length) v = this.object[t];
4371
+ } else if(this.object.hasOwnProperty('c') &&
4372
+ this.object.hasOwnProperty('u')) {
4128
4373
  // Object holds link lists for cluster balance computation
4129
- return MODEL.flowBalance(this.object, t);
4374
+ v = MODEL.flowBalance(this.object, t);
4130
4375
  } else if(this.object instanceof Expression) {
4131
4376
  // Object is an expression
4132
- return this.object.result(t);
4377
+ v = this.object.result(t);
4133
4378
  } else if(typeof this.object === 'number') {
4134
- return this.object;
4379
+ v = this.object;
4380
+ } else {
4381
+ // NOTE: this fall-through should not occur
4382
+ console.log('Note field value issue:', this.object);
4135
4383
  }
4136
- // NOTE: this fall-through should not occur
4137
- console.log('Note field value issue:', this.object);
4138
- return VM.UNDEFINED;
4384
+ if(Math.abs(this.multiplier - 1) > VM.NEAR_ZERO &&
4385
+ v > VM.MINUS_INFINITY && v < VM.PLUS_INFINITY) {
4386
+ v *= this.multiplier;
4387
+ }
4388
+ v = VM.sig4Dig(v);
4389
+ if(this.unit !== '1') v += ' ' + this.unit;
4390
+ return v;
4139
4391
  }
4140
4392
 
4141
4393
  } // END of class NoteField
@@ -4198,6 +4450,13 @@ class Note extends ObjectWithXYWH {
4198
4450
  this.width = safeStrToInt(nodeContentByTag(node, 'width'));
4199
4451
  this.height = safeStrToInt(nodeContentByTag(node, 'height'));
4200
4452
  this.color.text = xmlDecoded(nodeContentByTag(node, 'color'));
4453
+ if(IO_CONTEXT) {
4454
+ const fel = this.fieldEntities;
4455
+ for(let i = 0; i < fel.length; i++) {
4456
+ this.rewriteTags(fel[i], IO_CONTEXT.actualName(fel[i]));
4457
+ }
4458
+ IO_CONTEXT.rewrite(this.color);
4459
+ }
4201
4460
  }
4202
4461
 
4203
4462
  setCluster(c) {
@@ -4223,24 +4482,93 @@ class Note extends ObjectWithXYWH {
4223
4482
  for(let i = 0; i < tags.length; i++) {
4224
4483
  const
4225
4484
  tag = tags[i],
4226
- ena = tag.slice(2, tag.length - 2).trim().split('|');
4485
+ inner = tag.slice(2, tag.length - 2).trim(),
4486
+ bar = inner.lastIndexOf('|'),
4487
+ arrow = inner.lastIndexOf('->');
4488
+ // Check if a unit conversion scalar was specified
4489
+ let ena,
4490
+ from_unit = '1',
4491
+ to_unit = '',
4492
+ multiplier = 1;
4493
+ if(arrow > bar) {
4494
+ // Now for sure it is entity->unit or entity|attr->unit
4495
+ ena = inner.split('->');
4496
+ // As example, assume that unit = 'kWh' (so the value of the
4497
+ // field should be displayed in kilowatthour)
4498
+ // NOTE: use .trim() instead of UI.cleanName(...) here;
4499
+ // this forces the modeler to be exact, and that permits proper
4500
+ // renaming of scale units in note fields
4501
+ to_unit = ena[1].trim();
4502
+ ena = ena[0].split('|');
4503
+ if(!MODEL.scale_units.hasOwnProperty(to_unit)) {
4504
+ UI.warn(`Unknown scale unit "${to_unit}"`);
4505
+ to_unit = '1';
4506
+ }
4507
+ } else {
4508
+ ena = inner.split('|');
4509
+ }
4227
4510
  // Look up entity for name and attribute
4228
- const obj = MODEL.objectByName(ena[0]);
4511
+ const obj = MODEL.objectByName(ena[0].trim());
4229
4512
  if(obj instanceof DatasetModifier) {
4230
- this.fields.push(new NoteField(tag, obj.expression));
4513
+ // NOTE: equations are (for now) dimensionless => unit '1'
4514
+ if(obj.dataset !== MODEL.equations_dataset) {
4515
+ from_unit = obj.dataset.scale_unit;
4516
+ multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4517
+ }
4518
+ this.fields.push(new NoteField(tag, obj.expression, to_unit, multiplier));
4231
4519
  } else if(obj) {
4232
4520
  // If attribute omitted, use default attribute of entity type
4233
4521
  const attr = (ena.length > 1 ? ena[1].trim() : obj.defaultAttribute);
4234
- // Variable may specify a vector-type attribute
4235
- let val = obj.attributeValue(attr);
4522
+ let val = null;
4523
+ // NOTE: for datasets, use the active modifier
4524
+ if(!attr && obj instanceof Dataset) {
4525
+ val = obj.activeModifierExpression;
4526
+ } else {
4527
+ // Variable may specify a vector-type attribute
4528
+ val = obj.attributeValue(attr);
4529
+ }
4236
4530
  // If not, it may be a cluster unit balance
4237
4531
  if(!val && attr.startsWith('=') && obj instanceof Cluster) {
4238
4532
  val = {c: obj, u: attr.substring(1).trim()};
4533
+ from_unit = val.u;
4534
+ }
4535
+ if(obj instanceof Dataset) {
4536
+ from_unit = obj.scale_unit;
4537
+ } else if(obj instanceof Product) {
4538
+ if(attr === 'L') {
4539
+ from_unit = obj.scale_unit;
4540
+ } else if(attr === 'CP' || attr === 'HCP') {
4541
+ from_unit = MODEL.currency_unit;
4542
+ }
4543
+ } else if(obj instanceof Link) {
4544
+ const node = (obj.from_node instanceof Process ?
4545
+ obj.to_node : obj.from_node);
4546
+ if(attr === 'F') {
4547
+ if(obj.multiplier <= VM.LM_MEAN) {
4548
+ from_unit = node.scale_unit;
4549
+ } else {
4550
+ from_unit = '1';
4551
+ }
4552
+ }
4553
+ } else if(attr === 'CI' || attr === 'CO' || attr === 'CF') {
4554
+ from_unit = MODEL.currency_unit;
4239
4555
  }
4240
4556
  // If not, it may be an expression-type attribute
4241
- if(!val) val = obj.attributeExpression(attr);
4557
+ if(!val) {
4558
+ val = obj.attributeExpression(attr);
4559
+ if(obj instanceof Product) {
4560
+ if(attr === 'IL' || attr === 'LB' || attr === 'UB') {
4561
+ from_unit = obj.scale_unit;
4562
+ } else if(attr === 'P') {
4563
+ from_unit = MODEL.currency_unit + '/' + obj.scale_unit;
4564
+ }
4565
+ }
4566
+ }
4567
+ // If no TO unit, add the FROM unit
4568
+ if(to_unit === '') to_unit = from_unit;
4242
4569
  if(val) {
4243
- this.fields.push(new NoteField(tag, val));
4570
+ multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4571
+ this.fields.push(new NoteField(tag, val, to_unit, multiplier));
4244
4572
  } else {
4245
4573
  UI.warn(`Unknown ${obj.type.toLowerCase()} attribute "${attr}"`);
4246
4574
  }
@@ -4251,10 +4579,48 @@ class Note extends ObjectWithXYWH {
4251
4579
  }
4252
4580
  this.parsed = true;
4253
4581
  }
4582
+
4583
+ get fieldEntities() {
4584
+ // Return a list with names of entities used in fields
4585
+ const
4586
+ fel = [],
4587
+ tags = this.contents.match(/\[\[[^\]]+\]\]/g);
4588
+ for(let i = 0; i < tags.length; i++) {
4589
+ const
4590
+ tag = tags[i],
4591
+ inner = tag.slice(2, tag.length - 2).trim(),
4592
+ vb = inner.lastIndexOf('|'),
4593
+ ua = inner.lastIndexOf('->');
4594
+ if(vb >= 0) {
4595
+ addDistinct(inner.slice(0, vb), fel);
4596
+ } else if(ua >= 0 &&
4597
+ MODEL.scale_units.hasOwnProperty(inner.slice(ua + 2))) {
4598
+ addDistinct(inner.slice(0, ua), fel);
4599
+ } else {
4600
+ addDistinct(inner, fel);
4601
+ }
4602
+ }
4603
+ return fel;
4604
+ }
4605
+
4606
+ rewriteTags(en1, en2) {
4607
+ // Rewrite tags that reference entity name `en1` to reference `en2` instead
4608
+ if(en1 === en2) return;
4609
+ const
4610
+ raw = en1.split(/\s+/).join('\\\\s+'),
4611
+ re = new RegExp('\\[\\[\\s*' + raw + '\\s*(\\->|\\||\\])', 'g'),
4612
+ tags = this.contents.match(re);
4613
+ if(tags) {
4614
+ for(let i = 0; i < tags.length; i++) {
4615
+ this.contents = this.contents.replace(tags[i], tags[i].replace(en1, en2));
4616
+ }
4617
+ }
4618
+ }
4254
4619
 
4255
4620
  rewriteFields(en1, en2) {
4256
4621
  // Rename fields that reference entity name `en1` to reference `en2` instead
4257
4622
  // NOTE: this does not affect the expression code
4623
+ if(en1 === en2) return;
4258
4624
  for(let i = 0; i < this.fields.length; i++) {
4259
4625
  const
4260
4626
  f = this.fields[i],
@@ -4263,12 +4629,17 @@ class Note extends ObjectWithXYWH {
4263
4629
  // Separate tag into variable and attribute + offset string (if any)
4264
4630
  let e = tag,
4265
4631
  a = '',
4266
- vb = tag.lastIndexOf('|');
4632
+ vb = tag.lastIndexOf('|'),
4633
+ ua = tag.lastIndexOf('->');
4267
4634
  if(vb >= 0) {
4268
4635
  e = tag.slice(0, vb);
4269
4636
  // NOTE: attribute string includes the vertical bar '|'
4270
4637
  a = tag.slice(vb);
4271
- }
4638
+ } else if(ua >= 0 && MODEL.scale_units.hasOwnProperty(tag.slice(ua + 2))) {
4639
+ e = tag.slice(0, ua);
4640
+ // NOTE: attribute string includes the unit conversion arrow '->'
4641
+ a = tag.slice(ua);
4642
+ }
4272
4643
  // Check for match
4273
4644
  const r = UI.replaceEntity(e, en1, en2);
4274
4645
  if(r) {
@@ -4284,7 +4655,7 @@ class Note extends ObjectWithXYWH {
4284
4655
  let txt = this.contents;
4285
4656
  for(let i = 0; i < this.fields.length; i++) {
4286
4657
  const nf = this.fields[i];
4287
- txt = txt.replace(nf.field, VM.sig4Dig(nf.value));
4658
+ txt = txt.replace(nf.field, nf.value);
4288
4659
  }
4289
4660
  return txt;
4290
4661
  }
@@ -4441,7 +4812,7 @@ class NodeBox extends ObjectWithXYWH {
4441
4812
  delete MODEL.products[old_id];
4442
4813
  } else if(this instanceof Cluster) {
4443
4814
  MODEL.clusters[new_id] = this;
4444
- delete MODEL.products[old_id];
4815
+ delete MODEL.clusters[old_id];
4445
4816
  } else {
4446
4817
  // NOTE: this should never happen => report an error
4447
4818
  UI.alert('Can only rename processes, products and clusters');
@@ -4706,7 +5077,7 @@ class Arrow {
4706
5077
  } else {
4707
5078
  if(p[0] && p[1]) {
4708
5079
  console.log('ERROR: Two distinct flows on monodirectional arrow',
4709
- this, sum);
5080
+ this, sum, p);
4710
5081
  return [0, 0, 0, false, false];
4711
5082
  }
4712
5083
  status = 1;
@@ -6322,6 +6693,8 @@ class Node extends NodeBox {
6322
6693
  ds = MODEL.addDataset(dsn);
6323
6694
  // Use the LB attribute as default value for the dataset
6324
6695
  ds.default_value = parseFloat(this.lower_bound.text);
6696
+ // UB data has same unit as product
6697
+ ds.scale_unit = this.scale_unit;
6325
6698
  ds.data = stringToFloatArray(lb_data);
6326
6699
  ds.computeVector();
6327
6700
  ds.computeStatistics();
@@ -6334,6 +6707,8 @@ class Node extends NodeBox {
6334
6707
  dsn = this.displayName + ' UPPER BOUND DATA',
6335
6708
  ds = MODEL.addDataset(dsn);
6336
6709
  ds.default_value = parseFloat(this.upper_bound.text);
6710
+ // UB data has same unit as product
6711
+ ds.scale_unit = this.scale_unit;
6337
6712
  ds.data = stringToFloatArray(ub_data);
6338
6713
  ds.computeVector();
6339
6714
  ds.computeStatistics();
@@ -6961,6 +7336,8 @@ class Product extends Node {
6961
7336
  ds = MODEL.addDataset(dsn);
6962
7337
  // Use the price attribute as default value for the dataset
6963
7338
  ds.default_value = parseFloat(this.price.text);
7339
+ // NOTE: dataset unit then is a currency
7340
+ ds.scale_unit = MODEL.currency_unit;
6964
7341
  ds.data = stringToFloatArray(data);
6965
7342
  ds.computeVector();
6966
7343
  ds.computeStatistics();
@@ -7343,12 +7720,10 @@ class Link {
7343
7720
 
7344
7721
  // CLASS DatasetModifier
7345
7722
  class DatasetModifier {
7346
- constructor(dataset, selector, params=false) {
7723
+ constructor(dataset, selector) {
7347
7724
  this.dataset = dataset;
7348
7725
  this.selector = selector;
7349
7726
  this.expression = new Expression(dataset, selector, '');
7350
- // Equations may have parameters
7351
- this.parameters = params;
7352
7727
  this.expression_cache = {};
7353
7728
  }
7354
7729
 
@@ -7379,17 +7754,12 @@ class DatasetModifier {
7379
7754
  // NOTE: for some reason, selector may become empty string, so prevent
7380
7755
  // saving such unidentified modifiers
7381
7756
  if(this.selector.trim().length === 0) return '';
7382
- let pstr = (Array.isArray(this.parameters) ?
7383
- this.parameters.join('\\').trim() : '');
7384
- if(pstr) pstr = ' parameters="' + xmlEncoded(pstr) + '"';
7385
- return ['<modifier', pstr, '><selector>', xmlEncoded(this.selector),
7757
+ return ['<modifier><selector>', xmlEncoded(this.selector),
7386
7758
  '</selector><expression>', xmlEncoded(this.expression.text),
7387
7759
  '</expression></modifier>'].join('');
7388
7760
  }
7389
7761
 
7390
7762
  initFromXML(node) {
7391
- const pstr = nodeParameterValue(node, 'parameters').trim();
7392
- this.parameters = (pstr ? xmlDecoded(pstr).split('\\'): false);
7393
7763
  this.expression.text = xmlDecoded(nodeContentByTag(node, 'expression'));
7394
7764
  if(IO_CONTEXT) {
7395
7765
  // Contextualize the included expression
@@ -7421,6 +7791,7 @@ class Dataset {
7421
7791
  this.name = name;
7422
7792
  this.comments = '';
7423
7793
  this.default_value = 0;
7794
+ this.scale_unit = '1';
7424
7795
  this.time_scale = 1;
7425
7796
  this.time_unit = CONFIGURATION.default_time_unit;
7426
7797
  this.method = 'nearest';
@@ -7519,6 +7890,14 @@ class Dataset {
7519
7890
  }
7520
7891
  return this.default_value * MODEL.timeStepDuration / this.timeStepDuration;
7521
7892
  }
7893
+
7894
+ changeScaleUnit(name) {
7895
+ let su = MODEL.addScaleUnit(name);
7896
+ if(su !== this.scale_unit) {
7897
+ this.scale_unit = su;
7898
+ MODEL.cleanUpScaleUnits();
7899
+ }
7900
+ }
7522
7901
 
7523
7902
  matchingModifiers(l) {
7524
7903
  // Returns the list of selectors of this dataset (in order: from most to
@@ -7655,47 +8034,45 @@ class Dataset {
7655
8034
  }
7656
8035
  return null;
7657
8036
  }
8037
+
8038
+ get activeModifierExpression() {
8039
+ if(MODEL.running_experiment) {
8040
+ // If an experiment is running, check if dataset modifiers match the
8041
+ // combination of selectors for the active run
8042
+ const mm = this.matchingModifiers(MODEL.running_experiment.activeCombination);
8043
+ // If so, use the first match
8044
+ if(mm.length > 0) return mm[0].expression;
8045
+ }
8046
+ if(this.default_selector) {
8047
+ // If no experiment (so "normal" run), use default selector if specified
8048
+ const dm = this.modifiers[this.default_selector];
8049
+ if(dm) return dm.expression;
8050
+ // Exception should never occur, but check anyway and log it
8051
+ console.log('WARNING: Dataset "' + this.name +
8052
+ `" has no default selector "${this.default_selector}"`);
8053
+ }
8054
+ // Fall-through: return vector instead of expression
8055
+ return this.vector;
8056
+ }
7658
8057
 
7659
8058
  addModifier(selector, node=null, ioc=null) {
7660
- let s = selector,
7661
- params = false;
8059
+ let s = selector;
7662
8060
  // Firstly, sanitize the selector
7663
8061
  if(this === MODEL.equations_dataset) {
7664
8062
  // Equation identifiers cannot contain characters that have special
7665
8063
  // meaning in a variable identifier
7666
- s = s.replace(/[\*\?\|\[\]\{\}\:\@\#]/g, '');
8064
+ s = s.replace(/[\*\?\|\[\]\{\}\@\#]/g, '');
7667
8065
  if(s !== selector) {
7668
- UI.warn('Equation name cannot contain [, ], {, }, |, :, @, #, * or ?');
8066
+ UI.warn('Equation name cannot contain [, ], {, }, |, @, #, * or ?');
7669
8067
  return null;
7670
8068
  }
7671
- // Check whether equation has parameters
7672
- const ss = s.split('\\');
7673
- if(ss.length > 1) {
7674
- s = ss.shift();
7675
- // Store parameter names in lower case
7676
- for(let i = 0; i < ss.length; i++) {
7677
- ss[i] = ss[i].toLowerCase();
7678
- }
7679
- params = ss;
7680
- }
8069
+ // Reduce inner spaces to one, and trim outer spaces
8070
+ s = s.replace(/\s+/g, ' ').trim();
8071
+ // Then prefix it when the IO context argument is defined
8072
+ if(ioc) s = ioc.actualName(s);
7681
8073
  // If equation already exists, return its modifier
7682
8074
  const id = UI.nameToID(s);
7683
- if(this.modifiers.hasOwnProperty(id)) {
7684
- const exm = this.modifiers[id];
7685
- if(params) {
7686
- if(!exm.parameters) {
7687
- UI.warn(`Existing equation ${exm.displayName} has no parameters`);
7688
- } else {
7689
- const
7690
- newp = params.join('\\'),
7691
- oldp = exm.parameters.join('\\');
7692
- if(newp !== oldp) {
7693
- UI.warn(`Parameter mismatch: expected \\${oldp}, not \\${newp}`);
7694
- }
7695
- }
7696
- }
7697
- return exm;
7698
- }
8075
+ if(this.modifiers.hasOwnProperty(id)) return this.modifiers[id];
7699
8076
  // New equation identifier must not equal some entity ID
7700
8077
  const obj = MODEL.objectByName(s);
7701
8078
  if(obj) {
@@ -7703,8 +8080,6 @@ class Dataset {
7703
8080
  UI.warningEntityExists(obj);
7704
8081
  return null;
7705
8082
  }
7706
- // Also reduce inner spaces to one, and trim outer spaces
7707
- s = s.replace(/\s+/g, ' ').trim();
7708
8083
  } else {
7709
8084
  // Standard dataset modifier selectors are much more restricted, but
7710
8085
  // to be user-friendly, special chars are removed automatically
@@ -7722,12 +8097,10 @@ class Dataset {
7722
8097
  UI.warn(UI.WARNING.INVALID_SELECTOR);
7723
8098
  return null;
7724
8099
  }
7725
- // Then prefix it when the IO context argument is defined
7726
- if(ioc) s = ioc.actualName(s);
7727
8100
  // Then add a dataset modifier to this dataset
7728
8101
  const id = UI.nameToID(s);
7729
8102
  if(!this.modifiers.hasOwnProperty(id)) {
7730
- this.modifiers[id] = new DatasetModifier(this, s, params);
8103
+ this.modifiers[id] = new DatasetModifier(this, s);
7731
8104
  }
7732
8105
  // Finally, initialize it when the XML node argument is defined
7733
8106
  if(node) this.modifiers[id].initFromXML(node);
@@ -7762,7 +8135,8 @@ class Dataset {
7762
8135
  const xml = ['<dataset', p, '><name>', xmlEncoded(n),
7763
8136
  '</name><notes>', cmnts,
7764
8137
  '</notes><default>', this.default_value,
7765
- '</default><time-scale>', this.time_scale,
8138
+ '</default><unit>', xmlEncoded(this.scale_unit),
8139
+ '</unit><time-scale>', this.time_scale,
7766
8140
  '</time-scale><time-unit>', this.time_unit,
7767
8141
  '</time-unit><method>', this.method,
7768
8142
  '</method><url>', xmlEncoded(this.url),
@@ -7776,10 +8150,11 @@ class Dataset {
7776
8150
  initFromXML(node) {
7777
8151
  this.comments = xmlDecoded(nodeContentByTag(node, 'notes'));
7778
8152
  this.default_value = safeStrToFloat(nodeContentByTag(node, 'default'));
8153
+ this.scale_unit = xmlDecoded(nodeContentByTag(node, 'unit')) || '1';
7779
8154
  this.time_scale = safeStrToFloat(nodeContentByTag(node, 'time-scale'), 1);
7780
- this.time_unit = nodeContentByTag(node, 'time-unit');
7781
- if(!this.time_unit) this.time_unit = CONFIGURATION.default_time_unit;
7782
- this.method = nodeContentByTag(node, 'method');
8155
+ this.time_unit = nodeContentByTag(node, 'time-unit') ||
8156
+ CONFIGURATION.default_time_unit;
8157
+ this.method = nodeContentByTag(node, 'method') || 'nearest';
7783
8158
  this.periodic = nodeParameterValue(node, 'periodic') === '1';
7784
8159
  this.array = nodeParameterValue(node, 'array') === '1';
7785
8160
  this.black_box = nodeParameterValue(node, 'black-box') === '1';
@@ -9619,8 +9994,13 @@ class Experiment {
9619
9994
  this.variables = [];
9620
9995
  this.configuration_dims = 0;
9621
9996
  this.column_scenario_dims = 0;
9997
+ this.iterator_ranges = [[0,0], [0,0], [0,0]];
9998
+ this.iterator_dimensions = [];
9622
9999
  this.settings_selectors = [];
9623
10000
  this.settings_dimensions = [];
10001
+ this.combination_selectors = [];
10002
+ this.combination_dimensions = [];
10003
+ this.available_dimensions = [];
9624
10004
  this.actor_selectors = [];
9625
10005
  this.actor_dimensions = [];
9626
10006
  this.excluded_selectors = '';
@@ -9680,6 +10060,56 @@ class Experiment {
9680
10060
  return this.combinations[this.active_combination_index];
9681
10061
  }
9682
10062
 
10063
+ get iteratorRangeString() {
10064
+ // Returns the iterator ranges as "from,to" pairs separated by |
10065
+ const ir = [];
10066
+ for(let i = 0; i < 3; i++) {
10067
+ ir.push(this.iterator_ranges[i].join(','));
10068
+ }
10069
+ return ir.join('|');
10070
+ }
10071
+
10072
+ parseIteratorRangeString(s) {
10073
+ // Parses `s` as "from,to" pairs, ignoring syntax errors
10074
+ if(s) {
10075
+ const ir = s.split('|');
10076
+ // Add 2 extra substrings to have at least 3
10077
+ ir.push('', '');
10078
+ for(let i = 0; i < 3; i++) {
10079
+ const r = ir[i].split(',');
10080
+ // Likewise add extra substring to have at least 2
10081
+ r.push('');
10082
+ // Parse integers, defaulting to 0
10083
+ this.iterator_ranges[i] = [safeStrToInt(r[0], 0), safeStrToInt(r[1], 0)];
10084
+ }
10085
+ }
10086
+ }
10087
+
10088
+ updateIteratorDimensions() {
10089
+ // Create iterator selectors for each index variable having a relevant range
10090
+ this.iterator_dimensions = [];
10091
+ const il = ['i', 'j', 'k'];
10092
+ for(let i = 0; i < 3; i++) {
10093
+ const r = this.iterator_ranges[i];
10094
+ if(r[0] || r[1]) {
10095
+ const
10096
+ sel = [],
10097
+ k = il[i] + '=';
10098
+ // NOTE: iterate from FROM to TO limit also when FROM > TO
10099
+ if(r[0] <= r[1]) {
10100
+ for(let j = r[0]; j <= r[1]; j++) {
10101
+ sel.push(k + j);
10102
+ }
10103
+ } else {
10104
+ for(let j = r[0]; j >= r[1]; j--) {
10105
+ sel.push(k + j);
10106
+ }
10107
+ }
10108
+ this.iterator_dimensions.push(sel);
10109
+ }
10110
+ }
10111
+ }
10112
+
9683
10113
  matchingCombinationIndex(sl) {
9684
10114
  // Returns index of combination with most selectors in common wilt `sl`
9685
10115
  let high = 0,
@@ -9715,6 +10145,16 @@ class Experiment {
9715
10145
  `<sdim>${xmlEncoded(this.settings_dimensions[i].join(','))}</sdim>`;
9716
10146
  if(sd.indexOf(dim) < 0) sd += dim;
9717
10147
  }
10148
+ let cs = '';
10149
+ for(let i = 0; i < this.combination_selectors.length; i++) {
10150
+ cs += `<csel>${xmlEncoded(this.combination_selectors[i])}</csel>`;
10151
+ }
10152
+ let cd = '';
10153
+ for(let i = 0; i < this.combination_dimensions.length; i++) {
10154
+ const dim =
10155
+ `<cdim>${xmlEncoded(this.combination_dimensions[i].join(','))}</cdim>`;
10156
+ if(cd.indexOf(dim) < 0) cd += dim;
10157
+ }
9718
10158
  let as = '';
9719
10159
  for(let i = 0; i < this.actor_selectors.length; i++) {
9720
10160
  as += this.actor_selectors[i].asXML;
@@ -9733,6 +10173,7 @@ class Experiment {
9733
10173
  return ['<experiment configuration-dims="', this.configuration_dims,
9734
10174
  '" column_scenario-dims="', this.column_scenario_dims,
9735
10175
  (this.completed ? '" completed="1' : ''),
10176
+ '" iterator-ranges="', this.iteratorRangeString,
9736
10177
  '" started="', this.time_started,
9737
10178
  '" stopped="', this.time_stopped,
9738
10179
  '" variables="', this.download_settings.variables,
@@ -9749,7 +10190,9 @@ class Experiment {
9749
10190
  '</dimensions><chart-titles>', ct,
9750
10191
  '</chart-titles><settings-selectors>', ss,
9751
10192
  '</settings-selectors><settings-dimensions>', sd,
9752
- '</settings-dimensions><actor-selectors>', as,
10193
+ '</settings-dimensions><combination-selectors>', cs,
10194
+ '</combination-selectors><combination-dimensions>', cd,
10195
+ '</combination-dimensions><actor-selectors>', as,
9753
10196
  '</actor-selectors><excluded-selectors>',
9754
10197
  xmlEncoded(this.excluded_selectors),
9755
10198
  '</excluded-selectors><clusters-to-ignore>', cti,
@@ -9762,6 +10205,7 @@ class Experiment {
9762
10205
  nodeParameterValue(node, 'configuration-dims'));
9763
10206
  this.column_scenario_dims = safeStrToInt(
9764
10207
  nodeParameterValue(node, 'column-scenario-dims'));
10208
+ this.parseIteratorRangeString(nodeParameterValue(node, 'iterator-ranges'));
9765
10209
  this.completed = nodeParameterValue(node, 'completed') === '1';
9766
10210
  this.time_started = safeStrToInt(nodeParameterValue(node, 'started'));
9767
10211
  this.time_stopped = safeStrToInt(nodeParameterValue(node, 'stopped'));
@@ -9817,6 +10261,24 @@ class Experiment {
9817
10261
  }
9818
10262
  }
9819
10263
  }
10264
+ n = childNodeByTag(node, 'combination-selectors');
10265
+ if(n && n.childNodes) {
10266
+ for(let i = 0; i < n.childNodes.length; i++) {
10267
+ c = n.childNodes[i];
10268
+ if(c.nodeName === 'csel') {
10269
+ this.combination_selectors.push(xmlDecoded(nodeContent(c)));
10270
+ }
10271
+ }
10272
+ }
10273
+ n = childNodeByTag(node, 'combination-dimensions');
10274
+ if(n && n.childNodes) {
10275
+ for(let i = 0; i < n.childNodes.length; i++) {
10276
+ c = n.childNodes[i];
10277
+ if(c.nodeName === 'cdim') {
10278
+ this.combination_dimensions.push(xmlDecoded(nodeContent(c)).split(','));
10279
+ }
10280
+ }
10281
+ }
9820
10282
  n = childNodeByTag(node, 'actor-selectors');
9821
10283
  if(n && n.childNodes) {
9822
10284
  for(let i = 0; i < n.childNodes.length; i++) {
@@ -9865,7 +10327,9 @@ class Experiment {
9865
10327
  // Returns dimension index if any dimension contains any selector in
9866
10328
  // dimension `d`, or -1 otherwise
9867
10329
  for(let i = 0; i < this.dimensions.length; i++) {
9868
- if(intersection(this.dimensions[i], d).length > 0) return i;
10330
+ const xd = this.dimensions[i].slice();
10331
+ this.expandCombinationSelectors(xd);
10332
+ if(intersection(xd, d).length > 0) return i;
9869
10333
  }
9870
10334
  return -1;
9871
10335
  }
@@ -9874,7 +10338,7 @@ class Experiment {
9874
10338
  // Removes dimension `d` from list and returns its old index
9875
10339
  for(let i = 0; i < this.dimensions.length; i++) {
9876
10340
  if(intersection(this.dimensions[i], d).length > 0) {
9877
- this.dimensions.splice(i);
10341
+ this.dimensions.splice(i, 1);
9878
10342
  return i;
9879
10343
  }
9880
10344
  }
@@ -9911,7 +10375,170 @@ class Experiment {
9911
10375
  if(adi >= 0) this.dimensions[adi] = d;
9912
10376
  }
9913
10377
  }
10378
+
10379
+ get allDimensionSelectors() {
10380
+ const sl = Object.keys(MODEL.listOfAllSelectors);
10381
+ // Add selectors of actor, iterator and settings dimensions
10382
+ return sl;
10383
+ }
9914
10384
 
10385
+ orthogonalSelectors(c) {
10386
+ // Returns TRUE iff the selectors in set `c` all are elements of
10387
+ // different experiment dimensions
10388
+ const
10389
+ // Make a copy of `c` so it can be safely expanded
10390
+ xc = c.slice(),
10391
+ // Start with a copy of all model dimensions
10392
+ dl = MODEL.dimensions.slice(),
10393
+ issues = [];
10394
+ // Add dimensions defined for this experiment
10395
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10396
+ dl.push(this.settings_dimensions[i]);
10397
+ }
10398
+ for(let i = 0; i < this.actor_dimensions.length; i++) {
10399
+ dl.push(this.actor_dimensions[i]);
10400
+ }
10401
+ // Expand `c` as it may contain combination selectors
10402
+ this.expandCombinationSelectors(xc);
10403
+ // Check for all these dimensions that `c` contains known selectors
10404
+ // and that no two or more selectors occur in the same dimension
10405
+ let unknown = xc.slice();
10406
+ for(let i = 0; i < dl.length; i++) {
10407
+ const idc = intersection(dl[i], xc);
10408
+ unknown = complement(unknown, idc);
10409
+ if(idc.length > 1) {
10410
+ const pair = idc.join(' & ');
10411
+ if(issues.indexOf(pair) < 0) issues.push(pair);
10412
+ }
10413
+ }
10414
+ if(unknown.length > 0) {
10415
+ UI.warn('Combination contains ' +
10416
+ pluralS(unknown.length, 'undefined selector') +
10417
+ ' (' + unknown.join(', ') + ')');
10418
+ return false;
10419
+ }
10420
+ if(issues.length > 0) {
10421
+ UI.warn('Combination contains multiple selectors from same dimension (' +
10422
+ issues.join(', ') + ')');
10423
+ return false;
10424
+ }
10425
+ return true;
10426
+ }
10427
+
10428
+ expandCombinationSelectors(cs) {
10429
+ // Expansion of combination selectors in a selector set `cs` means
10430
+ // that if, for example, `cs` = (A, C1) where C1 is a combination
10431
+ // selector defined as C1 = (B, C2) with A and B being "normal"
10432
+ // selectors, then C1 must be removed from `cs`, while B and the
10433
+ // expansion of C2 must be appended to `cs`.
10434
+ // NOTE: the original selectors C1 and C2 must be removed because
10435
+ // *dimension* selectors cannot be a used as "normal" selectors
10436
+ // (e.g., for dataset modifiers, actor settings or model setting)
10437
+ // NOTE: traverse `cs` in reverse order to ensure that deleting and
10438
+ // appending produce the intended result
10439
+ for(let i = cs.length - 1; i >= 0; i--) {
10440
+ const s = cs[i];
10441
+ // Check whether selector `s` defines a combination
10442
+ for(let j = 0; j < this.combination_selectors.length; j++) {
10443
+ const tuple = this.combination_selectors[j].split('|');
10444
+ if(tuple[0] === s) {
10445
+ // First remove `s` from the original set...
10446
+ cs.splice(i, 1);
10447
+ // Let `xs` be the selector set to replace `s`
10448
+ const xs = tuple[1].split(' ');
10449
+ // Recursively expand `xs`, as it may contain combination selectors
10450
+ this.expandCombinationSelectors(xs);
10451
+ // ... and append its expansion
10452
+ cs.push(...xs);
10453
+ }
10454
+ }
10455
+ }
10456
+ }
10457
+
10458
+ orthogonalCombinationDimensions(sl) {
10459
+ // Returns TRUE iff the expansions of the selectors in set `sl`
10460
+ // are mutually exclusive
10461
+ const
10462
+ xl = {},
10463
+ issues = {};
10464
+ for(let i = 0; i < sl.length; i++) {
10465
+ const s = sl[i];
10466
+ xl[s] = [s];
10467
+ this.expandCombinationSelectors(xl[s]);
10468
+ issues[s] = [];
10469
+ }
10470
+ let ok = true;
10471
+ for(let i = 0; i < sl.length; i++) {
10472
+ const s1 = sl[i];
10473
+ for(let j = i + 1; j < sl.length; j++) {
10474
+ const
10475
+ s2 = sl[j],
10476
+ shared = intersection(xl[s1], xl[s2]);
10477
+ if(shared.length > 0) {
10478
+ issues[s1].push(`${s2}: ${shared.join(', ')}`);
10479
+ ok = false;
10480
+ }
10481
+ }
10482
+ }
10483
+ if(!ok) {
10484
+ const il = [];
10485
+ for(let i = 0; i < sl.length; i++) {
10486
+ const s = sl[i];
10487
+ if(issues[s].length > 0) {
10488
+ il.push(`${s} (${issues[s].join('; ')})`);
10489
+ }
10490
+ }
10491
+ UI.warn('Combination dimension is not orthogonal: ' + il.join(', '));
10492
+ }
10493
+ return ok;
10494
+ }
10495
+
10496
+ inferAvailableDimensions() {
10497
+ // Creates list of dimensions that are orthogonal to those already
10498
+ // selected for this experiment
10499
+ this.available_dimensions.length = 0;
10500
+ // For efficiency, do not use hasDimension but expand the dimensions
10501
+ // that are already selected once, and define a lookup function that
10502
+ // checks for orthogonality
10503
+ const
10504
+ axes = [],
10505
+ orthogonal = (d) => {
10506
+ for(let i = 0; i < axes.length; i++) {
10507
+ if(intersection(axes[i], d).length > 0) return false;
10508
+ }
10509
+ return true;
10510
+ };
10511
+ for(let i = 0; i < this.dimensions.length; i++) {
10512
+ axes.push(this.dimensions[i].slice());
10513
+ this.expandCombinationSelectors(axes[i]);
10514
+ }
10515
+ for(let i = 0; i < MODEL.dimensions.length; i++) {
10516
+ const d = MODEL.dimensions[i];
10517
+ if(orthogonal(d)) this.available_dimensions.push(d);
10518
+ }
10519
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10520
+ const d = this.settings_dimensions[i];
10521
+ if(orthogonal(d)) this.available_dimensions.push(d);
10522
+ }
10523
+ for(let i = 0; i < this.iterator_dimensions.length; i++) {
10524
+ const d = this.iterator_dimensions[i];
10525
+ if(orthogonal(d)) this.available_dimensions.push(d);
10526
+ }
10527
+ for(let i = 0; i < this.actor_dimensions.length; i++) {
10528
+ const d = this.actor_dimensions[i];
10529
+ if(orthogonal(d)) this.available_dimensions.push(d);
10530
+ }
10531
+ for(let i = 0; i < this.combination_dimensions.length; i++) {
10532
+ // NOTE: combination dimensions must be expanded before checking...
10533
+ const
10534
+ d = this.combination_dimensions[i],
10535
+ xd = d.slice();
10536
+ this.expandCombinationSelectors(xd);
10537
+ // ... but the original combination dimension must be added
10538
+ if(orthogonal(xd)) this.available_dimensions.push(d);
10539
+ }
10540
+ }
10541
+
9915
10542
  inferActualDimensions() {
9916
10543
  // Creates list of dimensions without excluded selectors
9917
10544
  this.actual_dimensions.length = 0;
@@ -9928,6 +10555,9 @@ class Experiment {
9928
10555
  if(n >= this.actual_dimensions.length) {
9929
10556
  // NOTE: do not push an empty selector list (can occur if no dimensions)
9930
10557
  if(s.length > 0) this.combinations.push(s);
10558
+ // NOTE: combinations may include *dimension* selectors
10559
+ // These then must be "expanded"
10560
+ this.expandCombinationSelectors(s);
9931
10561
  return;
9932
10562
  }
9933
10563
  const d = this.actual_dimensions[n];
@@ -9939,14 +10569,33 @@ class Experiment {
9939
10569
  }
9940
10570
  }
9941
10571
 
10572
+ renameSelectorInDimensions(olds, news) {
10573
+ // Update the combination dimensions that contain `olds`
10574
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10575
+ const si = this.settings_dimensions[i].indexOf(olds);
10576
+ if(si >= 0) this.settings_dimensions[i][si] = news;
10577
+ }
10578
+ for(let i = 0; i < this.combination_selectors.length; i++) {
10579
+ const
10580
+ c = this.combination_selectors[i].split('|'),
10581
+ sl = c[1].split(' '),
10582
+ si = sl.indexOf(olds);
10583
+ if(si >= 0) {
10584
+ sl[si] = news;
10585
+ c[1] = sl.join(' ');
10586
+ this.combination_selectors[i] = c.join('|');
10587
+ }
10588
+ }
10589
+ }
10590
+
9942
10591
  mayBeIgnored(c) {
9943
- // Returns TRUE iff `c` is on the list to be ignored
10592
+ // Returns TRUE iff cluster `c` is on the list to be ignored
9944
10593
  for(let i = 0; i < this.clusters_to_ignore.length; i++) {
9945
10594
  if(this.clusters_to_ignore[i].cluster === c) return true;
9946
10595
  }
9947
10596
  return false;
9948
10597
  }
9949
-
10598
+
9950
10599
  inferVariables() {
9951
10600
  // Create list of distinct variables in charts
9952
10601
  this.variables.length = 0;