# SPDX-License-Identifier: MIT
# https://gitlab.windenergy.dtu.dk/TOPFARM/OptiWindNet/
from collections.abc import Sequence
from itertools import chain, product
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from matplotlib.axes import Axes
from matplotlib.patches import PathPatch
from matplotlib.path import Path
from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar
from .geometric import rotate
from .interarraylib import describe_G
from .themes import Colors
NODESIZE = 35
NODESIZE_LABELED = 135
NODESIZE_LABELED_ROOT = 92
NODESIZE_DETOUR = 90
NODESIZE_LABELED_DETOUR = 215
__all__ = ('gplot', 'pplot')
FONTSIZE_LABEL = 5
FONTSIZE_LOAD = 7
FONTSIZE_ROOT_LABEL = 4
FONTSIZE_INFO_BOX = 12
FONTSIZE_LEGEND_STRIP = 6
def _is_ccw(X, Y):
# Signed area Shoelace (https://stackoverflow.com/a/30408825/287217).
return (
X[-1] * Y[0] - Y[-1] * X[0] + np.dot(X[:-1], Y[1:]) - np.dot(Y[:-1], X[1:])
) >= 0
[docs]
def gplot(
G: nx.Graph,
ax: Axes | None = None,
node_tag: str | bool | None = None,
landscape: bool = True,
infobox: bool = True,
scalebar: tuple[float, str] | None = None,
hide_ST: bool = True,
legend: bool = False,
min_dpi: int = 192,
dark: bool | None = None,
tag_border: bool = False,
**kwargs,
) -> Axes:
"""Plot site and routeset contained in G.
This function relies on matplotlib and networkx's drawing functions. If no
Axes instance is provided, a Figure with a single Axes will be created.
Extra arguments given to gplot() will be forwarded to Figure().
Args:
ax: Axes instance to plot into. If `None`, opens a new figure.
node_tag: text tag inside each node (e.g. 'load', 'label' or any of
the nodes' attributes). If `True`, tags the nodes with their numbers.
tag_border: if True, all border and obstacle vertices get a number tag.
landscape: True -> rotate the plot by G's attribute 'landscape_angle'.
infobox: Draw text box with summary of G's main properties: capacity,
number of turbines, number of feeders, total cable length.
scalebar: (span_in_data_units, label) add a small bar to indicate the
plotted features' scale (lower right corner).
hide_ST: If coordinates include a Delaunay supertriangle, adjust the
viewport to fit only the actual vertices (i.e. no ST vertices).
legend: Add description of linestyles and node shapes.
min_dpi: Minimum dots per inch to use. matplotlib's default is used if
it is greater than this value.
**kwargs: passed on to matplotlib's Figure()
Returns:
Axes instance containing the plot.
"""
c = Colors(dark)
if node_tag is None:
kw_axes = dict(aspect='equal', xmargin=0.005, ymargin=0.005)
root_size = node_size = NODESIZE
detour_size = NODESIZE_DETOUR
else:
kw_axes = dict(aspect='equal', xmargin=0.01, ymargin=0.01)
root_size = NODESIZE_LABELED_ROOT
detour_size = NODESIZE_LABELED_DETOUR
node_size = NODESIZE_LABELED
R, T, B = (G.graph[k] for k in 'RTB')
VertexC = G.graph['VertexC']
C, D = (G.graph.get(k, 0) for k in 'CD')
border, obstacles, landscape_angle = (
G.graph.get(k) for k in 'border obstacles landscape_angle'.split()
)
if landscape and landscape_angle:
# landscape_angle is not None and not 0
VertexC = rotate(VertexC, landscape_angle)
if ax is None:
dpi = max(min_dpi, plt.rcParams['figure.dpi'])
kw_fig = dict(frameon=False, layout='constrained', dpi=dpi)
fig = plt.figure(**(kw_fig | kwargs))
ax = fig.add_subplot(**kw_axes)
else:
ax.set(**kw_axes)
ax.set_axis_off()
# draw farm border
border_opt = dict(
facecolor=c.border_face,
linestyle='dashed',
edgecolor=c.kind2color['border'],
linewidth=0.7,
)
if border is not None:
borderC = VertexC[border]
if obstacles is None:
ax.fill(*borderC.T, **border_opt)
else:
border_is_ccw = _is_ccw(*borderC.T)
obstacleC_ = [VertexC[obstacle] for obstacle in obstacles]
# path for the external border
codes = (
[Path.MOVETO]
+ (borderC.shape[0] - 1) * [Path.LINETO]
+ [Path.CLOSEPOLY]
)
points = [row for row in borderC] + [borderC[0]]
# paths for the obstacle borders
for obstacleC in obstacleC_:
codes.extend(
[Path.MOVETO]
+ (obstacleC.shape[0] - 1) * [Path.LINETO]
+ [Path.CLOSEPOLY]
)
if _is_ccw(*obstacleC.T) != border_is_ccw:
points.extend([row for row in obstacleC] + [obstacleC[0]])
else:
points.extend([row for row in obstacleC[::-1]] + [obstacleC[-1]])
# create and add matplotlib artists
path = Path(points, codes)
patch = PathPatch(path, **border_opt)
ax.add_patch(patch)
elif obstacles is not None:
# draw only obstacles
for obstacle in obstacles:
ax.fill(*VertexC[obstacle].T, **border_opt)
# setup
roots = range(-R, 0)
pos = dict(enumerate(VertexC[:-R])) | dict(enumerate(VertexC[-R:], start=-R))
contour = range(0)
detour = range(0)
if C > 0 or D > 0:
fnT = G.graph['fnT']
contour = range(T + B, T + B + C)
detour = range(T + B + C, T + B + C + D)
pos |= dict(zip(detour, VertexC[fnT[detour]]))
pos |= dict(zip(contour, VertexC[fnT[contour]]))
# default value for subtree (i.e. color for unconnected nodes)
# is the last color of the tab20 colormap (i.e. 19)
subtrees = G.nodes(data='subtree', default=19)
node_colors = [c.colors[subtrees[n] % len(c.colors)] for n in range(T)]
edges_width = 1.0
edges_capstyle = 'round'
# draw edges
for graph, edge_kind in product((G, G.graph.get('overlay')), c.kind2style):
if graph is None:
continue
edges = [(u, v) for u, v, kind in graph.edges.data('kind') if kind == edge_kind]
if edges:
if 'cables' in G.graph:
# use variable edge width
edges_width = [0.8 + 0.8 * G.edges[uv]['cable'] for uv in edges]
art = nx.draw_networkx_edges(
graph,
pos,
edgelist=edges,
label=(edge_kind or 'route'),
width=edges_width,
style=c.kind2style[edge_kind],
alpha=c.kind2alpha[edge_kind],
edge_color=c.kind2color[edge_kind],
ax=ax,
)
art.set_capstyle(edges_capstyle) # type: ignore
# draw nodes
arts = nx.draw_networkx_nodes(
G,
pos,
ax=ax,
nodelist=roots,
linewidths=0.3,
node_color=c.root_face,
edgecolors=c.root_edge,
node_size=root_size,
node_shape='s',
label='OSS',
)
arts.set_clip_on(False)
arts = nx.draw_networkx_nodes(
G,
pos,
nodelist=range(T),
edgecolors=c.term_edge,
ax=ax,
label='WTG',
node_color=node_colors,
node_size=node_size,
linewidths=0.3,
)
arts.set_clip_on(False)
if D:
# draw rings around nodes that have Detour clones
arts = nx.draw_networkx_nodes(
G,
pos,
ax=ax,
nodelist=detour,
alpha=0.4,
edgecolors=c.detour_ring,
node_color='none',
node_size=detour_size,
label='corner',
)
arts.set_clip_on(False)
# draw labels
if 'has_loads' in G.graph and node_tag == 'load':
label_options = dict(
labels={n: G.nodes[n].get('load', '-') for n in range(-R, T)},
font_size=(
{t: FONTSIZE_LOAD for t in range(T)}
| {r: FONTSIZE_LABEL for r in range(-R, 0)}
),
)
elif isinstance(node_tag, str):
# 'label' or some other node attr from node_tag
label_options = dict(
labels={n: G.nodes[n].get(node_tag, '') for n in range(-R, T)},
font_size=(
{t: FONTSIZE_LABEL for t in range(T)}
| {r: FONTSIZE_ROOT_LABEL for r in range(-R, 0)}
),
)
elif node_tag is True:
# use the node number as label
label_options = dict(
labels={n: str(n) for n in range(-R, T)},
font_size=(
{t: FONTSIZE_LABEL for t in range(T)}
| {r: FONTSIZE_LOAD for r in range(-R, 0)}
),
)
else:
label_options = dict(labels={})
arts = nx.draw_networkx_labels(
G,
pos,
ax=ax,
font_color={
n: (c.root_edge if n < 0 else 'k') for n in label_options['labels'].keys()
},
**label_options,
)
for artist in arts.values():
artist.set_clip_on(False)
if scalebar is not None:
bar = AnchoredSizeBar(ax.transData, *scalebar, 'lower right', frameon=False)
ax.add_artist(bar)
capacity = G.graph.get('capacity')
if infobox and capacity is not None:
# using the `legend()` method is a hack to get the `loc='best'` search
# algorithm of matplotlib to place the info box not covering nodes
info_art = ax.legend(
[],
labelspacing=0,
facecolor=c.border_face,
edgecolor=c.fg_color,
title='\n'.join(describe_G(G)),
framealpha=0.6,
title_fontproperties={'size': FONTSIZE_INFO_BOX},
)
plt.setp(info_art.get_title(), multialignment='center', color=c.fg_color)
else:
info_art = None
if legend:
# even if calling `legend()` twice, the info box remains
ax.legend(
ncol=8,
fontsize=FONTSIZE_LEGEND_STRIP,
loc='lower center',
columnspacing=1,
labelcolor=c.fg_color,
handletextpad=0.3,
bbox_to_anchor=(0.5, -0.07),
frameon=False,
)
if info_art is not None:
ax.add_artist(info_art)
if tag_border:
border_ = border if border is not None else []
obstacles_ = obstacles if obstacles is not None else [()]
for b in chain(border_, *(obstacles_)):
ax.text(*VertexC[b], str(b), color=c.fg_color, size=FONTSIZE_ROOT_LABEL)
if hide_ST and VertexC.shape[0] > R + T + B:
# coordinates include the supertriangle, adjust view limits to hide it
nonStC = np.r_[VertexC[: T + B], VertexC[-R:]]
minima = np.min(nonStC, axis=0)
maxima = np.max(nonStC, axis=0)
xmargin, ymargin = abs(maxima - minima) * 0.05
(xlo, xhi), (ylo, yhi) = zip(minima, maxima)
ax.set_xlim(xlo - xmargin, xhi + xmargin)
ax.set_ylim(ylo - ymargin, yhi + ymargin)
return ax
[docs]
def pplot(P: nx.PlanarEmbedding, A: nx.Graph, **kwargs) -> Axes:
"""Plot PlanarEmbedding `P` using coordinates from `A`.
Wrapper for `.plotting.gplot()`. Performs what one would expect
from `gplot(P, ...)` - which does not work because P lacks coordinates and
node 'kind' attribute. The source needs to be `A` (as opposed to `G` or
`L`) because only `A` has the supertriangle's vertices coordinates.
Args:
P: Planar embedding to plot.
A: source of vertex coordinates and 'kind'.
Returns:
Axes instance containing the plot.
"""
H = nx.create_empty_copy(A)
if 'has_loads' in H.graph:
del H.graph['has_loads']
R, T, B = (A.graph[k] for k in 'RTB')
H.add_edges_from(P.edges, kind='planar')
fnT = np.arange(R + T + B + 3)
fnT[-R:] = range(-R, 0)
H.graph['fnT'] = fnT
return gplot(H, **kwargs)
def compare(positional=None, **title2G_dict):
"""
Plot layouts side by side. dict keys are inserted in the title.
Arguments must be either a sequence of graphs or multiple
`keyword`=«graph_instance»`.
"""
if positional is not None:
if isinstance(positional, Sequence):
title2G_dict |= {
chr(i): val for i, val in enumerate(positional, start=ord('A'))
}
else:
title2G_dict[''] = positional
fig, axes = plt.subplots(1, len(title2G_dict), squeeze=False)
for ax, (title, G) in zip(axes.ravel(), title2G_dict.items()):
gplot(G, ax=ax, node_tag=None)
creator = G.graph.get('creator', 'no edges')
ax.set_title(f'{title} – {G.graph["name"]} ({creator})')