# pylint: disable=all # flake8: noqa # Copied from original Odoo code: # https://github.com/odoo/odoo/blob/3e8b930b18b53e0d5cce3170d8c0f5cd8c7f4f6e/odoo/tools/graph.py # Part of Odoo. See LICENSE file for full copyright and licensing details. import operator import math from odoo import exceptions, _ # Limit the max number of iterations in computation of graph. GUARD_COUNTER_LIMIT = 20000 class Graph: def __init__(self, nodes, transitions, no_ancester=None): """Initialize graph's object @param nodes list of ids of nodes in the graph @param transitions list of edges in the graph in the form (source_node, destination_node) @param no_ancester list of nodes with no incoming edges """ self.nodes = nodes or [] self.edges = transitions or [] self.no_ancester = no_ancester or {} trans = {} for t in transitions: trans.setdefault(t[0], []) trans[t[0]].append(t[1]) self.transitions = trans self.result = {} def init_rank(self): """ Computes rank of the nodes of the graph by finding initial feasible tree """ self.edge_wt = {} for link in self.links: self.edge_wt[link] = ( self.result[link[1]]['x'] - self.result[link[0]]['x']) tot_node = len(self.partial_order) # do until all the nodes in the component are searched g_counter = 1 while self.tight_tree() < tot_node and g_counter < GUARD_COUNTER_LIMIT: g_counter += 1 if g_counter >= GUARD_COUNTER_LIMIT: raise exceptions.UserError(_( "Cannot compute diagram view. " "It seems that the flow is incorrect! " "Try to remove some broken routes without using " "diagram view." )) list_node = [] list_edge = [] for node in self.nodes: if node not in self.reachable_nodes: list_node.append(node) for edge in self.edge_wt: if edge not in self.tree_edges: list_edge.append(edge) slack = 100 for edge in list_edge: if ((edge[0] in self.reachable_nodes and edge[1] not in self.reachable_nodes) or (edge[1] in self.reachable_nodes and edge[0] not in self.reachable_nodes)): if slack > self.edge_wt[edge] - 1: slack = self.edge_wt[edge] - 1 new_edge = edge if new_edge[0] not in self.reachable_nodes: delta = -(self.edge_wt[new_edge] - 1) else: delta = self.edge_wt[new_edge] - 1 for node in self.result: if node in self.reachable_nodes: self.result[node]['x'] += delta for edge in self.edge_wt: self.edge_wt[edge] = ( self.result[edge[1]]['x'] - self.result[edge[0]]['x']) self.init_cutvalues() def tight_tree(self): self.reachable_nodes = [] self.tree_edges = [] self.reachable_node(self.start) return len(self.reachable_nodes) def reachable_node(self, node): """ Find the nodes of the graph which are only 1 rank apart from each other """ if node not in self.reachable_nodes: self.reachable_nodes.append(node) for edge in self.edge_wt: if edge[0] == node: if self.edge_wt[edge] == 1: self.tree_edges.append(edge) if edge[1] not in self.reachable_nodes: self.reachable_nodes.append(edge[1]) self.reachable_node(edge[1]) def init_cutvalues(self): """ Initialize cut values of edges of the feasible tree. Edges with negative cut-values are removed from the tree to optimize rank assignment """ self.cut_edges = {} self.head_nodes = [] i = 0 for edge in self.tree_edges: self.head_nodes = [] rest_edges = [] rest_edges += self.tree_edges del rest_edges[i] self.head_component(self.start, rest_edges) i += 1 positive = 0 negative = 0 for source_node in self.transitions: if source_node in self.head_nodes: for dest_node in self.transitions[source_node]: if dest_node not in self.head_nodes: negative += 1 else: for dest_node in self.transitions[source_node]: if dest_node in self.head_nodes: positive += 1 self.cut_edges[edge] = positive - negative def head_component(self, node, rest_edges): """ Find nodes which are reachable from the starting node, after removing an edge """ if node not in self.head_nodes: self.head_nodes.append(node) for edge in rest_edges: if edge[0] == node: self.head_component(edge[1], rest_edges) def process_ranking(self, node, level=0): """ Computes initial feasible ranking after making graph acyclic with depth-first search """ if node not in self.result: self.result[node] = {'y': None, 'x': level, 'mark': 0} else: if level > self.result[node]['x']: self.result[node]['x'] = level if self.result[node]['mark']==0: self.result[node]['mark'] = 1 for sec_end in self.transitions.get(node, []): self.process_ranking(sec_end, level + 1) def make_acyclic(self, parent, node, level, tree): """ Computes Partial-order of the nodes with depth-first search """ if node not in self.partial_order: self.partial_order[node] = {'level': level, 'mark': 0} if parent: tree.append((parent, node)) if self.partial_order[node]['mark'] == 0: self.partial_order[node]['mark'] = 1 for sec_end in self.transitions.get(node, []): self.links.append((node, sec_end)) self.make_acyclic(node, sec_end, level + 1, tree) return tree def rev_edges(self, tree): """ reverse the direction of the edges whose source-node-partail_order > destination-node-partail_order to make the graph acyclic """ is_cyclic = False i = 0 # Note, here 'links' is modified, thus there is not possible # to use enumerate # TODO: may be it have sense to rewrite without modification # of self.links? for link in self.links: src = link[0] des = link[1] edge_len = self.partial_order[des]['level'] - self.partial_order[src]['level'] if edge_len < 0: del self.links[i] self.links.insert(i, (des, src)) self.transitions[src].remove(des) self.transitions.setdefault(des, []).append(src) is_cyclic = True elif math.fabs(edge_len) > 1: is_cyclic = True i += 1 return is_cyclic def exchange(self, e, f): """ Exchange edges to make feasible-tree optimized :param e: edge with negative cut-value :param f: new edge with minimum slack-value """ del self.tree_edges[self.tree_edges.index(e)] self.tree_edges.append(f) self.init_cutvalues() def enter_edge(self, edge): """ Finds a new_edge with minimum slack value to replace an edge with negative cut-value @param edge edge with negative cut-value """ self.head_nodes = [] rest_edges = [] rest_edges += self.tree_edges del rest_edges[rest_edges.index(edge)] self.head_component(self.start, rest_edges) if edge[1] in self.head_nodes: lst_h_nodes = [] for node in self.result: if node not in self.head_nodes: lst_h_nodes.append(node) self.head_nodes = lst_h_nodes slack = 100 new_edge = edge for source_node in self.transitions: if source_node in self.head_nodes: for dest_node in self.transitions[source_node]: if dest_node not in self.head_nodes: if slack > (self.edge_wt[edge] - 1): slack = self.edge_wt[edge] - 1 new_edge = (source_node, dest_node) return new_edge def leave_edge(self): """Returns the edge with negative cut_value(if exists) """ if self.critical_edges: for edge in self.critical_edges: self.cut_edges[edge] = 0 for edge in self.cut_edges: if self.cut_edges[edge] < 0: return edge return None def finalize_rank(self, node, level): self.result[node]['x'] = level for destination in self.optimal_edges.get(node, []): self.finalize_rank(destination, level+1) def normalize(self): """The ranks are normalized by setting the least rank to zero. """ least_rank = min(x['x'] for x in self.result.values()) if least_rank != 0: for node in self.result: self.result[node]['x'] -= least_rank def make_chain(self): """ Edges between nodes more than one rank apart are replaced by chains of unit length edges between temporary nodes. """ for edge in self.edge_wt: if self.edge_wt[edge] > 1: self.transitions[edge[0]].remove(edge[1]) start = self.result[edge[0]]['x'] end = self.result[edge[1]]['x'] for rank in range(start+1, end): if not self.result.get((rank, 'temp'), False): self.result[(rank, 'temp')] = { 'y': None, 'x': rank, 'mark': 0, } for rank in range(start, end): if start == rank: self.transitions[edge[0]].append((rank + 1, 'temp')) elif rank == end-1: self.transitions.setdefault( (rank, 'temp'), []).append(edge[1]) else: self.transitions.setdefault( (rank, 'temp'), []).append((rank + 1, 'temp')) def init_order(self, node, level): """Initialize orders the nodes in each rank with depth-first search """ if not self.result[node]['y']: self.result[node]['y'] = self.order[level] self.order[level] += 1 for sec_end in self.transitions.get(node, []): if node != sec_end: self.init_order(sec_end, self.result[sec_end]['x']) def order_heuristic(self): for __ in range(12): self.wmedian() def wmedian(self): """ Applies median heuristic to find optimized order of the nodes within their ranks """ for level in self.levels: node_median = [] nodes = self.levels[level] for node in nodes: node_median.append((node, self.median_value(node, level-1))) sort_list = sorted(node_median, key=operator.itemgetter(1)) # Compute new list of nodes for this level (sorted by median value) self.levels[level] = [t[0] for t in sort_list] order = 0 # TODO: Is it correct, may be we have to use nodes from # self.levels[level] for node in nodes: self.result[node]['y'] = order order += 1 def median_value(self, node, adj_rank): """ Returns median value of a vertex , defined as the median position of the adjacent vertices :param node: node to process :param adj_rank: rank 1 less than the node's rank """ adj_nodes = self.adj_position(node, adj_rank) length = len(adj_nodes) m = length // 2 if length == 0: return -1.0 if length % 2 == 1: return adj_nodes[m] # median of the middle element if length == 2: return (adj_nodes[0] + adj_nodes[1]) / 2 left = adj_nodes[m-1] - adj_nodes[0] right = adj_nodes[length-1] - adj_nodes[m] return ( (adj_nodes[m-1] * right) + (adj_nodes[m] * left) ) / (left + right) def adj_position(self, node, adj_rank): """ Returns list of the present positions of the nodes adjacent to node in the given adjacent rank. :param node: node to process :param adj_rank: rank 1 less than the node's rank """ pre_level_nodes = self.levels.get(adj_rank, []) adj_nodes = [] if pre_level_nodes: for src in pre_level_nodes: if self.transitions.get(src) and node in self.transitions[src]: adj_nodes.append(self.result[src]['y']) return adj_nodes def preprocess_order(self): levels = {} for r in self.partial_order: lvl = self.result[r]['x'] levels.setdefault(lvl, []) levels[lvl].append(r) self.levels = levels def graph_order(self): """ Finds actual-order of the nodes with respect to maximum number of nodes in a rank in component """ mid_pos = 0.0 max_level = max(len(x) for x in self.levels.values()) for level in self.levels: if level: no = len(self.levels[level]) factor = (max_level - no) * 0.10 lst = self.levels[level] lst.reverse() if no % 2 == 0: first_half = lst[no // 2:] factor = -factor else: first_half = lst[no // 2 + 1:] # for the case when horizontal graph is there if max_level == 1: self.result[lst[no // 2]]['y'] = ( mid_pos + ( self.result[lst[no // 2]]['x'] % 2 * 0.5 ) ) else: self.result[lst[no // 2]]['y'] = mid_pos + factor last_half = lst[:no // 2] i = 1 for node in first_half: self.result[node]['y'] = mid_pos - (i + factor) i += 1 i = 1 for node in last_half: self.result[node]['y'] = mid_pos + (i + factor) i += 1 else: self.max_order += max_level+1 mid_pos = self.result[self.start]['y'] def tree_order(self, node, last=0): mid_pos = self.result[node]['y'] l = self.transitions.get(node, []) l.reverse() no = len(l) rest = no % 2 first_half = l[no // 2 + rest:] last_half = l[:no // 2] for i, child in enumerate(first_half): self.result[child]['y'] = mid_pos - (i+1 - (0 if rest else 0.5)) if self.transitions.get(child, False): if last: self.result[child]['y'] = ( last + len(self.transitions[child]) / 2 + 1) last = self.tree_order(child, last) if rest: mid_node = l[no // 2] self.result[mid_node]['y'] = mid_pos if self.transitions.get(mid_node, False): if last: self.result[mid_node]['y'] = ( last + len(self.transitions[mid_node]) / 2 + 1) if node != mid_node: last = self.tree_order(mid_node) else: if last: self.result[mid_node]['y'] = last + 1 self.result[node]['y'] = self.result[mid_node]['y'] mid_pos = self.result[node]['y'] i = 1 last_child = None for child in last_half: self.result[child]['y'] = mid_pos + (i - (0 if rest else 0.5)) last_child = child i += 1 if self.transitions.get(child, False): if last: self.result[child]['y'] = ( last + len(self.transitions[child]) / 2 + 1) if node != child: last = self.tree_order(child, last) if last_child: last = self.result[last_child]['y'] return last def process_order(self): """ Finds actual-order of the nodes with respect to maximum number of nodes in a rank in component """ if self.Is_Cyclic: max_level = max(len(x) for x in self.levels.values()) if max_level % 2: self.result[self.start]['y'] = (max_level+1) / 2 + self.max_order + (self.max_order and 1) else: self.result[self.start]['y'] = max_level / 2 + self.max_order + (self.max_order and 1) self.graph_order() else: self.result[self.start]['y'] = 0 self.tree_order(self.start, 0) min_order = math.fabs(min(x['y'] for x in self.result.values())) index = self.start_nodes.index(self.start) roots = [] if index > 0: for start in self.start_nodes[:index]: same = True for edge in self.tree_list[start][1:]: if edge in self.tree_list[self.start]: continue same = False break if same: roots.append(start) if roots: min_order += self.max_order else: min_order += self.max_order + 1 for level in self.levels: for node in self.levels[level]: self.result[node]['y'] += min_order if roots: roots.append(self.start) one_level_el = self.tree_list[self.start][0][1] base = self.result[one_level_el]['y'] # * 2 / (index + 2) no = len(roots) first_half = roots[:no // 2] if no % 2 == 0: last_half = roots[no // 2:] else: last_half = roots[no // 2 + 1:] factor = -math.floor(no // 2) for start in first_half: self.result[start]['y'] = base + factor factor += 1 if no % 2: self.result[roots[no // 2]]['y'] = base + factor factor += 1 for start in last_half: self.result[start]['y'] = base + factor factor += 1 self.max_order = max(x['y'] for x in self.result.values()) def find_starts(self): """ Finds other start nodes of the graph in the case when graph is disconnected """ rem_nodes = [] for node in self.nodes: if not self.partial_order.get(node): rem_nodes.append(node) g_counter = 0 while g_counter < GUARD_COUNTER_LIMIT: g_counter += 1 if g_counter >= GUARD_COUNTER_LIMIT: raise exceptions.UserError(_( "Cannot compute diagram view. " "It seems that the flow is incorrect! " "Try to remove some broken routes without using " "diagram view." )) if len(rem_nodes) == 1: self.start_nodes.append(rem_nodes[0]) break else: count = 0 new_start = rem_nodes[0] largest_tree = [] for node in rem_nodes: self.partial_order = {} tree = self.make_acyclic(None, node, 0, []) if len(tree)+1 > count: count = len(tree) + 1 new_start = node largest_tree = tree else: if not largest_tree: new_start = rem_nodes[0] rem_nodes.remove(new_start) self.start_nodes.append(new_start) for edge in largest_tree: if edge[0] in rem_nodes: rem_nodes.remove(edge[0]) if edge[1] in rem_nodes: rem_nodes.remove(edge[1]) if not rem_nodes: break def rank(self): """ Finds the optimized rank of the nodes using Network-simplex algorithm """ self.levels = {} self.critical_edges = [] self.partial_order = {} self.links = [] self.Is_Cyclic = False self.tree_list[self.start] = self.make_acyclic(None, self.start, 0, []) self.Is_Cyclic = self.rev_edges(self.tree_list[self.start]) self.process_ranking(self.start) self.init_rank() # make cut values of all tree edges to 0 to optimize feasible tree e = self.leave_edge() g_counter = 0 while e and g_counter < GUARD_COUNTER_LIMIT: g_counter += 1 if g_counter >= GUARD_COUNTER_LIMIT: raise exceptions.UserError(_( "Cannot compute diagram view. " "It seems that the flow is incorrect! " "Try to remove some broken routes without using " "diagram view." )) f = self.enter_edge(e) if e == f: self.critical_edges.append(e) else: self.exchange(e, f) e = self.leave_edge() # finalize rank using optimum feasible tree # self.optimal_edges = {} # for edge in self.tree_edges: # source = self.optimal_edges.setdefault(edge[0], []) # source.append(edge[1]) # # self.finalize_rank(self.start, 0) # normalization self.normalize() for edge in self.edge_wt: self.edge_wt[edge] = ( self.result[edge[1]]['x'] - self.result[edge[0]]['x']) def order_in_rank(self): """ Finds optimized order of the nodes within their ranks using median heuristic """ self.make_chain() self.preprocess_order() self.order = {} max_rank = max(x for x in self.levels) for i in range(max_rank + 1): self.order[i] = 0 self.init_order(self.start, self.result[self.start]['x']) for level in self.levels: self.levels[level].sort(key=lambda x: self.result[x]['y']) self.order_heuristic() self.process_order() def process(self, starting_node): """Process the graph to find ranks and order of the nodes @param starting_node node from where to start the graph search """ self.start_nodes = starting_node or [] self.partial_order = {} self.links = [] self.tree_list = {} if self.nodes: if self.start_nodes: # add dummy edges to the nodes which does not have # any incoming edges tree = self.make_acyclic(None, self.start_nodes[0], 0, []) for node in self.no_ancester: for sec_node in self.transitions.get(node, []): if sec_node in self.partial_order: self.transitions[self.start_nodes[0]].append(node) break self.partial_order = {} tree = self.make_acyclic(None, self.start_nodes[0], 0, []) # if graph is disconnected or no start-node is given # than to find starting_node for each component of the node if len(self.nodes) > len(self.partial_order): self.find_starts() self.max_order = 0 # for each component of the graph find ranks and order of the nodes for s in self.start_nodes: self.start = s # First step: Network simplex algorithm self.rank() # Second step: ordering nodes within ranks self.order_in_rank() def __str__(self): result = '' for lvl in self.levels: result += 'PosY: ' + str(lvl) + '\n' for node in self.levels[lvl]: result += '\tPosX: %s - Node: %s\n' % ( str(self.result[node]['y']), str(node)) return result def scale(self, maxx, maxy, nwidth=0, nheight=0, margin=20): """ Computes actual co-ordiantes of the nodes """ # for flat edges ie. source an destination nodes are on the same rank for src in self.transitions: for des in self.transitions[src]: if self.result[des]['x'] - self.result[src]['x'] == 0: self.result[src]['x'] += 0.08 self.result[des]['x'] -= 0.08 factorX = maxx + nheight factorY = maxy + nwidth for node in self.result: self.result[node]['y'] = ( (self.result[node]['y']) * factorX + margin) self.result[node]['x'] = ( (self.result[node]['x']) * factorY + margin) def result_get(self): return self.result def main(): starting_node = ['profile'] # put here nodes with flow_start=True nodes = [ 'project', 'account', 'hr', 'base', 'product', 'mrp', 'test', 'profile', ] transitions = [ ('profile', 'mrp'), ('mrp', 'project'), ('project', 'product'), ('mrp', 'hr'), ('mrp', 'test'), ('project', 'account'), ('project', 'hr'), ('product', 'base'), ('account', 'product'), ('account', 'test'), ('account', 'base'), ('hr', 'base'), ('test', 'base') ] radius = 20 g = Graph(nodes, transitions) g.process(starting_node) g.scale(radius * 3, radius * 3, radius, radius) from PIL import Image from PIL import ImageDraw img = Image.new("RGB", (800, 600), "#ffffff") draw = ImageDraw.Draw(img) result = g.result_get() node_res = {} for node in nodes: node_res[node] = result[node] for name, node in node_res.items(): draw.arc( (int(node['y']-radius), int(node['x']-radius), int(node['y']+radius), int(node['x']+radius)), 0, 360, (128, 128, 128)) draw.text( (int(node['y']), int(node['x'])), str(name), (128, 128, 128)) for t in transitions: draw.line( (int(node_res[t[0]]['y']), int(node_res[t[0]]['x']), int(node_res[t[1]]['y']), int(node_res[t[1]]['x'])), (128, 128, 128)) img.save("graph.png", "PNG") if __name__ == '__main__': main()