linny-r 1.9.3 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +172 -126
- package/console.js +2 -1
- package/package.json +1 -1
- package/post-install.js +93 -37
- package/server.js +73 -29
- package/static/images/eq-negated.png +0 -0
- package/static/images/power.png +0 -0
- package/static/images/tex.png +0 -0
- package/static/index.html +226 -11
- package/static/linny-r.css +458 -8
- package/static/scripts/linny-r-ctrl.js +6 -4
- package/static/scripts/linny-r-gui-actor-manager.js +1 -1
- package/static/scripts/linny-r-gui-chart-manager.js +20 -13
- package/static/scripts/linny-r-gui-constraint-editor.js +410 -50
- package/static/scripts/linny-r-gui-controller.js +138 -21
- package/static/scripts/linny-r-gui-dataset-manager.js +28 -20
- package/static/scripts/linny-r-gui-documentation-manager.js +11 -3
- package/static/scripts/linny-r-gui-equation-manager.js +1 -1
- package/static/scripts/linny-r-gui-experiment-manager.js +1 -1
- package/static/scripts/linny-r-gui-expression-editor.js +7 -1
- package/static/scripts/linny-r-gui-file-manager.js +63 -19
- package/static/scripts/linny-r-gui-finder.js +1 -1
- package/static/scripts/linny-r-gui-model-autosaver.js +1 -1
- package/static/scripts/linny-r-gui-monitor.js +1 -1
- package/static/scripts/linny-r-gui-paper.js +108 -25
- package/static/scripts/linny-r-gui-power-grid-manager.js +529 -0
- package/static/scripts/linny-r-gui-receiver.js +1 -1
- package/static/scripts/linny-r-gui-repository-browser.js +1 -1
- package/static/scripts/linny-r-gui-scale-unit-manager.js +1 -1
- package/static/scripts/linny-r-gui-sensitivity-analysis.js +1 -1
- package/static/scripts/linny-r-gui-tex-manager.js +110 -0
- package/static/scripts/linny-r-gui-undo-redo.js +1 -1
- package/static/scripts/linny-r-milp.js +1 -1
- package/static/scripts/linny-r-model.js +982 -123
- package/static/scripts/linny-r-utils.js +3 -3
- package/static/scripts/linny-r-vm.js +731 -252
- package/static/show-diff.html +1 -1
- 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-
|
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
|
-
//
|
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
|
-
|
874
|
-
|
875
|
-
|
876
|
-
|
877
|
-
|
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
|
-
|
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><
|
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) + ' V';
|
4887
|
+
if(kv < 10) return kv.toPrecision(2) + ' kV';
|
4888
|
+
if(kv >= 999.5) return (kv * 0.001).toPrecision(2) + ' MV';
|
4889
|
+
return Math.round(kv) + ' 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><
|
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
|
-
//
|
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(
|
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;
|
@@ -5523,6 +5710,12 @@ class NodeBox extends ObjectWithXYWH {
|
|
5523
5710
|
UI.warningInvalidName(name);
|
5524
5711
|
return false;
|
5525
5712
|
}
|
5713
|
+
// Check whether a non-node entity has this name.
|
5714
|
+
const nne = MODEL.namedObjectByID(UI.nameToID(name));
|
5715
|
+
if(nne && nne !== this) {
|
5716
|
+
UI.warningEntityExists(nne);
|
5717
|
+
return false;
|
5718
|
+
}
|
5526
5719
|
// Compose the full name.
|
5527
5720
|
if(actor_name === '') actor_name = UI.NO_ACTOR;
|
5528
5721
|
let fn = name;
|
@@ -5559,6 +5752,15 @@ class NodeBox extends ObjectWithXYWH {
|
|
5559
5752
|
// Change this object's name and actor.
|
5560
5753
|
this.actor = MODEL.addActor(actor_name);
|
5561
5754
|
this.name = name;
|
5755
|
+
// Ensure that actor cash flow data products have valid properties
|
5756
|
+
if(this.name.startsWith('$')) {
|
5757
|
+
this.scale_unit = MODEL.currency_unit;
|
5758
|
+
this.initial_level.text = '';
|
5759
|
+
this.price.text = '';
|
5760
|
+
this.is_data = true;
|
5761
|
+
this.is_buffer = false;
|
5762
|
+
this.integer_level = false;
|
5763
|
+
}
|
5562
5764
|
// Update actor list in case some actor name is no longer used.
|
5563
5765
|
MODEL.cleanUpActors();
|
5564
5766
|
MODEL.replaceEntityInExpressions(old_name, this.displayName);
|
@@ -7338,9 +7540,12 @@ class Node extends NodeBox {
|
|
7338
7540
|
}
|
7339
7541
|
|
7340
7542
|
get needsOnOffData() {
|
7341
|
-
//
|
7543
|
+
// Return TRUE if this node requires a binary ON/OFF variable.
|
7342
7544
|
// This means that at least one output link must have the "start-up",
|
7343
|
-
// "positive", "zero"
|
7545
|
+
// "positive", "zero", "shut-down", "spinning reserve" or
|
7546
|
+
// "first commit" multiplier.
|
7547
|
+
// NOTE: As of version 2.0.0, power grid processes also need ON/OFF.
|
7548
|
+
if(this.grid) return true;
|
7344
7549
|
for(let i = 0; i < this.outputs.length; i++) {
|
7345
7550
|
if(VM.LM_NEEDING_ON_OFF.indexOf(this.outputs[i].multiplier) >= 0) {
|
7346
7551
|
return true;
|
@@ -7349,8 +7554,19 @@ class Node extends NodeBox {
|
|
7349
7554
|
return false;
|
7350
7555
|
}
|
7351
7556
|
|
7557
|
+
get needsIsZeroData() {
|
7558
|
+
// Return TRUE if this node requires a binary IS ZERO variable.
|
7559
|
+
// This means that at least one output link must have the "zero"
|
7560
|
+
// multiplier.
|
7561
|
+
for(let i = 0; i < this.outputs.length; i++) {
|
7562
|
+
if(this.outputs[i].multiplier === VM.LM_ZERO) return true;
|
7563
|
+
}
|
7564
|
+
return false;
|
7565
|
+
}
|
7566
|
+
|
7352
7567
|
get needsStartUpData() {
|
7353
|
-
//
|
7568
|
+
// Return TRUE iff this node has an output data link for start-up
|
7569
|
+
// or first commit.
|
7354
7570
|
for(let i = 0; i < this.outputs.length; i++) {
|
7355
7571
|
const m = this.outputs[i].multiplier;
|
7356
7572
|
if(m === VM.LM_STARTUP || m === VM.LM_FIRST_COMMIT) return true;
|
@@ -7359,16 +7575,15 @@ class Node extends NodeBox {
|
|
7359
7575
|
}
|
7360
7576
|
|
7361
7577
|
get needsShutDownData() {
|
7362
|
-
//
|
7578
|
+
// Return TRUE iff this node has an output data link for shut-down.
|
7363
7579
|
for(let i = 0; i < this.outputs.length; i++) {
|
7364
|
-
|
7365
|
-
if(m === VM.LM_SHUTDOWN) return true;
|
7580
|
+
if(this.outputs[i].multiplier === VM.LM_SHUTDOWN) return true;
|
7366
7581
|
}
|
7367
7582
|
return false;
|
7368
7583
|
}
|
7369
7584
|
|
7370
7585
|
get needsFirstCommitData() {
|
7371
|
-
//
|
7586
|
+
// Return TRUE iff this node has an output data link for first commit.
|
7372
7587
|
for(let i = 0; i < this.outputs.length; i++) {
|
7373
7588
|
if(this.outputs[i].multiplier === VM.LM_FIRST_COMMIT) return true;
|
7374
7589
|
}
|
@@ -7376,7 +7591,7 @@ class Node extends NodeBox {
|
|
7376
7591
|
}
|
7377
7592
|
|
7378
7593
|
get linksToFirstCommitDataProduct() {
|
7379
|
-
//
|
7594
|
+
// Return data product P iff this node has an output link to P, and P has
|
7380
7595
|
// an output link for first commit
|
7381
7596
|
for(let i = 0; i < this.outputs.length; i++) {
|
7382
7597
|
const p = this.outputs[i].to_node;
|
@@ -7395,7 +7610,12 @@ class Node extends NodeBox {
|
|
7395
7610
|
}
|
7396
7611
|
|
7397
7612
|
setPredecessors() {
|
7398
|
-
// Recursive function to create list of all nodes that precede this one
|
7613
|
+
// Recursive function to create list of all nodes that precede this one.
|
7614
|
+
// NOTE: As of version 2.0.0, feedback links are no longer displayed
|
7615
|
+
// as such. To permit re-enabling this function, the functional part
|
7616
|
+
// of this method has been commented out.
|
7617
|
+
|
7618
|
+
/*
|
7399
7619
|
for(let i = 0; i < this.inputs.length; i++) {
|
7400
7620
|
const l = this.inputs[i];
|
7401
7621
|
if(!l.visited) {
|
@@ -7413,6 +7633,7 @@ class Node extends NodeBox {
|
|
7413
7633
|
}
|
7414
7634
|
}
|
7415
7635
|
}
|
7636
|
+
*/
|
7416
7637
|
return this.predecessors;
|
7417
7638
|
}
|
7418
7639
|
|
@@ -7557,6 +7778,99 @@ class Node extends NodeBox {
|
|
7557
7778
|
}
|
7558
7779
|
return nn;
|
7559
7780
|
}
|
7781
|
+
|
7782
|
+
get TEXforBinaries() {
|
7783
|
+
// Return LaTeX code for formulas that compute binary variables.
|
7784
|
+
// In the equations, binary variables are underlined to distinguish
|
7785
|
+
// them from (semi-)continuous variables.
|
7786
|
+
const
|
7787
|
+
NL = '\\\\\n',
|
7788
|
+
tex = [NL],
|
7789
|
+
x = (this.level_to_zero ? '\\hat{x}' : 'x'),
|
7790
|
+
sub = (MODEL.start_period !== MODEL.end_period ?
|
7791
|
+
'_{' + this.code + ',t}' : '_' + this.code),
|
7792
|
+
sub_1 = '_{' + this.code +
|
7793
|
+
(MODEL.start_period !== MODEL.end_period ? ',t-1}' : ',0}');
|
7794
|
+
if(this.needsOnOffData) {
|
7795
|
+
// Equations to compute OO variable (denoted as u for "up").
|
7796
|
+
// (a) L[t] - LB[t]*OO[t] >= 0
|
7797
|
+
tex.push(x + sub, '- LB' + sub, '\\mathbf{u}' + sub, '\\ge 0', NL);
|
7798
|
+
// (b) L[t] - UB[t]*OO[t] <= 0
|
7799
|
+
tex.push(x + sub, '- UB' + sub, '\\mathbf{u}' + sub, '\\le 0', NL);
|
7800
|
+
}
|
7801
|
+
if(this.needsIsZeroData) {
|
7802
|
+
// Equation to compute IZ variable (denoted as d for "down").
|
7803
|
+
// (c) OO[t] + IZ[t] = 1
|
7804
|
+
tex.push('\\mathbf{u}' + sub, '+ \\mathbf{d}' + sub, '= 0', NL);
|
7805
|
+
// (d) L[t] + IZ[t] >= LB[t]
|
7806
|
+
// NOTE: for semicontinuous variables, use 0 instead of LB[t]
|
7807
|
+
tex.push(x + sub, '+ \\mathbf{d}' + sub, '\\ge',
|
7808
|
+
(this.level_to_zero ? '0' : 'LB' + sub), NL);
|
7809
|
+
}
|
7810
|
+
if(this.needsStartUpData) {
|
7811
|
+
// Equation to compute start-up variable (denoted as su).
|
7812
|
+
// (e) OO[t-1] - OO[t] + SU[t] >= 0
|
7813
|
+
tex.push('\\mathbf{u}' + sub_1, '- \\mathbf{u}' + sub,
|
7814
|
+
'+ \\mathbf{su}' + sub, '\\ge 0', NL);
|
7815
|
+
// (f) OO[t] - SU[t] >= 0
|
7816
|
+
tex.push('\\mathbf{u}' + sub, '- \\mathbf{su}' + sub, '\\ge 0', NL);
|
7817
|
+
// (g) OO[t-1] + OO[t] + SU[t] <= 2
|
7818
|
+
tex.push('\\mathbf{u}' + sub_1, '+ \\mathbf{u}' + sub,
|
7819
|
+
'+ \\mathbf{su}' + sub, '\\le 2', NL);
|
7820
|
+
}
|
7821
|
+
if(this.needsShutDownData) {
|
7822
|
+
// Equation to compute shutdown variable (denoted as sd-circumflex).
|
7823
|
+
// (e2) OO[t] - OO[t-1] + SD[t] >= 0
|
7824
|
+
tex.push('\\mathbf{u}' + sub, '- \\mathbf{u}' + sub_1,
|
7825
|
+
'+ \\mathbf{sd}' + sub, '\\ge 0', NL);
|
7826
|
+
// (f2) OO[t] + SD[t] <= 1
|
7827
|
+
tex.push('\\mathbf{u}' + sub, '+ \\mathbf{sd}' + sub, '\\le 1', NL);
|
7828
|
+
// (g2) SD[t] - OO[t-1] - OO[t] <= 0
|
7829
|
+
tex.push('\\mathbf{sd}' + sub, '- \\mathbf{u}' + sub_1,
|
7830
|
+
'- \\mathbf{su}' + sub, '\\le 0', NL);
|
7831
|
+
}
|
7832
|
+
if(this.needsFirstCommitData) {
|
7833
|
+
// Equation to compute first commit variables (denoted as fc).
|
7834
|
+
// To detect a first commit, start-ups are counted using an extra
|
7835
|
+
// variable SC (denoted as suc) and then similar equations are
|
7836
|
+
// added to detect "start-up" for this counter. This means one more
|
7837
|
+
// binary SO (denoted as suo for "start-up occurred").
|
7838
|
+
// (h) SC[t] - SC[t-1] - SU[t] = 0
|
7839
|
+
tex.push('\\mathbf{suc}' + sub, '- \\mathbf{suc}' + sub_1,
|
7840
|
+
'- \\mathbf{su}' + sub, '= 0', NL);
|
7841
|
+
// (i) SC[t] - SO[t] >= 0
|
7842
|
+
tex.push('\\mathbf{suc}' + sub, '- \\mathbf{suo}' + sub, '\\ge 0', NL);
|
7843
|
+
// (j) SC[t] - run length * SO[t] <= 0
|
7844
|
+
tex.push('\\mathbf{suc}' + sub, '- N\\! \\mathbf{suo}' + sub, '\\le 0', NL);
|
7845
|
+
// (k) SO[t-1] - SO[t] + FC[t] >= 0
|
7846
|
+
tex.push('\\mathbf{suo}' + sub_1, '- \\mathbf{suc}' + sub,
|
7847
|
+
'+ \\mathbf{fc}' + sub, '\\ge 0', NL);
|
7848
|
+
// (l) SO[t] - FC[t] >= 0
|
7849
|
+
tex.push('\\mathbf{suo}' + sub, '- \\mathbf{fc}' + sub, '\\ge 0', NL);
|
7850
|
+
// (m) SO[t-1] + SO[t] + FC[t] <= 2
|
7851
|
+
tex.push('\\mathbf{suo}' + sub_1, '+ \\mathbf{suo}' + sub,
|
7852
|
+
'+ \\mathbf{fc}' + sub, '\\le 2', NL);
|
7853
|
+
}
|
7854
|
+
|
7855
|
+
/*
|
7856
|
+
To calculate the peak increase values, we need two continuous
|
7857
|
+
"chunk variables", i.e., only 1 tableau column per chunk, not 1 for
|
7858
|
+
each time step. These variables BPI and CPI will compute the highest
|
7859
|
+
value (for all t in the block (B) and for the chunk (C)) of the
|
7860
|
+
difference L[t] - block peak (BP) of previous block. This requires
|
7861
|
+
one equation for every t = 1, ..., block length:
|
7862
|
+
(n) L[t] - BPI[b] <= BP[b-1] (where b denotes the block number)
|
7863
|
+
plus one equation for every t = block length + 1 to chunk length:
|
7864
|
+
(o) L[t] - BPI[b] - CPI[b] <= BP[b-1]
|
7865
|
+
This ensures that CPI is the *additional* increase in the look-ahead
|
7866
|
+
Then use BPI[b] in first time step if block, and CPI[b] at first
|
7867
|
+
time step of the look-ahead period to compute the actual flow for
|
7868
|
+
the "peak increase" links. For all other time steps this AF equals 0.
|
7869
|
+
|
7870
|
+
*/
|
7871
|
+
|
7872
|
+
return tex.join(' ');
|
7873
|
+
}
|
7560
7874
|
|
7561
7875
|
} // END of class Node
|
7562
7876
|
|
@@ -7565,6 +7879,8 @@ class Node extends NodeBox {
|
|
7565
7879
|
class Process extends Node {
|
7566
7880
|
constructor(cluster, name, actor) {
|
7567
7881
|
super(cluster, name, actor);
|
7882
|
+
// By default, processes have the letter p, products the letter q.
|
7883
|
+
this.TEX_id = 'p';
|
7568
7884
|
// NOTE: A process can change level once in PACE steps (default 1/1).
|
7569
7885
|
// This means that for a simulation perio of N time steps, this process will
|
7570
7886
|
// have a vector of only N / PACE decision variables (plus associated
|
@@ -7580,6 +7896,11 @@ class Process extends Node {
|
|
7580
7896
|
this.level_to_zero = false;
|
7581
7897
|
// Process node can be collapsed to take up less space in the diagram
|
7582
7898
|
this.collapsed = false;
|
7899
|
+
// Process can represent a power grid element, in which case it needs
|
7900
|
+
// properties for calculating power flow.
|
7901
|
+
this.power_grid = null;
|
7902
|
+
this.length_in_km = 0;
|
7903
|
+
this.reactance = 0;
|
7583
7904
|
// Processes have 3 more result attributes: CP, CF, CI and CO
|
7584
7905
|
this.cash_flow = [];
|
7585
7906
|
this.cash_in = [];
|
@@ -7599,6 +7920,36 @@ class Process extends Node {
|
|
7599
7920
|
get typeLetter() {
|
7600
7921
|
return 'P';
|
7601
7922
|
}
|
7923
|
+
|
7924
|
+
get grid() {
|
7925
|
+
if(MODEL.with_power_flow) return this.power_grid;
|
7926
|
+
return null;
|
7927
|
+
}
|
7928
|
+
|
7929
|
+
get gridEdge() {
|
7930
|
+
// Return "FROM node -> TO node" as string if this is a grid process.
|
7931
|
+
const g = this.grid;
|
7932
|
+
if(!g) return '';
|
7933
|
+
let fn = null,
|
7934
|
+
tn = null;
|
7935
|
+
for(let i = 0; i < this.inputs.length; i++) {
|
7936
|
+
const l = this.inputs[i];
|
7937
|
+
if(l.multiplier == VM.LM_LEVEL) {
|
7938
|
+
fn = l.from_node;
|
7939
|
+
break;
|
7940
|
+
}
|
7941
|
+
}
|
7942
|
+
for(let i = 0; i < this.outputs.length; i++) {
|
7943
|
+
const l = this.outputs[i];
|
7944
|
+
if(l.multiplier == VM.LM_LEVEL && !l.to_node.is_data) {
|
7945
|
+
tn = l.from_node;
|
7946
|
+
break;
|
7947
|
+
}
|
7948
|
+
}
|
7949
|
+
fn = (fn ? fn.displayName : '???');
|
7950
|
+
tn = (tn ? tn.displayName : '???');
|
7951
|
+
return `${fn} ${UI.LINK_ARROW} ${tn}`;
|
7952
|
+
}
|
7602
7953
|
|
7603
7954
|
get attributes() {
|
7604
7955
|
const a = {name: this.displayName};
|
@@ -7649,20 +8000,25 @@ class Process extends Node {
|
|
7649
8000
|
if(this.integer_level) p += ' integer-level="1"';
|
7650
8001
|
if(this.level_to_zero) p += ' level-to-zero="1"';
|
7651
8002
|
if(this.equal_bounds) p += ' equal-bounds="1"';
|
8003
|
+
// NOTE: Save power grid related properties even when grid element
|
8004
|
+
// is not checked (so properties re-appear when re-checked).
|
7652
8005
|
return ['<process', p, '><name>', xmlEncoded(n),
|
7653
8006
|
'</name><owner>', xmlEncoded(this.actor.name),
|
7654
8007
|
'</owner><notes>', cmnts,
|
7655
8008
|
'</notes><upper-bound>', this.upper_bound.asXML,
|
7656
8009
|
'</upper-bound><lower-bound>', this.lower_bound.asXML,
|
7657
8010
|
'</lower-bound><initial-level>', this.initial_level.asXML,
|
7658
|
-
'</initial-level><
|
7659
|
-
'</pace
|
8011
|
+
'</initial-level><tex-id>', this.TEX_id,
|
8012
|
+
'</tex-id><pace>', this.pace_expression.asXML,
|
8013
|
+
'</pace><grid-id>', (this.power_grid ? this.power_grid.id : ''),
|
8014
|
+
'</grid-id><length>', this.length_in_km,
|
8015
|
+
'</length><x-coord>', x,
|
7660
8016
|
'</x-coord><y-coord>', y,
|
7661
8017
|
'</y-coord></process>'].join('');
|
7662
8018
|
}
|
7663
8019
|
|
7664
8020
|
initFromXML(node) {
|
7665
|
-
// NOTE:
|
8021
|
+
// NOTE: Do not set code while importing, as new code must be assigned!
|
7666
8022
|
if(!IO_CONTEXT) this.code = nodeParameterValue(node, 'code');
|
7667
8023
|
this.collapsed = nodeParameterValue(node, 'collapsed') === '1';
|
7668
8024
|
this.integer_level = nodeParameterValue(node, 'integer-level') === '1';
|
@@ -7672,23 +8028,28 @@ class Process extends Node {
|
|
7672
8028
|
this.comments = xmlDecoded(nodeContentByTag(node, 'notes'));
|
7673
8029
|
this.lower_bound.text = xmlDecoded(nodeContentByTag(node, 'lower-bound'));
|
7674
8030
|
this.upper_bound.text = xmlDecoded(nodeContentByTag(node, 'upper-bound'));
|
7675
|
-
// legacy models can have LB and UB hexadecimal data strings
|
8031
|
+
// legacy models can have LB and UB hexadecimal data strings.
|
7676
8032
|
this.convertLegacyBoundData(nodeContentByTag(node, 'lower-bound-data'),
|
7677
8033
|
nodeContentByTag(node, 'upper-bound-data'));
|
7678
8034
|
if(nodeParameterValue(node, 'reversible') === '1') {
|
7679
|
-
// For legacy "reversible" processes, the LB is set to -UB
|
8035
|
+
// For legacy "reversible" processes, the LB is set to -UB.
|
7680
8036
|
this.lower_bound.text = '-' + this.upper_bound.text;
|
7681
8037
|
}
|
7682
|
-
// NOTE:
|
8038
|
+
// NOTE: Legacy models have no initial level field => default to 0.
|
7683
8039
|
const ilt = xmlDecoded(nodeContentByTag(node, 'initial-level'));
|
7684
8040
|
this.initial_level.text = ilt || '0';
|
7685
|
-
// NOTE:
|
8041
|
+
// NOTE: Until version 1.0.16, pace was stored as a node parameter.
|
7686
8042
|
const pace_text = nodeParameterValue(node, 'pace') +
|
7687
8043
|
xmlDecoded(nodeContentByTag(node, 'pace'));
|
7688
|
-
// NOTE:
|
8044
|
+
// NOTE: Legacy models have no pace field => default to 1.
|
7689
8045
|
this.pace_expression.text = pace_text || '1';
|
7690
|
-
// NOTE:
|
8046
|
+
// NOTE: Immediately evaluate pace expression as integer.
|
7691
8047
|
this.pace = Math.max(1, Math.floor(this.pace_expression.result(1)));
|
8048
|
+
this.TEX_id = xmlDecoded(nodeContentByTag(node, 'tex-id') || 'p');
|
8049
|
+
this.power_grid = MODEL.powerGridByID(nodeContentByTag(node, 'grid-id'));
|
8050
|
+
this.length_in_km = safeStrToFloat(nodeContentByTag(node, 'length'), 0);
|
8051
|
+
// NOTE: Reactance may be an empty string to indicate "infer from length".
|
8052
|
+
this.reactance = nodeContentByTag(node, 'reactance');
|
7692
8053
|
this.x = safeStrToInt(nodeContentByTag(node, 'x-coord'));
|
7693
8054
|
this.y = safeStrToInt(nodeContentByTag(node, 'y-coord'));
|
7694
8055
|
if(IO_CONTEXT) {
|
@@ -7784,6 +8145,50 @@ class Process extends Node {
|
|
7784
8145
|
return (ub.isStatic ? ub.result(0) : VM.PLUS_INFINITY);
|
7785
8146
|
}
|
7786
8147
|
|
8148
|
+
lossRates(t) {
|
8149
|
+
// Returns a list of loss rates for the slope variables associated
|
8150
|
+
// with this process at time t.
|
8151
|
+
// NOTE: Rates depend on upper bound, which may be dynamic.
|
8152
|
+
// Source: section 4.4 of Neumann et al. (2022) Assessments of linear
|
8153
|
+
// power flow and transmission loss approximations in coordinated
|
8154
|
+
// capacity expansion problem. Applied Energy, 314: 118859.
|
8155
|
+
// https://doi.org/10.1016/j.apenergy.2022.118859
|
8156
|
+
if(!(this.grid && this.grid.loss_approximation)) return [0];
|
8157
|
+
let ub = this.upper_bound.result(t);
|
8158
|
+
if(ub >= VM.PLUS_INFINITY) {
|
8159
|
+
// When UB = +INF, this is interpreted as "unlimited", which is
|
8160
|
+
// implemented as 99999 grid power units.
|
8161
|
+
ub = VM.UNLIMITED_POWER_FLOW;
|
8162
|
+
}
|
8163
|
+
const
|
8164
|
+
la = this.grid.loss_approximation,
|
8165
|
+
// Let m be the highest per unit loss.
|
8166
|
+
m = ub * this.grid.resistancePerKm * this.length_in_km;
|
8167
|
+
// Linear loss approximation: 1 slope from 0 to m.
|
8168
|
+
if(la === 1) return [m];
|
8169
|
+
// 2-slope approximation of quadratic curve.
|
8170
|
+
if(la === 2) return [m * 0.25, m * 0.75];
|
8171
|
+
// 3-slope approximation of quadratic curve.
|
8172
|
+
return [m / 9, m * 3/9, m * 5/9];
|
8173
|
+
}
|
8174
|
+
|
8175
|
+
actualLossRate(t) {
|
8176
|
+
// Return the actual loss rate, which depends on the power flow
|
8177
|
+
// and the max. power flow (process UB).
|
8178
|
+
const g = this.grid;
|
8179
|
+
if(!g) return 0;
|
8180
|
+
const
|
8181
|
+
lr = this.lossRates(t),
|
8182
|
+
apl = Math.abs(this.actualLevel(t)),
|
8183
|
+
ub = this.upper_bound.result(t),
|
8184
|
+
la = g.loss_approximation,
|
8185
|
+
// Prevent division by 0.
|
8186
|
+
slope = (ub < VM.NEAR_ZERO ? 0 :
|
8187
|
+
// NOTE: Index may exceed # slopes - 1 when level = UB.
|
8188
|
+
Math.min(la - 1, Math.floor(apl * la / ub)));
|
8189
|
+
return lr[slope];
|
8190
|
+
}
|
8191
|
+
|
7787
8192
|
copyPropertiesFrom(p) {
|
7788
8193
|
// Set properties to be identical to those of process `p`
|
7789
8194
|
this.x = p.x;
|
@@ -7797,6 +8202,7 @@ class Process extends Node {
|
|
7797
8202
|
this.equal_bounds = p.equal_bounds;
|
7798
8203
|
this.level_to_zero = p.level_to_zero;
|
7799
8204
|
this.collapsed = p.collapsed;
|
8205
|
+
this.TEX_id = p.TEX_id;
|
7800
8206
|
}
|
7801
8207
|
|
7802
8208
|
differences(p) {
|
@@ -7809,6 +8215,32 @@ class Process extends Node {
|
|
7809
8215
|
if(Object.keys(d).length > 0) return d;
|
7810
8216
|
return null;
|
7811
8217
|
}
|
8218
|
+
|
8219
|
+
get TEXcode() {
|
8220
|
+
// Return LaTeX code for mathematical formula of constraints defined
|
8221
|
+
// by this process.
|
8222
|
+
const
|
8223
|
+
NL = '\\\\\n',
|
8224
|
+
tex = [NL],
|
8225
|
+
sub = (MODEL.start_period !== MODEL.end_period ?
|
8226
|
+
'_{' + this.TEX_id + ',t}' : '_' + this.TEX_id),
|
8227
|
+
lb = (this.lower_bound.defined ? 'LB' + sub + ' \\le' : ''),
|
8228
|
+
ub = (this.upper_bound.defined ? '\\le UB' + sub : '');
|
8229
|
+
// Integer constraint if applicable.
|
8230
|
+
if(this.integer_level) tex.push('x' + sub, '\\in \\mathbb{Z}', NL);
|
8231
|
+
// Bound constraints...
|
8232
|
+
if(lb && this.equal_bounds) {
|
8233
|
+
tex.push('x' + sub, '= LB' + sub);
|
8234
|
+
} else if(lb || ub) {
|
8235
|
+
tex.push(lb, 'x' + sub, ub);
|
8236
|
+
}
|
8237
|
+
// ... with semi-continuity if applicable.
|
8238
|
+
if(lb && this.level_to_zero) tex.push('\\vee x' + sub, '= 0');
|
8239
|
+
tex.push(NL);
|
8240
|
+
// Add equations for associated binary variables.
|
8241
|
+
tex.push(this.TEXforBinaries);
|
8242
|
+
return tex.join(' ');
|
8243
|
+
}
|
7812
8244
|
|
7813
8245
|
} // END of class Process
|
7814
8246
|
|
@@ -7818,6 +8250,8 @@ class Product extends Node {
|
|
7818
8250
|
constructor(cluster, name, actor) {
|
7819
8251
|
super(cluster, name, actor);
|
7820
8252
|
this.scale_unit = MODEL.default_unit;
|
8253
|
+
// By default, processes have the letter p, products the letter q.
|
8254
|
+
this.TEX_id = 'p';
|
7821
8255
|
// For products, the default bounds are [0, 0], and modeler-defined bounds
|
7822
8256
|
// typically are equal
|
7823
8257
|
this.equal_bounds = true;
|
@@ -7856,6 +8290,10 @@ class Product extends Node {
|
|
7856
8290
|
return 'Q';
|
7857
8291
|
}
|
7858
8292
|
|
8293
|
+
get grid() {
|
8294
|
+
return null;
|
8295
|
+
}
|
8296
|
+
|
7859
8297
|
get attributes() {
|
7860
8298
|
const a = {name: this.displayName};
|
7861
8299
|
a.LB = this.lower_bound.asAttribute;
|
@@ -8113,7 +8551,8 @@ class Product extends Node {
|
|
8113
8551
|
'</lower-bound><price>', this.price.asXML,
|
8114
8552
|
'</price><x-coord>', x,
|
8115
8553
|
'</x-coord><y-coord>', y,
|
8116
|
-
'</y-coord
|
8554
|
+
'</y-coord><tex-id>', this.TEX_id,
|
8555
|
+
'</tex-id></product>'].join('');
|
8117
8556
|
return xml;
|
8118
8557
|
}
|
8119
8558
|
|
@@ -8132,6 +8571,7 @@ class Product extends Node {
|
|
8132
8571
|
nodeParameterValue(node, 'hidden')) === '1';
|
8133
8572
|
this.scale_unit = MODEL.addScaleUnit(
|
8134
8573
|
xmlDecoded(nodeContentByTag(node, 'unit')));
|
8574
|
+
this.TEX_id = xmlDecoded(nodeContentByTag(node, 'tex-id') || 'q');
|
8135
8575
|
// Legacy models have tag "profit" instead of "price"
|
8136
8576
|
let pp = nodeContentByTag(node, 'price');
|
8137
8577
|
if(!pp) pp = nodeContentByTag(node, 'profit');
|
@@ -8295,6 +8735,7 @@ class Product extends Node {
|
|
8295
8735
|
this.no_slack = p.no_slack;
|
8296
8736
|
this.initial_level.text = p.initial_level.text;
|
8297
8737
|
this.integer_level = p.integer_level;
|
8738
|
+
this.TEX_id = p.TEX_id;
|
8298
8739
|
// NOTE: do not copy the `no_links` property, nor the import/export status
|
8299
8740
|
}
|
8300
8741
|
|
@@ -8305,6 +8746,71 @@ class Product extends Node {
|
|
8305
8746
|
return null;
|
8306
8747
|
}
|
8307
8748
|
|
8749
|
+
get TEXcode() {
|
8750
|
+
// Return LaTeX code for mathematical formula of constraints defined
|
8751
|
+
// by this product.
|
8752
|
+
const
|
8753
|
+
NL = '\\\\\n',
|
8754
|
+
tex = [NL],
|
8755
|
+
x = (this.level_to_zero ? '\\hat{x}' : 'x'),
|
8756
|
+
dyn = (MODEL.start_period !== MODEL.end_period),
|
8757
|
+
sub = (dyn ? '_{' + this.TEX_id + ',t}' : '_' + this.TEX_id),
|
8758
|
+
param = (x, p) => {
|
8759
|
+
if(!x.defined) return '';
|
8760
|
+
const v = safeStrToFloat(x.text, p + sub);
|
8761
|
+
if(typeof v === 'number') return VM.sig4Dig(v);
|
8762
|
+
return v;
|
8763
|
+
};
|
8764
|
+
// Integer constraint if applicable.
|
8765
|
+
if(this.integer_level) tex.push(x + sub, '\\in \\mathbb{Z}', NL);
|
8766
|
+
// Bounds can be explicit...
|
8767
|
+
let lb = param(this.lower_bound, 'LB'),
|
8768
|
+
ub = param(this.upper_bound, 'UB');
|
8769
|
+
if(lb && this.equal_bounds) ub = lb;
|
8770
|
+
// ... or implicit.
|
8771
|
+
if(!lb && !this.isSourceNode) lb = '0';
|
8772
|
+
if(!ub && !this.isSinkNode) ub = '0';
|
8773
|
+
// Add the bound constraints.
|
8774
|
+
if(lb && ub) {
|
8775
|
+
if(lb === ub) {
|
8776
|
+
tex.push(x + sub, '=', lb, NL);
|
8777
|
+
} else {
|
8778
|
+
tex.push(lb, '\\le ' + x + sub, '\\le', ub, NL);
|
8779
|
+
}
|
8780
|
+
} else if(lb) {
|
8781
|
+
tex.push(x + sub, '\\ge', lb, NL);
|
8782
|
+
} else if(ub) {
|
8783
|
+
tex.push(x + sub, '\\le', ub, NL);
|
8784
|
+
}
|
8785
|
+
// Add the "balance" constraint for links.
|
8786
|
+
tex.push(x + sub, '=');
|
8787
|
+
if(this.is_buffer) {
|
8788
|
+
// Insert X[t-1]
|
8789
|
+
tex.push(x + '_{' + this.code + (dyn ? ',t-1}' : ',0}'));
|
8790
|
+
}
|
8791
|
+
let first = true;
|
8792
|
+
for(let i = 0; i < this.inputs.length; i++) {
|
8793
|
+
let ltex = this.inputs[i].TEXcode.trim();
|
8794
|
+
if(!first && !ltex.startsWith('-')) tex.push('+');
|
8795
|
+
tex.push(ltex);
|
8796
|
+
first = false;
|
8797
|
+
}
|
8798
|
+
for(let i = 0; i < this.outputs.length; i++) {
|
8799
|
+
let ltex = this.outputs[i].TEXcode.trim();
|
8800
|
+
if(ltex.trim().startsWith('-')) {
|
8801
|
+
if(!first) {
|
8802
|
+
ltex = ltex.substring(1);
|
8803
|
+
ltex = '- ' + ltex;
|
8804
|
+
first = false;
|
8805
|
+
}
|
8806
|
+
} else {
|
8807
|
+
ltex = '- ' + ltex;
|
8808
|
+
}
|
8809
|
+
tex.push(ltex);
|
8810
|
+
}
|
8811
|
+
return tex.join(' ');
|
8812
|
+
}
|
8813
|
+
|
8308
8814
|
} // END of class Product
|
8309
8815
|
|
8310
8816
|
|
@@ -8515,6 +9021,8 @@ class Link {
|
|
8515
9021
|
actualDelay(t) {
|
8516
9022
|
// Scale the delay expression value of this link to a discrete number
|
8517
9023
|
// of time steps on the model time scale.
|
9024
|
+
// NOTE: For links from grid processes, delays are ignored.
|
9025
|
+
if(this.from_node.grid) return 0;
|
8518
9026
|
let d = Math.floor(VM.SIG_DIF_FROM_ZERO + this.flow_delay.result(t));
|
8519
9027
|
// NOTE: Negative values are permitted. This might invalidate cost
|
8520
9028
|
// price calculation -- to be checked!!
|
@@ -8551,6 +9059,61 @@ class Link {
|
|
8551
9059
|
fc.containsProduct(this.to_node))) return true;
|
8552
9060
|
return false;
|
8553
9061
|
}
|
9062
|
+
|
9063
|
+
get TEXcode() {
|
9064
|
+
// Return LaTeX code for the term for this link in the formula
|
9065
|
+
// for its TO node if this is a product. The TEX routines for
|
9066
|
+
// products will take care of the sign of this term.
|
9067
|
+
const
|
9068
|
+
dyn = MODEL.start_period !== MODEL.end_period,
|
9069
|
+
n1 = (this.to_node instanceof Process ? this.to_node : this.from_node),
|
9070
|
+
n2 = (n1 === this.to_node ? this.from_node: this.to_node),
|
9071
|
+
x = (n1.level_to_zero ? '\\hat(x)' : 'x'),
|
9072
|
+
fsub = (dyn ? '_{' + n1.TEX_id + ',t}' : '_' + n1.TEX_id),
|
9073
|
+
fsub_i = fsub.replace(',t}', ',i}'),
|
9074
|
+
rs = n1.TEX_id + ' \\rightarrow ' + n2.TEX_id,
|
9075
|
+
rsub = (dyn ? '_{' + rs + ',t}' : '_' + rs),
|
9076
|
+
param = (x, p, sub) => {
|
9077
|
+
if(!x.defined) return '';
|
9078
|
+
const v = safeStrToFloat(x.text, p + sub);
|
9079
|
+
if(typeof v === 'number') return (v === 1 ? '' :
|
9080
|
+
(v === -1 ? '-' : VM.sig4Dig(v)));
|
9081
|
+
return v;
|
9082
|
+
},
|
9083
|
+
r = param(this.relative_rate, 'R', rsub),
|
9084
|
+
d = param(this.flow_delay, '\\delta', rsub),
|
9085
|
+
dn = safeStrToInt(d, '?'),
|
9086
|
+
d_1 = (!d || typeof dn === 'number' ? dn + 1 : d + '+1');
|
9087
|
+
if(this.multiplier === VM.LM_LEVEL) {
|
9088
|
+
return r + ' ' + x + fsub;
|
9089
|
+
} else if(this.multiplier === VM.LM_THROUGHPUT) {
|
9090
|
+
return 'THR';
|
9091
|
+
} else if(this.multiplier === VM.LM_INCREASE) {
|
9092
|
+
return 'INC';
|
9093
|
+
} else if(this.multiplier === VM.LM_SUM) {
|
9094
|
+
if(d) return r + '\\sum_{i=t-' + d + '}^{t}{' + x + fsub_i + '}';
|
9095
|
+
return x + fsub;
|
9096
|
+
} else if(this.multiplier === VM.LM_MEAN) {
|
9097
|
+
if(d) return r + '{1 \\over {' + d_1 + '}} \sum_{i=t-' +
|
9098
|
+
d + '}^{t}{' + x + fsub_i + '}';
|
9099
|
+
return x + fsub;
|
9100
|
+
} else if(this.multiplier === VM.LM_STARTUP) {
|
9101
|
+
return r + ' \\mathbf{su}' + fsub;
|
9102
|
+
} else if(this.multiplier === VM.LM_POSITIVE) {
|
9103
|
+
return r + '\\mathbf{u}' + fsub;
|
9104
|
+
} else if(this.multiplier === VM.LM_ZERO) {
|
9105
|
+
return r + '\\mathbf{d}' + fsub;
|
9106
|
+
} else if(this.multiplier === VM.LM_SPINNING_RESERVE) {
|
9107
|
+
return 'SPIN';
|
9108
|
+
} else if(this.multiplier === VM.LM_FIRST_COMMIT) {
|
9109
|
+
return r + '\\mathbf{fc}' + fsub;
|
9110
|
+
} else if(this.multiplier === VM.LM_SHUTDOWN) {
|
9111
|
+
return r + '\\mathbf{sd}' + fsub;
|
9112
|
+
} else if(this.multiplier === VM.LM_PEAK_INC) {
|
9113
|
+
return 'PEAK';
|
9114
|
+
}
|
9115
|
+
return 'Unknown link multiplier: ' + this.multiplier;
|
9116
|
+
}
|
8554
9117
|
|
8555
9118
|
// NOTE: links do not draw themselves; they are visualized by Arrow objects
|
8556
9119
|
|
@@ -8584,6 +9147,7 @@ class DatasetModifier {
|
|
8584
9147
|
// NOTE: Identifier will be unique only for equations.
|
8585
9148
|
return UI.nameToID(this.selector);
|
8586
9149
|
}
|
9150
|
+
|
8587
9151
|
get displayName() {
|
8588
9152
|
// NOTE: When "displayed", dataset modifiers have their selector as name.
|
8589
9153
|
return this.selector;
|
@@ -9030,6 +9594,10 @@ class Dataset {
|
|
9030
9594
|
}
|
9031
9595
|
// Reduce inner spaces to one, and trim outer spaces.
|
9032
9596
|
s = s.replace(/\s+/g, ' ').trim();
|
9597
|
+
if(!s) {
|
9598
|
+
UI.warn(`Invalid equation name "${selector}"`);
|
9599
|
+
return null;
|
9600
|
+
}
|
9033
9601
|
if(s.startsWith(':')) {
|
9034
9602
|
// Methods must have no spaces directly after their leading colon,
|
9035
9603
|
// and must not contain other colons.
|
@@ -9053,21 +9621,9 @@ class Dataset {
|
|
9053
9621
|
return null;
|
9054
9622
|
}
|
9055
9623
|
} else {
|
9056
|
-
// Standard dataset modifier selectors are much more restricted
|
9057
|
-
|
9058
|
-
s
|
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;
|
9624
|
+
// Standard dataset modifier selectors are much more restricted.
|
9625
|
+
s = MODEL.validSelector(s);
|
9626
|
+
if(!s) return;
|
9071
9627
|
}
|
9072
9628
|
// Then add a dataset modifier to this dataset.
|
9073
9629
|
const id = UI.nameToID(s);
|
@@ -9270,7 +9826,8 @@ class ChartVariable {
|
|
9270
9826
|
// the Linny-R entity and its attribute, followed by its scale factor
|
9271
9827
|
// unless it equals 1 (no scaling).
|
9272
9828
|
const sf = (this.scale_factor === 1 ? '' :
|
9273
|
-
|
9829
|
+
// NOTE: Pass tiny = TRUE to permit very small scaling factors.
|
9830
|
+
` (x${VM.sig4Dig(this.scale_factor, true)})`);
|
9274
9831
|
// Display name of equation is just the equations dataset selector.
|
9275
9832
|
if(this.object instanceof DatasetModifier) {
|
9276
9833
|
let eqn = this.object.selector;
|
@@ -9323,9 +9880,9 @@ class ChartVariable {
|
|
9323
9880
|
` wildcard-index="${this.wildcard_index}"` : ''),
|
9324
9881
|
` sorted="${this.sorted}"`,
|
9325
9882
|
'><object-id>', xmlEncoded(id),
|
9326
|
-
'</object-id><attribute>', this.attribute,
|
9883
|
+
'</object-id><attribute>', xmlEncoded(this.attribute),
|
9327
9884
|
'</attribute><color>', this.color,
|
9328
|
-
'</color><scale-factor>', VM.sig4Dig(this.scale_factor),
|
9885
|
+
'</color><scale-factor>', VM.sig4Dig(this.scale_factor, true),
|
9329
9886
|
'</scale-factor><line-width>', VM.sig4Dig(this.line_width),
|
9330
9887
|
'</line-width></chart-variable>'].join('');
|
9331
9888
|
return xml;
|
@@ -9371,7 +9928,7 @@ class ChartVariable {
|
|
9371
9928
|
this.wildcard_index = (wci ? parseInt(wci) : false);
|
9372
9929
|
this.setProperties(
|
9373
9930
|
obj,
|
9374
|
-
nodeContentByTag(node, 'attribute'),
|
9931
|
+
xmlDecoded(nodeContentByTag(node, 'attribute')),
|
9375
9932
|
nodeParameterValue(node, 'stacked') === '1',
|
9376
9933
|
nodeContentByTag(node, 'color'),
|
9377
9934
|
safeStrToFloat(nodeContentByTag(node, 'scale-factor')),
|
@@ -9500,7 +10057,8 @@ class ChartVariable {
|
|
9500
10057
|
}
|
9501
10058
|
|
9502
10059
|
tallyVector() {
|
9503
|
-
//
|
10060
|
+
// Compute the histogram bin tallies for this chart variable.
|
10061
|
+
// Use local constants to save some time within the FOR loop.
|
9504
10062
|
const
|
9505
10063
|
bins = this.chart.bins,
|
9506
10064
|
bin1 = this.chart.first_bin,
|
@@ -9509,7 +10067,7 @@ class ChartVariable {
|
|
9509
10067
|
this.bin_tallies = Array(bins).fill(0);
|
9510
10068
|
for(let i = 1; i < l; i++) {
|
9511
10069
|
let v = this.vector[i];
|
9512
|
-
// NOTE:
|
10070
|
+
// NOTE: Ignore exceptional values in histogram.
|
9513
10071
|
if(v >= VM.MINUS_INFINITY && v <= VM.PLUS_INFINITY) {
|
9514
10072
|
const bi = Math.min(bins,
|
9515
10073
|
Math.floor((v - bin1 - VM.NEAR_ZERO) / binsize + 1));
|
@@ -9826,7 +10384,7 @@ class Chart {
|
|
9826
10384
|
}
|
9827
10385
|
|
9828
10386
|
timeScaleAsString(s) {
|
9829
|
-
//
|
10387
|
+
// Return number `s` (in hours) as string with most appropriate time unit.
|
9830
10388
|
if(s < 1/60) return VM.sig2Dig(s * 3600) + 's';
|
9831
10389
|
if(s < 1) return VM.sig2Dig(s * 60) + 'm';
|
9832
10390
|
if(s < 24) return VM.sig2Dig(s) + 'h';
|
@@ -10292,7 +10850,7 @@ class Chart {
|
|
10292
10850
|
this.plot_max_y = maxy;
|
10293
10851
|
y = miny;
|
10294
10852
|
const labels = [];
|
10295
|
-
while(y <=
|
10853
|
+
while(y - maxy <= VM.NEAR_ZERO) {
|
10296
10854
|
// NOTE: Large values having exponents will be "neat" numbers,
|
10297
10855
|
// so then display fewer decimals, as these will be zeroes.
|
10298
10856
|
const v = (Math.abs(y) > 1e5 ? VM.sig2Dig(y) : VM.sig4Dig(y));
|
@@ -11370,7 +11928,7 @@ class Experiment {
|
|
11370
11928
|
}
|
11371
11929
|
|
11372
11930
|
matchingCombinationIndex(sl) {
|
11373
|
-
// Returns index of combination with most selectors in common
|
11931
|
+
// Returns index of combination with most selectors in common with `sl`
|
11374
11932
|
let high = 0,
|
11375
11933
|
index = false;
|
11376
11934
|
// NOTE: results of current run are not available yet, hence length-1
|
@@ -12061,61 +12619,345 @@ class Experiment {
|
|
12061
12619
|
} // END of CLASS Experiment
|
12062
12620
|
|
12063
12621
|
|
12622
|
+
// CLASS BoundlineSelector
|
12623
|
+
class BoundlineSelector {
|
12624
|
+
constructor(boundline, selector, x='', g=false) {
|
12625
|
+
this.boundline = boundline;
|
12626
|
+
this.selector = selector;
|
12627
|
+
this.expression = new Expression(boundline, selector, x);
|
12628
|
+
this.grouping = g;
|
12629
|
+
}
|
12630
|
+
|
12631
|
+
get asXML() {
|
12632
|
+
// Prevent saving unidentified selectors.
|
12633
|
+
if(this.selector.trim().length === 0) return '';
|
12634
|
+
return ['<boundline-selector', (this.grouping ? ' points-x="1"' : ''),
|
12635
|
+
'><selector>', xmlEncoded(this.selector),
|
12636
|
+
'</selector><expression>', xmlEncoded(this.expression.text),
|
12637
|
+
'</expression></boundline-selector>'].join('');
|
12638
|
+
}
|
12639
|
+
|
12640
|
+
initFromXML(node) {
|
12641
|
+
this.grouping = nodeParameterValue(node, 'points-x') === '1';
|
12642
|
+
this.expression.text = xmlDecoded(nodeContentByTag(node, 'expression'));
|
12643
|
+
if(IO_CONTEXT) {
|
12644
|
+
// Contextualize the included expression.
|
12645
|
+
IO_CONTEXT.rewrite(this.expression);
|
12646
|
+
}
|
12647
|
+
}
|
12648
|
+
|
12649
|
+
} // END of class BoundlineSelector
|
12650
|
+
|
12651
|
+
|
12064
12652
|
// CLASS BoundLine
|
12065
12653
|
class BoundLine {
|
12066
12654
|
constructor(c) {
|
12067
12655
|
this.constraint = c;
|
12068
12656
|
// Default bound line imposes no constraint: Y >= 0 for all X.
|
12069
12657
|
this.points = [[0, 0], [100, 0]];
|
12658
|
+
this.storePoints();
|
12070
12659
|
this.type = VM.GE;
|
12071
|
-
this
|
12660
|
+
// SVG string for contour of this bound line (to reduce computation).
|
12072
12661
|
this.contour_path = '';
|
12662
|
+
this.point_data = [];
|
12663
|
+
this.url = '';
|
12664
|
+
this.selectors = [];
|
12665
|
+
this.selectors.push(new BoundlineSelector(this, '(default)', '0'));
|
12073
12666
|
}
|
12074
12667
|
|
12075
12668
|
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}) ` : '');
|
12669
|
+
return this.constraint.displayName + ' [' +
|
12670
|
+
VM.constraint_codes[this.type] + '] bound line #' +
|
12671
|
+
this.constraint.bound_lines.indexOf(this);
|
12080
12672
|
}
|
12081
12673
|
|
12082
12674
|
get copy() {
|
12083
12675
|
// Return a "clone" of this bound line.
|
12084
12676
|
let bl = new BoundLine(this.constraint);
|
12085
|
-
bl.
|
12086
|
-
|
12087
|
-
|
12088
|
-
bl.points.push([p[0], p[1]]);
|
12089
|
-
}
|
12677
|
+
bl.points_string = this.points_string;
|
12678
|
+
// NOTE: Reset boundline to its initial "as edited" state.
|
12679
|
+
bl.restorePoints();
|
12090
12680
|
bl.type = this.type;
|
12091
|
-
bl.selectors = this.selectors;
|
12092
12681
|
bl.contour_path = this.contour_path;
|
12682
|
+
bl.url = this.url;
|
12683
|
+
bl.point_data.length = 0;
|
12684
|
+
for(let i = 0; i < this.point_data.length; i++) {
|
12685
|
+
bl.point_data.push(this.point_data[i].slice());
|
12686
|
+
}
|
12687
|
+
bl.selectors.length = 0;
|
12688
|
+
for(let i = 0; i < this.selectors.length; i++) {
|
12689
|
+
const s = this.selectors[i];
|
12690
|
+
bl.selectors.push(new BoundlineSelector(s.boundline, s.selector,
|
12691
|
+
s.expression.text, s.grouping));
|
12692
|
+
}
|
12093
12693
|
return bl;
|
12094
12694
|
}
|
12095
12695
|
|
12696
|
+
get staticLine() {
|
12697
|
+
// Return TRUE if bound line has only the default selector, and this
|
12698
|
+
// evaluates as default index = 0.
|
12699
|
+
return this.selectors.length < 2 &&
|
12700
|
+
this.selectors[0].expression.text === '0';
|
12701
|
+
}
|
12702
|
+
|
12703
|
+
get selectorList() {
|
12704
|
+
// Return list of selector names only.
|
12705
|
+
const sl = [];
|
12706
|
+
for(let i = 0; i < this.selectors.length; i++) {
|
12707
|
+
sl.push(this.selectors[i].selector);
|
12708
|
+
}
|
12709
|
+
return sl;
|
12710
|
+
}
|
12711
|
+
|
12712
|
+
selectorByName(name) {
|
12713
|
+
// Return index of selector `n` in the list, or null if not found.
|
12714
|
+
for(let i = 0; i < this.selectors.length; i++) {
|
12715
|
+
if(this.selectors[i].selector === name) return this.selectors[i];
|
12716
|
+
}
|
12717
|
+
return null;
|
12718
|
+
}
|
12719
|
+
|
12720
|
+
addSelector(name, x='') {
|
12721
|
+
// Add selector if new, and return the named selector.
|
12722
|
+
const s = this.selectorByName(name);
|
12723
|
+
if(s) return s;
|
12724
|
+
name = MODEL.validSelector(name);
|
12725
|
+
if(name.indexOf('*') >= 0 || name.indexOf('?') >= 0) {
|
12726
|
+
UI.warn('Bound line selector cannot contain wildcards');
|
12727
|
+
return null;
|
12728
|
+
}
|
12729
|
+
if(name) {
|
12730
|
+
const s = new BoundlineSelector(this, name, x);
|
12731
|
+
this.selectors.push(s);
|
12732
|
+
this.selectors.sort((a, b) => compareSelectors(a.selector, b.selector));
|
12733
|
+
return s;
|
12734
|
+
}
|
12735
|
+
return null;
|
12736
|
+
}
|
12737
|
+
|
12738
|
+
setPointsFromData(index) {
|
12739
|
+
// Get point coordinates from bound line series data at index.
|
12740
|
+
if(index > 0 && index <= this.point_data.length) {
|
12741
|
+
const pd = this.point_data[index - 1];
|
12742
|
+
this.points.length = 0;
|
12743
|
+
for(let i = 0; i < pd.length; i += 2) {
|
12744
|
+
this.points.push([pd[i] * 100, pd[i + 1] * 100]);
|
12745
|
+
}
|
12746
|
+
} else {
|
12747
|
+
// Data is seen as 1-based array => default to original coordinates.
|
12748
|
+
this.restorePoints();
|
12749
|
+
}
|
12750
|
+
}
|
12751
|
+
|
12752
|
+
get activeSelectorIndex() {
|
12753
|
+
// Return the number of the first boundline selector that matches the
|
12754
|
+
// selector combination of the current experiment. Defaults to 0.
|
12755
|
+
if(this.selectors.length < 2) return 0;
|
12756
|
+
const x = MODEL.running_experiment;
|
12757
|
+
if(!x) return 0;
|
12758
|
+
for(let i = 1; i < this.selectors.length; i++) {
|
12759
|
+
if(x.activeCombination.indexOf(this.selectors[i].selector) >= 0) {
|
12760
|
+
return i;
|
12761
|
+
}
|
12762
|
+
}
|
12763
|
+
return 0;
|
12764
|
+
}
|
12765
|
+
|
12766
|
+
setDynamicPoints(t, draw=false) {
|
12767
|
+
// Adapt bound line point coordinates for the current time step
|
12768
|
+
// for the running experiment (if any).
|
12769
|
+
// NOTE: For speed, first perform the quick check whether this line
|
12770
|
+
// is static, as then the points do not change.
|
12771
|
+
if(this.staticLine) return;
|
12772
|
+
const
|
12773
|
+
bls = this.selectors[this.activeSelectorIndex],
|
12774
|
+
r = bls.expression.result(t);
|
12775
|
+
// Log errors on the console, but ignore them.
|
12776
|
+
if(r <= VM.ERROR || r >= VM.EXCEPTION) {
|
12777
|
+
console.log('ERROR: Exception in boundline selector expression', bls, r);
|
12778
|
+
// NOTE: Double-check whether result is a grouping.
|
12779
|
+
} else if(bls.grouping || Array.isArray(r)) {
|
12780
|
+
this.validatePoints(r);
|
12781
|
+
this.points.length = 0;
|
12782
|
+
for(let i = 0; i < r.length; i += 2) {
|
12783
|
+
this.points.push([r[i] * 100, r[i + 1] * 100]);
|
12784
|
+
}
|
12785
|
+
} else {
|
12786
|
+
// NOTE: Data is not a time series but "array type" data. The time
|
12787
|
+
// step co-determines the result. If modelers provide time series
|
12788
|
+
// data, then they shoud use `t` (absolute time) as index expression,
|
12789
|
+
// otherwise `rt` (relative time).
|
12790
|
+
// NOTE: Result is a floating point number, so truncate it to integer.
|
12791
|
+
this.setPointsFromData(Math.floor(r));
|
12792
|
+
}
|
12793
|
+
// Compute and store SVG for thumbnail only when needed.
|
12794
|
+
if(draw) CONSTRAINT_EDITOR.setContourPath(this);
|
12795
|
+
}
|
12796
|
+
|
12797
|
+
restorePoints() {
|
12798
|
+
// Restore point coordinates from original string.
|
12799
|
+
this.points = JSON.parse(this.points_string);
|
12800
|
+
}
|
12801
|
+
|
12802
|
+
storePoints() {
|
12803
|
+
// Set point coordinates to be the original string.
|
12804
|
+
this.points_string = JSON.stringify(this.points);
|
12805
|
+
}
|
12806
|
+
|
12807
|
+
get pointsDataString() {
|
12808
|
+
// Return point coordinates in data format (semicolon-separated numbers).
|
12809
|
+
const pd = [];
|
12810
|
+
for(let i = 0; i < this.points.length; i++) {
|
12811
|
+
const p = this.points[i];
|
12812
|
+
pd.push(p[0], p[1]);
|
12813
|
+
}
|
12814
|
+
return pd.join(';');
|
12815
|
+
}
|
12816
|
+
|
12817
|
+
get maxPoints() {
|
12818
|
+
// Return the highest number of points that this boundline can have.
|
12819
|
+
this.restorePoints();
|
12820
|
+
let n = this.points.length;
|
12821
|
+
for(let i = 0; i < this.point_data.length; i++) {
|
12822
|
+
n = Math.max(n, this.point_data[i].length);
|
12823
|
+
}
|
12824
|
+
const bls = this.selectors[this.activeSelectorIndex];
|
12825
|
+
if(bls.grouping) {
|
12826
|
+
const x = bls.expression;
|
12827
|
+
if(x.isStatic) {
|
12828
|
+
n = Math.max(n, x.result(1).length);
|
12829
|
+
} else {
|
12830
|
+
// For dynamic expressions, the grouping length may vary, so
|
12831
|
+
// we must check for the complete run length.
|
12832
|
+
for(let t = MODEL.start_period; t <= MODEL.end_period; t++) {
|
12833
|
+
n = Math.max(n, x.result(t).length);
|
12834
|
+
}
|
12835
|
+
}
|
12836
|
+
}
|
12837
|
+
return n;
|
12838
|
+
}
|
12839
|
+
|
12840
|
+
validatePoints(pd) {
|
12841
|
+
if(!Array.isArray(pd)) {
|
12842
|
+
console.log(pd); throw "Not an array";
|
12843
|
+
}
|
12844
|
+
// Ensure that array `pd` is a valid series of point coordinates.
|
12845
|
+
if(pd.length) {
|
12846
|
+
// Ensure that number of point coordinates is even (Y defaults to 0).
|
12847
|
+
if(pd.length % 2) pd.push(0);
|
12848
|
+
// Ensure that bound line has at least 2 points.
|
12849
|
+
if(pd.length === 2) pd.push(1, 0);
|
12850
|
+
} else {
|
12851
|
+
// Default to horizontal line Y = 0.
|
12852
|
+
pd.push(0, 0, 1, 0);
|
12853
|
+
}
|
12854
|
+
// NOTE: Point coordinates must lie between 0 and 1, and for the
|
12855
|
+
// X-coordinates, it should hold that X[0] = 0, X[i+1] >= X[i],
|
12856
|
+
// and X[N] = 1.
|
12857
|
+
if(pd[0] !== 0) pd[0] = 0;
|
12858
|
+
if(pd[pd.length - 2] !== 1) pd[pd.length - 2] = 1;
|
12859
|
+
let min = 0,
|
12860
|
+
last = 2;
|
12861
|
+
for(let i = 1; i < pd.length; i++) {
|
12862
|
+
const x = 1 - i % 2;
|
12863
|
+
pd[i] = Math.min(1, Math.max(x ? min : 0, pd[i]));
|
12864
|
+
if(x) {
|
12865
|
+
// Keep track of first point having X = 1, as this should be the
|
12866
|
+
// last point of the boundline.
|
12867
|
+
if(pd[i] > min) last = i + 1;
|
12868
|
+
min = pd[i];
|
12869
|
+
}
|
12870
|
+
}
|
12871
|
+
// Truncate any points after the first point having X = 1.
|
12872
|
+
pd.lengh = last;
|
12873
|
+
}
|
12874
|
+
|
12875
|
+
get pointDataString() {
|
12876
|
+
// Point data is stored as separate lines of semicolon-separated
|
12877
|
+
// floating point numbers, with N-digit precision to keep model files
|
12878
|
+
// compact (default: N = 8)
|
12879
|
+
let d = [];
|
12880
|
+
for(let i = 0; i < this.point_data.length; i++) {
|
12881
|
+
const
|
12882
|
+
opd = this.point_data[i],
|
12883
|
+
pd = [];
|
12884
|
+
for(let j = 0; j < opd.length; j++) {
|
12885
|
+
// Convert number to string with the desired precision.
|
12886
|
+
const f = opd[j].toPrecision(CONFIGURATION.dataset_precision);
|
12887
|
+
// Then parse it again, so that the number will be represented
|
12888
|
+
// (by JavaScript) in the most compact representation.
|
12889
|
+
pd.push(parseFloat(f));
|
12890
|
+
}
|
12891
|
+
this.validatePoints(pd);
|
12892
|
+
// Push point coordinates as space-separated string.
|
12893
|
+
d.push(pd.join(';'));
|
12894
|
+
}
|
12895
|
+
return d.join('\n');
|
12896
|
+
}
|
12897
|
+
|
12898
|
+
unpackPointDataString(str) {
|
12899
|
+
// Convert separate lines of semicolon-separated point data to a
|
12900
|
+
// list of lists of numbers.
|
12901
|
+
this.point_data.length = 0;
|
12902
|
+
str= str.trim();
|
12903
|
+
if(str) {
|
12904
|
+
const lines = str.split('\n');
|
12905
|
+
for(let i = 0; i < lines.length; i++) {
|
12906
|
+
const
|
12907
|
+
numbers = lines[i].split(';'),
|
12908
|
+
pd = [];
|
12909
|
+
for(let i = 0; i < numbers.length; i++) {
|
12910
|
+
pd.push(parseFloat(numbers[i].trim()));
|
12911
|
+
}
|
12912
|
+
this.validatePoints(pd);
|
12913
|
+
this.point_data.push(pd);
|
12914
|
+
}
|
12915
|
+
}
|
12916
|
+
}
|
12917
|
+
|
12096
12918
|
get asXML() {
|
12097
|
-
|
12098
|
-
|
12099
|
-
|
12100
|
-
|
12101
|
-
|
12919
|
+
// NOTE: Save boundline always with points-as-last-edited.
|
12920
|
+
this.restorePoints();
|
12921
|
+
const xml = ['<bound-line type="', this.type,
|
12922
|
+
'"><points>', JSON.stringify(this.points),
|
12923
|
+
'</points><contour>', this.contour_path,
|
12924
|
+
'</contour><url>', xmlEncoded(this.url),
|
12925
|
+
'</url><point-data>', xmlEncoded(this.pointDataString),
|
12926
|
+
'</point-data><selectors>'];
|
12927
|
+
for(let i = 0; i < this.selectors.length; i++) {
|
12928
|
+
xml.push(this.selectors[i].asXML);
|
12929
|
+
}
|
12930
|
+
xml.push('</selectors></bound-line>');
|
12931
|
+
return xml.join('');
|
12102
12932
|
}
|
12103
12933
|
|
12104
12934
|
initFromXML(node) {
|
12105
12935
|
this.type = safeStrToInt(nodeParameterValue(node, 'type'), VM.EQ);
|
12106
|
-
this.
|
12107
|
-
this.
|
12936
|
+
this.points_string = nodeContentByTag(node, 'points');
|
12937
|
+
this.restorePoints();
|
12108
12938
|
this.contour_path = nodeContentByTag(node, 'contour');
|
12109
|
-
|
12110
|
-
|
12111
|
-
|
12112
|
-
|
12113
|
-
|
12114
|
-
|
12115
|
-
|
12116
|
-
|
12117
|
-
|
12118
|
-
|
12939
|
+
this.url = xmlDecoded(nodeContentByTag(node, 'url'));
|
12940
|
+
if(this.url) {
|
12941
|
+
FILE_MANAGER.getRemoteData(this, this.url);
|
12942
|
+
} else {
|
12943
|
+
this.unpackPointDataString(
|
12944
|
+
xmlDecoded(nodeContentByTag(node, 'point-data')));
|
12945
|
+
}
|
12946
|
+
const n = childNodeByTag(node, 'selectors');
|
12947
|
+
if(n && n.childNodes && n.childNodes.length) {
|
12948
|
+
// NOTE: Only overwrite default selector if XML specifies selectors.
|
12949
|
+
this.selectors.length = 0;
|
12950
|
+
for(let i = 0; i < n.childNodes.length; i++) {
|
12951
|
+
const c = n.childNodes[i];
|
12952
|
+
if(c.nodeName === 'boundline-selector') {
|
12953
|
+
const
|
12954
|
+
s = xmlDecoded(nodeContentByTag(c, 'selector')),
|
12955
|
+
bls = new BoundlineSelector(this, s);
|
12956
|
+
bls.initFromXML(c);
|
12957
|
+
this.selectors.push(bls);
|
12958
|
+
}
|
12959
|
+
}
|
12960
|
+
}
|
12119
12961
|
}
|
12120
12962
|
|
12121
12963
|
get needsNoSOS() {
|
@@ -12272,7 +13114,15 @@ class Constraint {
|
|
12272
13114
|
this.bottom_y = 0;
|
12273
13115
|
this.from_offset = 0;
|
12274
13116
|
this.to_offset = 0;
|
12275
|
-
|
13117
|
+
this.reset();
|
13118
|
+
}
|
13119
|
+
|
13120
|
+
reset() {
|
13121
|
+
// Reset run-dependent properties.
|
13122
|
+
for(let i = 0; i < this.bound_lines.length; i++) {
|
13123
|
+
this.bound_lines[i].current = -1;
|
13124
|
+
}
|
13125
|
+
// Slack information is a "sparse vector" that is filled after solving.
|
12276
13126
|
this.slack_info = {};
|
12277
13127
|
}
|
12278
13128
|
|
@@ -12285,9 +13135,10 @@ class Constraint {
|
|
12285
13135
|
}
|
12286
13136
|
|
12287
13137
|
get identifier() {
|
12288
|
-
// NOTE:
|
12289
|
-
// this prevents problems when nodes are renamed
|
12290
|
-
// constraints have FOUR underscores between node IDs
|
13138
|
+
// NOTE: Constraint IDs are based on the node codes rather than IDs,
|
13139
|
+
// as this prevents problems when nodes are renamed. To ensure ID
|
13140
|
+
// uniqueness, constraints have FOUR underscores between node IDs
|
13141
|
+
// (links have three).
|
12291
13142
|
return this.from_node.code + '____' + this.to_node.code;
|
12292
13143
|
}
|
12293
13144
|
|
@@ -12297,7 +13148,7 @@ class Constraint {
|
|
12297
13148
|
}
|
12298
13149
|
|
12299
13150
|
get attributes() {
|
12300
|
-
// NOTE:
|
13151
|
+
// NOTE: This requires some thought, still!
|
12301
13152
|
const a = {name: this.displayName};
|
12302
13153
|
if(MODEL.infer_cost_prices) {
|
12303
13154
|
a.SOC = this.share_of_cost * this.soc_direction;
|
@@ -12311,16 +13162,16 @@ class Constraint {
|
|
12311
13162
|
}
|
12312
13163
|
|
12313
13164
|
attributeValue(a) {
|
12314
|
-
//
|
12315
|
-
// only A (active) and SOC (share of cost)
|
12316
|
-
if(a === 'A') return this.activeVector; //
|
12317
|
-
// NOTE:
|
12318
|
-
if(a === 'SOC') return this.share_of_cost * this.soc_direction;
|
13165
|
+
// Return the computed result for attribute `a`: for constraints,
|
13166
|
+
// only A (active) and SOC (share of cost).
|
13167
|
+
if(a === 'A') return this.activeVector; // Binary vector - see below.
|
13168
|
+
// NOTE: Negative share indicates Y->X direction of cost sharing.
|
13169
|
+
if(a === 'SOC') return this.share_of_cost * this.soc_direction;
|
12319
13170
|
return null;
|
12320
13171
|
}
|
12321
13172
|
|
12322
13173
|
get setsEquality() {
|
12323
|
-
//
|
13174
|
+
// Return TRUE iff this constraint has an EQ bound line.
|
12324
13175
|
for(let i = 0; i < this.bound_lines.length; i++) {
|
12325
13176
|
if(this.bound_lines[i].type === VM.EQ) return true;
|
12326
13177
|
}
|
@@ -12328,35 +13179,42 @@ class Constraint {
|
|
12328
13179
|
}
|
12329
13180
|
|
12330
13181
|
active(t) {
|
12331
|
-
//
|
13182
|
+
// Return 1 if (X, Y) is on the bound line AND Y is not on its own
|
13183
|
+
// bounds, otherwise 0.
|
12332
13184
|
if(!MODEL.solved) return 0;
|
12333
13185
|
const
|
12334
13186
|
fn = this.from_node,
|
12335
13187
|
tn = this.to_node;
|
12336
13188
|
let lbx = fn.lower_bound.result(t),
|
12337
13189
|
lby = tn.lower_bound.result(t);
|
12338
|
-
// NOTE: LB of semi-continuous processes is 0 if LB > 0
|
13190
|
+
// NOTE: LB of semi-continuous processes is 0 if LB > 0.
|
12339
13191
|
if(lbx > 0 && fn instanceof Process & fn.level_to_zero) lbx = 0;
|
12340
13192
|
if(lby > 0 && tn instanceof Process & tn.level_to_zero) lby = 0;
|
12341
13193
|
const
|
12342
13194
|
rx = fn.upper_bound.result(t) - lbx,
|
12343
13195
|
ry = tn.upper_bound.result(t) - lby;
|
12344
13196
|
// Prevent division by zero: when either range is 0, the constraint
|
12345
|
-
// must be active
|
13197
|
+
// must be active.
|
12346
13198
|
if(rx < VM.NEAR_ZERO || ry < VM.NEAR_ZERO) return 1;
|
12347
13199
|
// Otherwise, convert levels to % of range...
|
12348
13200
|
const
|
12349
13201
|
x = (fn.level[t] - lbx) / rx * 100,
|
12350
13202
|
y = (tn.level[t] - lby) / ry * 100;
|
12351
|
-
// ... and then check whether (%X, %Y) lies on the
|
13203
|
+
// ... and then check whether (%X, %Y) lies on the bound line.
|
12352
13204
|
for(let i = 0; i < this.bound_lines.length; i++) {
|
12353
13205
|
const bl = this.bound_lines[i];
|
12354
|
-
|
13206
|
+
// Bound line does NOT constrain when Y is on its own bound.
|
13207
|
+
if((bl.type === VM.LE && Math.abs(y - 100) < VM.NEAR_ZERO) ||
|
13208
|
+
(bl.type === VM.GE && Math.abs(y) < VM.NEAR_ZERO)) continue;
|
13209
|
+
// Actualize bound line points for current time step.
|
13210
|
+
bl.setDynamicPoints(t);
|
13211
|
+
if(bl.pointOnLine(x, y)) return 1;
|
12355
13212
|
}
|
12356
13213
|
return 0;
|
12357
13214
|
}
|
12358
13215
|
|
12359
13216
|
get activeVector() {
|
13217
|
+
// Return active state for all time steps in the optimization period.
|
12360
13218
|
const v = [];
|
12361
13219
|
for(let t = 0; t < MODEL.runLength + 1; t++) v.push(this.active(t));
|
12362
13220
|
return v;
|
@@ -12490,10 +13348,10 @@ class Constraint {
|
|
12490
13348
|
|
12491
13349
|
addBoundLine() {
|
12492
13350
|
// Adds a new bound line to this constraint, and returns this new line
|
12493
|
-
// NOTE:
|
12494
|
-
// exists and has no
|
13351
|
+
// NOTE: Returns the "base" bound line Y >= 0 (for any X) if it already
|
13352
|
+
// exists and has no associated dataset.
|
12495
13353
|
let bl = this.baseLine;
|
12496
|
-
if(bl &&
|
13354
|
+
if(bl && bl.point_data.length === 0) return bl;
|
12497
13355
|
bl = new BoundLine(this);
|
12498
13356
|
this.bound_lines.push(bl);
|
12499
13357
|
return bl;
|
@@ -12556,6 +13414,7 @@ if(NODE) module.exports = {
|
|
12556
13414
|
BlockMessages: BlockMessages,
|
12557
13415
|
ExperimentRun: ExperimentRun,
|
12558
13416
|
Experiment: Experiment,
|
13417
|
+
BoundlineSelector: BoundlineSelector,
|
12559
13418
|
BoundLine: BoundLine,
|
12560
13419
|
Constraint: Constraint
|
12561
13420
|
};
|