Source code for wakis.field

# copyright ################################# #
# This file is part of the wakis Package.     #
# Copyright (c) CERN, 2024.                   #
# ########################################### #


import copy

import numpy as xp

try:
    import cupy as xp_gpu

    imported_cupy = True
except ImportError:
    imported_cupy = False


[docs]class Field: """ Class to handle 3D vector fields stored in a flattened 1D array. Uses lexicographic numbering: n = 1 + (i-1) + (j-1)*Nx + (k-1)*Nx*Ny len(n) = Nx*Ny*Nz Parameters ---------- Nx : int Number of grid points in the x direction. Ny : int Number of grid points in the y direction. Nz : int Number of grid points in the z direction. dtype : type, optional Data type of the field array. Default is float. use_ones : bool, optional If True, initialize the field array with ones. Otherwise, zeros. Default is False. use_gpu : bool, optional If True, use CuPy for GPU arrays. Default is False. Attributes ---------- Nx, Ny, Nz : int Grid dimensions. N : int Total number of grid points (Nx * Ny * Nz). dtype : type Data type of the field array. on_gpu : bool Whether the field is stored on GPU. xp : module Numpy or cupy module, depending on use_gpu. array : ndarray Flattened 1D field array of shape (N*3,). """ def __init__(self, Nx, Ny, Nz, dtype=float, use_ones=False, use_gpu=False): self.Nx = Nx self.Ny = Ny self.Nz = Nz self.N = Nx * Ny * Nz self.dtype = dtype self.on_gpu = use_gpu if self.on_gpu: if imported_cupy: self.xp = xp_gpu else: raise ImportError( "[!] CuPy could not be imported, please check CUDA installation" ) else: self.xp = xp if use_ones: self.array = self.xp.ones(self.N * 3, dtype=self.dtype, order="F") else: self.array = self.xp.zeros(self.N * 3, dtype=self.dtype, order="F") @property def field_x(self): """Return the x-component of the field as a 1D array.""" return self.array[0 : self.N] @property def field_y(self): """Return the y-component of the field as a 1D array.""" return self.array[self.N : 2 * self.N] @property def field_z(self): """Return the z-component of the field as a 1D array.""" return self.array[2 * self.N : 3 * self.N] @field_x.setter def field_x(self, value): """Set the x-component of the field.""" if len(value.shape) > 1: self.from_matrix(value, "x") else: self.array[0 : self.N] = value @field_y.setter def field_y(self, value): """Set the y-component of the field.""" if len(value.shape) > 1: self.from_matrix(value, "y") else: self.array[self.N : 2 * self.N] = value @field_z.setter def field_z(self, value): """Set the z-component of the field.""" if len(value.shape) > 1: self.from_matrix(value, "z") else: self.array[2 * self.N : 3 * self.N] = value
[docs] def toarray(self): """ Return the flattened field array. Returns ------- array : ndarray Flattened field array of shape (N*3,). """ return self.array
[docs] def fromarray(self, array): """ Set the field array from a flattened array. Parameters ---------- array : ndarray Flattened field array of shape (N*3,). """ self.array[:] = array
[docs] def to_matrix(self, key): """ Return the specified component as a 3D matrix. Parameters ---------- key : int or str Component to return: 0 or 'x', 1 or 'y', 2 or 'z'. Returns ------- mat : ndarray 3D array of shape (Nx, Ny, Nz) for the selected component. """ if key == 0 or key == "x": return self.xp.reshape( self.array[0 : self.N], (self.Nx, self.Ny, self.Nz), order="F" ) if key == 1 or key == "y": return self.xp.reshape( self.array[self.N : 2 * self.N], (self.Nx, self.Ny, self.Nz), order="F", ) if key == 2 or key == "z": return self.xp.reshape( self.array[2 * self.N : 3 * self.N], (self.Nx, self.Ny, self.Nz), order="F", )
[docs] def from_matrix(self, mat, key): """ Set the specified component from a 3D matrix. Parameters ---------- mat : ndarray 3D array of shape (Nx, Ny, Nz). key : int or str Component to set: 0 or 'x', 1 or 'y', 2 or 'z'. """ if key == 0 or key == "x": self.array[0 : self.N] = self.xp.reshape(mat, self.N, order="F") elif key == 1 or key == "y": self.array[self.N : 2 * self.N] = self.xp.reshape(mat, self.N, order="F") elif key == 2 or key == "z": self.array[2 * self.N : 3 * self.N] = self.xp.reshape( mat, self.N, order="F" ) else: raise IndexError("Component id not valid")
[docs] def to_gpu(self): """ Move the field array to GPU (CuPy). Returns ------- None """ if imported_cupy: self.xp = xp_gpu self.array = self.xp.asarray(self.array) # to cupy arr self.on_gpu = True else: print("*** CuPy is not imported") pass
[docs] def from_gpu(self): """ Move the field array from GPU (CuPy) to CPU (NumPy). Returns ------- None """ if self.on_gpu: self.array = self.array.get() # to numpy arr self.on_gpu = False else: print("*** GPU is not enabled") pass
def __getitem__(self, key): """ Get a field value or slice. Parameters ---------- key : tuple, int, or slice - (ix, iy, iz, component): 3D index and component ('x', 'y', 'z', or 'Abs') - int: lexico-graphic index - slice: slice of the flattened array Returns ------- value : float or ndarray Field value(s) at the specified location. """ if type(key) is tuple: if len(key) != 4: raise IndexError("Need 3 indexes and component to access the field") if key[3] == 0 or key[3] == "x": if self.on_gpu: field = self.xp.reshape( self.array[0 : self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]].get() else: field = self.xp.reshape( self.array[0 : self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]] elif key[3] == 1 or key[3] == "y": if self.on_gpu: field = self.xp.reshape( self.array[self.N : 2 * self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]].get() else: field = self.xp.reshape( self.array[self.N : 2 * self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]] elif key[3] == 2 or key[3] == "z": if self.on_gpu: field = self.xp.reshape( self.array[2 * self.N : 3 * self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]].get() else: field = self.xp.reshape( self.array[2 * self.N : 3 * self.N], (self.Nx, self.Ny, self.Nz), order="F", ) return field[key[0], key[1], key[2]] elif type(key[3]) is str and key[3].lower() == "abs": field = self.get_abs() return field[key[0], key[1], key[2]] else: raise IndexError("Component id not valid") elif type(key) is int: if key <= self.N: if self.on_gpu: return self.array[key].get() else: return self.array[key] else: raise IndexError( "Lexico-graphic index cannot be higher than product of dimensions" ) elif type(key) is slice: if self.on_gpu: return self.array[key].get() else: return self.array[key] else: raise ValueError("key must be a 3-tuple or an integer") def __setitem__(self, key, value): """ Set a field value or slice. Parameters ---------- key : tuple, int, or slice - (ix, iy, iz, component): 3D index and component - int: lexico-graphic index - slice: slice of the flattened array value : float or ndarray Value(s) to set. """ if self.on_gpu: value = self.xp.asarray(value) if type(key) is tuple: if len(key) != 4: raise IndexError("Need 3 indexes and component to access the field") else: field = self.to_matrix(key[3]) field[key[0], key[1], key[2]] = value self.from_matrix(field, key[3]) elif type(key) is int: if key <= self.N: self.array[key] = value else: raise IndexError( "Lexico-graphic index cannot be higher than product of dimensions" ) elif type(key) is slice: self.array[key] = value else: raise IndexError("key must be a 3-tuple or an integer") def __mul__(self, other, dtype=None): """ Multiply the field by a scalar, array, or matrix. Parameters ---------- other : float, int, ndarray Scalar, 1D array, or 3D matrix to multiply. dtype : type, optional Data type for the result. Returns ------- Field New Field object with the result. """ if dtype is None: dtype = self.dtype # other is number if type(other) is float or type(other) is int: mulField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) mulField.array = self.array * other # other is matrix elif len(other.shape) > 1: mulField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) for d in ["x", "y", "z"]: mulField.from_matrix(self.to_matrix(d) * other, d) # other is 1d array else: mulField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) mulField.array = self.array * other return mulField def __div__(self, other, dtype=None): """ Divide the field by a scalar, array, or matrix. Parameters ---------- other : float, int, ndarray Scalar, 1D array, or 3D matrix to divide by. dtype : type, optional Data type for the result. Returns ------- Field New Field object with the result. """ if dtype is None: dtype = self.dtype # other is number if type(other) is float or type(other) is int: divField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) divField.array = self.array / other # other is matrix if len(other.shape) > 1: divField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) for d in ["x", "y", "z"]: divField.from_matrix(self.to_matrix(d) / other, d) # other is constant or 1d array else: divField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) divField.array = self.array / other return divField def __add__(self, other, dtype=None): """ Add another field, scalar, array, or matrix to this field. Parameters ---------- other : Field, float, int, ndarray Field, scalar, 1D array, or 3D matrix to add. dtype : type, optional Data type for the result. Returns ------- Field New Field object with the result. """ if dtype is None: dtype = self.dtype if type(other) is Field: addField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) addField.field_x = self.field_x + other.field_x addField.field_y = self.field_y + other.field_y addField.field_z = self.field_z + other.field_z # other is matrix elif len(other.shape) > 1: addField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) for d in ["x", "y", "z"]: addField.from_matrix(self.to_matrix(d) + other, d) # other is constant or 1d array else: addField = Field(self.Nx, self.Ny, self.Nz, dtype=dtype) addField.array = self.array + other return addField def __repr__(self): """String representation of the field (for debugging).""" return ( "x:\n" + self.field_x.__repr__() + "\n" + "y:\n" + self.field_y.__repr__() + "\n" + "z:\n" + self.field_z.__repr__() ) def __str__(self): """String representation of the field.""" return ( "x:\n" + self.field_x.__str__() + "\n" + "y:\n" + self.field_y.__str__() + "\n" + "z:\n" + self.field_z.__str__() )
[docs] def copy(self): """ Return a deep copy of the Field object. Returns ------- Field Deep copy of the field. """ obj = type(self).__new__(self.__class__) # Create empty instance for key, value in self.__dict__.items(): if key == "xp": obj.xp = self.xp # Just copy reference, no need for deepcopy elif key == "array" and self.on_gpu: obj.array = self.xp.array( self.array ) # Ensure CuPy array is copied properly else: obj.__dict__[key] = copy.deepcopy(value) return obj
[docs] def compute_ijk(self, n): """ Compute (i, j, k) indices from a lexico-graphic index. Parameters ---------- n : int Lexico-graphic index. Returns ------- i, j, k : int 3D indices corresponding to the lexico-graphic index. """ if n > (self.N): raise IndexError( "Lexico-graphic index cannot be higher than product of dimensions" ) k = n // (self.Nx * self.Ny) i = (n - k * self.Nx * self.Ny) % self.Nx j = (n - k * self.Nx * self.Ny) // self.Nx return i, j, k
[docs] def get_abs(self, as_matrix=True): """ Compute the magnitude of the field. Parameters ---------- as_matrix : bool, optional If True, return as a 3D matrix. If False, return as a 1D array. Returns ------- abs_field : ndarray Magnitude of the field. """ if as_matrix: if self.on_gpu: return xp.sqrt( self.to_matrix("x") ** 2 + self.to_matrix("y") ** 2 + self.to_matrix("z") ** 2 ).get() else: return xp.sqrt( self.to_matrix("x") ** 2 + self.to_matrix("y") ** 2 + self.to_matrix("z") ** 2 ) else: # 1d array if self.on_gpu: return xp.sqrt( self.field_x**2 + self.field_y**2 + self.field_z**2 ).get() else: return xp.sqrt(self.field_x**2 + self.field_y**2 + self.field_z**2)
[docs] def inspect( self, plane="ZY", cmap=None, backend="matplotlib", component="z", grid=None, position=None, bounding_box=False, show_grid=False, dpi=100, figsize=[10, 6], x=None, y=None, z=None, off_screen=False, handles=False, **kwargs, ): """ Visualize 2D slices of the field components. Supports two backends: ``'matplotlib'`` (default, static 2D imshow of all three components) and ``'pyvista'`` (interactive 3D slice with a position slider for a single selected component). Parameters ---------- plane : {'XY', 'XZ', 'YZ', 'ZX', 'ZY'}, optional Plane to visualize. Default is 'YZ'. cmap : str, optional Colormap for the plot. Default is 'Reds' for positive values and "bwr" for positive/negative values. backend : {'matplotlib', 'pyvista'}, optional Visualization backend. Default is 'matplotlib'. component : {'x', 'y', 'z', 'abs'}, optional Field component to display in PyVista mode. Ignored by the matplotlib backend (which always shows all three). Default 'z'. grid : GridFIT3D or None, optional Structured grid object providing real-space coordinates and STL solids. When provided the PyVista backend uses physical units and can overlay STL surface outlines. Falls back to cell-index coordinates when ``None``. Ignored by the matplotlib backend. position : float or None, optional Initial slice position along the axis normal to ``plane``. Defaults to the domain centre. PyVista backend only. bounding_box : bool, optional If True, add a wireframe bounding box of the domain. PyVista backend only. Default False. show_grid : bool, optional If True, overlay a wireframe grid slice at each slider update. PyVista backend only. Default False. dpi : int, optional Figure DPI (matplotlib backend only). Default is 100. figsize : list, optional Figure size (matplotlib backend only). Default is [8, 6]. x, y, z : int, slice, or None, optional Custom slice indices (matplotlib backend only). If all are not None, use as custom slice. off_screen : bool, optional Matplotlib: if True, return ``(fig, axs)`` instead of showing. PyVista: if True, return the ``pyvista.Plotter`` instead of opening an interactive window. Default is False. handles : bool, optional If True, return (fig, axs) instead of showing (matplotlib only). Default is False. **kwargs Additional keyword arguments forwarded to ``imshow`` (matplotlib backend only). Returns ------- fig, axs : tuple, optional Returned when ``backend='matplotlib'`` and ``handles=True`` or ``off_screen=True``. pyvista.Plotter, optional Returned when ``backend='pyvista'`` and ``off_screen=True``. None Otherwise. """ # ------------------------------------------------------------------ # # PyVista backend # # ------------------------------------------------------------------ # if backend.lower() == "pyvista": import pyvista as pv component = component.lower() # --- Build / retrieve the structured grid --- if grid is not None and hasattr(grid, "grid"): pv_grid = grid.grid xlo, xhi = grid.xmin, grid.xmax ylo, yhi = grid.ymin, grid.ymax zlo, zhi = grid.zmin, grid.zmax else: _x = xp.linspace(0, self.Nx, self.Nx + 1) _y = xp.linspace(0, self.Ny, self.Ny + 1) _z = xp.linspace(0, self.Nz, self.Nz + 1) xlo, xhi = 0, self.Nx ylo, yhi = 0, self.Ny zlo, zhi = 0, self.Nz X, Y, Z = xp.meshgrid(_x, _y, _z, indexing="ij") pv_grid = pv.StructuredGrid(X.transpose(), Y.transpose(), Z.transpose()) # --- Assign scalar data --- if component == "abs": scalars = "Field Abs" _arr = self.get_abs(as_matrix=True) if self.on_gpu and hasattr(_arr, "get"): _arr = _arr.get() pv_grid[scalars] = _arr.reshape(self.N) else: scalars = f"Field {component}" _arr = self.to_matrix(component) if self.on_gpu and hasattr(_arr, "get"): _arr = _arr.get() pv_grid[scalars] = _arr.reshape(self.N) pv_grid.set_active_scalars(scalars) # --- Plane โ†’ normal mapping --- plane_up = plane.upper() plane_to_normal = {"XY": "z", "YZ": "x", "ZY": "x", "XZ": "y", "ZX": "y"} if plane_up not in plane_to_normal: raise ValueError( f"plane must be one of 'XY', 'XZ', 'YZ', 'ZX', 'ZY'; got '{plane}'" ) normal = plane_to_normal[plane_up] if normal == "x": axis_min, axis_max = xlo, xhi slider_title = "X Position" cy = (ylo + yhi) / 2 cz = (zlo + zhi) / 2 def origin_fn(v): return (v, cy, cz) elif normal == "y": axis_min, axis_max = ylo, yhi slider_title = "Y Position" cx = (xlo + xhi) / 2 cz = (zlo + zhi) / 2 def origin_fn(v): return (cx, v, cz) else: # z axis_min, axis_max = zlo, zhi slider_title = "Z Position" cx = (xlo + xhi) / 2 cy = (ylo + yhi) / 2 def origin_fn(v): return (cx, cy, v) if position is None: position = (axis_min + axis_max) / 2 # --- Build plotter --- pv.global_theme.allow_empty_mesh = True pl = pv.Plotter() # Initial slice initial_slice = pv_grid.slice(normal=normal, origin=origin_fn(position)) slice_actor = pl.add_mesh(initial_slice, cmap=cmap, name="slice") # Optional STL solid outlines (only when grid is a GridFIT3D) outline_actors = {} if ( grid is not None and hasattr(grid, "stl_solids") and hasattr(grid, "read_stl") ): stl_colors_map = getattr(grid, "stl_colors", {}) for key in grid.stl_solids: surf = grid.read_stl(key) if surf is not None: init_outline = surf.slice( normal=normal, origin=origin_fn(position) ) color = "black" outline_actors[key] = ( pl.add_mesh( init_outline, color=color, name=f"outline_{key}" ), surf, ) # --- Update function --- def update_slice(val): new_slice = pv_grid.slice(normal=normal, origin=origin_fn(val)) slice_actor.mapper.SetInputData(new_slice) for key, (actor, surf) in outline_actors.items(): new_outline = surf.slice(normal=normal, origin=origin_fn(val)) actor.mapper.SetInputData(new_outline) if show_grid: pl.add_mesh( new_slice, style="wireframe", color="grey", opacity=0.3, name="grid_wire", ) _view_fns = { "XY": pl.view_xy, "XZ": pl.view_xz, "YZ": pl.view_yz, "ZX": pl.view_zx, "ZY": pl.view_zy, } _view_fns.get(plane_up, pl.view_yz)() pl.render() # --- Slider --- pl.add_slider_widget( update_slice, [axis_min, axis_max], value=position, title=slider_title, pointa=(0.8, 0.6), pointb=(0.95, 0.6), style="modern", ) # --- Optional bounding box --- if bounding_box: pl.add_mesh( pv.Box(bounds=(xlo, xhi, ylo, yhi, zlo, zhi)), style="wireframe", color="black", line_width=2, name="domain_box", ) # --- Camera / aesthetics --- # Use view_*() methods to explicitly set both position and viewup # so that the first letter of the plane is horizontal and the # second letter is vertical (e.g. 'YZ' โ†’ Y horizontal, Z vertical). _view_fns = { "XY": pl.view_xy, "XZ": pl.view_xz, "YZ": pl.view_yz, "ZX": pl.view_zx, "ZY": pl.view_zy, } _view_fns.get(plane_up, pl.view_yz)() pl.set_background("mistyrose", top="white") pl.add_axes() pl.enable_anti_aliasing() pl.enable_3_lights() if off_screen: pl.off_screen = True return pl else: pl.show() return None # ------------------------------------------------------------------ # # Matplotlib backend (original implementation) # # ------------------------------------------------------------------ # import matplotlib.pyplot as plt from mpl_toolkits.axes_grid1 import make_axes_locatable if None not in (x, y, z): # custom slice transpose = False extent = None xax, yax = "No. of cells", "No. of cells" pos = "Custom slice" elif plane == "XY" or plane == "YX": key = [slice(0, self.Nx), slice(0, self.Ny), int(self.Nz // 2)] x, y, z = key[0], key[1], key[2] extent = (0, self.Nx, 0, self.Ny) xax, yax = "nx", "ny" transpose = True pos = f"z={z}" elif plane == "XZ" or plane == "ZX": key = [slice(0, self.Nx), int(self.Ny // 2), slice(0, self.Nz)] x, y, z = key[0], key[1], key[2] extent = (0, self.Nz, 0, self.Nx) xax, yax = "nz", "nx" transpose = False pos = f"y={y}" elif plane == "YZ" or plane == "ZY": key = [int(self.Nx // 2), slice(0, self.Ny), slice(0, self.Nz)] x, y, z = key[0], key[1], key[2] extent = (0, self.Nz, 0, self.Ny) xax, yax = "nz", "ny" transpose = False pos = f"x={x}" fig, axs = plt.subplots(1, 3, tight_layout=True, figsize=figsize, dpi=dpi) dims = {0: "x", 1: "y", 2: "z"} im = {} # cmap if cmap is None: if self.get_abs(as_matrix=True).min() < 0: cmap = "bwr" else: cmap = "Reds" for d in [0, 1, 2]: field = self.to_matrix(d) if self.on_gpu and hasattr(field, "get"): field = field.get() if transpose: im[d] = axs[d].imshow( field[x, y, z].T, cmap=cmap, vmin=field.min(), vmax=field.max(), extent=extent, origin="lower", **kwargs, ) else: im[d] = axs[d].imshow( field[x, y, z], cmap=cmap, vmin=field.min(), vmax=field.max(), extent=extent, origin="lower", **kwargs, ) for i, ax in enumerate(axs): ax.set_title(f"Field {dims[i]}") fig.colorbar( im[i], cax=make_axes_locatable(ax).append_axes("right", size="5%", pad=0.1), ) ax.set_xlabel(xax) ax.set_ylabel(yax) fig.suptitle(f"Field at plane {plane}, {pos}") if handles: return fig, axs if not off_screen: plt.show(block=False) return None else: return fig, axs
[docs] def inspect3D( self, field="all", backend="pyista", grid=None, xmax=None, ymax=None, zmax=None, bounding_box=True, show_grid=True, cmap="viridis", dpi=100, off_screen=False, ): """ Visualize 3D field data on the structured grid using either Matplotlib (voxel rendering) or PyVista (interactive clipping and slicing). This method provides two complementary visualization backends: - **Matplotlib**: static voxel plots of the field components (x, y, z) or all combined, useful for quick inspection, but memory intensive. - **PyVista**: interactive 3D visualization with sliders to dynamically clip the volume along X, Y, and Z, and optional wireframe slices. Parameters ---------- field : {'x', 'y', 'z', 'abs', 'all'}, optional Which field component(s) to visualize. Default is 'abs'. The 'all' option creates separate subplots for each component (only with Matplotlib backend). backend : {'matplotlib', 'pyvista'}, optional Visualization backend to use. Default is 'pyvista'. grid : object, optional Structured grid object to use for visualization. If None, a grid is constructed from the solver's internal dimensions. x, y, z : int or float, optional Maximum extents in each direction for visualization. Defaults to the full grid dimensions if not specified. bounding_box : bool, optional If True, draw a wireframe bounding box of the simulation domain (only used in PyVista backend). Default is True. show_grid : bool, optional If True, show wireframe slice planes of the grid during interactive visualization (PyVista backend). Default is True. cmap : str, optional Colormap to apply to the scalar field. Default is 'viridis'. dpi : int, optional Resolution of Matplotlib figures (only for Matplotlib backend). Default is 100. off_screen : bool, optional Whether to display the figure/plot immediately. If True, return figure/axes (Matplotlib) or the Plotter object (PyVista) for further customization instead of showing directly. Default is False. Returns ------- fig, axs : tuple, optional Returned when `backend='matplotlib'` and `off_screen=True`. pl : pyvista.Plotter, optional Returned when `backend='pyvista'` and `off_screen=True`. Notes ----- - The PyVista backend provides interactive sliders to clip the volume along each axis independently and inspect internal structures of the 3D field. - The Matplotlib backend provides a quick static voxel rendering but is limited in interactivity and scalability. """ field = field.lower() # ---------- matplotlib backend --------------- if backend.lower() == "matplotlib": if self.Nx > 50 or self.Ny > 50 or self.Nz > 50: print( "[!] Warning: Matplotlib voxel rendering is not optimized \ for large grids. Consider using the `pyvista` backend for \ better performance and interactivity." ) import matplotlib as mpl import matplotlib.pyplot as plt fig = plt.figure(tight_layout=True, dpi=dpi, figsize=[12, 6]) plot_x, plot_y, plot_z = False, False, False if field == "all": plot_x = True plot_y = True plot_z = True elif field.lower() == "x": plot_x = True elif field.lower() == "y": plot_y = True elif field.lower() == "z": plot_z = True if xmax is None: xmax = self.Nx if ymax is None: ymax = self.Ny if zmax is None: zmax = self.Nz x, y, z = self.xp.mgrid[0 : xmax + 1, 0 : ymax + 1, 0 : zmax + 1] axs = [] # field x if plot_x: arr = self.to_matrix("x")[0 : int(xmax), 0 : int(ymax), 0 : int(zmax)] if field == "all": ax = fig.add_subplot(1, 3, 1, projection="3d") else: ax = fig.add_subplot(1, 1, 1, projection="3d") vmin, vmax = ( -self.xp.max(self.xp.abs(arr)), +self.xp.max(self.xp.abs(arr)), ) norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) colors = mpl.colormaps[cmap](norm(arr)) ax.voxels(x, y, z, filled=self.xp.ones_like(arr), facecolors=colors) m = mpl.cm.ScalarMappable(cmap=cmap, norm=norm) m.set_array([]) fig.colorbar(m, ax=ax, shrink=0.5, aspect=10) ax.set_title("Field x") axs.append(ax) # field y if plot_y: arr = self.to_matrix("y")[0 : int(xmax), 0 : int(ymax), 0 : int(zmax)] if field == "all": ax = fig.add_subplot(1, 3, 2, projection="3d") else: ax = fig.add_subplot(1, 1, 1, projection="3d") vmin, vmax = ( -self.xp.max(self.xp.abs(arr)), +self.xp.max(self.xp.abs(arr)), ) norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) colors = mpl.colormaps[cmap](norm(arr)) ax.voxels(x, y, z, filled=self.xp.ones_like(arr), facecolors=colors) m = mpl.cm.ScalarMappable(cmap=cmap, norm=norm) m.set_array([]) fig.colorbar(m, ax=ax, shrink=0.5, aspect=10) ax.set_title("Field y") axs.append(ax) # field z if plot_z: arr = self.to_matrix("z")[0 : int(xmax), 0 : int(ymax), 0 : int(zmax)] if field == "all": ax = fig.add_subplot(1, 3, 3, projection="3d") else: ax = fig.add_subplot(1, 1, 1, projection="3d") vmin, vmax = ( -self.xp.max(self.xp.abs(arr)), +self.xp.max(self.xp.abs(arr)), ) norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) colors = mpl.colormaps[cmap](norm(arr)) ax.voxels(x, y, z, filled=self.xp.ones_like(arr), facecolors=colors) m = mpl.cm.ScalarMappable(cmap=cmap, norm=norm) m.set_array([]) fig.colorbar(m, ax=ax, shrink=0.5, aspect=10) ax.set_title("Field z") axs.append(ax) for i, ax in enumerate(axs): ax.set_xlabel("Nx") ax.set_ylabel("Ny") ax.set_zlabel("Nz") ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) ax.set_xlim(self.Nx, 0) ax.set_ylim(self.Ny, 0) ax.set_zlim(self.Nz, 0) if off_screen: return fig, axs else: plt.show(block=False) return None # ----------- pyvista backend --------------- else: import pyvista as pv if grid is not None and hasattr(grid, "grid"): xlo, xhi, ylo, yhi, zlo, zhi = ( grid.xmin, grid.xmax, grid.ymin, grid.ymax, grid.zmin, grid.zmax, ) grid = grid.grid if field in ("x", "y", "z"): scalars = "Field " + field _arr = self.to_matrix(field) grid[scalars] = (_arr.get() if self.on_gpu else _arr).reshape( self.N ) else: # for all or abs scalars = "Field Abs" grid[scalars] = self.get_abs().reshape(self.N) if xmax is None: xmax = xhi if ymax is None: ymax = yhi if zmax is None: zmax = zhi else: print( "[!] `grid` is not passed or is not a GridFIT3D object -> Using #N cells instead " ) x = xp.linspace(0, self.Nx, self.Nx + 1) y = xp.linspace(0, self.Ny, self.Ny + 1) z = xp.linspace(0, self.Nz, self.Nz + 1) xlo, xhi, ylo, yhi, zlo, zhi = ( x.min(), x.max(), y.min(), y.max(), z.min(), z.max(), ) if xmax is None: xmax = self.Nx if ymax is None: ymax = self.Ny if zmax is None: zmax = self.Nz X, Y, Z = xp.meshgrid(x, y, z, indexing="ij") grid = pv.StructuredGrid(X.transpose(), Y.transpose(), Z.transpose()) if field in ("x", "y", "z"): scalars = "Field " + field _arr = self.to_matrix(field) grid[scalars] = (_arr.get() if self.on_gpu else _arr).reshape( self.N ) elif field.lower() == "abs": scalars = "Field Abs" grid[scalars] = self.get_abs().reshape(self.N) else: raise ValueError( "For PyVista backend, `field` must be 'x', 'y', 'z', or 'abs'" ) pv.global_theme.allow_empty_mesh = True pl = pv.Plotter() vals = {"x": xmax, "y": ymax, "z": zmax} # --- Update function --- def update_clip(val, axis="x"): vals[axis] = val # define bounds dynamically if axis == "x": slice_obj = grid.slice(normal="x", origin=(val, 0, 0)) elif axis == "y": slice_obj = grid.slice(normal="y", origin=(0, val, 0)) else: # z slice_obj = grid.slice(normal="z", origin=(0, 0, val)) # add clipped volume (scalars) pl.add_mesh( grid.clip_box( bounds=( xlo, vals["x"], ylo, vals["y"], zlo, vals["z"], ), invert=False, ), scalars=scalars, cmap=cmap, name="clip", ) # add slice wireframe (grid structure) if show_grid: pl.add_mesh( slice_obj, style="wireframe", color="grey", name="slice", ) # --- Sliders (placed side-by-side vertically) --- pl.add_slider_widget( lambda value: update_clip(value, "x"), [xlo, xhi], value=xmax, title="X Clip", pointa=(0.8, 0.8), pointb=(0.95, 0.8), # top-right style="modern", ) pl.add_slider_widget( lambda value: update_clip(value, "y"), [ylo, yhi], value=ymax, title="Y Clip", pointa=(0.8, 0.6), pointb=(0.95, 0.6), # middle-right style="modern", ) pl.add_slider_widget( lambda value: update_clip(value, "z"), [zlo, zhi], value=zmax, title="Z Clip", pointa=(0.8, 0.4), pointb=(0.95, 0.4), # lower-right style="modern", ) # Camera orientation pl.camera_position = "zx" pl.camera.azimuth += 30 pl.camera.elevation += 30 pl.set_background("mistyrose", top="white") try: pl.add_logo_widget("../docs/img/wakis-logo-pink.png") except Exception: pass pl.add_axes() pl.enable_3_lights() pl.enable_anti_aliasing() if bounding_box: pl.add_mesh( pv.Box(bounds=(xlo, xhi, ylo, yhi, zlo, zhi)), style="wireframe", color="black", line_width=2, name="domain_box", ) if off_screen: pl.off_screen = True return pl else: pl.show(auto_close=False, interactive_update=True) return None