diff --git a/src/ansys/dpf/core/animation.py b/src/ansys/dpf/core/animation.py index d251804c81e..eeeb7d14727 100644 --- a/src/ansys/dpf/core/animation.py +++ b/src/ansys/dpf/core/animation.py @@ -20,7 +20,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Module contains the function for modal animation creation.""" +"""Utility functions for creating DPF-based animations.""" + +from __future__ import annotations + +from typing import Any import numpy as np @@ -28,53 +32,84 @@ def animate_mode( - fields_container, - mode_number=1, - type_mode=0, - frame_number=None, - save_as="", - deform_scale_factor=1.0, + fields_container: dpf.FieldsContainer, + mode_number: int = 1, + type_mode: int = 0, + frame_number: int | None = None, + save_as: str = "", + deform_scale_factor: float = 1.0, **kwargs, -): - # other option: instead of `type` use `min_factor` and `max_factor`. - """Create a modal animation based on Fields contained in the FieldsContainer. +) -> Any: + """Animate a single mode shape by sweeping its displacement amplitude. + + Extracts the field for *mode_number* from *fields_container*, builds a + :class:`~ansys.dpf.core.FieldsContainer` of N amplitude-scaled copies of + that field, and delegates to + :meth:`FieldsContainer.animate `. - This method creates a movie or a gif based on the time ids of a ``FieldsContainer``. - For kwargs see pyvista.Plotter.open_movie/add_text/show. + The per-frame overlay shows the current relative displacement amplitude + (ranging from ``-1`` to ``1``) with the physical unit of the result field. Parameters ---------- - field_container : - Field container containing the modal results. - mode_number : int, optional - Mode number of the results to animation. The default is ``1``. - type_mode : int, optional - Whether it is 0 or 1. Default to 0. - If 0, the norm of the displacements will be scaled from 1 to -1 to 1. - If 1, the norm of the displacements will be scaled between -1 and 1. - save_as : Path of file to save the animation to. Defaults to None. Can be of any format - supported by pyvista.Plotter.write_frame (.gif, .mp4, ...). - deform_scale_factor : float, optional - Scale factor to apply when warping the mesh. Defaults to 1.0. + fields_container + Container of modal results. Must contain a ``"time"`` label whose IDs + correspond to mode numbers. + mode_number + Mode number to animate. Must be present in the container's ``"time"`` + label. The default is ``1``. + type_mode + Amplitude profile to use across the frames: + + * ``0`` (default): full cycle, amplitude sweeps ``1 → -1 → 1``. + * ``1``: positive half only, amplitude sweeps ``1 → 0 → 1``. + frame_number + Total number of frames in the animation. + For ``type_mode=0`` the value is forced to be odd (decremented by one + if even); defaults to ``41``. + For ``type_mode=1`` defaults to ``21``. + save_as + Path of the file to save the animation to. Supports any format + accepted by :func:`pyvista.Plotter.write_frame`, e.g. ``.gif`` or + ``.mp4``. Defaults to ``""`` (no file written). + deform_scale_factor + Scale factor applied when warping the mesh by the displacement field. + Defaults to ``1.0``. + **kwargs + Additional keyword arguments forwarded to + :meth:`FieldsContainer.animate ` + and ultimately to :class:`pyvista.Plotter` (e.g. ``off_screen``, + ``cpos``, ``framerate``, ``show_axes``). + + Returns + ------- + Any + The return value of :func:`pyvista.Plotter.show`. + + Raises + ------ + ValueError + If *mode_number* is not present in *fields_container*. + ValueError + If *type_mode* is not ``0`` or ``1``. Examples -------- - Import a modal result from a model. + Animate the first mode of a modal analysis and save as a GIF. >>> import ansys.dpf.core as dpf - >>> from ansys.dpf.core import examples + >>> from ansys.dpf.core import animation, examples >>> model = dpf.Model(examples.download_modal_frame()) >>> disp = model.results.displacement.on_all_time_freqs.eval() + >>> animation.animate_mode(disp, mode_number=1, save_as="mode1.gif") # doctest: +SKIP - Creates an animation from a modal result. - - >>> from ansys.dpf.core import animation - >>> animation.animate_mode(disp, mode_number=1, save_as="tmp.gif") + Use the absolute-value amplitude profile with a custom frame count. + >>> animation.animate_mode( # doctest: +SKIP + ... disp, mode_number=1, type_mode=1, frame_number=31, save_as="mode1_abs.gif" + ... ) """ - from ansys.dpf.core.animator import Animator - # Animation type if type_mode == 1: @@ -91,18 +126,15 @@ def animate_mode( else: raise ValueError( f"The type_mode {type_mode} is not accepted. " - + "Please select one in 'positive_disp' and 'full_disp'." + "Please select 0 (full cycle) or 1 (positive half only)." ) # Get fields available_mode_numbers = fields_container.get_available_ids_for_label("time") - if not mode_number in available_mode_numbers: + if mode_number not in available_mode_numbers: raise ValueError(f"The mode {mode_number} data is not available in field container.") fields_mode = fields_container.get_fields({"time": mode_number}) - mode_frequencies_field = fields_container.time_freq_support.time_frequencies - mode_frequencies = mode_frequencies_field.data - mode_frequency = mode_frequencies[available_mode_numbers.index(mode_number)] # Merge fields if needed if len(fields_mode) > 1: @@ -114,32 +146,34 @@ def animate_mode( field_mode = fields_mode[0] max_data = float(np.max(field_mode.data)) - loop_over = dpf.fields_factory.field_from_array(scale_factor_per_frame) - loop_over.unit = mode_frequencies_field.unit - # Create workflow - wf = dpf.Workflow() - wf.progress_bar = False - - # Add scaling operator - scaling_op = dpf.operators.math.scale() - scaling_op.inputs.field.connect(field_mode) - wf.add_operators([scaling_op]) - - wf.set_input_name("weights", scaling_op.inputs.weights) - wf.set_output_name("field", scaling_op.outputs.field) - wf.set_output_name("deform_by", scaling_op.outputs.field) + # Build a FieldsContainer of N amplitude-scaled copies of the mode field. + # Each entry is field_mode multiplied by one amplitude value from + # scale_factor_per_frame, so the standard FieldsContainer.animate path + # (extract_sub_fc → merge_fields → mesh.from_field) handles mode animation + # exactly like any other collection, removing a bespoke code path. + scaled_fields = [ + dpf.operators.math.scale(field=field_mode, weights=float(amp)).eval() + for amp in scale_factor_per_frame + ] + scaled_fc = dpf.fields_container_factory.over_time_freq_fields_container(scaled_fields) + + # Override the TimeFreqSupport so the per-frame overlay shows the current + # relative displacement amplitude rather than a bare integer frame index. + amp_field = dpf.fields_factory.field_from_array( + np.array(scale_factor_per_frame, dtype=np.double) + ) + amp_field.unit = field_mode.unit + tfs = dpf.TimeFreqSupport() + tfs.time_frequencies = amp_field + scaled_fc.time_freq_support = tfs - anim = Animator(workflow=wf, **kwargs) + kwargs.setdefault("clim", [0.0, max_data]) - return anim.animate( - loop_over=loop_over, - input_name="weights", - output_name="field", - save_as=save_as, - mode_number=mode_number, - mode_frequency=mode_frequency, - clim=[0, max_data], + return scaled_fc.animate( + label="time", + deform_by=scaled_fc, scale_factor=deform_scale_factor, + save_as=save_as, **kwargs, ) diff --git a/src/ansys/dpf/core/animator.py b/src/ansys/dpf/core/animator.py index 8c4a501761c..e1dbf5aa09b 100644 --- a/src/ansys/dpf/core/animator.py +++ b/src/ansys/dpf/core/animator.py @@ -58,12 +58,40 @@ def animate_workflow( output_name, input_name="loop_over", save_as="", - mode_number=None, - mode_frequency=None, scale_factor=1.0, shell_layer=core.shell_layers.top, + label=None, + output_type=None, **kwargs, ): + """Animate a workflow, rendering one frame per entry in *loop_over*. + + Supports two rendering modes controlled by *output_type*: + + * **Field mode** (default, ``output_type=None`` or + ``output_type=core.types.field``): the workflow output *output_name* is + retrieved as a :class:`~ansys.dpf.core.Field` and rendered with + :meth:`add_field`. An optional ``"deform_by"`` :class:`~ansys.dpf.core.Field` + output is used for mesh deformation. + * **Mesh mode** (``output_type=core.types.meshed_region``): the workflow + output *output_name* is retrieved as a + :class:`~ansys.dpf.core.MeshedRegion` and rendered with :meth:`add_mesh`. + When the workflow also exposes a ``"to_render_field"`` + :class:`~ansys.dpf.core.Field` output, the mesh is colored by that field + using :meth:`add_field` instead. An optional ``"deform_by"`` output is + still honoured in both sub-cases. + + When *label* is set the per-frame workflow input receives a + ``{label: label_id}`` dict, which is what + :class:`~ansys.dpf.core.operators.utility.extract_sub_fc` and + :class:`~ansys.dpf.core.operators.utility.extract_sub_mc` expect. + Both :meth:`FieldsContainer.animate ` and + :meth:`MeshesContainer.animate ` use + this path, including mode-shape animations (which pre-build a scaled + FieldsContainer before calling ``animate``). + When *label* is ``None`` the input receives a 0-based frame-index list, + which is the fallback for direct :class:`Animator` callers. + """ unit = loop_over.unit indices = loop_over.scoping.ids @@ -73,9 +101,11 @@ def animate_workflow( if type_scale in [int, float]: scale_factor = [float(scale_factor)] * len(indices) elif type_scale == list: - pass - # elif type_scale in [core.field.Field, core.fields_container.FieldsContainer]: - # scale_factor = ["Non-homogenous"]*len(indices) + if len(scale_factor) != len(indices): + raise ValueError( + f"The scale_factor list length ({len(scale_factor)}) must match the " + f"number of animation frames ({len(indices)})." + ) else: raise ValueError( "Argument scale_factor must be an int, a float, or a list of either, " @@ -109,36 +139,62 @@ def animate_workflow( def render_frame(frame): self._plotter.clear() - if mode_number is None: - workflow.connect(input_name, [frame]) - + # ── connect the per-frame input ─────────────────────────────────── + if label is not None: + # Label-space mode: a single connect fans to all registered + # label_space inputs (each extract_sub_* op shares the name). + workflow.connect(input_name, {label: int(indices[frame])}) else: - workflow.connect(input_name, loop_over.data[frame]) + # Fallback for direct Animator.animate() callers that supply a + # plain workflow driven by a 0-based frame-index list. + workflow.connect(input_name, [frame]) - field = workflow.get_output(output_name, core.types.field) + # ── retrieve deformation field if the workflow produces one ─────── deform = None if "deform_by" in workflow.output_names: deform = workflow.get_output("deform_by", core.types.field) - self.add_field( - field, - deform_by=deform, - scale_factor=scale_factor[frame], - scale_factor_legend=scale_factor[frame], - shell_layer=shell_layer, - **kwargs, - ) - kwargs_in = _sort_supported_kwargs(bound_method=self._plotter.add_text, **freq_kwargs) - if mode_number is None: - str_template = "t={0:{2}} {1}" - self._plotter.add_text( - str_template.format(indices[frame], unit, freq_fmt), **kwargs_in - ) + + # ── render: mesh mode or field mode ────────────────────────────── + if output_type == core.types.meshed_region: + mesh = workflow.get_output(output_name, core.types.meshed_region) + if "to_render_field" in workflow.output_names: + # Coloring requested: overlay the field onto the mesh. + color_field = workflow.get_output("to_render_field", core.types.field) + self.add_field( + color_field, + meshed_region=mesh, + deform_by=deform, + scale_factor=scale_factor[frame], + scale_factor_legend=scale_factor[frame], + shell_layer=shell_layer, + **kwargs, + ) + else: + self.add_mesh( + mesh, + deform_by=deform, + scale_factor=scale_factor[frame], + **kwargs, + ) else: - str_template = "mode={3}\nfrq={0:{2}} {1}" - self._plotter.add_text( - str_template.format(mode_frequency, unit, freq_fmt, mode_number), **kwargs_in + field = workflow.get_output(output_name, core.types.field) + self.add_field( + field, + deform_by=deform, + scale_factor=scale_factor[frame], + scale_factor_legend=scale_factor[frame], + shell_layer=shell_layer, + **kwargs, ) + # ── per-frame overlay text ──────────────────────────────────────── + kwargs_in = _sort_supported_kwargs(bound_method=self._plotter.add_text, **freq_kwargs) + prefix = ("t" if label == "time" else label) if label is not None else "t" + str_template = f"{prefix}={{0:{{2}}}} {{1}}" + self._plotter.add_text( + str_template.format(loop_over.data_as_list[frame], unit, freq_fmt), **kwargs_in + ) + if cpos: self._plotter.camera_position = cpos[frame] @@ -194,7 +250,19 @@ def animation(): class Animator: - """The DPF animator class.""" + """The DPF Animator class. + + Drives an animation by repeatedly connecting per-frame values to a + :class:`~ansys.dpf.core.Workflow` and rendering the result with PyVista. + Two output types are supported: + + * **Field output** (default): the workflow returns a + :class:`~ansys.dpf.core.Field` per frame, rendered as a contour plot. + * **Mesh output** (``output_type=core.types.meshed_region``): the workflow + returns a :class:`~ansys.dpf.core.MeshedRegion` per frame, rendered as a + geometry plot. Optionally the workflow also exposes a ``"to_render_field"`` + output for coloring and/or a ``"deform_by"`` output for mesh deformation. + """ def __init__(self, workflow=None, **kwargs): """ @@ -272,6 +340,8 @@ def animate( scale_factor: Union[float, Sequence[float]] = 1.0, freq_kwargs: dict = None, shell_layer: core.shell_layers = core.shell_layers.top, + label: str = None, + output_type=None, **kwargs, ): """ @@ -284,11 +354,14 @@ def animate( Can for example be a subset of sets of TimeFreqSupport.time_frequencies. The unit of the Field will be displayed if present. output_name: - Name of the workflow output to use as Field for each frame's contour. - Defaults to "to_render". + Name of the workflow output to retrieve for rendering each frame. + For Field animations (default) this should return a + :class:`~ansys.dpf.core.Field`; for mesh animations it should return a + :class:`~ansys.dpf.core.MeshedRegion`. Defaults to ``"to_render"``. input_name: - Name of the workflow inputs to feed loop_over values into. - Defaults to "loop_over". + Name of the workflow input to feed the per-frame value into. + Defaults to ``"loop_over"`` (0-based frame index list for mode-shape animations). + Use ``"label_space"`` together with *label* for label-space-based animations. save_as: Path of file to save the animation to. Defaults to None. Can be of any format supported by pyvista.Plotter.write_frame (.gif, .mp4, ...). @@ -302,13 +375,24 @@ def animate( shell_layer: Enum used to set the shell layer if the field to plot contains shell elements. Defaults to top layer. + label : str, optional + Name of the collection label being animated. When set, the per-frame + workflow input receives a ``{label: label_id}`` dict instead of a + 0-based index list, which is required by operators such as + :class:`~ansys.dpf.core.operators.utility.extract_sub_fc` and + :class:`~ansys.dpf.core.operators.utility.extract_sub_mc`. Also + used as the prefix in the per-frame overlay text + (e.g. ``"mat"`` → ``"mat=3"``, ``"time"`` → ``"t=0.001 s"``). + Defaults to ``None`` (mode-shape animation, index-list input). + output_type : core.types, optional + Expected type of the workflow's *output_name* output. Use + ``core.types.meshed_region`` to animate a :class:`~ansys.dpf.core.MeshesContainer`; + leave as ``None`` (or ``core.types.field``) for the standard Field animation. **kwargs : optional Additional keyword arguments for the animator. Used by :func:`pyvista.Plotter` (off_screen, cpos, ...), or by :func:`pyvista.Plotter.open_movie` (framerate, quality, ...) - - """ if freq_kwargs is None: freq_kwargs = {"font_size": 12, "fmt": ".3e"} @@ -323,6 +407,8 @@ def animate( scale_factor=scale_factor, freq_kwargs=freq_kwargs, shell_layer=shell_layer, + label=label, + output_type=output_type, **kwargs, ) diff --git a/src/ansys/dpf/core/fields_container.py b/src/ansys/dpf/core/fields_container.py index 5ab62c4d021..e04bd621038 100644 --- a/src/ansys/dpf/core/fields_container.py +++ b/src/ansys/dpf/core/fields_container.py @@ -29,7 +29,7 @@ from __future__ import annotations from contextlib import suppress -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Sequence, Union from ansys import dpf from ansys.dpf.core import errors as dpf_errors, field @@ -560,11 +560,13 @@ def animate( deform_by: Union[FieldsContainer, Result, Operator] = None, scale_factor: Union[float, Sequence[float]] = 1.0, shell_layer: shell_layers = shell_layers.top, + label: str = "time", **kwargs, ): """Create an animation based on the Fields contained in the FieldsContainer. - This method creates a movie or a gif based on the time ids of a FieldsContainer. + Iterates over the entries indexed by *label*, rendering each + :class:`~ansys.dpf.core.Field` as a separate frame. For kwargs see pyvista.Plotter.open_movie/add_text/show. Parameters @@ -582,6 +584,9 @@ def animate( shell_layer: Enum used to set the shell layer if the field to plot contains shell elements. Defaults to top layer. + label : str, optional + Name of the label to animate over. Defaults to ``"time"``. The label + must exist in this :class:`FieldsContainer`. **kwargs: Additional keyword arguments for the animator. Used by :func:`pyvista.Plotter` (off_screen, cpos, ...), @@ -590,35 +595,53 @@ def animate( """ from ansys.dpf.core.animator import Animator - # Create a workflow defining the result to render at each step of the animation + # ── validate label ─────────────────────────────────────────────── + available_labels = self.labels + if label not in available_labels: + raise ValueError( + f"Label '{label}' not found in this FieldsContainer. " + f"Available labels: {available_labels}" + ) + + # ── build the server-side workflow ──────────────────────────────────── + # Multiple operator inputs can share the same workflow input name, so a + # single workflow.connect("label_space", dict) fans out to every extract + # operator registered under that name. wf = dpf.core.Workflow() - # First define the workflow index input - forward_index = dpf.core.operators.utility.forward() - wf.set_input_name("loop_over", forward_index.inputs.any) - # Define the field extraction using the fields_container and indices - extract_field_op = dpf.core.operators.utility.extract_field(self) - to_render = extract_field_op.outputs.field - # Add the operators to the workflow - wf.add_operators([extract_field_op, forward_index]) - - # Treat multi-component fields by taking their norm + + # Field extraction: extract_sub_fc filters by the label_space dict and, + # with collapse_labels=True, removes the animated label from the output + # FC's label set. merge_fields merges all remaining fields into one Field. + extract_fc_op = dpf.core.operators.utility.extract_sub_fc( + fields_container=self, collapse_labels=True + ) + wf.set_input_name("label_space", extract_fc_op.inputs.label_space) + merge_field_op = dpf.core.operators.utility.merge_fields( + fields1=extract_fc_op.outputs.fields_container + ) + to_render_field = merge_field_op.outputs.merged_field + + # Extract the mesh support from the merged field — this makes the + # workflow expose a "to_render" MeshedRegion, the same convention used + # by MeshesContainer.animate, so animate_workflow always uses add_mesh / + # add_field regardless of whether the source is a FC or MC. + from_field_op = dpf.core.operators.mesh.from_field( + field=merge_field_op.outputs.merged_field + ) + wf.add_operators([extract_fc_op, merge_field_op, from_field_op]) + wf.set_output_name("to_render", from_field_op.outputs.mesh) + + # The field (scalar norm for multi-component) becomes "to_render_field" + # for coloring, matching the MeshesContainer.animate convention. n_components = self[0].component_count if n_components > 1: - norm_op = dpf.core.operators.math.norm(extract_field_op.outputs.field) + norm_op = dpf.core.operators.math.norm(merge_field_op.outputs.merged_field) wf.add_operator(norm_op) - to_render = norm_op.outputs.field - - # Get time steps IDs and values - loop_over = self.get_time_scoping() - frequencies = self.time_freq_support.time_frequencies - if frequencies is None: - raise ValueError("The fields_container has no time_frequencies.") + to_render_field = norm_op.outputs.field + wf.set_output_name("to_render_field", to_render_field) - # TODO: /!\ We should be using a mechanical::time_selector, however it is not wrapped. - # https://github.com/ansys/pydpf-core/issues/1984, todo was added in this PR - - wf.set_input_name("indices", extract_field_op.inputs.indices) # Have to do it this way - wf.connect("indices", forward_index) # Otherwise not accepted + # Get label IDs and build the loop_over values + label_scoping = self.get_label_scoping(label) deform = True # Define whether to deform and what with @@ -642,47 +665,53 @@ def animate( deform = False if deform: - scale_factor_fc = dpf.core.animator.scale_factor_to_fc(scale_factor, deform_by) - scale_factor_invert = dpf.core.operators.math.invert_fc(scale_factor_fc) - # Extraction of the field of interest based on index - # time_selector = dpf.core.Operator("mechanical::time_selector") - extract_field_op_2 = dpf.core.operators.utility.extract_field(deform_by) - wf.set_input_name("indices", extract_field_op_2.inputs.indices) - wf.connect("indices", forward_index) # Otherwise not accepted - # Scaling of the field based on scale_factor and index - extract_scale_factor_op = dpf.core.operators.utility.extract_field(scale_factor_invert) - wf.set_input_name("indices", extract_scale_factor_op.inputs.indices) - wf.connect("indices", forward_index) # Otherwise not accepted - - divide_op = dpf.core.operators.math.component_wise_divide( - extract_field_op_2.outputs.field, extract_scale_factor_op.outputs.field + # Deformation path: register under the same "label_space" name so + # the single workflow.connect call fans out here too. + extract_deform_fc_op = dpf.core.operators.utility.extract_sub_fc( + fields_container=deform_by, collapse_labels=True ) - wf.set_output_name("deform_by", divide_op.outputs.field) - - wf.add_operators( - [scale_factor_invert, extract_field_op_2, extract_scale_factor_op, divide_op] + wf.set_input_name("label_space", extract_deform_fc_op.inputs.label_space) + merge_deform_op = dpf.core.operators.utility.merge_fields( + fields1=extract_deform_fc_op.outputs.fields_container ) - else: - scale_factor = None - wf.set_output_name("to_render", to_render) + wf.set_output_name("deform_by", merge_deform_op.outputs.merged_field) + wf.add_operators([extract_deform_fc_op, merge_deform_op]) + wf.progress_bar = False - loop_over_field = dpf.core.fields_factory.field_from_array( - frequencies.data[loop_over.ids - 1] - ) - loop_over_field.scoping.ids = loop_over.ids - loop_over_field.unit = frequencies.unit + # Build loop_over field: use real time/freq values when animating over + # "time", otherwise fall back to the raw label IDs. + if label == "time" and self.time_freq_support is not None: + frequencies = self.time_freq_support.time_frequencies + if frequencies is None: + raise ValueError("The fields_container has no time_frequencies.") + values = frequencies.data[label_scoping.ids - 1] + unit = frequencies.unit + freq_fmt = ".3e" + else: + import numpy as _np + + values = _np.array(label_scoping.ids, dtype=float) + unit = "" + freq_fmt = "g" + + loop_over_field = dpf.core.fields_factory.field_from_array(values) + loop_over_field.scoping.ids = label_scoping.ids + loop_over_field.unit = unit # Initiate the Animator anim = Animator(workflow=wf, **kwargs) - kwargs.setdefault("freq_kwargs", {"font_size": 12, "fmt": ".3e"}) + kwargs.setdefault("freq_kwargs", {"font_size": 12, "fmt": freq_fmt}) return anim.animate( loop_over=loop_over_field, save_as=save_as, scale_factor=scale_factor, shell_layer=shell_layer, + input_name="label_space", + label=label, + output_type=dpf.core.types.meshed_region, **kwargs, ) diff --git a/src/ansys/dpf/core/meshes_container.py b/src/ansys/dpf/core/meshes_container.py index d95909de210..0f501d6bf4a 100644 --- a/src/ansys/dpf/core/meshes_container.py +++ b/src/ansys/dpf/core/meshes_container.py @@ -29,13 +29,24 @@ from __future__ import annotations -from ansys.dpf.core import elements, errors as dpf_errors, meshed_region +import os +from typing import TYPE_CHECKING, List, Optional, Union + +import numpy as np + +if TYPE_CHECKING: + from ansys.dpf.core import FieldsContainer, Operator, TimeFreqSupport + from ansys.dpf.core.results import Result + +import ansys.dpf.core as dpf +from ansys.dpf.core import elements, errors as dpf_errors from ansys.dpf.core.check_version import server_meet_version from ansys.dpf.core.collection_base import CollectionBase +from ansys.dpf.core.meshed_region import MeshedRegion from ansys.dpf.core.plotter import DpfPlotter -class MeshesContainer(CollectionBase[meshed_region.MeshedRegion]): +class MeshesContainer(CollectionBase[MeshedRegion]): """Represents a meshes container, which contains meshes split on a given space. Parameters @@ -50,7 +61,7 @@ class MeshesContainer(CollectionBase[meshed_region.MeshedRegion]): global server. """ - entries_type = meshed_region.MeshedRegion + entries_type = MeshedRegion def __init__(self, meshes_container=None, server=None): super().__init__(collection=meshes_container, server=server) @@ -62,7 +73,7 @@ def __init__(self, meshes_container=None, server=None): def create_subtype(self, obj_by_copy): """Create a meshed region sub type.""" - return meshed_region.MeshedRegion(mesh=obj_by_copy, server=self._server) + return MeshedRegion(mesh=obj_by_copy, server=self._server) def plot(self, fields_container=None, deform_by=None, scale_factor=1.0, **kwargs): """Plot the meshes container with a specific result if fields_container is specified. @@ -147,6 +158,170 @@ def plot(self, fields_container=None, deform_by=None, scale_factor=1.0, **kwargs kwargs.pop("notebook", None) return pl.show_figure(**kwargs) + def animate( + self, + save_as: Union[str, os.PathLike] = None, + deform_by: Union["FieldsContainer", "Result", "Operator", bool] = None, + scale_factor: Union[float, List[float]] = 1.0, + fields_container: Optional["FieldsContainer"] = None, + time_freq_support: Optional["TimeFreqSupport"] = None, + label: str = "time", + **kwargs, + ): + """Create an animation based on the meshes contained in the MeshesContainer. + + Iterates over the entries indexed by *label*, rendering each :class:`MeshedRegion + ` as a separate frame. Optionally colors each mesh + using a matching :class:`FieldsContainer ` and/or + deforms it. + + Parameters + ---------- + save_as : str, os.PathLike, optional + Path of the file to save the animation to. Defaults to ``None``. Supports any + format accepted by :func:`pyvista.Plotter.write_frame` (.gif, .mp4, …). + deform_by : FieldsContainer, Result, Operator, bool, optional + Used to deform the mesh at each frame. Must evaluate to a + :class:`FieldsContainer ` of 3D nodal vector + fields with a matching label structure. Set to ``False`` to disable deformation. + Defaults to ``None`` (no deformation). + scale_factor : float, list[float], optional + Scale factor applied to the deformation. Defaults to ``1.0``. Pass a list to + vary the factor per frame. + fields_container : FieldsContainer, optional + If provided, each mesh frame is colored by the corresponding field. The + :class:`FieldsContainer ` must share the same + label and IDs as this :class:`MeshesContainer + `. + time_freq_support : TimeFreqSupport, optional + When the animated label is ``"time"``, provide this to display actual + time/frequency values in the per-frame overlay text instead of label IDs. + Defaults to ``None``. + label : str, optional + Name of the label to animate over. Defaults to ``"time"``. + Must be a label present in the container. + **kwargs + Additional keyword arguments forwarded to the animator and plotter + (e.g. ``off_screen``, ``cpos``, ``framerate``, ``quality``). + + Examples + -------- + Animate a mesh split by material: + + >>> from ansys.dpf import core as dpf + >>> from ansys.dpf.core import examples + >>> model = dpf.Model(examples.find_multishells_rst()) + >>> mesh = model.metadata.meshed_region + >>> split_mesh_op = dpf.operators.mesh.split_mesh(mesh=mesh, property="mat") + >>> meshes_cont = split_mesh_op.eval() + >>> meshes_cont.animate(label="mat", off_screen=True) # doctest: +SKIP + + """ + from ansys.dpf.core.animator import Animator + + # Normalize save_as to str so downstream str methods (e.g. endswith) work + # with any os.PathLike value (e.g. pathlib.Path). + if save_as is not None: + save_as = os.fspath(save_as) + + # ── validate the label ──────────────────────────────────────────────── + available_labels = self.labels + if label not in available_labels: + raise ValueError( + f"Label '{label}' not found in this MeshesContainer. " + f"Available labels: {available_labels}" + ) + + label_scoping = self.get_label_scoping(label=label) + + # ── build the server-side workflow ──────────────────────────────────── + # Multiple operator inputs can share the same workflow input name, so a + # single workflow.connect("label_space", dict) fans out to every extract + # operator registered under that name. + wf = dpf.Workflow() + + # Mesh path: extract_sub_mc → merge_meshes → MeshedRegion + extract_mc_op = dpf.operators.utility.extract_sub_mc(meshes=self) + wf.set_input_name("label_space", extract_mc_op.inputs.label_space) + merge_op = dpf.operators.utility.merge_meshes( + meshes1=extract_mc_op.outputs.meshes_container + ) + wf.set_output_name("to_render", merge_op.outputs.merges_mesh) + wf.add_operators([extract_mc_op, merge_op]) + + # Optional coloring path: extract_sub_fc → extract_field → Field + # Convention for animate_workflow mesh-coloring mode: output "to_render_field" + if fields_container is not None: + extract_fc_op = dpf.operators.utility.extract_sub_fc( + fields_container=fields_container, collapse_labels=True + ) + # Shared name: same connect call fans out to this pin too. + wf.set_input_name("label_space", extract_fc_op.inputs.label_space) + # collapse_labels removes the animated label from the output FC's label + # set. merge_fields merges all remaining fields into a single Field. + merge_color_op = dpf.operators.utility.merge_fields( + fields1=extract_fc_op.outputs.fields_container + ) + wf.set_output_name("to_render_field", merge_color_op.outputs.merged_field) + wf.add_operators([extract_fc_op, merge_color_op]) + + # Optional deformation path: extract_sub_fc → extract_field → Field + # Convention for animate_workflow deformation: output "deform_by" + if deform_by is not False and deform_by is not None: + if isinstance(deform_by, bool): + raise ValueError( + "'deform_by=True' is not supported. Use False or None to disable " + "deformation, or provide a FieldsContainer/result object." + ) + if not isinstance(deform_by, dpf.FieldsContainer): + deform_by = deform_by.eval() + extract_deform_fc_op = dpf.operators.utility.extract_sub_fc( + fields_container=deform_by, collapse_labels=True + ) + # Shared name: same connect call fans out to this pin too. + wf.set_input_name("label_space", extract_deform_fc_op.inputs.label_space) + merge_deform_op = dpf.operators.utility.merge_fields( + fields1=extract_deform_fc_op.outputs.fields_container + ) + wf.set_output_name("deform_by", merge_deform_op.outputs.merged_field) + wf.add_operators([extract_deform_fc_op, merge_deform_op]) + + wf.progress_bar = False + + # ── build the loop_over Field (values shown in the overlay text) ────── + if label == "time" and time_freq_support is not None: + freq_field = time_freq_support.time_frequencies + if freq_field is None: + raise ValueError( + "The 'time' label requires 'time_freq_support.time_frequencies' " + "to be available." + ) + values = freq_field.data[label_scoping.ids - 1] + unit = freq_field.unit + freq_fmt = ".3e" + else: + values = np.array(label_scoping.ids, dtype=float) + unit = "" + freq_fmt = "g" + + loop_over_field = dpf.fields_factory.field_from_array(values) + loop_over_field.scoping.ids = label_scoping.ids + loop_over_field.unit = unit + + # ── run the animation via the generic Animator.animate ──────────────── + anim = Animator(workflow=wf, **kwargs) + kwargs.setdefault("freq_kwargs", {"font_size": 12, "fmt": freq_fmt}) + return anim.animate( + loop_over=loop_over_field, + output_name="to_render", + input_name="label_space", + save_as=save_as, + scale_factor=scale_factor, + label=label, + output_type=dpf.types.meshed_region, + **kwargs, + ) + def get_meshes(self, label_space): """Retrieve the meshes at a label space. diff --git a/src/ansys/dpf/core/plotter.py b/src/ansys/dpf/core/plotter.py index 5606c81eaa6..026f9e0bd8b 100644 --- a/src/ansys/dpf/core/plotter.py +++ b/src/ansys/dpf/core/plotter.py @@ -414,7 +414,10 @@ def add_field( ind = ind_2 overall_data[ind] = field.data[mask] else: - overall_data[:] = field.data[0] + if len(field.data) > 0: + overall_data[:] = field.data[0] + else: + overall_data[:] = np.nan # Filter kwargs for add_mesh kwargs_in = _sort_supported_kwargs(bound_method=self._plotter.add_mesh, **kwargs) # Have to remove any active scalar field from the pre-existing grid object, diff --git a/tests/test_animation.py b/tests/test_animation.py index 5b73cc6df26..5a94263bbec 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -64,3 +64,42 @@ def test_animate_mode_positive_disp(displacement_fields): def test_animator_animate_mode_fields_container_one_component(displacement_fields): animation.animate_mode(displacement_fields.select_component(0), mode_number=10) + + +def test_animate_mode_save_as(remove_gifs, displacement_fields): + """animate_mode should write a GIF file of non-trivial size.""" + animation.animate_mode( + displacement_fields, + mode_number=1, + save_as=gif_name, + off_screen=True, + ) + assert Path(gif_name).is_file() + assert Path(gif_name).stat().st_size > 6000 + + +def test_animate_mode_deform_scale_factor(displacement_fields): + """Non-unity deform_scale_factor should be accepted without error.""" + animation.animate_mode(displacement_fields, mode_number=1, deform_scale_factor=2.0) + + +def test_animate_mode_custom_frame_number(displacement_fields): + """Explicit frame_number controls how many scaled frames are produced (type_mode=0).""" + animation.animate_mode(displacement_fields, mode_number=1, frame_number=5) + + +def test_animate_mode_custom_frame_number_type1(displacement_fields): + """Explicit frame_number controls how many scaled frames are produced (type_mode=1).""" + animation.animate_mode(displacement_fields, mode_number=1, type_mode=1, frame_number=7) + + +def test_animate_mode_invalid_mode_number(displacement_fields): + """A mode number that is not in the container should raise ValueError.""" + with pytest.raises(ValueError, match="mode .* data is not available"): + animation.animate_mode(displacement_fields, mode_number=99999) + + +def test_animate_mode_invalid_type_mode(displacement_fields): + """An unsupported type_mode should raise ValueError.""" + with pytest.raises(ValueError, match="type_mode"): + animation.animate_mode(displacement_fields, mode_number=1, type_mode=99) diff --git a/tests/test_animator.py b/tests/test_animator.py index 482c2ee8827..c4fc9556fbe 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -211,27 +211,26 @@ def test_animator_animate_fields_container_scale_factor_raise_list_len( displacement_fields, ): scale_factor_list = [2.0] * (len(displacement_fields) - 2) - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="scale_factor list length"): displacement_fields.animate(scale_factor=scale_factor_list) - assert "The scale_factor list is not the same length" in e def test_animator_animate_fields_container_scale_factor_field(displacement_fields): + # A Field object is not a valid scale_factor type; expect a clear ValueError. scale_factor_field = dpf.fields_factory.field_from_array(displacement_fields[0].data) - with pytest.raises(NotImplementedError) as e: + with pytest.raises(ValueError, match="Argument scale_factor must be"): displacement_fields.animate(scale_factor=scale_factor_field) - assert "Scaling by a Field is not yet implemented." in e def test_animator_animate_fields_container_scale_factor_fc(displacement_fields): + # A FieldsContainer object is not a valid scale_factor type; expect a clear ValueError. fields = [] for f in displacement_fields: fields.append(dpf.fields_factory.field_from_array(f.data)) scale_factor_fc = dpf.fields_container_factory.over_time_freq_fields_container(fields) scale_factor_fc.time_freq_support = displacement_fields.time_freq_support - with pytest.raises(NotImplementedError) as e: + with pytest.raises(ValueError, match="Argument scale_factor must be"): displacement_fields.animate(scale_factor=scale_factor_fc) - assert "Scaling by a FieldsContainer is not yet implemented." in e def test_animator_animate_fields_container_cpos(remove_gifs, displacement_fields): @@ -252,3 +251,69 @@ def test_animator_animate_fields_container_cpos(remove_gifs, displacement_fields ) assert Path(gif_name).is_file() assert Path(gif_name).stat().st_size > 6000 + + +def test_animator_animate_scale_factor_none(displacement_fields): + """Passing scale_factor=None is accepted (treated internally as no scaling).""" + displacement_fields.animate(scale_factor=None, off_screen=True) + + +def test_animator_animate_fields_container_invalid_label_raises(displacement_fields): + """FieldsContainer.animate raises ValueError for a label not present.""" + with pytest.raises(ValueError, match="not found"): + displacement_fields.animate(label="nonexistent_label") + + +def test_animator_animate_fields_container_none_time_freq_raises(displacement_fields): + """FieldsContainer.animate raises ValueError when time_frequencies is None.""" + fc = displacement_fields.deep_copy() + tfs = dpf.TimeFreqSupport() + # A fresh TimeFreqSupport has no time_frequencies set + fc.time_freq_support = tfs + with pytest.raises(ValueError, match="no time_frequencies"): + fc.animate(off_screen=True) + + +# --------------------------------------------------------------------------- +# Tests for ansys.dpf.core.animation.animate_mode +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def modal_fields(): + """Displacement FieldsContainer (transient data) used as mock mode data for animate_mode tests.""" + model = dpf.Model(examples.find_msup_transient()) + return model.results.displacement.on_time_scoping([1, 2]).eval() + + +def test_animate_mode_type_mode_1(modal_fields): + """animate_mode with type_mode=1 (positive half) runs without error.""" + from ansys.dpf.core import animation + + animation.animate_mode(modal_fields, mode_number=1, type_mode=1, off_screen=True) + + +def test_animate_mode_even_frame_number(modal_fields): + """animate_mode with type_mode=0 and an even frame_number auto-corrects to odd.""" + from ansys.dpf.core import animation + + # Even frame_number should be silently decremented by one; no exception expected. + animation.animate_mode( + modal_fields, mode_number=1, type_mode=0, frame_number=10, off_screen=True + ) + + +def test_animate_mode_invalid_type_mode_raises(modal_fields): + """animate_mode raises ValueError for an unsupported type_mode.""" + from ansys.dpf.core import animation + + with pytest.raises(ValueError, match="type_mode 2 is not accepted"): + animation.animate_mode(modal_fields, mode_number=1, type_mode=2, off_screen=True) + + +def test_animate_mode_invalid_mode_number_raises(modal_fields): + """animate_mode raises ValueError when mode_number is absent from the container.""" + from ansys.dpf.core import animation + + with pytest.raises(ValueError, match="mode 999"): + animation.animate_mode(modal_fields, mode_number=999, off_screen=True) diff --git a/tests/test_meshescontainer.py b/tests/test_meshescontainer.py index d8275b2d309..f0493be1def 100644 --- a/tests/test_meshescontainer.py +++ b/tests/test_meshescontainer.py @@ -22,15 +22,24 @@ # -*- coding: utf-8 -*- +from pathlib import Path import weakref import numpy as np import pytest from ansys import dpf -from ansys.dpf.core import MeshesContainer +from ansys.dpf import core as dpf_core +from ansys.dpf.core import MeshesContainer, examples, misc import conftest +if misc.module_exists("pyvista"): + HAS_PYVISTA = True +else: + HAS_PYVISTA = False + +gif_name = "test_mc_animate.gif" + # TO DO: add server type @pytest.fixture() @@ -480,3 +489,197 @@ def test_all_shapes_do_not_mutate_input(elshape_body_mc): original = label_space.copy() method(label_space=label_space) assert label_space == original, f"{method.__name__} mutated the input label_space" + + +# ────────────────────────────────────────────────────────────────────────────── +# Animation tests +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def remove_mc_gif(request): + """Remove the test GIF after a test runs.""" + + def _remove(): + p = Path.cwd() / gif_name + if p.exists(): + p.unlink() + + request.addfinalizer(_remove) + + +@pytest.fixture() +def mat_meshes_container(): + """MeshesContainer split by material label from the multishells model.""" + model = dpf_core.Model(examples.find_multishells_rst()) + mesh = model.metadata.meshed_region + split_mesh_op = dpf_core.operators.mesh.split_mesh(mesh=mesh, property="mat") + return split_mesh_op.eval() + + +@pytest.fixture() +def elshape_meshes_container(): + """MeshesContainer split by element shape label from the multishells model.""" + model = dpf_core.Model(examples.find_multishells_rst()) + mesh = model.metadata.meshed_region + split_mesh_op = dpf_core.operators.mesh.split_mesh(mesh=mesh, property="elshape") + return split_mesh_op.eval() + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_auto_label(mat_meshes_container): + """animate() with the default label="time" raises when the container has no time label; + passing the real label explicitly works.""" + with pytest.raises(ValueError): + mat_meshes_container.animate(off_screen=True) + label = mat_meshes_container.labels[0] + mat_meshes_container.animate(label=label, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_explicit_label(mat_meshes_container): + """animate() with an explicit label parameter.""" + label = mat_meshes_container.labels[0] + mat_meshes_container.animate(label=label, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_elshape_label(elshape_meshes_container): + """animate() works for element-shape-split containers (non-time label).""" + elshape_meshes_container.animate(label="elshape", off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_save_gif(remove_mc_gif, mat_meshes_container): + """animate() saves a valid GIF when save_as is specified.""" + label = mat_meshes_container.labels[0] + mat_meshes_container.animate(label=label, save_as=gif_name, off_screen=True) + assert Path(gif_name).is_file(), "GIF file was not created." + assert Path(gif_name).stat().st_size > 1000, "GIF file is unexpectedly small." + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_with_fields_container(mat_meshes_container): + """animate() colors the mesh when a matching FieldsContainer is provided.""" + label = mat_meshes_container.labels[0] + disp_op = dpf_core.operators.result.displacement( + data_sources=dpf_core.DataSources(examples.find_multishells_rst()), + mesh=mat_meshes_container, + ) + disp_fc = disp_op.outputs.fields_container() + mat_meshes_container.animate(label=label, fields_container=disp_fc, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_scale_factor_list(mat_meshes_container): + """animate() accepts a list of scale factors (one per frame).""" + label = mat_meshes_container.labels[0] + n_frames = len(mat_meshes_container.get_label_scoping(label).ids) + mat_meshes_container.animate(label=label, scale_factor=[1.0] * n_frames, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_bad_label_raises(mat_meshes_container): + """animate() raises ValueError for an unknown label name.""" + with pytest.raises(ValueError, match="not found"): + mat_meshes_container.animate(label="nonexistent_label") + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_no_labels_raises(): + """animate() raises ValueError when the default label 'time' is not in the container.""" + mc = MeshesContainer() + with pytest.raises(ValueError, match="not found"): + mc.animate() + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_accepts_none_time_freq_support(mat_meshes_container): + """animate() accepts ``time_freq_support=None`` for a non-time label.""" + # ``mat_meshes_container`` is not time-labeled, so this test only verifies + # that passing the optional ``time_freq_support`` argument as ``None`` does + # not raise for the container's native label. + label = mat_meshes_container.labels[0] + mat_meshes_container.animate(label=label, time_freq_support=None, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animator_animate_meshes_container_via_workflow(mat_meshes_container): + """Animator.animate with a manually built mesh workflow produces frames without error.""" + from ansys.dpf.core.animator import Animator + + label = mat_meshes_container.labels[0] + label_scoping = mat_meshes_container.get_label_scoping(label) + + # Build the workflow: extract_sub_mc → merge_meshes, label_space registered directly + wf = dpf_core.Workflow() + extract_op = dpf_core.operators.utility.extract_sub_mc(meshes=mat_meshes_container) + wf.set_input_name("label_space", extract_op.inputs.label_space) + merge_op = dpf_core.operators.utility.merge_meshes(meshes1=extract_op.outputs.meshes_container) + wf.set_output_name("to_render", merge_op.outputs.merges_mesh) + wf.add_operators([extract_op, merge_op]) + wf.progress_bar = False + + # Build loop_over field + ids = label_scoping.ids + loop_over = dpf_core.fields_factory.field_from_array(ids.astype(float)) + loop_over.scoping.ids = ids + + anim = Animator(workflow=wf, notebook=False) + anim.animate( + loop_over=loop_over, + output_name="to_render", + input_name="label_space", + label=label, + output_type=dpf_core.types.meshed_region, + off_screen=True, + ) + + +@pytest.fixture() +def time_meshes_container(): + """MeshesContainer with a 'time' label built from the msup transient mesh.""" + model = dpf_core.Model(examples.find_msup_transient()) + mesh = model.metadata.meshed_region + mc = MeshesContainer() + mc.labels = ["time"] + for t_id in [1, 2]: + mc.add_mesh({"time": t_id}, mesh) + return mc + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_deform_by_true_raises(mat_meshes_container): + """animate() raises ValueError when deform_by=True.""" + label = mat_meshes_container.labels[0] + with pytest.raises(ValueError, match="deform_by=True"): + mat_meshes_container.animate(label=label, deform_by=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_save_as_pathlib(remove_mc_gif, mat_meshes_container): + """animate() saves a GIF correctly when save_as is given as a pathlib.Path.""" + label = mat_meshes_container.labels[0] + mat_meshes_container.animate(label=label, save_as=Path(gif_name), off_screen=True) + assert Path(gif_name).is_file(), "GIF file was not created." + assert Path(gif_name).stat().st_size > 1000, "GIF file is unexpectedly small." + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_time_label_with_tfs(time_meshes_container): + """animate() with label='time' and a valid TimeFreqSupport uses freq values in overlay.""" + tfs = dpf_core.TimeFreqSupport() + freq_field = dpf_core.fields_factory.field_from_array(np.array([0.1, 0.2], dtype=np.float64)) + freq_field.scoping.ids = [1, 2] + freq_field.unit = "s" + tfs.time_frequencies = freq_field + time_meshes_container.animate(label="time", time_freq_support=tfs, off_screen=True) + + +@pytest.mark.skipif(not HAS_PYVISTA, reason="Please install pyvista") +def test_animate_meshes_container_empty_time_freq_support_raises(time_meshes_container): + """animate() raises ValueError when time_freq_support has no time_frequencies.""" + tfs = dpf_core.TimeFreqSupport() + # A freshly-created TimeFreqSupport has time_frequencies == None + with pytest.raises(ValueError, match="time_freq_support.time_frequencies"): + time_meshes_container.animate(label="time", time_freq_support=tfs, off_screen=True)