# This file is part of FAST-OAD_CS23-HE : A framework for rapid Overall Aircraft Design of Hybrid
# Electric Aircraft.
# Copyright (C) 2025 ISAE-SUPAERO
import numpy as np
import logging
import scipy.sparse as sci_sparse
_LOGGER = logging.getLogger(__name__)
[docs]
class HierarchicalLayout:
"""
Implements the hierarchical graph layout algorithm.
The algorithm has three main phases:
1. Layer assignment (places nodes into layers using flow_hierarchy)
2. Crossing minimization (reduces edge crossings between layers using Tutte's algorithm)
3. Coordinate assignment (positions nodes within layers)
"""
def __init__(self, graph, orientation="TB", node_layer_dict={}, sorting=True):
"""
Initialize the hierarchical layout.
:param graph: NetworkX DiGraph
:param orientation: 'TB' (top-bottom), 'BT' (bottom-top), 'LR' (left-right),
'RL' (right-left)
:param sorting: True to enable tutte's drawing algorithm for sorting
:param node_layer_dict: Dictionary mapping nodes to their layer assignment (required)
"""
self.graph = graph
self.orientation = orientation
self.layers = []
self.positions = {}
self.node_layer_dict = node_layer_dict
self.sorting = sorting
def _assign_layers(self):
"""Assign each node to a layer using provided override with flow_hierarchy as fallback."""
max_layer = max(self.node_layer_dict.values())
self.layers = [[] for _ in range(max_layer + 1)]
for node, layer in self.node_layer_dict.items():
self.layers[layer].append(node)
return self.layers
def _resolve_crossings_with_tutte(self):
"""
Apply Tutte's drawing algorithm :cite:`tutte:1963` to minimize edge crossings
in hierarchical layout. The top layer and bottom layer are fixed as boundary nodes,
while interior layers are positioned to minimize edge crossings.
:return: Dictionary mapping nodes to (x, y) coordinates
"""
# Return the node set object of Networkx into ordinary list
all_nodes = list(self.graph.nodes())
num_layers = len(self.layers)
# Identify boundary nodes (first and last layers)
boundary_nodes = self.layers[0] + self.layers[-1]
interior_nodes = [node for node in all_nodes if node not in boundary_nodes]
# Create node to index mapping
interior_idx_mapping = {node: i for i, node in enumerate(interior_nodes)}
num_interior_nodes = len(interior_nodes)
# Build adjacency list for each interior node
adjacency_dict = {
node: [] for node in all_nodes
} # generate empty lists for each interior node
# Adding the neighbor connected nodes into the list
for source, target in self.graph.edges():
adjacency_dict[source].append(target)
adjacency_dict[target].append(source)
# Assign boundary coordinates based on layer positions
boundary_coords = {}
# First layer (top/left boundary) setup
top_nodes = self.layers[0]
num_top = len(top_nodes)
for i, node in enumerate(top_nodes):
if self.orientation in ["TB", "BT"]:
x = -(num_top - 1) / 2 + i
y = -1.0
else: # LR, RL
x = -1.0
y = -(num_top - 1) / 2 + i
boundary_coords[node] = (x, y)
# Last layer (bottom/right boundary) setup
bottom_nodes = self.layers[-1]
num_bottom = len(bottom_nodes)
for i, node in enumerate(bottom_nodes):
if self.orientation in ["TB", "BT"]:
x = -(num_bottom - 1) / 2 + i
y = 1.0
else: # LR, RL
x = 1.0
y = -(num_bottom - 1) / 2 + i
boundary_coords[node] = (x, y)
# Build Laplacian matrix for interior nodes
# Laplacian matrix is the A matrix of the AX = b_x , AY = b_y linear system of equations
laplacian_matrix = np.zeros((num_interior_nodes, num_interior_nodes))
b_x = np.zeros(num_interior_nodes)
b_y = np.zeros(num_interior_nodes)
for node in interior_nodes:
i = interior_idx_mapping[node]
neighbors = adjacency_dict[node]
degree = len(neighbors)
if degree == 0:
continue
# Fill up the diagonal with the amount of edges
laplacian_matrix[i, i] = degree
for neighbor in neighbors:
# For the connected neighbor node
if neighbor in interior_idx_mapping:
j = interior_idx_mapping[neighbor]
laplacian_matrix[i, j] -= 1
else:
# Boundary node contribution (directly assign value as the coordinate is fixed)
bx, by = boundary_coords[neighbor]
b_x[i] += bx
b_y[i] += by
# Solve linear systems
try:
laplacian_sparse = sci_sparse.csr_matrix(laplacian_matrix)
x_interior = sci_sparse.linalg.spsolve(laplacian_sparse, b_x)
y_interior = sci_sparse.linalg.spsolve(laplacian_sparse, b_y)
except Exception:
# If solver fails, return empty dict to fall back to traditional layout
_LOGGER.warning(
"Tutteās drawing sort failed. Reverting to the traditional layout specified in "
"the PT configuration file."
)
return {}
# Construct final coordinates
coords = boundary_coords.copy()
for node, i in interior_idx_mapping.items():
coords[node] = (x_interior[i], y_interior[i])
# Convert Tutte coordinates to hierarchical layer coordinates
# Map nodes back to layer positions based on their Tutte y-coordinate (or x for LR/RL)
positions = {}
for layer_idx, layer_nodes in enumerate(self.layers):
if self.orientation in ["TB", "BT"]:
# y coordinate determines vertical position
layer_y = layer_idx if self.orientation == "BT" else num_layers - 1 - layer_idx
# Sort nodes by their x-coordinate from Tutte
sorted_nodes = sorted(layer_nodes, key=lambda n: coords[n][0])
num_nodes = len(sorted_nodes)
x_offset = -(num_nodes - 1) / 2
for i, node in enumerate(sorted_nodes):
x = x_offset + i
positions[node] = (x, layer_y)
else: # LR, RL
# x coordinate determines horizontal position
layer_x = layer_idx if self.orientation == "LR" else num_layers - 1 - layer_idx
# Sort nodes by their y-coordinate from Tutte
sorted_nodes = sorted(layer_nodes, key=lambda n: coords[n][1])
num_nodes = len(sorted_nodes)
y_offset = -(num_nodes - 1) / 2
for i, node in enumerate(sorted_nodes):
y = y_offset + i
positions[node] = (layer_x, y)
self.positions = positions
def _assign_coordinates(self):
"""Assign x, y coordinates to nodes based on orientation."""
# If Tutte's algorithm was applied, positions already exist
if self.positions:
return self.positions
# Otherwise use traditional coordinate assignment
self.positions = {}
max_layer = len(self.layers) - 1
for layer_idx, nodes in enumerate(self.layers):
num_nodes = len(nodes)
if self.orientation == "TB":
y = max_layer - layer_idx
x_offset = -(num_nodes - 1) / 2
for i, node in enumerate(nodes):
x = x_offset + i
self.positions[node] = (x, y)
elif self.orientation == "BT":
y = layer_idx
x_offset = -(num_nodes - 1) / 2
for i, node in enumerate(nodes):
x = x_offset + i
self.positions[node] = (x, y)
elif self.orientation == "LR":
x = layer_idx
y_offset = -(num_nodes - 1) / 2
for i, node in enumerate(nodes):
y = y_offset + i
self.positions[node] = (x, y)
elif self.orientation == "RL":
x = max_layer - layer_idx
y_offset = -(num_nodes - 1) / 2
for i, node in enumerate(nodes):
y = y_offset + i
self.positions[node] = (x, y)
return self.positions
[docs]
def generate_networkx_layout(self):
"""Compute the layout by running all phases of the algorithm."""
self._assign_layers()
if self.sorting:
self._resolve_crossings_with_tutte()
# If Tutte's algorithm didn't already assign coordinates, do it now
if not self.positions:
self._assign_coordinates()
return self.positions