constrainthg 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,24 @@
1
+ """Initialization file for ConstraintHg."""
2
+
3
+ import logging
4
+
5
+ from constrainthg.hypergraph import *
6
+ import constrainthg.relations as R
7
+
8
+ LOG_LEVEL = logging.INFO
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ fh = logging.FileHandler("constrainthg.log")
13
+ fh.setLevel(LOG_LEVEL)
14
+ logger.info('Package begin execution')
15
+
16
+ log_formatter = logging.Formatter(
17
+ fmt="[{asctime} | {levelname}]: {message}",
18
+ style="{",
19
+ datefmt="%Y-%m-%d %H:%M",
20
+ )
21
+ fh.setFormatter(log_formatter)
22
+ logger.addHandler(fh)
23
+
24
+ logger.setLevel(LOG_LEVEL)
@@ -0,0 +1,920 @@
1
+ """
2
+ File: hypergraph.py
3
+ Author: John Morris, jhmrrs@clemson.edu, https://orcid.org/0009-0005-6571-1959
4
+ Purpose: A list of classes for storing and traversing a constraint hypergraph.
5
+ License: All rights reserved.
6
+ Versions:
7
+ - 0.0, 7 Oct. 2024: initialized
8
+ - 0.1, 4 Nov. 2024: basic searching demonstrated
9
+ """
10
+
11
+ from typing import Callable, List
12
+ from inspect import signature
13
+ import logging
14
+ logger = logging.getLogger(__name__)
15
+ import itertools
16
+ from enum import Enum
17
+
18
+ ## Helper functions
19
+ def append_to_dict_list(d: dict, key, val):
20
+ """Appends the value to a dictionary where the dict.values are lists."""
21
+ if key not in d:
22
+ d[key] = []
23
+ d[key].append(val)
24
+
25
+ def make_list(val)-> list:
26
+ """Ensures that the value is a list, or else a list containing the value."""
27
+ if isinstance(val, list):
28
+ return val
29
+ if isinstance(val, str):
30
+ return [val]
31
+ try:
32
+ return list(val)
33
+ except TypeError:
34
+ return [val]
35
+
36
+ def make_set(val)-> list:
37
+ """Ensures that the value is a set, or else a set containing the value."""
38
+ if isinstance(val, set):
39
+ return val
40
+ if isinstance(val, str):
41
+ return {val}
42
+ try:
43
+ return set(val)
44
+ except TypeError:
45
+ return {val}
46
+
47
+ class TNode:
48
+ """A basic tree node for printing tree structures."""
49
+ class conn:
50
+ """A class of connectors used for indicating child nodes."""
51
+ elbow = "└──"
52
+ pipe = "│ "
53
+ tee = "├──"
54
+ blank = " "
55
+ elbow_join = "└◯─"
56
+ tee_join = "├◯─"
57
+ elbow_stop = "└●─"
58
+ tee_stop = "├●─"
59
+
60
+ def __init__(self, label: str, node_label: str, value=None, children: list=None,
61
+ cost: float=None, trace: list=None, gen_edge_label: str=None,
62
+ gen_edge_cost: float=0.0, join_status: str='None'):
63
+ """
64
+ Creates the root of a search tree.
65
+
66
+ Parameters
67
+ ----------
68
+ label : str
69
+ A unique identifier for the TNode, necessary for pathfinding.
70
+ node_label : str
71
+ A string identifying the node represented by the TNode.
72
+ value : Any, optional
73
+ The value of the tree solved to the TNode.
74
+ children : list, optional
75
+ TNodes that form the source nodes of an edge leading to the TNode.
76
+ cost : float, optional
77
+ Value indicating the solving the tree rooted at the TNode.
78
+ trace : list, optional
79
+ Top down trace of how the TNode could be resolved, used for path exploration.
80
+ gen_edge_label : str, optional
81
+ A unique label for the edge generating the TNode (of which `children` are source nodes).
82
+ gen_edge_cost : float, default=0.
83
+ Value for weight (cost) of the generating edge, default is 0.0.
84
+ join_status : str, optional
85
+ Indicates if the TNode is the last of a set of children, used for printing.
86
+
87
+ Properties
88
+ ----------
89
+ index : int
90
+ The maximum times the TNode or any child TNodes are repeated in the tree.
91
+ """
92
+ self.node_label = node_label
93
+ self.label = label
94
+ self.value = value
95
+ self.children = [] if children is None else children
96
+ self.cost = cost
97
+ self.trace = [] if trace is None else trace
98
+ self.gen_edge_label = gen_edge_label
99
+ self.gen_edge_cost = gen_edge_cost
100
+ self.values = {node_label : [value,]}
101
+ self.join_status = join_status
102
+ self.index = max([1] + [c.index for c in self.children])
103
+
104
+ def print_conn(self, last=True)-> str:
105
+ """Selecter function for the connector string on the tree print."""
106
+ if last:
107
+ if self.join_status == 'join':
108
+ return self.conn.elbow_join
109
+ if self.join_status == 'join_stop':
110
+ return self.conn.elbow_stop
111
+ return self.conn.elbow
112
+ if self.join_status == 'join':
113
+ return self.conn.tee_join
114
+ if self.join_status == 'join_stop':
115
+ return self.conn.tee_stop
116
+ return self.conn.tee
117
+
118
+ def print_tree(self, last=True, header='', checked_edges:list=None)-> str:
119
+ """Prints the tree centered at the TNode
120
+
121
+ Adapted from https://stackoverflow.com/a/76691030/15496939, PierreGtch,
122
+ under CC BY-SA 4.0.
123
+ """
124
+ out = str()
125
+ out += header + self.print_conn(last) + str(self)
126
+ if checked_edges is None:
127
+ checked_edges = []
128
+ if self.gen_edge_label in checked_edges:
129
+ out += ' (derivative)\n' if len(self.children) != 0 else '\n'
130
+ return out
131
+ out += '\n'
132
+ if self.gen_edge_label is not None:
133
+ checked_edges.append(self.gen_edge_label)
134
+ for i, child in enumerate(self.children):
135
+ c_header = header + (self.conn.blank if last else self.conn.pipe)
136
+ c_last = i == len(self.children) - 1
137
+ out += child.print_tree(header=c_header, last=c_last, checked_edges=checked_edges)
138
+ return out
139
+
140
+ def get_descendents(self)-> list:
141
+ """Returns a list of child nodes on all depths (includes self)."""
142
+ out = [self]
143
+ for c in self.children:
144
+ out += c.get_descendents()
145
+ return out
146
+
147
+ def get_tree_cost(self, root=None, checked_edges: set=None):
148
+ """Returns the cost of solving to the leaves of the tree."""
149
+ if root is None:
150
+ root = self
151
+ if checked_edges is None:
152
+ checked_edges = set()
153
+ total_cost = 0
154
+ if root.gen_edge_label not in checked_edges:
155
+ total_cost += root.gen_edge_cost
156
+ checked_edges.add(root.gen_edge_label)
157
+ for st in root.children:
158
+ total_cost += self.get_tree_cost(st, checked_edges)
159
+ return total_cost
160
+
161
+ def __str__(self)-> str:
162
+ out = self.node_label
163
+ if self.value is not None:
164
+ if isinstance(self.value, float):
165
+ out += f'={self.value:.4g}'
166
+ else:
167
+ out += f'={self.value}'
168
+ out += f', index={self.index}'
169
+ if self.cost is not None:
170
+ out += f', cost={self.cost:.4g}'
171
+ return out
172
+
173
+ class Node:
174
+ """A value in the hypergraph, equivalent to a wired connection."""
175
+ def __init__(self, label: str, static_value=None, generating_edges: set=None,
176
+ leading_edges: set=None, super_nodes: set=None, sub_nodes: set=None,
177
+ description: str=None):
178
+ """Creates a new `Node` object.
179
+
180
+ Parameters
181
+ ----------
182
+ label : str
183
+ A unique identifier for the node.
184
+ static_value : Any, optional
185
+ The constant value of the node, set as an input.
186
+ generating_edges : set, optional
187
+ A set of edges that have the node as their target.
188
+ leading_edges : set, optional
189
+ A set of edges that have the node as one their sources.
190
+ super_nodes : Set[Node], optional
191
+ A set of nodes that have this node as a subset, see note [1].
192
+ sub_nodes : Set[Node], optional
193
+ A set of nodes that that have this node as a super node, see note [1].
194
+ description : str, Optional
195
+ A description of the node useful for debugging.
196
+ is_constant : bool, default=False
197
+ Describes whether the node should be reset in between simulations.
198
+ starting_index : int, default=1
199
+ The starting index of the node
200
+
201
+ Properties
202
+ ----------
203
+ is_constant : bool, default = False
204
+ Boolean indicating if the value of the node should change.
205
+
206
+ Notes
207
+ -----
208
+ 1. The subsetting accomplished by `super_nodes` is best conducted using `via` functions
209
+ on the edge, as these will be executed for every node value. One case where
210
+ this is impossible is when the node has leading edges when generated by a
211
+ certain generating edge. In this case the `via` function cannot be used as the
212
+ viability is *edge* dependent, not *value* dependent. Super nodes are provided
213
+ for this purpose, though do not provide full functionality. When searching,
214
+ the leading edges of each super node are added to the search queue as a valid
215
+ path away from the node.
216
+ """
217
+ self.label = label
218
+ self.static_value = static_value
219
+ self.generating_edges = set() if generating_edges is None else generating_edges
220
+ self.leading_edges = set() if leading_edges is None else leading_edges
221
+ self.description = description
222
+ self.is_constant = static_value is not None
223
+ self.super_nodes = set() if super_nodes is None else make_set(super_nodes)
224
+ self.sub_nodes = set() if sub_nodes is None else make_set(sub_nodes)
225
+ for sup_node in self.super_nodes:
226
+ if not isinstance(sup_node, tuple):
227
+ sup_node.sub_nodes.add(self)
228
+ for sub_node in self.sub_nodes:
229
+ if not isinstance(sub_node, tuple):
230
+ sub_node.super_nodes.add(self)
231
+
232
+ def __str__(self)-> str:
233
+ out = self.label
234
+ if self.description is not None:
235
+ out += ': ' + self.description
236
+ return out
237
+
238
+ def __iadd__(self, o):
239
+ return self.union(self, o)
240
+
241
+ @staticmethod
242
+ def union(a, *args):
243
+ """Performs a deep union of the two nodes, replacing values of `a` with those
244
+ of `b` where necessary."""
245
+ for b in args:
246
+ if not isinstance(a, Node) or not isinstance(b, Node):
247
+ raise TypeError("Inputs must be of type Node.")
248
+ if b.label is not None:
249
+ a.label = b.label
250
+ if b.static_value is not None:
251
+ a.static_value = b.static_value
252
+ a.is_constant = b.is_constant
253
+ if b.description is not None:
254
+ a.description = b.description
255
+ a.generating_edges = a.generating_edges.union(b.generating_edges)
256
+ a.leading_edges = a.leading_edges.union(b.leading_edges)
257
+ a.super_nodes = a.super_nodes.union(b.super_nodes)
258
+ a.sub_nodes = a.sub_nodes.union(b.sub_nodes)
259
+ return a
260
+
261
+ class EdgeProperty(Enum):
262
+ """Enumerated object describing various configurations of an Edge that can be
263
+ passed during setup. Used as shorthand for common configurations."""
264
+ LEVEL = 1
265
+ """Every source node in the edge must have the same index for the edge to be viable."""
266
+
267
+ class Edge:
268
+ """A relationship along a set of nodes (the source) that produces a single value."""
269
+ def __init__(self, label: str, source_nodes: dict, target: Node, rel: Callable,
270
+ via: Callable=None, weight: float=1.0, index_offset: int=0,
271
+ edge_props: EdgeProperty=None):
272
+ """Creates a new `Edge` object. This should generally be called from a Hypergraph
273
+ object using the Hypergraph.addEdge method.
274
+
275
+ Parameters
276
+ ----------
277
+ label : str
278
+ A unique string identifier for the edge.
279
+ source_nodes : dict{str : Node | Tuple(str, str)} | list[Node |
280
+ Tuple(str, str)] | Tuple(str, str) | Node
281
+ A dictionary of `Node` objects forming the source nodes of the edge,
282
+ where the key is the identifiable label for each source used in rel processing.
283
+ The Node object may be a Node, or a length-2 Tuple (identifier : attribute)
284
+ with the first element an identifier in the edge and the second element a
285
+ string referencing an attribute of the identified Node to use as the value
286
+ (a pseudo node).
287
+ rel : Callable
288
+ A function taking the values of the source nodes and returning a single
289
+ value (the target).
290
+ via : Callable, optional
291
+ A function that must be true for the edge to be traversable (viable).
292
+ Default to uncondtionally
293
+ true if not set.
294
+ weight : float > 0.0, default=1.0
295
+ The quanitified cost of traversing the edge. Must be positive, akin to a
296
+ distance measurement.
297
+ index_offset : int, default=0
298
+ Offset to apply to the target once solved for. Akin to iterating to the
299
+ next level of a cycle.
300
+ edge_props : List(EdgeProperty) | EdgeProperty | str | int, optional
301
+ A list of enumerated types that are used to configure the edge.
302
+
303
+ Properties
304
+ ----------
305
+ found_tnodes : dict
306
+ A dict of lists of source_tnodes that are viable trees to a source node,
307
+ format: {node_label : List[TNode,]}
308
+ subset_alt_labels : dict
309
+ A dictionary of alternate node labels if a source node is a super set,
310
+ format: {node_label : List[alt_node_label,]}
311
+ """
312
+ self.rel = rel
313
+ self.via = self.via_true if via is None else via
314
+ self.source_nodes = self.identify_source_nodes(source_nodes, self.rel, self.via)
315
+ self.create_found_tnodes_dict()
316
+ self.target = target
317
+ self.weight = abs(weight)
318
+ self.label = label
319
+ self.index_offset = index_offset
320
+ self.edge_props = self.setup_edge_properties(edge_props)
321
+
322
+ def create_found_tnodes_dict(self):
323
+ """Creates the found_tnodes dictionary, accounting for super nodes."""
324
+ self.subset_alt_labels = {}
325
+ self.found_tnodes = {}
326
+ for sn in self.source_nodes.values():
327
+ if not isinstance(sn, tuple):
328
+ self.subset_alt_labels[sn.label] = []
329
+ self.found_tnodes[sn.label] = []
330
+ for sub_sn in sn.sub_nodes:
331
+ self.subset_alt_labels[sn.label].append(sub_sn.label)
332
+
333
+ def add_source_node(self, sn):
334
+ """Adds a source node to an initialized edge.
335
+
336
+ Parameters
337
+ ----------
338
+ sn : dict | Node | Tuple(str, str)
339
+ The source node to be added to the edge.
340
+ """
341
+ if isinstance(sn, dict):
342
+ key, sn = list(sn.items())[0]
343
+ else:
344
+ key = self.get_source_node_identifier()
345
+ if not isinstance(sn, tuple):
346
+ sn.leading_edges.add(self)
347
+ self.found_tnodes[sn.label] = []
348
+
349
+ source_nodes = self.source_nodes | {key: sn}
350
+ if hasattr(self, 'og_source_nodes'):
351
+ self.og_source_nodes[key] = sn
352
+ self.source_nodes = self.identify_source_nodes(source_nodes)
353
+ self.edge_props = self.setup_edge_properties(self.edge_props)
354
+
355
+ def setup_edge_properties(self, inputs: None)-> list:
356
+ """Parses the edge properties."""
357
+ eps = []
358
+ if inputs is None:
359
+ return eps
360
+ inputs = make_list(inputs)
361
+ for ep in inputs:
362
+ if isinstance(ep, EdgeProperty):
363
+ eps.append(ep)
364
+ elif ep in EdgeProperty.__members__:
365
+ eps.append(EdgeProperty[ep])
366
+ elif ep in [item.value for item in EdgeProperty]:
367
+ eps.append(EdgeProperty(ep))
368
+ else:
369
+ logger.warning(f"Unrecognized edge property: {ep}")
370
+ for ep in eps:
371
+ self.handle_edge_property(ep)
372
+ return eps
373
+
374
+ def get_source_node_identifier(self, offset: int=0):
375
+ """Returns a generic label for a source node."""
376
+ return f's{len(self.source_nodes) + offset + 1}'
377
+
378
+ def handle_edge_property(self, edge_prop: EdgeProperty):
379
+ """Perform macro functions defined by the EdgeProperty."""
380
+ if edge_prop is EdgeProperty.LEVEL:
381
+ if not hasattr(self, 'og_source_nodes'):
382
+ self.og_source_nodes = dict(self.source_nodes.items())
383
+ self.og_rel = self.rel
384
+ self.og_via = self.via
385
+ sns = dict(self.source_nodes.items())
386
+ tuple_idxs = {label:el[0] for label, el in sns.items() if isinstance(el, tuple)}
387
+ for label, sn in sns.items():
388
+ if isinstance(sn, tuple) or label in tuple_idxs.values():
389
+ continue
390
+ next_id = self.get_source_node_identifier()
391
+ self.source_nodes[next_id] = (label, 'index')
392
+ tuple_idxs[next_id] = label
393
+ def og_kwargs(**kwargs):
394
+ """Returns the original keywords specified when the edge was created."""
395
+ return {key: val for key,val in kwargs.items() if key in self.og_source_nodes}
396
+ def level_check(*args, **kwargs):
397
+ """Returns true if all passed indices are equivalent."""
398
+ if not self.og_via(*args, **kwargs):
399
+ return False
400
+ idxs = {val for key, val in kwargs.items() if key in tuple_idxs}
401
+ return len(idxs) == 1
402
+
403
+ self.via = level_check
404
+ self.rel = lambda *args, **kwargs : self.og_rel(*args, **og_kwargs(**kwargs))
405
+
406
+ @staticmethod
407
+ def get_named_arguments(methods: List[Callable])-> set:
408
+ """Returns keywords for any keyed, required arguments (non-default)."""
409
+ out = set()
410
+ for method in methods:
411
+ for p in signature(method).parameters.values():
412
+ if p.kind == p.POSITIONAL_OR_KEYWORD and p.default is p.empty:
413
+ out.add(p.name)
414
+ return out
415
+
416
+ def identify_source_nodes(self, source_nodes, rel: Callable=None, via: Callable=None):
417
+ """Returns a {str: node} dictionary where each string is the keyword label used
418
+ in the rel and via methods."""
419
+ if rel is None:
420
+ rel = self.rel
421
+ if via is None:
422
+ via = self.via
423
+ if isinstance(source_nodes, dict):
424
+ return self.identify_labeled_source_nodes(source_nodes, rel, via)
425
+ source_nodes = make_list(source_nodes)
426
+ return self.identify_unlabeled_source_nodes(source_nodes, rel, via)
427
+
428
+ def identify_unlabeled_source_nodes(self, source_nodes: list, rel: Callable, via: Callable):
429
+ """Returns a {str: node} dictionary where each string is the keyword label used
430
+ in the rel and via methods."""
431
+ arg_keys = self.get_named_arguments([via, rel])
432
+ arg_keys = arg_keys.union({f's{i+1}' for i in range(len(source_nodes) - len(arg_keys))})
433
+
434
+ out = dict(zip(arg_keys, source_nodes))
435
+ return out
436
+
437
+ def identify_labeled_source_nodes(self, source_nodes: dict, rel: Callable, via: Callable):
438
+ """Returns a {str: node} dictionary where each string is the keyword label used
439
+ in the rel and via methods."""
440
+ out = {}
441
+ arg_keys = self.get_named_arguments([rel, via])
442
+ arg_keys = arg_keys.union({str(key) for key in source_nodes})
443
+
444
+ for arg_key in arg_keys:
445
+ if len(source_nodes) == 0:
446
+ return out
447
+ if arg_key in source_nodes:
448
+ sn_key = arg_key
449
+ else:
450
+ sn_key = list(source_nodes.keys())[0]
451
+ out[arg_key] = source_nodes[sn_key]
452
+ del source_nodes[sn_key]
453
+
454
+ return out
455
+
456
+ def process(self, source_tnodes: list):
457
+ """Processes the TNodes to get the value of the target."""
458
+ labeled_values = self.get_source_values(source_tnodes)
459
+ target_val = self.process_values(labeled_values)
460
+ return target_val
461
+
462
+ def get_source_values(self, source_tnodes: list):
463
+ """Returns a dictionary of source values with their relevant keys."""
464
+ source_values = {}
465
+
466
+ tuple_keys = filter(lambda key : isinstance(self.source_nodes[key], tuple),
467
+ self.source_nodes)
468
+ psuedo_nodes = {key : self.source_nodes[key] for key in tuple_keys}
469
+ for key in psuedo_nodes:
470
+ pseudo_identifier, pseduo_attribute = psuedo_nodes[key]
471
+ if pseudo_identifier in self.source_nodes:
472
+ sn_label = self.source_nodes[pseudo_identifier].label
473
+ for st in source_tnodes:
474
+ if st.node_label == sn_label:
475
+ source_values[key] = getattr(st, pseduo_attribute)
476
+ break
477
+
478
+ for st in source_tnodes:
479
+ for key, sn in self.source_nodes.items():
480
+ if not isinstance(sn, tuple) and st.node_label == sn.label:
481
+ source_values[key] = st.value
482
+ break
483
+ return source_values
484
+
485
+ def process_values(self, source_vals: dict)-> float:
486
+ """Finds the target value based on the source values."""
487
+ if None in source_vals:
488
+ return None
489
+ if self.via(**source_vals):
490
+ return self.rel(**source_vals)
491
+ return None
492
+
493
+ def get_source_tnode_combinations(self, t: TNode, DEBUG: bool=False):
494
+ """Returns all viable combinations of source nodes using the TNode `t`."""
495
+ node_label = t.node_label
496
+ if node_label not in self.found_tnodes:
497
+ for label, sub_labels in self.subset_alt_labels.items():
498
+ if t.node_label in sub_labels:
499
+ node_label = label
500
+ break
501
+ if t.label in [ft.label for ft in self.found_tnodes[node_label]]:
502
+ return []
503
+
504
+ append_to_dict_list(self.found_tnodes, t.node_label, t)
505
+ st_candidates = []
506
+
507
+ if DEBUG:
508
+ for st_label, sts in self.found_tnodes.items():
509
+ var_info = ', '.join(f'{str(st.value)[:4]}({st.index})' for st in sts)
510
+ msg = f' - {st_label}: ' + var_info
511
+ logger.log(logging.DEBUG + 2, msg)
512
+
513
+ for st_label, sts in self.found_tnodes.items():
514
+ if st_label == t.node_label:
515
+ st_candidates.append([t])
516
+ elif len(sts) == 0:
517
+ return []
518
+ else:
519
+ st_candidates.append(sts)
520
+
521
+ st_combos = itertools.product(*st_candidates)
522
+ return st_combos
523
+
524
+ @staticmethod
525
+ def via_true(*args, **kwargs):
526
+ """Returns true for all inputs (unconditional edge)."""
527
+ return True
528
+
529
+ def __str__(self):
530
+ return self.label
531
+
532
+ class Pathfinder:
533
+ """Object for searching a path through the hypergraph from a collection of source
534
+ nodes to a single target node. If the hypergraph is fully constrained and viable,
535
+ then the result of the search is a singular value of the target node."""
536
+ def __init__(self, target: Node, sources: list, nodes: dict):
537
+ """Creates a new Pathfinder object.
538
+
539
+ Parameters
540
+ ----------
541
+ target : Node
542
+ The Node that the Pathfinder will attempt to solve for.
543
+ source_nodes : list
544
+ A list of Node objects that have static values for the simulation.
545
+ nodes : dict
546
+ A dictionary of nodes taken from the hypergraph as {label : Node}.
547
+ """
548
+ self.nodes = nodes
549
+ self.source_nodes = sources
550
+ self.target_node = target
551
+ self.search_roots = []
552
+ self.search_counter = 0
553
+ """Number of nodes explored"""
554
+ self.explored_edges = {}
555
+ """Dict counting the number of times edges were processed {label : int}"""
556
+
557
+ def search(self, debug_nodes: list=None, debug_edges: list=None, search_depth: int=10000):
558
+ """Searches the hypergraph for a path from the source nodes to the target
559
+ node. Returns the solved TNode for the target and a dictionary of found values
560
+ {label : [Any,]}. """
561
+ debug_nodes = [] if debug_nodes is None else debug_nodes
562
+ debug_edges = [] if debug_edges is None else debug_edges
563
+ logger.info(f'Begin search for {self.target_node.label}')
564
+
565
+ for sn in self.source_nodes:
566
+ st = TNode(f'{sn.label}#0', sn.label, sn.static_value, cost=0.)
567
+ self.search_roots.append(st)
568
+
569
+ while len(self.search_roots) > 0:
570
+ if self.search_counter > search_depth:
571
+ self.log_debugging_report()
572
+ raise Exception("Maximum search limit exceeded.")
573
+ logger.debug('Search trees: ' + ', '.join(f'{s.node_label}' for s in self.search_roots))
574
+
575
+ root = self.select_root()
576
+ if root.node_label is self.target_node.label:
577
+ logger.info(f'Finished search for {self.target_node.label} with value of {root.value}')
578
+ self.log_debugging_report()
579
+ return root, root.values
580
+
581
+ self.explore(root, debug_nodes, debug_edges)
582
+
583
+ logger.info('Finished search, no solutions found')
584
+ self.log_debugging_report()
585
+ return None, None
586
+
587
+ def explore(self, t: TNode, debug_nodes: list=None, debug_edges: list=None):
588
+ """Discovers all possible routes from the TNode."""
589
+ n = self.nodes[t.node_label]
590
+ super_node_leading_edges = (sup_n.leading_edges for sup_n in n.super_nodes)
591
+ leading_edges = n.leading_edges.union(*super_node_leading_edges)
592
+ if n.label in debug_nodes:
593
+ logger.log(logging.DEBUG + 2, f'Exploring {n.label}, index: {t.index}, ' +
594
+ 'leading edges: ' + ', '.join(str(le) for le in leading_edges) +
595
+ f'\n{t.print_tree()}')
596
+
597
+ for i, edge in enumerate(leading_edges):
598
+ if edge.label not in self.explored_edges:
599
+ self.explored_edges[edge.label] = [0, 0, 0]
600
+ self.explored_edges[edge.label][0] += 1
601
+ DEBUG = edge.label in debug_edges
602
+ level = logging.DEBUG + (2 if DEBUG else 0)
603
+ logger.log(level, f"Edge {i}, <{edge.label}>:")
604
+
605
+ combos = edge.get_source_tnode_combinations(t, DEBUG)
606
+ for j, combo in enumerate(combos):
607
+ node_indices = ', '.join(f'{n.node_label} ({n.index})' for n in combo)
608
+ logger.debug(f' - Combo {j}: ' + node_indices)
609
+ pt = self.make_parent_tnode(combo, edge.target, edge)
610
+ self.explored_edges[edge.label][1] += 1
611
+ if pt is not None:
612
+ self.explored_edges[edge.label][2] += 1
613
+
614
+ def make_parent_tnode(self, source_tnodes: list, node: Node, edge: Edge):
615
+ """Creates a TNode for the next step along the edge."""
616
+ parent_val = edge.process(source_tnodes)
617
+ if parent_val is None:
618
+ return None
619
+ node_label = node.label
620
+ children = source_tnodes
621
+ gen_edge_label = edge.label + '#' + str(self.search_counter)
622
+ label = f'{node_label}#{self.search_counter}'
623
+ parent_t = TNode(label, node_label, parent_val, children,
624
+ gen_edge_label=gen_edge_label, gen_edge_cost=edge.weight)
625
+ parent_t.values = self.merge_found_values(parent_val, node.label, source_tnodes)
626
+ parent_t.cost = parent_t.get_tree_cost()
627
+ parent_t.index += edge.index_offset
628
+ self.search_roots.append(parent_t)
629
+ self.search_counter += 1
630
+ return parent_t
631
+
632
+ def select_root(self)-> TNode:
633
+ """Determines the most optimal path to explore."""
634
+ #TODO: Check all leading edges to find the node with the lowest cost path
635
+ if len(self.search_roots) == 0:
636
+ return None
637
+ # root = None
638
+ # for st in self.search_roots:
639
+ # if st.label == self.target_node.label:
640
+ # root = st
641
+ # break
642
+ # if root is None or root.cost > st.cost:
643
+ # root = st
644
+ # self.search_roots.remove(root)
645
+ # return root
646
+ root = min(self.search_roots, key=lambda t : t.cost)
647
+ self.search_roots.remove(root)
648
+ return root
649
+
650
+ def merge_found_values(self, parent_val, parent_label, source_tnodes: list)-> dict:
651
+ """Merges the values found in the source nodes with the parent node."""
652
+ values = {parent_label: []}
653
+ for st in source_tnodes:
654
+ for label, st_values in st.values.items():
655
+ if label not in values or len(st_values) > len(values[label]):
656
+ values[label] = st_values
657
+ values[parent_label].append(parent_val)
658
+ return values
659
+
660
+ def log_debugging_report(self):
661
+ """Prints a debugging report of the search."""
662
+ out = f'\nDebugging Report for {self.target_node.label}:\n'
663
+ out += f'\tFinal search counter: {self.search_counter}\n'
664
+ out += '\tExplored edges (# explored | # processed | # valid solution):\n'
665
+ sorted_edges = list(self.explored_edges.items())
666
+ sorted_edges.sort(key=lambda a:max(a[1]), reverse=True)
667
+ for e, vals in sorted_edges:
668
+ out += f'\t\t<{e}>: ' + ' | '.join([str(v) for v in vals]) + '\n'
669
+ logger.log(logging.DEBUG + 1, out)
670
+
671
+ class Hypergraph:
672
+ """Builder class for a hypergraph. See demos for examples on how to use."""
673
+ def __init__(self):
674
+ """Initialize a Hypergraph."""
675
+ self.nodes = {}
676
+ self.edges = {}
677
+
678
+ def get_node(self, node_key)-> Node:
679
+ """Caller function for finding a node in the hypergraph."""
680
+ if isinstance(node_key, Node):
681
+ node_key = node_key.label
682
+ try:
683
+ return self.nodes[node_key]
684
+ except KeyError:
685
+ return None
686
+
687
+ def get_edge(self, edge_key)-> Node:
688
+ """Caller function for finding a node in the hypergraph."""
689
+ if isinstance(edge_key, Edge):
690
+ edge_key = edge_key.label
691
+ try:
692
+ return self.edges[edge_key]
693
+ except KeyError:
694
+ return None
695
+
696
+ def reset(self):
697
+ """Clears all values in the hypergraph."""
698
+ for node in self.nodes.values():
699
+ if not node.is_constant:
700
+ node.static_value = None
701
+
702
+ def request_node_label(self, requested_label=None)-> str:
703
+ """Generates a unique label for a node in the hypergraph"""
704
+ label = 'n'
705
+ if requested_label is not None:
706
+ label = requested_label
707
+ i = 0
708
+ check_label = label
709
+ while check_label in self.nodes:
710
+ check_label = label + str(i := i + 1)
711
+ return check_label
712
+
713
+ def request_edge_label(self, requested_label: str=None, source_nodes: list=None)-> str:
714
+ """Generates a unique label for an edge in the hypergraph."""
715
+ label = 'e'
716
+ if requested_label is not None:
717
+ label = requested_label
718
+ elif source_nodes is not None:
719
+ label = '('+ ','.join(s.label[:4] for s in source_nodes[:-1]) + ')'
720
+ label += '->' + source_nodes[-1].label[:8]
721
+ i = 0
722
+ check_label = label
723
+ while check_label in self.edges:
724
+ check_label = label + str(i := i + 1)
725
+ return check_label
726
+
727
+ def add_node(self, *args, **kwargs)-> Node:
728
+ """Wraps Node.__init__() and also inserts the Node into the hypergraph."""
729
+ node = Node(*args, **kwargs)
730
+ self.insert_node(node)
731
+
732
+ def insert_node(self, node: Node, value=None)-> Node:
733
+ """Adds a node to the hypergraph via a union operation."""
734
+ if isinstance(node, tuple):
735
+ return None
736
+ if isinstance(node, Node):
737
+ if node.label in self.nodes:
738
+ label = node.label
739
+ self.nodes[label] += node
740
+ else:
741
+ label = self.request_node_label(node.label)
742
+ self.nodes[label] = node
743
+ else:
744
+ if node in self.nodes:
745
+ label = node
746
+ else:
747
+ label = self.request_node_label(node)
748
+ self.nodes[label] = Node(label, value)
749
+ return self.nodes[label]
750
+
751
+ def add_edge(self, sources: dict, target, rel, via=None, weight: float=1.0,
752
+ label: str=None, index_offset: int=0, edge_props=None):
753
+ """Adds an edge to the hypergraph.
754
+
755
+ Parameters
756
+ ----------
757
+ sources : dict{str : Node | Tuple(Node, str)} | list[Node |
758
+ Tuple(Node, str)] | Tuple(Node, str) | Node
759
+ A dictionary of `Node` objects forming the source nodes of the edge,
760
+ where the key is the identifiable label for each source used in rel processing.
761
+ The Node object may be a Node, or a length-2 Tuple with the second element
762
+ a string referencing an attribute of the Node to use as the value (a pseudo
763
+ node).
764
+ targets : list | str | Node
765
+ A list of nodes that are the target of the given edge, with the same type
766
+ as sources. Since each edge can only have one target, this makes a unique
767
+ edge for each target.
768
+ rel : Callable
769
+ A function taking in a value for each source node that returns a single
770
+ value for the target.
771
+ weight : float, default=1.0
772
+ The cost of traversing the edge. Must be positive.
773
+ label : str, optional
774
+ A unique identifier for the edge.
775
+ index_offset : int, default=0
776
+ Offset to apply to the target once solved for. Akin to iterating to the
777
+ next level of a cycle.
778
+ edge_props : List(EdgeProperty) | EdgeProperty | str | int, optional
779
+ A list of enumerated types that are used to configure the edge.
780
+ """
781
+ source_nodes, source_inputs = self.get_nodes_and_identifiers(sources)
782
+ target_nodes, target_inputs = self.get_nodes_and_identifiers([target])
783
+ label = self.request_edge_label(label, source_nodes + target_nodes)
784
+ edge = Edge(label, source_inputs, target_nodes[0], rel, via, weight,
785
+ index_offset=index_offset, edge_props=edge_props)
786
+ self.edges[label] = edge
787
+ for sn in source_nodes:
788
+ sn.leading_edges.add(edge)
789
+ for tn in target_nodes:
790
+ tn.generating_edges.add(edge)
791
+ return edge
792
+
793
+ def insert_edge(self, edge: Edge):
794
+ """Inserts a fully formed edge into the hypergraph."""
795
+ if not isinstance(edge, Edge):
796
+ raise TypeError('edge must be of type `Edge`')
797
+ self.edges[edge.label] = edge
798
+ tn = self.insert_node(edge.target)
799
+ tn.generating_edges.add(edge)
800
+
801
+ def get_nodes_and_identifiers(self, nodes):
802
+ """Helper function for getting a list of nodes and their identified argument
803
+ format for various input types."""
804
+ if isinstance(nodes, dict):
805
+ node_list, inputs = [], {}
806
+ for key, node in nodes.items():
807
+ if isinstance(node, tuple):
808
+ if node[0] not in nodes:
809
+ raise Exception(f"Pseudo node identifier '{node[0]}' not included in Edge.")
810
+ else:
811
+ node = self.insert_node(node)
812
+ node_list.append(node)
813
+ inputs[key] = node
814
+ return node_list, inputs
815
+
816
+ nodes = make_list(nodes)
817
+ node_list = [self.insert_node(n) for n in nodes]
818
+ inputs = [self.get_node(node) for node in nodes if not isinstance(node, tuple)]
819
+ return node_list, inputs
820
+
821
+ def set_node_values(self, node_values: dict):
822
+ """Sets the values of the given nodes."""
823
+ for key, value in node_values.items():
824
+ node = self.get_node(key)
825
+ node.static_value = value
826
+
827
+ def solve(self, target, node_values: dict=None, toPrint: bool=False,
828
+ debug_nodes: list=None, debug_edges: list=None, search_depth: int=100000):
829
+ """Runs a DFS search to identify the first valid solution for `target`.
830
+
831
+ Parameters
832
+ ----------
833
+ target : Node | str
834
+ The node or label of the node to solve for.
835
+ node_values : dict, optional
836
+ A dictionary {label : value} of input values.
837
+ toPrint : bool, default=False
838
+ Prints the search tree if set to true.
839
+ debug_nodes : List[label,], optional
840
+ A list of node labels to log debugging information for
841
+ debug_edges : List[label,], optional
842
+ A list of edge labels to log debugging information for
843
+ search_depth : int, default=100000
844
+ Number of nodes to explore before concluding no valid path.
845
+
846
+ Returns
847
+ -------
848
+ TNode | None
849
+ the TNode for the minimum-cost path found
850
+ dict | None
851
+ a dictionary of values found for each node in the search path, as {label : List[value,]}
852
+ """
853
+ self.reset()
854
+ if node_values is not None:
855
+ self.set_node_values(node_values)
856
+ source_nodes = [self.get_node(label) for label in node_values]
857
+ source_nodes += [node for node in self.nodes.values() if node.is_constant and node.label not in node_values]
858
+ else:
859
+ source_nodes = [node for node in self.nodes.values() if node.is_constant]
860
+ target_node = self.get_node(target)
861
+ pf = Pathfinder(target_node, source_nodes, self.nodes)
862
+ try:
863
+ t, found_values = pf.search(debug_nodes, debug_edges, search_depth)
864
+ except Exception as e:
865
+ logger.error(str(e))
866
+ raise e
867
+ if toPrint:
868
+ if t is not None:
869
+ print(t.print_tree())
870
+ else:
871
+ print("No solutions found")
872
+ return t, found_values
873
+
874
+ def print_paths(self, target, to_print: bool=False)-> str:
875
+ """Prints the hypertree of all paths to the target node."""
876
+ target_node = self.get_node(target)
877
+ target_tnode = self.print_paths_helper(target_node)
878
+ out = target_tnode.print_tree()
879
+ if to_print:
880
+ print(out)
881
+ return out
882
+
883
+ def print_paths_helper(self, node: Node, join_status='none', trace: list=None)-> TNode:
884
+ """Recursive helper to print all paths to the target node."""
885
+ if isinstance(node, tuple):
886
+ return None
887
+ label = f'{node.label}#{len(trace)}'
888
+ t = TNode(label, node.label, node.static_value, join_status=join_status,
889
+ trace=trace)
890
+ branch_costs = []
891
+ for edge in node.generating_edges:
892
+ if self.edge_in_cycle(edge, t):
893
+ t.node_label += '[CYCLE]'
894
+ return t
895
+
896
+ child_cost = 0
897
+ for i, child in enumerate(edge.source_nodes.values()):
898
+ c_join_status = self.get_join_status(i, len(edge.source_nodes))
899
+ c_trace = t.trace + [(t, edge)]
900
+ c_tnode = self.print_paths_helper(child, c_join_status, c_trace)
901
+ if c_tnode is None:
902
+ continue
903
+ child_cost += c_tnode.cost if c_tnode.cost is not None else 0.0
904
+ t.children.append(c_tnode)
905
+ branch_costs.append(child_cost + edge.weight)
906
+
907
+ t.cost = min(branch_costs) if len(branch_costs) > 0 else 0.
908
+ return t
909
+
910
+ def edge_in_cycle(self, edge: Edge, t: TNode):
911
+ """Returns true if the edge is part of a cycle in the tree rooted at the TNode."""
912
+ return edge.label in [e.label for tt, e in t.trace]
913
+
914
+ def get_join_status(self, index, num_children):
915
+ """Returns whether or not the node at the given index is part of a hyperedge
916
+ (`join`) or specifically the last node in a hyperedge (`join_stop`) or a
917
+ singular edge (`none`)"""
918
+ if num_children > 1:
919
+ return 'join_stop' if index == num_children - 1 else 'join'
920
+ return 'none'
@@ -0,0 +1,21 @@
1
+ """Main caller for ConstaintHg, showing a basic demo."""
2
+
3
+ from constrainthg.hypergraph import Hypergraph
4
+ from constrainthg import relations as R
5
+
6
+ print("ConstraintHg, a hypergraph modeling toolbox for systems engineering...\n")
7
+ print("Demo:")
8
+
9
+ hg = Hypergraph()
10
+ hg.add_edge(['A', 'B'], 'C', R.Rsum)
11
+ hg.add_edge('A', 'D', R.Rnegate)
12
+ hg.add_edge('B', 'E', R.Rnegate)
13
+ hg.add_edge(['D', 'E'], 'F', R.Rsum)
14
+ hg.add_edge('F', 'C', R.Rnegate)
15
+
16
+ print(hg.print_paths('C'))
17
+
18
+ print("**Inputs A and E**")
19
+ hg.solve('C', {'A':3, 'E':-7}, toPrint=True)
20
+ print("**Inputs A and B**")
21
+ hg.solve('C', {'A':3, 'B':7}, toPrint=True)
@@ -0,0 +1,165 @@
1
+ """
2
+ File: relations.py
3
+ Author: John Morris, jhmrrs@clemson.edu, https://orcid.org/0009-0005-6571-1959
4
+ Purpose: A list of basic relations employable with edges in the hypergraph.
5
+ License: All rights reserved.
6
+ Versions:
7
+ - 0.0, 7 Oct. 2024: initialized
8
+ Notes:
9
+ - Generally imported as import relations as R
10
+ - All relationship functions begin with a capital R, so that they are normally called
11
+ as `R.Rfunction`
12
+ - Each relationships should have *args, and **kwargs as its arguments and only
13
+ arguments. Specific keywords referenced in kwargs should be `s1`, `s2`, ... only.
14
+ """
15
+
16
+ import numpy as np
17
+
18
+ # AUX FUNCTIONS
19
+ def extend(args: list, kwargs: dict)-> list:
20
+ """Combines all arguments into a single list, with args leading."""
21
+ return list(args) + list(kwargs.values())
22
+
23
+ def get_keyword_arguments(args: list, kwargs: dict, excluded_keys: list):
24
+ """Combines all arguments except those with a given key. Returns the arguments
25
+ for the given keys as a dictionary
26
+ and the remaining arguments as a list.
27
+
28
+ Note that keys not found in `kwargs` are taken from `args` in the order of the
29
+ `excluded_keys` list."""
30
+ if not isinstance(excluded_keys, list):
31
+ excluded_keys = [excluded_keys]
32
+ exceptional_vals, to_combine = {}, []
33
+
34
+ for key, val in kwargs.items():
35
+ if key in excluded_keys:
36
+ exceptional_vals[key] = val
37
+ else:
38
+ to_combine.append(val)
39
+
40
+ i = 0
41
+ for key in excluded_keys:
42
+ if key not in exceptional_vals:
43
+ exceptional_vals[key] = args[i]
44
+ i += 1
45
+
46
+ to_combine += list(args[i:])
47
+ return to_combine, exceptional_vals
48
+
49
+ # ALGEBRAIC RELATIONS
50
+ def Rsum(*args, **kwargs):
51
+ """Sums all arguments."""
52
+ args = extend(args, kwargs)
53
+ return sum(args)
54
+
55
+ def Rmultiply(*args, **kwargs):
56
+ """Multiplies all arguments together."""
57
+ args = extend(args, kwargs)
58
+ out = 1
59
+ for s in args:
60
+ out *= s
61
+ return out
62
+
63
+ def Rsubtract(*args, **kwargs):
64
+ """Subtracts from `s1` all other arguments."""
65
+ args, kwargs = get_keyword_arguments(args, kwargs, 's1')
66
+ return kwargs['s1'] - sum(args)
67
+
68
+ def Rdivide(*args, **kwargs):
69
+ """Divides `s1` by all other arguments."""
70
+ args, kwargs = get_keyword_arguments(args, kwargs, 's1')
71
+ s1 = kwargs['s1']
72
+ for s in args:
73
+ s1 /= s
74
+ return s1
75
+
76
+ def Rceiling(*args, **kwargs):
77
+ """Returns the ceiling of the first argument"""
78
+ args = extend(args, kwargs)
79
+ return np.ceil(args[0])
80
+
81
+ def Rfloor(*args, **kwargs):
82
+ """Returns the floor of the first argument"""
83
+ args = extend(args, kwargs)
84
+ return np.floor(args[0])
85
+
86
+ def Rfloor_divide(*args, **kwargs):
87
+ """Returns the largest integer smaller or equal to the division of s1 and s2."""
88
+ args, kwargs = get_keyword_arguments(args, kwargs, ['s1', 's2'])
89
+ return kwargs['s1'] // kwargs['s2']
90
+
91
+ def Rnegate(*args, **kwargs):
92
+ """Returns the negative of the first argument."""
93
+ args = extend(args, kwargs)
94
+ return -args[0]
95
+
96
+ def Rinvert(*args, **kwargs):
97
+ """Inverts the first argument."""
98
+ args = extend(args, kwargs)
99
+ return 1 / args[0]
100
+
101
+ def Rmean(*args, **kwargs):
102
+ """Returns the mean of all arguments."""
103
+ args = extend(args, kwargs)
104
+ return np.mean(args)
105
+
106
+ def Rmax(*args, **kwargs):
107
+ """Returns the maximum of all arguments."""
108
+ args = extend(args, kwargs)
109
+ return max(args)
110
+
111
+ def Rmin(*args, **kwargs):
112
+ """Returns the minimum of all arguments."""
113
+ args = extend(args, kwargs)
114
+ return min(args)
115
+
116
+ def mult_and_sum(mult_identifiers: list, sum_identifiers: list):
117
+ """Convenient shorthand for multiplying the values identified in
118
+ `mult_identifiers` and adding them to the values identified in `sum_identifiers`."""
119
+ if not isinstance(mult_identifiers, list):
120
+ mult_identifiers = [mult_identifiers]
121
+ if not isinstance(sum_identifiers, list):
122
+ sum_identifiers = [sum_identifiers]
123
+ labels = mult_identifiers + sum_identifiers
124
+ def Rmultandsum(*args, **kwargs):
125
+ out = 1.0
126
+ args, kwargs = get_keyword_arguments(args, kwargs, labels)
127
+ for label in mult_identifiers:
128
+ out *= kwargs[label]
129
+ for label in sum_identifiers:
130
+ out += kwargs[label]
131
+ return out
132
+ return Rmultandsum
133
+
134
+ # OPERATIONS
135
+ def Rincrement(*args, **kwargs):
136
+ """Increments the maximum source by 1."""
137
+ args = extend(args, kwargs)
138
+ return max(args) + 1
139
+
140
+ def Rfirst(*args, **kwargs):
141
+ """Returns the first argument."""
142
+ args, kwargs = get_keyword_arguments(args, kwargs, 's1')
143
+ return kwargs['s1']
144
+
145
+ def equal(identifier: str):
146
+ """Returns a method that returns the argument with the same keyword as
147
+ `identifier`."""
148
+ def Requal(*args, **kwargs):
149
+ args, kwargs = get_keyword_arguments(args, kwargs, identifier)
150
+ return kwargs[identifier]
151
+ return Requal
152
+
153
+ def geq(identifier: str, val: int):
154
+ """Returns a method that returns True if the identifier is greater than or equal
155
+ to `val`."""
156
+ def Rcyclecounter(*args, **kwargs):
157
+ args, kwargs = get_keyword_arguments(args, kwargs, identifier)
158
+ return kwargs[identifier] >= val
159
+ return Rcyclecounter
160
+
161
+ # TRIGONOMETRY
162
+ def Rsin(*args, **kwargs):
163
+ """Returns the sine of the mean of all arguments."""
164
+ args = extend(args, kwargs)
165
+ return np.sin(np.mean(args))
@@ -0,0 +1,4 @@
1
+ Al rights reserved. All code written and referenced in this package is the property of the authors listed below, and cannot be used without permission.
2
+
3
+ Authors:
4
+ - John Morris, jhmrrs@clemson.edu, https://orcid.org/0009-0005-6571-1959
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.1
2
+ Name: constrainthg
3
+ Version: 0.1.0
4
+ Summary: Methods for building and simulating constraint hypergraphs.
5
+ Author-email: John Morris <jhmrrs@clemson.edu>
6
+ License: All rights reserved
7
+ Project-URL: Homepage, https://github.com/jmorris335/ConstraintHg/wiki
8
+ Project-URL: Documentation, https://constrainthg.readthedocs.io/en/latest/
9
+ Project-URL: Repository, https://github.com/jmorris335/ConstraintHg
10
+ Project-URL: Bug Tracker, https://github.com/jmorris335/ConstraintHg/issues
11
+ Keywords: hypergraphs,systems engineering
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python
14
+ Requires-Python: >=3.1
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+
19
+ ![Static Badge](https://img.shields.io/badge/homepage-blue?link=https%3A%2F%2Fgithub.com%2Fjmorris335%2FConstraintHg%2Fwiki)
20
+ ![Read the Docs](https://img.shields.io/readthedocs/constrainthg?link=https%3A%2F%2Fconstrainthg.readthedocs.io%2Fen%2Flatest%2Findex.html) ![Static Badge](https://img.shields.io/badge/tests-passing-brightgreen) ![GitHub Release](https://img.shields.io/github/v/release/jmorris335/ConstraintHg?include_prereleases&display_name=tag) ![GitHub last commit](https://img.shields.io/github/last-commit/jmorris335/ConstraintHg)
21
+
22
+
23
+
24
+ # ConstraintHg
25
+ This repository enables usage of hypergraphs to define and execute system models. **It is not a rigorous data storage solution. Do not use this as a database.** Note that this repo is under active development (no official release yet), therefore changes may occur rapidly. Fork the repository before using it.
26
+
27
+ ## Install
28
+ ConstraintHg is listed on the Python Package Index. Just use `pip install constrainthg` to get started.
29
+
30
+ # Introduction
31
+ Hypergraphs are normal graphs but without the constraint that edges must only link between two nodes. Because of this expanded generality, hypergraphs can be used to model more complex relationships. For instance, the relationship `A + B = C` is a multinodal relationship between three nodes, A, B, and C. You can think of all three nodes being linked by a 2D hyperedge, so that to move along that hyperedge you need at least two of three nodes.
32
+
33
+ An constraint hypergraph is a hypergraph where the relationships are constraints that can be solved for by some execution engine, generally via API calls. These constraints reveal the behavior of the system. The goal is for the hypergraph to be platform agnostic, while API calls allow for edges to be processed on any available software.
34
+
35
+ Processing a series of nodes and edges (a "route") is what constitutes a simulation, so one of the uses of an constraint hypergraph is enabling high-level simulation ability from any possible entry point in a system model.
36
+
37
+ ## Getting started
38
+ *Note that this demo is found in [`demos/demo_basic.py`](https://github.com/jmorris335/ConstraintHg/blob/main/demos/demo_basic.py)*
39
+ Let's build a basic constraint hypergraph of the following equations:
40
+ - $A + B = C$
41
+ - $A = -D$
42
+ - $B = -E$
43
+ - $D + E = F$
44
+ - $F = -C$
45
+
46
+ First, import the classes.
47
+ ```[python]
48
+ from constrainthg.hypergraph import Hypergraph
49
+ import constrainthg.relations as R
50
+ ```
51
+
52
+ A hypergraph consists of edges that map between a set of nodes to a single node. We provide the mapping by defining a constraint function (many of which are already defined in the `relationships` module). The two relationships defined in the governing equations are addition and negation. Using the typical syntax, we refer to the functions defined in `relationships` with `R.<name>`, in this case `R.Rsum` and `R.Rnegate`. To make the hypergraph we'll need to compose the 5 edges (equations) given above.
53
+ ```[python]
54
+ hg = Hypergraph()
55
+ hg.addEdge(['A', 'B'], C, R.Rsum)
56
+ hg.addEdge('A', 'D', R.Rnegate)
57
+ hg.addEdge('B', 'E', R.Rnegate)
58
+ hg.addEdge(['D', 'E'], 'F', R.Rsum)
59
+ hg.addEdge('F', 'C', R.Rnegate)
60
+ ```
61
+
62
+ We can verify that the hypergraph was made correctly by tracing all possible paths for generating C using the `printPaths` function.
63
+ ```[python]
64
+ print(hg.printPaths('C'))
65
+ ```
66
+
67
+ This should give us the following output. Hyperedges are indicated with a `◯`, with the last source separated from other edges with a `●`.
68
+ ```
69
+ └──C, cost=1
70
+ ├◯─A, cost=0
71
+ ├●─B, cost=0
72
+ └──F, cost=3
73
+ ├◯─D, cost=1
74
+ │ └──A, cost=0
75
+ └●─E, cost=1
76
+ └──B, cost=0
77
+ ```
78
+
79
+ Compute the value of $C$ by picking a set of source nodes (inputs), such as $A$ and $B$ or $A$ and $E$. Set values for the inputs and the solver will automatically calulate an optimized route to simulate $C$.
80
+ ```[python]
81
+ print("**Inputs A and E**")
82
+ hg.solve('C', {'A':3, 'E':-7}, toPrint=True)
83
+ print("**Inputs A and B**")
84
+ hg.solve('C', {'A':3, 'B':7}, toPrint=True)
85
+ ```
86
+
87
+ The output of the above should be:
88
+ ```
89
+ **Inputs A and E**
90
+ └──C= 10, cost=3
91
+ └──F= -10, cost=2
92
+ ├──D= -3, cost=1
93
+ │ └──A= 3, cost=0
94
+ └──E= -7, cost=0
95
+
96
+ **Inputs A and B**
97
+ └──C= 10, cost=1
98
+ ├──A= 3, cost=0
99
+ └──B= 7, cost=0
100
+ ```
101
+
102
+ Check out the [demos](https://github.com/jmorris335/ConstraintHg/tree/main/demos) directory for more examples.
103
+
104
+ ## Licensing and Usage
105
+ Author: [John Morris](https://www.people.clemson.edu/jhmrrs/)
106
+ Organization: [PLM Center](https://github.com/Clemson-PLMC) at Clemson University
107
+ Contact: Reach out to my GitHub profile ([jmorris335](https://github.com/jmorris335))
108
+ Usage: An official release will *likely* be provided under the CC BY-NC-SA 4.0 license, but for now **all rights are reserved**. For usage, please reach out to the author directly.
@@ -0,0 +1,10 @@
1
+ constrainthg/__init__.py,sha256=JWjSbgQaF39dbm8AGletdVnUeMTBkXalASp6AtIq4Yg,522
2
+ constrainthg/hypergraph.py,sha256=Sx1sonY7H__Hk1HAOewF03uW94_QEqG8mS-YHmryntg,39192
3
+ constrainthg/main_cli.py,sha256=6UlhXp0cgchuK4QQaFn4XUNWhn88kRnQoRJKsW7kvhs,609
4
+ constrainthg/relations.py,sha256=ot7PyaxRnw66OJBsKzEmQhYzBMHLtxeGxeiFJgynBKk,5329
5
+ constrainthg-0.1.0.dist-info/LICENSE,sha256=HppV_EaH3Uj2byyARQaA5QnKsIAcUtgEqoCNcmfBYis,234
6
+ constrainthg-0.1.0.dist-info/METADATA,sha256=hWq-enia7qnB-d_p65-rnJ3b5GAVEur_AhRp6EnT5kU,5609
7
+ constrainthg-0.1.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
8
+ constrainthg-0.1.0.dist-info/entry_points.txt,sha256=sl-PFSPLlPo9afQNrYOZBn1bsohsyZhX1733ht0lnj4,59
9
+ constrainthg-0.1.0.dist-info/top_level.txt,sha256=WBFtq48tBB6zapDM68PWiDmIdm72L7XYosXvpwhEUcA,13
10
+ constrainthg-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ constrainthg-cli = constrainthg:main_cli
@@ -0,0 +1 @@
1
+ constrainthg