From 506c8a8f41e4f265362ba4ab58df5a62964f6c2d Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Fri, 31 Jul 2026 14:38:43 -0700 Subject: [PATCH 1/9] feat: add VTK plotting for unstructured meshes Add VTKQuickPlot, QuickPlot/dynamic_plot hooks, the vtk optional extra, and headless OSMesa CI setup for GIF export. Extracted from the unstructured finite-volume stack. Co-authored-by: Cursor --- .github/workflows/_nox.yml | 16 +- CHANGELOG.md | 1 + docs/source/api/plotting/index.rst | 1 + docs/source/api/plotting/plot_vtk.rst | 5 + packages/pybamm/pyproject.toml | 4 +- packages/pybamm/src/pybamm/__init__.py | 1 + .../src/pybamm/plotting/dynamic_plot.py | 30 +- .../pybamm/src/pybamm/plotting/plot_vtk.py | 852 ++++++++++++++++++ .../pybamm/src/pybamm/plotting/quick_plot.py | 411 ++++++++- .../tests/unit/test_plotting/test_plot_vtk.py | 423 +++++++++ .../unit/test_plotting/test_quick_plot.py | 2 +- uv.lock | 45 +- 12 files changed, 1734 insertions(+), 57 deletions(-) create mode 100644 docs/source/api/plotting/plot_vtk.rst create mode 100644 packages/pybamm/src/pybamm/plotting/plot_vtk.py create mode 100644 packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py diff --git a/.github/workflows/_nox.yml b/.github/workflows/_nox.yml index 04f8dac8d0..0d6a74afed 100644 --- a/.github/workflows/_nox.yml +++ b/.github/workflows/_nox.yml @@ -80,7 +80,7 @@ jobs: uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 if: startsWith(matrix.leg.os, 'ubuntu') with: - packages: gfortran gcc graphviz pandoc + packages: gfortran gcc graphviz pandoc libosmesa6 libegl1 execute_install_scripts: true # dot -c is for registering graphviz fonts and plugins @@ -91,6 +91,11 @@ jobs: sudo dot -c sudo apt-get install libopenblas-dev + # VTK off-screen GIF export needs a software OpenGL backend on headless runners. + - name: Prefer OSMesa for VTK on Linux + if: startsWith(matrix.leg.os, 'ubuntu') + run: echo "VTK_DEFAULT_OPENGL_WINDOW=vtkOSOpenGLRenderWindow" >> "$GITHUB_ENV" + # Kept separate and opt-out: texlive-latex-extra is large and uncached. - name: Install TeXLive for Linux if: ${{ startsWith(matrix.leg.os, 'ubuntu') && inputs.texlive }} @@ -114,6 +119,15 @@ jobs: if: startsWith(matrix.leg.os, 'windows') run: winget install --id Graphviz.Graphviz --exact --accept-source-agreements --accept-package-agreements + # VTK save_gif needs OSMesa on headless Windows runners (osmesa.dll on PATH). + - name: Setup headless OpenGL on Windows + if: startsWith(matrix.leg.os, 'windows') + uses: pyvista/setup-headless-display-action@5bc8de3bc71fcda7a96439571287a554901541a0 # v4 + with: + pyvista: "false" + mesa3d-release: "24.3.0" + install-mesa3d-offscreen: "true" + - name: Set up Python ${{ matrix.leg.python }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6154e5f4..fbf31ef93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Features +- Added VTK-based plotting (`VTKQuickPlot`) for unstructured mesh solutions, including headless CI OpenGL setup. ([#5689](https://github.com/pybamm-team/PyBaMM/pull/5689)) - Added `FiniteVolumeUnstructured` spatial method and unstructured processed-variable support for cell-centered data on arbitrary meshes. The TPFA Laplacian carries an implicit non-orthogonal correction (`"non-orthogonal correction"` option: `"over-relaxed"` or `"minimum"`) and gradients use a least-squares reconstruction, so both are exact on linear fields and second-order on skewed triangle and tetrahedral meshes. Diffusion coefficients reach faces through the distance-weighted harmonic mean, as in `FiniteVolume`, so material interfaces carry the exact series flux. ([#5688](https://github.com/pybamm-team/PyBaMM/pull/5688)) - Added unstructured mesh support (`UnstructuredSubMesh`, generators, and interface coupling) for arbitrary 2D/3D domains. Hexahedra must have planar faces (warped hexes raise a `GeometryError`), and `UserSuppliedUnstructuredMesh` accepts tetrahedral, triangular, and quadrilateral cells only. ([#5687](https://github.com/pybamm-team/PyBaMM/pull/5687)) - Generalised `VectorField` to N components and added `Component`/`Norm` operators for multi-dimensional vector fields. ([#5686](https://github.com/pybamm-team/PyBaMM/pull/5686)) diff --git a/docs/source/api/plotting/index.rst b/docs/source/api/plotting/index.rst index 796df15fd7..5416399b0c 100644 --- a/docs/source/api/plotting/index.rst +++ b/docs/source/api/plotting/index.rst @@ -10,3 +10,4 @@ Plotting plot_summary_variables plot_3d_cross_section plot_3d_heatmap + plot_vtk diff --git a/docs/source/api/plotting/plot_vtk.rst b/docs/source/api/plotting/plot_vtk.rst new file mode 100644 index 0000000000..eaf6018f72 --- /dev/null +++ b/docs/source/api/plotting/plot_vtk.rst @@ -0,0 +1,5 @@ +VTK Quick Plot +============== + +.. autoclass:: pybamm.VTKQuickPlot + :members: diff --git a/packages/pybamm/pyproject.toml b/packages/pybamm/pyproject.toml index 6b5e217259..00efa23165 100644 --- a/packages/pybamm/pyproject.toml +++ b/packages/pybamm/pyproject.toml @@ -66,11 +66,13 @@ bpx = ["bpx>=1.1.1,<1.2.0"] # Low-overhead progress bars tqdm = ["tqdm"] jax = ["jax>=0.7.0, <0.9.0; python_version >= '3.11' and (sys_platform != 'darwin' or platform_machine != 'x86_64')"] +# VTK-based interactive visualization for unstructured meshes +vtk = ["vtk>=9.0.0"] # Contains all optional dependencies, except for jax, and dev dependencies all = [ "scikit-fem>=12.0.2", "meshio>=5.3.0", - "pybamm[examples,plot,cite,bpx,tqdm]", + "pybamm[examples,plot,cite,bpx,tqdm,vtk]", ] [dependency-groups] diff --git a/packages/pybamm/src/pybamm/__init__.py b/packages/pybamm/src/pybamm/__init__.py index f9fbc95875..f06bea5525 100644 --- a/packages/pybamm/src/pybamm/__init__.py +++ b/packages/pybamm/src/pybamm/__init__.py @@ -237,6 +237,7 @@ from .plotting.dynamic_plot import dynamic_plot from .plotting.plot_3d_cross_section import plot_3d_cross_section from .plotting.plot_3d_heatmap import plot_3d_heatmap +from .plotting.plot_vtk import VTKQuickPlot from .plotting.nyquist_plot import nyquist_plot # Simulation diff --git a/packages/pybamm/src/pybamm/plotting/dynamic_plot.py b/packages/pybamm/src/pybamm/plotting/dynamic_plot.py index 4cde0d3972..9281e69344 100644 --- a/packages/pybamm/src/pybamm/plotting/dynamic_plot.py +++ b/packages/pybamm/src/pybamm/plotting/dynamic_plot.py @@ -11,12 +11,34 @@ def dynamic_plot(*args, **kwargs): The key-word argument 'show_plot' is passed to the 'dynamic_plot' method, not the `QuickPlot` class. + Pass ``backend="vtk"`` to use the VTK-based viewer for unstructured + mesh solutions instead of matplotlib. + Returns ------- - plot : :class:`pybamm.QuickPlot` - The 'QuickPlot' object that was created + plot : :class:`pybamm.QuickPlot` or :class:`pybamm.VTKQuickPlot` + The plot object that was created """ - kwargs_for_class = {k: v for k, v in kwargs.items() if k != "show_plot"} + backend = kwargs.pop("backend", "matplotlib") + show_plot = kwargs.pop("show_plot", True) + + if backend == "vtk": + from pybamm.plotting.plot_vtk import VTKQuickPlot + + output_variables = kwargs.pop("output_variables", None) + options = kwargs.pop("options", None) + interpolate_time = kwargs.pop("interpolate_time", False) + plot = VTKQuickPlot( + *args, + output_variables=output_variables, + options=options, + interpolate_time=interpolate_time, + **kwargs, + ) + plot.dynamic_plot(show_plot) + return plot + + kwargs_for_class = {k: v for k, v in kwargs.items()} plot = pybamm.QuickPlot(*args, **kwargs_for_class) - plot.dynamic_plot(kwargs.get("show_plot", True)) + plot.dynamic_plot(show_plot) return plot diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py new file mode 100644 index 0000000000..f0b9d2a494 --- /dev/null +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -0,0 +1,852 @@ +""" +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. +""" + +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 +} + +_AXIS_INDEX = {"x": 0, "y": 1, "z": 2} + + +def _build_vtk_grid(mesh, scale=None): + """Build a ``vtkUnstructuredGrid`` from an unstructured mesh.""" + import vtk + + nodes = mesh.nodes + if scale is not None: + nodes = nodes * np.asarray(scale)[: nodes.shape[1]] + + pts = vtk.vtkPoints() + pts.SetNumberOfPoints(len(nodes)) + for i, nd in enumerate(nodes): + if len(nd) == 2: + pts.SetPoint(i, nd[0], nd[1], 0.0) + else: + pts.SetPoint(i, nd[0], nd[1], nd[2]) + + 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 ValueError( + "Unable to infer VTK cell type from mesh connectivity with " + f"{nverts} vertices per element" + ) + + cell_type = _VTK_CELL_TYPE[element_key] + for cell in mesh.elements: + id_list = vtk.vtkIdList() + for v in cell: + id_list.InsertNextId(int(v)) + grid.InsertNextCell(cell_type, id_list) + + return grid + + +def _compute_scale(mesh): + """Per-axis scale factors that normalise coordinate spans to the largest.""" + nodes = mesh.nodes + 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 == "auto": + return _compute_scale(mesh) + if scale_opt is None: + return None + return np.asarray(scale_opt) + + +def _set_cell_scalars(grid, name, values): + """Set (or update) a cell scalar array on a VTK grid.""" + import vtk + + arr = grid.GetCellData().GetArray(name) + if arr is None: + arr = vtk.vtkFloatArray() + arr.SetName(name) + arr.SetNumberOfTuples(len(values)) + grid.GetCellData().AddArray(arr) + grid.GetCellData().SetActiveScalars(name) + for i, v in enumerate(values): + arr.SetValue(i, float(v)) + arr.Modified() + grid.Modified() + + +def _set_point_scalars(grid, name, values): + """Set (or update) a point scalar array on a VTK grid.""" + import vtk + + arr = grid.GetPointData().GetArray(name) + if arr is None: + arr = vtk.vtkFloatArray() + arr.SetName(name) + arr.SetNumberOfTuples(len(values)) + grid.GetPointData().AddArray(arr) + grid.GetPointData().SetActiveScalars(name) + for i, v in enumerate(values): + arr.SetValue(i, float(v)) + arr.Modified() + grid.Modified() + + +def _is_unstructured_spatial_variable(pv): + return isinstance( + pv, + ( + pybamm.ProcessedVariableUnstructuredFVM, + pybamm.ProcessedVariableUnstructured, + ), + ) + + +def _data_at_time(pv, t): + if hasattr(pv, "_data_at_time"): + return pv._data_at_time(t) + return pv(t) + + +def _viridis_lut(vmin, vmax, n=256): + """Build a VTK lookup table using the matplotlib viridis colormap.""" + import vtk + + try: + from matplotlib.cm import viridis as _cmap + except ImportError: + 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. + + Off-screen Linux uses OSMesa (``vtkOSOpenGLRenderWindow``); macOS/Windows + use the platform window with off-screen rendering enabled. Instantiating + the OSMesa window on unsupported platforms segfaults. + """ + import sys + + import vtk + + if ( + off_screen + and sys.platform.startswith("linux") + and hasattr(vtk, "vtkOSOpenGLRenderWindow") + ): + window = vtk.vtkOSOpenGLRenderWindow() + else: + window = vtk.vtkRenderWindow() + if off_screen: + window.SetOffScreenRendering(1) + return window + + +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 list thereof + output_variables : list of str + 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}, + ]} + """ + + def __init__( + self, + solutions, + output_variables=None, + options=None, + interpolate_time=False, + ): + if isinstance(solutions, pybamm.Simulation): + solutions = solutions.solution + if isinstance(solutions, pybamm.Solution): + solutions = [solutions] + self.solution = solutions[0] + + if output_variables is None: + output_variables = list(self.solution.all_models[0].variables.keys())[:1] + if isinstance(output_variables, str): + output_variables = [output_variables] + + 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] + if isinstance(pv, pybamm.ProcessedVariableUnstructuredFVM): + self.spatial_names.append(name) + self.spatial_vars.append(pv) + self.spatial_is_cell_data.append(True) + elif isinstance(pv, pybamm.ProcessedVariableUnstructured): + self.spatial_names.append(name) + self.spatial_vars.append(pv) + self.spatial_is_cell_data.append(False) + else: + self.scalar_names.append(name) + self.scalar_vars.append(pv) + + self.output_variables = output_variables + self.mesh = self.spatial_vars[0].mesh if self.spatial_vars else None + self.t_pts = self.solution.t + self.interpolate_time = interpolate_time + + _defaults = {"plot_type": "3d", "scale": "auto"} + raw_opts = options or {} + + # Build spatial_panels: flat list of (name, opts_dict) tuples. + 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) + for single_opt in opt_list: + merged = dict(_defaults) + merged.update(single_opt) + self.spatial_panels.append((name, merged)) + + # ------------------------------------------------------------------ + + def dynamic_plot(self, show_plot=True): + """Launch an interactive VTK window with a time slider.""" + import vtk + + n_spatial = len(self.spatial_panels) + n_scalar = len(self.scalar_names) + n_panels = n_spatial + n_scalar + + # --- Precompute spatial data --- + spatial_data = {} + spatial_mins = {} + spatial_maxs = {} + for name, pv in zip(self.spatial_names, self.spatial_vars, strict=True): + pv.initialise() + data = np.column_stack([_data_at_time(pv, t).ravel() for t in self.t_pts]) + spatial_data[name] = data + spatial_mins[name] = float(data.min()) + spatial_maxs[name] = float(data.max()) + + # --- Precompute scalar (0D) data --- + scalar_data = {} + for name, pv in zip(self.scalar_names, self.scalar_vars, strict=True): + pv.initialise() + vals = np.array([float(pv(t).ravel()[0]) for t in self.t_pts]) + scalar_data[name] = vals + + # --- Layout --- + 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 + + 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 = [] + c2p_filters = [] + cutters = [] + chart_views = [] + time_markers = [] + + panel_idx = 0 + + # --- Spatial panels --- + first_3d_cam = None + 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 + ) + } + + for name, opts in self.spatial_panels: + plot_type = opts.get("plot_type", "3d") + var_scale = _resolve_scale(opts.get("scale", "auto"), self.mesh) + is_cell_data = is_cell_data_by_name[name] + panel_names.append(name) + + g = _build_vtk_grid(self.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) + + c2p = None + if is_cell_data: + c2p = vtk.vtkCellDataToPointData() + c2p.SetInputData(g) + c2p.Update() + c2p_filters.append(c2p) + + # Determine pipeline source: cutter for slices, direct/converted for 3d + pipeline_source = c2p.GetOutputPort() if c2p is not None else g + cutter = None + if plot_type == "slice": + axis_key = None + for ak in ("x", "y", "z"): + if ak in opts: + axis_key = ak + break + if axis_key is None: + raise ValueError( + f"plot_type='slice' for '{name}' requires one of " + f"'x', 'y', or 'z' specifying the slice fraction" + ) + axis_idx = _AXIS_INDEX[axis_key] + frac = float(opts[axis_key]) + nodes = self.mesh.nodes + lo = float(nodes[:, axis_idx].min()) + hi = float(nodes[:, axis_idx].max()) + phys_val = lo + frac * (hi - lo) + 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 c2p is not None: + cutter.SetInputConnection(pipeline_source) + else: + cutter.SetInputData(pipeline_source) + cutter.Update() + + mapper_source = cutter.GetOutputPort() + else: + if c2p 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.18) + sb.SetHeight(0.5) + sb.SetPosition(0.80, 0.25) + sb.GetLabelTextProperty().SetFontSize(24) + sb.GetLabelTextProperty().SetColor(0, 0, 0) + sb.SetUnconstrainedFontSize(True) + sb.SetLabelFormat("%-#6.3g") + + title_actor = vtk.vtkTextActor() + title_actor.SetInput(name) + title_actor.GetTextProperty().SetFontSize(36) + 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.AddActor2D(sb) + ren.AddActor2D(title_actor) + ren.SetBackground(1, 1, 1) + + row = panel_idx // n_cols + col = panel_idx % n_cols + y0 = panel_top - (row + 1) * panel_height + y1 = panel_top - row * panel_height + ren.SetViewport(col / n_cols, y0, (col + 1) / n_cols, y1) + + # Cube axes + if self.mesh is not None: + mesh_nodes = self.mesh.nodes + dim = mesh_nodes.shape[1] + + 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(10.0) + cube_axes.SetLabelOffset(10) + cube_axes.SetTitleOffset([20, 20]) + + orig_ranges = [ + (float(mesh_nodes[:, d].min()), float(mesh_nodes[:, d].max())) + 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(28) + tp.SetColor(0.15, 0.15, 0.15) + tp.SetBold(True) + lp = cube_axes.GetLabelTextProperty(ax_id) + lp.SetFontSize(22) + lp.SetColor(0.25, 0.25, 0.25) + cube_axes.SetXTitle("X") + cube_axes.SetYTitle("Y") + cube_axes.SetZTitle("Z") + cube_axes.SetXLabelFormat("%.2g") + cube_axes.SetYLabelFormat("%.2g") + cube_axes.SetZLabelFormat("%.2g") + cube_axes.XAxisMinorTickVisibilityOff() + cube_axes.YAxisMinorTickVisibilityOff() + cube_axes.ZAxisMinorTickVisibilityOff() + + 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 single perspective camera. + if plot_type == "slice": + ren.ResetCamera() + cam = ren.GetActiveCamera() + cam.SetParallelProjection(True) + pos = list(cam.GetPosition()) + fp = list(cam.GetFocalPoint()) + gb = g.GetBounds() + offset = ( + max( + gb[1] - gb[0], + gb[3] - gb[2], + gb[5] - gb[4], + ) + * 2 + ) + # Look from the negative side so OuterEdges places + # axis labels on the top/left edges (more viewport room). + 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) + if self.mesh is not None: + cube_axes.SetCamera(cam) + else: + if first_3d_cam is None: + ren.ResetCamera() + first_3d_cam = ren.GetActiveCamera() + if self.mesh is not None and self.mesh.dimension == 3: + first_3d_cam.Azimuth(-55) + first_3d_cam.Elevation(25) + if self.mesh is not None: + cube_axes.SetCamera(first_3d_cam) + else: + ren.SetActiveCamera(first_3d_cam) + if self.mesh is not None: + cube_axes.SetCamera(first_3d_cam) + + panel_idx += 1 + + # --- Scalar (0D chart) panels --- + for name in self.scalar_names: + vals = scalar_data[name] + v_min, v_max = float(vals.min()), float(vals.max()) + 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) + chart.GetAxis(1).SetTitle("Time [s]") + chart.GetAxis(0).SetTitle(name) + chart.GetAxis(1).GetTitleProperties().SetFontSize(28) + chart.GetAxis(1).GetTitleProperties().SetColor(0, 0, 0) + chart.GetAxis(1).GetLabelProperties().SetFontSize(22) + chart.GetAxis(1).GetLabelProperties().SetColor(0, 0, 0) + chart.GetAxis(0).GetTitleProperties().SetFontSize(28) + chart.GetAxis(0).GetTitleProperties().SetColor(0, 0, 0) + chart.GetAxis(0).GetLabelProperties().SetFontSize(22) + chart.GetAxis(0).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 = vtk.vtkFloatArray() + t_arr.SetName("Time") + v_arr = vtk.vtkFloatArray() + v_arr.SetName(name) + for i in range(len(self.t_pts)): + t_arr.InsertNextValue(float(self.t_pts[i])) + v_arr.InsertNextValue(float(vals[i])) + 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) + + row = panel_idx // n_cols + col = panel_idx % n_cols + y0 = panel_top - (row + 1) * panel_height + y1 = panel_top - row * panel_height + ren.SetViewport(col / n_cols, y0, (col + 1) / n_cols, y1) + + window.AddRenderer(ren) + all_renderers.append(ren) + chart_views.append((chart, view, scene)) + panel_idx += 1 + + # --- Fill any unused grid cells with white --- + while panel_idx < n_rows * n_cols: + ren = vtk.vtkRenderer() + ren.SetBackground(1, 1, 1) + row = panel_idx // n_cols + col = panel_idx % n_cols + y0 = panel_top - (row + 1) * panel_height + y1 = panel_top - row * panel_height + ren.SetViewport(col / n_cols, y0, (col + 1) / n_cols, y1) + window.AddRenderer(ren) + panel_idx += 1 + + # --- Slider background (white strip at bottom) --- + 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 label + 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.AddActor2D(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) + + # Look-up table for snapping to nearest timestep + _t_array = np.asarray(self.t_pts) + + # Keep references for interpolated mode + _spatial_vars = { + name: pv + for name, pv in zip( + self.spatial_names, + self.spatial_vars, + strict=True, + ) + } + + def on_slider(obj, event): + t_now = float(obj.GetRepresentation().GetValue()) + t_now = max(t_min, min(t_now, t_max)) + + if self.interpolate_time: + # Evaluate every spatial variable at exact time + for sname, g, c2p, cut in zip( + panel_names, + spatial_grids, + c2p_filters, + cutters, + strict=True, + ): + vals = _data_at_time(_spatial_vars[sname], t_now).ravel() + if is_cell_data_by_name[sname]: + _set_cell_scalars(g, sname, vals) + else: + _set_point_scalars(g, sname, vals) + if c2p is not None: + c2p.Modified() + c2p.Update() + if cut is not None: + cut.Update() + else: + # Snap to nearest stored timestep (fast) + t_idx = int(np.argmin(np.abs(_t_array - t_now))) + for sname, g, c2p, cut in zip( + panel_names, + spatial_grids, + c2p_filters, + cutters, + strict=True, + ): + if is_cell_data_by_name[sname]: + _set_cell_scalars(g, sname, spatial_data[sname][:, t_idx]) + else: + _set_point_scalars(g, sname, spatial_data[sname][:, t_idx]) + if c2p is not None: + c2p.Modified() + c2p.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: + window.Render() + + slider = vtk.vtkSliderWidget() + slider.SetInteractor(interactor) + slider.SetRepresentation(slider_rep) + slider.SetAnimationModeToAnimate() + slider.EnabledOn() + slider.AddObserver("InteractionEvent", on_slider) + + if show_plot: + interactor.Initialize() + window.Render() + interactor.Start() + + self._window = window + self._interactor = interactor + self._slider = slider + + def save_gif(self, filename, fps=10, n_frames=100, width=1800, height=900): + """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. + """ + import vtk + from PIL import 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, + ) + print(f"Saved {len(frames)}-frame GIF to {filename}") diff --git a/packages/pybamm/src/pybamm/plotting/quick_plot.py b/packages/pybamm/src/pybamm/plotting/quick_plot.py index dea94bf328..096ec7c329 100644 --- a/packages/pybamm/src/pybamm/plotting/quick_plot.py +++ b/packages/pybamm/src/pybamm/plotting/quick_plot.py @@ -292,6 +292,7 @@ def set_output_variables(self, output_variables, solutions): self.second_spatial_variable = {} self.x_first_and_y_second = {} self.is_y_z = {} + self.is_vector_field = {} # Calculate subplot positions based on number of variables supplied self.subplot_positions = {} @@ -349,12 +350,12 @@ def set_output_variables(self, output_variables, solutions): spatial_var_value * self.spatial_factor ) - elif first_variable.dimensions == 2: - # Don't allow 2D variables if there are multiple solutions + elif first_variable.dimensions in (2, 3): + # Don't allow 2D/3D variables if there are multiple solutions if len(variables) > 1: raise NotImplementedError( - "Cannot plot 2D variables when comparing multiple solutions, " - f"but '{variable_tuple[0]}' is 2D" + "Cannot plot 2D/3D variables when comparing multiple solutions, " + f"but '{variable_tuple[0]}' is {first_variable.dimensions}D" ) # But do allow if just a single solution else: @@ -396,6 +397,9 @@ def set_output_variables(self, output_variables, solutions): # Store variables and subplot position self.variables[variable_tuple] = variables + self.is_vector_field[variable_tuple] = getattr( + first_variable, "is_vector_field", False + ) self.subplot_positions[variable_tuple] = (self.n_rows, self.n_cols, k + 1) def _get_spatial_var(self, key, variable, dimension): @@ -413,7 +417,14 @@ def _get_spatial_var(self, key, variable, dimension): spatial_var_value = variable.second_dim_pts if variable.domain[0] == "current collector": domain = "current collector" - elif isinstance(variable, pybamm.ProcessedVariable2DFVM): + elif isinstance( + variable, + ( + pybamm.ProcessedVariable2DFVM, + pybamm.ProcessedVariableUnstructuredFVM, + pybamm.ProcessedVariableVectorFieldUnstructuredFVM, + ), + ): domain = variable.domain[0] else: domain = variable.domains["secondary"][0] @@ -437,9 +448,13 @@ def reset_axis(self): elif variable_lists[0][0].dimensions == 1: x_min = self.first_spatial_variable[key][0] x_max = self.first_spatial_variable[key][-1] - elif variable_lists[0][0].dimensions == 2: - # different order based on whether the domains are x-r, x-z or y-z, etc - if self.x_first_and_y_second[key] is False: + elif variable_lists[0][0].dimensions in (2, 3): + if variable_lists[0][0].dimensions == 3: + x_min = self.first_spatial_variable[key][0] + x_max = self.first_spatial_variable[key][-1] + y_min = self.second_spatial_variable[key][0] + y_max = self.second_spatial_variable[key][-1] + elif self.x_first_and_y_second[key] is False: x_min = self.second_spatial_variable[key][0] x_max = self.second_spatial_variable[key][-1] y_min = self.first_spatial_variable[key][0] @@ -454,23 +469,41 @@ def reset_axis(self): self.axis_limits[key] = [x_min, x_max, y_min, y_max] # Get min and max variable values - if self.variable_limits[key] == "fixed": + if self.is_vector_field.get(key, False): + var_min, var_max = None, None + elif self.variable_limits[key] == "fixed": # fixed variable limits: calculate "globlal" min and max - spatial_vars = self.spatial_variable_dict[key] - var_min = np.min( - [ - ax_min(var(self.ts_seconds[i], **spatial_vars)) - for i, variable_list in enumerate(variable_lists) - for var in variable_list - ] - ) - var_max = np.max( - [ - ax_max(var(self.ts_seconds[i], **spatial_vars)) - for i, variable_list in enumerate(variable_lists) - for var in variable_list - ] - ) + if variable_lists[0][0].dimensions == 3: + var_min = np.min( + [ + ax_min(var(self.ts_seconds[i])) + for i, variable_list in enumerate(variable_lists) + for var in variable_list + ] + ) + var_max = np.max( + [ + ax_max(var(self.ts_seconds[i])) + for i, variable_list in enumerate(variable_lists) + for var in variable_list + ] + ) + else: + spatial_vars = self.spatial_variable_dict[key] + var_min = np.min( + [ + ax_min(var(self.ts_seconds[i], **spatial_vars)) + for i, variable_list in enumerate(variable_lists) + for var in variable_list + ] + ) + var_max = np.max( + [ + ax_max(var(self.ts_seconds[i], **spatial_vars)) + for i, variable_list in enumerate(variable_lists) + for var in variable_list + ] + ) if np.isnan(var_min) or np.isnan(var_max): raise ValueError( "The variable limits are set to 'fixed' but the min and max " @@ -528,13 +561,18 @@ def plot(self, t, dynamic=False): solution_handles = [] for k, (key, variable_lists) in enumerate(self.variables.items()): - ax = self.fig.add_subplot(self.gridspec[k]) + is_3d = variable_lists[0][0].dimensions == 3 + if is_3d: + ax = self.fig.add_subplot(self.gridspec[k], projection="3d") + else: + ax = self.fig.add_subplot(self.gridspec[k]) self.axes.add(key, ax) - x_min, x_max, y_min, y_max = self.axis_limits[key] - ax.set_xlim(x_min, x_max) - if y_min is not None and y_max is not None: - ax.set_ylim(y_min, y_max) - ax.xaxis.set_major_locator(plt.MaxNLocator(3)) + if not is_3d: + x_min, x_max, y_min, y_max = self.axis_limits[key] + ax.set_xlim(x_min, x_max) + if y_min is not None and y_max is not None: + ax.set_ylim(y_min, y_max) + ax.xaxis.set_major_locator(plt.MaxNLocator(3)) self.plots[key] = defaultdict(dict) variable_handles = [] # Set labels for the first subplot only (avoid repetition) @@ -597,6 +635,39 @@ def plot(self, t, dynamic=False): for boundary in variable_lists[0][0].internal_boundaries: boundary_scaled = boundary * self.spatial_factor ax.axvline(boundary_scaled, color="0.5", lw=1, zorder=0) + elif self.is_vector_field.get(key, False): + variable = variable_lists[0][0] + if variable.dimensions == 2: + X, Z, U, W = variable.get_quiver_data(t_in_seconds) + Xs = X * self.spatial_factor + Zs = Z * self.spatial_factor + mag = np.sqrt(U**2 + W**2) + mag_max = np.max(mag) if np.max(mag) > 0 else 1.0 + norm = colors.Normalize(vmin=0, vmax=mag_max) + safe_mag = np.where(mag > 0, mag, 1.0) + U_norm = U / safe_mag + W_norm = W / safe_mag + ax.set_xlabel(f"x [{self.spatial_unit}]") + ax.set_ylabel(f"z [{self.spatial_unit}]") + self.plots[key][0][0] = ax.quiver( + Xs, + Zs, + U_norm, + W_norm, + mag, + cmap="viridis", + norm=norm, + scale=X.shape[0] * 1.2, + scale_units="width", + width=0.004, + ) + self.colorbars[key] = self.fig.colorbar( + self.plots[key][0][0], + ax=ax, + label="|" + str(key[0]) + "|", + ) + else: + self._plot_3d_quiver(ax, variable, t_in_seconds, key, cm, colors) elif variable_lists[0][0].dimensions == 2: # Read dictionary of spatial variables spatial_vars = self.spatial_variable_dict[key] @@ -620,20 +691,26 @@ def plot(self, t, dynamic=False): vmin, vmax = self.variable_limits[key] # store the plot and the var data (for testing) as cant access # z data from QuadMesh or QuadContourSet object - if self.is_y_z[key] is True: - self.plots[key][0][0] = ax.pcolormesh( - x, - y, - var, - vmin=vmin, - vmax=vmax, - shading=self.shading, - ) + is_unstructured = isinstance( + variable, pybamm.ProcessedVariableUnstructuredFVM + ) + if self.is_y_z[key] is True or is_unstructured: + kw = {"vmin": vmin, "vmax": vmax, "shading": self.shading} + if is_unstructured: + import matplotlib + + cmap_copy = matplotlib.colormaps["viridis"].copy() + cmap_copy.set_bad("white") + kw["cmap"] = cmap_copy + self.plots[key][0][0] = ax.pcolormesh(x, y, var, **kw) else: self.plots[key][0][0] = ax.contourf( x, y, var, levels=100, vmin=vmin, vmax=vmax ) self.plots[key][0][1] = var + if is_unstructured: + self._overlay_mesh_wireframe(ax, variable) + ax.set_aspect("equal") if vmin is None and vmax is None: vmin = ax_min(var) vmax = ax_max(var) @@ -641,6 +718,50 @@ def plot(self, t, dynamic=False): cm.ScalarMappable(colors.Normalize(vmin=vmin, vmax=vmax)), ax=ax, ) + elif variable_lists[0][0].dimensions == 3: + variable = variable_lists[0][0] + vmin, vmax = self.variable_limits[key] + if vmin is None: + vmin = ax_min(variable(t_in_seconds)) + if vmax is None: + vmax = ax_max(variable(t_in_seconds)) + norm = colors.Normalize(vmin=vmin, vmax=vmax) + import matplotlib.pyplot as _plt + + cmap = _plt.cm.viridis + s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = variable.get_3d_slices( + t_in_seconds + ) + fc1 = self._slice_facecolors(s1, cmap, norm) + fc2 = self._slice_facecolors(s2, cmap, norm) + ax.plot_surface( + xx1, + yy1, + zz1, + facecolors=fc1, + rstride=1, + cstride=1, + shade=False, + ) + ax.plot_surface( + xx2, + yy2, + zz2, + facecolors=fc2, + rstride=1, + cstride=1, + shade=False, + ) + ax.set_xlabel("$x$") + ax.set_ylabel("$y$") + ax.set_zlabel("$z$") + self.plots[key][0][0] = (s1, s2) + self.colorbars[key] = self.fig.colorbar( + cm.ScalarMappable(norm=norm, cmap=cmap), + ax=ax, + shrink=0.6, + pad=0.1, + ) # Set either y label or legend entries if len(key) == 1: title = split_long_string(key[0]) @@ -682,6 +803,73 @@ def plot(self, t, dynamic=False): bottom = max(legend_top, slider_top) self.gridspec.tight_layout(self.fig, rect=[0, bottom, 1, 1]) + @staticmethod + def _slice_facecolors(data, cmap, norm, base_alpha=0.85): + """Compute RGBA facecolors for ``plot_surface``, with NaN faces + rendered fully transparent so that cavities appear as holes.""" + import numpy as np + + nan_mask = np.isnan(data) + fc = cmap(norm(np.where(nan_mask, 0.0, data))) + fc[..., 3] = np.where(nan_mask, 0.0, base_alpha) + return fc + + def _overlay_mesh_wireframe(self, ax, variable): + """Draw mesh element edges as a light wireframe on a 2D axis.""" + from matplotlib.collections import PolyCollection + + mesh = variable.mesh + if mesh.dimension != 2: + return + verts = mesh.nodes[mesh.elements] * self.spatial_factor + poly = PolyCollection( + verts, facecolors="none", edgecolors=(0, 0, 0, 0.12), linewidths=0.3 + ) + ax.add_collection(poly) + + def _plot_3d_quiver(self, ax, variable, t, key, cm, colors): + """Render quiver arrows on two orthogonal 3D slice planes.""" + sf = self.spatial_factor + data = variable.get_quiver_data(t) + X1, Z1, U_xz, W_xz, y_mid = data[0:5] + X2, Y2, U_xy, V_xy, z_mid = data[5:10] + + x_span = (X1.max() - X1.min()) * sf + arrow_len = x_span * 0.08 if x_span > 0 else 0.08 + + Y1_plane = np.full_like(X1, y_mid * sf) + ax.quiver( + X1 * sf, + Y1_plane, + Z1 * sf, + U_xz, + np.zeros_like(U_xz), + W_xz, + length=arrow_len, + normalize=True, + color="steelblue", + alpha=0.8, + ) + + Z2_plane = np.full_like(X2, z_mid * sf) + ax.quiver( + X2 * sf, + Y2 * sf, + Z2_plane, + U_xy, + V_xy, + np.zeros_like(U_xy), + length=arrow_len, + normalize=True, + color="darkorange", + alpha=0.8, + ) + + ax.set_xlabel(f"$x$ [{self.spatial_unit}]") + ax.set_ylabel(f"$y$ [{self.spatial_unit}]") + ax.set_zlabel(f"$z$ [{self.spatial_unit}]") + self.plots[key][0][0] = "quiver_3d" + def dynamic_plot(self, show_plot=True, step=None): """ Generate a dynamic plot with a slider to control the time. @@ -714,8 +902,14 @@ def dynamic_plot(self, show_plot=True, step=None): # create an initial plot at time self.min_t self.plot(self.min_t, dynamic=True) + has_3d = any(vl[0][0].dimensions == 3 for vl in self.variables.values()) + axcolor = "lightgoldenrodyellow" - ax_slider = plt.axes([0.315, 0.02, 0.37, 0.03], facecolor=axcolor) + if has_3d: + t_bottom = 0.08 + ax_slider = plt.axes([0.315, t_bottom, 0.37, 0.03], facecolor=axcolor) + else: + ax_slider = plt.axes([0.315, 0.02, 0.37, 0.03], facecolor=axcolor) self.slider = Slider( ax_slider, f"Time [{self.time_unit}]", @@ -726,6 +920,46 @@ def dynamic_plot(self, show_plot=True, step=None): ) self.slider.on_changed(self.slider_update) + if has_3d: + self._slice_sliders = {} + var_3d = next( + vl[0][0] + for vl in self.variables.values() + if vl[0][0].dimensions == 3 + ) + y_pts = var_3d.second_dim_pts + z_pts = var_3d.third_dim_pts + + ax_y = plt.axes([0.315, 0.04, 0.37, 0.025], facecolor=axcolor) + self._slice_sliders["y"] = Slider( + ax_y, + "$y$ slice", + y_pts[0], + y_pts[-1], + valinit=var_3d._slice_positions["y"], + color="#ff7f0e", + ) + ax_z = plt.axes([0.315, 0.005, 0.37, 0.025], facecolor=axcolor) + self._slice_sliders["z"] = Slider( + ax_z, + "$z$ slice", + z_pts[0], + z_pts[-1], + valinit=var_3d._slice_positions["z"], + color="#2ca02c", + ) + + def _on_slice_change(_): + for vl in self.variables.values(): + v = vl[0][0] + if v.dimensions == 3: + v._slice_positions["y"] = self._slice_sliders["y"].val + v._slice_positions["z"] = self._slice_sliders["z"].val + self.slider_update(self.slider.val) + + self._slice_sliders["y"].on_changed(_on_slice_change) + self._slice_sliders["z"].on_changed(_on_slice_change) + if show_plot: # pragma: no cover plt.show() @@ -759,6 +993,39 @@ def slider_update(self, t): y_min, y_max = self.axis_limits[key][2:] if y_min is None and y_max is None: ax.set_ylim(var_min, var_max) + elif self.is_vector_field.get(key, False): + variable = self.variables[key][0][0] + ax.clear() + if variable.dimensions == 2: + X, Z, U, W = variable.get_quiver_data(time_in_seconds) + Xs = X * self.spatial_factor + Zs = Z * self.spatial_factor + mag = np.sqrt(U**2 + W**2) + mag_max = np.max(mag) if np.max(mag) > 0 else 1.0 + norm = colors.Normalize(vmin=0, vmax=mag_max) + safe_mag = np.where(mag > 0, mag, 1.0) + U_norm = U / safe_mag + W_norm = W / safe_mag + ax.set_xlabel(f"x [{self.spatial_unit}]") + ax.set_ylabel(f"z [{self.spatial_unit}]") + self.plots[key][0][0] = ax.quiver( + Xs, + Zs, + U_norm, + W_norm, + mag, + cmap="viridis", + norm=norm, + scale=X.shape[0] * 1.2, + scale_units="width", + width=0.004, + ) + if key in self.colorbars: + self.colorbars[key].update_normal(self.plots[key][0][0]) + else: + self._plot_3d_quiver(ax, variable, time_in_seconds, key, cm, colors) + title = split_long_string(key[0]) if len(key) == 1 else "" + ax.set_title(title, fontsize="medium") elif self.variables[key][0][0].dimensions == 2: # 2D plot: plot as a function of x and y at time t # Read dictionary of spatial variables @@ -776,20 +1043,26 @@ def slider_update(self, t): var = variable(time_in_seconds, **spatial_vars).T # store the plot and the var data (for testing) as cant access # z data from QuadMesh or QuadContourSet object - if self.is_y_z[key] is True: - self.plots[key][0][0] = ax.pcolormesh( - x, - y, - var, - vmin=vmin, - vmax=vmax, - shading=self.shading, - ) + is_unstructured = isinstance( + variable, pybamm.ProcessedVariableUnstructuredFVM + ) + if self.is_y_z[key] is True or is_unstructured: + kw = {"vmin": vmin, "vmax": vmax, "shading": self.shading} + if is_unstructured: + import matplotlib + + cmap_copy = matplotlib.colormaps["viridis"].copy() + cmap_copy.set_bad("white") + kw["cmap"] = cmap_copy + self.plots[key][0][0] = ax.pcolormesh(x, y, var, **kw) else: self.plots[key][0][0] = ax.contourf( x, y, var, levels=100, vmin=vmin, vmax=vmax ) self.plots[key][0][1] = var + if is_unstructured: + self._overlay_mesh_wireframe(ax, variable) + ax.set_aspect("equal") if (vmin, vmax) == (None, None): vmin = ax_min(var) vmax = ax_max(var) @@ -797,6 +1070,46 @@ def slider_update(self, t): cb.update_normal( cm.ScalarMappable(colors.Normalize(vmin=vmin, vmax=vmax)) ) + elif self.variables[key][0][0].dimensions == 3: + variable = self.variables[key][0][0] + vmin, vmax = self.variable_limits[key] + if vmin is None: + vmin = ax_min(variable(time_in_seconds)) + if vmax is None: + vmax = ax_max(variable(time_in_seconds)) + norm = colors.Normalize(vmin=vmin, vmax=vmax) + import matplotlib.pyplot as _plt + + cmap = _plt.cm.viridis + ax.clear() + s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = variable.get_3d_slices( + time_in_seconds + ) + fc1 = self._slice_facecolors(s1, cmap, norm) + fc2 = self._slice_facecolors(s2, cmap, norm) + ax.plot_surface( + xx1, + yy1, + zz1, + facecolors=fc1, + rstride=1, + cstride=1, + shade=False, + ) + ax.plot_surface( + xx2, + yy2, + zz2, + facecolors=fc2, + rstride=1, + cstride=1, + shade=False, + ) + ax.set_xlabel("$x$") + ax.set_ylabel("$y$") + ax.set_zlabel("$z$") + title = split_long_string(key[0]) if len(key) == 1 else "" + ax.set_title(title, fontsize="medium") self.fig.canvas.draw_idle() diff --git a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py new file mode 100644 index 0000000000..aea91169e3 --- /dev/null +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -0,0 +1,423 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +import pybamm +from pybamm.plotting.plot_vtk import ( + VTKQuickPlot, + _build_vtk_grid, + _compute_scale, + _data_at_time, + _is_unstructured_spatial_variable, + _make_render_window, + _resolve_scale, + _set_cell_scalars, + _set_point_scalars, + _viridis_lut, +) + +vtk = pytest.importorskip("vtk") + + +def _tetra_mesh(): + nodes = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ) + return pybamm.UnstructuredSubMesh(nodes, np.array([[0, 1, 2, 3]])) + + +def _cell_solution(): + mesh = _tetra_mesh() + model = pybamm.BaseModel() + xyz = [pybamm.SpatialVariable(axis, domain="mesh") for axis in "xyz"] + model._geometry = { + "mesh": {var: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)} for var in xyz} + } + + field = pybamm.StateVector(slice(0, 1), domain="mesh") + field.mesh = mesh + model.variables = {"field": field, "scalar": pybamm.t} + model.update_processed_variables(model.variables) + + t = np.array([0.0, 1.0, 2.0]) + y = np.asfortranarray([[1.0, 2.0, 3.0]]) + return pybamm.Solution(t, y, model, {}), mesh + + +def _triangle_solution(): + mesh = pybamm.UnstructuredSubMesh( + np.array([[0.0, 0.0], [2.0, 0.0], [0.0, 1.0]]), + np.array([[0, 1, 2]]), + ) + model = pybamm.BaseModel() + x = pybamm.SpatialVariable("x", domain="mesh") + z = pybamm.SpatialVariable("z", domain="mesh") + model._geometry = { + "mesh": { + x: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(2)}, + z: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, + } + } + field = pybamm.StateVector(slice(0, 1), domain="mesh") + field.mesh = mesh + model.variables = {"field": field} + model.update_processed_variables(model.variables) + solution = pybamm.Solution( + np.array([0.0, 1.0]), np.asfortranarray([[1.0, 2.0]]), model, {} + ) + return solution + + +def _node_solution(): + mesh = SimpleNamespace( + nodes=np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ), + elements=np.array([[0, 1, 2, 3]]), + dimension=3, + npts=4, + ) + model = pybamm.BaseModel() + xyz = [pybamm.SpatialVariable(axis, domain="mesh") for axis in "xyz"] + model._geometry = { + "mesh": {var: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)} for var in xyz} + } + + field = pybamm.StateVector(slice(0, 4), domain="mesh") + field.mesh = mesh + model.variables = {"node field": field} + model.update_processed_variables(model.variables) + + t = np.array([0.0, 1.0, 2.0]) + y = np.asfortranarray( + [ + [0.0, 1.0, 2.0], + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + [3.0, 4.0, 5.0], + ] + ) + solution = pybamm.Solution(t, y, model, {}) + casadi_field, field, _ = solution._convert_to_casadi(field, {}, y.shape) + solution._variables["node field"] = pybamm.ProcessedVariableUnstructured( + "node field", [field], [casadi_field], solution + ) + return solution, mesh + + +def _first_actor(renderer): + actors = renderer.GetActors() + actors.InitTraversal() + return actors.GetNextActor() + + +class TestVTKHelpers: + @pytest.mark.parametrize( + ("n_vertices", "cell_type"), + [ + (3, vtk.VTK_TRIANGLE), + (4, vtk.VTK_TETRA), + (8, vtk.VTK_HEXAHEDRON), + ], + ) + def test_build_grid_infers_cell_type(self, n_vertices, cell_type): + nodes = np.column_stack( + [ + np.arange(n_vertices, dtype=float), + np.arange(n_vertices, dtype=float) + 1, + np.arange(n_vertices, dtype=float) + 2, + ] + ) + mesh = SimpleNamespace(nodes=nodes, elements=np.array([np.arange(n_vertices)])) + + grid = _build_vtk_grid(mesh) + + assert grid.GetNumberOfPoints() == n_vertices + assert grid.GetNumberOfCells() == 1 + assert grid.GetCellType(0) == cell_type + np.testing.assert_array_equal( + [grid.GetCell(0).GetPointId(i) for i in range(n_vertices)], + np.arange(n_vertices), + ) + + def test_build_grid_uses_element_type_and_scales_2d_points(self): + mesh = SimpleNamespace( + nodes=np.array([[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]), + elements=np.array([[0, 1, 2, 3]]), + element_type="quad", + ) + + grid = _build_vtk_grid(mesh, scale=(2.0, 3.0, 99.0)) + + assert grid.GetCellType(0) == vtk.VTK_QUAD + np.testing.assert_allclose(grid.GetPoint(0), [2.0, 6.0, 0.0]) + np.testing.assert_allclose(grid.GetPoint(2), [6.0, 12.0, 0.0]) + + def test_build_grid_rejects_unknown_connectivity(self): + mesh = SimpleNamespace( + nodes=np.zeros((5, 3)), elements=np.array([[0, 1, 2, 3, 4]]) + ) + + with pytest.raises(ValueError, match="5 vertices per element"): + _build_vtk_grid(mesh) + + def test_scale_options(self): + mesh = SimpleNamespace(nodes=np.array([[0.0, 2.0, 3.0], [4.0, 2.0, 5.0]])) + + np.testing.assert_allclose(_compute_scale(mesh), [1.0, 1.0, 2.0]) + np.testing.assert_allclose(_resolve_scale("auto", mesh), [1.0, 1.0, 2.0]) + assert _resolve_scale(None, mesh) is None + np.testing.assert_allclose(_resolve_scale((3, 2, 1), mesh), [3, 2, 1]) + + zero_mesh = SimpleNamespace(nodes=np.ones((3, 2))) + np.testing.assert_array_equal(_compute_scale(zero_mesh), [1.0, 1.0]) + + def test_set_and_update_cell_and_point_scalars(self): + grid = _build_vtk_grid(_tetra_mesh()) + + _set_cell_scalars(grid, "cell", [1.25]) + cell_array = grid.GetCellData().GetArray("cell") + assert grid.GetCellData().GetScalars().GetName() == "cell" + assert cell_array.GetNumberOfTuples() == 1 + assert cell_array.GetValue(0) == pytest.approx(1.25) + + _set_cell_scalars(grid, "cell", [3.5]) + assert grid.GetCellData().GetArray("cell") is cell_array + assert cell_array.GetValue(0) == pytest.approx(3.5) + + _set_point_scalars(grid, "point", [0.5, 1.5, 2.5, 3.5]) + point_array = grid.GetPointData().GetArray("point") + assert grid.GetPointData().GetScalars().GetName() == "point" + np.testing.assert_allclose( + [point_array.GetValue(i) for i in range(4)], [0.5, 1.5, 2.5, 3.5] + ) + + _set_point_scalars(grid, "point", [4, 3, 2, 1]) + assert grid.GetPointData().GetArray("point") is point_array + np.testing.assert_allclose( + [point_array.GetValue(i) for i in range(4)], [4, 3, 2, 1] + ) + + def test_processed_variable_helpers(self): + cell_solution, _ = _cell_solution() + cell_variable = cell_solution["field"] + scalar_variable = cell_solution["scalar"] + node_solution, _ = _node_solution() + node_variable = node_solution["node field"] + + assert _is_unstructured_spatial_variable(cell_variable) + assert _is_unstructured_spatial_variable(node_variable) + assert not _is_unstructured_spatial_variable(scalar_variable) + np.testing.assert_allclose(_data_at_time(cell_variable, 0.5), [[1.5]]) + assert _data_at_time(scalar_variable, 0.5) == pytest.approx(0.5) + + def test_viridis_lookup_table(self): + lut = _viridis_lut(-2.0, 4.0, n=8) + + assert lut.GetNumberOfTableValues() == 8 + np.testing.assert_allclose(lut.GetRange(), [-2.0, 4.0]) + assert lut.GetTableValue(0)[3] == pytest.approx(1.0) + assert lut.GetTableValue(7)[3] == pytest.approx(1.0) + assert lut.GetTableValue(0) != lut.GetTableValue(7) + + def test_make_render_window_offscreen(self): + import sys + + window = _make_render_window(off_screen=True) + + assert window.GetOffScreenRendering() == 1 + if sys.platform.startswith("linux"): + assert isinstance(window, vtk.vtkOSOpenGLRenderWindow) + + +class TestVTKQuickPlot: + def test_initialisation_accepts_solution_simulation_and_options(self): + solution, mesh = _cell_solution() + + default_plot = VTKQuickPlot(solution) + assert default_plot.output_variables == ["field"] + assert default_plot.mesh is mesh + assert default_plot.spatial_panels == [ + ("field", {"plot_type": "3d", "scale": "auto"}) + ] + + simulation = pybamm.Simulation(solution.all_models[0]) + simulation._solution = solution + plot = VTKQuickPlot( + simulation, + "field", + options={ + "field": [ + {"plot_type": "3d", "scale": None}, + {"plot_type": "slice", "z": 0.25}, + ] + }, + interpolate_time=True, + ) + assert plot.solution is solution + assert plot.spatial_names == ["field"] + assert plot.scalar_names == [] + assert plot.interpolate_time + assert plot.spatial_panels == [ + ("field", {"plot_type": "3d", "scale": None}), + ( + "field", + {"plot_type": "slice", "scale": "auto", "z": 0.25}, + ), + ] + assert VTKQuickPlot([solution], "scalar").solution is solution + + def test_dynamic_plot_cell_data_slices_scalar_chart_and_snapped_slider(self): + solution, _ = _cell_solution() + plot = VTKQuickPlot( + solution, + ["field", "scalar"], + options={ + "field": [ + {"plot_type": "3d"}, + {"plot_type": "slice", "x": 0.4}, + {"plot_type": "slice", "y": 0.4}, + {"plot_type": "slice", "z": 0.4}, + ] + }, + ) + + plot.dynamic_plot(show_plot=False) + + assert plot._window.GetWindowName() == "PyBaMM - field, scalar" + assert plot._window.GetSize() == (1950, 1040) + assert plot._window.GetRenderers().GetNumberOfItems() == 7 + assert plot._slider.GetEnabled() == 1 + + plot._slider.GetRepresentation().SetValue(1.6) + plot._slider.InvokeEvent("InteractionEvent") + + renderers = plot._window.GetRenderers() + renderers.InitTraversal() + field_renderer = renderers.GetNextItem() + mapped_data = _first_actor(field_renderer).GetMapper().GetInput() + values = mapped_data.GetPointData().GetArray("field") + assert values.GetValue(0) == pytest.approx(3.0) + + def test_dynamic_plot_2d_panels_share_camera(self): + plot = VTKQuickPlot( + _triangle_solution(), + "field", + options={"field": [{"plot_type": "3d"}, {"plot_type": "3d"}]}, + ) + plot.dynamic_plot(show_plot=False) + + renderers = plot._window.GetRenderers() + renderers.InitTraversal() + first = renderers.GetNextItem() + second = renderers.GetNextItem() + assert first.GetActiveCamera() is second.GetActiveCamera() + assert first.GetActiveCamera().GetParallelProjection() == 0 + + def test_dynamic_plot_interpolates_cell_data(self): + solution, _ = _cell_solution() + plot = VTKQuickPlot( + solution, + "field", + options={"field": {"scale": None}}, + interpolate_time=True, + ) + plot.dynamic_plot(show_plot=False) + + plot._slider.GetRepresentation().SetValue(1.25) + plot._slider.InvokeEvent("InteractionEvent") + + renderers = plot._window.GetRenderers() + renderers.InitTraversal() + mapped_data = _first_actor(renderers.GetNextItem()).GetMapper().GetInput() + values = mapped_data.GetPointData().GetArray("field") + assert values.GetValue(0) == pytest.approx(2.25) + + def test_dynamic_plot_interpolates_node_data_and_updates_slice(self): + solution, _ = _node_solution() + plot = VTKQuickPlot( + solution, + "node field", + options={ + "node field": [ + {"plot_type": "3d"}, + {"plot_type": "slice", "x": 0.25}, + ] + }, + interpolate_time=True, + ) + plot.dynamic_plot(show_plot=False) + + plot._slider.GetRepresentation().SetValue(1.25) + plot._slider.InvokeEvent("InteractionEvent") + + renderers = plot._window.GetRenderers() + renderers.InitTraversal() + point_data = _first_actor(renderers.GetNextItem()).GetMapper().GetInput() + values = point_data.GetPointData().GetArray("node field") + np.testing.assert_allclose( + [values.GetValue(i) for i in range(4)], [1.25, 2.25, 3.25, 4.25] + ) + + def test_dynamic_plot_node_data_direct_and_slice_pipelines(self): + solution, _ = _node_solution() + plot = VTKQuickPlot( + solution, + "node field", + options={ + "node field": [ + {"plot_type": "3d", "scale": None}, + {"plot_type": "slice", "z": 0.3, "scale": None}, + ] + }, + ) + plot.dynamic_plot(show_plot=False) + + renderers = plot._window.GetRenderers() + renderers.InitTraversal() + direct_data = _first_actor(renderers.GetNextItem()).GetMapper().GetInput() + point_values = direct_data.GetPointData().GetArray("node field") + np.testing.assert_allclose( + [point_values.GetValue(i) for i in range(4)], [0, 1, 2, 3] + ) + + plot._slider.GetRepresentation().SetValue(2.0) + plot._slider.InvokeEvent("InteractionEvent") + np.testing.assert_allclose( + [point_values.GetValue(i) for i in range(4)], [2, 3, 4, 5] + ) + + def test_dynamic_plot_slice_requires_axis(self): + solution, _ = _cell_solution() + plot = VTKQuickPlot( + solution, "field", options={"field": {"plot_type": "slice"}} + ) + + with pytest.raises(ValueError, match="requires one of 'x', 'y', or 'z'"): + plot.dynamic_plot(show_plot=False) + + def test_save_gif_builds_plot_and_writes_animation(self, tmp_path): + Image = pytest.importorskip("PIL.Image") + solution, _ = _cell_solution() + plot = VTKQuickPlot(solution, "field") + output = tmp_path / "field.gif" + + plot.save_gif(output, fps=5, n_frames=2, width=160, height=100) + plot.save_gif(output, fps=5, n_frames=2, width=160, height=100) + + assert output.stat().st_size > 0 + with Image.open(output) as image: + assert image.size == (160, 100) + assert image.n_frames == 2 + assert image.info["duration"] == 200 diff --git a/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py b/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py index 45956fc020..c745adf8d9 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py +++ b/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py @@ -218,7 +218,7 @@ def test_simple_ode_model(self, solver): quick_plot.dynamic_plot(show_plot=False) quick_plot.slider_update(0.01) - with pytest.raises(NotImplementedError, match=r"Cannot plot 2D variables"): + with pytest.raises(NotImplementedError, match=r"Cannot plot 2D/3D variables"): pybamm.QuickPlot([solution, solution], ["2D variable"]) # Test different variable limits diff --git a/uv.lock b/uv.lock index f5ee3eb0d4..9e288ee4ae 100644 --- a/uv.lock +++ b/uv.lock @@ -3051,6 +3051,7 @@ all = [ { name = "pybtex" }, { name = "scikit-fem" }, { name = "tqdm" }, + { name = "vtk" }, ] bpx = [ { name = "bpx" }, @@ -3073,6 +3074,9 @@ pydiffsol = [ tqdm = [ { name = "tqdm" }, ] +vtk = [ + { name = "vtk" }, +] [package.dev-dependencies] dev = [ @@ -3141,9 +3145,11 @@ requires-dist = [ { name = "tqdm", marker = "extra == 'all'" }, { name = "tqdm", marker = "extra == 'tqdm'" }, { name = "typing-extensions", specifier = ">=4.16.0" }, + { name = "vtk", marker = "extra == 'all'", specifier = ">=9.0.0" }, + { name = "vtk", marker = "extra == 'vtk'", specifier = ">=9.0.0" }, { name = "xarray", specifier = ">=2022.6.0" }, ] -provides-extras = ["all", "bpx", "cite", "examples", "jax", "plot", "pydiffsol", "tqdm"] +provides-extras = ["all", "bpx", "cite", "examples", "jax", "plot", "pydiffsol", "tqdm", "vtk"] [package.metadata.requires-dev] dev = [ @@ -4919,6 +4925,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "vtk" +version = "9.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/3f/f4d0cbc05c1a494b2cf590135949f44fde97f0e7470bc5df20c9f8a3da61/vtk-9.6.2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8ed0c1e329fd857c696c44609df7be952fcb08c69e30c94e025fbcef712480ee", size = 114703522, upload-time = "2026-05-19T04:46:19.668Z" }, + { url = "https://files.pythonhosted.org/packages/41/e3/47546c2baf31e31d6039866948f6d4f2aae636e06f16c8cccd2ba0fc11fa/vtk-9.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:809065272b207439f13ef0f62767f9041b4c6d61dd4dc2f60a1a62201843dc4b", size = 106906792, upload-time = "2026-05-19T04:46:24.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/c28d4070cca9923c419be191ca6d4b444bc030a992b38243bf6d114042d0/vtk-9.6.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a0df4ed93b6ae7f05cb6cee9d80566f4c06c6b4ce9116ee406445703c379ef7", size = 145981000, upload-time = "2026-05-19T04:46:30.482Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2f/320ae500942ffbf8c19205e3f98797a575db8e8b0e7815abf17601423a53/vtk-9.6.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3cde8ba867cce14fdce8d8ec7bdef36a328cbd126433490109eccc586e489916", size = 135731415, upload-time = "2026-05-19T04:46:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/c3/58/eb6c9788ec15b30a3e3e0885b92b5fa1f9dc6e619460020c707828a83ade/vtk-9.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:9b55baa61beafc00d68b571ef07b71e2343a02200f089f07b6a40c3ebe01844a", size = 81289717, upload-time = "2026-05-19T04:46:41.149Z" }, + { url = "https://files.pythonhosted.org/packages/06/15/910b90b0b44d474f7cc71ccfe6e63393421fc0d161aabb490afe871830a3/vtk-9.6.2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ab2848c26c70fe57c41656d5ab48f47e8fa4f78ccbb113cf86c8c6de71c3118d", size = 114703453, upload-time = "2026-05-19T04:46:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/8b4a37663aacd242c70c7a8feb2d2a4140ef5ded39ec0193af2d5a673098/vtk-9.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:40fb9d9172cbd0b85a7f39df3646029449e563c61f54bedd3244427035f55ba3", size = 106906589, upload-time = "2026-05-19T04:46:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/5c/148d54b90a2cd39809512d63d70b9b672f0e58f49d937f964a3167d63cf8/vtk-9.6.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0fd9fa3f851192619ac0cec05591ab88adbed67ba063903297d2bb40b457bd00", size = 145980985, upload-time = "2026-05-19T04:46:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/ee/9a4f42a8b98cfb095570ef203685186843389bb66d0da6d36581821888e8/vtk-9.6.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e640839fa24fc7c2153387d535527cfcd7d270e17e0d47f00fefd80b3b043577", size = 135731405, upload-time = "2026-05-19T04:47:01.547Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/e511d83d6b4d5b0acbce5e6a82110c510e3b416b39c667e6a52b1f78291a/vtk-9.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b935949cfc80f1d300d0b0ed8ccab47fb45c337910966de33a486d342e3c7daa", size = 81290711, upload-time = "2026-05-19T04:47:06.029Z" }, + { url = "https://files.pythonhosted.org/packages/60/d3/344f3664586f1f8bb5f7950db73c257439f5c796e0978e7a53e91d0fe2aa/vtk-9.6.2-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:2adcaed1cc4d3411a6b19834d6ed7d480f9ad9252e56546ea9930e66beae84e8", size = 114881278, upload-time = "2026-05-19T04:47:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b1/754bd95da3d216e852014758c9ad626601482e2e0b1da98fa83adc5fe1b8/vtk-9.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d1eb5368039dd6a88e8102bee1db50e6f1e5a12945887ea1025069a99fb8088e", size = 106960169, upload-time = "2026-05-19T04:47:17.187Z" }, + { url = "https://files.pythonhosted.org/packages/96/f8/b392298c74aa7b88c731a43253ccd50b388bf42a1a29b50fa735c4f22f41/vtk-9.6.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c9f31430d15afbf46c2076cf3e30b4d6136512a5faabee8318552ccea907323", size = 146027355, upload-time = "2026-05-19T04:47:22.412Z" }, + { url = "https://files.pythonhosted.org/packages/71/35/67a7760852100c98a8fb6f1221e7eb359ad4d0b1b9edd8beea42c79e16c4/vtk-9.6.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:91e1962c93217cf91ca4fe50762dd26dfc22290b74b8b0de77576f93f7c17abb", size = 135789817, upload-time = "2026-05-19T04:47:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/58e162d9cdbf02a3578ef4e68f4003b8cb9f351b7aa409960da3cc383017/vtk-9.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:83b4af00b31395a13acb20e26a42ee097b85e41a0a087be2340d3749cb58250f", size = 81305902, upload-time = "2026-05-19T04:47:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/2eb42db93ac200180ca1a6ffe6f95d010181528e9490464621c31628e35f/vtk-9.6.2-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:155a09485a9efcbb0afb0214159bb920cf437fe4d897a6f945f6d66a533d863c", size = 114899285, upload-time = "2026-05-19T04:47:37.237Z" }, + { url = "https://files.pythonhosted.org/packages/00/51/5abfa4dc321b864e57bde87be8a328ee97b3aa33cc5e8f736c162fac63cb/vtk-9.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f7055447d36914ed8b39c5738defe467566ef2d7a75ca92a3023aefc269e1f06", size = 106962262, upload-time = "2026-05-19T04:47:41.886Z" }, + { url = "https://files.pythonhosted.org/packages/bd/75/4a1fe360256b99779d534b2387d0efa70952167d53d716f60ff39d62994d/vtk-9.6.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb85c7fbad59209a08e428479defbdf96f974a9f39d4212960fb1a24a919613c", size = 146027814, upload-time = "2026-05-19T04:47:47.238Z" }, + { url = "https://files.pythonhosted.org/packages/e8/30/90ea11e053d7e0571d70a7f301cebe04cc83874b533ddeaedb5258b0f9e6/vtk-9.6.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:35e1e9ffb6457f16c37d0e025f7db8619961dffc3e9a85a8e5d358d5099c277c", size = 135792045, upload-time = "2026-05-19T04:47:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/5b/e03640322971339899f982691396038d793ecfde8adbc208a97d501d6b8e/vtk-9.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:4e9ad047a2b658550d5099dc808b26e331a9c94c3226c28453873035b8b48b41", size = 81307827, upload-time = "2026-05-19T04:47:58.76Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a4/615178228ec84e3793332fbdd62f85965e246366727ef31b20670ce4877d/vtk-9.6.2-cp314-cp314-macosx_10_10_x86_64.whl", hash = "sha256:8a3edd56b63d1ab4ff022e70dac50dd54a66019462c37734e288c9e222809624", size = 114534790, upload-time = "2026-05-19T04:48:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/19f6f3f4520595e28b425fe7cae220fa8f4fb963ba9d0c0c4001f54ba8ae/vtk-9.6.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da01ec70cdfef3fddf659fd35a0258e820065d5331b3c43d5f7e3548351118ea", size = 106971615, upload-time = "2026-05-19T04:48:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/bb/84/80465e452292a219e4b72bded477cee155f9ec44b856e3a877e0f95eba54/vtk-9.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f64288a71b313251300bc798458848aabbfd241230546ce75af90b8e099f14b", size = 146043416, upload-time = "2026-05-19T04:48:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/c2/00/16e9faacb40cdcf487f8f5cb39d4304e71ebfafe38eb38a2c8f74527ca83/vtk-9.6.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1c3c050668eacb73db20fd07f844321a05dc3d7c60221db7137bd0b8f93a4d82", size = 135816286, upload-time = "2026-05-19T04:48:21.241Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/11594ed6bb95393f5656c96945ae22e297dfc3ccc5d7cd816973aaf9fc0d/vtk-9.6.2-cp314-cp314-win_amd64.whl", hash = "sha256:7b99aad09f712442345bb1c2a5529ad46da3a26fdc12cce54408ff800ccfbb19", size = 83246191, upload-time = "2026-05-19T04:48:26.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/a71b05850acbfb99e0de63c940ae874b665a393adb186a0e9c64cf6c9d64/vtk-9.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691e3a9ae1b62784cf2e9b7d87696bdeac14e1b9905bf020f4c86fab5b756626", size = 145665062, upload-time = "2026-05-19T04:48:31.223Z" }, + { url = "https://files.pythonhosted.org/packages/4a/57/6410098435a3976cc749c151c4e09bff4bcf4af0ebeb13b3e47fbd5871e5/vtk-9.6.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b06725993112097f43daefca0f3637a73a63c67b61a9c124455d881bc05cc9a4", size = 135630292, upload-time = "2026-05-19T04:48:37.053Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" From 034714d81378f58da6f215032700766325e6de26 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 3 Aug 2026 14:51:37 -0700 Subject: [PATCH 2/9] fix(plotting): draw each VTK panel on its own variable's mesh Every panel was built from the FIRST spatial variable's mesh, so a 3-domain variable (e.g. electrolyte concentration) plotted alongside a 5-domain one was painted onto the larger grid: all values shifted by the leading domains' cell count, rendering electrolyte data on current-collector tabs. Build each panel's grid from that variable's own mesh, and make _set_cell/point_scalars raise on a length mismatch so this bug class errors instead of drawing a wrong picture. Co-Authored-By: Claude Fable 5 --- .../pybamm/src/pybamm/plotting/plot_vtk.py | 23 +++++++++++++++++-- .../tests/unit/test_plotting/test_plot_vtk.py | 13 +++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index f0b9d2a494..cb83333c50 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -92,6 +92,12 @@ def _set_cell_scalars(grid, name, values): """Set (or update) a cell scalar array on a VTK grid.""" import vtk + if len(values) != grid.GetNumberOfCells(): + raise ValueError( + f"Cannot attach {len(values)} cell values for {name!r} to a grid " + f"with {grid.GetNumberOfCells()} cells: the variable and the " + f"grid describe different meshes." + ) arr = grid.GetCellData().GetArray(name) if arr is None: arr = vtk.vtkFloatArray() @@ -109,6 +115,13 @@ def _set_point_scalars(grid, name, values): """Set (or update) a point scalar array on a VTK grid.""" import vtk + if len(values) != grid.GetNumberOfPoints(): + raise ValueError( + f"Cannot attach {len(values)} point values for {name!r} to a grid " + f"with {grid.GetNumberOfPoints()} points: the variable and the " + f"grid describe different meshes." + ) + arr = grid.GetPointData().GetArray(name) if arr is None: arr = vtk.vtkFloatArray() @@ -333,13 +346,19 @@ def dynamic_plot(self, show_plot=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") - var_scale = _resolve_scale(opts.get("scale", "auto"), self.mesh) + # Each variable is drawn on its OWN mesh: a 3-domain variable + # (e.g. electrolyte concentration) must not be painted onto the + # first variable's 5-domain grid, which shifts every value by + # the leading domains' cell count. + panel_mesh = pv_by_name[name].mesh + 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(self.mesh, scale=var_scale) + g = _build_vtk_grid(panel_mesh, scale=var_scale) if is_cell_data: _set_cell_scalars(g, name, spatial_data[name][:, 0]) else: diff --git a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py index aea91169e3..7eb69f093b 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -209,6 +209,19 @@ def test_set_and_update_cell_and_point_scalars(self): [point_array.GetValue(i) for i in range(4)], [4, 3, 2, 1] ) + def test_scalar_length_mismatch_raises(self): + """A variable must not attach to a grid built from a different mesh. + + Painting a 3-domain variable onto another variable's larger grid + shifts every value by the leading domains' cell count (e.g. + electrolyte concentration rendered on current-collector tabs). + """ + grid = _build_vtk_grid(_tetra_mesh()) + with pytest.raises(ValueError, match="different meshes"): + _set_cell_scalars(grid, "cell", [1.0, 2.0]) + with pytest.raises(ValueError, match="different meshes"): + _set_point_scalars(grid, "point", [1.0]) + def test_processed_variable_helpers(self): cell_solution, _ = _cell_solution() cell_variable = cell_solution["field"] From fcd42904d8544278b8d7b47d57592bc067422b10 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Thu, 3 Sep 2026 12:12:34 -0700 Subject: [PATCH 3/9] Sample unstructured variables for plotting on the plotting side The regular visualisation grid, mid-plane slices, quiver sampling and slice-plane positions used to live on the unstructured processed variables; the spatial-method PR now ships them as pure point interpolators. Add pybamm.plotting.unstructured_plot_grid with plot_grid, default_slice_positions, midplane_slices and quiver_data, and have QuickPlot own the grid and slice positions per plotted variable (the slice sliders update QuickPlot state). Meshes expose `vertices`, not `nodes`; fix plot_vtk and its test stand-ins. Co-Authored-By: Claude Opus 5 --- docs/source/api/plotting/index.rst | 1 + .../api/plotting/unstructured_plot_grid.rst | 5 + .../pybamm/src/pybamm/plotting/plot_vtk.py | 8 +- .../pybamm/src/pybamm/plotting/quick_plot.py | 88 +++++++---- .../pybamm/plotting/unstructured_plot_grid.py | 97 ++++++++++++ .../tests/unit/test_plotting/test_plot_vtk.py | 14 +- .../test_unstructured_plot_grid.py | 147 ++++++++++++++++++ .../test_solvers/test_processed_variable.py | 7 +- 8 files changed, 324 insertions(+), 43 deletions(-) create mode 100644 docs/source/api/plotting/unstructured_plot_grid.rst create mode 100644 packages/pybamm/src/pybamm/plotting/unstructured_plot_grid.py create mode 100644 packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py diff --git a/docs/source/api/plotting/index.rst b/docs/source/api/plotting/index.rst index 5416399b0c..bc43f4f45d 100644 --- a/docs/source/api/plotting/index.rst +++ b/docs/source/api/plotting/index.rst @@ -11,3 +11,4 @@ Plotting plot_3d_cross_section plot_3d_heatmap plot_vtk + unstructured_plot_grid diff --git a/docs/source/api/plotting/unstructured_plot_grid.rst b/docs/source/api/plotting/unstructured_plot_grid.rst new file mode 100644 index 0000000000..b9510fa3f6 --- /dev/null +++ b/docs/source/api/plotting/unstructured_plot_grid.rst @@ -0,0 +1,5 @@ +Unstructured plot sampling +========================== + +.. automodule:: pybamm.plotting.unstructured_plot_grid + :members: diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index cb83333c50..17ea2cc191 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -26,7 +26,7 @@ def _build_vtk_grid(mesh, scale=None): """Build a ``vtkUnstructuredGrid`` from an unstructured mesh.""" import vtk - nodes = mesh.nodes + nodes = mesh.vertices if scale is not None: nodes = nodes * np.asarray(scale)[: nodes.shape[1]] @@ -69,7 +69,7 @@ def _build_vtk_grid(mesh, scale=None): def _compute_scale(mesh): """Per-axis scale factors that normalise coordinate spans to the largest.""" - nodes = mesh.nodes + nodes = mesh.vertices spans = np.array( [nodes[:, d].max() - nodes[:, d].min() for d in range(nodes.shape[1])] ) @@ -388,7 +388,7 @@ def dynamic_plot(self, show_plot=True): ) axis_idx = _AXIS_INDEX[axis_key] frac = float(opts[axis_key]) - nodes = self.mesh.nodes + nodes = self.mesh.vertices lo = float(nodes[:, axis_idx].min()) hi = float(nodes[:, axis_idx].max()) phys_val = lo + frac * (hi - lo) @@ -480,7 +480,7 @@ def dynamic_plot(self, show_plot=True): # Cube axes if self.mesh is not None: - mesh_nodes = self.mesh.nodes + mesh_nodes = self.mesh.vertices dim = mesh_nodes.shape[1] if plot_type == "slice": diff --git a/packages/pybamm/src/pybamm/plotting/quick_plot.py b/packages/pybamm/src/pybamm/plotting/quick_plot.py index 096ec7c329..2e77e9a37f 100644 --- a/packages/pybamm/src/pybamm/plotting/quick_plot.py +++ b/packages/pybamm/src/pybamm/plotting/quick_plot.py @@ -6,6 +6,12 @@ import numpy as np import pybamm +from pybamm.plotting.unstructured_plot_grid import ( + default_slice_positions, + midplane_slices, + plot_grid, + quiver_data, +) from pybamm.util import import_optional_dependency @@ -293,6 +299,8 @@ def set_output_variables(self, output_variables, solutions): self.x_first_and_y_second = {} self.is_y_z = {} self.is_vector_field = {} + self._unstructured_grids = {} + self._slice_positions = {} # Calculate subplot positions based on number of variables supplied self.subplot_positions = {} @@ -318,16 +326,6 @@ def set_output_variables(self, output_variables, solutions): # just use the first solution to check this first_solution = variables[0] first_variable = first_solution[0] - if isinstance( - first_variable, - pybamm.ProcessedVariableUnstructuredFVM - | pybamm.ProcessedVariableVectorFieldUnstructuredFVM, - ): - raise NotImplementedError( - f"QuickPlot cannot plot '{variable_tuple[0]}': variables on " - "unstructured meshes have no plotting support yet. Query the " - "variable at points with solution[name](t, x=..., z=...) instead." - ) domain = first_variable.domain # check all other solutions against the first solution for idx, variable in enumerate(first_solution): @@ -338,6 +336,18 @@ def set_output_variables(self, output_variables, solutions): ) self.spatial_variable_dict[variable_tuple] = {} + if isinstance( + first_variable, + pybamm.ProcessedVariableUnstructuredFVM + | pybamm.ProcessedVariableVectorFieldUnstructuredFVM, + ): + # display grid and slice planes are plot state, not variable state + self._unstructured_grids[variable_tuple] = plot_grid(first_variable) + if first_variable.dimensions == 3: + self._slice_positions[variable_tuple] = default_slice_positions( + first_variable + ) + # Set the x variable (i.e. "x" or "r" for any one-dimensional variables) if first_variable.dimensions == 1: (spatial_var_name, spatial_var_value) = self._get_spatial_var( @@ -405,6 +415,12 @@ def set_output_variables(self, output_variables, solutions): def _get_spatial_var(self, key, variable, dimension): """Return the appropriate spatial variable(s)""" + grid = self._unstructured_grids.get(key) + if grid is not None: + names = list(grid) + name = names[0] if dimension == "first" else names[1] + return name, grid[name] + # Extract name and value # Special case for current collector, which is 2D but in a weird way (both # first and second variables are in the same domain, not auxiliary domain) @@ -638,7 +654,9 @@ def plot(self, t, dynamic=False): elif self.is_vector_field.get(key, False): variable = variable_lists[0][0] if variable.dimensions == 2: - X, Z, U, W = variable.get_quiver_data(t_in_seconds) + X, Z, U, W = quiver_data( + variable, t_in_seconds, self._unstructured_grids[key] + ) Xs = X * self.spatial_factor Zs = Z * self.spatial_factor mag = np.sqrt(U**2 + W**2) @@ -729,8 +747,11 @@ def plot(self, t, dynamic=False): import matplotlib.pyplot as _plt cmap = _plt.cm.viridis - s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = variable.get_3d_slices( - t_in_seconds + s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = midplane_slices( + variable, + t_in_seconds, + self._unstructured_grids[key], + self._slice_positions[key], ) fc1 = self._slice_facecolors(s1, cmap, norm) fc2 = self._slice_facecolors(s2, cmap, norm) @@ -821,7 +842,7 @@ def _overlay_mesh_wireframe(self, ax, variable): mesh = variable.mesh if mesh.dimension != 2: return - verts = mesh.nodes[mesh.elements] * self.spatial_factor + verts = mesh.vertices[mesh.elements] * self.spatial_factor poly = PolyCollection( verts, facecolors="none", edgecolors=(0, 0, 0, 0.12), linewidths=0.3 ) @@ -830,7 +851,9 @@ def _overlay_mesh_wireframe(self, ax, variable): def _plot_3d_quiver(self, ax, variable, t, key, cm, colors): """Render quiver arrows on two orthogonal 3D slice planes.""" sf = self.spatial_factor - data = variable.get_quiver_data(t) + data = quiver_data( + variable, t, self._unstructured_grids[key], self._slice_positions[key] + ) X1, Z1, U_xz, W_xz, y_mid = data[0:5] X2, Y2, U_xy, V_xy, z_mid = data[5:10] @@ -922,13 +945,15 @@ def dynamic_plot(self, show_plot=True, step=None): if has_3d: self._slice_sliders = {} - var_3d = next( - vl[0][0] - for vl in self.variables.values() + key_3d = next( + key + for key, vl in self.variables.items() if vl[0][0].dimensions == 3 ) - y_pts = var_3d.second_dim_pts - z_pts = var_3d.third_dim_pts + grid_3d = self._unstructured_grids[key_3d] + positions = self._slice_positions[key_3d] + y_pts = grid_3d["y"] + z_pts = grid_3d["z"] ax_y = plt.axes([0.315, 0.04, 0.37, 0.025], facecolor=axcolor) self._slice_sliders["y"] = Slider( @@ -936,7 +961,7 @@ def dynamic_plot(self, show_plot=True, step=None): "$y$ slice", y_pts[0], y_pts[-1], - valinit=var_3d._slice_positions["y"], + valinit=positions["y"], color="#ff7f0e", ) ax_z = plt.axes([0.315, 0.005, 0.37, 0.025], facecolor=axcolor) @@ -945,16 +970,14 @@ def dynamic_plot(self, show_plot=True, step=None): "$z$ slice", z_pts[0], z_pts[-1], - valinit=var_3d._slice_positions["z"], + valinit=positions["z"], color="#2ca02c", ) def _on_slice_change(_): - for vl in self.variables.values(): - v = vl[0][0] - if v.dimensions == 3: - v._slice_positions["y"] = self._slice_sliders["y"].val - v._slice_positions["z"] = self._slice_sliders["z"].val + for slice_positions in self._slice_positions.values(): + slice_positions["y"] = self._slice_sliders["y"].val + slice_positions["z"] = self._slice_sliders["z"].val self.slider_update(self.slider.val) self._slice_sliders["y"].on_changed(_on_slice_change) @@ -997,7 +1020,9 @@ def slider_update(self, t): variable = self.variables[key][0][0] ax.clear() if variable.dimensions == 2: - X, Z, U, W = variable.get_quiver_data(time_in_seconds) + X, Z, U, W = quiver_data( + variable, time_in_seconds, self._unstructured_grids[key] + ) Xs = X * self.spatial_factor Zs = Z * self.spatial_factor mag = np.sqrt(U**2 + W**2) @@ -1082,8 +1107,11 @@ def slider_update(self, t): cmap = _plt.cm.viridis ax.clear() - s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = variable.get_3d_slices( - time_in_seconds + s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = midplane_slices( + variable, + time_in_seconds, + self._unstructured_grids[key], + self._slice_positions[key], ) fc1 = self._slice_facecolors(s1, cmap, norm) fc2 = self._slice_facecolors(s2, cmap, norm) diff --git a/packages/pybamm/src/pybamm/plotting/unstructured_plot_grid.py b/packages/pybamm/src/pybamm/plotting/unstructured_plot_grid.py new file mode 100644 index 0000000000..cb117ad078 --- /dev/null +++ b/packages/pybamm/src/pybamm/plotting/unstructured_plot_grid.py @@ -0,0 +1,97 @@ +"""Sampling of unstructured-mesh processed variables for plotting. + +Unstructured processed variables only interpolate at requested points; the +regular visualisation grid, mid-plane slices and quiver sampling that +:class:`pybamm.QuickPlot` draws are display choices and live here. +""" + +from __future__ import annotations + +import numpy as np + +N_POINTS_2D = 200 +N_POINTS_3D = 80 +N_QUIVER = 20 + + +def plot_grid(variable, n_points=None): + """Regular grid over the variable's mesh bounding box. + + Parameters + ---------- + variable : ProcessedVariableUnstructuredFVM or ProcessedVariableVectorFieldUnstructuredFVM + The variable to plot. + n_points : int, optional + Points per axis; defaults to 200 in 2D and 80 in 3D. + + Returns + ------- + dict + One 1D array per axis, keyed ``"x", "z"`` in 2D and ``"x", "y", "z"`` + in 3D, in that order. + """ + vertices = variable.mesh.vertices + dimension = variable.mesh.dimension + if n_points is None: + n_points = N_POINTS_3D if dimension == 3 else N_POINTS_2D + names = ("x", "z") if dimension == 2 else ("x", "y", "z") + return { + name: np.linspace(vertices[:, k].min(), vertices[:, k].max(), n_points) + for k, name in enumerate(names) + } + + +def default_slice_positions(variable): + """Mid-plane ``{"y": ..., "z": ...}`` positions of a 3D variable's mesh.""" + vertices = variable.mesh.vertices + return { + "y": 0.5 * (vertices[:, 1].min() + vertices[:, 1].max()), + "z": 0.5 * (vertices[:, 2].min() + vertices[:, 2].max()), + } + + +def midplane_slices(variable, t, grid, slice_positions): + """Two orthogonal slices through a 3D scalar variable at time ``t``. + + Returns ``(s1, xx1, yy1, zz1, s2, xx2, yy2, zz2)``: the x-z plane at + ``slice_positions["y"]`` followed by the x-y plane at + ``slice_positions["z"]``, each on the ``grid`` with points outside the + domain set to NaN. + """ + x, y, z = grid["x"], grid["y"], grid["z"] + y_mid, z_mid = slice_positions["y"], slice_positions["z"] + s1 = variable(t, x=x, y=np.array([y_mid]), z=z).squeeze(axis=1) + xx1, zz1 = np.meshgrid(x, z, indexing="ij") + yy1 = np.full_like(xx1, y_mid) + s2 = variable(t, x=x, y=y, z=np.array([z_mid])).squeeze(axis=2) + xx2, yy2 = np.meshgrid(x, y, indexing="ij") + zz2 = np.full_like(xx2, z_mid) + return s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 + + +def quiver_data(variable, t, grid, slice_positions=None, n_points=N_QUIVER): + """Vector components on a coarse grid for quiver arrows at time ``t``. + + Returns ``(X, Z, U, W)`` in 2D. In 3D two mid-plane slices are returned + as ``(X1, Z1, U_xz, W_xz, y_mid, X2, Y2, U_xy, V_xy, z_mid)``: the x-z + plane at ``slice_positions["y"]`` followed by the x-y plane at + ``slice_positions["z"]``. + """ + x = np.linspace(grid["x"][0], grid["x"][-1], n_points) + z = np.linspace(grid["z"][0], grid["z"][-1], n_points) + if variable.dimensions == 2: + u, w = variable(t, x=x, z=z) + X, Z = np.meshgrid(x, z, indexing="ij") + return X, Z, u, w + + y = np.linspace(grid["y"][0], grid["y"][-1], n_points) + y_mid, z_mid = slice_positions["y"], slice_positions["z"] + u_xz, _, w_xz = ( + c.squeeze(axis=1) for c in variable(t, x=x, y=np.array([y_mid]), z=z) + ) + X1, Z1 = np.meshgrid(x, z, indexing="ij") + u_xy, v_xy, _ = ( + c.squeeze(axis=2) for c in variable(t, x=x, y=y, z=np.array([z_mid])) + ) + X2, Y2 = np.meshgrid(x, y, indexing="ij") + return X1, Z1, u_xz, w_xz, y_mid, X2, Y2, u_xy, v_xy, z_mid diff --git a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py index 7eb69f093b..b7bc109c4b 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -76,7 +76,7 @@ def _triangle_solution(): def _node_solution(): mesh = SimpleNamespace( - nodes=np.array( + vertices=np.array( [ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], @@ -139,7 +139,9 @@ def test_build_grid_infers_cell_type(self, n_vertices, cell_type): np.arange(n_vertices, dtype=float) + 2, ] ) - mesh = SimpleNamespace(nodes=nodes, elements=np.array([np.arange(n_vertices)])) + mesh = SimpleNamespace( + vertices=nodes, elements=np.array([np.arange(n_vertices)]) + ) grid = _build_vtk_grid(mesh) @@ -153,7 +155,7 @@ def test_build_grid_infers_cell_type(self, n_vertices, cell_type): def test_build_grid_uses_element_type_and_scales_2d_points(self): mesh = SimpleNamespace( - nodes=np.array([[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]), + vertices=np.array([[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]), elements=np.array([[0, 1, 2, 3]]), element_type="quad", ) @@ -166,21 +168,21 @@ def test_build_grid_uses_element_type_and_scales_2d_points(self): def test_build_grid_rejects_unknown_connectivity(self): mesh = SimpleNamespace( - nodes=np.zeros((5, 3)), elements=np.array([[0, 1, 2, 3, 4]]) + vertices=np.zeros((5, 3)), elements=np.array([[0, 1, 2, 3, 4]]) ) with pytest.raises(ValueError, match="5 vertices per element"): _build_vtk_grid(mesh) def test_scale_options(self): - mesh = SimpleNamespace(nodes=np.array([[0.0, 2.0, 3.0], [4.0, 2.0, 5.0]])) + mesh = SimpleNamespace(vertices=np.array([[0.0, 2.0, 3.0], [4.0, 2.0, 5.0]])) np.testing.assert_allclose(_compute_scale(mesh), [1.0, 1.0, 2.0]) np.testing.assert_allclose(_resolve_scale("auto", mesh), [1.0, 1.0, 2.0]) assert _resolve_scale(None, mesh) is None np.testing.assert_allclose(_resolve_scale((3, 2, 1), mesh), [3, 2, 1]) - zero_mesh = SimpleNamespace(nodes=np.ones((3, 2))) + zero_mesh = SimpleNamespace(vertices=np.ones((3, 2))) np.testing.assert_array_equal(_compute_scale(zero_mesh), [1.0, 1.0]) def test_set_and_update_cell_and_point_scalars(self): diff --git a/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py b/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py new file mode 100644 index 0000000000..3fdbf3035c --- /dev/null +++ b/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py @@ -0,0 +1,147 @@ +import casadi +import numpy as np + +import pybamm +from pybamm.plotting.unstructured_plot_grid import ( + default_slice_positions, + midplane_slices, + plot_grid, + quiver_data, +) + + +def _to_casadi(symbol, y): + t_MX = casadi.MX.sym("t") + y_MX = casadi.MX.sym("y", y.shape[0]) + inputs_MX = casadi.vertcat() + return casadi.Function( + "variable", [t_MX, y_MX, inputs_MX], [symbol.to_casadi(t_MX, y_MX, inputs={})] + ) + + +def _unstructured_solution(dim, n): + """Solution on the unit box with scalar ``u = x (1 + t)`` and a constant + vector field ``flux`` of components ``(2, -3[, 4])``.""" + from pybamm.meshes.unstructured_submesh import UnstructuredMeshGenerator + + domain = "negative electrode" + x = pybamm.SpatialVariable("x_n", domain=[domain], coord_sys="cartesian") + if dim == 2: + z = pybamm.SpatialVariable( + "z_2d", domain=[domain], coord_sys="cartesian", direction="tb" + ) + geometry = {domain: {x: {"min": 0, "max": 1}, z: {"min": 0, "max": 1}}} + var_pts = {x: n, z: n} + components = (2.0, -3.0) + else: + y = pybamm.SpatialVariable("y", domain=[domain], coord_sys="cartesian") + z = pybamm.SpatialVariable("z", domain=[domain], coord_sys="cartesian") + geometry = { + domain: { + x: {"min": 0, "max": 1}, + y: {"min": 0, "max": 1}, + z: {"min": 0, "max": 1}, + } + } + var_pts = {x: n, y: n, z: n} + components = (2.0, -3.0, 4.0) + mesh = pybamm.Mesh(geometry, {domain: UnstructuredMeshGenerator()}, var_pts) + disc = pybamm.Discretisation(mesh, {domain: pybamm.FiniteVolumeUnstructured()}) + var = pybamm.Variable("u", domain=[domain]) + flux = pybamm.VectorField( + *[pybamm.PrimaryBroadcast(pybamm.Scalar(c), domain) for c in components] + ) + model = pybamm.BaseModel() + model.rhs = {var: pybamm.Scalar(0)} + model.initial_conditions = {var: pybamm.Scalar(0)} + model.variables = {"u": var, "flux": flux} + model_disc = disc.process_model(model, inplace=False) + model_disc._geometry = geometry + submesh = mesh[domain] + t_sol = np.array([0.0, 1.0]) + y_sol = submesh.cell_centroids[:, 0][:, np.newaxis] * (1 + t_sol)[np.newaxis, :] + return pybamm.Solution(t_sol, y_sol, model_disc, {}), components + + +class TestUnstructuredPlotGrid: + def test_plot_grid(self): + solution, _ = _unstructured_solution(2, 4) + grid = plot_grid(solution["u"]) + assert list(grid) == ["x", "z"] + assert all(len(pts) == 200 for pts in grid.values()) + np.testing.assert_allclose([grid["x"][0], grid["x"][-1]], [0, 1]) + solution_3d, _ = _unstructured_solution(3, 3) + grid = plot_grid(solution_3d["u"], n_points=7) + assert list(grid) == ["x", "y", "z"] + assert all(len(pts) == 7 for pts in grid.values()) + assert len(plot_grid(solution_3d["u"])["z"]) == 80 + + def test_midplane_slices(self): + solution, _ = _unstructured_solution(3, 3) + variable = solution["u"] + grid = plot_grid(variable, n_points=12) + positions = default_slice_positions(variable) + np.testing.assert_allclose([positions["y"], positions["z"]], 0.5) + s1, xx1, yy1, zz1, s2, xx2, yy2, zz2 = midplane_slices( + variable, 1.0, grid, positions + ) + for arr in (s1, xx1, yy1, zz1, s2, xx2, yy2, zz2): + assert arr.shape == (12, 12) + np.testing.assert_allclose(yy1, 0.5) + np.testing.assert_allclose(zz2, 0.5) + # u = 2x at t = 1: linear interpolation between cell centroids is exact + # between the first and last centroid (x in [1/6, 5/6]); outside the + # domain the slices are NaN + assert np.isfinite(s1).mean() > 0.5 + for values, xx in ((s1, xx1), (s2, xx2)): + interior = (xx > 0.2) & (xx < 0.8) + np.testing.assert_allclose(values[interior], 2 * xx[interior], atol=1e-8) + + def test_quiver_data_2d(self): + solution, (u_val, w_val) = _unstructured_solution(2, 4) + flux = solution["flux"] + X, Z, U, W = quiver_data(flux, 0.5, plot_grid(flux)) + for arr in (X, Z, U, W): + assert arr.shape == (20, 20) + np.testing.assert_allclose(U[np.isfinite(U)], u_val, rtol=1e-8) + np.testing.assert_allclose(W[np.isfinite(W)], w_val, rtol=1e-8) + + def test_quiver_data_3d(self): + solution, (u_val, v_val, w_val) = _unstructured_solution(3, 3) + flux = solution["flux"] + positions = {"y": 0.4, "z": 0.6} + data = quiver_data(flux, 0.5, plot_grid(flux), positions, n_points=6) + X1, Z1, u_xz, w_xz, y_mid, X2, Y2, u_xy, v_xy, z_mid = data + assert (y_mid, z_mid) == (0.4, 0.6) + for arr in (X1, Z1, u_xz, w_xz, X2, Y2, u_xy, v_xy): + assert arr.shape == (6, 6) + np.testing.assert_allclose(u_xz[np.isfinite(u_xz)], u_val, rtol=1e-8) + np.testing.assert_allclose(w_xz[np.isfinite(w_xz)], w_val, rtol=1e-8) + np.testing.assert_allclose(v_xy[np.isfinite(v_xy)], v_val, rtol=1e-8) + + +class TestQuickPlotUnstructured: + def test_2d_scalar_and_vector(self): + solution, _ = _unstructured_solution(2, 4) + quick_plot = pybamm.QuickPlot(solution, ["u", "flux"]) + assert list(quick_plot._unstructured_grids[("u",)]) == ["x", "z"] + quick_plot.plot(0.5) + image = quick_plot.plots[("u",)][0][1] + assert image.shape == (200, 200) + assert np.isfinite(image).mean() > 0.5 + quick_plot.slider_update(1.0) + assert quick_plot.plots[("flux",)][0][0] is not None + pybamm.close_plots() + + def test_3d_slices_and_slice_sliders(self): + solution, _ = _unstructured_solution(3, 3) + quick_plot = pybamm.QuickPlot(solution, ["u", "flux"]) + np.testing.assert_allclose(quick_plot._slice_positions[("u",)]["y"], 0.5) + quick_plot.dynamic_plot(show_plot=False) + s1, _ = quick_plot.plots[("u",)][0][0] + assert s1.shape == (80, 80) + quick_plot._slice_sliders["y"].set_val(0.25) + for positions in quick_plot._slice_positions.values(): + np.testing.assert_allclose(positions["y"], 0.25) + assert quick_plot.plots[("flux",)][0][0] == "quiver_3d" + pybamm.close_plots() diff --git a/packages/pybamm/tests/unit/test_solvers/test_processed_variable.py b/packages/pybamm/tests/unit/test_solvers/test_processed_variable.py index 94d17788ba..ea00c75b6f 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_processed_variable.py +++ b/packages/pybamm/tests/unit/test_solvers/test_processed_variable.py @@ -2453,9 +2453,10 @@ def test_vector_field_via_solution_2d(self): np.testing.assert_allclose(comps[0], 2.0, rtol=1e-12) np.testing.assert_allclose(comps[1], -3.0, rtol=1e-12) - # QuickPlot has no unstructured support in this PR and must say so - with pytest.raises(NotImplementedError, match="unstructured meshes"): - pybamm.QuickPlot(solution, ["u"]) + # QuickPlot samples unstructured variables through the plotting helpers + quick_plot = pybamm.QuickPlot(solution, ["u"]) + assert list(quick_plot._unstructured_grids[("u",)]) == ["x", "z"] + pybamm.close_plots() def test_vector_field_3d(self): geometry, submesh, disc, _, _ = self._make_setup(dim=3, n=3) From 8e1bd9d75533580f57c4a211ec1a73cb846a4bf7 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 14:02:46 -0700 Subject: [PATCH 4/9] Use vtkViewport.AddViewProp; VTK 9.7 removed the AddActor2D alias The CI matrix installs extras unlocked (prebuilt-solver-wheel mode uses uv pip install), so it picked up VTK 9.7.0, where the legacy AddActor2D name no longer exists. AddViewProp is the underlying method in every supported VTK. Lock VTK 9.7.0 so local environments test the same version. Co-Authored-By: Claude Opus 5 --- .../pybamm/src/pybamm/plotting/plot_vtk.py | 6 +- uv.lock | 316 +++++++++--------- 2 files changed, 161 insertions(+), 161 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index 17ea2cc191..bdb9601346 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -468,8 +468,8 @@ def dynamic_plot(self, show_plot=True): ren = vtk.vtkRenderer() ren.AddActor(actor) - ren.AddActor2D(sb) - ren.AddActor2D(title_actor) + ren.AddViewProp(sb) + ren.AddViewProp(title_actor) ren.SetBackground(1, 1, 1) row = panel_idx // n_cols @@ -706,7 +706,7 @@ def dynamic_plot(self, show_plot=True): time_text.GetTextProperty().SetBold(True) time_text.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() time_text.SetPosition(0.01, 0.15) - slider_bg.AddActor2D(time_text) + slider_bg.AddViewProp(time_text) # Time slider — scaled in physical time (seconds) t_min = float(self.t_pts[0]) diff --git a/uv.lock b/uv.lock index 9e288ee4ae..6676ce05cc 100644 --- a/uv.lock +++ b/uv.lock @@ -610,7 +610,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -694,7 +694,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1005,7 +1005,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1242,17 +1242,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -1281,17 +1281,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } wheels = [ @@ -1303,7 +1303,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -1344,11 +1344,11 @@ name = "jax" version = "0.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jaxlib" }, - { name = "ml-dtypes" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "opt-einsum" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "ml-dtypes", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "opt-einsum", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/1d/f545a3e8dab0d23da34ac03770897578f639e82252a452d3a8a919eb33c7/jax-0.8.3.tar.gz", hash = "sha256:cf0569abe750f08a7c06421134576ca292851ab763f16edba719b35269c0ff5e", size = 2505784, upload-time = "2026-01-29T22:52:39.992Z" } wheels = [ @@ -1360,9 +1360,9 @@ name = "jaxlib" version = "0.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ml-dtypes" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d9/d3/f56d5058317a0cf2116ba0f33e46333f78bbc072d00f94d434161e6ff92c/jaxlib-0.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71a87c8e49a6c80d42e3912187f8bb3a341f841748f3aab5028ab099478d4887", size = 55930126, upload-time = "2026-01-29T22:51:22.323Z" }, @@ -1819,7 +1819,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -1853,7 +1853,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -2139,7 +2139,7 @@ name = "ml-dtypes" version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ @@ -2205,12 +2205,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -2239,12 +2239,12 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } @@ -2635,10 +2635,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2713,9 +2713,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } wheels = [ @@ -2800,7 +2800,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4065,7 +4065,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4138,7 +4138,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ @@ -4266,23 +4266,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli" }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -4300,23 +4300,23 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -4341,23 +4341,23 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -4372,12 +4372,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, - { name = "starlette" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "colorama", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", marker = "python_full_version < '3.11'" }, + { name = "uvicorn", marker = "python_full_version < '3.11'" }, + { name = "watchfiles", marker = "python_full_version < '3.11'" }, + { name = "websockets", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } wheels = [ @@ -4406,13 +4406,13 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "colorama" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "starlette" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "starlette", marker = "python_full_version >= '3.11'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11'" }, + { name = "watchfiles", marker = "python_full_version >= '3.11'" }, + { name = "websockets", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -4441,7 +4441,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } wheels = [ @@ -4470,7 +4470,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } @@ -4927,39 +4927,39 @@ wheels = [ [[package]] name = "vtk" -version = "9.6.2" +version = "9.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/3f/f4d0cbc05c1a494b2cf590135949f44fde97f0e7470bc5df20c9f8a3da61/vtk-9.6.2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8ed0c1e329fd857c696c44609df7be952fcb08c69e30c94e025fbcef712480ee", size = 114703522, upload-time = "2026-05-19T04:46:19.668Z" }, - { url = "https://files.pythonhosted.org/packages/41/e3/47546c2baf31e31d6039866948f6d4f2aae636e06f16c8cccd2ba0fc11fa/vtk-9.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:809065272b207439f13ef0f62767f9041b4c6d61dd4dc2f60a1a62201843dc4b", size = 106906792, upload-time = "2026-05-19T04:46:24.878Z" }, - { url = "https://files.pythonhosted.org/packages/8a/55/c28d4070cca9923c419be191ca6d4b444bc030a992b38243bf6d114042d0/vtk-9.6.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a0df4ed93b6ae7f05cb6cee9d80566f4c06c6b4ce9116ee406445703c379ef7", size = 145981000, upload-time = "2026-05-19T04:46:30.482Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2f/320ae500942ffbf8c19205e3f98797a575db8e8b0e7815abf17601423a53/vtk-9.6.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3cde8ba867cce14fdce8d8ec7bdef36a328cbd126433490109eccc586e489916", size = 135731415, upload-time = "2026-05-19T04:46:35.907Z" }, - { url = "https://files.pythonhosted.org/packages/c3/58/eb6c9788ec15b30a3e3e0885b92b5fa1f9dc6e619460020c707828a83ade/vtk-9.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:9b55baa61beafc00d68b571ef07b71e2343a02200f089f07b6a40c3ebe01844a", size = 81289717, upload-time = "2026-05-19T04:46:41.149Z" }, - { url = "https://files.pythonhosted.org/packages/06/15/910b90b0b44d474f7cc71ccfe6e63393421fc0d161aabb490afe871830a3/vtk-9.6.2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ab2848c26c70fe57c41656d5ab48f47e8fa4f78ccbb113cf86c8c6de71c3118d", size = 114703453, upload-time = "2026-05-19T04:46:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e5/8b4a37663aacd242c70c7a8feb2d2a4140ef5ded39ec0193af2d5a673098/vtk-9.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:40fb9d9172cbd0b85a7f39df3646029449e563c61f54bedd3244427035f55ba3", size = 106906589, upload-time = "2026-05-19T04:46:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/df/5c/148d54b90a2cd39809512d63d70b9b672f0e58f49d937f964a3167d63cf8/vtk-9.6.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0fd9fa3f851192619ac0cec05591ab88adbed67ba063903297d2bb40b457bd00", size = 145980985, upload-time = "2026-05-19T04:46:56.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/ee/9a4f42a8b98cfb095570ef203685186843389bb66d0da6d36581821888e8/vtk-9.6.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e640839fa24fc7c2153387d535527cfcd7d270e17e0d47f00fefd80b3b043577", size = 135731405, upload-time = "2026-05-19T04:47:01.547Z" }, - { url = "https://files.pythonhosted.org/packages/27/bb/e511d83d6b4d5b0acbce5e6a82110c510e3b416b39c667e6a52b1f78291a/vtk-9.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b935949cfc80f1d300d0b0ed8ccab47fb45c337910966de33a486d342e3c7daa", size = 81290711, upload-time = "2026-05-19T04:47:06.029Z" }, - { url = "https://files.pythonhosted.org/packages/60/d3/344f3664586f1f8bb5f7950db73c257439f5c796e0978e7a53e91d0fe2aa/vtk-9.6.2-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:2adcaed1cc4d3411a6b19834d6ed7d480f9ad9252e56546ea9930e66beae84e8", size = 114881278, upload-time = "2026-05-19T04:47:12.045Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b1/754bd95da3d216e852014758c9ad626601482e2e0b1da98fa83adc5fe1b8/vtk-9.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d1eb5368039dd6a88e8102bee1db50e6f1e5a12945887ea1025069a99fb8088e", size = 106960169, upload-time = "2026-05-19T04:47:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/96/f8/b392298c74aa7b88c731a43253ccd50b388bf42a1a29b50fa735c4f22f41/vtk-9.6.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c9f31430d15afbf46c2076cf3e30b4d6136512a5faabee8318552ccea907323", size = 146027355, upload-time = "2026-05-19T04:47:22.412Z" }, - { url = "https://files.pythonhosted.org/packages/71/35/67a7760852100c98a8fb6f1221e7eb359ad4d0b1b9edd8beea42c79e16c4/vtk-9.6.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:91e1962c93217cf91ca4fe50762dd26dfc22290b74b8b0de77576f93f7c17abb", size = 135789817, upload-time = "2026-05-19T04:47:27.551Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/58e162d9cdbf02a3578ef4e68f4003b8cb9f351b7aa409960da3cc383017/vtk-9.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:83b4af00b31395a13acb20e26a42ee097b85e41a0a087be2340d3749cb58250f", size = 81305902, upload-time = "2026-05-19T04:47:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/b8/2eb42db93ac200180ca1a6ffe6f95d010181528e9490464621c31628e35f/vtk-9.6.2-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:155a09485a9efcbb0afb0214159bb920cf437fe4d897a6f945f6d66a533d863c", size = 114899285, upload-time = "2026-05-19T04:47:37.237Z" }, - { url = "https://files.pythonhosted.org/packages/00/51/5abfa4dc321b864e57bde87be8a328ee97b3aa33cc5e8f736c162fac63cb/vtk-9.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f7055447d36914ed8b39c5738defe467566ef2d7a75ca92a3023aefc269e1f06", size = 106962262, upload-time = "2026-05-19T04:47:41.886Z" }, - { url = "https://files.pythonhosted.org/packages/bd/75/4a1fe360256b99779d534b2387d0efa70952167d53d716f60ff39d62994d/vtk-9.6.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb85c7fbad59209a08e428479defbdf96f974a9f39d4212960fb1a24a919613c", size = 146027814, upload-time = "2026-05-19T04:47:47.238Z" }, - { url = "https://files.pythonhosted.org/packages/e8/30/90ea11e053d7e0571d70a7f301cebe04cc83874b533ddeaedb5258b0f9e6/vtk-9.6.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:35e1e9ffb6457f16c37d0e025f7db8619961dffc3e9a85a8e5d358d5099c277c", size = 135792045, upload-time = "2026-05-19T04:47:53.848Z" }, - { url = "https://files.pythonhosted.org/packages/da/5b/e03640322971339899f982691396038d793ecfde8adbc208a97d501d6b8e/vtk-9.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:4e9ad047a2b658550d5099dc808b26e331a9c94c3226c28453873035b8b48b41", size = 81307827, upload-time = "2026-05-19T04:47:58.76Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a4/615178228ec84e3793332fbdd62f85965e246366727ef31b20670ce4877d/vtk-9.6.2-cp314-cp314-macosx_10_10_x86_64.whl", hash = "sha256:8a3edd56b63d1ab4ff022e70dac50dd54a66019462c37734e288c9e222809624", size = 114534790, upload-time = "2026-05-19T04:48:04.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/63/19f6f3f4520595e28b425fe7cae220fa8f4fb963ba9d0c0c4001f54ba8ae/vtk-9.6.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da01ec70cdfef3fddf659fd35a0258e820065d5331b3c43d5f7e3548351118ea", size = 106971615, upload-time = "2026-05-19T04:48:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/bb/84/80465e452292a219e4b72bded477cee155f9ec44b856e3a877e0f95eba54/vtk-9.6.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f64288a71b313251300bc798458848aabbfd241230546ce75af90b8e099f14b", size = 146043416, upload-time = "2026-05-19T04:48:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/c2/00/16e9faacb40cdcf487f8f5cb39d4304e71ebfafe38eb38a2c8f74527ca83/vtk-9.6.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1c3c050668eacb73db20fd07f844321a05dc3d7c60221db7137bd0b8f93a4d82", size = 135816286, upload-time = "2026-05-19T04:48:21.241Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/11594ed6bb95393f5656c96945ae22e297dfc3ccc5d7cd816973aaf9fc0d/vtk-9.6.2-cp314-cp314-win_amd64.whl", hash = "sha256:7b99aad09f712442345bb1c2a5529ad46da3a26fdc12cce54408ff800ccfbb19", size = 83246191, upload-time = "2026-05-19T04:48:26.1Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/a71b05850acbfb99e0de63c940ae874b665a393adb186a0e9c64cf6c9d64/vtk-9.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691e3a9ae1b62784cf2e9b7d87696bdeac14e1b9905bf020f4c86fab5b756626", size = 145665062, upload-time = "2026-05-19T04:48:31.223Z" }, - { url = "https://files.pythonhosted.org/packages/4a/57/6410098435a3976cc749c151c4e09bff4bcf4af0ebeb13b3e47fbd5871e5/vtk-9.6.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b06725993112097f43daefca0f3637a73a63c67b61a9c124455d881bc05cc9a4", size = 135630292, upload-time = "2026-05-19T04:48:37.053Z" }, + { url = "https://files.pythonhosted.org/packages/09/51/fa0acc077a712e3ce1a683868c78fbba29d00a3a590808575160ac627bcc/vtk-9.7.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:43d327f6691e74a97b2a2e44bfd9fed7ea4e5c04f88f7398810a5c199c111f2a", size = 110868260, upload-time = "2026-08-15T21:36:12.314Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/3d19704c84d41ffe46127b61c314e0b3737a520471ea1e1719f77820a7a6/vtk-9.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca0db727b20111009cf26c15194bcd3cddd90dc47cc0278707a9f57debac03d9", size = 102946141, upload-time = "2026-08-15T21:36:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3b/e1c4a1c92f93c422c580461574b3a703c558de8d697a6884e985541bc3ae/vtk-9.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56c17a68af845e7bfa1d56c3b764f6ee1bd6ff67f65af8bab5ff7541ad749d7", size = 139625215, upload-time = "2026-08-15T21:36:22.432Z" }, + { url = "https://files.pythonhosted.org/packages/e2/58/109f7d1a8f069e101f3db389a6f78bb13edc5d48262c248fa286ce689a5f/vtk-9.7.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:baa2e146611ff93f493104152802773cdfada177856dd29694b34224aad50324", size = 129381180, upload-time = "2026-08-15T21:36:27.31Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/561609e42e479fe215b1e9ffa02ded1a0c31e5a3edbdb2b539429f727e51/vtk-9.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab3bc1f8463b0d37a121630a1a44afc01982e622919e0a6e5df6ed14bb6bea54", size = 80419456, upload-time = "2026-08-15T21:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/42/5521187d480ce634e17aee5e6cac7e052eb4b6d4ce6833d825002d597669/vtk-9.7.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ef393a988d177086a3cdbe86c5529dacea7d3a1620244596bb94435478eb2545", size = 110868355, upload-time = "2026-08-15T21:36:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/a6fba1d43690f2bcd24ad27e8a061e68aaaf12e4867921d2c055333425b5/vtk-9.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dea2ecda02180e446f61341ce6fcf6596691470ab08bc5989443437473b9b1c6", size = 102945919, upload-time = "2026-08-15T21:36:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/af/b5/d2124ab63019e6db4d397b054a8ec540c37f3436fff50bae5e3bd34ed3ff/vtk-9.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64c06b819cb97706ed7be717a9757a97aece3c427c06c9a1d47ad296231ca2b", size = 139624932, upload-time = "2026-08-15T21:36:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/a8ec8b526907a3f83dc9f18be654a1c72a3ef494936da2532d4130712935/vtk-9.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:43344d4129bbd9c713e48bdd18a0f52744d36ee66cbdd6add415a530a96d246e", size = 129381463, upload-time = "2026-08-15T21:36:49.877Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/61876263b16543d84d5df2da7aec12492a83d40f89942824068d41a53297/vtk-9.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:38ac723bcf99aa331bbd4e74daced2440e0afb8ceae73e0b5b1b76d4ad8d594f", size = 80420210, upload-time = "2026-08-15T21:36:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/1302dcc11cd58ec272bb256a5b923ec503aea016d948a1746ec3799bf937/vtk-9.7.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:cb03235710fb9c1c987a9364b10fe9b91e0dd8b2f0692b415a6257cf6fbf6da2", size = 111042472, upload-time = "2026-08-15T21:37:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/a4/24/54f867b55584e209bdfba364fdb8bc56fb88a2574bde32f687e8c3c37ef0/vtk-9.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43730881347129c564f55ed1481187c57c18ec92a618a26aa83a9b62de5e419f", size = 102999804, upload-time = "2026-08-15T21:37:04.88Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/8d4213c57e06bd181e0d540ef75ea5fb50b528c0085a56f905804513ae0d/vtk-9.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ade6991deefb29803837f3a8525e720d3718094cedc7c3c4ed3eeb9e20586d77", size = 139661657, upload-time = "2026-08-15T21:37:09.781Z" }, + { url = "https://files.pythonhosted.org/packages/f9/6d/b87ccf7e1891850e004c2e1231daf8aacde911e3ee83005c13571b5c19bd/vtk-9.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d7bbfbb997b2d22f1569c15e5c5d216fc7e184924652a33204f0e2b277200fa6", size = 129433898, upload-time = "2026-08-15T21:37:15.644Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/e80b5210283f9451805ab3973ab232449a6acefa4e7f4d0c3195d134e426/vtk-9.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:bb2fc0cd53afcf03bf5436f89c4be6bbaadde7f0f7b444a3e3bde8d7cfaaeb95", size = 80435493, upload-time = "2026-08-15T21:37:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/52/cc/2d6750ac498013bc651bb0bbe720f63fabd9504bb22d7a282b9052caeabb/vtk-9.7.0-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:f3a13fc62fef2db91b8ca5f36dc4b4e5cc4ae981584df343f1be05cf4b496a37", size = 111056432, upload-time = "2026-08-15T21:37:28.115Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c9/b2cc1998a09bc80812c55418cf75ec4aeb91f8b81a1368675d45d79c9dd4/vtk-9.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:af67723b39616b3a1f2fc9e685dcdde0b73965db0635fa1405a7b46fef19bb12", size = 103003337, upload-time = "2026-08-15T21:37:33.996Z" }, + { url = "https://files.pythonhosted.org/packages/86/46/06fe7fa56b2fa6e19e9c2838f03dffb85a647b6ccee9685f652fba709756/vtk-9.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b44be314570f74453bfc8398a86279322528b9a1bd8a76c956ddd7beef9beec", size = 139662075, upload-time = "2026-08-15T21:37:40.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/296ebece46f39e684575b8ced65416e7712a9e6fb66929cc34e9ac749b32/vtk-9.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fc6ed2d88571e94e785e6ca0716bf552ba5853d9b6795f48a3a5a0791859446c", size = 129434592, upload-time = "2026-08-15T21:37:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a1/32630dd1879be54182649655bc007b6589717a918338712a579b2ad0d13b/vtk-9.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:cd8496c654d5d852e1d75a3b3ca9d56987e7ff230041f5c6e1bfaca65366c655", size = 80434823, upload-time = "2026-08-15T21:37:56.256Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ee/17e36ad8c03dc71399e77846d14b2eecb2aa989c8e8727d97b1ad3dd32ae/vtk-9.7.0-cp314-cp314-macosx_10_10_x86_64.whl", hash = "sha256:314b9a75587015f4b78c529037c9b07539a7af174740e0eb7da903084d686196", size = 110702476, upload-time = "2026-08-15T21:38:02.91Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b1/50545f998761c24a6814d6f8039ac1d8451a2ec4b2634a239b4c95fbea60/vtk-9.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c00666d7239bb9a46fbcba079c39c3e58eb168b72f2dbcc7ff6d4ebc4ba4baa", size = 103009267, upload-time = "2026-08-15T21:38:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/1a6014bb9bb2603559f1cecb6307b30512a526748056b6b008046c7585f6/vtk-9.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9eb83719486f6d0dadf5f3247b76b1bcf3cb6b474e538557d2f4c7ac2f5dacf4", size = 139670197, upload-time = "2026-08-15T21:38:15.393Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/e8571ec57a030b6beaf46bcaf072ae74e84589b7390e57c2062cae448b9a/vtk-9.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0c37b2924018972ed6cb860b02f18d76876eeb82791188e32cc5da95fdbb408a", size = 129464645, upload-time = "2026-08-15T21:38:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9e/81fbd582a771623faf4fc74b2274ed794a9d63143172b5b93d45f2930a1e/vtk-9.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f76742c0023be548aeb17c95f8cba8015b0d7dbcc33a23bd140c0a0605e23962", size = 82365539, upload-time = "2026-08-15T21:38:26.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/03a78a35bc9e107c58c1b3b7f9f21c310b875b1423e78cc5ae41f602cdd8/vtk-9.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3346d50bbf05d03c98207f985d3cccb6e497802ee90b00cdd362f8f333f627a7", size = 139383763, upload-time = "2026-08-15T21:38:37.364Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ae/083065ff7bd8b10b91e49e69bae145c2c2ab8a229b93a2779a3713438e7b/vtk-9.7.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:79bc62954dcb3fb93209f555eff564cd0ce98a459645998df160e970c5d4d17b", size = 129359528, upload-time = "2026-08-15T21:38:46.482Z" }, ] [[package]] @@ -5198,9 +5198,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -5229,9 +5229,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/85/113ff1e2cde9e8a5b13c2f0ef4e9f5cd6ca3a036b6452f4dd523419289b5/xarray-2026.1.0.tar.gz", hash = "sha256:0c9814761f9d9a9545df37292d3fda89f83201f3e02ae0f09f03313d9cfdd5e2", size = 3107024, upload-time = "2026-01-28T17:49:03.822Z" } wheels = [ From de1242e8e613d5d3e57c64ab733632ff7a9700be Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 14:53:24 -0700 Subject: [PATCH 5/9] Build the NaN-as-white colormap with with_extremes matplotlib 3.11 (which the unlocked CI matrix installs) emits a PendingDeprecationWarning for Colormap.set_bad, and the test suite treats warnings as errors. with_extremes(bad=...) has been available since 3.4. Lock matplotlib 3.11.1 so local runs see what CI sees. Co-Authored-By: Claude Opus 5 --- .../pybamm/src/pybamm/plotting/quick_plot.py | 14 ++- uv.lock | 109 ++++++++++++++++-- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/quick_plot.py b/packages/pybamm/src/pybamm/plotting/quick_plot.py index 2e77e9a37f..3d9008b3f0 100644 --- a/packages/pybamm/src/pybamm/plotting/quick_plot.py +++ b/packages/pybamm/src/pybamm/plotting/quick_plot.py @@ -717,9 +717,10 @@ def plot(self, t, dynamic=False): if is_unstructured: import matplotlib - cmap_copy = matplotlib.colormaps["viridis"].copy() - cmap_copy.set_bad("white") - kw["cmap"] = cmap_copy + # NaN (outside the domain) renders white + kw["cmap"] = matplotlib.colormaps["viridis"].with_extremes( + bad="white" + ) self.plots[key][0][0] = ax.pcolormesh(x, y, var, **kw) else: self.plots[key][0][0] = ax.contourf( @@ -1076,9 +1077,10 @@ def slider_update(self, t): if is_unstructured: import matplotlib - cmap_copy = matplotlib.colormaps["viridis"].copy() - cmap_copy.set_bad("white") - kw["cmap"] = cmap_copy + # NaN (outside the domain) renders white + kw["cmap"] = matplotlib.colormaps["viridis"].with_extremes( + bad="white" + ) self.plots[key][0][0] = ax.pcolormesh(x, y, var, **kw) else: self.plots[key][0][0] = ax.contourf( diff --git a/uv.lock b/uv.lock index 6676ce05cc..b6078e5948 100644 --- a/uv.lock +++ b/uv.lock @@ -1954,18 +1954,19 @@ wheels = [ name = "matplotlib" version = "3.10.9" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2025,6 +2026,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "(python_full_version == '3.11.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/35/04/3079499fa8cb661ea66d13d6439d5a3ae6710a7afd5c7f72e08914f275f8/matplotlib-3.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3", size = 9456022, upload-time = "2026-07-18T03:39:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319", size = 9285475, upload-time = "2026-07-18T03:39:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/31b15a2ca56d4ddd6aaa1c884c2f51cf9a61cfaf5ca6f6fbd6343d38e6df/matplotlib-3.11.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f", size = 10847102, upload-time = "2026-07-18T03:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be", size = 11131087, upload-time = "2026-07-18T03:39:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/97/c5/5e100efdd67abb7de20befaa333612ef9bfc63417fb71398f904f25d083c/matplotlib-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2", size = 10929036, upload-time = "2026-07-18T03:39:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6", size = 9489571, upload-time = "2026-07-18T03:39:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/facabdc2f1f6caba7e856db64dfedddca25f7608df07d96a1c8fd114fd3b/matplotlib-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685", size = 9164486, upload-time = "2026-07-18T03:39:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/18da6cd01cf96354534f98c468a25380c68ce582a2c9dd0cae12b04af4f2/matplotlib-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae", size = 9504876, upload-time = "2026-07-18T03:39:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/f0b63555a18b79d038c81fd6126f35fc4dfce0eaff48d96103348c7cf935/matplotlib-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda", size = 9336120, upload-time = "2026-07-18T03:39:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dd/f210ec7c4a6f198d5567237048a93d0811fb5a1f1691f13320e592f95b41/matplotlib-3.11.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb", size = 10858033, upload-time = "2026-07-18T03:39:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/d6d5324507c5fbb316db48e258c09c2807f3de03d9af47017e120070926f/matplotlib-3.11.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2", size = 11141827, upload-time = "2026-07-18T03:39:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/0f/68/3c22e9320bdce2c4d2f1320643ef706db7a24cb7420eea28b97a2d67f5a8/matplotlib-3.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d", size = 10943061, upload-time = "2026-07-18T03:39:32.356Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/907ed190ee81a9df581e0ed5456134fc0f7cb55ffcfda2f9e54ca900761c/matplotlib-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb", size = 9540074, upload-time = "2026-07-18T03:39:34.789Z" }, + { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -3046,7 +3128,8 @@ dependencies = [ all = [ { name = "bpx" }, { name = "jupyter" }, - { name = "matplotlib" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "meshio" }, { name = "pybtex" }, { name = "scikit-fem" }, @@ -3066,7 +3149,8 @@ jax = [ { name = "jax", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, ] plot = [ - { name = "matplotlib" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] pydiffsol = [ { name = "pydiffsol" }, @@ -4930,7 +5014,8 @@ name = "vtk" version = "9.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/09/51/fa0acc077a712e3ce1a683868c78fbba29d00a3a590808575160ac627bcc/vtk-9.7.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:43d327f6691e74a97b2a2e44bfd9fed7ea4e5c04f88f7398810a5c199c111f2a", size = 110868260, upload-time = "2026-08-15T21:36:12.314Z" }, From e801a7d0d09e498412e92010581d784bdad8a1a8 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 15:46:20 -0700 Subject: [PATCH 6/9] Cover the VTK dynamic_plot dispatch and remaining plotting branches Tests for dynamic_plot(backend="vtk"), the on-screen render window, the matplotlib-less lookup-table fallback, QuickPlot's 3D "tight" limits path and the wireframe helper's 3D guard. The interactive-only lines in VTKQuickPlot.dynamic_plot (interactor.Start and the render inside the slider callback) cannot run headless and are marked no cover, as QuickPlot already does for plt.show. Co-Authored-By: Claude Opus 5 --- .../pybamm/src/pybamm/plotting/plot_vtk.py | 4 +-- .../tests/unit/test_plotting/test_plot_vtk.py | 27 +++++++++++++++++++ .../test_unstructured_plot_grid.py | 11 ++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index bdb9601346..6b903b6895 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -795,7 +795,7 @@ def on_slider(obj, event): mt_arr.Modified() mtable.Modified() time_text.SetInput(f"t = {t_now:.4g} s") - if show_plot: + if show_plot: # pragma: no cover window.Render() slider = vtk.vtkSliderWidget() @@ -805,7 +805,7 @@ def on_slider(obj, event): slider.EnabledOn() slider.AddObserver("InteractionEvent", on_slider) - if show_plot: + if show_plot: # pragma: no cover interactor.Initialize() window.Render() interactor.Start() diff --git a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py index b7bc109c4b..6583842ee7 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -436,3 +436,30 @@ def test_save_gif_builds_plot_and_writes_animation(self, tmp_path): assert image.size == (160, 100) assert image.n_frames == 2 assert image.info["duration"] == 200 + + +class TestPlotVTKEntryPoints: + def test_dynamic_plot_vtk_backend(self): + solution, _ = _cell_solution() + plot = pybamm.dynamic_plot( + solution, output_variables=["field"], backend="vtk", show_plot=False + ) + assert isinstance(plot, pybamm.VTKQuickPlot) + assert hasattr(plot, "_window") + + def test_viridis_lut_falls_back_without_matplotlib(self, monkeypatch): + import sys + + from pybamm.plotting.plot_vtk import _viridis_lut + + monkeypatch.setitem(sys.modules, "matplotlib.cm", None) + lut = _viridis_lut(0.0, 1.0) + assert lut.GetRange() == (0.0, 1.0) + assert lut.GetNumberOfTableValues() > 0 + + def test_make_render_window_on_screen_object(self): + from pybamm.plotting.plot_vtk import _make_render_window + + window = _make_render_window(off_screen=False) + assert isinstance(window, vtk.vtkRenderWindow) + assert window.GetOffScreenRendering() == 0 diff --git a/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py b/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py index 3fdbf3035c..6d6cc4761d 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py +++ b/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py @@ -145,3 +145,14 @@ def test_3d_slices_and_slice_sliders(self): np.testing.assert_allclose(positions["y"], 0.25) assert quick_plot.plots[("flux",)][0][0] == "quiver_3d" pybamm.close_plots() + + def test_3d_tight_limits_and_wireframe_guard(self): + solution, _ = _unstructured_solution(3, 3) + quick_plot = pybamm.QuickPlot(solution, ["u"], variable_limits="tight") + quick_plot.plot(0.5) + quick_plot.slider_update(1.0) + s1, _ = quick_plot.plots[("u",)][0][0] + assert np.isfinite(s1).any() + # the 2D wireframe overlay is a no-op on a 3D mesh (returns before drawing) + assert quick_plot._overlay_mesh_wireframe(None, solution["u"]) is None + pybamm.close_plots() From 7d5a5a700f8d130ef2b51754273d49dc50fd0f4e Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 16:06:04 -0700 Subject: [PATCH 7/9] Don't assert on-screen rendering state; headless CI returns an OSMesa window Co-Authored-By: Claude Opus 5 --- packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py index 6583842ee7..c910d80c50 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -460,6 +460,7 @@ def test_viridis_lut_falls_back_without_matplotlib(self, monkeypatch): def test_make_render_window_on_screen_object(self): from pybamm.plotting.plot_vtk import _make_render_window + # the factory may still return an OSMesa window (VTK_DEFAULT_OPENGL_WINDOW + # on headless CI), so only the type is asserted window = _make_render_window(off_screen=False) assert isinstance(window, vtk.vtkRenderWindow) - assert window.GetOffScreenRendering() == 0 From 60d850dcaa351798718a5af540573f6ab5f84fe1 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 16:53:40 -0700 Subject: [PATCH 8/9] Make VTK panel labels legible: three ticks per axis, plain scalar-bar numbers VTK's automatic major ticks piled a dozen labels onto short or stretched axes ("0.060.080.100.12") and the through-cell axis became a smear under the 100x display stretch. Set three explicit labels per axis, print the range of any axis thinner than 5% of the largest in its title instead of ticks, drop the "(x10^-6)" label scaling, and format scalar-bar values with %.4g so "1.27e+03" is no longer clipped to "1.27e+" and "303." loses its dangling point. Slightly smaller axis and panel-title fonts. Co-Authored-By: Claude Opus 5 --- .../pybamm/src/pybamm/plotting/plot_vtk.py | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index 6b903b6895..32618bde0f 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -449,17 +449,19 @@ def dynamic_plot(self, show_plot=True): sb.SetLookupTable(lut) sb.SetTitle("") sb.SetNumberOfLabels(5) - sb.SetWidth(0.18) + sb.SetWidth(0.2) sb.SetHeight(0.5) - sb.SetPosition(0.80, 0.25) - sb.GetLabelTextProperty().SetFontSize(24) + sb.SetPosition(0.79, 0.25) + sb.GetLabelTextProperty().SetFontSize(22) sb.GetLabelTextProperty().SetColor(0, 0, 0) sb.SetUnconstrainedFontSize(True) - sb.SetLabelFormat("%-#6.3g") + # 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(36) + title_actor.GetTextProperty().SetFontSize(30) title_actor.GetTextProperty().SetColor(0, 0, 0) title_actor.GetTextProperty().SetBold(True) title_actor.GetTextProperty().SetJustificationToCentered() @@ -496,9 +498,11 @@ def dynamic_plot(self, show_plot=True): cube_axes.SetFlyModeToOuterEdges() if plot_type == "slice": cube_axes.SetTickLocationToInside() - cube_axes.SetScreenSize(10.0) - cube_axes.SetLabelOffset(10) - cube_axes.SetTitleOffset([20, 20]) + 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) orig_ranges = [ (float(mesh_nodes[:, d].min()), float(mesh_nodes[:, d].max())) @@ -513,21 +517,38 @@ def dynamic_plot(self, show_plot=True): for ax_id in range(3): tp = cube_axes.GetTitleTextProperty(ax_id) - tp.SetFontSize(28) + tp.SetFontSize(22) tp.SetColor(0.15, 0.15, 0.15) tp.SetBold(True) lp = cube_axes.GetLabelTextProperty(ax_id) - lp.SetFontSize(22) + lp.SetFontSize(17) lp.SetColor(0.25, 0.25, 0.25) - cube_axes.SetXTitle("X") - cube_axes.SetYTitle("Y") - cube_axes.SetZTitle("Z") - cube_axes.SetXLabelFormat("%.2g") - cube_axes.SetYLabelFormat("%.2g") - cube_axes.SetZLabelFormat("%.2g") + cube_axes.SetXTitle("x [m]") + cube_axes.SetYTitle("y [m]") + cube_axes.SetZTitle("z [m]") + cube_axes.SetXLabelFormat("%.3g") + cube_axes.SetYLabelFormat("%.3g") + cube_axes.SetZLabelFormat("%.3g") cube_axes.XAxisMinorTickVisibilityOff() cube_axes.YAxisMinorTickVisibilityOff() cube_axes.ZAxisMinorTickVisibilityOff() + # Three explicit labels per axis: VTK's automatic major ticks + # crowd short or stretched axes into an unreadable pile. An + # axis much thinner than the others (the through-cell + # direction) gets its range in the title instead of labels. + extents = [hi - lo for lo, hi in orig_ranges[:dim]] + for axis, (lo, hi) in enumerate(orig_ranges[:dim]): + letter = "XYZ"[axis] + if extents[axis] < 0.05 * max(extents): + getattr(cube_axes, f"Set{letter}AxisLabelVisibility")(False) + getattr(cube_axes, f"Set{letter}Title")( + f"{letter.lower()} [m]: {lo:.3g} to {hi:.3g}" + ) + continue + labels = vtk.vtkStringArray() + for value in np.linspace(lo, hi, 3): + labels.InsertNextValue(f"{value:.3g}") + cube_axes.SetAxisLabels(axis, labels) if plot_type == "slice": if axis_idx == 0: From 038195db2eb6b8479fc3143b6d17447ce551a580 Mon Sep 17 00:00:00 2001 From: Alexander Bills Date: Mon, 7 Sep 2026 17:02:48 -0700 Subject: [PATCH 9/9] Label a thin axis at its two ends rather than hiding its ticks Co-Authored-By: Claude Opus 5 --- .../pybamm/src/pybamm/plotting/plot_vtk.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/pybamm/src/pybamm/plotting/plot_vtk.py b/packages/pybamm/src/pybamm/plotting/plot_vtk.py index 32618bde0f..99475c1885 100644 --- a/packages/pybamm/src/pybamm/plotting/plot_vtk.py +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -532,21 +532,15 @@ def dynamic_plot(self, show_plot=True): cube_axes.XAxisMinorTickVisibilityOff() cube_axes.YAxisMinorTickVisibilityOff() cube_axes.ZAxisMinorTickVisibilityOff() - # Three explicit labels per axis: VTK's automatic major ticks - # crowd short or stretched axes into an unreadable pile. An - # axis much thinner than the others (the through-cell - # direction) gets its range in the title instead of labels. + # Explicit labels: VTK's automatic major ticks crowd short or + # stretched axes into an unreadable pile. Three per axis, but + # only the two ends on an axis much thinner than the others + # (the through-cell direction under a display stretch). extents = [hi - lo for lo, hi in orig_ranges[:dim]] for axis, (lo, hi) in enumerate(orig_ranges[:dim]): - letter = "XYZ"[axis] - if extents[axis] < 0.05 * max(extents): - getattr(cube_axes, f"Set{letter}AxisLabelVisibility")(False) - getattr(cube_axes, f"Set{letter}Title")( - f"{letter.lower()} [m]: {lo:.3g} to {hi:.3g}" - ) - continue + thin = extents[axis] < 0.05 * max(extents) labels = vtk.vtkStringArray() - for value in np.linspace(lo, hi, 3): + for value in np.linspace(lo, hi, 2 if thin else 3): labels.InsertNextValue(f"{value:.3g}") cube_axes.SetAxisLabels(axis, labels)