Source code for pybamm.plotting.plot_vtk

"""
VTK-based interactive visualization for unstructured mesh solutions.

Provides :class:`VTKQuickPlot`, a drop-in alternative to the matplotlib-based
:class:`QuickPlot` for 2D and 3D unstructured mesh data (cell-centered FVM
and node-centered FEM).

Also supports 0D (time-series) variables rendered as VTK line charts.
"""

from __future__ import annotations

from typing import Any

import numpy as np

import pybamm

_VTK_CELL_TYPE = {
    "triangle": 5,  # VTK_TRIANGLE
    "quad": 9,  # VTK_QUAD
    "tetrahedron": 10,  # VTK_TETRA
    "hexahedron": 12,  # VTK_HEXAHEDRON
}
_PLOT_TYPES = frozenset({"3d", "slice"})
_PANEL_OPTION_KEYS = frozenset({"plot_type", "scale", "x", "y", "z"})


def _mesh_vertices(mesh):
    """Vertex coordinates of an unstructured mesh.

    Finite-volume meshes store them as ``vertices``; the scikit-fem 3D mesh
    that backs node-centred variables stores them as ``nodes``.
    """
    return mesh.vertices if hasattr(mesh, "vertices") else mesh.nodes


def _build_vtk_grid(mesh, scale=None):
    """Build a ``vtkUnstructuredGrid`` from an unstructured mesh."""
    vtk = pybamm.import_optional_dependency("vtk")
    numpy_support = pybamm.import_optional_dependency("vtk.util.numpy_support")

    nodes = np.asarray(_mesh_vertices(mesh), dtype=float)
    if scale is not None:
        nodes = nodes * np.asarray(scale)[: nodes.shape[1]]
    if nodes.shape[1] == 2:
        nodes = np.column_stack([nodes, np.zeros(len(nodes))])

    pts = vtk.vtkPoints()
    pts.SetData(numpy_support.numpy_to_vtk(np.ascontiguousarray(nodes), deep=True))

    grid = vtk.vtkUnstructuredGrid()
    grid.SetPoints(pts)

    if hasattr(mesh, "element_type"):
        element_key = mesh.element_type
    else:
        nverts = mesh.elements.shape[1]
        if nverts == 4:
            element_key = "tetrahedron"
        elif nverts == 8:
            element_key = "hexahedron"
        elif nverts == 3:
            element_key = "triangle"
        else:
            raise pybamm.GeometryError(
                "Unable to infer VTK cell type from mesh connectivity with "
                f"{nverts} vertices per element"
            )

    cell_type = _VTK_CELL_TYPE[element_key]
    elements = np.asarray(mesh.elements)
    n_cells, n_verts = elements.shape
    id_type = np.int64 if vtk.vtkIdTypeArray().GetDataTypeSize() == 8 else np.int32
    cells = vtk.vtkCellArray()
    cells.SetData(
        numpy_support.numpy_to_vtkIdTypeArray(
            np.arange(0, (n_cells + 1) * n_verts, n_verts, dtype=id_type), deep=True
        ),
        numpy_support.numpy_to_vtkIdTypeArray(
            np.ascontiguousarray(elements.ravel(), dtype=id_type), deep=True
        ),
    )
    grid.SetCells(cell_type, cells)

    return grid


def _compute_scale(mesh):
    """Per-axis scale factors that normalise coordinate spans to the largest."""
    nodes = _mesh_vertices(mesh)
    spans = np.array(
        [nodes[:, d].max() - nodes[:, d].min() for d in range(nodes.shape[1])]
    )
    max_span = spans.max()
    if max_span == 0:
        return np.ones(nodes.shape[1])
    return max_span / np.where(spans > 0, spans, max_span)


def _resolve_scale(scale_opt, mesh):
    """Turn a scale option into a concrete array or None."""
    if scale_opt is None:
        return None
    if isinstance(scale_opt, str):
        if scale_opt == "auto":
            return _compute_scale(mesh)
        raise pybamm.OptionError(
            f"Unknown scale option {scale_opt!r}: use 'auto', None or one factor "
            "per axis."
        )
    scale = np.asarray(scale_opt, dtype=float)
    dimension = _mesh_vertices(mesh).shape[1]
    if scale.ndim != 1 or len(scale) < dimension:
        raise pybamm.OptionError(
            f"The scale option needs one factor per axis ({dimension} for this "
            f"mesh), got {scale_opt!r}."
        )
    return scale


def _set_scalars(attribute_data, expected, kind, name, values):
    """Set (or update) a named float scalar array on cell or point data."""
    numpy_support = pybamm.import_optional_dependency("vtk.util.numpy_support")

    # float64: float32 would collapse a small spread on a large offset (298.15 K
    # +- 1e-5) onto a few colours while the colour bar shows the full range
    values = np.ascontiguousarray(values, dtype=np.float64).ravel()
    if len(values) != expected:
        raise pybamm.ShapeError(
            f"Cannot attach {len(values)} {kind} values for {name!r} to a grid "
            f"with {expected} {kind}s: the variable and the grid describe "
            "different meshes."
        )
    arr = attribute_data.GetArray(name)
    if arr is None:
        arr = numpy_support.numpy_to_vtk(values, deep=True)
        arr.SetName(name)
        attribute_data.AddArray(arr)
        attribute_data.SetActiveScalars(name)
    else:
        # one vectorised copy into VTK's buffer instead of a per-value loop
        numpy_support.vtk_to_numpy(arr)[:] = values
        arr.Modified()


def _set_cell_scalars(grid, name, values):
    """Set (or update) a cell scalar array on a VTK grid."""
    _set_scalars(grid.GetCellData(), grid.GetNumberOfCells(), "cell", name, values)
    grid.Modified()


def _set_point_scalars(grid, name, values):
    """Set (or update) a point scalar array on a VTK grid."""
    _set_scalars(grid.GetPointData(), grid.GetNumberOfPoints(), "point", name, values)
    grid.Modified()


def _variable_kind(pv):
    """Classify a processed variable for VTK plotting.

    Returns ``"cell"`` for cell-centred unstructured data, ``"node"`` for
    node-centred unstructured data, ``"scalar"`` for a 0D time series and
    ``None`` for anything VTKQuickPlot cannot draw.
    """
    if isinstance(pv, pybamm.ProcessedVariableUnstructuredFVM):
        return "cell"
    if isinstance(pv, pybamm.ProcessedVariableUnstructured):
        return "node"
    if getattr(pv, "dimensions", None) == 0:
        return "scalar"
    return None


def _finite_range(name, values):
    """Min and max of the finite samples; NaNs must not set the range."""
    finite = values[np.isfinite(values)]
    if finite.size == 0:
        raise pybamm.OptionError(f"'{name}' has no finite values to plot")
    return float(finite.min()), float(finite.max())


def _viridis_lut(vmin, vmax, n=256):
    """Build a VTK lookup table using the matplotlib viridis colormap."""
    vtk = pybamm.import_optional_dependency("vtk")

    try:
        _cmap = pybamm.import_optional_dependency("matplotlib.cm", "viridis")
    except ModuleNotFoundError:
        lut = vtk.vtkLookupTable()
        lut.SetHueRange(0.667, 0.0)
        lut.SetRange(vmin, vmax)
        lut.Build()
        return lut

    lut = vtk.vtkLookupTable()
    lut.SetNumberOfTableValues(n)
    lut.SetRange(vmin, vmax)
    for i in range(n):
        r, g, b, a = _cmap(i / (n - 1))
        lut.SetTableValue(i, r, g, b, a)
    lut.Build()
    return lut


def _make_render_window(off_screen=False):
    """Create a VTK render window.

    VTK's object factory picks the window class, so a headless host selects
    OSMesa with ``VTK_DEFAULT_OPENGL_WINDOW=vtkOSOpenGLRenderWindow`` while a
    desktop keeps its native OpenGL window.
    """
    vtk = pybamm.import_optional_dependency("vtk")

    window = vtk.vtkRenderWindow()
    if off_screen:
        window.SetOffScreenRendering(1)
    return window


[docs] class VTKQuickPlot: """Interactive VTK visualization for unstructured mesh solutions. Supports spatial (unstructured 2D/3D) and 0D (time-series) variables. Parameters ---------- solutions : :class:`pybamm.Solution` or :class:`pybamm.Simulation` The solution to plot; a single-element list is also accepted. output_variables : str or list of str, optional Variables to plot. Defaults to the model's default quick-plot variables that are 0D or live on an unstructured mesh. options : dict, optional Per-variable options keyed by variable name. Each value is a dict that may contain: - ``"plot_type"``: ``"3d"`` (default) or ``"slice"`` - ``"x"`` / ``"y"`` / ``"z"``: float in [0, 1] giving the slice position as a fraction of the axis range (required when ``plot_type`` is ``"slice"``) - ``"scale"``: ``"auto"`` (default), ``None``, or ``(sx, sy, sz)`` A variable's value may also be a **list** of such dicts, in which case one panel is created per entry:: options={"T": [ {"plot_type": "3d"}, {"plot_type": "slice", "x": 0.5}, ]} interpolate_time : bool, optional Evaluate the variables at the exact slider time instead of snapping to the nearest stored solution time. Default is False. """ def __init__( self, solutions: pybamm.Solution | pybamm.Simulation | list[pybamm.Solution | pybamm.Simulation], output_variables: str | list[str] | None = None, options: dict[str, dict[str, Any] | list[dict[str, Any]]] | None = None, interpolate_time: bool = False, ): solutions = pybamm.QuickPlot.preprocess_solutions(solutions) if len(solutions) != 1: raise pybamm.OptionError( f"VTKQuickPlot plots a single solution, but {len(solutions)} were " "given. Use pybamm.QuickPlot to compare solutions." ) self.solution = solutions[0] if output_variables is None: output_variables = self._default_output_variables() if isinstance(output_variables, str): output_variables = [output_variables] if len(output_variables) == 0: raise pybamm.OptionError( "VTKQuickPlot needs at least one output variable to plot." ) self.spatial_names = [] self.spatial_vars = [] self.spatial_is_cell_data = [] self.scalar_names = [] self.scalar_vars = [] for name in output_variables: pv = self.solution[name] kind = _variable_kind(pv) if kind in ("cell", "node"): self.spatial_names.append(name) self.spatial_vars.append(pv) self.spatial_is_cell_data.append(kind == "cell") elif kind == "scalar": self.scalar_names.append(name) self.scalar_vars.append(pv) else: raise pybamm.OptionError( f"VTKQuickPlot cannot plot '{name}': only scalar variables on " "unstructured meshes and 0D time series are supported. Use " "pybamm.QuickPlot for structured-mesh and vector-field " "variables." ) self.output_variables = output_variables self.t_pts = self.solution.t self.interpolate_time = interpolate_time _defaults = {"plot_type": "3d", "scale": "auto"} raw_opts = options or {} unknown = sorted(set(raw_opts) - set(self.spatial_names)) if unknown: raise pybamm.OptionError( f"Options were given for {unknown}, which are not spatial output " f"variables of this plot ({self.spatial_names})." ) self.spatial_panels = [] for name in self.spatial_names: var_opt = raw_opts.get(name, _defaults) if isinstance(var_opt, dict): opt_list = [var_opt] else: opt_list = list(var_opt) if not opt_list: raise pybamm.OptionError( f"The options for '{name}' are empty, so it would get no panel; " "pass one option dict per panel, or drop it from " "output_variables." ) for single_opt in opt_list: unknown_keys = sorted(set(single_opt) - _PANEL_OPTION_KEYS) if unknown_keys: raise pybamm.OptionError( f"Unknown option(s) {unknown_keys} for '{name}'; the panel " f"options are {sorted(_PANEL_OPTION_KEYS)}." ) merged = dict(_defaults) merged.update(single_opt) if merged["plot_type"] not in _PLOT_TYPES: raise pybamm.OptionError( f"Unknown plot_type {merged['plot_type']!r} for '{name}'; " f"use one of {sorted(_PLOT_TYPES)}." ) self.spatial_panels.append((name, merged)) def _default_output_variables(self) -> list[str]: """The model's default quick-plot variables that VTK can draw.""" defaults = self.solution.all_models[0].default_quick_plot_variables or [] # a default may group several names onto one QuickPlot axis; VTK draws # one panel per variable, so the groups are flattened names = [ name for entry in defaults for name in ([entry] if isinstance(entry, str) else entry) ] plottable = [ name for name in names if _variable_kind(self.solution[name]) is not None ] if not plottable: raise pybamm.OptionError( "VTKQuickPlot has no default variables for this model: none of " f"its default quick-plot variables {names} are 0D or on " "an unstructured mesh. Pass output_variables explicitly." ) return plottable
[docs] def dynamic_plot(self, show_plot: bool = True) -> None: """Launch an interactive VTK window with a time slider.""" vtk = pybamm.import_optional_dependency("vtk") numpy_support = pybamm.import_optional_dependency("vtk.util.numpy_support") n_spatial = len(self.spatial_panels) n_scalar = len(self.scalar_names) n_panels = n_spatial + n_scalar spatial_data = {} spatial_mins = {} spatial_maxs = {} for name, pv in zip(self.spatial_names, self.spatial_vars, strict=True): data = np.asarray(pv(self.t_pts), dtype=float) spatial_data[name] = data spatial_mins[name], spatial_maxs[name] = _finite_range(name, data) scalar_data = {} scalar_ranges = {} for name, pv in zip(self.scalar_names, self.scalar_vars, strict=True): vals = np.asarray(pv(self.t_pts), dtype=float).ravel() scalar_data[name] = vals scalar_ranges[name] = _finite_range(name, vals) slider_h = 0.08 panel_top = 1.0 panel_bot = slider_h n_cols = int(np.ceil(np.sqrt(n_panels))) n_rows = int(np.ceil(n_panels / n_cols)) panel_height = (panel_top - panel_bot) / n_rows def viewport(index): """Normalised (xmin, ymin, xmax, ymax) of panel ``index``, row-major.""" row, col = divmod(index, n_cols) return ( col / n_cols, panel_top - (row + 1) * panel_height, (col + 1) / n_cols, panel_top - row * panel_height, ) window = _make_render_window(off_screen=not show_plot) window.SetSize(650 * n_cols, 520 * n_rows) window.SetWindowName("PyBaMM - " + ", ".join(self.output_variables)) all_renderers = [] spatial_grids = [] cell_to_point_filters = [] cutters = [] chart_views = [] time_markers = [] panel_idx = 0 # 3d panels whose scaled grids share bounds (same mesh) share one camera shared_cameras = {} spatial_renderers = [] panel_names = [] is_cell_data_by_name = { name: is_cell for name, is_cell in zip( self.spatial_names, self.spatial_is_cell_data, strict=True ) } pv_by_name = dict(zip(self.spatial_names, self.spatial_vars, strict=True)) for name, opts in self.spatial_panels: plot_type = opts.get("plot_type", "3d") # each variable is drawn on its own mesh: painting a 3-domain variable # onto a 5-domain grid would shift every value by the leading cells panel_mesh = pv_by_name[name].mesh panel_nodes = _mesh_vertices(panel_mesh) dim = panel_nodes.shape[1] # column k of the vertex array is drawn on VTK axis k; 2D meshes # store their in-plane coordinates as (x, z) axis_names = ("x", "z") if dim == 2 else ("x", "y", "z") axis_columns = {axis: k for k, axis in enumerate(axis_names)} var_scale = _resolve_scale(opts.get("scale", "auto"), panel_mesh) is_cell_data = is_cell_data_by_name[name] panel_names.append(name) g = _build_vtk_grid(panel_mesh, scale=var_scale) if is_cell_data: _set_cell_scalars(g, name, spatial_data[name][:, 0]) else: _set_point_scalars(g, name, spatial_data[name][:, 0]) spatial_grids.append(g) cell_to_point = None if is_cell_data: cell_to_point = vtk.vtkCellDataToPointData() cell_to_point.SetInputData(g) cell_to_point.Update() cell_to_point_filters.append(cell_to_point) pipeline_source = ( cell_to_point.GetOutputPort() if cell_to_point is not None else g ) cutter = None if plot_type == "slice": axes_given = [ak for ak in ("x", "y", "z") if ak in opts] if len(axes_given) != 1: raise pybamm.OptionError( f"plot_type='slice' for '{name}' requires exactly one of " f"'x', 'y', or 'z' specifying the slice fraction, got " f"{axes_given}." ) axis_key = axes_given[0] if axis_key not in axis_columns: raise pybamm.OptionError( f"Cannot slice '{name}' along '{axis_key}': its " f"{dim}D mesh has coordinates {', '.join(axis_names)}." ) axis_idx = axis_columns[axis_key] frac = float(opts[axis_key]) if not 0.0 <= frac <= 1.0: raise pybamm.OptionError( f"The slice position for '{name}' must be a fraction in " f"[0, 1] of the {axis_key} range, got {frac}." ) lo = float(panel_nodes[:, axis_idx].min()) hi = float(panel_nodes[:, axis_idx].max()) # a plane exactly on a boundary face cuts nothing: stay just inside margin = 1e-6 * (hi - lo) phys_val = min(max(lo + frac * (hi - lo), lo + margin), hi - margin) scaled_val = ( phys_val * var_scale[axis_idx] if var_scale is not None else phys_val ) plane = vtk.vtkPlane() origin = [0.0, 0.0, 0.0] origin[axis_idx] = scaled_val plane.SetOrigin(origin) normal = [0.0, 0.0, 0.0] normal[axis_idx] = 1.0 plane.SetNormal(normal) cutter = vtk.vtkCutter() cutter.SetCutFunction(plane) if cell_to_point is not None: cutter.SetInputConnection(pipeline_source) else: cutter.SetInputData(pipeline_source) cutter.Update() mapper_source = cutter.GetOutputPort() else: if cell_to_point is not None: mapper_source = pipeline_source else: mapper_source = None cutters.append(cutter) lut = _viridis_lut(spatial_mins[name], spatial_maxs[name]) mapper = vtk.vtkDataSetMapper() if mapper_source is not None: mapper.SetInputConnection(mapper_source) else: mapper.SetInputData(g) mapper.SetScalarRange(spatial_mins[name], spatial_maxs[name]) mapper.SetScalarModeToUsePointData() mapper.SelectColorArray(name) mapper.SetLookupTable(lut) mapper.InterpolateScalarsBeforeMappingOn() actor = vtk.vtkActor() actor.SetMapper(mapper) if plot_type == "slice": actor.GetProperty().EdgeVisibilityOff() else: actor.GetProperty().EdgeVisibilityOn() actor.GetProperty().SetEdgeColor(0.2, 0.2, 0.2) actor.GetProperty().SetLineWidth(0.3) sb = vtk.vtkScalarBarActor() sb.SetLookupTable(lut) sb.SetTitle("") sb.SetNumberOfLabels(5) sb.SetWidth(0.2) sb.SetHeight(0.5) sb.SetPosition(0.79, 0.25) sb.GetLabelTextProperty().SetFontSize(22) sb.GetLabelTextProperty().SetColor(0, 0, 0) sb.SetUnconstrainedFontSize(True) # 4 significant figures without a width spec: "1265" and "303.2" # rather than a clipped "1.27e+" or a dangling "303." sb.SetLabelFormat("%.4g") title_actor = vtk.vtkTextActor() title_actor.SetInput(name) title_actor.GetTextProperty().SetFontSize(30) title_actor.GetTextProperty().SetColor(0, 0, 0) title_actor.GetTextProperty().SetBold(True) title_actor.GetTextProperty().SetJustificationToCentered() title_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() title_actor.SetPosition(0.5, 0.92) ren = vtk.vtkRenderer() ren.AddActor(actor) ren.AddViewProp(sb) ren.AddViewProp(title_actor) ren.SetBackground(1, 1, 1) ren.SetViewport(*viewport(panel_idx)) if plot_type == "slice": # Use the cutter output bounds so axes align with # the visible slice geometry, not the full 3D grid. axes_bounds = list(cutter.GetOutput().GetBounds()) else: axes_bounds = list(g.GetBounds()) cube_axes = vtk.vtkCubeAxesActor() cube_axes.SetBounds(axes_bounds) cube_axes.SetUseAxisOrigin(False) cube_axes.SetFlyModeToOuterEdges() if plot_type == "slice": cube_axes.SetTickLocationToInside() cube_axes.SetScreenSize(8.0) cube_axes.SetLabelOffset(8) cube_axes.SetTitleOffset([16, 16]) # print coordinates as they are, without a "(x10^-6)" factor cube_axes.SetLabelScaling(False, 0, 0, 0) # label what is drawn: a slice can span less than its mesh, so the # physical ranges come from the (scaled) bounds of the drawn geometry axis_scale = var_scale if var_scale is not None else np.ones(dim) orig_ranges = [ ( axes_bounds[2 * d] / axis_scale[d], axes_bounds[2 * d + 1] / axis_scale[d], ) for d in range(dim) ] if dim >= 1: cube_axes.SetXAxisRange(*orig_ranges[0]) if dim >= 2: cube_axes.SetYAxisRange(*orig_ranges[1]) if dim >= 3: cube_axes.SetZAxisRange(*orig_ranges[2]) for ax_id in range(3): tp = cube_axes.GetTitleTextProperty(ax_id) tp.SetFontSize(22) tp.SetColor(0.15, 0.15, 0.15) tp.SetBold(True) lp = cube_axes.GetLabelTextProperty(ax_id) lp.SetFontSize(17) lp.SetColor(0.25, 0.25, 0.25) cube_axes.SetXTitle(f"{axis_names[0]} [m]") cube_axes.SetYTitle(f"{axis_names[1]} [m]") if dim == 3: cube_axes.SetZTitle("z [m]") else: cube_axes.ZAxisVisibilityOff() cube_axes.SetXLabelFormat("%.3g") cube_axes.SetYLabelFormat("%.3g") cube_axes.SetZLabelFormat("%.3g") cube_axes.XAxisMinorTickVisibilityOff() cube_axes.YAxisMinorTickVisibilityOff() cube_axes.ZAxisMinorTickVisibilityOff() # VTK's automatic ticks crowd stretched axes: label three points per # axis, or just the two ends of an axis much thinner than the others extents = [hi - lo for lo, hi in orig_ranges[:dim]] for axis, (lo, hi) in enumerate(orig_ranges[:dim]): thin = extents[axis] < 0.05 * max(extents) labels = vtk.vtkStringArray() for value in np.linspace(lo, hi, 2 if thin else 3): labels.InsertNextValue(f"{value:.3g}") cube_axes.SetAxisLabels(axis, labels) if plot_type == "slice": if axis_idx == 0: cube_axes.XAxisVisibilityOff() cube_axes.SetXAxisTickVisibility(False) cube_axes.SetXAxisLabelVisibility(False) elif axis_idx == 1: cube_axes.YAxisVisibilityOff() cube_axes.SetYAxisTickVisibility(False) cube_axes.SetYAxisLabelVisibility(False) else: cube_axes.ZAxisVisibilityOff() cube_axes.SetZAxisTickVisibility(False) cube_axes.SetZAxisLabelVisibility(False) ren.AddActor(cube_axes) window.AddRenderer(ren) all_renderers.append(ren) spatial_renderers.append(ren) # Camera setup: slice panels get independent orthographic cameras; # 3d panels share a perspective camera per set of grid bounds. if plot_type == "slice": ren.ResetCamera() cam = ren.GetActiveCamera() cam.SetParallelProjection(True) fp = list(cam.GetFocalPoint()) gb = g.GetBounds() offset = ( max( gb[1] - gb[0], gb[3] - gb[2], gb[5] - gb[4], ) * 2 ) # face-on from the negative side: OuterEdges then puts the axis # labels on the top/left edges, where the viewport has room pos = list(fp) pos[axis_idx] = fp[axis_idx] - offset cam.SetPosition(pos) view_up = [0, 0, 0] if axis_idx == 2: view_up[1] = 1 elif axis_idx == 1: view_up[2] = 1 else: view_up[1] = 1 cam.SetViewUp(view_up) ren.ResetCamera() cam.Zoom(0.70) cube_axes.SetCamera(cam) else: camera_key = tuple(np.round(g.GetBounds(), 12)) cam = shared_cameras.get(camera_key) if cam is None: ren.ResetCamera() cam = ren.GetActiveCamera() if dim == 3: cam.Azimuth(-55) cam.Elevation(25) shared_cameras[camera_key] = cam else: ren.SetActiveCamera(cam) cube_axes.SetCamera(cam) panel_idx += 1 for name in self.scalar_names: vals = scalar_data[name] v_min, v_max = scalar_ranges[name] v_pad = max((v_max - v_min) * 0.05, 1e-10) chart = vtk.vtkChartXY() chart.SetTitle(name) chart.GetTitleProperties().SetFontSize(36) chart.GetTitleProperties().SetBold(True) chart.GetTitleProperties().SetColor(0, 0, 0) for axis_index, title in ((1, "Time [s]"), (0, name)): axis = chart.GetAxis(axis_index) axis.SetTitle(title) axis.GetTitleProperties().SetFontSize(28) axis.GetTitleProperties().SetColor(0, 0, 0) axis.GetLabelProperties().SetFontSize(22) axis.GetLabelProperties().SetColor(0, 0, 0) chart.GetAxis(1).SetRange(float(self.t_pts[0]), float(self.t_pts[-1])) chart.GetAxis(0).SetRange(v_min - v_pad, v_max + v_pad) table = vtk.vtkTable() t_arr = numpy_support.numpy_to_vtk( np.asarray(self.t_pts, dtype=np.float64), deep=True ) t_arr.SetName("Time") v_arr = numpy_support.numpy_to_vtk(vals.astype(np.float64), deep=True) v_arr.SetName(name) table.AddColumn(t_arr) table.AddColumn(v_arr) line = chart.AddPlot(vtk.vtkChart.LINE) line.SetInputData(table, 0, 1) line.SetColor(31, 119, 180, 255) line.SetWidth(2.0) marker_table = vtk.vtkTable() mt_arr = vtk.vtkFloatArray() mt_arr.SetName("t") mv_arr = vtk.vtkFloatArray() mv_arr.SetName("v") mt_arr.InsertNextValue(float(self.t_pts[0])) mt_arr.InsertNextValue(float(self.t_pts[0])) mv_arr.InsertNextValue(v_min - v_pad) mv_arr.InsertNextValue(v_max + v_pad) marker_table.AddColumn(mt_arr) marker_table.AddColumn(mv_arr) marker_line = chart.AddPlot(vtk.vtkChart.LINE) marker_line.SetInputData(marker_table, 0, 1) marker_line.SetColor(200, 50, 50, 200) marker_line.SetWidth(1.5) time_markers.append((mt_arr, marker_table)) view = vtk.vtkContextActor() scene = vtk.vtkContextScene() scene.AddItem(chart) view.SetScene(scene) ren = vtk.vtkRenderer() ren.AddActor(view) scene.SetRenderer(ren) ren.SetBackground(1, 1, 1) ren.SetViewport(*viewport(panel_idx)) window.AddRenderer(ren) all_renderers.append(ren) chart_views.append((chart, view, scene)) panel_idx += 1 while panel_idx < n_rows * n_cols: ren = vtk.vtkRenderer() ren.SetBackground(1, 1, 1) ren.SetViewport(*viewport(panel_idx)) window.AddRenderer(ren) panel_idx += 1 slider_bg = vtk.vtkRenderer() slider_bg.SetBackground(1, 1, 1) slider_bg.SetViewport(0, 0, 1, slider_h) window.AddRenderer(slider_bg) interactor = vtk.vtkRenderWindowInteractor() interactor.SetRenderWindow(window) time_text = vtk.vtkTextActor() time_text.SetInput(f"t = {self.t_pts[0]:.4g} s") time_text.GetTextProperty().SetFontSize(28) time_text.GetTextProperty().SetColor(0, 0, 0) time_text.GetTextProperty().SetBold(True) time_text.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() time_text.SetPosition(0.01, 0.15) slider_bg.AddViewProp(time_text) # Time slider — scaled in physical time (seconds) t_min = float(self.t_pts[0]) t_max = float(self.t_pts[-1]) slider_rep = vtk.vtkSliderRepresentation2D() slider_rep.SetMinimumValue(t_min) slider_rep.SetMaximumValue(t_max) slider_rep.SetValue(t_min) slider_rep.SetTitleText("") slider_rep.GetPoint1Coordinate().SetCoordinateSystemToNormalizedDisplay() slider_rep.GetPoint1Coordinate().SetValue(0.15, slider_h * 0.5) slider_rep.GetPoint2Coordinate().SetCoordinateSystemToNormalizedDisplay() slider_rep.GetPoint2Coordinate().SetValue(0.95, slider_h * 0.5) slider_rep.SetSliderLength(0.04) slider_rep.SetSliderWidth(0.06) slider_rep.SetTubeWidth(0.015) slider_rep.SetEndCapLength(0.02) slider_rep.SetEndCapWidth(0.06) slider_rep.GetTitleProperty().SetColor(0, 0, 0) slider_rep.GetLabelProperty().SetColor(0, 0, 0) slider_rep.GetLabelProperty().SetFontSize(16) slider_rep.GetSliderProperty().SetColor(0.2, 0.4, 0.8) slider_rep.GetTubeProperty().SetColor(0.7, 0.7, 0.7) slider_rep.GetCapProperty().SetColor(0.5, 0.5, 0.5) slider_rep.GetSelectedProperty().SetColor(0.3, 0.5, 0.9) _t_array = np.asarray(self.t_pts) def on_slider(obj, event): t_now = float(obj.GetRepresentation().GetValue()) t_now = max(t_min, min(t_now, t_max)) if not self.interpolate_time: t_idx = int(np.argmin(np.abs(_t_array - t_now))) # label the stored time that is drawn, not the raw slider value t_now = float(_t_array[t_idx]) for sname, g, cell_to_point, cut in zip( panel_names, spatial_grids, cell_to_point_filters, cutters, strict=True, ): if self.interpolate_time: vals = pv_by_name[sname](t_now).ravel() else: vals = spatial_data[sname][:, t_idx] if is_cell_data_by_name[sname]: _set_cell_scalars(g, sname, vals) else: _set_point_scalars(g, sname, vals) if cell_to_point is not None: cell_to_point.Modified() cell_to_point.Update() if cut is not None: cut.Update() for mt_arr, mtable in time_markers: mt_arr.SetValue(0, t_now) mt_arr.SetValue(1, t_now) mt_arr.Modified() mtable.Modified() time_text.SetInput(f"t = {t_now:.4g} s") if show_plot: # pragma: no cover window.Render() slider = vtk.vtkSliderWidget() slider.SetInteractor(interactor) slider.SetRepresentation(slider_rep) slider.SetAnimationModeToAnimate() slider.EnabledOn() slider.AddObserver("InteractionEvent", on_slider) if show_plot: # pragma: no cover interactor.Initialize() window.Render() interactor.Start() self._window = window self._interactor = interactor self._slider = slider self._time_text = time_text self._time_markers = [mt_arr for mt_arr, _ in time_markers]
[docs] def save_gif( self, filename: str, fps: int = 10, n_frames: int = 100, width: int = 1800, height: int = 900, ) -> None: """Render an animation to a GIF file. Parameters ---------- filename : str Output path (e.g. ``"anim.gif"``). fps : int Frames per second. n_frames : int Number of frames (evenly spaced in time). width, height : int Pixel dimensions of each frame. """ vtk = pybamm.import_optional_dependency("vtk") Image = pybamm.import_optional_dependency("PIL.Image") if not hasattr(self, "_window") or not self._window.GetOffScreenRendering(): self.dynamic_plot(show_plot=False) win = self._window win.SetOffScreenRendering(1) win.SetSize(width, height) t_min = float(self.t_pts[0]) t_max = float(self.t_pts[-1]) frame_times = np.linspace(t_min, t_max, n_frames) frames = [] for t in frame_times: self._slider.GetRepresentation().SetValue(t) self._slider.InvokeEvent("InteractionEvent") win.Render() w2i = vtk.vtkWindowToImageFilter() w2i.SetInput(win) w2i.Update() img_data = w2i.GetOutput() w_px, h_px, _ = img_data.GetDimensions() n_comp = img_data.GetNumberOfScalarComponents() raw = np.frombuffer( memoryview(img_data.GetPointData().GetScalars()), dtype=np.uint8, ).reshape(h_px, w_px, n_comp) frames.append(Image.fromarray(raw[::-1])) frames[0].save( filename, save_all=True, append_images=frames[1:], duration=int(1000 / fps), loop=0, ) pybamm.logger.info(f"Saved {len(frames)}-frame GIF to {filename}")