linny-r 1.3.3 → 1.4.0

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.
@@ -328,6 +328,31 @@ class LinnyRModel {
328
328
  return null;
329
329
  }
330
330
 
331
+ wildcardEquationByID(id) {
332
+ // Returns the tuple [dataset modifier, number] holding the first
333
+ // wildcard equation for which the ID (e.g., "abc ??") matches with
334
+ // `id`, or NULL if no match is found.
335
+ // NOTE: `id` must contain a number, not a wildcard.
336
+ if(!this.equations_dataset) return null;
337
+ const ids = Object.keys(this.equations_dataset.modifiers);
338
+ for(let i = 0; i < ids.length; i++) {
339
+ // Skip the modifier ID is identical to `id` (see NOTE above).
340
+ if(ids[i] !== id) {
341
+ const re = wildcardMatchRegex(ids[i], true);
342
+ if(re) {
343
+ const m = [...id.matchAll(re)];
344
+ if(m.length > 0) {
345
+ const n = parseInt(m[0][1]);
346
+ if(n || n === 0) {
347
+ return [this.equations_dataset.modifiers[ids[i]], n];
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+
331
356
  namedObjectByID(id) {
332
357
  // NOTE: not only entities, but also equations are "named objects", meaning
333
358
  // that their name must be unique in a model (unlike the titles of charts
@@ -361,8 +386,8 @@ class LinnyRModel {
361
386
  }
362
387
 
363
388
  objectByName(name) {
364
- // Looks up a named object based on its display name
365
- // NOTE: top cluster is uniquely identified by its name
389
+ // Looks up a named object based on its display name.
390
+ // NOTE: Top cluster is uniquely identified by its name.
366
391
  if(name === UI.TOP_CLUSTER_NAME || name === UI.FORMER_TOP_CLUSTER_NAME) {
367
392
  return this.clusters[UI.nameToID(UI.TOP_CLUSTER_NAME)];
368
393
  }
@@ -389,7 +414,7 @@ class LinnyRModel {
389
414
  // No link? then standard conversion to ID
390
415
  return this.namedObjectByID(UI.nameToID(name));
391
416
  }
392
-
417
+
393
418
  setByType(type) {
394
419
  // Returns a "dictionary" object with entities of the specified types
395
420
  if(type === 'Process') return this.processes;
@@ -450,6 +475,46 @@ class LinnyRModel {
450
475
  return list;
451
476
  }
452
477
 
478
+ allMatchingEntities(re, attr='') {
479
+ // NOTE: this routine is computationally intensive as it performs matches
480
+ // on the display names of entities while iterating over all relevant sets
481
+ const
482
+ me = [],
483
+ res = re.toString();
484
+
485
+ function scan(dict) {
486
+ // Try to match all entities in `dict`
487
+ for(let k in dict) if(dict.hasOwnProperty(k)) {
488
+ const
489
+ e = dict[k],
490
+ m = [...e.displayName.matchAll(re)];
491
+ if(m.length > 0) {
492
+ // If matches, ensure that the groups have identical values
493
+ const n = parseInt(m[0][1]);
494
+ let same = true;
495
+ for(let i = 1; same && i < m.length; i++) {
496
+ same = parseInt(m[i][1]) === n;
497
+ }
498
+ // If so, add the entity to the set
499
+ if(same) me.push(e);
500
+ }
501
+ }
502
+ }
503
+
504
+ // Links limit the search (constraints have no attributes => skip)
505
+ if(res.indexOf(UI.LINK_ARROW) >= 0) {
506
+ scan(this.links);
507
+ } else {
508
+ if(!attr || VM.actor_attr.indexOf(attr) >= 0) scan(this.actors);
509
+ if(!attr || VM.cluster_attr.indexOf(attr) >= 0) scan(this.clusters);
510
+ if(!attr) scan(this.datasets);
511
+ if(!attr || VM.process_attr.indexOf(attr) >= 0) scan(this.processes);
512
+ if(!attr || VM.product_attr.indexOf(attr) >= 0) scan(this.products);
513
+ if(!attr && this.equations_dataset) scan(this.equations_dataset.modifiers);
514
+ }
515
+ return me;
516
+ }
517
+
453
518
  get clustersToIgnore() {
454
519
  // Returns a "dictionary" with all clusters that are to be ignored
455
520
  const cti = {};
@@ -1221,17 +1286,17 @@ class LinnyRModel {
1221
1286
  if(name === UI.EQUATIONS_DATASET_NAME) {
1222
1287
  // When including a module, the current equations must be saved,
1223
1288
  // then the newly parsed dataset must have its modifiers prefixed,
1224
- // and then be merged with the original equations dataset
1289
+ // and then be merged with the original equations dataset.
1225
1290
  if(IO_CONTEXT) eqds = this.equations_dataset;
1226
1291
  // When equations dataset is added, recognize it as such, or its
1227
- // modifier selectors may be rejected while initializing from XML
1292
+ // modifier selectors may be rejected while initializing from XML.
1228
1293
  this.equations_dataset = d;
1229
1294
  }
1230
1295
  if(node) d.initFromXML(node);
1231
1296
  if(eqds) {
1232
- // Restore pointer to original equations dataset
1297
+ // Restore pointer to original equations dataset.
1233
1298
  this.equations_dataset = eqds;
1234
- // Return the extended equations dataset
1299
+ // Return the extended equations dataset.
1235
1300
  return eqds;
1236
1301
  } else {
1237
1302
  this.datasets[id] = d;
@@ -1357,9 +1422,9 @@ class LinnyRModel {
1357
1422
  if(!this.align_to_grid) return;
1358
1423
  let move = false;
1359
1424
  const fc = this.focal_cluster;
1360
- for(let i = 0; i < fc.notes.length; i++) {
1361
- move = fc.notes[i].alignToGrid() || move;
1362
- }
1425
+ // NOTE: Do not align notes to the grid. This will permit more
1426
+ // precise positioning, while aligning will not improve the layout
1427
+ // of the diagram because notes are not connected to arrows.
1363
1428
  for(let i = 0; i < fc.processes.length; i++) {
1364
1429
  move = fc.processes[i].alignToGrid() || move;
1365
1430
  }
@@ -1510,7 +1575,8 @@ class LinnyRModel {
1510
1575
  }
1511
1576
 
1512
1577
  get clusterOrProcessInSelection() {
1513
- // Return TRUE if current selection contains at least one cluster or process
1578
+ // Return TRUE if current selection contains at least one cluster
1579
+ // or process.
1514
1580
  for(let i = 0; i < this.selection.length; i++) {
1515
1581
  const obj = this.selection[i];
1516
1582
  if(obj instanceof Cluster || obj instanceof Process) return true;
@@ -1519,9 +1585,9 @@ class LinnyRModel {
1519
1585
  }
1520
1586
 
1521
1587
  moveSelection(dx, dy){
1522
- // Move all selected nodes unless cursor was not moved
1523
- // NOTE: no undo, as moves are incremental; the original positions have been
1524
- // stored on MOUSE DOWN
1588
+ // Move all selected nodes unless cursor was not moved.
1589
+ // NOTE: No undo, as moves are incremental; the original positions
1590
+ // have been stored on MOUSE DOWN.
1525
1591
  if(dx === 0 && dy === 0) return;
1526
1592
  let obj,
1527
1593
  minx = 0,
@@ -1587,6 +1653,23 @@ class LinnyRModel {
1587
1653
  }
1588
1654
  return true;
1589
1655
  }
1656
+
1657
+ eligibleFromToNodes(type) {
1658
+ // Returns a list of nodes of given type (Process, Product or Data)
1659
+ // that are visible in the focal cluster
1660
+ const
1661
+ fc = this.focal_cluster,
1662
+ el = [];
1663
+ if(type === 'Process') {
1664
+ for(let i = 0; i < fc.processes.length; i++) el.push(fc.processes[i]);
1665
+ } else {
1666
+ for(let i = 0; i < fc.product_positions.length; i++) {
1667
+ const p = fc.product_positions[i].product;
1668
+ if((type === 'Data' && p.is_data) || !p.is_data) el.push(p);
1669
+ }
1670
+ }
1671
+ return el;
1672
+ }
1590
1673
 
1591
1674
  get selectionAsXML() {
1592
1675
  // Returns XML for the selected entities, and also for the entities
@@ -1609,16 +1692,20 @@ class LinnyRModel {
1609
1692
  extras = [],
1610
1693
  from_tos = [],
1611
1694
  xml = [],
1612
- ft_xml = [],
1613
1695
  extra_xml = [],
1696
+ ft_xml = [],
1697
+ selc_xml = [],
1614
1698
  selected_xml = [];
1615
1699
  for(let i = 0; i < this.selection.length; i++) {
1616
1700
  const obj = this.selection[i];
1617
1701
  entities[obj.type].push(obj);
1618
- selected_xml.push('<sel>' + xmlEncoded(obj.displayName) + '</sel>');
1702
+ if(obj instanceof Cluster) selc_xml.push(
1703
+ '<selc name="', xmlEncoded(obj.name),
1704
+ '" actor-name="', xmlEncoded(obj.actor.name), '"></selc>');
1705
+ selected_xml.push(`<sel>${xmlEncoded(obj.displayName)}</sel>`);
1619
1706
  }
1620
- // Expand clusters by adding all its model entities to their respective
1621
- // lists
1707
+ // Expand (sub)clusters by adding all their model entities to their
1708
+ // respective lists
1622
1709
  for(let i = 0; i < entities.Cluster.length; i++) {
1623
1710
  const c = entities.Cluster[i];
1624
1711
  c.clearAllProcesses();
@@ -1645,7 +1732,7 @@ class LinnyRModel {
1645
1732
  }
1646
1733
  // Only add the XML for notes in the selection
1647
1734
  for(let i = 0; i < entities.Note.length; i++) {
1648
- xml.push(n.asXML);
1735
+ xml.push(entities.Note[i].asXML);
1649
1736
  }
1650
1737
  for(let i = 0; i < entities.Product.length; i++) {
1651
1738
  const p = entities.Product[i];
@@ -1701,7 +1788,11 @@ class LinnyRModel {
1701
1788
  for(let i = 0; i < from_tos.length; i++) {
1702
1789
  const p = from_tos[i];
1703
1790
  ft_xml.push('<from-to type="', p.type, '" name="', xmlEncoded(p.name));
1704
- if(p instanceof Process) ft_xml.push('" actor-name="', xmlEncoded(p.actor.name));
1791
+ if(p instanceof Process) {
1792
+ ft_xml.push('" actor-name="', xmlEncoded(p.actor.name));
1793
+ } else if(p.is_data) {
1794
+ ft_xml.push('" is-data="1');
1795
+ }
1705
1796
  ft_xml.push('"></from-to>');
1706
1797
  }
1707
1798
  for(let i = 0; i < extras.length; i++) {
@@ -1714,7 +1805,8 @@ class LinnyRModel {
1714
1805
  '"><entities>', xml.join(''),
1715
1806
  '</entities><from-tos>', ft_xml.join(''),
1716
1807
  '</from-tos><extras>', extra_xml.join(''),
1717
- '</extras><selection>', selected_xml.join(''),
1808
+ '</extras><selected-clusters>', selc_xml.join(''),
1809
+ '</selected-clusters><selection>', selected_xml.join(''),
1718
1810
  '</selection></copy>'].join('');
1719
1811
  }
1720
1812
 
@@ -1819,7 +1911,7 @@ class LinnyRModel {
1819
1911
  for(let i = 0; i < notes.length; i++) {
1820
1912
  const c = this.addNote();
1821
1913
  if(c) {
1822
- c.copyPropertiesFrom(notes[i]);
1914
+ c.copyPropertiesFrom(notes[i], renumber);
1823
1915
  c.x += 100;
1824
1916
  c.y += 100;
1825
1917
  cloned_selection.push(c);
@@ -2183,7 +2275,7 @@ class LinnyRModel {
2183
2275
 
2184
2276
  get datasetVariables() {
2185
2277
  // Returns list with all ChartVariable objects in this model that
2186
- // reference a regular dataset, i.e., not an equation
2278
+ // reference a regular dataset, i.e., not an equation.
2187
2279
  const vl = [];
2188
2280
  for(let i = 0; i < MODEL.charts.length; i++) {
2189
2281
  const c = MODEL.charts[i];
@@ -2349,19 +2441,21 @@ class LinnyRModel {
2349
2441
 
2350
2442
  parseXML(data) {
2351
2443
  // Parse data string into XML tree
2352
- try {
2444
+ // try {
2353
2445
  // NOTE: Convert %23 back to # (escaped by function saveModel)
2354
2446
  const xml = parseXML(data.replace(/%23/g, '#'));
2355
2447
  // NOTE: loading, not including => make sure that IO context is NULL
2356
2448
  IO_CONTEXT = null;
2357
2449
  this.initFromXML(xml);
2358
2450
  return true;
2451
+ /*
2359
2452
  } catch(err) {
2360
2453
  // Cursor is set to WAITING when loading starts
2361
2454
  UI.normalCursor();
2362
2455
  UI.alert('Error while parsing model: ' + err);
2363
2456
  return false;
2364
2457
  }
2458
+ */
2365
2459
  }
2366
2460
 
2367
2461
  initFromXML(node) {
@@ -2956,7 +3050,14 @@ class LinnyRModel {
2956
3050
  }
2957
3051
 
2958
3052
  resetExpressions() {
2959
- // Create a new vector for all expression attributes of all model entities
3053
+ // Create a new vector for all expression attributes of all model
3054
+ // entities, using the appropriate default value.
3055
+
3056
+ // Ensure that the equations dataset must have default value UNDEFINED
3057
+ // so the modeler is warned when a wildcard equation fails to obtain
3058
+ // a valid wildcard number.
3059
+ this.equations_dataset.default_value = VM.UNDEFINED;
3060
+
2960
3061
  let obj, l, p;
2961
3062
  for(obj in this.actors) if(this.actors.hasOwnProperty(obj)) {
2962
3063
  p = this.actors[obj];
@@ -2974,9 +3075,7 @@ class LinnyRModel {
2974
3075
  this.cleanVector(p.cash_in, 0, 0);
2975
3076
  this.cleanVector(p.cash_out, 0, 0);
2976
3077
  // NOTE: note fields also must be reset
2977
- for(let i = 0; i < p.notes.length; i++) {
2978
- p.notes[i].parsed = false;
2979
- }
3078
+ p.resetNoteFields();
2980
3079
  }
2981
3080
  for(obj in this.processes) if(this.processes.hasOwnProperty(obj)) {
2982
3081
  p = this.processes[obj];
@@ -3236,6 +3335,7 @@ class LinnyRModel {
3236
3335
  for(let i = 0; i < constraints.length; i++) {
3237
3336
  const
3238
3337
  c = constraints[i],
3338
+ // NOTE: constraints in list have levels greater than near-zero
3239
3339
  fl = c.from_node.actualLevel(t),
3240
3340
  tl = c.to_node.actualLevel(t);
3241
3341
  let tcp;
@@ -3306,10 +3406,10 @@ class LinnyRModel {
3306
3406
  if(af > VM.NEAR_ZERO) {
3307
3407
  // Prevent division by zero
3308
3408
  // NOTE: level can be zero even if actual flow > 0!
3309
- const al = p.actualLevel(dt);
3409
+ const al = p.nonZeroLevel(dt);
3310
3410
  // NOTE: scale to level only when level > 1, or fixed
3311
3411
  // costs for start-up or first commit will be amplified
3312
- if(al > VM.ON_OFF_THRESHOLD) cp -= pr * af / Math.max(al, 1);
3412
+ if(al > VM.NEAR_ZERO) cp -= pr * af / Math.max(al, 1);
3313
3413
  }
3314
3414
  }
3315
3415
  }
@@ -3428,8 +3528,8 @@ class LinnyRModel {
3428
3528
  p.cost_price[t] = cp;
3429
3529
  // For stocks, the CP includes stock price on t-1
3430
3530
  if(p.is_buffer) {
3431
- const prevl = p.actualLevel(t-1);
3432
- if(prevl > 0) {
3531
+ const prevl = p.nonZeroLevel(t-1);
3532
+ if(prevl > VM.NEAR_ZERO) {
3433
3533
  cp = (cnp + prevl * p.stockPrice(t-1)) / (qnp + prevl);
3434
3534
  }
3435
3535
  p.stock_price[t] = cp;
@@ -3486,7 +3586,7 @@ class LinnyRModel {
3486
3586
  // Then (also) look for links having AF = 0 ...
3487
3587
  for(let i = links.length-1; i >= 0; i--) {
3488
3588
  const af = links[i].actualFlow(t);
3489
- if(Math.abs(af) < VM.SIG_DIF_FROM_ZERO) {
3589
+ if(Math.abs(af) < VM.NEAR_ZERO) {
3490
3590
  // ... and set their UCP to 0
3491
3591
  links[i].unit_cost_price = 0;
3492
3592
  links.splice(i, 1);
@@ -3522,7 +3622,7 @@ class LinnyRModel {
3522
3622
  let hcp = VM.MINUS_INFINITY;
3523
3623
  for(let i = 0; i < p.inputs.length; i++) {
3524
3624
  const l = p.inputs[i];
3525
- if(l.from_node instanceof Process && l.actualFlow(t) > 0) {
3625
+ if(l.from_node instanceof Process && l.actualFlow(t) > VM.NEAR_ZERO) {
3526
3626
  const ld = l.actualDelay(t);
3527
3627
  // NOTE: only consider the allocated share of cost
3528
3628
  let cp = l.from_node.costPrice(t - ld) * l.share_of_cost;
@@ -4515,7 +4615,8 @@ class ObjectWithXYWH {
4515
4615
  }
4516
4616
 
4517
4617
  alignToGrid() {
4518
- // Align this object to the grid, and return TRUE if this involved a move
4618
+ // Align this object to the grid, and return TRUE if this involved
4619
+ // a move
4519
4620
  const
4520
4621
  ox = this.x,
4521
4622
  oy = this.y,
@@ -4536,22 +4637,29 @@ class ObjectWithXYWH {
4536
4637
  } // END of CLASS ObjectWithXYWH
4537
4638
 
4538
4639
 
4539
- // CLASS NoteField: numeric value of "field" [[dataset]] in note text
4640
+ // CLASS NoteField: numeric value of "field" [[variable]] in note text
4540
4641
  class NoteField {
4541
- constructor(f, o, u='1', m=1) {
4642
+ constructor(n, f, o, u='1', m=1, w=false) {
4643
+ // `n` is the note that "owns" this note field
4542
4644
  // `f` holds the unmodified tag string [[dataset]] to be replaced by
4543
4645
  // the value of vector or expression `o` for the current time step;
4544
- // if specified, `u` is the unit of the value to be displayed, and
4545
- // `m` is the multiplier for the value to be displayed
4646
+ // if specified, `u` is the unit of the value to be displayed,
4647
+ // `m` is the multiplier for the value to be displayed, and `w` is
4648
+ // the wildcard number to use in a wildcard equation
4649
+ this.note = n;
4546
4650
  this.field = f;
4547
4651
  this.object = o;
4548
4652
  this.unit = u;
4549
4653
  this.multiplier = m;
4654
+ this.wildcard_number = (w ? parseInt(w) : false);
4550
4655
  }
4551
4656
 
4552
4657
  get value() {
4553
4658
  // Returns the numeric value of this note field as a numeric string
4554
4659
  // followed by its unit (unless this is 1)
4660
+ // If object is the note, this means field [[#]] (note number context)
4661
+ // If this is undefined (empty string) display a double question mark
4662
+ if(this.object === this.note) return this.note.numberContext || '\u2047';
4555
4663
  let v = VM.UNDEFINED;
4556
4664
  const t = MODEL.t;
4557
4665
  if(Array.isArray(this.object)) {
@@ -4563,7 +4671,7 @@ class NoteField {
4563
4671
  v = MODEL.flowBalance(this.object, t);
4564
4672
  } else if(this.object instanceof Expression) {
4565
4673
  // Object is an expression
4566
- v = this.object.result(t);
4674
+ v = this.object.result(t, this.wildcard_number);
4567
4675
  } else if(typeof this.object === 'number') {
4568
4676
  v = this.object;
4569
4677
  } else {
@@ -4588,7 +4696,7 @@ class Note extends ObjectWithXYWH {
4588
4696
  super(cluster);
4589
4697
  const dt = new Date();
4590
4698
  // NOTE: use timestamp in msec to generate a unique identifier
4591
- this.timestamp = dt.getTime();
4699
+ this.timestamp = dt.getTime();
4592
4700
  this.contents = '';
4593
4701
  this.lines = [];
4594
4702
  this.fields = [];
@@ -4604,17 +4712,68 @@ class Note extends ObjectWithXYWH {
4604
4712
  return 'Note';
4605
4713
  }
4606
4714
 
4715
+ get clusterPrefix() {
4716
+ // Returns the name of the cluster containing this note, followed
4717
+ // by a colon+space, except when this cluster is the top cluster.
4718
+ if(this.cluster === MODEL.top_cluster) return '';
4719
+ return this.cluster.displayName + UI.PREFIXER;
4720
+ }
4721
+
4607
4722
  get displayName() {
4608
- return `Note #${this.cluster.notes.indexOf(this) + 1} in ` +
4609
- this.cluster.displayName;
4723
+ const
4724
+ n = this.number,
4725
+ type = (n ? `Numbered note #${n}` : 'Note');
4726
+ return `${this.clusterPrefix}${type} at (${this.x}, ${this.y})`;
4610
4727
  }
4611
4728
 
4612
- get numberContext() {
4613
- // Returns the string to be used to evaluate #
4614
- // NOTE: this does not apply to notes, so return empty string
4729
+ get number() {
4730
+ // Returns the number of this note if specified (e.g. as #123).
4731
+ // NOTE: this only applies to notes having note fields.
4732
+ const m = this.contents.replace(/\s+/g, ' ')
4733
+ .match(/^[^\]]*#(\d+).*\[\[[^\]]+\]\]/);
4734
+ if(m) return m[1];
4615
4735
  return '';
4616
4736
  }
4617
4737
 
4738
+ get numberContext() {
4739
+ // Returns the string to be used to evaluate #. For notes this is
4740
+ // their note number if specified, otherwise the number context of a
4741
+ // nearby enode, and otherwise the number context of their cluster.
4742
+ let n = this.number;
4743
+ if(n) return n;
4744
+ n = this.nearbyNode;
4745
+ if(n) return n.numberContext;
4746
+ return this.cluster.numberContext;
4747
+ }
4748
+
4749
+ get nearbyNode() {
4750
+ // Returns a node in the cluster of this note that is closest to this
4751
+ // note (Euclidian distance between center points), but with at most
4752
+ // 30 pixel units between their rims.
4753
+ const
4754
+ c = this.cluster,
4755
+ nodes = c.processes.concat(c.product_positions, c.sub_clusters);
4756
+ let nn = nodes[0] || null;
4757
+ if(nn) {
4758
+ let md = 1e+10;
4759
+ // Find the nearest node
4760
+ for(let i = 0; i < nodes.length; i++) {
4761
+ const n = nodes[i];
4762
+ const
4763
+ dx = (n.x - this.x),
4764
+ dy = (n.y - this.y),
4765
+ d = Math.sqrt(dx*dx + dy*dy);
4766
+ if(d < md) {
4767
+ nn = n;
4768
+ md = d;
4769
+ }
4770
+ }
4771
+ if(Math.abs(nn.x - this.x) < (nn.width + this.width) / 2 + 30 &&
4772
+ Math.abs(nn.y - this.y) < (nn.height + this.height) / 2 + 30) return nn;
4773
+ }
4774
+ return null;
4775
+ }
4776
+
4618
4777
  get asXML() {
4619
4778
  return ['<note><timestamp>', this.timestamp,
4620
4779
  '</timestamp><contents>', xmlEncoded(this.contents),
@@ -4649,27 +4808,27 @@ class Note extends ObjectWithXYWH {
4649
4808
  }
4650
4809
 
4651
4810
  setCluster(c) {
4652
- // Place this note into the specified cluster `c`
4811
+ // Place this note into the specified cluster `c`.
4653
4812
  if(this.cluster) {
4654
- // Remove this note from its current cluster's note list
4813
+ // Remove this note from its current cluster's note list.
4655
4814
  const i = this.cluster.notes.indexOf(this);
4656
4815
  if(i >= 0) this.cluster.notes.splice(i, 1);
4657
4816
  // Set its new cluster pointer...
4658
4817
  this.cluster = c;
4659
- // ... and add it to the new cluster's note list
4818
+ // ... and add it to the new cluster's note list.
4660
4819
  if(c.notes.indexOf(this) < 0) c.notes.push(this);
4661
4820
  }
4662
4821
  }
4663
4822
 
4664
4823
  get tagList() {
4665
- // Returns a list of matches for [[...]], or NULL if none
4824
+ // Returns a list of matches for [[...]], or NULL if none.
4666
4825
  return this.contents.match(/\[\[[^\]]+\]\]/g);
4667
4826
  }
4668
4827
 
4669
4828
  parseFields() {
4670
- // Fills the list of fields by parsing all [[...]] tags in the text
4829
+ // Fills the list of fields by parsing all [[...]] tags in the text.
4671
4830
  // NOTE: this does not affect the text itself; tags will be replaced
4672
- // by numerical values only when drawing the note
4831
+ // by numerical values only when drawing the note.
4673
4832
  this.fields.length = 0;
4674
4833
  const tags = this.tagList;
4675
4834
  if(tags) {
@@ -4679,19 +4838,25 @@ class Note extends ObjectWithXYWH {
4679
4838
  inner = tag.slice(2, tag.length - 2).trim(),
4680
4839
  bar = inner.lastIndexOf('|'),
4681
4840
  arrow = inner.lastIndexOf('->');
4682
- // Check if a unit conversion scalar was specified
4841
+ // Special case: [[#]] denotes the number context of this note.
4842
+ if(tag.replace(/\s+/, '') === '[[#]]') {
4843
+ this.fields.push(new NoteField(this, tag, this));
4844
+ // Done, so move on to the next tag
4845
+ continue;
4846
+ }
4847
+ // Check if a unit conversion scalar was specified.
4683
4848
  let ena,
4684
4849
  from_unit = '1',
4685
4850
  to_unit = '',
4686
4851
  multiplier = 1;
4687
4852
  if(arrow > bar) {
4688
- // Now for sure it is entity->unit or entity|attr->unit
4853
+ // Now for sure it is entity->unit or entity|attr->unit.
4689
4854
  ena = inner.split('->');
4690
4855
  // As example, assume that unit = 'kWh' (so the value of the
4691
- // field should be displayed in kilowatthour)
4692
- // NOTE: use .trim() instead of UI.cleanName(...) here;
4693
- // this forces the modeler to be exact, and that permits proper
4694
- // renaming of scale units in note fields
4856
+ // field should be displayed in kilowatthour).
4857
+ // NOTE: use .trim() instead of UI.cleanName(...) here. This
4858
+ // forces the modeler to be exact, and that permits proper
4859
+ // renaming of scale units in note fields.
4695
4860
  to_unit = ena[1].trim();
4696
4861
  ena = ena[0].split('|');
4697
4862
  if(!MODEL.scale_units.hasOwnProperty(to_unit)) {
@@ -4701,27 +4866,56 @@ class Note extends ObjectWithXYWH {
4701
4866
  } else {
4702
4867
  ena = inner.split('|');
4703
4868
  }
4704
- // Look up entity for name and attribute
4705
- const obj = MODEL.objectByName(ena[0].trim());
4706
- if(obj instanceof DatasetModifier) {
4707
- // NOTE: equations are (for now) dimensionless => unit '1'
4869
+ // Look up entity for name and attribute.
4870
+ // NOTE: A leading colon denotes "prefix with cluster name".
4871
+ let en = UI.colonPrefixedName(ena[0].trim(), this.clusterPrefix),
4872
+ id = UI.nameToID(en),
4873
+ // First try to match `id` with the IDs of wildcard equations,
4874
+ // (e.g., "abc 123" would match with "abc ??").
4875
+ w = MODEL.wildcardEquationByID(id),
4876
+ obj = null,
4877
+ wildcard = false;
4878
+ if(w) {
4879
+ // If wildcard equation match, w[0] is the equation (instance
4880
+ // of DatasetModifier), and w[1] the matching number.
4881
+ obj = w[0];
4882
+ wildcard = w[1];
4883
+ } else {
4884
+ obj = MODEL.objectByID(id);
4885
+ }
4886
+ // If not found, this may be due to # wildcards in the name.
4887
+ if(!obj && en.indexOf('#') >= 0) {
4888
+ // First try substituting # by the context number.
4889
+ const numcon = this.numberContext;
4890
+ obj = MODEL.objectByName(en.replace('#', numcon));
4891
+ // If no match, check whether the name matches a wildcard equation.
4892
+ if(!obj) {
4893
+ obj = MODEL.equationByID(UI.nameToID(en.replace('#', '??')));
4894
+ if(obj) wildcard = numcon;
4895
+ }
4896
+ }
4897
+ if(!obj) {
4898
+ UI.warn(`Unknown model entity "${en}"`);
4899
+ } else if(obj instanceof DatasetModifier) {
4900
+ // NOTE: equations are (for now) dimensionless => unit '1'.
4708
4901
  if(obj.dataset !== MODEL.equations_dataset) {
4709
4902
  from_unit = obj.dataset.scale_unit;
4710
4903
  multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4711
4904
  }
4712
- this.fields.push(new NoteField(tag, obj.expression, to_unit, multiplier));
4905
+ this.fields.push(new NoteField(this, tag, obj.expression, to_unit,
4906
+ multiplier, wildcard));
4713
4907
  } else if(obj) {
4714
- // If attribute omitted, use default attribute of entity type
4908
+ // If attribute omitted, use default attribute of entity type.
4715
4909
  const attr = (ena.length > 1 ? ena[1].trim() : obj.defaultAttribute);
4716
4910
  let val = null;
4717
- // NOTE: for datasets, use the active modifier
4911
+ // NOTE: for datasets, use the active modifier if no attribute.
4718
4912
  if(!attr && obj instanceof Dataset) {
4719
4913
  val = obj.activeModifierExpression;
4720
4914
  } else {
4721
- // Variable may specify a vector-type attribute
4915
+ // Variable may specify a vector-type attribute.
4722
4916
  val = obj.attributeValue(attr);
4723
4917
  }
4724
- // If not, it may be a cluster unit balance
4918
+ // If not, it may be a cluster unit balance.
4725
4919
  if(!val && attr.startsWith('=') && obj instanceof Cluster) {
4726
4920
  val = {c: obj, u: attr.substring(1).trim()};
4727
4921
  from_unit = val.u;
@@ -4747,9 +4941,20 @@ class Note extends ObjectWithXYWH {
4747
4941
  } else if(attr === 'CI' || attr === 'CO' || attr === 'CF') {
4748
4942
  from_unit = MODEL.currency_unit;
4749
4943
  }
4750
- // If not, it may be an expression-type attribute
4944
+ // If still no value, `attr` may be an expression-type attribute.
4751
4945
  if(!val) {
4752
4946
  val = obj.attributeExpression(attr);
4947
+ // For wildcard expressions, provide the tail number of `attr`
4948
+ // as number context.
4949
+ if(val && val.isWildcardExpression) {
4950
+ const nr = matchingNumber(attr, val.attribute);
4951
+ if(nr) {
4952
+ wildcard = nr;
4953
+ } else {
4954
+ UI.warn(`Attribute "${attr}" does not provide a number`);
4955
+ continue;
4956
+ }
4957
+ }
4753
4958
  if(obj instanceof Product) {
4754
4959
  if(attr === 'IL' || attr === 'LB' || attr === 'UB') {
4755
4960
  from_unit = obj.scale_unit;
@@ -4758,16 +4963,15 @@ class Note extends ObjectWithXYWH {
4758
4963
  }
4759
4964
  }
4760
4965
  }
4761
- // If no TO unit, add the FROM unit
4966
+ // If no TO unit, add the FROM unit.
4762
4967
  if(to_unit === '') to_unit = from_unit;
4763
4968
  if(val) {
4764
4969
  multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4765
- this.fields.push(new NoteField(tag, val, to_unit, multiplier));
4970
+ this.fields.push(new NoteField(this, tag, val, to_unit,
4971
+ multiplier, wildcard));
4766
4972
  } else {
4767
4973
  UI.warn(`Unknown ${obj.type.toLowerCase()} attribute "${attr}"`);
4768
4974
  }
4769
- } else {
4770
- UI.warn(`Unknown model entity "${ena[0].trim()}"`);
4771
4975
  }
4772
4976
  }
4773
4977
  }
@@ -4775,20 +4979,24 @@ class Note extends ObjectWithXYWH {
4775
4979
  }
4776
4980
 
4777
4981
  get fieldEntities() {
4778
- // Return a list with names of entities used in fields
4982
+ // Return a list with names of entities used in fields.
4779
4983
  const
4780
4984
  fel = [],
4781
4985
  tags = this.tagList;
4782
4986
  for(let i = 0; i < tags.length; i++) {
4783
4987
  const
4784
4988
  tag = tags[i],
4785
- inner = tag.slice(2, tag.length - 2).trim(),
4989
+ // Trim brackets and padding spaces on both sides, and then
4990
+ // expand leading colons that denote prefixes.
4991
+ inner = UI.colonPrefixedName(tag.slice(2, tag.length - 2).trim()),
4786
4992
  vb = inner.lastIndexOf('|'),
4787
4993
  ua = inner.lastIndexOf('->');
4788
4994
  if(vb >= 0) {
4995
+ // Vertical bar? Then the entity name is the left part.
4789
4996
  addDistinct(inner.slice(0, vb), fel);
4790
4997
  } else if(ua >= 0 &&
4791
4998
  MODEL.scale_units.hasOwnProperty(inner.slice(ua + 2))) {
4999
+ // Unit arrow? Then trim the "->unit" part.
4792
5000
  addDistinct(inner.slice(0, ua), fel);
4793
5001
  } else {
4794
5002
  addDistinct(inner, fel);
@@ -4812,29 +5020,30 @@ class Note extends ObjectWithXYWH {
4812
5020
  }
4813
5021
 
4814
5022
  rewriteFields(en1, en2) {
4815
- // Rename fields that reference entity name `en1` to reference `en2` instead
4816
- // NOTE: this does not affect the expression code
5023
+ // Rename fields that reference entity name `en1` to reference `en2`
5024
+ // instead.
5025
+ // NOTE: This does not affect the expression code.
4817
5026
  if(en1 === en2) return;
4818
5027
  for(let i = 0; i < this.fields.length; i++) {
4819
5028
  const
4820
5029
  f = this.fields[i],
4821
- // Trim the double brackets on both sides
4822
- tag = f.field.slice(2, f.field.length - 2);
4823
- // Separate tag into variable and attribute + offset string (if any)
5030
+ // Trim the double brackets and padding spaces on both sides.
5031
+ tag = f.field.slice(2, f.field.length - 2).trim();
5032
+ // Separate tag into variable and attribute + offset string (if any).
4824
5033
  let e = tag,
4825
5034
  a = '',
4826
5035
  vb = tag.lastIndexOf('|'),
4827
5036
  ua = tag.lastIndexOf('->');
4828
5037
  if(vb >= 0) {
4829
5038
  e = tag.slice(0, vb);
4830
- // NOTE: attribute string includes the vertical bar '|'
5039
+ // NOTE: Attribute string includes the vertical bar '|'.
4831
5040
  a = tag.slice(vb);
4832
5041
  } else if(ua >= 0 && MODEL.scale_units.hasOwnProperty(tag.slice(ua + 2))) {
4833
5042
  e = tag.slice(0, ua);
4834
- // NOTE: attribute string includes the unit conversion arrow '->'
5043
+ // NOTE: Attribute string includes the unit conversion arrow '->'.
4835
5044
  a = tag.slice(ua);
4836
5045
  }
4837
- // Check for match
5046
+ // Check for match.
4838
5047
  const r = UI.replaceEntity(e, en1, en2);
4839
5048
  if(r) {
4840
5049
  e = `[[${r}${a}]]`;
@@ -4845,6 +5054,8 @@ class Note extends ObjectWithXYWH {
4845
5054
  }
4846
5055
 
4847
5056
  get evaluateFields() {
5057
+ // Returns the text content of this note with all tags replaced
5058
+ // by their note field values.
4848
5059
  if(!this.parsed) this.parseFields();
4849
5060
  let txt = this.contents;
4850
5061
  for(let i = 0; i < this.fields.length; i++) {
@@ -4855,51 +5066,66 @@ class Note extends ObjectWithXYWH {
4855
5066
  }
4856
5067
 
4857
5068
  resize() {
4858
- // Resizes the note; returns TRUE iff size has changed
5069
+ // Resizes the note; returns TRUE iff size has changed.
4859
5070
  let txt = this.evaluateFields;
4860
5071
  const
4861
5072
  w = this.width,
4862
5073
  h = this.height,
4863
- // Minimumm note width of 10 characters
5074
+ // Minimumm note width of 10 characters.
4864
5075
  n = Math.max(txt.length, 10),
4865
5076
  fh = UI.textSize('hj').height;
4866
- // Approximate the width to obtain a rectangle
5077
+ // Approximate the width to obtain a rectangle.
4867
5078
  // NOTE: 3:1 may seem exagerated, but characters are higher than wide,
4868
- // and there will be more (short) lines due to newlines and wrapping
5079
+ // and there will be more (short) lines due to newlines and wrapping.
4869
5080
  let tw = Math.ceil(3*Math.sqrt(n)) * fh / 2;
4870
5081
  this.lines = UI.stringToLineArray(txt, tw).join('\n');
4871
5082
  let bb = UI.textSize(this.lines, 8);
4872
- // Ensure that shape is wider than tall
5083
+ // Aim to make the shape wider than tall.
4873
5084
  let nw = bb.width,
4874
5085
  nh = bb.height;
4875
5086
  while(bb.width < bb.height * 1.7) {
4876
5087
  tw *= 1.2;
4877
5088
  this.lines = UI.stringToLineArray(txt, tw).join('\n');
4878
5089
  bb = UI.textSize(this.lines, 8);
4879
- // Prevent infinite loop
5090
+ // Prevent infinite loop.
4880
5091
  if(nw <= bb.width || nh > bb.height) break;
4881
5092
  }
4882
5093
  this.height = 1.05 * (bb.height + 6);
4883
5094
  this.width = bb.width + 6;
5095
+ // Boolean return value indicates whether size has changed.
4884
5096
  return this.width != w || this.height != h;
4885
5097
  }
4886
5098
 
4887
5099
  containsPoint(mpx, mpy) {
5100
+ // Returns TRUE iff given coordinates lie within the note rectangle.
4888
5101
  return (Math.abs(mpx - this.x) <= this.width / 2 &&
4889
5102
  Math.abs(mpy - this.y) <= this.height / 2);
4890
5103
  }
4891
5104
 
4892
- copyPropertiesFrom(n) {
4893
- // Set properties to be identical to those of note `n`
5105
+ copyPropertiesFrom(n, renumber=false) {
5106
+ // Sets properties to be identical to those of note `n`.
4894
5107
  this.x = n.x;
4895
5108
  this.y = n.y;
4896
- this.contents = n.contents;
5109
+ let cont = n.contents;
5110
+ if(renumber) {
5111
+ // Renumbering only applies to notes having note fields; then the
5112
+ // note number must be denoted like #123, and occur before the first
5113
+ // note field.
5114
+ const m = cont.match(/^[^\]]*#(\d+).*\[\[[^\]]+\]\]/);
5115
+ if(m) {
5116
+ const nn = this.cluster.nextAvailableNoteNumber(m[1]);
5117
+ cont = cont.replace(/#\d+/, `#${nn}`);
5118
+ }
5119
+ }
5120
+ this.contents = cont;
5121
+ // NOTE: Renumbering does not affect the note fields or the color
5122
+ // expression. This is a design choice; the modeler can use wildcards.
4897
5123
  this.color.text = n.color.text;
4898
5124
  this.parsed = false;
4899
5125
  }
4900
5126
 
4901
5127
  differences(n) {
4902
- // Return "dictionary" of differences, or NULL if none
5128
+ // Return "dictionary" of differences, or NULL if none.
4903
5129
  const d = differences(this, n, UI.MC.NOTE_PROPS);
4904
5130
  if(Object.keys(d).length > 0) return d;
4905
5131
  return null;
@@ -4941,6 +5167,17 @@ class NodeBox extends ObjectWithXYWH {
4941
5167
  // NOTE: Display nothing if entity is "black-boxed"
4942
5168
  if(n.startsWith(UI.BLACK_BOX)) return '';
4943
5169
  n = `<em>${this.type}:</em> ${n}`;
5170
+ // For clusters, add how many processes and products they contain
5171
+ if(this instanceof Cluster) {
5172
+ let d = '';
5173
+ if(this.all_processes) {
5174
+ const dl = [];
5175
+ dl.push(pluralS(this.all_processes.length, 'process'));
5176
+ dl.push(pluralS(this.all_products.length, 'product'));
5177
+ d = dl.join(', ').toLowerCase();
5178
+ }
5179
+ if(d) n += `<span class="node-details">${d}</span>`;
5180
+ }
4944
5181
  if(DEBUGGING && MODEL.solved) {
4945
5182
  n += ' [';
4946
5183
  if(this instanceof Process || this instanceof Product) {
@@ -4965,10 +5202,11 @@ class NodeBox extends ObjectWithXYWH {
4965
5202
  }
4966
5203
 
4967
5204
  get numberContext() {
4968
- // Returns the string to be used to evaluate #, so for clusters, processes
4969
- // and products this is the string of trailing digits (or empty if none)
4970
- // of the node name, or if that does not end with a number, the trailing
4971
- // digits of the first prefix (from right to left) that does
5205
+ // Returns the string to be used to evaluate #, so for clusters,
5206
+ // processes and products this is the string of trailing digits
5207
+ // (or empty if none) of the node name, or if that does not end on
5208
+ // a number, the trailing digits of the first prefix (from right to
5209
+ // left) that does end on a number
4972
5210
  const sn = UI.prefixesAndName(this.name);
4973
5211
  let nc = endsWithDigits(sn.pop());
4974
5212
  while(!nc && sn.length > 0) {
@@ -4977,33 +5215,50 @@ class NodeBox extends ObjectWithXYWH {
4977
5215
  return nc;
4978
5216
  }
4979
5217
 
5218
+ get similarNumberedEntities() {
5219
+ // Returns a list of nodes of the same type that have a number
5220
+ // context similar to this node.
5221
+ const nc = this.numberContext;
5222
+ if(!nc) return [];
5223
+ const
5224
+ re = wildcardMatchRegex(this.displayName.replace(nc, '#')),
5225
+ nodes = MODEL.setByType(this.type),
5226
+ similar = [];
5227
+ for(let id in nodes) if(nodes.hasOwnProperty(id)) {
5228
+ const n = nodes[id];
5229
+ if(n.displayName.match(re)) similar.push(n);
5230
+ }
5231
+ return similar;
5232
+ }
5233
+
4980
5234
  rename(name, actor_name) {
4981
- // Changes the name and/or actor name of this process, product or cluster
4982
- // NOTE: returns TRUE if rename was successful, FALSE on error, and a
4983
- // process, product or cluster if such entity having the new name already
4984
- // exists
5235
+ // Changes the name and/or actor name of this node (process, product
5236
+ // or cluster).
5237
+ // NOTE: Returns TRUE if rename was successful, FALSE on error, and
5238
+ // a process, product or cluster if such entity having the new name
5239
+ // already exists.
4985
5240
  name = UI.cleanName(name);
4986
5241
  if(!UI.validName(name)) {
4987
5242
  UI.warningInvalidName(name);
4988
5243
  return false;
4989
5244
  }
4990
- // Compose the full name
5245
+ // Compose the full name.
4991
5246
  if(actor_name === '') actor_name = UI.NO_ACTOR;
4992
5247
  let fn = name;
4993
5248
  if(actor_name != UI.NO_ACTOR) fn += ` (${actor_name})`;
4994
5249
  // Get the ID (derived from the full name) and check if MODEL already
4995
- // contains another entity with this ID
5250
+ // contains another entity with this ID.
4996
5251
  const
4997
5252
  old_name = this.displayName,
4998
5253
  old_id = this.identifier,
4999
5254
  new_id = UI.nameToID(fn),
5000
5255
  n = MODEL.nodeBoxByID(new_id);
5001
- // If so, do NOT rename, but return this object instead
5002
- // NOTE: if entity with this name is THIS entity, it typically means
5003
- // a cosmetic name change (upper/lower case) which SHOULD be performed
5256
+ // If so, do NOT rename, but return this object instead.
5257
+ // NOTE: If entity with this name is THIS entity, it typically means
5258
+ // a cosmetic name change (upper/lower case) which SHOULD be performed.
5004
5259
  if(n && n !== this) return n;
5005
- // Otherwise, if IDs differ, add this object under its new key, and remove
5006
- // its old entry
5260
+ // Otherwise, if IDs differ, add this object under its new key, and
5261
+ // remove its old entry.
5007
5262
  if(old_id != new_id) {
5008
5263
  if(this instanceof Process) {
5009
5264
  MODEL.processes[new_id] = this;
@@ -5015,21 +5270,21 @@ class NodeBox extends ObjectWithXYWH {
5015
5270
  MODEL.clusters[new_id] = this;
5016
5271
  delete MODEL.clusters[old_id];
5017
5272
  } else {
5018
- // NOTE: this should never happen => report an error
5273
+ // NOTE: This should never happen => report an error.
5019
5274
  UI.alert('Can only rename processes, products and clusters');
5020
5275
  return false;
5021
5276
  }
5022
5277
  }
5023
- // Change this object's name and actor
5278
+ // Change this object's name and actor.
5024
5279
  this.actor = MODEL.addActor(actor_name);
5025
5280
  this.name = name;
5026
- // Update actor list in case some actor name is no longer used
5281
+ // Update actor list in case some actor name is no longer used.
5027
5282
  MODEL.cleanUpActors();
5028
5283
  MODEL.replaceEntityInExpressions(old_name, this.displayName);
5029
5284
  MODEL.inferIgnoredEntities();
5030
- // NOTE: renaming may affect the node's display size
5285
+ // NOTE: Renaming may affect the node's display size.
5031
5286
  if(this.resize()) this.drawWithLinks();
5032
- // NOTE: only TRUE indicates a successful (cosmetic) name change
5287
+ // NOTE: Only TRUE indicates a successful (cosmetic) name change.
5033
5288
  return true;
5034
5289
  }
5035
5290
 
@@ -5478,7 +5733,8 @@ class Cluster extends NodeBox {
5478
5733
  }
5479
5734
 
5480
5735
  attributeValue(a) {
5481
- // Return the computed result for attribute a (for clusters always a vector)
5736
+ // Return the computed result for attribute `a`
5737
+ // For clusters, this is always a vector
5482
5738
  if(a === 'CF') return this.cash_flow;
5483
5739
  if(a === 'CI') return this.cash_in;
5484
5740
  if(a === 'CO') return this.cash_out;
@@ -5745,6 +6001,27 @@ class Cluster extends NodeBox {
5745
6001
  return notes;
5746
6002
  }
5747
6003
 
6004
+ resetNoteFields() {
6005
+ // Ensure that all note fields are parsed anew when a note in this
6006
+ // cluster are drawn.
6007
+ for(let i = 0; i < this.notes.length; i++) {
6008
+ this.notes[i].parsed = false;
6009
+ }
6010
+ }
6011
+
6012
+ nextAvailableNoteNumber(n) {
6013
+ // Returns the first integer greater than `n` that is not already in use
6014
+ // by a note of this cluster
6015
+ let nn = parseInt(n) + 1;
6016
+ const nrs = [];
6017
+ for(let i = 0; i < this.notes.length; i++) {
6018
+ const nr = this.notes[i].number;
6019
+ if(nr) nrs.push(parseInt(nr));
6020
+ }
6021
+ while(nrs.indexOf(nn) >= 0) nn++;
6022
+ return nn;
6023
+ }
6024
+
5748
6025
  clearAllProcesses() {
5749
6026
  // Clear `all_processes` property of this cluster AND of all its parent clusters
5750
6027
  this.all_processes = null;
@@ -7147,6 +7424,7 @@ class Process extends Node {
7147
7424
  }
7148
7425
 
7149
7426
  get defaultAttribute() {
7427
+ // Default attribute of processes is their level
7150
7428
  return 'L';
7151
7429
  }
7152
7430
 
@@ -7162,6 +7440,7 @@ class Process extends Node {
7162
7440
  }
7163
7441
 
7164
7442
  attributeExpression(a) {
7443
+ // Processes have three expression attributes
7165
7444
  if(a === 'LB') return this.lower_bound;
7166
7445
  if(a === 'UB') {
7167
7446
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
@@ -7557,6 +7836,7 @@ class Product extends Node {
7557
7836
  }
7558
7837
 
7559
7838
  get defaultAttribute() {
7839
+ // Products have their level as default attribute
7560
7840
  return 'L';
7561
7841
  }
7562
7842
 
@@ -7570,6 +7850,7 @@ class Product extends Node {
7570
7850
  }
7571
7851
 
7572
7852
  attributeExpression(a) {
7853
+ // Products have four expression attributes
7573
7854
  if(a === 'LB') return this.lower_bound;
7574
7855
  if(a === 'UB') {
7575
7856
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
@@ -7580,6 +7861,7 @@ class Product extends Node {
7580
7861
  }
7581
7862
 
7582
7863
  changeScaleUnit(name) {
7864
+ // Changes the scale unit for this product to `name`
7583
7865
  let su = MODEL.addScaleUnit(name);
7584
7866
  if(su !== this.scale_unit) {
7585
7867
  this.scale_unit = su;
@@ -7867,6 +8149,7 @@ class Link {
7867
8149
  }
7868
8150
 
7869
8151
  get defaultAttribute() {
8152
+ // For links, the default attribute is their actual flow
7870
8153
  return 'F';
7871
8154
  }
7872
8155
 
@@ -7878,6 +8161,7 @@ class Link {
7878
8161
  }
7879
8162
 
7880
8163
  attributeExpression(a) {
8164
+ // Links have two expression attributes
7881
8165
  if(a === 'R') return this.relative_rate;
7882
8166
  if(a === 'D') return this.flow_delay;
7883
8167
  return null;
@@ -7978,20 +8262,45 @@ class DatasetModifier {
7978
8262
  }
7979
8263
 
7980
8264
  get hasWildcards() {
7981
- return this.selector.indexOf('*') >= 0 || this.selector.indexOf('?') >= 0;
8265
+ // Returns TRUE if this modifier contains wildcards
8266
+ return this.dataset.isWildcardSelector(this.selector);
8267
+ }
8268
+
8269
+ get numberContext() {
8270
+ // Returns the string to be used to evaluate #.
8271
+ // NOTE: If the selector contains wildcards, return "?" to indicate
8272
+ // that the value of # cannot be inferred at compile time.
8273
+ if(this.hasWildcards) return '?';
8274
+ // Otherwise, # is the string of digits at the end of the selector.
8275
+ // NOTE: equation names are like entity names, so treat them as such,
8276
+ // i.e., also check for prefixes that end on digits.
8277
+ const sn = UI.prefixesAndName(this.name);
8278
+ let nc = endsWithDigits(sn.pop());
8279
+ while(!nc && sn.length > 0) {
8280
+ nc = endsWithDigits(sn.pop());
8281
+ }
8282
+ // NOTE: if the selector has no tail number, return the number context
8283
+ // of the dataset of this modifier.
8284
+ return nc || this.dataset.numberContext;
7982
8285
  }
7983
8286
 
7984
8287
  match(s) {
7985
- if(this.hasWildcards) {
7986
- // NOTE: replace ? by . (any character) in pattern and * by .*
7987
- const re = new RegExp(
7988
- this.selector.replace(/\?/g, '.').replace(/\*/g, '.*'));
7989
- return re.test(s);
8288
+ // Returns TRUE if string `s` matches with the wildcard pattern of
8289
+ // the selector.
8290
+ if(!this.hasWildcards) return s === this.selector;
8291
+ let re;
8292
+ if(this.dataset === MODEL.equations_dataset) {
8293
+ // Equations wildcards only match with digits
8294
+ re = wildcardMatchRegex(this.selector, true);
7990
8295
  } else {
7991
- return s === this.selector;
8296
+ // Selector wildcards match with any character, so replace ? by .
8297
+ // (any character) in pattern, and * by .*
8298
+ const raw = this.selector.replace(/\?/g, '.').replace(/\*/g, '.*');
8299
+ re = new RegExp(`^${raw}$`);
7992
8300
  }
8301
+ return re.test(s);
7993
8302
  }
7994
-
8303
+
7995
8304
  } // END of class DatasetModifier
7996
8305
 
7997
8306
 
@@ -8056,7 +8365,9 @@ class Dataset {
8056
8365
  }
8057
8366
 
8058
8367
  get numberContext() {
8059
- // Returns the string to be used to evaluate # (empty string if undefined)
8368
+ // Returns the string to be used to evaluate #
8369
+ // Like for nodes, this is the string of digits at the end of the
8370
+ // dataset name (if any) or an empty string (to denote undefined)
8060
8371
  const sn = UI.prefixesAndName(this.name);
8061
8372
  let nc = endsWithDigits(sn.pop());
8062
8373
  while(!nc && sn.length > 0) {
@@ -8064,7 +8375,7 @@ class Dataset {
8064
8375
  }
8065
8376
  return nc;
8066
8377
  }
8067
-
8378
+
8068
8379
  get selectorList() {
8069
8380
  // Returns sorted list of selectors (those with wildcards last)
8070
8381
  const sl = [];
@@ -8084,14 +8395,45 @@ class Dataset {
8084
8395
  return sl;
8085
8396
  }
8086
8397
 
8087
- get allModifiersAreStatic() {
8088
- // Returns TRUE if all modifier expressions are static
8089
- for(let k in this.modifiers) if(this.modifiers.hasOwnProperty(k)) {
8090
- if(!this.modifiers[k].expression.isStatic) return false;
8398
+ isWildcardSelector(s) {
8399
+ // Returns TRUE if `s` contains * or ?
8400
+ // NOTE: for equations, the wildcard must be ??
8401
+ if(this.dataset === MODEL.equations_dataset) return s.indexOf('??') >= 0;
8402
+ return s.indexOf('*') >= 0 || s.indexOf('?') >= 0;
8403
+ }
8404
+
8405
+ matchingModifiers(l) {
8406
+ // Returns the list of modifiers of this dataset (in order: from most
8407
+ // to least specific) that match with 1 or more elements of `l`
8408
+ const
8409
+ sl = this.selectorList,
8410
+ shared = [];
8411
+ for(let i = 0; i < l.length; i++) {
8412
+ for(let j = 0; j < sl.length; j++) {
8413
+ const m = this.modifiers[UI.nameToID(sl[j])];
8414
+ if(m.match(l[i])) addDistinct(m, shared);
8415
+ }
8416
+ }
8417
+ return shared;
8418
+ }
8419
+
8420
+ modifiersAreStatic(l) {
8421
+ // Returns TRUE if expressions for all modifiers in `l` are static
8422
+ // NOTE: `l` may be a list of modifiers or strings
8423
+ for(let i = 0; i < l.length; i++) {
8424
+ let sel = l[i];
8425
+ if(sel instanceof DatasetModifier) sel = sel.selector;
8426
+ if(this.modifiers.hasOwnProperty(sel) &&
8427
+ !this.modifiers[sel].expression.isStatic) return false;
8091
8428
  }
8092
8429
  return true;
8093
8430
  }
8094
8431
 
8432
+ get allModifiersAreStatic() {
8433
+ // Returns TRUE if all modifier expressions are static
8434
+ return this.modifiersAreStatic(Object.keys(this.modifiers));
8435
+ }
8436
+
8095
8437
  get inferPrefixableModifiers() {
8096
8438
  // Returns list of dataset modifiers with expressions that do not
8097
8439
  // reference any variable and hence could probably better be represented
@@ -8135,21 +8477,6 @@ class Dataset {
8135
8477
  }
8136
8478
  }
8137
8479
 
8138
- matchingModifiers(l) {
8139
- // Returns the list of selectors of this dataset (in order: from most to
8140
- // least specific) that match with 1 or more elements of `l`
8141
- const
8142
- sl = this.selectorList,
8143
- shared = [];
8144
- for(let i = 0; i < l.length; i++) {
8145
- for(let j = 0; j < sl.length; j++) {
8146
- const m = this.modifiers[UI.nameToID(sl[j])];
8147
- if(m.match(l[i])) addDistinct(m, shared);
8148
- }
8149
- }
8150
- return shared;
8151
- }
8152
-
8153
8480
  get dataString() {
8154
8481
  // Data is stored simply as semicolon-separated floating point numbers,
8155
8482
  // with N-digit precision to keep model files compact (default: N = 8)
@@ -8252,21 +8579,21 @@ class Dataset {
8252
8579
  }
8253
8580
 
8254
8581
  attributeValue(a) {
8255
- // Returns the computed result for attribute `a`
8582
+ // Returns the computed result for attribute `a`.
8256
8583
  // NOTE: Datasets have ONE attribute (their vector) denoted by the empty
8257
- // string; all other "attributes" should be modifier selectors
8584
+ // string; all other "attributes" should be modifier selectors, and
8585
+ // their value should be obtained using attributeExpression (see below).
8258
8586
  if(a === '') return this.vector;
8259
8587
  return null;
8260
8588
  }
8261
8589
 
8262
8590
  attributeExpression(a) {
8263
- // Returns expression for selector `a`, or NULL if no such selector exists
8264
- // NOTE: selectors no longer are case-sensitive
8591
+ // Returns expression for selector `a` (also considering wildcard
8592
+ // modifiers), or NULL if no such selector exists.
8593
+ // NOTE: selectors no longer are case-sensitive.
8265
8594
  if(a) {
8266
- a = UI.nameToID(a);
8267
- for(let m in this.modifiers) if(this.modifiers.hasOwnProperty(m)) {
8268
- if(m === a) return this.modifiers[m].expression;
8269
- }
8595
+ const mm = this.matchingModifiers([a]);
8596
+ if(mm.length > 0) return mm[0].expression;
8270
8597
  }
8271
8598
  return null;
8272
8599
  }
@@ -8297,15 +8624,22 @@ class Dataset {
8297
8624
  if(this === MODEL.equations_dataset) {
8298
8625
  // Equation identifiers cannot contain characters that have special
8299
8626
  // meaning in a variable identifier
8300
- s = s.replace(/[\*\?\|\[\]\{\}\@\#]/g, '');
8627
+ s = s.replace(/[\*\|\[\]\{\}\@\#]/g, '');
8301
8628
  if(s !== selector) {
8302
- UI.warn('Equation name cannot contain [, ], {, }, |, @, #, * or ?');
8629
+ UI.warn('Equation name cannot contain [, ], {, }, |, @, # or *');
8630
+ return null;
8631
+ }
8632
+ // Wildcard selectors must be exactly 2 consecutive question marks,
8633
+ // so reduce longer sequences (no warning)
8634
+ s = s.replace(/\?\?+/g, '??');
8635
+ if(s.split('??').length > 2) {
8636
+ UI.warn('Equation name can contain only 1 wildcard');
8303
8637
  return null;
8304
8638
  }
8305
8639
  // Reduce inner spaces to one, and trim outer spaces
8306
8640
  s = s.replace(/\s+/g, ' ').trim();
8307
- // Then prefix it when the IO context argument is defined
8308
- if(ioc) s = ioc.actualName(s);
8641
+ // Then prefix it when the IO context argument is defined
8642
+ if(ioc) s = ioc.actualName(s);
8309
8643
  // If equation already exists, return its modifier
8310
8644
  const id = UI.nameToID(s);
8311
8645
  if(this.modifiers.hasOwnProperty(id)) return this.modifiers[id];
@@ -9743,6 +10077,9 @@ class ExperimentRunResult {
9743
10077
  }
9744
10078
  } else if(v instanceof Dataset) {
9745
10079
  // This dataset will be an "outcome" dataset => store statistics only
10080
+ // @@TO DO: deal with wildcard equations: these will have *multiple*
10081
+ // vectors associated with numbered entities (via #) and therefore
10082
+ // *all* these results should be stored (with # replaced by its value)
9746
10083
  this.x_variable = false;
9747
10084
  this.object_id = v.identifier;
9748
10085
  if(v === MODEL.equations_dataset && a) {