linny-r 1.1.23 → 1.2.1

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 = {};
@@ -688,8 +689,8 @@ class LinnyRModel {
688
689
  // Merge into dimension if there are shared selectors
689
690
  for(let i = 0; i < this.dimensions.length; i++) {
690
691
  const c = complement(sl, this.dimensions[i]);
691
- if(c.length > 0 && c.length < sl.length) {
692
- this.dimensions[i].push(...c);
692
+ if(c.length < sl.length) {
693
+ if(c.length > 0) this.dimensions[i].push(...c);
693
694
  newdim = false;
694
695
  break;
695
696
  }
@@ -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 ||
@@ -1005,7 +1181,7 @@ class LinnyRModel {
1005
1181
  }
1006
1182
  const id = UI.nameToID(name);
1007
1183
  let d = this.namedObjectByID(id);
1008
- if(d) {
1184
+ if(d && d !== this.equations_dataset) {
1009
1185
  if(IO_CONTEXT) {
1010
1186
  IO_CONTEXT.supersede(d);
1011
1187
  } else {
@@ -1015,11 +1191,25 @@ 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
+ // Return the extended equations dataset
1209
+ return eqds;
1210
+ } else {
1211
+ this.datasets[id] = d;
1212
+ }
1023
1213
  return d;
1024
1214
  }
1025
1215
 
@@ -1269,8 +1459,8 @@ class LinnyRModel {
1269
1459
 
1270
1460
  setSelection() {
1271
1461
  // 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)
1462
+ // NOTE: to be called after loading a model, and after UNDO/REDO (and
1463
+ // then before drawing the diagram)
1274
1464
  const fc = this.focal_cluster;
1275
1465
  this.selection.length = 0;
1276
1466
  this.selection_related_arrows.length = 0;
@@ -1835,67 +2025,90 @@ class LinnyRModel {
1835
2025
  }
1836
2026
  }
1837
2027
 
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
2028
+ get allExpressions() {
2029
+ // Returns list of all Expression objects
2030
+ // NOTE: start with dataset expressions, so that when recompiling
2031
+ // their `level-based` property is set before recompiling the
2032
+ // other expressions
2033
+ const x = [];
2034
+ for(let k in this.datasets) if(this.datasets.hasOwnProperty(k)) {
2035
+ const ds = this.datasets[k];
2036
+ // NOTE: dataset modifier expressions include the equations
2037
+ for(let m in ds.modifiers) if(ds.modifiers.hasOwnProperty(m)) {
2038
+ x.push(ds.modifiers[m].expression);
2039
+ }
2040
+ }
1852
2041
  for(let k in this.actors) if(this.actors.hasOwnProperty(k)) {
1853
- ioc.rewrite(this.actors[k].weight, en1, en2);
2042
+ x.push(this.actors[k].weight);
1854
2043
  }
1855
- // Check all process attribute expressions
1856
2044
  for(let k in this.processes) if(this.processes.hasOwnProperty(k)) {
1857
2045
  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);
2046
+ x.push(p.lower_bound, p.upper_bound, p.initial_level, p.pace_expression);
1862
2047
  }
1863
- // Check all product attribute expressions
1864
2048
  for(let k in this.products) if(this.products.hasOwnProperty(k)) {
1865
2049
  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);
2050
+ x.push(p.lower_bound, p.upper_bound, p.initial_level, p.price);
1870
2051
  }
1871
- // Check all notes in clusters for their color expressions and fields
1872
2052
  for(let k in this.clusters) if(this.clusters.hasOwnProperty(k)) {
1873
2053
  const c = this.clusters[k];
1874
2054
  for(let i = 0; i < c.notes.length; i++) {
1875
2055
  const n = c.notes[i];
1876
- ioc.rewrite(n.color, en1, en2);
1877
- // Also rename entities in note fields
1878
- n.rewriteFields(en1, en2);
2056
+ x.push(n.color);
1879
2057
  }
1880
2058
  }
1881
- // Check all link rate & delay expressions
1882
2059
  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);
2060
+ const l = this.links[k];
2061
+ x.push(l.relative_rate, l.flow_delay);
1885
2062
  }
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);
2063
+ return x;
2064
+ }
2065
+
2066
+ replaceEntityInExpressions(en1, en2) {
2067
+ // Replace entity name `en1` by `en2` in all variables in all expressions
2068
+ // (provided that they are not identical)
2069
+ if(en1 === en2) return;
2070
+ // NOTE: ignore case and multiple spaces in `en1`, but conserve those in
2071
+ // new name `en2` (except for leading and trailing spaces)
2072
+ en1 = en1.trim().replace(/\s+/g, ' ').toLowerCase();
2073
+ en2 = en2.trim();
2074
+ // NOTE: Neither entity name may be empty
2075
+ if(!en1 || !en2) return;
2076
+ // NOTE: use the `rewrite` method of class IOContext; this will keep track
2077
+ // of the number of replacements made
2078
+ const ioc = new IOContext();
2079
+ // Iterate over all expressions
2080
+ const ax = this.allExpressions;
2081
+ for(let i = 0; i < ax.length; i++) {
2082
+ ioc.rewrite(ax[i], en1, en2);
2083
+ }
2084
+ // Iterate over all notes in clusters to rename entities in note fields
2085
+ for(let k in this.clusters) if(this.clusters.hasOwnProperty(k)) {
2086
+ const cn = this.clusters[k].notes;
2087
+ for(let i = 0; i < cn.length; i++) {
2088
+ cn[i].rewriteFields(en1, en2);
1891
2089
  }
1892
2090
  }
1893
2091
  if(ioc.replace_count) {
1894
2092
  UI.notify(`Renamed ${pluralS(ioc.replace_count, 'variable')} in ` +
1895
2093
  pluralS(ioc.expression_count, 'expression'));
1896
2094
  }
2095
+ // Also rename entities in parameters and outcomes of sensitivity analysis
2096
+ for(let i = 0; i < this.sensitivity_parameters.length; i++) {
2097
+ const sp = this.sensitivity_parameters[i].split('|');
2098
+ if(sp[0].toLowerCase() === en1) {
2099
+ sp[0] = en2;
2100
+ this.sensitivity_parameters[i] = sp.join('|');
2101
+ }
2102
+ }
2103
+ for(let i = 0; i < this.sensitivity_outcomes.length; i++) {
2104
+ const so = this.sensitivity_outcomes[i].split('|');
2105
+ if(so[0].toLowerCase() === en1) {
2106
+ so[0] = en2;
2107
+ this.sensitivity_outcomes[i] = so.join('|');
2108
+ }
2109
+ }
1897
2110
  // Name was changed, so update controller dialogs to display the new name
1898
- UI.updateControllerDialogs('CDEFX');
2111
+ UI.updateControllerDialogs('CDEFJX');
1899
2112
  }
1900
2113
 
1901
2114
  replaceAttributeInExpressions(ena, a) {
@@ -1905,58 +2118,40 @@ class LinnyRModel {
1905
2118
  // or in the new attribute `a` (except for leading and trailing spaces)
1906
2119
  a = a.trim();
1907
2120
  ena = ena.split('|');
1908
- // Double-check that `a` is no empty and `ena` contains a vertical bar
2121
+ // Double-check that `a` is not empty and `ena` contains a vertical bar
1909
2122
  if(!a || ena.length < 2) return;
1910
2123
  // Prepare regex to match [entity|attribute] including brackets, but case-
1911
2124
  // tolerant and spacing-tolerant
1912
2125
  const
1913
2126
  en = escapeRegex(ena[0].trim().replace(/\s+/g, ' ').toLowerCase()),
1914
2127
  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');
2128
+ raw = en.replace(/\s/, `\s+`) + `\s*\|\s*` + escapeRegex(at),
2129
+ re = new RegExp(`\[\s*${raw}\s*(\@[^\]]+)?\s*\]`, 'gi');
1919
2130
  // Count replacements made
1920
2131
  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);
2132
+ // Iterate over all expressions
2133
+ const ax = this.allExpressions;
2134
+ for(let i = 0; i < ax.length; i++) {
2135
+ n += ax[i].replaceAttribute(re, at, a);
1932
2136
  }
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);
1940
- }
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);
2137
+ // Also rename attributes in parameters and outcomes of sensitivity analysis
2138
+ let sa_cnt = 0;
2139
+ const enat = en + '|' + at;
2140
+ for(let i = 0; i < this.sensitivity_parameters.length; i++) {
2141
+ const sp = this.sensitivity_parameters[i];
2142
+ if(sp.toLowerCase() === enat) {
2143
+ this.sensitivity_parameters[i] = sp.split('|')[0] + '|' + a;
2144
+ sa_cnt++;
1946
2145
  }
1947
2146
  }
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);
2147
+ for(let i = 0; i < this.sensitivity_outcomes.length; i++) {
2148
+ const so = this.sensitivity_outcomes[i];
2149
+ if(so.toLowerCase() === enat) {
2150
+ this.sensitivity_outcomes[i] = so.split('|')[0] + '|' + a;
2151
+ sa_cnt++;
1958
2152
  }
1959
2153
  }
2154
+ if(sa_cnt > 0) SENSITIVITY_ANALYSIS.updateDialog();
1960
2155
  return n;
1961
2156
  }
1962
2157
 
@@ -2056,7 +2251,9 @@ class LinnyRModel {
2056
2251
  for(i = 0; i < n.childNodes.length; i++) {
2057
2252
  c = n.childNodes[i];
2058
2253
  if(c.nodeName === 'scaleunit') {
2059
- this.addScaleUnit(xmlDecoded(nodeContentByTag(c, 'name')));
2254
+ this.addScaleUnit(xmlDecoded(nodeContentByTag(c, 'name')),
2255
+ nodeContentByTag(c, 'scalar'),
2256
+ xmlDecoded(nodeContentByTag(c, 'base-unit')));
2060
2257
  }
2061
2258
  }
2062
2259
  }
@@ -2196,14 +2393,14 @@ class LinnyRModel {
2196
2393
  // will then add the module prefix to the selector
2197
2394
  if(IO_CONTEXT) {
2198
2395
  if(name === UI.EQUATIONS_DATASET_NAME) {
2199
- const mn = childNodeByTag(node, 'modifiers');
2396
+ const mn = childNodeByTag(c, 'modifiers');
2200
2397
  if(mn && mn.childNodes) {
2201
2398
  for(let j = 0; j < mn.childNodes.length; j++) {
2202
- const c = mn.childNodes[j];
2203
- if(c.nodeName === 'modifier') {
2399
+ const cc = mn.childNodes[j];
2400
+ if(cc.nodeName === 'modifier') {
2204
2401
  this.equations_dataset.addModifier(
2205
- xmlDecoded(nodeContentByTag(c, 'selector')),
2206
- c, IO_CONTEXT);
2402
+ xmlDecoded(nodeContentByTag(cc, 'selector')),
2403
+ cc, IO_CONTEXT);
2207
2404
  }
2208
2405
  }
2209
2406
  }
@@ -2367,12 +2564,11 @@ class LinnyRModel {
2367
2564
  '</end-period><look-ahead-period>', this.look_ahead,
2368
2565
  '</look-ahead-period><round-sequence>', this.round_sequence,
2369
2566
  '</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>';
2567
+ let obj;
2568
+ for(obj in this.scale_units) if(this.scale_units.hasOwnProperty(obj)) {
2569
+ xml += this.scale_units[obj].asXML;
2373
2570
  }
2374
2571
  xml += '</scaleunits><actors>';
2375
- let obj;
2376
2572
  for(obj in this.actors) {
2377
2573
  // NOTE: do not to save "(no actor)"
2378
2574
  if(this.actors.hasOwnProperty(obj) && obj != UI.nameToID(UI.NO_ACTOR)) {
@@ -2589,6 +2785,10 @@ class LinnyRModel {
2589
2785
  this.cleanVector(p.cash_flow, 0, 0);
2590
2786
  this.cleanVector(p.cash_in, 0, 0);
2591
2787
  this.cleanVector(p.cash_out, 0, 0);
2788
+ // NOTE: note fields also must be reset
2789
+ for(let i = 0; i < p.notes.length; i++) {
2790
+ p.notes[i].parsed = false;
2791
+ }
2592
2792
  }
2593
2793
  for(obj in this.processes) if(this.processes.hasOwnProperty(obj)) {
2594
2794
  p = this.processes[obj];
@@ -2679,30 +2879,9 @@ class LinnyRModel {
2679
2879
 
2680
2880
  compileExpressions() {
2681
2881
  // 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();
2882
+ const ax = this.allExpressions;
2883
+ for(let i = 0; i < ax.length; i++) {
2884
+ ax[i].compile();
2706
2885
  }
2707
2886
  }
2708
2887
 
@@ -3344,17 +3523,6 @@ class LinnyRModel {
3344
3523
  // Start with the Linny-R model properties
3345
3524
  let diff = differences(this, m, Object.keys(UI.MC.SETTINGS_PROPS));
3346
3525
  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
3526
  // NOTE: dataset differences will also detect equation differences
3359
3527
  for(let i = 0; i < UI.MC.ENTITY_PROPS.length; i++) {
3360
3528
  const ep = UI.MC.ENTITY_PROPS[i];
@@ -3691,8 +3859,8 @@ class IOContext {
3691
3859
  }
3692
3860
 
3693
3861
  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
3862
+ // Binds the formal name `fn` of an entity in a module to the actual
3863
+ // name `an` it will have in the current model
3696
3864
  const id = UI.nameToID(fn);
3697
3865
  if(this.bindings.hasOwnProperty(id)) {
3698
3866
  this.bindings[id].bind(an);
@@ -3712,7 +3880,6 @@ class IOContext {
3712
3880
  // (and for processes and clusters: with actor name `an` if specified and
3713
3881
  // not "(no actor)")
3714
3882
  // NOTE: do not modify (no actor), nor the "dataset dot"
3715
- // @@TO DO: correctly handle equations!
3716
3883
  if(n === UI.NO_ACTOR || n === '.') return n;
3717
3884
  // NOTE: the top cluster of the included model has the prefix as its name
3718
3885
  if(n === UI.TOP_CLUSTER_NAME || n === UI.FORMER_TOP_CLUSTER_NAME) {
@@ -3847,7 +4014,7 @@ class IOContext {
3847
4014
  a,
3848
4015
  stat;
3849
4016
  while(true) {
3850
- p = x.text.indexOf('[', p + 1);
4017
+ p = x.text.indexOf('[', q + 1);
3851
4018
  if(p < 0) {
3852
4019
  // No more '[' => add remaining part of text, and quit
3853
4020
  s += x.text.slice(q + 1);
@@ -3952,6 +4119,79 @@ class IOContext {
3952
4119
  } // END of class IOContext
3953
4120
 
3954
4121
 
4122
+ // CLASS ScaleUnit
4123
+ class ScaleUnit {
4124
+ constructor(name, scalar, base_unit) {
4125
+ this.name = name;
4126
+ // NOTES:
4127
+ // (1) Undefined or empty strings default to '1'
4128
+ // (2) Multiplier is stored as string to preserve modeler's notation
4129
+ this.scalar = scalar || '1';
4130
+ this.base_unit = base_unit || '1';
4131
+ }
4132
+
4133
+ get multiplier() {
4134
+ // Returns scalar as number
4135
+ return safeStrToFloat(this.scalar, 1);
4136
+ }
4137
+
4138
+ conversionRates() {
4139
+ // Returns a "dictionary" {U1: R1, U2: R2, ...} such that Ui is a
4140
+ // scale unit that can be converted to *this* scaleunit U at rate Ri
4141
+ const cr = {};
4142
+ let p = 0, // previous count of entries
4143
+ n = 1;
4144
+ // At least one conversion: U -> U with rate 1
4145
+ cr[this.name] = 1;
4146
+ if(this.base_unit !== '1') {
4147
+ // Second conversion: U -> base of U with modeler-defined rate
4148
+ cr[this.base_unit] = this.multiplier;
4149
+ n++;
4150
+ }
4151
+ // Keep track of the number of keys; terminate as no new keys
4152
+ while(p < n) {
4153
+ p = n;
4154
+ // Iterate over all convertible scale units discovered so far
4155
+ for(let u in cr) if(cr.hasOwnProperty(u)) {
4156
+ // Look for conversions to units NOT yet detected
4157
+ for(let k in MODEL.scale_units) if(k != '1' &&
4158
+ MODEL.scale_units.hasOwnProperty(k)) {
4159
+ const
4160
+ su = MODEL.scale_units[k],
4161
+ b = su.base_unit;
4162
+ if(b === '1') continue;
4163
+ if(!cr.hasOwnProperty(k) && cr.hasOwnProperty(b)) {
4164
+ // Add unit if new while base unit is convertible
4165
+ cr[k] = cr[b] / su.multiplier;
4166
+ n++;
4167
+ } else if(cr.hasOwnProperty(k) && !cr.hasOwnProperty(b)) {
4168
+ // Likewise, add base unit if new while unit is convertible
4169
+ cr[b] = cr[k] * su.multiplier;
4170
+ n++;
4171
+ }
4172
+ }
4173
+ }
4174
+ }
4175
+ return cr;
4176
+ }
4177
+
4178
+ get asXML() {
4179
+ return ['<scaleunit><name>', xmlEncoded(this.name),
4180
+ '</name><scalar>', this.scalar,
4181
+ '</scalar><base-unit>', xmlEncoded(this.base_unit),
4182
+ '</base-unit></scaleunit>'].join('');
4183
+ }
4184
+
4185
+ // NOTE: NO initFromXML because scale units are added directly
4186
+
4187
+ differences(u) {
4188
+ // Return "dictionary" of differences, or NULL if none
4189
+ const d = differences(this, u, UI.MC.UNIT_PROPS);
4190
+ if(Object.keys(d).length > 0) return d;
4191
+ return null;
4192
+ }
4193
+ }
4194
+
3955
4195
  // CLASS Actor
3956
4196
  class Actor {
3957
4197
  constructor(name) {
@@ -4107,35 +4347,45 @@ class ObjectWithXYWH {
4107
4347
 
4108
4348
  // CLASS NoteField: numeric value of "field" [[dataset]] in note text
4109
4349
  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
4350
+ constructor(f, o, u='1', m=1) {
4351
+ // `f` holds the unmodified tag string [[dataset]] to be replaced by
4352
+ // the value of vector or expression `o` for the current time step;
4353
+ // if specified, `u` is the unit of the value to be displayed, and
4354
+ // `m` is the multiplier for the value to be displayed
4113
4355
  this.field = f;
4114
4356
  this.object = o;
4357
+ this.unit = u;
4358
+ this.multiplier = m;
4115
4359
  }
4116
4360
 
4117
4361
  get value() {
4118
- // Returns the numeric value of this note field
4362
+ // Returns the numeric value of this note field as a numeric string
4363
+ // followed by its unit (unless this is 1)
4364
+ let v = VM.UNDEFINED;
4119
4365
  const t = MODEL.t;
4120
4366
  if(Array.isArray(this.object)) {
4121
4367
  // 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')) {
4368
+ if(t < this.object.length) v = this.object[t];
4369
+ } else if(this.object.hasOwnProperty('c') &&
4370
+ this.object.hasOwnProperty('u')) {
4128
4371
  // Object holds link lists for cluster balance computation
4129
- return MODEL.flowBalance(this.object, t);
4372
+ v = MODEL.flowBalance(this.object, t);
4130
4373
  } else if(this.object instanceof Expression) {
4131
4374
  // Object is an expression
4132
- return this.object.result(t);
4375
+ v = this.object.result(t);
4133
4376
  } else if(typeof this.object === 'number') {
4134
- return this.object;
4377
+ v = this.object;
4378
+ } else {
4379
+ // NOTE: this fall-through should not occur
4380
+ console.log('Note field value issue:', this.object);
4135
4381
  }
4136
- // NOTE: this fall-through should not occur
4137
- console.log('Note field value issue:', this.object);
4138
- return VM.UNDEFINED;
4382
+ if(Math.abs(this.multiplier - 1) > VM.NEAR_ZERO &&
4383
+ v > VM.MINUS_INFINITY && v < VM.PLUS_INFINITY) {
4384
+ v *= this.multiplier;
4385
+ }
4386
+ v = VM.sig4Dig(v);
4387
+ if(this.unit !== '1') v += ' ' + this.unit;
4388
+ return v;
4139
4389
  }
4140
4390
 
4141
4391
  } // END of class NoteField
@@ -4198,6 +4448,13 @@ class Note extends ObjectWithXYWH {
4198
4448
  this.width = safeStrToInt(nodeContentByTag(node, 'width'));
4199
4449
  this.height = safeStrToInt(nodeContentByTag(node, 'height'));
4200
4450
  this.color.text = xmlDecoded(nodeContentByTag(node, 'color'));
4451
+ if(IO_CONTEXT) {
4452
+ const fel = this.fieldEntities;
4453
+ for(let i = 0; i < fel.length; i++) {
4454
+ this.rewriteTags(fel[i], IO_CONTEXT.actualName(fel[i]));
4455
+ }
4456
+ IO_CONTEXT.rewrite(this.color);
4457
+ }
4201
4458
  }
4202
4459
 
4203
4460
  setCluster(c) {
@@ -4223,24 +4480,93 @@ class Note extends ObjectWithXYWH {
4223
4480
  for(let i = 0; i < tags.length; i++) {
4224
4481
  const
4225
4482
  tag = tags[i],
4226
- ena = tag.slice(2, tag.length - 2).trim().split('|');
4483
+ inner = tag.slice(2, tag.length - 2).trim(),
4484
+ bar = inner.lastIndexOf('|'),
4485
+ arrow = inner.lastIndexOf('->');
4486
+ // Check if a unit conversion scalar was specified
4487
+ let ena,
4488
+ from_unit = '1',
4489
+ to_unit = '',
4490
+ multiplier = 1;
4491
+ if(arrow > bar) {
4492
+ // Now for sure it is entity->unit or entity|attr->unit
4493
+ ena = inner.split('->');
4494
+ // As example, assume that unit = 'kWh' (so the value of the
4495
+ // field should be displayed in kilowatthour)
4496
+ // NOTE: use .trim() instead of UI.cleanName(...) here;
4497
+ // this forces the modeler to be exact, and that permits proper
4498
+ // renaming of scale units in note fields
4499
+ to_unit = ena[1].trim();
4500
+ ena = ena[0].split('|');
4501
+ if(!MODEL.scale_units.hasOwnProperty(to_unit)) {
4502
+ UI.warn(`Unknown scale unit "${to_unit}"`);
4503
+ to_unit = '1';
4504
+ }
4505
+ } else {
4506
+ ena = inner.split('|');
4507
+ }
4227
4508
  // Look up entity for name and attribute
4228
- const obj = MODEL.objectByName(ena[0]);
4509
+ const obj = MODEL.objectByName(ena[0].trim());
4229
4510
  if(obj instanceof DatasetModifier) {
4230
- this.fields.push(new NoteField(tag, obj.expression));
4511
+ // NOTE: equations are (for now) dimensionless => unit '1'
4512
+ if(obj.dataset !== MODEL.equations_dataset) {
4513
+ from_unit = obj.dataset.scale_unit;
4514
+ multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4515
+ }
4516
+ this.fields.push(new NoteField(tag, obj.expression, to_unit, multiplier));
4231
4517
  } else if(obj) {
4232
4518
  // If attribute omitted, use default attribute of entity type
4233
4519
  const attr = (ena.length > 1 ? ena[1].trim() : obj.defaultAttribute);
4234
- // Variable may specify a vector-type attribute
4235
- let val = obj.attributeValue(attr);
4520
+ let val = null;
4521
+ // NOTE: for datasets, use the active modifier
4522
+ if(!attr && obj instanceof Dataset) {
4523
+ val = obj.activeModifierExpression;
4524
+ } else {
4525
+ // Variable may specify a vector-type attribute
4526
+ val = obj.attributeValue(attr);
4527
+ }
4236
4528
  // If not, it may be a cluster unit balance
4237
4529
  if(!val && attr.startsWith('=') && obj instanceof Cluster) {
4238
4530
  val = {c: obj, u: attr.substring(1).trim()};
4531
+ from_unit = val.u;
4532
+ }
4533
+ if(obj instanceof Dataset) {
4534
+ from_unit = obj.scale_unit;
4535
+ } else if(obj instanceof Product) {
4536
+ if(attr === 'L') {
4537
+ from_unit = obj.scale_unit;
4538
+ } else if(attr === 'CP' || attr === 'HCP') {
4539
+ from_unit = MODEL.currency_unit;
4540
+ }
4541
+ } else if(obj instanceof Link) {
4542
+ const node = (obj.from_node instanceof Process ?
4543
+ obj.to_node : obj.from_node);
4544
+ if(attr === 'F') {
4545
+ if(obj.multiplier <= VM.LM_MEAN) {
4546
+ from_unit = node.scale_unit;
4547
+ } else {
4548
+ from_unit = '1';
4549
+ }
4550
+ }
4551
+ } else if(attr === 'CI' || attr === 'CO' || attr === 'CF') {
4552
+ from_unit = MODEL.currency_unit;
4239
4553
  }
4240
4554
  // If not, it may be an expression-type attribute
4241
- if(!val) val = obj.attributeExpression(attr);
4555
+ if(!val) {
4556
+ val = obj.attributeExpression(attr);
4557
+ if(obj instanceof Product) {
4558
+ if(attr === 'IL' || attr === 'LB' || attr === 'UB') {
4559
+ from_unit = obj.scale_unit;
4560
+ } else if(attr === 'P') {
4561
+ from_unit = MODEL.currency_unit + '/' + obj.scale_unit;
4562
+ }
4563
+ }
4564
+ }
4565
+ // If no TO unit, add the FROM unit
4566
+ if(to_unit === '') to_unit = from_unit;
4242
4567
  if(val) {
4243
- this.fields.push(new NoteField(tag, val));
4568
+ multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4569
+ this.fields.push(new NoteField(tag, val, to_unit, multiplier));
4244
4570
  } else {
4245
4571
  UI.warn(`Unknown ${obj.type.toLowerCase()} attribute "${attr}"`);
4246
4572
  }
@@ -4251,10 +4577,48 @@ class Note extends ObjectWithXYWH {
4251
4577
  }
4252
4578
  this.parsed = true;
4253
4579
  }
4580
+
4581
+ get fieldEntities() {
4582
+ // Return a list with names of entities used in fields
4583
+ const
4584
+ fel = [],
4585
+ tags = this.contents.match(/\[\[[^\]]+\]\]/g);
4586
+ for(let i = 0; i < tags.length; i++) {
4587
+ const
4588
+ tag = tags[i],
4589
+ inner = tag.slice(2, tag.length - 2).trim(),
4590
+ vb = inner.lastIndexOf('|'),
4591
+ ua = inner.lastIndexOf('->');
4592
+ if(vb >= 0) {
4593
+ addDistinct(inner.slice(0, vb), fel);
4594
+ } else if(ua >= 0 &&
4595
+ MODEL.scale_units.hasOwnProperty(inner.slice(ua + 2))) {
4596
+ addDistinct(inner.slice(0, ua), fel);
4597
+ } else {
4598
+ addDistinct(inner, fel);
4599
+ }
4600
+ }
4601
+ return fel;
4602
+ }
4603
+
4604
+ rewriteTags(en1, en2) {
4605
+ // Rewrite tags that reference entity name `en1` to reference `en2` instead
4606
+ if(en1 === en2) return;
4607
+ const
4608
+ raw = en1.split(/\s+/).join('\\\\s+'),
4609
+ re = new RegExp('\\[\\[\\s*' + raw + '\\s*(\\->|\\||\\])', 'g'),
4610
+ tags = this.contents.match(re);
4611
+ if(tags) {
4612
+ for(let i = 0; i < tags.length; i++) {
4613
+ this.contents = this.contents.replace(tags[i], tags[i].replace(en1, en2));
4614
+ }
4615
+ }
4616
+ }
4254
4617
 
4255
4618
  rewriteFields(en1, en2) {
4256
4619
  // Rename fields that reference entity name `en1` to reference `en2` instead
4257
4620
  // NOTE: this does not affect the expression code
4621
+ if(en1 === en2) return;
4258
4622
  for(let i = 0; i < this.fields.length; i++) {
4259
4623
  const
4260
4624
  f = this.fields[i],
@@ -4263,12 +4627,17 @@ class Note extends ObjectWithXYWH {
4263
4627
  // Separate tag into variable and attribute + offset string (if any)
4264
4628
  let e = tag,
4265
4629
  a = '',
4266
- vb = tag.lastIndexOf('|');
4630
+ vb = tag.lastIndexOf('|'),
4631
+ ua = tag.lastIndexOf('->');
4267
4632
  if(vb >= 0) {
4268
4633
  e = tag.slice(0, vb);
4269
4634
  // NOTE: attribute string includes the vertical bar '|'
4270
4635
  a = tag.slice(vb);
4271
- }
4636
+ } else if(ua >= 0 && MODEL.scale_units.hasOwnProperty(tag.slice(ua + 2))) {
4637
+ e = tag.slice(0, ua);
4638
+ // NOTE: attribute string includes the unit conversion arrow '->'
4639
+ a = tag.slice(ua);
4640
+ }
4272
4641
  // Check for match
4273
4642
  const r = UI.replaceEntity(e, en1, en2);
4274
4643
  if(r) {
@@ -4284,7 +4653,7 @@ class Note extends ObjectWithXYWH {
4284
4653
  let txt = this.contents;
4285
4654
  for(let i = 0; i < this.fields.length; i++) {
4286
4655
  const nf = this.fields[i];
4287
- txt = txt.replace(nf.field, VM.sig4Dig(nf.value));
4656
+ txt = txt.replace(nf.field, nf.value);
4288
4657
  }
4289
4658
  return txt;
4290
4659
  }
@@ -4402,7 +4771,14 @@ class NodeBox extends ObjectWithXYWH {
4402
4771
  get numberContext() {
4403
4772
  // Returns the string to be used to evaluate #, so for clusters, processes
4404
4773
  // and products this is the string of trailing digits (or empty if none)
4405
- return endsWithDigits(this.name);
4774
+ // of the node name, or if that does not end with a number, the trailing
4775
+ // digits of the first prefix (from right to left) that does
4776
+ const sn = this.name.split(UI.PREFIXER);
4777
+ let nc = endsWithDigits(sn.pop());
4778
+ while(!nc && sn.length > 0) {
4779
+ nc = endsWithDigits(sn.pop());
4780
+ }
4781
+ return nc;
4406
4782
  }
4407
4783
 
4408
4784
  rename(name, actor_name) {
@@ -4441,7 +4817,7 @@ class NodeBox extends ObjectWithXYWH {
4441
4817
  delete MODEL.products[old_id];
4442
4818
  } else if(this instanceof Cluster) {
4443
4819
  MODEL.clusters[new_id] = this;
4444
- delete MODEL.products[old_id];
4820
+ delete MODEL.clusters[old_id];
4445
4821
  } else {
4446
4822
  // NOTE: this should never happen => report an error
4447
4823
  UI.alert('Can only rename processes, products and clusters');
@@ -4706,7 +5082,7 @@ class Arrow {
4706
5082
  } else {
4707
5083
  if(p[0] && p[1]) {
4708
5084
  console.log('ERROR: Two distinct flows on monodirectional arrow',
4709
- this, sum);
5085
+ this, sum, p);
4710
5086
  return [0, 0, 0, false, false];
4711
5087
  }
4712
5088
  status = 1;
@@ -6322,6 +6698,8 @@ class Node extends NodeBox {
6322
6698
  ds = MODEL.addDataset(dsn);
6323
6699
  // Use the LB attribute as default value for the dataset
6324
6700
  ds.default_value = parseFloat(this.lower_bound.text);
6701
+ // UB data has same unit as product
6702
+ ds.scale_unit = this.scale_unit;
6325
6703
  ds.data = stringToFloatArray(lb_data);
6326
6704
  ds.computeVector();
6327
6705
  ds.computeStatistics();
@@ -6334,6 +6712,8 @@ class Node extends NodeBox {
6334
6712
  dsn = this.displayName + ' UPPER BOUND DATA',
6335
6713
  ds = MODEL.addDataset(dsn);
6336
6714
  ds.default_value = parseFloat(this.upper_bound.text);
6715
+ // UB data has same unit as product
6716
+ ds.scale_unit = this.scale_unit;
6337
6717
  ds.data = stringToFloatArray(ub_data);
6338
6718
  ds.computeVector();
6339
6719
  ds.computeStatistics();
@@ -6961,6 +7341,8 @@ class Product extends Node {
6961
7341
  ds = MODEL.addDataset(dsn);
6962
7342
  // Use the price attribute as default value for the dataset
6963
7343
  ds.default_value = parseFloat(this.price.text);
7344
+ // NOTE: dataset unit then is a currency
7345
+ ds.scale_unit = MODEL.currency_unit;
6964
7346
  ds.data = stringToFloatArray(data);
6965
7347
  ds.computeVector();
6966
7348
  ds.computeStatistics();
@@ -7216,8 +7598,8 @@ class Link {
7216
7598
  tn = this.from_node;
7217
7599
  }
7218
7600
  // Otherwise, the FROM node is checked first
7219
- let nc = endsWithDigits(fn.name);
7220
- if(!nc) nc = endsWithDigits(tn.name);
7601
+ let nc = fn.numberContext;
7602
+ if(!nc) nc = tn.numberContext;
7221
7603
  return nc;
7222
7604
  }
7223
7605
 
@@ -7343,12 +7725,10 @@ class Link {
7343
7725
 
7344
7726
  // CLASS DatasetModifier
7345
7727
  class DatasetModifier {
7346
- constructor(dataset, selector, params=false) {
7728
+ constructor(dataset, selector) {
7347
7729
  this.dataset = dataset;
7348
7730
  this.selector = selector;
7349
7731
  this.expression = new Expression(dataset, selector, '');
7350
- // Equations may have parameters
7351
- this.parameters = params;
7352
7732
  this.expression_cache = {};
7353
7733
  }
7354
7734
 
@@ -7379,17 +7759,12 @@ class DatasetModifier {
7379
7759
  // NOTE: for some reason, selector may become empty string, so prevent
7380
7760
  // saving such unidentified modifiers
7381
7761
  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),
7762
+ return ['<modifier><selector>', xmlEncoded(this.selector),
7386
7763
  '</selector><expression>', xmlEncoded(this.expression.text),
7387
7764
  '</expression></modifier>'].join('');
7388
7765
  }
7389
7766
 
7390
7767
  initFromXML(node) {
7391
- const pstr = nodeParameterValue(node, 'parameters').trim();
7392
- this.parameters = (pstr ? xmlDecoded(pstr).split('\\'): false);
7393
7768
  this.expression.text = xmlDecoded(nodeContentByTag(node, 'expression'));
7394
7769
  if(IO_CONTEXT) {
7395
7770
  // Contextualize the included expression
@@ -7421,6 +7796,7 @@ class Dataset {
7421
7796
  this.name = name;
7422
7797
  this.comments = '';
7423
7798
  this.default_value = 0;
7799
+ this.scale_unit = '1';
7424
7800
  this.time_scale = 1;
7425
7801
  this.time_unit = CONFIGURATION.default_time_unit;
7426
7802
  this.method = 'nearest';
@@ -7476,7 +7852,12 @@ class Dataset {
7476
7852
 
7477
7853
  get numberContext() {
7478
7854
  // Returns the string to be used to evaluate # (empty string if undefined)
7479
- return endsWithDigits(this.name);
7855
+ const sn = this.name.split(UI.PREFIXER);
7856
+ let nc = endsWithDigits(sn.pop());
7857
+ while(!nc && sn.length > 0) {
7858
+ nc = endsWithDigits(sn.pop());
7859
+ }
7860
+ return nc;
7480
7861
  }
7481
7862
 
7482
7863
  get selectorList() {
@@ -7519,6 +7900,14 @@ class Dataset {
7519
7900
  }
7520
7901
  return this.default_value * MODEL.timeStepDuration / this.timeStepDuration;
7521
7902
  }
7903
+
7904
+ changeScaleUnit(name) {
7905
+ let su = MODEL.addScaleUnit(name);
7906
+ if(su !== this.scale_unit) {
7907
+ this.scale_unit = su;
7908
+ MODEL.cleanUpScaleUnits();
7909
+ }
7910
+ }
7522
7911
 
7523
7912
  matchingModifiers(l) {
7524
7913
  // Returns the list of selectors of this dataset (in order: from most to
@@ -7655,47 +8044,45 @@ class Dataset {
7655
8044
  }
7656
8045
  return null;
7657
8046
  }
8047
+
8048
+ get activeModifierExpression() {
8049
+ if(MODEL.running_experiment) {
8050
+ // If an experiment is running, check if dataset modifiers match the
8051
+ // combination of selectors for the active run
8052
+ const mm = this.matchingModifiers(MODEL.running_experiment.activeCombination);
8053
+ // If so, use the first match
8054
+ if(mm.length > 0) return mm[0].expression;
8055
+ }
8056
+ if(this.default_selector) {
8057
+ // If no experiment (so "normal" run), use default selector if specified
8058
+ const dm = this.modifiers[this.default_selector];
8059
+ if(dm) return dm.expression;
8060
+ // Exception should never occur, but check anyway and log it
8061
+ console.log('WARNING: Dataset "' + this.name +
8062
+ `" has no default selector "${this.default_selector}"`);
8063
+ }
8064
+ // Fall-through: return vector instead of expression
8065
+ return this.vector;
8066
+ }
7658
8067
 
7659
8068
  addModifier(selector, node=null, ioc=null) {
7660
- let s = selector,
7661
- params = false;
8069
+ let s = selector;
7662
8070
  // Firstly, sanitize the selector
7663
8071
  if(this === MODEL.equations_dataset) {
7664
8072
  // Equation identifiers cannot contain characters that have special
7665
8073
  // meaning in a variable identifier
7666
- s = s.replace(/[\*\?\|\[\]\{\}\:\@\#]/g, '');
8074
+ s = s.replace(/[\*\?\|\[\]\{\}\@\#]/g, '');
7667
8075
  if(s !== selector) {
7668
- UI.warn('Equation name cannot contain [, ], {, }, |, :, @, #, * or ?');
8076
+ UI.warn('Equation name cannot contain [, ], {, }, |, @, #, * or ?');
7669
8077
  return null;
7670
8078
  }
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
- }
8079
+ // Reduce inner spaces to one, and trim outer spaces
8080
+ s = s.replace(/\s+/g, ' ').trim();
8081
+ // Then prefix it when the IO context argument is defined
8082
+ if(ioc) s = ioc.actualName(s);
7681
8083
  // If equation already exists, return its modifier
7682
8084
  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
- }
8085
+ if(this.modifiers.hasOwnProperty(id)) return this.modifiers[id];
7699
8086
  // New equation identifier must not equal some entity ID
7700
8087
  const obj = MODEL.objectByName(s);
7701
8088
  if(obj) {
@@ -7703,8 +8090,6 @@ class Dataset {
7703
8090
  UI.warningEntityExists(obj);
7704
8091
  return null;
7705
8092
  }
7706
- // Also reduce inner spaces to one, and trim outer spaces
7707
- s = s.replace(/\s+/g, ' ').trim();
7708
8093
  } else {
7709
8094
  // Standard dataset modifier selectors are much more restricted, but
7710
8095
  // to be user-friendly, special chars are removed automatically
@@ -7722,12 +8107,10 @@ class Dataset {
7722
8107
  UI.warn(UI.WARNING.INVALID_SELECTOR);
7723
8108
  return null;
7724
8109
  }
7725
- // Then prefix it when the IO context argument is defined
7726
- if(ioc) s = ioc.actualName(s);
7727
8110
  // Then add a dataset modifier to this dataset
7728
8111
  const id = UI.nameToID(s);
7729
8112
  if(!this.modifiers.hasOwnProperty(id)) {
7730
- this.modifiers[id] = new DatasetModifier(this, s, params);
8113
+ this.modifiers[id] = new DatasetModifier(this, s);
7731
8114
  }
7732
8115
  // Finally, initialize it when the XML node argument is defined
7733
8116
  if(node) this.modifiers[id].initFromXML(node);
@@ -7762,7 +8145,8 @@ class Dataset {
7762
8145
  const xml = ['<dataset', p, '><name>', xmlEncoded(n),
7763
8146
  '</name><notes>', cmnts,
7764
8147
  '</notes><default>', this.default_value,
7765
- '</default><time-scale>', this.time_scale,
8148
+ '</default><unit>', xmlEncoded(this.scale_unit),
8149
+ '</unit><time-scale>', this.time_scale,
7766
8150
  '</time-scale><time-unit>', this.time_unit,
7767
8151
  '</time-unit><method>', this.method,
7768
8152
  '</method><url>', xmlEncoded(this.url),
@@ -7776,10 +8160,11 @@ class Dataset {
7776
8160
  initFromXML(node) {
7777
8161
  this.comments = xmlDecoded(nodeContentByTag(node, 'notes'));
7778
8162
  this.default_value = safeStrToFloat(nodeContentByTag(node, 'default'));
8163
+ this.scale_unit = xmlDecoded(nodeContentByTag(node, 'unit')) || '1';
7779
8164
  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');
8165
+ this.time_unit = nodeContentByTag(node, 'time-unit') ||
8166
+ CONFIGURATION.default_time_unit;
8167
+ this.method = nodeContentByTag(node, 'method') || 'nearest';
7783
8168
  this.periodic = nodeParameterValue(node, 'periodic') === '1';
7784
8169
  this.array = nodeParameterValue(node, 'array') === '1';
7785
8170
  this.black_box = nodeParameterValue(node, 'black-box') === '1';
@@ -9619,8 +10004,13 @@ class Experiment {
9619
10004
  this.variables = [];
9620
10005
  this.configuration_dims = 0;
9621
10006
  this.column_scenario_dims = 0;
10007
+ this.iterator_ranges = [[0,0], [0,0], [0,0]];
10008
+ this.iterator_dimensions = [];
9622
10009
  this.settings_selectors = [];
9623
10010
  this.settings_dimensions = [];
10011
+ this.combination_selectors = [];
10012
+ this.combination_dimensions = [];
10013
+ this.available_dimensions = [];
9624
10014
  this.actor_selectors = [];
9625
10015
  this.actor_dimensions = [];
9626
10016
  this.excluded_selectors = '';
@@ -9680,6 +10070,56 @@ class Experiment {
9680
10070
  return this.combinations[this.active_combination_index];
9681
10071
  }
9682
10072
 
10073
+ get iteratorRangeString() {
10074
+ // Returns the iterator ranges as "from,to" pairs separated by |
10075
+ const ir = [];
10076
+ for(let i = 0; i < 3; i++) {
10077
+ ir.push(this.iterator_ranges[i].join(','));
10078
+ }
10079
+ return ir.join('|');
10080
+ }
10081
+
10082
+ parseIteratorRangeString(s) {
10083
+ // Parses `s` as "from,to" pairs, ignoring syntax errors
10084
+ if(s) {
10085
+ const ir = s.split('|');
10086
+ // Add 2 extra substrings to have at least 3
10087
+ ir.push('', '');
10088
+ for(let i = 0; i < 3; i++) {
10089
+ const r = ir[i].split(',');
10090
+ // Likewise add extra substring to have at least 2
10091
+ r.push('');
10092
+ // Parse integers, defaulting to 0
10093
+ this.iterator_ranges[i] = [safeStrToInt(r[0], 0), safeStrToInt(r[1], 0)];
10094
+ }
10095
+ }
10096
+ }
10097
+
10098
+ updateIteratorDimensions() {
10099
+ // Create iterator selectors for each index variable having a relevant range
10100
+ this.iterator_dimensions = [];
10101
+ const il = ['i', 'j', 'k'];
10102
+ for(let i = 0; i < 3; i++) {
10103
+ const r = this.iterator_ranges[i];
10104
+ if(r[0] || r[1]) {
10105
+ const
10106
+ sel = [],
10107
+ k = il[i] + '=';
10108
+ // NOTE: iterate from FROM to TO limit also when FROM > TO
10109
+ if(r[0] <= r[1]) {
10110
+ for(let j = r[0]; j <= r[1]; j++) {
10111
+ sel.push(k + j);
10112
+ }
10113
+ } else {
10114
+ for(let j = r[0]; j >= r[1]; j--) {
10115
+ sel.push(k + j);
10116
+ }
10117
+ }
10118
+ this.iterator_dimensions.push(sel);
10119
+ }
10120
+ }
10121
+ }
10122
+
9683
10123
  matchingCombinationIndex(sl) {
9684
10124
  // Returns index of combination with most selectors in common wilt `sl`
9685
10125
  let high = 0,
@@ -9715,6 +10155,16 @@ class Experiment {
9715
10155
  `<sdim>${xmlEncoded(this.settings_dimensions[i].join(','))}</sdim>`;
9716
10156
  if(sd.indexOf(dim) < 0) sd += dim;
9717
10157
  }
10158
+ let cs = '';
10159
+ for(let i = 0; i < this.combination_selectors.length; i++) {
10160
+ cs += `<csel>${xmlEncoded(this.combination_selectors[i])}</csel>`;
10161
+ }
10162
+ let cd = '';
10163
+ for(let i = 0; i < this.combination_dimensions.length; i++) {
10164
+ const dim =
10165
+ `<cdim>${xmlEncoded(this.combination_dimensions[i].join(','))}</cdim>`;
10166
+ if(cd.indexOf(dim) < 0) cd += dim;
10167
+ }
9718
10168
  let as = '';
9719
10169
  for(let i = 0; i < this.actor_selectors.length; i++) {
9720
10170
  as += this.actor_selectors[i].asXML;
@@ -9733,6 +10183,7 @@ class Experiment {
9733
10183
  return ['<experiment configuration-dims="', this.configuration_dims,
9734
10184
  '" column_scenario-dims="', this.column_scenario_dims,
9735
10185
  (this.completed ? '" completed="1' : ''),
10186
+ '" iterator-ranges="', this.iteratorRangeString,
9736
10187
  '" started="', this.time_started,
9737
10188
  '" stopped="', this.time_stopped,
9738
10189
  '" variables="', this.download_settings.variables,
@@ -9749,7 +10200,9 @@ class Experiment {
9749
10200
  '</dimensions><chart-titles>', ct,
9750
10201
  '</chart-titles><settings-selectors>', ss,
9751
10202
  '</settings-selectors><settings-dimensions>', sd,
9752
- '</settings-dimensions><actor-selectors>', as,
10203
+ '</settings-dimensions><combination-selectors>', cs,
10204
+ '</combination-selectors><combination-dimensions>', cd,
10205
+ '</combination-dimensions><actor-selectors>', as,
9753
10206
  '</actor-selectors><excluded-selectors>',
9754
10207
  xmlEncoded(this.excluded_selectors),
9755
10208
  '</excluded-selectors><clusters-to-ignore>', cti,
@@ -9762,6 +10215,7 @@ class Experiment {
9762
10215
  nodeParameterValue(node, 'configuration-dims'));
9763
10216
  this.column_scenario_dims = safeStrToInt(
9764
10217
  nodeParameterValue(node, 'column-scenario-dims'));
10218
+ this.parseIteratorRangeString(nodeParameterValue(node, 'iterator-ranges'));
9765
10219
  this.completed = nodeParameterValue(node, 'completed') === '1';
9766
10220
  this.time_started = safeStrToInt(nodeParameterValue(node, 'started'));
9767
10221
  this.time_stopped = safeStrToInt(nodeParameterValue(node, 'stopped'));
@@ -9817,6 +10271,24 @@ class Experiment {
9817
10271
  }
9818
10272
  }
9819
10273
  }
10274
+ n = childNodeByTag(node, 'combination-selectors');
10275
+ if(n && n.childNodes) {
10276
+ for(let i = 0; i < n.childNodes.length; i++) {
10277
+ c = n.childNodes[i];
10278
+ if(c.nodeName === 'csel') {
10279
+ this.combination_selectors.push(xmlDecoded(nodeContent(c)));
10280
+ }
10281
+ }
10282
+ }
10283
+ n = childNodeByTag(node, 'combination-dimensions');
10284
+ if(n && n.childNodes) {
10285
+ for(let i = 0; i < n.childNodes.length; i++) {
10286
+ c = n.childNodes[i];
10287
+ if(c.nodeName === 'cdim') {
10288
+ this.combination_dimensions.push(xmlDecoded(nodeContent(c)).split(','));
10289
+ }
10290
+ }
10291
+ }
9820
10292
  n = childNodeByTag(node, 'actor-selectors');
9821
10293
  if(n && n.childNodes) {
9822
10294
  for(let i = 0; i < n.childNodes.length; i++) {
@@ -9865,7 +10337,9 @@ class Experiment {
9865
10337
  // Returns dimension index if any dimension contains any selector in
9866
10338
  // dimension `d`, or -1 otherwise
9867
10339
  for(let i = 0; i < this.dimensions.length; i++) {
9868
- if(intersection(this.dimensions[i], d).length > 0) return i;
10340
+ const xd = this.dimensions[i].slice();
10341
+ this.expandCombinationSelectors(xd);
10342
+ if(intersection(xd, d).length > 0) return i;
9869
10343
  }
9870
10344
  return -1;
9871
10345
  }
@@ -9874,7 +10348,7 @@ class Experiment {
9874
10348
  // Removes dimension `d` from list and returns its old index
9875
10349
  for(let i = 0; i < this.dimensions.length; i++) {
9876
10350
  if(intersection(this.dimensions[i], d).length > 0) {
9877
- this.dimensions.splice(i);
10351
+ this.dimensions.splice(i, 1);
9878
10352
  return i;
9879
10353
  }
9880
10354
  }
@@ -9911,7 +10385,170 @@ class Experiment {
9911
10385
  if(adi >= 0) this.dimensions[adi] = d;
9912
10386
  }
9913
10387
  }
10388
+
10389
+ get allDimensionSelectors() {
10390
+ const sl = Object.keys(MODEL.listOfAllSelectors);
10391
+ // Add selectors of actor, iterator and settings dimensions
10392
+ return sl;
10393
+ }
9914
10394
 
10395
+ orthogonalSelectors(c) {
10396
+ // Returns TRUE iff the selectors in set `c` all are elements of
10397
+ // different experiment dimensions
10398
+ const
10399
+ // Make a copy of `c` so it can be safely expanded
10400
+ xc = c.slice(),
10401
+ // Start with a copy of all model dimensions
10402
+ dl = MODEL.dimensions.slice(),
10403
+ issues = [];
10404
+ // Add dimensions defined for this experiment
10405
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10406
+ dl.push(this.settings_dimensions[i]);
10407
+ }
10408
+ for(let i = 0; i < this.actor_dimensions.length; i++) {
10409
+ dl.push(this.actor_dimensions[i]);
10410
+ }
10411
+ // Expand `c` as it may contain combination selectors
10412
+ this.expandCombinationSelectors(xc);
10413
+ // Check for all these dimensions that `c` contains known selectors
10414
+ // and that no two or more selectors occur in the same dimension
10415
+ let unknown = xc.slice();
10416
+ for(let i = 0; i < dl.length; i++) {
10417
+ const idc = intersection(dl[i], xc);
10418
+ unknown = complement(unknown, idc);
10419
+ if(idc.length > 1) {
10420
+ const pair = idc.join(' & ');
10421
+ if(issues.indexOf(pair) < 0) issues.push(pair);
10422
+ }
10423
+ }
10424
+ if(unknown.length > 0) {
10425
+ UI.warn('Combination contains ' +
10426
+ pluralS(unknown.length, 'undefined selector') +
10427
+ ' (' + unknown.join(', ') + ')');
10428
+ return false;
10429
+ }
10430
+ if(issues.length > 0) {
10431
+ UI.warn('Combination contains multiple selectors from same dimension (' +
10432
+ issues.join(', ') + ')');
10433
+ return false;
10434
+ }
10435
+ return true;
10436
+ }
10437
+
10438
+ expandCombinationSelectors(cs) {
10439
+ // Expansion of combination selectors in a selector set `cs` means
10440
+ // that if, for example, `cs` = (A, C1) where C1 is a combination
10441
+ // selector defined as C1 = (B, C2) with A and B being "normal"
10442
+ // selectors, then C1 must be removed from `cs`, while B and the
10443
+ // expansion of C2 must be appended to `cs`.
10444
+ // NOTE: the original selectors C1 and C2 must be removed because
10445
+ // *dimension* selectors cannot be a used as "normal" selectors
10446
+ // (e.g., for dataset modifiers, actor settings or model setting)
10447
+ // NOTE: traverse `cs` in reverse order to ensure that deleting and
10448
+ // appending produce the intended result
10449
+ for(let i = cs.length - 1; i >= 0; i--) {
10450
+ const s = cs[i];
10451
+ // Check whether selector `s` defines a combination
10452
+ for(let j = 0; j < this.combination_selectors.length; j++) {
10453
+ const tuple = this.combination_selectors[j].split('|');
10454
+ if(tuple[0] === s) {
10455
+ // First remove `s` from the original set...
10456
+ cs.splice(i, 1);
10457
+ // Let `xs` be the selector set to replace `s`
10458
+ const xs = tuple[1].split(' ');
10459
+ // Recursively expand `xs`, as it may contain combination selectors
10460
+ this.expandCombinationSelectors(xs);
10461
+ // ... and append its expansion
10462
+ cs.push(...xs);
10463
+ }
10464
+ }
10465
+ }
10466
+ }
10467
+
10468
+ orthogonalCombinationDimensions(sl) {
10469
+ // Returns TRUE iff the expansions of the selectors in set `sl`
10470
+ // are mutually exclusive
10471
+ const
10472
+ xl = {},
10473
+ issues = {};
10474
+ for(let i = 0; i < sl.length; i++) {
10475
+ const s = sl[i];
10476
+ xl[s] = [s];
10477
+ this.expandCombinationSelectors(xl[s]);
10478
+ issues[s] = [];
10479
+ }
10480
+ let ok = true;
10481
+ for(let i = 0; i < sl.length; i++) {
10482
+ const s1 = sl[i];
10483
+ for(let j = i + 1; j < sl.length; j++) {
10484
+ const
10485
+ s2 = sl[j],
10486
+ shared = intersection(xl[s1], xl[s2]);
10487
+ if(shared.length > 0) {
10488
+ issues[s1].push(`${s2}: ${shared.join(', ')}`);
10489
+ ok = false;
10490
+ }
10491
+ }
10492
+ }
10493
+ if(!ok) {
10494
+ const il = [];
10495
+ for(let i = 0; i < sl.length; i++) {
10496
+ const s = sl[i];
10497
+ if(issues[s].length > 0) {
10498
+ il.push(`${s} (${issues[s].join('; ')})`);
10499
+ }
10500
+ }
10501
+ UI.warn('Combination dimension is not orthogonal: ' + il.join(', '));
10502
+ }
10503
+ return ok;
10504
+ }
10505
+
10506
+ inferAvailableDimensions() {
10507
+ // Creates list of dimensions that are orthogonal to those already
10508
+ // selected for this experiment
10509
+ this.available_dimensions.length = 0;
10510
+ // For efficiency, do not use hasDimension but expand the dimensions
10511
+ // that are already selected once, and define a lookup function that
10512
+ // checks for orthogonality
10513
+ const
10514
+ axes = [],
10515
+ orthogonal = (d) => {
10516
+ for(let i = 0; i < axes.length; i++) {
10517
+ if(intersection(axes[i], d).length > 0) return false;
10518
+ }
10519
+ return true;
10520
+ };
10521
+ for(let i = 0; i < this.dimensions.length; i++) {
10522
+ axes.push(this.dimensions[i].slice());
10523
+ this.expandCombinationSelectors(axes[i]);
10524
+ }
10525
+ for(let i = 0; i < MODEL.dimensions.length; i++) {
10526
+ const d = MODEL.dimensions[i];
10527
+ if(orthogonal(d)) this.available_dimensions.push(d);
10528
+ }
10529
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10530
+ const d = this.settings_dimensions[i];
10531
+ if(orthogonal(d)) this.available_dimensions.push(d);
10532
+ }
10533
+ for(let i = 0; i < this.iterator_dimensions.length; i++) {
10534
+ const d = this.iterator_dimensions[i];
10535
+ if(orthogonal(d)) this.available_dimensions.push(d);
10536
+ }
10537
+ for(let i = 0; i < this.actor_dimensions.length; i++) {
10538
+ const d = this.actor_dimensions[i];
10539
+ if(orthogonal(d)) this.available_dimensions.push(d);
10540
+ }
10541
+ for(let i = 0; i < this.combination_dimensions.length; i++) {
10542
+ // NOTE: combination dimensions must be expanded before checking...
10543
+ const
10544
+ d = this.combination_dimensions[i],
10545
+ xd = d.slice();
10546
+ this.expandCombinationSelectors(xd);
10547
+ // ... but the original combination dimension must be added
10548
+ if(orthogonal(xd)) this.available_dimensions.push(d);
10549
+ }
10550
+ }
10551
+
9915
10552
  inferActualDimensions() {
9916
10553
  // Creates list of dimensions without excluded selectors
9917
10554
  this.actual_dimensions.length = 0;
@@ -9928,6 +10565,9 @@ class Experiment {
9928
10565
  if(n >= this.actual_dimensions.length) {
9929
10566
  // NOTE: do not push an empty selector list (can occur if no dimensions)
9930
10567
  if(s.length > 0) this.combinations.push(s);
10568
+ // NOTE: combinations may include *dimension* selectors
10569
+ // These then must be "expanded"
10570
+ this.expandCombinationSelectors(s);
9931
10571
  return;
9932
10572
  }
9933
10573
  const d = this.actual_dimensions[n];
@@ -9939,14 +10579,33 @@ class Experiment {
9939
10579
  }
9940
10580
  }
9941
10581
 
10582
+ renameSelectorInDimensions(olds, news) {
10583
+ // Update the combination dimensions that contain `olds`
10584
+ for(let i = 0; i < this.settings_dimensions.length; i++) {
10585
+ const si = this.settings_dimensions[i].indexOf(olds);
10586
+ if(si >= 0) this.settings_dimensions[i][si] = news;
10587
+ }
10588
+ for(let i = 0; i < this.combination_selectors.length; i++) {
10589
+ const
10590
+ c = this.combination_selectors[i].split('|'),
10591
+ sl = c[1].split(' '),
10592
+ si = sl.indexOf(olds);
10593
+ if(si >= 0) {
10594
+ sl[si] = news;
10595
+ c[1] = sl.join(' ');
10596
+ this.combination_selectors[i] = c.join('|');
10597
+ }
10598
+ }
10599
+ }
10600
+
9942
10601
  mayBeIgnored(c) {
9943
- // Returns TRUE iff `c` is on the list to be ignored
10602
+ // Returns TRUE iff cluster `c` is on the list to be ignored
9944
10603
  for(let i = 0; i < this.clusters_to_ignore.length; i++) {
9945
10604
  if(this.clusters_to_ignore[i].cluster === c) return true;
9946
10605
  }
9947
10606
  return false;
9948
10607
  }
9949
-
10608
+
9950
10609
  inferVariables() {
9951
10610
  // Create list of distinct variables in charts
9952
10611
  this.variables.length = 0;