diff --git a/.editorconfig b/.editorconfig index c300dcab8..5252b9663 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,3 +25,4 @@ indent_size = 4 [LICENSE] insert_final_newline = false + diff --git a/docs/api/compas_fea2.model.rst b/docs/api/compas_fea2.model.rst index 42e27dad1..5225cedb9 100644 --- a/docs/api/compas_fea2.model.rst +++ b/docs/api/compas_fea2.model.rst @@ -18,7 +18,7 @@ Parts .. autosummary:: :toctree: generated/ - DeformablePart + Part RigidPart Nodes diff --git a/docs/api/compas_fea2.units.rst b/docs/api/compas_fea2.units.rst index d4be026d2..d4cfb1a90 100644 --- a/docs/api/compas_fea2.units.rst +++ b/docs/api/compas_fea2.units.rst @@ -2,4 +2,4 @@ Units ******************************************************************************** -compas_fe2 can use Pint for units consistency. +compas_fea2 can use Pint for units consistency. diff --git a/docs/api/compas_fea2.utilities.rst b/docs/api/compas_fea2.utilities.rst deleted file mode 100644 index 262a9133e..000000000 --- a/docs/api/compas_fea2.utilities.rst +++ /dev/null @@ -1,25 +0,0 @@ -******************************************************************************** -Utilities -******************************************************************************** - -.. currentmodule:: compas_fea2.utilities - - -Functions -========= - -.. autosummary:: - :toctree: generated/ - -.. colorbar -.. combine_all_sets -.. group_keys_by_attribute -.. group_keys_by_attributes -.. identify_ranges -.. mesh_from_shell_elements -.. network_order -.. normalise_data -.. principal_stresses -.. process_data -.. postprocess -.. plotvoxels diff --git a/docs/userguide/basics.model.rst b/docs/userguide/basics.model.rst index 679014222..68ea2705b 100644 --- a/docs/userguide/basics.model.rst +++ b/docs/userguide/basics.model.rst @@ -34,20 +34,11 @@ Point(x=0.0, y=0.0, z=0.0) Besides coordinates, nodes have many other (optional) attributes. >>> node.mass -(None, None, None) +[None, None, None, None, None, None] >>> node.temperature >>> >>> node.dof {'x': True, 'y': True, 'z': True, 'xx': True, 'yy': True, 'zz': True} ->>> node.loads -{} ->>> node.displacements -{} - -Nodes also have a container for storing calculation results. - ->>> node.results -{} Elements diff --git a/requirements.txt b/requirements.txt index 0204c3800..5edb2dde3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ compas_viewer Click matplotlib pint -python-dotenv \ No newline at end of file +python-dotenv +h5py +matplotlib \ No newline at end of file diff --git a/scripts/shape_transformation.py b/scripts/shape_transformation.py deleted file mode 100644 index f56514420..000000000 --- a/scripts/shape_transformation.py +++ /dev/null @@ -1,17 +0,0 @@ -from compas_fea2.model.shapes import Rectangle, Frame - -r = Rectangle(w=100, h=300) -print(r.summary()) - -# Example of applying a transformation: -new_frame = Frame([0, 0, 1000], [1, 0, 0], [0, 1, 0]) -r_transf = r.oriented(new_frame) - -# Convert to meshes (if needed): -m1 = r.to_mesh() -m2 = r_transf.to_mesh() - -print(m1) - -print("Original rectangle centroid:", r.centroid) -print("Transformed rectangle centroid:", r_transf.centroid) \ No newline at end of file diff --git a/src/compas_fea2/UI/viewer/__init__.py b/src/compas_fea2/UI/viewer/__init__.py index ccf7f674f..e6b45d77e 100644 --- a/src/compas_fea2/UI/viewer/__init__.py +++ b/src/compas_fea2/UI/viewer/__init__.py @@ -1,9 +1,8 @@ from .viewer import FEA2Viewer from .scene import FEA2ModelObject from .scene import FEA2StepObject -from .scene import FEA2StressFieldResultsObject -from .scene import FEA2DisplacementFieldResultsObject -from .scene import FEA2ReactionFieldResultsObject +from .scene import FEA2Stress2DFieldResultsObject +from .scene import FEA2NodeFieldResultsObject from .primitives import ( _BCShape, @@ -22,7 +21,6 @@ "ArrowShape", "FEA2ModelObject", "FEA2StepObject", - "FEA2StressFieldResultsObject", - "FEA2DisplacementFieldResultsObject", - "FEA2ReactionFieldResultsObject", + "FEA2Stress2DFieldResultsObject", + "FEA2NodeFieldResultsObject", ] diff --git a/src/compas_fea2/UI/viewer/drawer.py b/src/compas_fea2/UI/viewer/drawer.py index db8267aec..470c57dc8 100644 --- a/src/compas_fea2/UI/viewer/drawer.py +++ b/src/compas_fea2/UI/viewer/drawer.py @@ -3,7 +3,7 @@ from compas.geometry import Line -def draw_field_vectors(field_locations, field_vectors, scale_results, translate=0, high=None, low=None, cmap=None, **kwargs): +def draw_field_vectors(locations, vectors, scale_results, translate=0, high=None, low=None, cmap=None, **kwargs): """Display a given vector field. Parameters @@ -17,25 +17,24 @@ def draw_field_vectors(field_locations, field_vectors, scale_results, translate= translate : float The translation factor for the results. """ - vectors = [] colors = [] - + lines = [] if cmap: - lengths = [v.length for v in field_vectors] + lengths = [v.length for v in vectors] min_value = high or min(lengths) max_value = low or max(lengths) else: - colors = [Color.red()] * len(field_vectors) + colors = [Color.red()] * len(vectors) - for pt, vector in zip(list(field_locations), list(field_vectors)): + for pt, vector in zip(list(locations), list(vectors)): if vector.length == 0: continue else: v = vector.scaled(scale_results) - vectors.append(Line.from_point_and_vector(pt, v).translated(v * translate)) + lines.append(Line.from_point_and_vector(pt, v).translated(v * translate)) if cmap: colors.append(cmap(vector.length, minval=min_value, maxval=max_value)) - return vectors, colors + return lines, colors def draw_field_contour(model, field_locations, field_results, high=None, low=None, cmap=None, **kwargs): diff --git a/src/compas_fea2/UI/viewer/scene.py b/src/compas_fea2/UI/viewer/scene.py index 34c3433fc..b31c6e7be 100644 --- a/src/compas_fea2/UI/viewer/scene.py +++ b/src/compas_fea2/UI/viewer/scene.py @@ -2,16 +2,14 @@ from __future__ import division from __future__ import print_function -from compas.scene import SceneObject # noqa: F401 from compas.colors import Color from compas.colors import ColorMap from compas.geometry import Vector - -from .drawer import draw_field_contour -from .drawer import draw_field_vectors - +from compas.scene import SceneObject # noqa: F401 +from compas_viewer.scene import BufferGeometry # noqa: F401 from compas_viewer.scene import Collection -from compas_viewer.scene import GroupObject +from compas_viewer.scene import GroupObject # noqa: F401 + from compas_fea2.model.bcs import FixedBC from compas_fea2.model.bcs import PinnedBC from compas_fea2.model.bcs import RollerBCX @@ -21,6 +19,9 @@ from compas_fea2.UI.viewer.primitives import PinBCShape from compas_fea2.UI.viewer.primitives import RollerBCShape +from .drawer import draw_field_contour +from .drawer import draw_field_vectors + color_palette = { "faces": Color.from_hex("#e8e5d4"), "edges": Color.from_hex("#4554ba"), @@ -75,7 +76,7 @@ def __init__(self, fast=False, show_bcs=True, show_parts=True, show_connectors=T line_color = kwargs.get("line_color", color_palette["edges"]) show_faces = kwargs.get("show_faces", True) show_lines = kwargs.get("show_lines", False) - show_nodes = kwargs.get("show_nodes", False) + show_nodes = kwargs.get("show_nodes", True) opacity = kwargs.get("opacity", 1.0) part_meshes = [] if show_parts: @@ -209,14 +210,14 @@ def __init__(self, scale_factor=1, **kwargs): step = kwargs.pop("item") # DRAW PATTERNS - patterns = [] - for pattern in step.patterns: - pattern_forces = [] - for node, load in pattern.node_load: + loadfields = [] + for load_field in step.load_fields: + loadfield_forces = [] + for node, load in load_field.node_load: x = load.x or 0 y = load.y or 0 z = load.z or 0 - pattern_forces.append( + loadfield_forces.append( ( Vector(x * scale_factor, y * scale_factor, z * scale_factor), # .to_mesh(anchor=node.point) { @@ -226,19 +227,19 @@ def __init__(self, scale_factor=1, **kwargs): }, ) ) - patterns.append( + loadfields.append( ( - pattern_forces, + loadfield_forces, { - "name": f"PATTERN-{pattern.name}", + "name": f"LOADFIELD-{load_field.name}", }, ) ) - super().__init__(item=patterns, name=f"STEP-{step.name}", componets=None, **kwargs) + super().__init__(item=loadfields, name=f"STEP-{step.name}", componets=None, **kwargs) -class FEA2StressFieldResultsObject(GroupObject): +class FEA2Stress2DFieldResultsObject(GroupObject): """StressFieldResults object for visualization. Parameters @@ -254,10 +255,10 @@ class FEA2StressFieldResultsObject(GroupObject): """ - def __init__(self, step, scale_factor=1, components=None, **kwargs): + def __init__(self, model, components=None, show_vectors=1, plane="mid", show_contour=False, **kwargs): field = kwargs.pop("item") - field_locations = list(field.locations(step)) + field_locations = [e.reference_point for e in field.locations] if not components: components = [0, 1, 2] @@ -265,72 +266,19 @@ def __init__(self, step, scale_factor=1, components=None, **kwargs): colors = {0: Color.blue(), 1: Color.yellow(), 2: Color.red()} collections = [] - for component in components: - field_results = [v[component] for v in field.principal_components_vectors(step)] - lines, _ = draw_field_vectors(field_locations, field_results, scale_factor, translate=-0.5) - collections.append((Collection(lines), {"name": f"PS-{names[component]}", "linecolor": colors[component], "linewidth": 3})) - - super().__init__(item=collections, name=f"RESULTS-{field.name}", **kwargs) - - -class FEA2DisplacementFieldResultsObject(GroupObject): - """DisplacementFieldResults object for visualization. - - Parameters - ---------- - field : :class:`compas_fea2.results.Field` - The field to visualize. - step : :class:`compas_fea2.problem.steps.Step` - The step to visualize. - scale_factor : float - The scale factor for the visualization. - components : list - The components to visualize. - - """ - - # FIXME: component is not used - def __init__(self, step, component=None, show_vectors=1, show_contour=False, **kwargs): - - field = kwargs.pop("item") - cmap = kwargs.get("cmap", ColorMap.from_palette("hawaii")) - - group_elements = [] if show_vectors: - vectors, colors = draw_field_vectors([n.point for n in field.locations(step)], list(field.vectors(step)), show_vectors, translate=0, cmap=cmap) - # group_elements.append((Collection(vectors), {"name": f"DISP-{component}", "linecolors": colors, "linewidth": 3})) - for v, c in zip(vectors, colors): - group_elements.append((v, {"name": f"DISP-{component}", "linecolor": c, "linewidth": 3})) + for component in components: + field_results = [v[component] for v in field.principal_components_vectors(plane)] + lines, _ = draw_field_vectors(field_locations, field_results, show_vectors, translate=-0.5) + collections.append((Collection(lines), {"name": f"PS-{names[component]}", "linecolor": colors[component], "linewidth": 3})) if show_contour: - from compas_fea2.model.elements import BeamElement - - field_locations = list(field.locations(step)) - field_results = list(field.component(step, component)) - min_value = min(field_results) - max_value = max(field_results) - part_vertexcolor = draw_field_contour(step.model, field_locations, field_results, min_value, max_value, cmap) - - # DRAW CONTOURS ON 2D and 3D ELEMENTS - for part, vertexcolor in part_vertexcolor.items(): - group_elements.append((part._discretized_boundary_mesh, {"name": part.name, "vertexcolor": vertexcolor, "use_vertexcolors": True})) + raise NotImplementedError("Contour visualization not implemented for stress fields.") - # DRAW CONTOURS ON 1D ELEMENTS - for part in step.model.parts: - for element in part.elements: - vertexcolor = {} - if isinstance(element, BeamElement): - for c, n in enumerate(element.nodes): - v = field_results[field_locations.index(n)] - for p in range(len(element.section._shape.points)): - vertexcolor[p + c * len(element.section._shape.points)] = cmap(v, minval=min_value, maxval=max_value) - # vertexcolor = {c: Color.red() for c in range(2*len(element.section._shape.points))} - group_elements.append((element.outermesh, {"name": element.name, "vertexcolor": vertexcolor, "use_vertexcolors": True})) + super().__init__(item=collections, name=f"STRESS-{field.name}", **kwargs) - super().__init__(item=group_elements, name=f"RESULTS-{field.name}", **kwargs) - -class FEA2ReactionFieldResultsObject(GroupObject): +class FEA2NodeFieldResultsObject(GroupObject): """DisplacementFieldResults object for visualization. Parameters @@ -346,34 +294,33 @@ class FEA2ReactionFieldResultsObject(GroupObject): """ - def __init__(self, step, component, show_vectors=1, show_contour=False, **kwargs): - # FIXME: component is not used - + def __init__(self, components=None, show_vectors=1, show_contour=False, **kwargs): field = kwargs.pop("item") cmap = kwargs.get("cmap", ColorMap.from_palette("hawaii")) + components = components or ["x", "y", "z"] group_elements = [] if show_vectors: - vectors, colors = draw_field_vectors([n.point for n in field.locations(step)], list(field.vectors(step)), show_vectors, translate=0, cmap=cmap) - # group_elements.append((Collection(vectors), {"name": f"DISP-{component}", "linecolors": colors, "linewidth": 3})) + vectors, colors = draw_field_vectors([n.point for n in field.locations], list(field.components_vectors(components)), show_vectors, translate=0, cmap=cmap) + for v, c in zip(vectors, colors): - group_elements.append((v, {"name": f"DISP-{component}", "linecolor": c, "linewidth": 3})) + group_elements.append((v, {"name": f"DISP-{''.join(components)}", "linecolor": c, "linewidth": 3})) if show_contour: from compas_fea2.model.elements import BeamElement - field_locations = list(field.locations(step)) - field_results = list(field.component(step, component)) + field_locations = list(field.locations) + field_results = [v.length for v in field.components_vectors(components)] min_value = min(field_results) max_value = max(field_results) - part_vertexcolor = draw_field_contour(step.model, field_locations, field_results, min_value, max_value, cmap) + part_vertexcolor = draw_field_contour(field.model, field_locations, field_results, min_value, max_value, cmap) # DRAW CONTOURS ON 2D and 3D ELEMENTS for part, vertexcolor in part_vertexcolor.items(): group_elements.append((part._discretized_boundary_mesh, {"name": part.name, "vertexcolor": vertexcolor, "use_vertexcolors": True})) # DRAW CONTOURS ON 1D ELEMENTS - for part in step.model.parts: + for part in field.model.parts: for element in part.elements: vertexcolor = {} if isinstance(element, BeamElement): diff --git a/src/compas_fea2/UI/viewer/viewer.py b/src/compas_fea2/UI/viewer/viewer.py index 19bcf1cff..62b636be6 100644 --- a/src/compas_fea2/UI/viewer/viewer.py +++ b/src/compas_fea2/UI/viewer/viewer.py @@ -1,15 +1,15 @@ import numpy as np from compas.itertools import remap_values -from compas_viewer.components import Button -from compas_viewer.viewer import Viewer -from compas_viewer.scene import GroupObject from compas.scene import register from compas.scene import register_scene_objects +from compas_viewer.components import Button +from compas_viewer.scene import GroupObject +from compas_viewer.viewer import Viewer from compas_fea2.UI.viewer.scene import FEA2ModelObject -from compas_fea2.UI.viewer.scene import FEA2DisplacementFieldResultsObject -from compas_fea2.UI.viewer.scene import FEA2ReactionFieldResultsObject +from compas_fea2.UI.viewer.scene import FEA2NodeFieldResultsObject from compas_fea2.UI.viewer.scene import FEA2StepObject +from compas_fea2.UI.viewer.scene import FEA2Stress2DFieldResultsObject def toggle_nodes(): @@ -134,6 +134,7 @@ def __init__(self, center=[1, 1, 1], scale_model=1000, **kwargs): self.model: GroupObject = None self.nodes: GroupObject = None self.displacements: GroupObject = None + self.reactions: GroupObject = None self.step: GroupObject = None self.ui.sidedock.show = True @@ -146,6 +147,7 @@ def __init__(self, center=[1, 1, 1], scale_model=1000, **kwargs): self.ui.sidedock.add(Button(text="Toggle Tension", action=toggle_tension)) self.ui.sidedock.add(Button(text="Toggle Friction", action=toggle_friction)) self.ui.sidedock.add(Button(text="Toggle Resultants", action=toggle_resultants)) + register_scene_objects() def add_parts(self, parts): pass @@ -155,34 +157,69 @@ def add_model(self, model, fast=True, show_parts=True, opacity=0.5, show_bcs=Tru register(model.__class__.__base__, FEA2ModelObject, context="Viewer") self.model = self.scene.add(model, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - def add_displacement_field(self, field, step, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): - register_scene_objects() - register(field.__class__.__base__, FEA2DisplacementFieldResultsObject, context="Viewer") + def add_displacement_field( + self, field, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, show_vectors=True, show_contours=False, **kwargs + ): + register(field.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") self.displacements = self.scene.add( - field, step=step, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs + item=field, + component=component, + fast=fast, + show_parts=show_parts, + opacity=opacity, + show_bcs=show_bcs, + show_loads=show_loads, + show_vectors=show_vectors, + show_contours=show_contours, + **kwargs, ) - def add_reaction_field(self, field, step, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): - register_scene_objects() - register(field.__class__.__base__, FEA2ReactionFieldResultsObject, context="Viewer") + def add_reaction_field( + self, field, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, show_vectors=True, show_contours=False, **kwargs + ): + register(field.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") self.reactions = self.scene.add( - field, step=step, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs + field, + model=model, + component=component, + fast=fast, + show_parts=show_parts, + opacity=opacity, + show_bcs=show_bcs, + show_loads=show_loads, + show_vectors=show_vectors, + show_contours=show_contours, + **kwargs, ) - def add_mode_shape(self, mode_shape, step, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): - register_scene_objects() - register(mode_shape.__class__.__base__, FEA2DisplacementFieldResultsObject, context="Viewer") - self.displacements = self.scene.add( - mode_shape, step=step, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs + def add_stress2D_field( + self, field, model, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, show_vectors=1, show_contours=False, plane="mid", **kwargs + ): + register(field.__class__.__base__, FEA2Stress2DFieldResultsObject, context="Viewer") + self.stresses = self.scene.add( + field, + model=model, + component=component, + fast=fast, + show_parts=show_parts, + opacity=opacity, + show_bcs=show_bcs, + show_loads=show_loads, + show_vectors=show_vectors, + show_contours=show_contours, + plane=plane, + **kwargs, ) + def add_mode_shape(self, mode_shape, component=None, fast=False, show_parts=True, opacity=0.5, show_bcs=True, show_loads=True, **kwargs): + register(mode_shape.__class__.__base__, FEA2NodeFieldResultsObject, context="Viewer") + self.displacements = self.scene.add(mode_shape, component=component, fast=fast, show_parts=show_parts, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + def add_step(self, step, show_loads=1): - register_scene_objects() register(step.__class__, FEA2StepObject, context="Viewer") self.step = self.scene.add(step, step=step, scale_factor=show_loads) def add_nodes(self, nodes): - self.nodes = [] for node in nodes: self.nodes.append( diff --git a/src/compas_fea2/__init__.py b/src/compas_fea2/__init__.py index e4d77dbb7..1db748d5c 100644 --- a/src/compas_fea2/__init__.py +++ b/src/compas_fea2/__init__.py @@ -11,15 +11,6 @@ __version__ = "0.3.1" -HERE = os.path.dirname(__file__) - -HOME = os.path.abspath(os.path.join(HERE, "../../")) -DATA = os.path.abspath(os.path.join(HOME, "data")) -UMAT = os.path.abspath(os.path.join(DATA, "umat")) -DOCS = os.path.abspath(os.path.join(HOME, "docs")) -TEMP = os.path.abspath(os.path.join(HOME, "temp")) - - def init_fea2(verbose=False, point_overlap=True, global_tolerance=1, precision=3, part_nodes_limit=100000): """Create a default environment file if it doesn't exist and loads its variables. @@ -47,30 +38,12 @@ def init_fea2(verbose=False, point_overlap=True, global_tolerance=1, precision=3 "POINT_OVERLAP={}".format(point_overlap), "GLOBAL_TOLERANCE={}".format(global_tolerance), "PRECISION={}".format(precision), - "PART_NODES_LIMIT={}".format(part_nodes_limit), ] ) ) load_dotenv(env_path) -if not load_dotenv(): - init_fea2() - -VERBOSE = os.getenv("VERBOSE").lower() == "true" -POINT_OVERLAP = os.getenv("POINT_OVERLAP").lower() == "true" -GLOBAL_TOLERANCE = float(os.getenv("GLOBAL_TOLERANCE")) -PRECISION = int(os.getenv("PRECISION")) -PART_NODES_LIMIT = int(os.getenv("PART_NODES_LIMIT")) -BACKEND = None -BACKENDS = defaultdict(dict) - - -def set_precision(precision): - global PRECISION - PRECISION = precision - - # pluggable function to be def _register_backend(): """Create the class registry for the plugin. @@ -111,4 +84,22 @@ def _get_backend_implementation(cls): return BACKENDS[BACKEND].get(cls) +HERE = os.path.dirname(__file__) + +HOME = os.path.abspath(os.path.join(HERE, "../../")) +DATA = os.path.abspath(os.path.join(HOME, "data")) +UMAT = os.path.abspath(os.path.join(DATA, "umat")) +DOCS = os.path.abspath(os.path.join(HOME, "docs")) +TEMP = os.path.abspath(os.path.join(HOME, "temp")) + +if not load_dotenv(): + init_fea2() + +VERBOSE = os.getenv("VERBOSE").lower() == "true" +POINT_OVERLAP = os.getenv("POINT_OVERLAP").lower() == "true" +GLOBAL_TOLERANCE = float(os.getenv("GLOBAL_TOLERANCE")) +PRECISION = int(os.getenv("PRECISION")) +BACKEND = None +BACKENDS = defaultdict(dict) + __all__ = ["HOME", "DATA", "DOCS", "TEMP"] diff --git a/src/compas_fea2/base.py b/src/compas_fea2/base.py index 7e57ebc22..1524bf4b1 100644 --- a/src/compas_fea2/base.py +++ b/src/compas_fea2/base.py @@ -1,12 +1,12 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import importlib +import json import uuid from abc import abstractmethod +from copy import deepcopy from typing import Iterable +import h5py +import numpy as np from compas.data import Data import compas_fea2 @@ -69,7 +69,7 @@ def __new__(cls, *args, **kwargs): def __init__(self, name=None, **kwargs): self.uid = uuid.uuid4() - super().__init__(name=name, **kwargs) + super().__init__() self._name = name or "".join([c for c in type(self).__name__ if c.isupper()]) + "_" + str(id(self)) self._registration = None self._key = None @@ -78,16 +78,6 @@ def __init__(self, name=None, **kwargs): def key(self): return self._key - @property - def input_key(self): - if self._key is None: - raise AttributeError(f"{self!r} does not have a key.") - if self._registration is None: - raise AttributeError(f"{self!r} is not registered to any part.") - if self._registration._key is None: - raise AttributeError(f"{self._registration!r} is not registered to a model.") - return self._key + self._registration._key + self.model._starting_key - def __repr__(self): return "{0}({1})".format(self.__class__.__name__, id(self)) @@ -142,5 +132,101 @@ def from_name(cls, name, **kwargs): obj = getattr(importlib.import_module(".".join([*module_info[:-1]])), "_" + name) return obj(**kwargs) - def data(self): - pass + # ========================================================================== + # Copy and Serialization + # ========================================================================== + def copy(self, cls=None, copy_guid=False, copy_name=False): + """Make an independent copy of the data object. + + Parameters + ---------- + cls : Type[:class:`compas.data.Data`], optional + The type of data object to return. + Defaults to the type of the current data object. + copy_guid : bool, optional + If True, the copy will have the same guid as the original. + + Returns + ------- + :class:`compas.data.Data` + An independent copy of this object. + + """ + if not cls: + cls = type(self) + obj = cls.__from_data__(deepcopy(self.__data__)) + if copy_name and self._name is not None: + obj._name = self.name + if copy_guid: + obj._guid = self.guid + return obj # type: ignore + + def to_hdf5(self, hdf5_path, group_name, mode="w"): + """ + Save the object to an HDF5 file using the __data__ property. + """ + with h5py.File(hdf5_path, mode) as hdf5_file: # "a" mode to append data + group = hdf5_file.require_group(f"{group_name}/{self.uid}") # Create a group for this object + + for key, value in self.to_hdf5_data().items(): + if isinstance(value, (list, np.ndarray)): + group.create_dataset(key, data=value) + else: + group.attrs[key] = json.dumps(value) + + @classmethod + def from_hdf5( + cls, + hdf5_path, + group_name, + uid, + ): + """ + Load an object from an HDF5 file using the __data__ property. + """ + with h5py.File(hdf5_path, "r") as hdf5_file: + group = hdf5_file[f"{group_name}/{uid}"] + data = {} + + # Load datasets (numerical values) + for key in group.keys(): + dataset = group[key][:] + data[key] = dataset.tolist() if dataset.shape != () else dataset.item() + + # Load attributes (strings, dictionaries, JSON lists) + for key, value in group.attrs.items(): + if isinstance(value, str): + # Convert "None" back to NoneType + if value == "None": + data[key] = None + # Convert JSON back to Python objects + elif value.startswith("[") or value.startswith("{"): + try: + data[key] = json.loads(value) + except json.JSONDecodeError: + data[key] = value # Keep it as a string if JSON parsing fails + else: + data[key] = value + else: + data[key] = value + + if not hasattr(cls, "__from_data__"): + raise NotImplementedError(f"{cls.__name__} does not implement the '__from_data__' method.") + return cls.__from_data__(data) + + def to_json(self, filepath, pretty=False, compact=False, minimal=False): + """Convert an object to its native data representation and save it to a JSON file. + + Parameters + ---------- + filepath : str + The path to the JSON file. + pretty : bool, optional + If True, format the output with newlines and indentation. + compact : bool, optional + If True, format the output without any whitespace. + minimal : bool, optional + If True, exclude the GUID from the JSON output. + + """ + json.dump(self.__data__, open(filepath, "w"), indent=4) diff --git a/src/compas_fea2/job/__init__.py b/src/compas_fea2/job/__init__.py index ba7b1af49..635f6032a 100644 --- a/src/compas_fea2/job/__init__.py +++ b/src/compas_fea2/job/__init__.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .input_file import InputFile from .input_file import ParametersFile diff --git a/src/compas_fea2/job/input_file.py b/src/compas_fea2/job/input_file.py index a5d4a04f5..34eaad20e 100644 --- a/src/compas_fea2/job/input_file.py +++ b/src/compas_fea2/job/input_file.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os from compas_fea2 import VERBOSE @@ -30,12 +26,15 @@ class InputFile(FEAData): """ - def __init__(self, **kwargs): + def __init__(self, problem, **kwargs): super(InputFile, self).__init__(**kwargs) - self._job_name = None - self._file_name = None + self._registration = problem self._extension = None - self._path = None + self.path = None + + @property + def file_name(self): + return "{}.{}".format(self.problem._name, self._extension) @property def problem(self): @@ -45,32 +44,6 @@ def problem(self): def model(self): return self.problem._registration - @property - def path(self): - return self._path - - @classmethod - def from_problem(cls, problem): - """Create an InputFile object from a :class:`compas_fea2.problem.Problem` - - Parameters - ---------- - problem : :class:`compas_fea2.problem.Problem` - Problem to be converted to InputFile. - - Returns - ------- - obj - InputFile for the analysis. - - """ - input_file = cls() - input_file._registration = problem - input_file._job_name = problem._name - input_file._file_name = "{}.{}".format(problem._name, input_file._extension) - input_file._path = problem.path.joinpath(input_file._file_name) - return input_file - # ============================================================================== # General methods # ============================================================================== @@ -92,16 +65,17 @@ def write_to_file(self, path=None): path = path or self.problem.path if not path: raise ValueError("A path to the folder for the input file must be provided") - file_path = os.path.join(path, self._file_name) + file_path = os.path.join(path, self.file_name) with open(file_path, "w") as f: f.writelines(self.jobdata()) if VERBOSE: - print("Input file generated in: {}".format(file_path)) + print("Input file generated in the following location: {}".format(file_path)) class ParametersFile(InputFile): - """""" + """Input file object for Optimizations. + """ - def __init__(self, name=None, **kwargs): - super(ParametersFile, self).__init__(name, **kwargs) + def __init__(self, **kwargs): + super(ParametersFile, self).__init__(**kwargs) raise NotImplementedError() diff --git a/src/compas_fea2/model/__init__.py b/src/compas_fea2/model/__init__.py index 5340d665e..aa2cd635d 100644 --- a/src/compas_fea2/model/__init__.py +++ b/src/compas_fea2/model/__init__.py @@ -1,10 +1,6 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .model import Model from .parts import ( - DeformablePart, + Part, RigidPart, ) from .nodes import Node @@ -44,9 +40,10 @@ from .sections import ( _Section, MassSection, + SpringSection, + ConnectorSection, BeamSection, GenericBeamSection, - SpringSection, AngleSection, BoxSection, CircularSection, @@ -71,6 +68,7 @@ ) from .connectors import ( Connector, + LinearConnector, RigidLinkConnector, SpringConnector, ZeroLengthConnector, @@ -114,9 +112,22 @@ InitialStressField, ) +from .interfaces import ( + Interface, +) + +from .interactions import ( + _Interaction, + Contact, + HardContactFrictionPenalty, + HardContactNoFriction, + LinearContactFrictionPenalty, + HardContactRough, +) + __all__ = [ "Model", - "DeformablePart", + "Part", "RigidPart", "Node", "_Element", @@ -148,6 +159,7 @@ "Timber", "_Section", "MassSection", + "ConnectorSection", "BeamSection", "GenericBeamSection", "SpringSection", @@ -166,7 +178,6 @@ "StrutSection", "TieSection", "_Constraint", - "RigidLinkConnector", "_MultiPointConstraint", "TieMPC", "BeamMPC", @@ -198,10 +209,18 @@ "_InitialCondition", "InitialTemperatureField", "InitialStressField", + "Interface", "Connector", + "LinearConnector", "SpringConnector", "RigidLinkConnector", "ZeroLengthConnector", "ZeroLengthContactConnector", "ZeroLengthSpringConnector", + "_Interaction", + "Contact", + "HardContactFrictionPenalty", + "HardContactNoFriction", + "LinearContactFrictionPenalty", + "HardContactRough", ] diff --git a/src/compas_fea2/model/bcs.py b/src/compas_fea2/model/bcs.py index 81a106269..b8238d23b 100644 --- a/src/compas_fea2/model/bcs.py +++ b/src/compas_fea2/model/bcs.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Dict from compas_fea2.base import FEAData @@ -16,15 +14,15 @@ Parameters ---------- name : str, optional - Uniqe identifier. If not provided it is automatically generated. Set a + Unique identifier. If not provided it is automatically generated. Set a name if you want a more human-readable input file. axes : str, optional - The refernce axes. + The reference axes. Attributes ---------- name : str - Uniqe identifier. + Unique identifier. x : bool Restrain translations along the x axis. y : bool @@ -40,7 +38,7 @@ components : dict Dictionary with component-value pairs summarizing the boundary condition. axes : str - The refernce axes. + The reference axes. """ @@ -49,8 +47,8 @@ class _BoundaryCondition(FEAData): __doc__ += docs - def __init__(self, axes="global", **kwargs): - super(_BoundaryCondition, self).__init__(**kwargs) + def __init__(self, axes: str = "global", **kwargs): + super().__init__(**kwargs) self._axes = axes self._x = False self._y = False @@ -60,44 +58,69 @@ def __init__(self, axes="global", **kwargs): self._zz = False @property - def x(self): + def x(self) -> bool: return self._x @property - def y(self): + def y(self) -> bool: return self._y @property - def z(self): + def z(self) -> bool: return self._z @property - def xx(self): + def xx(self) -> bool: return self._xx @property - def yy(self): + def yy(self) -> bool: return self._yy @property - def zz(self): + def zz(self) -> bool: return self._zz @property - def axes(self): + def axes(self) -> str: return self._axes @axes.setter - def axes(self, value): + def axes(self, value: str): self._axes = value @property - def components(self): + def components(self) -> Dict[str, bool]: return {c: getattr(self, c) for c in ["x", "y", "z", "xx", "yy", "zz"]} + @property + def __data__(self) -> Dict[str, any]: + return { + "class": self.__class__.__base__.__name__, + "axes": self._axes, + "x": self._x, + "y": self._y, + "z": self._z, + "xx": self._xx, + "yy": self._yy, + "zz": self._zz, + } + + @classmethod + def __from_data__(cls, data: Dict[str, any]): + return cls( + axes=data.get("axes", "global"), + x=data.get("x", False), + y=data.get("y", False), + z=data.get("z", False), + xx=data.get("xx", False), + yy=data.get("yy", False), + zz=data.get("zz", False), + ) + class GeneralBC(_BoundaryCondition): - """Costumized boundary condition.""" + """Customized boundary condition.""" __doc__ += docs __doc__ += """ @@ -117,8 +140,8 @@ class GeneralBC(_BoundaryCondition): Restrain rotations around the z axis. """ - def __init__(self, x=False, y=False, z=False, xx=False, yy=False, zz=False, **kwargs): - super(GeneralBC, self).__init__(**kwargs) + def __init__(self, x: bool = False, y: bool = False, z: bool = False, xx: bool = False, yy: bool = False, zz: bool = False, **kwargs): + super().__init__(**kwargs) self._x = x self._y = y self._z = z @@ -133,7 +156,7 @@ class FixedBC(_BoundaryCondition): __doc__ += docs def __init__(self, **kwargs): - super(FixedBC, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = True self._y = True self._z = True @@ -143,36 +166,36 @@ def __init__(self, **kwargs): class FixedBCX(_BoundaryCondition): - """A fixed nodal displacement boundary condition along and around Z""" + """A fixed nodal displacement boundary condition along and around X.""" __doc__ += docs def __init__(self, **kwargs): - super(FixedBC, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = True self._xx = True class FixedBCY(_BoundaryCondition): - """A fixed nodal displacement boundary condition along and around Y""" + """A fixed nodal displacement boundary condition along and around Y.""" __doc__ += docs def __init__(self, **kwargs): - super(FixedBC, self).__init__(**kwargs) + super().__init__(**kwargs) self._y = True self._yy = True class FixedBCZ(_BoundaryCondition): - """A fixed nodal displacement boundary condition along and around Z""" + """A fixed nodal displacement boundary condition along and around Z.""" __doc__ += docs def __init__(self, **kwargs): - super(FixedBC, self).__init__(**kwargs) - self._z = True + super().__init__(**kwargs) self._z = True + self._zz = True class PinnedBC(_BoundaryCondition): @@ -181,7 +204,7 @@ class PinnedBC(_BoundaryCondition): __doc__ += docs def __init__(self, **kwargs): - super(PinnedBC, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = True self._y = True self._z = True @@ -193,7 +216,7 @@ class ClampBCXX(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCXX, self).__init__(**kwargs) + super().__init__(**kwargs) self._xx = True @@ -203,7 +226,7 @@ class ClampBCYY(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCYY, self).__init__(**kwargs) + super().__init__(**kwargs) self._yy = True @@ -213,7 +236,7 @@ class ClampBCZZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(ClampBCZZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._zz = True @@ -223,7 +246,7 @@ class RollerBCX(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCX, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = False @@ -233,7 +256,7 @@ class RollerBCY(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCY, self).__init__(**kwargs) + super().__init__(**kwargs) self._y = False @@ -243,7 +266,7 @@ class RollerBCZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._z = False @@ -253,7 +276,7 @@ class RollerBCXY(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCXY, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = False self._y = False @@ -264,7 +287,7 @@ class RollerBCYZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCYZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._y = False self._z = False @@ -275,6 +298,6 @@ class RollerBCXZ(PinnedBC): __doc__ += docs def __init__(self, **kwargs): - super(RollerBCXZ, self).__init__(**kwargs) + super().__init__(**kwargs) self._x = False self._z = False diff --git a/src/compas_fea2/model/connectors.py b/src/compas_fea2/model/connectors.py index c54c15677..a36b95912 100644 --- a/src/compas_fea2/model/connectors.py +++ b/src/compas_fea2/model/connectors.py @@ -1,8 +1,10 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Dict +from typing import List +from typing import Optional +from typing import Union from compas_fea2.base import FEAData +from compas_fea2.model.groups import NodesGroup from compas_fea2.model.groups import _Group from compas_fea2.model.nodes import Node from compas_fea2.model.parts import RigidPart @@ -15,11 +17,9 @@ class Connector(FEAData): Parameters ---------- - nodes : list, :class:`compas_fea2.model.groups.NodeGroup` + nodes : list[Node] | compas_fea2.model.groups.NodeGroup The connected nodes. The nodes must be registered to different parts. For connecting nodes in the same part, check :class:`compas_fea2.model.elements.SpringElement`. - section : :class:`compas_fea2.model.sections.ConnectorSection` - The section containing the mechanical properties of the connector. Notes ----- @@ -27,14 +27,27 @@ class Connector(FEAData): """ - def __init__(self, nodes, **kwargs): - super(Connector, self).__init__(**kwargs) - self._key = None - self._nodes = None - self.nodes = nodes + def __init__(self, nodes: Union[List[Node], _Group], **kwargs): + super().__init__(**kwargs) + self._key: Optional[str] = None + self._nodes: Optional[List[Node]] = nodes @property - def nodes(self): + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "nodes": [node.__data__ for node in self._nodes], + } + + @classmethod + def __from_data__(cls, data, model): + from compas_fea2.model.nodes import Node + + nodes = [Node.__from_data__(node, model) for node in data["nodes"]] + return cls(nodes=nodes) + + @property + def nodes(self) -> List[Node]: return self._nodes @property @@ -42,7 +55,7 @@ def model(self): return self._registration @nodes.setter - def nodes(self, nodes): + def nodes(self, nodes: Union[List[Node], _Group]): if isinstance(nodes, _Group): nodes = nodes._members if isinstance(nodes, Node): @@ -59,46 +72,149 @@ def nodes(self, nodes): self._nodes = nodes +class LinearConnector(Connector): + """Linear connector. + + Parameters + ---------- + nodes : list[Node] | compas_fea2.model.groups.NodeGroup + The connected nodes. The nodes must be registered to different parts. + For connecting nodes in the same part, check :class:`compas_fea2.model.elements.SpringElement`. + dofs : str + The degrees of freedom to be connected. Options are 'beam', 'bar', or a list of integers. + """ + + def __init__(self, master, slave, section, **kwargs): + super().__init__(nodes=[master, slave], **kwargs) + if isinstance(master, _Group): + master = list(master._members)[0] + if isinstance(slave, _Group): + slave = list(slave._members)[0] + if master.part == slave.part: + raise ValueError("Nodes must belong to different parts") + self._master = master + self._slave = slave + self._section = section + self._nodes = NodesGroup(nodes=[self.master, self.slave]) + + @property + def nodes(self): + return self._nodes + + @property + def master(self): + return self._master + + @master.setter + def master(self, value): + self._master = value + self._nodes = NodesGroup(nodes=[self.master, self.slave]) + + @property + def slave(self): + return self._slave + + @slave.setter + def slave(self, value): + self._slave = value + self._nodes = NodesGroup(nodes=[self.master, self.slave]) + + @property + def section(self): + return self._section + + @property + def __data__(self): + data = super().__data__ + data.update({"dofs": self._dofs}) + return data + + @classmethod + def __from_data__(cls, data, model): + instance = super().__from_data__(data, model) + instance._dofs = data["dofs"] + return instance + + @property + def dofs(self) -> str: + return self._dofs + + class RigidLinkConnector(Connector): """Rigid link connector. Parameters ---------- - nodes : list, :class:`compas_fea2.model.groups.NodeGroup` + nodes : list[Node] | compas_fea2.model.groups.NodeGroup The connected nodes. The nodes must be registered to different parts. - For connecting nodes in the same part, check :class:`compas_fea2.model.elements.SpringElement`. + For connecting nodes in the same part, check :class:`compas_fea2.model.elements.RigidElement`. dofs : str The degrees of freedom to be connected. Options are 'beam', 'bar', or a list of integers. """ - def __init__(self, nodes, dofs="beam", **kwargs): - super(RigidLinkConnector, self).__init__(nodes, **kwargs) - self._dofs = dofs + def __init__(self, nodes: Union[List[Node], _Group], dofs: str = "beam", **kwargs): + super().__init__(nodes, **kwargs) + self._dofs: str = dofs + + @property + def __data__(self): + data = super().__data__ + data.update({"dofs": self._dofs}) + return data + + @classmethod + def __from_data__(cls, data, model): + instance = super().__from_data__(data, model) + instance._dofs = data["dofs"] + return instance @property - def dofs(self): + def dofs(self) -> str: return self._dofs class SpringConnector(Connector): """Spring connector.""" - def __init__(self, nodes, section, yielding=None, failure=None, **kwargs): - super(SpringConnector, self).__init__(nodes, **kwargs) + def __init__(self, nodes: Union[List[Node], _Group], section, yielding: Optional[Dict[str, float]] = None, failure: Optional[Dict[str, float]] = None, **kwargs): + super().__init__(nodes, **kwargs) self._section = section - self._yielding = yielding - self._failure = failure + self._yielding: Optional[Dict[str, float]] = yielding + self._failure: Optional[Dict[str, float]] = failure + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "section": self._section, + "yielding": self._yielding, + "failure": self._failure, + } + ) + return data + + @classmethod + def __from_data__(cls, data, model): + from importlib import import_module + + instance = super().__from_data__(data, model) + cls_section = import_module(".".join(data["section"]["class"].split(".")[:-1])) + instance._section = cls_section.__from_data__(data["section"]) + instance._yielding = data["yielding"] + instance._failure = data["failure"] + return instance @property def section(self): return self._section @property - def yielding(self): + def yielding(self) -> Optional[Dict[str, float]]: return self._yielding @yielding.setter - def yielding(self, value): + def yielding(self, value: Dict[str, float]): try: value["c"] value["t"] @@ -107,11 +223,11 @@ def yielding(self, value): self._yielding = value @property - def failure(self): + def failure(self) -> Optional[Dict[str, float]]: return self._failure @failure.setter - def failure(self, value): + def failure(self, value: Dict[str, float]): try: value["c"] value["t"] @@ -123,10 +239,22 @@ def failure(self, value): class ZeroLengthConnector(Connector): """Zero length connector connecting overlapping nodes.""" - def __init__(self, nodes, direction, **kwargs): - super(ZeroLengthConnector, self).__init__(nodes, **kwargs) + def __init__(self, nodes: Union[List[Node], _Group], direction, **kwargs): + super().__init__(nodes, **kwargs) self._direction = direction + @property + def __data__(self): + data = super().__data__ + data.update({"direction": self._direction}) + return data + + @classmethod + def __from_data__(cls, data, model): + instance = super().__from_data__(data, model) + instance._direction = data["direction"] + return instance + @property def direction(self): return self._direction @@ -135,28 +263,74 @@ def direction(self): class ZeroLengthSpringConnector(ZeroLengthConnector): """Spring connector connecting overlapping nodes.""" - def __init__(self, nodes, direction, section, yielding=None, failure=None, **kwargs): + def __init__(self, nodes: Union[List[Node], _Group], direction, section, yielding: Optional[Dict[str, float]] = None, failure: Optional[Dict[str, float]] = None, **kwargs): # SpringConnector.__init__(self, nodes=nodes, section=section, yielding=yielding, failure=failure) - ZeroLengthConnector.__init__(self, nodes, direction, **kwargs) + super().__init__(nodes, direction, **kwargs) + self._section = section + self._yielding = yielding + self._failure = failure + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "section": self._section, + "yielding": self._yielding, + "failure": self._failure, + } + ) + return data + + @classmethod + def __from_data__(cls, data, model): + from importlib import import_module + + instance = super().__from_data__(data, model) + cls_section = import_module(".".join(data["section"]["class"].split(".")[:-1])) + instance._section = cls_section.__from_data__(data["section"]) + instance._yielding = data["yielding"] + instance._failure = data["failure"] + return instance class ZeroLengthContactConnector(ZeroLengthConnector): """Contact connector connecting overlapping nodes.""" - def __init__(self, nodes, direction, Kn, Kt, mu, **kwargs): - super(ZeroLengthContactConnector, self).__init__(nodes, direction, **kwargs) - self._Kn = Kn - self._Kt = Kt - self._mu = mu + def __init__(self, nodes: Union[List[Node], _Group], direction, Kn: float, Kt: float, mu: float, **kwargs): + super().__init__(nodes, direction, **kwargs) + self._Kn: float = Kn + self._Kt: float = Kt + self._mu: float = mu @property - def Kn(self): + def Kn(self) -> float: return self._Kn @property - def Kt(self): + def Kt(self) -> float: return self._Kt @property - def mu(self): + def mu(self) -> float: return self._mu + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "Kn": self._Kn, + "Kt": self._Kt, + "mu": self._mu, + } + ) + return data + + @classmethod + def __from_data__(cls, data, model): + instance = super().__from_data__(data, model) + instance._Kn = data["Kn"] + instance._Kt = data["Kt"] + instance._mu = data["mu"] + return instance diff --git a/src/compas_fea2/model/constraints.py b/src/compas_fea2/model/constraints.py index 5a36673be..e0153ba78 100644 --- a/src/compas_fea2/model/constraints.py +++ b/src/compas_fea2/model/constraints.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData @@ -9,11 +5,20 @@ class _Constraint(FEAData): """Base class for constraints. A constraint removes degree of freedom of nodes in the model. - """ - def __init__(self, **kwargs): - super(_Constraint, self).__init__(**kwargs) + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + } + + @classmethod + def __from_data__(cls, data): + return cls(**data) # ------------------------------------------------------------------------------ @@ -22,22 +27,26 @@ def __init__(self, **kwargs): class _MultiPointConstraint(_Constraint): - """A MultiPointContrstaint (MPC) links a node (master) to other nodes (slaves) in the model. + """A MultiPointConstraint (MPC) links a node (master) to other nodes (slaves) in the model. Parameters ---------- + constraint_type : str + Type of the constraint. master : :class:`compas_fea2.model.Node` - Node that act as master. - slaves : [:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` + Node that acts as master. + slaves : List[:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` List or Group of nodes that act as slaves. tol : float Constraint tolerance, distance limit between master and slaves. Attributes ---------- + constraint_type : str + Type of the constraint. master : :class:`compas_fea2.model.Node` - Node that act as master. - slaves : [:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` + Node that acts as master. + slaves : List[:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` List or Group of nodes that act as slaves. tol : float Constraint tolerance, distance limit between master and slaves. @@ -48,10 +57,25 @@ class _MultiPointConstraint(_Constraint): """ - def __init__(self, constraint_type, **kwargs): - super(_MultiPointConstraint, self).__init__(**kwargs) + def __init__(self, constraint_type: str, **kwargs) -> None: + super().__init__(**kwargs) self.constraint_type = constraint_type + @property + def __data__(self): + data = super().__data__ + data.update( + { + "constraint_type": self.constraint_type, + # ...other attributes... + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls(constraint_type=data["constraint_type"], **data) + class TieMPC(_MultiPointConstraint): """Tie MPC that constraints axial translations.""" @@ -63,13 +87,13 @@ class BeamMPC(_MultiPointConstraint): # TODO check! class _SurfaceConstraint(_Constraint): - """A SurfaceContrstaint links a surface (master) to another surface (slave) in the model. + """A SurfaceConstraint links a surface (master) to another surface (slave) in the model. Parameters ---------- master : :class:`compas_fea2.model.Node` - Node that act as master. - slaves : [:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` + Node that acts as master. + slaves : List[:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` List or Group of nodes that act as slaves. tol : float Constraint tolerance, distance limit between master and slaves. @@ -77,14 +101,24 @@ class _SurfaceConstraint(_Constraint): Attributes ---------- master : :class:`compas_fea2.model.Node` - Node that act as master. - slaves : [:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` + Node that acts as master. + slaves : List[:class:`compas_fea2.model.Node`] | :class:`compas_fea2.model.NodesGroup` List or Group of nodes that act as slaves. tol : float Constraint tolerance, distance limit between master and slaves. """ + @property + def __data__(self): + data = super().__data__ + # ...update data with specific attributes... + return data + + @classmethod + def __from_data__(cls, data): + return cls(**data) + class TieConstraint(_SurfaceConstraint): """Tie constraint between two surfaces.""" diff --git a/src/compas_fea2/model/elements.py b/src/compas_fea2/model/elements.py index 457332773..53cb51600 100644 --- a/src/compas_fea2/model/elements.py +++ b/src/compas_fea2/model/elements.py @@ -1,13 +1,14 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from operator import itemgetter +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple from compas.datastructures import Mesh from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane +from compas.geometry import Point from compas.geometry import Polygon from compas.geometry import Polyhedron from compas.geometry import Vector @@ -16,6 +17,9 @@ from compas.itertools import pairwise from compas_fea2.base import FEAData +from compas_fea2.results import Result +from compas_fea2.results import ShellStressResult +from compas_fea2.results import SolidStressResult class _Element(FEAData): @@ -37,15 +41,15 @@ class _Element(FEAData): nodes : list[:class:`compas_fea2.model.Node`] Nodes to which the element is connected. nodes_key : str, read-only - Identifier based on the conntected nodes. + Identifier based on the connected nodes. section : :class:`compas_fea2.model._Section` Section object. implementation : str The name of the backend model implementation of the element. - part : :class:`compas_fea2.model.DeformablePart` | None + part : :class:`compas_fea2.model.Part` | None The parent part. on_boundary : bool | None - `True` it the element has a face on the boundary mesh of the part, `False` + `True` if the element has a face on the boundary mesh of the part, `False` otherwise, by default `None`. part : :class:`compas_fea2.model._Part`, read-only The Part where the element is assigned. @@ -71,10 +75,9 @@ class _Element(FEAData): """ - # FIXME frame and orientations are a bit different concepts. find a way to unify them - - def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): - super(_Element, self).__init__(**kwargs) + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(**kwargs) + self._part_key = None self._nodes = self._check_nodes(nodes) self._registration = nodes[0]._registration self._section = section @@ -89,109 +92,149 @@ def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): self._shape = None @property - def part(self): + def __data__(self): + return { + "class": self.__class__.__base__, + "nodes": [node.__data__ for node in self.nodes], + "section": self.section.__data__, + "implementation": self.implementation, + "rigid": self.rigid, + } + + @classmethod + def __from_data__(cls, data): + nodes = [node_data.pop("class").__from_data__(node_data) for node_data in data["nodes"]] + section = data["section"].pop("class").__from_data__(data["section"]) + return cls(nodes, section, implementation=data.get("implementation"), rigid=data.get("rigid")) + + @property + def part(self) -> "_Part": # noqa: F821 return self._registration @property - def model(self): + def model(self) -> "Model": # noqa: F821 return self.part.model @property - def nodes(self): + def results_cls(self) -> Result: + raise NotImplementedError("The results_cls property must be implemented in the subclass") + + @property + def nodes(self) -> List["Node"]: # noqa: F821 return self._nodes @nodes.setter - def nodes(self, value): + def nodes(self, value: List["Node"]): # noqa: F821 self._nodes = self._check_nodes(value) @property - def nodes_key(self): - return "-".join(sorted([str(node.key) for node in self.nodes], key=int)) + def nodes_key(self) -> str: + return [n.part_key for n in self.nodes] @property - def nodes_inputkey(self): - return "-".join(sorted([str(node.input_key) for node in self.nodes], key=int)) + def nodes_inputkey(self) -> str: + return "-".join(sorted([str(node.key) for node in self.nodes], key=int)) @property - def points(self): + def points(self) -> List["Point"]: return [node.point for node in self.nodes] @property - def section(self): + def section(self) -> "_Section": # noqa: F821 return self._section @section.setter - def section(self, value): - if self.part: - self.part.add_section(value) + def section(self, value: "_Section"): # noqa: F821 self._section = value @property - def frame(self): + def frame(self) -> Optional[Frame]: return self._frame @property - def implementation(self): + def implementation(self) -> Optional[str]: return self._implementation @property - def on_boundary(self): + def on_boundary(self) -> Optional[bool]: return self._on_boundary @on_boundary.setter - def on_boundary(self, value): + def on_boundary(self, value: bool): self._on_boundary = value - def _check_nodes(self, nodes): + def _check_nodes(self, nodes: List["Node"]) -> List["Node"]: # noqa: F821 if len(set([node._registration for node in nodes])) != 1: raise ValueError("At least one of node is registered to a different part or not registered") return nodes @property - def area(self): + def part_key(self) -> int: + return self._part_key + + @property + def area(self) -> float: raise NotImplementedError() @property - def volume(self): + def volume(self) -> float: raise NotImplementedError() @property - def results_format(self): + def results_format(self) -> Dict: raise NotImplementedError() @property - def reference_point(self): + def reference_point(self) -> "Point": raise NotImplementedError() @property - def rigid(self): + def rigid(self) -> bool: return self._rigid @property - def weight(self): + def mass(self) -> float: return self.volume * self.section.material.density - @property - def shape(self): - return self._shape + def weight(self, g: float) -> float: + return self.mass * g + @property + def nodal_mass(self) -> List[float]: + return [self.mass / len(self.nodes)] * 3 -# ============================================================================== -# 0D elements -# ============================================================================== + @property + def ndim(self) -> int: + return self._ndim -# TODO: remove. This is how abaqus does it but it is better to define the mass as a property of the nodes. class MassElement(_Element): """A 0D element for concentrated point mass.""" + @property + def __data__(self): + data = super().__data__ + return data + + @classmethod + def __from_data__(cls, data): + element = super().__from_data__(data) + return element + class _Element0D(_Element): """Element with 1 dimension.""" - def __init__(self, nodes, frame, implementation=None, rigid=False, **kwargs): - super(_Element0D, self).__init__(nodes, section=None, implementation=implementation, rigid=rigid, **kwargs) + def __init__(self, nodes: List["Node"], frame: Frame, implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(nodes, section=None, implementation=implementation, rigid=rigid, **kwargs) self._frame = frame + self._ndim = 0 + + @property + def __data__(self): + data = super().__data__() + data["frame"] = self.frame.__data__ + return data class SpringElement(_Element0D): @@ -204,8 +247,8 @@ class SpringElement(_Element0D): """ - def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): - super(_Element0D, self).__init__(nodes, section=section, implementation=implementation, rigid=rigid, **kwargs) + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(nodes, section=section, implementation=implementation, rigid=rigid, **kwargs) class LinkElement(_Element0D): @@ -217,13 +260,10 @@ class LinkElement(_Element0D): use :class:`compas_fea2.model.connectors.RigidLinkConnector`. """ - def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): - super(_Element0D, self).__init__(nodes, section=section, implementation=implementation, rigid=rigid, **kwargs) + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(nodes, section=section, implementation=implementation, rigid=rigid, **kwargs) -# ============================================================================== -# 1D elements -# ============================================================================== class _Element1D(_Element): """Element with 1 dimension. @@ -251,17 +291,35 @@ class _Element1D(_Element): The volume of the element. """ - def __init__(self, nodes, section, frame=None, implementation=None, rigid=False, **kwargs): - super(_Element1D, self).__init__(nodes, section, implementation=implementation, rigid=rigid, **kwargs) + def __init__(self, nodes: List["Node"], section: "_Section", frame: Optional[Frame] = None, implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(nodes, section, implementation=implementation, rigid=rigid, **kwargs) + if not frame: + raise ValueError("Frame is required for 1D elements") self._frame = frame if isinstance(frame, Frame) else Frame(nodes[0].point, Vector(*frame), Vector.from_start_end(nodes[0].point, nodes[-1].point)) - self._curve = Line(nodes[0].point, nodes[-1].point) + self._ndim = 1 + + @property + def __data__(self): + data = super().__data__ + data["frame"] = self.frame.__data__ + return data + + @classmethod + def __from_data__(cls, data): + nodes = [node_data.pop("class").__from_data__(node_data) for node_data in data["nodes"]] + section = data["section"].pop("class").__from_data__(data["section"]) + frame = Frame.__from_data__(data["frame"]) + return cls(nodes=nodes, section=section, frame=frame, implementation=data.get("implementation"), rigid=data.get("rigid")) + + @property + def curve(self) -> Line: + return Line(self.nodes[0].point, self.nodes[-1].point) @property - def outermesh(self): + def outermesh(self) -> Mesh: self._frame.point = self.nodes[0].point self._shape_i = self.section._shape.oriented(self._frame, check_planarity=False) self._shape_j = self._shape_i.translated(Vector.from_start_end(self.nodes[0].point, self.nodes[-1].point), check_planarity=False) - # create the outer mesh using the section information p = self._shape_i.points n = len(p) self._outermesh = Mesh.from_vertices_and_faces( @@ -270,17 +328,96 @@ def outermesh(self): return self._outermesh @property - def frame(self): + def frame(self) -> Frame: return self._frame @property - def length(self): + def shape(self) -> Optional["Shape"]: # noqa: F821 + return self._shape + + @property + def length(self) -> float: return distance_point_point(*[node.point for node in self.nodes]) @property - def volume(self): + def volume(self) -> float: return self.section.A * self.length + def plot_section(self): + self.section.plot() + + def plot_stress_distribution(self, step: "_Step", end: str = "end_1", nx: int = 100, ny: int = 100, *args, **kwargs): # noqa: F821 + """ Plot the stress distribution along the element. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The step to which the element belongs. + end : str + The end of the element to plot the stress distribution. Can be 'start' or 'end'. + nx : int + Number of points along the x axis. + ny : int + Number of points along the y axis. + *args : list + Additional arguments to pass to the plot function. + **kwargs : dict + Additional keyword arguments to pass to the plot function. + """ + if not hasattr(step, "section_forces_field"): + raise ValueError("The step does not have a section_forces_field") + r = step.section_forces_field.get_element_forces(self) + r.plot_stress_distribution(*args, **kwargs) + + def section_forces_result(self, step: "Step") -> "Result": # noqa: F821 + """Get the section forces result for the element. + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The analysis step. + + Returns + ------- + :class:`compas_fea2.results.Result` + The section forces result for the element. + """ + + if not hasattr(step, "section_forces_field"): + raise ValueError("The step does not have a section_forces_field") + return step.section_forces_field.get_result_at(self) + + def forces(self, step: "Step") -> "Result": # noqa: F821 + """Get the forces result for the element. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The analysis step. + + Returns + ------- + :class:`compas_fea2.results.Result` + The forces result for the element. + """ + r = self.section_forces_result(step) + return r.forces + + def moments(self, step: "_Step") -> "Result": # noqa: F821 + """ Get the moments result for the element. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The analysis step. + + Returns + ------- + :class:`compas_fea2.results.Result` + The moments result for the element. + """ + r = self.section_forces_result(step) + return r.moments + class BeamElement(_Element1D): """A 1D element that resists axial, shear, bending and torsion. @@ -296,9 +433,8 @@ class BeamElement(_Element1D): class TrussElement(_Element1D): """A 1D element that resists axial loads.""" - def __init__(self, nodes, section, implementation=None, rigid=False, **kwargs): - # TODO remove frame - super(TrussElement, self).__init__(nodes, section, frame=[1, 1, 1], implementation=implementation, rigid=rigid, **kwargs) + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__(nodes, section, frame=[1, 1, 1], implementation=implementation, rigid=rigid, **kwargs) class StrutElement(TrussElement): @@ -309,11 +445,6 @@ class TieElement(TrussElement): """A truss element that resists axial tensile loads.""" -# ============================================================================== -# 2D elements -# ============================================================================== - - class Face(FEAData): """Element representing a face. @@ -345,8 +476,8 @@ class Face(FEAData): """ - def __init__(self, nodes, tag, element=None, **kwargs): - super(Face, self).__init__(**kwargs) + def __init__(self, nodes: List["Node"], tag: str, element: Optional["_Element"] = None, **kwargs): # noqa: F821 + super().__init__(**kwargs) self._nodes = nodes self._tag = tag self._plane = Plane.from_three_points(*[node.xyz for node in nodes[:3]]) # TODO check when more than 3 nodes @@ -354,33 +485,84 @@ def __init__(self, nodes, tag, element=None, **kwargs): self._centroid = centroid_points([node.xyz for node in nodes]) @property - def nodes(self): + def __data__(self): + return { + "nodes": [node.__data__ for node in self.nodes], + "tag": self.tag, + "element": self.element.__data__ if self.element else None, + } + + @classmethod + def __from_data__(cls, data): + from importlib import import_module + + from compas_fea2.model import Node + + elements_module = import_module("compas_fea2.model.elements") + element_cls = getattr(elements_module, data["elements"]["class"]) + + nodes = [Node.__from_data__(node_data) for node_data in data["nodes"]] + element = element_cls[data["element"]["class"]].__from_data__(data["element"]) + return cls(nodes, data["tag"], element=element) + + @property + def nodes(self) -> List["Node"]: # noqa: F821 return self._nodes @property - def tag(self): + def tag(self) -> str: return self._tag @property - def plane(self): + def plane(self) -> Plane: return self._plane @property - def element(self): + def element(self) -> Optional["_Element"]: return self._registration @property - def polygon(self): + def part(self) -> "_Part": # noqa: F821 + return self.element.part + + @property + def model(self) -> "Model": # noqa: F821 + return self.element.model + + @property + def polygon(self) -> Polygon: return Polygon([n.xyz for n in self.nodes]) @property - def area(self): + def area(self) -> float: return self.polygon.area @property - def centroid(self): + def centroid(self) -> "Point": return self._centroid + @property + def nodes_key(self) -> List: + return [n._part_key for n in self.nodes] + + @property + def normal(self) -> Vector: + return self.plane.normal + + @property + def points(self) -> List["Point"]: + return [node.point for node in self.nodes] + + @property + def mesh(self) -> Mesh: + return Mesh.from_vertices_and_faces(self.points, [[c for c in range(len(self.points))]]) + + @property + def node_area(self) -> float: + mesh = self.mesh + vertex_area = [mesh.vertex_area(vertex) for vertex in mesh.vertices()] + return zip(self.nodes, vertex_area) + class _Element2D(_Element): """Element with 2 dimensions.""" @@ -396,8 +578,8 @@ class _Element2D(_Element): {'s1': (0,1,2), ...} """ - def __init__(self, nodes, section=None, implementation=None, rigid=False, **kwargs): - super(_Element2D, self).__init__( + def __init__(self, nodes: List["Node"], section: Optional["_Section"] = None, implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__( nodes=nodes, section=section, implementation=implementation, @@ -407,37 +589,38 @@ def __init__(self, nodes, section=None, implementation=None, rigid=False, **kwar self._faces = None self._face_indices = None + self._ndim = 2 @property - def nodes(self): + def nodes(self) -> List["Node"]: # noqa: F821 return self._nodes @nodes.setter - def nodes(self, value): + def nodes(self, value: List["Node"]): # noqa: F821 self._nodes = self._check_nodes(value) self._faces = self._construct_faces(self._face_indices) @property - def face_indices(self): + def face_indices(self) -> Optional[Dict[str, Tuple[int]]]: return self._face_indices @property - def faces(self): + def faces(self) -> Optional[List[Face]]: return self._faces @property - def volume(self): + def volume(self) -> float: return self._faces[0].area * self.section.t @property - def reference_point(self): + def reference_point(self) -> "Point": return centroid_points([face.centroid for face in self.faces]) @property - def outermesh(self): + def outermesh(self) -> Mesh: return Mesh.from_vertices_and_faces(self.points, list(self._face_indices.values())) - def _construct_faces(self, face_indices): + def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: """Construct the face-nodes dictionary. Parameters @@ -453,6 +636,23 @@ def _construct_faces(self, face_indices): """ return [Face(nodes=itemgetter(*indices)(self.nodes), tag=name, element=self) for name, indices in face_indices.items()] + def stress_results(self, step: "_Step") -> "Result": # noqa: F821 + """Get the stress results for the element. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The analysis step. + + Returns + ------- + :class:`compas_fea2.results.Result` + The stress results for the element. + """ + if not hasattr(step, "stress_field"): + raise ValueError("The step does not have a stress field") + return step.stress_field.get_result_at(self) + class ShellElement(_Element2D): """A 2D element that resists axial, shear, bending and torsion. @@ -462,8 +662,8 @@ class ShellElement(_Element2D): """ - def __init__(self, nodes, section=None, implementation=None, rigid=False, **kwargs): - super(ShellElement, self).__init__( + def __init__(self, nodes: List["Node"], section: Optional["_Section"] = None, implementation: Optional[str] = None, rigid: bool = False, **kwargs): # noqa: F821 + super().__init__( nodes=nodes, section=section, implementation=implementation, @@ -474,6 +674,10 @@ def __init__(self, nodes, section=None, implementation=None, rigid=False, **kwar self._face_indices = {"SPOS": tuple(range(len(nodes))), "SNEG": tuple(range(len(nodes)))[::-1]} self._faces = self._construct_faces(self._face_indices) + @property + def results_cls(self) -> Result: + return {"s": ShellStressResult} + class MembraneElement(_Element2D): """A shell element that resists only axial loads. @@ -489,14 +693,6 @@ class MembraneElement(_Element2D): """ -# ============================================================================== -# 3D elements -# ============================================================================== - - -# TODO add picture with node lables convention - - class _Element3D(_Element): """A 3D element that resists axial, shear, bending and torsion. Solid (continuum) elements can be used for linear analysis @@ -508,8 +704,8 @@ class _Element3D(_Element): """ - def __init__(self, nodes, section, implementation=None, **kwargs): - super(_Element3D, self).__init__( + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, **kwargs): # noqa: F821 + super().__init__( nodes=nodes, section=section, implementation=implementation, @@ -518,33 +714,52 @@ def __init__(self, nodes, section, implementation=None, **kwargs): self._face_indices = None self._faces = None self._frame = Frame.worldXY() + self._ndim = 3 @property - def frame(self): + def results_cls(self) -> Result: + return {"s": SolidStressResult} + + @property + def frame(self) -> Frame: return self._frame @property - def nodes(self): + def nodes(self) -> List["Node"]: # noqa: F821 return self._nodes @nodes.setter - def nodes(self, value): + def nodes(self, value: List["Node"]): # noqa: F821 self._nodes = value self._faces = self._construct_faces(self._face_indices) @property - def face_indices(self): + def face_indices(self) -> Optional[Dict[str, Tuple[int]]]: return self._face_indices @property - def faces(self): + def faces(self) -> Optional[List[Face]]: return self._faces @property - def reference_point(self): - return centroid_points([face.centroid for face in self.faces]) + def edges(self): + seen = set() + for _, face in self._faces.itmes(): + for u, v in pairwise(face + face[:1]): + if (u, v) not in seen: + seen.add((u, v)) + seen.add((v, u)) + yield u, v - def _construct_faces(self, face_indices): + @property + def centroid(self) -> "Point": + return centroid_points([node.point for node in self.nodes]) + + @property + def reference_point(self) -> "Point": + return self._reference_point or self.centroid + + def _construct_faces(self, face_indices: Dict[str, Tuple[int]]) -> List[Face]: """Construct the face-nodes dictionary. Parameters @@ -562,61 +777,109 @@ def _construct_faces(self, face_indices): return [Face(nodes=itemgetter(*indices)(self.nodes), tag=name, element=self) for name, indices in face_indices.items()] @property - def area(self): + def area(self) -> float: return self._area @classmethod - def from_polyhedron(cls, polyhedron, section, implementation=None, **kwargs): + def from_polyhedron(cls, polyhedron: Polyhedron, section: "_Section", implementation: Optional[str] = None, **kwargs) -> "_Element3D": # noqa: F821 from compas_fea2.model import Node element = cls([Node(vertex) for vertex in polyhedron.vertices], section, implementation, **kwargs) return element @property - def outermesh(self): + def outermesh(self) -> Mesh: return Polyhedron(self.points, list(self._face_indices.values())).to_mesh() class TetrahedronElement(_Element3D): - """A Solid element with 4 faces. + """A Solid element with 4 or 10 nodes. Notes ----- - The face labels are as follows: + This element can be either: + - C3D4: A 4-node tetrahedral element. + - C3D10: A 10-node tetrahedral element (with midside nodes). + Face labels (for the first 4 corner nodes) are: - S1: (0, 1, 2) - S2: (0, 1, 3) - S3: (1, 2, 3) - S4: (0, 2, 3) - where the number is the index of the the node in the nodes list + The C3D10 element includes 6 additional midside nodes: + - Edge (0,1) → Node 4 + - Edge (1,2) → Node 5 + - Edge (2,0) → Node 6 + - Edge (0,3) → Node 7 + - Edge (1,3) → Node 8 + - Edge (2,3) → Node 9 + Attributes + ---------- + nodes : List["Node"] + The list of nodes defining the element. """ - def __init__(self, *, nodes, section, implementation=None, **kwargs): - super(TetrahedronElement, self).__init__( + def __init__( + self, + nodes: List["Node"], # noqa: F821 + section: "_Section", # noqa: F821 + implementation: Optional[str] = None, + **kwargs, + ): + if len(nodes) not in {4, 10}: + raise ValueError("TetrahedronElement must have either 4 (C3D4) or 10 (C3D10) nodes.") + + self.element_type = "C3D10" if len(nodes) == 10 else "C3D4" + + super().__init__( nodes=nodes, section=section, implementation=implementation, **kwargs, ) - self._face_indices = {"s1": (0, 1, 2), "s2": (0, 1, 3), "s3": (1, 2, 3), "s4": (0, 2, 3)} + + # Define the face indices for a tetrahedron (first four corner nodes) + self._face_indices = { + "s1": (0, 1, 2), + "s2": (0, 1, 3), + "s3": (1, 2, 3), + "s4": (0, 2, 3), + } + self._faces = self._construct_faces(self._face_indices) @property def edges(self): + """Yields edges as (start_node, end_node), including midside nodes if present.""" seen = set() - for _, face in self._faces.itmes(): - for u, v in pairwise(face + face[:1]): + edges = [ + (0, 1, 4), + (1, 2, 5), + (2, 0, 6), + (0, 3, 7), + (1, 3, 8), + (2, 3, 9), + ] + + for edge in edges: + if self.element_type == "C3D10": + u, v, mid = edge + edge_pairs = [(u, mid), (mid, v)] # Split each edge into two segments + else: + u, v = edge[:2] + edge_pairs = [(u, v)] + + for u, v in edge_pairs: if (u, v) not in seen: seen.add((u, v)) seen.add((v, u)) yield u, v - # TODO use compas funcitons to compute differences and det @property - def volume(self): - """The volume property.""" + def volume(self) -> float: + """Calculates the volume using the first four corner nodes (C3D4 basis).""" def determinant_3x3(m): return m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[1][0] * (m[0][1] * m[2][2] - m[0][2] * m[2][1]) + m[2][0] * (m[0][1] * m[1][2] - m[0][2] * m[1][1]) @@ -624,20 +887,9 @@ def determinant_3x3(m): def subtract(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) - nodes_coord = [node.xyz for node in self.nodes] + nodes_coord = [node.xyz for node in self.nodes[:4]] # Use only first 4 nodes a, b, c, d = nodes_coord - return ( - abs( - determinant_3x3( - ( - subtract(a, b), - subtract(b, c), - subtract(c, d), - ) - ) - ) - / 6.0 - ) + return abs(determinant_3x3((subtract(a, b), subtract(b, c), subtract(c, d)))) / 6.0 class PentahedronElement(_Element3D): @@ -647,8 +899,8 @@ class PentahedronElement(_Element3D): class HexahedronElement(_Element3D): """A Solid cuboid element with 6 faces (extruded rectangle).""" - def __init__(self, nodes, section, implementation=None, **kwargs): - super(HexahedronElement, self).__init__( + def __init__(self, nodes: List["Node"], section: "_Section", implementation: Optional[str] = None, **kwargs): # noqa: F821 + super().__init__( nodes=nodes, section=section, implementation=implementation, diff --git a/src/compas_fea2/model/groups.py b/src/compas_fea2/model/groups.py index 405c32542..04d923070 100644 --- a/src/compas_fea2/model/groups.py +++ b/src/compas_fea2/model/groups.py @@ -1,105 +1,285 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +import logging +from importlib import import_module +from itertools import groupby +from typing import Any +from typing import Callable +from typing import Dict from typing import Iterable +from typing import List +from typing import Set +from typing import TypeVar from compas_fea2.base import FEAData -# TODO change lists to sets +# Define a generic type for members +T = TypeVar("T") + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) class _Group(FEAData): - """Base class for all groups. + """ + Base class for all groups. Parameters ---------- - members : set, optional - Set with the members belonging to the group. These can be either node, - elements, faces or parts. By default ``None``. + members : Iterable, optional + An iterable containing members belonging to the group. + Members can be nodes, elements, faces, or parts. Default is None. Attributes ---------- - registration : :class:`compas_fea2.model.DeformablePart` | :class:`compas_fea2.model.Model` - The parent object where the members of the Group belong. - + _members : Set[T] + The set of members belonging to the group. """ - def __init__(self, members=None, **kwargs): - super(_Group, self).__init__(**kwargs) - self._members = set() if not members else self._check_members(members) - - def __str__(self): - return """ -{} -{} -name : {} -# of members : {} -""".format( - self.__class__.__name__, len(self.__class__.__name__) * "-", self.name, len(self._members) - ) - - def _check_member(self, member): - if not isinstance(self, FacesGroup): - if member._registration != self._registration: - raise ValueError("{} is registered to a different object".format(member)) - return member - - def _check_members(self, members): - if not members or not isinstance(members, Iterable): - raise ValueError("{} must be a not empty iterable".format(members)) - # FIXME combine in more pythonic way - if isinstance(self, FacesGroup): - if len(set([member.element._registration for member in members])) != 1: - raise ValueError("At least one of the members to add is registered to a different object or not registered") - if self._registration: - if list(members).pop().element._registration != self._registration: - raise ValueError("At least one of the members to add is registered to a different object than the group") - else: - self._registration = list(members).pop().element._registration - else: - if len(set([member._registration for member in members])) != 1: - raise ValueError("At least one of the members to add is registered to a different object or not registered") - if self._registration: - if list(members).pop()._registration != self._registration: - raise ValueError("At least one of the members to add is registered to a different object than the group") - else: - self._registration = list(members).pop()._registration - return members - - def _add_member(self, member): - """Add a member to the group. + def __init__(self, members: Iterable[T] = None, **kwargs): + super().__init__(**kwargs) + self._members: Set[T] = set(members) if members else set() + self._part = None + self._model = None + + def __len__(self) -> int: + """Return the number of members in the group.""" + return len(self._members) + + def __contains__(self, item: T) -> bool: + """Check if an item is in the group.""" + return item in self._members + + def __iter__(self): + """Return an iterator over the members.""" + return iter(self._members) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}: {len(self._members)} members>" + + def __add__(self, other: "_Group") -> "_Group": + """Create a new group containing all members from this group and another group.""" + if not isinstance(other, _Group): + raise TypeError("Can only add another _Group instance.") + return self.__class__(self._members | other._members) + + def __sub__(self, other: "_Group") -> "_Group": + """Create a new group containing members that are in this group but not in another.""" + if not isinstance(other, _Group): + raise TypeError("Can only subtract another _Group instance.") + return self.__class__(self._members - other._members) + + @property + def members(self) -> Set[T]: + """Return the members of the group.""" + return self._members + + @property + def sorted(self) -> List[T]: + """ + Return the members of the group sorted in ascending order. + + Returns + ------- + List[T] + A sorted list of group members. + """ + return sorted(self._members, key=lambda x: x.key) + + def sorted_by(self, key: Callable[[T], any], reverse: bool = False) -> List[T]: + """ + Return the members of the group sorted based on a custom key function. + + Parameters + ---------- + key : Callable[[T], any] + A function that extracts a key from a member for sorting. + reverse : bool, optional + Whether to sort in descending order. Default is False. + + Returns + ------- + List[T] + A sorted list of group members based on the key function. + """ + return sorted(self._members, key=key, reverse=reverse) + + def subgroup(self, condition: Callable[[T], bool], **kwargs) -> "_Group": + """ + Create a subgroup based on a given condition. + + Parameters + ---------- + condition : Callable[[T], bool] + A function that takes a member as input and returns True if the member + should be included in the subgroup. + + Returns + ------- + _Group + A new group containing the members that satisfy the condition. + """ + filtered_members = set(filter(condition, self._members)) + return self.__class__(filtered_members, **kwargs) + + def group_by(self, key: Callable[[T], any]) -> Dict[any, "_Group"]: + """ + Group members into multiple subgroups based on a key function. Parameters ---------- - member : var - The member to add. This depends on the specific group type. + key : Callable[[T], any] + A function that extracts a key from a member for grouping. Returns ------- - var - The memeber. + Dict[any, _Group] + A dictionary where keys are the grouping values and values are `_Group` instances. + """ + sorted_members = self._members + # try: + # sorted_members = sorted(self._members, key=key) + # except TypeError: + # sorted_members = sorted(self._members, key=lambda x: x.key) + grouped_members = {k: set(v) for k, v in groupby(sorted_members, key=key)} + return {k: self.__class__(v, name=f"{self.name}") for k, v in grouped_members.items()} + + def union(self, other: "_Group") -> "_Group": """ - self._members.add(self._check_member(member)) - return member + Create a new group containing all members from this group and another group. - def _add_members(self, members): - """Add multiple members to the group. + Parameters + ---------- + other : _Group + Another group whose members should be combined with this group. + + Returns + ------- + _Group + A new group containing all members from both groups. + """ + if not isinstance(other, _Group): + raise TypeError("Can only perform union with another _Group instance.") + return self.__class__(self._members | other._members) + + def intersection(self, other: "_Group") -> "_Group": + """ + Create a new group containing only members that are present in both groups. Parameters ---------- - members : [var] - The members to add. These depend on the specific group type. + other : _Group + Another group to find common members with. Returns ------- - [var] - The memebers. + _Group + A new group containing only members found in both groups. """ - self._check_members(members) - for member in members: - self.members.add(member) - return members + if not isinstance(other, _Group): + raise TypeError("Can only perform intersection with another _Group instance.") + return self.__class__(self._members & other._members) + + def difference(self, other: "_Group") -> "_Group": + """ + Create a new group containing members that are in this group but not in another. + + Parameters + ---------- + other : _Group + Another group whose members should be removed from this group. + + Returns + ------- + _Group + A new group containing members unique to this group. + """ + if not isinstance(other, _Group): + raise TypeError("Can only perform difference with another _Group instance.") + return self.__class__(self._members - other._members) + + def add_member(self, member: T) -> None: + """ + Add a member to the group. + + Parameters + ---------- + member : T + The member to add. + """ + self._members.add(member) + + def add_members(self, members: Iterable[T]) -> None: + """ + Add multiple members to the group. + + Parameters + ---------- + members : Iterable[T] + The members to add. + """ + self._members.update(members) + + def remove_member(self, member: T) -> None: + """ + Remove a member from the group. + + Parameters + ---------- + member : T + The member to remove. + + Raises + ------ + KeyError + If the member is not found in the group. + """ + try: + self._members.remove(member) + except KeyError: + logger.warning(f"Member {member} not found in the group.") + + def remove_members(self, members: Iterable[T]) -> None: + """ + Remove multiple members from the group. + + Parameters + ---------- + members : Iterable[T] + The members to remove. + """ + self._members.difference_update(members) + + def serialize(self) -> Dict[str, Any]: + """ + Serialize the group to a dictionary. + + Returns + ------- + Dict[str, Any] + A dictionary representation of the group. + """ + return {"members": list(self._members)} + + @classmethod + def deserialize(cls, data: Dict[str, Any]) -> "_Group": + """ + Deserialize a dictionary to create a `_Group` instance. + + Parameters + ---------- + data : Dict[str, Any] + A dictionary representation of the group. + + Returns + ------- + _Group + A `_Group` instance with the deserialized members. + """ + return cls(set(data.get("members", []))) + + def clear(self) -> None: + """Remove all members from the group.""" + self._members.clear() class NodesGroup(_Group): @@ -129,20 +309,33 @@ class NodesGroup(_Group): def __init__(self, nodes, **kwargs): super(NodesGroup, self).__init__(members=nodes, **kwargs) + @property + def __data__(self): + data = super().__data__ + data.update({"nodes": [node.__data__ for node in self.nodes]}) + return data + + @classmethod + def __from_data__(cls, data): + from compas_fea2.model.nodes import Node + + return cls(nodes=[Node.__from_data__(node) for node in data["nodes"]]) + @property def part(self): - return self._registration + return self._part @property def model(self): - return self.part._registration + return self._model @property def nodes(self): return self._members def add_node(self, node): - """Add a node to the group. + """ + Add a node to the group. Parameters ---------- @@ -157,7 +350,8 @@ def add_node(self, node): return self._add_member(node) def add_nodes(self, nodes): - """Add multiple nodes to the group. + """ + Add multiple nodes to the group. Parameters ---------- @@ -191,14 +385,26 @@ class ElementsGroup(_Group): Notes ----- - ElementsGroups are registered to the same :class:`compas_fea2.model.DeformablePart` as - its elements and can belong to only one DeformablePart. + ElementsGroups are registered to the same :class:`compas_fea2.model.Part` as + its elements and can belong to only one Part. """ def __init__(self, elements, **kwargs): super(ElementsGroup, self).__init__(members=elements, **kwargs) + @property + def __data__(self): + data = super().__data__ + data.update({"elements": [element.__data__ for element in self.elements]}) + return data + + @classmethod + def __from_data__(cls, data): + elements_module = import_module("compas_fea2.model.elements") + elements = [getattr(elements_module, element_data["class"]).__from_data__(element_data) for element_data in data["elements"]] + return cls(elements=elements) + @property def part(self): return self._registration @@ -212,7 +418,8 @@ def elements(self): return self._members def add_element(self, element): - """Add an element to the group. + """ + Add an element to the group. Parameters ---------- @@ -223,12 +430,12 @@ def add_element(self, element): ------- :class:`compas_fea2.model.Element` The element added. - """ return self._add_member(element) def add_elements(self, elements): - """Add multiple elements to the group. + """ + Add multiple elements to the group. Parameters ---------- @@ -239,7 +446,6 @@ def add_elements(self, elements): ------- [:class:`compas_fea2.model.Element`] The elements added. - """ return self._add_members(elements) @@ -271,14 +477,26 @@ class FacesGroup(_Group): Notes ----- - FacesGroups are registered to the same :class:`compas_fea2.model.DeformablePart` as the + FacesGroups are registered to the same :class:`compas_fea2.model.Part` as the elements of its faces. """ - def __init__(self, *, faces, **kwargs): + def __init__(self, faces, **kwargs): super(FacesGroup, self).__init__(members=faces, **kwargs) + @property + def __data__(self): + data = super().__data__ + data.update({"faces": list(self.faces)}) + return data + + @classmethod + def __from_data__(cls, data): + obj = cls(faces=set(data["faces"])) + obj._registration = data["registration"] + return obj + @property def part(self): return self._registration @@ -300,7 +518,8 @@ def nodes(self): return nodes_set def add_face(self, face): - """Add a face to the group. + """ + Add a face to the group. Parameters ---------- @@ -310,13 +529,13 @@ def add_face(self, face): Returns ------- :class:`compas_fea2.model.Face` - The element added. - + The face added. """ return self._add_member(face) def add_faces(self, faces): - """Add multiple faces to the group. + """ + Add multiple faces to the group. Parameters ---------- @@ -327,7 +546,6 @@ def add_faces(self, faces): ------- [:class:`compas_fea2.model.Face`] The faces added. - """ return self._add_members(faces) @@ -340,12 +558,12 @@ class PartsGroup(_Group): name : str, optional Uniqe identifier. If not provided it is automatically generated. Set a name if you want a more human-readable input file. - parts : list[:class:`compas_fea2.model.DeformablePart`] + parts : list[:class:`compas_fea2.model.Part`] The parts belonging to the group. Attributes ---------- - parts : list[:class:`compas_fea2.model.DeformablePart`] + parts : list[:class:`compas_fea2.model.Part`] The parts belonging to the group. model : :class:`compas_fea2.model.Model` The model where the group is registered, by default `None`. @@ -360,6 +578,20 @@ class PartsGroup(_Group): def __init__(self, *, parts, **kwargs): super(PartsGroup, self).__init__(members=parts, **kwargs) + @property + def __data__(self): + data = super().__data__ + data.update({"parts": [part.__data__ for part in self.parts]}) + return data + + @classmethod + def __from_data__(cls, data): + from compas_fea2.model import _Part + + part_classes = {cls.__name__: cls for cls in _Part.__subclasses__()} + parts = [part_classes[part_data["class"]].__from_data__(part_data) for part_data in data["parts"]] + return cls(parts=parts) + @property def model(self): return self.part._registration @@ -369,7 +601,169 @@ def parts(self): return self._members def add_part(self, part): + """ + Add a part to the group. + + Parameters + ---------- + part : :class:`compas_fea2.model.Part` + The part to add. + + Returns + ------- + :class:`compas_fea2.model.Part` + The part added. + """ return self._add_member(part) def add_parts(self, parts): + """ + Add multiple parts to the group. + + Parameters + ---------- + parts : [:class:`compas_fea2.model.Part`] + The parts to add. + + Returns + ------- + [:class:`compas_fea2.model.Part`] + The parts added. + """ return self._add_members(parts) + + +class SectionsGroup(_Group): + """Base class for sections groups.""" + + def __init__(self, sections, **kwargs): + super(SectionsGroup, self).__init__(members=sections, **kwargs) + + @property + def sections(self): + return self._members + + def add_section(self, section): + return self._add_member(section) + + def add_sections(self, sections): + return self._add_members(sections) + + +class MaterialsGroup(_Group): + """Base class for materials groups.""" + + def __init__(self, materials, **kwargs): + super(MaterialsGroup, self).__init__(members=materials, **kwargs) + + @property + def materials(self): + return self._members + + def add_material(self, material): + return self._add_member(material) + + def add_materials(self, materials): + return self._add_members(materials) + + +class InterfacesGroup(_Group): + """Base class for interfaces groups.""" + + def __init__(self, interfaces, **kwargs): + super(InterfacesGroup, self).__init__(members=interfaces, **kwargs) + + @property + def interfaces(self): + return self._members + + def add_interface(self, interface): + return self._add_member(interface) + + def add_interfaces(self, interfaces): + return self._add_members(interfaces) + + +class BCsGroup(_Group): + """Base class for boundary conditions groups.""" + + def __init__(self, bcs, **kwargs): + super(BCsGroup, self).__init__(members=bcs, **kwargs) + + @property + def bcs(self): + return self._members + + def add_bc(self, bc): + return self._add_member(bc) + + def add_bcs(self, bcs): + return self._add_members(bcs) + + +class ConnectorsGroup(_Group): + """Base class for connectors groups.""" + + def __init__(self, connectors, **kwargs): + super(ConnectorsGroup, self).__init__(members=connectors, **kwargs) + + @property + def connectors(self): + return self._members + + def add_connector(self, connector): + return self._add_member(connector) + + def add_connectors(self, connectors): + return self._add_members(connectors) + + +class ConstraintsGroup(_Group): + """Base class for constraints groups.""" + + def __init__(self, constraints, **kwargs): + super(ConstraintsGroup, self).__init__(members=constraints, **kwargs) + + @property + def constraints(self): + return self._members + + def add_constraint(self, constraint): + return self._add_member(constraint) + + def add_constraints(self, constraints): + return self._add_members(constraints) + + +class ICsGroup(_Group): + """Base class for initial conditions groups.""" + + def __init__(self, ics, **kwargs): + super(ICsGroup, self).__init__(members=ics, **kwargs) + + @property + def ics(self): + return self._members + + def add_ic(self, ic): + return self._add_member(ic) + + def add_ics(self, ics): + return self._add_members(ics) + + +class ReleasesGroup(_Group): + """Base class for releases groups.""" + + def __init__(self, releases, **kwargs): + super(ReleasesGroup, self).__init__(members=releases, **kwargs) + + @property + def releases(self): + return self._members + + def add_release(self, release): + return self._add_member(release) + + def add_releases(self, releases): + return self._add_members(releases) diff --git a/src/compas_fea2/model/ics.py b/src/compas_fea2/model/ics.py index 5122fe80f..0c616dc32 100644 --- a/src/compas_fea2/model/ics.py +++ b/src/compas_fea2/model/ics.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData @@ -18,6 +14,16 @@ class _InitialCondition(FEAData): def __init__(self, **kwargs): super(_InitialCondition, self).__init__(**kwargs) + @property + def __data__(self): + return { + "type": self.__class__.__base__.__name__, + } + + @classmethod + def __from_data__(cls, data): + return cls(**data) + # FIXME this is not really a field in the sense that it is only applied to 1 node/element class InitialTemperatureField(_InitialCondition): @@ -52,6 +58,21 @@ def temperature(self): def temperature(self, value): self._t = value + @property + def __data__(self): + data = super().__data__ + data.update( + { + "temperature": self._t, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + temperature = data.pop("temperature") + return cls(temperature, **data) + class InitialStressField(_InitialCondition): """Stress field. @@ -86,3 +107,18 @@ def stress(self, value): if not isinstance(value, tuple) or len(value) != 3: raise TypeError("you must provide a tuple with 3 elements") self._s = value + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "stress": self._s, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + stress = data.pop("stress") + return cls(stress, **data) diff --git a/src/compas_fea2/model/interactions.py b/src/compas_fea2/model/interactions.py new file mode 100644 index 000000000..ce665cf80 --- /dev/null +++ b/src/compas_fea2/model/interactions.py @@ -0,0 +1,197 @@ +from compas_fea2.base import FEAData + + +class _Interaction(FEAData): + """Base class for all interactions.""" + + def __init__(self, **kwargs): + super(_Interaction, self).__init__(**kwargs) + + +# ------------------------------------------------------------------------------ +# SURFACE TO SURFACE INTERACTION +# ------------------------------------------------------------------------------ +class Contact(_Interaction): + """General contact interaction between two parts. + + Note + ---- + Interactions are registered to a :class:`compas_fea2.model.Model` and can be + assigned to multiple interfaces. + + Parameters + ---------- + name : str, optional + Uniqe identifier. If not provided it is automatically generated. Set a + name if you want a more human-readable input file. + normal : str + Behaviour of the contact along the direction normal to the interaction + surface. For faceted surfaces, this is the behavior along the direction + normal to each face. + tangent : + Behaviour of the contact along the directions tangent to the interaction + surface. For faceted surfaces, this is the behavior along the directions + tangent to each face. + + Attributes + ---------- + name : str + Uniqe identifier. If not provided it is automatically generated. Set a + name if you want a more human-readable input file. + normal : str + Behaviour of the contact along the direction normal to the interaction + surface. For faceted surfaces, this is the behavior along the direction + normal to each face. + tangent : + Behaviour of the contact along the directions tangent to the interaction + surface. For faceted surfaces, this is the behavior along the directions + tangent to each face. + """ + + def __init__(self, *, normal, tangent, **kwargs): + super(Contact, self).__init__(**kwargs) + self._tangent = tangent + self._normal = normal + + @property + def tangent(self): + return self._tangent + + @property + def normal(self): + return self._normal + + +class HardContactNoFriction(Contact): + """Hard contact interaction property with friction using a penalty + formulation. + + Parameters + ---------- + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + + Attributes + ---------- + name : str + Automatically generated id. You can change the name if you want a more + human readable input file. + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + """ + + def __init__(self, tol, **kwargs) -> None: + super().__init__(normal="HARD", tangent=None, **kwargs) + self._tol = tol + + +class HardContactFrictionPenalty(Contact): + """Hard contact interaction property with friction using a penalty + formulation. + + Parameters + ---------- + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + + Attributes + ---------- + name : str + Automatically generated id. You can change the name if you want a more + human readable input file. + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + """ + + def __init__(self, mu, tol, **kwargs) -> None: + super(HardContactFrictionPenalty, self).__init__(normal="HARD", tangent=mu, **kwargs) + self._tol = tol + + @property + def mu(self): + return self._tangent + + @property + def tol(self): + return self._tol + + @tol.setter + def tol(self, value): + self._tol = value + + +class LinearContactFrictionPenalty(Contact): + """Contact interaction property with linear softnening and friction using a + penalty formulation. + + Parameters + ---------- + stiffness : float + Stiffness of the the contact in the normal direction. + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + + Attributes + ---------- + name : str + Automatically generated id. You can change the name if you want a more + human readable input file. + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + """ + + def __init__(self, *, stiffness, mu, tolerance, **kwargs) -> None: + super(LinearContactFrictionPenalty, self).__init__(normal="Linear", tangent=mu, **kwargs) + self._tolerance = tolerance + self._stiffness = stiffness + + @property + def stiffness(self): + return self._stiffness + + @stiffness.setter + def stiffness(self, value): + self._stiffness = value + + @property + def tolerance(self): + return self._tolerance + + @tolerance.setter + def tolerance(self, value): + self._tolerance = value + + +class HardContactRough(Contact): + """Hard contact interaction property with indefinite friction (rough surfaces). + + Parameters + ---------- + name : str, optional + You can change the name if you want a more human readable input file. + + Attributes + ---------- + name : str + Automatically generated id. You can change the name if you want a more + human readable input file. + mu : float + Friction coefficient for tangential behaviour. + tollerance : float + Slippage tollerance during contact. + """ + + def __init__(self, **kwargs) -> None: + super(HardContactRough, self).__init__(normal="HARD", tangent="ROUGH", **kwargs) diff --git a/src/compas_fea2/model/interfaces.py b/src/compas_fea2/model/interfaces.py new file mode 100644 index 000000000..0b94909b5 --- /dev/null +++ b/src/compas_fea2/model/interfaces.py @@ -0,0 +1,77 @@ +from compas_fea2.base import FEAData + + +class Interface(FEAData): + """An interface is defined as a pair of master and slave surfaces + with a behavior property between them. + + Note + ---- + Interfaces are registered to a :class:`compas_fea2.model.Model`. + + Parameters + ---------- + name : str, optional + Uniqe identifier. If not provided it is automatically generated. Set a + name if you want a more human-readable input file. + master : :class:`compas_fea2.model.FacesGroup` + Group of element faces determining the Master surface. + slave : :class:`compas_fea2.model.FacesGroup` + Group of element faces determining the Slave surface. + behavior : :class:`compas_fea2.model._Interaction` + behavior type between master and slave. + + Attributes + ---------- + name : str + Uniqe identifier. If not provided it is automatically generated. Set a + name if you want a more human-readable input file. + master : :class:`compas_fea2.model.FacesGroup` + Group of element faces determining the Master surface. + slave : :class:`compas_fea2.model.FacesGroup` + Group of element faces determining the Slave surface. + behavior : :class:`compas_fea2.model._Interaction` | :class:`compas_fea2.model._Constraint` + behavior type between master and slave. + + """ + + def __init__(self, master, slave, behavior, name=None, **kwargs): + super(Interface, self).__init__(name=name, **kwargs) + self._master = master + self._slave = slave + self._behavior = behavior + + @property + def master(self): + return self._master + + @property + def slave(self): + return self._slave + + @property + def behavior(self): + return self._behavior + + +# class ContactInterface(Interface): +# """Interface for contact behavior + +# Parameters +# ---------- +# Interface : _type_ +# _description_ +# """ +# def __init__(self, master, slave, behavior, name=None, **kwargs): +# super().__init__(master, slave, behavior, name, **kwargs) + +# class ConstraintInterface(Interface): +# """Interface for contact behavior + +# Parameters +# ---------- +# Interface : _type_ +# _description_ +# """ +# def __init__(self, master, slave, behavior, name=None, **kwargs): +# super().__init__(master, slave, behavior, name, **kwargs) diff --git a/src/compas_fea2/model/materials/concrete.py b/src/compas_fea2/model/materials/concrete.py index 21c9b0811..87545ffb3 100644 --- a/src/compas_fea2/model/materials/concrete.py +++ b/src/compas_fea2/model/materials/concrete.py @@ -1,20 +1,19 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import log +from compas_fea2.units import UnitRegistry +from compas_fea2.units import units as u + from .material import _Material class Concrete(_Material): - """Elastic and plastic-cracking Eurocode based concrete material + """Elastic and plastic-cracking Eurocode-based concrete material. Parameters ---------- fck : float - Characteristic (5%) 28 day cylinder strength [MPa]. - v : float + Characteristic (5%) 28-day cylinder strength [MPa]. + v : float, optional Poisson's ratio v [-]. fr : list, optional Failure ratios. @@ -32,50 +31,65 @@ class Concrete(_Material): G : float Shear modulus G. fck : float - Characteristic (5%) 28 day cylinder strength. + Characteristic (5%) 28-day cylinder strength. fr : list Failure ratios. tension : dict - Parameters for modelling the tension side of the stress-strain curve. + Parameters for modeling the tension side of the stress-strain curve. compression : dict - Parameters for modelling the compression side of the stress-strain curve. + Parameters for modeling the compression side of the stress-strain curve. Notes ----- The concrete model is based on Eurocode 2 up to fck=90 MPa. """ - def __init__(self, *, fck, v=0.2, density=2400, fr=None, **kwargs): + def __init__(self, *, fck, E=None, v=None, density=None, fr=None, units=None, **kwargs): super(Concrete, self).__init__(density=density, **kwargs) + # Ensure small increment for stress-strain curve de = 0.0001 - fcm = fck + 8 - Ecm = 22 * 10**3 * (fcm / 10) ** 0.3 - ec1 = min(0.7 * fcm**0.31, 2.8) * 0.001 - ecu1 = 0.0035 if fck < 50 else (2.8 + 27 * ((98 - fcm) / 100.0) ** 4) * 0.001 - - k = 1.05 * Ecm * ec1 / fcm - e = [i * de for i in range(int(ecu1 / de) + 1)] - ec = [ei - e[1] for ei in e[1:]] - fctm = 0.3 * fck ** (2 / 3) if fck <= 50 else 2.12 * log(1 + fcm / 10) - f = [10**6 * fcm * (k * (ei / ec1) - (ei / ec1) ** 2) / (1 + (k - 2) * (ei / ec1)) for ei in e] - E = f[1] / e[1] - ft = [1.0, 0.0] - et = [0.0, 0.001] - fr = fr or [1.16, fctm / fcm] + # Compute material properties based on Eurocode 2 + fcm = fck + 8 # Mean compressive strength + Ecm = 22 * 10**3 * (fcm / 10) ** 0.3 # Young's modulus [MPa] + ec1 = min(0.7 * fcm**0.31, 2.8) * 0.001 # Strain at peak stress + ecu1 = 0.0035 if fck < 50 else (2.8 + 27 * ((98 - fcm) / 100.0) ** 4) * 0.001 # Ultimate strain - self.fck = fck * 10**6 - self.E = E - self.v = v - self.fc = f - self.ec = ec - self.ft = ft - self.et = et - self.fr = fr - # TODO these necessary if we have the above? - self.tension = {"f": ft, "e": et} - self.compression = {"f": f[1:], "e": ec} + # Stress-strain model parameters + k = 1.05 * Ecm * ec1 / fcm + e = [i * de for i in range(int(ecu1 / de) + 1)] # Strain values + ec = [ei - e[1] for ei in e[1:]] if len(e) > 1 else [0.0] # Adjusted strain values + + # Tensile strength according to Eurocode 2 + fctm = 0.3 * fck ** (2 / 3) if fck <= 50 else 2.12 * log(1 + fcm / 10) # Tensile strength + fc = [10**6 * fcm * (k * (ei / ec1) - (ei / ec1) ** 2) / (1 + (k - 2) * (ei / ec1)) for ei in ec] + + ft = [1.0, 0.0] # Tension stress-strain curve + et = [0.0, 0.001] # Corresponding strain values for tension + + fr = fr or [1.16, fctm / fcm] # Failure ratios default + + # Assign attributes + # BUG: change the units + self.E = Ecm * 10**6 if E is None else E # Convert GPa to MPa + self.fck = fck * 10**6 # Convert MPa to Pascals + self.fc = kwargs.get("fc", fc) + self.ec = kwargs.get("ec", ec) + self.v = v if v is not None else 0.17 + self.ft = kwargs.get("ft", ft) + self.et = kwargs.get("et", et) + self.fr = kwargs.get("fr", fr) + + # Ensure valid Young’s modulus calculation + if len(self.fc) > 1 and self.fc[1] == 0: + raise ValueError("fc[1] must be non-zero to calculate E.") + if len(self.ec) > 1 and self.ec[1] == 0: + raise ValueError("ec[1] must be non-zero for correct calculations.") + + # Tension and compression dictionaries + self.tension = {"f": self.ft, "e": self.et} + self.compression = {"f": self.fc[1:], "e": self.ec} @property def G(self): @@ -97,6 +111,41 @@ def __str__(self): self.name, self.density, self.E, self.v, self.G, self.fck, self.fr ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "fck": self.fck, + "E": self.E, + "v": self.v, + "fr": self.fr, + "tension": self.tension, + "compression": self.compression, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + fck=data["fck"], + E=data["E"], + v=data["v"], + density=data["density"], + fr=data["fr"], + ) + + # FIXME: this is only working for the basic material properties. + @classmethod + def C20_25(cls, units, **kwargs): + if not units: + units = u.get_default_units() + elif not isinstance(units, UnitRegistry): + units = u.get_units(units) + + return cls(fck=25 * units.MPa, E=30 * units.GPa, v=0.17, density=2400 * units("kg/m**3"), name="C20/25", **kwargs) + class ConcreteSmearedCrack(_Material): """Elastic and plastic, cracking concrete material. @@ -183,6 +232,39 @@ def __str__(self): self.name, self.density, self.E, self.v, self.G, self.fc, self.ec, self.ft, self.et, self.fr ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "class": self.__class__.__name__, + "E": self.E, + "v": self.v, + "fc": self.fc, + "ec": self.ec, + "ft": self.ft, + "et": self.et, + "fr": self.fr, + "tension": self.tension, + "compression": self.compression, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + E=data["E"], + v=data["v"], + density=data["density"], + fc=data["fc"], + ec=data["ec"], + ft=data["ft"], + et=data["et"], + fr=data["fr"], + name=data["name"], + ) + class ConcreteDamagedPlasticity(_Material): """Damaged plasticity isotropic and homogeneous material. @@ -234,3 +316,30 @@ def __init__(self, *, E, v, density, damage, hardening, stiffening, **kwargs): @property def G(self): return 0.5 * self.E / (1 + self.v) + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "class": self.__class__.__name__, + "E": self.E, + "v": self.v, + "damage": self.damage, + "hardening": self.hardening, + "stiffening": self.stiffening, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + E=data["E"], + v=data["v"], + density=data["density"], + damage=data["damage"], + hardening=data["hardening"], + stiffening=data["stiffening"], + name=data["name"], + ) diff --git a/src/compas_fea2/model/materials/material.py b/src/compas_fea2/model/materials/material.py index 771ae9b86..000de5738 100644 --- a/src/compas_fea2/model/materials/material.py +++ b/src/compas_fea2/model/materials/material.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData @@ -10,7 +6,7 @@ class _Material(FEAData): Parameters ---------- - denisty : float + density : float Density of the material. expansion : float, optional Thermal expansion coefficient, by default None. @@ -41,21 +37,40 @@ class _Material(FEAData): """ - def __init__(self, density, expansion=None, **kwargs): - super(_Material, self).__init__(**kwargs) + def __init__(self, density: float, expansion: float = None, **kwargs): + super().__init__(**kwargs) self.density = density self.expansion = expansion - self._key = None @property - def key(self): + def key(self) -> int: return self._key @property def model(self): return self._registration - def __str__(self): + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "density": self.density, + "expansion": self.expansion, + "name": self.name, + "uid": self.uid, + } + + @classmethod + def __from_data__(cls, data): + mat = cls( + density=data["density"], + expansion=data["expansion"], + ) + mat.uid = data["uid"] + mat.name = data["name"] + return mat + + def __str__(self) -> str: return """ {} {} @@ -66,7 +81,7 @@ def __str__(self): self.__class__.__name__, len(self.__class__.__name__) * "-", self.name, self.density, self.expansion ) - def __html__(self): + def __html__(self) -> str: return """

Hello World!

@@ -123,8 +138,8 @@ class ElasticOrthotropic(_Material): Shear modulus Gzx in z-x directions. """ - def __init__(self, Ex, Ey, Ez, vxy, vyz, vzx, Gxy, Gyz, Gzx, density, expansion=None, **kwargs): - super(ElasticOrthotropic, self).__init__(density=density, expansion=expansion, **kwargs) + def __init__(self, Ex: float, Ey: float, Ez: float, vxy: float, vyz: float, vzx: float, Gxy: float, Gyz: float, Gzx: float, density: float, expansion: float = None, **kwargs): + super().__init__(density=density, expansion=expansion, **kwargs) self.Ex = Ex self.Ey = Ey self.Ez = Ez @@ -135,7 +150,41 @@ def __init__(self, Ex, Ey, Ez, vxy, vyz, vzx, Gxy, Gyz, Gzx, density, expansion= self.Gyz = Gyz self.Gzx = Gzx - def __str__(self): + @property + def __data__(self): + data = super().__data__() + data.update( + { + "Ex": self.Ex, + "Ey": self.Ey, + "Ez": self.Ez, + "vxy": self.vxy, + "vyz": self.vyz, + "vzx": self.vzx, + "Gxy": self.Gxy, + "Gyz": self.Gyz, + "Gzx": self.Gzx, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + Ex=data["Ex"], + Ey=data["Ey"], + Ez=data["Ez"], + vxy=data["vxy"], + vyz=data["vyz"], + vzx=data["vzx"], + Gxy=data["Gxy"], + Gyz=data["Gyz"], + Gzx=data["Gzx"], + density=data["density"], + expansion=data["expansion"], + ) + + def __str__(self) -> str: return """ {} {} @@ -192,12 +241,27 @@ class ElasticIsotropic(_Material): """ - def __init__(self, E, v, density, expansion=None, **kwargs): - super(ElasticIsotropic, self).__init__(density=density, expansion=expansion, **kwargs) + def __init__(self, E: float, v: float, density: float, expansion: float = None, **kwargs): + super().__init__(density=density, expansion=expansion, **kwargs) self.E = E self.v = v - def __str__(self): + @property + def __data__(self): + data = super().__data__ + data.update( + { + "E": self.E, + "v": self.v, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls(data["E"], data["v"], data["density"], data["expansion"]) + + def __str__(self) -> str: return """ ElasticIsotropic Material ------------------------- @@ -213,14 +277,14 @@ def __str__(self): ) @property - def G(self): + def G(self) -> float: return 0.5 * self.E / (1 + self.v) class Stiff(_Material): """Elastic, very stiff and massless material.""" - def __init__(self, *, density, expansion=None, name=None, **kwargs): + def __init__(self, *, density: float, expansion: float = None, name: str = None, **kwargs): raise NotImplementedError() @@ -254,11 +318,31 @@ class ElasticPlastic(ElasticIsotropic): in the form of strain/stress value pairs. """ - def __init__(self, *, E, v, density, strain_stress, expansion=None, **kwargs): - super(ElasticPlastic, self).__init__(E=E, v=v, density=density, expansion=expansion, **kwargs) + def __init__(self, *, E: float, v: float, density: float, strain_stress: list[tuple[float, float]], expansion: float = None, **kwargs): + super().__init__(E=E, v=v, density=density, expansion=expansion, **kwargs) self.strain_stress = strain_stress - def __str__(self): + @property + def __data__(self): + data = super().__data__() + data.update( + { + "strain_stress": self.strain_stress, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + E=data["E"], + v=data["v"], + density=data["density"], + strain_stress=data["strain_stress"], + expansion=data["expansion"], + ) + + def __str__(self) -> str: return """ ElasticPlastic Material ----------------------- @@ -282,11 +366,11 @@ def __str__(self): class UserMaterial(FEAData): - """User Defined Material. Tho implement this type of material, a + """User Defined Material. To implement this type of material, a separate subroutine is required """ def __init__(self, **kwargs): - super(UserMaterial, self).__init__(self, **kwargs) + super().__init__(**kwargs) raise NotImplementedError("This class is not available for the selected backend plugin") diff --git a/src/compas_fea2/model/materials/steel.py b/src/compas_fea2/model/materials/steel.py index bd2ea5158..d92fb0088 100644 --- a/src/compas_fea2/model/materials/steel.py +++ b/src/compas_fea2/model/materials/steel.py @@ -1,6 +1,5 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from compas_fea2.units import UnitRegistry +from compas_fea2.units import units as u from .material import ElasticIsotropic @@ -47,15 +46,15 @@ class Steel(ElasticIsotropic): Parameters for modelling the compression side of the stress-strain curve. """ - def __init__(self, *, fy, fu, eu, E, v, density, **kwargs): + def __init__(self, *, E, v, density, fy, fu, eu, **kwargs): super(Steel, self).__init__(E=E, v=v, density=density, **kwargs) fu = fu or fy - E *= 10**9 - fu *= 10**6 - fy *= 10**6 - eu *= 0.01 + # E *= 10**9 + # fu *= 10**6 + # fy *= 10**6 + # eu *= 0.01 ep = eu - fy / E f = [fy, fu] @@ -98,9 +97,35 @@ def __str__(self): self.ep, ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "fy": self.fy, + "fu": self.fu, + "eu": self.eu, + "ep": self.ep, + "tension": self.tension, + "compression": self.compression, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls( + E=data["E"], + v=data["v"], + density=data["density"], + fy=data["fy"], + fu=data["fu"], + eu=data["eu"], + ) + # TODO check values and make unit independent @classmethod - def S355(cls): + def S355(cls, units=None): """Steel S355. Returns @@ -108,4 +133,9 @@ def S355(cls): :class:`compas_fea2.model.material.Steel` The precompiled steel material. """ - return cls(fy=355, fu=None, eu=20, E=210, v=0.3, density=7850, name=None) + if not units: + units = u(system="SI_mm") + elif not isinstance(units, UnitRegistry): + units = u(system=units) + + return cls(fy=355 * units.MPa, fu=None, eu=20, E=210 * units.GPa, v=0.3, density=7850 * units("kg/m**3"), name=None) diff --git a/src/compas_fea2/model/materials/timber.py b/src/compas_fea2/model/materials/timber.py index e1bc4fa70..9c36ae50a 100644 --- a/src/compas_fea2/model/materials/timber.py +++ b/src/compas_fea2/model/materials/timber.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData @@ -18,3 +14,17 @@ def __init__(self, *, density, **kwargs): Name of the material. """ super(Timber, self).__init__(density=density, **kwargs) + + @property + def __data__(self): + return { + "density": self.density, + "name": self.name, + } + + @classmethod + def __from_data__(cls, data): + return cls( + density=data["density"], + name=data["name"], + ) diff --git a/src/compas_fea2/model/model.py b/src/compas_fea2/model/model.py index d1d074954..790ddb54e 100644 --- a/src/compas_fea2/model/model.py +++ b/src/compas_fea2/model/model.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import gc import importlib import os @@ -10,27 +6,40 @@ from itertools import chain from itertools import groupby from pathlib import Path +from typing import Optional +from typing import Set +from typing import Union +from compas.datastructures import Graph from compas.geometry import Box from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Transformation from compas.geometry import bounding_box from compas.geometry import centroid_points from pint import UnitRegistry import compas_fea2 -from compas_fea2 import PART_NODES_LIMIT from compas_fea2.base import FEAData from compas_fea2.model.bcs import _BoundaryCondition from compas_fea2.model.connectors import Connector +from compas_fea2.model.constraints import _Constraint from compas_fea2.model.elements import _Element from compas_fea2.model.groups import ElementsGroup +from compas_fea2.model.groups import InterfacesGroup from compas_fea2.model.groups import NodesGroup from compas_fea2.model.groups import PartsGroup from compas_fea2.model.groups import _Group from compas_fea2.model.ics import _InitialCondition +from compas_fea2.model.interfaces import Interface +from compas_fea2.model.materials.material import _Material from compas_fea2.model.nodes import Node from compas_fea2.model.parts import RigidPart from compas_fea2.model.parts import _Part +from compas_fea2.model.sections import _Section +from compas_fea2.problem import Problem +from compas_fea2.UI import FEA2Viewer from compas_fea2.utilities._utils import get_docstring from compas_fea2.utilities._utils import part_method from compas_fea2.utilities._utils import problem_method @@ -41,20 +50,20 @@ class Model(FEAData): Parameters ---------- - description : str, optional + description : Optional[str], optional Some description of the model, by default ``None``. This will be added to the input file and can be useful for future reference. - author : str, optional + author : Optional[str], optional The name of the author of the model, by default ``None``. This will be added to the input file and can be useful for future reference. Attributes ---------- - description : str + description : Optional[str] Some description of the model. - author : str + author : Optional[str] The name of the author of the model. - parts : Set[:class:`compas_fea2.model.DeformablePart`] + parts : Set[:class:`compas_fea2.model.Part`] The parts of the model. bcs : dict Dictionary with the boundary conditions of the model and the nodes where @@ -77,87 +86,180 @@ class Model(FEAData): """ - def __init__(self, description=None, author=None, **kwargs): - super(Model, self).__init__(**kwargs) + def __init__(self, description: Optional[str] = None, author: Optional[str] = None, **kwargs): + super().__init__(**kwargs) self.description = description self.author = author self._key = 0 self._starting_key = 0 self._units = None - self._parts = set() - self._nodes = None + self._path = None + + self._graph = Graph() + self._parts: Set[_Part] = set() + self._materials: Set[_Material] = set() + self._sections: Set[_Section] = set() self._bcs = {} self._ics = {} - self._connectors = set() - self._constraints = set() - self._partsgroups = set() - self._problems = set() - self._results = {} - self._loads = {} - self._path = None - self._bounding_box = None - self._center = None - self._bottom_plane = None - self._top_plane = None - self._volume = None + self._interfaces: Set[Interface] = set() + self._connectors: Set[Connector] = set() + self._constraints: Set[_Constraint] = set() + self._partsgroups: Set[PartsGroup] = set() + self._groups: Set[_Group] = set() + self._problems: Set[Problem] = set() + @property def __data__(self): - return None + return { + "description": self.description, + "author": self.author, + "parts": [part.__data__ for part in self.parts], + "bcs": {bc.__data__: [node.__data__ for node in nodes] for bc, nodes in self.bcs.items()}, + "ics": {ic.__data__: [node.__data__ for node in nodes] for ic, nodes in self.ics.items()}, + "constraints": [constraint.__data__ for constraint in self.constraints], + "partgroups": [group.__data__ for group in self.partgroups], + "materials": [material.__data__ for material in self.materials], + "sections": [section.__data__ for section in self.sections], + "problems": [problem.__data__ for problem in self.problems], + "path": str(self.path) if self.path else None, + } + + @classmethod + def __from_data__(cls, data): + """Create a Model instance from a data dictionary. + + Parameters + ---------- + data : dict + The data dictionary. + + Returns + ------- + Model + The created Model instance. + """ + model = cls(description=data.get("description"), author=data.get("author")) + part_classes = {cls.__name__: cls for cls in _Part.__subclasses__()} + for part in data.get("parts", []): + model.add_part(part_classes[part["class"]].__from_data__(part)) + + bc_classes = {cls.__name__: cls for cls in _BoundaryCondition.__subclasses__()} + for bc_data, nodes_data in data.get("bcs", {}).items(): + model._bcs[bc_classes[bc_data["class"]].__from_data__(bc_data)] = [Node.__from_data__(node_data) for node_data in nodes_data] + + ic_classes = {cls.__name__: cls for cls in _InitialCondition.__subclasses__()} + for ic_data, nodes_data in data.get("ics", {}).items(): + model._ics[ic_classes[ic_data["class"]].__from_data__(ic_data)] = [Node.__from_data__(node_data) for node_data in nodes_data] + + constraint_classes = {cls.__name__: cls for cls in _Constraint.__subclasses__()} + for constraint_data in data.get("constraints", []): + model._constraints.add(constraint_classes[constraint_data["class"]].__from_data__(constraint_data)) + + group_classes = {cls.__name__: cls for cls in PartsGroup.__subclasses__()} + for group_data in data.get("partgroups", []): + model._partsgroups.add(group_classes[group_data["class"]].__from_data__(group_data)) + + problem_classes = {cls.__name__: cls for cls in Problem.__subclasses__()} + model._problems = {problem_classes[problem_data["class"]].__from_data__(problem_data) for problem_data in data.get("problems", [])} + model._path = Path(data.get("path")) if data.get("path") else None + return model + + @classmethod + def from_template(cls, template: str, **kwargs) -> "Model": + """Create a Model instance from a template. + + Parameters + ---------- + template : str + The name of the template. + **kwargs : dict + Additional keyword arguments. + + Returns + ------- + Model + The created Model instance. + + """ + raise NotImplementedError("This function is not available yet.") + module = importlib.import_module("compas_fea2.templates") + template = getattr(module, template) + return template(**kwargs) @property - def parts(self): + def parts(self) -> Set[_Part]: return self._parts @property - def partgroups(self): + def graph(self) -> Graph: + return self._graph + + @property + def groups(self) -> Set[_Group]: + return self._groups + + @property + def partgroups(self) -> Set[PartsGroup]: return self._partsgroups @property - def bcs(self): + def bcs(self) -> dict: return self._bcs @property - def ics(self): + def ics(self) -> dict: return self._ics @property - def constraints(self): + def constraints(self) -> Set[_Constraint]: return self._constraints @property - def connectors(self): + def connectors(self) -> Set[Connector]: return self._connectors @property - def materials(self): - materials = set() - for part in filter(lambda p: not isinstance(p, RigidPart), self.parts): - for material in part.materials: - materials.add(material) + def materials_dict(self) -> dict[Union[_Part, "Model"], list[_Material]]: + materials = {part: part.materials for part in filter(lambda p: not isinstance(p, RigidPart), self.parts)} + materials.update({self: list(self._materials)}) return materials @property - def sections(self): - sections = set() - for part in filter(lambda p: not isinstance(p, RigidPart), self.parts): - for section in part.sections: - sections.add(section) + def materials(self) -> Set[_Material]: + return set(chain(*list(self.materials_dict.values()))) + + @property + def sections_dict(self) -> dict[Union[_Part, "Model"], list[_Section]]: + sections = {part: part.sections for part in filter(lambda p: not isinstance(p, RigidPart), self.parts)} + sections.update({self: list(self._sections)}) return sections @property - def problems(self): + def sections(self) -> Set[_Section]: + return set(chain(*list(self.sections_dict.values()))) + + @property + def interfaces(self) -> Set[Interface]: + return InterfacesGroup(self._interfaces) + + @property + def interactions(self) -> Set[Interface]: + return self.interfaces.group_by(lambda x: getattr(x, "behavior")) + + @property + def problems(self) -> Set[Problem]: return self._problems @property - def loads(self): + def loads(self) -> dict: return self._loads @property - def path(self): + def path(self) -> Path: return self._path @path.setter - def path(self, value): + def path(self, value: Union[str, Path]): if not isinstance(value, Path): try: value = Path(value) @@ -166,75 +268,111 @@ def path(self, value): self._path = value.joinpath(self.name) @property - def nodes_set(self): + def nodes_set(self) -> Set[Node]: node_set = set() for part in self.parts: node_set.update(part.nodes) return node_set @property - def nodes(self): - n = [] - for part in self.parts: - n += list(part.nodes) + def nodes(self) -> list[Node]: + groups = [part.nodes for part in self.parts] + n = groups.pop(0) + for nodes in groups: + n += nodes return n @property - def points(self): + def points(self) -> list[Point]: return [n.point for n in self.nodes] @property - def elements(self): + def elements(self) -> list[_Element]: e = [] for part in self.parts: e += list(part.elements) return e @property - def bounding_box(self): + def bounding_box(self) -> Optional[Box]: try: bb = bounding_box(list(chain.from_iterable([part.bounding_box.points for part in self.parts if part.bounding_box]))) - return Box.from_bounding_box(bb) except Exception: - print("WARNING: bounding box not generated") return None + return Box.from_bounding_box(bb) @property - def center(self): + def center(self) -> Point: if self.bounding_box: return centroid_points(self.bounding_box.points) else: return centroid_points(self.points) @property - def bottom_plane(self): + def bottom_plane(self) -> Plane: return Plane.from_three_points(*[self.bounding_box.points[i] for i in self.bounding_box.bottom[:3]]) @property - def top_plane(self): + def top_plane(self) -> Plane: return Plane.from_three_points(*[self.bounding_box.points[i] for i in self.bounding_box.top[:3]]) @property - def volume(self): + def volume(self) -> float: return sum(p.volume for p in self.parts) @property - def units(self): + def units(self) -> UnitRegistry: return self._units @units.setter - def units(self, value): + def units(self, value: UnitRegistry): if not isinstance(value, UnitRegistry): return ValueError("Pint UnitRegistry required") self._units = value + def assign_keys(self, start: int = None, restart=False): + """Assign keys to the model and its parts. + + Parameters + ---------- + start : int + The starting key, by default None (the default starting key is used). + + Returns + ------- + None + + """ + start = start or self._starting_key + for i, material in enumerate(self.materials): + material._key = i + start + + for i, section in enumerate(self.sections): + section._key = i + start + + for i, connector in enumerate(self.connectors): + connector._key = i + start + + if not restart: + for i, node in enumerate(self.nodes): + node._key = i + start + + for i, element in enumerate(self.elements): + element._key = i + start + else: + for part in self.parts: + for i, node in enumerate(part.nodes): + node._key = i + start + + for i, element in enumerate(part.elements): + element._key = i + start + # ========================================================================= # Constructor methods # ========================================================================= @staticmethod - # @timer(message="Model loaded from cfm file in ") - def from_cfm(path): + def from_cfm(path: str) -> "Model": """Imports a Model object from a .cfm file using Pickle. Parameters @@ -272,15 +410,12 @@ def from_cfm(path): # De-constructor methods # ========================================================================= - def to_json(self): - raise NotImplementedError() - - def to_cfm(self, path): + def to_cfm(self, path: Union[str, Path]): """Exports the Model object to a .cfm file using Pickle. Parameters ---------- - path : str + path : Union[str, Path] Complete path to the new file (e.g., 'C:/temp/model.cfm'). Returns @@ -301,19 +436,19 @@ def to_cfm(self, path): # Parts methods # ========================================================================= - def find_part_by_name(self, name, casefold=False): + def find_part_by_name(self, name: str, casefold: bool = False) -> Optional[_Part]: """Find if there is a part with a given name in the model. Parameters ---------- name : str The name to match - casefolde : bool + casefold : bool, optional If `True` perform a case insensitive search, by default `False`. Returns ------- - :class:`compas_fea2.model.DeformablePart` + :class:`compas_fea2.model.Part` """ for part in self.parts: @@ -322,12 +457,12 @@ def find_part_by_name(self, name, casefold=False): if name_1 == name_2: return part - def contains_part(self, part): + def contains_part(self, part: _Part) -> bool: """Verify that the model contains a specific part. Parameters ---------- - part : :class:`compas_fea2.model.DeformablePart` + part : :class:`compas_fea2.model.Part` Returns ------- @@ -336,8 +471,26 @@ def contains_part(self, part): """ return part in self.parts - def add_part(self, part): - """Adds a DeformablePart to the Model. + def add_new_part(self, **kwargs) -> _Part: + """Add a new Part to the Model. + + Parameters + ---------- + name : str + The name of the part. + **kwargs : dict + Additional keyword arguments. + + Returns + ------- + :class:`compas_fea2.model.Part` + + """ + part = _Part(**kwargs) + return self.add_part(part) + + def add_part(self, part: _Part = None, **kwargs) -> _Part: + """Adds a Part to the Model. Parameters ---------- @@ -355,109 +508,370 @@ def add_part(self, part): If a part with the same name already exists in the model. """ - if not isinstance(part, _Part): - raise TypeError("{!r} is not a part.".format(part)) + if not part: + if "rigig" in kwargs: + from compas_fea2.model.parts import RigidPart - if self.contains_part(part): - if compas_fea2.VERBOSE: - print("SKIPPED: DeformablePart {!r} is already in the model.".format(part)) - return + part = RigidPart(**kwargs) + else: + from compas_fea2.model.parts import Part - if self.find_part_by_name(part.name): - raise ValueError("Duplicate name! The name '{}' is already in use.".format(part.name)) + part = Part(**kwargs) + + if not isinstance(part, _Part): + raise TypeError("{!r} is not a part.".format(part)) part._registration = self if compas_fea2.VERBOSE: print("{!r} registered to {!r}.".format(part, self)) - part._key = len(self._parts) * PART_NODES_LIMIT + part._key = len(self._parts) self._parts.add(part) - - if not isinstance(part, RigidPart): - for material in part.materials: - material._registration = self - - for section in part.sections: - section._registration = self - + self.graph.add_node(part, type="part") + self.graph.add_edge(self, part, relation="contains") return part - def add_parts(self, parts): + def add_parts(self, parts: list[_Part]) -> list[_Part]: """Add multiple parts to the model. Parameters ---------- - parts : list[:class:`compas_fea2.model.DeformablePart`] + parts : list[:class:`compas_fea2.model.Part`] Returns ------- - list[:class:`compas_fea2.model.DeformablePart`] + list[:class:`compas_fea2.model.Part`] """ return [self.add_part(part) for part in parts] + def copy_part(self, part: _Part, transformation: Transformation) -> _Part: + """Copy a part n times. + + Parameters + ---------- + part : :class:`compas_fea2.model._Part` + The part to copy. + + Returns + ------- + :class:`compas_fea2.model._Part` + The copied part. + + """ + new_part = part.copy() + new_part.transform(transformation) + return self.add_part(new_part) + + def array_parts(self, parts: list[_Part], n: int, transformation: Transformation) -> list[_Part]: + """Array a part n times along an axis. + + Parameters + ---------- + parts : list[:class:`compas_fea2.model.Part`] + The part to array. + n : int + The number of times to array the part. + axis : str, optional + The axis along which to array the part, by default "x". + + Returns + ------- + list[:class:`compas_fea2.model.Part`] + The list of arrayed parts. + + """ + + new_parts = [] + for i in range(n): + for part in parts: + new_part = part.copy() + new_part.transform(transformation * i) + new_parts.append(new_part) + return new_parts + # ========================================================================= - # Nodes methods + # Materials methods # ========================================================================= - @get_docstring(_Part) - @part_method - def find_node_by_key(self, key): - pass + def add_material(self, material: _Material) -> _Material: + """Add a material to the model. - @get_docstring(_Part) - @part_method - def find_node_by_inputkey(self, input_key): - pass + Parameters + ---------- + material : :class:`compas_fea2.model.materials.Material` - @get_docstring(_Part) - @part_method - def find_nodes_by_name(self, name): - pass + Returns + ------- + :class:`compas_fea2.model.materials.Material` - @get_docstring(_Part) - @part_method - def find_nodes_around_point(self, point, distance, plane=None, single=False): - pass + """ + if not isinstance(material, _Material): + raise TypeError("{!r} is not a material.".format(material)) + material._registration = self + self._key = len(self._materials) + self._materials.add(material) + return material - # @get_docstring(_Part) - # @part_method - # def find_closest_nodes_to_point(self, point, distance, number_of_nodes=1, plane=None): - # pass + def add_materials(self, materials: list[_Material]) -> list[_Material]: + """Add multiple materials to the model. - @get_docstring(_Part) - @part_method - def find_nodes_around_node(self, node, distance): - pass + Parameters + ---------- + materials : list[:class:`compas_fea2.model.materials.Material`] + + Returns + ------- + list[:class:`compas_fea2.model.materials.Material`] + + """ + return [self.add_material(material) for material in materials] + + def find_material_by_name(self, name: str) -> Optional[_Material]: + """Find a material by name. + + Parameters + ---------- + name : str + The name of the material. + + Returns + ------- + :class:`compas_fea2.model.materials.Material` + + """ + for material in self.materials: + if material.name == name: + return material + + def contains_material(self, material: _Material) -> bool: + """Verify that the model contains a specific material. + + Parameters + ---------- + material : :class:`compas_fea2.model.materials.Material` + + Returns + ------- + bool + + """ + return material in self.materials + + def find_material_by_key(self, key: int) -> Optional[_Material]: + """Find a material by key. + + Parameters + ---------- + key : int + The key of the material. + + Returns + ------- + :class:`compas_fea2.model.materials.Material` + + """ + for material in self.materials: + if material.key == key: + return material + + def find_material_by_inputkey(self, key: int) -> Optional[_Material]: + """Find a material by input key. + + Parameters + ---------- + key : int + The input key of the material. + + Returns + ------- + :class:`compas_fea2.model.materials.Material` + + """ + for material in self.materials: + if material.key == key: + return material + + def find_materials_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1) -> list[_Material]: + """Find materials by attribute. + + Parameters + ---------- + attr : str + The name of the attribute. + value : Union[str, int, float] + The value of the attribute. + tolerance : float, optional + The tolerance for the search, by default 1. + + Returns + ------- + list[:class:`compas_fea2.model.materials.Material`] + + """ + materials = [] + for material in self.materials: + if abs(getattr(material, attr) - value) < tolerance: + materials.append(material) + return materials + + # ========================================================================= + # Sections methods + # ========================================================================= + + def add_section(self, section: _Section) -> _Section: + """Add a section to the model. + + Parameters + ---------- + section : :class:`compas_fea2.model.sections.Section` + + Returns + ------- + :class:`compas_fea2.model.sections.Section` + + """ + if not isinstance(section, _Section): + raise TypeError("{!r} is not a section.".format(section)) + self._materials.add(section.material) + section._registration = self + self._sections.add(section) + return section + + def add_sections(self, sections: list[_Section]) -> list[_Section]: + """Add multiple sections to the model. + + Parameters + ---------- + sections : list[:class:`compas_fea2.model.sections.Section`] + + Returns + ------- + list[:class:`compas_fea2.model.sections.Section`] + + """ + return [self.add_section(section) for section in sections] + + def find_section_by_name(self, name: str) -> Optional[_Section]: + """Find a section by name. + + Parameters + ---------- + name : str + The name of the section. + + Returns + ------- + :class:`compas_fea2.model.sections.Section` + + """ + for section in self.sections: + if section.name == name: + return section + + def contains_section(self, section: _Section) -> bool: + """Verify that the model contains a specific section. + + Parameters + ---------- + section : :class:`compas_fea2.model.sections.Section` + + Returns + ------- + bool + + """ + return section in self.sections + + def find_section_by_key(self, key: int) -> Optional[_Section]: + """Find a section by key. + + Parameters + ---------- + key : int + The key of the section. + + Returns + ------- + :class:`compas_fea2.model.sections.Section` + + """ + for section in self.sections: + if section.key == key: + return section + + def find_section_by_inputkey(self, key: int) -> Optional[_Section]: + """Find a section by input key. + + Parameters + ---------- + key : int + The input key of the section. + + Returns + ------- + :class:`compas_fea2.model.sections.Section` + + """ + for section in self.sections: + if section.key == key: + return section + + def find_sections_by_attribute(self, attr: str, value: Union[str, int, float], tolerance: float = 1) -> list[_Section]: + """Find sections by attribute. + + Parameters + ---------- + attr : str + The name of the attribute. + value : Union[str, int, float] + The value of the attribute. + tolerance : float, optional + The tolerance for the search, by default 1. + + Returns + ------- + list[:class:`compas_fea2.model.sections.Section`] + + """ + sections = [] + for section in self.sections: + if abs(getattr(section, attr) - value) < tolerance: + sections.append(section) + return sections + + # ========================================================================= + # Nodes methods + # ========================================================================= @get_docstring(_Part) @part_method - def find_closest_nodes_to_node(self, node, distance, number_of_nodes=1, plane=None): + def find_node_by_key(self, key: int) -> Node: pass @get_docstring(_Part) @part_method - def find_nodes_by_attribute(self, attr, value, tolerance=1): + def find_node_by_name(self, name: str) -> Node: pass @get_docstring(_Part) @part_method - def find_nodes_on_plane(self, plane, tolerance=1): + def find_closest_nodes_to_node(self, node: Node, distance: float, number_of_nodes: int = 1, plane: Optional[Plane] = None) -> NodesGroup: pass @get_docstring(_Part) @part_method - def find_nodes_in_polygon(self, polygon, tolerance=1.1): + def find_nodes_on_plane(self, plane: Plane, tol: float = 1) -> NodesGroup: pass @get_docstring(_Part) @part_method - def find_nodes_where(self, conditions): + def find_nodes_in_polygon(self, polygon: Polygon, tol: float = 1.1) -> NodesGroup: pass @get_docstring(_Part) @part_method - def contains_node(self, node): + def contains_node(self, node: Node) -> Node: pass # ========================================================================= @@ -466,24 +880,59 @@ def contains_node(self, node): @get_docstring(_Part) @part_method - def find_element_by_key(self, key): + def find_element_by_key(self, key: int) -> _Element: pass @get_docstring(_Part) @part_method - def find_element_by_inputkey(self, key): + def find_element_by_name(self, name: str) -> _Element: pass + # ========================================================================= + # Faces methods + # ========================================================================= + @get_docstring(_Part) @part_method - def find_elements_by_name(self, name): + def find_faces_in_polygon(self, key: int) -> Node: pass # ========================================================================= # Groups methods # ========================================================================= + def add_group(self, group: _Group) -> _Group: + """Add a group to the model. - def add_parts_group(self, group): + Parameters + ---------- + group : :class:`compas_fea2.model.groups._Group` + + Returns + ------- + :class:`compas_fea2.model.groups._Group` + + """ + if not isinstance(group, _Group): + raise TypeError("{!r} is not a group.".format(group)) + self._model = self + self._registration = self + self._groups.add(group) + return group + + def add_groups(self, groups: list[_Group]) -> list[_Group]: + """Add multiple groups to the model. + + Parameters + ---------- + groups : list[:class:`compas_fea2.model.groups._Group`] + + Returns + ------- + list[:class:`compas_fea2.model.groups._Group`] + """ + return [self.add_group(group) for group in groups] + + def add_parts_group(self, group: PartsGroup) -> PartsGroup: """Add a PartsGroup object to the Model. Parameters @@ -500,33 +949,33 @@ def add_parts_group(self, group): if not isinstance(group, PartsGroup): raise TypeError("Only PartsGroups can be added to a model") self.partgroups.add(group) - group._registration = self # FIXME wrong because the members of the group might have a different registation + group._registration = self return group - def add_parts_groups(self, groups): + def add_parts_groups(self, groups: list[PartsGroup]) -> list[PartsGroup]: """Add a multiple PartsGroup object to the Model. Parameters ---------- - group : [:class:`compas_fea2.model.PartsGroup`] + group : list[:class:`compas_fea2.model.PartsGroup`] The list with the group object to add. Returns ------- - [:class:`compas_fea2.model.PartsGroup`] + list[:class:`compas_fea2.model.PartsGroup`] The list with the added groups. """ return [self.add_parts_group(group) for group in groups] - def group_parts_where(self, attr, value): + def group_parts_where(self, attr: str, value: Union[str, int, float]) -> PartsGroup: """Group a set of parts with a give value of a given attribute. Parameters ---------- attr : str The name of the attribute. - value : var + value : Union[str, int, float] The value of the attribute Returns @@ -541,7 +990,7 @@ def group_parts_where(self, attr, value): # BCs methods # ========================================================================= - def add_bcs(self, bc, nodes, axes="global"): + def add_bcs(self, bc: _BoundaryCondition, nodes: Union[list[Node], NodesGroup], axes: str = "global") -> _BoundaryCondition: """Add a :class:`compas_fea2.model._BoundaryCondition` to the model. Parameters @@ -583,13 +1032,11 @@ def add_bcs(self, bc, nodes, axes="global"): return bc - def _add_bc_type(self, bc_type, nodes, axes="global"): + def _add_bc_type(self, bc_type: str, nodes: Union[list[Node], NodesGroup], axes: str = "global") -> _BoundaryCondition: """Add a :class:`compas_fea2.model.BoundaryCondition` by type. Parameters ---------- - name : str - name of the boundary condition bc_type : str one of the boundary condition types specified above nodes : list[:class:`compas_fea2.model.Node`] or :class:`compas_fea2.model.NodesGroup` @@ -931,12 +1378,62 @@ def add_connector(self, connector): """ if not isinstance(connector, Connector): raise TypeError("{!r} is not a Connector.".format(connector)) - connector._key = len(self._connectors) self._connectors.add(connector) connector._registration = self - + self.add_group(connector.nodes) return connector + def add_connectors(self, connectors): + """Add multiple :class:`compas_fea2.model.Connector` objects to the model. + + Parameters + ---------- + connectors : list[:class:`compas_fea2.model.Connector`] + + Returns + ------- + list[:class:`compas_fea2.model.Connector`] + + """ + return [self.add_connector(connector) for connector in connectors] + + # ============================================================================== + # Interfaces methods + # ============================================================================== + def add_interface(self, interface): + """Add a :class:`compas_fea2.model.Interface` to the model. + + Parameters + ---------- + interface : :class:`compas_fea2.model.Interface` + The interface object to add to the model. + + Returns + ------- + :class:`compas_fea2.model.Interface` + + """ + if not isinstance(interface, Interface): + raise TypeError("{!r} is not an Interface.".format(interface)) + self._interfaces.add(interface) + interface._registration = self + + return interface + + def add_interfaces(self, interfaces): + """Add multiple :class:`compas_fea2.model.Interface` objects to the model. + + Parameters + ---------- + interfaces : list[:class:`compas_fea2.model.Interface`] + + Returns + ------- + list[:class:`compas_fea2.model.Interface`] + + """ + return [self.add_interface(interface) for interface in interfaces] + # ============================================================================== # Summary # ============================================================================== @@ -1119,12 +1616,12 @@ def write_input_file(self, problems=None, path=None, *args, **kwargs): # @get_docstring(Problem) @problem_method - def analyse(self, problems=None, *args, **kwargs): + def analyse(self, problems=None, path=None, *args, **kwargs): pass # @get_docstring(Problem) @problem_method - def analyze(self, problems=None, *args, **kwargs): + def analyze(self, problems=None, path=None, *args, **kwargs): pass # @get_docstring(Problem) @@ -1140,12 +1637,12 @@ def analyse_and_extract(self, problems=None, path=None, *args, **kwargs): # @get_docstring(Problem) @problem_method def analyse_and_store(self, problems=None, memory_only=False, *args, **kwargs): - pass + raise NotImplementedError() # @get_docstring(Problem) @problem_method def store_results_in_model(self, problems=None, *args, **kwargs): - pass + raise NotImplementedError() # ============================================================================== # Results methods @@ -1188,29 +1685,28 @@ def get_displacement_at_point_sql(self, problem, point, steps=None): # Viewer # ============================================================================== - def show(self, scale_model=1.0, show_bcs=1.0, **kwargs): + def show(self, fast=True, scale_model=1.0, show_parts=True, show_bcs=1.0, show_loads=1.0, **kwargs): """Visualise the model in the viewer. Parameters ---------- scale_model : float, optional + Scale factor for the model, by default 1.0 show_bcs : float, optional - kwargs : dict, optional - Additional keyword arguments for the viewer. + Scale factor for the boundary conditions, by default 1.0 + show_loads : float, optional + Scale factor for the loads, by default 1.0 """ - from compas.scene import register - from compas.scene import register_scene_objects - - from compas_fea2.UI.viewer import FEA2ModelObject - from compas_fea2.UI.viewer import FEA2Viewer - - register_scene_objects() # This has to be called before registering the model object - register(self.__class__, FEA2ModelObject, context="Viewer") viewer = FEA2Viewer(center=self.center, scale_model=scale_model) - viewer.scene.add(self, model=self, opacity=0.5, show_bcs=show_bcs, kwargs=kwargs) + viewer.config.vectorsize = 0.2 + viewer.add_model(self, show_parts=show_parts, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + # if show_loads: + # register(step.__class__, FEA2StepObject, context="Viewer") + # viewer.viewer.scene.add(step, step=step, scale_factor=show_loads) viewer.show() + viewer.scene.clear() @problem_method def show_displacements(self, problem, *args, **kwargs): diff --git a/src/compas_fea2/model/nodes.py b/src/compas_fea2/model/nodes.py index d03680445..751eb157c 100644 --- a/src/compas_fea2/model/nodes.py +++ b/src/compas_fea2/model/nodes.py @@ -1,8 +1,9 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Dict +from typing import List +from typing import Optional from compas.geometry import Point +from compas.geometry import transform_points from compas.tolerance import TOL import compas_fea2 @@ -74,11 +75,12 @@ class Node(FEAData): """ - def __init__(self, xyz, mass=None, temperature=None, **kwargs): - super(Node, self).__init__(**kwargs) + def __init__(self, xyz: List[float], mass: Optional[float] = None, temperature: Optional[float] = None, **kwargs): + super().__init__(**kwargs) self._key = None + self._part_key = None - self.xyz = xyz + self._xyz = xyz self._x = xyz[0] self._y = xyz[1] self._z = xyz[2] @@ -86,21 +88,45 @@ def __init__(self, xyz, mass=None, temperature=None, **kwargs): self._bc = None self._dof = {"x": True, "y": True, "z": True, "xx": True, "yy": True, "zz": True} - self._mass = mass if isinstance(mass, tuple) else tuple([mass] * 3) + self._mass = mass if isinstance(mass, list) else list([mass] * 6) self._temperature = temperature self._on_boundary = None self._is_reference = False self._loads = {} - self.total_load = None - self._displacements = {} - self._results = {} + self._total_load = None - self._connected_elements = [] + self._connected_elements = set() + + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "part_key": self._part_key, + "uid": self.uid, + "xyz": self.xyz, + "mass": self._mass, + "temperature": self._temperature, + "on_boundary": self._on_boundary, + "is_reference": self._is_reference, + } @classmethod - def from_compas_point(cls, point, mass=None, temperature=None): + def __from_data__(cls, data): + node = cls( + xyz=data["xyz"], + mass=data.get("mass"), + temperature=data.get("temperature"), + ) + node.uid = data.get("uid") + node._on_boundary = data.get("on_boundary") + node._is_reference = data.get("is_reference") + # node._part_key = data.get("part_key") + return node + + @classmethod + def from_compas_point(cls, point: Point, mass: Optional[float] = None, temperature: Optional[float] = None) -> "Node": """Create a Node from a :class:`compas.geometry.Point`. Parameters @@ -133,19 +159,23 @@ def from_compas_point(cls, point, mass=None, temperature=None): return cls(xyz=[point.x, point.y, point.z], mass=mass, temperature=temperature) @property - def part(self): + def part(self) -> "_Part": # noqa: F821 return self._registration @property - def model(self): + def part_key(self) -> int: + return self._part_key + + @property + def model(self) -> "Model": # noqa: F821 return self.part._registration @property - def xyz(self): + def xyz(self) -> List[float]: return [self._x, self._y, self._z] @xyz.setter - def xyz(self, value): + def xyz(self, value: List[float]): if len(value) != 3: raise ValueError("Provide a 3 element tuple or list") self._x = value[0] @@ -153,51 +183,51 @@ def xyz(self, value): self._z = value[2] @property - def x(self): + def x(self) -> float: return self._x @x.setter - def x(self, value): + def x(self, value: float): self._x = float(value) @property - def y(self): + def y(self) -> float: return self._y @y.setter - def y(self, value): + def y(self, value: float): self._y = float(value) @property - def z(self): + def z(self) -> float: return self._z @z.setter - def z(self, value): + def z(self, value: float): self._z = float(value) @property - def mass(self): + def mass(self) -> List[float]: return self._mass @mass.setter - def mass(self, value): - self._mass = value if isinstance(value, tuple) else tuple([value] * 3) + def mass(self, value: float): + self._mass = value if isinstance(value, list) else list([value] * 6) @property - def temperature(self): + def temperature(self) -> float: return self._temperature @temperature.setter - def temperature(self, value): + def temperature(self, value: float): self._temperature = value @property - def gkey(self): + def gkey(self) -> str: return TOL.geometric_key(self.xyz, precision=compas_fea2.PRECISION) @property - def dof(self): + def dof(self) -> Dict[str, bool]: if self.bc: return {attr: not bool(getattr(self.bc, attr)) for attr in ["x", "y", "z", "xx", "yy", "zz"]} else: @@ -208,19 +238,11 @@ def bc(self): return self._bc @property - def loads(self): - return self._loads - - @property - def displacements(self): - return self._displacements - - @property - def on_boundary(self): + def on_boundary(self) -> Optional[bool]: return self._on_boundary @property - def is_reference(self): + def is_reference(self) -> bool: return self._is_reference @property @@ -228,9 +250,97 @@ def results(self): return self._results @property - def point(self): + def point(self) -> Point: return Point(*self.xyz) @property - def connected_elements(self): + def connected_elements(self) -> List: return self._connected_elements + + # @property + # def loads(self) -> Dict: + # problems = self.model.problems + # steps = [problem.step for problem in problems] + # return {step: step.loads(step) for step in steps} + + def transform(self, transformation): + """Transform the node using a transformation matrix. + + Parameters + ---------- + transformation : list + A 4x4 transformation matrix. + """ + self.xyz = transform_points([self.xyz], transformation)[0] + + def transformed(self, transformation): + """Return a copy of the node transformed by a transformation matrix. + + Parameters + ---------- + transformation : list + A 4x4 transformation matrix. + + Returns + ------- + :class:`compas_fea2.model.Node` + A new node object with the transformed coordinates. + """ + node = self.copy() + node.transform(transformation) + return node + + def displacement(self, step): + """Get the displacement of the node at a given step. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The step for which to get the displacement. + + Returns + ------- + :class:`compas_fea2.results.DisplacementResult` + The displacement result at the node for the given step. + """ + if step.displacement_field: + return step.displacement_field.get_result_at(location=self) + + def reaction(self, step): + """Get the reaction of the node at a given step. + + Parameters + ---------- + step : :class:`compas_fea2.model.Step` + The step for which to get the reaction. + + Returns + ------- + :class:`compas_fea2.results.ReactionResult` + The reaction result at the node for the given step. + """ + + if step.reaction_field: + return step.reaction_field.get_result_at(location=self) + + @property + def displacements(self) -> Dict: + problems = self.model.problems + steps = [problem.step for problem in problems] + return {step: self.displacement(step) for step in steps} + + @property + def reactions(self) -> Dict: + problems = self.model.problems + steps = [problem.step for problem in problems] + return {step: self.reaction(step) for step in steps} + + @property + def results_cls(self): + from compas_fea2.results import DisplacementResult + from compas_fea2.results import ReactionResult + + return { + "u": DisplacementResult, + "rf": ReactionResult, + } diff --git a/src/compas_fea2/model/parts.py b/src/compas_fea2/model/parts.py index 0ec3806df..e2f9fdff2 100644 --- a/src/compas_fea2/model/parts.py +++ b/src/compas_fea2/model/parts.py @@ -1,11 +1,19 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from collections import defaultdict +from itertools import groupby from math import pi -from math import sqrt +from typing import Dict from typing import Iterable - +from typing import List +from typing import Optional +from typing import Set +from typing import Tuple +from typing import Union + +import compas +import matplotlib.pyplot as plt +import networkx as nx +import numpy as np +from compas.datastructures import Mesh from compas.geometry import Box from compas.geometry import Frame from compas.geometry import Plane @@ -15,10 +23,11 @@ from compas.geometry import Vector from compas.geometry import bounding_box from compas.geometry import centroid_points -from compas.geometry import distance_point_point_sqrd +from compas.geometry import centroid_points_weighted from compas.geometry import is_point_in_polygon_xy from compas.geometry import is_point_on_plane from compas.tolerance import TOL +from compas.topology import connected_components from scipy.spatial import KDTree import compas_fea2 @@ -34,7 +43,10 @@ from .elements import _Element3D from .groups import ElementsGroup from .groups import FacesGroup +from .groups import MaterialsGroup from .groups import NodesGroup +from .groups import SectionsGroup +from .groups import _Group from .materials.material import _Material from .nodes import Node from .releases import _BeamEndRelease @@ -49,13 +61,13 @@ class _Part(FEAData): Parameters ---------- name : str, optional - Uniqe identifier. If not provided it is automatically generated. Set a + Unique identifier. If not provided it is automatically generated. Set a name if you want a more human-readable input file. Attributes ---------- name : str - Uniqe identifier. If not provided it is automatically generated. Set a + Unique identifier. If not provided it is automatically generated. Set a name if you want a more human-readable input file. model : :class:`compas_fea2.model.Model` The parent model of the part. @@ -63,24 +75,18 @@ class _Part(FEAData): The nodes belonging to the part. nodes_count : int Number of nodes in the part. - gkey_node : {gkey : :class:`compas_fea2.model.Node`} - Dictionary that associates each node and its geometric key} + gkey_node : Dict[str, :class:`compas_fea2.model.Node`] + Dictionary that associates each node and its geometric key. materials : Set[:class:`compas_fea2.model._Material`] The materials belonging to the part. sections : Set[:class:`compas_fea2.model._Section`] The sections belonging to the part. elements : Set[:class:`compas_fea2.model._Element`] The elements belonging to the part. - element_types : {:class:`compas_fea2.model._Element` : [:class:`compas_fea2.model._Element`]] + element_types : Dict[:class:`compas_fea2.model._Element`, List[:class:`compas_fea2.model._Element`]] Dictionary with the elements of the part for each element type. element_count : int - Number of elements in the part - nodesgroups : Set[:class:`compas_fea2.model.NodesGroup`] - The groups of nodes belonging to the part. - elementsgroups : Set[:class:`compas_fea2.model.ElementsGroup`] - The groups of elements belonging to the part. - facesgroups : Set[:class:`compas_fea2.model.FacesGroup`] - The groups of element faces belonging to the part. + Number of elements in the part. boundary_mesh : :class:`compas.datastructures.Mesh` The outer boundary mesh enveloping the Part. discretized_boundary_mesh : :class:`compas.datastructures.Mesh` @@ -93,63 +99,228 @@ class _Part(FEAData): """ def __init__(self, **kwargs): - super(_Part, self).__init__(**kwargs) - self._nodes = set() - self._gkey_node = {} - self._sections = set() - self._materials = set() - self._elements = set() - self._releases = set() - - self._nodesgroups = set() - self._elementsgroups = set() - self._facesgroups = set() + super().__init__(**kwargs) + self._ndm = None + self._ndf = None + self._graph = nx.DiGraph() + self._nodes: Set[Node] = set() + self._gkey_node: Dict[str, Node] = {} + self._sections: Set[_Section] = set() + self._materials: Set[_Material] = set() + self._elements: Set[_Element] = set() + self._releases: Set[_BeamEndRelease] = set() + + self._groups: Set[_Group] = set() self._boundary_mesh = None self._discretized_boundary_mesh = None - self._bounding_box = None - self._volume = None - self._weight = None + self._reference_point = None + + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "ndm": self._ndm or None, + "ndf": self._ndf or None, + "nodes": [node.__data__ for node in self.nodes], + "gkey_node": {key: node.__data__ for key, node in self.gkey_node.items()}, + "materials": [material.__data__ for material in self.materials], + "sections": [section.__data__ for section in self.sections], + "elements": [element.__data__ for element in self.elements], + "releases": [release.__data__ for release in self.releases], + "reference_point": self.reference_point.__data__ if self.reference_point else None, + } + + def to_hdf5_data(self, hdf5_file, mode="a"): + group = hdf5_file.require_group(f"model/{'parts'}/{self.uid}") # Create a group for this object + group.attrs["class"] = str(self.__data__["class"]) + + @classmethod + def __from_data__(cls, data): + """Create a part instance from a data dictionary. + + Parameters + ---------- + data : dict + The data dictionary. + + Returns + ------- + _Part + The part instance. + """ + part = cls() + part._ndm = data.get("ndm") + part._ndf = data.get("ndf") + + # Deserialize nodes + uid_node = {node_data["uid"]: Node.__from_data__(node_data) for node_data in data.get("nodes", [])} + + # Deserialize materials + for material_data in data.get("materials", []): + material_cls = material_data.pop("class", None) + if not material_cls: + raise ValueError("Missing class information for material.") + + mat = part.add_material(material_cls.__from_data__(material_data)) + mat.uid = material_data["uid"] + + # Deserialize sections + for section_data in data.get("sections", []): + material_data = section_data.pop("material", None) + if not material_data or "uid" not in material_data: + raise ValueError("Material UID is missing in section data.") + + material = part.find_material_by_uid(material_data["uid"]) + if not material: + raise ValueError(f"Material with UID {material_data['uid']} not found.") + + section_cls = section_data.pop("class", None) + if not section_cls: + raise ValueError("Missing class information for section.") + + section = part.add_section(section_cls(material=material, **section_data)) + section.uid = section_data["uid"] + + # Deserialize elements + for element_data in data.get("elements", []): + section_data = element_data.pop("section", None) + if not section_data or "uid" not in section_data: + raise ValueError("Section UID is missing in element data.") + + section = part.find_section_by_uid(section_data["uid"]) + if not section: + raise ValueError(f"Section with UID {section_data['uid']} not found.") + + element_cls = element_data.pop("class", None) + if not element_cls: + raise ValueError("Missing class information for element.") + + nodes = [uid_node[node_data["uid"]] for node_data in element_data.pop("nodes", [])] + for node in nodes: + node._registration = part + element = element_cls(nodes=nodes, section=section, **element_data) + part.add_element(element, checks=False) + + part._boundary_mesh = Mesh.__from_data__(data.get("boundary_mesh")) if data.get("boundary_mesh") else None + if data.get("discretized_boundary_mesh"): + part._discretized_boundary_mesh = Mesh.__from_data__(data.get("discretized_boundary_mesh")) + else: + part._discretized_boundary_mesh = None + if rp := data.get("reference_point"): + part.reference_point = Node.__from_data__(rp) + return part + + @property + def reference_point(self) -> Optional[Node]: + return self._reference_point + + @reference_point.setter + def reference_point(self, value: Node): + self._reference_point = self.add_node(value) + value._is_reference = True @property - def nodes(self): - return self._nodes + def graph(self): + return self._graph @property - def points(self): - return [node.xyz for node in self.nodes] + def nodes(self) -> NodesGroup: + return NodesGroup(self._nodes) @property - def elements(self): - return self._elements + def nodes_sorted(self) -> List[Node]: + return self.nodes.sorted_by(key=lambda x: x.part_key) @property - def sections(self): - return self._sections + def points(self) -> List[Point]: + return [node.point for node in self._nodes] @property - def materials(self): - return self._materials + def points_sorted(self) -> List[Point]: + return [node.point for node in self.nodes.sorted_by(key=lambda x: x.part_key)] @property - def releases(self): - return self._releases + def elements(self) -> ElementsGroup: + return ElementsGroup(self._elements) + + @property + def faces(self) -> FacesGroup: + return FacesGroup([face for element in self.elements for face in element.faces]) + + @property + def elements_sorted(self) -> List[_Element]: + return self.elments.sorted_by(key=lambda x: x.part_key) + + @property + def elements_grouped(self) -> Dict[int, List[_Element]]: + sub_groups = self.elements.group_by(key=lambda x: x.__class__.__base__) + return {key: group.members for key, group in sub_groups} + + @property + def elements_faces(self) -> List[List[List["Face"]]]: # noqa: F821 + face_group = FacesGroup([face for element in self.elements for face in element.faces]) + face_group.group_by(key=lambda x: x.element) + return face_group + + @property + def elements_faces_grouped(self) -> Dict[int, List[List["Face"]]]: # noqa: F821 + return {key: [face for face in element.faces] for key, element in self.elements_grouped.items()} + + @property + def elements_faces_indices(self) -> List[List[List[float]]]: + return [face.nodes_key for face in self.elements_faces] + + @property + def elements_faces_indices_grouped(self) -> Dict[int, List[List[float]]]: + return {key: [face.nodes_key for face in element.faces] for key, element in self.elements_grouped.items()} + + @property + def elements_connectivity(self) -> List[List[int]]: + return [element.nodes_key for element in self.elements] + + @property + def elements_connectivity_grouped(self) -> Dict[int, List[List[float]]]: + elements_group = groupby(self.elements, key=lambda x: x.__class__.__base__) + return {key: [element.nodes_key for element in group] for key, group in elements_group} + + @property + def elements_centroids(self) -> List[List[float]]: + return [element.centroid for element in self.elements] + + @property + def sections(self) -> SectionsGroup: + return SectionsGroup(self._sections) + + @property + def sections_sorted(self) -> List[_Section]: + return self.sections.sorted_by(key=lambda x: x.part_key) + + @property + def sections_grouped_by_element(self) -> Dict[int, List[_Section]]: + sections_group = self.sections.group_by(key=lambda x: x.element) + return {key: group.members for key, group in sections_group} + + @property + def materials(self) -> MaterialsGroup: + return MaterialsGroup(self._materials) @property - def nodesgroups(self): - return self._nodesgroups + def materials_sorted(self) -> List[_Material]: + return self.materials.sorted_by(key=lambda x: x.part_key) @property - def elementsgroups(self): - return self._elementsgroups + def materials_grouped_by_section(self) -> Dict[int, List[_Material]]: + materials_group = self.materials.group_by(key=lambda x: x.section) + return {key: group.members for key, group in materials_group} @property - def facesgroups(self): - return self._facesgroups + def releases(self) -> Set[_BeamEndRelease]: + return self._releases @property - def gkey_node(self): + def gkey_node(self) -> Dict[str, Node]: return self._gkey_node @property @@ -161,27 +332,196 @@ def discretized_boundary_mesh(self): return self._discretized_boundary_mesh @property - def bounding_box(self): - try: - return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) - except Exception: - print("WARNING: BoundingBox not generated") - return None + def outer_faces(self): + """Extract the outer faces of the part.""" + # FIXME: extend to shell elements + face_count = defaultdict(int) + for tet in self.elements_connectivity: + faces = [ + tuple(sorted([tet[0], tet[1], tet[2]])), + tuple(sorted([tet[0], tet[1], tet[3]])), + tuple(sorted([tet[0], tet[2], tet[3]])), + tuple(sorted([tet[1], tet[2], tet[3]])), + ] + for face in faces: + face_count[face] += 1 + # Extract faces that appear only once (boundary faces) + outer_faces = np.array([face for face, count in face_count.items() if count == 1]) + return outer_faces + + @property + def outer_mesh(self): + """Extract the outer mesh of the part.""" + unique_vertices, unique_indices = np.unique(self.outer_faces, return_inverse=True) + vertices = np.array(self.points_sorted)[unique_vertices] + faces = unique_indices.reshape(self.outer_faces.shape).tolist() + return Mesh.from_vertices_and_faces(vertices.tolist(), faces) + + def extract_clustered_planes(self, tol: float = 1e-3, angle_tol: float = 2, verbose: bool = False): + """Extract unique planes from the part boundary mesh. + + Parameters + ---------- + tol : float, optional + Tolerance for geometric operations, by default 1e-3. + angle_tol : float, optional + Tolerance for normal vector comparison, by default 2. + verbose : bool, optional + If ``True`` print the extracted planes, by default False. + + Returns + ------- + list[:class:`compas.geometry.Plane`] + """ + mesh = self.discretized_boundary_mesh.copy() + unique_planes = [] + plane_data = [] + for fkey in mesh.faces(): + plane = mesh.face_plane(fkey) + normal = np.array(plane.normal) + offset = np.dot(normal, plane.point) + plane_data.append((normal, offset)) + + # Clusterize planes based on angular similarity + plane_normals = np.array([p[0] for p in plane_data]) + plane_offsets = np.array([p[1] for p in plane_data]) + cos_angle_tol = np.cos(np.radians(angle_tol)) + for _, (normal, offset) in enumerate(zip(plane_normals, plane_offsets)): + is_unique = True + for existing_normal, existing_offset in unique_planes: + if np.abs(np.dot(normal, existing_normal)) > cos_angle_tol: + if np.abs(offset - existing_offset) < tol: + is_unique = False + break + if is_unique: + unique_planes.append((normal, offset)) + + # Convert unique planes back to COMPAS + planes = [] + for normal, offset in unique_planes: + normal_vec = Vector(*normal) + point = normal_vec * offset + planes.append(Plane(point, normal_vec)) + + if verbose: + num_unique_planes = len(unique_planes) + print(f"Minimum number of planes describing the geometry: {num_unique_planes}") + for i, (normal, offset) in enumerate(unique_planes, 1): + print(f"Plane {i}: Normal = {normal}, Offset = {offset}") + + return planes + + def extract_submeshes(self, planes: List[Plane], tol: float = 1e-3, normal_tol: float = 2, split=False): + """Extract submeshes from the part based on the planes provided. + + Parameters + ---------- + planes : list[:class:`compas.geometry.Plane`] + Planes to slice the part. + tol : float, optional + Tolerance for geometric operations, by default 1e-3. + normal_tol : float, optional + Tolerance for normal vector comparison, by default 2. + split : bool, optional + If ``True`` split each submesh into connected components, by default False. + + Returns + ------- + list[:class:`compas.datastructures.Mesh`] + """ + + def split_into_subsubmeshes(submeshes): + """Split each submesh into connected components.""" + subsubmeshes = [] + + for mesh in submeshes: + mesh: Mesh + components = connected_components(mesh.adjacency) + + for comp in components: + faces = [fkey for fkey in mesh.faces() if set(mesh.face_vertices(fkey)).issubset(comp)] + submesh = Mesh() + + vkey_map = { + v: submesh.add_vertex( + x=mesh.vertex_attribute(v, "x"), + y=mesh.vertex_attribute(v, "y"), + z=mesh.vertex_attribute(v, "z"), + ) + for v in comp + } + + for fkey in faces: + submesh.add_face([vkey_map[v] for v in mesh.face_vertices(fkey)]) + + subsubmeshes.append(submesh) + + return subsubmeshes + + mesh: Mesh = self.discretized_boundary_mesh + submeshes = [Mesh() for _ in planes] + + # Step 1: Compute normalized plane and face normals + planes_normals = np.array([plane.normal for plane in planes]) + faces_normals = np.array([mesh.face_normal(face) for face in mesh.faces()]) + dot_products = np.dot(planes_normals, faces_normals.T) # (num_planes, num_faces) + plane_indices, face_indices = np.where(abs(dot_products) >= (1 - normal_tol)) + + for face_idx in np.unique(face_indices): # Loop over unique faces + face_vertices = mesh.face_vertices(face_idx) + face_coords = [mesh.vertex_coordinates(v) for v in face_vertices] + + # Get all planes matching this face's normal + matching_planes = [planes[p_idx] for p_idx in plane_indices[face_indices == face_idx]] + + # Step 5: Check if all vertices of the face lie on any of the matching planes + for plane in matching_planes: + if all(is_point_on_plane(coord, plane, tol) for coord in face_coords): + face_mesh = Mesh.from_vertices_and_faces(face_coords, [[c for c in range(len(face_coords))]]) + submeshes[planes.index(plane)].join(face_mesh) + break # Assign to first valid plane + if split: + for submesh in submeshes: + submesh.weld(precision=2) + submeshes = split_into_subsubmeshes(submeshes) + return submeshes + + @property + def all_interfaces(self): + from compas_fea2.model.interfaces import Interface + + planes = self.extract_clustered_planes(tol=1, angle_tol=2) + submeshes = self.extract_submeshes(planes, tol=1, normal_tol=2, split=True) + return [Interface(mesh=mesh) for mesh in submeshes] + + @property + def bounding_box(self) -> Optional[Box]: + # FIXME: add bounding box for linear elements (bb of the section outer boundary) + return Box.from_bounding_box(bounding_box([n.xyz for n in self.nodes])) @property - def center(self): + def center(self) -> Point: + """The geometric center of the part.""" return centroid_points(self.bounding_box.points) @property - def bottom_plane(self): + def centroid(self) -> Point: + """The geometric center of the part.""" + self.compute_nodal_masses() + points = [node.point for node in self.nodes] + weights = [sum(node.mass) / len(node.mass) for node in self.nodes] + return centroid_points_weighted(points, weights) + + @property + def bottom_plane(self) -> Plane: return Plane.from_three_points(*[self.bounding_box.points[i] for i in self.bounding_box.bottom[:3]]) @property - def top_plane(self): + def top_plane(self) -> Plane: return Plane.from_three_points(*[self.bounding_box.points[i] for i in self.bounding_box.top[:3]]) @property - def volume(self): + def volume(self) -> float: self._volume = 0.0 for element in self.elements: if element.volume: @@ -189,7 +529,7 @@ def volume(self): return self._volume @property - def weight(self): + def weight(self) -> float: self._weight = 0.0 for element in self.elements: if element.weight: @@ -205,21 +545,60 @@ def results(self): return self._results @property - def nodes_count(self): + def nodes_count(self) -> int: return len(self.nodes) - 1 @property - def elements_count(self): + def elements_count(self) -> int: return len(self.elements) - 1 @property - def element_types(self): + def element_types(self) -> Dict[type, List[_Element]]: element_types = {} for element in self.elements: element_types.setdefault(type(element), []).append(element) return element_types - def elements_by_dimension(self, dimension=1): + @property + def groups(self) -> Set[_Group]: + return self._groups + + def transform(self, transformation: Transformation) -> None: + """Transform the part. + + Parameters + ---------- + transformation : :class:`compas.geometry.Transformation` + The transformation to apply. + + """ + for node in self.nodes: + node.transform(transformation) + + def transformed(self, transformation: Transformation) -> "_Part": + """Return a transformed copy of the part. + + Parameters + ---------- + transformation : :class:`compas.geometry.Transformation` + The transformation to apply. + """ + part = self.copy() + part.transform(transformation) + return part + + def elements_by_dimension(self, dimension: int = 1) -> Iterable[_Element]: + """Get elements by dimension. + Parameters + ---------- + dimension : int + The dimension of the elements to get. 1 for 1D, 2 for 2D, and 3 for 3D. + Returns + ------- + Iterable[:class:`compas_fea2.model._Element`] + The elements of the specified dimension. + """ + if dimension == 1: return filter(lambda x: isinstance(x, _Element1D), self.elements) elif dimension == 2: @@ -234,7 +613,15 @@ def elements_by_dimension(self, dimension=1): # ========================================================================= @classmethod - def from_compas_lines(cls, lines, element_model="BeamElement", xaxis=[0, 1, 0], section=None, name=None, **kwargs): + def from_compas_lines( + cls, + lines: List["compas.geometry.Line"], + element_model: str = "BeamElement", + xaxis: List[float] = [0, 1, 0], + section: Optional["_Section"] = None, + name: Optional[str] = None, + **kwargs, + ) -> "_Part": """Generate a part from a list of :class:`compas.geometry.Line`. Parameters @@ -259,12 +646,17 @@ def from_compas_lines(cls, lines, element_model="BeamElement", xaxis=[0, 1, 0], import compas_fea2 prt = cls(name=name) - # nodes = [Node(n) for n in set([list(p) for l in lines for p in list(l)])] mass = kwargs.get("mass", None) for line in lines: - frame = Frame(line[0], xaxis, line.vector) - # FIXME change tolerance - nodes = [prt.find_nodes_around_point(list(p), 1, single=True) or Node(list(p), mass=mass) for p in list(line)] + frame = Frame(line.start, xaxis, line.vector) + + nodes = [] + for p in [line.start, line.end]: + if g := prt.nodes.subgroup(condition=lambda node: node.point == p): + nodes.append(list(g.nodes)[0]) + else: + nodes.append(Node(list(p), mass=mass)) + prt.add_nodes(nodes) element = getattr(compas_fea2.model, element_model)(nodes=nodes, section=section, frame=frame) if not isinstance(element, _Element1D): @@ -273,8 +665,8 @@ def from_compas_lines(cls, lines, element_model="BeamElement", xaxis=[0, 1, 0], return prt @classmethod - def shell_from_compas_mesh(cls, mesh, section, name=None, **kwargs): - """Creates a DeformablePart object from a :class:`compas.datastructures.Mesh`. + def shell_from_compas_mesh(cls, mesh, section: ShellSection, name: Optional[str] = None, **kwargs) -> "_Part": + """Creates a Part object from a :class:`compas.datastructures.Mesh`. To each face of the mesh is assigned a :class:`compas_fea2.model.ShellElement` object. Currently, the same section is applied to all the elements. @@ -282,7 +674,7 @@ def shell_from_compas_mesh(cls, mesh, section, name=None, **kwargs): Parameters ---------- mesh : :class:`compas.datastructures.Mesh` - Mesh to convert to a DeformablePart. + Mesh to convert to a Part. section : :class:`compas_fea2.model.ShellSection` Shell section assigned to each face. name : str, optional @@ -311,69 +703,58 @@ def shell_from_compas_mesh(cls, mesh, section, name=None, **kwargs): return part @classmethod - def from_gmsh(cls, gmshModel, section=None, name=None, **kwargs): - """Create a Part object from a gmshModel object. - - According to the `section` type provided, :class:`compas_fea2.model._Element2D` or - :class:`compas_fea2.model._Element3D` elements are created. - The same section is applied to all the elements. + def from_gmsh(cls, gmshModel, section: Optional[Union[SolidSection, ShellSection]] = None, name: Optional[str] = None, **kwargs) -> "_Part": + """Create a Part object from a gmshModel object with support for C3D4 and C3D10 elements. Parameters ---------- - gmshModel : obj - gmsh Model to convert. See [1]_. - section : obj - `compas_fea2` :class:`SolidSection` or :class:`ShellSection` sub-class - object to apply to the elements. + gmshModel : object + Gmsh Model to convert. + section : Union[SolidSection, ShellSection], optional + The section type (`SolidSection` or `ShellSection`). name : str, optional Name of the new part. split : bool, optional - If ``True`` create an additional node in the middle of the edges of the - elements to implement more refined element types. Check for example [2]_. + Feature under development. verbose : bool, optional - If ``True`` print a log, by default False. + If `True`, print logs. + rigid : bool, optional + If `True`, applies rigid constraints. check : bool, optional - If ``True`` performs sanity checks, by default False. This is a quite - resource-intense operation! Set to ``False`` for large models (>10000 - nodes). + If `True`, performs sanity checks (resource-intensive). Returns ------- - :class:`compas_fea2.model.Part` + _Part The part meshed. Notes ----- - The gmshModel must have the right dimension corresponding to the section provided. - - References - ---------- - .. [1] https://gitlab.onelab.info/gmsh/gmsh/blob/gmsh_4_9_1/api/gmsh.py - .. [2] https://web.mit.edu/calculix_v2.7/CalculiX/ccx_2.7/doc/ccx/node33.html - - Examples - -------- - >>> mat = ElasticIsotropic(name="mat", E=29000, v=0.17, density=2.5e-9) - >>> sec = SolidSection("mysec", mat) - >>> part = DeformablePart.from_gmsh("part_gmsh", gmshModel, sec) + - Detects whether elements are C3D4 (4-node) or C3D10 (10-node) and assigns correctly. + - The `gmshModel` should have the correct dimensions for the given section. """ - import numpy as np - part = cls(name=name) + # gmshModel.set_option("Mesh.ElementOrder", 2) + # gmshModel.set_option("Mesh.Optimize", 1) + # gmshModel.set_option("Mesh.OptimizeNetgen", 1) + # gmshModel.set_option("Mesh.SecondOrderLinear", 0) + # gmshModel.set_option("Mesh.OptimizeNetgen", 1) + gmshModel.heal() gmshModel.generate_mesh(3) model = gmshModel.model - # add nodes + # Add nodes node_coords = model.mesh.get_nodes()[1].reshape((-1, 3), order="C") fea2_nodes = np.array([part.add_node(Node(coords)) for coords in node_coords]) - # add elements + # Get elements gmsh_elements = model.mesh.get_elements() dimension = 2 if isinstance(section, SolidSection) else 1 - ntags_per_element = np.split(gmsh_elements[2][dimension] - 1, len(gmsh_elements[1][dimension])) # gmsh keys start from 1 + # gmsh keys start from 1 + ntags_per_element = np.split(gmsh_elements[2][dimension] - 1, len(gmsh_elements[1][dimension])) verbose = kwargs.get("verbose", False) rigid = kwargs.get("rigid", False) @@ -381,47 +762,62 @@ def from_gmsh(cls, gmshModel, section=None, name=None, **kwargs): for ntags in ntags_per_element: if kwargs.get("split", False): - raise NotImplementedError("this feature is under development") - # element_nodes = [fea2_nodes[ntag] for ntag in ntags] + raise NotImplementedError("This feature is under development") + element_nodes = fea2_nodes[ntags] if ntags.size == 3: - k = part.add_element(ShellElement(nodes=element_nodes, section=section, rigid=rigid, implementation=implementation)) + part.add_element( + ShellElement( + nodes=element_nodes, + section=section, + rigid=rigid, + implementation=implementation, + ) + ) + elif ntags.size == 4: if isinstance(section, ShellSection): - k = part.add_element(ShellElement(nodes=element_nodes, section=section, rigid=rigid, implementation=implementation)) + part.add_element( + ShellElement( + nodes=element_nodes, + section=section, + rigid=rigid, + implementation=implementation, + ) + ) else: - k = part.add_element(TetrahedronElement(nodes=element_nodes, section=section)) - part.ndf = 3 # FIXME try to move outside the loop + part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) + part.ndf = 3 # FIXME: move outside the loop + + elif ntags.size == 10: # C3D10 tetrahedral element + part.add_element(TetrahedronElement(nodes=element_nodes, section=section, rigid=rigid)) + part.ndf = 3 + elif ntags.size == 8: - k = part.add_element(HexahedronElement(nodes=element_nodes, section=section)) + part.add_element(HexahedronElement(nodes=element_nodes, section=section, rigid=rigid)) + else: - raise NotImplementedError("Element with {} nodes not supported".format(ntags.size)) + raise NotImplementedError(f"Element with {ntags.size} nodes not supported") + if verbose: - print("element {} added".format(k)) + print(f"Element {ntags} added") if not part._boundary_mesh: - gmshModel.generate_mesh(2) # FIXME Get the volumes without the mesh + gmshModel.generate_mesh(2) part._boundary_mesh = gmshModel.mesh_to_compas() if not part._discretized_boundary_mesh: - # gmshModel.generate_mesh(2) part._discretized_boundary_mesh = part._boundary_mesh if rigid: point = part._discretized_boundary_mesh.centroid() - part.reference_point = Node(xyz=[point.x, point.y, point.z]) - - # FIXME get the planes on each face of the part and compute the centroid -> move to Part - # centroid_face = {} - # for face in part._discretized_boundary_mesh.faces(): - # centroid_face[geometric_key(part._discretized_boundary_mesh.face_centroid(face))] = face - # part._discretized_boundary_mesh.centroid_face = centroid_face + part.reference_point = Node(xyz=point) return part @classmethod - def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): + def from_boundary_mesh(cls, boundary_mesh, name: Optional[str] = None, **kwargs) -> "_Part": """Create a Part object from a 3-dimensional :class:`compas.datastructures.Mesh` object representing the boundary envelope of the Part. The Part is discretized uniformly in Tetrahedra of a given mesh size. @@ -430,7 +826,7 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): Parameters ---------- boundary_mesh : :class:`compas.datastructures.Mesh` - Boundary envelope of the DeformablePart. + Boundary envelope of the Part. name : str, optional Name of the new Part. target_mesh_size : float, optional @@ -446,7 +842,7 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): Returns ------- - :class:`compas_fea2.model.Part` + _Part The part. """ @@ -477,12 +873,13 @@ def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) - del gmshModel + if gmshModel: + del gmshModel return part @classmethod - def from_step_file(cls, step_file, name=None, **kwargs): + def from_step_file(cls, step_file: str, name: Optional[str] = None, **kwargs) -> "_Part": """Create a Part object from a STEP file. Parameters @@ -502,7 +899,7 @@ def from_step_file(cls, step_file, name=None, **kwargs): Returns ------- - :class:`compas_fea2.model.Part` + _Part The part. """ @@ -535,295 +932,457 @@ def from_step_file(cls, step_file, name=None, **kwargs): part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) - del gmshModel + if gmshModel: + del gmshModel + print("Part created.") + + return part + + @classmethod + def from_brep(cls, brep, name: Optional[str] = None, **kwargs) -> "_Part": + """Create a Part object from a BREP file. + Parameters + ---------- + brep : str + Path to the BREP file. + name : str, optional + Name of the new Part. + mesh_size_at_vertices : dict, optional + Dictionary of vertex keys and target mesh sizes, by default None. + target_point_mesh_size : dict, optional + Dictionary of point coordinates and target mesh sizes, by default None. + meshsize_max : float, optional + Maximum mesh size, by default None. + meshsize_min : float, optional + Minimum mesh size, by default None. + Returns + ------- + _Part + The part. + """ + from compas_gmsh.models import MeshModel + + mesh_size_at_vertices = kwargs.get("mesh_size_at_vertices", None) + target_point_mesh_size = kwargs.get("target_point_mesh_size", None) + meshsize_max = kwargs.get("meshsize_max", None) + meshsize_min = kwargs.get("meshsize_min", None) + + print("Creating the part from the step file...") + gmshModel = MeshModel.from_brep(brep) + + if mesh_size_at_vertices: + for vertex, target in mesh_size_at_vertices.items(): + gmshModel.mesh_targetlength_at_vertex(vertex, target) + + if target_point_mesh_size: + gmshModel.heal() + for point, target in target_point_mesh_size.items(): + tag = gmshModel.model.occ.addPoint(*point, target) + gmshModel.model.occ.mesh.set_size([(0, tag)], target) + + if meshsize_max: + gmshModel.heal() + gmshModel.options.mesh.meshsize_max = meshsize_max + if meshsize_min: + gmshModel.heal() + gmshModel.options.mesh.meshsize_min = meshsize_min + + part = cls.from_gmsh(gmshModel=gmshModel, name=name, **kwargs) + + if gmshModel: + del gmshModel + print("Part created.") return part # ========================================================================= - # Nodes methods + # Materials methods # ========================================================================= - def find_node_by_key(self, key): - """Retrieve a node in the model using its key. + def find_materials_by_name(self, name: str) -> List[_Material]: + """Find all materials with a given name. Parameters ---------- - key : int - The node's key. + name : str Returns ------- - :class:`compas_fea2.model.Node` - The corresponding node. - + List[_Material] """ - for node in self.nodes: - if node.key == key: - return node + mg = MaterialsGroup(self.materials) + return mg.subgroup(condition=lambda x: x.name == name).materials - def find_node_by_inputkey(self, input_key): - """Retrieve a node in the model using its key. + def find_material_by_uid(self, uid: str) -> Optional[_Material]: + """Find a material with a given unique identifier. Parameters ---------- - input_key : int - The node's inputkey. + uid : str Returns ------- - :class:`compas_fea2.model.Node` - The corresponding node. - + Optional[_Material] """ - for node in self.nodes: - if node.input_key == input_key: - return node + for material in self.materials: + if material.uid == uid: + return material + return None - def find_nodes_by_name(self, name): - """Find all nodes with a given name. + def contains_material(self, material: _Material) -> bool: + """Verify that the part contains a specific material. Parameters ---------- - name : str + material : _Material Returns ------- - list[:class:`compas_fea2.model.Node`] - + bool """ - return [node for node in self.nodes if node.name == name] + return material in self.materials - def find_nodes_around_point(self, point, distance, plane=None, report=False, single=False, **kwargs): - """Find all nodes within a distance of a given geometrical location. + def add_material(self, material: _Material) -> _Material: + """Add a material to the part so that it can be referenced in section and element definitions. Parameters ---------- - point : :class:`compas.geometry.Point` - A geometrical location. - distance : float - Distance from the location. - plane : :class:`compas.geometry.Plane`, optional - Limit the search to one plane. - report : bool, optional - If True, return a dictionary with the node and its distance to the - point, otherwise, just the node. By default is False. - single : bool, optional - If True, return only the closest node, by default False. + material : _Material Returns ------- - list[:class:`compas_fea2.model.Node`] + _Material + Raises + ------ + TypeError + If the material is not a material. """ - d2 = distance**2 - nodes = self.find_nodes_on_plane(plane) if plane else self.nodes - if report: - return {node: sqrt(distance) for node in nodes if distance_point_point_sqrd(node.xyz, point) < d2} - nodes = [node for node in nodes if distance_point_point_sqrd(node.xyz, point) < d2] - if len(nodes) == 0: - if compas_fea2.VERBOSE: - print(f"No nodes found at {point}") - return [] - if single: - return nodes[0] - else: - return nodes + if not isinstance(material, _Material): + raise TypeError(f"{material!r} is not a material.") - def find_closest_nodes_to_point(self, point, number_of_nodes, report=False): - """ - Find the closest number_of_nodes nodes to a given point in the part. + self._materials.add(material) + material._registration = self + return material + + def add_materials(self, materials: List[_Material]) -> List[_Material]: + """Add multiple materials to the part. Parameters ---------- - point): list - List of coordinates representing the point in x,y,z. - number_of_nodes: int - The number of closest points to find. - report: bool - Whether to return distances along with the nodes. + materials : List[_Material] Returns ------- - list or dict: A list of the closest nodes, or a dictionary with nodes - and distances if report=True. + List[_Material] """ - if number_of_nodes <= 0: - raise ValueError("The number of nodes to find must be greater than 0.") - if number_of_nodes > len(self.points): - raise ValueError("The number of nodes to find exceeds the available nodes.") - - tree = KDTree(self.points) - distances, indices = tree.query(point, k=number_of_nodes) - if number_of_nodes == 1: - distances = [distances] - indices = [indices] - closest_nodes = [list(self.nodes)[i] for i in indices] - - if report: - # Return a dictionary with nodes and their distances - return {node: distance for node, distance in zip(closest_nodes, distances)} - - return closest_nodes + return [self.add_material(material) for material in materials] - def find_nodes_around_node(self, node, distance, plane=None, report=False, single=False): - """Find all nodes around a given node (excluding the node itself). + def find_material_by_name(self, name: str) -> Optional[_Material]: + """Find a material with a given name. Parameters ---------- - node : :class:`compas_fea2.model.Node` - The given node. - distance : float - Search radius. - plane : :class:`compas.geometry.Plane`, optional - Limit the search to one plane. + name : str Returns ------- - list[:class:`compas_fea2.model.Node`] - + Optional[_Material] """ - nodes = self.find_nodes_around_point(node.xyz, distance, plane, report=report, single=single) - if nodes and isinstance(nodes, Iterable): - if node in nodes: - del nodes[node] - return nodes + for material in self.materials: + if material.name == name: + return material + return None - def find_closest_nodes_to_node(self, node, distance, number_of_nodes=1, plane=None): - """Find the n closest nodes around a given node (excluding the node itself). + # ========================================================================= + # Sections methods + # ========================================================================= + + def find_sections_by_name(self, name: str) -> List[_Section]: + """Find all sections with a given name. Parameters ---------- - node : :class:`compas_fea2.model.Node` - The given node. - distance : float - Distance from the location. - number_of_nodes : int - Number of nodes to return. - plane : :class:`compas.geometry.Plane`, optional - Limit the search to one plane. + name : str Returns ------- - list[:class:`compas_fea2.model.Node`] - + List[_Section] """ - nodes = self.find_nodes_around_point(node.xyz, distance, plane, report=True) - if number_of_nodes > len(nodes): - number_of_nodes = len(nodes) - return [k for k, v in sorted(nodes.items(), key=lambda item: item[1])][:number_of_nodes] + return [section for section in self.sections if section.name == name] - def find_nodes_by_attribute(self, attr, value, tolerance=0.001): - """Find all nodes with a given value for the given attribute. + def find_section_by_uid(self, uid: str) -> Optional[_Section]: + """Find a section with a given unique identifier. Parameters ---------- - attr : str - Attribute name. - value : any - Appropriate value for the given attribute. - tolerance : float, optional - Tolerance for numeric attributes, by default 0.001. + uid : str Returns ------- - list[:class:`compas_fea2.model.Node`] - - Notes - ----- - Only numeric attributes are supported. - + Optional[_Section] """ - return list(filter(lambda x: abs(getattr(x, attr) - value) <= tolerance, self.nodes)) + for section in self.sections: + if section.uid == uid: + return section + return None - def find_nodes_on_plane(self, plane, tolerance=1): - """Find all nodes on a given plane. + def contains_section(self, section: _Section) -> bool: + """Verify that the part contains a specific section. Parameters ---------- - plane : :class:`compas.geometry.Plane` - The plane. - tolerance : float, optional - Tolerance for the search, by default 1. + section : _Section Returns ------- - list[:class:`compas_fea2.model.Node`] + bool + """ + return section in self.sections + + def add_section(self, section: _Section) -> _Section: + """Add a section to the part so that it can be referenced in element definitions. + + Parameters + ---------- + section : :class:`compas_fea2.model.Section` + + Returns + ------- + _Section + + Raises + ------ + TypeError + If the section is not a section. """ - return list(filter(lambda x: is_point_on_plane(Point(*x.xyz), plane, tolerance), self.nodes)) + if not isinstance(section, _Section): + raise TypeError("{!r} is not a section.".format(section)) - def find_nodes_in_polygon(self, polygon, tolerance=1.1): - """Find the nodes of the part that are contained within a planar polygon. + self.add_material(section.material) + self._sections.add(section) + section._registration = self + return section + + def add_sections(self, sections: List[_Section]) -> List[_Section]: + """Add multiple sections to the part. Parameters ---------- - polygon : :class:`compas.geometry.Polygon` - The polygon for the search. - tolerance : float, optional - Tolerance for the search, by default 1.1. + sections : list[:class:`compas_fea2.model.Section`] Returns ------- - list[:class:`compas_fea2.model.Node`] + list[:class:`compas_fea2.model.Section`] + """ + return [self.add_section(section) for section in sections] + def find_section_by_name(self, name: str) -> Optional[_Section]: + """Find a section with a given name. + + Parameters + ---------- + name : str + + Returns + ------- + Optional[_Section] """ - # TODO quick fix...change! - if not hasattr(polygon, "plane"): - try: - polygon.plane = Frame.from_points(*polygon.points[:3]) - except Exception: - polygon.plane = Frame.from_points(*polygon.points[-3:]) + for section in self.sections: + if section.name == name: + return section + return - S = Scale.from_factors([tolerance] * 3, polygon.frame) - T = Transformation.from_frame_to_frame(Frame.from_plane(polygon.plane), Frame.worldXY()) - nodes_on_plane = self.find_nodes_on_plane(Plane.from_frame(polygon.plane)) - polygon_xy = polygon.transformed(S) - polygon_xy = polygon.transformed(T) - return list(filter(lambda x: is_point_in_polygon_xy(Point(*x.xyz).transformed(T), polygon_xy), nodes_on_plane)) + # ========================================================================= + # Nodes methods + # ========================================================================= + def find_node_by_uid(self, uid: str) -> Optional[Node]: + """Retrieve a node in the part using its unique identifier. - # TODO quite slow...check how to make it faster - def find_nodes_where(self, conditions): - """Find the nodes where some conditions are met. + Parameters + ---------- + uid : str + The node's unique identifier. + + Returns + ------- + Optional[Node] + The corresponding node, or None if not found. + + """ + for node in self._nodes: + if node.uid == uid: + return node + return None + + def find_node_by_key(self, key: int) -> Optional[Node]: + """Retrieve a node in the model using its key. Parameters ---------- - conditions : list[str] - List with the strings of the required conditions. + key : int + The node's key. Returns ------- - list[:class:`compas_fea2.model.Node`] + Optional[Node] + The corresponding node, or None if not found. + + """ + for node in self._nodes: + if node.key == key: + return node + print(f"No nodes found with key {key}") + return None + + def find_node_by_name(self, name: str) -> List[Node]: + """Find a node with a given name. + + Parameters + ---------- + name : str + + Returns + ------- + List[Node] + List of nodes with the given name. + + """ + for node in self._nodes: + if node.name == name: + return node + print(f"No nodes found with name {name}") + return None + + def find_nodes_on_plane(self, plane: Plane, tol: float = 1.0) -> List[Node]: + """Find all nodes on a given plane. + + Parameters + ---------- + plane : Plane + The plane. + tol : float, optional + Tolerance for the search, by default 1.0. + + Returns + ------- + List[Node] + List of nodes on the given plane. + """ + return self.nodes.subgroup(condition=lambda x: is_point_on_plane(x.point, plane, tol)) + + def find_closest_nodes_to_point(self, point: List[float], number_of_nodes: int = 1, report: bool = False, single: bool = False) -> Union[List[Node], Dict[Node, float]]: + """ + Find the closest number_of_nodes nodes to a given point. + + Parameters + ---------- + point : :class:`compas.geometry.Point` | List[float] + Point or List of coordinates representing the point in x, y, z. + number_of_nodes : int + The number of closest points to find. + report : bool + Whether to return distances along with the nodes. + + Returns + ------- + List[Node] or Dict[Node, float] + A list of the closest nodes, or a dictionary with nodes + and distances if report=True. + """ + if number_of_nodes > len(self.nodes): + if compas_fea2.VERBOSE: + print(f"The number of nodes to find exceeds the available nodes. Capped to {len(self.nodes)}") + number_of_nodes = len(self.nodes) + if number_of_nodes < 0: + raise ValueError("The number of nodes to find must be positive") + + if number_of_nodes == 0: + return None + + tree = KDTree([n.xyz for n in self.nodes]) + distances, indices = tree.query(point, k=number_of_nodes) + if number_of_nodes == 1: + if single: + return list(self.nodes)[indices] + else: + distances = [distances] + indices = [indices] + closest_nodes = [list(self.nodes)[i] for i in indices] + + closest_nodes = [list(self.nodes)[i] for i in indices] # Ensure closest_nodes is initialized + + if report: + # Return a dictionary with nodes and their distances + return {node: distance for node, distance in zip(closest_nodes, distances)} + return NodesGroup(closest_nodes) + + def find_closest_nodes_to_node(self, node: Node, number_of_nodes: int = 1, report: Optional[bool] = False, single: bool = False) -> List[Node]: + """Find the n closest nodes around a given node (excluding the node itself). + + Parameters + ---------- + node : Node + The given node. + distance : float + Distance from the location. + number_of_nodes : int + Number of nodes to return. + plane : Optional[Plane], optional + Limit the search to one plane. + + Returns + ------- + List[Node] + List of the closest nodes. """ - import re + return self.find_closest_nodes_to_point(node.xyz, number_of_nodes, report=report, single=single) + + def find_nodes_in_polygon(self, polygon: "compas.geometry.Polygon", tol: float = 1.1) -> List[Node]: + """Find the nodes of the part that are contained within a planar polygon. + + Parameters + ---------- + polygon : compas.geometry.Polygon + The polygon for the search. + tol : float, optional + Tolerance for the search, by default 1.1. - nodes = [] - for condition in conditions: - # limit the serch to the already found nodes - part_nodes = self.nodes if not nodes else list(set.intersection(*nodes)) + Returns + ------- + List[Node] + List of nodes within the polygon. + """ + if not hasattr(polygon, "plane"): try: - eval(condition) - except NameError as ne: - var_name = re.findall(r"'([^']*)'", str(ne))[0] - nodes.append(set(filter(lambda n: eval(condition.replace(var_name, str(getattr(n, var_name)))), part_nodes))) - return list(set.intersection(*nodes)) + polygon.plane = Frame.from_points(*polygon.points[:3]) + except Exception: + polygon.plane = Frame.from_points(*polygon.points[-3:]) + S = Scale.from_factors([tol] * 3, polygon.frame) + T = Transformation.from_frame_to_frame(Frame.from_plane(polygon.plane), Frame.worldXY()) + nodes_on_plane: NodesGroup = self.find_nodes_on_plane(Plane.from_frame(polygon.plane)) + polygon_xy = polygon.transformed(S) + polygon_xy = polygon.transformed(T) + return nodes_on_plane.subgroup(condition=lambda x: is_point_in_polygon_xy(Point(*x.xyz).transformed(T), polygon_xy)) - def contains_node(self, node): + def contains_node(self, node: Node) -> bool: """Verify that the part contains a given node. Parameters ---------- - node : :class:`compas_fea2.model.Node` + node : Node The node to check. Returns ------- bool - + True if the node is in the part, False otherwise. """ return node in self.nodes - def add_node(self, node): + def add_node(self, node: Node) -> Node: """Add a node to the part. Parameters @@ -847,7 +1406,7 @@ def add_node(self, node): Examples -------- - >>> part = DeformablePart() + >>> part = Part() >>> node = Node(xyz=(1.0, 2.0, 3.0)) >>> part.add_node(node) @@ -855,27 +1414,23 @@ def add_node(self, node): if not isinstance(node, Node): raise TypeError("{!r} is not a node.".format(node)) - if self.contains_node(node): + # if not compas_fea2.POINT_OVERLAP: + # existing_node = self.find_nodes_around_point(node.xyz, distance=compas_fea2.GLOBAL_TOLERANCE) + # if existing_node: + # if compas_fea2.VERBOSE: + # print("NODE SKIPPED: Part {!r} has already a node at {}.".format(self, node.xyz)) + # return existing_node[0] + + if node not in self._nodes: + node._part_key = len(self.nodes) + self._nodes.add(node) + self._gkey_node[node.gkey] = node + node._registration = self if compas_fea2.VERBOSE: - print("NODE SKIPPED: Node {!r} already in part.".format(node)) - return - - if not compas_fea2.POINT_OVERLAP: - existing_node = self.find_nodes_around_point(node.xyz, distance=compas_fea2.GLOBAL_TOLERANCE) - if existing_node: - if compas_fea2.VERBOSE: - print("NODE SKIPPED: Part {!r} has already a node at {}.".format(self, node.xyz)) - return existing_node[0] - - node._key = len(self._nodes) - self._nodes.add(node) - self._gkey_node[node.gkey] = node - node._registration = self - if compas_fea2.VERBOSE: - print("Node {!r} registered to {!r}.".format(node, self)) + print("Node {!r} registered to {!r}.".format(node, self)) return node - def add_nodes(self, nodes): + def add_nodes(self, nodes: List[Node]) -> List[Node]: """Add multiple nodes to the part. Parameters @@ -889,7 +1444,7 @@ def add_nodes(self, nodes): Examples -------- - >>> part = DeformablePart() + >>> part = Part() >>> node1 = Node([1.0, 2.0, 3.0]) >>> node2 = Node([3.0, 4.0, 5.0]) >>> node3 = Node([3.0, 4.0, 5.0]) @@ -898,7 +1453,7 @@ def add_nodes(self, nodes): """ return [self.add_node(node) for node in nodes] - def remove_node(self, node): + def remove_node(self, node: Node) -> None: """Remove a :class:`compas_fea2.model.Node` from the part. Warnings @@ -911,15 +1466,14 @@ def remove_node(self, node): The node to remove. """ - # type: (Node) -> None if self.contains_node(node): self.nodes.remove(node) self._gkey_node.pop(node.gkey) node._registration = None if compas_fea2.VERBOSE: - print("Node {!r} removed from {!r}.".format(node, self)) + print(f"Node {node!r} removed from {self!r}.") - def remove_nodes(self, nodes): + def remove_nodes(self, nodes: List[Node]) -> None: """Remove multiple :class:`compas_fea2.model.Node` from the part. Warnings @@ -935,8 +1489,8 @@ def remove_nodes(self, nodes): for node in nodes: self.remove_node(node) - def is_node_on_boundary(self, node, precision=None): - """Check if a node is on the boundary mesh of the DeformablePart. + def is_node_on_boundary(self, node: Node, precision: Optional[float] = None) -> bool: + """Check if a node is on the boundary mesh of the Part. Parameters ---------- @@ -958,49 +1512,114 @@ def is_node_on_boundary(self, node, precision=None): if not self.discretized_boundary_mesh: raise AttributeError("The discretized_boundary_mesh has not been defined") if not node.on_boundary: - node._on_boundary = True if TOL.geometric_key(node.xyz, precision) in self.discretized_boundary_mesh.gkey_vertex() else False + node._on_boundary = TOL.geometric_key(node.xyz, precision) in self.discretized_boundary_mesh.gkey_vertex() return node.on_boundary - # ========================================================================= - # Elements methods - # ========================================================================= - def find_element_by_key(self, key): - """Retrieve an element in the model using its key. + def compute_nodal_masses(self) -> List[float]: + """Compute the nodal mass of the part. - Parameters - ---------- - key : int - The element's key. + Warnings + -------- + Rotational masses are not considered. Returns ------- - :class:`compas_fea2.model._Element` - The corresponding element. + list + List with the nodal masses. """ + # clear masses + for node in self.nodes: + for i in range(len(node.mass)): + node.mass[i] = 0.0 for element in self.elements: - if element.key == key: - return element + for node in element.nodes: + node.mass = [a + b for a, b in zip(node.mass, element.nodal_mass[:3])] + [0.0, 0.0, 0.0] + return [sum(node.mass[i] for node in self.nodes) for i in range(3)] + + def visualize_node_connectivity(self): + """Visualizes nodes with color coding based on connectivity.""" + degrees = {node: self.graph.degree(node) for node in self.graph.nodes} + pos = nx.spring_layout(self.graph) + + node_colors = [degrees[node] for node in self.graph.nodes] + + plt.figure(figsize=(8, 6)) + nx.draw(self.graph, pos, with_labels=True, node_color=node_colors, cmap=plt.cm.Blues, node_size=2000) + plt.title("Node Connectivity Visualization") + plt.show() + + def visualize_pyvis(self, filename="model_graph.html"): + """Visualizes the Model-Part and Element-Node graph using Pyvis. + The graph is saved as an HTML file, which can be opened in a web browser. + + Warnings + -------- + The Pyvis library must be installed to use this function. This function + is currently under development and may not work as expected. + + Parameters + ---------- + filename : str, optional + The name of the HTML file to save the graph, by default "model_graph.html". + """ + from pyvis.network import Network + + """Visualizes the Model-Part and Element-Node graph using Pyvis.""" + net = Network(notebook=True, height="750px", width="100%", bgcolor="#222222", font_color="white") - def find_element_by_inputkey(self, input_key): + # # Add all nodes from Model-Part Graph + # for node in self.model.graph.nodes: + # node_type = self.model.graph.nodes[node].get("type", "unknown") + + # if node_type == "model": + # net.add_node(str(node), label="Model", color="red", shape="box", size=30) + # elif node_type == "part": + # net.add_node(str(node), label=node.name, color="blue", shape="ellipse") + + # # Add all edges from Model-Part Graph + # for src, dst, data in self.model.graph.edges(data=True): + # net.add_edge(str(src), str(dst), color="gray", title=data.get("relation", "")) + + # Add all nodes from Element-Node Graph + for node in self.graph.nodes: + node_type = self.graph.nodes[node].get("type", "unknown") + + if node_type == "element": + net.add_node(str(node), label=node.name, color="yellow", shape="triangle") + elif node_type == "node": + net.add_node(str(node), label=node.name, color="green", shape="dot") + + # # Add all edges from Element-Node Graph + # for src, dst, data in self.graph.edges(data=True): + # net.add_edge(str(src), str(dst), color="lightgray", title=data.get("relation", "")) + + # Save and Open + net.show(filename) + print(f"Graph saved as {filename} - Open in a browser to view.") + + # ========================================================================= + # Elements methods + # ========================================================================= + def find_element_by_key(self, key: int) -> Optional[_Element]: """Retrieve an element in the model using its key. Parameters ---------- - input_key : int - The element's inputkey. + key : int + The element's key. Returns ------- - :class:`compas_fea2.model._Element` - The corresponding element. - + Optional[_Element] + The corresponding element, or None if not found. """ for element in self.elements: - if element.input_key == input_key: + if element.key == key: return element + return None - def find_elements_by_name(self, name): + def find_element_by_name(self, name: str) -> List[_Element]: """Find all elements with a given name. Parameters @@ -1009,153 +1628,149 @@ def find_elements_by_name(self, name): Returns ------- - list[:class:`compas_fea2.model._Element`] - + List[_Element] + List of elements with the given name. """ - return [element for element in self.elements if element.name == name] + for element in self.elements: + if element.key == name: + return element + return None - def contains_element(self, element): + def contains_element(self, element: _Element) -> bool: """Verify that the part contains a specific element. Parameters ---------- - element : :class:`compas_fea2.model._Element` + element : _Element Returns ------- bool - """ return element in self.elements - def add_element(self, element): + def add_element(self, element: _Element, checks=True) -> _Element: """Add an element to the part. Parameters ---------- - element : :class:`compas_fea2.model._Element` + element : _Element The element instance. + checks : bool, optional + Perform checks before adding the element, by default True. + Turned off during copy operations. Returns ------- - :class:`compas_fea2.model._Element` + _Element Raises ------ TypeError - If the element is not an element. - + If the element is not an instance of _Element. """ - if not isinstance(element, _Element): - raise TypeError("{!r} is not an element.".format(element)) - - if self.contains_element(element): + if checks and (not isinstance(element, _Element) or self.contains_element(element)): if compas_fea2.VERBOSE: - print("SKIPPED: Element {!r} already in part.".format(element)) - return + print(f"SKIPPED: {element!r} is not an element or already in part.") + return element self.add_nodes(element.nodes) - for node in element.nodes: - if element not in node.connected_elements: - node.connected_elements.append(element) - - if hasattr(element, "section"): - if element.section: - self.add_section(element.section) + node.connected_elements.add(element) - if hasattr(element.section, "material"): - if element.section.material: - self.add_material(element.section.material) + self.add_section(element.section) - element._key = len(self.elements) - self.elements.add(element) + element._part_key = len(self.elements) + self._elements.add(element) element._registration = self + + self.graph.add_node(element, type="element") + for node in element.nodes: + self.graph.add_node(node, type="node") + self.graph.add_edge(element, node, relation="connects") + if compas_fea2.VERBOSE: - print("Element {!r} registered to {!r}.".format(element, self)) + print(f"Element {element!r} registered to {self!r}.") + return element - def add_elements(self, elements): + def add_elements(self, elements: List[_Element]) -> List[_Element]: """Add multiple elements to the part. Parameters ---------- - elements : list[:class:`compas_fea2.model._Element`] + elements : List[_Element] Returns ------- - list[:class:`compas_fea2.model._Element`] - + List[_Element] """ return [self.add_element(element) for element in elements] - def remove_element(self, element): - """Remove a :class:`compas_fea2.model._Element` from the part. + def remove_element(self, element: _Element) -> None: + """Remove an element from the part. Parameters ---------- - element : :class:`compas_fea2.model._Element` + element : _Element The element to remove. Warnings -------- Removing elements can cause inconsistencies. - """ - # type: (_Element) -> None if self.contains_element(element): self.elements.remove(element) element._registration = None for node in element.nodes: node.connected_elements.remove(element) if compas_fea2.VERBOSE: - print("Element {!r} removed from {!r}.".format(element, self)) + print(f"Element {element!r} removed from {self!r}.") - def remove_elements(self, elements): - """Remove multiple :class:`compas_fea2.model.Element` from the part. + def remove_elements(self, elements: List[_Element]) -> None: + """Remove multiple elements from the part. Parameters ---------- - elements : list[:class:`compas_fea2.model._Element`] - List with the elements to remove. + elements : List[_Element] + List of elements to remove. Warnings -------- Removing elements can cause inconsistencies. - """ for element in elements: self.remove_element(element) - def is_element_on_boundary(self, element): + def is_element_on_boundary(self, element: _Element) -> bool: """Check if the element belongs to the boundary mesh of the part. Parameters ---------- - element : :class:`compas_fea2.model._Element` + element : _Element The element to check. Returns ------- bool - ``True`` if the element is on the boundary. - + True if the element is on the boundary, False otherwise. """ - # type: (_Element) -> bool from compas.geometry import centroid_points if element.on_boundary is None: - if not self._discretized_boundary_mesh.centroid_face: - centroid_face = {} - for face in self._discretized_boundary_mesh.faces(): - centroid_face[TOL.geometric_key(self._discretized_boundary_mesh.face_centroid(face))] = face + # if not self._discretized_boundary_mesh.face_centroid: + # centroid_face = {} + # for face in self._discretized_boundary_mesh.faces(): + # centroid_face[TOL.geometric_key(self._discretized_boundary_mesh.face_centroid(face))] = face if isinstance(element, _Element3D): if any(TOL.geometric_key(centroid_points([node.xyz for node in face.nodes])) in self._discretized_boundary_mesh.centroid_face for face in element.faces): element.on_boundary = True else: element.on_boundary = False elif isinstance(element, _Element2D): - if TOL.geometric_key(centroid_points([node.xyz for node in element.nodes])) in self._discretized_boundary_mesh.centroid_face: + centroid = centroid_points([node.xyz for node in element.nodes]) + geometric_key = TOL.geometric_key(centroid) + if geometric_key in self._discretized_boundary_mesh.centroid_face: element.on_boundary = True else: element.on_boundary = False @@ -1165,8 +1780,8 @@ def is_element_on_boundary(self, element): # Faces methods # ========================================================================= - def find_faces_on_plane(self, plane): - """Find the face of the elements that belongs to a given plane, if any. + def find_faces_on_plane(self, plane: Plane, tol: float = 1) -> List["compas_fea2.model.Face"]: + """Find the faces of the elements that belong to a given plane, if any. Parameters ---------- @@ -1181,60 +1796,110 @@ def find_faces_on_plane(self, plane): Notes ----- The search is limited to solid elements. - """ - faces = [] - for element in filter(lambda x: isinstance(x, (_Element2D, _Element3D)) and self.is_element_on_boundary(x), self._elements): - for face in element.faces: - if all([is_point_on_plane(node.xyz, plane) for node in face.nodes]): - faces.append(face) - return faces + elements_sub_group = self.elements.subgroup(condition=lambda x: isinstance(x, (_Element2D, _Element3D))) + faces_group = FacesGroup([face for element in elements_sub_group for face in element.faces]) + faces_subgroup = faces_group.subgroup(condition=lambda x: all(is_point_on_plane(node.xyz, plane, tol=tol) for node in x.nodes)) + return faces_subgroup - # ========================================================================= - # Groups methods - # ========================================================================= - - def find_groups_by_name(self, name): - """Find all groups with a given name. + def find_faces_in_polygon(self, polygon: "compas.geometry.Polygon", tol: float = 1.1) -> List["compas_fea2.model.Face"]: + """Find the faces of the elements that are contained within a planar polygon. Parameters ---------- - name : str + polygon : compas.geometry.Polygon + The polygon for the search. + tol : float, optional + Tolerance for the search, by default 1.1. Returns ------- - list[:class:`compas_fea2.model.Group`] + :class:`compas_fea2.model.FaceGroup`] + Subgroup of the faces within the polygon. + """ + # filter elements with faces + elements_sub_group = self.elements.subgroup(condition=lambda x: isinstance(x, (_Element2D, _Element3D))) + faces_group = FacesGroup([face for element in elements_sub_group for face in element.faces]) + # find faces on the plane of the polygon + if not hasattr(polygon, "plane"): + try: + polygon.plane = Frame.from_points(*polygon.points[:3]) + except Exception: + polygon.plane = Frame.from_points(*polygon.points[-3:]) + faces_subgroup = faces_group.subgroup(condition=lambda face: all(is_point_on_plane(node.xyz, polygon.plane) for node in face.nodes)) + # find faces within the polygon + S = Scale.from_factors([tol] * 3, polygon.frame) + T = Transformation.from_frame_to_frame(Frame.from_plane(polygon.plane), Frame.worldXY()) + polygon_xy = polygon.transformed(S) + polygon_xy = polygon.transformed(T) + faces_subgroup.subgroup(condition=lambda face: all(is_point_in_polygon_xy(Point(*node.xyz).transformed(T), polygon_xy) for node in face.nodes)) + return faces_subgroup + + def find_boudary_faces(self) -> List["compas_fea2.model.Face"]: + """Find the boundary faces of the part. + Returns + ------- + list[:class:`compas_fea2.model.Face`] + List with the boundary faces. + """ + return self.faces.subgroup(condition=lambda x: all(node.on_boundary for node in x.nodes)) + + def find_boundary_meshes(self, tol) -> List["compas.datastructures.Mesh"]: + """Find the boundary meshes of the part. + + Returns + ------- + list[:class:`compas.datastructures.Mesh`] + List with the boundary meshes. """ - return [group for group in self.groups if group.name == name] + planes = self.extract_clustered_planes(verbose=True) + submeshes = [Mesh() for _ in planes] + for element in self.elements: + for face in element.faces: + face_points = [node.xyz for node in face.nodes] + for i, plane in enumerate(planes): + if all(is_point_on_plane(point, plane, tol=tol) for point in face_points): + submeshes[i].join(face.mesh) + break + + print("Welding the boundary meshes...") + from compas_fea2 import PRECISION + + for submesh in submeshes: + submesh.weld(PRECISION) + return submeshes + + # ========================================================================= + # Groups methods + # ========================================================================= - def contains_group(self, group): - """Verify that the part contains a specific group. + def find_group_by_name(self, name: str) -> List[Union[NodesGroup, ElementsGroup, FacesGroup]]: + """Find all groups with a given name. Parameters ---------- - group : :class:`compas_fea2.model.Group` + name : str + The name of the group. Returns ------- - bool - + List[Union[NodesGroup, ElementsGroup, FacesGroup]] + List of groups with the given name. """ - if isinstance(group, NodesGroup): - return group in self._nodesgroups - elif isinstance(group, ElementsGroup): - return group in self._elementsgroups - elif isinstance(group, FacesGroup): - return group in self._facesgroups - else: - raise TypeError("{!r} is not a valid Group".format(group)) + for group in self.groups: + if group.name == name: + return group + print(f"No groups found with name {name}") + return None - def add_group(self, group): + def add_group(self, group: Union[NodesGroup, ElementsGroup, FacesGroup]) -> _Group: """Add a node or element group to the part. Parameters ---------- - group : :class:`compas_fea2.model.NodeGroup` | :class:`compas_fea2.model.ElementGroup` + group : :class:`compas_fea2.model.NodesGroup` | :class:`compas_fea2.model.ElementsGroup` | + :class:`compas_fea2.model.FacesGroup` Returns ------- @@ -1246,28 +1911,15 @@ def add_group(self, group): If the group is not a node or element group. """ - - if isinstance(group, NodesGroup): - self.add_nodes(group.nodes) - elif isinstance(group, ElementsGroup): - self.add_elements(group.elements) - - if self.contains_group(group): - if compas_fea2.VERBOSE: - print("SKIPPED: Group {!r} already in part.".format(group)) - return - if isinstance(group, NodesGroup): - self._nodesgroups.add(group) - elif isinstance(group, ElementsGroup): - self._elementsgroups.add(group) - elif isinstance(group, FacesGroup): - self._facesgroups.add(group) - else: - raise TypeError("{!r} is not a valid group.".format(group)) - group._registration = self # BUG wrong because the memebers of the group might have a different registation + # if self.__class__ not in group.__class__.allowed_registration: + # raise TypeError(f"{group.__class__!r} cannot be registered to {self.__class__!r}.") + group._registration = self + group._part = self + group._model = self.model + self._groups.add(group) return group - def add_groups(self, groups): + def add_groups(self, groups: List[Union[NodesGroup, ElementsGroup, FacesGroup]]) -> List[Union[_Group]]: """Add multiple groups to the part. Parameters @@ -1285,92 +1937,109 @@ def add_groups(self, groups): # Results methods # ============================================================================== - def sorted_nodes_by_displacement(self, step, component="length"): + def sorted_nodes_by_displacement(self, step: "_Step", component: str = "length") -> List[Node]: # noqa: F821 """Return a list with the nodes sorted by their displacement Parameters ---------- - problem : :class:`compas_fea2.problem.Problem` - The problem - step : :class:`compas_fea2.problem._Step`, optional - The step, by default None. If not provided, the last step of the - problem is used. + step : :class:`compas_fea2.problem._Step` + The step. component : str, optional - one of ['x', 'y', 'z', 'length'], by default 'length'. + One of ['x', 'y', 'z', 'length'], by default 'length'. Returns ------- list[:class:`compas_fea2.model.Node`] - The node sorted by displacment (ascending). + The nodes sorted by displacement (ascending). """ - return sorted(self.nodes, key=lambda n: getattr(Vector(*n.results[step.problem][step].get("U", None)), component)) - - def get_max_displacement(self, problem, step=None, component="length"): + return self.nodes.sorted_by(lambda n: getattr(Vector(*n.results[step].get("U", None)), component)) + + def get_max_displacement( + self, + problem: "Problem", # noqa: F821 + step: Optional["_Step"] = None, # noqa: F821 + component: str = "length", + ) -> Tuple[Node, float]: """Retrieve the node with the maximum displacement Parameters ---------- problem : :class:`compas_fea2.problem.Problem` - The problem + The problem. step : :class:`compas_fea2.problem._Step`, optional The step, by default None. If not provided, the last step of the problem is used. component : str, optional - one of ['x', 'y', 'z', 'length'], by default 'length'. + One of ['x', 'y', 'z', 'length'], by default 'length'. Returns ------- :class:`compas_fea2.model.Node`, float - The node and the displacement + The node and the displacement. """ step = step or problem._steps_order[-1] - node = self.sorted_nodes_by_displacement(problem=problem, step=step, component=component)[-1] + node = self.sorted_nodes_by_displacement(step=step, component=component)[-1] displacement = getattr(Vector(*node.results[problem][step].get("U", None)), component) return node, displacement - def get_min_displacement(self, problem, step=None, component="length"): + def get_min_displacement( + self, + problem: "Problem", # noqa: F821 + step: Optional["_Step"] = None, # noqa: F821 + component: str = "length", + ) -> Tuple[Node, float]: # noqa: F821 """Retrieve the node with the minimum displacement Parameters ---------- problem : :class:`compas_fea2.problem.Problem` - The problem + The problem. step : :class:`compas_fea2.problem._Step`, optional The step, by default None. If not provided, the last step of the problem is used. component : str, optional - one of ['x', 'y', 'z', 'length'], by default 'length'. + One of ['x', 'y', 'z', 'length'], by default 'length'. Returns ------- :class:`compas_fea2.model.Node`, float - The node and the displacement + The node and the displacement. """ step = step or problem._steps_order[-1] - node = self.sorted_nodes_by_displacement(problem=problem, step=step, component=component)[0] + node = self.sorted_nodes_by_displacement(step=step, component=component)[0] displacement = getattr(Vector(*node.results[problem][step].get("U", None)), component) return node, displacement - def get_average_displacement_at_point(self, problem, point, distance, step=None, component="length", project=False): + def get_average_displacement_at_point( + self, + problem: "Problem", # noqa: F821 + point: List[float], + distance: float, + step: Optional["_Step"] = None, # noqa: F821 + component: str = "length", + project: bool = False, # noqa: F821 + ) -> Tuple[List[float], float]: """Compute the average displacement around a point Parameters ---------- problem : :class:`compas_fea2.problem.Problem` - The problem + The problem. step : :class:`compas_fea2.problem._Step`, optional The step, by default None. If not provided, the last step of the problem is used. component : str, optional - one of ['x', 'y', 'z', 'length'], by default 'length'. + One of ['x', 'y', 'z', 'length'], by default 'length'. + project : bool, optional + If True, project the point onto the plane, by default False. Returns ------- - :class:`compas_fea2.model.Node`, float - The node and the displacement + List[float], float + The point and the average displacement. """ step = step or problem._steps_order[-1] @@ -1378,27 +2047,26 @@ def get_average_displacement_at_point(self, problem, point, distance, step=None, if nodes: displacements = [getattr(Vector(*node.results[problem][step].get("U", None)), component) for node in nodes] return point, sum(displacements) / len(displacements) + return point, 0.0 # ============================================================================== # Viewer # ============================================================================== - def show(self, scale_factor=1, draw_nodes=False, node_labels=False, solid=False): + def show(self, scale_factor: float = 1, draw_nodes: bool = False, node_labels: bool = False, solid: bool = False): """Draw the parts. Parameters ---------- - parts : :class:`compas_fea2.model.DeformablePart` | [:class:`compas_fea2.model.DeformablePart`] - The part or parts to draw. + scale_factor : float, optional + Scale factor for the visualization, by default 1. draw_nodes : bool, optional - if `True` draw the nodes of the part, by default False + If `True` draw the nodes of the part, by default False. node_labels : bool, optional - if `True` add the node lables, by default False - draw_envelope : bool, optional - if `True` draw the outer boundary of the part, by default False + If `True` add the node labels, by default False. solid : bool, optional - if `True` draw all the elements (also the internal ones) of the part - otherwise just show the boundary faces, by default True + If `True` draw all the elements (also the internal ones) of the part + otherwise just show the boundary faces, by default False. """ from compas_fea2.UI.viewer import FEA2Viewer @@ -1420,7 +2088,7 @@ def show(self, scale_factor=1, draw_nodes=False, node_labels=False, solid=False) v.show() -class DeformablePart(_Part): +class Part(_Part): """Deformable part.""" __doc__ += _Part.__doc__ @@ -1437,29 +2105,28 @@ class DeformablePart(_Part): """ def __init__(self, **kwargs): - super(DeformablePart, self).__init__(**kwargs) - self._materials = set() - self._sections = set() - self._releases = set() + super().__init__(**kwargs) @property - def materials(self): + def materials(self) -> Set[_Material]: return self._materials + return set(section.material for section in self.sections if section.material) @property - def sections(self): + def sections(self) -> Set[_Section]: return self._sections + return set(element.section for element in self.elements if element.section) @property - def releases(self): + def releases(self) -> Set[_BeamEndRelease]: return self._releases # ========================================================================= # Constructor methods # ========================================================================= @classmethod - def frame_from_compas_mesh(cls, mesh, section, name=None, **kwargs): - """Creates a DeformablePart object from a a :class:`compas.datastructures.Mesh`. + def frame_from_compas_mesh(cls, mesh: "compas.datastructures.Mesh", section: "compas_fea2.model.BeamSection", name: Optional[str] = None, **kwargs) -> "_Part": + """Creates a Part object from a :class:`compas.datastructures.Mesh`. To each edge of the mesh is assigned a :class:`compas_fea2.model.BeamElement`. Currently, the same section is applied to all the elements. @@ -1467,12 +2134,16 @@ def frame_from_compas_mesh(cls, mesh, section, name=None, **kwargs): Parameters ---------- mesh : :class:`compas.datastructures.Mesh` - Mesh to convert to a DeformablePart. + Mesh to convert to a Part. section : :class:`compas_fea2.model.BeamSection` Section to assign to the frame elements. name : str, optional - name of the new part. + Name of the new part. + Returns + ------- + _Part + The part created from the mesh. """ part = cls(name=name, **kwargs) vertex_node = {vertex: part.add_node(Node(mesh.vertex_coordinates(vertex))) for vertex in mesh.vertices()} @@ -1480,197 +2151,67 @@ def frame_from_compas_mesh(cls, mesh, section, name=None, **kwargs): for edge in mesh.edges(): nodes = [vertex_node[vertex] for vertex in edge] faces = mesh.edge_faces(edge) - n = [mesh.face_normal(f) for f in faces if f is not None] - if len(n) == 1: - n = n[0] + normals = [mesh.face_normal(f) for f in faces if f is not None] + if len(normals) == 1: + normal = normals[0] else: - n = n[0] + n[1] - v = list(mesh.edge_direction(edge)) - frame = n - frame.rotate(pi / 2, v, nodes[0].xyz) - part.add_element(BeamElement(nodes=[*nodes], section=section, frame=frame)) + normal = normals[0] + normals[1] + direction = list(mesh.edge_direction(edge)) + frame = normal + frame.rotate(pi / 2, direction, nodes[0].xyz) + part.add_element(BeamElement(nodes=nodes, section=section, frame=frame)) return part @classmethod - # @timer(message='part successfully imported from gmsh model in ') - def from_gmsh(cls, gmshModel, section, name=None, **kwargs): - """ """ - return super().from_gmsh(gmshModel, name=name, section=section, **kwargs) - - @classmethod - def from_boundary_mesh(cls, boundary_mesh, section, name=None, **kwargs): - """ """ - return super().from_boundary_mesh(boundary_mesh, section=section, name=name, **kwargs) - - # ========================================================================= - # Materials methods - # ========================================================================= - - def find_materials_by_name(self, name): - # type: (str) -> list - """Find all materials with a given name. - - Parameters - ---------- - name : str - - Returns - ------- - list[:class:`compas_fea2.model.Material`] - - """ - return [material for material in self.materials if material.name == name] - - def contains_material(self, material): - # type: (_Material) -> _Material - """Verify that the part contains a specific material. - - Parameters - ---------- - material : :class:`compas_fea2.model.Material` - - Returns - ------- - bool - - """ - return material in self.materials - - def add_material(self, material): - # type: (_Material) -> _Material - """Add a material to the part so that it can be referenced in section and element definitions. - - Parameters - ---------- - material : :class:`compas_fea2.model.Material` - - Returns - ------- - None - - Raises - ------ - TypeError - If the material is not a material. - - """ - if not isinstance(material, _Material): - raise TypeError("{!r} is not a material.".format(material)) - - if self.contains_material(material): - if compas_fea2.VERBOSE: - print("SKIPPED: Material {!r} already in part.".format(material)) - return - - material._key = len(self._materials) - self._materials.add(material) - material._registration = self._registration - return material - - def add_materials(self, materials): - # type: (_Material) -> list - """Add multiple materials to the part. - - Parameters - ---------- - materials : list[:class:`compas_fea2.model.Material`] - - Returns - ------- - None - - """ - return [self.add_material(material) for material in materials] - - # ========================================================================= - # Sections methods - # ========================================================================= - - def find_sections_by_name(self, name): - # type: (str) -> list - """Find all sections with a given name. - - Parameters - ---------- - name : str - - Returns - ------- - list[:class:`compas_fea2.model.Section`] - - """ - return [section for section in self.sections if section.name == name] - - def contains_section(self, section): - # type: (_Section) -> _Section - """Verify that the part contains a specific section. - - Parameters - ---------- - section : :class:`compas_fea2.model.Section` - - Returns - ------- - bool - - """ - return section in self.sections - - def add_section(self, section): - # type: (_Section) -> _Section - """Add a section to the part so that it can be referenced in element definitions. + def from_gmsh(cls, gmshModel: object, section: Union["compas_fea2.model.SolidSection", "compas_fea2.model.ShellSection"], name: Optional[str] = None, **kwargs) -> "_Part": + """Create a Part object from a gmshModel object. Parameters ---------- - section : :class:`compas_fea2.model.Section` + gmshModel : object + gmsh Model to convert. + section : Union[compas_fea2.model.SolidSection, compas_fea2.model.ShellSection] + Section to assign to the elements. + name : str, optional + Name of the new part. Returns ------- - None - - Raises - ------ - TypeError - If the section is not a section. - + _Part + The part created from the gmsh model. """ - if not isinstance(section, _Section): - raise TypeError("{!r} is not a section.".format(section)) + return super().from_gmsh(gmshModel, section=section, name=name, **kwargs) - if self.contains_section(section): - if compas_fea2.VERBOSE: - print("SKIPPED: Section {!r} already in part.".format(section)) - return - - self.add_material(section.material) - section._key = len(self.sections) - self._sections.add(section) - section._registration = self._registration - return section - - def add_sections(self, sections): - # type: (list) -> _Section - """Add multiple sections to the part. + @classmethod + def from_boundary_mesh( + cls, boundary_mesh: "compas.datastructures.Mesh", section: Union["compas_fea2.model.SolidSection", "compas_fea2.model.ShellSection"], name: Optional[str] = None, **kwargs + ) -> "_Part": + """Create a Part object from a 3-dimensional :class:`compas.datastructures.Mesh` + object representing the boundary envelope of the Part. Parameters ---------- - sections : list[:class:`compas_fea2.model.Section`] + boundary_mesh : :class:`compas.datastructures.Mesh` + Boundary envelope of the Part. + section : Union[compas_fea2.model.SolidSection, compas_fea2.model.ShellSection] + Section to assign to the elements. + name : str, optional + Name of the new part. Returns ------- - None - + _Part + The part created from the boundary mesh. """ - return [self.add_section(section) for section in sections] + return super().from_boundary_mesh(boundary_mesh, section=section, name=name, **kwargs) # ========================================================================= # Releases methods # ========================================================================= - def add_beam_release(self, element, location, release): - """Add a :class:`compas_fea2.model._BeamEndRelease` to an element in the - part. + def add_beam_release(self, element: BeamElement, location: str, release: _BeamEndRelease) -> _BeamEndRelease: + """Add a :class:`compas_fea2.model._BeamEndRelease` to an element in the part. Parameters ---------- @@ -1681,9 +2222,13 @@ def add_beam_release(self, element, location, release): release : :class:`compas_fea2.model._BeamEndRelease` Release type to apply. + Returns + ------- + :class:`compas_fea2.model._BeamEndRelease` + The release applied to the element. """ if not isinstance(release, _BeamEndRelease): - raise TypeError("{!r} is not a beam release element.".format(release)) + raise TypeError(f"{release!r} is not a beam release element.") release.element = element release.location = location self._releases.add(release) @@ -1695,46 +2240,95 @@ class RigidPart(_Part): __doc__ += _Part.__doc__ __doc__ += """ - Addtional Attributes - -------------------- + Additional Attributes + --------------------- reference_point : :class:`compas_fea2.model.Node` A node acting as a reference point for the part, by default `None`. This is required if the part is rigid as it controls its movement in space. """ - def __init__(self, reference_point=None, **kwargs): - super(RigidPart, self).__init__(**kwargs) + def __init__(self, reference_point: Optional[Node] = None, **kwargs): + super().__init__(**kwargs) self._reference_point = reference_point @property - def reference_point(self): - return self._reference_point + def __data__(self): + data = super().__data__ + data.update( + { + "class": self.__class__.__name__, + "reference_point": self.reference_point.__data__ if self.reference_point else None, + } + ) + return data - @reference_point.setter - def reference_point(self, value): - self._reference_point = self.add_node(value) - value._is_reference = True + @classmethod + def __from_data__(cls, data): + """Create a part instance from a data dictionary. + + Parameters + ---------- + data : dict + The data dictionary. + + Returns + ------- + _Part + The part instance. + """ + from compas_fea2.model import Node + + part = cls(reference_point=Node.__from_data__(data["reference_point"])) + for element_data in data.get("elements", []): + part.add_element(_Element.__from_data__(element_data)) + return part @classmethod - # @timer(message='part successfully imported from gmsh model in ') - def from_gmsh(cls, gmshModel, name=None, **kwargs): - """ """ + def from_gmsh(cls, gmshModel: object, name: Optional[str] = None, **kwargs) -> "_Part": + """Create a RigidPart object from a gmshModel object. + + Parameters + ---------- + gmshModel : object + gmsh Model to convert. + name : str, optional + Name of the new part. + + Returns + ------- + _Part + The part created from the gmsh model. + """ kwargs["rigid"] = True return super().from_gmsh(gmshModel, name=name, **kwargs) @classmethod - def from_boundary_mesh(cls, boundary_mesh, name=None, **kwargs): - """ """ + def from_boundary_mesh(cls, boundary_mesh: "compas.datastructures.Mesh", name: Optional[str] = None, **kwargs) -> _Part: + """Create a RigidPart object from a 3-dimensional :class:`compas.datastructures.Mesh` + object representing the boundary envelope of the Part. + + Parameters + ---------- + boundary_mesh : :class:`compas.datastructures.Mesh` + Boundary envelope of the RigidPart. + name : str, optional + Name of the new part. + + Returns + ------- + _Part + The part created from the boundary mesh. + """ kwargs["rigid"] = True return super().from_boundary_mesh(boundary_mesh, name=name, **kwargs) # ========================================================================= # Elements methods # ========================================================================= - # TODO this can be removed and the checks on the rigid part can be done in _part + # TODO: this can be moved to _Part - def add_element(self, element): + def add_element(self, element: _Element) -> _Element: # type: (_Element) -> _Element """Add an element to the part. diff --git a/src/compas_fea2/model/releases.py b/src/compas_fea2/model/releases.py index 0f59d99bc..feaabb86b 100644 --- a/src/compas_fea2/model/releases.py +++ b/src/compas_fea2/model/releases.py @@ -1,8 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import compas_fea2.model from compas_fea2.base import FEAData @@ -18,7 +13,7 @@ class _BeamEndRelease(FEAData): v2 : bool, optional Release displacements along local 2 direction, by default False m1 : bool, optional - Release rotations about loacl 1 direction, by default False + Release rotations about local 1 direction, by default False m2 : bool, optional Release rotations about local 2 direction, by default False t : bool, optional @@ -30,53 +25,80 @@ class _BeamEndRelease(FEAData): 'start' or 'end' element : :class:`compas_fea2.model.BeamElement` The element to release. - n : bool, optional + n : bool Release displacements along the local axial direction, by default False - v1 : bool, optional + v1 : bool Release displacements along local 1 direction, by default False - v2 : bool, optional + v2 : bool Release displacements along local 2 direction, by default False - m1 : bool, optional - Release rotations about loacl 1 direction, by default False - m2 : bool, optional + m1 : bool + Release rotations about local 1 direction, by default False + m2 : bool Release rotations about local 2 direction, by default False - t : bool, optional + t : bool Release rotations about local axial direction (torsion), by default False """ - def __init__(self, n=False, v1=False, v2=False, m1=False, m2=False, t=False, **kwargs): - super(_BeamEndRelease, self).__init__(**kwargs) - - self._element = None - self._location = None - self.n = n - self.v1 = v1 - self.v2 = v2 - self.m1 = m1 - self.m2 = m2 - self.t = t + def __init__(self, n: bool = False, v1: bool = False, v2: bool = False, m1: bool = False, m2: bool = False, t: bool = False, **kwargs): + super().__init__(**kwargs) + self._element: "BeamElement | None" # type: ignore + self._location: str | None = None + self.n: bool = n + self.v1: bool = v1 + self.v2: bool = v2 + self.m1: bool = m1 + self.m2: bool = m2 + self.t: bool = t @property - def element(self): + def element(self) -> "BeamElement | None": # type: ignore return self._element @element.setter - def element(self, value): - if not isinstance(value, compas_fea2.model.BeamElement): - raise TypeError("{!r} is not a beam element.".format(value)) + def element(self, value: "BeamElement"): # type: ignore + if not isinstance(value, "BeamElement"): # type: ignore + raise TypeError(f"{value!r} is not a beam element.") self._element = value @property - def location(self): + def location(self) -> str | None: return self._location @location.setter - def location(self, value): + def location(self, value: str): if value not in ("start", "end"): raise TypeError("the location can be either `start` or `end`") self._location = value + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "element": self._element, + "location": self._location, + "n": self.n, + "v1": self.v1, + "v2": self.v2, + "m1": self.m1, + "m2": self.m2, + "t": self.t, + } + + @classmethod + def __from_data__(cls, data): + obj = cls( + n=data["n"], + v1=data["v1"], + v2=data["v2"], + m1=data["m1"], + m2=data["m2"], + t=data["t"], + ) + obj._element = data["element"] + obj._location = data["location"] + return obj + class BeamEndPinRelease(_BeamEndRelease): """Assign a pin end release to a `compas_fea2.model.BeamElement`. @@ -84,7 +106,7 @@ class BeamEndPinRelease(_BeamEndRelease): Parameters ---------- m1 : bool, optional - Release rotations about loacl 1 direction, by default False + Release rotations about local 1 direction, by default False m2 : bool, optional Release rotations about local 2 direction, by default False t : bool, optional @@ -92,8 +114,8 @@ class BeamEndPinRelease(_BeamEndRelease): """ - def __init__(self, m1=False, m2=False, t=False, **kwargs): - super(BeamEndPinRelease, self).__init__(n=False, v1=False, v2=False, m1=m1, m2=m2, t=t, **kwargs) + def __init__(self, m1: bool = False, m2: bool = False, t: bool = False, **kwargs): + super().__init__(n=False, v1=False, v2=False, m1=m1, m2=m2, t=t, **kwargs) class BeamEndSliderRelease(_BeamEndRelease): @@ -108,5 +130,5 @@ class BeamEndSliderRelease(_BeamEndRelease): """ - def __init__(self, v1=False, v2=False, **kwargs): - super(BeamEndSliderRelease, self).__init__(v1=v1, v2=v2, n=False, m1=False, m2=False, t=False, **kwargs) + def __init__(self, v1: bool = False, v2: bool = False, **kwargs): + super().__init__(v1=v1, v2=v2, n=False, m1=False, m2=False, t=False, **kwargs) diff --git a/src/compas_fea2/model/sections.py b/src/compas_fea2/model/sections.py index 7ceeef819..acb3c1c63 100644 --- a/src/compas_fea2/model/sections.py +++ b/src/compas_fea2/model/sections.py @@ -1,20 +1,20 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import pi from math import sqrt +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import Polygon as mplPolygon +from matplotlib.path import Path + from compas_fea2 import units from compas_fea2.base import FEAData - -from .materials.material import _Material -from .shapes import Circle -from .shapes import IShape -from .shapes import Rectangle +from compas_fea2.model.shapes import Circle +from compas_fea2.model.shapes import IShape +from compas_fea2.model.shapes import LShape +from compas_fea2.model.shapes import Rectangle -def from_shape(shape, material, **kwargs): +def from_shape(shape, material: "_Material", **kwargs) -> dict: # noqa: F821 return { "A": shape.A, "Ixx": shape.Ixx, @@ -23,20 +23,21 @@ def from_shape(shape, material, **kwargs): "Avx": shape.Avx, "Avy": shape.Avy, "J": shape.J, - "g0": shape.g0, - "gw": shape.gw, "material": material, **kwargs, } class _Section(FEAData): - """Base class for sections. + """ + Base class for sections. Parameters ---------- material : :class:`~compas_fea2.model._Material` A material definition. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -51,39 +52,52 @@ class _Section(FEAData): ----- Sections are registered to a :class:`compas_fea2.model.Model` and can be assigned to elements in different Parts. - """ - def __init__(self, *, material, **kwargs): - super(_Section, self).__init__(**kwargs) + def __init__(self, material: "_Material", **kwargs): # noqa: F821 + super().__init__(**kwargs) self._material = material + @property + def __data__(self): + return { + "class": self.__class__.__base__, + "material": self.material.__data__, + "name": self.name, + "uid": self.uid, + } + + @classmethod + def __from_data__(cls, data): + material = data["material"].pop("class").__from_data__(data["material"]) + return cls(material=material) + + def __str__(self) -> str: + return f""" +Section {self.name} +{"-" * len(self.name)} +model : {self.model!r} +key : {self.key} +material : {self.material!r} +""" + @property def model(self): return self._registration @property - def material(self): + def material(self) -> "_Material": # noqa: F821 return self._material @material.setter - def material(self, value): + def material(self, value: "_Material"): # noqa: F821 + from compas_fea2.model.materials import _Material + if value: if not isinstance(value, _Material): raise ValueError("Material must be of type `compas_fea2.model._Material`.") self._material = value - def __str__(self): - return """ -Section {} ---------{} -model : {!r} -key : {} -material : {!r} -""".format( - self.name, "-" * len(self.name), self.model, self.key, self.material - ) - # ============================================================================== # 0D @@ -91,12 +105,15 @@ def __str__(self): class MassSection(FEAData): - """Section for point mass elements. + """ + Section for point mass elements. Parameters ---------- mass : float Point mass value. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -104,26 +121,36 @@ class MassSection(FEAData): Identifier of the element in the parent part. mass : float Point mass value. - """ - def __init__(self, mass, **kwargs): - super(MassSection, self).__init__(**kwargs) + def __init__(self, mass: float, **kwargs): + super().__init__(**kwargs) self.mass = mass - def __str__(self): - return """ -Mass Section {} ---------{} -model : {!r} -mass : {} -""".format( - self.name, "-" * len(self.name), self.model, self.mass - ) + def __str__(self) -> str: + return f""" +Mass Section {self.name} +{"-" * len(self.name)} +model : {self.model!r} +mass : {self.mass} +""" + + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "mass": self.mass, + "uid": self.uid, + } + + @classmethod + def __from_data__(cls, data): + return cls(mass=data["mass"], **data) class SpringSection(FEAData): - """Section for use with spring elements. + """ + Section for use with spring elements. Parameters ---------- @@ -131,8 +158,10 @@ class SpringSection(FEAData): Axial stiffness value. lateral : float Lateral stiffness value. - axial : float + rotational : float Rotational stiffness value. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -140,7 +169,7 @@ class SpringSection(FEAData): Axial stiffness value. lateral : float Lateral stiffness value. - axial : float + rotational : float Rotational stiffness value. Notes @@ -149,31 +178,75 @@ class SpringSection(FEAData): to elements in different Parts. """ - def __init__(self, axial, lateral, rotational, **kwargs): - super(SpringSection, self).__init__(**kwargs) + def __init__(self, axial: float, lateral: float, rotational: float, **kwargs): + super().__init__(**kwargs) self.axial = axial self.lateral = lateral self.rotational = rotational - def __str__(self): - return """ + @property + def __data__(self): + return { + "class": self.__class__.__base__.__name__, + "axial": self.axial, + "lateral": self.lateral, + "rotational": self.rotational, + "uid": self.uid, + "name": self.name, + } + + @classmethod + def __from_data__(cls, data): + sec = cls(axial=data["axial"], lateral=data["lateral"], rotational=data["rotational"]) + sec.uid = data["uid"] + sec.name = data["name"] + return sec + + def __str__(self) -> str: + return f""" Spring Section -------------- -Key : {} -axial stiffness : {} -lateral stiffness : {} -rotational stiffness : {} -""".format( - self.key, self.axial, self.lateral, self.rotational - ) +Key : {self.key} +axial stiffness : {self.axial} +lateral stiffness : {self.lateral} +rotational stiffness : {self.rotational} +""" @property def model(self): return self._registration @property - def stiffness(self): - return {"Axial": self._axial, "Lateral": self._lateral, "Rotational": self._rotational} + def stiffness(self) -> dict: + return {"Axial": self.axial, "Lateral": self.lateral, "Rotational": self.rotational} + + +class ConnectorSection(SpringSection): + """Section for use with connector elements. + + Parameters + ---------- + axial : float + Axial stiffness value. + lateral : float + Lateral stiffness value. + rotational : float + Rotational stiffness value. + **kwargs : dict, optional + Additional keyword arguments. + + Attributes + ---------- + axial : float + Axial stiffness value. + lateral : float + Lateral stiffness value. + rotational : float + Rotational stiffness value. + """ + + def __init__(self, axial: float = None, lateral: float = None, rotational: float = None, **kwargs): + super().__init__(axial, lateral, rotational, **kwargs) # ============================================================================== @@ -186,58 +259,62 @@ def stiffness(self): class BeamSection(_Section): - """Custom section for beam elements. + """ + Custom section for beam elements. Parameters ---------- A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - + shape : :class:`compas_fea2.shapes.Shape` + The shape of the section. """ - def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, g0, gw, material, **kwargs): - super(BeamSection, self).__init__(material=material, **kwargs) + def __init__(self, *, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, Avy: float, J: float, material: "_Material", **kwargs): # noqa: F821 + super().__init__(material=material, **kwargs) self.A = A self.Ixx = Ixx self.Iyy = Iyy @@ -245,43 +322,48 @@ def __init__(self, *, A, Ixx, Iyy, Ixy, Avx, Avy, J, g0, gw, material, **kwargs) self.Avx = Avx self.Avy = Avy self.J = J - self.g0 = g0 - self.gw = gw - - def __str__(self): - return """ -{} -{} -name : {} -material : {!r} - -A : {:~.4g} -Ixx : {:~.4g} -Iyy : {:~.4g} -Ixy : {:~.4g} -Avx : {:~.2g} -Avy : {:~.2g} -J : {} -g0 : {} -gw : {} -""".format( - self.__class__.__name__, - len(self.__class__.__name__) * "-", - self.name, - self.material, - (self.A * units["m**2"]), - (self.Ixx * units["m**4"]), - (self.Iyy * units["m**4"]), - (self.Ixy * units["m**4"]), - (self.Avx * units["m**2"]), - (self.Avy * units["m**2"]), - self.J, - self.g0, - self.gw, + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "A": self.A, + "Ixx": self.Ixx, + "Iyy": self.Iyy, + "Ixy": self.Ixy, + "Avx": self.Avx, + "Avy": self.Avy, + "J": self.J, + } ) + return data + + @classmethod + def __from_data__(cls, data): + section = super().__from_data__(data.pop("material")) + return section(**data) + + def __str__(self) -> str: + return f""" +{self.__class__.__name__} +{"-" * len(self.__class__.__name__)} +name : {self.name} +material : {self.material!r} + +A : {self.A * units["m**2"]:.4g} +Ixx : {self.Ixx * units["m**4"]:.4g} +Iyy : {self.Iyy * units["m**4"]:.4g} +Ixy : {self.Ixy * units["m**4"]:.4g} +Avx : {self.Avx * units["m**2"]:.2g} +Avy : {self.Avy * units["m**2"]:.2g} +J : {self.J} +g0 : {self.g0} +gw : {self.gw} +""" @classmethod - def from_shape(cls, shape, material, **kwargs): + def from_shape(cls, shape, material: "_Material", **kwargs): # noqa: F821 section = cls(**from_shape(shape, material, **kwargs)) section._shape = shape return section @@ -290,18 +372,419 @@ def from_shape(cls, shape, material, **kwargs): def shape(self): return self._shape + def plot(self): + self.shape.plot() + + def compute_stress(self, N: float = 0.0, Mx: float = 0.0, My: float = 0.0, Vx: float = 0.0, Vy: float = 0.0, x: float = 0.0, y: float = 0.0) -> tuple: + """ + Compute normal and shear stresses at a given point. + + Parameters + ---------- + N : float, optional + Axial force (default is 0.0). + Mx : float, optional + Bending moment about the x-axis (default is 0.0). + My : float, optional + Bending moment about the y-axis (default is 0.0). + Vx : float, optional + Shear force in the x-direction (default is 0.0). + Vy : float, optional + Shear force in the y-direction (default is 0.0). + x : float, optional + X-coordinate of the point (default is 0.0). + y : float, optional + Y-coordinate of the point (default is 0.0). + + Returns + ------- + tuple + Normal stress, shear stress in x-direction, shear stress in y-direction. + """ + sigma = (N / self.A) - (Mx * y / self.Ixx) + (My * x / self.Iyy) + tau_x = Vx / self.Avx if self.Avx else Vx / self.A + tau_y = Vy / self.Avy if self.Avy else Vy / self.A + return sigma, tau_x, tau_y + + def compute_stress_distribution(self, N: float = 0.0, Mx: float = 0.0, My: float = 0.0, Vx: float = 0.0, Vy: float = 0.0, nx: int = 50, ny: int = 50) -> tuple: + """ + Compute stress distribution over the section. + + Parameters + ---------- + N : float, optional + Axial force (default is 0.0). + Mx : float, optional + Bending moment about the x-axis (default is 0.0). + My : float, optional + Bending moment about the y-axis (default is 0.0). + Vx : float, optional + Shear force in the x-direction (default is 0.0). + Vy : float, optional + Shear force in the y-direction (default is 0.0). + nx : int, optional + Grid resolution in x direction (default is 50). + ny : int, optional + Grid resolution in y direction (default is 50). + + Returns + ------- + tuple + Grid of x-coordinates, grid of y-coordinates, grid of normal stresses, grid of shear stresses in x-direction, grid of shear stresses in y-direction. + """ + verts = [(p.x, p.y) for p in self.shape.points] + polygon_path = Path(verts) + xs = [p[0] for p in verts] + ys = [p[1] for p in verts] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + cx, cy, _ = self.shape.centroid + x = np.linspace(x_min, x_max, nx) + y = np.linspace(y_min, y_max, ny) + grid_x, grid_y = np.meshgrid(x, y) + points = np.vstack((grid_x.flatten(), grid_y.flatten())).T + inside = polygon_path.contains_points(points) + grid_sigma = np.full(grid_x.shape, np.nan) + grid_tau_x = np.full(grid_x.shape, np.nan) + grid_tau_y = np.full(grid_x.shape, np.nan) + + for i, (x_, y_) in enumerate(points): + if inside[i]: + x_fiber = x_ - cx + y_fiber = y_ - cy + sigma, tau_x, tau_y = self.compute_stress(N=N, Mx=Mx, My=My, Vx=Vx, Vy=Vy, x=x_fiber, y=y_fiber) + grid_sigma.flat[i] = sigma + grid_tau_x.flat[i] = tau_x + grid_tau_y.flat[i] = tau_y + + return grid_x, grid_y, grid_sigma, grid_tau_x, grid_tau_y + + def compute_neutral_axis(self, N: float = 0.0, Mx: float = 0.0, My: float = 0.0) -> tuple: + """ + Compute the neutral axis slope and intercept for the section. + + Parameters + ---------- + N : float, optional + Axial force (default is 0.0). + Mx : float, optional + Bending moment about the x-axis (default is 0.0). + My : float, optional + Bending moment about the y-axis (default is 0.0). + + Returns + ------- + tuple + Slope of the neutral axis (dy/dx) or None for vertical axis, intercept of the neutral axis in local coordinates. + + Raises + ------ + ValueError + If the neutral axis is undefined for pure axial load. + """ + if Mx == 0 and My == 0: + raise ValueError("Neutral axis is undefined for pure axial load.") + + # Centroid and properties + cx, cy, _ = self.shape.centroid + A = self.A + Ixx = self.Ixx + Iyy = self.Iyy + + # General slope-intercept form for the neutral axis + if Mx == 0: # Vertical neutral axis + slope = None + intercept = cx + (N * Iyy) / (My * A) + elif My == 0: # Horizontal neutral axis + slope = 0.0 + intercept = cy + (N * Ixx) / (Mx * A) + else: + slope = (My / Iyy) / (Mx / Ixx) + intercept = cy - slope * cx + (N * Ixx) / (Mx * A) + + return slope, intercept + + def plot_stress_distribution( + self, N: float = 0.0, Mx: float = 0.0, My: float = 0.0, Vx: float = 0.0, Vy: float = 0.0, nx: int = 50, ny: int = 50, cmap: str = "coolwarm", show_tau: bool = True + ): + """ + Visualize normal stress (\u03c3) and optionally shear stresses (\u03c4_x, \u03c4_y) with the neutral axis. + + Parameters + ---------- + N : float, optional + Axial force (default is 0.0). + Mx : float, optional + Bending moment about the x-axis (default is 0.0). + My : float, optional + Bending moment about the y-axis (default is 0.0). + Vx : float, optional + Shear force in the x-direction (default is 0.0). + Vy : float, optional + Shear force in the y-direction (default is 0.0). + nx : int, optional + Grid resolution in x direction (default is 50). + ny : int, optional + Grid resolution in y direction (default is 50). + cmap : str, optional + Colormap for stress visualization (default is "coolwarm"). + show_tau : bool, optional + Whether to display separate plots for shear stresses (\u03c4_x, \u03c4_y) (default is True). + """ + grid_x, grid_y, grid_sigma, grid_tau_x, grid_tau_y = self.compute_stress_distribution(N=N, Mx=Mx, My=My, Vx=Vx, Vy=Vy, nx=nx, ny=ny) + + # Plot normal stress (\u03c3) + fig_sigma, ax_sigma = plt.subplots(figsize=(6, 6)) + sigma_plot = ax_sigma.pcolormesh( + grid_x, + grid_y, + grid_sigma, + shading="auto", + cmap=cmap, + vmin=-np.nanmax(abs(grid_sigma)), # Symmetrical range for compression/tension + vmax=np.nanmax(abs(grid_sigma)), + ) + cbar_sigma = plt.colorbar(sigma_plot, ax=ax_sigma, fraction=0.046, pad=0.04) + cbar_sigma.set_label("Normal Stress (\u03c3) [N/mm²]", fontsize=9) + + # Add section boundary + verts = [(p.x, p.y) for p in self.shape.points] + xs, ys = zip(*verts + [verts[0]]) # Close the polygon loop + ax_sigma.plot(xs, ys, color="black", linewidth=1.5) + + # Set aspect ratio and axis labels + ax_sigma.set_aspect("equal", "box") + ax_sigma.set_xlabel("X (mm)", fontsize=9) + ax_sigma.set_ylabel("Y (mm)", fontsize=9) + + # Compute and plot the neutral axis on the \u03c3 plot + try: + slope, intercept = self.compute_neutral_axis(N=N, Mx=Mx, My=My) + except ValueError as e: + print(f"Warning: {e}") + slope, intercept = None, None + x_min, x_max = ax_sigma.get_xlim() + y_min, y_max = ax_sigma.get_ylim() + + if slope is None: # Vertical neutral axis + if intercept is not None: + ax_sigma.axvline(intercept, color="red", linestyle="--", linewidth=1.5) + else: # General or horizontal case + x_vals = np.linspace(x_min, x_max, 100) + y_vals = slope * x_vals + intercept + valid = (y_vals >= y_min) & (y_vals <= y_max) + if np.any(valid): + ax_sigma.plot(x_vals[valid], y_vals[valid], color="red", linestyle="--", linewidth=1.5) + + ax_sigma.legend(loc="upper left", fontsize=8) + plt.tight_layout() + plt.show() + + # Plot shear stresses (\u03c4_x and \u03c4_y) if requested + if show_tau: + # \u03c4_x (Shear Stress in X) + fig_tau_x, ax_tau_x = plt.subplots(figsize=(6, 6)) + tau_x_plot = ax_tau_x.pcolormesh( + grid_x, + grid_y, + grid_tau_x, + shading="auto", + cmap=cmap, + vmin=-np.nanmax(abs(grid_tau_x)), # Independent range for tau_x + vmax=np.nanmax(abs(grid_tau_x)), + ) + cbar_tau_x = plt.colorbar(tau_x_plot, ax=ax_tau_x, fraction=0.046, pad=0.04) + cbar_tau_x.set_label("Shear Stress (\u03c4_x) [N/mm²]", fontsize=9) + + ax_tau_x.plot(xs, ys, color="black", linewidth=1.5) + ax_tau_x.set_aspect("equal", "box") + ax_tau_x.set_xlabel("X (mm)", fontsize=9) + ax_tau_x.set_ylabel("Y (mm)", fontsize=9) + + plt.tight_layout() + plt.show() + + # \u03c4_y (Shear Stress in Y) + fig_tau_y, ax_tau_y = plt.subplots(figsize=(6, 6)) + tau_y_plot = ax_tau_y.pcolormesh( + grid_x, + grid_y, + grid_tau_y, + shading="auto", + cmap=cmap, + vmin=-np.nanmax(abs(grid_tau_y)), # Independent range for tau_y + vmax=np.nanmax(abs(grid_tau_y)), + ) + cbar_tau_y = plt.colorbar(tau_y_plot, ax=ax_tau_y, fraction=0.046, pad=0.04) + cbar_tau_y.set_label("Shear Stress (\u03c4_y) [N/mm²]", fontsize=9) + + ax_tau_y.plot(xs, ys, color="black", linewidth=1.5) + ax_tau_y.set_aspect("equal", "box") + ax_tau_y.set_xlabel("X (mm)", fontsize=9) + ax_tau_y.set_ylabel("Y (mm)", fontsize=9) + + plt.tight_layout() + plt.show() + + def plot_section_with_stress( + self, N: float = 0.0, Mx: float = 0.0, My: float = 0.0, Vx: float = 0.0, Vy: float = 0.0, direction: tuple = (1, 0), point: tuple = None, nx: int = 50, ny: int = 50 + ): + """ + Plot the section and overlay the stress distribution along a general direction. + + Parameters + ---------- + N : float, optional + Axial force (default is 0.0). + Mx : float, optional + Bending moment about the x-axis (default is 0.0). + My : float, optional + Bending moment about the y-axis (default is 0.0). + Vx : float, optional + Shear force in the x-direction (default is 0.0). + Vy : float, optional + Shear force in the y-direction (default is 0.0). + direction : tuple, optional + A 2D vector defining the direction of the line (default is (1, 0)). + point : tuple or None, optional + A point on the line (defaults to the section centroid if None). + nx : int, optional + Grid resolution in x direction (default is 50). + ny : int, optional + Grid resolution in y direction (default is 50). + + Raises + ------ + ValueError + If the specified line does not pass through the section. + """ + + # Normalize the direction vector + dx, dy = direction + norm = np.sqrt(dx**2 + dy**2) + dx /= norm + dy /= norm + + # Default point to centroid if not provided + if point is None: + cx, cy, _ = self.shape.centroid + else: + cx, cy = point + + # Compute stress distribution + grid_x, grid_y, grid_sigma, _, _ = self.compute_stress_distribution(N=N, Mx=Mx, My=My, Vx=Vx, Vy=Vy, nx=nx, ny=ny) + + # Extract stress along the specified direction + t_vals = np.linspace(-1, 1, 500) + line_x = cx + t_vals * dx # Line's x-coordinates + line_y = cy + t_vals * dy # Line's y-coordinates + + # Filter points inside the section boundary + path = Path([(p.x, p.y) for p in self.shape.points]) + points_on_line = [(x, y) for x, y in zip(line_x, line_y) if path.contains_point((x, y))] + + if not points_on_line: + raise ValueError("The specified line does not pass through the section.") + + # Extract stress values along the line + stresses = [grid_sigma[np.argmin(np.abs(grid_y[:, 0] - y)), np.argmin(np.abs(grid_x[0, :] - x))] for x, y in points_on_line] + + # Scale the stresses for visualization + max_abs_stress = max(abs(s) for s in stresses) + scaled_stress_x = [max(grid_x.flatten()) + 50 + 50 * (s / max_abs_stress) for s in stresses] + stress_coords = [(sx, y) for (x, y), sx in zip(points_on_line, scaled_stress_x)] + + # Create the plot + fig, ax = plt.subplots(figsize=(8, 6)) + + # Plot the Section Boundary + verts = [(p.x, p.y) for p in self.shape.points] + verts.append(verts[0]) # Close the section boundary + xs, ys = zip(*verts) + ax.plot(xs, ys, color="black", linewidth=1.5, label="Section Boundary") + ax.fill(xs, ys, color="#e6e6e6", alpha=0.8) + + # Plot Stress Profile + poly = mplPolygon(stress_coords + list(reversed(points_on_line)), closed=True, facecolor="#b3d9ff", edgecolor="blue", alpha=0.8) + ax.add_patch(poly) + + # Annotate Stress Values + ax.annotate( + f"{stresses[-1]:.1f} N/mm²", + xy=(scaled_stress_x[-1] + 10, points_on_line[-1][1]), + fontsize=9, + color="blue", + va="center", + ) + ax.annotate( + f"{stresses[0]:.1f} N/mm²", + xy=(scaled_stress_x[0] + 10, points_on_line[0][1]), + fontsize=9, + color="blue", + va="center", + ) + + # Axis Labels and Styling + ax.set_aspect("equal", "box") + ax.set_xlabel("X-axis (mm)", fontsize=9) + ax.set_ylabel("Y-axis (mm)", fontsize=9) + ax.set_title("Section with Stress Distribution Along Specified Direction", fontsize=12) + ax.axhline(0, color="black", linestyle="--", linewidth=1) # x-axis + ax.axvline(0, color="black", linestyle="--", linewidth=1) # y-axis + ax.legend(loc="upper left", fontsize=9) + plt.grid(True) + plt.show() + class GenericBeamSection(BeamSection): - """Generic beam cross-section for beam elements.""" + """ + Generic beam cross-section for beam elements. + + Parameters + ---------- + A : float + Cross section area. + Ixx : float + Inertia with respect to XX axis. + Iyy : float + Inertia with respect to YY axis. + Ixy : float + Inertia with respect to XY axis. + Avx : float + Shear area along x. + Avy : float + Shear area along y. + J : float + Torsion modulus. + g0 : float + Warping constant. + gw : float + Warping constant. + material : :class:`compas_fea2.model._Material` + The section material. + **kwargs : dict, optional + Additional keyword arguments. + """ - def __init__(self, A, Ixx, Iyy, Ixy, Avx, Avy, J, g0, gw, material, **kwargs): - super(GenericBeamSection, self).__init__(A=A, Ixx=Ixx, Iyy=Iyy, Ixy=Ixy, Avx=Avx, Avy=Avy, J=J, g0=g0, gw=gw, material=material, **kwargs) + def __init__(self, A: float, Ixx: float, Iyy: float, Ixy: float, Avx: float, Avy: float, J: float, g0: float, gw: float, material: "_Material", **kwargs): # noqa: F821 + super().__init__(A=A, Ixx=Ixx, Iyy=Iyy, Ixy=Ixy, Avx=Avx, Avy=Avy, J=J, g0=g0, gw=gw, material=material, **kwargs) + self._shape = Circle(radius=sqrt(A / pi)) - self._shape = Circle(radius=sqrt(self.A) / pi) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "g0": self.g0, + "gw": self.gw, + } + ) + return data class AngleSection(BeamSection): - """Uniform thickness angle cross-section for beam elements. + """ + Uniform thickness angle cross-section for beam elements. Parameters ---------- @@ -309,13 +792,14 @@ class AngleSection(BeamSection): Width. h : float Height. - t : float + t1 : float + Thickness. + t2 : float Thickness. - material : :class:`compas_fea2.model.Material` + material : :class:`compas_fea2.model._Material` The section material. - name : str, optional - Section name. If not provided, a unique identifier is automatically - assigned. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -323,75 +807,58 @@ class AngleSection(BeamSection): Width. h : float Height. - t : float + t1 : float + Thickness. + t2 : float Thickness. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - name : str - Section name. If not provided, a unique identifier is automatically - assigned. Warnings -------- - - Ixy not yet calculated. - + Ixy not yet calculated. """ - def __init__(self, w, h, t, material, **kwargs): - self.w = w - self.h = h - self.t = t - - p = 2.0 * (w + h - t) - xc = (w**2 + h * t - t**2) / p - yc = (h**2 + w * t - t**2) / p - - A = t * (w + h - t) - Ixx = (1.0 / 3) * (w * h**3 - (w - t) * (h - t) ** 3) - self.A * (h - yc) ** 2 - Iyy = (1.0 / 3) * (h * w**3 - (h - t) * (w - t) ** 3) - self.A * (w - xc) ** 2 - Ixy = 0 # FIXME - J = (1.0 / 3) * (h + w - t) * t**3 - Avx = 0 # FIXME - Avy = 0 # FIXME - g0 = 0 # FIXME - gw = 0 # FIXME + def __init__(self, w, h, t1, t2, material, **kwargs): + self._shape = LShape(w, h, t1, t2) + super().__init__(**from_shape(self._shape, material, **kwargs)) - super(AngleSection, self).__init__( - A=A, - Ixx=Ixx, - Iyy=Iyy, - Ixy=Ixy, - Avx=Avx, - Avy=Avy, - J=J, - g0=g0, - gw=gw, - material=material, - **kwargs, + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w": self._shape.w, + "h": self._shape.h, + "t1": self._shape.t1, + "t2": self._shape.t2, + } ) + return data -# TODO implement different thickness along the 4 sides +# FIXME: implement 'from_shape' method class BoxSection(BeamSection): - """Hollow rectangular box cross-section for beam elements. + """ + Hollow rectangular box cross-section for beam elements. Parameters ---------- @@ -405,6 +872,8 @@ class BoxSection(BeamSection): Flange thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -417,23 +886,23 @@ class BoxSection(BeamSection): tf : float Flange thickness. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. @@ -443,8 +912,7 @@ class BoxSection(BeamSection): Warnings -------- - - Ixy not yet calculated. - + Ixy not yet calculated. """ def __init__(self, w, h, tw, tf, material, **kwargs): @@ -480,9 +948,23 @@ def __init__(self, w, h, tw, tf, material, **kwargs): **kwargs, ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w": self.w, + "h": self.h, + "tw": self.tw, + "tf": self.tf, + } + ) + return data + class CircularSection(BeamSection): - """Solid circular cross-section for beam elements. + """ + Solid circular cross-section for beam elements. Parameters ---------- @@ -490,110 +972,117 @@ class CircularSection(BeamSection): Radius. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- r : float Radius. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, r, material, **kwargs): - self.r = r - - D = 2 * r - A = 0.25 * pi * D**2 - Ixx = Iyy = (pi * D**4) / 64.0 - Ixy = 0 - Avx = 0 - Avy = 0 - J = (pi * D**4) / 32 - g0 = 0 - gw = 0 + self._shape = Circle(r, 360) + super().__init__(**from_shape(self._shape, material, **kwargs)) - super(CircularSection, self).__init__( - A=A, - Ixx=Ixx, - Iyy=Iyy, - Ixy=Ixy, - Avx=Avx, - Avy=Avy, - J=J, - g0=g0, - gw=gw, - material=material, - **kwargs, + @property + def __data__(self): + data = super().__data__ + data.update( + { + "r": self._shape.radius, + } ) - self._shape = Circle(radius=r) + return data +# FIXME: implement 'from_shape' method class HexSection(BeamSection): - """Hexagonal hollow section. + """ + Hexagonal hollow section. Parameters ---------- r : float - outside radius + Outside radius. t : float - wall thickness - material : str - material name to be assigned to the section. + Wall thickness. + material : :class:`compas_fea2.model._Material` + The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- r : float - outside radius + Outside radius. t : float - wall thickness + Wall thickness. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. + Raises + ------ + NotImplementedError + If the section is not available for the selected backend. """ def __init__(self, r, t, material, **kwargs): raise NotImplementedError("This section is not available for the selected backend") + @property + def __data__(self): + data = super().__data__ + data.update( + { + "r": self.r, + "t": self.t, + } + ) + return data + class ISection(BeamSection): - """Equal flanged I-section for beam elements. + """ + Equal flanged I-section for beam elements. Parameters ---------- @@ -603,10 +1092,14 @@ class ISection(BeamSection): Height. tw : float Web thickness. - tf : float - Flange thickness. + tbf : float + Bottom flange thickness. + ttf : float + Top flange thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -616,42 +1109,405 @@ class ISection(BeamSection): Height. tw : float Web thickness. - tf : float - Flange thickness. + tbf : float + Bottom flange thickness. + ttf : float + Top flange thickness. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - - Notes - ----- - Currently you the thickness of the two flanges is the same. - """ - def __init__(self, w, h, tw, tf, material, **kwargs): - self._shape = IShape(w, h, tw, tf, tf) + def __init__(self, w, h, tw, tbf, ttf, material, **kwargs): + self._shape = IShape(w, h, tw, tbf, ttf) super().__init__(**from_shape(self._shape, material, **kwargs)) + @property + def k(self): + return 0.3 + 0.1 * ((self.shape.abf + self.shape.atf) / self.shape.area) + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w": self._shape.w, + "h": self._shape.h, + "tw": self._shape.tw, + "tbf": self._shape.tbf, + "ttf": self._shape.ttf, + } + ) + return data + + @classmethod + def IPE80(cls, material, **kwargs): + return cls(w=46, h=80, tw=3.8, tbf=6.1, ttf=6.1, material=material, **kwargs) + + @classmethod + def IPE100(cls, material, **kwargs): + return cls(w=55, h=100, tw=4.1, tbf=6.7, ttf=6.7, material=material, **kwargs) + + @classmethod + def IPE120(cls, material, **kwargs): + return cls(w=64, h=120, tw=4.4, tbf=7.2, ttf=7.2, material=material, **kwargs) + + @classmethod + def IPE140(cls, material, **kwargs): + return cls(w=73, h=140, tw=4.7, tbf=7.5, ttf=7.5, material=material, **kwargs) + + @classmethod + def IPE160(cls, material, **kwargs): + return cls(w=82, h=160, tw=5, tbf=7.4, ttf=7.4, material=material, **kwargs) + + @classmethod + def IPE180(cls, material, **kwargs): + return cls(w=91, h=180, tw=5.3, tbf=8, ttf=8, material=material, **kwargs) + + @classmethod + def IPE200(cls, material, **kwargs): + return cls(w=100, h=200, tw=5.6, tbf=8.5, ttf=8.5, material=material, **kwargs) + + @classmethod + def IPE220(cls, material, **kwargs): + return cls(w=110, h=220, tw=5.9, tbf=9.2, ttf=9.2, material=material, **kwargs) + + @classmethod + def IPE240(cls, material, **kwargs): + return cls(w=120, h=240, tw=6.2, tbf=9.8, ttf=9.8, material=material, **kwargs) + + @classmethod + def IPE270(cls, material, **kwargs): + return cls(w=135, h=270, tw=6.6, tbf=10.2, ttf=10.2, material=material, **kwargs) + + @classmethod + def IPE300(cls, material, **kwargs): + return cls(w=150, h=300, tw=7.1, tbf=10.7, ttf=10.7, material=material, **kwargs) + + @classmethod + def IPE330(cls, material, **kwargs): + return cls(w=160, h=330, tw=7.5, tbf=11.5, ttf=11.5, material=material, **kwargs) + + @classmethod + def IPE360(cls, material, **kwargs): + return cls(w=170, h=360, tw=8, tbf=12.7, ttf=12.7, material=material, **kwargs) + + @classmethod + def IPE400(cls, material, **kwargs): + return cls(w=180, h=400, tw=8.6, tbf=13.5, ttf=13.5, material=material, **kwargs) + + # HEA Sections + @classmethod + def HEA100(cls, material, **kwargs): + return cls(w=100, h=96, tw=5, tbf=8, ttf=8, material=material, **kwargs) + + @classmethod + def HEA120(cls, material, **kwargs): + return cls(w=120, h=114, tw=5, tbf=8, ttf=8, material=material, **kwargs) + + @classmethod + def HEA140(cls, material, **kwargs): + return cls(w=140, h=133, tw=5.5, tbf=8.5, ttf=8.5, material=material, **kwargs) + + @classmethod + def HEA160(cls, material, **kwargs): + return cls(w=160, h=152, tw=6, tbf=9, ttf=9, material=material, **kwargs) + + @classmethod + def HEA180(cls, material, **kwargs): + return cls(w=180, h=171, tw=6, tbf=9.5, ttf=9.5, material=material, **kwargs) + + @classmethod + def HEA200(cls, material, **kwargs): + return cls(w=200, h=190, tw=6.5, tbf=10, ttf=10, material=material, **kwargs) + + @classmethod + def HEA220(cls, material, **kwargs): + return cls(w=220, h=210, tw=7, tbf=11, ttf=11, material=material, **kwargs) + + @classmethod + def HEA240(cls, material, **kwargs): + return cls(w=240, h=230, tw=7.5, tbf=12, ttf=12, material=material, **kwargs) + + @classmethod + def HEA260(cls, material, **kwargs): + return cls(w=260, h=250, tw=7.5, tbf=12.5, ttf=12.5, material=material, **kwargs) + + @classmethod + def HEA280(cls, material, **kwargs): + return cls(w=280, h=270, tw=8, tbf=13, ttf=13, material=material, **kwargs) + + @classmethod + def HEA300(cls, material, **kwargs): + return cls(w=300, h=290, tw=9, tbf=14, ttf=14, material=material, **kwargs) + + @classmethod + def HEA320(cls, material, **kwargs): + return cls(w=320, h=310, tw=9.5, tbf=15, ttf=15, material=material, **kwargs) + + @classmethod + def HEA340(cls, material, **kwargs): + return cls(w=340, h=330, tw=10, tbf=16, ttf=16, material=material, **kwargs) + + @classmethod + def HEA360(cls, material, **kwargs): + return cls(w=360, h=350, tw=10, tbf=17, ttf=17, material=material, **kwargs) + + @classmethod + def HEA400(cls, material, **kwargs): + return cls(w=400, h=390, tw=11.5, tbf=18, ttf=18, material=material, **kwargs) + + @classmethod + def HEA450(cls, material, **kwargs): + return cls(w=450, h=440, tw=12.5, tbf=19, ttf=19, material=material, **kwargs) + + @classmethod + def HEA500(cls, material, **kwargs): + return cls(w=500, h=490, tw=14, tbf=21, ttf=21, material=material, **kwargs) + + @classmethod + def HEA550(cls, material, **kwargs): + return cls(w=550, h=540, tw=15, tbf=24, ttf=24, material=material, **kwargs) + + @classmethod + def HEA600(cls, material, **kwargs): + return cls(w=600, h=590, tw=15.5, tbf=25, ttf=25, material=material, **kwargs) + + @classmethod + def HEA650(cls, material, **kwargs): + return cls(w=650, h=640, tw=16, tbf=26, ttf=26, material=material, **kwargs) + + @classmethod + def HEA700(cls, material, **kwargs): + return cls(w=700, h=690, tw=16.5, tbf=27, ttf=27, material=material, **kwargs) + + @classmethod + def HEA800(cls, material, **kwargs): + return cls(w=800, h=790, tw=17.5, tbf=28, ttf=28, material=material, **kwargs) + + @classmethod + def HEA900(cls, material, **kwargs): + return cls(w=900, h=890, tw=18, tbf=29, ttf=29, material=material, **kwargs) + + @classmethod + def HEA1000(cls, material, **kwargs): + return cls(w=1000, h=990, tw=19, tbf=30, ttf=30, material=material, **kwargs) + + # HEB Sections + @classmethod + def HEB100(cls, material, **kwargs): + return cls(w=100, h=100, tw=6, tbf=10, ttf=10, material=material, **kwargs) + + @classmethod + def HEB120(cls, material, **kwargs): + return cls(w=120, h=120, tw=6.5, tbf=11, ttf=11, material=material, **kwargs) + + @classmethod + def HEB140(cls, material, **kwargs): + return cls(w=140, h=140, tw=7, tbf=12, ttf=12, material=material, **kwargs) + + @classmethod + def HEB160(cls, material, **kwargs): + return cls(w=160, h=160, tw=8, tbf=13, ttf=13, material=material, **kwargs) + + @classmethod + def HEB180(cls, material, **kwargs): + return cls(w=180, h=180, tw=8.5, tbf=14, ttf=14, material=material, **kwargs) + + @classmethod + def HEB200(cls, material, **kwargs): + return cls(w=200, h=200, tw=9, tbf=15, ttf=15, material=material, **kwargs) + + @classmethod + def HEB220(cls, material, **kwargs): + return cls(w=220, h=220, tw=9.5, tbf=16, ttf=16, material=material, **kwargs) + + @classmethod + def HEB240(cls, material, **kwargs): + return cls(w=240, h=240, tw=10, tbf=17, ttf=17, material=material, **kwargs) + + @classmethod + def HEB260(cls, material, **kwargs): + return cls(w=260, h=260, tw=10, tbf=17.5, ttf=17.5, material=material, **kwargs) + + @classmethod + def HEB280(cls, material, **kwargs): + return cls(w=280, h=280, tw=10.5, tbf=18, ttf=18, material=material, **kwargs) + + @classmethod + def HEB300(cls, material, **kwargs): + return cls(w=300, h=300, tw=11, tbf=19, ttf=19, material=material, **kwargs) + + @classmethod + def HEB320(cls, material, **kwargs): + return cls(w=320, h=320, tw=11.5, tbf=20, ttf=20, material=material, **kwargs) + + @classmethod + def HEB340(cls, material, **kwargs): + return cls(w=340, h=340, tw=12, tbf=21, ttf=21, material=material, **kwargs) + + @classmethod + def HEB360(cls, material, **kwargs): + return cls(w=360, h=360, tw=12.5, tbf=22, ttf=22, material=material, **kwargs) + + @classmethod + def HEB400(cls, material, **kwargs): + return cls(w=400, h=400, tw=13.5, tbf=23, ttf=23, material=material, **kwargs) + + @classmethod + def HEB450(cls, material, **kwargs): + return cls(w=450, h=450, tw=14, tbf=24, ttf=24, material=material, **kwargs) + + @classmethod + def HEB500(cls, material, **kwargs): + return cls(w=500, h=500, tw=14.5, tbf=25, ttf=25, material=material, **kwargs) + + @classmethod + def HEB550(cls, material, **kwargs): + return cls(w=550, h=550, tw=15, tbf=26, ttf=26, material=material, **kwargs) + + @classmethod + def HEB600(cls, material, **kwargs): + return cls(w=600, h=600, tw=15.5, tbf=27, ttf=27, material=material, **kwargs) + + @classmethod + def HEB650(cls, material, **kwargs): + return cls(w=650, h=650, tw=16, tbf=28, ttf=28, material=material, **kwargs) + + @classmethod + def HEB700(cls, material, **kwargs): + return cls(w=700, h=700, tw=16.5, tbf=29, ttf=29, material=material, **kwargs) + + @classmethod + def HEB800(cls, material, **kwargs): + return cls(w=800, h=800, tw=17.5, tbf=30, ttf=30, material=material, **kwargs) + + @classmethod + def HEB900(cls, material, **kwargs): + return cls(w=900, h=900, tw=18, tbf=31, ttf=31, material=material, **kwargs) + + @classmethod + def HEB1000(cls, material, **kwargs): + return cls(w=1000, h=1000, tw=19, tbf=32, ttf=32, material=material, **kwargs) + + # HEM Sections + @classmethod + def HEM100(cls, material, **kwargs): + return cls(w=120, h=106, tw=12, tbf=20, ttf=20, material=material, **kwargs) + + @classmethod + def HEM120(cls, material, **kwargs): + return cls(w=140, h=126, tw=12, tbf=21, ttf=21, material=material, **kwargs) + + @classmethod + def HEM140(cls, material, **kwargs): + return cls(w=160, h=146, tw=12, tbf=22, ttf=22, material=material, **kwargs) + + @classmethod + def HEM160(cls, material, **kwargs): + return cls(w=180, h=166, tw=13, tbf=23, ttf=23, material=material, **kwargs) + + @classmethod + def HEM180(cls, material, **kwargs): + return cls(w=200, h=186, tw=14, tbf=24, ttf=24, material=material, **kwargs) + + @classmethod + def HEM200(cls, material, **kwargs): + return cls(w=220, h=206, tw=15, tbf=25, ttf=25, material=material, **kwargs) + + @classmethod + def HEM220(cls, material, **kwargs): + return cls(w=240, h=226, tw=16, tbf=26, ttf=26, material=material, **kwargs) + + @classmethod + def HEM240(cls, material, **kwargs): + return cls(w=260, h=246, tw=17, tbf=27, ttf=27, material=material, **kwargs) + + @classmethod + def HEM260(cls, material, **kwargs): + return cls(w=280, h=266, tw=18, tbf=28, ttf=28, material=material, **kwargs) + + @classmethod + def HEM280(cls, material, **kwargs): + return cls(w=300, h=286, tw=19, tbf=29, ttf=29, material=material, **kwargs) + + @classmethod + def HEM300(cls, material, **kwargs): + return cls(w=320, h=306, tw=20, tbf=30, ttf=30, material=material, **kwargs) + + @classmethod + def HEM320(cls, material, **kwargs): + return cls(w=340, h=326, tw=21, tbf=31, ttf=31, material=material, **kwargs) + + @classmethod + def HEM340(cls, material, **kwargs): + return cls(w=360, h=346, tw=22, tbf=32, ttf=32, material=material, **kwargs) + + @classmethod + def HEM360(cls, material, **kwargs): + return cls(w=380, h=366, tw=23, tbf=33, ttf=33, material=material, **kwargs) + + @classmethod + def HEM400(cls, material, **kwargs): + return cls(w=400, h=396, tw=24, tbf=34, ttf=34, material=material, **kwargs) + + @classmethod + def HEM450(cls, material, **kwargs): + return cls(w=450, h=446, tw=25, tbf=35, ttf=35, material=material, **kwargs) + + @classmethod + def HEM500(cls, material, **kwargs): + return cls(w=500, h=496, tw=26, tbf=36, ttf=36, material=material, **kwargs) + + @classmethod + def HEM550(cls, material, **kwargs): + return cls(w=550, h=546, tw=27, tbf=37, ttf=37, material=material, **kwargs) + + @classmethod + def HEM600(cls, material, **kwargs): + return cls(w=600, h=596, tw=28, tbf=38, ttf=38, material=material, **kwargs) + + @classmethod + def HEM650(cls, material, **kwargs): + return cls(w=650, h=646, tw=29, tbf=39, ttf=39, material=material, **kwargs) + + @classmethod + def HEM700(cls, material, **kwargs): + return cls(w=700, h=696, tw=30, tbf=40, ttf=40, material=material, **kwargs) + + @classmethod + def HEM800(cls, material, **kwargs): + return cls(w=800, h=796, tw=31, tbf=41, ttf=41, material=material, **kwargs) + + @classmethod + def HEM900(cls, material, **kwargs): + return cls(w=900, h=896, tw=32, tbf=42, ttf=42, material=material, **kwargs) + + @classmethod + def HEM1000(cls, material, **kwargs): + return cls(w=1000, h=996, tw=33, tbf=43, ttf=43, material=material, **kwargs) + class PipeSection(BeamSection): - """Hollow circular cross-section for beam elements. + """ + Hollow circular cross-section for beam elements. Parameters ---------- @@ -661,6 +1517,8 @@ class PipeSection(BeamSection): Wall thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -669,26 +1527,25 @@ class PipeSection(BeamSection): t : float Wall thickness. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, r, t, material, **kwargs): @@ -720,9 +1577,21 @@ def __init__(self, r, t, material, **kwargs): **kwargs, ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "r": self.r, + "t": self.t, + } + ) + return data + class RectangularSection(BeamSection): - """Solid rectangular cross-section for beam elements. + """ + Solid rectangular cross-section for beam elements. Parameters ---------- @@ -732,6 +1601,8 @@ class RectangularSection(BeamSection): Height. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -740,35 +1611,47 @@ class RectangularSection(BeamSection): h : float Height. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, w, h, material, **kwargs): self._shape = Rectangle(w, h) super().__init__(**from_shape(self._shape, material, **kwargs)) + self.k = 5 / 6 + + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w": self._shape.w, + "h": self._shape.h, + } + ) + return data class TrapezoidalSection(BeamSection): - """Solid trapezoidal cross-section for beam elements. + """ + Solid trapezoidal cross-section for beam elements. Parameters ---------- @@ -780,6 +1663,8 @@ class TrapezoidalSection(BeamSection): Height. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -790,30 +1675,29 @@ class TrapezoidalSection(BeamSection): h : float Height. A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. Warnings -------- - - J not yet calculated. - + J not yet calculated. """ def __init__(self, w1, w2, h, material, **kwargs): @@ -847,6 +1731,18 @@ def __init__(self, w1, w2, h, material, **kwargs): **kwargs, ) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "w1": self.w1, + "w2": self.w2, + "h": self.h, + } + ) + return data + # ============================================================================== # 1D - no cross-section @@ -854,7 +1750,8 @@ def __init__(self, w1, w2, h, material, **kwargs): class TrussSection(BeamSection): - """For use with truss elements. + """ + For use with truss elements. Parameters ---------- @@ -862,30 +1759,31 @@ class TrussSection(BeamSection): Area. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, A, material, **kwargs): @@ -912,9 +1810,20 @@ def __init__(self, A, material, **kwargs): ) self._shape = Circle(radius=sqrt(A) / pi, segments=16) + @property + def __data__(self): + data = super().__data__ + data.update( + { + "A": self.A, + } + ) + return data + class StrutSection(TrussSection): - """For use with strut elements. + """ + For use with strut elements. Parameters ---------- @@ -922,30 +1831,31 @@ class StrutSection(TrussSection): Area. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, A, material, **kwargs): @@ -953,7 +1863,8 @@ def __init__(self, A, material, **kwargs): class TieSection(TrussSection): - """For use with tie elements. + """ + For use with tie elements. Parameters ---------- @@ -961,30 +1872,31 @@ class TieSection(TrussSection): Area. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- A : float - Cross section. + Cross section area. Ixx : float - Inertia wrt XX. + Inertia with respect to XX axis. Iyy : float - Inertia wrt YY. + Inertia with respect to YY axis. Ixy : float - Inertia wrt XY. + Inertia with respect to XY axis. Avx : float Shear area along x. Avy : float - Shear area along y + Shear area along y. J : float Torsion modulus. g0 : float - ??? + Warping constant. gw : float - ??? + Warping constant. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, A, material, **kwargs): @@ -997,7 +1909,8 @@ def __init__(self, A, material, **kwargs): class ShellSection(_Section): - """Section for shell elements. + """ + Section for shell elements. Parameters ---------- @@ -1005,6 +1918,8 @@ class ShellSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -1012,16 +1927,26 @@ class ShellSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, t, material, **kwargs): super(ShellSection, self).__init__(material=material, **kwargs) self.t = t + @property + def __data__(self): + data = super().__data__ + data.update( + { + "t": self.t, + } + ) + return data + class MembraneSection(_Section): - """Section for membrane elements. + """ + Section for membrane elements. Parameters ---------- @@ -1029,6 +1954,8 @@ class MembraneSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- @@ -1036,13 +1963,22 @@ class MembraneSection(_Section): Thickness. material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, t, material, **kwargs): super(MembraneSection, self).__init__(material=material, **kwargs) self.t = t + @property + def __data__(self): + data = super().__data__ + data.update( + { + "t": self.t, + } + ) + return data + # ============================================================================== # 3D @@ -1050,18 +1986,20 @@ def __init__(self, t, material, **kwargs): class SolidSection(_Section): - """Section for solid elements. + """ + Section for solid elements. Parameters ---------- material : :class:`compas_fea2.model._Material` The section material. + **kwargs : dict, optional + Additional keyword arguments. Attributes ---------- material : :class:`compas_fea2.model._Material` The section material. - """ def __init__(self, material, **kwargs): diff --git a/src/compas_fea2/model/shapes.py b/src/compas_fea2/model/shapes.py index ed1140dd5..2e496df53 100644 --- a/src/compas_fea2/model/shapes.py +++ b/src/compas_fea2/model/shapes.py @@ -1,8 +1,14 @@ +import math +from functools import cached_property from math import atan2 from math import degrees from math import pi from math import sqrt +from typing import List +from typing import Optional +from typing import Tuple +import matplotlib.pyplot as plt import numpy as np from compas.datastructures import Mesh from compas.geometry import Frame @@ -11,221 +17,289 @@ from compas.geometry import Rotation from compas.geometry import Transformation from compas.geometry import Translation +from matplotlib.lines import Line2D +from matplotlib.patches import Polygon as MplPolygon from compas_fea2.base import FEAData class Shape(Polygon, FEAData): - def __init__(self, points, frame=None, check_planarity=True): + """ + A base class representing a planar polygonal shape for FEA, + providing computed properties like area, centroid, and inertia. + + This class: + - Inherits from `compas.geometry.Polygon` for geometric functionality. + - Implements FEAData to integrate with compas_fea2. + - Maintains its own local frame and transformations. + - Caches computed properties (area, centroid, inertia, etc.) to avoid + repeated recalculations. + + References: + - NASA: https://www.grc.nasa.gov/www/k-12/airplane/areas.html + - Wikipedia: https://en.wikipedia.org/wiki/Second_moment_of_area + """ + + def __init__(self, points: List[Point], frame: Optional[Frame] = None, check_planarity: bool = True): super().__init__(points) if not self.is_planar and check_planarity: - raise ValueError("The points must belong to the same plane") + raise ValueError("The points must lie in the same plane.") + + # Store local frame, default = worldXY self._frame = frame or Frame.worldXY() + + # Transformation from local frame to world XY self._T = Transformation.from_frame_to_frame(self._frame, Frame.worldXY()) - self._J = None - self._g0 = None - self._gw = None - self._Avx = None - self._Avy = None - def __str__(self): + def __str__(self) -> str: return f""" type: {self.__class__.__name__} number of points: {len(self._points)} - number of edges: {len(self._points)} # Assuming closed polygon + number of edges: {len(self._points)} # (closed polygon) """ - # ========================================================================== + @property + def __data__(self) -> dict: + """Return a dictionary representation of the shape.""" + return { + "class": self.__class__.__name__, + "points": [point.__data__ for point in self.points], + "frame": self.frame.__data__, + } + + @classmethod + def __from_data__(cls, data: dict) -> "Shape": + """Create a shape instance from a dictionary representation.""" + points = [Point.__from_data__(pt) for pt in data["points"]] + frame = Frame.__from_data__(data["frame"]) + return cls(points, frame) + + # -------------------------------------------------------------------------- # Properties - # ========================================================================== + # -------------------------------------------------------------------------- + @cached_property + def area(self) -> float: + """Area of the shape in the local plane (inherited from `Polygon`).""" + return super().area + @property - def points_xy(self): + def A(self) -> float: + """Alias for area.""" + return self.area + + @cached_property + def points_xy(self) -> List[Point]: """Coordinates of the polygon’s points in the worldXY frame.""" return [p.transformed(self._T) for p in self.points] @property - def A(self): - """Area of the shape.""" - return self.area + def xy_arrays(self) -> Tuple[List[float], List[float]]: + """ + Convenience arrays for X and Y of the shape's points in worldXY, + appending the first point again to close the loop. + """ + x_vals = [pt.x for pt in self.points_xy] + y_vals = [pt.y for pt in self.points_xy] + # append the first point to close + x_vals.append(x_vals[0]) + y_vals.append(y_vals[0]) + return x_vals, y_vals + + @cached_property + def centroid_xy(self) -> Point: + """ + Centroid of the polygon in the worldXY plane. - @property - def centroid_xy(self): - """Compute the centroid in the worldXY plane.""" - sx = sy = 0 + Formula reference (polygon centroid): + - NASA: https://www.grc.nasa.gov/www/k-12/airplane/areas.html + """ x, y = self.xy_arrays n = len(self.points_xy) + sx = sy = 0.0 for i in range(n): j = (i + 1) % n - common_factor = x[i] * y[j] - x[j] * y[i] - sx += (x[i] + x[j]) * common_factor - sy += (y[i] + y[j]) * common_factor - factor = 1 / (6 * self.area) + cross = x[i] * y[j] - x[j] * y[i] + sx += (x[i] + x[j]) * cross + sy += (y[i] + y[j]) * cross + factor = 1.0 / (6.0 * self.area) return Point(sx * factor, sy * factor, 0.0) @property - def xy_arrays(self): - """Convenience arrays for X and Y of the shape points in worldXY.""" - x = [c[0] for c in self.points_xy] - x.append(self.points_xy[0][0]) - y = [c[1] for c in self.points_xy] - y.append(self.points_xy[0][1]) - return x, y - - @property - def centroid(self): - """Centroid in the shape’s local frame (undoing T).""" + def centroid(self) -> Point: + """Centroid in the shape’s local frame (undoing `self._T`).""" return self.centroid_xy.transformed(self._T.inverted()) @property - def frame(self): - """Shape’s local frame (compas Frame object).""" + def frame(self) -> Frame: + """Shape’s local frame.""" return self._frame + # -------------------------------------------------------------------------- + # Second moment of area (inertia) and related + # -------------------------------------------------------------------------- + @cached_property + def inertia_xy(self) -> Tuple[float, float, float]: + """ + (Ixx, Iyy, Ixy) about the centroid in the local x-y plane (units: length^4). + + Formula reference (polygon second moments): + - NASA: https://www.grc.nasa.gov/www/k-12/airplane/areas.html + """ + x, y = self.xy_arrays + n = len(self.points) + sum_x = sum_y = sum_xy = 0.0 + + for i in range(n): + j = (i + 1) % n + a = x[i] * y[j] - x[j] * y[i] + sum_x += (y[i] ** 2 + y[i] * y[j] + y[j] ** 2) * a + sum_y += (x[i] ** 2 + x[i] * x[j] + x[j] ** 2) * a + sum_xy += (x[i] * y[j] + 2 * x[i] * y[i] + 2 * x[j] * y[j] + x[j] * y[i]) * a + + area = self.area + cx, cy, _ = self.centroid_xy + factor = 1.0 / 12.0 + + Ixx = sum_x * factor - area * (cy**2) + Iyy = sum_y * factor - area * (cx**2) + Ixy = (sum_xy / 24.0) - area * cx * cy + return (Ixx, Iyy, Ixy) + @property - def Ixx(self): - """Moment of inertia about the local x-axis (through centroid).""" + def Ixx(self) -> float: + """Moment of inertia about local x-axis (through centroid).""" return self.inertia_xy[0] @property - def rx(self): - """Radius of inertia w.r.t. the local x-axis (through centroid).""" - return self.radii[0] + def Iyy(self) -> float: + """Moment of inertia about local y-axis (through centroid).""" + return self.inertia_xy[1] @property - def Iyy(self): - """Moment of inertia about the local y-axis (through centroid).""" - return self.inertia_xy[1] + def Ixy(self) -> float: + """Product of inertia about local x and y axes (through centroid).""" + return self.inertia_xy[2] + + @cached_property + def radii(self) -> Tuple[float, float]: + """Radii of gyration about local x and y axes.""" + Ixx, Iyy, _ = self.inertia_xy + return (sqrt(Ixx / self.area), sqrt(Iyy / self.area)) @property - def ry(self): - """Radius of inertia w.r.t. the local y-axis (through centroid).""" - return self.radii[1] + def rx(self) -> float: + """Radius of gyration about local x-axis.""" + return self.radii[0] @property - def Ixy(self): - """Product of inertia w.r.t. local x and y axes.""" - return self.inertia_xy[2] + def ry(self) -> float: + """Radius of gyration about local y-axis.""" + return self.radii[1] + + @cached_property + def principal(self) -> Tuple[float, float, float]: + """ + (I1, I2, theta): principal moments of inertia and + the orientation of the principal axis (theta) from x-axis to I1. + + Angle sign convention: rotation from x-axis toward y-axis is positive. + """ + Ixx, Iyy, Ixy = self.inertia_xy + avg = 0.5 * (Ixx + Iyy) + diff = 0.5 * (Ixx - Iyy) + # angle + theta = 0.5 * atan2(-Ixy, diff) + # principal values + radius = math.sqrt(diff**2 + Ixy**2) + I1 = avg + radius # TODO: check this + I2 = avg - radius + return (I1, I2, theta) @property - def I1(self): + def I1(self) -> float: """First principal moment of inertia.""" return self.principal[0] @property - def I2(self): + def I2(self) -> float: """Second principal moment of inertia.""" return self.principal[1] @property - def theta(self): + def theta(self) -> float: """ - Angle (in radians) between the first principal inertia axis and the local x-axis. - Positive angle indicates rotation from x-axis towards y-axis. + Angle (radians) between the local x-axis and the axis of I1. + Positive angle: rotation from x-axis toward y-axis. """ return self.principal[2] + @cached_property + def principal_radii(self) -> Tuple[float, float]: + """Radii of gyration about the principal axes.""" + I1, I2, _ = self.principal + return (sqrt(I1 / self.area), sqrt(I2 / self.area)) + @property - def r1(self): - """Radius of inertia w.r.t. the 1st principal axis.""" + def r1(self) -> float: + """Radius of gyration about the first principal axis.""" return self.principal_radii[0] @property - def r2(self): - """Radius of inertia w.r.t. the 2nd principal axis.""" + def r2(self) -> float: + """Radius of gyration about the second principal axis.""" return self.principal_radii[1] + # -------------------------------------------------------------------------- + # Optional FEA properties + # -------------------------------------------------------------------------- @property - def Avx(self): - """Shear area in the x-direction.""" - return self._Avx + def Avx(self) -> Optional[float]: + """Shear area in the x-direction (if defined).""" + raise NotImplementedError() @property - def Avy(self): - """Shear area in the y-direction.""" - return self._Avy - - @property - def g0(self): - """Shear modulus in the x-y plane.""" - return self._g0 - - @property - def gw(self): - """Shear modulus in the x-z plane.""" - return self._gw + def Avy(self) -> Optional[float]: + """Shear area in the y-direction (if defined).""" + raise NotImplementedError() @property def J(self): - """Torsional constant.""" - return self._J + """Torsional constant (polar moment of inertia).""" + raise NotImplementedError() - # ========================================================================== + # -------------------------------------------------------------------------- # Methods - # ========================================================================== - @property - def inertia_xy(self): - """Compute the moments and product of inertia about the centroid (local x, y).""" - x, y = self.xy_arrays - n = len(self.points) - sum_x = sum_y = sum_xy = 0.0 - for i in range(n): - j = (i + 1) % n - a = x[i] * y[j] - x[j] * y[i] - sum_x += (y[i] ** 2 + y[i] * y[j] + y[j] ** 2) * a - sum_y += (x[i] ** 2 + x[i] * x[j] + x[j] ** 2) * a - sum_xy += (x[i] * y[j] + 2 * x[i] * y[i] + 2 * x[j] * y[j] + x[j] * y[i]) * a - area = self.area - cx, cy, _ = self.centroid_xy - factor = 1 / 12 - Ixx = sum_x * factor - area * cy**2 - Iyy = sum_y * factor - area * cx**2 - Ixy = (sum_xy / 24) - area * cx * cy - return (Ixx, Iyy, Ixy) - - @property - def radii(self): - """Compute the radii of inertia w.r.t. local x and y axes.""" - Ixx, Iyy, _ = self.inertia_xy - return (sqrt(Ixx / self.area), sqrt(Iyy / self.area)) - - @property - def principal_radii(self): - """Compute the radii of inertia w.r.t. the principal axes.""" - I1, I2, _ = self.principal - return (sqrt(I1 / self.area), sqrt(I2 / self.area)) - - @property - def principal(self): - """ - Compute the principal moments of inertia and the orientation (theta) - of the principal axes. - Returns (I1, I2, theta). + # -------------------------------------------------------------------------- + def translated(self, vector, check_planarity: bool = True) -> "Shape": """ - Ixx, Iyy, Ixy = self.inertia_xy - avg = (Ixx + Iyy) / 2 - diff = (Ixx - Iyy) / 2 - theta = atan2(-Ixy, diff) / 2 - radius = sqrt(diff**2 + Ixy**2) - I1 = avg + radius - I2 = avg - radius - return (I1, I2, theta) + Return a translated copy of the shape. + The new shape will have an updated frame. - def translated(self, vector, check_planarity=True): - """Return a translated copy of the shape.""" + Args: + vector: A translation vector. + check_planarity: Whether to verify planarity of new shape. + """ T = Translation.from_vector(vector) new_frame = Frame.from_transformation(T) - return Shape([point.transformed(T) for point in self._points], new_frame, check_planarity=check_planarity) - - def oriented(self, frame, check_planarity=True): - """Return a shape oriented to a new frame.""" - from math import pi + new_points = [pt.transformed(T) for pt in self._points] + return Shape(new_points, new_frame, check_planarity=check_planarity) - T = Transformation.from_frame_to_frame(self._frame, frame) * Rotation.from_axis_and_angle([1, 0, 0], pi / 2) - return Shape([point.transformed(T) for point in self._points], frame, check_planarity=check_planarity) + def oriented(self, frame: Frame, check_planarity: bool = True) -> "Shape": + """ + Return a shape oriented to a new frame. + Example: flipping or rotating the shape by a custom orientation. + """ + rot = Rotation.from_axis_and_angle([1, 0, 0], pi / 2) + T = Transformation.from_frame_to_frame(self._frame, frame) * rot + new_points = [pt.transformed(T) for pt in self._points] + return Shape(new_points, frame, check_planarity=check_planarity) - def summary(self): - """Provide a text summary of cross-sectional properties.""" - props = ( + def summary(self) -> str: + """ + Provide a text summary of cross-sectional properties. + Rounds the values to 2 decimals for convenience. + """ + props = [ self.A, self.centroid[0], self.centroid[1], @@ -239,10 +313,9 @@ def summary(self): self.r1, self.r2, degrees(self.theta), - ) + ] props = [round(prop, 2) for prop in props] - - summ = f""" + return f""" Area A = {props[0]} @@ -264,73 +337,403 @@ def summary(self): r2 = {props[11]} θ = {props[12]}° """ - return summ - def to_mesh(self): - """Convert the shape to a mesh.""" - vertices = [point for point in self.points] + def to_mesh(self) -> Mesh: + """Convert the shape to a compas Mesh.""" + vertices = self.points[:] # polygon points faces = [list(range(len(vertices)))] return Mesh.from_vertices_and_faces(vertices, faces) + def plot( + self, + fill: bool = True, + facecolor: str = "#B0C4DE", # light steel blue + edgecolor: str = "#333333", # dark gray + centroid_color: str = "red", + axis_color: str = "#555555", # medium gray + alpha: float = 0.6, + figsize=(8, 6), + ): + # Use a clean style (white background, subtle grid) + with plt.style.context("seaborn-v0_8-whitegrid"): + fig, ax = plt.subplots(figsize=figsize) + + # --------------------------------------------------------------------- + # 1) Polygon Patch + # --------------------------------------------------------------------- + pts_xy = self.points_xy # world XY coordinates + coords = [(p.x, p.y) for p in pts_xy] + + polygon = MplPolygon(coords, closed=True, fill=fill, facecolor=facecolor if fill else "none", edgecolor=edgecolor, alpha=alpha, linewidth=1.5) + ax.add_patch(polygon) + + # --------------------------------------------------------------------- + # 2) Centroid Marker + # --------------------------------------------------------------------- + c = self.centroid_xy + ax.plot(c.x, c.y, marker="o", color=centroid_color, markersize=6, zorder=5) + + # --------------------------------------------------------------------- + # 3) Principal Axes + # --------------------------------------------------------------------- + # We'll draw lines for I1 and I2 through the centroid with some length + xs = [p.x for p in pts_xy] + ys = [p.y for p in pts_xy] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + + diag = math.hypot(max_x - min_x, max_y - min_y) + axis_len = 0.5 * diag # half the diagonal of bounding box + + theta = self.theta # angle of I1 from local x-axis + # I1 axis + x_i1 = c.x + axis_len * math.cos(theta) + y_i1 = c.y + axis_len * math.sin(theta) + x_i1_neg = c.x - axis_len * math.cos(theta) + y_i1_neg = c.y - axis_len * math.sin(theta) + + ax.add_line(Line2D([x_i1_neg, x_i1], [y_i1_neg, y_i1], color=axis_color, linestyle="--", linewidth=1.5, zorder=4)) + + # I2 axis is perpendicular => theta + pi/2 + theta_2 = theta + math.pi / 2 + x_i2 = c.x + axis_len * math.cos(theta_2) + y_i2 = c.y + axis_len * math.sin(theta_2) + x_i2_neg = c.x - axis_len * math.cos(theta_2) + y_i2_neg = c.y - axis_len * math.sin(theta_2) + + ax.add_line(Line2D([x_i2_neg, x_i2], [y_i2_neg, y_i2], color=axis_color, linestyle="--", linewidth=1.5, zorder=4)) + + # --------------------------------------------------------------------- + # 4) Annotations for Key Properties + # --------------------------------------------------------------------- + txt = ( + f"Shape: {self.__class__.__name__}\n" + f"Area (A): {self.A:.2f}\n" + f"Centroid: ({c.x:.2f}, {c.y:.2f})\n" + f"Ixx: {self.Ixx:.2e}\n" + f"Iyy: {self.Iyy:.2e}\n" + f"Ixy: {self.Ixy:.2e}\n" + f"I1: {self.I1:.2e}\n" + f"I2: {self.I2:.2e}\n" + f"Theta (deg): {math.degrees(theta):.2f}" + ) + + text_x = max_x + 0.1 * (max_x - min_x) + text_y = max_y + + ax.text(text_x, text_y, txt, fontsize=9, verticalalignment="top", bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8), color="#333333") + + # ------------------------------------------------------------- + # 5) Final Touches + # ------------------------------------------------------------- + ax.set_aspect("equal", "box") + + # Expand x-limits so text is visible on the right + # We add an extra margin for the text box + x_margin = 0.3 * (max_x - min_x) + ax.set_xlim(min_x - 0.1 * diag, max_x + x_margin) + # And a bit of margin on y-limits + y_margin = 0.1 * (max_y - min_y) if (max_y - min_y) != 0 else 1.0 + ax.set_ylim(min_y - y_margin, max_y + y_margin) + + # Optional: remove ticks for a "clean" look + # ax.set_xticks([]) + # ax.set_yticks([]) + + plt.show() + + +# ------------------------------------------------------------------------------ +# Below are specific shape subclasses. They override the constructor +# to set their own `_points` geometry but otherwise rely on the parent class. +# ------------------------------------------------------------------------------ + + +class Circle(Shape): + """ + A circular cross section defined by a radius and segmented approximation. + """ + + def __init__(self, radius: float, segments: int = 360, frame: Optional[Frame] = None): + self._radius = radius + self._segments = segments + pts = self._set_points() + super().__init__(pts, frame=frame) + + def _set_points(self) -> List[Point]: + thetas = np.linspace(0, 2 * pi, self._segments, endpoint=False) + return [Point(self._radius * math.cos(t), self._radius * math.sin(t), 0.0) for t in thetas] + + @property + def radius(self) -> float: + return self._radius + + @radius.setter + def radius(self, val: float): + self._radius = val + self.points = self._set_points() + + @property + def segments(self) -> int: + return self._segments + + @property + def diameter(self) -> float: + return 2 * self._radius + + @property + def circumference(self) -> float: + return 2 * pi * self._radius + + @property + def J(self) -> float: + """Polar moment of inertia for a circular cross-section.""" + return pi * self._radius**4 / 2 + + @property + def Avx(self) -> float: + """Shear area in the x-direction.""" + return 9 / 10 * self.area + + @property + def Avy(self) -> float: + """Shear area in the y-direction.""" + return 9 / 10 * self.area + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "radius": self._radius, + "segments": self._segments, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Circle": + instance = cls(data["radius"], data["segments"], Frame.__from_data__(data["frame"])) + return instance + + +class Ellipse(Shape): + """ + An elliptical cross section defined by two principal radii (radius_a, radius_b). + """ + + def __init__(self, radius_a: float, radius_b: float, segments: int = 32, frame: Optional[Frame] = None): + self._radius_a = radius_a + self._radius_b = radius_b + self._segments = segments + pts = self._set_points() + super().__init__(pts, frame=frame) + + def _set_points(self) -> List[Point]: + thetas = np.linspace(0, 2 * pi, self._segments, endpoint=False) + return [Point(self._radius_a * math.cos(t), self._radius_b * math.sin(t), 0.0) for t in thetas] + + @property + def radius_a(self) -> float: + return self._radius_a + + @radius_a.setter + def radius_a(self, val: float): + self._radius_a = val + self.points = self._set_points() + + @property + def radius_b(self) -> float: + return self._radius_b + + @radius_b.setter + def radius_b(self, val: float): + self._radius_b = val + self.points = self._set_points() + + @property + def segments(self) -> int: + return self._segments + + @property + def J(self) -> float: + return pi * self._radius_a * self._radius_b**3 / 2 + + def _calculate_shear_area(self): + """ + Calculate the shear area (A_s) of a solid elliptical cross-section along x and y axes. + + Parameters: + a (float): Semi-major axis of the ellipse (longer radius). + b (float): Semi-minor axis of the ellipse (shorter radius). + + Returns: + tuple: Shear area along x-axis (A_s,x) and y-axis (A_s,y). + """ + # Total area of the ellipse + + # Shear coefficients + kappa_x = (4 / 3) * (self._radius_b**2 / (self._radius_a**2 + self._radius_b**2)) + kappa_y = (4 / 3) * (self._radius_a**2 / (self._radius_a**2 + self._radius_b**2)) + + # Shear areas + A_s_x = kappa_x * self.A + A_s_y = kappa_y * self.A + return A_s_x, A_s_y + + @property + def Avx(self) -> float: + return self._calculate_shear_area()[0] + + @property + def Avy(self) -> float: + return self._calculate_shear_area()[1] + + @property + def circumference(self) -> float: + a = self._radius_a + b = self._radius_b + return pi * (3 * (a + b) - sqrt((3 * a + b) * (a + 3 * b))) + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "radius_a": self._radius_a, + "radius_b": self._radius_b, + "segments": self._segments, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Ellipse": + instance = cls(data["radius_a"], data["radius_b"], data["segments"], Frame.__from_data__(data["frame"])) + return instance + class Rectangle(Shape): - def __init__(self, w, h, frame=None): + """ + Rectangle shape specified by width (w) and height (h). + """ + + def __init__(self, w: float, h: float, frame: Optional[Frame] = None): self._w = w self._h = h - points = [ + pts = [ Point(-w / 2, -h / 2, 0.0), Point(w / 2, -h / 2, 0.0), Point(w / 2, h / 2, 0.0), Point(-w / 2, h / 2, 0.0), ] - super().__init__(points, frame=frame) - self._Avy = 0.833 * self.area # TODO: Check this - self._Avx = 0.833 * self.area # TODO: Check this - l1 = max(w, h) - l2 = min(w, h) - self._J = (l1 * l2**3) * (0.33333 - 0.21 * (l2 / l1) * (1 - (l2**4) / (l2 * l1**4))) - self._g0 = 0 # Placeholder - self._gw = 0 # Placeholder + super().__init__(pts, frame=frame) @property - def w(self): + def w(self) -> float: return self._w @property - def h(self): + def h(self) -> float: return self._h + @property + def J(self): + """Torsional constant (polar moment of inertia). + Roark's Formulas for stress & Strain, 7th Edition, Warren C. Young & Richard G. Budynas + """ + a = self.w + b = self.h + if b > a: + b = self.w + a = self.h + term1 = 16 / 3 + term2 = 3.36 * (b / a) * (1 - (b**4) / (12 * a**4)) + J = (a * b**3 / 16) * (term1 - term2) + + return J + + @property + def Avx(self) -> float: + return 5 / 6 * self.area + + @property + def Avy(self) -> float: + return 5 / 6 * self.area + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "w": self._w, + "h": self._h, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Rectangle": + instance = cls(data["w"], data["h"], Frame.__from_data__(data["frame"])) + return instance + class Rhombus(Shape): - def __init__(self, a, b, frame=None): + """ + Rhombus shape specified by side lengths a and b + (the shape is basically a diamond shape). + """ + + def __init__(self, a: float, b: float, frame: Optional[Frame] = None): self._a = a self._b = b - points = [ + pts = [ Point(0.0, -b / 2, 0.0), Point(a / 2, 0.0, 0.0), Point(0.0, b / 2, 0.0), Point(-a / 2, 0.0, 0.0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) @property - def a(self): + def a(self) -> float: return self._a @property - def b(self): + def b(self) -> float: return self._b + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Rhombus": + instance = cls(data["a"], data["b"], Frame.__from_data__(data["frame"])) + return instance + class UShape(Shape): - def __init__(self, a, b, t1, t2, t3, direction="up", frame=None): + """ + U-shaped cross section. + """ + + def __init__(self, a: float, b: float, t1: float, t2: float, t3: float, direction: str = "up", frame: Optional[Frame] = None): self._a = a self._b = b self._t1 = t1 self._t2 = t2 self._t3 = t3 self._direction = direction - points = [ + + pts = [ Point(-a / 2, -b / 2, 0.0), Point(a / 2, -b / 2, 0.0), Point(a / 2, b / 2, 0.0), @@ -340,37 +743,62 @@ def __init__(self, a, b, t1, t2, t3, direction="up", frame=None): Point(t1 - a / 2, b / 2, 0.0), Point(-a / 2, b / 2, 0.0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) @property - def a(self): + def a(self) -> float: return self._a @property - def b(self): + def b(self) -> float: return self._b @property - def t1(self): + def t1(self) -> float: return self._t1 @property - def t2(self): + def t2(self) -> float: return self._t2 @property - def t3(self): + def t3(self) -> float: return self._t3 + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + "t1": self._t1, + "t2": self._t2, + "t3": self._t3, + "direction": self._direction, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "UShape": + instance = cls(data["a"], data["b"], data["t1"], data["t2"], data["t3"], data["direction"], Frame.__from_data__(data["frame"])) + return instance + class TShape(Shape): - def __init__(self, a, b, t1, t2, direction="up", frame=None): + """ + T-shaped cross section. + """ + + def __init__(self, a: float, b: float, t1: float, t2: float, direction: str = "up", frame: Optional[Frame] = None): self._a = a self._b = b self._t1 = t1 self._t2 = t2 self._direction = direction - points = [ + + pts = [ Point(-a / 2, -b / 2, 0.0), Point(a / 2, -b / 2, 0.0), Point(a / 2, t1 - b / 2, 0.0), @@ -380,83 +808,192 @@ def __init__(self, a, b, t1, t2, direction="up", frame=None): Point((a - t2) / 2 - a / 2, t1 - b / 2, 0.0), Point(-a / 2, t1 - b / 2, 0.0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) @property - def a(self): + def a(self) -> float: return self._a @property - def b(self): + def b(self) -> float: return self._b @property - def t1(self): + def t1(self) -> float: return self._t1 @property - def t2(self): + def t2(self) -> float: return self._t2 + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + "t1": self._t1, + "t2": self._t2, + "direction": self._direction, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "TShape": + instance = cls(data["a"], data["b"], data["t1"], data["t2"], data["direction"], Frame.__from_data__(data["frame"])) + return instance + class IShape(Shape): - def __init__(self, w, h, tw, tbf, ttf, direction="up", frame=None): + """ + I-shaped (or wide-flange) cross section. + """ + + def __init__(self, w: float, h: float, tw: float, tbf: float, ttf: float, direction: str = "up", frame: Optional[Frame] = None): self._w = w self._h = h self._tw = tw self._tbf = tbf self._ttf = ttf self._direction = direction - points = [ - Point(-w / 2, -h / 2, 0.0), - Point(w / 2, -h / 2, 0.0), - Point(w / 2, -h / 2 + tbf, 0.0), - Point(tw / 2, -h / 2 + tbf, 0.0), - Point(tw / 2, h / 2 - tbf, 0.0), - Point(w / 2, h / 2 - tbf, 0.0), - Point(w / 2, h / 2, 0.0), - Point(-w / 2, h / 2, 0.0), - Point(-w / 2, h / 2 - ttf, 0.0), - Point(-tw / 2, h / 2 - ttf, 0.0), - Point(-tw / 2, -h / 2 + ttf, 0.0), - Point(-w / 2, -h / 2 + ttf, 0.0), + + # Half-dimensions for clarity + half_w = 0.5 * w + half_h = 0.5 * h + half_tw = 0.5 * tw + + pts = [ + # Bottom outer edge + Point(-half_w, -half_h, 0.0), + Point(half_w, -half_h, 0.0), + # Move up by bottom flange thickness (tbf) + Point(half_w, -half_h + tbf, 0.0), + Point(half_tw, -half_h + tbf, 0.0), + # Web to top + Point(half_tw, half_h - ttf, 0.0), + Point(half_w, half_h - ttf, 0.0), + # Top outer edge + Point(half_w, half_h, 0.0), + Point(-half_w, half_h, 0.0), + # Move down by top flange thickness (ttf) + Point(-half_w, half_h - ttf, 0.0), + Point(-half_tw, half_h - ttf, 0.0), + # Web down to bottom + Point(-half_tw, -half_h + tbf, 0.0), + Point(-half_w, -half_h + tbf, 0.0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) @property - def w(self): + def w(self) -> float: return self._w @property - def h(self): + def h(self) -> float: return self._h @property - def tw(self): + def tw(self) -> float: return self._tw @property - def tbf(self): + def hw(self) -> float: + return self.h - self.tbf - self.ttf + + @property + def tbf(self) -> float: return self._tbf @property - def ttf(self): + def ttf(self) -> float: return self._ttf @property - def J(self): - """Torsional constant approximation.""" - return (1 / 3) * (self.w * (self.tbf**3 + self.ttf**3) + (self.h - self.tbf - self.ttf) * self.tw**3) + def atf(self) -> float: + return self._ttf * self.w + + @property + def abf(self) -> float: + return self._tbf * self.w + + @property + def aw(self) -> float: + return self._tw * self.hw + + @property + def J(self) -> float: + """ + Torsional constant approximation for an I-beam cross-section. + (Very rough formula.) + """ + return (1.0 / 3.0) * (self.w * (self.tbf**3 + self.ttf**3) + (self.h - self.tbf - self.ttf) * self.tw**3) + + def shear_area_I_beam_axes(self): + """ + Calculate the shear area (A_s) of an I-beam cross-section along x- and y-axes, + allowing for different flange thicknesses. + + Returns: + tuple: Shear area along x-axis (A_s,x) and y-axis (A_s,y). + """ + # Web area + + # Shear coefficients + kappa_web = 1 + kappa_flange = 1 / 3 + + # Shear area along x-axis + A_s_x = kappa_web * self.aw + kappa_flange * self.abf + kappa_flange * self.atf + + # Shear area along y-axis (primarily web contribution) + A_s_y = self.aw + + return A_s_x, A_s_y + + @property + def Avx(self) -> float: + return self.shear_area_I_beam_axes()[0] + + @property + def Avy(self) -> float: + return self.shear_area_I_beam_axes()[1] + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "w": self._w, + "h": self._h, + "tw": self._tw, + "tbf": self._tbf, + "ttf": self._ttf, + "direction": self._direction, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "IShape": + instance = cls(data["w"], data["h"], data["tw"], data["tbf"], data["ttf"], data["direction"], Frame.__from_data__(data["frame"])) + return instance class LShape(Shape): - def __init__(self, a, b, t1, t2, direction="up", frame=None): + """ + L-shaped cross section (angle profile). + """ + + def __init__(self, a: float, b: float, t1: float, t2: float, direction: str = "up", frame: Optional[Frame] = None): self._a = a self._b = b self._t1 = t1 self._t2 = t2 self._direction = direction - points = [ + + pts = [ Point(-a / 2, -b / 2, 0.0), Point(a / 2, -b / 2, 0.0), Point(a / 2, t1 - b / 2, 0.0), @@ -464,36 +1001,60 @@ def __init__(self, a, b, t1, t2, direction="up", frame=None): Point(t2 - a / 2, b / 2, 0.0), Point(-a / 2, b / 2, 0.0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) @property - def a(self): + def a(self) -> float: return self._a @property - def b(self): + def b(self) -> float: return self._b @property - def t1(self): + def t1(self) -> float: return self._t1 @property - def t2(self): + def t2(self) -> float: return self._t2 + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + "t1": self._t1, + "t2": self._t2, + "direction": self._direction, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "LShape": + instance = cls(data["a"], data["b"], data["t1"], data["t2"], data["direction"], Frame.__from_data__(data["frame"])) + return instance + class CShape(Shape): - def __init__(self, height, flange_width, web_thickness, flange_thickness, frame=None): + """ + C-shaped cross section (channel). + """ + + def __init__(self, height: float, flange_width: float, web_thickness: float, flange_thickness: float, frame: Optional[Frame] = None): self._height = height self._flange_width = flange_width self._web_thickness = web_thickness self._flange_thickness = flange_thickness - hw = self._web_thickness / 2.0 - hf = self._flange_width - h = self._height - ft = self._flange_thickness - points = [ + + hw = web_thickness / 2.0 + hf = flange_width + h = height + ft = flange_thickness + pts = [ Point(0, 0, 0), Point(hf, 0, 0), Point(hf, ft, 0), @@ -503,11 +1064,42 @@ def __init__(self, height, flange_width, web_thickness, flange_thickness, frame= Point(hf, h, 0), Point(0, h, 0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "height": self._height, + "flange_width": self._flange_width, + "web_thickness": self._web_thickness, + "flange_thickness": self._flange_thickness, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "CShape": + instance = cls(data["height"], data["flange_width"], data["web_thickness"], data["flange_thickness"], Frame.__from_data__(data["frame"])) + return instance class CustomI(Shape): - def __init__(self, height, top_flange_width, bottom_flange_width, web_thickness, top_flange_thickness, bottom_flange_thickness, frame=None): + """ + Custom "I"-like shape with different top/bottom flange widths. + """ + + def __init__( + self, + height: float, + top_flange_width: float, + bottom_flange_width: float, + web_thickness: float, + top_flange_thickness: float, + bottom_flange_thickness: float, + frame: Optional[Frame] = None, + ): self._height = height self._top_flange_width = top_flange_width self._bottom_flange_width = bottom_flange_width @@ -515,13 +1107,15 @@ def __init__(self, height, top_flange_width, bottom_flange_width, web_thickness, self._top_flange_thickness = top_flange_thickness self._bottom_flange_thickness = bottom_flange_thickness - htf = top_flange_width / 2 - hbf = bottom_flange_width / 2 - hw = web_thickness / 2 - # shifts for centering - shift_x = hw / 2 - shift_y = height / 2 - points = [ + htf = top_flange_width / 2.0 + hbf = bottom_flange_width / 2.0 + hw = web_thickness / 2.0 + + # shift_x, shift_y can help center the shape about the local origin + shift_x = hw / 2.0 + shift_y = height / 2.0 + + pts = [ Point(-hbf - shift_x, -shift_y, 0), Point(hbf - shift_x, -shift_y, 0), Point(hbf - shift_x, bottom_flange_thickness - shift_y, 0), @@ -535,191 +1129,336 @@ def __init__(self, height, top_flange_width, bottom_flange_width, web_thickness, Point(-hw - shift_x, bottom_flange_thickness - shift_y, 0), Point(-hbf - shift_x, bottom_flange_thickness - shift_y, 0), ] - super().__init__(points, frame=frame) + super().__init__(pts, frame=frame) + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "height": self._height, + "top_flange_width": self._top_flange_width, + "bottom_flange_width": self._bottom_flange_width, + "web_thickness": self._web_thickness, + "top_flange_thickness": self._top_flange_thickness, + "bottom_flange_thickness": self._bottom_flange_thickness, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "CustomI": + instance = cls( + data["height"], + data["top_flange_width"], + data["bottom_flange_width"], + data["web_thickness"], + data["top_flange_thickness"], + data["bottom_flange_thickness"], + Frame.__from_data__(data["frame"]), + ) + return instance class Star(Shape): - def __init__(self, a, b, c, frame=None): + """ + A star-like shape, parameterized by a, b, c for demonstration. + """ + + def __init__(self, a: float, b: float, c: float, frame: Optional[Frame] = None): self._a = a self._b = b self._c = c - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): + def _set_points(self) -> List[Point]: return [ Point(0.0, 0.0, 0.0), - Point(self._a / 2, self._c, 0.0), + Point(self._a / 2.0, self._c, 0.0), Point(self._a, 0.0, 0.0), - Point(self._a - self._c, self._b / 2, 0.0), + Point(self._a - self._c, self._b / 2.0, 0.0), Point(self._a, self._b, 0.0), - Point(self._a / 2, self._b - self._c, 0.0), + Point(self._a / 2.0, self._b - self._c, 0.0), Point(0.0, self._b, 0.0), - Point(self._c, self._b / 2, 0.0), + Point(self._c, self._b / 2.0, 0.0), ] @property - def a(self): + def a(self) -> float: return self._a @a.setter - def a(self, val): + def a(self, val: float): self._a = val self.points = self._set_points() @property - def b(self): + def b(self) -> float: return self._b @b.setter - def b(self, val): + def b(self, val: float): self._b = val self.points = self._set_points() @property - def c(self): + def c(self) -> float: return self._c @c.setter - def c(self, val): + def c(self, val: float): self._c = val self.points = self._set_points() - -class Circle(Shape): - def __init__(self, radius, segments=70, frame=None): - self._radius = radius - self._segments = segments - points = self._set_points() - super().__init__(points, frame=frame) - - @property - def radius(self): - return self._radius - - @radius.setter - def radius(self, val): - self._radius = val - self.points = self._set_points() - - def _set_points(self): - return [Point(self._radius * np.cos(theta), self._radius * np.sin(theta), 0.0) for theta in np.linspace(0, 2 * pi, self._segments, endpoint=False)] - - -class Ellipse(Shape): - def __init__(self, radius_a, radius_b, segments=32, frame=None): - self._radius_a = radius_a - self._radius_b = radius_b - self._segments = segments - points = self._set_points() - super().__init__(points, frame=frame) - - @property - def radius_a(self): - return self._radius_a - - @radius_a.setter - def radius_a(self, val): - self._radius_a = val - self.points = self._set_points() - @property - def radius_b(self): - return self._radius_b - - @radius_b.setter - def radius_b(self, val): - self._radius_b = val - self.points = self._set_points() + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "a": self._a, + "b": self._b, + "c": self._c, + } + ) + return data - def _set_points(self): - return [Point(self._radius_a * np.cos(theta), self._radius_b * np.sin(theta), 0.0) for theta in np.linspace(0, 2 * pi, self._segments, endpoint=False)] + @classmethod + def __from_data__(cls, data: dict) -> "Star": + instance = cls(data["a"], data["b"], data["c"], Frame.__from_data__(data["frame"])) + return instance class Hexagon(Shape): - def __init__(self, side_length, frame=None): + """ + A regular hexagon specified by its side length. + """ + + def __init__(self, side_length: float, frame: Optional[Frame] = None): self._side_length = side_length - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): - return [Point(self._side_length * np.cos(np.pi / 3 * i), self._side_length * np.sin(np.pi / 3 * i), 0.0) for i in range(6)] + def _set_points(self) -> List[Point]: + return [ + Point( + self._side_length * math.cos(math.pi / 3 * i), + self._side_length * math.sin(math.pi / 3 * i), + 0.0, + ) + for i in range(6) + ] @property - def side_length(self): + def side_length(self) -> float: return self._side_length @side_length.setter - def side_length(self, val): + def side_length(self, val: float): self._side_length = val self.points = self._set_points() + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "side_length": self._side_length, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Hexagon": + instance = cls(data["side_length"], Frame.__from_data__(data["frame"])) + return instance + class Pentagon(Shape): - def __init__(self, circumradius, frame=None): + """ + A regular pentagon specified by its circumradius. + """ + + def __init__(self, circumradius: float, frame: Optional[Frame] = None): self._circumradius = circumradius - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): + def _set_points(self) -> List[Point]: angle = 2 * pi / 5 - return [Point(self._circumradius * np.cos(i * angle), self._circumradius * np.sin(i * angle), 0.0) for i in range(5)] + return [ + Point( + self._circumradius * math.cos(i * angle), + self._circumradius * math.sin(i * angle), + 0.0, + ) + for i in range(5) + ] + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "circumradius": self._circumradius, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Pentagon": + instance = cls(data["circumradius"], Frame.__from_data__(data["frame"])) + return instance class Octagon(Shape): - def __init__(self, circumradius, frame=None): + """ + A regular octagon specified by its circumradius. + """ + + def __init__(self, circumradius: float, frame: Optional[Frame] = None): self._circumradius = circumradius - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): + def _set_points(self) -> List[Point]: angle = 2 * pi / 8 - return [Point(self._circumradius * np.cos(i * angle), self._circumradius * np.sin(i * angle), 0.0) for i in range(8)] + return [ + Point( + self._circumradius * math.cos(i * angle), + self._circumradius * math.sin(i * angle), + 0.0, + ) + for i in range(8) + ] + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "circumradius": self._circumradius, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Octagon": + instance = cls(data["circumradius"], Frame.__from_data__(data["frame"])) + return instance class Triangle(Shape): - def __init__(self, circumradius, frame=None): + """ + An equilateral triangle specified by its circumradius. + """ + + def __init__(self, circumradius: float, frame: Optional[Frame] = None): self._circumradius = circumradius - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): + def _set_points(self) -> List[Point]: angle = 2 * pi / 3 - return [Point(self._circumradius * np.cos(i * angle), self._circumradius * np.sin(i * angle), 0.0) for i in range(3)] + return [ + Point( + self._circumradius * math.cos(i * angle), + self._circumradius * math.sin(i * angle), + 0.0, + ) + for i in range(3) + ] + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "circumradius": self._circumradius, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Triangle": + instance = cls(data["circumradius"], Frame.__from_data__(data["frame"])) + return instance class Parallelogram(Shape): - def __init__(self, width, height, angle, frame=None): + """ + A parallelogram specified by width, height, and the angle (in radians). + """ + + def __init__(self, width: float, height: float, angle: float, frame: Optional[Frame] = None): self._width = width self._height = height - self._angle = angle # radians - points = self._set_points() - super().__init__(points, frame=frame) + self._angle = angle + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): - dx = self._height * np.sin(self._angle) - dy = self._height * np.cos(self._angle) + def _set_points(self) -> List[Point]: + dx = self._height * math.sin(self._angle) + dy = self._height * math.cos(self._angle) return [ - Point(0, 0, 0), - Point(self._width, 0, 0), - Point(self._width + dx, dy, 0), - Point(dx, dy, 0), + Point(0.0, 0.0, 0.0), + Point(self._width, 0.0, 0.0), + Point(self._width + dx, dy, 0.0), + Point(dx, dy, 0.0), ] + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "width": self._width, + "height": self._height, + "angle": self._angle, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Parallelogram": + instance = cls(data["width"], data["height"], data["angle"], Frame.__from_data__(data["frame"])) + return instance + class Trapezoid(Shape): - def __init__(self, top_width, bottom_width, height, frame=None): + """ + A trapezoid specified by top width, bottom width, and height. + """ + + def __init__(self, top_width: float, bottom_width: float, height: float, frame: Optional[Frame] = None): self._top_width = top_width self._bottom_width = bottom_width self._height = height - points = self._set_points() - super().__init__(points, frame=frame) + pts = self._set_points() + super().__init__(pts, frame=frame) - def _set_points(self): - dx = (self._bottom_width - self._top_width) / 2 + def _set_points(self) -> List[Point]: + dx = (self._bottom_width - self._top_width) / 2.0 return [ - Point(dx, 0, 0), - Point(dx + self._top_width, 0, 0), - Point(self._bottom_width, self._height, 0), - Point(0, self._height, 0), + Point(dx, 0.0, 0.0), + Point(dx + self._top_width, 0.0, 0.0), + Point(self._bottom_width, self._height, 0.0), + Point(0.0, self._height, 0.0), ] + + @property + def __data__(self) -> dict: + data = super().__data__ + data.update( + { + "top_width": self._top_width, + "bottom_width": self._bottom_width, + "height": self._height, + } + ) + return data + + @classmethod + def __from_data__(cls, data: dict) -> "Trapezoid": + instance = cls(data["top_width"], data["bottom_width"], data["height"], Frame.__from_data__(data["frame"])) + return instance diff --git a/src/compas_fea2/problem/__init__.py b/src/compas_fea2/problem/__init__.py index 13c668bea..6cb54fa54 100644 --- a/src/compas_fea2/problem/__init__.py +++ b/src/compas_fea2/problem/__init__.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .problem import Problem from .displacements import GeneralDisplacement from .loads import ( @@ -15,12 +11,15 @@ HarmonicPressureLoad, ThermalLoad, ) + from .fields import ( + LoadField, + DisplacementField, + NodeLoadField, + PointLoadField, _PrescribedField, PrescribedTemperatureField, ) - -from .patterns import Pattern, NodeLoadPattern, PointLoadPattern, LineLoadPattern, AreaLoadPattern, VolumeLoadPattern from .combinations import LoadCombination from .steps import ( @@ -37,16 +36,6 @@ DirectCyclicStep, ) -from .outputs import ( - FieldOutput, - DisplacementFieldOutput, - AccelerationFieldOutput, - VelocityFieldOutput, - Stress2DFieldOutput, - # StrainFieldOutput, - ReactionFieldOutput, - HistoryOutput, -) __all__ = [ "Problem", @@ -60,12 +49,13 @@ "HarmonicPointLoad", "HarmonicPressureLoad", "ThermalLoad", - "Pattern", - "NodeLoadPattern", - "PointLoadPattern", - "LineLoadPattern", - "AreaLoadPattern", - "VolumeLoadPattern", + "LoadField", + "DisplacementField", + "NodeLoadField", + "PointLoadField", + "LineLoadField", + "PressureLoadField", + "VolumeLoadField", "_PrescribedField", "PrescribedTemperatureField", "LoadCombination", @@ -87,4 +77,5 @@ "VelocityFieldOutput", "Stress2DFieldOutput", "ReactionFieldOutput", + "SectionForcesFieldOutput", ] diff --git a/src/compas_fea2/problem/combinations.py b/src/compas_fea2/problem/combinations.py index e4404fc87..11d37465f 100644 --- a/src/compas_fea2/problem/combinations.py +++ b/src/compas_fea2/problem/combinations.py @@ -1,20 +1,13 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData - +import compas_fea2 class LoadCombination(FEAData): - """Load combination object used to combine patterns together at each step. + """Load combination used to combine load fields together at each step. Parameters ---------- factors : dict() Dictionary with the factors for each load case: {"load case": factor} - name : str, optional - Name to assign to the combination, by default None (automatically assigned). - """ def __init__(self, factors, **kwargs): @@ -32,11 +25,11 @@ def step(self): @property def problem(self): - return self.step._registration + return self.step.problem @property def model(self): - self.problem._registration + self.problem.model @classmethod def ULS(cls): @@ -50,10 +43,20 @@ def SLS(cls): def Fire(cls): return cls(factors={"DL": 1, "SDL": 1, "LL": 0.3}, name="Fire") + def __data__(self): + return { + "factors": self.factors, + "name": self.name, + } + + @classmethod + def __from_data__(cls, data): + return cls(factors=data["factors"], name=data.get("name")) + # BUG: Rewrite. this is not general and does not account for different loads types @property def node_load(self): - """Generator returning each node and the correponding total factored + """Generator returning each node and the corresponding total factored load of the combination. Returns @@ -62,11 +65,13 @@ def node_load(self): :class:`compas_fea2.model.node.Node`, :class:`compas_fea2.problem.loads.NodeLoad` """ nodes_loads = {} - for pattern in self.step.patterns: - if pattern.load_case in self.factors: - for node, load in pattern.node_load: - if node in nodes_loads: - nodes_loads[node] += load * self.factors[pattern.load_case] - else: - nodes_loads[node] = load * self.factors[pattern.load_case] + for load_field in self.step.load_fields: + if isinstance(load_field, compas_fea2.problem.LoadField): + if load_field.load_case in self.factors: + for node in load_field.distribution : + for load in load_field.loads: + if node in nodes_loads: + nodes_loads[node] += load * self.factors[load_field.load_case] + else: + nodes_loads[node] = load * self.factors[load_field.load_case] return zip(list(nodes_loads.keys()), list(nodes_loads.values())) diff --git a/src/compas_fea2/problem/displacements.py b/src/compas_fea2/problem/displacements.py index 93d3e0f9d..9460c1447 100644 --- a/src/compas_fea2/problem/displacements.py +++ b/src/compas_fea2/problem/displacements.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData @@ -75,3 +71,18 @@ def axes(self, value): @property def components(self): return {c: getattr(self, c) for c in ["x", "y", "z", "xx", "yy", "zz"]} + + def __data__(self): + return { + "x": self.x, + "y": self.y, + "z": self.z, + "xx": self.xx, + "yy": self.yy, + "zz": self.zz, + "axes": self._axes, + } + + @classmethod + def __from_data__(cls, data): + return cls(x=data["x"], y=data["y"], z=data["z"], xx=data["xx"], yy=data["yy"], zz=data["zz"], axes=data["axes"]) diff --git a/src/compas_fea2/problem/fields.py b/src/compas_fea2/problem/fields.py index 2a90249b1..dbadea21d 100644 --- a/src/compas_fea2/problem/fields.py +++ b/src/compas_fea2/problem/fields.py @@ -1,8 +1,209 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Iterable from compas_fea2.base import FEAData +from compas_fea2.problem.loads import GravityLoad + +# TODO implement __*__ magic method for combination + + +class LoadField(FEAData): + """A pattern is the spatial distribution of a specific set of forces, + displacements, temperatures, and other effects which act on a structure. + Any combination of nodes and elements may be subjected to loading and + kinematic conditions. + + Parameters + ---------- + load : :class:`compas_fea2.problem._Load` | :class:`compas_fea2.problem.GeneralDisplacement` + The load/displacement assigned to the pattern. + distribution : list + List of :class:`compas_fea2.model.Node` or :class:`compas_fea2.model._Element`. The + application in space of the load/displacement. + load_case : str, optional + The load case to which this pattern belongs. + axes : str, optional + Coordinate system for the load components. Default is "global". + name : str, optional + Unique identifier for the pattern. + + Attributes + ---------- + load : :class:`compas_fea2.problem._Load` + The load of the pattern. + distribution : list + List of :class:`compas_fea2.model.Node` or :class:`compas_fea2.model._Element`. + name : str + Unique identifier. + + Notes + ----- + Patterns are registered to a :class:`compas_fea2.problem._Step`. + """ + + def __init__( + self, + loads, + distribution, + load_case=None, + **kwargs, + ): + super(LoadField, self).__init__(**kwargs) + self._distribution = distribution if isinstance(distribution, Iterable) else [distribution] + self._loads = loads if isinstance(loads, Iterable) else [loads * (1 / len(self._distribution))] * len(self._distribution) + self.load_case = load_case + self._registration = None + + @property + def loads(self): + return self._loads + + @property + def distribution(self): + return self._distribution + + @property + def step(self): + if self._registration: + return self._registration + else: + raise ValueError("Register the Pattern to a Step first.") + + @property + def problem(self): + return self.step.problem + + @property + def model(self): + return self.problem.model + + # def __add__(self, other): + # if not isinstance(other, Pattern): + # raise TypeError("Can only combine with another Pattern") + # combined_distribution = self._distribution + other._distribution + # combined_components = {k: (getattr(self, k) or 0) + (getattr(other, k) or 0) for k in self.components} + # return Pattern( + # combined_distribution, + # x=combined_components["x"], + # y=combined_components["y"], + # z=combined_components["z"], + # xx=combined_components["xx"], + # yy=combined_components["yy"], + # zz=combined_components["zz"], + # load_case=self.load_case or other.load_case, + # axes=self.axes, + # name=self.name or other.name, + # ) + + +class DisplacementField(LoadField): + """A distribution of a set of displacements over a set of nodes. + + Parameters + ---------- + displacement : object + The displacement to be applied. + nodes : list + List of nodes where the displacement is applied. + load_case : object, optional + The load case to which this pattern belongs. + """ + + def __init__(self, displacements, nodes, load_case=None, **kwargs): + nodes = nodes if isinstance(nodes, Iterable) else [nodes] + displacements = displacements if isinstance(displacements, Iterable) else [displacements] * len(nodes) + super(DisplacementField, self).__init__(loads=displacements, distribution=nodes, load_case=load_case, **kwargs) + + @property + def nodes(self): + return self._distribution + + @property + def displacements(self): + return self._loads + + @property + def node_displacement(self): + """Return a list of tuples with the nodes and the assigned displacement.""" + return zip(self.nodes, self.displacements) + + +class NodeLoadField(LoadField): + """A distribution of a set of concentrated loads over a set of nodes. + + Parameters + ---------- + load : object + The load to be applied. + nodes : list + List of nodes where the load is applied. + load_case : object, optional + The load case to which this pattern belongs. + """ + + def __init__(self, loads, nodes, load_case=None, **kwargs): + super(NodeLoadField, self).__init__(loads=loads, distribution=nodes, load_case=load_case, **kwargs) + + @property + def nodes(self): + return self._distribution + + @property + def loads(self): + return self._loads + + @property + def node_load(self): + """Return a list of tuples with the nodes and the assigned load.""" + return zip(self.nodes, self.loads) + + +class PointLoadField(NodeLoadField): + """A distribution of a set of concentrated loads over a set of points. + The loads are applied to the closest nodes to the points. + + Parameters + ---------- + load : object + The load to be applied. + points : list + List of points where the load is applied. + load_case : object, optional + The load case to which this pattern belongs. + tolerance : float, optional + Tolerance for finding the closest nodes to the points. + """ + + def __init__(self, loads, points, load_case=None, tolerance=1, **kwargs): + self._points = points + self._tolerance = tolerance + # FIXME: this is not working, the patternhas no model! + distribution = [self.model.find_closest_nodes_to_point(point, distance=self._tolerance)[0] for point in self.points] + super().__init__(loads, distribution, load_case, **kwargs) + + @property + def points(self): + return self._points + + @property + def nodes(self): + return self._distribution + + +class GravityLoadField(LoadField): + """Volume distribution of a gravity load case. + + Parameters + ---------- + g : float + Value of gravitational acceleration. + parts : list + List of parts where the load is applied. + load_case : object, optional + The load case to which this pattern belongs. + """ + + def __init__(self, g=9.81, parts=None, load_case=None, **kwargs): + super(GravityLoadField, self).__init__(GravityLoad(g=g), parts, load_case, **kwargs) class _PrescribedField(FEAData): diff --git a/src/compas_fea2/problem/loads.py b/src/compas_fea2/problem/loads.py index fa15e1f63..bbfdaf732 100644 --- a/src/compas_fea2/problem/loads.py +++ b/src/compas_fea2/problem/loads.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData # TODO: make units independent using the utilities function @@ -217,6 +213,26 @@ def __init__(self, g, x=0, y=0, z=-1, **kwargs): def g(self): return self._g + @property + def vector(self): + return [self.g * self.x, self.g * self.y, self.g * self.z] + + @property + def components(self): + components = {i: self.vector[j] for j, i in enumerate(["x", "y", "z"])} + components.update({i: 0 for i in ["xx", "yy", "zz"]}) + return components + + def __mul__(self, factor): + if isinstance(factor, (float, int)): + new_components = {k: (getattr(self, k) or 0) * factor for k in ["x", "y", "z"]} + return GravityLoad(self.g, **new_components) + else: + raise NotImplementedError + + def __rmul__(self, other): + return self.__mul__(other) + class PrestressLoad(Load): """Prestress load""" diff --git a/src/compas_fea2/problem/outputs.py b/src/compas_fea2/problem/outputs.py deleted file mode 100644 index d3757738a..000000000 --- a/src/compas_fea2/problem/outputs.py +++ /dev/null @@ -1,345 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from itertools import chain - -from compas_fea2.base import FEAData - - -class _Output(FEAData): - """Base class for output requests. - - Parameters - ---------- - FEAData : _type_ - _description_ - - Notes - ----- - Outputs are registered to a :class:`compas_fea2.problem.Step`. - - """ - - def __init__(self, field_name, components_names, invariants_names, **kwargs): - super(_Output, self).__init__(**kwargs) - self._field_name = field_name - self._components_names = components_names - self._invariants_names = invariants_names - self._results_func = None - - @property - def step(self): - return self._registration - - @property - def problem(self): - return self.step._registration - - @property - def model(self): - return self.problem._registration - - @property - def field_name(self): - return self._field_name - - @property - def description(self): - return self._description - - @property - def components_names(self): - return self._components_names - - @property - def invariants_names(self): - return self._invariants_names - - @classmethod - def get_sqltable_schema(cls): - """ - Returns a dictionary describing the SQL table structure - for this output type. Should be overridden by subclasses. - """ - raise NotImplementedError("Subclasses must define their table schema.") - - @classmethod - def get_jsontable_schema(cls): - """ - Returns a JSON-like dict describing the SQL table structure - for this output type. Subclasses should override. - """ - raise NotImplementedError("Subclasses must define their table schema.") - - def create_table_for_output_class(self, connection, results): - """ - Reads the table schema from `output_cls.get_table_schema()` - and creates the table in the given database. - - Parameters - ---------- - database_path : str - Path to the folder where the database is located - database_name : str - Name of the database file, e.g. 'results.db' - output_cls : _Output subclass - A class like NodeOutput that implements `get_table_schema()` - """ - - cursor = connection.cursor() - - schema = self.get_sqltable_schema() - table_name = schema["table_name"] - columns_info = schema["columns"] - - # Build CREATE TABLE statement: - columns_sql = [] - for col_name, col_def in columns_info: - columns_sql.append(f"{col_name} {col_def}") - columns_sql_str = ", ".join(columns_sql) - create_sql = f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_sql_str})" - cursor.execute(create_sql) - connection.commit() - - # Insert data into the table: - insert_columns = [col_name for (col_name, col_def) in columns_info if "PRIMARY KEY" not in col_def.upper()] - col_names_str = ", ".join(insert_columns) - placeholders_str = ", ".join(["?"] * len(insert_columns)) - sql = f"INSERT INTO {table_name} ({col_names_str}) VALUES ({placeholders_str})" - cursor.executemany(sql, results) - connection.commit() - - -class _NodeFieldOutput(_Output): - """NodeFieldOutput object for requesting the fields at the nodes from the analysis.""" - - def __init__(self, field_name, components_names, invariants_names, **kwargs): - super().__init__(field_name, components_names, invariants_names, **kwargs) - self._results_func = "find_node_by_inputkey" - - -class _ElementFieldOutput(_Output): - """ElementFieldOutput object for requesting the fields at the elements from the analysis.""" - - def __init__(self, field_name, components_names, invariants_names, **kwargs): - super().__init__(field_name, components_names, invariants_names, **kwargs) - self._results_func = "find_element_by_inputkey" - - -class DisplacementFieldOutput(_NodeFieldOutput): - """DisplacmentFieldOutput object for requesting the displacements at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(DisplacementFieldOutput, self).__init__("u", ["ux", "uy", "uz", "uxx", "uyy", "uzz"], ["magnitude"], **kwargs) - - @classmethod - def get_sqltable_schema(cls): - """ - Return a dict describing the table name and each column - (column_name, column_type, constraints). - """ - return { - "table_name": "u", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("ux", "REAL"), - ("uy", "REAL"), - ("uz", "REAL"), - ("uxx", "REAL"), - ("uyy", "REAL"), - ("uzz", "REAL"), - ], - } - - -class AccelerationFieldOutput(_NodeFieldOutput): - """AccelerationFieldOutput object for requesting the accelerations at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(AccelerationFieldOutput, self).__init__("a", ["ax", "ay", "az", "axx", "ayy", "azz"], ["magnitude"], **kwargs) - - @classmethod - def get_sqltable_schema(cls): - """ - Return a dict describing the table name and each column - (column_name, column_type, constraints). - """ - return { - "table_name": "a", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("ax", "REAL"), - ("ay", "REAL"), - ("az", "REAL"), - ("axx", "REAL"), - ("ayy", "REAL"), - ("azz", "REAL"), - ], - } - - -class VelocityFieldOutput(_NodeFieldOutput): - """VelocityFieldOutput object for requesting the velocities at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(VelocityFieldOutput, self).__init__("v", ["vx", "vy", "vz", "vxx", "vyy", "vzz"], ["magnitude"], **kwargs) - - @classmethod - def get_sqltable_schema(cls): - """ - Return a dict describing the table name and each column - (column_name, column_type, constraints). - """ - return { - "table_name": "v", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("vx", "REAL"), - ("vy", "REAL"), - ("vz", "REAL"), - ("vxx", "REAL"), - ("vyy", "REAL"), - ("vzz", "REAL"), - ], - } - - -class ReactionFieldOutput(_NodeFieldOutput): - """ReactionFieldOutput object for requesting the reaction forces at the nodes - from the analysis.""" - - def __init__(self, **kwargs): - super(ReactionFieldOutput, self).__init__("rf", ["rfx", "rfy", "rfz", "rfxx", "rfyy", "rfzz"], ["magnitude"], **kwargs) - - @classmethod - def get_sqltable_schema(cls): - """ - Return a dict describing the table name and each column - (column_name, column_type, constraints). - """ - return { - "table_name": "rf", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("rfx", "REAL"), - ("rfy", "REAL"), - ("rfz", "REAL"), - ("rfxx", "REAL"), - ("rfyy", "REAL"), - ("rfzz", "REAL"), - ], - } - - -class Stress2DFieldOutput(_ElementFieldOutput): - """StressFieldOutput object for requesting the stresses at the elements from the analysis.""" - - def __init__(self, **kwargs): - super(Stress2DFieldOutput, self).__init__("s2d", ["s11", "s22", "s12", "m11", "m22", "m12"], ["von_mises"], **kwargs) - - @classmethod - def get_sqltable_schema(cls): - """ - Return a dict describing the table name and each column - (column_name, column_type, constraints). - """ - return { - "table_name": "s2d", - "columns": [ - ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), - ("input_key", "INTEGER"), - ("step", "TEXT"), - ("part", "TEXT"), - ("s11", "REAL"), - ("s22", "REAL"), - ("s12", "REAL"), - ("m11", "REAL"), - ("m22", "REAL"), - ("m12", "REAL"), - # ("von_mises", "REAL"), - ], - } - - -class FieldOutput(_Output): - """FieldOutput object for specification of the fields (stresses, displacements, - etc..) to output from the analysis. - - Parameters - ---------- - nodes_outputs : list - list of node fields to output - elements_outputs : list - list of elements fields to output - - Attributes - ---------- - name : str - Automatically generated id. You can change the name if you want a more - human readable input file. - nodes_outputs : list - list of node fields to output - elements_outputs : list - list of elements fields to output - - """ - - def __init__(self, node_outputs=None, element_outputs=None, contact_outputs=None, **kwargs): - super(FieldOutput, self).__init__(**kwargs) - self._node_outputs = node_outputs - self._element_outputs = element_outputs - self._contact_outputs = contact_outputs - - @property - def node_outputs(self): - return self._node_outputs - - @property - def element_outputs(self): - return self._element_outputs - - @property - def contact_outputs(self): - return self._contact_outputs - - @property - def outputs(self): - return chain(self.node_outputs, self.element_outputs, self.contact_outputs) - - -class HistoryOutput(_Output): - """HistoryOutput object for recording the fields (stresses, displacements, - etc..) from the analysis. - - Parameters - ---------- - name : str, optional - Uniqe identifier. If not provided it is automatically generated. Set a - name if you want a more human-readable input file. - - Attributes - ---------- - name : str - Uniqe identifier. If not provided it is automatically generated. Set a - name if you want a more human-readable input file. - - """ - - def __init__(self, **kwargs): - super(HistoryOutput, self).__init__(**kwargs) diff --git a/src/compas_fea2/problem/patterns.py b/src/compas_fea2/problem/patterns.py deleted file mode 100644 index 945ed85f8..000000000 --- a/src/compas_fea2/problem/patterns.py +++ /dev/null @@ -1,276 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import itertools -from typing import Iterable - -from compas_fea2.base import FEAData -from compas_fea2.problem.loads import ConcentratedLoad -from compas_fea2.problem.loads import GravityLoad - -# TODO implement __*__ magic method for combination - - -class Pattern(FEAData): - """A pattern is the spatial distribution of a specific set of forces, - displacements, temperatures, and other effects which act on a structure. - Any combination of nodes and elements may be subjected to loading and - kinematic conditions. - - Parameters - ---------- - load : :class:`compas_fea2.problem._Load` | :class:`compas_fea2.problem.GeneralDisplacement` - The load/displacement assigned to the pattern. - distribution : list - List of :class:`compas_fea2.model.Node` or :class:`compas_fea2.model._Element`. The - application in space of the load/displacement. - load_case : object, optional - The load case to which this pattern belongs. - axes : str, optional - Coordinate system for the load components. Default is "global". - name : str, optional - Unique identifier for the pattern. - - Attributes - ---------- - load : :class:`compas_fea2.problem._Load` - The load of the pattern. - distribution : list - List of :class:`compas_fea2.model.Node` or :class:`compas_fea2.model._Element`. - name : str - Unique identifier. - - Notes - ----- - Patterns are registered to a :class:`compas_fea2.problem._Step`. - """ - - def __init__( - self, - load, - distribution, - load_case=None, - **kwargs, - ): - super(Pattern, self).__init__(**kwargs) - self._load = load - self._distribution = distribution if isinstance(distribution, Iterable) else [distribution] - self._nodes = None - self.load_case = load_case - self._registration = None - - @property - def load(self): - return self._load - - @property - def distribution(self): - return self._distribution - - @property - def step(self): - return self._registration - - @property - def problem(self): - return self.step._registration - - @property - def model(self): - return self.problem._registration - - # def __add__(self, other): - # if not isinstance(other, Pattern): - # raise TypeError("Can only combine with another Pattern") - # combined_distribution = self._distribution + other._distribution - # combined_components = {k: (getattr(self, k) or 0) + (getattr(other, k) or 0) for k in self.components} - # return Pattern( - # combined_distribution, - # x=combined_components["x"], - # y=combined_components["y"], - # z=combined_components["z"], - # xx=combined_components["xx"], - # yy=combined_components["yy"], - # zz=combined_components["zz"], - # load_case=self.load_case or other.load_case, - # axes=self.axes, - # name=self.name or other.name, - # ) - - -class NodeLoadPattern(Pattern): - """Nodal distribution of a load case. - - Parameters - ---------- - load : object - The load to be applied. - nodes : list - List of nodes where the load is applied. - load_case : object, optional - The load case to which this pattern belongs. - """ - - def __init__(self, load, nodes, load_case=None, **kwargs): - super(NodeLoadPattern, self).__init__(load=load, distribution=nodes, load_case=load_case, **kwargs) - - @property - def nodes(self): - return self._distribution - - @property - def load(self): - return self._load - - @property - def node_load(self): - return zip(self.nodes, [self.load] * len(self.nodes)) - - -class PointLoadPattern(NodeLoadPattern): - """Point distribution of a load case. - - Parameters - ---------- - load : object - The load to be applied. - points : list - List of points where the load is applied. - load_case : object, optional - The load case to which this pattern belongs. - tolerance : float, optional - Tolerance for finding the closest nodes to the points. - """ - - def __init__(self, load, points, load_case=None, tolerance=1, **kwargs): - super(PointLoadPattern, self).__init__(load, points, load_case, **kwargs) - self._points = points - self.tolerance = tolerance - self._distribution = [self.model.find_closest_nodes_to_point(point, distance=self.tolerance)[0] for point in self.points] - - @property - def points(self): - return self._points - - @property - def nodes(self): - return self._distribution - - -class LineLoadPattern(Pattern): - """Line distribution of a load case. - - Parameters - ---------- - load : object - The load to be applied. - polyline : object - The polyline along which the load is distributed. - load_case : object, optional - The load case to which this pattern belongs. - tolerance : float, optional - Tolerance for finding the closest nodes to the polyline. - discretization : int, optional - Number of segments to divide the polyline into. - """ - - def __init__(self, load, polyline, load_case=None, tolerance=1, discretization=10, **kwargs): - if not isinstance(load, ConcentratedLoad): - raise TypeError("LineLoadPattern only supports ConcentratedLoad") - super(LineLoadPattern, self).__init__(load, polyline, load_case, **kwargs) - self.tolerance = tolerance - self.discretization = discretization - - @property - def polyline(self): - return self._distribution - - @property - def points(self): - return self.polyline.divide_polyline(self.discretization) - - @property - def nodes(self): - return [self.model.find_closest_nodes_to_point(point, distance=self.distance)[0] for point in self.points] - - @property - def node_load(self): - return zip(self.nodes, [self.load] * self.nodes) - - -class AreaLoadPattern(Pattern): - """Area distribution of a load case. - - Parameters - ---------- - load : object - The load to be applied. - polygon : object - The polygon defining the area where the load is distributed. - load_case : object, optional - The load case to which this pattern belongs. - tolerance : float, optional - Tolerance for finding the nodes within the polygon. - """ - - def __init__(self, load, polygon, load_case=None, tolerance=1.05, **kwargs): - if not isinstance(load, ConcentratedLoad): - raise TypeError("For the moment AreaLoadPattern only supports ConcentratedLoad") - super(AreaLoadPattern, self).__init__(load=load, distribution=polygon, load_case=load_case, **kwargs) - self.tolerance = tolerance - - @property - def polygon(self): - return self._distribution - - @property - def nodes(self): - return self.model.find_nodes_in_polygon(self.polygon, tolerance=self.tolerance) - - @property - def node_load(self): - return zip(self.nodes, [self.load] * self.nodes) - - -class VolumeLoadPattern(Pattern): - """Volume distribution of a load case (e.g., gravity load). - - Parameters - ---------- - load : object - The load to be applied. - parts : list - List of parts where the load is applied. - load_case : object, optional - The load case to which this pattern belongs. - """ - - def __init__(self, load, parts, load_case=None, **kwargs): - if not isinstance(load, GravityLoad): - raise TypeError("For the moment VolumeLoadPattern only supports ConcentratedLoad") - super(VolumeLoadPattern, self).__init__(load, parts, load_case, **kwargs) - - @property - def parts(self): - return self._distribution - - @property - def nodes(self): - return list(set(itertools.chain.from_iterable(self.parts))) - - @property - def node_load(self): - nodes_loads = {} - for part in self.parts: - for element in part.elements: - vol = element.volume - den = element.section.material.density - n_nodes = len(element.nodes) - load = ConcentratedLoad(**{k: v * vol * den / n_nodes if v else v for k, v in self.load.components.items()}) - for node in element.nodes: - if node in nodes_loads: - nodes_loads[node] += load - else: - nodes_loads[node] = load - return zip(list(nodes_loads.keys()), list(nodes_loads.values())) diff --git a/src/compas_fea2/problem/problem.py b/src/compas_fea2/problem/problem.py index 5cd2c1885..f6229cb34 100644 --- a/src/compas_fea2/problem/problem.py +++ b/src/compas_fea2/problem/problem.py @@ -1,27 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os +import shutil from pathlib import Path - -from compas.colors import Color -from compas.colors import ColorMap -from compas.geometry import Point -from compas.geometry import Vector -from compas.geometry import centroid_points_weighted -from compas.geometry import sum_vectors +from typing import List +from typing import Optional +from typing import Union from compas_fea2.base import FEAData from compas_fea2.job.input_file import InputFile from compas_fea2.problem.steps import StaticStep from compas_fea2.problem.steps import Step -from compas_fea2.results import DisplacementFieldResults -from compas_fea2.results import ReactionFieldResults -from compas_fea2.results import StressFieldResults -from compas_fea2.results import ModalShape from compas_fea2.results.database import ResultsDatabase - from compas_fea2.UI.viewer import FEA2Viewer @@ -52,6 +40,8 @@ class Problem(FEAData): list of analysis steps in the order they are applied. path : str, :class:`pathlib.Path` Path to the analysis folder where all the files will be saved. + path_db : str, :class:`pathlib.Path` + Path to the SQLite database where the results are stored. results : :class:`compas_fea2.results.Results` Results object with the analyisis results. @@ -75,75 +65,66 @@ class Problem(FEAData): """ - def __init__(self, description=None, **kwargs): + def __init__(self, description: Optional[str] = None, **kwargs): super(Problem, self).__init__(**kwargs) self.description = description self._path = None self._path_db = None self._steps = set() self._steps_order = [] # TODO make steps a list + self._rdb = None @property - def model(self): + def model(self) -> "Model": # noqa: F821 return self._registration @property - def steps(self): + def steps(self) -> set: return self._steps @property - def path(self): + def path(self) -> Path: return self._path @path.setter - def path(self, value): + def path(self, value: Union[str, Path]): self._path = value if isinstance(value, Path) else Path(value) self._path_db = os.path.join(self._path, "{}-results.db".format(self.name)) @property - def path_db(self): + def path_db(self) -> str: return self._path_db @property - def results_db(self): - if os.path.exists(self.path_db): - return ResultsDatabase(self.path_db) - - @property - def displacement_field(self): - return DisplacementFieldResults(problem=self) - - @property - def reaction_field(self): - return ReactionFieldResults(problem=self) - - @property - def temperature_field(self): - raise NotImplementedError - - @property - def stress_field(self): - return StressFieldResults(problem=self) + def rdb(self) -> ResultsDatabase: + return self._rdb or ResultsDatabase.sqlite(self) - def modal_shape(self, mode): - return ModalShape(problem=self, mode=mode) + @rdb.setter + def rdb(self, value: str): + if not hasattr(ResultsDatabase, value): + raise ValueError("Invalid ResultsDatabase option") + self._rdb = getattr(ResultsDatabase, value)(self) @property - def steps_order(self): + def steps_order(self) -> List[Step]: return self._steps_order @steps_order.setter - def steps_order(self, value): + def steps_order(self, value: List[Step]): for step in value: if not self.is_step_in_problem(step, add=False): raise ValueError("{!r} must be previously added to {!r}".format(step, self)) self._steps_order = value + @property + def input_file(self) -> InputFile: + return InputFile(self) + # ========================================================================= # Step methods # ========================================================================= - def find_step_by_name(self, name): + def find_step_by_name(self, name: str) -> Optional[Step]: # type: (str) -> Step """Find if there is a step with the given name in the problem. @@ -160,7 +141,7 @@ def find_step_by_name(self, name): if step.name == name: return step - def is_step_in_problem(self, step, add=True): + def is_step_in_problem(self, step: Step, add: bool = True) -> Union[bool, Step]: """Check if a :class:`compas_fea2.problem._Step` is defined in the Problem. Parameters @@ -192,7 +173,7 @@ def is_step_in_problem(self, step, add=True): return False return True - def add_step(self, step): + def add_step(self, step: Step) -> Step: # # type: (_Step) -> Step """Adds a :class:`compas_fea2.problem._Step` to the problem. The name of the Step must be unique @@ -218,7 +199,7 @@ def add_step(self, step): self._steps_order.append(step) return step - def add_static_step(self, step=None, **kwargs): + def add_static_step(self, **kwargs) -> Step: # # type: (_Step) -> Step """Adds a :class:`compas_fea2.problem._Step` to the problem. The name of the Step must be unique @@ -234,14 +215,8 @@ def add_static_step(self, step=None, **kwargs): ------- :class:`compas_fea2.problem._Step` """ - if step: - if not isinstance(step, StaticStep): - raise TypeError("You must provide a valid compas_fea2 Step object") - if self.find_step_by_name(step): - raise ValueError("There is already a step with the same name in the model.") - else: - step = StaticStep(**kwargs) + step = StaticStep(**kwargs) step._key = len(self._steps) self._steps.add(step) @@ -249,7 +224,7 @@ def add_static_step(self, step=None, **kwargs): self._steps_order.append(step) return step - def add_steps(self, steps): + def add_steps(self, steps: List[Step]) -> List[Step]: """Adds multiple :class:`compas_fea2.problem._Step` objects to the problem. Parameters @@ -263,7 +238,7 @@ def add_steps(self, steps): """ return [self.add_step(step) for step in steps] - def define_steps_order(self, order): + def define_steps_order(self, order: List[Step]): """Defines the order in which the steps are applied during the analysis. Parameters @@ -285,7 +260,7 @@ def define_steps_order(self, order): raise TypeError("{} is not a step".format(step)) self._steps_order = order - def add_linear_perturbation_step(self, lp_step, base_step): + def add_linear_perturbation_step(self, lp_step: "LinearPerturbation", base_step: str): # noqa: F821 """Add a linear perturbation step to a previously defined step. Parameters @@ -304,15 +279,11 @@ def add_linear_perturbation_step(self, lp_step, base_step): """ raise NotImplementedError - # ========================================================================= - # Loads methods - # ========================================================================= - # ============================================================================== # Summary # ============================================================================== - def summary(self): + def summary(self) -> str: # type: () -> str """Prints a summary of the Problem object. @@ -347,7 +318,7 @@ def summary(self): # ========================================================================= # Analysis methods # ========================================================================= - def write_input_file(self, path=None): + def write_input_file(self, path: Optional[Union[Path, str]] = None) -> InputFile: # type: (Path |str) -> None """Writes the input file. @@ -367,32 +338,80 @@ def write_input_file(self, path=None): path = Path(path) if not path.exists(): path.mkdir(parents=True) - input_file = InputFile.from_problem(self) - input_file.write_to_file(path) - return input_file + return self.input_file.write_to_file(path) - def _check_analysis_path(self, path): - """Check the analysis path and adds the correct folder structure. + def _check_analysis_path(self, path: Path, erase_data: bool = False) -> Path: + """Check and prepare the analysis path, ensuring the correct folder structure. Parameters ---------- path : :class:`pathlib.Path` Path where the input file will be saved. + erase_data : bool, optional + If True, automatically erase the folder's contents if it is recognized as an FEA2 results folder. Default is False. Returns ------- :class:`pathlib.Path` Path where the input file will be saved. + Raises + ------ + ValueError + If the folder is not a valid FEA2 results folder and `erase_data` is True but not confirmed by the user. """ - if path: - self.model.path = path - self.path = self.model.path.joinpath(self.name) - if not self.path and not self.model.path: - raise AttributeError("You must provide a path for storing the model and the analysis results.") + + def _delete_folder_contents(folder_path: Path): + """Helper method to delete all contents of a folder.""" + for root, dirs, files in os.walk(folder_path): + for file in files: + os.remove(Path(root) / file) + for dir in dirs: + shutil.rmtree(Path(root) / dir) + + if not isinstance(path, Path): + path = Path(path) + + # Prepare the main and analysis paths + self.model.path = path + self.path = self.model.path.joinpath(self.name) + + if self.path.exists(): + # Check if the folder contains FEA2 results + is_fea2_folder = any(fname.endswith("-results.db") for fname in os.listdir(self.path)) + + if is_fea2_folder: + if not erase_data: + user_input = input(f"The directory {self.path} already exists and contains FEA2 results. Do you want to delete its contents? (Y/n): ").strip().lower() + erase_data = user_input in ["y", "yes", ""] + + if erase_data: + _delete_folder_contents(self.path) + print(f"All contents of {self.path} have been deleted.") + else: + print(f"WARNING: The directory {self.path} already exists and contains FEA2 results. Duplicated results expected.") + else: + # Folder exists but is not an FEA2 results folder + if erase_data and erase_data == "armageddon": + _delete_folder_contents(self.path) + else: + user_input = ( + input(f"ATTENTION! The directory {self.path} already exists and might NOT be a FEA2 results folder. Do you want to DELETE its contents? (y/N): ") + .strip() + .lower() + ) + if user_input in ["y", "yes"]: + _delete_folder_contents(self.path) + print(f"All contents of {self.path} have been deleted.") + else: + raise ValueError(f"The directory {self.path} exists but is not recognized as a valid FEA2 results folder, and its contents were not cleared.") + else: + # Create the directory if it does not exist + self.path.mkdir(parents=True, exist_ok=True) + return self.path - def analyse(self, path=None, *args, **kwargs): + def analyse(self, path: Optional[Union[Path, str]] = None, erase_data: bool = False, *args, **kwargs): """Analyse the problem in the selected backend. Raises @@ -401,13 +420,35 @@ def analyse(self, path=None, *args, **kwargs): This method is implemented only at the backend level. """ + # generate keys + self.model.assign_keys() raise NotImplementedError("this function is not available for the selected backend") - def analyze(self, *args, **kwargs): + def analyze(self, path: Optional[Union[Path, str]] = None, erase_data: bool = False, *args, **kwargs): """American spelling of the analyse method""" - self.analyse(*args, **kwargs) + self.analyse(path=path, *args, **kwargs) - def analyse_and_extract(self, path=None, *args, **kwargs): + def extract_results(self, path: Optional[Union[Path, str]] = None, erase_data: Optional[Union[bool, str]] = False, *args, **kwargs): + """Extract the results from the native database system to SQLite. + + Parameters + ---------- + path : :class:`pathlib.Path` + Path to the folder where the results are saved. + erase_data : bool, optional + If True, automatically erase the folder's contents if it is recognized as an FEA2 results folder. Default is False. + Pass "armageddon" to erase all contents of the folder without checking. + + Raises + ------ + NotImplementedError + This method is implemented only at the backend level. + """ + if path: + self.path = path + raise NotImplementedError("this function is not available for the selected backend") + + def analyse_and_extract(self, path: Optional[Union[Path, str]] = None, erase_data: bool = False, *args, **kwargs): """Analyse the problem in the selected backend and extract the results from the native database system to SQLite. @@ -416,6 +457,8 @@ def analyse_and_extract(self, path=None, *args, **kwargs): NotImplementedError This method is implemented only at the backend level. """ + if path: + self.path = path raise NotImplementedError("this function is not available for the selected backend") def restart_analysis(self, *args, **kwargs): @@ -448,69 +491,6 @@ def restart_analysis(self, *args, **kwargs): # Results methods - general # ========================================================================= - # ========================================================================= - # Results methods - reactions - # ========================================================================= - def get_total_reaction(self, step=None): - """Compute the total reaction vector - - Parameters - ---------- - step : :class:`compas_fea2.problem._Step`, optional - The analysis step, by default the last step. - - Returns - ------- - :class:`compas.geometry.Vector` - The resultant vector. - :class:`compas.geometry.Point` - The application point. - """ - if not step: - step = self.steps_order[-1] - reactions = self.reaction_field - locations, vectors, vectors_lengths = [], [], [] - for reaction in reactions.results(step): - locations.append(reaction.location.xyz) - vectors.append(reaction.vector) - vectors_lengths.append(reaction.vector.length) - return Vector(*sum_vectors(vectors)), Point(*centroid_points_weighted(locations, vectors_lengths)) - - def get_min_max_reactions(self, step=None): - """Get the minimum and maximum reaction values for the last step. - - Parameters - ---------- - step : _type_, optional - _description_, by default None - """ - if not step: - step = self.steps_order[-1] - reactions = self.reaction_field - return reactions.get_limits_absolute(step) - - def get_min_max_reactions_component(self, component, step=None): - """Get the minimum and maximum reaction values for the last step. - - Parameters - ---------- - component : _type_ - _description_ - step : _type_, optional - _description_, by default None - """ - if not step: - step = self.steps_order[-1] - reactions = self.reaction_field - return reactions.get_limits_component(component, step) - - # def get_total_moment(self, step=None): - # if not step: - # step = self.steps_order[-1] - # vector, location = self.get_total_reaction(step) - - # return sum_vectors([reaction.vector for reaction in reactions.results]) - # ========================================================================= # Results methods - displacements # ========================================================================= @@ -518,7 +498,9 @@ def get_min_max_reactions_component(self, component, step=None): # ========================================================================= # Viewer methods # ========================================================================= - def show(self, fast=True, scale_model=1.0, show_parts=True, show_bcs=1.0, show_loads=1.0, **kwargs): + def show( + self, steps: Optional[List[Step]] = None, fast: bool = True, scale_model: float = 1.0, show_parts: bool = True, show_bcs: float = 1.0, show_loads: float = 1.0, **kwargs + ): """Visualise the model in the viewer. Parameters @@ -531,209 +513,34 @@ def show(self, fast=True, scale_model=1.0, show_parts=True, show_bcs=1.0, show_l Scale factor for the loads, by default 1.0 """ + if not steps: + steps = self.steps_order viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) viewer.config.vectorsize = 0.2 - viewer.add_model(self.model, show_parts=show_parts, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - # if show_loads: - # register(step.__class__, FEA2StepObject, context="Viewer") - # viewer.viewer.scene.add(step, step=step, scale_factor=show_loads) - viewer.show() - viewer.scene.clear() - - def show_principal_stress_vectors(self, step=None, components=None, scale_model=1, scale_results=1, show_loads=True, show_bcs=True, **kwargs): - """Display the principal stress results for a given step. - - Parameters - ---------- - step : _type_, optional - _description_, by default None - components : _type_, optional - _description_, by default None - scale_model : int, optional - _description_, by default 1 - scale_results : int, optional - _description_, by default 1 - show_loads : bool, optional - _description_, by default True - show_bcs : bool, optional - _description_, by default True - """ - - from compas.scene import register - from compas.scene import register_scene_objects - - from compas_fea2.UI.viewer import FEA2ModelObject - from compas_fea2.UI.viewer import FEA2StepObject - from compas_fea2.UI.viewer import FEA2StressFieldResultsObject - from compas_fea2.UI.viewer import FEA2Viewer - - if not step: - step = self.steps_order[-1] - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - viewer.viewer.config.vectorsize = 0.2 - - register_scene_objects() # This has to be called before registering the model object - - register(self.model.__class__.__bases__[-1], FEA2ModelObject, context="Viewer") - viewer.viewer.scene.add(self.model, model=self.model, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - - register(self.stress_field.__class__.__bases__[-1], FEA2StressFieldResultsObject, context="Viewer") - viewer.viewer.scene.add(self.stress_field, field=self.stress_field, step=step, scale_factor=scale_results, components=components, **kwargs) - - if show_loads: - register(step.__class__, FEA2StepObject, context="Viewer") - viewer.viewer.scene.add(step, step=step, scale_factor=show_loads) - - viewer.viewer.show() - - def show_deformed(self, step=None, opacity=1, show_bcs=1, scale_results=1, scale_model=1, show_loads=0.1, show_original=False, **kwargs): - """Display the structure in its deformed configuration. - - Parameters - ---------- - step : :class:`compas_fea2.problem._Step`, optional - The Step of the analysis, by default None. If not provided, the last - step is used. - - Returns - ------- - None - - """ - if not step: - step = self.steps_order[-1] - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - - if show_original: - viewer.add_model(self.model, fast=True, opacity=show_original, show_bcs=False, **kwargs) - # TODO create a copy of the model first - displacements = step.problem.displacement_field - for displacement in displacements.results(step): - vector = displacement.vector.scaled(scale_results) - displacement.node.xyz = sum_vectors([Vector(*displacement.location.xyz), vector]) - viewer.add_model(self.model, fast=True, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - viewer.show() - - def show_displacements(self, step=None, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=True, show_contours=True, **kwargs): - """Display the displacement field results for a given step. + viewer.add_model(self.model, show_parts=show_parts, opacity=0.5, show_bcs=show_bcs, **kwargs) - Parameters - ---------- - step : _type_, optional - _description_, by default None - scale_model : int, optional - _description_, by default 1 - show_loads : bool, optional - _description_, by default True - component : _type_, optional - _description_, by default - - """ - if not step: - step = self.steps_order[-1] - - if not step.problem.displacement_field: - raise ValueError("No displacement field results available for this step") - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - viewer.add_model(self.model, fast=fast, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - viewer.add_displacement_field(step.problem.displacement_field, fast=fast, step=step, component=component, show_vectors=show_vectors, show_contour=show_contours, **kwargs) - if show_loads: + for step in steps: viewer.add_step(step, show_loads=show_loads) - viewer.show() - viewer.scene.clear() - def show_reactions(self, step=None, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=True, show_contours=True, **kwargs): - """Display the reaction field results for a given step. - - Parameters - ---------- - step : _type_, optional - _description_, by default None - scale_model : int, optional - _description_, by default 1 - show_bcs : bool, optional - _description_, by default True - component : _type_, optional - _description_, by default - translate : _type_, optional - _description_, by default -1 - scale_results : _type_, optional - _description_, by default 1 - """ - - if not step: - step = self.steps_order[-1] - - if not step.problem.reaction_field: - raise ValueError("No displacement field results available for this step") - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - viewer.add_model(self.model, fast=fast, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) - viewer.add_reaction_field(step.problem.reaction_field, fast=fast, step=step, component=component, show_vectors=show_vectors, show_contour=show_contours, **kwargs) - if show_loads: - viewer.add_step(step, show_loads=show_loads) viewer.show() viewer.scene.clear() - def show_stress_contour(self, step=None, stresstype="vonmieses", high=None, low=None, cmap=None, side=None, scale_model=1.0, show_bcs=True, **kwargs): - from compas.scene import register - from compas.scene import register_scene_objects - - from compas_fea2.UI.viewer import FEA2ModelObject - from compas_fea2.UI.viewer import FEA2Viewer - - register_scene_objects() # This has to be called before registering the model object - register(self.model.__class__.__bases__[-1], FEA2ModelObject, context="Viewer") - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - viewer.viewer.scene.add(self.model, model=self.model, opacity=0.3, show_bcs=show_bcs, show_parts=True) - - if not step: - step = self.steps_order[-1] - field_locations = list(self.stress_field.locations(step, point=True)) - field_results = list(getattr(self.stress_field, stresstype)(step)) - - # # Get values - min_value = high or min(field_results) - max_value = low or max(field_results) - cmap = cmap or ColorMap.from_palette("hawaii") - points = [] - for n, v in zip(field_locations, field_results): - if kwargs.get("bound", None): - if v >= kwargs["bound"][1] or v <= kwargs["bound"][0]: - color = Color.red() - else: - color = cmap(v, minval=min_value, maxval=max_value) - else: - color = cmap(v, minval=min_value, maxval=max_value) - points.append((n, {"pointcolor": color, "pointsize": 20})) - - viewer.viewer.scene.add(points, name=f"{stresstype} Contour") - viewer.viewer.show() - - def show_mode_shape( - self, step, mode, fast=True, opacity=1, scale_results=1, scale_model=1.0, show_bcs=True, show_original=0.25, show_contour=False, show_vectors=False, **kwargs - ): - - viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) - - if show_original: - viewer.add_model(self.model, show_parts=True, fast=True, opacity=show_original, show_bcs=False, **kwargs) - - shape = step.problem.modal_shape(mode) - if show_vectors: - viewer.add_mode_shape(shape, fast=fast, step=step, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) - - # TODO create a copy of the model first - for displacement in shape.results(step): - vector = displacement.vector.scaled(scale_results) - displacement.node.xyz = sum_vectors([Vector(*displacement.location.xyz), vector]) - - if show_contour: - viewer.add_mode_shape(shape, fast=fast, step=step, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) - viewer.add_model(self.model, fast=fast, opacity=opacity, show_bcs=show_bcs, **kwargs) - viewer.show() + def __data__(self) -> dict: + """Returns a dictionary representation of the Problem object.""" + return { + "description": self.description, + "steps": [step.__data__() for step in self.steps], + "path": str(self.path), + "path_db": str(self.path_db), + } + + @classmethod + def __from_data__(cls, data: dict) -> "Problem": + """Creates a Problem object from a dictionary representation.""" + problem = cls(description=data.get("description")) + problem.path = data.get("path") + problem._path_db = data.get("path_db") + problem._steps = set(Step.__from_data__(step_data) for step_data in data.get("steps", [])) + problem._steps_order = list(problem._steps) + return problem diff --git a/src/compas_fea2/problem/steps/__init__.py b/src/compas_fea2/problem/steps/__init__.py index 2ae6240c4..ee3afb841 100644 --- a/src/compas_fea2/problem/steps/__init__.py +++ b/src/compas_fea2/problem/steps/__init__.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .step import ( Step, GeneralStep, @@ -27,7 +23,7 @@ ComplexEigenValue, BucklingAnalysis, LinearStaticPerturbation, - StedyStateDynamic, + SteadyStateDynamic, SubstructureGeneration, ) @@ -40,7 +36,7 @@ "StaticStep", "StaticRiksStep", "LinearStaticPerturbation", - "StedyStateDynamic", + "SteadyStateDynamic", "SubstructureGeneration", "BucklingAnalysis", "DynamicStep", diff --git a/src/compas_fea2/problem/steps/dynamic.py b/src/compas_fea2/problem/steps/dynamic.py index 6ba527562..3bdbef01a 100644 --- a/src/compas_fea2/problem/steps/dynamic.py +++ b/src/compas_fea2/problem/steps/dynamic.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .step import GeneralStep @@ -12,6 +8,17 @@ def __init__(self, **kwargs): super(DynamicStep, self).__init__(**kwargs) raise NotImplementedError + def __data__(self): + data = super(DynamicStep, self).__data__() + # Add DynamicStep specific data here + return data + + @classmethod + def __from_data__(cls, data): + obj = super(DynamicStep, cls).__from_data__(data) + # Initialize DynamicStep specific attributes here + return obj + def add_harmonic_point_load(self): raise NotImplementedError diff --git a/src/compas_fea2/problem/steps/perturbations.py b/src/compas_fea2/problem/steps/perturbations.py index 48d00e0b2..d920d8420 100644 --- a/src/compas_fea2/problem/steps/perturbations.py +++ b/src/compas_fea2/problem/steps/perturbations.py @@ -1,24 +1,33 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from compas.geometry import Vector +from compas.geometry import sum_vectors + +from compas_fea2.results import ModalAnalysisResult +from compas_fea2.UI import FEA2Viewer from .step import Step class _Perturbation(Step): """A perturbation is a change of the state of the structure after an analysis - step. Differently from Steps, perturbations' changes are not carried over to - the next step. - - Parameters - ---------- - Step : _type_ - _description_ + step. Perturbations' changes are not carried over to the next step. """ def __init__(self, **kwargs): super(_Perturbation, self).__init__(**kwargs) + def __data__(self): + data = super(_Perturbation, self).__data__() + data.update( + { + "type": self.__class__.__name__, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls(**data) + class ModalAnalysis(_Perturbation): """Perform a modal analysis of the Model from the resulting state after an @@ -26,10 +35,8 @@ class ModalAnalysis(_Perturbation): Parameters ---------- - name : str - Name of the ModalStep. modes : int - Number of modes to analyse. + Number of modes. """ @@ -37,6 +44,120 @@ def __init__(self, modes=1, **kwargs): super(ModalAnalysis, self).__init__(**kwargs) self.modes = modes + @property + def rdb(self): + return self.problem.rdb + + def _get_results_from_db(self, mode, **kwargs): + """Get the results for the given members and steps. + + Parameters + ---------- + members : _type_ + _description_ + steps : _type_ + _description_ + + Returns + ------- + _type_ + _description_ + """ + filters = {} + filters["step"] = [self.name] + filters["mode"] = [mode] + + # Get the eigenvalue + eigenvalue = self.rdb.get_rows("eigenvalues", ["lambda"], filters)[0][0] + + # Get the eiginvectors + all_columns = ["step", "part", "key", "x", "y", "z", "xx", "yy", "zz"] + results_set = self.rdb.get_rows("eigenvectors", ["step", "part", "key", "x", "y", "z", "xx", "yy", "zz"], filters) + results_set = [{k: v for k, v in zip(all_columns, row)} for row in results_set] + eigenvector = self.rdb.to_result(results_set, "find_node_by_key", "u")[self] + + return eigenvalue, eigenvector + + @property + def results(self): + for mode in range(self.modes): + yield self.mode_result(mode + 1) + + @property + def frequencies(self): + for mode in range(self.modes): + yield self.mode_frequency(mode + 1) + + @property + def shapes(self): + for mode in range(self.modes): + yield self.mode_shape(mode + 1) + + def mode_shape(self, mode): + return self.mode_result(mode).shape + + def mode_frequency(self, mode): + return self.mode_result(mode).frequency + + def mode_result(self, mode): + eigenvalue, eigenvector = self._get_results_from_db(mode) + return ModalAnalysisResult(step=self, mode=mode, eigenvalue=eigenvalue, eigenvector=eigenvector) + + def show_mode_shape(self, mode, fast=True, opacity=1, scale_results=1, show_bcs=True, show_original=0.25, show_contour=False, show_vectors=False, **kwargs): + """Show the mode shape of a given mode. + + Parameters + ---------- + mode : int + The mode to show. + fast : bool, optional + Show the mode shape fast, by default True + opacity : float, optional + Opacity of the model, by default 1 + scale_results : float, optional + Scale the results, by default 1 + show_bcs : bool, optional + Show the boundary conditions, by default True + show_original : float, optional + Show the original model, by default 0.25 + show_contour : bool, optional + Show the contour, by default False + show_vectors : bool, optional + Show the vectors, by default False + + """ + viewer = FEA2Viewer(center=self.model.center, scale_model=1) + + if show_original: + viewer.add_model(self.model, show_parts=True, fast=True, opacity=show_original, show_bcs=False, **kwargs) + + shape = self.mode_shape(mode) + if show_vectors: + viewer.add_mode_shape(shape, fast=fast, show_parts=False, component=None, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + + # TODO create a copy of the model first + for displacement in shape.results: + vector = displacement.vector.scaled(scale_results) + displacement.node.xyz = sum_vectors([Vector(*displacement.location.xyz), vector]) + + if show_contour: + viewer.add_mode_shape(shape, fast=fast, component=None, show_vectors=False, show_contour=show_contour, **kwargs) + viewer.add_model(self.model, fast=fast, opacity=opacity, show_bcs=show_bcs, **kwargs) + viewer.show() + + def __data__(self): + data = super(ModalAnalysis, self).__data__() + data.update( + { + "modes": self.modes, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls(modes=data["modes"], **data) + class ComplexEigenValue(_Perturbation): """""" @@ -45,6 +166,13 @@ def __init__(self, **kwargs): super().__init__(**kwargs) raise NotImplementedError + def __data__(self): + return super(ComplexEigenValue, self).__data__() + + @classmethod + def __from_data__(cls, data): + return cls(**data) + class BucklingAnalysis(_Perturbation): """""" @@ -78,6 +206,22 @@ def Subspace( algorithhm="Subspace", ) + def __data__(self): + data = super(BucklingAnalysis, self).__data__() + data.update( + { + "modes": self._modes, + "vectors": self._vectors, + "iterations": self._iterations, + "algorithm": self._algorithm, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + return cls(modes=data["_modes"], vectors=data["_vectors"], iterations=data["_iterations"], algorithm=data["_algorithm"], **data) + class LinearStaticPerturbation(_Perturbation): """""" @@ -86,14 +230,28 @@ def __init__(self, **kwargs): super().__init__(**kwargs) raise NotImplementedError + def __data__(self): + return super(LinearStaticPerturbation, self).__data__() + + @classmethod + def __from_data__(cls, data): + return cls(**data) -class StedyStateDynamic(_Perturbation): + +class SteadyStateDynamic(_Perturbation): """""" def __init__(self, **kwargs): super().__init__(**kwargs) raise NotImplementedError + def __data__(self): + return super(SteadyStateDynamic, self).__data__() + + @classmethod + def __from_data__(cls, data): + return cls(**data) + class SubstructureGeneration(_Perturbation): """""" @@ -101,3 +259,10 @@ class SubstructureGeneration(_Perturbation): def __init__(self, **kwargs): super().__init__(**kwargs) raise NotImplementedError + + def __data__(self): + return super(SubstructureGeneration, self).__data__() + + @classmethod + def __from_data__(cls, data): + return cls(**data) diff --git a/src/compas_fea2/problem/steps/quasistatic.py b/src/compas_fea2/problem/steps/quasistatic.py index 096cc34e2..ed037ed01 100644 --- a/src/compas_fea2/problem/steps/quasistatic.py +++ b/src/compas_fea2/problem/steps/quasistatic.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .step import GeneralStep @@ -12,6 +8,17 @@ def __init__(self, **kwargs): super(QuasiStaticStep, self).__init__(**kwargs) raise NotImplementedError + def __data__(self): + data = super(QuasiStaticStep, self).__data__() + # Add specific data for QuasiStaticStep + return data + + @classmethod + def __from_data__(cls, data): + obj = super(QuasiStaticStep, cls).__from_data__(data) + # Initialize specific attributes for QuasiStaticStep + return obj + class DirectCyclicStep(GeneralStep): """Step for a direct cyclic analysis.""" @@ -19,3 +26,14 @@ class DirectCyclicStep(GeneralStep): def __init__(self, **kwargs): super(DirectCyclicStep, self).__init__(**kwargs) raise NotImplementedError + + def __data__(self): + data = super(DirectCyclicStep, self).__data__() + # Add specific data for DirectCyclicStep + return data + + @classmethod + def __from_data__(cls, data): + obj = super(DirectCyclicStep, cls).__from_data__(data) + # Initialize specific attributes for DirectCyclicStep + return obj diff --git a/src/compas_fea2/problem/steps/static.py b/src/compas_fea2/problem/steps/static.py index ee98da568..77f72d302 100644 --- a/src/compas_fea2/problem/steps/static.py +++ b/src/compas_fea2/problem/steps/static.py @@ -1,13 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas_fea2.problem.patterns import AreaLoadPattern -from compas_fea2.problem.patterns import LineLoadPattern -from compas_fea2.problem.patterns import NodeLoadPattern -from compas_fea2.problem.patterns import PointLoadPattern -from compas_fea2.problem.patterns import VolumeLoadPattern - from .step import GeneralStep @@ -66,8 +56,6 @@ class StaticStep(GeneralStep): ones defined in the present step, otherwise the loads are added. loads : dict Dictionary of the loads assigned to each part in the model in the step. - gravity : :class:`compas_fea2.problem.GravityLoad` - Gravity load to assing to the whole model. displacements : dict Dictionary of the displacements assigned to each part in the model in the step. @@ -78,250 +66,46 @@ def __init__( max_increments=100, initial_inc_size=1, min_inc_size=0.00001, + max_inc_size=1, time=1, nlgeom=False, modify=True, **kwargs, ): - super(StaticStep, self).__init__( + super().__init__( max_increments=max_increments, initial_inc_size=initial_inc_size, min_inc_size=min_inc_size, + max_inc_size=max_inc_size, time=time, nlgeom=nlgeom, modify=modify, **kwargs, ) - def add_node_pattern(self, nodes, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): - """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the - ``Step`` at specific points. - - Parameters - ---------- - name : str - name of the point load - x : float, optional - x component (in global coordinates) of the point load, by default None - y : float, optional - y component (in global coordinates) of the point load, by default None - z : float, optional - z component (in global coordinates) of the point load, by default None - xx : float, optional - moment about the global x axis of the point load, by default None - yy : float, optional - moment about the global y axis of the point load, by default None - zz : float, optional - moment about the global z axis of the point load, by default None - axes : str, optional - 'local' or 'global' axes, by default 'global' - - Returns - ------- - :class:`compas_fea2.problem.PointLoad` - - Warnings - -------- - local axes are not supported yet - - """ - from compas_fea2.problem import ConcentratedLoad - - return self.add_load_pattern(NodeLoadPattern(load=ConcentratedLoad(x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, axes=axes), nodes=nodes, load_case=load_case, **kwargs)) - - def add_point_pattern(self, points, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", tolerance=None, **kwargs): - """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the - ``Step`` at specific points. - - Parameters - ---------- - name : str - name of the point load - x : float, optional - x component (in global coordinates) of the point load, by default None - y : float, optional - y component (in global coordinates) of the point load, by default None - z : float, optional - z component (in global coordinates) of the point load, by default None - xx : float, optional - moment about the global x axis of the point load, by default None - yy : float, optional - moment about the global y axis of the point load, by default None - zz : float, optional - moment about the global z axis of the point load, by default None - axes : str, optional - 'local' or 'global' axes, by default 'global' - - Returns - ------- - :class:`compas_fea2.problem.PointLoad` - - Warnings - -------- - local axes are not supported yet - - """ - return self.add_load_pattern(PointLoadPattern(points=points, x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, load_case=load_case, axes=axes, tolerance=tolerance, **kwargs)) - - def add_prestress_load(self): - raise NotImplementedError - - def add_line_load(self, polyline, load_case=None, discretization=10, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", tolerance=None, **kwargs): - """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the - ``Step`` along a prescribed path. - - Parameters - ---------- - name : str - name of the point load - part : str - name of the :class:`compas_fea2.problem.DeformablePart` where the load is applied - where : int or list(int), obj - It can be either a key or a list of keys, or a NodesGroup of the nodes where the load is - applied. - x : float, optional - x component (in global coordinates) of the point load, by default None - y : float, optional - y component (in global coordinates) of the point load, by default None - z : float, optional - z component (in global coordinates) of the point load, by default None - xx : float, optional - moment about the global x axis of the point load, by default None - yy : float, optional - moment about the global y axis of the point load, by default None - zz : float, optional - moment about the global z axis of the point load, by default None - axes : str, optional - 'local' or 'global' axes, by default 'global' - - Returns - ------- - :class:`compas_fea2.problem.PointLoad` - - Warnings - -------- - local axes are not supported yet - - """ - return self.add_load_pattern( - LineLoadPattern(polyline=polyline, x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, load_case=load_case, axes=axes, tolerance=tolerance, discretization=discretization, **kwargs) + def __data__(self): + return { + "max_increments": self.max_increments, + "initial_inc_size": self.initial_inc_size, + "min_inc_size": self.min_inc_size, + "time": self.time, + "nlgeom": self.nlgeom, + "modify": self.modify, + # Add other attributes as needed + } + + @classmethod + def __from_data__(cls, data): + return cls( + max_increments=data["max_increments"], + initial_inc_size=data["initial_inc_size"], + min_inc_size=data["min_inc_size"], + time=data["time"], + nlgeom=data["nlgeom"], + modify=data["modify"], + # Add other attributes as needed ) - def add_areaload_pattern(self, polygon, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): - """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the - ``Step`` along a prescribed path. - - Parameters - ---------- - name : str - name of the point load - part : str - name of the :class:`compas_fea2.problem.DeformablePart` where the load is applied - where : int or list(int), obj - It can be either a key or a list of keys, or a NodesGroup of the nodes where the load is - applied. - x : float, optional - x component (in global coordinates) of the point load, by default None - y : float, optional - y component (in global coordinates) of the point load, by default None - z : float, optional - z component (in global coordinates) of the point load, by default None - xx : float, optional - moment about the global x axis of the point load, by default None - yy : float, optional - moment about the global y axis of the point load, by default None - zz : float, optional - moment about the global z axis of the point load, by default None - axes : str, optional - 'local' or 'global' axes, by default 'global' - - Returns - ------- - :class:`compas_fea2.problem.PointLoad` - - Warnings - -------- - local axes are not supported yet - - """ - return self.add_load_pattern(AreaLoadPattern(polygon=polygon, x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, load_case=load_case, axes=axes, **kwargs)) - - def add_tributary_load(self): - raise NotImplementedError - - def add_gravity_load_pattern(self, parts, g=9.81, x=0.0, y=0.0, z=-1.0, load_case=None, **kwargs): - """Add a :class:`compas_fea2.problem.GravityLoad` load to the ``Step`` - - Parameters - ---------- - g : float, optional - acceleration of gravity, by default 9.81 - x : float, optional - x component of the gravity direction vector (in global coordinates), by default 0. - y : [type], optional - y component of the gravity direction vector (in global coordinates), by default 0. - z : [type], optional - z component of the gravity direction vector (in global coordinates), by default -1. - distribution : [:class:`compas_fea2.model.PartsGroup`] | [:class:`compas_fea2.model.ElementsGroup`] - Group of parts or elements affected by gravity. - - Notes - ----- - The gravity field is applied to the whole model. To remove parts of the - model from the calculation of the gravity force, you can assign to them - a 0 mass material. - - Warnings - -------- - Be careful to assign a value of *g* consistent with the units in your - model! - - """ - raise NotImplementedError() - from compas_fea2.problem import GravityLoad - - return self.add_load_pattern(VolumeLoadPattern(load=GravityLoad(g=g, x=x, y=y, z=z, **kwargs), parts=parts, load_case=load_case, **kwargs)) - - # ========================================================================= - # Fields methods - # ========================================================================= - # FIXME change to pattern - def add_temperature_pattern(self, field, node): - raise NotImplementedError() - # if not isinstance(field, PrescribedTemperatureField): - # raise TypeError("{!r} is not a PrescribedTemperatureField.".format(field)) - - # if not isinstance(node, Node): - # raise TypeError("{!r} is not a Node.".format(node)) - - # node._temperature = field - # self._fields.setdefault(node.part, {}).setdefault(field, set()).add(node) - # return field - - # ========================================================================= - # Displacements methods - # ========================================================================= - def add_displacement(self, nodes, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): - """Add a displacement at give nodes to the Step object. - - Parameters - ---------- - displacement : obj - :class:`compas_fea2.problem.GeneralDisplacement` object. - - Returns - ------- - None - - """ - raise NotImplementedError() - # if axes != "global": - # raise NotImplementedError("local axes are not supported yet") - # displacement = GeneralDisplacement(x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, axes=axes, , **kwargs) - # if not isinstance(nodes, Iterable): - # nodes = [nodes] - # return self.add_load_pattern(Pattern(value=displacement, distribution=nodes)) - class StaticRiksStep(StaticStep): """Step for use in a static analysis when Riks method is necessary.""" diff --git a/src/compas_fea2/problem/steps/step.py b/src/compas_fea2/problem/steps/step.py index 356d4e3a4..f7507403e 100644 --- a/src/compas_fea2/problem/steps/step.py +++ b/src/compas_fea2/problem/steps/step.py @@ -1,11 +1,20 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Iterable + +from compas.geometry import Point +from compas.geometry import Vector +from compas.geometry import centroid_points_weighted +from compas.geometry import sum_vectors from compas_fea2.base import FEAData from compas_fea2.problem.displacements import GeneralDisplacement -from compas_fea2.problem.fields import _PrescribedField -from compas_fea2.problem.loads import Load +from compas_fea2.problem.fields import DisplacementField +from compas_fea2.problem.fields import NodeLoadField +from compas_fea2.problem.fields import PointLoadField +from compas_fea2.results import DisplacementFieldResults +from compas_fea2.results import ReactionFieldResults +from compas_fea2.results import SectionForcesFieldResults +from compas_fea2.results import StressFieldResults +from compas_fea2.UI import FEA2Viewer # ============================================================================== # Base Steps @@ -45,14 +54,15 @@ class Step(FEAData): Developer-only class. """ - def __init__(self, **kwargs): + def __init__(self, replace=False, **kwargs): super(Step, self).__init__(**kwargs) + self.replace = replace self._field_outputs = set() self._history_outputs = set() self._results = None self._key = None - self._patterns = set() + self._load_fields = set() self._load_cases = set() self._combination = None @@ -62,7 +72,7 @@ def problem(self): @property def model(self): - return self.problem._registration + return self.problem.model @property def field_outputs(self): @@ -73,8 +83,8 @@ def load_cases(self): return self._load_cases @property - def load_patterns(self): - return self._patterns + def load_fields(self): + return self._load_fields @property def combination(self): @@ -99,17 +109,17 @@ def combination(self, combination): # for case in combination.load_cases: # if case not in self._load_cases: # raise ValueError(f"{case} is not a valid load case.") - for pattern in self.load_patterns: - if pattern.load_case in combination.load_cases: - factor = combination.factors[pattern.load_case] - for node, load in pattern.node_load: + for field in self.load_fields: + if field.load_case in combination.load_cases: + factor = combination.factors[field.load_case] + for node, load in field.node_load: factored_load = factor * load - node.loads.setdefault(self, {}).setdefault(combination, {})[pattern] = factored_load - if node.total_load: - node.total_load += factored_load + node.loads.setdefault(self, {}).setdefault(combination, {})[field] = factored_load + if node._total_load: + node._total_load += factored_load else: - node.total_load = factored_load + node._total_load = factored_load @property def history_outputs(self): @@ -119,16 +129,15 @@ def history_outputs(self): def results(self): return self._results - @property - def key(self): - return self._key - + # ========================================================================== + # Field outputs + # ========================================================================== def add_output(self, output): """Request a field or history output. Parameters ---------- - output : :class:`compas_fea2.problem._Output` + output : :class:`compas_fea2.Results.FieldResults` The requested output. Returns @@ -141,8 +150,8 @@ def add_output(self, output): TypeError if the output is not an instance of an :class:`compas_fea2.problem._Output`. """ - output._registration = self - self._field_outputs.add(output) + # output._registration = self + self._field_outputs.add(output(self)) return output def add_outputs(self, outputs): @@ -169,6 +178,50 @@ def add_outputs(self, outputs): # ========================================================================== # Results methods # ========================================================================== + @property + def displacement_field(self): + return DisplacementFieldResults(self) + + @property + def reaction_field(self): + return ReactionFieldResults(self) + + @property + def temperature_field(self): + raise NotImplementedError + + @property + def stress_field(self): + return StressFieldResults(self) + + @property + def section_forces_field(self): + return SectionForcesFieldResults(self) + + def __data__(self): + return { + "name": self.name, + "field_outputs": list(self._field_outputs), + "history_outputs": list(self._history_outputs), + "results": self._results, + "key": self._key, + "patterns": list(self._load_fields), + "load_cases": list(self._load_cases), + "combination": self._combination, + } + + @classmethod + def __from_data__(cls, data): + obj = cls() + obj.name = data["name"] + obj._field_outputs = set(data["field_outputs"]) + obj._history_outputs = set(data["history_outputs"]) + obj._results = data["results"] + obj._key = data["key"] + obj._load_fields = set(data["load_fields"]) + obj._load_cases = set(data["load_cases"]) + obj._combination = data["combination"] + return obj # ============================================================================== @@ -242,34 +295,24 @@ class GeneralStep(Step): """ - def __init__(self, max_increments, initial_inc_size, min_inc_size, time, nlgeom=False, modify=False, restart=False, **kwargs): + def __init__(self, max_increments, initial_inc_size, min_inc_size, max_inc_size, time, nlgeom=False, modify=False, restart=False, **kwargs): super(GeneralStep, self).__init__(**kwargs) - self._max_increments = max_increments self._initial_inc_size = initial_inc_size self._min_inc_size = min_inc_size + self._max_inc_size = max_inc_size self._time = time self._nlgeom = nlgeom self._modify = modify self._restart = restart - self._patterns = set() - self._load_cases = set() - - @property - def patterns(self): - return self._patterns @property def displacements(self): - return list(filter(lambda p: isinstance(p.load, GeneralDisplacement), self._patterns)) - - @property - def load_patterns(self): - return list(filter(lambda p: isinstance(p.load, Load), self._patterns)) + return list(filter(lambda p: isinstance(p, DisplacementField), self._load_fields)) @property - def fields(self): - return list(filter(lambda p: isinstance(p.load, _PrescribedField), self._patterns)) + def loads(self): + return list(filter(lambda p: not isinstance(p, DisplacementField), self._load_fields)) @property def max_increments(self): @@ -283,13 +326,17 @@ def initial_inc_size(self): def min_inc_size(self): return self._min_inc_size + @property + def max_inc_size(self): + return self._max_inc_size + @property def time(self): return self._time @property - def nlgeometry(self): - return self.nlgeom + def nlgeom(self): + return self._nlgeom @property def modify(self): @@ -304,9 +351,9 @@ def restart(self, value): self._restart = value # ============================================================================== - # Patterns + # Load Fields # ============================================================================== - def add_load_pattern(self, load_pattern): + def add_load_field(self, field, *kwargs): """Add a general :class:`compas_fea2.problem.patterns.Pattern` to the Step. Parameters @@ -319,31 +366,22 @@ def add_load_pattern(self, load_pattern): :class:`compas_fea2.problem.patterns.Pattern` """ - from compas_fea2.problem.patterns import Pattern - - if not isinstance(load_pattern, Pattern): - raise TypeError("{!r} is not a LoadPattern.".format(load_pattern)) - - # FIXME: ugly... - try: - if self.problem: - if self.model: - if not list(load_pattern.distribution).pop().model == self.model: - raise ValueError("The load pattern is not applied to a valid reagion of {!r}".format(self.model)) - except Exception: - pass - - self._patterns.add(load_pattern) - self._load_cases.add(load_pattern.load_case) - load_pattern._registration = self - return load_pattern - - def add_load_patterns(self, load_patterns): + from compas_fea2.problem.fields import LoadField + + if not isinstance(field, LoadField): + raise TypeError("{!r} is not a LoadPattern.".format(field)) + + self._load_fields.add(field) + self._load_cases.add(field.load_case) + field._registration = self + return field + + def add_load_fields(self, fields): """Add multiple :class:`compas_fea2.problem.patterns.Pattern` to the Problem. Parameters ---------- - load_patterns : list(:class:`compas_fea2.problem.patterns.Pattern`) + patterns : list(:class:`compas_fea2.problem.patterns.Pattern`) The load patterns to add to the Problem. Returns @@ -351,9 +389,459 @@ def add_load_patterns(self, load_patterns): list(:class:`compas_fea2.problem.patterns.Pattern`) """ - for load_pattern in load_patterns: - self.add_load_pattern(load_pattern) + fields = fields if isinstance(fields, Iterable) else [fields] + for field in fields: + self.add_load_field(field) + + def add_uniform_node_load(self, nodes, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): + """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the + ``Step`` at specific points. + + Parameters + ---------- + name : str + name of the point load + x : float, optional + x component (in global coordinates) of the point load, by default None + y : float, optional + y component (in global coordinates) of the point load, by default None + z : float, optional + z component (in global coordinates) of the point load, by default None + xx : float, optional + moment about the global x axis of the point load, by default None + yy : float, optional + moment about the global y axis of the point load, by default None + zz : float, optional + moment about the global z axis of the point load, by default None + axes : str, optional + 'local' or 'global' axes, by default 'global' + + Returns + ------- + :class:`compas_fea2.problem.PointLoad` + + Warnings + -------- + local axes are not supported yet + + """ + from compas_fea2.problem import ConcentratedLoad + + return self.add_load_field(NodeLoadField(loads=ConcentratedLoad(x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, axes=axes), nodes=nodes, load_case=load_case, **kwargs)) + + def add_uniform_point_load(self, points, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", tolerance=None, **kwargs): + """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the + ``Step`` at specific points. + + Parameters + ---------- + name : str + name of the point load + x : float, optional + x component (in global coordinates) of the point load, by default None + y : float, optional + y component (in global coordinates) of the point load, by default None + z : float, optional + z component (in global coordinates) of the point load, by default None + xx : float, optional + moment about the global x axis of the point load, by default None + yy : float, optional + moment about the global y axis of the point load, by default None + zz : float, optional + moment about the global z axis of the point load, by default None + axes : str, optional + 'local' or 'global' axes, by default 'global' + + Returns + ------- + :class:`compas_fea2.problem.PointLoad` + + Warnings + -------- + local axes are not supported yet + + """ + return self.add_load_field(PointLoadField(points=points, x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, load_case=load_case, axes=axes, tolerance=tolerance, **kwargs)) + + def add_prestress_load(self): + raise NotImplementedError + + def add_line_load(self, polyline, load_case=None, discretization=10, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", tolerance=None, **kwargs): + """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the + ``Step`` along a prescribed path. + + Parameters + ---------- + name : str + name of the point load + part : str + name of the :class:`compas_fea2.problem.Part` where the load is applied + where : int or list(int), obj + It can be either a key or a list of keys, or a NodesGroup of the nodes where the load is + applied. + x : float, optional + x component (in global coordinates) of the point load, by default None + y : float, optional + y component (in global coordinates) of the point load, by default None + z : float, optional + z component (in global coordinates) of the point load, by default None + xx : float, optional + moment about the global x axis of the point load, by default None + yy : float, optional + moment about the global y axis of the point load, by default None + zz : float, optional + moment about the global z axis of the point load, by default None + axes : str, optional + 'local' or 'global' axes, by default 'global' + + Returns + ------- + :class:`compas_fea2.problem.PointLoad` + + Warnings + -------- + local axes are not supported yet + + """ + raise NotImplementedError("Line loads are not implemented yet.") + + def add_area_load(self, polygon, load_case=None, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): + """Add a :class:`compas_fea2.problem.PointLoad` subclass object to the + ``Step`` along a prescribed path. + + Parameters + ---------- + name : str + name of the point load + part : str + name of the :class:`compas_fea2.problem.Part` where the load is applied + where : int or list(int), obj + It can be either a key or a list of keys, or a NodesGroup of the nodes where the load is + applied. + x : float, optional + x component (in global coordinates) of the point load, by default None + y : float, optional + y component (in global coordinates) of the point load, by default None + z : float, optional + z component (in global coordinates) of the point load, by default None + xx : float, optional + moment about the global x axis of the point load, by default None + yy : float, optional + moment about the global y axis of the point load, by default None + zz : float, optional + moment about the global z axis of the point load, by default None + axes : str, optional + 'local' or 'global' axes, by default 'global' + + Returns + ------- + :class:`compas_fea2.problem.PointLoad` + + Warnings + -------- + local axes are not supported yet + """ + from compas_fea2.problem import ConcentratedLoad + + loaded_faces = self.model.find_faces_in_polygon(polygon) + nodes = [] + loads = [] + components = {"x": x or 0, "y": y or 0, "z": z or 0, "xx": xx or 0, "yy": yy or 0, "zz": zz or 0} + for face in loaded_faces: + for node, area in face.node_area: + nodes.append(node) + factored_components = {k: v * area for k, v in components.items()} + loads.append(ConcentratedLoad(**factored_components)) + load_field = NodeLoadField(loads=loads, nodes=nodes, load_case=load_case, **kwargs) + return self.add_load_field(load_field) + + def add_gravity_load(self, parts=None, g=9.81, x=0.0, y=0.0, z=-1.0, load_case=None, **kwargs): + """Add a :class:`compas_fea2.problem.GravityLoad` load to the ``Step`` + + Parameters + ---------- + g : float, optional + acceleration of gravity, by default 9.81 + x : float, optional + x component of the gravity direction vector (in global coordinates), by default 0. + y : [type], optional + y component of the gravity direction vector (in global coordinates), by default 0. + z : [type], optional + z component of the gravity direction vector (in global coordinates), by default -1. + distribution : [:class:`compas_fea2.model.PartsGroup`] | [:class:`compas_fea2.model.ElementsGroup`] + Group of parts or elements affected by gravity. + + Notes + ----- + The gravity field is applied to the whole model. To remove parts of the + model from the calculation of the gravity force, you can assign to them + a 0 mass material. + + Warnings + -------- + Be careful to assign a value of *g* consistent with the units in your + model! + """ + # try: + # from compas_fea2.problem import GravityLoad + # gravity = GravityLoad(x=x, y=y, z=z, g=g, load_case=load_case, **kwargs) + # except ImportError: + from compas_fea2.problem import ConcentratedLoad + + parts = parts or self.model.parts + nodes = [] + loads = [] + for part in parts: + part.compute_nodal_masses() + for node in part.nodes: + nodes.append(node) + loads.append(ConcentratedLoad(x=node.mass[0] * g * x, y=node.mass[1] * g * y, z=node.mass[2] * g * z)) + load_field = NodeLoadField(loads=loads, nodes=nodes, load_case=load_case, **kwargs) + self.add_load_field(load_field) + + def add_temperature_field(self, field, node): + """Add a temperature field to the Step object. + + Parameters + ---------- + field : :class:`compas_fea2.problem.fields.PrescribedTemperatureField` + The temperature field to add. + node : :class:`compas_fea2.model.Node` + The node to which the temperature field is applied. + + Returns + ------- + :class:`compas_fea2.problem.fields.PrescribedTemperatureField` + The temperature field that was added. + """ + raise NotImplementedError() + # if not isinstance(field, PrescribedTemperatureField): + # raise TypeError("{!r} is not a PrescribedTemperatureField.".format(field)) + + # if not isinstance(node, Node): + # raise TypeError("{!r} is not a Node.".format(node)) + + # node._temperature = field + # self._fields.setdefault(node.part, {}).setdefault(field, set()).add(node) + # return field + + def add_uniform_displacement_field(self, nodes, x=None, y=None, z=None, xx=None, yy=None, zz=None, axes="global", **kwargs): + """Add a displacement at give nodes to the Step object. + + Parameters + ---------- + displacement : obj + :class:`compas_fea2.problem.GeneralDisplacement` object. + + Returns + ------- + None + + """ + from compas_fea2.problem import DisplacementField + + displacement = GeneralDisplacement(x=x, y=y, z=z, xx=xx, yy=yy, zz=zz, axes=axes, **kwargs) + return self.add_load_field(DisplacementField(displacement, nodes)) + + # ============================================================================== + # Combinations + # ============================================================================== + + # ========================================================================= + # Results methods - reactions + # ========================================================================= + def get_total_reaction(self, step=None): + """Compute the total reaction vector + + Parameters + ---------- + step : :class:`compas_fea2.problem._Step`, optional + The analysis step, by default the last step. + + Returns + ------- + :class:`compas.geometry.Vector` + The resultant vector. + :class:`compas.geometry.Point` + The application point. + """ + if not step: + step = self.steps_order[-1] + reactions = self.reaction_field + locations, vectors, vectors_lengths = [], [], [] + for reaction in reactions.results(step): + locations.append(reaction.location.xyz) + vectors.append(reaction.vector) + vectors_lengths.append(reaction.vector.length) + return Vector(*sum_vectors(vectors)), Point(*centroid_points_weighted(locations, vectors_lengths)) + + def get_min_max_reactions(self, step=None): + """Get the minimum and maximum reaction values for the last step. + + Parameters + ---------- + step : _type_, optional + _description_, by default None + """ + if not step: + step = self.steps_order[-1] + reactions = self.reaction_field + return reactions.get_limits_absolute(step) + + def get_min_max_reactions_component(self, component, step=None): + """Get the minimum and maximum reaction values for the last step. + + Parameters + ---------- + component : _type_ + _description_ + step : _type_, optional + _description_, by default None + """ + if not step: + step = self.steps_order[-1] + reactions = self.reaction_field + return reactions.get_limits_component(component, step) + + # def get_total_moment(self, step=None): + # if not step: + # step = self.steps_order[-1] + # vector, location = self.get_total_reaction(step) + + # return sum_vectors([reaction.vector for reaction in reactions.results]) # ============================================================================== - # Combination + # Visualisation # ============================================================================== + + def show_deformed(self, opacity=1, show_bcs=1, scale_results=1, scale_model=1, show_loads=0.1, show_original=False, **kwargs): + """Display the structure in its deformed configuration. + + Parameters + ---------- + step : :class:`compas_fea2.problem._Step`, optional + The Step of the analysis, by default None. If not provided, the last + step is used. + + Returns + ------- + None + + """ + viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) + + if show_original: + viewer.add_model(self.model, fast=True, opacity=show_original, show_bcs=False, **kwargs) + # TODO create a copy of the model first + displacements = self.displacement_field + for displacement in displacements.results: + vector = displacement.vector.scaled(scale_results) + displacement.node.xyz = sum_vectors([Vector(*displacement.node.xyz), vector]) + viewer.add_model(self.model, fast=True, opacity=opacity, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + if show_loads: + viewer.add_step(self, show_loads=show_loads) + viewer.show() + + def show_displacements(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=True, show_contour=True, **kwargs): + """Display the displacement field results for a given step. + + Parameters + ---------- + step : _type_, optional + _description_, by default None + scale_model : int, optional + _description_, by default 1 + show_loads : bool, optional + _description_, by default True + component : _type_, optional + _description_, by default + + """ + if not self.displacement_field: + raise ValueError("No displacement field results available for this step") + + viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) + viewer.add_model(self.model, fast=fast, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + viewer.add_displacement_field(self.displacement_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + if show_loads: + viewer.add_step(self, show_loads=show_loads) + viewer.show() + viewer.scene.clear() + + def show_reactions(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contour=False, **kwargs): + """Display the reaction field results for a given step. + + Parameters + ---------- + step : _type_, optional + _description_, by default None + scale_model : int, optional + _description_, by default 1 + show_bcs : bool, optional + _description_, by default True + component : _type_, optional + _description_, by default + translate : _type_, optional + _description_, by default -1 + scale_results : _type_, optional + _description_, by default 1 + """ + if not self.reaction_field: + raise ValueError("No reaction field results available for this step") + + viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) + viewer.add_model(self.model, fast=fast, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + viewer.add_reaction_field(self.reaction_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, **kwargs) + + if show_loads: + viewer.add_step(self, show_loads=show_loads) + viewer.show() + viewer.scene.clear() + + def show_stress(self, fast=True, show_bcs=1, scale_model=1, show_loads=0.1, component=None, show_vectors=1, show_contour=False, plane="mid", **kwargs): + if not self.stress_field: + raise ValueError("No reaction field results available for this step") + + viewer = FEA2Viewer(center=self.model.center, scale_model=scale_model) + viewer.add_model(self.model, fast=fast, show_parts=True, opacity=0.5, show_bcs=show_bcs, show_loads=show_loads, **kwargs) + viewer.add_stress2D_field(self.stress_field, fast=fast, model=self.model, component=component, show_vectors=show_vectors, show_contour=show_contour, plane=plane, **kwargs) + + if show_loads: + viewer.add_step(self, show_loads=show_loads) + viewer.show() + viewer.scene.clear() + + def __data__(self): + data = super(GeneralStep, self).__data__() + data.update( + { + "max_increments": self._max_increments, + "initial_inc_size": self._initial_inc_size, + "min_inc_size": self._min_inc_size, + "time": self._time, + "nlgeom": self._nlgeom, + "modify": self._modify, + "restart": self._restart, + } + ) + return data + + @classmethod + def __from_data__(cls, data): + obj = cls( + max_increments=data["max_increments"], + initial_inc_size=data["initial_inc_size"], + min_inc_size=data["min_inc_size"], + time=data["time"], + nlgeom=data["nlgeom"], + modify=data["modify"], + restart=data["restart"], + ) + obj._field_outputs = set(data["field_outputs"]) + obj._history_outputs = set(data["history_outputs"]) + obj._results = data["results"] + obj._key = data["key"] + obj._load_fields = set(data["patterns"]) + obj._load_cases = set(data["load_cases"]) + obj._combination = data["combination"] + return obj diff --git a/src/compas_fea2/problem/steps_combinations.py b/src/compas_fea2/problem/steps_combinations.py index 7a36914fc..730baad47 100644 --- a/src/compas_fea2/problem/steps_combinations.py +++ b/src/compas_fea2/problem/steps_combinations.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas_fea2.base import FEAData diff --git a/src/compas_fea2/results/__init__.py b/src/compas_fea2/results/__init__.py index 87b6e7085..bee254bf1 100644 --- a/src/compas_fea2/results/__init__.py +++ b/src/compas_fea2/results/__init__.py @@ -1,17 +1,13 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .results import ( Result, DisplacementResult, AccelerationResult, VelocityResult, + ReactionResult, StressResult, MembraneStressResult, ShellStressResult, SolidStressResult, - ModalAnalysisResult, ) from .fields import ( @@ -20,6 +16,12 @@ VelocityFieldResults, StressFieldResults, ReactionFieldResults, + SectionForcesFieldResults, + ContactForcesFieldResults, +) + +from .modal import ( + ModalAnalysisResult, ModalShape, ) @@ -29,6 +31,7 @@ "DisplacementResult", "AccelerationResult", "VelocityResult", + "ReactionResult", "StressResult", "MembraneStressResult", "ShellStressResult", @@ -38,6 +41,8 @@ "VelocityFieldResults", "ReactionFieldResults", "StressFieldResults", + "ContactForcesFieldResults", + "SectionForcesFieldResults", "ModalAnalysisResult", "ModalShape", ] diff --git a/src/compas_fea2/results/database.py b/src/compas_fea2/results/database.py index 6d0290f89..2d4031639 100644 --- a/src/compas_fea2/results/database.py +++ b/src/compas_fea2/results/database.py @@ -1,35 +1,172 @@ +import json import sqlite3 +import h5py +import numpy as np -class ResultsDatabase: +from compas_fea2.base import FEAData + + +class ResultsDatabase(FEAData): + def __init__(self, problem, **kwargs): + super().__init__(**kwargs) + self._registration = problem + + @property + def problem(self): + return self._registration + + @property + def model(self): + return self.problem.model + + @classmethod + def sqlite(cls, problem, **kwargs): + return SQLiteResultsDatabase(problem, **kwargs) + + @classmethod + def hdf5(cls, problem, **kwargs): + return HDF5ResultsDatabase(problem, **kwargs) + + @classmethod + def json(cls, problem, **kwargs): + return JSONResultsDatabase(problem, **kwargs) + + +class JSONResultsDatabase(ResultsDatabase): + def __init__(self, problem, **kwargs): + super().__init__(problem=problem, **kwargs) + + def get_results_set(self, filename, field): + """Get a set of results from a JSON file.""" + with open(filename, "r") as f: + data = json.load(f) + return data[field] + + +class HDF5ResultsDatabase(ResultsDatabase): + """HDF5 wrapper class to store and access FEA results.""" + + def __init__(self, problem, **kwargs): + super().__init__(problem, **kwargs) + + def save_to_hdf5(self, key, data): + """Save data to the HDF5 database.""" + with h5py.File(self.db_path, "a") as hdf5_file: + group = hdf5_file.require_group(key) + for k, v in data.items(): + if isinstance(v, (int, float, np.ndarray)): + group.create_dataset(k, data=np.array(v)) + elif isinstance(v, list) and all(isinstance(i, (int, float)) for i in v): + group.create_dataset(k, data=np.array(v, dtype="f8")) + elif isinstance(v, list) and all(isinstance(i, FEAData) for i in v): + # sub_group = group.require_group(k) + for i, obj in enumerate(v): + obj.save_to_hdf5(self.db_path, f"{key}/{k}/element_{i}") + elif isinstance(v, dict): + group.attrs[k] = json.dumps(v) + elif isinstance(v, str): + group.attrs[k] = v + else: + print(f"Warning: Skipping {k} (Unsupported type {type(v)})") + + def load_from_hdf5(self, key): + """Load data from the HDF5 database.""" + data = {} + with h5py.File(self.db_path, "r") as hdf5_file: + if key not in hdf5_file: + raise KeyError(f"Key '{key}' not found in HDF5 database.") + group = hdf5_file[key] + for k in group.keys(): + if k.startswith("element_"): + data.setdefault("elements", {})[int(k.split("_")[-1])] = self.load_from_hdf5(f"{key}/{k}") + else: + dataset = group[k][:] + data[k] = dataset.tolist() if dataset.shape != () else dataset.item() + for k, v in group.attrs.items(): + if isinstance(v, str) and (v.startswith("[") or v.startswith("{")): + try: + data[k] = json.loads(v) + except json.JSONDecodeError: + data[k] = v + else: + data[k] = v + return data + + def extract_field(self, field_name): + """Extract a specific field group from the HDF5 database.""" + with h5py.File(self.db_path, "r") as hdf5_file: + if field_name in hdf5_file: + return {key: hdf5_file[field_name][key][:] for key in hdf5_file[field_name].keys()} + else: + raise KeyError(f"Field '{field_name}' not found in HDF5 database.") + + def get_max_value(self, field_name): + """Get the maximum value of a specific field.""" + with h5py.File(self.db_path, "r") as hdf5_file: + if field_name in hdf5_file: + return max([np.max(hdf5_file[field_name][key][:]) for key in hdf5_file[field_name].keys()]) + else: + raise KeyError(f"Field '{field_name}' not found in HDF5 database.") + + def get_results_at_element(self, element_id): + """Retrieve results for a specific element.""" + with h5py.File(self.db_path, "r") as hdf5_file: + element_key = f"elements/element_{element_id}" + if element_key in hdf5_file: + return {key: hdf5_file[element_key][key][:] for key in hdf5_file[element_key].keys()} + else: + raise KeyError(f"Element '{element_id}' not found in HDF5 database.") + + def get_results_at_node(self, node_id): + """Retrieve results for a specific node.""" + with h5py.File(self.db_path, "r") as hdf5_file: + node_key = f"nodes/node_{node_id}" + if node_key in hdf5_file: + return {key: hdf5_file[node_key][key][:] for key in hdf5_file[node_key].keys()} + else: + raise KeyError(f"Node '{node_id}' not found in HDF5 database.") + + +class SQLiteResultsDatabase(ResultsDatabase): """sqlite3 wrapper class to access the SQLite database.""" - def __init__(self, db_uri): + def __init__(self, problem, **kwargs): """ - Initialize DataRetriever with the database URI. + Initialize ResultsDatabase with the database URI. Parameters ---------- - db_uri : str - The database URI. + problem : object + The problem instance containing the database path. """ - self.db_uri = db_uri + super().__init__(problem=problem, **kwargs) + + self.db_uri = problem.path_db self.connection = self.db_connection() self.cursor = self.connection.cursor() - def db_connection(self): + def db_connection(self, remove=False): """ Create and return a connection to the SQLite database. + Parameters + ---------- + remove : bool, optional + If True, remove the existing database before creating a new connection. + Returns ------- - connection : Connection + sqlite3.Connection The database connection. """ + if remove: + self._check_and_delete_existing_db() return sqlite3.connect(self.db_uri) def execute_query(self, query, params=None): - """Execute a previously-defined query. + """ + Execute a previously-defined query. Parameters ---------- @@ -55,35 +192,66 @@ def execute_query(self, query, params=None): # ========================================================================= @property def table_names(self): + """ + Get the names of all tables in the database. + + Returns + ------- + list + A list of table names. + """ self.cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") return [row[0] for row in self.cursor.fetchall()] @property def fields(self): + """ + Get the names of all fields in the database, excluding the 'fields' table. + + Returns + ------- + list + A list of field names. + """ return [c for c in self.table_names if c != "fields"] def column_names(self, table_name): + """ + Get the names of all columns in a given table. + + Parameters + ---------- + table_name : str + The name of the table. + + Returns + ------- + list + A list of column names. + """ self.cursor.execute(f"PRAGMA table_info({table_name});") return [row[1] for row in self.cursor.fetchall()] def get_table(self, table_name): - """Get a table from the database. + """ + Get a table from the database. Parameters ---------- table_name : str The name of the table. - Return - ------ - str - The table name. + Returns + ------- + list of tuples + The table data. """ - return table_name + query = f"SELECT * FROM {table_name}" + return self.execute_query(query) def get_column_values(self, table_name, column_name): - """Get all the rows in a given table that match the filtering criteria - and return the values for each column. + """ + Get all the values in a given column from a table. Parameters ---------- @@ -95,13 +263,14 @@ def get_column_values(self, table_name, column_name): Returns ------- list - The column values. + A list of values from the specified column. """ query = f"SELECT {column_name} FROM {table_name}" return self.execute_query(query) def get_column_unique_values(self, table_name, column_name): - """Get all the values in a column and remove duplicates. + """ + Get all the unique values in a given column from a table. Parameters ---------- @@ -117,8 +286,9 @@ def get_column_unique_values(self, table_name, column_name): """ return set(self.get_column_values(table_name, column_name)) - def get_rows(self, table_name, columns_names, filters): - """Get all the rows in a given table that match the filtering criteria + def get_rows(self, table_name, columns_names, filters, func=None): + """ + Get all the rows in a given table that match the filtering criteria and return the values for each column. Parameters @@ -130,45 +300,85 @@ def get_rows(self, table_name, columns_names, filters): order. filters : dict Filtering criteria as {"column_name":[admissible values]} + func : str, optional + SQL function to apply to the columns. Default is None. - Return - ------ + Returns + ------- list of lists - list with each row as a list + List with each row as a list. """ filter_conditions = " AND ".join([f"{k} IN ({','.join(['?' for _ in v])})" for k, v in filters.items()]) - query = f"SELECT {', '.join(columns_names)} FROM {table_name} WHERE {filter_conditions}" + if not func: + query = f"SELECT {', '.join(columns_names)} FROM {table_name} WHERE {filter_conditions}" + else: + query = f"SELECT {', '.join(columns_names)} FROM {table_name} WHERE {filter_conditions} ORDER BY ({func[1]}) {func[0]} LIMIT 1" params = [item for sublist in filters.values() for item in sublist] return self.execute_query(query, params) - def get_func_row(self, table_name, column_name, func, filters, columns_names): - """Get all the rows in a given table that match the filtering criteria - and apply a function to it. - - Currently supported functions: + # ========================================================================= + # FEA2 Methods + # ========================================================================= - - MAX: + def to_result(self, results_set, results_func, field_name): + """ + Convert a set of results in the database to the appropriate + result object. Parameters ---------- - table_name : str - The name of the table. - column_name : str - The name of the column. - func : str - The function to apply (e.g., "MAX"). - filters : dict - Filtering criteria as {"column_name":[admissible values]} - columns_names : list - Name of each column to retrieve. The results are output in the same - order. + results_set : list of tuples + The set of results retrieved from the database. + results_class : class + The class to instantiate for each result. + results_func : str + The function to call on the part to get the member. Returns ------- - list - The result row. + dict + Dictionary grouping the results per Step. """ - filter_conditions = " AND ".join([f"{k} IN ({','.join(['?' for _ in v])})}}" for k, v in filters.items()]) - query = f"SELECT {', '.join(columns_names)} FROM {table_name} WHERE {filter_conditions} ORDER BY {func}({column_name}) DESC LIMIT 1" - params = [item for sublist in filters.values() for item in sublist] - return self.execute_query(query, params) + results = {} + for r in results_set: + step = self.problem.find_step_by_name(r.pop("step")) + results.setdefault(step, []) + m = getattr(self.model, results_func)(r.pop("key"))[0] + if not m: + raise ValueError(f"Member not in {self.model}") + results[step].append(m.results_cls[field_name](m, **r)) + return results + + def create_table_for_output_class(self, output_cls, results): + """ + Reads the table schema from `output_cls.get_table_schema()` + and creates the table in the given database. + + Parameters + ---------- + output_cls : _Output subclass + A class like NodeOutput that implements `get_table_schema()`. + connection : sqlite3.Connection + SQLite3 connection object. + results : list of tuples + Data to be inserted into the table. + """ + cursor = self.connection.cursor() + + schema = output_cls.sqltable_schema + table_name = schema["table_name"] + columns_info = schema["columns"] + + # Build CREATE TABLE statement: + columns_sql_str = ", ".join([f"{col_name} {col_def}" for col_name, col_def in columns_info]) + create_sql = f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_sql_str})" + cursor.execute(create_sql) + self.connection.commit() + + # Insert data into the table: + insert_columns = [col_name for col_name, col_def in columns_info if "PRIMARY KEY" not in col_def.upper()] + col_names_str = ", ".join(insert_columns) + placeholders_str = ", ".join(["?"] * len(insert_columns)) + sql = f"INSERT INTO {table_name} ({col_names_str}) VALUES ({placeholders_str})" + cursor.executemany(sql, results) + self.connection.commit() diff --git a/src/compas_fea2/results/fields.py b/src/compas_fea2/results/fields.py index 200c845ec..3ff098617 100644 --- a/src/compas_fea2/results/fields.py +++ b/src/compas_fea2/results/fields.py @@ -1,32 +1,32 @@ +from itertools import groupby from typing import Iterable import numpy as np from compas.geometry import Frame -from compas.geometry import Point from compas.geometry import Transformation from compas.geometry import Vector from compas_fea2.base import FEAData -from compas_fea2.model import _Element2D -from compas_fea2.model import _Element3D -from .results import AccelerationResult -from .results import DisplacementResult -from .results import ReactionResult -from .results import ShellStressResult -from .results import SolidStressResult -from .results import VelocityResult +from .database import ResultsDatabase # noqa: F401 +from .results import AccelerationResult # noqa: F401 +from .results import DisplacementResult # noqa: F401 +from .results import ReactionResult # noqa: F401 +from .results import SectionForcesResult # noqa: F401 +from .results import ShellStressResult # noqa: F401 +from .results import SolidStressResult # noqa: F401 +from .results import VelocityResult # noqa: F401 class FieldResults(FEAData): """FieldResults object. This is a collection of Result objects that define a field. - The objects uses SQLite queries to efficiently retrieve the results from the results database. + The objects use SQLite queries to efficiently retrieve the results from the results database. - The field results are defined over multiple steps + The field results are defined over multiple steps. - You can use FieldResults to visualise a field over a part or the model, or to compute - global quantiies, such as maximum or minimum values. + You can use FieldResults to visualize a field over a part or the model, or to compute + global quantities, such as maximum or minimum values. Parameters ---------- @@ -43,10 +43,10 @@ class FieldResults(FEAData): The analysis step where the results are defined. problem : :class:`compas_fea2.problem.Problem` The Problem where the Step is registered. - model : :class:`compas_fea2.problem.Model + model : :class:`compas_fea2.problem.Model` The Model where the Step is registered. db_connection : :class:`sqlite3.Connection` | None - Connection object or None + Connection object or None. components : dict A dictionary with {"component name": component value} for each component of the result. invariants : dict @@ -54,300 +54,327 @@ class FieldResults(FEAData): Notes ----- - FieldResults are registered to a :class:`compas_fea2.problem.Problem`. - + FieldResults are registered to a :class:`compas_fea2.problem.Step`. """ - def __init__(self, problem, field_name, *args, **kwargs): + def __init__(self, step, *args, **kwargs): super(FieldResults, self).__init__(*args, **kwargs) - self._registration = problem - self._field_name = field_name - self._table = self.problem.results_db.get_table(field_name) - self._components_names = None - self._invariants_names = None - self._results_class = None - self._results_func = None + self._registration = step @property - def field_name(self): - return self._field_name + def sqltable_schema(self): + fields = [] + predefined_fields = [ + ("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), + ("key", "INTEGER"), + ("step", "TEXT"), + ("part", "TEXT"), + ] + + fields.extend(predefined_fields) + + for comp in self.components_names: + fields.append((comp, "REAL")) + return { + "table_name": self.field_name, + "columns": fields, + } @property - def problem(self): + def step(self) -> "_Step": # noqa: F821 return self._registration @property - def model(self): - return self.problem.model + def problem(self) -> "Problem": # noqa: F821 + return self.step.problem @property - def rdb(self): - return self.problem.results_db + def model(self) -> "Model": # noqa: F821 + return self.problem.model @property - def components_names(self): - return self._components_names + def field_name(self) -> str: + raise NotImplementedError("This method should be implemented in the subclass.") @property - def invariants_names(self): - return self._invariants_names + def results_func(self) -> str: + raise NotImplementedError("This method should be implemented in the subclass.") - def _get_db_results(self, members, steps, **kwargs): - """Get the results for the given members and steps in the database - format. - - Parameters - ---------- - members : _type_ - The FieldResults object containing the field results data. - steps : _type_ - _description_ + @property + def rdb(self) -> ResultsDatabase: + return self.problem.rdb - Returns - ------- - _type_ - _description_ - """ - if not isinstance(members, Iterable): - members = [members] - if not isinstance(steps, Iterable): - steps = [steps] - - members_keys = set([member.input_key for member in members]) - parts_names = set([member.part.name for member in members]) - steps_names = set([step.name for step in steps]) - - if isinstance(members[0], _Element3D): - columns = ["step", "part", "input_key"] + self._components_names_3d - field_name = self._field_name_3d - elif isinstance(members[0], _Element2D): - columns = ["step", "part", "input_key"] + self._components_names_2d - field_name = self._field_name_2d - else: - columns = ["step", "part", "input_key"] + self._components_names - field_name = self._field_name + @property + def results(self) -> list: + return self._get_results_from_db(columns=self.components_names)[self.step] - filters = {"input_key": members_keys, "part": parts_names, "step": steps_names} + @property + def results_sorted(self) -> list: + return sorted(self.results, key=lambda x: x.key) - if kwargs.get("mode", None): - filters["mode"] = set([kwargs['mode'] for member in members]) + @property + def locations(self) -> Iterable: + """Return the locations where the field is defined. - results_set = self.rdb.get_rows(field_name, columns, filters) - return results_set + Yields + ------ + :class:`compas.geometry.Point` + The location where the field is defined. + """ + for r in self.results: + yield r.location - def _to_result(self, results_set): - """Convert a set of results in database format to the appropriate - result object. + def _get_results_from_db(self, members=None, columns=None, filters=None, func=None, **kwargs): + """Get the results for the given members and steps. Parameters ---------- - results_set : _type_ - _description_ + members : list, optional + List of members to filter results. + columns : list, optional + List of columns to retrieve. + filters : dict, optional + Dictionary of filters to apply. Returns ------- - dic - Dictiorany grouping the results per Step. + dict + Dictionary of results. """ - results = {} - for r in results_set: - step = self.problem.find_step_by_name(r[0]) - results.setdefault(step, []) - part = self.model.find_part_by_name(r[1]) or self.model.find_part_by_name(r[1], casefold=True) - if not part: - raise ValueError(f"Part {r[1]} not in model") - m = getattr(part, self._results_func)(r[2]) - results[step].append(self._results_class(m, *r[3:])) - return results - - def get_results(self, members, steps, **kwargs): - """Get the results for the given members and steps. + if not columns: + columns = self.components_names - Parameters - ---------- - members : _type_ - _description_ - steps : _type_ - _description_ + if not filters: + filters = {} - Returns - ------- - _type_ - _description_ - """ - results_set = self._get_db_results(members, steps, **kwargs) - return self._to_result(results_set) + filters["step"] = [self.step.name] - def get_max_result(self, component, step, **kwargs): - """Get the result where a component is maximum for a given step. + if members: + if not isinstance(members, Iterable): + members = [members] + filters["key"] = set([member.key for member in members]) + filters["part"] = set([member.part.name for member in members]) + + all_columns = ["step", "part", "key"] + columns + + results_set = self.rdb.get_rows(self.field_name, all_columns, filters, func) + results_set = [{k: v for k, v in zip(all_columns, row)} for row in results_set] + + return self.rdb.to_result(results_set, results_func=self.results_func, field_name=self.field_name, **kwargs) + + def get_result_at(self, location): + """Get the result for a given location. Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ + location : object + The location to retrieve the result for. Returns ------- - :class:`compas_fea2.results.Result` - The appriate Result object. + object + The result at the given location. """ - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MAX", {"step": [step.name]}, self.results_columns) - return self._to_result(results_set)[step][0] - - def get_min_result(self, component, step, **kwargs): - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MIN", {"step": [step.name]}, self.results_columns) - return self._to_result(results_set)[step][0] + return self._get_results_from_db(members=location, columns=self.components_names)[self.step][0] - def get_max_component(self, component, step, **kwargs): + def get_max_result(self, component): """Get the result where a component is maximum for a given step. Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ + component : str + The component to retrieve the maximum result for. Returns ------- :class:`compas_fea2.results.Result` - The appriate Result object. + The appropriate Result object. """ - return self.get_max_result(component, step).vector[component - 1] + func = ["DESC", component] + return self._get_results_from_db(columns=self.components_names, func=func)[self.step][0] - def get_min_component(self, component, step, **kwargs): + def get_min_result(self, component): """Get the result where a component is minimum for a given step. Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ + component : str + The component to retrieve the minimum result for. Returns ------- :class:`compas_fea2.results.Result` The appropriate Result object. """ - return self.get_min_result(component, step).vector[component - 1] + func = ["ASC", component] + return self._get_results_from_db(columns=self.components_names, func=func)[self.step][0] - def get_limits_component(self, component, step, **kwargs): - """Get the result objects with the min and max value of a given - component in a step. + def get_limits_component(self, component): + """Get the result objects with the min and max value of a given component in a step. Parameters ---------- component : int - The index of the component to retrieve (e.g., 0 for the first component). - step : :class:`compas_fea2.problem.Step` - The analysis step where the results are defined. + The index of the component to retrieve. Returns ------- list A list containing the result objects with the minimum and maximum value of the given component in the step. """ - return [self.get_min_result(component, step), self.get_max_result(component, step)] + return [self.get_min_result(component, self.step), self.get_max_result(component, self.step)] - def get_limits_absolute(self, step, **kwargs): - limits = [] - for func in ["MIN", "MAX"]: - limits.append(self.rdb.get_func_row(self.field_name, "magnitude", func, {"step": [step.name]}, self.results_columns)) - return [self._to_result(limit)[step][0] for limit in limits] + def component_scalar(self, component): + """Return the value of selected component.""" + for result in self.results: + yield getattr(result, component, None) - def get_results_at_point(self, point, distance, plane=None, steps=None, **kwargs): - """Get the displacement of the model around a location (point). + def filter_by_component(self, component, threshold=None): + """Filter results by a specific component, optionally using a threshold. Parameters ---------- - point : [float] - The coordinates of the point. - steps : _type_, optional - _description_, by default None + componen : str + The name of the component to filter by (e.g., "Fx_1"). + threshold : float, optional + A threshold value to filter results. Only results above this value are included. Returns ------- dict - Dictionary with {step: result} - + A dictionary of filtered elements and their results. """ - nodes = self.model.find_nodes_around_point(point, distance, plane) - if not nodes: - print(f"WARNING: No nodes found at {point} within {distance}") - else: - results = [] - steps = steps or self.problem.steps - results = self.get_results(nodes, steps) - return results + if component not in self.components_names: + raise ValueError(f"Component '{component}' is not valid. Choose from {self.components_names}.") - def locations(self, step=None, point=False, **kwargs): - """Return the locations where the field is defined. + for result in self.results: + component_value = getattr(result, component, None) + if component_value is not None and (threshold is None or component_value >= threshold): + yield result - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + +# ------------------------------------------------------------------------------ +# Node Field Results +# ------------------------------------------------------------------------------ +class NodeFieldResults(FieldResults): + """Node field results. + + This class handles the node field results from a finite element analysis. + + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. + + Attributes + ---------- + components_names : list of str + Names of the node components. + invariants_names : list of str + Names of the invariants of the node field. + results_class : class + The class used to instantiate the node results. + results_func : str + The function used to find nodes by key. + """ + + def __init__(self, step, *args, **kwargs): + super(NodeFieldResults, self).__init__(step=step, *args, **kwargs) + self._results_func = "find_node_by_key" + + @property + def components_names(self): + return ["x", "y", "z", "rx", "ry", "rz"] + + @property + def field_name(self): + return self._field_name + + @property + def results_func(self): + return self._results_func + + @property + def vectors(self): + """Return the vectors where the field is defined. Yields ------ - :class:`compas.geometry.Point` - The location where the field is defined. + :class:`compas.geometry.Vector` + The vector where the field is defined. """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): - if point: - yield r.node.point - else: - yield r.node - - def vectors(self, step=None, **kwargs): - """Return the locations where the field is defined. + for r in self.results: + yield r.vector - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + @property + def vectors_rotation(self): + """Return the vectors where the field is defined. Yields ------ - :class:`compas.geometry.Point` - The location where the field is defined. + :class:`compas.geometry.Vector` + The vector where the field is defined. """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): - yield r.vector + for r in self.results: + yield r.vector_rotation - def component(self, step=None, component=None, **kwargs): - """Return the locations where the field is defined. + def compute_resultant(self, sub_set=None): + """Compute the translation resultant, moment resultant, and location of the field. Parameters ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + sub_set : list, optional + List of locations to filter the results. If None, all results are considered. - Yields - ------ - :class:`compas.geometry.Point` - The location where the field is defined. + Returns + ------- + tuple + The translation resultant as :class:`compas.geometry.Vector`, + moment resultant as :class:`compas.geometry.Vector`, + and location as a :class:`compas.geometry.Point`. """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): - if component is None: - yield r.vector.magnitude - else: - yield r.vector[component] - - -class DisplacementFieldResults(FieldResults): + from compas.geometry import Point + from compas.geometry import centroid_points_weighted + from compas.geometry import cross_vectors + from compas.geometry import sum_vectors + + results_subset = list(filter(lambda x: x.location in sub_set, self.results)) if sub_set else self.results + vectors = [r.vector for r in results_subset] + locations = [r.location.xyz for r in results_subset] + resultant_location = Point(*centroid_points_weighted(locations, [v.length for v in vectors])) + resultant_vector = sum_vectors(vectors) + moment_vector = sum_vectors(cross_vectors(Vector(*loc) - resultant_location, vec) for loc, vec in zip(locations, vectors)) + + return Vector(*resultant_vector), Vector(*moment_vector), resultant_location + + def components_vectors(self, components): + """Return a vector representing the given components.""" + for vector in self.vectors: + v_copy = vector.copy() + for c in ["x", "y", "z"]: + if c not in components: + setattr(v_copy, c, 0) + yield v_copy + + def components_vectors_rotation(self, components): + """Return a vector representing the given components.""" + for vector in self.results.vectors_rotation: + v_copy = vector.copy() + for c in ["x", "y", "z"]: + if c not in components: + setattr(v_copy, c, 0) + yield v_copy + + +class DisplacementFieldResults(NodeFieldResults): """Displacement field results. This class handles the displacement field results from a finite element analysis. - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. Attributes ---------- @@ -361,447 +388,442 @@ class DisplacementFieldResults(FieldResults): The function used to find nodes by key. """ - def __init__(self, problem, *args, **kwargs): - super(DisplacementFieldResults, self).__init__(problem=problem, field_name="u", *args, **kwargs) - self._components_names = ["ux", "uy", "uz", "uxx", "uyy", "uzz"] - self._invariants_names = ["magnitude"] - self._results_class = DisplacementResult - self._results_func = "find_node_by_key" - - def results(self, step): - nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] + def __init__(self, step, *args, **kwargs): + super(DisplacementFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "u" -class ModalShape(FieldResults): - """Displacement field results. +class AccelerationFieldResults(NodeFieldResults): + """Acceleration field results. - This class handles the displacement field results from a finite element analysis. + This class handles the acceleration field results from a finite element analysis. - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. Attributes ---------- components_names : list of str - Names of the displacement components. + Names of the acceleration components. invariants_names : list of str - Names of the invariants of the displacement field. + Names of the invariants of the acceleration field. results_class : class - The class used to instantiate the displacement results. + The class used to instantiate the acceleration results. results_func : str The function used to find nodes by key. """ - def __init__(self, problem, mode, *args, **kwargs): - super(ModalShape, self).__init__(problem=problem, field_name="eigenvectors", *args, **kwargs) - self._components_names = ["dof_1", "dof_2", "dof_3", "dof_4", "dof_5", "dof_6"] - self._invariants_names = ["magnitude"] - self._results_class = DisplacementResult - self._results_func = "find_node_by_key" - self.mode = mode - - def results(self, step): - nodes = self.model.nodes - return self.get_results(nodes, steps=step, mode=self.mode)[step] + def __init__(self, step, *args, **kwargs): + super(AccelerationFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "a" -class AccelerationFieldResults(FieldResults): - """Displacement field results. +class VelocityFieldResults(NodeFieldResults): + """Velocity field results. - This class handles the displacement field results from a finite element analysis. + This class handles the velocity field results from a finite element analysis. - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. Attributes ---------- components_names : list of str - Names of the displacement components. + Names of the velocity components. invariants_names : list of str - Names of the invariants of the displacement field. + Names of the invariants of the velocity field. results_class : class - The class used to instantiate the displacement results. + The class used to instantiate the velocity results. results_func : str The function used to find nodes by key. """ - def __init__(self, problem, *args, **kwargs): - super(AccelerationFieldResults, self).__init__(problem=problem, field_name="a", *args, **kwargs) - self._components_names = ["ax", "ay", "az", "axx", "ayy", "azz"] - self._invariants_names = ["magnitude"] - self._results_class = AccelerationResult - self._results_func = "find_node_by_key" - - def results(self, step): - nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] + def __init__(self, step, *args, **kwargs): + super(VelocityFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "v" -class VelocityFieldResults(FieldResults): - """Displacement field results. +class ReactionFieldResults(NodeFieldResults): + """Reaction field results. - This class handles the displacement field results from a finite element analysis. + This class handles the reaction field results from a finite element analysis. - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + Parameters + ---------- + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. Attributes ---------- components_names : list of str - Names of the displacement components. + Names of the reaction components. invariants_names : list of str - Names of the invariants of the displacement field. + Names of the invariants of the reaction field. results_class : class - The class used to instantiate the displacement results. + The class used to instantiate the reaction results. results_func : str The function used to find nodes by key. """ - def __init__(self, problem, *args, **kwargs): - super(VelocityFieldResults, self).__init__(problem=problem, field_name="v", *args, **kwargs) - self._components_names = ["vx", "vy", "vz", "vxx", "vyy", "vzz"] - self._invariants_names = ["magnitude"] - self._results_class = VelocityResult - self._results_func = "find_node_by_key" + def __init__(self, step, *args, **kwargs): + super(ReactionFieldResults, self).__init__(step=step, *args, **kwargs) + self._field_name = "rf" - def results(self, step): - nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] +class ContactForcesFieldResults(NodeFieldResults): + """Reaction field results. -class ReactionFieldResults(FieldResults): - """Reaction field. + This class handles the reaction field results from a finite element analysis. Parameters ---------- - problem : :class:`compas_fea2.problem.Problem` - The Problem where the Step is registered. + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. + + Attributes + ---------- + components_names : list of str + Names of the reaction components. + invariants_names : list of str + Names of the invariants of the reaction field. + results_class : class + The class used to instantiate the reaction results. + results_func : str + The function used to find nodes by key. """ - def __init__(self, problem, *args, **kwargs): - super(ReactionFieldResults, self).__init__(problem=problem, field_name="rf", *args, **kwargs) - self._components_names = ["rfx", "rfy", "rfz", "rfxx", "rfyy", "rfzz"] - self._invariants_names = ["magnitude"] - self._results_class = ReactionResult - self._results_func = "find_node_by_key" + def __init__(self, step, *args, **kwargs): + super().__init__(step=step, *args, **kwargs) + self._field_name = "c" + + +# ------------------------------------------------------------------------------ +# Section Forces Field Results +# ------------------------------------------------------------------------------ +class ElementFieldResults(FieldResults): + """Element field results. + + This class handles the element field results from a finite element analysis. + """ + + def __init__(self, step, *args, **kwargs): + super(ElementFieldResults, self).__init__(step=step, *args, **kwargs) + self._results_func = "find_element_by_key" - def results(self, step): - nodes = self.model.nodes - return self.get_results(nodes, steps=step)[step] +class SectionForcesFieldResults(ElementFieldResults): + """Section forces field results. -class StressFieldResults(FEAData): - """_summary_ + This class handles the section forces field results from a finite element analysis. Parameters ---------- - FieldResults : _type_ - _description_ + step : :class:`compas_fea2.problem._Step` + The analysis step where the results are defined. + + Attributes + ---------- + components_names : list of str + Names of the section forces components. + invariants_names : list of str + Names of the invariants of the section forces field. + results_class : class + The class used to instantiate the section forces results. + results_func : str + The function used to find elements by key. """ - def __init__(self, problem, *args, **kwargs): - super(StressFieldResults, self).__init__(*args, **kwargs) - self._registration = problem - self._components_names_2d = ["s11", "s22", "s12", "m11", "m22", "m12"] - self._components_names_3d = ["s11", "s22", "s23", "s12", "s13", "s33"] - self._field_name_2d = "s2d" - self._field_name_3d = "s3d" - self._results_class_2d = ShellStressResult - self._results_class_3d = SolidStressResult + def __init__(self, step, *args, **kwargs): + super(SectionForcesFieldResults, self).__init__(step=step, *args, **kwargs) self._results_func = "find_element_by_key" + self._field_name = "sf" @property def field_name(self): return self._field_name @property - def problem(self): - return self._registration - - @property - def model(self): - return self.problem.model - - @property - def rdb(self): - return self.problem.results_db + def results_func(self): + return self._results_func @property def components_names(self): - return self._components_names + return ["Fx1", "Fy1", "Fz1", "Mx1", "My1", "Mz1", "Fx2", "Fy2", "Fz2", "Mx2", "My2", "Mz2"] - @property - def invariants_names(self): - return self._invariants_names - - def _get_results_from_db(self, members, steps): - """Get the results for the given members and steps in the database - format. + def get_element_forces(self, element): + """Get the section forces for a given element. Parameters ---------- - members : _type_ - _description_ - steps : _type_ - _description_ + element : object + The element to retrieve the section forces for. Returns ------- - _type_ - _description_ + object + The section forces result for the specified element. """ - if not isinstance(members, Iterable): - members = [members] - if not isinstance(steps, Iterable): - steps = [steps] - - members_keys = {} - parts_names = {} - for member in members: - members_keys[member.input_key] = member - parts_names[member.part.name] = member.part - steps_names = {step.name: step for step in steps} - - if isinstance(members[0], _Element3D): - columns = ["step", "part", "input_key"] + self._components_names_3d - field_name = self._field_name_3d - elif isinstance(members[0], _Element2D): - columns = ["step", "part", "input_key"] + self._components_names_2d - field_name = self._field_name_2d - else: - raise ValueError("Not an element") + return self.get_result_at(element) - results_set = self.rdb.get_rows(field_name, columns, {"input_key": members_keys, "part": parts_names, "step": steps_names}) - return self._to_fea2_results(results_set, members_keys, steps_names) - - def _to_fea2_results(self, results_set, members_keys, steps_names): - """Convert a set of results from database format to the appropriate - result object. + def get_elements_forces(self, elements): + """Get the section forces for a list of elements. Parameters ---------- - results_set : _type_ - _description_ + elements : list + The elements to retrieve the section forces for. - Returns - ------- - dic - Dictiorany grouping the results per Step. + Yields + ------ + object + The section forces result for each element. """ - results = {} - - for r in results_set: - step = steps_names[r[0]] - m = members_keys[r[2]] - results.setdefault(step, []) - if isinstance(m, _Element3D): - cls = self._results_class_3d - columns = self._components_names_3d - else: - cls = self._results_class_2d - columns = self._components_names_2d - values = {k.lower(): v for k, v in zip(columns, r[3:])} - results[step].append(cls(m, **values)) - return results - - def get_max_component(self, component, step): - """Get the result where a component is maximum for a given step. + for element in elements: + yield self.get_element_forces(element) - Parameters - ---------- - component : _type_ - _description_ - step : _type_ - _description_ + def export_to_dict(self): + """Export all field results to a dictionary. Returns ------- - :class:`compas_fea2.results.Result` - The appriate Result object. + dict + A dictionary containing all section force results. """ - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MAX", {"step": [step.name]}, self.results_columns) - return self._to_fea2_results(results_set)[step][0] + raise NotImplementedError() - def get_min_component(self, component, step): - results_set = self.rdb.get_func_row(self.field_name, self.field_name + str(component), "MIN", {"step": [step.name]}, self.results_columns) - return self._to_fea2_results(results_set)[step][0] - - def get_limits_component(self, component, step): - """Get the result objects with the min and max value of a given - component in a step. + def export_to_csv(self, file_path): + """Export all field results to a CSV file. Parameters ---------- - component : _type_ - _description_ - step : _type_ - _description_ - - Returns - ------- - list(:class:`compas_fea2.results.StressResults) - _description_ + file_path : str + Path to the CSV file. """ - return [self.get_min_component(component, step), self.get_max_component(component, step)] + raise NotImplementedError() - def get_limits_absolute(self, step): - limits = [] - for func in ["MIN", "MAX"]: - limits.append(self.rdb.get_func_row(self.field_name, "magnitude", func, {"step": [step.name]}, self.results_columns)) - return [self._to_fea2_results(limit)[step][0] for limit in limits] - def get_results_at_point(self, point, distance, plane=None, steps=None): - """Get the displacement of the model around a location (point). +# ------------------------------------------------------------------------------ +# Stress Field Results +# ------------------------------------------------------------------------------ - Parameters - ---------- - point : [float] - The coordinates of the point. - steps : _type_, optional - _description_, by default None - Returns - ------- - dict - Dictionary with {'part':..; 'node':..; 'vector':...} +class StressFieldResults(ElementFieldResults): + """ + Generalized stress field results for both 2D and 3D elements. + Stress results are computed in the global coordinate system. + Operations on stress results are performed on the field level to improve efficiency. + """ - """ - nodes = self.model.find_nodes_around_point(point, distance, plane) - results = [] - for step in steps: - results.append(self.get_results(nodes, steps)[step]) + def __init__(self, step, *args, **kwargs): + super().__init__(step=step, *args, **kwargs) + self._results_func = "find_element_by_key" + self._field_name = "s" - def results(self, step=None): - """Return the stress results for the given analysis Step. - If the step is not specified, the last step is used. + @property + def grouped_results(self): + """Groups elements by their dimensionality (2D or 3D) correctly.""" + sorted_results = sorted(self.results, key=lambda r: r.element.ndim) # Ensure sorting before grouping + return {k: list(v) for k, v in groupby(sorted_results, key=lambda r: r.element.ndim)} - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step, optional - The analysis step. By default, the last step is used. + @property + def field_name(self): + return self._field_name + + @property + def results_func(self): + return self._results_func + + @property + def components_names(self): + return ["s11", "s22", "s33", "s12", "s23", "s13"] + + @property + def invariants_names(self): + return ["von_mises_stress", "principal_stress_min", "principal_stress_mid", "principal_stress_max"] + + def get_component_value(self, component, **kwargs): + """Return the value of the selected component.""" + if component not in self.components_names: + for result in self.results: + yield getattr(result, component, None) + else: + raise (ValueError(f"Component '{component}' is not valid. Choose from {self.components_names}.")) + + def get_invariant_value(self, invariant, **kwargs): + """Return the value of the selected invariant.""" + if invariant not in self.invariants_names: + for result in self.results: + yield getattr(result, invariant, None) + else: + raise (ValueError(f"Invariant '{invariant}' is not valid. Choose from {self.invariants_names}.")) + + def global_stresses(self, plane="mid"): + """Compute stress tensors in the global coordinate system.""" + new_frame = Frame.worldXY() + transformed_tensors = [] + + grouped_results = self.grouped_results + + # Process 2D elements + if 2 in grouped_results: + results_2d = grouped_results[2] + local_stresses_2d = np.array([r.plane_results(plane).local_stress for r in results_2d]) + + # Zero out out-of-plane components + local_stresses_2d[:, 2, :] = 0.0 + local_stresses_2d[:, :, 2] = 0.0 + + # Compute transformation matrices + rotation_matrices_2d = np.array([Transformation.from_change_of_basis(r.element.frame, new_frame).matrix[:3, :3] for r in results_2d]) + + # Apply tensor transformation + transformed_tensors.append( + np.einsum( + "nij,njk,nlk->nil", + rotation_matrices_2d, + local_stresses_2d, + rotation_matrices_2d, + ) + ) + + # Process 3D elements + if 3 in grouped_results: + results_3d = grouped_results[3] + local_stresses_3d = np.array([r.local_stress for r in results_3d]) + transformed_tensors.append(local_stresses_3d) + + if not transformed_tensors: + return np.empty((0, 3, 3)) + + return np.concatenate(transformed_tensors, axis=0) + + def average_stress_at_nodes(self, component="von_mises_stress"): + """ + Compute the nodal average of von Mises stress using efficient NumPy operations. Returns ------- - list(:class:`compas_fea2.results.StressResult`) - A list with al the results of the field for the analysis step. + np.ndarray + (N_nodes,) array containing the averaged von Mises stress per node. """ - step or self.problem.steps_order[-1] - return self._get_results_from_db(self.model.elements, steps=step)[step] + # Compute von Mises stress at element level + element_von_mises = self.von_mises_stress() # Shape: (N_elements,) - def locations(self, step=None, point=False): - """Return the locations where the field is defined. + # Extract all node indices in a single operation + node_indices = np.array([n.key for e in self.results for n in e.element.nodes]) # Shape (N_total_entries,) - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + # Repeat von Mises stress for each node in the corresponding element + node_counts = [len(e.element.nodes) for e in self.results] + repeated_von_mises = np.repeat(element_von_mises, repeats=node_counts, axis=0) # Shape (N_total_entries,) - Yields - ------ - :class:`compas.geometry.Point` - The location where the field is defined. - """ - step = step or self.problem.steps_order[-1] - for s in self.results(step): - if point: - yield Point(*s.reference_point) - else: - yield s.reference_point + # Get the number of unique nodes + max_node_index = node_indices.max() + 1 - def global_stresses(self, step=None): - """Stress field in global coordinates + # Initialize accumulators for sum and count + nodal_stress_sum = np.zeros(max_node_index) # Summed von Mises stresses + nodal_counts = np.zeros(max_node_index) # Count occurrences per node - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step, by default None + # Accumulate stresses and counts at each node + np.add.at(nodal_stress_sum, node_indices, repeated_von_mises) + np.add.at(nodal_counts, node_indices, 1) + + # Prevent division by zero + nodal_counts[nodal_counts == 0] = 1 + # Compute final nodal von Mises stress average + nodal_avg_von_mises = nodal_stress_sum / nodal_counts + + return nodal_avg_von_mises # Shape: (N_nodes,) + + def average_stress_tensor_at_nodes(self): + """ + Compute the nodal average of the full stress tensor. Returns ------- - numpy array - The stress tensor defined at each location of the field in - global coordinates. + np.ndarray + (N_nodes, 3, 3) array containing the averaged stress tensor per node. """ - step = step or self.problem.steps_order[-1] - results = self.results(step) - n_locations = len(results) - new_frame = Frame.worldXY() + # Compute global stress tensor at the element level + element_stresses = self.global_stresses() # Shape: (N_elements, 3, 3) - # Initialize tensors and rotation_matrices arrays - tensors = np.zeros((n_locations, 3, 3)) - rotation_matrices = np.zeros((n_locations, 3, 3)) + # Extract all node indices in a single operation + node_indices = np.array([n.key for e in self.results for n in e.element.nodes]) # Shape (N_total_entries,) - from_change_of_basis = Transformation.from_change_of_basis - np_array = np.array + # Repeat stress tensors for each node in the corresponding element + repeated_stresses = np.repeat(element_stresses, repeats=[len(e.element.nodes) for e in self.results], axis=0) # Shape (N_total_entries, 3, 3) - for i, r in enumerate(results): - tensors[i] = r.local_stress - rotation_matrices[i] = np_array(from_change_of_basis(r.element.frame, new_frame).matrix)[:3, :3] + # Get the number of unique nodes + max_node_index = node_indices.max() + 1 - # Perform the tensor transformation using numpy's batch matrix multiplication - transformed_tensors = rotation_matrices @ tensors @ rotation_matrices.transpose(0, 2, 1) + # Initialize accumulators for sum and count + nodal_stress_sum = np.zeros((max_node_index, 3, 3)) # Summed stress tensors + nodal_counts = np.zeros((max_node_index, 1, 1)) # Count occurrences per node - return transformed_tensors + # Accumulate stresses and counts at each node + np.add.at(nodal_stress_sum, node_indices, repeated_stresses) + np.add.at(nodal_counts, node_indices, 1) - def principal_components(self, step=None): - """Compute the eigenvalues and eigenvetors of the stress field at each location. + # Prevent division by zero + nodal_counts[nodal_counts == 0] = 1 - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step in which the stress filed is defined. If not - provided, the last analysis step is used. + # Compute final nodal stress tensor average + nodal_avg_stress = nodal_stress_sum / nodal_counts + + return nodal_avg_stress # Shape: (N_nodes, 3, 3) + + def von_mises_stress(self, plane="mid"): + """ + Compute von Mises stress for 2D and 3D elements. Returns ------- - touple(np.array, np.array) - The eigenvalues and the eigenvectors, not ordered. + np.ndarray + Von Mises stress values per element. """ - step = step or self.problem.steps_order[-1] - return np.linalg.eig(self.global_stresses(step)) + stress_tensors = self.global_stresses(plane) # Shape: (N_elements, 3, 3) - def principal_components_vectors(self, step=None): - """Compute the principal components of the stress field at each location - as vectors. + # Extract stress components + S11, S22, S33 = stress_tensors[:, 0, 0], stress_tensors[:, 1, 1], stress_tensors[:, 2, 2] + S12, S23, S13 = stress_tensors[:, 0, 1], stress_tensors[:, 1, 2], stress_tensors[:, 0, 2] - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step in which the stress filed is defined. If not - provided, the last analysis step is used. + # Compute von Mises stress + sigma_vm = np.sqrt(0.5 * ((S11 - S22) ** 2 + (S22 - S33) ** 2 + (S33 - S11) ** 2 + 6 * (S12**2 + S23**2 + S13**2))) + return sigma_vm - Yields - ------ - list(:class:`compas.geometry.Vector) - list with the vectors corresponding to max, mid and min principal componets. + def principal_components(self, plane="mid"): """ - step = step or self.problem.steps_order[-1] - eigenvalues, eigenvectors = self.principal_components(step) - sorted_indices = np.argsort(eigenvalues, axis=1) - sorted_eigenvalues = np.take_along_axis(eigenvalues, sorted_indices, axis=1) - sorted_eigenvectors = np.take_along_axis(eigenvectors, sorted_indices[:, np.newaxis, :], axis=2) - for i in range(eigenvalues.shape[0]): - yield [Vector(*sorted_eigenvectors[i, :, j]) * sorted_eigenvalues[i, j] for j in range(eigenvalues.shape[1])] - - def vonmieses(self, step=None): - """Compute the principal components of the stress field at each location - as vectors. + Compute sorted principal stresses and directions. - Parameters - ---------- - step : :class:`compas_fea2.problem.steps.Step`, optional - The analysis step in which the stress filed is defined. If not - provided, the last analysis step is used. + Returns + ------- + tuple + (principal_stresses, principal_directions) + - `principal_stresses`: (N_elements, 3) array containing sorted principal stresses. + - `principal_directions`: (N_elements, 3, 3) array containing corresponding eigenvectors. + """ + stress_tensors = self.global_stresses(plane) # Shape: (N_elements, 3, 3) + # Ensure symmetry (avoiding numerical instability)** + stress_tensors = 0.5 * (stress_tensors + np.transpose(stress_tensors, (0, 2, 1))) - Yields - ------ - list(:class:`compas.geometry.Vector) - list with the vectors corresponding to max, mid and min principal componets. - """ - step = step or self.problem.steps_order[-1] - for r in self.results(step): - yield r.von_mises_stress + # Compute eigenvalues and eigenvectors (batch operation)** + eigvals, eigvecs = np.linalg.eigh(stress_tensors) + + # Sort eigenvalues & corresponding eigenvectors (by absolute magnitude)** + sorted_indices = np.argsort(np.abs(eigvals), axis=1) # Sort based on absolute value + sorted_eigvals = np.take_along_axis(eigvals, sorted_indices, axis=1) + sorted_eigvecs = np.take_along_axis(eigvecs, sorted_indices[:, :, None], axis=2) + + # Ensure consistent orientation** + reference_vector = np.array([1.0, 0.0, 0.0]) # Arbitrary reference vector + alignment_check = np.einsum("nij,j->ni", sorted_eigvecs, reference_vector) # Dot product with reference + flip_mask = alignment_check < 0 # Identify vectors needing flipping + sorted_eigvecs[flip_mask] *= -1 # Flip incorrectly oriented eigenvectors + + return sorted_eigvals, sorted_eigvecs diff --git a/src/compas_fea2/results/modal.py b/src/compas_fea2/results/modal.py new file mode 100644 index 000000000..c61cc2582 --- /dev/null +++ b/src/compas_fea2/results/modal.py @@ -0,0 +1,177 @@ +# from .results import Result +import numpy as np + +from compas_fea2.base import FEAData + +from .fields import NodeFieldResults + + +class ModalAnalysisResult(FEAData): + """Modal analysis result. + + Parameters + ---------- + mode : int + Mode number. + eigenvalue : float + Eigenvalue. + eigenvector : list + List of DisplacementResult objects. + + Attributes + ---------- + mode : int + Mode number. + eigenvalue : float + Eigenvalue. + frequency : float + Frequency of the mode. + omega : float + Angular frequency of the mode. + period : float + Period of the mode. + eigenvector : list + List of DisplacementResult objects. + """ + + _field_name = "eigen" + _results_func = "find_node_by_key" + _components_names = ["x", "y", "z", "xx", "yy", "zz"] + _invariants_names = ["magnitude"] + + def __init__(self, *, step, mode, eigenvalue, eigenvector, **kwargs): + super(ModalAnalysisResult, self).__init__(**kwargs) + self.step = step + self._mode = mode + self._eigenvalue = eigenvalue + self._eigenvector = eigenvector + + @property + def mode(self): + return self._mode + + @property + def eigenvalue(self): + return self._eigenvalue + + @property + def frequency(self): + return self.omega / (2 * np.pi) + + @property + def omega(self): + return np.sqrt(self._eigenvalue) + + @property + def period(self): + return 1 / self.frequency + + @property + def eigenvector(self): + return self._eigenvector + + @property + def shape(self): + return ModalShape(step=self.step, results=self._eigenvector) + + def _normalize_eigenvector(self): + """ + Normalize the eigenvector to obtain the mode shape. + Mode shapes are typically scaled so the maximum displacement is 1. + """ + max_val = np.max(np.abs(self._eigenvector)) + return self._eigenvector / max_val if max_val != 0 else self._eigenvector + + def participation_factor(self, mass_matrix): + """ + Calculate the modal participation factor. + :param mass_matrix: Global mass matrix. + :return: Participation factor. + """ + if len(self.eigenvector) != len(mass_matrix): + raise ValueError("Eigenvector length must match the mass matrix size") + return np.dot(self.eigenvector.T, np.dot(mass_matrix, self.eigenvector)) + + def modal_contribution(self, force_vector): + """ + Calculate the contribution of this mode to the global response for a given force vector. + :param force_vector: External force vector. + :return: Modal contribution. + """ + return np.dot(self.eigenvector, force_vector) / self.eigenvalue + + def to_dict(self): + """ + Export the modal analysis result as a dictionary. + """ + return { + "mode": self.mode, + "eigenvalue": self.eigenvalue, + "frequency": self.frequency, + "omega": self.omega, + "period": self.period, + "eigenvector": self.eigenvector.tolist(), + "mode_shape": self.mode_shape.tolist(), + } + + def to_json(self, filepath): + import json + + with open(filepath, "w") as f: + json.dump(self.to_dict(), f, indent=4) + + def to_csv(self, filepath): + import csv + + with open(filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["Mode", "Eigenvalue", "Frequency", "Omega", "Period", "Eigenvector", "Mode Shape"]) + writer.writerow([self.mode, self.eigenvalue, self.frequency, self.omega, self.period, ", ".join(map(str, self.eigenvector)), ", ".join(map(str, self.mode_shape))]) + + def __repr__(self): + return f"ModalAnalysisResult(mode={self.mode}, eigenvalue={self.eigenvalue:.4f}, frequency={self.frequency:.4f} Hz, period={self.period:.4f} s)" + + +class ModalShape(NodeFieldResults): + """ModalShape result applied as Displacement field. + + Parameters + ---------- + step : :class:`compas_fea2.problem.Step` + The analysis step + results : list + List of DisplcementResult objects. + """ + + def __init__(self, step, results, *args, **kwargs): + super(ModalShape, self).__init__(step=step, results_cls=ModalAnalysisResult, *args, **kwargs) + self._results = results + self._field_name = "eigen" + + @property + def results(self): + return self._results + + def _get_results_from_db(self, members=None, columns=None, filters=None, **kwargs): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_result_at(self, location): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_max_result(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_min_result(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_max_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_min_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_limits_component(self, component): + raise NotImplementedError("this method is not applicable for ModalShape results") + + def get_limits_absolute(self): + raise NotImplementedError("this method is not applicable for ModalShape results") diff --git a/src/compas_fea2/results/results.py b/src/compas_fea2/results/results.py index 69109c939..530fdd70e 100644 --- a/src/compas_fea2/results/results.py +++ b/src/compas_fea2/results/results.py @@ -1,6 +1,6 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +import base64 +import os +from io import BytesIO import matplotlib.pyplot as plt import numpy as np @@ -9,7 +9,7 @@ from compas.geometry import Vector from compas_fea2.base import FEAData -from compas_fea2.model import ElasticIsotropic +from compas_fea2.model.materials.material import ElasticIsotropic class Result(FEAData): @@ -36,16 +36,24 @@ class Result(FEAData): """ + _field_name = "" # name of the field + _results_func = "" # function to find the location + _results_func_output = "" # function to find the location + _components_names = [] # names of the components + _invariants_names = [] # names of the invariants + def __init__(self, **kwargs): super(Result, self).__init__(**kwargs) - self._title = None + self._field_name = self.__class__._field_name + self._results_func = self.__class__._results_func + self._results_func_output = self.__class__._results_func_output + self._components_names = self.__class__._components_names + self._invariants_names = self.__class__._invariants_names self._registration = None - self._components = {} - self._invariants = {} @property - def title(self): - return self._title + def field_name(self): + return self._field_name @property def location(self): @@ -57,15 +65,16 @@ def reference_point(self): @property def components(self): - return self._components + return {component: getattr(self, component) for component in self._components_names} + + @property + def components_names(self): + return self._components_names @property def invariants(self): return self._invariants - def to_file(self, *args, **kwargs): - raise NotImplementedError("this function is not available for the selected backend") - def safety_factor(self, component, allowable): """Compute the safety factor (absolute ration value/limit) of the displacement. @@ -112,10 +121,9 @@ class NodeResult(Result): NodeResults are registered to a :class:`compas_fea2.model.Node` """ - def __init__(self, node, title, x=None, y=None, z=None, xx=None, yy=None, zz=None, **kwargs): + def __init__(self, node, x=None, y=None, z=None, xx=None, yy=None, zz=None, **kwargs): super(NodeResult, self).__init__(**kwargs) self._registration = node - self._title = title self._x = x self._y = y self._z = z @@ -124,12 +132,36 @@ def __init__(self, node, title, x=None, y=None, z=None, xx=None, yy=None, zz=Non self._zz = zz @property - def node(self): - return self._registration + def x(self): + return self._x @property - def components(self): - return {self._title + component: getattr(self, component) for component in ["x", "y", "z", "xx", "yy", "zz"]} + def y(self): + return self._y + + @property + def z(self): + return self._z + + @property + def xx(self): + return self._xx + + @property + def yy(self): + return self._yy + + @property + def zz(self): + return self._zz + + @property + def field_name(self): + return self._field_name + + @property + def node(self): + return self._registration @property def vector(self): @@ -143,6 +175,10 @@ def vector_rotation(self): def magnitude(self): return self.vector.length + @property + def magnitude_rotation(self): + return self.vector_rotation.length + class DisplacementResult(NodeResult): """DisplacementResult object. @@ -152,8 +188,14 @@ class DisplacementResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "u" + _results_func = "find_node_by_key" + _results_func_output = "find_node_by_inputkey" + _components_names = ["x", "y", "z", "xx", "yy", "zz"] + _invariants_names = ["magnitude"] + def __init__(self, node, x=0.0, y=0.0, z=0.0, xx=0.0, yy=0.0, zz=0.0, **kwargs): - super(DisplacementResult, self).__init__(node, "u", x, y, z, xx, yy, zz, **kwargs) + super(DisplacementResult, self).__init__(node, x, y, z, xx, yy, zz, **kwargs) class AccelerationResult(NodeResult): @@ -164,8 +206,14 @@ class AccelerationResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "a" + _results_func = "find_node_by_key" + _results_func_output = "find_node_by_inputkey" + _components_names = ["x", "y", "z", "xx", "yy", "zz"] + _invariants_names = ["magnitude"] + def __init__(self, node, x=0.0, y=0.0, z=0.0, xx=0.0, yy=0.0, zz=0.0, **kwargs): - super(AccelerationResult, self).__init__(node, "a", x, y, z, xx, yy, zz, **kwargs) + super(AccelerationResult, self).__init__(node, x, y, z, xx, yy, zz, **kwargs) class VelocityResult(NodeResult): @@ -176,8 +224,14 @@ class VelocityResult(NodeResult): DisplacementResults are registered to a :class:`compas_fea2.model.Node` """ + _field_name = "v" + _results_func = "find_node_by_key" + _results_func_output = "find_node_by_inputkey" + _components_names = ["x", "y", "z", "xx", "yy", "zz"] + _invariants_names = ["magnitude"] + def __init__(self, node, x=0.0, y=0.0, z=0.0, xx=0.0, yy=0.0, zz=0.0, **kwargs): - super(VelocityResult, self).__init__(node, "v", x, y, z, xx, yy, zz, **kwargs) + super(VelocityResult, self).__init__(node, x, y, z, xx, yy, zz, **kwargs) class ReactionResult(NodeResult): @@ -220,15 +274,26 @@ class ReactionResult(NodeResult): ReactionResults are registered to a :class:`compas_fea2.model.Node` """ - def __init__(self, node, x, y, z, xx, yy, zz, **kwargs): - super(ReactionResult, self).__init__(node, "rf", x, y, z, xx, yy, zz, **kwargs) + _field_name = "rf" + _results_func = "find_node_by_key" + _results_func_output = "find_node_by_inputkey" + _components_names = ["x", "y", "z", "xx", "yy", "zz"] + _invariants_names = ["magnitude"] + + def __init__(self, node, x=0, y=0, z=0, xx=0, yy=0, zz=0, **kwargs): + super(ReactionResult, self).__init__(node, x, y, z, xx, yy, zz, **kwargs) + +# --------------------------------------------------------------------------------------------- +# Element Results +# --------------------------------------------------------------------------------------------- -class Element1DResult(Result): + +class ElementResult(Result): """Element1DResult object.""" def __init__(self, element, **kwargs): - super(Element1DResult, self).__init__(**kwargs) + super(ElementResult, self).__init__(**kwargs) self._registration = element @property @@ -236,75 +301,287 @@ def element(self): return self._registration -class SectionForcesResult(Element1DResult): - """DisplacementResult object. +class SectionForcesResult(ElementResult): + """SectionForcesResult object. Parameters ---------- - node : :class:`compas_fea2.model.Node` - The location of the result. - rf1 : float - The x component of the reaction vector. - rf2 : float - The y component of the reaction vector. - rf3 : float - The z component of the reaction vector. + element : :class:`compas_fea2.model.Element` + The element to which the result is associated. + Fx_1, Fy_1, Fz_1 : float + Components of the force vector at the first end of the element. + Mx_1, My_1, Mz_1 : float + Components of the moment vector at the first end of the element. + Fx_2, Fy_2, Fz_2 : float + Components of the force vector at the second end of the element. + Mx_2, My_2, Mz_2 : float + Components of the moment vector at the second end of the element. Attributes ---------- - location : :class:`compas_fea2.model.Node` ` - The location of the result. - node : :class:`compas_fea2.model.Node` - The location of the result. - components : dict - A dictionary with {"component name": component value} for each component of the result. - invariants : dict - A dictionary with {"invariant name": invariant value} for each invariant of the result. - rf1 : float - The x component of the reaction vector. - rf2 : float - The y component of the reaction vector. - rf3 : float - The z component of the reaction vector. - vector : :class:`compas.geometry.Vector` - The displacement vector. - magnitude : float - The absolute value of the displacement. + end_1 : :class:`compas_fea2.model.Node` + The first end node of the element. + end_2 : :class:`compas_fea2.model.Node` + The second end node of the element. + force_vector_1 : :class:`compas.geometry.Vector` + The force vector at the first end of the element. + moment_vector_1 : :class:`compas.geometry.Vector` + The moment vector at the first end of the element. + force_vector_2 : :class:`compas.geometry.Vector` + The force vector at the second end of the element. + moment_vector_2 : :class:`compas.geometry.Vector` + The moment vector at the second end of the element. + forces : dict + Dictionary containing force vectors for both ends of the element. + moments : dict + Dictionary containing moment vectors for both ends of the element. + net_force : :class:`compas.geometry.Vector` + The net force vector across the element. + net_moment : :class:`compas.geometry.Vector` + The net moment vector across the element. Notes ----- - SectionForcesResults are registered to a :class:`compas_fea2.model._Element + SectionForcesResults are registered to a :class:`compas_fea2.model._Element`. + + Methods + ------- + to_dict() + Export the section forces and moments to a dictionary. """ - def __init__(self, element, **kwargs): + _field_name = "sf" + _results_func = "find_element_by_key" + _results_func_output = "find_element_by_inputkey" + _components_names = ["Fx_1", "Fy_1", "Fz_1", "Mx_1", "My_1", "Mz_1", "Fx_2", "Fy_2", "Fz_2", "Mx_2", "My_2", "Mz_2"] + _invariants_names = ["magnitude"] + + def __init__(self, element, Fx_1=0, Fy_1=0, Fz_1=0, Mx_1=0, My_1=0, Mz_1=0, Fx_2=0, Fy_2=0, Fz_2=0, Mx_2=0, My_2=0, Mz_2=0, **kwargs): super(SectionForcesResult, self).__init__(element, **kwargs) + self._Fx_1 = Fx_1 + self._Fy_1 = Fy_1 + self._Fz_1 = Fz_1 + self._Mx_1 = Mx_1 + self._My_1 = My_1 + self._Mz_1 = Mz_1 + self._Fx_1 = Fx_2 + self._Fy_1 = Fy_2 + self._Fz_1 = Fz_2 + self._Mx_1 = Mx_2 + self._My_1 = My_2 + self._Mz_1 = Mz_2 + + self._force_vector_1 = Vector(Fx_1, Fy_1, Fz_1) + self._moment_vector_1 = Vector(Mx_1, My_1, Mz_1) + + self._force_vector_2 = Vector(Fx_2, Fy_2, Fz_2) + self._moment_vector_2 = Vector(Mx_2, My_2, Mz_2) + + def __repr__(self): + """String representation of the SectionForcesResult.""" + return ( + f"SectionForcesResult(\n" + f" Element: {self.element},\n" + f" End 1 Force: {self.force_vector_1}, Moment: {self.moment_vector_1},\n" + f" End 2 Force: {self.force_vector_2}, Moment: {self.moment_vector_2},\n" + f" Net Force: {self.net_force}, Net Moment: {self.net_moment}\n" + f")" + ) @property - def forces_vector(self): - pass + def Fx_1(self): + return self._Fx_1 @property - def moments_vector(self): - pass + def Fy_1(self): + return self._Fy_1 @property - def element(self): - return self.location + def Fz_1(self): + return self._Fz_1 + @property + def Mx_1(self): + return self._Mx_1 -class Element2DResult(Result): - """Element1DResult object.""" + @property + def My_1(self): + return self._My_1 - def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(**kwargs) - self._registration = element + @property + def Mz_1(self): + return self._Mz_1 @property - def element(self): - return self._registration + def Fx_2(self): + return self._Fx_2 + + @property + def Fy_2(self): + return self._Fy_2 + + @property + def Fz_2(self): + return self._Fz_2 + + @property + def Mx_2(self): + return self._Mx_2 + + @property + def My_2(self): + return self._My_2 + @property + def Mz_2(self): + return self._Mz_2 + + @property + def end_1(self): + """Returns the first end node of the element.""" + return self.element.nodes[0] + + @property + def end_2(self): + """Returns the second end node of the element.""" + return self.element.nodes[1] + + @property + def force_vector_1(self): + """Returns the force vector at the first end of the element.""" + return self._force_vector_1 + + @property + def moment_vector_1(self): + """Returns the moment vector at the first end of the element.""" + return self._moment_vector_1 + + @property + def force_vector_2(self): + """Returns the force vector at the second end of the element.""" + return self._force_vector_2 + + @property + def moment_vector_2(self): + """Returns the moment vector at the second end of the element.""" + return self._moment_vector_2 + + @property + def forces(self): + """Returns a dictionary of force vectors for both ends.""" + return { + self.end_1: self.force_vector_1, + self.end_2: self.force_vector_2, + } + + @property + def moments(self): + """Returns a dictionary of moment vectors for both ends.""" + return { + self.end_1: self.moment_vector_1, + self.end_2: self.moment_vector_2, + } + + @property + def net_force(self): + """Returns the net force vector across the element.""" + return self.force_vector_2 + self.force_vector_1 + + @property + def net_moment(self): + """Returns the net moment vector across the element.""" + return self.moment_vector_2 + self.moment_vector_1 + + def plot_stress_distribution(self, end="end_1", nx=100, ny=100): + """ + Plot the axial stress distribution along the element. + + Parameters + ---------- + location : str, optional + The end of the element ('end_1' or 'end_2'). Default is 'end_1'. + nx : int, optional + Number of points to plot along the element. Default is 100. + """ + force_vector = self.force_vector_1 if end == "end_1" else self.force_vector_2 + moment_vector = self.moment_vector_1 if end == "end_1" else self.moment_vector_2 + N = force_vector.z # Axial force + Vx = force_vector.x # Shear force in x-direction + Vy = force_vector.y # Shear force in y-direction + Mx = moment_vector.x # Bending moment about x-axis + My = moment_vector.y # Bending moment about y-axis + + self.element.section.plot_stress_distribution(N, Vx, Vy, Mx, My, nx=nx, ny=ny) + + def sectional_analysis_summary(self, end="end_1"): + """ + Generate a summary of sectional analysis for the specified end. + + Parameters + ---------- + location : str, optional + The end of the element ('end_1' or 'end_2'). Default is 'end_1'. + + Returns + ------- + dict + A dictionary summarizing the results of the sectional analysis. + """ + return None + return { + "normal_stress": self.compute_normal_stress(end), + "shear_stress": self.compute_shear_stress(end), + "utilization": self.compute_utilization(end), + "interaction_check": self.check_interaction(end), + } + + def to_dict(self): + """Export the section forces and moments to a dictionary.""" + return { + "element": self.element, + "end_1": { + "force": self.force_vector_1, + "moment": self.moment_vector_1, + }, + "end_2": { + "force": self.force_vector_2, + "moment": self.moment_vector_2, + }, + "net_force": self.net_force, + "net_moment": self.net_moment, + } + + def to_json(self, file_path): + """Export the result to a JSON file. + + Parameters + ---------- + file_path : str + Path to the JSON file. + """ + import json + + with open(file_path, "w") as f: + json.dump(self.to_dict(), f, indent=4) + + def to_csv(self, file_path): + """Export the result to a CSV file. -class StressResult(Element2DResult): + Parameters + ---------- + file_path : str + Path to the CSV file. + """ + import csv + + with open(file_path, mode="w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["End", "Fx", "Fy", "Fz", "Mx", "My", "Mz"]) + writer.writerow(["End 1", self.force_vector_1.x, self.force_vector_1.y, self.force_vector_1.z, self.moment_vector_1.x, self.moment_vector_1.y, self.moment_vector_1.z]) + writer.writerow(["End 2", self.force_vector_2.x, self.force_vector_2.y, self.force_vector_2.z, self.moment_vector_2.x, self.moment_vector_2.y, self.moment_vector_2.z]) + + +class StressResult(ElementResult): """StressResult object. Parameters @@ -383,22 +660,50 @@ class StressResult(Element2DResult): StressResults are registered to a :class:`compas_fea2.model._Element """ + # _field_name = "s" + # _results_func = "find_element_by_key" + # _results_func_output = "find_element_by_inputkey" + # _components_names = ["s11", "s22", "s33", "s12", "s23", "s13"] + # _invariants_names = ["magnitude"] + def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): - super(StressResult, self).__init__(element, **kwargs) - self._title = None + super().__init__(element, **kwargs) + self._s11 = s11 + self._s12 = s12 + self._s13 = s13 + self._s22 = s22 + self._s23 = s23 + self._s33 = s33 + + # Define stress tensor in local coordinates self._local_stress = np.array([[s11, s12, s13], [s12, s22, s23], [s13, s23, s33]]) - self._global_stress = self.transform_stress_tensor(self._local_stress, Frame.worldXY()) - self._components = {f"S{i+1}{j+1}": self._local_stress[i][j] for j in range(len(self._local_stress[0])) for i in range(len(self._local_stress))} + + # Store individual components + self._components = {f"s{i + 1}{j + 1}": self._local_stress[i, j] for i in range(3) for j in range(3)} @property - def local_stress(self): - # In local coordinates - return self._local_stress + def s11(self): + return self._s11 @property - def global_stress(self): - # In global coordinates - return self._global_stress + def s12(self): + return self._s12 + + @property + def s13(self): + return self._s13 + + @property + def s22(self): + return self._s22 + + @property + def s23(self): + return self._s23 + + @property + def s33(self): + return self._s33 @property def global_strain(self): @@ -424,41 +729,49 @@ def global_strain(self): return strain_tensor @property - def element(self): - return self.location + def local_stress(self): + """Stress tensor in local coordinates.""" + return self._local_stress + + @property + def global_stress(self): + """Stress tensor transformed to global coordinates.""" + return self.transform_stress_tensor(self._local_stress, Frame.worldXY()) @property - # First invariant def I1(self): + """First invariant (trace of stress tensor).""" return np.trace(self.global_stress) @property - # Second invariant def I2(self): + """Second invariant of the stress tensor.""" return 0.5 * (self.I1**2 - np.trace(np.dot(self.global_stress, self.global_stress))) @property - # Third invariant def I3(self): + """Third invariant (determinant of stress tensor).""" return np.linalg.det(self.global_stress) @property - # Second invariant of the deviatoric stress tensor: J2 def J2(self): + """Second invariant of deviatoric stress tensor.""" return 0.5 * np.trace(np.dot(self.deviatoric_stress, self.deviatoric_stress)) @property - # Third invariant of the deviatoric stress tensor: J3 def J3(self): + """Third invariant of deviatoric stress tensor.""" return np.linalg.det(self.deviatoric_stress) @property def hydrostatic_stress(self): - return self.I1 / len(self.global_stress) + """Mean (hydrostatic) stress.""" + return self.I1 / 3 @property def deviatoric_stress(self): - return self.global_stress - np.eye(len(self.global_stress)) * self.hydrostatic_stress + """Deviatoric stress tensor.""" + return self.global_stress - np.eye(3) * self.hydrostatic_stress @property # Octahedral normal and shear stresses @@ -467,57 +780,85 @@ def octahedral_stresses(self): tau_oct = np.sqrt(2 * self.J2 / 3) return sigma_oct, tau_oct - @property - def principal_stresses_values(self): - eigenvalues = np.linalg.eigvalsh(self.global_stress) - sorted_indices = np.argsort(eigenvalues) - return eigenvalues[sorted_indices] - @property def principal_stresses(self): return zip(self.principal_stresses_values, self.principal_stresses_vectors) @property def smax(self): - return max(self.principal_stresses_values) + """Maximum principal stress.""" + return self.principal_stresses_values[-1] @property def smin(self): - return min(self.principal_stresses_values) + """Minimum principal stress.""" + return self.principal_stresses_values[0] @property def smid(self): - if len(self.principal_stresses_values) == 3: - return [x for x in self.principal_stresses_values if x != self.smin and x != self.smax] - else: - return None + """Middle principal stress (works for both 2D & 3D).""" + return np.median(self.principal_stresses_values) + + @property + def principal_stresses_values(self): + """Sorted principal stresses (eigenvalues of stress tensor).""" + eigvals = np.linalg.eigvalsh(self.global_stress) + return np.sort(eigvals) # Ensures correct order @property def principal_stresses_vectors(self): - eigenvalues, eigenvectors = np.linalg.eig(self.global_stress) - # Sort the eigenvalues/vectors from low to high - sorted_indices = np.argsort(eigenvalues) - eigenvectors = eigenvectors[:, sorted_indices] - eigenvalues = eigenvalues[sorted_indices] - return [Vector(*eigenvectors[:, i].tolist()) * abs(eigenvalues[i]) for i in range(len(eigenvalues))] + """Sorted eigenvectors of the stress tensor.""" + eigvals, eigvecs = np.linalg.eigh(self.global_stress) + sorted_indices = np.argsort(eigvals) + sorted_eigvecs = eigvecs[:, sorted_indices] + + # Ensure consistent orientation using reference vector + ref_vector = np.array([1.0, 0.0, 0.0]) + alignment_check = np.einsum("ij,j->i", sorted_eigvecs, ref_vector) + flip_mask = alignment_check < 0 + sorted_eigvecs[:, flip_mask] *= -1 + + return [Vector(*sorted_eigvecs[:, i]) * abs(eigvals[sorted_indices][i]) for i in range(3)] @property def von_mises_stress(self): - return np.sqrt(self.J2 * 3) + """Von Mises stress.""" + return np.sqrt(3 * self.J2) @property def tresca_stress(self): + """Tresca stress (max absolute shear stress).""" return max(abs(self.principal_stresses_values - np.roll(self.principal_stresses_values, -1))) @property def safety_factor_max(self, allowable_stress): - # Simple safety factor analysis based on maximum principal stress - return abs(allowable_stress / self.smax) if self.smax != 0 else 1 + """Maximum safety factor based on principal stress.""" + return abs(allowable_stress / self.smax) if self.smax != 0 else np.inf @property def safety_factor_min(self, allowable_stress): - # Simple safety factor analysis based on maximum principal stress - return abs(allowable_stress / self.smin) if self.smin != 0 else 1 + """Minimum safety factor based on principal stress.""" + return abs(allowable_stress / self.smin) if self.smin != 0 else np.inf + + def transform_stress_tensor(self, tensor, new_frame): + """ + Transforms the stress tensor to a new reference frame. + + Parameters: + ----------- + new_frame : `compas.geometry.Frame` + The new reference frame. + + Returns: + -------- + np.ndarray + Transformed stress tensor. + """ + R = Transformation.from_change_of_basis(self.element.frame, new_frame) + R_matrix = np.array(R.matrix)[:3, :3] + + # Efficient transformation using einsum + return np.einsum("ij,jk,lk->il", R_matrix, tensor, R_matrix) @property def strain_energy_density(self): @@ -533,30 +874,10 @@ def strain_energy_density(self): e = self.global_strain # Strain tensor # For isotropic materials, using the formula: U = 1/2 * stress : strain - U = 0.5 * np.tensile(s, e) + U = 0.5 * np.tensordot(s, e) return U - def transform_stress_tensor(self, tensor, new_frame): - """ - Transforms the stress tensor to a new frame using the provided 3x3 rotation matrix. - This function works for both 2D and 3D stress tensors. - - Parameters: - ----------- - new_frame : `class`:"compas.geometry.Frame" - The new refernce Frame - - Returns: - numpy array - Transformed stress tensor as a numpy array of the same dimension as the input. - """ - - R = Transformation.from_change_of_basis(self.element.frame, new_frame) - R_matrix = np.array(R.matrix)[:3, :3] - - return R_matrix @ tensor @ R_matrix.T - def stress_along_direction(self, direction): """ Computes the stress along a given direction. @@ -589,8 +910,8 @@ def compute_mohr_circles_3d(self): def compute_mohr_circle_2d(self): # Ensure the stress tensor is 2D - if self.global_stress.shape != (2, 2): - raise ValueError("The stress tensor must be 2D for Mohr's Circle.") + # if self.global_stress.shape != (2, 2): + # raise ValueError("The stress tensor must be 2D for Mohr's Circle.") # Calculate the center and radius of the Mohr's Circle sigma_x, sigma_y, tau_xy = self.global_stress[0, 0], self.global_stress[1, 1], self.global_stress[0, 1] @@ -603,13 +924,13 @@ def compute_mohr_circle_2d(self): y = radius * np.sin(theta) return x, y, center, radius, sigma_x, sigma_y, tau_xy - def draw_mohr_circle_2d(self): + def draw_mohr_circle_2d(self, show=True): """ Draws the three Mohr's circles for a 3D stress state. """ x, y, center, radius, sigma_x, sigma_y, tau_xy = self.compute_mohr_circle_2d() # Plotting - plt.figure(figsize=(8, 8)) + fig = plt.figure(figsize=(8, 8)) plt.plot(x, y, label="Mohr's Circle") # Plotting the principal stresses @@ -631,12 +952,22 @@ def draw_mohr_circle_2d(self): plt.title("Mohr's Circle") plt.axis("equal") plt.legend() - plt.show() - - def draw_mohr_circles_3d(self): + # Show the plot + if show: + plt.show() + else: + # Save plot as base64 + buffer = BytesIO() + plt.savefig(buffer, format="png", bbox_inches="tight") + plt.close(fig) + buffer.seek(0) + return base64.b64encode(buffer.read()).decode("utf-8") + + def draw_mohr_circles_3d(self, show=True): """ Draws the three Mohr's circles for a 3D stress state. """ + circles = self.compute_mohrs_circles_3d() # Create a figure and axis for the plot @@ -665,8 +996,6 @@ def draw_mohr_circles_3d(self): plt.legend() plt.grid(True) plt.axis("equal") - - # Show the plot plt.show() # ========================================================================= @@ -711,213 +1040,236 @@ def thermal_stress_analysis(self, temperature_change): # Delta_sigma = E * alpha * Delta_T return self.location.section.material.E * self.location.section.material.expansion * temperature_change + def generate_html_report(self, file_path): + """ + Generates an HTML report summarizing the stress results, including Mohr's Circles and yield criteria. -class MembraneStressResult(StressResult): - def __init__(self, element, *, s11, s12, s22, **kwargs): - super(MembraneStressResult, self).__init__(element, s11=s11, s12=s12, s13=0, s22=s22, s23=0, s33=0, **kwargs) - self._title = "s2d" - - -class ShellStressResult(MembraneStressResult): - def __init__(self, element, *, s11, s12, s22, m11, m22, m12, **kwargs): - super(ShellStressResult, self).__init__(element, s11=s11, s12=s12, s22=s22, **kwargs) - self._title = "s2d" - self._local_bending_moments = np.array([[m11, m12, 0], [m12, m22, 0], [0, 0, 0]]) - self._local_stress_top = self.local_stress_membrane + 6 / self.element.section.t**2 * self._local_bending_moments - self._local_stress_bottom = self.local_stress_membrane - 6 / self.element.section.t**2 * self._local_bending_moments - - # self._global_stress_membrane = self.transform_stress_tensor(self.local_stress_membrane, Frame.worldXY()) - self._global_stress_top = self.transform_stress_tensor(self.local_stress_top, Frame.worldXY()) - self._global_stress_bottom = self.transform_stress_tensor(self.local_stress_bottom, Frame.worldXY()) - - self._stress_components = {f"S{i+1}{j+1}": self._local_stress[i][j] for j in range(len(self._local_stress[0])) for i in range(len(self._local_stress))} - self._bending_components = { - f"M{i+1}{j+1}": self._local_bending_moments[i][j] for j in range(len(self._local_bending_moments[0])) for i in range(len(self._local_bending_moments)) - } - - @property - def local_stress_membrane(self): - return self._local_stress - - @property - def local_stress_bottom(self): - return self._local_stress_bottom - - @property - def local_stress_top(self): - return self._local_stress_top - - @property - def global_stress_membrane(self): - return self._global_stress # self._global_stress_membrane - - @property - def global_stress_top(self): - return self._global_stress_top + Warning + ------- + This method is a work in progress and may not be fully functional yet. - @property - def global_stress_bottom(self): - return self._global_stress_bottom + Parameters + ---------- + file_path : str + The path where the HTML report will be saved. - @property - def hydrostatic_stress_top(self): - return self.I1_top / len(self.global_stress_top) + """ - @property - def hydrostatic_stress_bottom(self): - return self.I1_bottom / len(self.global_stress_bottom) + mohr_circle_image = self.draw_mohr_circle_2d(show=False) + + # Generate Yield Criteria Placeholder (for simplicity, add descriptions) + yield_criteria_description = ( + "Yield criteria such as Tresca and von Mises are used to assess material failure under combined stresses. " + "The von Mises stress for this result is {:.4f}, and the Tresca stress is {:.4f}.".format(self.von_mises_stress, self.tresca_stress) + ) + + # HTML Content + html_content = f""" + + + + Stress Result Report + + + +

Stress Result Report

+

Element Information

+

Element ID: {self.element.id if hasattr(self.element, "id") else "N/A"}

+

Frame: {self.element.frame if hasattr(self.element, "frame") else "N/A"}

+ +

Stress Tensor

+ + + + + + {"".join([f"" for key, value in self._components.items()])} +
ComponentValue
{key}{value:.4f}
+ +

Principal Stresses

+ + + + + + {"".join([f"" for i, value in enumerate(self.principal_stresses_values)])} +
Principal StressValue
Principal Stress {i + 1}{value:.4f}
+ +

Von Mises Stress

+

{self.von_mises_stress:.4f}

+ +

Hydrostatic Stress

+

{self.hydrostatic_stress:.4f}

+ +

Octahedral Stresses

+

Normal Stress: {self.octahedral_stresses[0]:.4f}

+

Shear Stress: {self.octahedral_stresses[1]:.4f}

+ +

Mohr's Circle

+
+ Mohr's Circle +
+ +

Yield Criteria

+
{yield_criteria_description}
+ + + """ - @property - def deviatoric_stress_top(self): - return self.global_stress_top - np.eye(len(self.global_stress_top)) * self.hydrostatic_stress_top + # Save the HTML file + with open(os.path.join(file_path, self.element.name + ".html"), "w") as file: + file.write(html_content) - @property - def deviatoric_stress_bottom(self): - return self.global_stress_bottom - np.eye(len(self.global_stress_bottom)) * self.hydrostatic_stress_bottom - @property - # First invariant - def I1_top(self): - return np.trace(self.global_stress_top) +class MembraneStressResult(StressResult): + def __init__(self, element, s11=0, s12=0, s22=0, **kwargs): + super(MembraneStressResult, self).__init__(element, s11=s11, s12=s12, s13=0, s22=s22, s23=0, s33=0, **kwargs) + self._title = "s2d" - @property - # First invariant - def I1_bottom(self): - return np.trace(self.global_stress_bottom) - @property - # Second invariant - def I2_top(self): - return 0.5 * (self.I1_top**2 - np.trace(np.dot(self.global_stress_top, self.global_stress_top))) +class ShellStressResult(Result): + """ + ShellStressResult object. - @property - # Second invariant - def I2_bottom(self): - return 0.5 * (self.I2_bottom**2 - np.trace(np.dot(self.global_stress_bottom, self.global_stress_bottom))) + Parameters + ---------- + element : :class:`compas_fea2.model._Element` + The location of the result. + s11 : float + The 11 component of the stress tensor in local coordinates (in-plane axial). + s22 : float + The 22 component of the stress tensor in local coordinates (in-plane axial). + s12 : float + The 12 component of the stress tensor in local coordinates (in-plane shear). - @property - # Third invariant - def I3_top(self): - return np.linalg.det(self.global_stress_top) + sb11 : float + The 11 component of the stress tensor in local coordinates due to bending on the top face. + sb22 : float + The 22 component of the stress tensor in local coordinates due to bending on the top face. + t12 : float - @property - # Third invariant - def I3_bottom(self): - return np.linalg.det(self.global_stress_bottom) + """ - @property - # Second invariant of the deviatoric stress tensor: J2 - def J2_top(self): - return 0.5 * np.trace(np.dot(self.deviatoric_stress_top, self.deviatoric_stress_top)) + _field_name = "s2d" + _results_func = "find_element_by_key" + _results_func_output = "find_element_by_inputkey" + _components_names = ["s11", "s22", "s12", "sb11", "sb22", "sb12", "tq1", "tq2"] + _invariants_names = ["magnitude"] + + def __init__(self, element, s11=0, s22=0, s12=0, sb11=0, sb22=0, sb12=0, tq1=0, tq2=0, **kwargs): + super(ShellStressResult, self).__init__(**kwargs) + self._s11 = s11 + self._s22 = s22 + self._s12 = s12 + self._sb11 = sb11 + self._sb22 = sb22 + self._sb12 = sb12 + self._tq1 = tq1 + self._tq2 = tq2 - @property - # Second invariant of the deviatoric stress tensor: J2 - def J2_bottom(self): - return 0.5 * np.trace(np.dot(self.deviatoric_stress_bottom, self.deviatoric_stress_bottom)) + self._registration = element + self._mid_plane_stress_result = MembraneStressResult(element, s11=s11, s12=s12, s22=s22) + self._top_plane_stress_result = MembraneStressResult(element, s11=s11 + sb11, s12=s12, s22=s22 + sb22) + self._bottom_plane_stress_result = MembraneStressResult(element, s11=s11 - sb11, s12=s12, s22=s22 - sb22) @property - # Third invariant of the deviatoric stress tensor: J3 - def J3_top(self): - return np.linalg.det(self.deviatoric_stress_top) + def s11(self): + return self._s11 @property - # Third invariant of the deviatoric stress tensor: J3 - def J3_bottom(self): - return np.linalg.det(self.deviatoric_stress_bottom) + def s22(self): + return self._s22 @property - def principal_stresses_values(self): - eigenvalues = np.linalg.eigvalsh(self.global_stress[:2, :2]) - sorted_indices = np.argsort(eigenvalues) - return eigenvalues[sorted_indices] + def s12(self): + return self._s12 @property - def principal_stresses_values_top(self): - eigenvalues = np.linalg.eigvalsh(self.global_stress_top[:2, :2]) - sorted_indices = np.argsort(eigenvalues) - return eigenvalues[sorted_indices] + def sb11(self): + return self._sb11 @property - def principal_stresses_values_bottom(self): - eigenvalues = np.linalg.eigvalsh(self.global_stress_bottom[:2, :2]) - sorted_indices = np.argsort(eigenvalues) - return eigenvalues[sorted_indices] + def sb22(self): + return self._sb22 @property - def principal_stresses_vectors(self): - eigenvalues, eigenvectors = np.linalg.eig(self.global_stress[:2, :2]) - # Sort the eigenvalues/vectors from low to high - sorted_indices = np.argsort(eigenvalues) - eigenvectors = eigenvectors[:, sorted_indices] - eigenvalues = eigenvalues[sorted_indices] - return [Vector(*eigenvectors[:, i].tolist()) * abs(eigenvalues[i]) for i in range(len(eigenvalues))] + def sb12(self): + return self._sb12 @property - def principal_stresses_vectors_top(self): - eigenvalues, eigenvectors = np.linalg.eig(self.global_stress_top[:2, :2]) - # Sort the eigenvalues/vectors from low to high - sorted_indices = np.argsort(eigenvalues) - eigenvectors = eigenvectors[:, sorted_indices] - eigenvalues = eigenvalues[sorted_indices] - return [Vector(*eigenvectors[:, i].tolist()) * abs(eigenvalues[i]) for i in range(len(eigenvalues))] + def tq1(self): + return self._tq1 @property - def principal_stresses_vectors_bottom(self): - eigenvalues, eigenvectors = np.linalg.eig(self.global_stress_bottom[:2, :2]) - # Sort the eigenvalues/vectors from low to high - sorted_indices = np.argsort(eigenvalues) - eigenvectors = eigenvectors[:, sorted_indices] - eigenvalues = eigenvalues[sorted_indices] - return [Vector(*eigenvectors[:, i].tolist()) * abs(eigenvalues[i]) for i in range(len(eigenvalues))] + def tq2(self): + return self._tq2 @property - def principal_stresses_top(self): - return zip(self.principal_stresses_values_top, self.principal_stresses_vectors_top) + def mid_plane_stress_result(self): + return self._mid_plane_stress_result @property - def principal_stresses_bottom(self): - return zip(self.principal_stresses_values_bottom, self.principal_stresses_vectors_bottom) + def top_plane_stress_result(self): + return self._top_plane_stress_result @property - def von_mises_stress_top(self): - return np.sqrt(self.J2_top * 3) - - @classmethod - def from_components(cls, location, components): - stress_components = {k.lower(): v for k, v in components.items() if k in ("S11", "S22", "S12")} - bending_components = {k.lower(): v for k, v in components.items() if k in ("M11", "M22", "M12")} - return cls(location, **stress_components, **bending_components) - - def membrane_stress(self, frame): - return self.transform_stress_tensor(self.local_stress_membrane, frame) - - def top_stress(self, frame): - return self.transform_stress_tensor(self.local_stress_top, frame) + def bottom_plane_stress_result(self): + return self._bottom_plane_stress_result - def bottom_stress(self, frame): - return self.transform_stress_tensor(self.local_stress_bottom, frame) - - def stress_along_direction(self, direction, side="mid"): - tensors = {"mid": self.global_stress_bottom, "top": self.global_stress_top, "bottom": self.global_stress_bottom} - unit_direction = np.array(direction) / np.linalg.norm(direction) - return unit_direction.T @ tensors[side] @ unit_direction - - -class Element3DResult(Result): - """Element1DResult object.""" + def plane_results(self, plane): + results = { + "mid": self.mid_plane_stress_result, + "top": self.top_plane_stress_result, + "bottom": self.bottom_plane_stress_result, + } + return results[plane] - def __init__(self, element, **kwargs): - super(Element2DResult, self).__init__(**kwargs) - self._registration = element - - @property - def element(self): - return self._registration + def generate_html_report(self, file_path, plane="mid"): + self.plane_results(plane).generate_html_report(file_path) # TODO: double inheritance StressResult and Element3DResult class SolidStressResult(StressResult): - def __init__(self, element, *, s11, s12, s13, s22, s23, s33, **kwargs): + def __init__(self, element, s11, s12, s13, s22, s23, s33, **kwargs): super(SolidStressResult, self).__init__(element=element, s11=s11, s12=s12, s13=s13, s22=s22, s23=s23, s33=s33, **kwargs) self._title = "s3d" @@ -928,92 +1280,3 @@ class StrainResult(Result): class EnergyResult(Result): pass - - -class ModalAnalysisResult(Result): - def __init__(self, mode, eigenvalue, eigenvector, **kwargs): - super(ModalAnalysisResult, self).__init__(mode, **kwargs) - self._mode = mode - self._eigenvalue = eigenvalue - self._eigenvector = eigenvector - - @property - def mode(self): - return self._mode - - @property - def eigenvalue(self): - return self._eigenvalue - - @property - def frequency(self): - return self._eigenvalue - - @property - def omega(self): - return np.sqrt(self._eigenvalue) - - @property - def period(self): - return 2 * np.pi / self.omega - - @property - def eigenvector(self): - return self._eigenvector - - def _normalize_eigenvector(self): - """ - Normalize the eigenvector to obtain the mode shape. - Mode shapes are typically scaled so the maximum displacement is 1. - """ - max_val = np.max(np.abs(self._eigenvector)) - return self._eigenvector / max_val if max_val != 0 else self._eigenvector - - def participation_factor(self, mass_matrix): - """ - Calculate the modal participation factor. - :param mass_matrix: Global mass matrix. - :return: Participation factor. - """ - if len(self.eigenvector) != len(mass_matrix): - raise ValueError("Eigenvector length must match the mass matrix size") - return np.dot(self.eigenvector.T, np.dot(mass_matrix, self.eigenvector)) - - def modal_contribution(self, force_vector): - """ - Calculate the contribution of this mode to the global response for a given force vector. - :param force_vector: External force vector. - :return: Modal contribution. - """ - return np.dot(self.eigenvector, force_vector) / self.eigenvalue - - def to_dict(self): - """ - Export the modal analysis result as a dictionary. - """ - return { - "mode": self.mode, - "eigenvalue": self.eigenvalue, - "frequency": self.frequency, - "omega": self.omega, - "period": self.period, - "eigenvector": self.eigenvector.tolist(), - "mode_shape": self.mode_shape.tolist(), - } - - def to_json(self, filepath): - import json - - with open(filepath, "w") as f: - json.dump(self.to_dict(), f, indent=4) - - def to_csv(self, filepath): - import csv - - with open(filepath, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow(["Mode", "Eigenvalue", "Frequency", "Omega", "Period", "Eigenvector", "Mode Shape"]) - writer.writerow([self.mode, self.eigenvalue, self.frequency, self.omega, self.period, ", ".join(map(str, self.eigenvector)), ", ".join(map(str, self.mode_shape))]) - - def __repr__(self): - return f"ModalAnalysisResult(mode={self.mode}, eigenvalue={self.eigenvalue:.4f}, " f"frequency={self.frequency:.4f} Hz, period={self.period:.4f} s)" diff --git a/src/compas_fea2/units/fea2_en.txt b/src/compas_fea2/units/fea2_en.txt index bc48d227d..cb7074f65 100644 --- a/src/compas_fea2/units/fea2_en.txt +++ b/src/compas_fea2/units/fea2_en.txt @@ -503,6 +503,12 @@ octave = 1 ; logbase: 2; logfactor: 1 = oct neper = 1 ; logbase: 2.71828182845904523536028747135266249775724709369995; logfactor: 0.5 = Np # neper = 1 ; logbase: eulers_number; logfactor: 0.5 = Np +# Environmental +kilogram_CO2e = [mass] = kgCO2e = kg_CO2e +gram_CO2e = 1e-3 * kilogram_CO2e = gCO2e = g_CO2e +tonne_CO2e = 1e3 * kilogram_CO2e = tCO2e = t_CO2e +pound_CO2e = 0.45359237 * kilogram_CO2e = lbCO2e = lb_CO2e + #### UNIT GROUPS #### # Mostly for length, area, volume, mass, force # (customary or specialized units) diff --git a/src/compas_fea2/utilities/__init__.py b/src/compas_fea2/utilities/__init__.py index a8f241eea..e69de29bb 100644 --- a/src/compas_fea2/utilities/__init__.py +++ b/src/compas_fea2/utilities/__init__.py @@ -1,9 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from .loads import mesh_points_pattern - -__all__ = [ - "mesh_points_pattern", -] diff --git a/src/compas_fea2/utilities/_utils.py b/src/compas_fea2/utilities/_utils.py index 9af848fad..84782cd64 100644 --- a/src/compas_fea2/utilities/_utils.py +++ b/src/compas_fea2/utilities/_utils.py @@ -5,23 +5,61 @@ import itertools import os import subprocess +import sys +import threading +import time from functools import wraps from time import perf_counter +from typing import Generator +from typing import Optional from compas_fea2 import VERBOSE +def with_spinner(message="Running"): + """Decorator to add a spinner animation to a function.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + stop_event = threading.Event() + spinner_thread = threading.Thread(target=spinner_animation, args=(message, stop_event)) + spinner_thread.start() + try: + result = func(*args, **kwargs) + return result + finally: + stop_event.set() + spinner_thread.join() + + return wrapper + + return decorator + + +def spinner_animation(message, stop_event): + """Spinner animation for indicating progress.""" + spinner = "|/-\\" + idx = 0 + while not stop_event.is_set(): + sys.stdout.write(f"\r{message} {spinner[idx % len(spinner)]}") + sys.stdout.flush() + time.sleep(0.2) # Adjust for speed + idx += 1 + sys.stdout.write("\rDone! \n") # Clear the line when done + + def timer(_func=None, *, message=None): """Print the runtime of the decorated function""" def decorator_timer(func): @wraps(func) def wrapper_timer(*args, **kwargs): + start_time = perf_counter() # 1 value = func(*args, **kwargs) + end_time = perf_counter() # 2 + run_time = end_time - start_time # 3 if VERBOSE: - start_time = perf_counter() # 1 - end_time = perf_counter() # 2 - run_time = end_time - start_time # 3 m = message or "Finished {!r} in".format(func.__name__) print("{} {:.4f} secs".format(m, run_time)) return value @@ -34,52 +72,47 @@ def wrapper_timer(*args, **kwargs): return decorator_timer(_func) -def launch_process(cmd_args, cwd, verbose=False, **kwargs): - """Open a subprocess and print the output. +def launch_process(cmd_args: list[str], cwd: Optional[str] = None, verbose: bool = False, **kwargs) -> Generator[bytes, None, None]: + """Open a subprocess and yield its output line by line. Parameters ---------- cmd_args : list[str] - problem object. - cwd : str - path where to start the subprocess - output : bool, optional - print the output of the subprocess, by default `False`. - - Returns - ------- - None - + List of command arguments to execute. + cwd : str, optional + Path where to start the subprocess, by default None. + verbose : bool, optional + Print the output of the subprocess, by default `False`. + + Yields + ------ + bytes + Output lines from the subprocess. + + Raises + ------ + FileNotFoundError + If the command executable is not found. + subprocess.CalledProcessError + If the subprocess exits with a non-zero return code. """ - # p = Popen(cmd_args, stdout=PIPE, stderr=PIPE, cwd=cwd, shell=True, env=os.environ) - # while True: - # line = p.stdout.readline() - # if not line: - # break - # line = line.strip().decode() - # if verbose: - # yield line - - # stdout, stderr = p.communicate() - # return stdout.decode(), stderr.decode() try: - p = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, shell=True, env=os.environ) - - while True: - line = p.stdout.readline() - if not line: - break - yield line - - p.wait() + env = os.environ.copy() + with subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, shell=True, env=env, **kwargs) as process: + assert process.stdout is not None + for line in process.stdout: + yield line.decode(errors="replace").strip() - if p.returncode != 0: - raise subprocess.CalledProcessError(p.returncode, cmd_args) + process.wait() + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, cmd_args) except FileNotFoundError as e: - print(f"Error: {e}") + print(f"Error: Command not found - {e}") + raise except subprocess.CalledProcessError as e: print(f"Error: Command '{cmd_args}' failed with return code {e.returncode}") + raise class extend_docstring: @@ -137,10 +170,18 @@ def wrapper(*args, **kwargs): func_name = f.__qualname__.split(".")[-1] self_obj = args[0] res = [vars for part in self_obj.parts if (vars := getattr(part, func_name)(*args[1::], **kwargs))] - if isinstance(res[0], list): + if not res: + return res + # if res is a list of lists + elif isinstance(res[0], list): res = list(itertools.chain.from_iterable(res)) - # res = list(itertools.chain.from_iterable(res)) - return res + return res + # if res is a Group + elif "Group" in str(res[0].__class__): + combined_members = set.union(*(group._members for group in res)) + return res[0].__class__(combined_members) + else: + return res return wrapper @@ -164,7 +205,7 @@ def step_method(f): def wrapper(*args, **kwargs): func_name = f.__qualname__.split(".")[-1] self_obj = args[0] - res = [vars for step in self_obj.steps if (vars := getattr(step, func_name)(*args[1::], **kwargs))] + res = [vars for step in self_obj.steps if (vars := getattr(step, func_name)(*args[1:], **kwargs))] res = list(itertools.chain.from_iterable(res)) return res @@ -197,44 +238,6 @@ def wrapper(*args, **kwargs): return wrapper -# def problem_method(f): -# """Run a problem level method. In this way it is possible to bring to the -# model level some of the functions of the problems. - -# Parameters -# ---------- -# method : str -# name of the method to call. - -# Returns -# ------- -# [var] -# List results of the method per each problem in the model. -# """ - -# @wraps(f) -# def wrapper(*args, **kwargs): -# func_name = f.__qualname__.split(".")[-1] -# self_obj = args[0] -# problems = kwargs.setdefault("problems", self_obj.problems) -# if not problems: -# raise ValueError("No problems found in the model") -# if not isinstance(problems, Iterable): -# problems = [problems] -# vars = [] -# for problem in problems: -# if problem.model != self_obj: -# raise ValueError("{} is not registered to this model".format(problem)) -# if "steps" in kwargs: -# kwargs.setdefault("steps", self_obj.steps) -# var = getattr(problem, func_name)(*args[1::], **kwargs) -# if var: -# vars.append(var) -# return vars - -# return wrapper - - def to_dimensionless(func): """Decorator to convert pint Quantity objects to dimensionless in the base units.""" diff --git a/src/compas_fea2/utilities/loads.py b/src/compas_fea2/utilities/loads.py deleted file mode 100644 index 6b4f65a7f..000000000 --- a/src/compas_fea2/utilities/loads.py +++ /dev/null @@ -1,34 +0,0 @@ -def mesh_points_pattern(model, mesh, t=0.05, side="top"): - """Find all the nodes of a model vertically (z) aligned with the vertices of a given mesh. - - Parameters - ---------- - model : :class:`compas_fea2.model.Model` - The model - mesh : :class:`compas.datastructures.Mesh` - The mesh - t : float, optional - A prescribed tolerance for the search, by default 0.05 - side : str, optional - filter the nodes to one side, by default 'top' - - Returns - ------- - dict - {vertex:{'area': float, - 'nodes':[:class:`compas_fea2.model.Node`]}, - } - """ - - pattern = {} - for vertex in mesh.vertices(): - point = mesh.vertex_coordinates(vertex) - tributary_area = mesh.vertex_area(vertex) - for part in model.parts: # filter(lambda p: 'block' in p.name, model.parts): - nodes = part.find_nodes_where([f"{point[0]-t} <= x <= {point[0]+t}", f"{point[1]-t} <= y <= {point[1]+t}"]) - if nodes: - if side == "top": - pattern.setdefault(vertex, {})["area"] = tributary_area - pattern[vertex].setdefault("nodes", []).append(list(sorted(nodes, key=lambda n: n.z))[-1]) - # TODO add additional sides - return pattern diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 47a37ed90..17959f8ba 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -1,17 +1,17 @@ import unittest from compas_fea2.model.connectors import SpringConnector, ZeroLengthSpringConnector from compas_fea2.model import Node -from compas_fea2.model import DeformablePart +from compas_fea2.model import Part from compas_fea2.model import SpringSection class TestSpringConnector(unittest.TestCase): def test_initialization(self): node1 = Node([0, 0, 0]) - prt_1 = DeformablePart() + prt_1 = Part() prt_1.add_node(node1) node2 = Node([1, 0, 0]) - prt_2 = DeformablePart() + prt_2 = Part() prt_2.add_node(node2) section = SpringSection(axial=1, lateral=1, rotational=1) # Replace with actual section class connector = SpringConnector(nodes=[node1, node2], section=section) @@ -21,10 +21,10 @@ def test_initialization(self): class TestZeroLengthSpringConnector(unittest.TestCase): def test_initialization(self): node1 = Node([0, 0, 0]) - prt_1 = DeformablePart() + prt_1 = Part() prt_1.add_node(node1) node2 = Node([1, 0, 0]) - prt_2 = DeformablePart() + prt_2 = Part() prt_2.add_node(node2) direction = [1, 0, 0] section = SpringSection(axial=1, lateral=1, rotational=1) diff --git a/tests/test_groups.py b/tests/test_groups.py index a1a269067..753a19a75 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,6 +1,6 @@ import unittest from compas_fea2.model.groups import NodesGroup, ElementsGroup, FacesGroup, PartsGroup -from compas_fea2.model import Node, BeamElement, DeformablePart, ShellElement, ShellSection, Steel +from compas_fea2.model import Node, BeamElement, Part, ShellElement, ShellSection, Steel class TestNodesGroup(unittest.TestCase): @@ -37,7 +37,7 @@ def test_add_face(self): class TestPartsGroup(unittest.TestCase): def test_add_part(self): - part = DeformablePart() + part = Part() group = PartsGroup(parts=[part]) self.assertIn(part, group.parts) diff --git a/tests/test_model.py b/tests/test_model.py index 7a82d1a98..22693d6d3 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,19 +1,19 @@ import unittest from compas_fea2.model.model import Model -from compas_fea2.model.parts import DeformablePart +from compas_fea2.model.parts import Part from compas_fea2.problem import Problem class TestModel(unittest.TestCase): def test_add_part(self): model = Model() - part = DeformablePart() + part = Part() model.add_part(part) self.assertIn(part, model.parts) def test_find_part_by_name(self): model = Model() - part = DeformablePart(name="test_part") + part = Part(name="test_part") model.add_part(part) found_part = model.find_part_by_name("test_part") self.assertEqual(found_part, part) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 8089171bd..f4042b0c9 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -7,14 +7,14 @@ class TestNode(unittest.TestCase): def test_initialization(self): node = Node([1, 2, 3]) self.assertEqual(node.xyz, [1, 2, 3]) - self.assertEqual(node.mass, (None, None, None)) + self.assertEqual(node.mass, [None, None, None, None, None, None]) self.assertIsNone(node.temperature) def test_mass_setter(self): - node = Node([1, 2, 3], mass=10) - self.assertEqual(node.mass, (10, 10, 10)) - node.mass = (5, 5, 5) - self.assertEqual(node.mass, (5, 5, 5)) + node = Node([1, 2, 3], mass=[10, 10, 10, 10, 10, 10]) + self.assertEqual(node.mass, [10, 10, 10, 10, 10, 10]) + node.mass = [5, 5, 5, 5, 5, 5] + self.assertEqual(node.mass, [5, 5, 5, 5, 5, 5]) def test_temperature_setter(self): node = Node([1, 2, 3], temperature=100) diff --git a/tests/test_parts.py b/tests/test_parts.py index 067bbc4fc..24aeff783 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -1,5 +1,5 @@ import unittest -from compas_fea2.model.parts import DeformablePart, RigidPart +from compas_fea2.model.parts import Part, RigidPart from compas_fea2.model import Node, BeamElement from compas_fea2.model import Steel from compas_fea2.model import RectangularSection @@ -7,13 +7,13 @@ class TestPart(unittest.TestCase): def test_add_node(self): - part = DeformablePart() + part = Part() node = Node([0, 0, 0]) part.add_node(node) self.assertIn(node, part.nodes) def test_add_element(self): - part = DeformablePart() + part = Part() node1 = Node([0, 0, 0]) node2 = Node([1, 0, 0]) part.add_node(node1) @@ -23,16 +23,14 @@ def test_add_element(self): part.add_element(element) self.assertIn(element, part.elements) - -class TestDeformablePart(unittest.TestCase): def test_add_material(self): - part = DeformablePart() + part = Part() material = Steel.S355() part.add_material(material) self.assertIn(material, part.materials) def test_add_section(self): - part = DeformablePart() + part = Part() material = Steel.S355() section = RectangularSection(w=1, h=1, material=material) part.add_section(section) diff --git a/tests/test_sections.py b/tests/test_sections.py index 0a83008ce..26d37a2fe 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -17,11 +17,11 @@ def test_rectangular_section(self): def test_circular_section(self): section = CircularSection(r=10, material=self.material) self.assertEqual(section.shape.radius, 10) - self.assertAlmostEqual(section.A, 314.159, places=3) + self.assertAlmostEqual(section.A, 314.14, places=2) self.assertEqual(section.material, self.material) def test_isection(self): - section = ISection(w=100, h=200, tw=10, tf=20, material=self.material) + section = ISection(w=100, h=200, tw=10, ttf=20, tbf=20, material=self.material) self.assertEqual(section.shape.w, 100) self.assertEqual(section.shape.h, 200) self.assertEqual(section.shape.tw, 10) diff --git a/tests/test_shapes.py b/tests/test_shapes.py index ed1881efd..3dddb5b70 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -10,12 +10,25 @@ def test_rectangle(self): self.assertEqual(rect.h, 50) self.assertAlmostEqual(rect.A, 5000) self.assertIsInstance(rect.centroid, Point) + self.assertEqual(rect.centroid.x, 0) + self.assertEqual(rect.centroid.y, 0) + self.assertEqual(rect.centroid.z, 0) + self.assertAlmostEqual(rect.Ixx, 100 * 50**3 / 12, 3) + self.assertAlmostEqual(rect.Iyy, 100**3 * 50 / 12, 3) + self.assertAlmostEqual(rect.J, 2_861_002.60, places=2) + self.assertAlmostEqual(rect.Avx, 4_166.67, places=2) + self.assertAlmostEqual(rect.Avy, 4_166.67, places=2) def test_circle(self): - circle = Circle(radius=10, segments=70) + circle = Circle(radius=10) self.assertEqual(circle.radius, 10) self.assertAlmostEqual(circle.A, 314.159, places=0) self.assertIsInstance(circle.centroid, Point) + self.assertAlmostEqual(circle.Ixx, 7853, 0) + self.assertAlmostEqual(circle.Iyy, 7853, 0) + self.assertAlmostEqual(circle.J, 15708, places=0) + self.assertAlmostEqual(circle.Avx, 283, places=0) + self.assertAlmostEqual(circle.Avy, 283, places=0) def test_ishape(self): ishape = IShape(w=100, h=200, tw=10, tbf=20, ttf=20)