diff --git a/doc/source/api/index.rst b/doc/source/api/index.rst index 151e3ef1e..3b50fe054 100644 --- a/doc/source/api/index.rst +++ b/doc/source/api/index.rst @@ -16,6 +16,7 @@ For in-depth documentation on the different failure criteria, refer to the ACP h data_sources failure_criteria layup_info + ply_wise_data result_definition sampling_point server_helpers diff --git a/doc/source/api/ply_wise_data.rst b/doc/source/api/ply_wise_data.rst new file mode 100644 index 000000000..d34328fea --- /dev/null +++ b/doc/source/api/ply_wise_data.rst @@ -0,0 +1,12 @@ +Ply wise data +------------- + +.. module:: ansys.dpf.composites.ply_wise_data + +.. autosummary:: + :toctree: _autosummary + + ReductionStrategy + get_ply_wise_data + + diff --git a/examples/006_filter_composite_data_example.py b/examples/006_filter_composite_data_example.py index fb832e296..a71696e07 100644 --- a/examples/006_filter_composite_data_example.py +++ b/examples/006_filter_composite_data_example.py @@ -4,9 +4,15 @@ Filter result data by different criteria ---------------------------------------- -This example show how data filtering can be used for custom postprocessing of +This example shows how data filtering can be used for custom postprocessing of layered composites. You can filter strains and stresses by material, layer, or -analysis ply. The example filters data by layer, spot, and node, as well as material +analysis ply. Filtering by analysis ply is implemented on the server side and +exposed with the :func:`.get_ply_wise_data` function. In this case, the data is +filtered (and reduced) on the server side and only the resulting field is returned +to the client. This is the recommended way to filter data if possible. +For more complex filtering, the data is transferred to the client side and filtered +using numpy functionality. +The examples show filtering data by layer, spot, and node, as well as material or analysis ply ID. To learn more about how layered result data is organized, see :ref:`select_indices`. """ @@ -24,17 +30,14 @@ from ansys.dpf.composites.composite_model import CompositeModel from ansys.dpf.composites.constants import Spot, Sym3x3TensorComponent from ansys.dpf.composites.example_helper import get_continuous_fiber_example_files -from ansys.dpf.composites.layup_info import ( - AnalysisPlyInfoProvider, - get_all_analysis_ply_names, - get_dpf_material_id_by_analysis_ply_map, -) +from ansys.dpf.composites.layup_info import AnalysisPlyInfoProvider, get_all_analysis_ply_names +from ansys.dpf.composites.ply_wise_data import ReductionStrategy, get_ply_wise_data from ansys.dpf.composites.select_indices import ( get_selected_indices, get_selected_indices_by_analysis_ply, get_selected_indices_by_dpf_material_ids, ) -from ansys.dpf.composites.server_helpers import connect_to_or_start_server +from ansys.dpf.composites.server_helpers import connect_to_or_start_server, version_equal_or_later # %% # Start a DPF server and copy the example files into the current working directory. @@ -55,6 +58,44 @@ stress_operator.inputs.bool_rotate_to_global(False) stress_field = stress_operator.get_output(pin=0, output_type=dpf.types.fields_container)[0] +# %% +# Filter data by analysis ply +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +# %% +# List all available analysis plies. +all_ply_names = get_all_analysis_ply_names(composite_model.get_mesh()) +all_ply_names + +# %% +# The easiest way to filter data by analysis ply is to use the :func:`.get_ply_wise_data` function. +# This function supports different reduction strategies such as computing the average, +# maximum, or minimum over the spot locations. +# It also supports selecting a specific spot (TOP, MID, BOT) directly. +# This example selects the maximum value over all spots for each node and then requests +# the elemental location, which implies averaging over all nodes in an element. +# Using the :func:`.get_ply_wise_data` function has the advantage that all the averaging +# and filtering is done on the server side. +if version_equal_or_later(server, "8.0"): + elemental_max = get_ply_wise_data( + field=stress_field, + ply_name="P1L1__ud_patch ns1", + mesh=composite_model.get_mesh(), + component=Sym3x3TensorComponent.TENSOR11, + reduction_strategy=ReductionStrategy.MAX, + requested_location=dpf.locations.elemental, + ) + + composite_model.get_mesh().plot(elemental_max) + + +# %% +# Generic client-side filtering +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# This example shows how to filter data by layer, spot, and node using the generic filtering on +# the client side. +# This code plots stress values in the material direction for the first node and top spot. + # %% # Get element information for all elements and show the first one as an example. element_ids = stress_field.scoping.ids @@ -62,9 +103,7 @@ element_infos[0] # %% -# Plot result data -# ~~~~~~~~~~~~~~~~ -# For the top layer, plot stress values in the material direction for the first node and top spot. +# Get filtered data component = Sym3x3TensorComponent.TENSOR11 result_field = dpf.field.Field(location=dpf.locations.elemental, nature=dpf.natures.scalar) with result_field.as_local_field() as local_result_field: @@ -82,23 +121,45 @@ composite_model.get_mesh().plot(result_field) + # %% -# List analysis plies -# ~~~~~~~~~~~~~~~~~~~ -# List all available analysis plies. -all_ply_names = get_all_analysis_ply_names(composite_model.get_mesh()) -all_ply_names +# Filter by material +# ~~~~~~~~~~~~~~~~~~~~~ +# Loop over all elements and get the maximum stress in the material direction +# for all plies that have a specific UD material. + +ud_material_id = composite_model.material_names["Epoxy Carbon UD (230 GPa) Prepreg"] +component = Sym3x3TensorComponent.TENSOR11 + +material_result_field = dpf.field.Field(location=dpf.locations.elemental, nature=dpf.natures.scalar) +with material_result_field.as_local_field() as local_result_field: + element_ids = stress_field.scoping.ids + + for element_id in element_ids: + element_info = composite_model.get_element_info(element_id) + assert element_info is not None + if ud_material_id in element_info.dpf_material_ids: + stress_data = stress_field.get_entity_data_by_id(element_id) + selected_indices = get_selected_indices_by_dpf_material_ids( + element_info, [ud_material_id] + ) + + value = np.max(stress_data[selected_indices][:, component]) + local_result_field.append([value], element_id) + +composite_model.get_mesh().plot(material_result_field) # %% -# Plot results -# ~~~~~~~~~~~~ -# Loop all elements that contain a given ply and plot the maximum stress value +# Filter by analysis ply on the client side +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Loop over all elements that contain a given ply and plot the maximum stress value # in the material direction in this ply. component = Sym3x3TensorComponent.TENSOR11 analysis_ply_info_provider = AnalysisPlyInfoProvider( mesh=composite_model.get_mesh(), name="P1L1__ud_patch ns1" ) + ply_result_field = dpf.field.Field(location=dpf.locations.elemental, nature=dpf.natures.scalar) with ply_result_field.as_local_field() as local_result_field: element_ids = analysis_ply_info_provider.property_field.scoping.ids @@ -116,31 +177,3 @@ composite_model.get_mesh().plot(ply_result_field) - -# %% -# Loop all elements and get the maximum stress in the material direction -# for all plies that have a material with DPF material ID. -# Note: It is not possible to get a DPF material ID for a -# given material name. It is only possible to get a DPF material -# ID from an analysis ply. -material_map = get_dpf_material_id_by_analysis_ply_map( - composite_model.get_mesh(), data_source_or_streams_provider=composite_model.data_sources.rst -) -ud_material_id = material_map["P1L1__ud_patch ns1"] -component = Sym3x3TensorComponent.TENSOR11 - -material_result_field = dpf.field.Field(location=dpf.locations.elemental, nature=dpf.natures.scalar) -with material_result_field.as_local_field() as local_result_field: - element_ids = analysis_ply_info_provider.property_field.scoping.ids - - for element_id in element_ids: - stress_data = stress_field.get_entity_data_by_id(element_id) - element_info = composite_model.get_element_info(element_id) - assert element_info is not None - - selected_indices = get_selected_indices_by_dpf_material_ids(element_info, [ud_material_id]) - - value = np.max(stress_data[selected_indices][:, component]) - local_result_field.append([value], element_id) - -composite_model.get_mesh().plot(material_result_field) diff --git a/src/ansys/dpf/composites/__init__.py b/src/ansys/dpf/composites/__init__.py index 5bf3fa01d..58f4f7fec 100644 --- a/src/ansys/dpf/composites/__init__.py +++ b/src/ansys/dpf/composites/__init__.py @@ -14,6 +14,7 @@ data_sources, failure_criteria, layup_info, + ply_wise_data, result_definition, sampling_point, select_indices, @@ -27,6 +28,7 @@ "data_sources", "failure_criteria", "layup_info", + "ply_wise_data", "result_definition", "sampling_point", "server_helpers", diff --git a/src/ansys/dpf/composites/layup_info/_layup_info.py b/src/ansys/dpf/composites/layup_info/_layup_info.py index f8b758db4..af09fc68e 100644 --- a/src/ansys/dpf/composites/layup_info/_layup_info.py +++ b/src/ansys/dpf/composites/layup_info/_layup_info.py @@ -219,8 +219,8 @@ def get_dpf_material_id_by_analyis_ply_map( DPF data source with rst file or streams_provider. The streams provider is available from :attr:`.CompositeModel.core_model` (under metadata.streams_provider). - Note - ---- + Notes + ----- Cache the output because the computation can be performance-critical. """ warn( diff --git a/src/ansys/dpf/composites/ply_wise_data.py b/src/ansys/dpf/composites/ply_wise_data.py new file mode 100644 index 000000000..5618851cf --- /dev/null +++ b/src/ansys/dpf/composites/ply_wise_data.py @@ -0,0 +1,87 @@ +"""Methods to get ply-wise data from a result field.""" + +from enum import Enum, IntEnum +from typing import Union + +from ansys.dpf.core import Field, MeshedRegion, Operator, operators +from ansys.dpf.gate.common import locations + +__all__ = ("ReductionStrategy", "get_ply_wise_data") + + +class ReductionStrategy(Enum): + """Provides the reduction strategy for getting from spot values to a single value.""" + + MIN = "MIN" + MAX = "MAX" + AVG = "AVG" + BOT = "BOT" + MID = "MID" + TOP = "TOP" + + +def get_ply_wise_data( + field: Field, + ply_name: str, + mesh: MeshedRegion, + reduction_strategy: ReductionStrategy = ReductionStrategy.AVG, + requested_location: str = locations.elemental_nodal, + component: Union[IntEnum, int] = 0, +) -> Field: + """Get ply-wise data from a field. + + Parameters + ---------- + field: + Field to extract data from. + ply_name: + Name of the ply to extract data from. + mesh : + Meshed region enriched with composite information. + Use the ``CompositeModel.get_mesh()`` method to get the meshed region. + reduction_strategy : + Reduction strategy for getting from spot values (BOT, MID, TOP) to a single value + per corner node and layer. The default is ``AVG``. + requested_location : + Location of the output field. The default is ``"elemental_nodal"``. Options are + ``"elemental"``, ``"elemental_nodal"``, and ``"nodal"``. + component : + Component to extract data from. The default is ``0``. + """ + component_int = component.value if isinstance(component, IntEnum) else component + component_selector = operators.logic.component_selector() + + component_selector.inputs.field.connect(field) + component_selector.inputs.component_number.connect(component_int) + single_component_field = component_selector.outputs.field() + + filter_ply_data_op = Operator("composite::filter_ply_data_operator") + filter_ply_data_op.inputs.field(single_component_field) + filter_ply_data_op.inputs.mesh(mesh) + filter_ply_data_op.inputs.ply_id(ply_name) + filter_ply_data_op.inputs.reduction_strategy(reduction_strategy.value) + elemental_nodal_data = filter_ply_data_op.outputs.field() + + if requested_location == locations.elemental_nodal: + return elemental_nodal_data + + if requested_location == locations.elemental: + elemental_nodal_to_elemental = operators.averaging.elemental_mean() + elemental_nodal_to_elemental.inputs.field.connect(elemental_nodal_data) + out_field = elemental_nodal_to_elemental.outputs.field() + out_field.location = locations.elemental + return out_field + + if requested_location == locations.nodal: + elemental_nodal_to_nodal = operators.averaging.elemental_nodal_to_nodal() + elemental_nodal_to_nodal.inputs.mesh.connect(mesh) + elemental_nodal_to_nodal.inputs.field.connect(elemental_nodal_data) + out_field = elemental_nodal_to_nodal.outputs.field() + out_field.location = locations.nodal + return out_field + + raise RuntimeError( + f"Invalid requested location {requested_location}. " + f"Valid locations are {locations.elemental_nodal}, " + f"{locations.elemental}, and {locations.nodal}." + ) diff --git a/tests/ply_wise_data_test.py b/tests/ply_wise_data_test.py new file mode 100644 index 000000000..e81ebb0f7 --- /dev/null +++ b/tests/ply_wise_data_test.py @@ -0,0 +1,144 @@ +from collections.abc import Sequence + +from ansys.dpf.gate.common import locations +import numpy as np + +from ansys.dpf.composites.composite_model import CompositeModel +from ansys.dpf.composites.constants import Sym3x3TensorComponent +from ansys.dpf.composites.ply_wise_data import ReductionStrategy, get_ply_wise_data +from ansys.dpf.composites.server_helpers import version_equal_or_later + +from .helper import get_basic_shell_files + + +def get_reduced_value(all_spot_values: Sequence[float], reduction_strategy: ReductionStrategy): + if reduction_strategy == ReductionStrategy.AVG: + return sum(all_spot_values) / len(all_spot_values) + if reduction_strategy == ReductionStrategy.MIN: + return min(all_spot_values) + if reduction_strategy == ReductionStrategy.MAX: + return max(all_spot_values) + if reduction_strategy == ReductionStrategy.BOT: + return all_spot_values[0] + if reduction_strategy == ReductionStrategy.MID: + return all_spot_values[2] + if reduction_strategy == ReductionStrategy.TOP: + return all_spot_values[1] + raise ValueError(f"Unknown reduction strategy: {reduction_strategy}") + + +ALL_REDUCTION_STRATEGIES = [ + ReductionStrategy.AVG, + ReductionStrategy.MIN, + ReductionStrategy.MAX, + ReductionStrategy.BOT, + ReductionStrategy.MID, + ReductionStrategy.TOP, +] + +ALL_TENSOR_COMPONENTS = [ + Sym3x3TensorComponent.TENSOR11, + Sym3x3TensorComponent.TENSOR22, + Sym3x3TensorComponent.TENSOR22, + Sym3x3TensorComponent.TENSOR21, + Sym3x3TensorComponent.TENSOR31, + Sym3x3TensorComponent.TENSOR32, +] + + +def get_all_spot_values_first_element_first_node(stress_field, component=0): + all_spot_values = [] + entity_data = stress_field.get_entity_data_by_id(1)[:, component] + number_of_nodes = 4 + for spot_index in range(3): + all_spot_values.append(entity_data[spot_index * number_of_nodes]) + return all_spot_values + + +def test_get_ply_wise_data(dpf_server): + if not version_equal_or_later(dpf_server, "8.0"): + return + files = get_basic_shell_files() + + composite_model = CompositeModel(files, server=dpf_server) + + stress_result_op = composite_model.core_model.results.stress() + stress_result_op.inputs.bool_rotate_to_global(False) + stress_field = stress_result_op.outputs.fields_container()[0] + + first_ply = "P1L1__woven_45" + + element_id = 1 + first_node_index = 0 + + for component in ALL_TENSOR_COMPONENTS: + for reduction_strategy in ALL_REDUCTION_STRATEGIES: + all_spot_values = get_all_spot_values_first_element_first_node( + stress_field, component=component.value + ) + + elemental_nodal_data = get_ply_wise_data( + stress_field, + first_ply, + composite_model.get_mesh(), + component=component, + reduction_strategy=reduction_strategy, + ) + + assert len(elemental_nodal_data.scoping.ids) == 4 + assert len(elemental_nodal_data.get_entity_data_by_id(element_id)) == 4 + assert elemental_nodal_data.location == locations.elemental_nodal + + assert np.allclose( + elemental_nodal_data.get_entity_data_by_id(element_id)[first_node_index], + get_reduced_value(all_spot_values, reduction_strategy), + ) + + elemental_data = get_ply_wise_data( + stress_field, + first_ply, + composite_model.get_mesh(), + component=component, + reduction_strategy=reduction_strategy, + requested_location=locations.elemental, + ) + assert len(elemental_data.scoping.ids) == 4 + assert len(elemental_data.get_entity_data_by_id(element_id)) == 1 + assert elemental_data.location == locations.elemental + + assert np.allclose( + elemental_data.get_entity_data_by_id(element_id)[first_node_index], + sum(elemental_nodal_data.get_entity_data_by_id(element_id)) / 4, + ) + + nodal_data = get_ply_wise_data( + stress_field, + first_ply, + composite_model.get_mesh(), + component=component, + reduction_strategy=reduction_strategy, + requested_location=locations.nodal, + ) + assert len(nodal_data.scoping.ids) == 9 + assert len(nodal_data.get_entity_data_by_id(element_id)) == 1 + assert nodal_data.location == locations.nodal + + # Select node that only belongs to element 1 + # no averaging needed + node_index_with_no_neighbours = 1 + first_node_index_of_element_1 = ( + composite_model.get_mesh().elements.connectivities_field.get_entity_data_by_id( + element_id + )[node_index_with_no_neighbours] + ) + node_id_to_index = composite_model.get_mesh().nodes.mapping_id_to_index + node_id = list(node_id_to_index.keys())[ + list(node_id_to_index.values()).index(first_node_index_of_element_1) + ] + + assert np.allclose( + nodal_data.get_entity_data_by_id(node_id)[0], + elemental_nodal_data.get_entity_data_by_id(element_id)[ + node_index_with_no_neighbours + ], + )