from typing import Sequence import numpy as np import sympy as sp from collections import defaultdict def inverse_direction(direction): """Returns inverse i.e. negative of given direction tuple""" return tuple([-i for i in direction]) def is_valid_stencil(stencil, max_neighborhood=None): """ Tests if a nested sequence is a valid stencil i.e. all the inner sequences have the same length. If max_neighborhood is specified, it is also verified that the stencil does not contain any direction components with absolute value greater than the maximal neighborhood. """ expected_dim = len(stencil[0]) for d in stencil: if len(d) != expected_dim: return False if max_neighborhood is not None: for d_i in d: if abs(d_i) > max_neighborhood: return False return True def is_symmetric_stencil(stencil): """Tests for every direction d, that -d is also in the stencil""" for d in stencil: if inverse_direction(d) not in stencil: return False return True def stencils_have_same_entries(s1, s2): if len(s1) != len(s2): return False return len(set(s1) - set(s2)) == 0 # -------------------------------------Expression - Coefficient Form Conversion ---------------------------------------- def stencil_coefficient_dict(expr): """Extracts coefficients in front of field accesses in a expression. Expression may only access a single field at a single index. Returns: center, coefficient dict, nonlinear part where center is the single field that is accessed in expression accessed at center and coefficient dict maps offsets to coefficients. The nonlinear part is everything that is not in the form of coefficient times field access. Examples: >>> import pystencils as ps >>> f = ps.fields("f(3) : double[2D]") >>> field, coeffs, nonlinear_part = stencil_coefficient_dict(2 * f[0, 1](1) + 3 * f[-1, 0](1) + 123) >>> assert nonlinear_part == 123 and field == f(1) >>> sorted(coeffs.items()) [((-1, 0), 3), ((0, 1), 2)] """ from .field import Field expr = expr.expand() field_accesses = expr.atoms(Field.Access) fields = set(fa.field for fa in field_accesses) accessed_indices = set(fa.index for fa in field_accesses) if len(fields) != 1: raise ValueError("Could not extract stencil coefficients. " "Expression has to be a linear function of exactly one field.") if len(accessed_indices) != 1: raise ValueError("Could not extract stencil coefficients. Field is accessed at multiple indices") field = fields.pop() idx = accessed_indices.pop() coefficients = defaultdict(lambda: 0) coefficients.update({fa.offsets: expr.coeff(fa) for fa in field_accesses}) linear_part = sum(c * field[off](*idx) for off, c in coefficients.items()) nonlinear_part = expr - linear_part return field(*idx), coefficients, nonlinear_part def stencil_coefficients(expr): """Returns two lists - one with accessed offsets and one with their coefficients. Same restrictions as `stencil_coefficient_dict` apply. Expression must not have any nonlinear part >>> import pystencils as ps >>> f = ps.fields("f(3) : double[2D]") >>> coff = stencil_coefficients(2 * f[0, 1](1) + 3 * f[-1, 0](1)) """ field_center, coefficients, nonlinear_part = stencil_coefficient_dict(expr) assert nonlinear_part == 0 stencil = list(coefficients.keys()) entries = [coefficients[c] for c in stencil] return stencil, entries def stencil_coefficient_list(expr, matrix_form=False): """Returns stencil coefficients in the form of nested lists Same restrictions as `stencil_coefficient_dict` apply. Expression must not have any nonlinear part Examples: >>> import pystencils as ps >>> f = ps.fields("f: double[2D]") >>> stencil_coefficient_list(2 * f[0, 1] + 3 * f[-1, 0]) [[0, 0, 0], [3, 0, 0], [0, 2, 0]] >>> stencil_coefficient_list(2 * f[0, 1] + 3 * f[-1, 0], matrix_form=True) Matrix([ [0, 2, 0], [3, 0, 0], [0, 0, 0]]) """ field_center, coefficients, nonlinear_part = stencil_coefficient_dict(expr) assert nonlinear_part == 0 field = field_center.field dim = field.spatial_dimensions max_offsets = defaultdict(lambda: 0) for offset in coefficients.keys(): for d, off in enumerate(offset): max_offsets[d] = max(max_offsets[d], abs(off)) if dim == 1: result = [coefficients[(i,)] for i in range(-max_offsets[0], max_offsets[0] + 1)] return sp.Matrix(result) if matrix_form else result else: y_range = list(range(-max_offsets[1], max_offsets[1] + 1)) if matrix_form: y_range.reverse() if dim == 2: result = [[coefficients[(i, j)] for i in range(-max_offsets[0], max_offsets[0] + 1)] for j in y_range] return sp.Matrix(result) if matrix_form else result elif dim == 3: result = [[[coefficients[(i, j, k)] for i in range(-max_offsets[0], max_offsets[0] + 1)] for j in y_range] for k in range(-max_offsets[2], max_offsets[2] + 1)] return [sp.Matrix(l) for l in result] if matrix_form else result else: raise ValueError("Can only handle fields with 1,2 or 3 spatial dimensions") # ------------------------------------- Point-on-compass notation ------------------------------------------------------ def offset_component_to_direction_string(coordinate_id: int, value: int) -> str: """Translates numerical offset to string notation. x offsets are labeled with east 'E' and 'W', y offsets with north 'N' and 'S' and z offsets with top 'T' and bottom 'B' If the absolute value of the offset is bigger than 1, this number is prefixed. Args: coordinate_id: integer 0, 1 or 2 standing for x,y and z value: integer offset Examples: >>> offset_component_to_direction_string(0, 1) 'E' >>> offset_component_to_direction_string(1, 2) '2N' """ assert 0 <= coordinate_id < 3, "Works only for at most 3D arrays" name_components = (('W', 'E'), # west, east ('S', 'N'), # south, north ('B', 'T')) # bottom, top if value == 0: result = "" elif value < 0: result = name_components[coordinate_id][0] else: result = name_components[coordinate_id][1] if abs(value) > 1: result = "%d%s" % (abs(value), result) return result def offset_to_direction_string(offsets: Sequence[int]) -> str: """ Translates numerical offset to string notation. For details see :func:`offset_component_to_direction_string` Args: offsets: 3-tuple with x,y,z offset Examples: >>> offset_to_direction_string([1, -1, 0]) 'SE' >>> offset_to_direction_string(([-3, 0, -2])) '2B3W' """ if len(offsets) > 3: return str(offsets) names = ["", "", ""] for i in range(len(offsets)): names[i] = offset_component_to_direction_string(i, offsets[i]) name = "".join(reversed(names)) if name == "": name = "C" return name def direction_string_to_offset(direction: str, dim: int = 3): """ Reverse mapping of :func:`offset_to_direction_string` Args: direction: string representation of offset dim: dimension of offset, i.e the length of the returned list Examples: >>> direction_string_to_offset('NW', dim=3) array([-1, 1, 0]) >>> direction_string_to_offset('NW', dim=2) array([-1, 1]) >>> direction_string_to_offset(offset_to_direction_string((3,-2,1))) array([ 3, -2, 1]) """ offset_dict = { 'C': np.array([0, 0, 0]), 'W': np.array([-1, 0, 0]), 'E': np.array([1, 0, 0]), 'S': np.array([0, -1, 0]), 'N': np.array([0, 1, 0]), 'B': np.array([0, 0, -1]), 'T': np.array([0, 0, 1]), } offset = np.array([0, 0, 0]) while len(direction) > 0: factor = 1 first_non_digit = 0 while direction[first_non_digit].isdigit(): first_non_digit += 1 if first_non_digit > 0: factor = int(direction[:first_non_digit]) direction = direction[first_non_digit:] cur_offset = offset_dict[direction[0]] offset += factor * cur_offset direction = direction[1:] return offset[:dim] # -------------------------------------- Visualization ----------------------------------------------------------------- def visualize_stencil(stencil, **kwargs): dim = len(stencil[0]) if dim == 2: visualize_stencil_2d(stencil, **kwargs) else: slicing = False if 'slice' in kwargs: slicing = kwargs['slice'] del kwargs['slice'] if slicing: visualize_stencil_3d_by_slicing(stencil, **kwargs) else: visualize_stencil_3d(stencil, **kwargs) def visualize_stencil_2d(stencil, axes=None, figure=None, data=None, textsize='12', **kwargs): """ Creates a matplotlib 2D plot of the stencil Args: stencil: sequence of directions axes: optional matplotlib axes data: data to annotate the directions with, if none given, the indices are used textsize: size of annotation text """ from matplotlib.patches import BoxStyle import matplotlib.pyplot as plt if axes is None: if figure is None: figure = plt.gcf() axes = figure.gca() text_box_style = BoxStyle("Round", pad=0.3) head_length = 0.1 max_offsets = [max(abs(d[c]) for d in stencil) for c in (0, 1)] if data is None: data = list(range(len(stencil))) for direction, annotation in zip(stencil, data): assert len(direction) == 2, "Works only for 2D stencils" if not(direction[0] == 0 and direction[1] == 0): axes.arrow(0, 0, direction[0], direction[1], head_width=0.08, head_length=head_length, color='k') if isinstance(annotation, sp.Basic): annotation = "$" + sp.latex(annotation) + "$" else: annotation = str(annotation) def position_correction(d, magnitude=0.18): if d < 0: return -magnitude elif d > 0: return +magnitude else: return 0 text_position = [direction[c] + position_correction(direction[c]) for c in (0, 1)] axes.text(*text_position, annotation, verticalalignment='center', zorder=30, horizontalalignment='center', size=textsize, bbox=dict(boxstyle=text_box_style, facecolor='#00b6eb', alpha=0.85, linewidth=0)) axes.set_axis_off() axes.set_aspect('equal') max_offsets = [m if m > 0 else 0.1 for m in max_offsets] border = 0.1 axes.set_xlim([-border - max_offsets[0], border + max_offsets[0]]) axes.set_ylim([-border - max_offsets[1], border + max_offsets[1]]) def visualize_stencil_3d_by_slicing(stencil, slice_axis=2, figure=None, data=None, **kwargs): """Visualizes a 3D, first-neighborhood stencil by plotting 3 slices along a given axis. Args: stencil: stencil as sequence of directions slice_axis: 0, 1, or 2 indicating the axis to slice through data: optional data to print as text besides the arrows """ import matplotlib.pyplot as plt for d in stencil: for element in d: assert element == -1 or element == 0 or element == 1, "This function can only first neighborhood stencils" if figure is None: figure = plt.gcf() axes = [figure.add_subplot(1, 3, i + 1) for i in range(3)] splitted_directions = [[], [], []] splitted_data = [[], [], []] axes_names = ['x', 'y', 'z'] for i, d in enumerate(stencil): split_idx = d[slice_axis] + 1 reduced_dir = tuple([element for j, element in enumerate(d) if j != slice_axis]) splitted_directions[split_idx].append(reduced_dir) splitted_data[split_idx].append(i if data is None else data[i]) for i in range(3): visualize_stencil_2d(splitted_directions[i], axes=axes[i], data=splitted_data[i], **kwargs) for i in [-1, 0, 1]: axes[i + 1].set_title("Cut at %s=%d" % (axes_names[slice_axis], i)) def visualize_stencil_3d(stencil, figure=None, axes=None, data=None, textsize='8'): """ Draws 3D stencil into a 3D coordinate system, parameters are similar to :func:`visualize_stencil_2d` If data is None, no labels are drawn. To draw the labels as in the 2D case, use ``data=list(range(len(stencil)))`` """ from matplotlib.patches import FancyArrowPatch from mpl_toolkits.mplot3d import proj3d import matplotlib.pyplot as plt from matplotlib.patches import BoxStyle from itertools import product, combinations import numpy as np class Arrow3D(FancyArrowPatch): def __init__(self, xs, ys, zs, *args, **kwargs): FancyArrowPatch.__init__(self, (0, 0), (0, 0), *args, **kwargs) self._verts3d = xs, ys, zs def draw(self, renderer): xs3d, ys3d, zs3d = self._verts3d xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, renderer.M) self.set_positions((xs[0], ys[0]), (xs[1], ys[1])) FancyArrowPatch.draw(self, renderer) if axes is None: if figure is None: figure = plt.figure() axes = figure.gca(projection='3d') axes.set_aspect("equal") if data is None: data = [None] * len(stencil) text_offset = 1.25 text_box_style = BoxStyle("Round", pad=0.3) # Draw cell (cube) r = [-1, 1] for s, e in combinations(np.array(list(product(r, r, r))), 2): if np.sum(np.abs(s - e)) == r[1] - r[0]: axes.plot3D(*zip(s, e), color="k", alpha=0.5) for d, annotation in zip(stencil, data): assert len(d) == 3, "Works only for 3D stencils" if not (d[0] == 0 and d[1] == 0 and d[2] == 0): if d[0] == 0: color = '#348abd' elif d[1] == 0: color = '#fac364' elif sum([abs(d) for d in d]) == 2: color = '#95bd50' else: color = '#808080' a = Arrow3D([0, d[0]], [0, d[1]], [0, d[2]], mutation_scale=20, lw=2, arrowstyle="-|>", color=color) axes.add_artist(a) if annotation: if isinstance(annotation, sp.Basic): annotation = "$" + sp.latex(annotation) + "$" else: annotation = str(annotation) axes.text(d[0] * text_offset, d[1] * text_offset, d[2] * text_offset, annotation, verticalalignment='center', zorder=30, size=textsize, bbox=dict(boxstyle=text_box_style, facecolor='#777777', alpha=0.6, linewidth=0)) axes.set_xlim([-text_offset * 1.1, text_offset * 1.1]) axes.set_ylim([-text_offset * 1.1, text_offset * 1.1]) axes.set_zlim([-text_offset * 1.1, text_offset * 1.1]) axes.set_axis_off() def visualize_stencil_expression(expr, **kwargs): """Displays coefficients of a linear update expression of a single field as matplotlib arrow drawing.""" stencil, coefficients = stencil_coefficients(expr) dim = len(stencil[0]) assert 0 < dim <= 3 if dim == 1: return stencil_coefficient_list(expr, matrix_form=True) elif dim == 2: return visualize_stencil_2d(stencil, data=coefficients, **kwargs) elif dim == 3: return visualize_stencil_3d_by_slicing(stencil, data=coefficients, **kwargs)