diff --git a/.flake8 b/.flake8 index 6fae6bf9b61..b33711a789b 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,7 @@ [flake8] exclude = venv, tui.py, ansys/api/fluent +per-file-ignores = + ansys/fluent/postprocessing/pyvista/graphics.py:N801 select = W191, W291, W293, W391, E115, E117, E122, E124, E125, E225, E231, E301, E303, E501, F401, F403, N801, N802, N803, N804, N805, N806 count = True max-complexity = 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 587c1baae9f..53bd39248eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: | make flake8 - testimport: + test-import: name: Smoke Tests runs-on: ${{ matrix.os }} strategy: @@ -67,16 +67,54 @@ jobs: Python-${{ runner.os }}-${{ matrix.python-version }} - name: Install pyfluent - run: | - make install + run: make install - name: Test import - run: | - make test-import + run: make test-import + + test-post-import: + name: Post Import + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest] + python-version: ['3.7', '3.8', '3.9', '3.10'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Linux pip cache + uses: actions/cache@v2 + if: ${{ runner.os == 'Linux' }} + with: + path: ~/.cache/pip + key: Python-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('setup.py') }}-${{ hashFiles('requirements_*.txt') }} + restore-keys: | + Python-${{ runner.os }}-${{ matrix.python-version }} + + - name: Window pip cache + uses: actions/cache@v2 + if: ${{ runner.os == 'Windows' }} + with: + path: ~\AppData\Local\pip\Cache + key: Python-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('setup.py') }}-${{ hashFiles('requirements_*.txt') }} + restore-keys: | + Python-${{ runner.os }}-${{ matrix.python-version }} + + - name: Install pyfluent + run: make install + + - name: Test post import + run: make test-post-import build_test: name: Build and Unit Testing - needs: testimport + needs: test-import runs-on: ubuntu-latest steps: @@ -96,12 +134,10 @@ jobs: Python-${{ runner.os }}-${{ matrix.python-version }} - name: Install pyfluent - run: | - make install + run: make install - name: Unit Testing - run: | - make unittest + run: make unittest - name: Check package run: | diff --git a/Makefile b/Makefile index 0f42b338e51..f0a5b9bf4f0 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ install: test-import: @python -c "import ansys.fluent.solver as pyfluent" +test-post-import: + @python -c "import ansys.fluent.postprocessing.pyvista as pv" + unittest: @echo "Running unittest" @pip install -r requirements_test.txt diff --git a/README.rst b/README.rst index 32d8ae53f9d..571f04adb44 100644 --- a/README.rst +++ b/README.rst @@ -25,17 +25,8 @@ Usage session.check_health() session.tui.solver.file.read_case(case_file_name='elbow.cas.gz') session.tui.solver.define.models.unsteady_2nd_order("yes") - session.tui.solver.solve.initialize.initialize_flow() + session.tui.solver.solve.initialize.initialize_flow() session.tui.solver.solve.dual_time_iterate(number_of_time_steps=2, maximum_number_of_iterations_per_time_step=3) - session.tui.solver.display.objects.contour['contour-1'] = {'boundary_values': True, 'color_map': {'color': 'field-velocity', 'font_automatic': True, 'font_name': 'Helvetica', 'font_size': 0.032, 'format': '%0.2e', 'length': 0.54, 'log_scale': False, 'position': 1, 'show_all': True, 'size': 100, 'user_skip': 9, 'visible': True, 'width': 6.0}, 'coloring': {'smooth': False}, 'contour_lines': False, 'display_state_name': 'None', 'draw_mesh': False, 'field': 'pressure', 'filled': True, 'mesh_object': '', 'node_values': True, 'range_option': {'auto_range_on': {'global_range': True}}, 'surfaces_list': [2, 5]} - session.tui.solver.display.objects.contour['contour-1']() - session.tui.solver.display.objects.contour['contour-1'].field.set_state('velocity-magnitude') - session.tui.solver.display.objects.contour['contour-1'].field() - session.tui.solver.display.objects.contour['contour-1'].color_map.size.set_state(80.0) - session.tui.solver.display.objects.contour['contour-1'].color_map.size() - session.tui.solver.display.objects.contour['contour-1'].rename('my-contour') - del session.tui.solver.display.objects.contour['my-contour'] - Settings objects **************** @@ -56,3 +47,78 @@ Settings objects provide a more natural way to access and modify Fluent settings Meshing TUI and workflow ************************ TUI and meshing workflows from Fluent meshing are exposed. Please check `meshing.rst `_ for example usage. + + +Post Processing +*************** + +In Fluent (server) +^^^^^^^^^^^^^^^^^^ + +.. code:: python + + session.tui.solver.display.objects.contour['contour-1'] = {'boundary_values': True, 'color_map': {'color': 'field-velocity', 'font_automatic': True, 'font_name': 'Helvetica', 'font_size': 0.032, 'format': '%0.2e', 'length': 0.54, 'log_scale': False, 'position': 1, 'show_all': True, 'size': 100, 'user_skip': 9, 'visible': True, 'width': 6.0}, 'coloring': {'smooth': False}, 'contour_lines': False, 'display_state_name': 'None', 'draw_mesh': False, 'field': 'pressure', 'filled': True, 'mesh_object': '', 'node_values': True, 'range_option': {'auto_range_on': {'global_range': True}}, 'surfaces_list': [2, 5]} + session.tui.solver.display.objects.contour['contour-1']() + session.tui.solver.display.objects.contour['contour-1'].field.set_state('velocity-magnitude') + session.tui.solver.display.objects.contour['contour-1'].field() + session.tui.solver.display.objects.contour['contour-1'].color_map.size.set_state(80.0) + session.tui.solver.display.objects.contour['contour-1'].color_map.size() + session.tui.solver.display.objects.contour['contour-1'].rename('my-contour') + del session.tui.solver.display.objects.contour['my-contour'] + +PyVista (client) +^^^^^^^^^^^^^^^^ + +.. code:: python + + #import module + import ansys.fluent.postprocessing.pyvista as pv + + #get the graphics objects for the session + + graphics_session1 = pv.Graphics(session) + mesh1 = graphics_session1.Meshes["mesh-1"] + contour1 = graphics_session1.Contours["contour-1"] + contour2 = graphics_session1.Contours["contour-2"] + surface1 = graphics_session1.Surfaces["surface-1"] + + #set graphics objects properties + + #mesh + mesh1.show_edges = True + mesh1.surfaces_list = ['symmetry'] + + #contour + contour1.field = "velocity-magnitude" + contour1.surfaces_list = ['symmetry'] + + contour2.field = "temperature" + contour2.surfaces_list = ['symmetry', 'wall'] + + #copy + graphics_session1.Contours["contour-3"] = contour2() + + #update + contour3 = graphics_session1.Contours["contour-3"] + contour3.update(contour1()) + + #delete + del graphics_session1.Contours["contour-3"] + + #loop + for name, _ in graphics_session1.Contours.items(): + print(name) + + #iso surface + surface1.surface_type.iso_surface.field= "velocity-magnitude" + surface1.surface_type.iso_surface.rendering= "contour" + + #display in default plotter + contour1.display() + mesh1.display() + surface1.display() + + #display in seprate plotter e.g. plotter-2 + contour1.display("plotter-2") + + session.exit() \ No newline at end of file diff --git a/ansys/fluent/postprocessing/pyvista/__init__.py b/ansys/fluent/postprocessing/pyvista/__init__.py new file mode 100644 index 00000000000..c1552773302 --- /dev/null +++ b/ansys/fluent/postprocessing/pyvista/__init__.py @@ -0,0 +1,2 @@ +from ansys.fluent.postprocessing.pyvista.plotter import plotter # noqa: F401 +from ansys.fluent.postprocessing.pyvista.graphics import Graphics # noqa: F401 diff --git a/ansys/fluent/postprocessing/pyvista/graphics.py b/ansys/fluent/postprocessing/pyvista/graphics.py new file mode 100644 index 00000000000..80c63753b61 --- /dev/null +++ b/ansys/fluent/postprocessing/pyvista/graphics.py @@ -0,0 +1,322 @@ +import sys +from typing import Optional +from ansys.fluent.postprocessing.pyvista.plotter import plotter +from ansys.fluent.solver.meta import ( + Attribute, + PyLocalNamedObjectMeta, + PyLocalPropertyMeta, + PyLocalContainer, +) + + +class Graphics: + """ + Graphics objects provider. + """ + + def __init__(self, session): + self.session = session + self._init_module(self, sys.modules[__name__]) + + def _init_module(self, obj, mod): + for name, cls in mod.__dict__.items(): + if cls.__class__.__name__ == "PyLocalNamedObjectMeta": + setattr(obj, cls.PLURAL, PyLocalContainer(obj, cls)) + + +class Mesh(metaclass=PyLocalNamedObjectMeta): + """ + Mesh graphics. + """ + + PLURAL = "Meshes" + + def display(self, plotter_id: Optional[str] = None): + """ + Display mesh graphics. + """ + plotter.plot_graphics(self, plotter_id) + + class surfaces_list(metaclass=PyLocalPropertyMeta): + """ + List of surfaces for mesh graphics. + """ + + @Attribute + def allowed_values(self): + return list(self.session.field_data.get_surfaces_info().keys()) + + class show_edges(metaclass=PyLocalPropertyMeta): + """ + Show edges for mesh. + """ + + value = False + + +class Surface(metaclass=PyLocalNamedObjectMeta): + """ + Surface graphics. + """ + + PLURAL = "Surfaces" + + def display(self, plotter_id: Optional[str] = None): + """ + Display contour graphics. + """ + plotter.plot_graphics(self, plotter_id) + + class show_edges(metaclass=PyLocalPropertyMeta): + """ + Show edges for surface. + """ + + value = True + + class surface_type(metaclass=PyLocalPropertyMeta): + """ + Specify surface type. + """ + + def availability(self, name): + if name == "plane_surface": + return self.surface_type() == "plane-surface" + if name == "iso_surface": + return self.surface_type() == "iso-surface" + return True + + class surface_type(metaclass=PyLocalPropertyMeta): + value = "iso-surface" + + @Attribute + def allowed_values(self): + return ["plane_surface", "iso_surface"] + + class plane_surface(metaclass=PyLocalPropertyMeta): + """ + Plane surface data. + """ + + class iso_surface(metaclass=PyLocalPropertyMeta): + """ + Iso surface data. + """ + + class field(metaclass=PyLocalPropertyMeta): + """ + Iso surface field. + """ + + @Attribute + def allowed_values(self): + return [ + v["solver_name"] + for k, v in self.session.field_data.get_fields_info() + .items() + ] + + class rendering(metaclass=PyLocalPropertyMeta): + """ + Iso surface rendering. + """ + + value = "mesh" + + @Attribute + def allowed_values(self): + return ["mesh", "contour"] + + class iso_value(metaclass=PyLocalPropertyMeta): + """ + Iso surface iso value. + """ + + def _reset_on_change(self): + return [self.parent.field] + + @property + def value(self): + if getattr(self, "_value", None) == None: + range = self.range + self._value = range[0] if range else None + return self._value + + @value.setter + def value(self, value): + self._value = value + + @Attribute + def range(self): + field = self.parent.field() + if field: + return self.session.field_data.get_range(field, True) + + +class Contour(metaclass=PyLocalNamedObjectMeta): + """ + Contour graphics. + """ + + PLURAL = "Contours" + + def display(self, plotter_id: Optional[str] = None): + """ + Display Contour graphics. + """ + plotter.plot_graphics(self, plotter_id) + + class field(metaclass=PyLocalPropertyMeta): + """ + Contour field. + """ + + @Attribute + def allowed_values(self): + return [ + v["solver_name"] + for k, v in self.session.field_data.get_fields_info().items() + ] + + class surfaces_list(metaclass=PyLocalPropertyMeta): + """ + Contour surfaces. + """ + + @Attribute + def allowed_values(self): + return list(self.session.field_data.get_surfaces_info().keys()) + + class filled(metaclass=PyLocalPropertyMeta): + """ + Show filled contour. + """ + + value = True + + class node_values(metaclass=PyLocalPropertyMeta): + """ + Show nodal data. + """ + + value = True + + class boundary_values(metaclass=PyLocalPropertyMeta): + """ + Show boundary values. + """ + + value = False + + class contour_lines(metaclass=PyLocalPropertyMeta): + """ + Show contour lines. + """ + + value = False + + class show_edges(metaclass=PyLocalPropertyMeta): + """ + Show edges. + """ + + value = False + + class range_option(metaclass=PyLocalPropertyMeta): + """ + Specify range options. + """ + + def availability(self, name): + if name == "auto_range_on": + return self.range_option() == "auto-range-on" + if name == "auto_range_off": + return self.range_option() == "auto-range-off" + return True + + class range_option(metaclass=PyLocalPropertyMeta): + + value = "auto-range-on" + + @Attribute + def allowed_values(self): + return ["auto-range-on", "auto-range-off"] + + class auto_range_on(metaclass=PyLocalPropertyMeta): + """ + Specify auto range on. + """ + + class global_range(metaclass=PyLocalPropertyMeta): + """ + Show global range. + """ + + value = False + + class auto_range_off(metaclass=PyLocalPropertyMeta): + """ + Specify auto range off. + """ + + class clip_to_range(metaclass=PyLocalPropertyMeta): + """ + Clip contour within range. + """ + + value = False + + class minimum(metaclass=PyLocalPropertyMeta): + """ + Range minimum. + """ + + def _reset_on_change(self): + return [ + self.parent.parent.parent.field, + self.parent.parent.parent.node_values, + ] + + @property + def value(self): + if getattr(self, "_value", None) == None: + field = self.parent.parent.parent.field() + if field: + field_range = self.session.field_data.get_range( + field, + self.parent.parent.parent.node_values(), + ) + self._value = field_range[0] + return self._value + + @value.setter + def value(self, value): + self._value = value + + class maximum(metaclass=PyLocalPropertyMeta): + """ + Range maximum. + """ + + def _reset_on_change(self): + return [ + self.parent.parent.parent.field, + self.parent.parent.parent.node_values, + ] + + @property + def value(self): + if getattr(self, "_value", None) == None: + field = self.parent.parent.parent.field() + if field: + field_range = self.session.field_data.get_range( + field, + self.parent.parent.parent.node_values(), + ) + self._value = field_range[1] + + return self._value + + @value.setter + def value(self, value): + self._value = value diff --git a/ansys/fluent/postprocessing/pyvista/plotter.py b/ansys/fluent/postprocessing/pyvista/plotter.py new file mode 100644 index 00000000000..83750e97ec6 --- /dev/null +++ b/ansys/fluent/postprocessing/pyvista/plotter.py @@ -0,0 +1,350 @@ +import sys +import threading +#import signal +from typing import Optional +import numpy as np +from pyvistaqt import BackgroundPlotter +import pyvista as pv + + +class Singleton(type): + __instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls.__instances: + cls.__instances[cls] = super(Singleton, cls).__call__( + *args, **kwargs + ) + return cls.__instances[cls] + + +class _Plotter(metaclass=Singleton): + """ + Plot the graphics object. + + Properties + ---------- + background_plotter + BackgroundPlotter to plot graphics. + + Methods + ------- + plot_graphics(obj, plotter_id: str) + Plot graphics. + """ + + __condition = threading.Condition() + + def __init__(self): + self.__exit = False + self.__active_plotter = None + self.__graphics = {} + self.__plotter_thread = None + self.__plotters = {} + + def plot_graphics( + self, obj: object, plotter_id: Optional[str] = None + ) -> None: + if self.__exit: + return + if not plotter_id: + plotter_id = obj.session.id + with self.__condition: + self.__graphics[plotter_id] = obj + self.__active_plotter = self.__plotters.get(plotter_id) + + if not self.__plotter_thread: + self.__plotter_thread = threading.Thread( + target=self._display, args=(), daemon=True + ) + self.__plotter_thread.start() + + with self.__condition: + self.__condition.wait() + self.__plotters[plotter_id] = self.__active_plotter + + # private methods + + def _exit(self) -> None: + if self.__plotter_thread: + with self.__condition: + self.__exit = True + self.__condition.wait() + self.__plotter_thread.join() + self.__plotter_thread = None + + def _init_properties(self): + self.__active_plotter.theme.cmap = "jet" + self.__active_plotter.background_color = "white" + self.__active_plotter.theme.font.color = "black" + + def _display(self): + while True: + with self.__condition: + if self.__exit: + break + if ( + not self.__active_plotter or self.__active_plotter._closed + ) and len(self.__graphics) > 0: + plotter_id = next(iter(self.__graphics)) + self.__active_plotter = BackgroundPlotter( + title=f"PyFluent ({plotter_id})" + ) + self._init_properties() + self.__active_plotter.add_callback( + self._get_refresh_for_plotter(plotter_id), + 100, + ) + self.__active_plotter.app.processEvents() + with self.__condition: + for plotter in self.__plotters.values(): + plotter.close() + self.__active_plotter.app.quit() + self.__active_plotter = None + self.__plotters.clear() + self.__condition.notify() + + def _display_contour(self, obj): + if not obj.surfaces_list() or not obj.field(): + raise RuntimeError("Contour definition is incomplete.") + + # contour properties + field = obj.field() + range_option = obj.range_option.range_option() + filled = obj.filled() + contour_lines = obj.contour_lines() + node_values = obj.node_values() + boundary_values = obj.boundary_values() + + # scalar bar properties + scalar_bar_args = dict( + title_font_size=20, + label_font_size=16, + shadow=True, + fmt="%.6e", + font_family="arial", + vertical=True, + position_x=0.06, + position_y=0.3, + ) + + field_data = obj.session.field_data + surfaces_info = field_data.get_surfaces_info() + surface_ids = [ + id + for surf in obj.surfaces_list() + for id in surfaces_info[surf]["surface_id"] + ] + # get scalar field data + scalar_field_data = field_data.get_scalar_field( + surface_ids, + field, + node_values, + boundary_values, + ) + meta_data = None + plotter = self.__active_plotter + + # loop over all meshes + for mesh_data in scalar_field_data: + + topology = "line" if mesh_data["faces"][0][0] == 2 else "face" + if topology == "line": + mesh = pv.PolyData( + np.array(mesh_data["vertices"]), + lines=np.hstack(mesh_data["faces"]), + ) + else: + mesh = pv.PolyData( + np.array(mesh_data["vertices"]), + faces=np.hstack(mesh_data["faces"]), + ) + if node_values: + mesh.point_data[field] = np.array(mesh_data["scalar_field"]) + else: + mesh.cell_data[field] = np.array(mesh_data["scalar_field"]) + if not meta_data: + meta_data = mesh_data["meta_data"] + + if range_option == "auto-range-off": + auto_range_off = obj.range_option.auto_range_off + if auto_range_off.clip_to_range(): + if np.min(mesh[field]) < auto_range_off.maximum(): + maximum_below = mesh.clip_scalar( + scalars=field, + value=auto_range_off.maximum(), + ) + if ( + np.max(maximum_below[field]) + > auto_range_off.minimum() + ): + minimum_above = maximum_below.clip_scalar( + scalars=field, + invert=False, + value=auto_range_off.minimum(), + ) + if filled: + plotter.add_mesh( + minimum_above, + scalars=field, + show_edges=obj.show_edges(), + scalar_bar_args=scalar_bar_args, + ) + + if (not filled or contour_lines) and ( + np.min(minimum_above[field]) + != np.max(minimum_above[field]) + ): + plotter.add_mesh( + minimum_above.contour(isosurfaces=20) + ) + else: + if filled: + plotter.add_mesh( + mesh, + clim=[ + auto_range_off.minimum(), + auto_range_off.maximum(), + ], + scalars=field, + show_edges=obj.show_edges(), + scalar_bar_args=scalar_bar_args, + ) + if (not filled or contour_lines) and ( + np.min(mesh[field]) != np.max(mesh[field]) + ): + plotter.add_mesh(mesh.contour(isosurfaces=20)) + else: + auto_range_on = obj.range_option.auto_range_on + if auto_range_on.global_range(): + if filled: + plotter.add_mesh( + mesh, + clim=[ + meta_data.scalarFieldrange.globalmin, + meta_data.scalarFieldrange.globalmax, + ], + scalars=field, + show_edges=obj.show_edges(), + scalar_bar_args=scalar_bar_args, + ) + if (not filled or contour_lines) and ( + np.min(mesh[field]) != np.max(mesh[field]) + ): + plotter.add_mesh(mesh.contour(isosurfaces=20)) + + else: + if filled: + plotter.add_mesh( + mesh, + scalars=field, + show_edges=obj.show_edges(), + scalar_bar_args=scalar_bar_args, + ) + if (not filled or contour_lines) and ( + np.min(mesh[field]) != np.max(mesh[field]) + ): + plotter.add_mesh(mesh.contour(isosurfaces=20)) + + def _display_iso_surface(self, obj): + field = obj.surface_type.iso_surface.field() + if not field: + raise RuntimeError("Iso surface definition is incomplete.") + + dummy_surface_name = "_dummy_iso_surface_for_pyfluent" + surfaces_list = list(obj.session.field_data.get_surfaces_info().keys()) + iso_value = obj.surface_type.iso_surface.iso_value() + if dummy_surface_name in surfaces_list: + obj.session.tui.solver.surface.delete_surface(dummy_surface_name) + + obj.session.tui.solver.surface.iso_surface( + field, dummy_surface_name, (), (), iso_value, () + ) + + from ansys.fluent.postprocessing.pyvista.graphics import Graphics + + surfaces_list = list(obj.session.field_data.get_surfaces_info().keys()) + if not dummy_surface_name in surfaces_list: + raise RuntimeError("Iso surface creation failed.") + graphics_session = Graphics(obj.session) + if obj.surface_type.iso_surface.rendering() == "mesh": + mesh = graphics_session.Meshes[dummy_surface_name] + mesh.surfaces_list = [dummy_surface_name] + mesh.show_edges = True + self._display_mesh(mesh) + del graphics_session.Meshes[dummy_surface_name] + else: + contour = graphics_session.Contours[dummy_surface_name] + contour.field = obj.surface_type.iso_surface.field() + contour.surfaces_list = [dummy_surface_name] + contour.show_edges = True + contour.range_option.auto_range_on.global_range = True + self._display_contour(contour) + del graphics_session.Contours[dummy_surface_name] + obj.session.tui.solver.surface.delete_surface(dummy_surface_name) + + def _display_mesh(self, obj): + if not obj.surfaces_list(): + raise RuntimeError("Mesh definition is incomplete.") + field_data = obj.session.field_data + surfaces_info = field_data.get_surfaces_info() + surface_ids = [ + id + for surf in obj.surfaces_list() + for id in surfaces_info[surf]["surface_id"] + ] + surfaces_data = field_data.get_surfaces(surface_ids) + for mesh_data in surfaces_data: + topology = "line" if mesh_data["faces"][0][0] == 2 else "face" + if topology == "line": + mesh = pv.PolyData( + np.array(mesh_data["vertices"]), + lines=np.hstack(mesh_data["faces"]), + ) + else: + mesh = pv.PolyData( + np.array(mesh_data["vertices"]), + faces=np.hstack(mesh_data["faces"]), + ) + self.__active_plotter.add_mesh( + mesh, show_edges=obj.show_edges(), color="lightgrey" + ) + + def _get_refresh_for_plotter(self, plotter_id: str): + def refresh(): + with self.__condition: + obj = self.__graphics.get(plotter_id) + if not obj: + self.__condition.notify() + return + + del self.__graphics[plotter_id] + plotter = self.__active_plotter + plotter.clear() + + camera = plotter.camera.copy() + + if obj.__class__.__name__ == "Mesh": + self._display_mesh(obj) + elif obj.__class__.__name__ == "Surface": + if obj.surface_type.surface_type() == "iso-surface": + self._display_iso_surface(obj) + elif obj.__class__.__name__ == "Contour": + self._display_contour(obj) + + plotter.camera = camera.copy() + self.__condition.notify() + + return refresh + + +plotter = _Plotter() + + +def signal_handler(sig, frame): + plotter._exit() + sys.exit(0) + + +# Need to associate ctrl+z signal +# signal.signal(signal.SIGINT, signal_handler) diff --git a/ansys/fluent/services/field_data.py b/ansys/fluent/services/field_data.py new file mode 100644 index 00000000000..47b1daed815 --- /dev/null +++ b/ansys/fluent/services/field_data.py @@ -0,0 +1,154 @@ +from typing import List, Dict +import grpc +from ansys.api.fluent.v0 import fielddata_pb2 as FieldDataProtoModule +from ansys.api.fluent.v0 import fielddata_pb2_grpc as FieldGrpcModule + + +class FieldDataService: + def __init__(self, channel: grpc.Channel, metadata): + self.__stub = FieldGrpcModule.FieldDataStub(channel) + self.__metadata = metadata + + def get_surfaces(self, request): + return self.__stub.GetSurfaces(request, metadata=self.__metadata) + + def get_range(self, request): + return self.__stub.GetRange(request, metadata=self.__metadata) + + def get_scalar_field(self, request): + return self.__stub.GetScalarField(request, metadata=self.__metadata) + + def get_fields_info(self, request): + return self.__stub.GetFieldsInfo(request, metadata=self.__metadata) + + def get_surfaces_info(self, request): + return self.__stub.GetSurfacesInfo(request, metadata=self.__metadata) + + +class FieldData: + """ + Provide the field data. + + Methods + ------- + get_range(field: str, node_value: bool, surface_ids: List[int]) + -> List[float] + Get field range i.e. minimum and maximum value. + + get_fields_info(self) -> dict + Get fields information i.e. field name, domain and section. + + get_surfaces_info(self) -> dict + Get surfaces information i.e. surface name, id and type. + + get_surfaces(surface_ids: List[int], overset_mesh: bool) -> List[Dict] + Get surfaces data i.e. coordinates and connectivity. + + def get_scalar_field( + surface_ids: List[int], scalar_field: str, node_value: bool, + boundary_value: bool) -> List[Dict] + Get field data i.e. surface data and associated scalar field values. + + """ + + def __init__(self, service: FieldDataService): + self.__service = service + + def get_range( + self, field: str, node_value: bool = False, surface_ids: List[int] = [] + ) -> List[float]: + request = FieldDataProtoModule.GetRangeRequest() + request.fieldName = field + request.nodeValue = node_value + request.surfaceid.extend( + [FieldDataProtoModule.SurfaceId(id=int(id)) for id in surface_ids] + ) + response = self.__service.get_range(request) + return [response.minimum, response.maximum] + + def get_fields_info(self) -> dict: + request = FieldDataProtoModule.GetFieldsInfoRequest() + response = self.__service.get_fields_info(request) + return { + field_info.displayName: { + "solver_name": field_info.solverName, + "section": field_info.section, + "domain": field_info.domain, + } + for field_info in response.fieldInfo + } + + def get_surfaces_info(self) -> dict: + request = FieldDataProtoModule.GetSurfacesInfoResponse() + response = self.__service.get_surfaces_info(request) + return { + surface_info.surfaceName: { + "surface_id": [surf.id for surf in surface_info.surfaceId], + "zone_id": surface_info.zoneId.id, + "zone_type": surface_info.zoneType, + "type": surface_info.type, + } + for surface_info in response.surfaceInfo + } + + def _extract_surfaces_data(self, response_iterator): + return [ + { + "vertices": [ + [point.x, point.y, point.z] + for point in response.surfacedata.point + ], + "faces": [ + [len(facet.node)] + [node for node in facet.node] + for facet in response.surfacedata.facet + ], + } + for response in response_iterator + ] + + def get_surfaces( + self, surface_ids: List[int], overset_mesh: bool = False + ) -> List[Dict]: + request = FieldDataProtoModule.GetSurfacesRequest() + request.surfaceid.extend( + [FieldDataProtoModule.SurfaceId(id=int(id)) for id in surface_ids] + ) + request.oversetMesh = overset_mesh + response_iterator = self.__service.get_surfaces(request) + return self._extract_surfaces_data(response_iterator) + + def _extract_scalar_field_data(self, response_iterator): + return [ + { + "vertices": [ + [point.x, point.y, point.z] + for point in response.scalarfielddata.surfacedata.point + ], + "faces": [ + [len(facet.node)] + [node for node in facet.node] + for facet in response.scalarfielddata.surfacedata.facet + ], + "scalar_field": [ + data for data in response.scalarfielddata.scalarfield.data + ], + "meta_data": response.scalarfielddata.scalarfieldmetadata, + } + for response in response_iterator + ] + + def get_scalar_field( + self, + surface_ids: List[int], + scalar_field: str, + node_value: bool, + boundary_value: bool, + ) -> List[Dict]: + request = FieldDataProtoModule.GetScalarFieldRequest() + request.surfaceid.extend( + [FieldDataProtoModule.SurfaceId(id=int(id)) for id in surface_ids] + ) + request.scalarfield = scalar_field + request.nodevalue = node_value + request.boundaryvalues = boundary_value + response_iterator = self.__service.get_scalar_field(request) + return self._extract_scalar_field_data(response_iterator) diff --git a/ansys/fluent/session.py b/ansys/fluent/session.py index 9b43ef698f0..cccfd0f524c 100644 --- a/ansys/fluent/session.py +++ b/ansys/fluent/session.py @@ -1,4 +1,5 @@ import atexit +import itertools from threading import Lock, Thread import grpc @@ -14,6 +15,7 @@ from ansys.fluent.services.datamodel_tui import PyMenu as PyMenu_TUI from ansys.fluent.services.health_check import HealthCheckService from ansys.fluent.services.transcript import TranscriptService +from ansys.fluent.services.field_data import FieldDataService, FieldData from ansys.fluent.services.settings import SettingsService from ansys.fluent.solver import flobject @@ -60,12 +62,14 @@ class Session: """ __all_sessions = [] + __on_exit_cbs = [] + __id_iter = itertools.count() def __init__(self, server_info_filepath): address, password = parse_server_info_file(server_info_filepath) self.__channel = grpc.insecure_channel(address) self.__metadata = [("password", password)] - + self.__id = f"session-{next(Session.__id_iter)}" self.__transcript_service: TranscriptService = None self.__transcript_thread: Thread = None self.__lock = Lock() @@ -75,6 +79,11 @@ def __init__(self, server_info_filepath): self.__datamodel_service_tui = DatamodelService_TUI( self.__channel, self.__metadata ) + + self.__field_data_service = FieldDataService( + self.__channel, self.__metadata + ) + self.field_data = FieldData(self.__field_data_service) self.tui = Session.Tui(self.__datamodel_service_tui) self.__datamodel_service_se = DatamodelService_SE( @@ -89,6 +98,10 @@ def __init__(self, server_info_filepath): Session.__all_sessions.append(self) + @property + def id(self): + return self.__id + def setup_settings_objects(self): proxy = SettingsService(self.__channel, self.__metadata) r = flobject.get_root(flproxy=proxy) @@ -152,10 +165,16 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.exit() + @classmethod + def register_on_exit(cls, callback): + cls.__on_exit_cbs.append(callback) + @staticmethod def exit_all(): for session in Session.__all_sessions: session.exit() + for cb in Session.__on_exit_cbs: + cb() class Tui: def __init__(self, service): diff --git a/ansys/fluent/solver/meta.py b/ansys/fluent/solver/meta.py index 78bdf87c8a6..dd077b05a03 100644 --- a/ansys/fluent/solver/meta.py +++ b/ansys/fluent/solver/meta.py @@ -1,10 +1,35 @@ +from collections.abc import MutableMapping +from pprint import pformat + # pylint: disable=unused-private-member # pylint: disable=bad-mcs-classmethod-argument - from ansys.fluent.services.datamodel_tui import ( PyMenu, - convert_path_to_grpc_path - ) + convert_path_to_grpc_path, +) + + +class Attribute: + VALID_NAMES = ["range", "allowed_values"] + + def __init__(self, function): + self.function = function + + def __set_name__(self, obj, name): + if not name in self.VALID_NAMES: + raise ValueError( + f"Attribute {name} is not allowed." + f"Expected values are {self.VALID_NAMES}" + ) + if not hasattr(obj, "attributes"): + obj.attributes = set() + obj.attributes.add(name) + + def __set__(self, obj, value): + raise AttributeError("Attributes are read only.") + + def __get__(self, obj, type=None): + return self.function(obj) class PyMenuMeta(type): @@ -16,7 +41,9 @@ def wrapper(self, path, service): for name, cls in self.__class__.__dict__.items(): if cls.__class__.__name__ == "PyMenuMeta": setattr( - self, name, cls(self.path + [(name, None)], service) + self, + name, + cls(self.path + [(name, None)], service), ) if cls.__class__.__name__ == "PyNamedObjectMeta": setattr( @@ -65,6 +92,313 @@ def __new__(cls, name, bases, attrs): return super(PyMenuMeta, cls).__new__(cls, name, bases, attrs) +class PyLocalPropertyMeta(type): + @classmethod + def __create_validate(cls): + def wrapper(self, value): + old_value = self() + if old_value and type(old_value) != type(value): + raise TypeError( + f"Value {value}, should be of type {type(old_value)}" + ) + attrs = getattr(self, "attributes", None) + if attrs: + for attr in attrs: + if attr == "range" and ( + value < self.range[0] or value > self.range[1] + ): + raise ValueError( + f"Value {value}, is not within valid range" + f" {self.range}." + ) + if attr == "allowed_values": + if isinstance(value, list): + if not all( + v in self.allowed_values for v in value + ): + raise ValueError( + f"Not all values in {value}, are in the " + "list of allowed values " + f"{self.allowed_values}." + ) + elif not value in self.allowed_values: + raise ValueError( + f"Value {value}, is not in the list of " + f"allowed values {self.allowed_values}." + ) + + return value + + return wrapper + + @classmethod + def __create_init(cls): + def wrapper(self, parent): + self.session = parent.session + self.parent = parent + self._on_change_cbs = [] + reset_on_change = ( + hasattr(self, "_reset_on_change") + and getattr(self, "_reset_on_change")() + ) + if reset_on_change: + for obj in reset_on_change: + obj._register_on_change_cb( + lambda: setattr(self, "_value", None) + ) + for name, cls in self.__class__.__dict__.items(): + if cls.__class__.__name__ == "PyLocalPropertyMeta": + setattr( + self, + name, + cls(self), + ) + if cls.__class__.__name__ == "PyLocalNamedObjectMeta": + setattr( + self, + cls.PLURAL, + PyLocalContainer(self, cls), + ) + + return wrapper + + @classmethod + def __create_getattribute(cls): + def wrapper(self, name): + if name == "availability": + return object.__getattribute__(self, "availability") + availability = ( + getattr(self, "availability")(name) + if hasattr(self, "availability") + else True + ) + if availability: + return object.__getattribute__(self, name) + else: + return None + + return wrapper + + @classmethod + def __create_get_state(cls): + def wrapper(self, show_attributes=False): + state = {} + for name, cls in self.__class__.__dict__.items(): + if cls.__class__.__name__ == "PyLocalPropertyMeta": + availability = ( + getattr(self, "availability")(name) + if hasattr(self, "availability") + else True + ) + if availability: + o = getattr(self, name) + state[name] = o(show_attributes) + attrs = show_attributes and getattr( + o, "attributes", False + ) + if attrs: + for attr in attrs: + state[name + "." + attr] = getattr(o, attr) + + if len(state) > 0: + return state + else: + try: + return self.value + except: + return None + + return wrapper + + @classmethod + def __create_set_state(cls): + def wrapper(self, value): + if isinstance(value, dict): + for k, v in value.items(): + setattr(self, k, v) + else: + self.value = self._validate(value) + for on_change_cb in self._on_change_cbs: + on_change_cb() + + return wrapper + + @classmethod + def __create_setattr(cls): + def wrapper(self, name, value): + attr = getattr(self, name, None) + if ( + attr + and attr.__class__.__class__.__name__ == "PyLocalPropertyMeta" + ): + attr.set_state(value) + else: + object.__setattr__(self, name, value) + + return wrapper + + @classmethod + def __create_register_on_change(cls): + def wrapper(self, on_change_cb): + self._on_change_cbs.append(on_change_cb) + + return wrapper + + @classmethod + def __create_repr(cls): + def wrapper(self): + data = self(True) + if isinstance(data, dict): + return pformat(data, depth=1, indent=2) + else: + return f"{data}" + + return wrapper + + def __new__(cls, name, bases, attrs): + attrs["__init__"] = cls.__create_init() + attrs["__call__"] = cls.__create_get_state() + attrs["__getattribute__"] = cls.__create_getattribute() + attrs["__setattr__"] = cls.__create_setattr() + attrs["__repr__"] = cls.__create_repr() + attrs["_validate"] = cls.__create_validate() + attrs["_register_on_change_cb"] = cls.__create_register_on_change() + attrs["set_state"] = cls.__create_set_state() + attrs["parent"] = None + return super(PyLocalPropertyMeta, cls).__new__(cls, name, bases, attrs) + + +class PyLocalNamedObjectMeta(type): + @classmethod + def __create_init(cls): + def wrapper(self, name, parent): + self.__name = name + self.session = parent.session + self.parent = parent + for name, cls in self.__class__.__dict__.items(): + if cls.__class__.__name__ == "PyLocalPropertyMeta": + setattr( + self, + name, + cls(self), + ) + if cls.__class__.__name__ == "PyLocalNamedObjectMeta": + setattr( + self, + cls.PLURAL, + PyLocalContainer(self, cls), + ) + + return wrapper + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # c1 = graphics.contour['contour-1'] + # c2 = graphics.contour['contour-2'] + # c1.update(c2()) + @classmethod + def __create_updateitem(cls): + def wrapper(self, value): + for name, val in value.items(): + getattr(self, name).set_state(val) + + return wrapper + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # graphics.contour['contour-1']() + @classmethod + def __create_get_state(cls): + def wrapper(self, show_attributes=False): + state = {} + for name, cls in self.__class__.__dict__.items(): + if cls.__class__.__name__ == "PyLocalPropertyMeta": + availability = ( + getattr(self, "availability")(name) + if hasattr(self, "availability") + else True + ) + if availability: + o = getattr(self, name) + state[name] = o(show_attributes) + attrs = show_attributes and getattr( + o, "attributes", None + ) + if attrs: + for attr in attrs: + state[name + "." + attr] = getattr(o, attr) + return state + + return wrapper + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # graphics.contour['contour-1'].field = "temperature" + @classmethod + def __create_setattr(cls): + def wrapper(self, name, value): + attr = getattr(self, name, None) + if ( + attr + and attr.__class__.__class__.__name__ == "PyLocalPropertyMeta" + ): + attr.set_state(value) + else: + object.__setattr__(self, name, value) + + return wrapper + + @classmethod + def __create_repr(cls): + def wrapper(self): + return pformat(self(True), depth=1, indent=2) + + return wrapper + + def __new__(cls, name, bases, attrs): + attrs["__init__"] = cls.__create_init() + attrs["__call__"] = cls.__create_get_state() + attrs["__setattr__"] = cls.__create_setattr() + attrs["__repr__"] = cls.__create_repr() + attrs["update"] = cls.__create_updateitem() + attrs["parent"] = None + return super(PyLocalNamedObjectMeta, cls).__new__( + cls, name, bases, attrs + ) + + +class PyLocalContainer(MutableMapping): + def __init__(self, parent, object_class): + self.__object_class = object_class + self.__parent = parent + self.__collection: dict = {} + + def __iter__(self): + return iter(self.__collection) + + def __len__(self): + return len(self.__collection) + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # c1 = graphics.Contours['contour-1'] + def __getitem__(self, name): + o = self.__collection.get(name, None) + if not o: + o = self.__collection[name] = self.__object_class( + name, self.__parent + ) + return o + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # c1 = graphics.Contours['contour-1'] + # graphics.Contours['contour-2'] = c1() + def __setitem__(self, name, value): + o = self[name] + o.update(value) + + # graphics = ansys.fluent.postprocessing.pyvista.Graphics(session1) + # del graphics.Contours['contour-1'] + def __delitem__(self, name): + del self.__collection[name] + + class PyNamedObjectMeta(type): @classmethod def __create_init(cls): @@ -74,7 +408,9 @@ def wrapper(self, path, name, service): for name, cls in self.__class__.__dict__.items(): if cls.__class__.__name__ == "PyMenuMeta": setattr( - self, name, cls(self.path + [(name, None)], service) + self, + name, + cls(self.path + [(name, None)], service), ) if cls.__class__.__name__ == "PyNamedObjectMeta": setattr( diff --git a/grpc/ansys-api-fluent-v0-0.0.1.tar.gz b/grpc/ansys-api-fluent-v0-0.0.1.tar.gz deleted file mode 100644 index 4fcebd103dd..00000000000 Binary files a/grpc/ansys-api-fluent-v0-0.0.1.tar.gz and /dev/null differ diff --git a/setup.py b/setup.py index c51ba0aad5d..a2ee41c97c5 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,8 @@ """Setup file for ansys-fluent-solver""" import os +import platform +import struct +import sys from setuptools import find_namespace_packages, setup @@ -15,9 +18,24 @@ install_requires = [ "grpcio>=1.30.0", - "protobuf>=3.12.2" + "numpy>=1.21.5", + "pyvista>=0.33.2", + "protobuf>=3.12.2", + "pyvistaqt>=0.7.0", + "PySide6>=6.2.3", ] +is64 = struct.calcsize("P") * 8 == 64 +if sys.version_info.minor == 10 and is64: + if platform.system().lower() == "linux": + install_requires.append( + "vtk @ https://github.com/pyvista/pyvista-wheels/raw/main/vtk-9.1.0.dev0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" # noqa: E501 + ) + elif platform.system().lower() == "windows": + install_requires.append( + "vtk @ https://github.com/pyvista/pyvista-wheels/raw/main/vtk-9.1.0.dev0-cp310-cp310-win_amd64.whl" # noqa: E501 + ) + packages = [] for package in find_namespace_packages(include="ansys*"): if package.startswith("ansys.api"):