linny-r 1.9.3 → 2.0.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.
Files changed (37) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/server.js +1 -1
  5. package/static/images/eq-negated.png +0 -0
  6. package/static/images/power.png +0 -0
  7. package/static/images/tex.png +0 -0
  8. package/static/index.html +225 -10
  9. package/static/linny-r.css +458 -8
  10. package/static/scripts/linny-r-ctrl.js +6 -4
  11. package/static/scripts/linny-r-gui-actor-manager.js +1 -1
  12. package/static/scripts/linny-r-gui-chart-manager.js +20 -13
  13. package/static/scripts/linny-r-gui-constraint-editor.js +410 -50
  14. package/static/scripts/linny-r-gui-controller.js +127 -12
  15. package/static/scripts/linny-r-gui-dataset-manager.js +28 -20
  16. package/static/scripts/linny-r-gui-documentation-manager.js +11 -3
  17. package/static/scripts/linny-r-gui-equation-manager.js +1 -1
  18. package/static/scripts/linny-r-gui-experiment-manager.js +1 -1
  19. package/static/scripts/linny-r-gui-expression-editor.js +7 -1
  20. package/static/scripts/linny-r-gui-file-manager.js +31 -13
  21. package/static/scripts/linny-r-gui-finder.js +1 -1
  22. package/static/scripts/linny-r-gui-model-autosaver.js +1 -1
  23. package/static/scripts/linny-r-gui-monitor.js +1 -1
  24. package/static/scripts/linny-r-gui-paper.js +108 -25
  25. package/static/scripts/linny-r-gui-power-grid-manager.js +529 -0
  26. package/static/scripts/linny-r-gui-receiver.js +1 -1
  27. package/static/scripts/linny-r-gui-repository-browser.js +1 -1
  28. package/static/scripts/linny-r-gui-scale-unit-manager.js +1 -1
  29. package/static/scripts/linny-r-gui-sensitivity-analysis.js +1 -1
  30. package/static/scripts/linny-r-gui-tex-manager.js +110 -0
  31. package/static/scripts/linny-r-gui-undo-redo.js +1 -1
  32. package/static/scripts/linny-r-milp.js +1 -1
  33. package/static/scripts/linny-r-model.js +973 -120
  34. package/static/scripts/linny-r-utils.js +3 -3
  35. package/static/scripts/linny-r-vm.js +714 -248
  36. package/static/show-diff.html +1 -1
  37. package/static/show-png.html +1 -1
@@ -10,7 +10,7 @@ the Linny-R project.
10
10
  */
11
11
 
12
12
  /*
13
- Copyright (c) 2017-2023 Delft University of Technology
13
+ Copyright (c) 2017-2024 Delft University of Technology
14
14
 
15
15
  Permission is hereby granted, free of charge, to any person obtaining a copy
16
16
  of this software and associated documentation files (the "Software"), to deal
@@ -56,6 +56,7 @@ class LinnyRModel {
56
56
  this.decimal_comma = CONFIGURATION.decimal_comma;
57
57
  // NOTE: Default scale unit list comprises only the primitive base unit
58
58
  this.scale_units = {'1': new ScaleUnit('1', '1', '1')};
59
+ this.power_grids = {};
59
60
  this.actors = {};
60
61
  this.products = {};
61
62
  this.processes = {};
@@ -81,7 +82,7 @@ class LinnyRModel {
81
82
  this.ignored_entities = {};
82
83
  this.black_box = false;
83
84
  this.black_box_entities = {};
84
-
85
+
85
86
  // Actor related properties
86
87
  this.actor_list = [];
87
88
  this.rounds = 1;
@@ -96,6 +97,7 @@ class LinnyRModel {
96
97
  this.look_ahead = 0;
97
98
  this.grid_pixels = 20;
98
99
  this.align_to_grid = true;
100
+ this.with_power_flow = false;
99
101
  this.infer_cost_prices = false;
100
102
  this.report_results = false;
101
103
  this.show_block_arrows = true;
@@ -279,6 +281,32 @@ class LinnyRModel {
279
281
  return ok;
280
282
  }
281
283
 
284
+ powerGridByID(id) {
285
+ // Return power grid identified by hex string `id`.
286
+ if(this.power_grids.hasOwnProperty(id)) return this.power_grids[id];
287
+ return null;
288
+ }
289
+
290
+ powerGridByName(n) {
291
+ // Return power grid identified by name `n`.
292
+ for(let k in this.power_grids) if(this.power_grids.hasOwnProperty(k)) {
293
+ const pg = this.power_grids[k];
294
+ if(ciCompare(pg.name, n) === 0) return pg;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ get powerGridsWithKVL() {
300
+ // Return list of power grids for which Kirchhoff's voltage law must
301
+ // be enforced.
302
+ const pgl = [];
303
+ for(let k in this.power_grids) if(this.power_grids.hasOwnProperty(k)) {
304
+ const pg = this.power_grids[k];
305
+ if(pg.kirchhoff) pgl.push(pg);
306
+ }
307
+ return pgl;
308
+ }
309
+
282
310
  noteByID(id) {
283
311
  // NOTE: Note object identifiers have syntax #cluster name#time stamp#
284
312
  const parts = id.split('#');
@@ -812,8 +840,25 @@ class LinnyRModel {
812
840
  return -1;
813
841
  }
814
842
 
843
+ validSelector(name) {
844
+ // Return sanitized selector name, or empty string if invalid.
845
+ const s = name.replace(/[^a-zA-Z0-9\+\-\%\_\*\?]/g, '');
846
+ let msg = '';
847
+ if(s !== name) {
848
+ msg = UI.WARNING.SELECTOR_SYNTAX;
849
+ } else if(name.indexOf('*') !== name.lastIndexOf('*')) {
850
+ // A selector can only contain 1 star.
851
+ msg = UI.WARNING.SINGLE_WILDCARD;
852
+ }
853
+ if(msg) {
854
+ UI.warn(msg);
855
+ return '';
856
+ }
857
+ return s;
858
+ }
859
+
815
860
  isDimensionSelector(s) {
816
- // Returns TRUE if `s` is a dimension selector in some experiment
861
+ // Return TRUE if `s` is a dimension selector in some experiment.
817
862
  for(let i = 0; i < this.experiments.length; i++) {
818
863
  if(this.experiments[i].isDimensionSelector(s)) return true;
819
864
  }
@@ -860,37 +905,51 @@ class LinnyRModel {
860
905
  return this.end_period - this.start_period + 1 + this.look_ahead;
861
906
  }
862
907
 
908
+ processSelectorList(sl) {
909
+ // Checke whether selector list `sl` constitutes a new dimension.
910
+ // Ignore lists of fewer than 2 "plain" selectors.
911
+ if(sl.length > 1) {
912
+ let newdim = true;
913
+ // Merge into dimension if there are shared selectors.
914
+ for(let i = 0; i < this.dimensions.length; i++) {
915
+ const c = complement(sl, this.dimensions[i]);
916
+ if(c.length < sl.length) {
917
+ if(c.length > 0) this.dimensions[i].push(...c);
918
+ newdim = false;
919
+ break;
920
+ }
921
+ }
922
+ // If only new selectors, add the list as a dimension.
923
+ if(newdim) {
924
+ this.dimensions.push(sl);
925
+ }
926
+ }
927
+ }
928
+
863
929
  inferDimensions() {
864
930
  // Generate the list of dimensions for experimental design.
865
931
  // NOTE: A dimension is a list of one or more relevant selectors.
866
- let newdim;
867
932
  this.dimensions.length = 0;
868
933
  // NOTE: Ignore the equations dataset.
869
934
  for(let d in this.datasets) if(this.datasets.hasOwnProperty(d) &&
870
935
  this.datasets[d] !== this.equations_dataset) {
936
+ // Get the selector list for this dataset.
937
+ const ds = this.datasets[d];
938
+ // NOTE: Ignore wildcard selectors!
939
+ this.processSelectorList(ds.plainSelectors);
940
+ }
941
+ // Analyze constraint bound lines in the same way.
942
+ for(let k in this.constraints) if(this.constraints.hasOwnProperty(k)) {
871
943
  // Get selector list
872
- const
873
- ds = this.datasets[d],
874
- // NOTE: Ignore wildcard selectors!
875
- sl = ds.plainSelectors;
876
- // Ignore datasets with fewer than 2 "plain" selectors.
877
- if(sl.length > 1) {
878
- newdim = true;
879
- // Merge into dimension if there are shared selectors
880
- for(let i = 0; i < this.dimensions.length; i++) {
881
- const c = complement(sl, this.dimensions[i]);
882
- if(c.length < sl.length) {
883
- if(c.length > 0) this.dimensions[i].push(...c);
884
- newdim = false;
885
- break;
886
- }
887
- }
888
- // If only new selectors, add the list as a dimension
889
- if(newdim) {
890
- this.dimensions.push(sl);
891
- }
944
+ const c = this.constraints[k];
945
+ for(let i = 0; i < c.bound_lines.length; i++) {
946
+ const sl = c.bound_lines[i].selectorList;
947
+ // NOTE: Ignore the first selector "(default)".
948
+ sl.shift();
949
+ this.processSelectorList(sl);
892
950
  }
893
951
  }
952
+
894
953
  }
895
954
 
896
955
  expandDimension(sl) {
@@ -1246,8 +1305,16 @@ class LinnyRModel {
1246
1305
  return VM.UNDEFINED;
1247
1306
  }
1248
1307
 
1308
+ addPowerGrid(id, node=null) {
1309
+ // Add a power grid to the model.
1310
+ let pg = new PowerGrid(id);
1311
+ if(node) pg.initFromXML(node);
1312
+ this.power_grids[id] = pg;
1313
+ return pg;
1314
+ }
1315
+
1249
1316
  addNote(node=null) {
1250
- // Add a note to the focal cluster
1317
+ // Add a note to the focal cluster.
1251
1318
  let n = new Note(this.focal_cluster);
1252
1319
  if(node) n.initFromXML(node);
1253
1320
  this.focal_cluster.notes.push(n);
@@ -2667,10 +2734,12 @@ class LinnyRModel {
2667
2734
  this.encrypt = nodeParameterValue(node, 'encrypt') === '1';
2668
2735
  this.decimal_comma = nodeParameterValue(node, 'decimal-comma') === '1';
2669
2736
  this.align_to_grid = nodeParameterValue(node, 'align-to-grid') === '1';
2737
+ this.with_power_flow = nodeParameterValue(node, 'power-flow') === '1';
2670
2738
  this.infer_cost_prices = nodeParameterValue(node, 'cost-prices') === '1';
2671
2739
  this.report_results = nodeParameterValue(node, 'report-results') === '1';
2672
2740
  this.show_block_arrows = nodeParameterValue(node, 'block-arrows') === '1';
2673
- this.always_diagnose = nodeParameterValue(node, 'diagnose') === '1';
2741
+ // NOTE: Diagnosis option should default to TRUE unless *set* to FALSE.
2742
+ this.always_diagnose = nodeParameterValue(node, 'diagnose') !== '0';
2674
2743
  this.show_notices = nodeParameterValue(node, 'show-notices') === '1';
2675
2744
  this.name = xmlDecoded(nodeContentByTag(node, 'name'));
2676
2745
  this.author = xmlDecoded(nodeContentByTag(node, 'author'));
@@ -2729,6 +2798,16 @@ class LinnyRModel {
2729
2798
  }
2730
2799
  }
2731
2800
  }
2801
+ // Power grids are not "entities", and can be included "as is"
2802
+ n = childNodeByTag(node, 'powergrids');
2803
+ if(n && n.childNodes) {
2804
+ for(i = 0; i < n.childNodes.length; i++) {
2805
+ c = n.childNodes[i];
2806
+ if(c.nodeName === 'grid') {
2807
+ this.addPowerGrid(nodeContentByTag(c, 'id'), c);
2808
+ }
2809
+ }
2810
+ }
2732
2811
  // When including a model, actors may be bound to an existing actor
2733
2812
  n = childNodeByTag(node, 'actors');
2734
2813
  if(n && n.childNodes) {
@@ -3017,14 +3096,18 @@ class LinnyRModel {
3017
3096
  '" current="', this.current_time_step,
3018
3097
  '" rounds="', this.rounds,
3019
3098
  '" no-actor-round-flags="', this.actors[UI.nameToID(UI.NO_ACTOR)].round_flags,
3099
+ // NOTE: Add the diagnosis option *explicitly* to differentiate
3100
+ // between older models saved *without* this option, and newer
3101
+ // models having this option switched off.
3102
+ '" diagnose="', (this.always_diagnose ? '1' : '0'),
3020
3103
  '"'].join('');
3021
3104
  if(this.encrypt) p += ' encrypt="1"';
3022
3105
  if(this.decimal_comma) p += ' decimal-comma="1"';
3023
3106
  if(this.align_to_grid) p += ' align-to-grid="1"';
3107
+ if(this.with_power_flow) p += ' power-flow="1"';
3024
3108
  if(this.infer_cost_prices) p += ' cost-prices="1"';
3025
3109
  if(this.report_results) p += ' report-results="1"';
3026
3110
  if(this.show_block_arrows) p += ' block-arrows="1"';
3027
- if(this.always_diagnose) p += ' diagnose="1"';
3028
3111
  if(this.show_notices) p += ' show-notices="1"';
3029
3112
  let xml = this.xml_header + ['<model', p, '><name>', xmlEncoded(this.name),
3030
3113
  '</name><author>', xmlEncoded(this.author),
@@ -3050,7 +3133,11 @@ class LinnyRModel {
3050
3133
  for(obj in this.scale_units) if(this.scale_units.hasOwnProperty(obj)) {
3051
3134
  xml += this.scale_units[obj].asXML;
3052
3135
  }
3053
- xml += '</scaleunits><actors>';
3136
+ xml += '</scaleunits><powergrids>';
3137
+ for(obj in this.power_grids) if(this.power_grids.hasOwnProperty(obj)) {
3138
+ xml += this.power_grids[obj].asXML;
3139
+ }
3140
+ xml += '</powergrids><actors>';
3054
3141
  for(obj in this.actors) {
3055
3142
  // NOTE: do not to save "(no actor)"
3056
3143
  if(this.actors.hasOwnProperty(obj) && obj != UI.nameToID(UI.NO_ACTOR)) {
@@ -4765,11 +4852,97 @@ class ScaleUnit {
4765
4852
  }
4766
4853
  }
4767
4854
 
4855
+ // CLASS PowerGrid
4856
+ class PowerGrid {
4857
+ constructor(id) {
4858
+ // NOTE: PowerGrids are uniquely identified by a hexadecimal string.
4859
+ this.id = id;
4860
+ this.name = '';
4861
+ this.comments = '';
4862
+ // Default color is blue (arbitrary choice).
4863
+ this.color = '#0000ff';
4864
+ // Default voltage is 150 kV (arbitrary choice).
4865
+ this.kilovolts = 150;
4866
+ // Default power unit is MW (will be used as unit of grid nodes)
4867
+ this.power_unit = 'MW';
4868
+ this.kirchhoff = true;
4869
+ // Loss approximation can be 0 (no losses), 1 (linear with load),
4870
+ // 2 (quadratic, approximated with two linear tangents) or
4871
+ // 3 (quadratic, approximated with three linear tangents).
4872
+ this.loss_approximation = 0;
4873
+ }
4874
+
4875
+ get type() {
4876
+ return 'Grid';
4877
+ }
4878
+
4879
+ get displayName() {
4880
+ return this.name;
4881
+ }
4882
+
4883
+ get voltage() {
4884
+ // Return the voltage in the most compact human-readable notation.
4885
+ const kv = this.kilovolts;
4886
+ if(kv < 0.5) return Math.round(kv * 1000) + '&thinsp;V';
4887
+ if(kv < 10) return kv.toPrecision(2) + '&thinsp;kV';
4888
+ if(kv >= 999.5) return (kv * 0.001).toPrecision(2) + '&thinsp;MV';
4889
+ return Math.round(kv) + '&thinsp;kV';
4890
+ }
4891
+
4892
+ // NOTE: The functions below are based on Birchfield et al. (2017)
4893
+ // A Metric-Based Validation Process to Assess the Realism of Synthetic
4894
+ // Power Grids. Energies, 10(1233). DOI: 10.3390/en10081233.
4895
+ // Table 3 in this paper lists for 6 voltage levels (115 - 500 kV) the
4896
+ // median of per unit per km reactance, and Table 4 lists for the same
4897
+ // voltage levels the median of X/R ratios.
4898
+
4899
+ get reactancePerKm() {
4900
+ // Approximate resistance based on reactance per km and voltage.
4901
+ // The curve 28 * (kV ^ -1.9) fits quite well on the Table 3 data.
4902
+ return 28 * Math.pow(this.kilovolts, -1.9);
4903
+ }
4904
+
4905
+ get resistancePerKm() {
4906
+ // Approximate resistance based on reactance per km and voltage.
4907
+ // The line 0.032 * kV + 1 fits quite well on the Table 4 data.
4908
+ // NOTE: Scaled by 0.003 because approximation gave unrealistic
4909
+ // high losses. This is a "quick fix" for lack of better knowledge.
4910
+ return 0.003 * this.reactancePerKm / (0.032 * this.kilovolts + 1);
4911
+ }
4912
+
4913
+ get asXML() {
4914
+ let p = ` loss-approximation="${this.loss_approximation}"`;
4915
+ if(this.kirchhoff) p += ' kirchhoff="1"';
4916
+ return ['<grid', p, '><id>', this.id,
4917
+ '</id><name>', xmlEncoded(this.name),
4918
+ '</name><notes>', xmlEncoded(this.comments),
4919
+ '</notes><color>', this.color,
4920
+ '</color><kilovolts>', this.kilovolts,
4921
+ '</kilovolts><power-unit>', this.power_unit,
4922
+ '</power-unit></grid>'].join('');
4923
+ }
4924
+
4925
+ initFromXML(node) {
4926
+ this.loss_approximation = safeStrToInt(
4927
+ nodeParameterValue(node, 'loss-approximation'), 0);
4928
+ this.name = xmlDecoded(nodeContentByTag(node, 'name'));
4929
+ this.comments = xmlDecoded(nodeContentByTag(node, 'notes'));
4930
+ this.kirchhoff = nodeParameterValue(node, 'kirchhoff') === '1';
4931
+ this.color = nodeContentByTag(node, 'color');
4932
+ this.kilovolts = safeStrToFloat(nodeContentByTag(node, 'kilovolts'), 150);
4933
+ this.power_unit = nodeContentByTag(node, 'power-unit') || 'MW';
4934
+ }
4935
+
4936
+ } // END of class PowerGrid
4937
+
4938
+
4768
4939
  // CLASS Actor
4769
4940
  class Actor {
4770
4941
  constructor(name) {
4771
4942
  this.name = name;
4772
4943
  this.comments = '';
4944
+ // By default, actors are labeled in formulas with the letter a.
4945
+ this.TEX_id = 'a';
4773
4946
  // Actors have 1 input attribute: W
4774
4947
  this.weight = new Expression(this, 'W', '1');
4775
4948
  // Actors have 3 result attributes: CF, CI and CO
@@ -4823,11 +4996,13 @@ class Actor {
4823
4996
  return ['<actor round-flags="', this.round_flags,
4824
4997
  '"><name>', xmlEncoded(this.name),
4825
4998
  '</name><notes>', xmlEncoded(this.comments),
4826
- '</notes><weight>', this.weight.asXML,
4999
+ '</notes><tex-id>', this.TEX_id,
5000
+ '</tex-id><weight>', this.weight.asXML,
4827
5001
  '</weight></actor>'].join('');
4828
5002
  }
4829
5003
 
4830
5004
  initFromXML(node) {
5005
+ this.TEX_id = xmlDecoded(nodeContentByTag(node, 'tex-id') || 'a');
4831
5006
  this.weight.text = xmlDecoded(nodeContentByTag(node, 'weight'));
4832
5007
  if(IO_CONTEXT) IO_CONTEXT.rewrite(this.weight);
4833
5008
  this.comments = nodeContentByTag(node, 'notes');
@@ -5451,12 +5626,12 @@ class NodeBox extends ObjectWithXYWH {
5451
5626
  }
5452
5627
 
5453
5628
  get infoLineName() {
5454
- // Returns display name plus VM variable indices
5629
+ // Return display name plus VM variable indices when debugging.
5455
5630
  let n = this.displayName;
5456
- // NOTE: Display nothing if entity is "black-boxed"
5631
+ // NOTE: Display nothing if entity is "black-boxed".
5457
5632
  if(n.startsWith(UI.BLACK_BOX)) return '';
5458
5633
  n = `<em>${this.type}:</em> ${n}`;
5459
- // For clusters, add how many processes and products they contain
5634
+ // For clusters, add how many processes and products they contain.
5460
5635
  if(this instanceof Cluster) {
5461
5636
  let d = '';
5462
5637
  if(this.all_processes) {
@@ -5467,7 +5642,19 @@ class NodeBox extends ObjectWithXYWH {
5467
5642
  }
5468
5643
  if(d) n += `<span class="node-details">${d}</span>`;
5469
5644
  }
5470
- if(DEBUGGING && MODEL.solved) {
5645
+ if(!MODEL.solved) return n;
5646
+ const g = this.grid;
5647
+ if(g) {
5648
+ const
5649
+ ub = VM.sig4Dig(this.upper_bound.result(MODEL.t)),
5650
+ l = this.length_in_km,
5651
+ x = VM.sig4Dig(g.reactancePerKm * l, true),
5652
+ r = VM.sig4Dig(g.resistancePerKm * l, true),
5653
+ alr = VM.sig4Dig(this.actualLossRate(MODEL.t) * 100, true);
5654
+ n += ` [${g.name}] ${ub} ${g.power_unit}, ${l} km,` +
5655
+ ` x = ${x}, R = ${r}, loss rate = ${alr}%`;
5656
+ }
5657
+ if(DEBUGGING) {
5471
5658
  n += ' [';
5472
5659
  if(this instanceof Process || this instanceof Product) {
5473
5660
  n += this.level_var_index;
@@ -5559,6 +5746,15 @@ class NodeBox extends ObjectWithXYWH {
5559
5746
  // Change this object's name and actor.
5560
5747
  this.actor = MODEL.addActor(actor_name);
5561
5748
  this.name = name;
5749
+ // Ensure that actor cash flow data products have valid properties
5750
+ if(this.name.startsWith('$')) {
5751
+ this.scale_unit = MODEL.currency_unit;
5752
+ this.initial_level.text = '';
5753
+ this.price.text = '';
5754
+ this.is_data = true;
5755
+ this.is_buffer = false;
5756
+ this.integer_level = false;
5757
+ }
5562
5758
  // Update actor list in case some actor name is no longer used.
5563
5759
  MODEL.cleanUpActors();
5564
5760
  MODEL.replaceEntityInExpressions(old_name, this.displayName);
@@ -7338,9 +7534,12 @@ class Node extends NodeBox {
7338
7534
  }
7339
7535
 
7340
7536
  get needsOnOffData() {
7341
- // Returns TRUE if this node requires a binary ON/OFF variable
7537
+ // Return TRUE if this node requires a binary ON/OFF variable.
7342
7538
  // This means that at least one output link must have the "start-up",
7343
- // "positive", "zero" or "spinning reserve" multiplier
7539
+ // "positive", "zero", "shut-down", "spinning reserve" or
7540
+ // "first commit" multiplier.
7541
+ // NOTE: As of version 2.0.0, power grid processes also need ON/OFF.
7542
+ if(this.grid) return true;
7344
7543
  for(let i = 0; i < this.outputs.length; i++) {
7345
7544
  if(VM.LM_NEEDING_ON_OFF.indexOf(this.outputs[i].multiplier) >= 0) {
7346
7545
  return true;
@@ -7349,8 +7548,19 @@ class Node extends NodeBox {
7349
7548
  return false;
7350
7549
  }
7351
7550
 
7551
+ get needsIsZeroData() {
7552
+ // Return TRUE if this node requires a binary IS ZERO variable.
7553
+ // This means that at least one output link must have the "zero"
7554
+ // multiplier.
7555
+ for(let i = 0; i < this.outputs.length; i++) {
7556
+ if(this.outputs[i].multiplier === VM.LM_ZERO) return true;
7557
+ }
7558
+ return false;
7559
+ }
7560
+
7352
7561
  get needsStartUpData() {
7353
- // Returns TRUE iff this node has an output data link for start-up
7562
+ // Return TRUE iff this node has an output data link for start-up
7563
+ // or first commit.
7354
7564
  for(let i = 0; i < this.outputs.length; i++) {
7355
7565
  const m = this.outputs[i].multiplier;
7356
7566
  if(m === VM.LM_STARTUP || m === VM.LM_FIRST_COMMIT) return true;
@@ -7359,16 +7569,15 @@ class Node extends NodeBox {
7359
7569
  }
7360
7570
 
7361
7571
  get needsShutDownData() {
7362
- // Returns TRUE iff this node has an output data link for shut-down
7572
+ // Return TRUE iff this node has an output data link for shut-down.
7363
7573
  for(let i = 0; i < this.outputs.length; i++) {
7364
- const m = this.outputs[i].multiplier;
7365
- if(m === VM.LM_SHUTDOWN) return true;
7574
+ if(this.outputs[i].multiplier === VM.LM_SHUTDOWN) return true;
7366
7575
  }
7367
7576
  return false;
7368
7577
  }
7369
7578
 
7370
7579
  get needsFirstCommitData() {
7371
- // Returns TRUE iff this node has an output data link for first commit
7580
+ // Return TRUE iff this node has an output data link for first commit.
7372
7581
  for(let i = 0; i < this.outputs.length; i++) {
7373
7582
  if(this.outputs[i].multiplier === VM.LM_FIRST_COMMIT) return true;
7374
7583
  }
@@ -7376,7 +7585,7 @@ class Node extends NodeBox {
7376
7585
  }
7377
7586
 
7378
7587
  get linksToFirstCommitDataProduct() {
7379
- // Returns data product P iff this node has an output link to P, and P has
7588
+ // Return data product P iff this node has an output link to P, and P has
7380
7589
  // an output link for first commit
7381
7590
  for(let i = 0; i < this.outputs.length; i++) {
7382
7591
  const p = this.outputs[i].to_node;
@@ -7395,7 +7604,12 @@ class Node extends NodeBox {
7395
7604
  }
7396
7605
 
7397
7606
  setPredecessors() {
7398
- // Recursive function to create list of all nodes that precede this one
7607
+ // Recursive function to create list of all nodes that precede this one.
7608
+ // NOTE: As of version 2.0.0, feedback links are no longer displayed
7609
+ // as such. To permit re-enabling this function, the functional part
7610
+ // of this method has been commented out.
7611
+
7612
+ /*
7399
7613
  for(let i = 0; i < this.inputs.length; i++) {
7400
7614
  const l = this.inputs[i];
7401
7615
  if(!l.visited) {
@@ -7413,6 +7627,7 @@ class Node extends NodeBox {
7413
7627
  }
7414
7628
  }
7415
7629
  }
7630
+ */
7416
7631
  return this.predecessors;
7417
7632
  }
7418
7633
 
@@ -7557,6 +7772,99 @@ class Node extends NodeBox {
7557
7772
  }
7558
7773
  return nn;
7559
7774
  }
7775
+
7776
+ get TEXforBinaries() {
7777
+ // Return LaTeX code for formulas that compute binary variables.
7778
+ // In the equations, binary variables are underlined to distinguish
7779
+ // them from (semi-)continuous variables.
7780
+ const
7781
+ NL = '\\\\\n',
7782
+ tex = [NL],
7783
+ x = (this.level_to_zero ? '\\hat{x}' : 'x'),
7784
+ sub = (MODEL.start_period !== MODEL.end_period ?
7785
+ '_{' + this.code + ',t}' : '_' + this.code),
7786
+ sub_1 = '_{' + this.code +
7787
+ (MODEL.start_period !== MODEL.end_period ? ',t-1}' : ',0}');
7788
+ if(this.needsOnOffData) {
7789
+ // Equations to compute OO variable (denoted as u for "up").
7790
+ // (a) L[t] - LB[t]*OO[t] >= 0
7791
+ tex.push(x + sub, '- LB' + sub, '\\mathbf{u}' + sub, '\\ge 0', NL);
7792
+ // (b) L[t] - UB[t]*OO[t] <= 0
7793
+ tex.push(x + sub, '- UB' + sub, '\\mathbf{u}' + sub, '\\le 0', NL);
7794
+ }
7795
+ if(this.needsIsZeroData) {
7796
+ // Equation to compute IZ variable (denoted as d for "down").
7797
+ // (c) OO[t] + IZ[t] = 1
7798
+ tex.push('\\mathbf{u}' + sub, '+ \\mathbf{d}' + sub, '= 0', NL);
7799
+ // (d) L[t] + IZ[t] >= LB[t]
7800
+ // NOTE: for semicontinuous variables, use 0 instead of LB[t]
7801
+ tex.push(x + sub, '+ \\mathbf{d}' + sub, '\\ge',
7802
+ (this.level_to_zero ? '0' : 'LB' + sub), NL);
7803
+ }
7804
+ if(this.needsStartUpData) {
7805
+ // Equation to compute start-up variable (denoted as su).
7806
+ // (e) OO[t-1] - OO[t] + SU[t] >= 0
7807
+ tex.push('\\mathbf{u}' + sub_1, '- \\mathbf{u}' + sub,
7808
+ '+ \\mathbf{su}' + sub, '\\ge 0', NL);
7809
+ // (f) OO[t] - SU[t] >= 0
7810
+ tex.push('\\mathbf{u}' + sub, '- \\mathbf{su}' + sub, '\\ge 0', NL);
7811
+ // (g) OO[t-1] + OO[t] + SU[t] <= 2
7812
+ tex.push('\\mathbf{u}' + sub_1, '+ \\mathbf{u}' + sub,
7813
+ '+ \\mathbf{su}' + sub, '\\le 2', NL);
7814
+ }
7815
+ if(this.needsShutDownData) {
7816
+ // Equation to compute shutdown variable (denoted as sd-circumflex).
7817
+ // (e2) OO[t] - OO[t-1] + SD[t] >= 0
7818
+ tex.push('\\mathbf{u}' + sub, '- \\mathbf{u}' + sub_1,
7819
+ '+ \\mathbf{sd}' + sub, '\\ge 0', NL);
7820
+ // (f2) OO[t] + SD[t] <= 1
7821
+ tex.push('\\mathbf{u}' + sub, '+ \\mathbf{sd}' + sub, '\\le 1', NL);
7822
+ // (g2) SD[t] - OO[t-1] - OO[t] <= 0
7823
+ tex.push('\\mathbf{sd}' + sub, '- \\mathbf{u}' + sub_1,
7824
+ '- \\mathbf{su}' + sub, '\\le 0', NL);
7825
+ }
7826
+ if(this.needsFirstCommitData) {
7827
+ // Equation to compute first commit variables (denoted as fc).
7828
+ // To detect a first commit, start-ups are counted using an extra
7829
+ // variable SC (denoted as suc) and then similar equations are
7830
+ // added to detect "start-up" for this counter. This means one more
7831
+ // binary SO (denoted as suo for "start-up occurred").
7832
+ // (h) SC[t] - SC[t-1] - SU[t] = 0
7833
+ tex.push('\\mathbf{suc}' + sub, '- \\mathbf{suc}' + sub_1,
7834
+ '- \\mathbf{su}' + sub, '= 0', NL);
7835
+ // (i) SC[t] - SO[t] >= 0
7836
+ tex.push('\\mathbf{suc}' + sub, '- \\mathbf{suo}' + sub, '\\ge 0', NL);
7837
+ // (j) SC[t] - run length * SO[t] <= 0
7838
+ tex.push('\\mathbf{suc}' + sub, '- N\\! \\mathbf{suo}' + sub, '\\le 0', NL);
7839
+ // (k) SO[t-1] - SO[t] + FC[t] >= 0
7840
+ tex.push('\\mathbf{suo}' + sub_1, '- \\mathbf{suc}' + sub,
7841
+ '+ \\mathbf{fc}' + sub, '\\ge 0', NL);
7842
+ // (l) SO[t] - FC[t] >= 0
7843
+ tex.push('\\mathbf{suo}' + sub, '- \\mathbf{fc}' + sub, '\\ge 0', NL);
7844
+ // (m) SO[t-1] + SO[t] + FC[t] <= 2
7845
+ tex.push('\\mathbf{suo}' + sub_1, '+ \\mathbf{suo}' + sub,
7846
+ '+ \\mathbf{fc}' + sub, '\\le 2', NL);
7847
+ }
7848
+
7849
+ /*
7850
+ To calculate the peak increase values, we need two continuous
7851
+ "chunk variables", i.e., only 1 tableau column per chunk, not 1 for
7852
+ each time step. These variables BPI and CPI will compute the highest
7853
+ value (for all t in the block (B) and for the chunk (C)) of the
7854
+ difference L[t] - block peak (BP) of previous block. This requires
7855
+ one equation for every t = 1, ..., block length:
7856
+ (n) L[t] - BPI[b] <= BP[b-1] (where b denotes the block number)
7857
+ plus one equation for every t = block length + 1 to chunk length:
7858
+ (o) L[t] - BPI[b] - CPI[b] <= BP[b-1]
7859
+ This ensures that CPI is the *additional* increase in the look-ahead
7860
+ Then use BPI[b] in first time step if block, and CPI[b] at first
7861
+ time step of the look-ahead period to compute the actual flow for
7862
+ the "peak increase" links. For all other time steps this AF equals 0.
7863
+
7864
+ */
7865
+
7866
+ return tex.join(' ');
7867
+ }
7560
7868
 
7561
7869
  } // END of class Node
7562
7870
 
@@ -7565,6 +7873,8 @@ class Node extends NodeBox {
7565
7873
  class Process extends Node {
7566
7874
  constructor(cluster, name, actor) {
7567
7875
  super(cluster, name, actor);
7876
+ // By default, processes have the letter p, products the letter q.
7877
+ this.TEX_id = 'p';
7568
7878
  // NOTE: A process can change level once in PACE steps (default 1/1).
7569
7879
  // This means that for a simulation perio of N time steps, this process will
7570
7880
  // have a vector of only N / PACE decision variables (plus associated
@@ -7580,6 +7890,11 @@ class Process extends Node {
7580
7890
  this.level_to_zero = false;
7581
7891
  // Process node can be collapsed to take up less space in the diagram
7582
7892
  this.collapsed = false;
7893
+ // Process can represent a power grid element, in which case it needs
7894
+ // properties for calculating power flow.
7895
+ this.power_grid = null;
7896
+ this.length_in_km = 0;
7897
+ this.reactance = 0;
7583
7898
  // Processes have 3 more result attributes: CP, CF, CI and CO
7584
7899
  this.cash_flow = [];
7585
7900
  this.cash_in = [];
@@ -7599,6 +7914,36 @@ class Process extends Node {
7599
7914
  get typeLetter() {
7600
7915
  return 'P';
7601
7916
  }
7917
+
7918
+ get grid() {
7919
+ if(MODEL.with_power_flow) return this.power_grid;
7920
+ return null;
7921
+ }
7922
+
7923
+ get gridEdge() {
7924
+ // Return "FROM node -> TO node" as string if this is a grid process.
7925
+ const g = this.grid;
7926
+ if(!g) return '';
7927
+ let fn = null,
7928
+ tn = null;
7929
+ for(let i = 0; i < this.inputs.length; i++) {
7930
+ const l = this.inputs[i];
7931
+ if(l.multiplier == VM.LM_LEVEL) {
7932
+ fn = l.from_node;
7933
+ break;
7934
+ }
7935
+ }
7936
+ for(let i = 0; i < this.outputs.length; i++) {
7937
+ const l = this.outputs[i];
7938
+ if(l.multiplier == VM.LM_LEVEL && !l.to_node.is_data) {
7939
+ tn = l.from_node;
7940
+ break;
7941
+ }
7942
+ }
7943
+ fn = (fn ? fn.displayName : '???');
7944
+ tn = (tn ? tn.displayName : '???');
7945
+ return `${fn} ${UI.LINK_ARROW} ${tn}`;
7946
+ }
7602
7947
 
7603
7948
  get attributes() {
7604
7949
  const a = {name: this.displayName};
@@ -7649,20 +7994,25 @@ class Process extends Node {
7649
7994
  if(this.integer_level) p += ' integer-level="1"';
7650
7995
  if(this.level_to_zero) p += ' level-to-zero="1"';
7651
7996
  if(this.equal_bounds) p += ' equal-bounds="1"';
7997
+ // NOTE: Save power grid related properties even when grid element
7998
+ // is not checked (so properties re-appear when re-checked).
7652
7999
  return ['<process', p, '><name>', xmlEncoded(n),
7653
8000
  '</name><owner>', xmlEncoded(this.actor.name),
7654
8001
  '</owner><notes>', cmnts,
7655
8002
  '</notes><upper-bound>', this.upper_bound.asXML,
7656
8003
  '</upper-bound><lower-bound>', this.lower_bound.asXML,
7657
8004
  '</lower-bound><initial-level>', this.initial_level.asXML,
7658
- '</initial-level><pace>', this.pace_expression.asXML,
7659
- '</pace><x-coord>', x,
8005
+ '</initial-level><tex-id>', this.TEX_id,
8006
+ '</tex-id><pace>', this.pace_expression.asXML,
8007
+ '</pace><grid-id>', (this.power_grid ? this.power_grid.id : ''),
8008
+ '</grid-id><length>', this.length_in_km,
8009
+ '</length><x-coord>', x,
7660
8010
  '</x-coord><y-coord>', y,
7661
8011
  '</y-coord></process>'].join('');
7662
8012
  }
7663
8013
 
7664
8014
  initFromXML(node) {
7665
- // NOTE: do not set code while importing, as new code must be assigned!
8015
+ // NOTE: Do not set code while importing, as new code must be assigned!
7666
8016
  if(!IO_CONTEXT) this.code = nodeParameterValue(node, 'code');
7667
8017
  this.collapsed = nodeParameterValue(node, 'collapsed') === '1';
7668
8018
  this.integer_level = nodeParameterValue(node, 'integer-level') === '1';
@@ -7672,23 +8022,28 @@ class Process extends Node {
7672
8022
  this.comments = xmlDecoded(nodeContentByTag(node, 'notes'));
7673
8023
  this.lower_bound.text = xmlDecoded(nodeContentByTag(node, 'lower-bound'));
7674
8024
  this.upper_bound.text = xmlDecoded(nodeContentByTag(node, 'upper-bound'));
7675
- // legacy models can have LB and UB hexadecimal data strings
8025
+ // legacy models can have LB and UB hexadecimal data strings.
7676
8026
  this.convertLegacyBoundData(nodeContentByTag(node, 'lower-bound-data'),
7677
8027
  nodeContentByTag(node, 'upper-bound-data'));
7678
8028
  if(nodeParameterValue(node, 'reversible') === '1') {
7679
- // For legacy "reversible" processes, the LB is set to -UB
8029
+ // For legacy "reversible" processes, the LB is set to -UB.
7680
8030
  this.lower_bound.text = '-' + this.upper_bound.text;
7681
8031
  }
7682
- // NOTE: legacy models have no initial level field => default to 0
8032
+ // NOTE: Legacy models have no initial level field => default to 0.
7683
8033
  const ilt = xmlDecoded(nodeContentByTag(node, 'initial-level'));
7684
8034
  this.initial_level.text = ilt || '0';
7685
- // NOTE: until version 1.0.16, pace was stored as a node parameter;
8035
+ // NOTE: Until version 1.0.16, pace was stored as a node parameter.
7686
8036
  const pace_text = nodeParameterValue(node, 'pace') +
7687
8037
  xmlDecoded(nodeContentByTag(node, 'pace'));
7688
- // NOTE: legacy models have no pace field => default to 1
8038
+ // NOTE: Legacy models have no pace field => default to 1.
7689
8039
  this.pace_expression.text = pace_text || '1';
7690
- // NOTE: immediately evaluate pace expression as integer
8040
+ // NOTE: Immediately evaluate pace expression as integer.
7691
8041
  this.pace = Math.max(1, Math.floor(this.pace_expression.result(1)));
8042
+ this.TEX_id = xmlDecoded(nodeContentByTag(node, 'tex-id') || 'p');
8043
+ this.power_grid = MODEL.powerGridByID(nodeContentByTag(node, 'grid-id'));
8044
+ this.length_in_km = safeStrToFloat(nodeContentByTag(node, 'length'), 0);
8045
+ // NOTE: Reactance may be an empty string to indicate "infer from length".
8046
+ this.reactance = nodeContentByTag(node, 'reactance');
7692
8047
  this.x = safeStrToInt(nodeContentByTag(node, 'x-coord'));
7693
8048
  this.y = safeStrToInt(nodeContentByTag(node, 'y-coord'));
7694
8049
  if(IO_CONTEXT) {
@@ -7784,6 +8139,50 @@ class Process extends Node {
7784
8139
  return (ub.isStatic ? ub.result(0) : VM.PLUS_INFINITY);
7785
8140
  }
7786
8141
 
8142
+ lossRates(t) {
8143
+ // Returns a list of loss rates for the slope variables associated
8144
+ // with this process at time t.
8145
+ // NOTE: Rates depend on upper bound, which may be dynamic.
8146
+ // Source: section 4.4 of Neumann et al. (2022) Assessments of linear
8147
+ // power flow and transmission loss approximations in coordinated
8148
+ // capacity expansion problem. Applied Energy, 314: 118859.
8149
+ // https://doi.org/10.1016/j.apenergy.2022.118859
8150
+ if(!(this.grid && this.grid.loss_approximation)) return [0];
8151
+ let ub = this.upper_bound.result(t);
8152
+ if(ub >= VM.PLUS_INFINITY) {
8153
+ // When UB = +INF, this is interpreted as "unlimited", which is
8154
+ // implemented as 99999 grid power units.
8155
+ ub = VM.UNLIMITED_POWER_FLOW;
8156
+ }
8157
+ const
8158
+ la = this.grid.loss_approximation,
8159
+ // Let m be the highest per unit loss.
8160
+ m = ub * this.grid.resistancePerKm * this.length_in_km;
8161
+ // Linear loss approximation: 1 slope from 0 to m.
8162
+ if(la === 1) return [m];
8163
+ // 2-slope approximation of quadratic curve.
8164
+ if(la === 2) return [m * 0.25, m * 0.75];
8165
+ // 3-slope approximation of quadratic curve.
8166
+ return [m / 9, m * 3/9, m * 5/9];
8167
+ }
8168
+
8169
+ actualLossRate(t) {
8170
+ // Return the actual loss rate, which depends on the power flow
8171
+ // and the max. power flow (process UB).
8172
+ const g = this.grid;
8173
+ if(!g) return 0;
8174
+ const
8175
+ lr = this.lossRates(t),
8176
+ apl = Math.abs(this.actualLevel(t)),
8177
+ ub = this.upper_bound.result(t),
8178
+ la = g.loss_approximation,
8179
+ // Prevent division by 0.
8180
+ slope = (ub < VM.NEAR_ZERO ? 0 :
8181
+ // NOTE: Index may exceed # slopes - 1 when level = UB.
8182
+ Math.min(la - 1, Math.floor(apl * la / ub)));
8183
+ return lr[slope];
8184
+ }
8185
+
7787
8186
  copyPropertiesFrom(p) {
7788
8187
  // Set properties to be identical to those of process `p`
7789
8188
  this.x = p.x;
@@ -7797,6 +8196,7 @@ class Process extends Node {
7797
8196
  this.equal_bounds = p.equal_bounds;
7798
8197
  this.level_to_zero = p.level_to_zero;
7799
8198
  this.collapsed = p.collapsed;
8199
+ this.TEX_id = p.TEX_id;
7800
8200
  }
7801
8201
 
7802
8202
  differences(p) {
@@ -7809,6 +8209,32 @@ class Process extends Node {
7809
8209
  if(Object.keys(d).length > 0) return d;
7810
8210
  return null;
7811
8211
  }
8212
+
8213
+ get TEXcode() {
8214
+ // Return LaTeX code for mathematical formula of constraints defined
8215
+ // by this process.
8216
+ const
8217
+ NL = '\\\\\n',
8218
+ tex = [NL],
8219
+ sub = (MODEL.start_period !== MODEL.end_period ?
8220
+ '_{' + this.TEX_id + ',t}' : '_' + this.TEX_id),
8221
+ lb = (this.lower_bound.defined ? 'LB' + sub + ' \\le' : ''),
8222
+ ub = (this.upper_bound.defined ? '\\le UB' + sub : '');
8223
+ // Integer constraint if applicable.
8224
+ if(this.integer_level) tex.push('x' + sub, '\\in \\mathbb{Z}', NL);
8225
+ // Bound constraints...
8226
+ if(lb && this.equal_bounds) {
8227
+ tex.push('x' + sub, '= LB' + sub);
8228
+ } else if(lb || ub) {
8229
+ tex.push(lb, 'x' + sub, ub);
8230
+ }
8231
+ // ... with semi-continuity if applicable.
8232
+ if(lb && this.level_to_zero) tex.push('\\vee x' + sub, '= 0');
8233
+ tex.push(NL);
8234
+ // Add equations for associated binary variables.
8235
+ tex.push(this.TEXforBinaries);
8236
+ return tex.join(' ');
8237
+ }
7812
8238
 
7813
8239
  } // END of class Process
7814
8240
 
@@ -7818,6 +8244,8 @@ class Product extends Node {
7818
8244
  constructor(cluster, name, actor) {
7819
8245
  super(cluster, name, actor);
7820
8246
  this.scale_unit = MODEL.default_unit;
8247
+ // By default, processes have the letter p, products the letter q.
8248
+ this.TEX_id = 'p';
7821
8249
  // For products, the default bounds are [0, 0], and modeler-defined bounds
7822
8250
  // typically are equal
7823
8251
  this.equal_bounds = true;
@@ -7856,6 +8284,10 @@ class Product extends Node {
7856
8284
  return 'Q';
7857
8285
  }
7858
8286
 
8287
+ get grid() {
8288
+ return null;
8289
+ }
8290
+
7859
8291
  get attributes() {
7860
8292
  const a = {name: this.displayName};
7861
8293
  a.LB = this.lower_bound.asAttribute;
@@ -8113,7 +8545,8 @@ class Product extends Node {
8113
8545
  '</lower-bound><price>', this.price.asXML,
8114
8546
  '</price><x-coord>', x,
8115
8547
  '</x-coord><y-coord>', y,
8116
- '</y-coord></product>'].join('');
8548
+ '</y-coord><tex-id>', this.TEX_id,
8549
+ '</tex-id></product>'].join('');
8117
8550
  return xml;
8118
8551
  }
8119
8552
 
@@ -8132,6 +8565,7 @@ class Product extends Node {
8132
8565
  nodeParameterValue(node, 'hidden')) === '1';
8133
8566
  this.scale_unit = MODEL.addScaleUnit(
8134
8567
  xmlDecoded(nodeContentByTag(node, 'unit')));
8568
+ this.TEX_id = xmlDecoded(nodeContentByTag(node, 'tex-id') || 'q');
8135
8569
  // Legacy models have tag "profit" instead of "price"
8136
8570
  let pp = nodeContentByTag(node, 'price');
8137
8571
  if(!pp) pp = nodeContentByTag(node, 'profit');
@@ -8295,6 +8729,7 @@ class Product extends Node {
8295
8729
  this.no_slack = p.no_slack;
8296
8730
  this.initial_level.text = p.initial_level.text;
8297
8731
  this.integer_level = p.integer_level;
8732
+ this.TEX_id = p.TEX_id;
8298
8733
  // NOTE: do not copy the `no_links` property, nor the import/export status
8299
8734
  }
8300
8735
 
@@ -8305,6 +8740,70 @@ class Product extends Node {
8305
8740
  return null;
8306
8741
  }
8307
8742
 
8743
+ get TEXcode() {
8744
+ // Return LaTeX code for mathematical formula of constraints defined
8745
+ // by this product.
8746
+ const
8747
+ NL = '\\\\\n',
8748
+ tex = [NL],
8749
+ x = (this.level_to_zero ? '\\hat{x}' : 'x'),
8750
+ sub = (MODEL.start_period !== MODEL.end_period ?
8751
+ '_{' + this.TEX_id + ',t}' : '_' + this.TEX_id),
8752
+ param = (x, p) => {
8753
+ if(!x.defined) return '';
8754
+ const v = safeStrToFloat(x.text, p + sub);
8755
+ if(typeof v === 'number') return VM.sig4Dig(v);
8756
+ return v;
8757
+ };
8758
+ // Integer constraint if applicable.
8759
+ if(this.integer_level) tex.push(x + sub, '\\in \\mathbb{Z}', NL);
8760
+ // Bounds can be explicit...
8761
+ let lb = param(this.lower_bound, 'LB'),
8762
+ ub = param(this.upper_bound, 'UB');
8763
+ if(lb && this.equal_bounds) ub = lb;
8764
+ // ... or implicit.
8765
+ if(!lb && !this.isSourceNode) lb = '0';
8766
+ if(!ub && !this.isSinkNode) ub = '0';
8767
+ // Add the bound constraints.
8768
+ if(lb && ub) {
8769
+ if(lb === ub) {
8770
+ tex.push(x + sub, '=', lb, NL);
8771
+ } else {
8772
+ tex.push(lb, '\\le ' + x + sub, '\\le', ub, NL);
8773
+ }
8774
+ } else if(lb) {
8775
+ tex.push(x + sub, '\\ge', lb, NL);
8776
+ } else if(ub) {
8777
+ tex.push(x + sub, '\\le', ub, NL);
8778
+ }
8779
+ // Add the "balance" constraint for links.
8780
+ tex.push(x + sub, '=');
8781
+ if(this.is_buffer) {
8782
+ // Insert X[t-1]
8783
+ tex.push(x + '_{' + this.code +
8784
+ (MODEL.start_period !== MODEL.end_period ? ',t-1}' : ',0}'));
8785
+ }
8786
+ let first = true;
8787
+ for(let i = 0; i < this.inputs.length; i++) {
8788
+ let ltex = this.inputs[i].TEXcode.trim();
8789
+ if(!first && !ltex.startsWith('-')) tex.push('+');
8790
+ tex.push(ltex);
8791
+ first = false;
8792
+ }
8793
+ for(let i = 0; i < this.outputs.length; i++) {
8794
+ let ltex = this.outputs[i].TEXcode.trim();
8795
+ if(ltex.trim().startsWith('-')) {
8796
+ ltex = ltex.substring(1);
8797
+ if(!first) {
8798
+ ltex = '+ ' + ltex;
8799
+ first = false;
8800
+ }
8801
+ }
8802
+ tex.push(ltex);
8803
+ }
8804
+ return tex.join(' ');
8805
+ }
8806
+
8308
8807
  } // END of class Product
8309
8808
 
8310
8809
 
@@ -8515,6 +9014,8 @@ class Link {
8515
9014
  actualDelay(t) {
8516
9015
  // Scale the delay expression value of this link to a discrete number
8517
9016
  // of time steps on the model time scale.
9017
+ // NOTE: For links from grid processes, delays are ignored.
9018
+ if(this.from_node.grid) return 0;
8518
9019
  let d = Math.floor(VM.SIG_DIF_FROM_ZERO + this.flow_delay.result(t));
8519
9020
  // NOTE: Negative values are permitted. This might invalidate cost
8520
9021
  // price calculation -- to be checked!!
@@ -8551,6 +9052,63 @@ class Link {
8551
9052
  fc.containsProduct(this.to_node))) return true;
8552
9053
  return false;
8553
9054
  }
9055
+
9056
+ get TEXcode() {
9057
+ // Return LaTeX code for the term for this link in the formula
9058
+ // for its TO node if this is a product. The TEX routines for
9059
+ // products will take care of the sign of this term.
9060
+ const
9061
+ dyn = MODEL.start_period !== MODEL.end_period,
9062
+ x = (this.from_node.level_to_zero ? '\\hat(x)' : 'x'),
9063
+ fsub = (dyn ?
9064
+ '_{' + this.from_node.TEX_id + ',t}' :
9065
+ '_' + this.from_node.TEX_id),
9066
+ fsub_i = fsub.replace(',t}', ',i}'),
9067
+ rsub = (dyn ?
9068
+ '_{' + this.from_node.TEX_id +
9069
+ ' \\rightarrow ' + this.to_node.TEX_id + ',t}' :
9070
+ '_' + this.from_node.TEX_id),
9071
+ param = (x, p, sub) => {
9072
+ if(!x.defined) return '';
9073
+ const v = safeStrToFloat(x.text, p + sub);
9074
+ if(typeof v === 'number') return (v === 1 ? '' :
9075
+ (v === -1 ? '-' : VM.sig4Dig(v)));
9076
+ return v;
9077
+ },
9078
+ r = param(this.relative_rate, 'R', rsub),
9079
+ d = param(this.flow_delay, '\\delta', rsub),
9080
+ dn = safeStrToInt(d, '?'),
9081
+ d_1 = (!d || typeof dn === 'number' ? dn + 1 : d + '+1');
9082
+ if(this.multiplier === VM.LM_LEVEL) {
9083
+ return r + ' ' + x + fsub;
9084
+ } else if(this.multiplier === VM.LM_THROUGHPUT) {
9085
+ return 'THR';
9086
+ } else if(this.multiplier === VM.LM_INCREASE) {
9087
+ return 'INC';
9088
+ } else if(this.multiplier === VM.LM_SUM) {
9089
+ if(d) return r + '\\sum_{i=t-' + d + '}^{t}{' + x + fsub_i + '}';
9090
+ return x + fsub;
9091
+ } else if(this.multiplier === VM.LM_MEAN) {
9092
+ if(d) return r + '{1 \\over {' + d_1 + '}} \sum_{i=t-' +
9093
+ d + '}^{t}{' + x + fsub_i + '}';
9094
+ return x + fsub;
9095
+ } else if(this.multiplier === VM.LM_STARTUP) {
9096
+ return r + ' \\mathbf{su}' + fsub;
9097
+ } else if(this.multiplier === VM.LM_POSITIVE) {
9098
+ return r + '\\mathbf{u}' + fsub;
9099
+ } else if(this.multiplier === VM.LM_ZERO) {
9100
+ return r + '\\mathbf{d}' + fsub;
9101
+ } else if(this.multiplier === VM.LM_SPINNING_RESERVE) {
9102
+ return 'SPIN';
9103
+ } else if(this.multiplier === VM.LM_FIRST_COMMIT) {
9104
+ return r + '\\mathbf{fc}' + fsub;
9105
+ } else if(this.multiplier === VM.LM_SHUTDOWN) {
9106
+ return r + '\\mathbf{sd}' + fsub;
9107
+ } else if(this.multiplier === VM.LM_PEAK_INC) {
9108
+ return 'PEAK';
9109
+ }
9110
+ return 'Unknown link multiplier: ' + this.multiplier;
9111
+ }
8554
9112
 
8555
9113
  // NOTE: links do not draw themselves; they are visualized by Arrow objects
8556
9114
 
@@ -8584,6 +9142,7 @@ class DatasetModifier {
8584
9142
  // NOTE: Identifier will be unique only for equations.
8585
9143
  return UI.nameToID(this.selector);
8586
9144
  }
9145
+
8587
9146
  get displayName() {
8588
9147
  // NOTE: When "displayed", dataset modifiers have their selector as name.
8589
9148
  return this.selector;
@@ -9030,6 +9589,10 @@ class Dataset {
9030
9589
  }
9031
9590
  // Reduce inner spaces to one, and trim outer spaces.
9032
9591
  s = s.replace(/\s+/g, ' ').trim();
9592
+ if(!s) {
9593
+ UI.warn(`Invalid equation name "${selector}"`);
9594
+ return null;
9595
+ }
9033
9596
  if(s.startsWith(':')) {
9034
9597
  // Methods must have no spaces directly after their leading colon,
9035
9598
  // and must not contain other colons.
@@ -9053,21 +9616,9 @@ class Dataset {
9053
9616
  return null;
9054
9617
  }
9055
9618
  } else {
9056
- // Standard dataset modifier selectors are much more restricted, but
9057
- // to be user-friendly, special chars are removed automatically.
9058
- s = s.replace(/[^a-zA-Z0-9\+\-\%\_\*\?]/g, '');
9059
- let msg = '';
9060
- if(s !== selector) msg = UI.WARNING.SELECTOR_SYNTAX;
9061
- // A selector can only contain 1 star.
9062
- if(s.indexOf('*') !== s.lastIndexOf('*')) msg = UI.WARNING.SINGLE_WILDCARD;
9063
- if(msg) {
9064
- UI.warn(msg);
9065
- return null;
9066
- }
9067
- }
9068
- if(s.trim().length === 0) {
9069
- UI.warn(UI.WARNING.INVALID_SELECTOR);
9070
- return null;
9619
+ // Standard dataset modifier selectors are much more restricted.
9620
+ s = MODEL.validSelector(s);
9621
+ if(!s) return;
9071
9622
  }
9072
9623
  // Then add a dataset modifier to this dataset.
9073
9624
  const id = UI.nameToID(s);
@@ -9270,7 +9821,8 @@ class ChartVariable {
9270
9821
  // the Linny-R entity and its attribute, followed by its scale factor
9271
9822
  // unless it equals 1 (no scaling).
9272
9823
  const sf = (this.scale_factor === 1 ? '' :
9273
- ` (x${VM.sig4Dig(this.scale_factor)})`);
9824
+ // NOTE: Pass tiny = TRUE to permit very small scaling factors.
9825
+ ` (x${VM.sig4Dig(this.scale_factor, true)})`);
9274
9826
  // Display name of equation is just the equations dataset selector.
9275
9827
  if(this.object instanceof DatasetModifier) {
9276
9828
  let eqn = this.object.selector;
@@ -9323,9 +9875,9 @@ class ChartVariable {
9323
9875
  ` wildcard-index="${this.wildcard_index}"` : ''),
9324
9876
  ` sorted="${this.sorted}"`,
9325
9877
  '><object-id>', xmlEncoded(id),
9326
- '</object-id><attribute>', this.attribute,
9878
+ '</object-id><attribute>', xmlEncoded(this.attribute),
9327
9879
  '</attribute><color>', this.color,
9328
- '</color><scale-factor>', VM.sig4Dig(this.scale_factor),
9880
+ '</color><scale-factor>', VM.sig4Dig(this.scale_factor, true),
9329
9881
  '</scale-factor><line-width>', VM.sig4Dig(this.line_width),
9330
9882
  '</line-width></chart-variable>'].join('');
9331
9883
  return xml;
@@ -9371,7 +9923,7 @@ class ChartVariable {
9371
9923
  this.wildcard_index = (wci ? parseInt(wci) : false);
9372
9924
  this.setProperties(
9373
9925
  obj,
9374
- nodeContentByTag(node, 'attribute'),
9926
+ xmlDecoded(nodeContentByTag(node, 'attribute')),
9375
9927
  nodeParameterValue(node, 'stacked') === '1',
9376
9928
  nodeContentByTag(node, 'color'),
9377
9929
  safeStrToFloat(nodeContentByTag(node, 'scale-factor')),
@@ -9826,7 +10378,7 @@ class Chart {
9826
10378
  }
9827
10379
 
9828
10380
  timeScaleAsString(s) {
9829
- // Returns number `s` (in hours) as string with most appropriate time unit
10381
+ // Return number `s` (in hours) as string with most appropriate time unit.
9830
10382
  if(s < 1/60) return VM.sig2Dig(s * 3600) + 's';
9831
10383
  if(s < 1) return VM.sig2Dig(s * 60) + 'm';
9832
10384
  if(s < 24) return VM.sig2Dig(s) + 'h';
@@ -10292,7 +10844,7 @@ class Chart {
10292
10844
  this.plot_max_y = maxy;
10293
10845
  y = miny;
10294
10846
  const labels = [];
10295
- while(y <= maxy) {
10847
+ while(y - maxy <= VM.NEAR_ZERO) {
10296
10848
  // NOTE: Large values having exponents will be "neat" numbers,
10297
10849
  // so then display fewer decimals, as these will be zeroes.
10298
10850
  const v = (Math.abs(y) > 1e5 ? VM.sig2Dig(y) : VM.sig4Dig(y));
@@ -12061,61 +12613,345 @@ class Experiment {
12061
12613
  } // END of CLASS Experiment
12062
12614
 
12063
12615
 
12616
+ // CLASS BoundlineSelector
12617
+ class BoundlineSelector {
12618
+ constructor(boundline, selector, x='', g=false) {
12619
+ this.boundline = boundline;
12620
+ this.selector = selector;
12621
+ this.expression = new Expression(boundline, selector, x);
12622
+ this.grouping = g;
12623
+ }
12624
+
12625
+ get asXML() {
12626
+ // Prevent saving unidentified selectors.
12627
+ if(this.selector.trim().length === 0) return '';
12628
+ return ['<boundline-selector', (this.grouping ? ' points-x="1"' : ''),
12629
+ '><selector>', xmlEncoded(this.selector),
12630
+ '</selector><expression>', xmlEncoded(this.expression.text),
12631
+ '</expression></boundline-selector>'].join('');
12632
+ }
12633
+
12634
+ initFromXML(node) {
12635
+ this.grouping = nodeParameterValue(node, 'points-x') === '1';
12636
+ this.expression.text = xmlDecoded(nodeContentByTag(node, 'expression'));
12637
+ if(IO_CONTEXT) {
12638
+ // Contextualize the included expression.
12639
+ IO_CONTEXT.rewrite(this.expression);
12640
+ }
12641
+ }
12642
+
12643
+ } // END of class BoundlineSelector
12644
+
12645
+
12064
12646
  // CLASS BoundLine
12065
12647
  class BoundLine {
12066
12648
  constructor(c) {
12067
12649
  this.constraint = c;
12068
12650
  // Default bound line imposes no constraint: Y >= 0 for all X.
12069
12651
  this.points = [[0, 0], [100, 0]];
12652
+ this.storePoints();
12070
12653
  this.type = VM.GE;
12071
- this.selectors = '';
12654
+ // SVG string for contour of this bound line (to reduce computation).
12072
12655
  this.contour_path = '';
12656
+ this.point_data = [];
12657
+ this.url = '';
12658
+ this.selectors = [];
12659
+ this.selectors.push(new BoundlineSelector(this, '(default)', '0'));
12073
12660
  }
12074
12661
 
12075
12662
  get displayName() {
12076
- return this.constraint.displayName + ': ' +
12077
- VM.constraint_codes[this.type] + ' bound line #' +
12078
- this.constraint.bound_lines.indexOf(this) +
12079
- (this.selectors ? ` (${this.selectors}) ` : '');
12663
+ return this.constraint.displayName + ' [' +
12664
+ VM.constraint_codes[this.type] + '] bound line #' +
12665
+ this.constraint.bound_lines.indexOf(this);
12080
12666
  }
12081
12667
 
12082
12668
  get copy() {
12083
12669
  // Return a "clone" of this bound line.
12084
12670
  let bl = new BoundLine(this.constraint);
12085
- bl.points.length = 0;
12086
- for(let i = 0; i < this.points.length; i++) {
12087
- const p = this.points[i];
12088
- bl.points.push([p[0], p[1]]);
12089
- }
12671
+ bl.points_string = this.points_string;
12672
+ // NOTE: Reset boundline to its initial "as edited" state.
12673
+ bl.restorePoints();
12090
12674
  bl.type = this.type;
12091
- bl.selectors = this.selectors;
12092
12675
  bl.contour_path = this.contour_path;
12676
+ bl.url = this.url;
12677
+ bl.point_data.length = 0;
12678
+ for(let i = 0; i < this.point_data.length; i++) {
12679
+ bl.point_data.push(this.point_data[i].slice());
12680
+ }
12681
+ bl.selectors.length = 0;
12682
+ for(let i = 0; i < this.selectors.length; i++) {
12683
+ const s = this.selectors[i];
12684
+ bl.selectors.push(new BoundlineSelector(s.boundline, s.selector,
12685
+ s.expression.text, s.grouping));
12686
+ }
12093
12687
  return bl;
12094
12688
  }
12095
12689
 
12690
+ get staticLine() {
12691
+ // Return TRUE if bound line has only the default selector, and this
12692
+ // evaluates as default index = 0.
12693
+ return this.selectors.length < 2 &&
12694
+ this.selectors[0].expression.text === '0';
12695
+ }
12696
+
12697
+ get selectorList() {
12698
+ // Return list of selector names only.
12699
+ const sl = [];
12700
+ for(let i = 0; i < this.selectors.length; i++) {
12701
+ sl.push(this.selectors[i].selector);
12702
+ }
12703
+ return sl;
12704
+ }
12705
+
12706
+ selectorByName(name) {
12707
+ // Return index of selector `n` in the list, or null if not found.
12708
+ for(let i = 0; i < this.selectors.length; i++) {
12709
+ if(this.selectors[i].selector === name) return this.selectors[i];
12710
+ }
12711
+ return null;
12712
+ }
12713
+
12714
+ addSelector(name, x='') {
12715
+ // Add selector if new, and return the named selector.
12716
+ const s = this.selectorByName(name);
12717
+ if(s) return s;
12718
+ name = MODEL.validSelector(name);
12719
+ if(name.indexOf('*') >= 0 || name.indexOf('?') >= 0) {
12720
+ UI.warn('Bound line selector cannot contain wildcards');
12721
+ return null;
12722
+ }
12723
+ if(name) {
12724
+ const s = new BoundlineSelector(this, name, x);
12725
+ this.selectors.push(s);
12726
+ this.selectors.sort((a, b) => compareSelectors(a.selector, b.selector));
12727
+ return s;
12728
+ }
12729
+ return null;
12730
+ }
12731
+
12732
+ setPointsFromData(index) {
12733
+ // Get point coordinates from bound line series data at index.
12734
+ if(index > 0 && index <= this.point_data.length) {
12735
+ const pd = this.point_data[index - 1];
12736
+ this.points.length = 0;
12737
+ for(let i = 0; i < pd.length; i += 2) {
12738
+ this.points.push([pd[i] * 100, pd[i + 1] * 100]);
12739
+ }
12740
+ } else {
12741
+ // Data is seen as 1-based array => default to original coordinates.
12742
+ this.restorePoints();
12743
+ }
12744
+ }
12745
+
12746
+ get activeSelectorIndex() {
12747
+ // Return the number of the first boundline selector that matches the
12748
+ // selector combination of the current experiment. Defaults to 0.
12749
+ if(this.selectors.length < 2) return 0;
12750
+ const x = MODEL.running_experiment;
12751
+ if(!x) return 0;
12752
+ for(let i = 1; i < this.selectors.length; i++) {
12753
+ if(x.activeCombination.indexOf(this.selectors[i].selector) >= 0) {
12754
+ return i;
12755
+ }
12756
+ }
12757
+ return 0;
12758
+ }
12759
+
12760
+ setDynamicPoints(t, draw=false) {
12761
+ // Adapt bound line point coordinates for the current time step
12762
+ // for the running experiment (if any).
12763
+ // NOTE: For speed, first perform the quick check whether this line
12764
+ // is static, as then the points do not change.
12765
+ if(this.staticLine) return;
12766
+ const
12767
+ bls = this.selectors[this.activeSelectorIndex],
12768
+ r = bls.expression.result(t);
12769
+ // Log errors on the console, but ignore them.
12770
+ if(r <= VM.ERROR || r >= VM.EXCEPTION) {
12771
+ console.log('ERROR: Exception in boundline selector expression', bls, r);
12772
+ // NOTE: Double-check whether result is a grouping.
12773
+ } else if(bls.grouping || Array.isArray(r)) {
12774
+ this.validatePoints(r);
12775
+ this.points.length = 0;
12776
+ for(let i = 0; i < r.length; i += 2) {
12777
+ this.points.push([r[i] * 100, r[i + 1] * 100]);
12778
+ }
12779
+ } else {
12780
+ // NOTE: Data is not a time series but "array type" data. The time
12781
+ // step co-determines the result. If modelers provide time series
12782
+ // data, then they shoud use `t` (absolute time) as index expression,
12783
+ // otherwise `rt` (relative time).
12784
+ // NOTE: Result is a floating point number, so truncate it to integer.
12785
+ this.setPointsFromData(Math.floor(r));
12786
+ }
12787
+ // Compute and store SVG for thumbnail only when needed.
12788
+ if(draw) CONSTRAINT_EDITOR.setContourPath(this);
12789
+ }
12790
+
12791
+ restorePoints() {
12792
+ // Restore point coordinates from original string.
12793
+ this.points = JSON.parse(this.points_string);
12794
+ }
12795
+
12796
+ storePoints() {
12797
+ // Set point coordinates to be the original string.
12798
+ this.points_string = JSON.stringify(this.points);
12799
+ }
12800
+
12801
+ get pointsDataString() {
12802
+ // Return point coordinates in data format (semicolon-separated numbers).
12803
+ const pd = [];
12804
+ for(let i = 0; i < this.points.length; i++) {
12805
+ const p = this.points[i];
12806
+ pd.push(p[0], p[1]);
12807
+ }
12808
+ return pd.join(';');
12809
+ }
12810
+
12811
+ get maxPoints() {
12812
+ // Return the highest number of points that this boundline can have.
12813
+ this.restorePoints();
12814
+ let n = this.points.length;
12815
+ for(let i = 0; i < this.point_data.length; i++) {
12816
+ n = Math.max(n, this.point_data[i].length);
12817
+ }
12818
+ const bls = this.selectors[this.activeSelectorIndex];
12819
+ if(bls.grouping) {
12820
+ const x = bls.expression;
12821
+ if(x.isStatic) {
12822
+ n = Math.max(n, x.result(1).length);
12823
+ } else {
12824
+ // For dynamic expressions, the grouping length may vary, so
12825
+ // we must check for the complete run length.
12826
+ for(let t = MODEL.start_period; t <= MODEL.end_period; t++) {
12827
+ n = Math.max(n, x.result(t).length);
12828
+ }
12829
+ }
12830
+ }
12831
+ return n;
12832
+ }
12833
+
12834
+ validatePoints(pd) {
12835
+ if(!Array.isArray(pd)) {
12836
+ console.log(pd); throw "Not an array";
12837
+ }
12838
+ // Ensure that array `pd` is a valid series of point coordinates.
12839
+ if(pd.length) {
12840
+ // Ensure that number of point coordinates is even (Y defaults to 0).
12841
+ if(pd.length % 2) pd.push(0);
12842
+ // Ensure that bound line has at least 2 points.
12843
+ if(pd.length === 2) pd.push(1, 0);
12844
+ } else {
12845
+ // Default to horizontal line Y = 0.
12846
+ pd.push(0, 0, 1, 0);
12847
+ }
12848
+ // NOTE: Point coordinates must lie between 0 and 1, and for the
12849
+ // X-coordinates, it should hold that X[0] = 0, X[i+1] >= X[i],
12850
+ // and X[N] = 1.
12851
+ if(pd[0] !== 0) pd[0] = 0;
12852
+ if(pd[pd.length - 2] !== 1) pd[pd.length - 2] = 1;
12853
+ let min = 0,
12854
+ last = 2;
12855
+ for(let i = 1; i < pd.length; i++) {
12856
+ const x = 1 - i % 2;
12857
+ pd[i] = Math.min(1, Math.max(x ? min : 0, pd[i]));
12858
+ if(x) {
12859
+ // Keep track of first point having X = 1, as this should be the
12860
+ // last point of the boundline.
12861
+ if(pd[i] > min) last = i + 1;
12862
+ min = pd[i];
12863
+ }
12864
+ }
12865
+ // Truncate any points after the first point having X = 1.
12866
+ pd.lengh = last;
12867
+ }
12868
+
12869
+ get pointDataString() {
12870
+ // Point data is stored as separate lines of semicolon-separated
12871
+ // floating point numbers, with N-digit precision to keep model files
12872
+ // compact (default: N = 8)
12873
+ let d = [];
12874
+ for(let i = 0; i < this.point_data.length; i++) {
12875
+ const
12876
+ opd = this.point_data[i],
12877
+ pd = [];
12878
+ for(let j = 0; j < opd.length; j++) {
12879
+ // Convert number to string with the desired precision.
12880
+ const f = opd[j].toPrecision(CONFIGURATION.dataset_precision);
12881
+ // Then parse it again, so that the number will be represented
12882
+ // (by JavaScript) in the most compact representation.
12883
+ pd.push(parseFloat(f));
12884
+ }
12885
+ this.validatePoints(pd);
12886
+ // Push point coordinates as space-separated string.
12887
+ d.push(pd.join(';'));
12888
+ }
12889
+ return d.join('\n');
12890
+ }
12891
+
12892
+ unpackPointDataString(str) {
12893
+ // Convert separate lines of semicolon-separated point data to a
12894
+ // list of lists of numbers.
12895
+ this.point_data.length = 0;
12896
+ str= str.trim();
12897
+ if(str) {
12898
+ const lines = str.split('\n');
12899
+ for(let i = 0; i < lines.length; i++) {
12900
+ const
12901
+ numbers = lines[i].split(';'),
12902
+ pd = [];
12903
+ for(let i = 0; i < numbers.length; i++) {
12904
+ pd.push(parseFloat(numbers[i].trim()));
12905
+ }
12906
+ this.validatePoints(pd);
12907
+ this.point_data.push(pd);
12908
+ }
12909
+ }
12910
+ }
12911
+
12096
12912
  get asXML() {
12097
- return ['<bound-line type="', this.type,
12098
- '"><points>', JSON.stringify(this.points),
12099
- '</points><selectors>', xmlEncoded(this.selectors),
12100
- '</selectors><contour>', this.contour_path,
12101
- '</contour></bound-line>'].join('');
12913
+ // NOTE: Save boundline always with points-as-last-edited.
12914
+ this.restorePoints();
12915
+ const xml = ['<bound-line type="', this.type,
12916
+ '"><points>', JSON.stringify(this.points),
12917
+ '</points><contour>', this.contour_path,
12918
+ '</contour><url>', xmlEncoded(this.url),
12919
+ '</url><point-data>', xmlEncoded(this.pointDataString),
12920
+ '</point-data><selectors>'];
12921
+ for(let i = 0; i < this.selectors.length; i++) {
12922
+ xml.push(this.selectors[i].asXML);
12923
+ }
12924
+ xml.push('</selectors></bound-line>');
12925
+ return xml.join('');
12102
12926
  }
12103
12927
 
12104
12928
  initFromXML(node) {
12105
12929
  this.type = safeStrToInt(nodeParameterValue(node, 'type'), VM.EQ);
12106
- this.points = JSON.parse(nodeContentByTag(node, 'points'));
12107
- this.selectors = xmlDecoded(nodeContentByTag(node, 'selectors'));
12930
+ this.points_string = nodeContentByTag(node, 'points');
12931
+ this.restorePoints();
12108
12932
  this.contour_path = nodeContentByTag(node, 'contour');
12109
- }
12110
-
12111
- get isActive() {
12112
- // Return TRUE if this line has no selectors, or if its selectors match
12113
- // with the selectors of the current experiment run.
12114
- if(!this.selectors) return true;
12115
- const x = MODEL.running_experiment;
12116
- if(!x) return false;
12117
- const ss = intersection(this.selectors.split(' '), x.active_combination);
12118
- return ss.length > 0;
12933
+ this.url = xmlDecoded(nodeContentByTag(node, 'url'));
12934
+ if(this.url) {
12935
+ FILE_MANAGER.getRemoteData(this, this.url);
12936
+ } else {
12937
+ this.unpackPointDataString(
12938
+ xmlDecoded(nodeContentByTag(node, 'point-data')));
12939
+ }
12940
+ const n = childNodeByTag(node, 'selectors');
12941
+ if(n && n.childNodes && n.childNodes.length) {
12942
+ // NOTE: Only overwrite default selector if XML specifies selectors.
12943
+ this.selectors.length = 0;
12944
+ for(let i = 0; i < n.childNodes.length; i++) {
12945
+ const c = n.childNodes[i];
12946
+ if(c.nodeName === 'boundline-selector') {
12947
+ const
12948
+ s = xmlDecoded(nodeContentByTag(c, 'selector')),
12949
+ bls = new BoundlineSelector(this, s);
12950
+ bls.initFromXML(c);
12951
+ this.selectors.push(bls);
12952
+ }
12953
+ }
12954
+ }
12119
12955
  }
12120
12956
 
12121
12957
  get needsNoSOS() {
@@ -12272,7 +13108,15 @@ class Constraint {
12272
13108
  this.bottom_y = 0;
12273
13109
  this.from_offset = 0;
12274
13110
  this.to_offset = 0;
12275
- // Slack information is a "sparse vector" that is filled after solving
13111
+ this.reset();
13112
+ }
13113
+
13114
+ reset() {
13115
+ // Reset run-dependent properties.
13116
+ for(let i = 0; i < this.bound_lines.length; i++) {
13117
+ this.bound_lines[i].current = -1;
13118
+ }
13119
+ // Slack information is a "sparse vector" that is filled after solving.
12276
13120
  this.slack_info = {};
12277
13121
  }
12278
13122
 
@@ -12285,9 +13129,10 @@ class Constraint {
12285
13129
  }
12286
13130
 
12287
13131
  get identifier() {
12288
- // NOTE: constraint IDs are based on the node codes rather than IDs, as
12289
- // this prevents problems when nodes are renamed; to ensure ID uniqueness,
12290
- // constraints have FOUR underscores between node IDs (links have three)
13132
+ // NOTE: Constraint IDs are based on the node codes rather than IDs,
13133
+ // as this prevents problems when nodes are renamed. To ensure ID
13134
+ // uniqueness, constraints have FOUR underscores between node IDs
13135
+ // (links have three).
12291
13136
  return this.from_node.code + '____' + this.to_node.code;
12292
13137
  }
12293
13138
 
@@ -12297,7 +13142,7 @@ class Constraint {
12297
13142
  }
12298
13143
 
12299
13144
  get attributes() {
12300
- // NOTE: this requires some thought, still!
13145
+ // NOTE: This requires some thought, still!
12301
13146
  const a = {name: this.displayName};
12302
13147
  if(MODEL.infer_cost_prices) {
12303
13148
  a.SOC = this.share_of_cost * this.soc_direction;
@@ -12311,16 +13156,16 @@ class Constraint {
12311
13156
  }
12312
13157
 
12313
13158
  attributeValue(a) {
12314
- // Returns the computed result for attribute `a`: for constraints,
12315
- // only A (active) and SOC (share of cost)
12316
- if(a === 'A') return this.activeVector; // binary vector - see below
12317
- // NOTE: negative share indicates Y->X direction of cost sharing
12318
- if(a === 'SOC') return this.share_of_cost * this.soc_direction; // number
13159
+ // Return the computed result for attribute `a`: for constraints,
13160
+ // only A (active) and SOC (share of cost).
13161
+ if(a === 'A') return this.activeVector; // Binary vector - see below.
13162
+ // NOTE: Negative share indicates Y->X direction of cost sharing.
13163
+ if(a === 'SOC') return this.share_of_cost * this.soc_direction;
12319
13164
  return null;
12320
13165
  }
12321
13166
 
12322
13167
  get setsEquality() {
12323
- // Returns TRUE iff this constraint has an EQ boundline
13168
+ // Return TRUE iff this constraint has an EQ bound line.
12324
13169
  for(let i = 0; i < this.bound_lines.length; i++) {
12325
13170
  if(this.bound_lines[i].type === VM.EQ) return true;
12326
13171
  }
@@ -12328,35 +13173,42 @@ class Constraint {
12328
13173
  }
12329
13174
 
12330
13175
  active(t) {
12331
- // Returns 1 if (X, Y) is on the bound line, otherwise 0
13176
+ // Return 1 if (X, Y) is on the bound line AND Y is not on its own
13177
+ // bounds, otherwise 0.
12332
13178
  if(!MODEL.solved) return 0;
12333
13179
  const
12334
13180
  fn = this.from_node,
12335
13181
  tn = this.to_node;
12336
13182
  let lbx = fn.lower_bound.result(t),
12337
13183
  lby = tn.lower_bound.result(t);
12338
- // NOTE: LB of semi-continuous processes is 0 if LB > 0
13184
+ // NOTE: LB of semi-continuous processes is 0 if LB > 0.
12339
13185
  if(lbx > 0 && fn instanceof Process & fn.level_to_zero) lbx = 0;
12340
13186
  if(lby > 0 && tn instanceof Process & tn.level_to_zero) lby = 0;
12341
13187
  const
12342
13188
  rx = fn.upper_bound.result(t) - lbx,
12343
13189
  ry = tn.upper_bound.result(t) - lby;
12344
13190
  // Prevent division by zero: when either range is 0, the constraint
12345
- // must be active
13191
+ // must be active.
12346
13192
  if(rx < VM.NEAR_ZERO || ry < VM.NEAR_ZERO) return 1;
12347
13193
  // Otherwise, convert levels to % of range...
12348
13194
  const
12349
13195
  x = (fn.level[t] - lbx) / rx * 100,
12350
13196
  y = (tn.level[t] - lby) / ry * 100;
12351
- // ... and then check whether (%X, %Y) lies on the boundline
13197
+ // ... and then check whether (%X, %Y) lies on the bound line.
12352
13198
  for(let i = 0; i < this.bound_lines.length; i++) {
12353
13199
  const bl = this.bound_lines[i];
12354
- if(bl.isActive && bl.pointOnLine(x, y)) return 1;
13200
+ // Bound line does NOT constrain when Y is on its own bound.
13201
+ if((bl.type === VM.LE && Math.abs(y - 100) < VM.NEAR_ZERO) ||
13202
+ (bl.type === VM.GE && Math.abs(y) < VM.NEAR_ZERO)) continue;
13203
+ // Actualize bound line points for current time step.
13204
+ bl.setDynamicPoints(t);
13205
+ if(bl.pointOnLine(x, y)) return 1;
12355
13206
  }
12356
13207
  return 0;
12357
13208
  }
12358
13209
 
12359
13210
  get activeVector() {
13211
+ // Return active state for all time steps in the optimization period.
12360
13212
  const v = [];
12361
13213
  for(let t = 0; t < MODEL.runLength + 1; t++) v.push(this.active(t));
12362
13214
  return v;
@@ -12490,10 +13342,10 @@ class Constraint {
12490
13342
 
12491
13343
  addBoundLine() {
12492
13344
  // Adds a new bound line to this constraint, and returns this new line
12493
- // NOTE: returns the "base" bound line Y >= 0 (for any X) if it already
12494
- // exists and has no specified selectors
13345
+ // NOTE: Returns the "base" bound line Y >= 0 (for any X) if it already
13346
+ // exists and has no associated dataset.
12495
13347
  let bl = this.baseLine;
12496
- if(bl && !bl.selectors) return bl;
13348
+ if(bl && bl.point_data.length === 0) return bl;
12497
13349
  bl = new BoundLine(this);
12498
13350
  this.bound_lines.push(bl);
12499
13351
  return bl;
@@ -12556,6 +13408,7 @@ if(NODE) module.exports = {
12556
13408
  BlockMessages: BlockMessages,
12557
13409
  ExperimentRun: ExperimentRun,
12558
13410
  Experiment: Experiment,
13411
+ BoundlineSelector: BoundlineSelector,
12559
13412
  BoundLine: BoundLine,
12560
13413
  Constraint: Constraint
12561
13414
  };