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..bc43f4f45d 100644 --- a/docs/source/api/plotting/index.rst +++ b/docs/source/api/plotting/index.rst @@ -10,3 +10,5 @@ Plotting plot_summary_variables plot_3d_cross_section plot_3d_heatmap + plot_vtk + unstructured_plot_grid 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/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/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..99475c1885 --- /dev/null +++ b/packages/pybamm/src/pybamm/plotting/plot_vtk.py @@ -0,0 +1,886 @@ +""" +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.vertices + 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.vertices + 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 + + 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() + 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 + + 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() + 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 + ) + } + + pv_by_name = dict(zip(self.spatial_names, self.spatial_vars, strict=True)) + for name, opts in self.spatial_panels: + plot_type = opts.get("plot_type", "3d") + # Each variable is drawn on its OWN mesh: 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(panel_mesh, scale=var_scale) + if is_cell_data: + _set_cell_scalars(g, name, spatial_data[name][:, 0]) + else: + _set_point_scalars(g, name, spatial_data[name][:, 0]) + spatial_grids.append(g) + + 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.vertices + 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.2) + sb.SetHeight(0.5) + sb.SetPosition(0.79, 0.25) + sb.GetLabelTextProperty().SetFontSize(22) + sb.GetLabelTextProperty().SetColor(0, 0, 0) + sb.SetUnconstrainedFontSize(True) + # 4 significant figures without a width spec: "1265" and "303.2" + # rather than a clipped "1.27e+" or a dangling "303." + sb.SetLabelFormat("%.4g") + + title_actor = vtk.vtkTextActor() + title_actor.SetInput(name) + title_actor.GetTextProperty().SetFontSize(30) + title_actor.GetTextProperty().SetColor(0, 0, 0) + title_actor.GetTextProperty().SetBold(True) + title_actor.GetTextProperty().SetJustificationToCentered() + title_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() + title_actor.SetPosition(0.5, 0.92) + + ren = vtk.vtkRenderer() + ren.AddActor(actor) + ren.AddViewProp(sb) + ren.AddViewProp(title_actor) + ren.SetBackground(1, 1, 1) + + 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.vertices + 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(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())) + for d in range(dim) + ] + if dim >= 1: + cube_axes.SetXAxisRange(*orig_ranges[0]) + if dim >= 2: + cube_axes.SetYAxisRange(*orig_ranges[1]) + if dim >= 3: + cube_axes.SetZAxisRange(*orig_ranges[2]) + + for ax_id in range(3): + tp = cube_axes.GetTitleTextProperty(ax_id) + tp.SetFontSize(22) + tp.SetColor(0.15, 0.15, 0.15) + tp.SetBold(True) + lp = cube_axes.GetLabelTextProperty(ax_id) + lp.SetFontSize(17) + lp.SetColor(0.25, 0.25, 0.25) + cube_axes.SetXTitle("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() + # 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]): + thin = extents[axis] < 0.05 * max(extents) + labels = vtk.vtkStringArray() + for value in np.linspace(lo, hi, 2 if thin else 3): + labels.InsertNextValue(f"{value:.3g}") + cube_axes.SetAxisLabels(axis, labels) + + if plot_type == "slice": + if axis_idx == 0: + cube_axes.XAxisVisibilityOff() + cube_axes.SetXAxisTickVisibility(False) + cube_axes.SetXAxisLabelVisibility(False) + elif axis_idx == 1: + cube_axes.YAxisVisibilityOff() + cube_axes.SetYAxisTickVisibility(False) + cube_axes.SetYAxisLabelVisibility(False) + else: + cube_axes.ZAxisVisibilityOff() + cube_axes.SetZAxisTickVisibility(False) + cube_axes.SetZAxisLabelVisibility(False) + + ren.AddActor(cube_axes) + + window.AddRenderer(ren) + all_renderers.append(ren) + spatial_renderers.append(ren) + + # Camera setup: slice panels get independent orthographic cameras; + # 3d panels share a 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.AddViewProp(time_text) + + # Time slider — scaled in physical time (seconds) + t_min = float(self.t_pts[0]) + t_max = float(self.t_pts[-1]) + slider_rep = vtk.vtkSliderRepresentation2D() + slider_rep.SetMinimumValue(t_min) + slider_rep.SetMaximumValue(t_max) + slider_rep.SetValue(t_min) + slider_rep.SetTitleText("") + slider_rep.GetPoint1Coordinate().SetCoordinateSystemToNormalizedDisplay() + slider_rep.GetPoint1Coordinate().SetValue(0.15, slider_h * 0.5) + slider_rep.GetPoint2Coordinate().SetCoordinateSystemToNormalizedDisplay() + slider_rep.GetPoint2Coordinate().SetValue(0.95, slider_h * 0.5) + slider_rep.SetSliderLength(0.04) + slider_rep.SetSliderWidth(0.06) + slider_rep.SetTubeWidth(0.015) + slider_rep.SetEndCapLength(0.02) + slider_rep.SetEndCapWidth(0.06) + slider_rep.GetTitleProperty().SetColor(0, 0, 0) + slider_rep.GetLabelProperty().SetColor(0, 0, 0) + slider_rep.GetLabelProperty().SetFontSize(16) + slider_rep.GetSliderProperty().SetColor(0.2, 0.4, 0.8) + slider_rep.GetTubeProperty().SetColor(0.7, 0.7, 0.7) + slider_rep.GetCapProperty().SetColor(0.5, 0.5, 0.5) + slider_rep.GetSelectedProperty().SetColor(0.3, 0.5, 0.9) + + # 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: # pragma: no cover + window.Render() + + slider = vtk.vtkSliderWidget() + slider.SetInteractor(interactor) + slider.SetRepresentation(slider_rep) + slider.SetAnimationModeToAnimate() + slider.EnabledOn() + slider.AddObserver("InteractionEvent", on_slider) + + if show_plot: # pragma: no cover + interactor.Initialize() + window.Render() + interactor.Start() + + self._window = window + self._interactor = interactor + self._slider = slider + + 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..3d9008b3f0 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 @@ -292,6 +298,9 @@ 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 = {} + self._unstructured_grids = {} + self._slice_positions = {} # Calculate subplot positions based on number of variables supplied self.subplot_positions = {} @@ -317,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): @@ -337,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( @@ -349,12 +360,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,11 +407,20 @@ 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): """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) @@ -413,7 +433,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 +464,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 +485,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 +577,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 +651,41 @@ 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 = 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) + 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 +709,27 @@ 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 + + # 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( 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 +737,53 @@ 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 = 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) + 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 +825,75 @@ 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.vertices[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 = 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] + + 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 +926,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 +944,46 @@ def dynamic_plot(self, show_plot=True, step=None): ) self.slider.on_changed(self.slider_update) + if has_3d: + self._slice_sliders = {} + key_3d = next( + key + for key, vl in self.variables.items() + if vl[0][0].dimensions == 3 + ) + 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( + ax_y, + "$y$ slice", + y_pts[0], + y_pts[-1], + valinit=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=positions["z"], + color="#2ca02c", + ) + + def _on_slice_change(_): + 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) + self._slice_sliders["z"].on_changed(_on_slice_change) + if show_plot: # pragma: no cover plt.show() @@ -759,6 +1017,41 @@ 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 = 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) + 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 +1069,27 @@ 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 + + # 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( 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 +1097,49 @@ 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 = 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) + 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/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 new file mode 100644 index 0000000000..c910d80c50 --- /dev/null +++ b/packages/pybamm/tests/unit/test_plotting/test_plot_vtk.py @@ -0,0 +1,466 @@ +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( + vertices=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( + vertices=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( + 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", + ) + + 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( + 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(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(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): + 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_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"] + 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 + + +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 + + # 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) 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/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..6d6cc4761d --- /dev/null +++ b/packages/pybamm/tests/unit/test_plotting/test_unstructured_plot_grid.py @@ -0,0 +1,158 @@ +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() + + 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() 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) diff --git a/uv.lock b/uv.lock index f5ee3eb0d4..b6078e5948 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 = [ @@ -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" @@ -2139,7 +2221,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 +2287,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 +2321,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 +2717,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 +2795,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 +2882,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 = [ @@ -3046,11 +3128,13 @@ 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" }, { name = "tqdm" }, + { name = "vtk" }, ] bpx = [ { name = "bpx" }, @@ -3065,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" }, @@ -3073,6 +3158,9 @@ pydiffsol = [ tqdm = [ { name = "tqdm" }, ] +vtk = [ + { name = "vtk" }, +] [package.dev-dependencies] dev = [ @@ -3141,9 +3229,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 = [ @@ -4059,7 +4149,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 = [ @@ -4132,7 +4222,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 = [ @@ -4260,23 +4350,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 = [ @@ -4294,23 +4384,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 = [ @@ -4335,23 +4425,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 = [ @@ -4366,12 +4456,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 = [ @@ -4400,13 +4490,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 = [ @@ -4435,7 +4525,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 = [ @@ -4464,7 +4554,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" } @@ -4919,6 +5009,44 @@ 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.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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" }, + { 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]] name = "watchfiles" version = "1.1.1" @@ -5155,9 +5283,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 = [ @@ -5186,9 +5314,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 = [