diff --git a/docs/examples/howto_meshlib/plot_meshlib_pyvista_input.py b/docs/examples/howto_meshlib/plot_meshlib_pyvista_input.py index a457de915b1bf4244999a6482a9b4c5f8e711981..6ad01daf3e7148639346ad69b446c91cd8f29382 100644 --- a/docs/examples/howto_meshlib/plot_meshlib_pyvista_input.py +++ b/docs/examples/howto_meshlib/plot_meshlib_pyvista_input.py @@ -8,15 +8,10 @@ For this example we create meshes from pyvista surfaces. """ # %% -import numpy as np # For visualization only - -import ogstools.meshplotlib as mpl # For visualization only from ogstools.meshlib.boundary import Layer from ogstools.meshlib.boundary_set import LayerSet from ogstools.meshlib.boundary_subset import Gaussian2D, Surface -from ogstools.meshlib.region import ( - to_region_tetraeder, -) +from ogstools.meshlib.region import to_region_tetraeder # See other examples for different meshing algorithms @@ -30,13 +25,9 @@ surface3 = Surface(Gaussian2D(**args, height_offset=-200), material_id=2) ls = LayerSet([Layer(surface1, surface2), Layer(surface2, surface3)]) mesh = to_region_tetraeder(ls, 40).mesh +# interprets values as categories +mesh["regions"] = [str(m) for m in mesh["MaterialIDs"]] # %% # Visualize the prism mesh - -slices = np.reshape(mesh.slice_along_axis(n=4, axis="y"), (1, -1)) -mpl.setup.aspect_limits = [0.2, 5.0] -fig = mpl.plot(slices, "MaterialIDs") -for ax, slice in zip(fig.axes, np.ravel(slices)): - ax.set_title(f"z = {slice.center[2]:.1f} {mpl.setup.length.data_unit}") -_ = fig +mesh.plot(scalars="regions", show_edges=True) diff --git a/docs/examples/howto_meshlib/plot_meshlib_vtu_input.py b/docs/examples/howto_meshlib/plot_meshlib_vtu_input.py index a2956693ca917b3d2cbeee87f221bcf561c9bb91..f8b60287e28c1fd6d04f83e8e3c89b5aafcab318 100644 --- a/docs/examples/howto_meshlib/plot_meshlib_vtu_input.py +++ b/docs/examples/howto_meshlib/plot_meshlib_vtu_input.py @@ -10,9 +10,6 @@ For this example we create meshes from surface layers. # %% from pathlib import Path # To get example vtu files -import numpy as np # For visualization only - -import ogstools.meshplotlib as mpl # For visualization only from ogstools.definitions import TESTS_DIR # To get example vtu files from ogstools.meshlib.boundary import Layer from ogstools.meshlib.boundary_set import LayerSet @@ -24,10 +21,6 @@ from ogstools.meshlib.region import ( to_region_voxel, ) -mpl.setup.reset() -mpl.setup.length.output_unit = "km" -mpl.setup.aspect_limits = [0.2, 5.0] - # %% # The loaded surfaces are defined within VTU files and adhere to properties such as non-intersecting boundaries with consistent x and y bounds. Alternatively, surfaces can also be created using PyVista with the same properties. @@ -54,28 +47,30 @@ pm = to_region_prism(layer_set1, resolution=200).mesh vm = to_region_voxel(layer_set1, resolution=[200, 200, 50]).mesh tm = to_region_tetraeder(layer_set1, resolution=200).mesh - +# %% [markdown] +# Simplified mesh +# --------------- # %% -# Visualize the prism mesh - -mesh = pm -slices = np.reshape(mesh.slice_along_axis(n=4, axis="y"), (-1, 1)) -fig = mpl.plot(slices, "MaterialIDs") -for ax, slice in zip(fig.axes, np.ravel(slices)): - ax.set_title(f"z = {slice.center[2]:.1f} {mpl.setup.length.data_unit}") +sm["regions"] = [str(m) for m in sm["MaterialIDs"]] +sm.scale([1, 1, 5]).plot(scalars="regions", show_edges=True) +# %% [markdown] +# Voxel mesh +# --------------- # %% -# Visualize meshes with different meshing algorithm +vm["regions"] = [str(m) for m in vm["MaterialIDs"]] +vm.scale([1, 1, 5]).plot(scalars="regions", show_edges=True) -meshes = [sm, vm, pm, tm] -names = [ - "to_region_simplified", - "to_region_voxel", - "to_region_prism", - "to_region_tetraeder", -] +# %% [markdown] +# Prism mesh +# --------------- +# %% +pm["regions"] = [str(m) for m in pm["MaterialIDs"]] +pm.scale([1, 1, 5]).plot(scalars="regions", show_edges=True) -x_slices = np.reshape([mesh.slice("x") for mesh in meshes], (-1, 1)) -fig = mpl.plot(x_slices, "MaterialIDs") -for ax, name in zip(fig.axes, names): - ax.set_title(name) +# %% [markdown] +# Tetraeder mesh +# --------------- +# %% +tm["regions"] = [str(m) for m in tm["MaterialIDs"]] +tm.scale([1, 1, 5]).plot(scalars="regions", show_edges=True) diff --git a/docs/examples/howto_meshplotlib/plot_animation.py b/docs/examples/howto_meshplotlib/plot_animation.py index 8e5ab568dabd5612c9de9da98bc6e5580a8542d0..47ff7a8612abdb2ab61966b8e69fb3079d248649 100644 --- a/docs/examples/howto_meshplotlib/plot_animation.py +++ b/docs/examples/howto_meshplotlib/plot_animation.py @@ -41,7 +41,9 @@ timevalues = np.linspace( # Note that rendering many frames in conjunction with large meshes might take # a really long time. titles = [f"{tv/(365.25*86400):.1f} yrs" for tv in timevalues] -si = Scalar("Si", "", "%", "Saturation") +si = Scalar( + data_name="Si", data_unit="", output_unit="%", output_name="Saturation" +) anim = animate(mesh_series, si, timevalues, titles) # the animation can be saved (as mp4) like so: # from ogstools.meshplotlib.animation import save_animation diff --git a/docs/examples/howto_meshplotlib/plot_aspect_ratios.py b/docs/examples/howto_meshplotlib/plot_aspect_ratios.py new file mode 100644 index 0000000000000000000000000000000000000000..015c7cfe4ba08d0f67b257407db23d4643c75547 --- /dev/null +++ b/docs/examples/howto_meshplotlib/plot_aspect_ratios.py @@ -0,0 +1,90 @@ +""" +Aspect ratios +============= + +.. sectionauthor:: Florian Zill (Helmholtz Centre for Environmental Research GmbH - UFZ) + +By default plots with meshplotlib try to retain the true mesh proportions. +If the meshes aspect ratio lies outside of predefined limits +(setup.min_ax_aspect, setup.max_ax_aspect) the axes get compressed to stay +inside the given limits. The following examples shall illustrate this +behaviour. +""" + +# %% +import numpy as np +import pyvista as pv + +from ogstools.meshplotlib import plot, setup + +setup.reset() +print(f"{setup.min_ax_aspect=}") +print(f"{setup.max_ax_aspect=}") + + +# sphinx_gallery_start_ignore +def custom_mesh(dx: float, dy: float): + number_of_points = 50 + x = np.linspace(0, dx, num=number_of_points) + y = np.linspace(0, dy, num=number_of_points) + xx, yy = np.meshgrid(x, y) + zz = np.zeros(xx.shape) + points = np.c_[xx.reshape(-1), yy.reshape(-1), zz.reshape(-1)] + mesh = pv.PolyData(points).delaunay_2d() + mesh.point_data["example"] = np.sin(mesh.points[:, 0]) * np.cos( + mesh.points[:, 1] + ) + return mesh + + +# sphinx_gallery_end_ignore + +# %% [markdown] +# The following fits inside the defined limits and gets displayed with true +# proportions. + +# %% +fig = plot(custom_mesh(np.pi * 2, np.pi), "example") +# %% [markdown] +# This one would be too wide and thus and gets compressed to fit the maximum +# aspect ratio. + +# %% +fig = plot(custom_mesh(np.pi * 4, np.pi), "example") +# %% [markdown] +# When plotting multiple meshes together, this applies to each subplot. +# So here each subplot has true proportions again since each one fits the limits. + +# %% +fig = plot( + [custom_mesh(np.pi * 2, np.pi), custom_mesh(np.pi * 2, np.pi)], "example" +) +# %% [markdown] +# The following figure would be to tall and is clipped to the minimum aspect +# ratio. + +# %% +fig = plot(custom_mesh(np.pi, np.pi * 2), "example") +# %% [markdown] +# The same is true here: + +# %% +fig = plot( + [custom_mesh(np.pi, np.pi * 2), custom_mesh(np.pi, np.pi * 2)], "example" +) + +# %% [markdown] +# You can enforce true proportions regardless of the resulting figures +# dimensions, by setting the limiting values to None. In this case we get a +# very wide figure. + +# %% +setup.min_ax_aspect = None +setup.max_ax_aspect = None +fig = plot(custom_mesh(np.pi * 3, np.pi), "example") + +# %% [markdown] +# And in this case we get a very tall figure. + +# %% +fig = plot(custom_mesh(np.pi, np.pi * 3), "example") diff --git a/docs/examples/howto_meshplotlib/plot_meshplotlib_2d.py b/docs/examples/howto_meshplotlib/plot_meshplotlib_2d.py index d1722ffda1fa9662016533c686fb1354d4a3281b..9be7ab03bbee74806a3a276ccb9f91e8a9ad28f3 100644 --- a/docs/examples/howto_meshplotlib/plot_meshplotlib_2d.py +++ b/docs/examples/howto_meshplotlib/plot_meshplotlib_2d.py @@ -37,11 +37,6 @@ fig = mpl.plot(mesh, presets.pressure.get_mask()) # Let's plot the fluid velocity field. fig = mpl.plot(mesh, presets.velocity) -# %% -# It is also possible to plot a shape on top, e.g. to display an overburden. -mpl.plot_on_top(fig.axes[0], mesh, lambda x: min(max(0, 0.1 * (x - 3)), 100)) -fig # noqa: B018 - # %% # We can also plot components of vector property: diff --git a/docs/examples/howto_meshplotlib/plot_special.py b/docs/examples/howto_meshplotlib/plot_special.py new file mode 100644 index 0000000000000000000000000000000000000000..77b0283435dbdc4785cb8a2364165326c50ce604 --- /dev/null +++ b/docs/examples/howto_meshplotlib/plot_special.py @@ -0,0 +1,46 @@ +""" +How to plot limits and differences +================================== + +.. sectionauthor:: Florian Zill (Helmholtz Centre for Environmental Research GmbH - UFZ) + +In this example we plot the limits of all nodes in a model through all +timesteps, as well as differences between to timesteps. For this purpose we use +a component transport example from the ogs benchmark gallery +(https://www.opengeosys.org/docs/benchmarks/hydro-component/elder/). + +To see this benchmark results over all timesteps have a look at +:ref:`sphx_glr_auto_examples_howto_meshplotlib_plot_animation.py`. +""" + +# %% +from ogstools.meshplotlib import examples, plot_diff, plot_limit, setup +from ogstools.propertylib import Scalar + +setup.reset() +mesh_series = examples.meshseries_CT_2D +si = Scalar( + data_name="Si", data_unit="", output_unit="%", output_name="Saturation" +) +# alternatively: +# from ogstools.meshlib import MeshSeries +# mesh_series = MeshSeries("filepath/filename_pvd_or_xdmf") + +# %% [markdown] +# Maximum though all timesteps + +# %% +fig = plot_limit(mesh_series, si, "max") + +# %% [markdown] +# Minimum though all timesteps + +# %% +fig = plot_limit(mesh_series, si, "min") + + +# %% [markdown] +# Difference between the last and he first timestep: + +# %% +fig = plot_diff(mesh_series.read(-1), mesh_series.read(0), si) diff --git a/ogstools/meshlib/mesh_series.py b/ogstools/meshlib/mesh_series.py index 71ebe5e3f04d320603f84556ae969159b4f987cb..552e13f34fc3c8604bde450bf700b2e9d7f4b37e 100644 --- a/ogstools/meshlib/mesh_series.py +++ b/ogstools/meshlib/mesh_series.py @@ -11,6 +11,7 @@ from meshio.xdmf.time_series import ( cell_data_from_raw, xdmf_to_numpy_type, ) +from tqdm.auto import tqdm class TimeSeriesReader(meshio.xdmf.TimeSeriesReader): @@ -156,12 +157,22 @@ class MeshSeries: self._pvd_reader = pv.PVDReader(filepath) elif self._data_type == "xdmf": self._xdmf_reader = TimeSeriesReader(filepath) + self._read_xdmf(0) # necessary to initialize hdf5_files + meshes = self.hdf5["meshes"] + self.hdf5_bulk_name = list(meshes.keys())[ + np.argmax([meshes[m]["geometry"].shape[1] for m in meshes]) + ] elif self._data_type == "vtu": self._vtu_reader = pv.XMLUnstructuredGridReader(filepath) else: msg = "Can only read 'pvd', 'xdmf' or 'vtu' files." raise TypeError(msg) + @property + def hdf5(self): + # We assume there is only one h5 file + return next(iter(self._xdmf_reader.hdf5_files.values())) + def _read_pvd(self, timestep: int) -> pv.UnstructuredGrid: self._pvd_reader.set_active_time_point(timestep) return self._pvd_reader.read()[0] @@ -251,3 +262,18 @@ class MeshSeries: timevalue - t_vals[ts1] ) return mesh + + def values(self, data_name: str) -> np.ndarray: + """ + Get the data in the MeshSeries for all timesteps. + + :param data_name: Name of the data in the MeshSeries. + + :returns: A numpy array of the requested data for all timesteps + """ + mesh = self.read(0).copy() + if self._data_type == "xdmf": + return self.hdf5["meshes"][self.hdf5_bulk_name][data_name] + if self._data_type == "pvd": + return [self.read(t)[data_name] for t in tqdm(self.timesteps)] + return mesh[data_name] diff --git a/ogstools/meshplotlib/__init__.py b/ogstools/meshplotlib/__init__.py index acd2a332c58e484e05720eeb9f4bd0a6cacf6570..3702477db2f628fab93fe6f853e1748e22bb6355 100644 --- a/ogstools/meshplotlib/__init__.py +++ b/ogstools/meshplotlib/__init__.py @@ -3,13 +3,15 @@ from .plot_setup import _setup as setup # noqa: I001: noqa -from .core import plot, subplot +from .core import plot_diff, plot_limit, plot, subplot from .plot_features import plot_contour, plot_on_top __all__ = [ "setup", "plot", - "plot_on_top", "plot_contour", + "plot_diff", + "plot_limit", + "plot_on_top", "subplot", ] diff --git a/ogstools/meshplotlib/animation.py b/ogstools/meshplotlib/animation.py index 0df18f1cc812e25a312bc0e0e9fcbda54af6d99a..295d97e42b756873e41842e65a034a6c94406e30 100644 --- a/ogstools/meshplotlib/animation.py +++ b/ogstools/meshplotlib/animation.py @@ -1,8 +1,7 @@ import warnings from collections.abc import Sequence from functools import partial -from typing import Optional as Opt -from typing import Union +from typing import Optional, Union import matplotlib as mpl import numpy as np @@ -20,8 +19,8 @@ from .core import _plot_on_figure, plot def animate( mesh_series: MeshSeries, property: Property, - timesteps: Opt[Sequence] = None, - titles: Opt[list[str]] = None, + timesteps: Optional[Sequence] = None, + titles: Optional[list[str]] = None, ) -> FuncAnimation: """ Create an animation for a property of a mesh series with given timesteps. diff --git a/ogstools/meshplotlib/core.py b/ogstools/meshplotlib/core.py index 0968c7eba11117d70192e3150d2a78de6ffad3a3..54f7755a1c9567b70dba0f98ca3c9382111db765 100644 --- a/ogstools/meshplotlib/core.py +++ b/ogstools/meshplotlib/core.py @@ -1,7 +1,8 @@ """Meshplotlib core utilitites.""" + import types -from typing import Optional as Opt -from typing import Union +from copy import deepcopy +from typing import Literal, Optional, Union import numpy as np import pyvista as pv @@ -14,6 +15,7 @@ from matplotlib import ticker as mticker from matplotlib import transforms as mtransforms from matplotlib.patches import Rectangle as Rect +from ogstools.meshlib import MeshSeries from ogstools.propertylib import Property, Vector from ogstools.propertylib.presets import _resolve_property @@ -30,14 +32,29 @@ def _q_zero_line(property: Property, levels: np.ndarray): ) -def get_data(mesh: pv.UnstructuredGrid, property: Property) -> np.ndarray: +def has_masked_values(mesh: pv.UnstructuredGrid, property: Property) -> bool: + return ( + not property.is_mask() + and property.mask in mesh.cell_data + and (len(mesh.cell_data[property.mask]) != 0) + ) + + +def get_data( + mesh: pv.UnstructuredGrid, property: Property, masked: bool = False +) -> np.ndarray: """Get the data associated with a scalar or vector property from a mesh.""" - if property.data_name in mesh.point_data: - return mesh.point_data[property.data_name] - if property.data_name in mesh.cell_data: - return mesh.cell_data[property.data_name] - msg = f"Property not found in mesh {mesh}." - raise IndexError(msg) + if ( + property.data_name not in mesh.point_data + and property.data_name not in mesh.cell_data + ): + msg = f"Property not found in mesh {mesh}." + raise IndexError(msg) + if masked: + return mesh.ctp(True).threshold(value=[1, 1], scalars=property.mask)[ + property.data_name + ] + return mesh[property.data_name] def get_level_boundaries(levels: np.ndarray): @@ -69,7 +86,6 @@ def get_cmap_norm( conti_cmap = mcolors.LinearSegmentedColormap.from_list( "temperature_cmap", np.vstack((cool_colors, warm_colors)) ) - bilinear = vmin < 0 < vmax if bilinear: vmin, vmax = np.max(np.abs([vmin, vmax])) * np.array([-1.0, 1.0]) if property.categoric: @@ -113,6 +129,7 @@ def add_colorbars( property: Property, levels: np.ndarray, pad: float = 0.05, + labelsize: Optional[float] = None, ) -> None: """Add a colorbar to the matplotlib figure.""" cmap, norm = get_cmap_norm(levels, property) @@ -137,13 +154,13 @@ def add_colorbars( unit_str = ( f" / {property.get_output_unit()}" if property.get_output_unit() else "" ) - cb.set_label( - property.output_name.replace("_", " ") + unit_str, - size=setup.rcParams_scaled["font.size"], + labelsize = ( + setup.rcParams_scaled["font.size"] if labelsize is None else labelsize ) - cb.ax.tick_params( - labelsize=setup.rcParams_scaled["font.size"], direction="out" + cb.set_label( + property.output_name.replace("_", " ") + unit_str, size=labelsize ) + cb.ax.tick_params(labelsize=labelsize, direction="out") mf = mticker.ScalarFormatter(useMathText=True, useOffset=True) mf.set_scientific(True) mf.set_powerlimits([-3, 3]) @@ -175,7 +192,7 @@ def subplot( mesh: pv.UnstructuredGrid, property: Union[Property, str], ax: plt.Axes, - levels: Opt[np.ndarray] = None, + levels: Optional[np.ndarray] = None, ) -> None: """ Plot the property field of a mesh on a matplotlib.axis. @@ -194,11 +211,7 @@ def subplot( ax.axis("auto") - if ( - not property.is_mask() - and property.mask in mesh.cell_data - and len(mesh.cell_data[property.mask]) - ): + if has_masked_values(mesh, property): subplot(mesh, property.get_mask(), ax) mesh = mesh.ctp(True).threshold(value=[1, 1], scalars=property.mask) @@ -239,15 +252,15 @@ def subplot( surf = mesh.extract_surface() - if setup.show_region_bounds and "MaterialIDs" in mesh.cell_data: - pf.plot_layer_boundaries(ax, surf, projection) - show_edges = setup.show_element_edges if isinstance(setup.show_element_edges, str): show_edges = setup.show_element_edges == property.data_name if show_edges: pf.plot_element_edges(ax, surf, projection) + if setup.show_region_bounds and "MaterialIDs" in mesh.cell_data: + pf.plot_layer_boundaries(ax, surf, projection) + if isinstance(property, Vector): pf.plot_streamlines(ax, surf_tri, property, projection) @@ -302,7 +315,7 @@ def _get_rows_cols( # TODO: fixed_figure_size -> ax aspect automatic -def _fig_init(rows: int, cols: int, ax_aspect: float = 1.0) -> mfigure.Figure: +def _fig_init(rows: int, cols: int, aspect: float = 1.0) -> mfigure.Figure: nx_cb = 1 if setup.combined_colorbar else cols default_size = 8 cb_width = 4 @@ -310,14 +323,10 @@ def _fig_init(rows: int, cols: int, ax_aspect: float = 1.0) -> mfigure.Figure: x_label_height = 1 figsize = setup.fig_scale * np.asarray( [ - default_size * cols * ax_aspect + cb_width * nx_cb + y_label_width, + default_size * cols * aspect + cb_width * nx_cb + y_label_width, default_size * rows + x_label_height, ] ) - if figsize[0] / figsize[1] > setup.fig_aspect_limits[1]: - figsize[0] = figsize[1] * setup.fig_aspect_limits[1] - elif figsize[0] / figsize[1] < setup.fig_aspect_limits[0]: - figsize[0] = figsize[1] * setup.fig_aspect_limits[0] fig, _ = plt.subplots( rows, cols, @@ -343,7 +352,9 @@ def get_combined_levels( p_min, p_max = np.inf, -np.inf unique_vals = np.array([]) for mesh in np.ravel(meshes): - values = property.magnitude.strip_units(get_data(mesh, property)) + values = property.magnitude.strip_units( + get_data(mesh, property, masked=has_masked_values(mesh, property)) + ) if setup.log_scaled: # TODO: can be improved values = np.log10(np.where(values > 1e-14, values, 1e-14)) p_min = min(p_min, np.nanmin(values)) if setup.p_min is None else p_min @@ -445,23 +456,64 @@ def plot( data_shape = _meshes[0][property].shape property = _resolve_property(property, data_shape) data_aspects = np.asarray([get_data_aspect(mesh) for mesh in _meshes]) - data_aspects[ - (data_aspects > setup.ax_aspect_limits[0]) - & (data_aspects < setup.ax_aspect_limits[1]) - ] = 1.0 - clamped_aspects = np.clip(data_aspects, *setup.ax_aspect_limits) - ax_aspects = data_aspects / clamped_aspects - _fig = _fig_init( - rows=shape[0], cols=shape[1], ax_aspect=np.mean(ax_aspects) - ) + if setup.min_ax_aspect is None and setup.max_ax_aspect is None: + fig_aspect = np.mean(data_aspects) + else: + fig_aspect = np.mean( + np.clip(data_aspects, setup.min_ax_aspect, setup.max_ax_aspect) + ) + ax_aspects = fig_aspect / data_aspects + _fig = _fig_init(rows=shape[0], cols=shape[1], aspect=fig_aspect) n_axs = shape[0] * shape[1] - # setting the aspect twice is intended - # the first time results in properly spaced ticks - # the second time fixes any deviations from the set aspect due to - # additional colorbar(s) or secondary axes. - for ax, aspect in zip(_fig.axes[: n_axs + 1], clamped_aspects): - ax.set_aspect(aspect) fig = _plot_on_figure(_fig, meshes, property) - for ax, aspect in zip(fig.axes[: n_axs + 1], clamped_aspects): - ax.set_aspect(aspect) + for ax, aspect in zip(fig.axes[: n_axs + 1], ax_aspects): + ax.set_aspect(1.0 / aspect) return fig + + +def plot_diff( + mesh1: pv.UnstructuredGrid, + mesh2: pv.UnstructuredGrid, + property: Union[Property, str], +) -> mfigure.Figure: + if isinstance(property, str): + data_shape = mesh1[property].shape + property = _resolve_property(property, data_shape) + diff_mesh = deepcopy(mesh1) + diff_mesh[property.data_name] -= mesh2[property.data_name] + data_property = property.replace(output_unit=property.data_unit) + diff_unit = (data_property(1) - data_property(1)).units + diff_property = property.replace( + data_unit=diff_unit, + output_unit=diff_unit, + output_name=property.output_name + " difference", + bilinear_cmap=True, + ) + return plot(diff_mesh, diff_property) + + +def plot_limit( + mesh_series: MeshSeries, + property: Union[Property, str], + limit: Literal["min", "max"], +) -> mfigure.Figure: + """ + Plot the property limits through all timesteps of a MeshSeries. + + :param mesh_series: MeshSeries object containing the data to be plotted + :param property: The property field to be evaluated + :param limit: Type of limit to be computed + + :returns: A matplotlib Figure + """ + mesh = mesh_series.read(0) + if isinstance(property, str): + data_shape = mesh[property].shape + property = _resolve_property(property, data_shape) + func = {"min": np.min, "max": np.max}[limit] + vals = mesh_series.values(property.data_name) + func(vals, out=mesh[property.data_name], axis=0) + limit_property = property.replace( + output_name=limit + " " + property.output_name + ) + return plot(mesh, limit_property) diff --git a/ogstools/meshplotlib/plot_setup.py b/ogstools/meshplotlib/plot_setup.py index 9ca591098bf6014a5d6fb7c2cc3ec3346f97c9ab..c50115be9626a5c769a691fba4c80d1189432015 100644 --- a/ogstools/meshplotlib/plot_setup.py +++ b/ogstools/meshplotlib/plot_setup.py @@ -1,6 +1,5 @@ """Plot configuration setup.""" - from dataclasses import dataclass from typing import Union @@ -36,13 +35,10 @@ class PlotSetup: "The resolution (dots per inch) for the figure." fig_scale: float "A scaling factor for the figure." - ax_aspect_limits: tuple[float, float] - "Lower and upper limit of the ax aspect ratio. For meshes with data ratios" - "outside these bounds the aspect ratio gets clamped. Inside these bounds," - "the axes keep an aspect ratio of 1 (units are equally long on both axes)." - fig_aspect_limits: tuple[float, float] - "Lower and upper limit of the figure aspect ratio. If a figure would exceed" - "them, they get clipped to prevent overly long or wide figures." + min_ax_aspect: float + "Minimum aspect ratio of subplots." + max_ax_aspect: float + "Maximum aspect ratio of subplots." invert_colorbar: bool "A boolean indicating whether to invert the colorbar." layout: str @@ -109,8 +105,8 @@ class PlotSetup: """Create a PlotSetup instance from a dictionary.""" return cls( fig_scale=obj["fig_scale"], - ax_aspect_limits=obj["ax_aspect_limits"], - fig_aspect_limits=obj["fig_aspect_limits"], + min_ax_aspect=obj["min_ax_aspect"], + max_ax_aspect=obj["max_ax_aspect"], invert_colorbar=obj["invert_colorbar"], dpi=obj["dpi"], num_levels=obj["num_levels"], diff --git a/ogstools/meshplotlib/plot_setup_defaults.py b/ogstools/meshplotlib/plot_setup_defaults.py index 42f14b713d6d02287d72fa71c7718996590865f2..29810fa350d783dfc11ac55f1a018040c8d492c1 100644 --- a/ogstools/meshplotlib/plot_setup_defaults.py +++ b/ogstools/meshplotlib/plot_setup_defaults.py @@ -10,10 +10,10 @@ setup_dict = { "default_cmap": "RdBu_r", "dpi": 120, "fig_scale": 1.0, - "ax_aspect_limits": (0.4, 2.2), - "fig_aspect_limits": (0.2, 5.0), + "min_ax_aspect": 1.0, + "max_ax_aspect": 2.0, "invert_colorbar": False, - "layout": "constrained", + "layout": "compressed", "length": ["m", "m"], "material_names": None, "num_levels": 11, @@ -34,12 +34,15 @@ setup_dict = { "custom_cmap": None, "cmap_dict": { "displacement": "Greens", - "temperature": ["Blues", "plasma"], + "temperature": "plasma", "pressure": "Blues", "velocity": "coolwarm", "MaterialIDs": "tab20", }, - "cmap_dict_if_bilinear": {"displacement": "PRGn"}, + "cmap_dict_if_bilinear": { + "displacement": "PRGn", + "temperature": ["Blues", "plasma"], + }, "cmap_if_mask": ["lightgrey", "green"], "rcParams": { "font.weight": "normal", diff --git a/ogstools/physics/nuclearwasteheat/nuclearwaste.py b/ogstools/physics/nuclearwasteheat/nuclearwaste.py index 3377556846152cb681efcc5303c82e8929718b6c..86f417d35ef41876fb6f258c3ba04f207dd9140c 100644 --- a/ogstools/physics/nuclearwasteheat/nuclearwaste.py +++ b/ogstools/physics/nuclearwasteheat/nuclearwaste.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from typing import Optional as Opt -from typing import Union +from typing import Optional, Union import numpy as np from pint.facets.plain import PlainQuantity @@ -31,7 +30,7 @@ class NuclearWaste: self, t: Union[PlainQuantity, float, np.ndarray], baseline: bool = False, - ncl_id: Opt[int] = None, + ncl_id: Optional[int] = None, time_unit: str = "s", power_unit: str = "W", ) -> Union[float, np.ndarray]: @@ -84,7 +83,7 @@ class Repository: self, t: Union[PlainQuantity, float, np.ndarray], baseline: bool = False, - ncl_id: Opt[int] = None, + ncl_id: Optional[int] = None, time_unit: str = "s", power_unit: str = "W", ) -> Union[float, np.ndarray]: diff --git a/ogstools/propertylib/presets.py b/ogstools/propertylib/presets.py index 8712f01803d986453516f34c3626e025c0eeaa07..93bc44b6bdf9a0a6dc56e7fe0b968415237a83a3 100644 --- a/ogstools/propertylib/presets.py +++ b/ogstools/propertylib/presets.py @@ -22,7 +22,7 @@ hydraulic_height = Scalar("pressure", "m", "m", "hydraulic_height", H_mask) qp_ratio = Scalar("sigma", "Pa", "percent", "QP_ratio", M_mask, v2s.qp_ratio) strain = Matrix("epsilon", "", "percent", "strain", M_mask) stress = Matrix("sigma", "Pa", "MPa", "stress", M_mask) -temperature = Scalar("temperature", "K", "°C", mask=T_mask) +temperature = Scalar("temperature", "K", "°C", mask=T_mask, bilinear_cmap=True) velocity = Vector("velocity", "m/s", "m/s", "darcy_velocity", H_mask) von_mises_stress = Scalar("sigma", "Pa", "MPa", "von_Mises_stress", M_mask, v2s.von_mises) # fmt: on diff --git a/pyproject.toml b/pyproject.toml index c7d831d433f177bf4141a6c9b23a7354fc2255de..940c1ba39eca158119543c8053abb379308875f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "meshio==5.3.4", "matplotlib>=3.7.1", "nbconvert>=7.9.2", - "pyvista[all,trame]>=0.39.1", + "pyvista[all,trame]>=0.40.1", "rich>=13.4.2", "scipy>=1.10.1", "Pint>=0.22", diff --git a/tests/test_meshplotlib.py b/tests/test_meshplotlib.py index e51f85c7e5f2d8c6a998328552d4da4bb5449331..6113d32282e643101961abfcb29c03195e25fdb2 100644 --- a/tests/test_meshplotlib.py +++ b/tests/test_meshplotlib.py @@ -7,7 +7,7 @@ from tempfile import mkstemp import numpy as np from pyvista import examples as pv_examples -from ogstools.meshplotlib import examples, plot, setup +from ogstools.meshplotlib import examples, plot, plot_diff, plot_limit, setup from ogstools.meshplotlib.animation import animate, save_animation from ogstools.meshplotlib.levels import get_levels from ogstools.meshplotlib.plot_features import plot_on_top @@ -58,6 +58,17 @@ class MeshplotlibTest(unittest.TestCase): fig.axes[0], mesh, lambda x: min(max(0, 0.1 * (x - 3)), 100) ) + def test_diff_plots(self): + """Test creation of difference plots.""" + meshseries = examples.meshseries_CT_2D + plot_diff(meshseries.read(0), meshseries.read(1), "Si") + + def test_limit_plots(self): + """Test creation of limit plots.""" + meshseries = examples.meshseries_CT_2D + plot_limit(meshseries, "Si", "min") + plot_limit(meshseries, "Si", "max") + def test_animation(self): """Test creation of animation.""" meshseries = examples.meshseries_THM_2D @@ -67,7 +78,7 @@ class MeshplotlibTest(unittest.TestCase): anim.to_jshtml() def test_save_animation(self): - """Test creation of animation.""" + """Test saving of an animation.""" meshseries = examples.meshseries_THM_2D timevalues = np.linspace(0, meshseries.timevalues[-1], num=3) anim = animate(meshseries, presets.temperature, timevalues)