linny-r 1.3.1 → 1.3.3
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/console.js +45 -6
- package/package.json +1 -1
- package/server.js +47 -6
- package/static/index.html +59 -1
- package/static/linny-r.css +56 -0
- package/static/scripts/linny-r-ctrl.js +16 -1
- package/static/scripts/linny-r-gui.js +335 -7
- package/static/scripts/linny-r-milp.js +237 -13
- package/static/scripts/linny-r-model.js +109 -21
- package/static/scripts/linny-r-utils.js +10 -2
- package/static/scripts/linny-r-vm.js +233 -170
@@ -3237,6 +3237,14 @@ class GUIController extends Controller {
|
|
3237
3237
|
this.modals.replace.cancel.addEventListener('click',
|
3238
3238
|
() => UI.modals.replace.hide());
|
3239
3239
|
|
3240
|
+
// The PASTE dialog appears when name conflicts must be resolved
|
3241
|
+
this.paste_modal = new ModalDialog('paste');
|
3242
|
+
this.paste_modal.ok.addEventListener('click',
|
3243
|
+
() => UI.setPasteMapping());
|
3244
|
+
this.paste_modal.cancel.addEventListener('click',
|
3245
|
+
() => UI.paste_modal.hide());
|
3246
|
+
|
3247
|
+
// The CHECK UPDATE dialog appears when a new version is detected
|
3240
3248
|
this.check_update_modal = new ModalDialog('check-update');
|
3241
3249
|
this.check_update_modal.ok.addEventListener('click',
|
3242
3250
|
() => UI.shutDownServer());
|
@@ -5231,7 +5239,6 @@ class GUIController extends Controller {
|
|
5231
5239
|
copySelection() {
|
5232
5240
|
// Save selection as XML in local storage of the browser
|
5233
5241
|
const xml = MODEL.selectionAsXML;
|
5234
|
-
//console.log('HERE copy xml', xml);
|
5235
5242
|
if(xml) {
|
5236
5243
|
window.localStorage.setItem('Linny-R-selection-XML', xml);
|
5237
5244
|
this.updateButtons();
|
@@ -5252,15 +5259,333 @@ class GUIController extends Controller {
|
|
5252
5259
|
return false;
|
5253
5260
|
}
|
5254
5261
|
|
5255
|
-
|
5262
|
+
promptForMapping(mapping) {
|
5263
|
+
// Prompt user to specify name conflict resolution strategy
|
5264
|
+
console.log('HERE prompt for mapping', mapping);
|
5265
|
+
const md = this.paste_modal;
|
5266
|
+
md.mapping = mapping;
|
5267
|
+
md.element('from-prefix').innerText = mapping.from_prefix || '';
|
5268
|
+
md.element('to-prefix').innerText = mapping.to_prefix || '';
|
5269
|
+
md.element('ftp').style.display = (mapping.from_prefix ? 'block' : 'none');
|
5270
|
+
md.element('from-actor').innerText = mapping.from_actor || '';
|
5271
|
+
md.element('to-actor').innerText = mapping.to_actor || '';
|
5272
|
+
md.element('fta').style.display = (mapping.from_actor ? 'block' : 'none');
|
5273
|
+
md.element('actor').value = mapping.actor || '';
|
5274
|
+
md.element('prefix').value = mapping.prefix || '';
|
5275
|
+
const
|
5276
|
+
ft = Object.keys(mapping.from_to).sort(ciCompare),
|
5277
|
+
sl = [];
|
5278
|
+
if(ft.length) {
|
5279
|
+
// Add selectors for unresolved FROM/TO nodes
|
5280
|
+
for(let i = 0; i < ft.length; i++) {
|
5281
|
+
const ti = mapping.from_to[ft[i]];
|
5282
|
+
if(ft[i] === ti) {
|
5283
|
+
sl.push('<div class="paste-option"><span>', ft[i], '</span> ',
|
5284
|
+
'<div class="paste-select"><select id="paste-ft-', i,
|
5285
|
+
'" style="font-size: 12px"><option value="', ti, '">', ti,
|
5286
|
+
'</option></select></div></div>');
|
5287
|
+
}
|
5288
|
+
}
|
5289
|
+
md.element('scroll-area').innerHTML = sl.join('');
|
5290
|
+
}
|
5291
|
+
// Open dialog, which will call pasteSelection(...) on OK
|
5292
|
+
this.paste_modal.show();
|
5293
|
+
}
|
5294
|
+
|
5295
|
+
setPasteMapping() {
|
5296
|
+
// Updates the paste mapping as specified by the modeler and then
|
5297
|
+
// proceeds to paste
|
5298
|
+
const
|
5299
|
+
md = this.paste_modal,
|
5300
|
+
mapping = Object.assign(md.mapping, {});
|
5301
|
+
mapping.actor = md.element('actor').value;
|
5302
|
+
mapping.prefix = md.element('prefix').value.trim();
|
5303
|
+
mapping.increment = true;
|
5304
|
+
this.pasteSelection(mapping);
|
5305
|
+
}
|
5306
|
+
|
5307
|
+
pasteSelection(mapping={}) {
|
5256
5308
|
// If selection has been saved as XML in local storage, test to
|
5257
5309
|
// see whether PASTE would result in name conflicts, and if so,
|
5258
5310
|
// open the name conflict resolution window
|
5259
|
-
|
5260
|
-
|
5261
|
-
|
5262
|
-
|
5311
|
+
let xml = window.localStorage.getItem('Linny-R-selection-XML');
|
5312
|
+
console.log('HERE xml', xml);
|
5313
|
+
try {
|
5314
|
+
xml = parseXML(xml);
|
5315
|
+
} catch(e) {
|
5316
|
+
console.log(e);
|
5317
|
+
this.alert('Paste failed due to invalid XML');
|
5318
|
+
return;
|
5263
5319
|
}
|
5320
|
+
|
5321
|
+
// For now, while still implementing:
|
5322
|
+
this.notify('Paste not fully implemented yet -- WORK IN PROGRESS!');
|
5323
|
+
|
5324
|
+
const
|
5325
|
+
entities_node = childNodeByTag(xml, 'entities'),
|
5326
|
+
from_tos_node = childNodeByTag(xml, 'from-tos'),
|
5327
|
+
extras_node = childNodeByTag(xml, 'extras'),
|
5328
|
+
selection_node = childNodeByTag(xml, 'selection'),
|
5329
|
+
actor_names = [],
|
5330
|
+
new_entities = [],
|
5331
|
+
name_map = {},
|
5332
|
+
name_conflicts = [];
|
5333
|
+
|
5334
|
+
// AUXILIARY FUNCTIONS
|
5335
|
+
|
5336
|
+
function fullName(node) {
|
5337
|
+
// Returns full entity name inferred from XML node data
|
5338
|
+
if(node.nodeName === 'from-to') {
|
5339
|
+
const
|
5340
|
+
n = xmlDecoded(nodeParameterValue(node, 'name')),
|
5341
|
+
an = xmlDecoded(nodeParameterValue(node, 'actor-name'));
|
5342
|
+
if(an && an !== UI.NO_ACTOR) {
|
5343
|
+
addDistinct(an, actor_names);
|
5344
|
+
return `${n} (${an})`;
|
5345
|
+
}
|
5346
|
+
return n;
|
5347
|
+
}
|
5348
|
+
if(node.nodeName !== 'link' && node.nodeName !== 'constraint') {
|
5349
|
+
const
|
5350
|
+
n = xmlDecoded(nodeContentByTag(node, 'name')),
|
5351
|
+
an = xmlDecoded(nodeContentByTag(node, 'actor-name'));
|
5352
|
+
if(an && an !== UI.NO_ACTOR) {
|
5353
|
+
addDistinct(an, actor_names);
|
5354
|
+
return `${n} (${an})`;
|
5355
|
+
}
|
5356
|
+
return n;
|
5357
|
+
} else {
|
5358
|
+
let fn = xmlDecoded(nodeContentByTag(node, 'from-name')),
|
5359
|
+
fa = xmlDecoded(nodeContentByTag(node, 'from-owner')),
|
5360
|
+
tn = xmlDecoded(nodeContentByTag(node, 'to-name')),
|
5361
|
+
ta = xmlDecoded(nodeContentByTag(node, 'to-owner')),
|
5362
|
+
arrow = (node.nodeName === 'link' ? UI.LINK_ARROW : UI.CONSTRAINT_ARROW);
|
5363
|
+
if(fa && fa !== UI.NO_ACTOR) {
|
5364
|
+
addDistinct(fa, actor_names);
|
5365
|
+
fn = `${fn} (${fa})`;
|
5366
|
+
}
|
5367
|
+
if(ta && ta !== UI.NO_ACTOR) {
|
5368
|
+
addDistinct(ta, actor_names);
|
5369
|
+
tn = `${tn} (${ta})`;
|
5370
|
+
}
|
5371
|
+
return `${fn}${arrow}${tn}`;
|
5372
|
+
}
|
5373
|
+
}
|
5374
|
+
|
5375
|
+
function nameAndActor(name) {
|
5376
|
+
// Returns tuple [entity name, actor name] if `name` ends with
|
5377
|
+
// a parenthesized string that identifies an actor in the selection
|
5378
|
+
const ai = name.lastIndexOf(' (');
|
5379
|
+
if(ai < 0) return [name, ''];
|
5380
|
+
let actor = name.slice(ai + 2, -1);
|
5381
|
+
// Test whether parenthesized string denotes an actor
|
5382
|
+
if(actor_names.indexOf(actor) >= 0 || actor === mapping.actor ||
|
5383
|
+
actor === mapping.from_actor || actor === mapping.to_actor) {
|
5384
|
+
name = name.substring(0, ai);
|
5385
|
+
} else {
|
5386
|
+
actor = '';
|
5387
|
+
}
|
5388
|
+
return [name, actor];
|
5389
|
+
}
|
5390
|
+
|
5391
|
+
function mappedName(n) {
|
5392
|
+
// Returns full name `n` modified according to the mapping
|
5393
|
+
// NOTE: links and constraints require two mappings (recursion!)
|
5394
|
+
if(n.indexOf(UI.LINK_ARROW) > 0) {
|
5395
|
+
const ft = n.split(UI.LINK_ARROW);
|
5396
|
+
return mappedName(ft[0]) + UI.LINK_ARROW + mappedName(ft[1]);
|
5397
|
+
}
|
5398
|
+
if(n.indexOf(UI.CONSTRAINT_ARROW) > 0) {
|
5399
|
+
const ft = n.split(UI.CONSTRAINT_ARROW);
|
5400
|
+
return mappedName(ft[0]) + UI.CONSTRAINT_ARROW + mappedName(ft[1]);
|
5401
|
+
}
|
5402
|
+
// Mapping precedence order:
|
5403
|
+
// (1) prefix inherited from cluster
|
5404
|
+
// (2) actor name inherited from cluster
|
5405
|
+
// (3) actor name specified by modeler
|
5406
|
+
// (4) prefix specified by modeler
|
5407
|
+
// (5) auto-increment tail number
|
5408
|
+
// (6) nearest eligible node
|
5409
|
+
if(mapping.from_prefix && n.startsWith(mapping.from_prefix)) {
|
5410
|
+
return n.replace(mapping.from_prefix, mapping.to_prefix);
|
5411
|
+
}
|
5412
|
+
if(mapping.from_actor) {
|
5413
|
+
const ai = n.lastIndexOf(mapping.from_actor);
|
5414
|
+
if(ai > 0) return n.substring(0, ai) + mapping.to_actor;
|
5415
|
+
}
|
5416
|
+
// NOTE: specified actor cannot override existing actor
|
5417
|
+
if(mapping.actor && !nameAndActor(n)[1]) {
|
5418
|
+
return `${n} (${mapping.actor})`;
|
5419
|
+
}
|
5420
|
+
if(mapping.prefix) {
|
5421
|
+
return mapping.prefix + UI.PREFIXER + n;
|
5422
|
+
}
|
5423
|
+
let nr = endsWithDigits(n);
|
5424
|
+
if(mapping.increment && nr) {
|
5425
|
+
return n.replace(new RegExp(nr + '$'), parseInt(nr) + 1);
|
5426
|
+
}
|
5427
|
+
// No mapping => return original name
|
5428
|
+
return n;
|
5429
|
+
}
|
5430
|
+
|
5431
|
+
function nameConflicts(node) {
|
5432
|
+
// Maps names of entities defined by the child nodes of `node`
|
5433
|
+
// while detecting name conflicts
|
5434
|
+
for(let i = 0; i < node.childNodes.length; i++) {
|
5435
|
+
const c = node.childNodes[i];
|
5436
|
+
if(c.nodeName !== 'link' && c.nodeName !== 'constraint') {
|
5437
|
+
const
|
5438
|
+
fn = fullName(c),
|
5439
|
+
mn = mappedName(fn);
|
5440
|
+
// Name conflict occurs when the mapped name is already in use
|
5441
|
+
// in the target model, or when the original name is mapped onto
|
5442
|
+
// different names (this might occur due to modeler input)
|
5443
|
+
if(MODEL.objectByName(mn) || (name_map[fn] && name_map[fn] !== mn)) {
|
5444
|
+
addDistinct(fn, name_conflicts);
|
5445
|
+
} else {
|
5446
|
+
name_map[fn] = mn;
|
5447
|
+
}
|
5448
|
+
}
|
5449
|
+
}
|
5450
|
+
}
|
5451
|
+
|
5452
|
+
function addEntityFromNode(node) {
|
5453
|
+
// Adds entity to model based on XML node data and mapping
|
5454
|
+
// NOTE: do not add if an entity having this type and mapped name
|
5455
|
+
// already exists; name conflicts accross entity types may occur
|
5456
|
+
// and result in error messages
|
5457
|
+
const
|
5458
|
+
et = node.nodeName,
|
5459
|
+
fn = fullName(node),
|
5460
|
+
mn = mappedName(fn);
|
5461
|
+
let obj;
|
5462
|
+
if(et === 'process' && !MODEL.processByID(UI.nameToID(mn))) {
|
5463
|
+
const
|
5464
|
+
na = nameAndActor(mn),
|
5465
|
+
new_actor = !MODEL.actorByID(UI.nameToID(na[1]));
|
5466
|
+
obj = MODEL.addProcess(na[0], na[1], node);
|
5467
|
+
if(obj) {
|
5468
|
+
obj.code = '';
|
5469
|
+
obj.setCode();
|
5470
|
+
if(new_actor) new_entities.push(obj.actor);
|
5471
|
+
new_entities.push(obj);
|
5472
|
+
}
|
5473
|
+
} else if(et === 'product' && !MODEL.productByID(UI.nameToID(mn))) {
|
5474
|
+
obj = MODEL.addProduct(mn, node);
|
5475
|
+
if(obj) {
|
5476
|
+
obj.code = '';
|
5477
|
+
obj.setCode();
|
5478
|
+
new_entities.push(obj);
|
5479
|
+
}
|
5480
|
+
} else if(et === 'cluster' && !MODEL.clusterByID(UI.nameToID(mn))) {
|
5481
|
+
const
|
5482
|
+
na = nameAndActor(mn),
|
5483
|
+
new_actor = !MODEL.actorByID(UI.nameToID(na[1]));
|
5484
|
+
obj = MODEL.addCluster(na[0], na[1], node);
|
5485
|
+
if(obj) {
|
5486
|
+
if(new_actor) new_entities.push(obj.actor);
|
5487
|
+
new_entities.push(obj);
|
5488
|
+
}
|
5489
|
+
} else if(et === 'dataset' && !MODEL.datasetByID(UI.nameToID(mn))) {
|
5490
|
+
obj = MODEL.addDataset(mn, node);
|
5491
|
+
if(obj) new_entities.push(obj);
|
5492
|
+
} else if(et === 'link' || et === 'constraint') {
|
5493
|
+
const
|
5494
|
+
ft = mn.split(et === 'link' ? UI.LINK_ARROW : UI.CONSTRAINT_ARROW),
|
5495
|
+
fl = MODEL.objectByName(ft[0]),
|
5496
|
+
tl = MODEL.objectByName(ft[1]);
|
5497
|
+
if(fl && tl) {
|
5498
|
+
obj = (et === 'link' ?
|
5499
|
+
MODEL.addLink(fl, tl, node) :
|
5500
|
+
MODEL.addConstraint(fl, tl, node));
|
5501
|
+
if(obj) new_entities.push(obj);
|
5502
|
+
} else {
|
5503
|
+
UI.alert(`Failed to paste ${et} ${fn} as ${mn}`);
|
5504
|
+
}
|
5505
|
+
}
|
5506
|
+
}
|
5507
|
+
|
5508
|
+
const
|
5509
|
+
mts = nodeParameterValue(xml, 'model-timestamp'),
|
5510
|
+
cn = nodeParameterValue(xml, 'cluster-name'),
|
5511
|
+
ca = nodeParameterValue(xml, 'cluster-actor'),
|
5512
|
+
fc = MODEL.focal_cluster,
|
5513
|
+
fcn = fc.name,
|
5514
|
+
fca = fc.actor.name,
|
5515
|
+
sp = this.sharedPrefix(cn, fcn),
|
5516
|
+
fpn = (cn === UI.TOP_CLUSTER_NAME ? '' : cn.replace(sp, '')),
|
5517
|
+
tpn = (fcn === UI.TOP_CLUSTER_NAME ? '' : fcn.replace(sp, ''));
|
5518
|
+
// Infer mapping from XML data and focal cluster name & actor name
|
5519
|
+
mapping.shared_prefix = sp;
|
5520
|
+
mapping.from_prefix = (fpn ? sp + fpn + UI.PREFIXER : sp);
|
5521
|
+
mapping.to_prefix = (tpn ? sp + tpn + UI.PREFIXER : sp);
|
5522
|
+
mapping.from_actor = (ca === UI.NO_ACTOR ? '' : ca);
|
5523
|
+
mapping.to_actor = (fca === UI.NO_ACTOR ? '' : fca);
|
5524
|
+
// Prompt for mapping when pasting to the same model and cluster
|
5525
|
+
if(parseInt(mts) === MODEL.time_created.getTime() &&
|
5526
|
+
ca === fca && mapping.from_prefix === mapping.to_prefix &&
|
5527
|
+
!(mapping.prefix || mapping.actor || mapping.increment)) {
|
5528
|
+
this.promptForMapping(mapping);
|
5529
|
+
return;
|
5530
|
+
}
|
5531
|
+
// Also prompt if FROM and/or TO nodes are not selected, and map to
|
5532
|
+
// existing entities
|
5533
|
+
if(from_tos_node.childNodes.length && !mapping.from_to) {
|
5534
|
+
const ft_map = {};
|
5535
|
+
for(let i = 0; i < from_tos_node.childNodes.length; i++) {
|
5536
|
+
const
|
5537
|
+
c = from_tos_node.childNodes[i],
|
5538
|
+
fn = fullName(c),
|
5539
|
+
mn = mappedName(fn);
|
5540
|
+
if(MODEL.objectByName(mn)) ft_map[fn] = mn;
|
5541
|
+
}
|
5542
|
+
// Prompt only for FROM/TO nodes that map to existing nodes
|
5543
|
+
if(Object.keys(ft_map).length) {
|
5544
|
+
mapping.from_to = ft_map;
|
5545
|
+
this.promptForMapping(mapping);
|
5546
|
+
return;
|
5547
|
+
}
|
5548
|
+
}
|
5549
|
+
|
5550
|
+
// Only check for selected entities and from-to's; extra's should be
|
5551
|
+
// used if they exist, or should be created when copying to a different
|
5552
|
+
// model
|
5553
|
+
name_map.length = 0;
|
5554
|
+
nameConflicts(entities_node);
|
5555
|
+
nameConflicts(from_tos_node);
|
5556
|
+
if(name_conflicts.length) {
|
5557
|
+
UI.notify(pluralS(name_conflicts.length, 'name conflict'));
|
5558
|
+
console.log('HERE name conflicts', name_conflicts);
|
5559
|
+
return;
|
5560
|
+
}
|
5561
|
+
|
5562
|
+
// No conflicts => add all
|
5563
|
+
console.log('HERE name map', name_map);
|
5564
|
+
for(let i = 0; i < extras_node.childNodes.length; i++) {
|
5565
|
+
addEntityFromNode(extras_node.childNodes[i]);
|
5566
|
+
}
|
5567
|
+
for(let i = 0; i < from_tos_node.childNodes.length; i++) {
|
5568
|
+
addEntityFromNode(from_tos_node.childNodes[i]);
|
5569
|
+
}
|
5570
|
+
for(let i = 0; i < entities_node.childNodes.length; i++) {
|
5571
|
+
addEntityFromNode(entities_node.childNodes[i]);
|
5572
|
+
}
|
5573
|
+
// Update diagram, showing newly added nodes as selection
|
5574
|
+
MODEL.clearSelection();
|
5575
|
+
for(let i = 0; i < selection_node.childNodes.length; i++) {
|
5576
|
+
const
|
5577
|
+
n = xmlDecoded(nodeContent(selection_node.childNodes[i])),
|
5578
|
+
obj = MODEL.objectByName(mappedName(n));
|
5579
|
+
if(obj) {
|
5580
|
+
// NOTE: selected products must be positioned
|
5581
|
+
if(obj instanceof Product) MODEL.focal_cluster.addProductPosition(obj);
|
5582
|
+
MODEL.select(obj);
|
5583
|
+
}
|
5584
|
+
}
|
5585
|
+
// Force redrawing the selection to ensure that links to positioned
|
5586
|
+
// products are displayed as arrows instead of block arrows
|
5587
|
+
fc.clearAllProcesses();
|
5588
|
+
UI.drawDiagram(MODEL);
|
5264
5589
|
}
|
5265
5590
|
|
5266
5591
|
//
|
@@ -6235,6 +6560,7 @@ UI.logHeapSize(`BEFORE creating post data`);
|
|
6235
6560
|
token: VM.solver_token,
|
6236
6561
|
block: VM.block_count,
|
6237
6562
|
round: VM.round_sequence[VM.current_round],
|
6563
|
+
columns: VM.columnsInBlock,
|
6238
6564
|
data: VM.lines,
|
6239
6565
|
timeout: top
|
6240
6566
|
});
|
@@ -7687,7 +8013,9 @@ class ActorManager {
|
|
7687
8013
|
xp = new ExpressionParser(x);
|
7688
8014
|
if(n !== UI.NO_ACTOR) {
|
7689
8015
|
nn = this.actor_name.value.trim();
|
7690
|
-
|
8016
|
+
// NOTE: prohibit colons in actor names to avoid confusion with
|
8017
|
+
// prefixed entities
|
8018
|
+
if(!UI.validName(nn) || nn.indexOf(':') >= 0) {
|
7691
8019
|
UI.warn(UI.WARNING.INVALID_ACTOR_NAME);
|
7692
8020
|
return false;
|
7693
8021
|
}
|
@@ -82,6 +82,63 @@ 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 === 'cplex') {
|
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
|
+
// NOTE: CPLEX log file is located in the Linny-R working directory
|
91
|
+
this.log = path.join(workspace.solver_output, 'cplex.log');
|
92
|
+
// NOTE: CPLEX command line accepts space separated commands ...
|
93
|
+
this.args = [
|
94
|
+
'read ' + this.user_model,
|
95
|
+
'write ' + this.solver_model + ' lp',
|
96
|
+
'set timelimit 300',
|
97
|
+
'optimize',
|
98
|
+
'write ' + this.solution + ' 0',
|
99
|
+
'quit'
|
100
|
+
];
|
101
|
+
// ... when CPLEX is called with -c option; then each command must be
|
102
|
+
// enclosed in double quotes; SCIP outputs its messages to a log file
|
103
|
+
// terminal, so these must be caputured in a log file
|
104
|
+
this.solve_cmd = 'cplex -c "' + this.args.join('" "') + '"';
|
105
|
+
this.errors = {};
|
106
|
+
} else if(this.id === 'scip') {
|
107
|
+
this.ext = '.lp';
|
108
|
+
this.user_model = path.join(workspace.solver_output, 'usr_model.lp');
|
109
|
+
this.solver_model = path.join(workspace.solver_output, 'solver_model.lp');
|
110
|
+
this.solution = path.join(workspace.solver_output, 'model.sol');
|
111
|
+
this.log = path.join(workspace.solver_output, 'model.log');
|
112
|
+
// NOTE: SCIP command line accepts space separated commands ...
|
113
|
+
this.args = [
|
114
|
+
'read', this.user_model,
|
115
|
+
'write problem', this.solver_model,
|
116
|
+
'set limit time', 300,
|
117
|
+
'optimize',
|
118
|
+
'write solution', this.solution,
|
119
|
+
'quit'
|
120
|
+
];
|
121
|
+
// ... when SCIP is called with -c option; then commands must be
|
122
|
+
// enclosed in double quotes; SCIP outputs its messages to the
|
123
|
+
// terminal, so these must be caputured in a log file
|
124
|
+
this.solve_cmd = 'scip -c "' + this.args.join(' ') + '" >' + this.log;
|
125
|
+
this.errors = {
|
126
|
+
1: 'User interrupt',
|
127
|
+
2: 'Node limit reached',
|
128
|
+
3: 'Total node limit reached',
|
129
|
+
4: 'Stalling node limit reached',
|
130
|
+
5: 'Time limit reached',
|
131
|
+
6: 'Memory limit reached',
|
132
|
+
7: 'Gap limit reached',
|
133
|
+
8: 'Solution limit reached',
|
134
|
+
9: 'Solution improvement limit reached',
|
135
|
+
10: 'Restart limit reached',
|
136
|
+
11: 'Optimal solution found',
|
137
|
+
12: 'Problem is infeasible',
|
138
|
+
13: 'Problem is unbounded',
|
139
|
+
14: 'Problem is either infeasible or unbounded',
|
140
|
+
15: 'Solver terminated by user'
|
141
|
+
};
|
85
142
|
} else if(this.id === 'lp_solve') {
|
86
143
|
// Execute file commands differ across platforms
|
87
144
|
if(os.platform().startsWith('win')) {
|
@@ -129,8 +186,12 @@ module.exports = class MILPSolver {
|
|
129
186
|
error: '',
|
130
187
|
messages: []
|
131
188
|
};
|
132
|
-
|
189
|
+
// Number of columns (= decision variables) is passed to ensure
|
190
|
+
// that solution vector is complete and its values are placed in
|
191
|
+
// the correct order
|
192
|
+
result.columns = parseInt(sp.get('columns')) || 0;
|
133
193
|
// Default timeout per block is 30 seconds
|
194
|
+
let timeout = parseInt(sp.get('timeout'));
|
134
195
|
if(isNaN(timeout)) timeout = 30;
|
135
196
|
if(!this.id) {
|
136
197
|
result.status = -999;
|
@@ -165,19 +226,36 @@ module.exports = class MILPSolver {
|
|
165
226
|
// (2) the shell option must be set to TRUE (so the command is
|
166
227
|
// executed within an OS shell script) or LP_solve will interpret
|
167
228
|
// the first argument as the model file, and complain
|
168
|
-
// (3) output must be ignored, as LP_solve
|
169
|
-
// about 0-value coefficients, and these would otherwise also
|
229
|
+
// (3) output must be ignored, as LP_solve warnings should not also
|
170
230
|
// appear on the console
|
171
231
|
// (4) prevent Windows opening a visible sub-process shell window
|
172
232
|
const
|
173
233
|
cmd = this.solve_cmd + ' ' + this.args.join(' '),
|
174
234
|
options = {shell: true, stdio: 'ignore', windowsHide: true};
|
175
|
-
spawn = child_process.spawnSync(cmd,
|
176
|
-
} else {
|
235
|
+
spawn = child_process.spawnSync(cmd, options);
|
236
|
+
} else if(this.id === 'gurobi') {
|
237
|
+
// When using Gurobi, the standard way with arguments works well
|
177
238
|
this.args[0] = 'TimeLimit=' + timeout;
|
178
|
-
// When using Gurobi, the standard way works well
|
179
239
|
const options = {windowsHide: true};
|
180
240
|
spawn = child_process.spawnSync(this.solver_path, this.args, options);
|
241
|
+
} else if(this.id === 'cplex') {
|
242
|
+
// Delete previous solver model file (if any)
|
243
|
+
try {
|
244
|
+
if(this.solver_model) fs.unlinkSync(this.solver_model);
|
245
|
+
} catch(err) {
|
246
|
+
// Ignore error
|
247
|
+
}
|
248
|
+
// Spawn using the LP_solve approach
|
249
|
+
const
|
250
|
+
cmd = this.solve_cmd.replace(/timelimit \d+/, `timelimit ${timeout}`),
|
251
|
+
options = {shell: true, cwd: 'user/solver', stdio: 'ignore', windowsHide: true};
|
252
|
+
spawn = child_process.spawnSync(cmd, options);
|
253
|
+
} else if(this.id === 'scip') {
|
254
|
+
// When using SCIP, take the LP_solve approach
|
255
|
+
const
|
256
|
+
cmd = this.solve_cmd.replace(/limit time \d+/, `limit time ${timeout}`),
|
257
|
+
options = {shell: true, stdio: 'ignore', windowsHide: true};
|
258
|
+
spawn = child_process.spawnSync(cmd, options);
|
181
259
|
}
|
182
260
|
status = spawn.status;
|
183
261
|
} catch(err) {
|
@@ -200,7 +278,37 @@ module.exports = class MILPSolver {
|
|
200
278
|
|
201
279
|
processSolverOutput(result) {
|
202
280
|
// Read solver output files and return solution (or error)
|
203
|
-
const
|
281
|
+
const
|
282
|
+
x_values = [],
|
283
|
+
x_dict = {},
|
284
|
+
getValuesFromDict = () => {
|
285
|
+
// Returns a result vector for as many real numbers (as strings!)
|
286
|
+
// as there are columns (0 if not reported by the solver)
|
287
|
+
// (1) Sort on variable name
|
288
|
+
const vlist = Object.keys(x_dict).sort();
|
289
|
+
// Start with column 1
|
290
|
+
let col = 1;
|
291
|
+
for(let i = 0; i < vlist.length; i++) {
|
292
|
+
const
|
293
|
+
v = vlist[i],
|
294
|
+
// Variable names have zero-padded column numbers, e.g. "X001"
|
295
|
+
vnr = parseInt(v.substring(1));
|
296
|
+
// Add zeros for unreported variables until column number matches
|
297
|
+
while(col < vnr) {
|
298
|
+
x_values.push('0');
|
299
|
+
col++;
|
300
|
+
}
|
301
|
+
x_values.push(x_dict[v]);
|
302
|
+
col++;
|
303
|
+
}
|
304
|
+
// Add zeros to vector for remaining columns
|
305
|
+
while(col <= result.columns) {
|
306
|
+
x_values.push('0');
|
307
|
+
col++;
|
308
|
+
}
|
309
|
+
// No return value; function operates on x_values
|
310
|
+
};
|
311
|
+
|
204
312
|
if(this.id === 'gurobi') {
|
205
313
|
// `messages` must be an array of strings
|
206
314
|
result.messages = fs.readFileSync(this.log, 'utf8').split(os.EOL);
|
@@ -236,6 +344,120 @@ module.exports = class MILPSolver {
|
|
236
344
|
result.error = 'No solution found';
|
237
345
|
}
|
238
346
|
}
|
347
|
+
} else if(this.id === 'cplex') {
|
348
|
+
result.seconds = 0;
|
349
|
+
const
|
350
|
+
msg = fs.readFileSync(this.log, 'utf8'),
|
351
|
+
no_license = (msg.indexOf('No license found') >= 0);
|
352
|
+
// `messages` must be an array of strings
|
353
|
+
result.messages = msg.split(os.EOL);
|
354
|
+
let solved = false,
|
355
|
+
output = [];
|
356
|
+
if(no_license) {
|
357
|
+
result.error = 'Too many variables for unlicensed CPLEX solver';
|
358
|
+
result.status = -13;
|
359
|
+
} else if(result.status !== 0) {
|
360
|
+
// Non-zero solver exit code indicates serious trouble
|
361
|
+
result.error = 'CPLEX solver terminated with error';
|
362
|
+
result.status = -13;
|
363
|
+
} else {
|
364
|
+
try {
|
365
|
+
output = fs.readFileSync(this.solution, 'utf8').trim();
|
366
|
+
if(output.indexOf('CPLEXSolution') >= 0) {
|
367
|
+
solved = true;
|
368
|
+
output = output.split(os.EOL);
|
369
|
+
}
|
370
|
+
} catch(err) {
|
371
|
+
console.log('No CPLEX solution file');
|
372
|
+
}
|
373
|
+
}
|
374
|
+
if(solved) {
|
375
|
+
// CPLEX saves solution as XML, but for now just extract the
|
376
|
+
// status and then the variables
|
377
|
+
let i = 0;
|
378
|
+
while(i < output.length) {
|
379
|
+
const s = output[i].split('"');
|
380
|
+
if(s[0].indexOf('objectiveValue') >= 0) {
|
381
|
+
result.obj = s[1];
|
382
|
+
} else if(s[0].indexOf('solutionStatusValue') >= 0) {
|
383
|
+
result.status = s[1];
|
384
|
+
} else if(s[0].indexOf('solutionStatusString') >= 0) {
|
385
|
+
result.error = s[1];
|
386
|
+
break;
|
387
|
+
}
|
388
|
+
i++;
|
389
|
+
}
|
390
|
+
if(['1', '101', '102'].indexOf(result.status) >= 0) {
|
391
|
+
result.status = 0;
|
392
|
+
result.error = '';
|
393
|
+
}
|
394
|
+
// Fill dictionary with variable name: value entries
|
395
|
+
while(i < output.length) {
|
396
|
+
const m = output[i].match(/^.*name="(X[^"]+)".*value="([^"]+)"/);
|
397
|
+
if(m !== null) x_dict[m[1]] = m[2];
|
398
|
+
i++;
|
399
|
+
}
|
400
|
+
// Fill the solution vector, adding 0 for missing columns
|
401
|
+
getValuesFromDict();
|
402
|
+
} else {
|
403
|
+
console.log('No solution found');
|
404
|
+
}
|
405
|
+
} else if(this.id === 'scip') {
|
406
|
+
result.seconds = 0;
|
407
|
+
// `messages` must be an array of strings
|
408
|
+
result.messages = fs.readFileSync(this.log, 'utf8').split(os.EOL);
|
409
|
+
let solved = false,
|
410
|
+
output = [];
|
411
|
+
if(result.status !== 0) {
|
412
|
+
// Non-zero solver exit code indicates serious trouble
|
413
|
+
result.error = 'SCIP solver terminated with error';
|
414
|
+
} else {
|
415
|
+
try {
|
416
|
+
output = fs.readFileSync(
|
417
|
+
this.solution, 'utf8').trim().split(os.EOL);
|
418
|
+
} catch(err) {
|
419
|
+
console.log('No SCIP solution file');
|
420
|
+
}
|
421
|
+
// Look in messages for solver status and solving time
|
422
|
+
for(let i = 0; i < result.messages.length; i++) {
|
423
|
+
const m = result.messages[i];
|
424
|
+
if(m.startsWith('SCIP Status')) {
|
425
|
+
if(m.indexOf('problem is solved') >= 0) {
|
426
|
+
if(m.indexOf('infeasible') >= 0) {
|
427
|
+
result.status = (m.indexOf('unbounded') >= 0 ? 14 : 12);
|
428
|
+
} else if(m.indexOf('unbounded') >= 0) {
|
429
|
+
result.status = 13;
|
430
|
+
} else {
|
431
|
+
solved = true;
|
432
|
+
}
|
433
|
+
} else if(m.indexOf('interrupted') >= 0) {
|
434
|
+
if(m.indexOf('time limit') >= 0) {
|
435
|
+
result.status = 5;
|
436
|
+
} else if(m.indexOf('memory limit') >= 0) {
|
437
|
+
result.status = 6;
|
438
|
+
}
|
439
|
+
}
|
440
|
+
if(result.status) result.error = this.errors[result.status];
|
441
|
+
} else if (m.startsWith('Solving Time')) {
|
442
|
+
result.seconds = parseFloat(m.split(':')[1]);
|
443
|
+
}
|
444
|
+
}
|
445
|
+
}
|
446
|
+
if(solved) {
|
447
|
+
// Look for line with first variable
|
448
|
+
let i = 0;
|
449
|
+
while(i < output.length && !output[i].startsWith('X')) i++;
|
450
|
+
// Fill dictionary with variable name: value entries
|
451
|
+
while(i < output.length) {
|
452
|
+
const v = output[i].split(/\s+/);
|
453
|
+
x_dict[v[0]] = v[1];
|
454
|
+
i++;
|
455
|
+
}
|
456
|
+
// Fill the solution vector, adding 0 for missing columns
|
457
|
+
getValuesFromDict();
|
458
|
+
} else {
|
459
|
+
console.log('No solution found');
|
460
|
+
}
|
239
461
|
} else if(this.id === 'lp_solve') {
|
240
462
|
// Read solver messages from file
|
241
463
|
// NOTE: Linny-R client expects a list of strings
|
@@ -246,7 +468,7 @@ module.exports = class MILPSolver {
|
|
246
468
|
result.seconds = 0;
|
247
469
|
let i = 0,
|
248
470
|
solved = false;
|
249
|
-
while(i< output.length && !solved) {
|
471
|
+
while(i < output.length && !solved) {
|
250
472
|
msgs.push(output[i]);
|
251
473
|
const m = output[i].match(/in total (\d+\.\d+) seconds/);
|
252
474
|
if(m && m.length > 1) result.seconds = parseFloat(m[1]);
|
@@ -255,14 +477,16 @@ module.exports = class MILPSolver {
|
|
255
477
|
}
|
256
478
|
result.messages = msgs;
|
257
479
|
if(solved) {
|
258
|
-
|
480
|
+
// Look for line with first variable
|
481
|
+
while(i < output.length && !output[i].startsWith('X')) i++;
|
482
|
+
// Fill dictionary with variable name: value entries
|
259
483
|
while(i < output.length) {
|
260
|
-
|
261
|
-
|
262
|
-
v = parseFloat(v);
|
263
|
-
x_values.push(v);
|
484
|
+
const v = output[i].split(/\s+/);
|
485
|
+
x_dict[v[0]] = v[1];
|
264
486
|
i++;
|
265
487
|
}
|
488
|
+
// Fill the solution vector, adding 0 for missing columns
|
489
|
+
getValuesFromDict();
|
266
490
|
} else {
|
267
491
|
console.log('No solution found');
|
268
492
|
}
|