diff --git a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst index 76ce85189f3..e2eeb3da6f3 100644 --- a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/GALLERY_HEADER.rst @@ -31,6 +31,17 @@ componentized, and easily distributable custom operators. +++ Requires DPF 7.1 or above (2024 R1). + .. grid-item-card:: Write a DPF Python plugin for a custom file format + :link: tutorials_custom_operators_and_plugins_python_plugin_for_custom_file_format + :link-type: ref + :text-align: center + :class-header: sd-bg-light sd-text-dark + :class-footer: sd-bg-light sd-text-dark + + This tutorial shows how to implement a complete DPF Python plugin that reads data from a + custom file format, including a streams provider, result info provider, + time-frequency support provider, mesh provider, and result providers. + .. grid-item-card:: Create a DPF plugin with multiple operators :text-align: center :class-header: sd-bg-light sd-text-dark diff --git a/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/python_plugin_for_custom_file_format.py b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/python_plugin_for_custom_file_format.py new file mode 100644 index 00000000000..b2f9ece1895 --- /dev/null +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/python_plugin_for_custom_file_format.py @@ -0,0 +1,491 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# _order: 2 +""" +.. _tutorials_custom_operators_and_plugins_python_plugin_for_custom_file_format: + +Write a DPF Python plugin for a custom file format +==================================================== + +This tutorial shows how to implement a complete DPF Python plugin that reads +data from a custom binary or ASCII result file format. + +The example uses a minimal ASCII format called *MyFormat* (``.myf``) that +stores harmonic analysis results for a single HEX8 element: nodal displacement +(vector, 3 components) and elemental temperature (scalar). Five operator +classes are required to make DPF aware of the format: + +- **streams_provider** — opens the file and wraps it in a + :class:`~ansys.dpf.core.streams_container.StreamsContainer`. +- **result_info_provider** — declares the available result quantities. +- **time_freq_support_provider** — describes the frequency axis. +- **mesh_provider** — builds the :class:`~ansys.dpf.core.meshed_region.MeshedRegion`. +- **result_provider** — reads one result field per frequency set. + +All source files are provided under +:mod:`ansys.dpf.core.examples.python_plugins.my_format_plugin`. +""" + +############################################################################### +# The custom file format +# ---------------------- +# +# The ``.myf`` file is a plain-text format. Its structure is described below. +# A sample file ``cube_harmonic.myf`` ships with this tutorial. +# +# .. code-block:: text +# +# ANALYSIS_TYPE harmonic +# UNIT_SYSTEM m_kg_s +# +# FREQUENCIES +# NUM_FREQS 2 +# 1 100.0 # freq-set id value [Hz] +# 2 200.0 +# +# NODES +# NUM_NODES 8 +# 1 0.0 0.0 0.0 +# ... # id x y z +# +# ELEMENTS +# NUM_ELEMENTS 1 +# 1 HEX8 1 2 3 4 5 6 7 8 # id cell_type node_ids... +# +# RESULT +# name displacement +# location NODAL +# num_components 3 +# FREQ_ID 1 +# 1 dx dy dz # node_id component_values +# ... +# FREQ_ID 2 +# ... +# +# RESULT +# name temperature +# location ELEMENTAL +# num_components 1 +# FREQ_ID 1 +# 1 25.5 # elem_id value +# FREQ_ID 2 +# 1 30.2 +# + +############################################################################### +# Step 1 — Parse the file +# ----------------------- +# +# The parser lives in ``my_format_reader.py``. It exposes a ``read()`` function +# that returns a ``MyFormatModel`` dataclass: node coordinates, element +# connectivity, frequency list, and result data indexed by +# ``{freq_set_id: {entity_id: [values]}}``. +# +# .. code-block:: python +# +# from dataclasses import dataclass, field +# from typing import Dict, List +# +# @dataclass +# class MyFormatResult: +# name: str +# location: str # "NODAL" or "ELEMENTAL" +# num_components: int +# data: Dict[int, Dict[int, List[float]]] # freq_id -> entity_id -> values +# +# @dataclass +# class MyFormatModel: +# analysis_type: str +# unit_system: str +# frequencies: Dict[int, float] # freq_set_id -> Hz value +# node_coords: Dict[int, List[float]] # node_id -> [x, y, z] +# elements: Dict[int, List[int]] # elem_id -> node_ids +# results: List[MyFormatResult] +# +# def read(file_path: str) -> MyFormatModel: +# """Parse a .myf file and return a MyFormatModel.""" +# ... +# + +############################################################################### +# Step 2 — Implement the streams provider +# ---------------------------------------- +# +# DPF looks for a ``{namespace}::stream_provider`` operator (singular) when a +# :class:`~ansys.dpf.core.data_sources.DataSources` with a matching ``key`` is +# passed to :class:`~ansys.dpf.core.model.Model`. The streams provider wraps the +# DataSources in a +# :class:`~ansys.dpf.core.streams_container.StreamsContainer` so that +# downstream operators can access the file path without re-parsing the +# DataSources. +# +# .. code-block:: python +# +# from ansys.dpf.core.custom_operator import CustomOperatorBase +# from ansys.dpf.core.streams_container import StreamsContainer +# from ansys.dpf import core as dpf +# +# class streams_provider(CustomOperatorBase): +# +# @property +# def name(self): +# return "myformat::stream_provider" # singular: stream_provider +# +# def run(self): +# ds: dpf.DataSources = self.get_input(4, dpf.DataSources) +# sc = StreamsContainer(data_sources=ds) +# self.set_output(0, sc) +# self.set_succeeded() +# + +############################################################################### +# Step 3 — Implement the result info provider +# -------------------------------------------- +# +# ``result_info_provider`` reads the result metadata from the file and returns a +# :class:`~ansys.dpf.core.result_info.ResultInfo` object that lists the +# available result quantities (name, location, tensor nature, physical +# dimension). +# +# Operator pins follow the standard DPF convention: pin 3 accepts an optional +# :class:`~ansys.dpf.core.streams_container.StreamsContainer` (preferred when +# already open); pin 4 is the fallback :class:`~ansys.dpf.core.data_sources.DataSources`. +# +# .. code-block:: python +# +# from ansys.dpf.core.available_result import Homogeneity +# from ansys.dpf.core.result_info import analysis_types, physics_types +# +# class result_info_provider(CustomOperatorBase): +# +# @property +# def name(self): +# return "myformat::myformat::result_info_provider" +# +# def run(self): +# file_path = _get_file_path(self) # helper: pin 3 or pin 4 +# model = reader.read(file_path) +# +# result_info = dpf.ResultInfo( +# analysis_type=analysis_types.harmonic, +# physics_type=physics_types.mechanical, +# ) +# for res in model.results: +# result_info.add_result( +# operator_name=f"myformat::{res.name}", +# scripting_name=res.name, +# homogeneity=Homogeneity.displacement, # per result +# location=dpf.locations.nodal, # per result +# nature=dpf.natures.vector, # per result +# dimensions=[res.num_components], # [3] for vector, [1] for scalar +# description=f"MyFormat result: {res.name}", +# ) +# +# self.set_output(0, result_info) +# self.set_succeeded() +# + +############################################################################### +# Step 4 — Implement the time-frequency support provider +# ------------------------------------------------------- +# +# ``time_freq_support_provider`` builds a +# :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` from the +# ``FREQUENCIES`` block. All frequency values are placed in a single harmonic +# step (step id 1) using ``append_step``. +# +# .. code-block:: python +# +# class time_freq_support_provider(CustomOperatorBase): +# +# @property +# def name(self): +# return "myformat::myformat::time_freq_support_provider" +# +# def run(self): +# file_path = _get_file_path(self) +# model = reader.read(file_path) +# +# tfs = dpf.TimeFreqSupport() +# freq_values = list(model.frequencies.values()) # plain list of floats +# tfs.append_step(step_id=1, step_time_frequencies=freq_values) +# +# self.set_output(0, tfs) +# self.set_succeeded() +# + +############################################################################### +# Step 5 — Implement the mesh provider +# ------------------------------------- +# +# ``mesh_provider`` builds a +# :class:`~ansys.dpf.core.meshed_region.MeshedRegion` by iterating over the +# node and element data from the parsed model. +# +# .. code-block:: python +# +# class mesh_provider(CustomOperatorBase): +# +# @property +# def name(self): +# return "myformat::myformat::mesh_provider" +# +# def run(self): +# file_path = _get_file_path(self) +# model = reader.read(file_path) +# +# mesh = dpf.MeshedRegion() +# mesh.unit = "m" +# +# # Add nodes (id, [x, y, z]) +# for node_id, coords in model.node_coords.items(): +# mesh.nodes.add_node(node_id, coords) +# +# # Add elements: map 1-based node ids to 0-based node indices +# node_ids = list(model.node_coords.keys()) +# node_index = {nid: idx for idx, nid in enumerate(node_ids)} +# for elem_id, connectivity in model.elements.items(): +# indices = [node_index[nid] for nid in connectivity] +# mesh.elements.add_solid_element(elem_id, indices) +# +# self.set_output(0, mesh) +# self.set_succeeded() +# + +############################################################################### +# Step 6 — Implement the result providers +# ---------------------------------------- +# +# Result operators follow a common base class ``_result_provider``. The base +# class handles reading the file and building the +# :class:`~ansys.dpf.core.fields_container.FieldsContainer`. Each concrete +# subclass only needs to declare its :attr:`name` property and a +# ``_result_name`` class attribute. +# +# The :class:`~ansys.dpf.core.fields_container.FieldsContainer` uses +# the label ``"time"`` to index fields by frequency-set id. +# +# .. code-block:: python +# +# class _result_provider(CustomOperatorBase): +# _result_name: str = "" # override in subclass +# +# def run(self): +# file_path = _get_file_path(self) +# try: +# time_scoping = self.get_input(0, int) # optional pin 0 +# except DPFServerException: +# time_scoping = None +# +# model = reader.read(file_path) +# fc = _build_fields_container(model, self._result_name, time_scoping) +# self.set_output(0, fc) +# self.set_succeeded() +# +# +# class displacement_provider(_result_provider): +# _result_name = "displacement" +# +# @property +# def name(self): +# return "myformat::myformat::displacement" +# +# +# class temperature_provider(_result_provider): +# _result_name = "temperature" +# +# @property +# def name(self): +# return "myformat::myformat::temperature" +# + +############################################################################### +# Step 7 — Declare the plugin entry-point +# ---------------------------------------- +# +# DPF calls a function named ``load_operators(*args)`` when loading the plugin. +# It must call :func:`~ansys.dpf.core.custom_operator.record_operator` for +# every operator class defined in the plugin. +# +# The ``name`` argument passed to +# :func:`~ansys.dpf.core.core.load_library` (for example +# ``"py_my_format_plugin"``) tells DPF which Python file to import (here +# ``my_format_plugin.py``), so the entry-point file name and the ``name`` +# argument must be consistent. +# +# .. code-block:: python +# +# from ansys.dpf.core.custom_operator import record_operator +# from streams_provider import streams_provider +# from result_info_provider import result_info_provider +# from time_freq_support_provider import time_freq_support_provider +# from mesh_info_provider import mesh_info_provider +# from mesh_provider import mesh_provider +# from result_provider import displacement_provider, temperature_provider +# +# def load_operators(*args): +# record_operator(streams_provider, *args) +# record_operator(result_info_provider, *args) +# record_operator(time_freq_support_provider, *args) +# record_operator(mesh_info_provider, *args) +# record_operator(mesh_provider, *args) +# record_operator(displacement_provider, *args) +# record_operator(temperature_provider, *args) +# + +############################################################################### +# Start a DPF gRPC server +# ------------------------ +# +# Python plugins are only supported on gRPC servers. Start a dedicated local +# server to avoid interfering with any globally configured server. + +import ansys.dpf.core as dpf + +server = dpf.start_local_server(config=dpf.AvailableServerConfigs.GrpcServer, as_global=False) + +############################################################################### +# Load the plugin +# ---------------- +# +# :func:`~ansys.dpf.core.core.load_library` registers all operators declared in +# ``load_operators`` into the server registry. +# +# - The first argument is the **directory** containing the plugin source files. +# - The second argument ``"py_"`` tells DPF to import the Python file +# ``.py`` from that directory (here ``my_format_plugin.py``). +# - The third argument is the entry-point function name inside that file. + +from ansys.dpf.core.examples.python_plugins import my_format_plugin + +dpf.load_library( + filename=my_format_plugin.plugin_dir, + name="py_my_format_plugin", + symbol="load_operators", + server=server, + generate_operators=False, +) + +available = dpf.dpf_operator.available_operator_names(server=server) +myformat_ops = sorted(op for op in available if op.startswith("myformat")) +print("Registered myformat operators:", myformat_ops) + +############################################################################### +# Open the result file with :class:`~ansys.dpf.core.model.Model` +# --------------------------------------------------------------- +# +# Create a :class:`~ansys.dpf.core.data_sources.DataSources` pointing to the +# sample ``.myf`` file. The ``key`` argument identifies the result type. +# :meth:`~ansys.dpf.core.data_sources.DataSources.register_namespace` maps +# that key to the operator namespace, so DPF's generic dispatcher operators +# (``ResultInfoProvider``, ``TimeFreqSupportProvider``, ``mesh_provider``, …) +# know to look for ``myformat::myformat::result_info_provider`` etc. +# +# .. note:: +# +# The naming convention for all providers **except** ``stream_provider`` is +# ``{namespace}::{key}::{operator_name}`` (two-level prefix). This mirrors +# how C++ plugins register their operators — for example, the CGNS plugin +# uses ``cgns::cgns::result_info_provider``. Only ``stream_provider`` uses a +# single-level name (``myformat::stream_provider``) because the C++ generic +# stream dispatcher looks it up differently. + +my_ds = dpf.DataSources(server=server) +my_ds.set_result_file_path(str(my_format_plugin.sample_file), key="myformat") +my_ds.register_namespace(result_key="myformat", namespace="myformat") + +my_model = dpf.Model(data_sources=my_ds, server=server) +print(my_model) + +############################################################################### +# Inspect the result metadata and time-frequency support +# ------------------------------------------------------- +# +# :attr:`~ansys.dpf.core.model.Model.metadata` exposes the +# :class:`~ansys.dpf.core.result_info.ResultInfo` and the +# :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` through the +# standard DPF metadata pipeline. + +result_info = my_model.metadata.result_info +print("Analysis type :", result_info.analysis_type) +print("Number of results:", result_info.n_results) +for i in range(result_info.n_results): + r = result_info.available_results[i] + print(f" [{i}] name={r.name!r:20s} location={r.native_location!r}") + +tfs = my_model.metadata.time_freq_support +print("Number of frequency sets:", tfs.n_sets) +print("Frequencies [Hz] :", tfs.time_frequencies.data) + +############################################################################### +# Inspect the mesh +# ----------------- +# +# :attr:`~ansys.dpf.core.model.Model.metadata` also provides the +# :class:`~ansys.dpf.core.meshed_region.MeshedRegion` through the +# ``meshed_region`` property. + +mesh = my_model.metadata.meshed_region +print("Number of nodes :", mesh.nodes.n_nodes) +print("Number of elements:", mesh.elements.n_elements) + +############################################################################### +# Extract displacement results +# ----------------------------- +# +# Result operators are called by their full ``{namespace}::{key}::{name}`` +# scripting name. Connect the +# :class:`~ansys.dpf.core.data_sources.DataSources` on pin 4 and request the +# output :class:`~ansys.dpf.core.fields_container.FieldsContainer`. +# Use :meth:`~ansys.dpf.core.fields_container.FieldsContainer.get_label_space` +# to retrieve the frequency-set label of each field. + +disp_op = dpf.Operator("myformat::myformat::displacement", server=server) +disp_op.connect(4, my_ds) +disp_fc = disp_op.get_output(0, dpf.FieldsContainer) + +print(f"Displacement: {len(disp_fc)} fields") +for i in range(len(disp_fc)): + label = disp_fc.get_label_space(i) + field = disp_fc[i] + print( + f" freq_set={label['time']:d} " + f"shape=({len(field.scoping)}, {field.component_count}) " + f"first node: {field.get_entity_data(0)}" + ) + +############################################################################### +# Extract temperature results +# ---------------------------- +# +# The same pattern applies for the elemental scalar temperature result. + +temp_op = dpf.Operator("myformat::myformat::temperature", server=server) +temp_op.connect(4, my_ds) +temp_fc = temp_op.get_output(0, dpf.FieldsContainer) + +print(f"Temperature: {len(temp_fc)} fields") +for i in range(len(temp_fc)): + label = temp_fc.get_label_space(i) + field = temp_fc[i] + print(f" freq_set={label['time']:d} element values = {field.data.flatten()}") diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/__init__.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/__init__.py new file mode 100644 index 00000000000..7738e0a69b6 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/__init__.py @@ -0,0 +1,40 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""MyFormat DPF plugin package. + +Exposes ``plugin_dir`` so that the tutorial can locate the plugin folder +without hard-coding a path. + +Example +------- +>>> from ansys.dpf.core.examples.python_plugins import my_format_plugin +>>> plugin_dir = my_format_plugin.plugin_dir +""" + +from pathlib import Path + +#: Absolute path to the directory containing the plugin source files. +plugin_dir: Path = Path(__file__).parent + +#: Absolute path to the bundled sample ``.myf`` result file. +sample_file: Path = plugin_dir / "cube_harmonic.myf" diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/cube_harmonic.myf b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/cube_harmonic.myf new file mode 100644 index 00000000000..d365eeb26f5 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/cube_harmonic.myf @@ -0,0 +1,58 @@ +# MyFormat Simulation Results v1.0 +# A unit cube (one HEX8 element, 8 nodes) under harmonic loading. +# Lines starting with '#' are comments. Blank lines are ignored. + +ANALYSIS_TYPE harmonic +UNIT_SYSTEM m_kg_s + +FREQUENCIES + NUM_FREQS 2 + 1 100.0 + 2 200.0 + +NODES + NUM_NODES 8 + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 1.0 1.0 0.0 + 4 0.0 1.0 0.0 + 5 0.0 0.0 1.0 + 6 1.0 0.0 1.0 + 7 1.0 1.0 1.0 + 8 0.0 1.0 1.0 + +ELEMENTS + NUM_ELEMENTS 1 + 1 HEX8 1 2 3 4 5 6 7 8 + +RESULT + name displacement + location NODAL + num_components 3 + FREQ_ID 1 + 1 1.0e-3 2.0e-3 0.5e-3 + 2 1.2e-3 1.8e-3 0.6e-3 + 3 1.1e-3 1.9e-3 0.55e-3 + 4 0.9e-3 2.1e-3 0.45e-3 + 5 2.0e-3 1.5e-3 1.0e-3 + 6 2.2e-3 1.3e-3 1.1e-3 + 7 2.1e-3 1.4e-3 1.05e-3 + 8 1.8e-3 1.6e-3 0.95e-3 + FREQ_ID 2 + 1 2.0e-3 4.0e-3 1.0e-3 + 2 2.4e-3 3.6e-3 1.2e-3 + 3 2.2e-3 3.8e-3 1.1e-3 + 4 1.8e-3 4.2e-3 0.9e-3 + 5 4.0e-3 3.0e-3 2.0e-3 + 6 4.4e-3 2.6e-3 2.2e-3 + 7 4.2e-3 2.8e-3 2.1e-3 + 8 3.6e-3 3.2e-3 1.9e-3 + +RESULT + name temperature + location ELEMENTAL + num_components 1 + FREQ_ID 1 + 1 25.5 + FREQ_ID 2 + 1 30.2 diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_info_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_info_provider.py new file mode 100644 index 00000000000..33cac5caf5c --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_info_provider.py @@ -0,0 +1,165 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""mesh_info_provider operator for MyFormat files. + +Returns a :class:`~ansys.dpf.core.generic_data_container.GenericDataContainer` +with the mesh metadata (node count, element count, element types, zone info). +DPF wraps this container into a :class:`~ansys.dpf.core.mesh_info.MeshInfo` +object on the client side. +""" + +import my_format_reader as reader +from result_info_provider import _get_file_path + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) + +# Map MyFormat element type strings to DPF element_types enum values. +_ELEMENT_TYPE_MAP = { + "HEX8": dpf.element_types.Hex8, + "HEX20": dpf.element_types.Hex20, + "TET4": dpf.element_types.Tet4, + "TET10": dpf.element_types.Tet10, + "QUAD4": dpf.element_types.Quad4, + "TRI3": dpf.element_types.Tri3, + "WEDGE6": dpf.element_types.Wedge6, + "PYRAMID5": dpf.element_types.Pyramid5, +} + + +class mesh_info_provider(CustomOperatorBase): + """Return mesh metadata for a MyFormat file. + + Outputs a :class:`~ansys.dpf.core.generic_data_container.GenericDataContainer` + with the standard ``mesh_info`` schema consumed by + DPF's built-in ``mesh_info_provider`` routing. + + Inputs + ------ + pin 3 : StreamsContainer, optional + Streams container returned by the streams_provider. + pin 4 : DataSources + DataSources with a path to a ``.myf`` file (used when pin 3 is absent). + + Outputs + ------- + pin 0 : GenericDataContainer (mesh_info) + Mesh metadata including node count, element count, zone names, and + available element types. + """ + + def run(self): + """Run the operator.""" + file_path = _get_file_path(self) + + model = reader.read(file_path) + gdc = _build_mesh_info(model) + + self.set_output(0, gdc) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification("Reads mesh metadata from a MyFormat (.myf) result file.") + spec.inputs = { + 3: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=True, + document="Streams container (optional); takes priority over pin 4.", + ), + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=True, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="mesh_info", + type_names=dpf.GenericDataContainer, + optional=False, + document="GenericDataContainer with mesh metadata.", + name_derived_class="mesh_info", + ), + } + spec.properties = SpecificationProperties( + user_name="MyFormat mesh info provider", category="myformat" + ) + return spec + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::mesh_info_provider" + + +def _build_mesh_info(model: reader.MyFormatModel) -> dpf.GenericDataContainer: + """Build a GenericDataContainer with the ``mesh_info`` schema.""" + num_nodes = len(model.node_coords) + num_elements = len(model.elements) + + # Collect unique element types present in the file. + elem_type_ids = [] + for elem in model.elements.values(): + et = _ELEMENT_TYPE_MAP.get(elem["type"].upper(), dpf.element_types.General) + if et.value not in elem_type_ids: + elem_type_ids.append(et.value) + + # Build zone information. The MyFormat file has a single "body" zone. + zone_id = 1 + zone_names = dpf.StringField(nentities=1) + zone_names.append(data=["body_1"], scopingid=zone_id) + zone_names.location = dpf.locations.zone + + zone_scoping = dpf.Scoping(location="zone", ids=[zone_id]) + cell_zone_scoping = dpf.Scoping(location="zone", ids=[zone_id]) + face_zone_scoping = dpf.Scoping(location="zone", ids=[]) + + cell_zone_names = dpf.StringField(nentities=1) + cell_zone_names.append(data=["body_1"], scopingid=zone_id) + cell_zone_names.location = dpf.locations.zone + + face_zone_names = dpf.StringField() + face_zone_names.location = dpf.locations.zone + + available_elem_types = dpf.Scoping(location=dpf.locations.overall, ids=elem_type_ids) + + gdc = dpf.GenericDataContainer() + gdc.set_property(property_name="num_nodes", prop=num_nodes) + gdc.set_property(property_name="num_elements", prop=num_elements) + gdc.set_property(property_name="zone_names", prop=zone_names) + gdc.set_property(property_name="zone_scoping", prop=zone_scoping) + gdc.set_property(property_name="available_elem_types", prop=available_elem_types) + gdc.set_property(property_name="cell_zone_names", prop=cell_zone_names) + gdc.set_property(property_name="cell_zone_scoping", prop=cell_zone_scoping) + gdc.set_property(property_name="face_zone_names", prop=face_zone_names) + gdc.set_property(property_name="face_zone_scoping", prop=face_zone_scoping) + return gdc diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_provider.py new file mode 100644 index 00000000000..0a89b8188c6 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_provider.py @@ -0,0 +1,138 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""mesh_provider operator for MyFormat files. + +Returns a :class:`~ansys.dpf.core.meshed_region.MeshedRegion` built from +the ``NODES`` and ``ELEMENTS`` blocks of the file. +""" + +import my_format_reader as reader +from result_info_provider import _get_file_path + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) + +# Map MyFormat element type strings to DPF add_*_element helpers. +_ADD_ELEMENT = { + "HEX8": "add_solid_element", + "HEX20": "add_solid_element", + "TET4": "add_solid_element", + "TET10": "add_solid_element", + "QUAD4": "add_shell_element", + "TRI3": "add_shell_element", + "WEDGE6": "add_solid_element", + "PYRAMID5": "add_solid_element", +} + + +class mesh_provider(CustomOperatorBase): + """Return a :class:`~ansys.dpf.core.meshed_region.MeshedRegion` for a MyFormat file. + + Inputs + ------ + pin 3 : StreamsContainer, optional + Streams container returned by the streams_provider. + pin 4 : DataSources + DataSources with a path to a ``.myf`` file (used when pin 3 is absent). + + Outputs + ------- + pin 0 : MeshedRegion + The mesh with nodes and elements read from the file. + """ + + def run(self): + """Run the operator.""" + file_path = _get_file_path(self) + + model = reader.read(file_path) + mesh = _build_mesh(model) + + self.set_output(0, mesh) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification("Reads the mesh from a MyFormat (.myf) result file.") + spec.inputs = { + 3: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=True, + document="Streams container (optional); takes priority over pin 4.", + ), + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=True, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="mesh", + type_names=dpf.MeshedRegion, + optional=False, + document="MeshedRegion with nodes and elements from the file.", + ), + } + spec.properties = SpecificationProperties( + user_name="MyFormat mesh provider", category="myformat" + ) + return spec + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::mesh_provider" + + +def _build_mesh(model: reader.MyFormatModel) -> dpf.MeshedRegion: + """Build a :class:`~ansys.dpf.core.meshed_region.MeshedRegion` from a parsed model.""" + mesh = dpf.MeshedRegion() + mesh.unit = "m" + + # Add nodes. + for node_id, coords in model.node_coords.items(): + mesh.nodes.add_node(node_id, coords) + + # Map node IDs to 0-based indices (required by add_*_element connectivity). + node_id_list = list(model.node_coords.keys()) + node_id_to_index = {nid: idx for idx, nid in enumerate(node_id_list)} + + # Add elements. + for elem_id, elem in model.elements.items(): + elem_type = elem["type"].upper() + connectivity_indices = [node_id_to_index[nid] for nid in elem["connectivity"]] + + add_method_name = _ADD_ELEMENT.get(elem_type, "add_solid_element") + add_method = getattr(mesh.elements, add_method_name) + add_method(elem_id, connectivity_indices) + + return mesh diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_plugin.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_plugin.py new file mode 100644 index 00000000000..6b6ff0bae04 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_plugin.py @@ -0,0 +1,48 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Entry point for the MyFormat DPF plugin. + +DPF calls ``load_operators`` when the plugin is loaded. Every custom operator +class must be registered here with +:func:`~ansys.dpf.core.custom_operator.record_operator`. +""" + +from mesh_info_provider import mesh_info_provider +from mesh_provider import mesh_provider +from result_info_provider import result_info_provider +from result_provider import displacement_provider, temperature_provider +from streams_provider import streams_provider +from time_freq_support_provider import time_freq_support_provider + +from ansys.dpf.core.custom_operator import record_operator + + +def load_operators(*args): + """Call with the DPF server to register all operators of the plugin.""" + record_operator(streams_provider, *args) + record_operator(result_info_provider, *args) + record_operator(time_freq_support_provider, *args) + record_operator(mesh_info_provider, *args) + record_operator(mesh_provider, *args) + record_operator(displacement_provider, *args) + record_operator(temperature_provider, *args) diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_reader.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_reader.py new file mode 100644 index 00000000000..f4e6fdfb3b0 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_reader.py @@ -0,0 +1,231 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Utility module to parse files in the MyFormat ASCII format (.myf).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List + + +@dataclass +class MyFormatResult: + """Data for one result quantity across all frequencies. + + Parameters + ---------- + name : str + Result name (e.g., ``"displacement"``, ``"temperature"``). + location : str + ``"NODAL"`` or ``"ELEMENTAL"``. + num_components : int + Number of components per entity (e.g., 3 for displacement, 1 for temperature). + data : dict + Mapping ``{freq_id: {entity_id: [v1, v2, ...]}}`` + """ + + name: str + location: str + num_components: int + data: Dict[int, Dict[int, List[float]]] = field(default_factory=dict) + + +@dataclass +class MyFormatModel: + """In-memory representation of a parsed ``.myf`` file. + + Parameters + ---------- + analysis_type : str + Analysis type string read from the ``ANALYSIS_TYPE`` section. + unit_system : str + Unit system string read from the ``UNIT_SYSTEM`` section. + frequencies : dict + Mapping ``{freq_id: frequency_value}`` for each frequency set. + node_coords : dict + Mapping ``{node_id: [x, y, z]}`` for each node. + elements : dict + Mapping ``{elem_id: {"type": str, "connectivity": [node_ids]}}``. + results : list + :class:`MyFormatResult` objects, one per ``RESULT`` block in the file. + """ + + analysis_type: str + unit_system: str + frequencies: Dict[int, float] + node_coords: Dict[int, List[float]] + elements: Dict[int, dict] + results: List[MyFormatResult] + + +def read(file_path: str | Path) -> MyFormatModel: + """Parse a ``.myf`` file and return a :class:`MyFormatModel`. + + Parameters + ---------- + file_path : str or pathlib.Path + Path to the ``.myf`` file to read. + + Returns + ------- + MyFormatModel + In-memory representation of the file contents. + + Raises + ------ + FileNotFoundError + If *file_path* does not point to an existing file. + ValueError + If the file is malformed. + """ + path = Path(file_path) + lines = path.read_text(encoding="utf-8").splitlines() + + def _tokens(line: str) -> List[str]: + return line.strip().split() + + analysis_type = "unknown" + unit_system = "m_kg_s" + frequencies: Dict[int, float] = {} + node_coords: Dict[int, List[float]] = {} + elements: Dict[int, dict] = {} + results: List[MyFormatResult] = [] + + i = 0 + while i < len(lines): + line = lines[i].strip() + + # Skip comments and blank lines. + if not line or line.startswith("#"): + i += 1 + continue + + tokens = _tokens(line) + keyword = tokens[0].upper() + + if keyword == "ANALYSIS_TYPE": + analysis_type = tokens[1] + + elif keyword == "UNIT_SYSTEM": + unit_system = tokens[1] + + elif keyword == "FREQUENCIES": + # Next non-blank/non-comment line: NUM_FREQS N + i += 1 + while lines[i].strip().startswith("#") or not lines[i].strip(): + i += 1 + num_freqs = int(_tokens(lines[i])[1]) + for _ in range(num_freqs): + i += 1 + parts = _tokens(lines[i]) + frequencies[int(parts[0])] = float(parts[1]) + + elif keyword == "NODES": + i += 1 + while lines[i].strip().startswith("#") or not lines[i].strip(): + i += 1 + num_nodes = int(_tokens(lines[i])[1]) + for _ in range(num_nodes): + i += 1 + parts = _tokens(lines[i]) + node_coords[int(parts[0])] = [float(parts[1]), float(parts[2]), float(parts[3])] + + elif keyword == "ELEMENTS": + i += 1 + while lines[i].strip().startswith("#") or not lines[i].strip(): + i += 1 + num_elems = int(_tokens(lines[i])[1]) + for _ in range(num_elems): + i += 1 + parts = _tokens(lines[i]) + elem_id = int(parts[0]) + elem_type = parts[1] + connectivity = [int(n) for n in parts[2:]] + elements[elem_id] = {"type": elem_type, "connectivity": connectivity} + + elif keyword == "RESULT": + result_name = None + location = None + num_components = None + result_data: Dict[int, Dict[int, List[float]]] = {} + current_freq_id = None + + while True: + i += 1 + if i >= len(lines): + break + inner = lines[i].strip() + if not inner or inner.startswith("#"): + continue + + inner_tokens = _tokens(inner) + inner_key = inner_tokens[0].upper() + + if inner_key == "NAME": + result_name = inner_tokens[1] + elif inner_key == "LOCATION": + location = inner_tokens[1].upper() + elif inner_key == "NUM_COMPONENTS": + num_components = int(inner_tokens[1]) + elif inner_key == "FREQ_ID": + current_freq_id = int(inner_tokens[1]) + result_data[current_freq_id] = {} + elif inner_key in { + "RESULT", + "NODES", + "ELEMENTS", + "FREQUENCIES", + "ANALYSIS_TYPE", + "UNIT_SYSTEM", + }: + # A new top-level block starts; step back so the outer loop + # processes this line. + i -= 1 + break + else: + # Entity value line: id v1 [v2 v3 ...] + if current_freq_id is not None: + entity_id = int(inner_tokens[0]) + values = [float(v) for v in inner_tokens[1:]] + result_data[current_freq_id][entity_id] = values + + results.append( + MyFormatResult( + name=result_name, + location=location, + num_components=num_components, + data=result_data, + ) + ) + + i += 1 + + return MyFormatModel( + analysis_type=analysis_type, + unit_system=unit_system, + frequencies=frequencies, + node_coords=node_coords, + elements=elements, + results=results, + ) diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_info_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_info_provider.py new file mode 100644 index 00000000000..6b50e2056f6 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_info_provider.py @@ -0,0 +1,173 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""result_info_provider operator for MyFormat files. + +Returns a :class:`~ansys.dpf.core.result_info.ResultInfo` describing the +available result quantities (name, location, nature, homogeneity) and the +analysis type. +""" + +import my_format_reader as reader + +from ansys.dpf import core as dpf +from ansys.dpf.core.available_result import Homogeneity +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) +from ansys.dpf.core.result_info import analysis_types, physics_types + +# Map MyFormat location strings to DPF location constants. +_LOCATION_MAP = { + "NODAL": dpf.locations.nodal, + "ELEMENTAL": dpf.locations.elemental, +} + +# Map number of components to DPF natures. +_NATURE_MAP = { + 1: dpf.natures.scalar, + 3: dpf.natures.vector, + 6: dpf.natures.symmatrix, +} + +# Map result names to DPF Homogeneity values. +_HOMOGENEITY_MAP = { + "displacement": Homogeneity.displacement, + "temperature": Homogeneity.temperature, +} + + +class result_info_provider(CustomOperatorBase): + """Return a :class:`~ansys.dpf.core.result_info.ResultInfo` for a MyFormat file. + + Inputs + ------ + pin 3 : StreamsContainer, optional + Streams container returned by the streams_provider. + pin 4 : DataSources + DataSources with a path to a ``.myf`` file (used when pin 3 is absent). + + Outputs + ------- + pin 0 : ResultInfo + Metadata describing the available result quantities. + """ + + def run(self): + """Run the operator.""" + file_path = _get_file_path(self) + + model = reader.read(file_path) + result_info = _build_result_info(model) + + self.set_output(0, result_info) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification("Reads result metadata from a MyFormat (.myf) result file.") + spec.inputs = { + 3: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=True, + document="Streams container (optional); takes priority over pin 4.", + ), + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=True, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="result_info", + type_names=dpf.ResultInfo, + optional=False, + document="ResultInfo describing available results.", + ), + } + spec.properties = SpecificationProperties( + user_name="MyFormat result info provider", category="myformat" + ) + return spec + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::result_info_provider" + + +# --------------------------------------------------------------------------- +# Helpers shared across operator modules +# --------------------------------------------------------------------------- + + +def _get_file_path(operator: CustomOperatorBase) -> str: + """Return the result file path from pin 3 (StreamsContainer) or pin 4 (DataSources).""" + try: + sc: dpf.StreamsContainer = operator.get_input(3, dpf.StreamsContainer) + result_files = sc.datasources.result_files + if result_files: + return result_files[0] + except Exception: + pass + ds: dpf.DataSources = operator.get_input(4, dpf.DataSources) + return ds.result_files[0] + + +def _build_result_info(model: reader.MyFormatModel) -> dpf.ResultInfo: + """Build a :class:`~ansys.dpf.core.result_info.ResultInfo` from a parsed model.""" + analysis_type_map = { + "static": analysis_types.static, + "harmonic": analysis_types.harmonic, + "transient": analysis_types.transient, + "modal": analysis_types.modal, + } + a_type = analysis_type_map.get(model.analysis_type.lower(), analysis_types.static) + + result_info = dpf.ResultInfo( + analysis_type=a_type, + physics_type=physics_types.mechanical, + ) + + for res in model.results: + homogeneity = _HOMOGENEITY_MAP.get(res.name.lower(), Homogeneity.dimensionless) + location = _LOCATION_MAP.get(res.location, dpf.locations.nodal) + nature = _NATURE_MAP.get(res.num_components, dpf.natures.scalar) + + result_info.add_result( + operator_name=f"myformat::myformat::{res.name}", + scripting_name=res.name, + homogeneity=homogeneity, + location=location, + nature=nature, + dimensions=[res.num_components], + description=f"MyFormat result: {res.name}", + ) + + return result_info diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_provider.py new file mode 100644 index 00000000000..ee91fa38f10 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_provider.py @@ -0,0 +1,237 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Result provider operators for MyFormat files. + +Two concrete result operators are provided: + +- :class:`displacement_provider` — returns a nodal displacement + :class:`~ansys.dpf.core.fields_container.FieldsContainer`. +- :class:`temperature_provider` — returns an elemental temperature + :class:`~ansys.dpf.core.fields_container.FieldsContainer`; + +Both inherit from the base :class:`_result_provider` class, which handles +reading the file and building the FieldsContainer. Only the ``name`` property +and constructor arguments differ between the two. + +Result operators follow the DPF pin convention for result providers: + +- Pin 0 : ``time_scoping`` (optional ``int``) — 1-based frequency-set index to + extract. When absent all frequency sets are returned. +- Pin 3 : ``streams_container`` (optional) — takes priority over pin 4. +- Pin 4 : ``data_sources`` — fallback when pin 3 is absent. +- Output pin 0 : :class:`~ansys.dpf.core.fields_container.FieldsContainer`. +""" + +import my_format_reader as reader +from result_info_provider import _get_file_path + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) +from ansys.dpf.gate.errors import DPFServerException + + +class _result_provider(CustomOperatorBase): + """Base class for MyFormat result providers.""" + + # Subclasses set this to the result name as it appears in the .myf file. + _result_name: str = "" + + def run(self): + """Run the operator.""" + file_path = _get_file_path(self) + + # Optional: restrict to a single frequency set. + try: + time_scoping: int = self.get_input(0, int) + except DPFServerException: + time_scoping = None + + model = reader.read(file_path) + fc = _build_fields_container(model, self._result_name, time_scoping) + + self.set_output(0, fc) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification( + f"Reads the '{self._result_name}' result from a MyFormat (.myf) file." + ) + spec.inputs = { + 0: PinSpecification( + name="time_scoping", + type_names=int, + optional=True, + document="1-based index of the frequency set to extract. " + "All sets are returned when absent.", + ), + 3: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=True, + document="Streams container (optional); takes priority over pin 4.", + ), + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=True, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="fields_container", + type_names=dpf.FieldsContainer, + optional=False, + document="FieldsContainer with one Field per frequency set.", + ), + } + spec.properties = SpecificationProperties( + user_name=f"MyFormat {self._result_name} provider", category="myformat" + ) + return spec + + +class displacement_provider(_result_provider): + """Return nodal displacement results from a MyFormat file. + + Each field in the output :class:`~ansys.dpf.core.fields_container.FieldsContainer` + corresponds to one frequency set (label ``"time"``). + + Inputs / Outputs + ---------------- + See :class:`_result_provider` for the full pin description. + """ + + _result_name = "displacement" + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::displacement" + + +class temperature_provider(_result_provider): + """Return elemental temperature results from a MyFormat file. + + Each field in the output :class:`~ansys.dpf.core.fields_container.FieldsContainer` + corresponds to one frequency set (label ``"time"``). + + Inputs / Outputs + ---------------- + See :class:`_result_provider` for the full pin description. + """ + + _result_name = "temperature" + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::temperature" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _build_fields_container( + model: reader.MyFormatModel, + result_name: str, + time_scoping: int | None, +) -> dpf.FieldsContainer: + """Build a FieldsContainer for *result_name* from a parsed model. + + Parameters + ---------- + model : MyFormatModel + Parsed representation of the .myf file. + result_name : str + Result name to extract (e.g., ``"displacement"``). + time_scoping : int or None + 1-based index of the frequency set to return, or ``None`` for all sets. + + Returns + ------- + FieldsContainer + One :class:`~ansys.dpf.core.field.Field` per requested frequency set, + labelled with ``"time"``. + """ + # Find the result entry. + result_entry = next((r for r in model.results if r.name.lower() == result_name.lower()), None) + if result_entry is None: + raise ValueError( + f"Result '{result_name}' not found in the file. " + f"Available results: {[r.name for r in model.results]}" + ) + + location = dpf.locations.nodal if result_entry.location == "NODAL" else dpf.locations.elemental + num_comp = result_entry.num_components + + fc = dpf.FieldsContainer() + fc.add_label("time") + + # Determine which frequency sets to return. + freq_ids = list(result_entry.data.keys()) + if time_scoping is not None: + if time_scoping not in freq_ids: + raise ValueError( + f"Requested frequency set id {time_scoping} not found. " + f"Available ids: {freq_ids}" + ) + freq_ids = [time_scoping] + + for freq_id in freq_ids: + entity_data = result_entry.data[freq_id] + entity_ids = list(entity_data.keys()) + + scoping = dpf.Scoping(location=location, ids=entity_ids) + + if num_comp == 1: + field = dpf.Field( + nature=dpf.natures.scalar, location=location, nentities=len(entity_ids) + ) + else: + field = dpf.fields_factory.create_vector_field( + num_entities=len(entity_ids), + num_comp=num_comp, + location=location, + ) + + flat_data = [] + for eid in entity_ids: + flat_data.extend(entity_data[eid]) + + field.scoping = scoping + field.data = flat_data + field.name = result_name + + fc.add_field(label_space={"time": freq_id}, field=field) + + return fc diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/streams_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/streams_provider.py new file mode 100644 index 00000000000..d3ecafe5374 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/streams_provider.py @@ -0,0 +1,126 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""streams_provider operator for MyFormat files. + +The streams_provider is the entry point for DPF's result-file pipeline. +It receives a :class:`~ansys.dpf.core.data_sources.DataSources` and returns a +:class:`~ansys.dpf.core.streams_container.StreamsContainer` that wraps the open +file handle. Downstream operators (result_info_provider, mesh_provider, etc.) +can receive this container on their pin 3 instead of re-opening the file. + +DPF discovers this operator via its namespaced name +``"myformat::stream_provider"``. When a +:class:`~ansys.dpf.core.data_sources.DataSources` whose result key is +``"myformat"`` is passed to a :class:`~ansys.dpf.core.model.Model`, DPF calls +this operator automatically. +""" + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) +from ansys.dpf.core.streams_container import StreamsContainer + + +def _make_streams_container(ds: dpf.DataSources) -> StreamsContainer: + """Create a StreamsContainer from a DataSources. + + Compatible with both the new pydpf-core API (``data_sources`` keyword) and + the old API found on standalone DPF servers where ``StreamsContainer`` does + not yet accept a ``data_sources`` argument. + """ + try: + return StreamsContainer(data_sources=ds) + except TypeError: + # Fallback for older pydpf-core shipped with the DPF server: + # call streams_new via the low-level C-API and wrap the resulting + # internal handle in a StreamsContainer. + from ansys.dpf.gate import streams_capi + + streams_api = ds._server.get_api_for_type(capi=streams_capi.StreamsCAPI, grpcapi=None) + return StreamsContainer(streams_container=streams_api.streams_new(ds)) + + +class streams_provider(CustomOperatorBase): + """Create a :class:`~ansys.dpf.core.streams_container.StreamsContainer` from a MyFormat file. + + Inputs + ------ + pin 4 : DataSources + DataSources whose first result file points to a ``.myf`` file. + + Outputs + ------- + pin 0 : StreamsContainer + An open StreamsContainer wrapping the result file. + """ + + def run(self): + """Run the operator.""" + ds: dpf.DataSources = self.get_input(4, dpf.DataSources) + + # Build a StreamsContainer that stores the DataSources reference. + # Downstream operators retrieve the file path via sc.datasources. + sc = _make_streams_container(ds) + + self.set_output(0, sc) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification("Creates a StreamsContainer from a MyFormat (.myf) result file.") + spec.inputs = { + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=False, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=False, + document="StreamsContainer wrapping the open MyFormat file.", + ), + } + spec.properties = SpecificationProperties( + user_name="MyFormat streams provider", category="myformat" + ) + return spec + + @property + def name(self) -> str: + """Return the operator scripting name. + + DPF uses the prefix ``myformat::`` to route provider requests for + DataSources whose result key is ``"myformat"``. + The suffix ``stream_provider`` (singular) is the name DPF looks up + internally when constructing a :class:`~ansys.dpf.core.model.Model`. + """ + return "myformat::stream_provider" diff --git a/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/time_freq_support_provider.py b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/time_freq_support_provider.py new file mode 100644 index 00000000000..8f73dad35c3 --- /dev/null +++ b/src/ansys/dpf/core/examples/python_plugins/my_format_plugin/time_freq_support_provider.py @@ -0,0 +1,117 @@ +# Copyright (C) 2020 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""time_freq_support_provider operator for MyFormat files. + +Returns a :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` with +the frequency sets read from the ``FREQUENCIES`` block of the file. +""" + +import my_format_reader as reader +from result_info_provider import _get_file_path + +from ansys.dpf import core as dpf +from ansys.dpf.core.custom_operator import CustomOperatorBase +from ansys.dpf.core.operator_specification import ( + CustomSpecification, + PinSpecification, + SpecificationProperties, +) + + +class time_freq_support_provider(CustomOperatorBase): + """Return a :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` for a MyFormat file. + + Inputs + ------ + pin 3 : StreamsContainer, optional + Streams container returned by the streams_provider. + pin 4 : DataSources + DataSources with a path to a ``.myf`` file (used when pin 3 is absent). + + Outputs + ------- + pin 0 : TimeFreqSupport + Object describing the frequency sets in the result file. + """ + + def run(self): + """Run the operator.""" + file_path = _get_file_path(self) + + model = reader.read(file_path) + tfs = _build_time_freq_support(model) + + self.set_output(0, tfs) + self.set_succeeded() + + @property + def specification(self) -> CustomSpecification: + """Return the operator specification.""" + spec = CustomSpecification( + "Reads the time/frequency information from a MyFormat (.myf) result file." + ) + spec.inputs = { + 3: PinSpecification( + name="streams_container", + type_names=dpf.StreamsContainer, + optional=True, + document="Streams container (optional); takes priority over pin 4.", + ), + 4: PinSpecification( + name="data_sources", + type_names=dpf.DataSources, + optional=True, + document="DataSources with a path to a .myf file.", + ), + } + spec.outputs = { + 0: PinSpecification( + name="time_freq_support", + type_names=dpf.TimeFreqSupport, + optional=False, + document="TimeFreqSupport with the frequency sets of the result file.", + ), + } + spec.properties = SpecificationProperties( + user_name="MyFormat time/frequency support provider", category="myformat" + ) + return spec + + @property + def name(self) -> str: + """Return the operator scripting name.""" + return "myformat::myformat::time_freq_support_provider" + + +def _build_time_freq_support(model: reader.MyFormatModel) -> dpf.TimeFreqSupport: + """Build a :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` from a parsed model.""" + tfs = dpf.TimeFreqSupport() + + # Group all frequency values into a single step for harmonic analyses. + freq_values = list(model.frequencies.values()) + tfs.append_step( + step_id=1, + step_time_frequencies=freq_values, + ) + + return tfs