linny-r 1.3.0 → 1.3.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.
@@ -82,6 +82,42 @@ module.exports = class MILPSolver {
82
82
  14: 'Optimization still in progress',
83
83
  15: 'User-specified objective limit has been reached'
84
84
  };
85
+ } else if(this.id === 'scip') {
86
+ this.ext = '.lp';
87
+ this.user_model = path.join(workspace.solver_output, 'usr_model.lp');
88
+ this.solver_model = path.join(workspace.solver_output, 'solver_model.lp');
89
+ this.solution = path.join(workspace.solver_output, 'model.sol');
90
+ this.log = path.join(workspace.solver_output, 'model.log');
91
+ // NOTE: SCIP command line accepts space separated commands ...
92
+ this.args = [
93
+ 'read', this.user_model,
94
+ 'write problem', this.solver_model,
95
+ 'set limit time', 300,
96
+ 'optimize',
97
+ 'write solution', this.solution,
98
+ 'quit'
99
+ ];
100
+ // ... when SCIP is called with -c option; then commands must be
101
+ // enclosed in double quotes; SCIP outputs its messages to the
102
+ // terminal, so these must be caputured in a log file
103
+ this.solve_cmd = 'scip -c "' + this.args.join(' ') + '" >' + this.log;
104
+ this.errors = {
105
+ 1: 'User interrupt',
106
+ 2: 'Node limit reached',
107
+ 3: 'Total node limit reached',
108
+ 4: 'Stalling node limit reached',
109
+ 5: 'Time limit reached',
110
+ 6: 'Memory limit reached',
111
+ 7: 'Gap limit reached',
112
+ 8: 'Solution limit reached',
113
+ 9: 'Solution improvement limit reached',
114
+ 10: 'Restart limit reached',
115
+ 11: 'Optimal solution found',
116
+ 12: 'Problem is infeasible',
117
+ 13: 'Problem is unbounded',
118
+ 14: 'Problem is either infeasible or unbounded',
119
+ 15: 'Solver terminated by user'
120
+ };
85
121
  } else if(this.id === 'lp_solve') {
86
122
  // Execute file commands differ across platforms
87
123
  if(os.platform().startsWith('win')) {
@@ -129,8 +165,12 @@ module.exports = class MILPSolver {
129
165
  error: '',
130
166
  messages: []
131
167
  };
132
- let timeout = parseInt(sp.get('timeout'));
168
+ // Number of columns (= decision variables) is passed to ensure
169
+ // that solution vector is complete and its values are placed in
170
+ // the correct order
171
+ result.columns = parseInt(sp.get('columns')) || 0;
133
172
  // Default timeout per block is 30 seconds
173
+ let timeout = parseInt(sp.get('timeout'));
134
174
  if(isNaN(timeout)) timeout = 30;
135
175
  if(!this.id) {
136
176
  result.status = -999;
@@ -165,19 +205,24 @@ module.exports = class MILPSolver {
165
205
  // (2) the shell option must be set to TRUE (so the command is
166
206
  // executed within an OS shell script) or LP_solve will interpret
167
207
  // the first argument as the model file, and complain
168
- // (3) output must be ignored, as LP_solve will output many warnings
169
- // about 0-value coefficients, and these would otherwise also
208
+ // (3) output must be ignored, as LP_solve warnings should not also
170
209
  // appear on the console
171
210
  // (4) prevent Windows opening a visible sub-process shell window
172
211
  const
173
212
  cmd = this.solve_cmd + ' ' + this.args.join(' '),
174
213
  options = {shell: true, stdio: 'ignore', windowsHide: true};
175
- spawn = child_process.spawnSync(cmd, options);
176
- } else {
214
+ spawn = child_process.spawnSync(cmd, options);
215
+ } else if(this.id === 'gurobi') {
216
+ // When using Gurobi, the standard way with arguments works well
177
217
  this.args[0] = 'TimeLimit=' + timeout;
178
- // When using Gurobi, the standard way works well
179
218
  const options = {windowsHide: true};
180
219
  spawn = child_process.spawnSync(this.solver_path, this.args, options);
220
+ } else if(this.id === 'scip') {
221
+ // When using SCIP, take the LP_solve approach
222
+ const
223
+ cmd = this.solve_cmd.replace(/limit time \d+/, `limit time ${timeout}`),
224
+ options = {shell: true, stdio: 'ignore', windowsHide: true};
225
+ spawn = child_process.spawnSync(cmd, options);
181
226
  }
182
227
  status = spawn.status;
183
228
  } catch(err) {
@@ -200,7 +245,37 @@ module.exports = class MILPSolver {
200
245
 
201
246
  processSolverOutput(result) {
202
247
  // Read solver output files and return solution (or error)
203
- const x_values = [];
248
+ const
249
+ x_values = [],
250
+ x_dict = {},
251
+ getValuesFromDict = () => {
252
+ // Returns a result vector for as many real numbers (as strings!)
253
+ // as there are columns (0 if not reported by the solver)
254
+ // (1) Sort on variable name
255
+ const vlist = Object.keys(x_dict).sort();
256
+ // Start with column 1
257
+ let col = 1;
258
+ for(let i = 0; i < vlist.length; i++) {
259
+ const
260
+ v = vlist[i],
261
+ // Variable names have zero-padded column numbers, e.g. "X001"
262
+ vnr = parseInt(v.substring(1));
263
+ // Add zeros for unreported variables until column number matches
264
+ while(col < vnr) {
265
+ x_values.push('0');
266
+ col++;
267
+ }
268
+ x_values.push(x_dict[v]);
269
+ col++;
270
+ }
271
+ // Add zeros to vector for remaining columns
272
+ while(col <= result.columns) {
273
+ x_values.push('0');
274
+ col++;
275
+ }
276
+ // No return value; function operates on x_values
277
+ };
278
+
204
279
  if(this.id === 'gurobi') {
205
280
  // `messages` must be an array of strings
206
281
  result.messages = fs.readFileSync(this.log, 'utf8').split(os.EOL);
@@ -236,6 +311,60 @@ module.exports = class MILPSolver {
236
311
  result.error = 'No solution found';
237
312
  }
238
313
  }
314
+ } else if(this.id === 'scip') {
315
+ result.seconds = 0;
316
+ // `messages` must be an array of strings
317
+ result.messages = fs.readFileSync(this.log, 'utf8').split(os.EOL);
318
+ let solved = false,
319
+ output = [];
320
+ if(result.status !== 0) {
321
+ // Non-zero solver exit code indicates serious trouble
322
+ result.error = 'SCIP solver terminated with error';
323
+ } else {
324
+ try {
325
+ output = fs.readFileSync(
326
+ this.solution, 'utf8').trim().split(os.EOL);
327
+ } catch(err) {
328
+ console.log('No SCIP solution file');
329
+ }
330
+ // Look in messages for solver status and solving time
331
+ for(let i = 0; i < result.messages.length; i++) {
332
+ const m = result.messages[i];
333
+ if(m.startsWith('SCIP Status')) {
334
+ if(m.indexOf('problem is solved') >= 0) {
335
+ solved = true;
336
+ } else if(m.indexOf('interrupted') >= 0) {
337
+ if(m.indexOf('time limit') >= 0) {
338
+ result.status = 5;
339
+ } else if(m.indexOf('memory limit') >= 0) {
340
+ result.status = 6;
341
+ } else if(m.indexOf('infeasible') >= 0) {
342
+ result.status = (m.indexOf('unbounded') >= 0 ? 14 : 12);
343
+ } else if(m.indexOf('unbounded') >= 0) {
344
+ result.status = 13;
345
+ }
346
+ result.error = this.errors[result.status];
347
+ }
348
+ } else if (m.startsWith('Solving Time')) {
349
+ result.seconds = parseFloat(m.split(':')[1]);
350
+ }
351
+ }
352
+ }
353
+ if(solved) {
354
+ // Look for line with first variable
355
+ let i = 0;
356
+ while(i < output.length && !output[i].startsWith('X')) i++;
357
+ // Fill dictionary with variable name: value entries
358
+ while(i < output.length) {
359
+ const v = output[i].split(/\s+/);
360
+ x_dict[v[0]] = v[1];
361
+ i++;
362
+ }
363
+ // Fill the solution vector, adding 0 for missing columns
364
+ getValuesFromDict();
365
+ } else {
366
+ console.log('No solution found');
367
+ }
239
368
  } else if(this.id === 'lp_solve') {
240
369
  // Read solver messages from file
241
370
  // NOTE: Linny-R client expects a list of strings
@@ -246,7 +375,7 @@ module.exports = class MILPSolver {
246
375
  result.seconds = 0;
247
376
  let i = 0,
248
377
  solved = false;
249
- while(i< output.length && !solved) {
378
+ while(i < output.length && !solved) {
250
379
  msgs.push(output[i]);
251
380
  const m = output[i].match(/in total (\d+\.\d+) seconds/);
252
381
  if(m && m.length > 1) result.seconds = parseFloat(m[1]);
@@ -255,14 +384,16 @@ module.exports = class MILPSolver {
255
384
  }
256
385
  result.messages = msgs;
257
386
  if(solved) {
258
- while(i < output.length && !output[i].startsWith('C1')) i++;
387
+ // Look for line with first variable
388
+ while(i < output.length && !output[i].startsWith('X')) i++;
389
+ // Fill dictionary with variable name: value entries
259
390
  while(i < output.length) {
260
- let v = output[i].replace(/C\d+\s*/, '');
261
- // Remove variable names from result output
262
- v = parseFloat(v);
263
- x_values.push(v);
391
+ const v = output[i].split(/\s+/);
392
+ x_dict[v[0]] = v[1];
264
393
  i++;
265
394
  }
395
+ // Fill the solution vector, adding 0 for missing columns
396
+ getValuesFromDict();
266
397
  } else {
267
398
  console.log('No solution found');
268
399
  }
@@ -277,6 +277,21 @@ class LinnyRModel {
277
277
  return null;
278
278
  }
279
279
 
280
+ productByID(id) {
281
+ if(this.products.hasOwnProperty(id)) return this.products[id];
282
+ return null;
283
+ }
284
+
285
+ processByID(id) {
286
+ if(this.processes.hasOwnProperty(id)) return this.processes[id];
287
+ return null;
288
+ }
289
+
290
+ clusterByID(id) {
291
+ if(this.clusters.hasOwnProperty(id)) return this.clusters[id];
292
+ return null;
293
+ }
294
+
280
295
  nodeBoxByID(id) {
281
296
  if(this.products.hasOwnProperty(id)) return this.products[id];
282
297
  if(this.processes.hasOwnProperty(id)) return this.processes[id];
@@ -1572,7 +1587,7 @@ class LinnyRModel {
1572
1587
  }
1573
1588
  return true;
1574
1589
  }
1575
-
1590
+
1576
1591
  get selectionAsXML() {
1577
1592
  // Returns XML for the selected entities, and also for the entities
1578
1593
  // referenced by expressions for their attributes.
@@ -1595,34 +1610,92 @@ class LinnyRModel {
1595
1610
  from_tos = [],
1596
1611
  xml = [],
1597
1612
  ft_xml = [],
1598
- extra_xml = [];
1613
+ extra_xml = [],
1614
+ selected_xml = [];
1599
1615
  for(let i = 0; i < this.selection.length; i++) {
1600
1616
  const obj = this.selection[i];
1601
1617
  entities[obj.type].push(obj);
1618
+ selected_xml.push('<sel>' + xmlEncoded(obj.displayName) + '</sel>');
1602
1619
  }
1620
+ // Expand clusters by adding all its model entities to their respective
1621
+ // lists
1622
+ for(let i = 0; i < entities.Cluster.length; i++) {
1623
+ const c = entities.Cluster[i];
1624
+ c.clearAllProcesses();
1625
+ c.categorizeEntities();
1626
+ // All processes and products in (sub)clusters must be copied
1627
+ mergeDistinct(c.all_processes, entities.Process);
1628
+ mergeDistinct(c.all_products, entities.Product);
1629
+ // Likewise for all related links and constraints
1630
+ mergeDistinct(c.related_links, entities.Link);
1631
+ mergeDistinct(c.related_constraints, entities.Constraint);
1632
+ // NOTE: add entities referenced by notes within selected clusters
1633
+ // to `extras`, but not the XML for these notes, as this is already
1634
+ // part of the clusters' XML
1635
+ const an = c.allNotes;
1636
+ // Add selected notes as these must also be checked for "extras"
1637
+ mergeDistinct(entities.Note, an);
1638
+ for(let i = 0; i < an.length; i++) {
1639
+ const n = an[i];
1640
+ mergeDistinct(n.color.referencedEntities, extras);
1641
+ for(let i = 0; i < n.fields.length; i++) {
1642
+ addDistinct(n.object, extras);
1643
+ }
1644
+ }
1645
+ }
1646
+ // Only add the XML for notes in the selection
1603
1647
  for(let i = 0; i < entities.Note.length; i++) {
1604
- const n = entities.Note[i];
1605
1648
  xml.push(n.asXML);
1606
1649
  }
1607
1650
  for(let i = 0; i < entities.Product.length; i++) {
1608
- xml.push(entities.Product[i].asXML);
1651
+ const p = entities.Product[i];
1652
+ mergeDistinct(p.lower_bound.referencedEntities, extras);
1653
+ mergeDistinct(p.upper_bound.referencedEntities, extras);
1654
+ mergeDistinct(p.initial_level.referencedEntities, extras);
1655
+ mergeDistinct(p.price.referencedEntities, extras);
1656
+ xml.push(p.asXML);
1609
1657
  }
1610
1658
  for(let i = 0; i < entities.Process.length; i++) {
1611
- xml.push(entities.Process[i].asXML);
1612
- }
1659
+ const p = entities.Process[i];
1660
+ mergeDistinct(p.lower_bound.referencedEntities, extras);
1661
+ mergeDistinct(p.upper_bound.referencedEntities, extras);
1662
+ mergeDistinct(p.initial_level.referencedEntities, extras);
1663
+ mergeDistinct(p.pace_expression.referencedEntities, extras);
1664
+ xml.push(p.asXML);
1665
+ }
1666
+ // Only now add the XML for the selected clusters
1613
1667
  for(let i = 0; i < entities.Cluster.length; i++) {
1614
1668
  xml.push(entities.Cluster[i].asXML);
1615
1669
  }
1670
+ // Add all links that have (implicitly via clusters) been selected
1616
1671
  for(let i = 0; i < entities.Link.length; i++) {
1617
1672
  const l = entities.Link[i];
1618
- if(this.selection.indexOf(l.from_node) < 0) addDistinct(l.from_node, from_tos);
1619
- if(this.selection.indexOf(l.to_node) < 0) addDistinct(l.to_node, from_tos);
1673
+ // NOTE: the FROM and/or TO node need not be selected; if not, put
1674
+ // them in a separate list
1675
+ if(entities.Process.indexOf(l.from_node) < 0 &&
1676
+ entities.Product.indexOf(l.from_node) < 0) {
1677
+ addDistinct(l.from_node, from_tos);
1678
+ }
1679
+ if(entities.Process.indexOf(l.to_node) < 0 &&
1680
+ entities.Product.indexOf(l.to_node) < 0) {
1681
+ addDistinct(l.to_node, from_tos);
1682
+ }
1683
+ mergeDistinct(l.relative_rate.referencedEntities, extras);
1684
+ mergeDistinct(l.flow_delay.referencedEntities, extras);
1620
1685
  xml.push(l.asXML);
1621
1686
  }
1622
1687
  for(let i = 0; i < entities.Constraint.length; i++) {
1623
1688
  const c = entities.Constraint[i];
1624
- if(this.selection.indexOf(c.from_node) < 0) addDistinct(c.from_node, from_tos);
1625
- if(this.selection.indexOf(c.to_node) < 0) addDistinct(c.to_node, from_tos);
1689
+ // NOTE: the FROM and/or TO node need not be selected; if not, put
1690
+ // them in a separate list
1691
+ if(entities.Process.indexOf(c.from_node) < 0 &&
1692
+ entities.Product.indexOf(c.from_node) < 0) {
1693
+ addDistinct(c.from_node, from_tos);
1694
+ }
1695
+ if(entities.Process.indexOf(c.to_node) < 0 &&
1696
+ entities.Product.indexOf(c.to_node) < 0) {
1697
+ addDistinct(c.to_node, from_tos);
1698
+ }
1626
1699
  xml.push(c.asXML);
1627
1700
  }
1628
1701
  for(let i = 0; i < from_tos.length; i++) {
@@ -1631,7 +1704,9 @@ class LinnyRModel {
1631
1704
  if(p instanceof Process) ft_xml.push('" actor-name="', xmlEncoded(p.actor.name));
1632
1705
  ft_xml.push('"></from-to>');
1633
1706
  }
1634
- for(let i = 0; i < extras.length; i++) extra_xml.push(extras[i].asXML);
1707
+ for(let i = 0; i < extras.length; i++) {
1708
+ extra_xml.push(extras[i].asXML);
1709
+ }
1635
1710
  return ['<copy timestamp="', Date.now(),
1636
1711
  '" model-timestamp="', this.time_created.getTime(),
1637
1712
  '" cluster-name="', xmlEncoded(fc_name),
@@ -1639,7 +1714,8 @@ class LinnyRModel {
1639
1714
  '"><entities>', xml.join(''),
1640
1715
  '</entities><from-tos>', ft_xml.join(''),
1641
1716
  '</from-tos><extras>', extra_xml.join(''),
1642
- '</extras></copy>'].join('');
1717
+ '</extras><selection>', selected_xml.join(''),
1718
+ '</selection></copy>'].join('');
1643
1719
  }
1644
1720
 
1645
1721
  dropSelectionIntoCluster(c) {
@@ -4375,7 +4451,10 @@ class Actor {
4375
4451
 
4376
4452
  rename(name) {
4377
4453
  // Change the name of this actor
4378
- name = UI.cleanName(name);
4454
+ // NOTE: since version 1.3.2, colons are prohibited in actor names to
4455
+ // avoid confusion with prefixed entities; they are silently removed
4456
+ // to avoid model compatibility issues
4457
+ name = UI.cleanName(name).replace(':', '');
4379
4458
  if(!UI.validName(name)) {
4380
4459
  UI.warn(UI.WARNING.INVALID_ACTOR_NAME);
4381
4460
  return null;
@@ -5414,6 +5493,7 @@ class Cluster extends NodeBox {
5414
5493
  get asXML() {
5415
5494
  let xml;
5416
5495
  const
5496
+ cmnts = xmlEncoded(this.comments),
5417
5497
  flags = (this.collapsed ? ' collapsed="1"' : '') +
5418
5498
  (this.ignore ? ' ignore="1"' : '') +
5419
5499
  (this.black_box ? ' black-box="1"' : '') +
@@ -5422,7 +5502,8 @@ class Cluster extends NodeBox {
5422
5502
  '</name><owner>', xmlEncoded(this.actor.name),
5423
5503
  '</owner><x-coord>', this.x,
5424
5504
  '</x-coord><y-coord>', this.y,
5425
- '</y-coord><process-set>'].join('');
5505
+ '</y-coord><comments>', cmnts,
5506
+ '</comments><process-set>'].join('');
5426
5507
  for(let i = 0; i < this.processes.length; i++) {
5427
5508
  let n = this.processes[i].displayName;
5428
5509
  const id = UI.nameToID(n);
@@ -5458,6 +5539,7 @@ class Cluster extends NodeBox {
5458
5539
  initFromXML(node) {
5459
5540
  this.x = safeStrToInt(nodeContentByTag(node, 'x-coord'));
5460
5541
  this.y = safeStrToInt(nodeContentByTag(node, 'y-coord'));
5542
+ this.comments = xmlDecoded(nodeContentByTag(node, 'comments'));
5461
5543
  this.collapsed = nodeParameterValue(node, 'collapsed') === '1';
5462
5544
  this.ignore = nodeParameterValue(node, 'ignore') === '1';
5463
5545
  this.black_box = nodeParameterValue(node, 'black-box') === '1';
@@ -5468,8 +5550,8 @@ class Cluster extends NodeBox {
5468
5550
  name,
5469
5551
  actor;
5470
5552
 
5471
- // NOTE: to compensate for shameful bug in earlier version, look for
5472
- // "product-positions" node and for "notes" node in the process-set,
5553
+ // NOTE: to compensate for a shameful bug in an earlier version, look
5554
+ // for "product-positions" node and for "notes" node in the process-set,
5473
5555
  // as it may have been put there instead of in the cluster node itself
5474
5556
  const
5475
5557
  hidden_pp = childNodeByTag(n, 'product-positions'),
@@ -5649,13 +5731,19 @@ class Cluster extends NodeBox {
5649
5731
  addDistinct(this.product_positions[i].product, prods);
5650
5732
  }
5651
5733
  for(let i = 0; i < this.sub_clusters.length; i++) {
5652
- const ap = this.sub_clusters[i].allProducts; // recursion!
5653
- for(let j = 0; j < ap.length; j++) {
5654
- addDistinct(ap[j], prods);
5655
- }
5734
+ mergeDistinct(this.sub_clusters[i].allProducts, prods); // recursion!
5656
5735
  }
5657
5736
  return prods;
5658
5737
  }
5738
+
5739
+ get allNotes() {
5740
+ // Return the set of all notes in this cluster and its subclusters
5741
+ let notes = this.notes.slice();
5742
+ for(let i = 0; i < this.sub_clusters.length; i++) {
5743
+ notes = notes.concat(this.sub_clusters[i].allNotes); // recursion!
5744
+ }
5745
+ return notes;
5746
+ }
5659
5747
 
5660
5748
  clearAllProcesses() {
5661
5749
  // Clear `all_processes` property of this cluster AND of all its parent clusters
@@ -6897,7 +6985,7 @@ class Process extends Node {
6897
6985
  // The production level in time T thus corresponds to decision variable
6898
6986
  // X[Math.floor((T-1) / PACE + 1]
6899
6987
  this.pace = 1;
6900
- this.pace_expression = new Expression(this, 'PACE', '1');
6988
+ this.pace_expression = new Expression(this, 'LCF', '1');
6901
6989
  // NOTE: processes have NO input attributes other than LB, UB and IL
6902
6990
  // for processes, the default bounds are [0, +INF]
6903
6991
  this.equal_bounds = false;
@@ -256,8 +256,9 @@ function markFirstDifference(s1, s2) {
256
256
  //
257
257
 
258
258
  function ciCompare(a, b) {
259
- // Performs case-insensitive comparison
260
- return a.localeCompare(b, undefined, {sensitivity: 'base'});
259
+ // Performs case-insensitive comparison that does differentiate
260
+ // between accented characters (as this differentiates between identifiers)
261
+ return a.localeCompare(b, undefined, {sensitivity: 'accent'});
261
262
  }
262
263
 
263
264
  function endsWithDigits(str) {
@@ -439,6 +440,13 @@ function addDistinct(e, list) {
439
440
  if(list.indexOf(e) < 0) list.push(e);
440
441
  }
441
442
 
443
+ function mergeDistinct(list, into) {
444
+ // Adds elements of `list` to `into` if not already in `into`
445
+ for(let i = 0; i < list.length; i++) {
446
+ addDistinct(list[i], into);
447
+ }
448
+ }
449
+
442
450
  function setString(sl) {
443
451
  // Returns elements of stringlist `sl` in set notation
444
452
  return '{' + sl.join(', ') + '}';