From 1fef53a6dff106dd5e41b6fd15d17e11e0a88a71 Mon Sep 17 00:00:00 2001 From: PProfizi Date: Sat, 14 Feb 2026 10:46:33 +0100 Subject: [PATCH 1/8] Set elements_faces_reversed PropertyField in polyhedron mesh --- tests/test_plotter.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_plotter.py b/tests/test_plotter.py index 6643e5a9daa..89369595508 100644 --- a/tests/test_plotter.py +++ b/tests/test_plotter.py @@ -925,29 +925,35 @@ def test_plot_polyhedron(): mesh.elements.add_solid_element(0, element_connectivity) # Set the "cell_types" PropertyField - cell_types_f = core.PropertyField() + cell_types_f = core.PropertyField(location=core.locations.elemental) for cell_index, cell_type in enumerate(cell_types): cell_types_f.append(cell_type, cell_index) mesh.set_property_field("eltype", cell_types_f) # Set the "faces_nodes_connectivity" PropertyField - connectivity_f = core.PropertyField() + connectivity_f = core.PropertyField(location=core.locations.faces) for face_index, face_connectivity in enumerate(faces_connectivity): connectivity_f.append(face_connectivity, face_index) mesh.set_property_field("faces_nodes_connectivity", connectivity_f) # Set the "elements_faces_connectivity" PropertyField - elements_faces_f = core.PropertyField() + elements_faces_f = core.PropertyField(location=core.locations.elemental) for element_index, element_faces in enumerate(elements_faces): elements_faces_f.append(element_faces, element_index) mesh.set_property_field("elements_faces_connectivity", elements_faces_f) # Set the "faces_types" PropertyField - faces_types_f = core.PropertyField() + faces_types_f = core.PropertyField(location=core.locations.faces) for face_index, face_type in enumerate(faces_types): faces_types_f.append(face_type, face_index) mesh.set_property_field("faces_type", faces_types_f) + # Set the "elements_faces_reversed" PropertyField + elements_faces_reversed_f = core.PropertyField(location=core.locations.faces) + for face_index, face_type in enumerate(faces_types): + elements_faces_reversed_f.append([0], face_index) + mesh.set_property_field("elements_faces_reversed", elements_faces_reversed_f) + # Plot the MeshedRegion mesh.plot() From 79ad7f98b19a29a695815df6140547267c46c2c4 Mon Sep 17 00:00:00 2001 From: PProfizi Date: Mon, 16 Feb 2026 09:49:50 +0100 Subject: [PATCH 2/8] Set elements_faces_reversed PropertyField in polyhedron mesh --- tests/test_plotter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_plotter.py b/tests/test_plotter.py index 89369595508..06cafb3bd90 100644 --- a/tests/test_plotter.py +++ b/tests/test_plotter.py @@ -949,9 +949,9 @@ def test_plot_polyhedron(): mesh.set_property_field("faces_type", faces_types_f) # Set the "elements_faces_reversed" PropertyField - elements_faces_reversed_f = core.PropertyField(location=core.locations.faces) - for face_index, face_type in enumerate(faces_types): - elements_faces_reversed_f.append([0], face_index) + elements_faces_reversed_f = core.PropertyField(location=core.locations.elemental) + for element_index, element_faces in enumerate(elements_faces): + elements_faces_reversed_f.append([0] * len(element_faces), element_index) mesh.set_property_field("elements_faces_reversed", elements_faces_reversed_f) # Plot the MeshedRegion From 5607ef1786ddd543ed5854051b798dfdefa4572d Mon Sep 17 00:00:00 2001 From: PProfizi Date: Tue, 3 Mar 2026 16:46:23 +0100 Subject: [PATCH 3/8] WIP --- src/ansys/dpf/core/data_sources.py | 7 +- src/ansys/dpf/core/stream.py | 95 +++++++++++++++++++++++++ src/ansys/dpf/core/streams_container.py | 40 ++++++++++- tests/test_streams_container.py | 27 +++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/ansys/dpf/core/stream.py diff --git a/src/ansys/dpf/core/data_sources.py b/src/ansys/dpf/core/data_sources.py index dc7ed4ee455..244fcf7a65f 100644 --- a/src/ansys/dpf/core/data_sources.py +++ b/src/ansys/dpf/core/data_sources.py @@ -42,7 +42,7 @@ if TYPE_CHECKING: # pragma: no cover from ansys.dpf import core as dpf - from ansys.dpf.core import server_types + from ansys.dpf.core import LabelSpace, server_types from ansys.dpf.core.server_types import AnyServerType from ansys.grpc.dpf import data_sources_pb2 @@ -706,6 +706,11 @@ def namespace(self, result_key: str) -> str: """ return self._api.data_sources_get_namespace(self, result_key) + def label_space_for_path(self, index: int) -> LabelSpace: + from ansys.dpf.core import LabelSpace + + return LabelSpace(self._api.data_sources_get_label_space_by_path_index(self, index)) + @property def streams_container(self) -> dpf.StreamsContainer: """Get the streams container representation of the data sources. diff --git a/src/ansys/dpf/core/stream.py b/src/ansys/dpf/core/stream.py new file mode 100644 index 00000000000..cb82603e3f3 --- /dev/null +++ b/src/ansys/dpf/core/stream.py @@ -0,0 +1,95 @@ +# 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. + +# -*- coding: utf-8 -*- +""" +Stream. + +Contains classes used to populate a StreamsContainer. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import os + +import ansys.dpf.core as dpf + + +class Stream(ABC): + """Python wrapper for a DPF Stream. + + A Stream is a data source that is open and ready to be used. Once the files in the stream are opened, they stay opened and they keep some data in cache to make the next evaluations faster. To close the opened files, release the handles. + + Note + ---- + Only available for an InProcess server configuration. + + Examples + -------- + >>> from ansys.dpf import core as dpf + """ + + def __init__(self, file_path: str | os.PathLike): + self._path = str(file_path) + if not os.path.exists(self._path): + raise FileNotFoundError(f"File {self._path} does not exist.") + self._handle = self._create_handle() + + def _create_handle(self): + """Create and return the resource handle. Subclasses must implement.""" + return open(self._path, "rb") + + def _release_handle(self): + """Release the resource handle. Subclasses must implement.""" + if self._handle: + self._handle.close() + + def release(self): + """Release the handles of the stream to close the opened files.""" + self._release_handle() + self._handle = None + + @property + def stream_type_name(self) -> str: + """Get the type name of the stream.""" + return "stream_type" + + @property + def file_path(self) -> str: + """Get the file name of the stream.""" + return str(self._path) + + @property + @abstractmethod + def time_freq_support(self) -> dpf.TimeFreqSupport: + """Get the time and frequency support of the stream.""" + pass + + @property + @abstractmethod + def result_info(self) -> dpf.ResultInfo: + """Get the result info of the stream.""" + pass + + def __delete__(self): + self.release() diff --git a/src/ansys/dpf/core/streams_container.py b/src/ansys/dpf/core/streams_container.py index 73c90a4f5fc..b4d6a909470 100644 --- a/src/ansys/dpf/core/streams_container.py +++ b/src/ansys/dpf/core/streams_container.py @@ -30,11 +30,14 @@ import traceback import warnings -from ansys.dpf.core import data_sources, errors, server as server_module +from ansys.dpf.core import errors, server as server_module +from ansys.dpf.core.data_sources import DataSources from ansys.dpf.core.server_types import BaseServer +from ansys.dpf.core.stream import Stream from ansys.dpf.gate import ( data_processing_capi, data_processing_grpcapi, + integral_types, streams_capi, ) @@ -60,7 +63,11 @@ class StreamsContainer: >>> sc = streams_provider.outputs.streams_container() """ - def __init__(self, streams_container=None, server: BaseServer = None): + def __init__( + self, + streams_container=None, + server: BaseServer = None, + ): # step 1: get server self._server = server_module.get_or_create_server( streams_container._server if isinstance(streams_container, StreamsContainer) else server @@ -105,7 +112,7 @@ def _server(self, value): @property def datasources(self): """Return the data sources.""" - return data_sources.DataSources(data_sources=self._api.streams_get_data_sources(self)) + return DataSources(data_sources=self._api.streams_get_data_sources(self)) def release_handles(self): """Release the streams.""" @@ -119,3 +126,30 @@ def __del__(self): self._deleter_func[0](self._deleter_func[1](self)) except: # pylint: disable=bare-except warnings.warn(traceback.format_exc()) + + def add_stream(self, stream: Stream, label_space: dict = None): + """Add a stream to the container.""" + import ctypes + + release_func_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + delete_func_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + stream_type_name = integral_types.MutableString(stream.stream_type_name) + if label_space: + self._api.streams_add_external_stream_with_label_space( + self, + streamTypeName=stream_type_name, + filePath=stream.file_path, + releaseFileFunc=release_func_type(stream.release), + deleteFunc=delete_func_type(stream.__delete__), + var1=stream, + labelspace=label_space, + ) + else: + self._api.streams_add_external_stream( + self, + streamTypeName=stream_type_name, + filePath=stream.file_path, + releaseFileFunc=release_func_type(stream.release), + deleteFunc=delete_func_type(stream.__delete__), + var1=stream, + ) diff --git a/tests/test_streams_container.py b/tests/test_streams_container.py index 2bbacd04747..41b82c17d16 100644 --- a/tests/test_streams_container.py +++ b/tests/test_streams_container.py @@ -100,3 +100,30 @@ def test_retrieve_ip(server_in_process): # but not 0.0.0:0, 9999.999.999.999:999, 0.0.0.0 ip_addr_regex = r"([0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]{1,5}" assert re.match(ip_addr_regex, addr) != None + + +from ansys.dpf.core.stream import Stream + + +class DummyStream(Stream): + def __init__(self, file_path=None, server=None): + super().__init__(file_path=file_path) + self._server = server + + @property + def time_freq_support(self) -> dpf.core.TimeFreqSupport: + return dpf.core.TimeFreqSupport() + + @property + def result_info(self) -> dpf.core.ResultInfo: + return dpf.core.ResultInfo() + + def stream_type_name(self) -> str: + return "dummy_stream" + + +def test_streams_container_add_stream(server_in_process, simple_bar): + dummy_stream = DummyStream(file_path=simple_bar) + + sc = dpf.core.StreamsContainer(server=server_in_process) + sc.add_stream(stream=dummy_stream, label_space={"dummy": "1"}) From da6ab439e91922ffa59e187c4f3ad8d5f3342f29 Mon Sep 17 00:00:00 2001 From: PProfizi Date: Fri, 20 Mar 2026 20:46:58 +0100 Subject: [PATCH 4/8] feat: allow StreamsContainer creation from DataSources and add Stream base class --- src/ansys/dpf/core/stream.py | 133 +++++++++++-- src/ansys/dpf/core/streams_container.py | 242 +++++++++++++++++++++--- tests/test_streams_container.py | 11 +- 3 files changed, 346 insertions(+), 40 deletions(-) diff --git a/src/ansys/dpf/core/stream.py b/src/ansys/dpf/core/stream.py index cb82603e3f3..3e66cd74cd3 100644 --- a/src/ansys/dpf/core/stream.py +++ b/src/ansys/dpf/core/stream.py @@ -30,66 +30,169 @@ from __future__ import annotations from abc import ABC, abstractmethod -import os +from pathlib import Path import ansys.dpf.core as dpf class Stream(ABC): - """Python wrapper for a DPF Stream. + """Abstract base class for a DPF external stream. - A Stream is a data source that is open and ready to be used. Once the files in the stream are opened, they stay opened and they keep some data in cache to make the next evaluations faster. To close the opened files, release the handles. + A stream is an open, ready-to-use data source. Once the underlying file + is opened it stays open and keeps data in cache so that subsequent + evaluations are faster. To close the file and free the cache, call + :meth:`release`. + + Subclasses must override :attr:`stream_type_name`, :attr:`time_freq_support`, + and :attr:`result_info`. They may also override :meth:`_create_handle` and + :meth:`_release_handle` to manage a custom resource instead of the default + binary file handle. + + Once instantiated, a stream can be registered with a + :class:`~ansys.dpf.core.streams_container.StreamsContainer` via + :meth:`~ansys.dpf.core.streams_container.StreamsContainer.add_stream` so + that DPF operators can consume it. Note ---- - Only available for an InProcess server configuration. + Streams are only meaningful when used with a + :class:`~ansys.dpf.core.streams_container.StreamsContainer`, which itself + requires an InProcess server configuration. Examples -------- + Define a minimal concrete stream for an RST result file: + >>> from ansys.dpf import core as dpf + >>> from ansys.dpf.core import examples + >>> from ansys.dpf.core.stream import Stream + ... + >>> class RstStream(Stream): + ... @property + ... def stream_type_name(self) -> str: + ... return "rst" + ... @property + ... def time_freq_support(self): + ... return dpf.TimeFreqSupport() + ... @property + ... def result_info(self): + ... return dpf.ResultInfo() + ... + >>> rst_path = examples.find_simple_bar() + >>> stream = RstStream(rst_path) + >>> sc = dpf.StreamsContainer() + >>> sc.add_stream(stream, group=1, is_result=1, result=1) """ - def __init__(self, file_path: str | os.PathLike): - self._path = str(file_path) - if not os.path.exists(self._path): + def __init__(self, file_path: str | Path): + """ + Create a stream for the given file. + + Parameters + ---------- + file_path : str or pathlib.Path + Absolute or relative path to the result file. The file must + exist on disk at construction time. + + Raises + ------ + FileNotFoundError + If *file_path* does not point to an existing file. + """ + self._path = Path(file_path) + if not self._path.exists(): raise FileNotFoundError(f"File {self._path} does not exist.") self._handle = self._create_handle() def _create_handle(self): - """Create and return the resource handle. Subclasses must implement.""" - return open(self._path, "rb") + """Open the file and return its handle. + + The default implementation opens the file in binary read mode. + Override this method in subclasses that manage a different kind of + resource (e.g. a memory-mapped buffer or a network socket). + + Returns + ------- + object + An open file handle or equivalent resource. + """ + return self._path.open("rb") def _release_handle(self): - """Release the resource handle. Subclasses must implement.""" + """Close the resource handle. + + The default implementation calls ``close()`` on the handle returned by + :meth:`_create_handle`. Override this method in subclasses that + manage a different kind of resource. + """ if self._handle: self._handle.close() def release(self): - """Release the handles of the stream to close the opened files.""" + """Close the file and release cached data. + + After calling this method the stream is no longer usable. Any + subsequent read requests routed through a + :class:`~ansys.dpf.core.streams_container.StreamsContainer` that + references this stream will fail. + """ self._release_handle() self._handle = None @property def stream_type_name(self) -> str: - """Get the type name of the stream.""" + """Type identifier recognised by the DPF server for this stream. + + The value must match a stream type registered on the server side + (for example ``"rst"``, ``"d3plot"``, ``"cgns"``). + Subclasses **must** override this property to return the correct type + string; the base-class implementation returns the placeholder + ``"stream_type"``. + + Returns + ------- + str + Stream type identifier. + """ return "stream_type" @property def file_path(self) -> str: - """Get the file name of the stream.""" + """Absolute path to the file backing this stream. + + Returns + ------- + str + Path to the result file as a string. + """ return str(self._path) @property @abstractmethod def time_freq_support(self) -> dpf.TimeFreqSupport: - """Get the time and frequency support of the stream.""" + """Time/frequency support describing the available time steps. + + Returns + ------- + ansys.dpf.core.TimeFreqSupport + Object describing the time and frequency sets available in this + stream. + """ pass @property @abstractmethod def result_info(self) -> dpf.ResultInfo: - """Get the result info of the stream.""" + """Metadata describing the results available in this stream. + + Returns + ------- + ansys.dpf.core.ResultInfo + Object containing solver type, available result quantities, and + unit system information. + """ pass def __delete__(self): + """Release resources when the descriptor protocol deletes this object.""" self.release() diff --git a/src/ansys/dpf/core/streams_container.py b/src/ansys/dpf/core/streams_container.py index b4d6a909470..42a399f8721 100644 --- a/src/ansys/dpf/core/streams_container.py +++ b/src/ansys/dpf/core/streams_container.py @@ -32,6 +32,7 @@ from ansys.dpf.core import errors, server as server_module from ansys.dpf.core.data_sources import DataSources +from ansys.dpf.core.label_space import LabelSpace from ansys.dpf.core.server_types import BaseServer from ansys.dpf.core.stream import Stream from ansys.dpf.gate import ( @@ -43,31 +44,96 @@ class StreamsContainer: - """Python wrapper for operator input or output of streams. + """Holds a collection of open, ready-to-use data streams. - Streams define open, ready-to-use, data sources. - Once the files in the streams are opened, they stay opened and - they keep some data in cache to make the next evaluations faster. - To close the opened files, release the handles. + A :class:`StreamsContainer` wraps one or more + :class:`~ansys.dpf.core.stream.Stream` objects (or the DPF-internal + equivalent). Because the underlying files stay open and keep cached + data between evaluations, repeated reads are significantly faster than + reopening from a + :class:`~ansys.dpf.core.data_sources.DataSources`. + + There are three ways to obtain a :class:`StreamsContainer`: + + 1. **From a Model** — DPF opens the file automatically; retrieve the + container via ``model.metadata.streams_provider``. + 2. **From a DataSources** — wrap an existing + :class:`~ansys.dpf.core.data_sources.DataSources` so that DPF + manages the open handles. + 3. **From scratch** — create an empty container and register one or + more custom :class:`~ansys.dpf.core.stream.Stream` objects with + :meth:`add_stream`. This is the entry point for Python custom + plug-ins that provide their own ``streams_provider`` operator. + + To close the open files and release cached data, call + :meth:`release_handles`. Note ---- - Only available for an InProcess server configuration. + Only available with an InProcess server configuration. Examples -------- + Obtain a container from an existing model: + >>> from ansys.dpf import core as dpf >>> from ansys.dpf.core import examples >>> model = dpf.Model(examples.find_multishells_rst()) - >>> streams_provider = model.metadata.streams_provider - >>> sc = streams_provider.outputs.streams_container() + >>> sc = model.metadata.streams_provider.outputs.streams_container() + + Create a container from a :class:`~ansys.dpf.core.data_sources.DataSources`: + + >>> ds = dpf.DataSources(examples.find_simple_bar()) + >>> sc = dpf.StreamsContainer(data_sources=ds) + + Create an empty container and register a custom stream: + + >>> from ansys.dpf.core.stream import Stream + >>> class MyStream(Stream): + ... @property + ... def stream_type_name(self): return "rst" + ... @property + ... def time_freq_support(self): return dpf.TimeFreqSupport() + ... @property + ... def result_info(self): return dpf.ResultInfo() + ... + >>> sc = dpf.StreamsContainer() + >>> sc.add_stream(MyStream(examples.find_simple_bar()), group=1, is_result=1, result=1) """ def __init__( self, streams_container=None, server: BaseServer = None, + data_sources: DataSources = None, ): + """Create or wrap a DPF :class:`StreamsContainer`. + + Parameters + ---------- + streams_container : StreamsContainer or internal object, optional + * If a :class:`StreamsContainer` instance is given, a shallow + duplicate reference to the same underlying object is created. + * If a raw internal DPF object (void pointer) is given, it is + adopted directly. This is used internally when operators + return a streams container. + * If ``None`` (the default), a new container is allocated on the + server. + server : BaseServer, optional + DPF server to use. Must be an InProcess server. Defaults to + the global server. + data_sources : DataSources, optional + :class:`~ansys.dpf.core.data_sources.DataSources` to associate + with the new container. Ignored when *streams_container* is not + ``None``. When ``None`` and no *streams_container* is given, an + empty :class:`~ansys.dpf.core.data_sources.DataSources` is + created automatically. + + Raises + ------ + ServerTypeError + If the resolved server uses a gRPC communication protocol. + """ # step 1: get server self._server = server_module.get_or_create_server( streams_container._server if isinstance(streams_container, StreamsContainer) else server @@ -93,6 +159,11 @@ def __init__( ) else: self._internal_obj = streams_container + else: + # Create a new StreamsContainer, optionally wrapping the given DataSources + if data_sources is None: + data_sources = DataSources(server=self._server) + self._internal_obj = self._api.streams_new(data_sources) self.owned = False @property @@ -111,45 +182,168 @@ def _server(self, value): @property def datasources(self): - """Return the data sources.""" + """DataSources associated with this container. + + Returns the :class:`~ansys.dpf.core.data_sources.DataSources` that + lists the result files backing this container's open streams. + + Returns + ------- + ansys.dpf.core.DataSources + Data sources for this container. + """ return DataSources(data_sources=self._api.streams_get_data_sources(self)) def release_handles(self): - """Release the streams.""" + """Close all open files and release cached data. + + After calling this method all streams in the container are closed. + The container object itself remains valid — files will be reopened + automatically on the next evaluation that requires them. + """ self._api.streams_release_handles(self) def __del__(self): """Delete the entry.""" try: # delete - if not self.owned: + if not getattr(self, "owned", False): self._deleter_func[0](self._deleter_func[1](self)) except: # pylint: disable=bare-except warnings.warn(traceback.format_exc()) - def add_stream(self, stream: Stream, label_space: dict = None): - """Add a stream to the container.""" + def add_stream( + self, stream: Stream, group: int = None, is_result: int = None, result: int = None + ): + """Add an external stream to the container. + + Two registration strategies are available depending on how the + :class:`StreamsContainer` was created: + + **Label-space strategy** (``group`` / ``is_result`` / ``result`` supplied): + The stream is registered under the given label values. Use this + when the container was created without a backing + :class:`~ansys.dpf.core.data_sources.DataSources`, or when you want + explicit control over the labels. A + :class:`~ansys.dpf.core.streams_container.StreamsContainer` created + from scratch holds a fixed three-label schema: ``group``, + ``is_result``, and ``result`` (all integers). + + **DataSources-lookup strategy** (no labels supplied): + The stream's :attr:`~ansys.dpf.core.stream.Stream.file_path` is + matched against the files already registered in the container's + underlying :class:`~ansys.dpf.core.data_sources.DataSources`. The + label space stored there is reused automatically. Use this when the + container was created via + ``StreamsContainer(data_sources=ds)`` and the file is already in + ``ds``. + + Parameters + ---------- + stream : Stream + The stream to add. Must be a concrete subclass of + :class:`~ansys.dpf.core.stream.Stream` that implements at least + :attr:`~ansys.dpf.core.stream.Stream.stream_type_name` and + :attr:`~ansys.dpf.core.stream.Stream.file_path`. + group : int, optional + Value for the ``group`` label. When *all* three label arguments are + ``None`` (the default) the DataSources-lookup strategy is used + instead. + is_result : int, optional + Value for the ``is_result`` label. Use ``1`` for a result stream, + ``0`` for an auxiliary stream. + result : int, optional + Value for the ``result`` label. + + Raises + ------ + DPFServerException + If the label-space strategy is used and the supplied labels do not + match the container's schema, or if the DataSources-lookup strategy + is used and the stream's file path is not found in the container's + ``DataSources``. + + Examples + -------- + **Label-space strategy** — container created without a DataSources: + + >>> from ansys.dpf import core as dpf + >>> from ansys.dpf.core import examples + >>> from ansys.dpf.core.stream import Stream + ... + >>> class MyStream(Stream): + ... @property + ... def stream_type_name(self) -> str: + ... return "rst" + ... @property + ... def time_freq_support(self): + ... return dpf.TimeFreqSupport() + ... @property + ... def result_info(self): + ... return dpf.ResultInfo() + ... + >>> rst_path = examples.find_simple_bar() + >>> sc = dpf.StreamsContainer() + >>> sc.add_stream(MyStream(rst_path), group=1, is_result=1, result=1) + + **DataSources-lookup strategy** — container created from a DataSources: + + >>> ds = dpf.DataSources(rst_path) + >>> sc = dpf.StreamsContainer(data_sources=ds) + >>> sc.add_stream(MyStream(rst_path)) # labels inferred from ds + """ import ctypes release_func_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p) delete_func_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p) - stream_type_name = integral_types.MutableString(stream.stream_type_name) - if label_space: - self._api.streams_add_external_stream_with_label_space( + stream_type_name = integral_types.MutableString(stream.stream_type_name.encode()) + + def _release(_user_data): + stream.release() + + def _delete(_user_data): + stream.release() + + release_func = release_func_type(_release) + delete_func = delete_func_type(_delete) + # The C++ layer requires a non-null instance pointer; the closures above + # capture 'stream' directly so the user-data value is not dereferenced. + var1 = ctypes.c_void_p(1) + + # Keep the ctypes callbacks and the stream alive for as long as this + # StreamsContainer exists; otherwise GC frees them before DPF calls them. + if not hasattr(self, "_stream_callbacks"): + self._stream_callbacks = [] + self._stream_callbacks.append((stream, release_func, delete_func)) + + if group is None and is_result is None and result is None: + # DataSources-lookup strategy: match file path against the + # container's underlying DataSources to infer the label space. + self._api.streams_add_external_stream( self, streamTypeName=stream_type_name, filePath=stream.file_path, - releaseFileFunc=release_func_type(stream.release), - deleteFunc=delete_func_type(stream.__delete__), - var1=stream, - labelspace=label_space, + releaseFileFunc=release_func, + deleteFunc=delete_func, + var1=var1, ) else: - self._api.streams_add_external_stream( + # Label-space strategy: register under explicitly provided labels, + # defaulting missing ones to 1. + label_space = LabelSpace( + label_space={ + "group": group if group is not None else 1, + "is_result": is_result if is_result is not None else 1, + "result": result if result is not None else 1, + }, + server=self._server, + ) + self._api.streams_add_external_stream_with_label_space( self, streamTypeName=stream_type_name, filePath=stream.file_path, - releaseFileFunc=release_func_type(stream.release), - deleteFunc=delete_func_type(stream.__delete__), - var1=stream, + releaseFileFunc=release_func, + deleteFunc=delete_func, + var1=var1, + labelspace=label_space, ) diff --git a/tests/test_streams_container.py b/tests/test_streams_container.py index 41b82c17d16..8cd7012c184 100644 --- a/tests/test_streams_container.py +++ b/tests/test_streams_container.py @@ -118,6 +118,7 @@ def time_freq_support(self) -> dpf.core.TimeFreqSupport: def result_info(self) -> dpf.core.ResultInfo: return dpf.core.ResultInfo() + @property def stream_type_name(self) -> str: return "dummy_stream" @@ -126,4 +127,12 @@ def test_streams_container_add_stream(server_in_process, simple_bar): dummy_stream = DummyStream(file_path=simple_bar) sc = dpf.core.StreamsContainer(server=server_in_process) - sc.add_stream(stream=dummy_stream, label_space={"dummy": "1"}) + sc.add_stream(stream=dummy_stream, group=1, is_result=1, result=1) + + +def test_streams_container_add_stream_from_datasources(server_in_process, simple_bar): + ds = dpf.core.DataSources(simple_bar, server=server_in_process) + sc = dpf.core.StreamsContainer(data_sources=ds, server=server_in_process) + dummy_stream = DummyStream(file_path=simple_bar) + # No labels needed: the file path is matched against the DataSources entries. + sc.add_stream(stream=dummy_stream) From 4f4a63ec813e8a53bac085fa37f1c68284793f61 Mon Sep 17 00:00:00 2001 From: PProfizi Date: Fri, 20 Mar 2026 21:45:48 +0100 Subject: [PATCH 5/8] Add support for streamscontainer as operator output/input for custom operators --- src/ansys/dpf/core/_custom_operators_helpers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ansys/dpf/core/_custom_operators_helpers.py b/src/ansys/dpf/core/_custom_operators_helpers.py index b8115c93076..0ab2958c6c3 100644 --- a/src/ansys/dpf/core/_custom_operators_helpers.py +++ b/src/ansys/dpf/core/_custom_operators_helpers.py @@ -37,6 +37,7 @@ result_info, scoping, scopings_container, + streams_container, string_field, time_freq_support, workflow, @@ -89,6 +90,10 @@ def __operator_main__(operator_functor, data): (workflow.Workflow, external_operator_api.external_operator_put_out_workflow), (data_tree.DataTree, external_operator_api.external_operator_put_out_data_tree), (dpf_operator.Operator, external_operator_api.external_operator_put_out_operator), + ( + streams_container.StreamsContainer, + external_operator_api.external_operator_put_out_streams, + ), ( custom_type_field.CustomTypeField, external_operator_api.external_operator_put_out_custom_type_field, @@ -174,4 +179,9 @@ def __operator_main__(operator_functor, data): ), # TO DO : (dpf_operator.Operator, external_operator_api.external_operator_get_in_operator, # "operator"), + ( + streams_container.StreamsContainer, + external_operator_api.external_operator_get_in_streams, + "streams_container", + ), ] From 508223fa4a3c62b7c1926a4d7ae872908c0a901d Mon Sep 17 00:00:00 2001 From: PProfizi Date: Fri, 20 Mar 2026 21:46:35 +0100 Subject: [PATCH 6/8] Add a tutorial for a custom plugin with readers --- .../GALLERY_HEADER.rst | 11 + .../python_plugin_for_custom_file_format.py | 494 ++++++++++++++++++ .../my_format_plugin/__init__.py | 40 ++ .../my_format_plugin/cube_harmonic.myf | 58 ++ .../my_format_plugin/mesh_info_provider.py | 165 ++++++ .../my_format_plugin/mesh_provider.py | 138 +++++ .../my_format_plugin/my_format_plugin.py | 48 ++ .../my_format_plugin/my_format_reader.py | 231 ++++++++ .../my_format_plugin/result_info_provider.py | 173 ++++++ .../my_format_plugin/result_provider.py | 237 +++++++++ .../my_format_plugin/streams_provider.py | 126 +++++ .../time_freq_support_provider.py | 117 +++++ 12 files changed, 1838 insertions(+) create mode 100644 doc/sphinx_gallery_tutorials/custom_operators_and_plugins/python_plugin_for_custom_file_format.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/__init__.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/cube_harmonic.myf create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_info_provider.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/mesh_provider.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_plugin.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/my_format_reader.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_info_provider.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/result_provider.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/streams_provider.py create mode 100644 src/ansys/dpf/core/examples/python_plugins/my_format_plugin/time_freq_support_provider.py 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..49c351bbeef --- /dev/null +++ b/doc/sphinx_gallery_tutorials/custom_operators_and_plugins/python_plugin_for_custom_file_format.py @@ -0,0 +1,494 @@ +# 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::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::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::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::displacement" +# +# +# class temperature_provider(_result_provider): +# _result_name = "temperature" +# +# @property +# def name(self): +# return "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 +# --------------------- +# +# Create a :class:`~ansys.dpf.core.data_sources.DataSources` pointing to the +# sample ``.myf`` file. The ``key`` argument tells DPF which result type this +# file belongs to. + +my_ds = dpf.DataSources(server=server) +my_ds.set_result_file_path(str(my_format_plugin.sample_file), key="myformat") + +############################################################################### +# Inspect the result metadata +# ---------------------------- +# +# Call ``myformat::result_info_provider`` directly, passing the +# :class:`~ansys.dpf.core.data_sources.DataSources` on pin 4. This returns a +# :class:`~ansys.dpf.core.result_info.ResultInfo` describing all available +# result quantities. + +ri_op = dpf.Operator("myformat::result_info_provider", server=server) +ri_op.connect(4, my_ds) +result_info = ri_op.get_output(0, dpf.ResultInfo) + +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}") + +############################################################################### +# Inspect the time-frequency support +# ------------------------------------ +# +# ``myformat::time_freq_support_provider`` returns a +# :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` with the +# frequency axis from the ``FREQUENCIES`` block of the ``.myf`` file. + +tfs_op = dpf.Operator("myformat::time_freq_support_provider", server=server) +tfs_op.connect(4, my_ds) +tfs = tfs_op.get_output(0, dpf.TimeFreqSupport) + +print("Number of frequency sets:", tfs.n_sets) +print("Frequencies [Hz] :", tfs.time_frequencies.data) + +############################################################################### +# Retrieve the mesh +# ------------------ +# +# ``myformat::mesh_provider`` builds a +# :class:`~ansys.dpf.core.meshed_region.MeshedRegion` from the node +# coordinates and element connectivity in the file. + +mesh_op = dpf.Operator("myformat::mesh_provider", server=server) +mesh_op.connect(4, my_ds) +mesh = mesh_op.get_output(0, dpf.MeshedRegion) + +print("Number of nodes :", mesh.nodes.n_nodes) +print("Number of elements:", mesh.elements.n_elements) + +############################################################################### +# Extract displacement results +# ----------------------------- +# +# The ``myformat::displacement`` operator returns a +# :class:`~ansys.dpf.core.fields_container.FieldsContainer` with one nodal +# vector :class:`~ansys.dpf.core.field.Field` per frequency set, labelled by +# ``"time"`` (the 1-based frequency-set id). +# 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::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 +# ---------------------------- +# +# ``myformat::temperature`` returns an elemental scalar +# :class:`~ansys.dpf.core.fields_container.FieldsContainer`, one field per +# frequency set. + +temp_op = dpf.Operator("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..6d7f4011dd6 --- /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::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..2c551832391 --- /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::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..1f56eb7b857 --- /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::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::{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..4cb90ecb5cd --- /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::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::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..10f7c11d8b4 --- /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::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 From e3c5172769b8dffeef92186345054a4c3a964f9b Mon Sep 17 00:00:00 2001 From: PProfizi Date: Mon, 23 Mar 2026 11:59:56 +0100 Subject: [PATCH 7/8] Working tutorial --- .../python_plugin_for_custom_file_format.py | 95 +++++++++---------- .../my_format_plugin/mesh_info_provider.py | 2 +- .../my_format_plugin/mesh_provider.py | 2 +- .../my_format_plugin/result_info_provider.py | 4 +- .../my_format_plugin/result_provider.py | 4 +- .../time_freq_support_provider.py | 2 +- 6 files changed, 53 insertions(+), 56 deletions(-) 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 index 49c351bbeef..b2f9ece1895 100644 --- 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 @@ -180,7 +180,7 @@ # # @property # def name(self): -# return "myformat::result_info_provider" +# return "myformat::myformat::result_info_provider" # # def run(self): # file_path = _get_file_path(self) # helper: pin 3 or pin 4 @@ -220,7 +220,7 @@ # # @property # def name(self): -# return "myformat::time_freq_support_provider" +# return "myformat::myformat::time_freq_support_provider" # # def run(self): # file_path = _get_file_path(self) @@ -248,7 +248,7 @@ # # @property # def name(self): -# return "myformat::mesh_provider" +# return "myformat::myformat::mesh_provider" # # def run(self): # file_path = _get_file_path(self) @@ -308,7 +308,7 @@ # # @property # def name(self): -# return "myformat::displacement" +# return "myformat::myformat::displacement" # # # class temperature_provider(_result_provider): @@ -316,7 +316,7 @@ # # @property # def name(self): -# return "myformat::temperature" +# return "myformat::myformat::temperature" # ############################################################################### @@ -391,62 +391,61 @@ print("Registered myformat operators:", myformat_ops) ############################################################################### -# Open the result file -# --------------------- +# 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 tells DPF which result type this -# file belongs to. +# 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 -# ---------------------------- +# Inspect the result metadata and time-frequency support +# ------------------------------------------------------- # -# Call ``myformat::result_info_provider`` directly, passing the -# :class:`~ansys.dpf.core.data_sources.DataSources` on pin 4. This returns a -# :class:`~ansys.dpf.core.result_info.ResultInfo` describing all available -# result quantities. - -ri_op = dpf.Operator("myformat::result_info_provider", server=server) -ri_op.connect(4, my_ds) -result_info = ri_op.get_output(0, dpf.ResultInfo) +# :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}") -############################################################################### -# Inspect the time-frequency support -# ------------------------------------ -# -# ``myformat::time_freq_support_provider`` returns a -# :class:`~ansys.dpf.core.time_freq_support.TimeFreqSupport` with the -# frequency axis from the ``FREQUENCIES`` block of the ``.myf`` file. - -tfs_op = dpf.Operator("myformat::time_freq_support_provider", server=server) -tfs_op.connect(4, my_ds) -tfs = tfs_op.get_output(0, dpf.TimeFreqSupport) - +tfs = my_model.metadata.time_freq_support print("Number of frequency sets:", tfs.n_sets) print("Frequencies [Hz] :", tfs.time_frequencies.data) ############################################################################### -# Retrieve the mesh -# ------------------ +# Inspect the mesh +# ----------------- # -# ``myformat::mesh_provider`` builds a -# :class:`~ansys.dpf.core.meshed_region.MeshedRegion` from the node -# coordinates and element connectivity in the file. - -mesh_op = dpf.Operator("myformat::mesh_provider", server=server) -mesh_op.connect(4, my_ds) -mesh = mesh_op.get_output(0, dpf.MeshedRegion) +# :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) @@ -454,14 +453,14 @@ # Extract displacement results # ----------------------------- # -# The ``myformat::displacement`` operator returns a -# :class:`~ansys.dpf.core.fields_container.FieldsContainer` with one nodal -# vector :class:`~ansys.dpf.core.field.Field` per frequency set, labelled by -# ``"time"`` (the 1-based frequency-set id). +# 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::displacement", server=server) +disp_op = dpf.Operator("myformat::myformat::displacement", server=server) disp_op.connect(4, my_ds) disp_fc = disp_op.get_output(0, dpf.FieldsContainer) @@ -479,11 +478,9 @@ # Extract temperature results # ---------------------------- # -# ``myformat::temperature`` returns an elemental scalar -# :class:`~ansys.dpf.core.fields_container.FieldsContainer`, one field per -# frequency set. +# The same pattern applies for the elemental scalar temperature result. -temp_op = dpf.Operator("myformat::temperature", server=server) +temp_op = dpf.Operator("myformat::myformat::temperature", server=server) temp_op.connect(4, my_ds) temp_fc = temp_op.get_output(0, dpf.FieldsContainer) 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 index 6d7f4011dd6..33cac5caf5c 100644 --- 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 @@ -118,7 +118,7 @@ def specification(self) -> CustomSpecification: @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::mesh_info_provider" + return "myformat::myformat::mesh_info_provider" def _build_mesh_info(model: reader.MyFormatModel) -> dpf.GenericDataContainer: 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 index 2c551832391..0a89b8188c6 100644 --- 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 @@ -110,7 +110,7 @@ def specification(self) -> CustomSpecification: @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::mesh_provider" + return "myformat::myformat::mesh_provider" def _build_mesh(model: reader.MyFormatModel) -> dpf.MeshedRegion: 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 index 1f56eb7b857..6b50e2056f6 100644 --- 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 @@ -119,7 +119,7 @@ def specification(self) -> CustomSpecification: @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::result_info_provider" + return "myformat::myformat::result_info_provider" # --------------------------------------------------------------------------- @@ -161,7 +161,7 @@ def _build_result_info(model: reader.MyFormatModel) -> dpf.ResultInfo: nature = _NATURE_MAP.get(res.num_components, dpf.natures.scalar) result_info.add_result( - operator_name=f"myformat::{res.name}", + operator_name=f"myformat::myformat::{res.name}", scripting_name=res.name, homogeneity=homogeneity, location=location, 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 index 4cb90ecb5cd..ee91fa38f10 100644 --- 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 @@ -134,7 +134,7 @@ class displacement_provider(_result_provider): @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::displacement" + return "myformat::myformat::displacement" class temperature_provider(_result_provider): @@ -153,7 +153,7 @@ class temperature_provider(_result_provider): @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::temperature" + return "myformat::myformat::temperature" # --------------------------------------------------------------------------- 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 index 10f7c11d8b4..8f73dad35c3 100644 --- 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 @@ -100,7 +100,7 @@ def specification(self) -> CustomSpecification: @property def name(self) -> str: """Return the operator scripting name.""" - return "myformat::time_freq_support_provider" + return "myformat::myformat::time_freq_support_provider" def _build_time_freq_support(model: reader.MyFormatModel) -> dpf.TimeFreqSupport: From 34afc25639cde02a3b733455c002085a5dfd1bc2 Mon Sep 17 00:00:00 2001 From: PProfizi Date: Mon, 23 Mar 2026 15:20:42 +0100 Subject: [PATCH 8/8] Add a docstring to DataSources.label_space_for_path --- src/ansys/dpf/core/data_sources.py | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/ansys/dpf/core/data_sources.py b/src/ansys/dpf/core/data_sources.py index 244fcf7a65f..88e92f1e117 100644 --- a/src/ansys/dpf/core/data_sources.py +++ b/src/ansys/dpf/core/data_sources.py @@ -707,6 +707,44 @@ def namespace(self, result_key: str) -> str: return self._api.data_sources_get_namespace(self, result_key) def label_space_for_path(self, index: int) -> LabelSpace: + """Return the label space associated with the path at the given index. + + When files are added to the data sources with a domain ID (for distributed solves), + each path is internally tagged with a label space that describes the subset of the + model it covers. This method retrieves that label space by the position of the path + in the data sources. + + Parameters + ---------- + index: + 0-based index of the path in the data sources. + + Returns + ------- + LabelSpace + Label space associated with the path at the given index. For domain files, this + contains at least the ``"domain_id"`` label whose value matches the domain ID + supplied when the path was added. Returns an empty + :class:`LabelSpace ` if no label space + was set for that path. + + Examples + -------- + Get the label space of distributed result files added with domain IDs. + + >>> from ansys.dpf import core as dpf + >>> + >>> # Create the DataSources object + >>> my_data_sources = dpf.DataSources() + >>> # Add two result files covering different domains + >>> my_data_sources.set_domain_result_file_path(path='/tmp/file0.rst', key='rst', domain_id=0) + >>> my_data_sources.set_domain_result_file_path(path='/tmp/file1.rst', key='rst', domain_id=1) + >>> # Retrieve the label space for the first path (domain_id=0) + >>> label_space_0 = my_data_sources.label_space_for_path(index=0) + >>> # Retrieve the label space for the second path (domain_id=1) + >>> label_space_1 = my_data_sources.label_space_for_path(index=1) + + """ from ansys.dpf.core import LabelSpace return LabelSpace(self._api.data_sources_get_label_space_by_path_index(self, index))